Skip to content

Account & Auth

Demo: 530753a (Nov 8, 2026)Prod: 530753a (Nov 8, 2026)

The single HDS app that owns authentication (sign-in, registration, password reset, MFA) and account management (profile, preferences, security, connected apps, data rights, audit). It is a fork of Pryv’s app-web-user-account and is the successor to the retired app-web-auth3-hds — one deployment serves both account hostnames on clean paths (no .html).

Your app does not build any of this itself. It links into the account app for the ceremony and the account pages, and reads account-level preferences through hds-lib.

PathPurpose
/signinSign-in (username or email + password)
/registerAccount creation
/reset-password, /change-passwordPassword recovery / change
/mfa-challengeInline MFA
/auth, /oauth2-authorizeThe Pryv auth ceremony / OAuth2 entry
/account/{profile,preferences,security,apps,data,audit-access/:id}Account management pages
/verify-emailPublic. Landing for the mailed address-verification link. Reads ?verifyToken=&username= and drops the token from the address bar immediately, so neither history nor an outbound Referer carries it. Also accepts a pasted code, for a reader on another device.
/account/delegationManage delegates of this account, accounts you control as a delegate, and create a fully-controlled managed account.
/account/appsConnected apps. Accesses that run a delegation are listed apart and cannot be revoked one by one — they go away when the delegation is detached. An app access granted through a delegation is an ordinary, revocable app and stays in the main list.
/cmc-accept (alias /cmc/approve), /cmc-scope-updateCMC consent hand-offs (below)
/connectConnect, reconnect or disconnect a partner service (Mira, Tempdrop) for the signed-in user (below)

A custom HDS app should build none of register / reset / change-password / profile / preferences — link to the account app instead.

Send the user to an account path and pass these optional query params (all carried across the app’s internal /signin bounce, so a signed-out deep-link keeps them):

  • username — a sign-in hint; the calling app usually already knows who the user is, so the sign-in form pre-fills it.
  • backUrl + backLabel — render a ← Back to {backLabel} affordance that returns the user to your app when they’re done.
  • pryvServiceInfoUrl — optional and normally omitted. Each deployment already knows the Pryv platform it serves (from a settings.json served next to the bundle), and the HDS deployments serve only that platform (allowedServiceInfoUrls: []): a link naming another platform is refused before anything is sent there. Apps linking in are not expected to send it.

Settings vs. profile — where preferences live

Section titled “Settings vs. profile — where preferences live”

Two distinct stores, both via hds-lib:

  • HDSSettings — per-app. Bound to an app’s baseStream, so each app gets its own private copy (e.g. theme, which is legitimately per-app).
  • HDSProfile — account-level, shared across every HDS app. Holds identity (displayName, avatar, …) and the account preferences every app should agree on: preferredLocales, timezone, dateFormat, unitSystem. These live on the account’s own profile-preferences stream, which is what makes them interoperable. The account app owns writing them.

Read a shared preference through resolveAccountPreference(key) (from hds-lib), never by reading one store directly. Its precedence is: a genuinely stored profile value → the legacy per-app HDSSettings value → the profile default (incl. browser inference). This is what lets an app honour an account-level choice while still working for users who only ever set a per-app value.

A custom app needs no CMC consent UI of its own. /cmc-accept is the canonical path a requester points @pryv/cmc.requestAccept at; CmcApprove is app-agnostic and drives sign-in + consent + accept/decline.

Requester recipe:

import { requestAccept } from '@pryv/cmc';
requestAccept({
authUrl: 'https://account.hds.ngo/cmc-accept', // the account app owns the consent UI
capabilityUrl, // the offer to read (anonymous readOffer resolves it)
scopeStreamId, // optional — the stream scope being granted
pryvApi,
accessName, // optional — a label for the created access
returnUrl, // optional — where to send the result in redirect mode
mode, // 'popup' (default) | 'redirect'
});

Result return: the account app returns the outcome only: { ok: true, acceptEventId } or { ok: false, reason } (reason is the platform error id, e.g. cmc-capability-consumed). It never carries a credential: the requester obtains its data-grant endpoint on its own side with @pryv/cmc waitForAccept (grantedAccessApiEndpoint).

  • popup mode: window.opener.postMessage({ type: 'cmc-accept-result', ...res });
  • redirect mode: navigates to returnUrl with ?cmcAcceptResult=<json>.

/cmc-scope-update mirrors the same shape for updating an existing grant’s scope.

Scope note (accept vs. revoke): /cmc-accept is accept/decline of a pending offer, and /cmc-scope-update updates a grant’s scope. Revoking an existing grant is a different action and lives in the account UI at /account/audit-access (accesses.delete), not on these hand-off routes.

Security properties to know: the result carries no token in either mode. Popup mode pins postMessage to the opener’s origin when it can establish it (the returnUrl or the referrer) and falls back to targetOrigin: '*' otherwise; @pryv/cmc’s requestAccept listener does not validate ev.origin, so treat the result as an outcome signal, and read the grant through waitForAccept. The page names the requester by its verified username@host; a requester’s self-chosen display name is shown only as “calls itself …”.

An app offers “Connect your Mira account” without running the connector: it sends the user to

https://account.hds.ngo/connect?app=<catalogue id>&returnUrl=<https URL>&state=<nonce>[&action=connect|reconnect|disconnect]

(demo: demo-account.datasafe.dev). app is a catalogue id with a hook (bridge-mira, bridge-tempdrop). Any https returnUrl works, with no registration. Each card names the host the user returns to, and nothing runs before the user clicks. The answer comes back on returnUrl as state, app, action, result=connected|disconnected|cancelled|error and a stable reason when not connected. It never carries a token, an apiEndpoint or a link.

  • connect: provisions the connector’s model streams, gives the bridge its access, and runs the partner sign-in. A CMC connector (Mira) gets a minimal access and returns an offer that /connect/return accepts. A plain-access connector (Tempdrop: its catalogue entry lists permissions) gets the data access, minted from those permissions. An attempt that fails or is cancelled deletes the access it created. An already-connected user sees “already connected” instead of connecting twice.
  • reconnect: the partner sign-in again on the existing connection. For a plain-access connector it first brings the access to the catalogue’s permissions.
  • disconnect: runs the bridge’s disconnect hook, revokes the connector’s accesses, and sets its status disconnected.

The app reads the connector’s status from the user’s own account, never from the bridge: read on the catalogue entry’s statusStreamId (e.g. sync-status-mira), one sync-status/connector-v1 event (see Data Model). App-side guide: agents.datasafe.dev/partner-services.

  • React 19 + TypeScript + Vite + Tailwind + React Router 7.
  • Pryv is reached only through hds-lib (never a direct pryv import).
  • A static SPA served by nginx on Dokku (since 2026-10-01), so the pages can send Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY: the account pages cannot be framed. Each deployment writes its own settings.json (dev/demo vs. production platform).

Email verification and delegation (open-pryv.io >= 2.0.0-rc.18)

Section titled “Email verification and delegation (open-pryv.io >= 2.0.0-rc.18)”

Email verification. Addresses carry a verification state, and the Profile page badges each one Verified / Not verified / Unconfirmed. An account created before the feature existed reads Unconfirmed: its address was asserted at sign-up, never proved. Verification is optional on HDS: the registration gate (account.emailVerification.requireAtRegistration) is off on every core, and the sign-up form instead offers a “confirm this address” opt-in that mails a link after the account is created, so registration never blocks on it.

Operators: set auth.emailVerificationPageURL to this app’s /verify-email and keep services.email.enabled.verifyEmail explicit — upstream defaults it to true, so an absent line silently inherits upstream’s policy rather than yours.

Delegation. One account can be controlled by others, for a parent managing a child’s account or a trusted adult managing a dependent’s. A delegate is owner-equivalent for everything except removing a delegation, which requires a genuine login on the controlled account.

Because a delegate token is owner-equivalent and the API reports the controlled account as the user, the app shows a persistent banner on every route naming both accounts whenever access-info.delegation says the session is delegated, with a one-click return to the delegate’s own session. Without it nothing on screen would distinguish acting-as from being the owner.

Cross-core delegation requires an explicit core.url on each core; see pryv/open-pryv.io#134.

Carer onboarding: 2.0.0-rc.30 to rc.32 (deployed on all HDS cores, 2026-10-02)

Section titled “Carer onboarding: 2.0.0-rc.30 to rc.32 (deployed on all HDS cores, 2026-10-02)”

The path for a carer (a parent for a child) who signs up, creates the managed account and answers an app’s consent request for it, in one window. Full API detail: open-pryv.io CHANGELOG-v2.md.

Create the managed account during the grant

Section titled “Create the managed account during the grant”

With authRequest.actAs ("allow" or a username) on a platform running delegation, the “Who is this access for?” step is shown even when the user manages no account yet, with “Create an account for someone you look after” below the choices. The new account is then selected, and the grant is made for it (ACCEPTED carries delegation.controlledUsername).

Section titled “Answer consent invites inside the authorisation request (cmcInvites)”

POST /reg/access may carry cmcInvites: [{ capabilityUrl, mandatory?, for? }] (1 to 8 entries; for is 'self', the default, or 'target', the account the access is granted for). The auth page shows one Approve / Decline block per invite, then accepts the approved ones before creating the app access. ACCEPTED carries one outcome per invite, in order: { acceptEventId, dataGrantAccessId?, acceptedFor? }, { declined: true } or { reason }. A declined mandatory invite ends the request REFUSED with reasonId: 'REFUSED_MANDATORY_CONSENT'; a mandatory one that could not be accepted, with 'MANDATORY_CONSENT_FAILED'.

The outcomes are a hint from the page, not a proof: confirm from your own inbox (consent/accept-cmc). A core that predates rc.32 drops the field from its 201 answer, which is how to detect support.

