@amedia/user
v1.0.5
Published
Client lib for working with aID user and associated data
Maintainers
Keywords
Readme
@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 efficiently.
TL;DR (details in the linked chapters)
- UserDataRequest User data like name, access, KV-storage, etc. 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 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. @amedia/user
requires a browser to run. If your app performs server-side rendering, consult framework documentation on how to do this.
Getting user data
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
| name | type | description | | ------------------ | ------------- | ------------------------------------------------------- | | uuid | string | The users unique ID. (NOT for tracking). | | trackingKey | string | Unique ID of user that can be used for tracking. | | sessionTrackingKey | string | Tracking key for the user session. | | name | string | Users full name. | | access | Array | Users access on current site (list of access features). |
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 unnecessarily.
Storage
Storage contains namespaces stored in aID (Europa). A namespace is a named set of key value pairs stored for a user. An example could be favourite_teams namespace, which could contain team_id as a key and timestamp it was stored as a 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 you 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 and writing!
State
State is meant 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:
| name | type | description | | ------------- | ------------- | ----------------------------------------------------------------------------- | | 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 directly 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:
| name | type | descriptions | | -------------- | ------------- | -------------------- | | 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 quick, 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);
});
// If you wish to unsubscribe from further updates:
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 at 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') // Tell us who you are :) This helps with tracability.
.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);
// Maybe you're able to render your app in a reduced state (using 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 /* milliseconds */ });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 a 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);
// Maybe you're able to render your app in a reduced state (using partialData).
} else if (e instanceof EmergencyModeError) {
console.log(e.activeEmergencyModes);
// Maybe you're able to render your app in a non-personalized way?
}
});Actions
getLoginUrl({requestedUrl: string context: string}): string
Builds and return the aID login URL. See goToLoginPage for details
goToLoginPage({requestedUrl: string context: string}): void
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 the cache and contact various endpoints 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.
Unlocking the paywall
Sites hosted through Amedia may use a Paywall. This is based on a JWT-cookie called aid.jwt that is validated and parsed
by Varnish. When content is requested from a backend, it returns with header information used by varnish to decide if
the content is available to the reader, and a header telling varnish where to get the version that includes the paywall.
The paywall (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 pageredirectOnAccess(requestedUrl: string): If the paywall unlocker finds sufficient access for the user, it will send the user torequestedUrl. (Provides more flexibility thanreloadOnAccess())- Deprecated (pass in constructor instead):
withAccessFeatures(accessFeatures: string[]): Specify what access features will give access to the current content.
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 autologin 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()orredirectOnAccess(). - accessFeatures: List of access features the user has access to. Empty list if noy logged in 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(['pluss'])
.redirectOnAccess('https://my.neat/place') // Optional
.reloadOnAccess() // Optional
.tryUnlock()
.then(({ isLoggedIn, hasAccess, accessFeatures }) => {
// If you did not specify any "on access"-action above
// we will return here afterwards.
console.log({ isLoggedIn, hasAccess, accessFeatures });
})
.catch((error) => {
// Known potential errors are:
// This guards potential reload/redirect loop. Access should have been granted at this time
PaywallError(
'Unlock check already performed.',
PaywallError.ERROR_RELOADED_NO_ACCESS
);
// Internal error. Access should have been granted at this time.
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
);
// Internal error. We are unable to get access tokens/cookies to set.
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
SiteAccessRequest checks the current users' access to 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
autologin 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(['sport_premium'])
.withSiteDomain('www.direktesport.no')
.requestAccess()
.then(({ isLoggedIn, hasAccess, primarySite, accessFeatures }) => {
console.log({ isLoggedIn, hasAccess, primarySite, accessFeatures });
})
.catch((error) => {
// Known potential errors are:
// You would have to show some error / try again mechanic etc here.
SiteAccessRequestError(
'Unable to resolve access for site (www.direktesport.no)'
);
// If you attempt this on localhost
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 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 over-fetching and fetching multiple times when once would do.
Event-based actions
Much like node-userdata-browser/userdata-client did, we use 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 with multiple different components across the page and only process the requests in one place. In addition, if the component 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 lets us 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')
