@amedia/user
The goal of this module is to simplify the usage of aID data and services in Amedia frontend code. The module is designed to be used several places on a webpage independently, and the module will ensure that requests from different parts of the webpage are handled in an efficient manner.
TL;DR (details in the linked chapters)
- UserDataRequest
All current user data (including login state and access). Call it as much as you like, but only ask for what you actually need.
- PaywallUnlockRequest
When you need to actively verify login state and access. May perform redirects and expensive calls for subscription activation, and thus limited in how much it accepts to be called.
- SiteAccessRequest
When you need to actively verify access. Specify which access feature(s) you need. May attempt subscription activation.
- User actions
Login, logout, that sort of thing.
Usage
Please be advised that you are required to use ESM in order to use this module.
Importing the module
Add this module to your project by running npm install -d @amedia/user
. This will allow for type-hinting, and will provide some mock-functionality. To get the actual implementation for production you must swap out @amedia/user
with https://assets.acdn.no/pkg/@amedia/user/v0/user.js
. This can be done easily with import-maps. For inspiration, see Ego, Bacchus or Saturn on how to use this module through an Eik plugin. You can read more about Eik on Slite.
Mocking
When working exclusively on a frontend app, it might be cumbersome to boot the entire aid rig locally for development. To make this easier, the npm package provides a mock for you to use.
To enable mocked data you just comment out the import map translation. This will cause your bundler to include the mocks.
Then mocked, you'll be logged in as a user with full access. Check the browser console for information about this persona, other personas available, and how to choose between them.
Caveats of the mock
This is not intended to be a complete mock as it would've soon required us to reimplement most of aID in the mock :)
Supported actions:
- UserDataRequest will return data based on current persona
- PaywallUnlockRequest will only return state based on current persona (e.g. no reload/redirect)
Unsupported (will throw Error):
- goToLoginPage
- logout
- updateStorage
- saveStorage
Getting user data
The most common use case for aID on newspapers is to get some kind of data about a user, this could be the user's name, some stored preference, access or data that has been derived about the user.
To get user data you first need to create a new UserDataRequest()
instance, then specify what data you need and lastly decide if you want to wait until all data is available, or you want to get partial updates.
Data model
Attributes
Attributes are data directly related to the user, with the exception of access. Supported attributes are:
uuid | string | The users unique ID. (NOT for tracking) |
name | string | Users full name |
trackingKey | string | Unique ID of user that can be used for tracking (pseudonym) This is also known as the legacy name user_key |
access | Array | Users access on current site (list of access features) |
extraData | Record<string,unknown> | Data that has been modelled about the user (gender, ad segments etc. from Nebula) |
privacyPreferences (deprecated) | PrivacyPreferences | Privacy preferences set on Min aID. (Deprecated - CMP is now used) |
sessionTrackingKey | string | Tracking key for the user session |
Attributes are requested by calling .withAttributes(attributes: Array<string>)
on a UserDataRequest
with a list of the attributes you need.
In the response, attributes
is an object where each requested attribute is an attribute. So you can get the name by accessing attributes.name
for example.
NOTE: Don't request attributes you don't intend to use. This previously had a latency-cost attached to it. This is no longer the case. However, it's best not to keep user data around where it's not needed.
NOTE2: If you need to know if the current user is an employee, you are encouraged to use data from extraData
rather than access
as extraData
is quicker. This is not the case anymore. access
is now quicker to use and more precise.
Storage
Storage contains namespaces stored in aID (Europa). A namespace is a named set of key value pairs stored for a specific user. An example could be favourite_teams, which could contain team_id as key and timestamp it was stored as favourite as value.
Storage is requested by calling .withStorage(namespaces: Array<string>)
on a UserDataRequest
. If a namespace does not exist it will be "created" in that you'll get an empty object back. Saving data to it will perform the actual creation.
In the response, storage
is an object with each namespace as an attribute which leads to an object with attributes for each key in the namespace. So you can access storage.favourite_teams.myteamid
and get the value stored for teamid
in the favourite_teams
namespace. This value will be whatever was saved there, but always a string.
type Storage = { [namespace: string]: Record<string, string> };
Client Storage
This works just as regular storage, but the namespaces are scoped to oauth clients.
Client Storage is requested by calling .withClientStorage(clientId: string, namespaces: Array<string>)
on a UserDataRequest.
In the response, clientStorage[clientId]
is an object with each namespace as en attribute that works as described above.
type ClientStorage = { [clientId: string]: Storage };
Writing to storage namespaces
A namespace is a proxied instance of Namespace
.
You access this as any old object, but it provides a save()-method for when to wish to persist this namespace for later use.
import { UserDataRequest } from '@amedia/user';
const { storage } = await new UserDataRequest()
.withStorage(['my_namespace'])
.fetch();
storage.my_namespace.my_key = 'my data';
storage.my_namespace.save();
delete storage.my_namespace.my_key;
storage.my_namespace.save();
In this example, my_key
in my_namespace
would be updated to my data
(and then deleted). Other data would probably be left "untouched". Meaning, if this namespace sees a lot of changes outside this instance you may end up overwriting other changes if you delay a lot between reading av writing!
State
State is ment to describe the current context the user is in right now. It's not directly requested, but will always be a part of the response.
Current attributes in state
:
isLoggedIn | boolean | Is the user currently logged in? If not, most attributes will be undefined |
emergencyMode | Array | Current state of emergency mode in production environment (paywall, aid etc.) |
Access not attached to a user
A visitor may have access through their IP address, or a specific token that we periodically enable for "avis i skolen".
Current elements in the array nonUserAccess
:
customer | string | "conocophillips" etc |
accessFeatures | Array | ["pluss"] |
Executing the request
Once you have created a request and specified the data you need, the final step is to decide how you want to get the data once it's available. You can either choose to subscribe to data as they get loaded from the various backends or wait until all fetching is done and get everything in one package.
The response to the request will be an object with attributes for state, attributes and storage. In the examples below these are unpacked for easier access.
Subscribing to updates
The most flexible way to get userdata is by subscribing to updates. This is done by calling .subscribe()
on the UserDataRequest
with a callback function that can handle the updates once they arrive. This is especially useful when the time it can take to get the data can vary. Getting the user's name is very fast, so you can display the user's name before you have received storage for example.
Full usage example:
import { UserDataRequest } from '@amedia/user';
const unsubscribe = new UserDataRequest()
.withAttributes(['name', 'trackingKey'])
.withStorage(['my_namespace'])
.withClientStorage('client-id', ['my-client-namespace'])
.withNonUserAccess()
.subscribe(({ attributes, state, storage, clientStorage, nonUserAccess }) => {
console.log(attributes, state, storage, clientStorage, nonUserAccess);
});
unsubscribe();
The callback you supplied to subscribe is called whenever there are any changes to the properties you have requested, so be prepared to re-render.
Also, your requested properties might not arrive all at once. This callback will be called whenever we get data in from the various services.
You should check the state object for potential emergency modes and act accordingly.
Should you wish to end your subscription, just call the returned function from subscribe.
Fetching all the data once (no re-renders)
In many use-cases, it's more convenient to just wait until everything is ready, so you don't have to handle multiple callbacks. In these cases, it's easier to call .fetch()
. This method will return a Promise
that resolves the same object you would get in the subscribe
callback.
Full usage example:
import { UserDataRequest } from '@amedia/user';
new UserDataRequest()
.withContext('my-app')
.withAttributes(['name'])
.withStorage(['my_namespace'])
.withClientStorage('client-id', ['my-client-namespace'])
.withNonUserAccess()
.fetch()
.then(({ attributes, state, storage, clientStorage, nonUserAccess }) => {
console.log(attributes, state, storage, clientStorage, nonUserAccess);
});
Fetch will wait until it has all the data you requested before resolving, unless the user is not currently logged in. Internally .fetch()
will use .subscribe()
and just wait until everything is ready before unsubscribing and then resolving the returned promise.
If you really need to render the user's name or get their uuid etc. as fast as possible, use .subscribe()
, if not, this is probably easier.
Timeouts when using fetch
Especially poor connections may result in almost eternal wait times - which is no good if your app is locked while waiting on the data. Because of this we have introduced a timeout. A timeout will cause the fetch to reject with FetchTimeoutError.
FetchTimeoutError will include any data we might have gotten before we timed out
new UserDataRequest()
.withContext('my-app')
.withAttributes(['name'])
.withStorage(['my_namespace'])
.fetch()
.catch((e) => {
if (e instanceof FetchTimeoutError) {
console.log(e.partialData);
}
});
If you're running a background task and don't mind waiting longer than default (10 seconds), you may set a longer timeout in fetch:
new UserDataRequest()
.withContext('my-app')
.withAttributes(['name'])
.withStorage(['my_namespace'])
.fetch({ timeout: 60000 });
This may also be lowered below the default, but network request do take some time, even on fast networks, so don't shoot your app in the foot by lowering it too much.
Emergency mode when using fetch
To avoid hanging on timeout, fetch rejects with EmergencyModeError
, when a relevant emergency mode is active.
If you receive this error, there will be no user data available.
Your app should act accordingly, eg:
- Render non-personalized
- Show placeholder with an error message
- Not rendering at all
new UserDataRequest()
.withContext('my-app')
.withAttributes(['name'])
.withStorage(['my_namespace'])
.fetch()
.catch((e) => {
if (e instanceof FetchTimeoutError) {
console.log(e.partialData);
} else if (e instanceOf EmergencyModeError) {
console.log(e.activeEmergencyModes);
}
});
Actions
getLoginUrl({requestedUrl: string context: string}): string
Builds and return the aID login URL. See goToLoginPage
for details
Send the user to the aID login page. It will take care of building the correct URL, including your current site domain, so the login page is skinned correctly. By default, the user will be sent back to the current url after login has been completed. You may include a requestedUrl if you need to control where the user should end up.
Note: We also support sending a context string when requesting a login. Please include your app-name or more recognizable string. If your app has multiple places for requesting login, it's nice to include that info as well. Eg: goToLoginPage({context: my-app_switch-user})
. The context will help us understand where logins originate from to assist in debugging.
requestDataRefresh(): void
If you need to ensure the latest data is available, you may request a data refresh. @amedia/user will look up all data it has in cache and contact various endponints to ensure they are up to date. If you're subscribing to updates, your callback will be executed if anything changes.
Note: A data refresh will be automatically performed every time the page regains focus (eg. user switches tabs)
logout(): Promise<void>
This action will log the user out of aID. The logout is global, meaning it will log the session out of all sites and aid.no. After logout has completed the promise will resolve, giving your app the opportunity to reset state / reload the page etc.
pollForAccess(requredAccessFeatures = ['pluss'], timeout_millis = 3000): Promise<true>
Polling for expected access. Will either return true when required access
features are attained, or TimeoutError.
aidUrls: Record<string, URL>
Get various, environment aware, URLs to pages often linked to from other apps. This includes pages like the aID profile page, Terms of Service, Privacy Statement, Privacy Preferences, Family Sharing and the like.
DEPRECATED: ~updateStorage(storage: Storage): void~
See: Writing-to-storage-namespaces
The safest way to update a value is to use updateStorage()
. This will send a PATCH
to aID (Europa) to only update the specified attributes of a namespace, and keep the rest as it is. This reduces concurrency issues where someone could have updated a different attribute after you read the namespace.
Example of use:
updateStorage({
my_namespace: {
my: 'data',
},
});
DEPRECATED: ~saveStorage(storage: Storage): void~
See: Writing-to-storage-namespaces
Will send a PUT
to aID (Europa) to update the entire namespace/namespaces, deleting missing data in the process. Please be advised that you may be overwriting updates done by other apps to these namespaces, so it's best to save with a recent namespace if it sees heavy writes :)
Example of use:
saveStorage({
my_namespace: {
my: 'data',
},
});
Unlocking the paywall
Amedia's sites use a Paywall that's based on a cookie called VSTOKEN that is read by Varnish and used by the content system to decide if the content should be shown to the user or not. If not, the content will load what we call an incentive, a dialogue above the content, that will allow the user to log in and/or buy subscription. The incentive will use PaywallUnlockRequest
to see if it can somehow grant access to the user.
In some cases it makes sense to create a different user interface for a paywall. If so we recommend that you use PaywallUnlockRequest
in order to get the same functionality.
More information about the paywall can be found here: How the Paywall works
Information about the incentive can also be useful, since you probably need to replicate this functionality if you create a new user interface: How the Incentive works
To unlock the paywall, you first need to create a new PaywallUnlockRequest
. Once created, you can set some options by calling methods on this request:
reloadOnAccess()
: If the paywall unlocker finds sufficient access for the user it will reload the current page
redirectOnAccess(requestedUrl: string)
: If the paywall unlocker finds sufficient access for the user, it will send the user
to requestedUrl
. (Provides more flexibility than reloadOnAccess()
)
withAccessFeatures(accessFeatures: string[])
: Specify what access features will give access to the current content.
withSiteDomain(domain: string)
: Specify which site we to attempt activation against. The default is the current domain. This is mostly useful for alt.no which often knows what domain the user came from.
- DEPRECATED: This will fail if you attempt to unlock a paywall on a page without a paywall or its own access features. Feature will be removed in the next major release. Use SiteAccessRequest instead.
More details about the access model can be found here: Access model
Once your options have been set up, you can call tryUnlock()
. This is when all the magic happens. The unlocker will try to auto login the user on the current domain, check access, activate subscriptions in the subscription system, check for stuff like IP based access etc. When this is done, it will resolve the Promise
that was returned by tryUnlock()
.
The resolution to the promise contains an object with attributes you can use to get details about why the user did not get access (or if he had access if you don't opt for reload or redirect). The object contains the following:
- isLoggedIn: True if the user is logged in, false otherwise. Useful if you want to show a login button, which makes no sense for logged in users.
- hasAccess: True if the user has access. Always false if you called
reloadOnAccess()
or redirectOnAccess()
.
- accessFeatures: List of access features the user has access to. Empty list if user is logged out or has no access. This list is useful if you want to upgrade from 'pluss' to 'plussalt' for instance. You might want to give a different user experience to users with some access than users with no access.
Full usage example:
import { PaywallUnlockRequest } from '...';
new PaywallUnlockRequest()
.withAccessFeatures(['pluss'])
.redirectOnAccess('https://my.neat/place')
.reloadOnAccess()
.tryUnlock()
.then(({ isLoggedIn, hasAccess, accessFeatures }) => {
console.log({ isLoggedIn, hasAccess, accessFeatures });
})
.catch((error) => {
PaywallError(
'Unlock check already performed.',
PaywallError.ERROR_RELOADED_NO_ACCESS
);
PaywallError(
'New subscription was attached and checked successfully, but internal systems disagree about current site access.',
PaywallError.ERROR_NO_ACCESS_AFTER_SUCCESSFUL_ACTIVATION_WITH_REQUESTED_ACCESS_FEATURES
);
PaywallError(
'Could not set access cookies',
PaywallError.ERROR_ENABLING_ACCESS_FAILED
);
});
Note that this might result in redirects/reloads if the user is not already logged in. In that case you will end up here again if logging in did not result in access.
Activating subscriptions / finding upgrade path
Check the current users access on a specific site. If you're on a site that has its own access features (e.g. has a paywall), you should use PaywallUnlockRequest instead. This will set cookies as well.
withSiteDomain(siteDomain: string)
The domain you with to check access for / attempt activation against.
withAccessFeatures(accessFeatures: Array<string>)
Which access features do you require in order to consider access is granted.
Once your options have been set up, you can call requestAccess()
. This is when all the magic happens. It will try to auto login the user on the current domain, check access, activate subscriptions in the subscription systems, check for stuff like IP based access etc. When this is done, it will resolve the Promise
that was returned.
The resolved promise contains the following:
- isLoggedIn: True if the user is logged in, false otherwise. Useful if you want to show a login button (see goToLoginPage(...)).
- hasAccess: True if the user has access that fulfils your
withAccessFeatures
-requirements.
- accessFeatures: List of access features the user actually has at this time. Empty list if user is logged out or has no access. This list is useful if you want to upgrade from 'newspaper' to 'plussalt' for instance. You might want to give a different user experience to users with some access than users with no access.
- primarySite: Best guess of where the user may attain the access you requested.
Full usage example:
import { SiteAccessRequest } from '@amedia/user';
import { SiteAccessResolverError } from './SiteAccessRequest';
new SiteAccessRequest()
.withSiteDomain('www.direktesport.no')
.withAccessFeatures(['sport_premium'])
.requestAccess()
.then(({ isLoggedIn, hasAccess, primarySite, accessFeatures }) => {
console.log({ isLoggedIn, hasAccess, primarySite, accessFeatures });
})
.catch((error) => {
SiteAccessRequestError(
'Unable to resolve access for site (www.direktesport.no)'
);
Error(
`@amedia/user cannot request site access for (www.direktesport.no) on localhost`
);
});
Note that this might result in redirects/reloads if the user is not already logged in. In that case you will end up here again if logging in did not result in access.
Known limitations
Subscription activation can only happen against the subscription system related to the withSiteDomain
. #team-abo will need to look into activation across all subscription systems to improve this behavior.
Performance
This app aims to be able to deliver a lot of data fast. Still, we might need to call several services in order to get what you need. We do try to cache whenever this makes sense, but please don't ask for data you don't need.
Behind the scenes
There are a few techniques used to avoid overfetching and fetching multiple times when once would do.
Event-based actions
Much like node-userdata-browser/userdata-client did, we utilize events to message that data is being requested. This allowed us to decouple your request from the particular instance that will serve it. That in turn allows us to have this library loaded by multiple different components across the page, and only process the requests one place. In addition, if the component that's currently processing requests should be unloaded for whatever reason, one of the other instances will automatically take over.
Request de-duping
Since we now process from a single instance of the package it's easy to avoid fetching the same resource multiple times.
Caching
@amedia/user has a caching layer that easily let ut switch out the caching mechanism.
We have two strategies:
- SessionStorage: (to survive page loads) Used if available.
- Global: Stores to a global variable on the current page.
Debugging
If you're curious about calls, response times etc, there is a debug flag you can set on the URL debug=something
. If you'd like it to survive page loads, you can add a key to localStorage or sessionStorage: localStorage.setItem('amedia-user:debug', 'something')
Contributing to @amedia/user
Hey, thanks! :)
It's often nice to do development while testing on a real app. You may do so using npm link
. When you run then npm run build
(or npm run dev
if you want to watch for changes and build automatically) this will expose actual code, letting you develop faster. This is because we still export user.js from the package, but replaces its content with an error if used directly.
PS: Remember to comment-out the module in import maps ;-)
Testing live
You can test your version live by utilizing a browser extension like Requestly or similar. Run npm run dev
(which will build and watch for changes locally and starts a webserver listening on localhost:9999). Then add a rule in the extension that replaces requests for @amedia/user/v0/user.js with http://localhost:9999/user.js. Now reload and watch your wonderful change do its thing.
Publishing new versions
Creating a new version
We use Changesets.
npx changeset
- Follow the instructions
- Add the changeset
git add .changeset
- Commit and push
After merging a release PR will be created/updated (given that tests pass).
Merging this will push a new version to assets.acdn.no.
Deploying
First snap then prod:
If amedia/user breaks in prod it may take down 100+ newspapers entirely,
so please test on snap first :)
We use importmaps as a means for deployment: https://github.com/amedia/importmap.
Follow the readme there for the current deployment practice.
Until we shut down eikserver, it's nice to have a valid alias should the
newly created importmap and ESI-trickery fail: