@phosra/link is the provider-side SDK for Phosra Link. The recommended
integration is a server-only v3 credential, one same-origin catch-all route, one
durable worker, and the mandatory-branded React component.
@phosra/link is ESM-only. The Golden server is Node-only and must be imported
from @phosra/link/server. Never import credentials or server APIs into a client
component.Family setup vNext
The additive family setup surface handles one destination account with zero or many provider-authorized children. It supports child-, family-, platform-, repair-, and preconnection entry points; existing and shared destination profiles; reviewed profile creation; policy translation notices; partial background completion; and signed provider events. It does not turn Phosra Link into a parent dashboard. Compose the durable runtime in the parental-controls application’s server process:familyLink.handler on the same origin as the parent-facing component for
all methods under /api/phosra/link/vnext/sessions. Run familyLink.runWorker()
in one long-running worker process. The handler creates a session only after your
authenticate adapter identifies the parent and your children adapter proves
the bounded child set.
The server returns a short-lived, one-time pfl1 token to your own authenticated
launch route. Give that token to the mandatory-branded React flow in memory:
Recommended architecture
Your application owns only its existing parent authentication, child lookup, and policy facts. Link owns environment discovery, trust verification, signed Intent V2 creation, public consent copy, OAuth handoff, durable delivery, retries, and evidence-based status.Next.js needs no transport plumbing — on 0.7.59 or later. Next patches
globalThis.fetch, and on its dynamic path (every fetch in next dev) it hands
back a reconstructed Response whose intrinsic URL slot is empty. Before 0.7.59
the SDK read that slot to bind a signed document to the URL it requested, so it
refused its own reads: createGoldenLinkServer(...) threw
GoldenEnvironmentBootstrapError: ENVIRONMENT_PRESET_INVALID for every call made
from inside a render or route handler, while the same composition worked in a
plain Node worker — so ceremonies completed and rules were enforced, but the app
could never read its own connection state.If you see that on an older minor, upgrade rather than reaching for a
globalThis.fetch workaround. 0.7.59 compares against the URL the SDK already
knows it asked for, so a stock composition — a plain import and one
createGoldenLinkServer(...) call, exactly as above — is all that is required.{application.origin}/api/phosra/link/callback, and the handler derives its
/api/phosra/link/sessions route from that same constant. Mount the catch-all
anywhere else and every request answers 404 ROUTE_NOT_FOUND, with nothing in
the body to tell you why.
The catch-all is important. It serves the callback, session creation and status,
retry/cancel, and signed platform-event receiver under /api/phosra/link/*.
Mounting only the callback breaks recovery and prevents later platform evidence
from reaching the provider.
children.resolve must return the census child id
id is the child’s census identity — a UUID the census issued — not your own
primary key. The SDK accepts a bare UUID or an already-prefixed child:<uuid>
and normalises both to child:<uuid>; anything else is refused.
Store the census id alongside your own child record (the reference provider keeps
a dedicated census_child_ref column) and return that column here. Your local id
is fine as the childRef your UI passes in — it just must not cross the SDK
boundary as id, because that value becomes the grant target_ref and the
consent target.
If your children do not exist on the census yet, provision them first — the Link
ceremony cannot mint one for you. POST /setup/quick returns a family, a child,
and an active policy in one call; its child.id is the value this adapter must
return. See the Quickstart.
The last row is the reason to wire
onDiagnostic before your first ceremony: the
HTTP body carries LINK_OPERATION_FAILED and nothing else, and the silent case
carries nothing at all until it fails downstream.
policy.resolve returns exactly one content_rating rule
The installed rule-copy edition supports one policy category. Return an array
holding exactly one enabled rule in this exact shape — no extra keys, no params
wrapper, no label or description (the SDK owns all public copy, so an application
cannot make a granted category sound weaker than it is):
null when this parent/child/platform combination has no policy: the
handler answers 404 PLATFORM_NOT_AVAILABLE, which is the honest outcome, not a
fault. Anything else — a second rule, a duplicate category, an unknown family
or scale, a max_allowed outside 0–21, an extra property, a params wrapper —
throws LinkPolicyError inside the SDK and surfaces as a 502 with the
diagnostic subcode START_RESOLVE_PLATFORM_FAILED, because the policy adapter is
called while the platform is being resolved.
If your product stores per-board ceilings (MPAA, TV Parental Guidelines, ESRB,
PEGI), convert them to a single age before you return: take the maximum
age-equivalent across the boards the parent has already chosen, so the ceiling you
send is the one they actually set rather than an invented stricter one.
The authenticated householdRef boundary
householdRef is your stable, provider-local family authority. Return it only
after authenticating the parent and proving that the child belongs to that
parent. The SDK derives a platform-scoped digest for signed Intent V2. The raw
value is not a browser input and is not sent to the platform.
Never use a display name, email address, or client-supplied family ID as
householdRef.
Your adapters must be deterministic for the life of a ceremony
children.resolve and policy.resolve are called again on the OAuth
callback, and the SDK asserts that the authority it journaled when the session
was created still matches. A change to id, ageBand, the derived householdRef
digest, or the resolved policy scope between start and finish rejects the finish.
Shared-profile consent preserves the host contract
An occupied same-family platform profile does not add a provider API or React state for the host to implement. The Golden Gatekeeper authorize handler recommends a separate profile and presents Choose another profile, Confirm sharing, and Cancel and return in that order. It explains that the strictest combined protections apply and that shared activity cannot be reliably attributed to one child. Before token exchange, the handler displays only the authenticated platform catalog label recovered from its server-side sealed catalog and the verified same-family member count. The signed selected-profile presentation follows token exchange. It does not expose raw platform IDs, family/child authority, target or aggregate identifiers, or policy parameters; forms carry only bounded opaque tokens. Names and numeric ages of existing members require a future provider-signed member-display resolver and are not claimed by this release.Run the durable worker
Run exactly one worker loop in a worker process and abort it during graceful shutdown. This is a standalone process, so import by relative path — the@/
alias your framework resolves does not exist for plain node:
await is fine here: this module is a process entry point, so a
failed bootstrap exits and your supervisor restarts it. The hazard described above
is a shared module that your routes import.
The worker serializes delivery and retry work. A successful HTTP response means
a verified receiver acknowledgement, not that every control is applied. Parent
status advances only when signed platform evidence supports it.
The verified environment expires; a long-lived process must be able to rebuild
it. The server captures the signed environment manifest once, at construction,
and every trust-bearing operation re-checks it against the wall clock. Nothing
inside the server re-fetches it. The manifests in use today carry a 30-day
validity window (the live production manifest is issued and expires exactly 30
days apart), so a container that outlives its captured manifest starts refusing
Link operations with
phosra/link-server: durable Trust List authority conflict.The memoised factory above is what makes recovery possible: drop the cached
promise (or restart the process) and the next call rebuilds against a fresh
manifest. A module-scope singleton has no way back.Render the parent experience
PhosraLinkSessionStartError and refusalCode are ten lines you write once —
Preserve the refusal code below has both.
Forward the supplied signal. Link cancels abandoned session creation, supports
restart-safe same-tab recovery, polls bounded server-authoritative status, and
never turns a delivery acknowledgement into a fake success state.
PhosraLinkDialog remains an alias for source compatibility. New code should use
the shorter PhosraLink export.
Shared-profile confirmation preserves the same createSession, onEvent, and
onExit behavior. Do not wrap the component in a second consent modal.
Preserve the refusal code
A refused session start answers with a code-bearing body —{"error":{"code":"ORIGIN_REJECTED"}}
for the most common permanent fault. throw new Error("Unable to start") throws
that away, and the dialog can then only offer a generic retry for something
retrying cannot fix. Read the code defensively and carry it on a typed error:
ORIGIN_REJECTED is permanent configuration, never transient — see
ORIGIN_REJECTED for the parent-facing copy and
the fix.
What onExit and onEvent mean
onExit receives a reason, and only two of the six mean a connection exists:
If you ignore the argument and only
setOpen(false), the parent completes the
flow, the dialog closes, and your page still renders Connect — because nothing
revalidated.
onEvent receives opened, handoff_started, status_changed, linked,
completed, cancelled, expired, and error. Status events are observational
and can be frequent; treat linked and completed as the only ones that earn a
host refresh. The error event carries a code of START_FAILED,
STATUS_FAILED, STATUS_TIMEOUT, CANCEL_UNCONFIRMED, or RETRY_UNCONFIRMED.
Neither
onEvent nor onExit explains why a ceremony failed — by design,
both are browser-facing and content-free. The server-side onDiagnostic hook is
the only channel that carries the cause: an operation, a subcode
(START_ORIGIN_REJECTED, START_RESOLVE_CHILD_FAILED,
CALLBACK_RESOLVE_PLATFORM_FAILED, …), a causeClass, a retryable flag, and a
correlationId. It is deliberately free of messages, stack traces, URLs,
identifiers, and user values, so it is safe to log verbatim.Server API
Grant reads and disconnects require the exact app-authorized tuple
{ grantId, platformDid, targetRef }. Do not accept that tuple directly from an
untrusted browser without re-authorizing it against your own tenant boundary.
Recover the grant tuple
GoldenLinkServer has no grant-enumeration method, and the ceremony does not
hand the tuple back to your UI. Read it from the package’s own store, then
re-authorize it against your tenant before you pass it to any operation:
listGrantsByChild is scoped by target, so the filter above is what binds the
grant to the parent your own session already proved. Reject anything that does
not match exactly one active row.
Let a parent resume or start over
A connection that stalls part-way through is the normal case a first integration forgets, and the result is a dead Connect button forever. Give the parent both affordances:restartIncompleteGrant never touches a confirmed connection, so it is safe to
expose behind a Start over control. Label the pair with plain copy —
Resume and Start over — and keep the last verified state visible while
either runs.
Let a parent refresh an existing connection
When a platform reportsdegraded, stale, or a previously applied result that
needs to be checked again, do not create another Link ceremony. The same method
also resumes an exhausted profile refresh after disconnectGrant() so the
platform can fetch the signed revocation tombstone and finish its release.
Re-authorize the child and exact active or disconnect-pending grant in your own
database, then pass only that exact tuple to the SDK:
runWorker() process delivers it, and Gatekeeper independently checks current
grant authority before reopening only a failed degraded or stale profile
lifecycle. A signed revocation request cannot reopen healthy or unrelated
authority. A healthy shared-profile aggregate and its other child memberships
remain intact. Keep the last verified state visible while the refresh runs;
label the parent action with plain copy such as Sync now or
Check disconnect.
If the platform is unreachable for the request’s five-minute validity window,
the request becomes immutable expired evidence. A later authorized parent check
mints the next signed retry epoch; repeated checks while a request is still
active reuse that exact request rather than creating parallel work.
Schema and upgrades
The package owns its own provider tables and ships the complete SQL inside the npm tarball.ready() — which the Golden factory calls for you — applies that
SQL, which means your runtime database role needs DDL rights unless you apply
it out of band.
Apply it as a deploy step instead, with a role that has database authority, and
the runtime role can drop CREATE:
DATABASE_URL. It does not read PHOSRA_CREDENTIAL or
touch the census, Trust List, or delivery configuration. Run exactly one
migration owner — never the web process and the worker concurrently.
Local development
Your credential pins the exact origin your app is served from. A v3 credential carriesapplication.origin, and the SDK enforces it on every
mutation: a request whose Origin header does not match is refused
403 ORIGIN_REJECTED before any connection claim is made. This is deliberate —
it is what stops another site from starting Link ceremonies against your
provider — but it means a production credential cannot be used from
http://localhost. Production credentials are HTTPS-origin-only, and the
production census’s signed manifest publishes allow_http_loopback: false.
Sandbox credentials, and where they come from today
Sandbox credentials are Phosra Link’s test keys. They are the same v3 envelope shape and differ from production credentials in exactly two ways that matter here:
The loopback allowance is not a special case in the SDK — it is published in the
sandbox census’s signed environment manifest as
redirect_policy.allow_http_loopback, with an exact host list
(127.0.0.1, localhost), and the SDK admits an http origin only when the
credential says sandbox and the verified manifest allows that hostname.
Once you have one, nothing in your code changes:
curl examples elsewhere in these docs.
One cookie jar on localhost
Ports and the callback path
The origin is an origin: scheme, host, and port, with no path. If your dev server runs on port 3005, the credential must sayhttp://localhost:3005;
http://localhost (port 80) is a different origin and will be rejected. A
provider credential also derives its callback as
{origin}/api/phosra/link/callback, which must be the catch-all route you
already mounted — so a provider’s client registration for local development
lists http://localhost:3005/api/phosra/link/callback exactly.
If you change dev ports, request a new sandbox credential. There is no way to
edit an issued one: the origin is inside the signed envelope.
When you are ready for real families
SwapPHOSRA_CREDENTIAL for your production credential and deploy to the HTTPS
origin it names. No code changes — the same createGoldenLinkServer call reads
whichever credential is present, and the credential decides which census,
trust root, and environment it belongs to.
Evidence states are intentionally different
boundmeans a durable connection exists.rule_recordedmeans requested controls were recorded, not applied.profile_readymeans the platform has a verified profile and results are being checked.appliedwithE4means every requested control has current concrete platform evidence.degraded,refused, andstaleremain visible; the SDK does not paint them green.revokedmeans the provider authority is revoked. Platform removal is complete only after the signed release lifecycle confirms it.
Advanced compatibility API
createLink(...) and the low-level ceremony functions remain available for older
writer-plane integrations. They are not the recommended durability boundary for a
new parent application. New integrations should begin with
createGoldenLinkServer(...) and PhosraLink.
See the Provider quickstart, branding contract,
and disconnect guide.