The alternative without invites still works: an authUrl carrying next=/cmc-accept?… continues to the consent offer in the same window once the access is granted, instead of closing.

A delegate may write consent/accept-cmc for the account it manages. The data grant carries the delegation lineage, so access-info with its token reports delegation: { isDelegatedAccess: true, controlledUsername, delegate, grantedVia: 'app' }, and the accept event records content.approvedBy. Publishing an offer and widening a grant stay reserved to the account owner.

Removing a delegate: the owner reviews those consents first

Section titled “Removing a delegate: the owner reviews those consents first”

On /account/delegation, removing a delegate who gave consents for the account lists them, each with Keep or Withdraw and nothing preselected. A kept grant becomes the owner’s own (its lineage is removed, its requester is told nothing); a withdrawn one is deleted and its requester receives consent/revoke-cmc. API: delegations.detachDelegate with keepAccessIds.

TypeScript: the cmcInvites and keepAccessIds typings ship in pryv 3.15.0, which hds-lib 2.8.0 does not carry yet (it pins 3.14.2). Cast until a later hds-lib release.

What 2.0.0-rc.23 adds (deployed on all HDS cores, 2026-09-19)

Section titled “What 2.0.0-rc.23 adds (deployed on all HDS cores, 2026-09-19)”

service/info now advertises the account app and delegation

Section titled “service/info now advertises the account app and delegation”
  • service.account — the account app’s root URL, served in service/info when the operator configures it. Set on demo, ch1 and us1. The hds-lib sign-in button uses it for its Manage my account link, so an app no longer has to know the account hostname.
  • features.delegation — true when the platform runs account delegation. The auth page reads it to decide whether to offer granting for an account you control.

⚠️ The serviceInfo embedded in an access request is TRIMMED. A /reg/access poll answers with features: null even on a core that advertises them. If you need features, fetch service/info itself rather than trusting the copy carried in the request.

Grant an app access for an account you control

Section titled “Grant an app access for an account you control”

When the platform runs delegation and the signed-in user actively controls another account, /auth asks “Who is this access for?” before showing the permissions. Choosing a controlled account makes the grant there, using a delegate token obtained for that purpose and held in memory only.

  • The app’s authRequest.actAs steers it: "deny" (the signed-in account only), "allow", or a username to preselect. A named account the user does not actively control is explained rather than silently ignored.
  • The ACCEPTED result then carries delegation: { isDelegatedAccess: true, controlledUsername, delegate: { username } }, so the requesting app knows whose account it received and who granted it.
  • On the controlled account the created access carries the server-set lineage marker delegated-child, which is what keeps it listed as an ordinary revocable app.

Credential hand-off — the token no longer rides in the poll answer

Section titled “Credential hand-off — the token no longer rides in the poll answer”

An app may ask for credentialHandoff: 'shared-secret' on POST /reg/access. pryv 3.13.0 requests it by default. The accept page then mints a one-time secret on the user’s own core and posts only its key, so the ACCEPTED body carries:

{
"status": "ACCEPTED",
"apiEndpoint": "https://demo.datasafe.dev/<username>/",
"handoff": { "type": "shared-secret", "key": "…" }
}

with no token and a token-less apiEndpoint. The app retrieves the credential exactly once with POST <apiEndpoint>shared-secrets/retrieve { key }; a second attempt answers 403. So the token never reaches the core that answered the authorization request, nor that core’s logs, and a theft becomes detectable because the legitimate retrieve fails loudly.

connectFromKey in pryv 3.13.0 redeems it transparently, so an app using the standard flow needs no change. Opt out with authRequest.credentialHandoff: 'inline'. Two cases always fall back to inline delivery: a consent-form request (the server needs the token to verify the grant) and a delegated grant (a delegation-derived token may not create the secret).

authRequest.consent lets an app say how each permission should be presented: allowUserChoice (false by default: accept the whole set or deny), mandatory (ids the user cannot leave out) and optIn (ids offered not pre-selected). The consent screen then renders a selectable list, and the access is created with exactly what the user granted.

The core does not echo this form in the shape you sent it. You post { allowUserChoice, mandatory: [ids], optIn: [ids] }; the poll answers { allowUserChoice, permissions: [{ streamId, level, mandatory?: true, optIn?: true }] }, with the annotations folded into each entry. Read the resolved shape, not the one you sent.

Behaviour change in the hds-lib sign-in button

Section titled “Behaviour change in the hds-lib sign-in button”

pryv 3.13.0 replaces the button’s bare logout confirmation with an account menu (the signed-in username, the service, Manage my account, Log out) and can remember several accounts per app in a second cookie, pryv-libjs-<appId>-profiles.

SIGNOUT now fires when the user chooses Log out, not on the click itself. An app that treated a click-raised SIGNOUT as “the menu was opened” must invert that handling, and a sign-out routine that clears pryv-libjs-<appId> should clear the -profiles cookie too. Set authSettings.menu: false to keep the previous behaviour.