@phosra/link
v0.7.80
Published
Phosra Link SDK for connecting a parent application to an OCSS (Open Child Safety Specification) platform. It provides the mandatory-branded parent experience, the same-origin provider route, durable delivery, and the lower-level writer-plane APIs.
Downloads
2,757
Readme
@phosra/link
Phosra Link SDK for connecting a parent application to an OCSS (Open Child Safety Specification) platform. It provides the mandatory-branded parent experience, the same-origin provider route, durable delivery, and the lower-level writer-plane APIs.
Install
npm install @phosra/linkpg (node-postgres) is a runtime dependency — Link's durable authority and delivery
stores are Postgres-backed. React is an optional peer dependency used by
@phosra/link/react.
Database migration (run before the web and worker processes)
Apply the complete provider schema once per deployment, before either runtime
process starts. The migration needs only database authority: DATABASE_URL is its
only input. It does not need PHOSRA_CREDENTIAL, census or Trust List settings,
writer keys, or delivery secrets.
// scripts/migrate-phosra-link.ts
import { Pool } from "pg"
import { migrateLinkServerSchema } from "@phosra/link/server"
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) throw new Error("DATABASE_URL is required")
const pool = new Pool({ connectionString: databaseUrl })
try {
await migrateLinkServerSchema(pool)
} finally {
await pool.end()
}Run this entry point as the single pre-deploy migration owner. Start the web process only after it succeeds, then start one worker against the same database. Do not run migrations concurrently from the web and worker processes.
Provider application quickstart (recommended for new v3 integrations)
A v3 PHOSRA_CREDENTIAL contains the pinned environment authority. The SDK derives
the correct environment, trust roots, platform endpoints, public rule copy, granted
scope, durable stores, and same-origin protocol from that credential. Do not copy
those values into browser code or accept them from a form.
// lib/phosra-link-server.ts — server-only composition shared by two processes.
import { Pool } from "pg"
import { createGoldenLinkServer } from "@phosra/link/server"
export const phosraLinkServer = await createGoldenLinkServer({
credential: process.env.PHOSRA_CREDENTIAL!,
database: new Pool({ connectionString: process.env.DATABASE_URL }),
// The only app-owned business seams:
authenticate: appAuth.authenticatedParent,
children: { resolve: appChildren.resolveForParent },
policy: { resolve: appPolicy.rulesForChildAndPlatform },
})// app/api/phosra/link/[...phosra]/route.ts — web process only.
import { phosraLinkServer as server } from "@/lib/phosra-link-server"
// This catch-all file routes the credential's callback and every sibling route:
// /api/phosra/link/callback
// /api/phosra/link/sessions
// /api/phosra/link/sessions/:ceremonyId
// /api/phosra/link/sessions/:ceremonyId/retry
// /api/phosra/link/sessions/:linkId/events
export const GET = server.handler
export const POST = server.handler
export const DELETE = server.handlerKeep this code in the catch-all route shown above, not only in
app/api/phosra/link/callback/route.ts. The credential-configured v3 callback,
session collection, ceremony status/retry, and Link event receiver must all resolve
through the same handler or the sibling paths will return 404.
The POST /api/phosra/link/sessions/:linkId/events route is the platform-to-provider
machine-event receiver. Here :linkId is the signed UUIDv8 Link ID from the platform
event authority, not the parent's UUIDv4 ceremony ID used by
GET .../sessions/:ceremonyId.
Keep it mounted: after the delivery ACK, platforms use this same handler to submit
signed profile materialization, rule confirmation, observation, and release events.
Omitting it can leave a successfully linked session at
rule_recorded because later evidence has nowhere to arrive. Platform SDKs call it
with a UUIDv4 Idempotency-Key and the exact Link event or release-event content and
accept media types; app developers mount the handler and must not parse or rewrite
those machine messages.
The factory returns only after the package-owned schema is ready and the pinned environment has passed its checks. The runtime readiness guard is useful for local development, but it does not replace the single-owner pre-deploy migration above. Run one SDK-owned worker loop in the provider worker process; abort it during graceful shutdown. The loop never overlaps delivery attempts.
// scripts/phosra-link-worker.ts — separate long-running process/service.
import { phosraLinkServer as server } from "../lib/phosra-link-server"
const shutdown = new AbortController()
process.once("SIGTERM", () => shutdown.abort())
process.once("SIGINT", () => shutdown.abort())
await server.runWorker({ signal: shutdown.signal })Run that entry point as its own worker service against the same database and
credential as the web service. Never await runWorker() from the web server,
the catch-all route module, a request handler, or a build step. A web-only
deployment is incomplete even when session creation and the parent UI appear
healthy.
Projecting an active connection into your application
A completed browser callback, a saved grant, a successful delivery attempt, or a verified receiver ACK is not enough to mark a host-owned connection active. Those facts prove progress through Link, not that the receiver has signed an applied report for every current required rule.
Reconcile host state from the exact grant authority. Bind all three identifiers
to the row you intend to update, and project active only when
platformReport is exactly complete:
async function reconcileConnection(connection: {
grantId: string
platformDid: string
targetRef: string
}) {
const projection = await server.getSnapshotForGrant({
grantId: connection.grantId,
platformDid: connection.platformDid,
targetRef: connection.targetRef,
}).catch((error) => {
log.warn("Phosra Link projection unavailable", { error })
return null
})
if (projection?.snapshot.stage === "revoked") {
if (projection.releaseStatus === "complete") {
await removeExactActiveConnection(connection)
}
// An incomplete release is not authority to erase the last known state.
return
}
if (projection?.platformReport !== "complete") {
// null, incomplete, failed, or a lookup error remains pending/attention.
// Do not create, repair, or upgrade an active connection here.
return
}
await upsertExactActiveConnection({
...connection,
// An absent presentation is not authority to erase a prior verified label.
...(projection.selectedProfile === null
? {}
: { selectedProfile: projection.selectedProfile }),
})
}Handle lookup errors like an unavailable projection: retain the last known host
state and retry later. Do not convert uncertainty into either a new active row
or a disconnect. Removal requires both the exact signed revoked snapshot and
releaseStatus === "complete" for that same grant, platform, and target.
platformReport: "complete" means the receiver supplied valid signed applied
reports for every required rule in the current profile generation. It is the
minimum authority for the host's active-connection projection; it is not an
independent observation and does not by itself earn the stronger E4 applied
stage. Use the full Link snapshot for parent-facing enforcement detail instead
of reducing every state to a boolean.
In the parent UI, render the mandatory-branded component from the React subpath only
after the parent chooses a child and platform and opens the Link flow. Its
session response contains SDK-generated display copy and platform identity; the
component owns handoff, restart-safe recovery, polling, cancel, and evidence-based
status. When the current verified stage is degraded, it offers Try again for the
same connection. The SDK schedules an exact durable platform retry, waits until the
server-authoritative next_check_at, and keeps the degraded result visible unless new
signed evidence earns a better state. The stale stage remains distinct and
recoverable across reloads, but does not offer retry until a verified freshness
authority is available.
Link 0.7.40 preserves the provider's last signed revoked observation across a generation change. The platform may accept it only when its independent current authority explicitly allows revoked recovery and every delivery and authority binding is exact. This lets a parent finish an interrupted disconnect without weakening the rule that older non-revoked retry requests are rejected. Link 0.7.41 lets an explicit parent retry mint one new signed epoch after a prior authorization failure, so a repaired platform can be reached without rewriting or deleting the immutable failed request. Link 0.7.42 also permits one new signed epoch after an accepted requeue when the platform still reports degraded or stale. Completed, active, and recovered results remain idempotent.
import { PhosraLink } from "@phosra/link/react"
{linkOpen ? <PhosraLink
createSession={async ({ signal }) => {
const response = await fetch("/api/phosra/link/sessions", {
method: "POST",
signal,
headers: { "content-type": "application/json" },
body: JSON.stringify({
platform_did: selectedPlatformDid,
child_ref: selectedChildId,
return_to: window.location.pathname,
}),
})
if (!response.ok) throw new Error("Unable to start Phosra Link")
return response.json()
}}
onEvent={recordLinkEvent}
onExit={closeLink}
/> : null}Always forward the supplied signal to the request that creates the session.
Phosra Link aborts it when the 15-second creation fence expires, the component
unmounts or closes, or the parent starts over. Each retry receives a new signal;
an adapter must never reuse a prior controller or continue an abandoned request.
This keeps browser cancellation aligned with the SDK's ceremony authority and
prevents a late response from adopting or cancelling the wrong connection.
Shared profile confirmation needs no host UI
If a parent chooses a platform profile already linked inside the same verified family, the platform's Golden Gatekeeper authorize handler presents the mandatory Phosra Link confirmation. It recommends a separate profile, explains that sharing applies the strictest combined protections and limits per-child activity attribution, and offers Choose another profile, Confirm sharing, and Cancel and return in that order.
Provider applications keep the same PhosraLink component, createSession,
onEvent, and onExit integration. Do not recreate the confirmation in the
host application or pass family, child, profile, target, or policy authority
through browser props. Gatekeeper 0.8.1 displays the authenticated platform
catalog label recovered from its server-side sealed catalog and the same-family
member count. The signed selected-profile presentation follows token exchange;
it is not the source of the confirmation label. Member names and numeric ages
are intentionally deferred until a future provider-signed member-display
resolver exists.
No recoveryKey is required. After authenticating the parent and resolving the
authoritative child and platform, the server SDK returns a versioned opaque recovery
scope bound to that provider, parent, child, platform, application origin, and
environment. The React SDK hashes that scope again for its sessionStorage key and
stores only the bounded ceremony locator; it never stores a raw identifier,
authorization URL, credential, policy, code, state, or platform secret.
Reopening the same Link flow creates one unhanded probe session to learn the current
verified scope. If an unexpired ceremony already owns that exact scope, Link retires
the unused pending intent after re-verifying the authenticated parent-session and
child commitments, then resumes the prior ceremony. A different parent, child, platform,
origin, or environment cannot inherit it. A scope is intentionally one recovery slot:
the latest successful handoff write wins, and an older simultaneous instance cannot
clear the newer claim. Legacy/custom session endpoints may supply the advanced
recoveryKey override; an invalid override or unavailable browser storage fails
closed and Link does not offer a same-tab handoff it cannot recover.
See examples/server-junior.ts for the packed,
type-checked server composition core. V1/v2 credentials are compatibility-only and
use the advanced compatibility APIs; the five-input golden server is v3-only and
rejects host environment, transport, lifecycle, receiver, network, and clock
overrides.
vNext family setup
The additive family-plan contract freezes one parent-reviewed platform-account plan above the existing scalar child/profile grants. It does not widen or replace the v1 session wire shape. The package now includes PostgreSQL session, accepted-plan, operation, destination-graph, and signed-event persistence; task-oriented vNext routes; a restart-safe worker; a Notflix qualification adapter; and an accessible, responsive family mapping and review surface.
import {
acceptFamilyPlanV1,
encodeAcceptedFamilyPlanV1,
makeInMemoryFamilyPlanStoreV1,
materializeAcceptedFamilyPlanOperationsV1,
parseAcceptedFamilyPlanV1,
type FamilyPlanDraftV1,
} from "@phosra/link"
const accepted = acceptFamilyPlanV1(draft satisfies FamilyPlanDraftV1, {
expected_revision: draft.revision,
display_plan_digest: reviewedDisplayDigest,
accepted_by: authenticatedParentRef,
accepted_at: "2026-08-04T16:30:00Z",
})
const acceptedBytes = encodeAcceptedFamilyPlanV1(accepted)
const restored = parseAcceptedFamilyPlanV1(acceptedBytes, accepted)
// Reference/conformance implementation only; it is not process-durable.
const plans = makeInMemoryFamilyPlanStoreV1()
const stored = await plans.putAccepted(restored)
const reopened = await plans.getAccepted(accepted.plan_id, accepted.accepted_revision)
const queuedOperations = materializeAcceptedFamilyPlanOperationsV1(reopened!.plan)Acceptance fails closed when the reviewed revision is stale, a choice remains
unresolved, a material exception is unacknowledged, an operation crosses the
provider-authorized child set, or the operation graph is duplicate, incomplete,
cyclic, or ambiguously bound. The returned plan and every dependency list are
detached and frozen so later draft mutation cannot change the accepted operation
set. FamilyPlanAcceptanceError extends the package's stable LinkError surface.
Every operation must also carry an explicit execution_mode (automatic or
guided) and retry_safety (idempotent, reconcile_before_retry, or
manual_only); Link never supplies a retry default. connect_account and
guided_step operations are necessarily guided plus manual_only. Profile,
mapping, and policy operations are automatic, but their retry safety remains an
adapter-capability decision bound to the reviewed capability_revision.
classifyFamilyPlanOperationSafetyV1 turns that exact versioned adapter
declaration into the fields the draft builder must present for review. A declared
same-key replay guarantee produces idempotent. Without replay safety,
authoritative post-attempt observation produces reconcile_before_retry; without
either guarantee, an automatic operation becomes manual_only. Connection and
guided-step declarations remain guided plus manual_only, even when their
completion can be authoritatively observed. The classifier rejects stale
capability revisions, missing or unknown fields, widened objects, automatic
connection/guided operations, guided platform mutations, and replay guarantees on
human-guided work. FamilyPlanCapabilityError uses the stable
family_plan_capability_revision_conflict code for stale authority and
family_plan_capability_invalid for malformed declarations through the package's
LinkError surface.
Normal draft construction should use buildFamilyPlanOperationV1 and
buildFamilyPlanDraftV1, not hand-written safety fields. The operation builder
accepts references, readiness, exceptions, dependencies, and one exact capability
declaration; callers cannot provide execution_mode or retry_safety overrides.
The draft builder applies the plan's single capability_revision to every
operation, validates the authorized child set and complete acyclic dependency
graph, derives whether unresolved choices remain, and returns a deeply frozen
FamilyPlanDraftV1. Cross-kind capabilities, stale capability revisions,
self/duplicate dependencies, missing dependency targets, crossed child scope, and
false ready_for_review or needs_choices projections fail closed through typed
builder errors.
The byte codec emits only canonical JSON and the parser rejects malformed UTF-8, noncanonical JSON, duplicate members, unknown fields, invalid operation authority, and any mismatch with the optional exact expected plan. Parsed plans are detached and deeply frozen, making the artifact safe to hand to a later durable-store slice.
FamilyPlanStoreV1 defines that durable-store seam. Its accepted-plan coordinate
is the exact (plan_id, accepted_revision) pair: replaying identical canonical
authority is idempotent, while different authority at that coordinate throws
FamilyPlanStoreConflictError and preserves the original acceptance. Stored
records include the canonical bytes and their base64url SHA-256 digest. Every
returned plan and byte array is detached from store-owned state. A valid missing
coordinate returns null; malformed locators fail closed. The exported in-memory
implementation is intended for conformance, tests, and integration development,
not production durability.
Repository-owned store adapters must run the shared
test/helpers/family-plan-store-contract.ts suite through their own isolated
factory and cleanup fixture. The suite is adapter-neutral and covers retrieval,
exact replay, immutable-coordinate conflicts, caller-mutation isolation,
independent revisions, concurrent exact replay, and concurrent conflicting
acceptance. The PostgreSQL adapter runs the same contract and adds transactional,
restart, and compare-and-set coverage. Additional adapters should register with
the shared contract rather than copying it.
materializeAcceptedFamilyPlanOperationsV1 deterministically expands valid
accepted authority into one immutable, independently addressable work record per
operation. Every record carries the provider, platform account, inventory and
capability revisions, child/destination authority, dependency IDs, accepted-plan
artifact digest, operation-authority digest, accepted execution mode and retry
safety, and a bounded content-derived idempotency key. Materialization preserves
review order with a zero-based
operation_index, starts every operation as queued with zero attempts, and
does not perform any destination mutation. Re-materializing identical accepted
authority returns equal records; changing any accepted-plan authority changes
the operation keys. The operation worker executes these records under expiring
leases, reconciles uncertain results before any unsafe retry, preserves partial
success, and publishes reconstructible signed provider events.
For production PostgreSQL composition, use the server subpath:
import { makePostgresFamilyLinkRuntimeV1 } from "@phosra/link/server"
const familyLink = makePostgresFamilyLinkRuntimeV1({
database: pool,
adapter: destinationAdapter,
application_origin: "https://app.bark.example",
provider_did: "did:ocss:bark",
client_token_key: familyLinkClientTokenKey,
authenticate: authenticateBarkParent,
children: barkChildAuthority,
platforms: phosraPlatformDirectory,
accounts: destinationAccountAuthority,
provider_events: {
signing_key: familyEventSigningKey,
destination_url: "https://app.bark.example/webhooks/phosra-link",
},
})
await familyLink.migrate()
// Mount familyLink.handler at its same-origin /api/phosra/link/vnext paths.
const shutdown = new AbortController()
process.once("SIGTERM", () => shutdown.abort())
await familyLink.runWorker({ signal: shutdown.signal })The parent application creates sessions server-side for child, family,
platform, repair, or zero-child preconnection entries. The browser receives
only a one-time pfl1 token and a safe projection. Bootstrap consumes that token
and issues a Secure, HttpOnly, SameSite=Strict continuation cookie that is
cryptographically bound to the one family session and expires with it; raw parent
authority is never placed in the cookie. Account inventory, adapter capabilities,
child/household authority, review labels, and operation safety are resolved again
on the server; the browser submits choices, not operations.
import { createFamilyLink, FamilyPhosraLinkFlow } from "@phosra/link/react"
const link = createFamilyLink({
token: oneTimeFamilyLinkToken,
onLoad: renderLink,
onEvent: recordNonSensitiveMilestone,
onPlanAccepted: () => refreshBarkServerProjection(),
onExit: () => restoreBarkReturnContext(),
})
await link.preload()
link.open()The component and its JSON routes must share the parental-controls application's
origin. Browser requests use credentials: "same-origin"; do not proxy the UI
from an unrelated Phosra domain or widen the continuation cookie for cross-site
use. A native iOS or Android app can present a provider-owned web route containing
FamilyPhosraLinkFlow in its existing authenticated web view. Pass the one-time
token to that page through a native-to-page message or initial in-memory page
state, immediately remove any transient URL fragment if one is used, and route
terminal callbacks back into the provider app. The parent still starts and ends
inside the provider, while the server runtime, account handoff, review authority,
and durable worker remain identical across web and native shells.
FamilyPhosraLinkFlow is the turn-key React controller: it drives preload,
destination authentication return, the server-authored masked account checkpoint,
profile choices, explicit sharing and translation acknowledgements, exact review,
acceptance, and safe exit. FamilyPhosraLink is the lower-level controlled renderer
for hosts that intentionally own that controller. Both render the projection with Phosra branding and a
controlled partner appearance profile. Shared destinations require explicit
acknowledgement, unresolved choices block approval, and final approval submits the
exact server-rendered review digest. Closing before approval preserves the draft;
closing after durable acceptance does not cancel background execution. Ongoing
settings, age-change approval, notifications, and support remain in the parental-
control product.
The account authority adapter may expose discover() after the destination handoff;
Link projects only an opaque account reference, a masked differentiator, and a profile
count, then re-resolves the reference before confirmation. For policy writes, return
one policy_authorities entry per authorized child. Its content-addressed
policy_ref, factual display summary (for example Content rating: TV-Y7),
translation outcome, and approval requirement become part of the reviewed plan.
Changing that authority invalidates review instead of applying newly looked-up rules.
Formal OCSS status and Phosra runtime admission are deliberately separate. Legacy
OAuth resolution still fails closed on a missing or inactive OCSS entry. A server
may configure LinkConfig.runtimeAdmission to consult a separately reviewed
capability record with rollout, expiry, and kill-switch policy; this can admit a
Phosra-provisional integration without claiming OCSS verification. Never surface
developer governance labels as parent-facing trust claims.
For the signed operational registry behind that resolver, use
signIntegrationCapabilityAdmissionV1 to produce canonical Ed25519 artifacts and
makeIntegrationRuntimeAdmissionRegistryV1 to load them atomically. Pass
registry.resolve as runtimeAdmission. Every lookup re-binds the signed record to
the current root-verified OCSS fact and enforces its capability, rollout cohort,
expiry, disagreement flag, and kill switch.
Local whole-product qualification (testing only)
Prerequisite: install OpenSSL 1.1.1 or newer, or a compatible LibreSSL build that
supports req -addext and x509 -extfile, and make openssl available on PATH.
Confirm the local tool before starting with openssl version. If TLS generation
cannot run, the helper fails closed with
FRESH_AUTHORITY_TLS_GENERATION_FAILED and never exposes raw command or environment
details.
Use @phosra/link/testing to walk a real parent app and platform locally with real
signed credentials, consent encryption, RFC 9421 requests, replay authority,
endpoint minting, and a router-signed profile. The policy is the source of the age
ceiling; do not duplicate the number in a second fixture.
import {
cleanupLocalPairResourcesV1,
createLocalLinkPairAuthorityV1,
installLocalPairNetworkV1,
startLocalPairAuthorityServerV1,
writeLocalPairProcessBootstrapV1,
} from "@phosra/link/testing"
const pair = createLocalLinkPairAuthorityV1({
contentRatingMaxAllowed: policy.config.max_allowed,
// Optional for an isolated local provider app; omitted stays localhost:3000.
providerApplicationOrigin: "http://localhost:3019",
})
const network = installLocalPairNetworkV1(pair)
let bootstrap
let authority
try {
bootstrap = await writeLocalPairProcessBootstrapV1("./tmp/link-pair", pair)
authority = await startLocalPairAuthorityServerV1({
pair,
extension: { staticGetRoutes: [jwksRoute, policyRoute, confirmation404Route] },
})
await walkParentAndPlatform({
providerCredential: pair.provider.credential,
platformCredential: pair.platform.credential,
childProcessEnv: {
NODE_OPTIONS: bootstrap.nodeOptions,
NODE_EXTRA_CA_CERTS: bootstrap.nodeExtraCaCerts,
},
})
console.log(authority.snapshot()) // safe counts + request digests only
} finally {
await cleanupLocalPairResourcesV1({ authority, bootstrap, network })
}Static host routes are cloned and bounded before the listener starts; they cannot
override Link-owned paths. A profile URL returns 404 until the exact consent-bound
mint succeeds, then serves only that minted label. Each call creates a new private
run secret, signed provider/platform credentials, trusted localhost chain, and
separate wrong-chain fixture; no fixed private key is published. Temporary key
files are mode-restricted and removed immediately. The helper denies external
origins. It is for local automated/human qualification only—never import the testing subpath into
staging or production. A default pair captures the current clock once and signs one
coherent 30-day manifest, Trust List, and profile window. For a multi-process test,
capture one canonical whole-second pairNow, persist that non-secret value in the
fresh run manifest, and pass the same explicit now: pairNow and policy ceiling to
every process; explicit clocks are fixed hermetic test fixtures, while default-clock
pairs are rechecked against wall time when the local authority starts. Never rebuild
one side from a newly sampled clock or reuse credentials from an older run. The
complete executable version is
examples/local-pair-junior.ts, which is compiled
by npm run typecheck:examples.
Legacy compatibility writer-plane API: createLink
createLink remains available for direct OCSS writer clients and existing
integrations. New provider applications should start with the v3 server and React
facades above. Its schema helpers provision the legacy writer-plane tables only;
they are not the Golden server migration path.
createLink collapses the 8-field LinkConfig to three and returns a
signed-by-default compatibility client. One key (the writer key you already
hold), one census URL, one database — no shared connect secret.
Which census URL. There is ONE production census and
https://prodapi.phosra.comis its name;https://phosra-api-prod-bootstrap-production.up.railway.appis the same service under its Railway origin name (verified 2026-07-26 — byte-identical/health, trust-list, succession and environment-manifest responses, both verifying under rootroot-prod-bootstrap-2026-07/824vsCATBxyUiA-znpGx01N48NNs_3gPE3M7f7vIEaI). Do not configurecensus.phosra.com: it is the intended canonical alias but no service is attached yet, so every path —/healthincluded — returns Railway's{"code":404,"message":"Application not found"}.Two caveats while this lands. (1) The
prodapi.phosra.compin is in source but not yet published — on npmlatest(0.7.56) that host is unpinned, socreateLinkthrowsno bundled Trust-List root for census host. Until a release ships, either passtrustRoot: "824vsCATBxyUiA-znpGx01N48NNs_3gPE3M7f7vIEaI"explicitly or use the Railway origin name, which 0.7.56 already pins. (2) Railway's edge intermittently answersprodapi.phosra.comwith its*.up.railway.appwildcard certificate (~1 handshake in 4, measured 2026-07-26) — a TLS altname error that a retry usually clears; the Railway origin name was 60/60 clean.
import { createLink } from "@phosra/link"
import pg from "pg"
const link = createLink({
census: "https://prodapi.phosra.com", // THE production census (root-prod-bootstrap-2026-07)
writerSeed: process.env.OCSS_WRITER_SEED!, // your Ed25519 accreditation seed (base64url)
writerKeyId: "did:ocss:your-org#2026-07", // the DID+kid published on your Trust-List entry
db: new pg.Pool({ connectionString: process.env.LINK_DB_URL }),
})
// Provision the product tables + run the accreditation self-check (once, at boot).
await link.ready()
// Connect a child (Plaid-style 3-leg ceremony) — signed delivery by default:
const { authorizeUrl, state, sessionId } =
await link.connect.start({ platformDid: "did:ocss:notflix", redirectUri, parentSessionRef })
// …redirect the parent, then on the OAuth callback:
const { childProfiles } = await link.connect.resume({ code, state, parentSessionRef })
const result = await link.connect.finish({
sessionId, platformChildProfileId: childProfiles[0].id, childId: "child:<uuid>",
granted_scope: ["addictive_pattern_block"], state,
})
// Write an enforcement rule under the granted scope:
await link.enforce(result.grant_id, "addictive_pattern_block", "child:<uuid>", { decision: "block" })
// Batch-provision N age-banded profiles in one action (signed by default):
await link.provision("did:ocss:notflix", { state: "acct", children: [ /* … */ ] })
// Withdraw consent:
await link.revoke(result.grant_id)What is derived (so nothing downstream changes): writerDid from
writerKeyId; the router DID (constant did:ocss:phosra-router); the router
payload key (pulled from the Trust List at use time); the family-hash
householdSecret = HKDF(writerSeed, "phosra-link/household-hash/v1"); and the
household/parent persona = HKDF(writerSeed, "phosra-link/parent-persona/v1")
→ a deterministic did:ocss:household-<hash>.
- Pinned roots, no TOFU. For a canonical census host the Trust-List root
ships with the SDK. For any other host you MUST pass
trustRoot(the base64url root X) — the SDK never trusts a root it just met. - Signed by default.
connect.finishandprovisiondeliver the sender-DID-signed writer envelope (verified to the pinned root); there is no connect secret to mint or rotate. An undocumented__legacyConnectSecretescape hatch remains for receivers that have not migrated off HMAC. - Ships its legacy writer-plane schema.
link.migrate()(ormigrateLinkSchema(pool)/printLinkSchema()) provisions only the compatibility tables used by this API. It does not provision the complete Golden provider schema. Golden integrations must usemigrateLinkServerSchema(pool)from@phosra/link/serveras the single pre-deploy migration owner. - Accreditation self-check.
link.verifyAccreditation()(run bylink.ready()and before every write) root-verifies the Trust List, matches your writer public key, and requires an active enforcement-agent entry — elseNotAccreditedError. PassverifyAccreditation: falseto disable. - Census registration of the derived household DID. The derived
did:ocss:household-<hash>persona signs the consent attestation; for the census to verify that signature the persona must be admitted on the census (or pass your own already-registeredparentKey). This is an operator step, not a code change.
Typed errors
Every failure is a LinkError subclass with a stable code:
NotAccreditedError, PlatformNotConnectableError, LaneInactiveError,
StateExpiredError, ParentSessionMismatchError, DeliveryFailedError (with
.status + a .hint — a 401 delivery says "add your DID to the platform
allowlist"), SchemaNotReadyError, BandMismatchError. Branch on .code or
instanceof; the human-readable messages are unchanged from prior versions.
Legacy low-level API reference
This manual LinkConfig surface is retained for existing writer-plane
integrations. It is not the recommended provider quickstart and its individual
census, Trust List, writer, parent, and household values must not be copied into a
new Golden integration. New applications use one PHOSRA_CREDENTIAL, the Golden
server quickstart above, and migrateLinkServerSchema(pool) for deployment.
import { makeLinkStore, createLinkSession, completeLink, directive } from "@phosra/link"
import type { LinkConfig, LinkSession } from "@phosra/link"
import pg from "pg"
// 1. Construct your config (real keys, live census URL).
// Obtain a key from the Phosra developer console or via
// POST /api/v1/developers/orgs/{orgID}/keys (see docs.phosra.com).
const config: LinkConfig = {
censusBaseUrl: "https://phosra-api-sandbox-production.up.railway.app", // the ONE canonical partner sandbox host (see the manifest caveat below)
trustRootXB64Url: process.env.OCSS_TRUST_ROOT_X!, // sandbox root X (public): CMHWy3vUAiEcYDdE_bDvkRuEqwxkklS0tV-TYHJTlWU
parentKey: { seed: new Uint8Array(32) /* supply real Ed25519 seed */, keyID: "did:ocss:household-acme#parent-key-2026" },
writerKey: { seed: new Uint8Array(32) /* supply real Ed25519 seed */, keyID: "did:ocss:your-org#writer-key-2026" },
writerDid: "did:ocss:your-org",
routerDid: "did:ocss:phosra-router",
householdSecret: process.env.HOUSEHOLD_SECRET!, // high-entropy per-family shared secret
pool: new pg.Pool({ connectionString: process.env.LINK_DB_URL }),
}
// 2. Obtain the Postgres-backed grant store (synchronous — takes cfg.pool directly).
// NOTE (manifest caveat, verified 2026-07-26): the partner sandbox census does
// NOT currently serve /.well-known/phosra/environment-manifest-v1 — it returns
// plain-text "404 page not found", i.e. the route is absent from its deployed
// build. Anything that resolves its environment, trust roots or platform
// endpoints FROM the manifest (the v3 createGoldenLinkServer/PHOSRA_CREDENTIAL
// path) therefore cannot bootstrap against this host yet. This explicit
// LinkConfig path, which carries censusBaseUrl and trustRootXB64Url directly,
// is unaffected. The production census (prodapi.phosra.com) and the
// Phosra-internal staging sandbox both serve the manifest normally.
// completeLink/directive also use the store internally; use this reference for
// direct grant queries (listGrants, getGrant, etc.).
const store = makeLinkStore(config.pool)
// 3. Open a link session for a parent authorizing access for a platform.
const session: LinkSession = createLinkSession(
"did:ocss:your-platform", // audience_did — the platform being granted write access
["addictive_pattern_block"], // granted_scope
"child:a11ce0fa-0000-4000-8000-0000000000a1", // target_ref — the child's full census UUID (normalizeChildRef rejects a truncated id)
{ ageHint: "13_15" },
)
// 4. Complete the link: posts a consent_attestation to the census.
const { grant_id } = await completeLink(config, session)
// 5. Write a rule directive under the granted scope.
const res = await directive(config, grant_id, "addictive_pattern_block", session.target_ref, {
decision: "block",
})Create-and-link (no existing profile, no prior grant)
completeLink binds an EXISTING platform profile via the connect ceremony. When the
child has no profile on the platform yet (one parent action → create the profile
AND bind it — EXT-01 batch provisioning), the chain is three public calls, per child:
import { ingestConsentAttestation, mintEnforcementEndpoint, provisionProfiles } from "@phosra/link"
// 1. Consent FIRST (the census enforcement-endpoint mint is consent-gated):
// signed as the AUTHORITY HOLDER (config.parentKey — the household persona).
const consent = await ingestConsentAttestation(config, {
platformDid: "did:ocss:notflix",
childRef: "child:<census-child-uuid>", // a census subject (POST /api/v1/subjects)
band: "13_15", // §8.3.2 AGE band, never a capability band
})
// 2. Mint the §9.3(b) resolver-bound endpoint (signed as config.writerKey),
// naming the consent standing on the binding:
const { endpointIdLabel } = await mintEnforcementEndpoint(config, {
platformDid: "did:ocss:notflix",
childRef: "child:<census-child-uuid>",
standingRef: consent.standingRef, // "consent:attestation:<key>"
})
// 3. Deliver the batch to the platform's connect receiver, sender-DID-signed
// (EXT-01 §3.6 — zero shared secret; `state` is the platform-minted principal
// ref, e.g. its OAuth access token from the parent's authorize-approve):
await provisionProfiles(config, "did:ocss:notflix", {
state: platformAccessToken,
children: [{ endpoint_id_label: endpointIdLabel, age_band: "13_15", display_hint: "Nia" }],
auth: { mode: "signed" },
})Rules then live on the child's policy (the census compiles the served
enforcement profile from the policy's rules; the binding only routes the
platform's pull): write them with directive(...) under a grant, or directly
via the rule-write API citing consent.standingRef as standing_ref.
Parent sessions (the ceremony's auth binding)
The parent authenticates with your app's own auth — Phosra never sees parent
credentials. What @phosra/link needs is a trustworthy binding between your
authenticated parent session and the OAuth ceremony:
- Verified mode (recommended): set
parentSessionSecretinLinkConfig. After your login succeeds, issue a token withissueParentSession(secret, { parentId })(e.g. as an HttpOnly cookie) and pass it asparentSessionReftoinitPlatformOAuth/completePlatformOAuth/bindProfile. The SDK verifies it (HMAC + expiry, fail closed) and binds the ceremony to the verified session id — an expired login cannot start or finish a ceremony, and one parent's state can never be completed by another parent's session. - BYO mode: leave
parentSessionSecretunset and pass your own server-side session identifier asparentSessionRef. Contract: derive it server-side from your authenticated session; NEVER accept it from the client.
For v2/v3 PHOSRA_CREDENTIAL servers, the golden callback handler performs the
parent authorization, exact child/profile selection, platform-session bind, and
ceremony-authority write in one transaction. The callback is exact-replay safe,
and a failed authority write cannot leave the session partially bound. The v1
credential path remains available only as the explicit legacy fallback.
See examples/reference-bff/ for the full working wiring.
Receiving sealed harm signals (EXT-03, the F6 lane)
The parent app pulls its sealed harm excerpts from the census over the documented
§8.3.9 recipient-pull (GET /api/v1/harm-context — receiver-scoped, sealed_payload
served byte-verbatim) and decrypts them client-side with its own EC P-256 payload
key. The census/router never holds that key (the §3A.3 router-blind invariant).
import { harmContextReadiness, receiveHarmContext } from "@phosra/link"
// payload_jwk prerequisite checklist + the harm_context_ready flag:
const { harm_context_ready, checklist } = harmContextReadiness(config) // LinkConfig w/ payloadJwk
if (!harm_context_ready) throw new Error(checklist.filter(c => !c.ok).map(c => c.item).join(", "))
const { signals, rejected } = await receiveHarmContext({ client, payloadJwk: config.payloadJwk! })
// signals[i].signal is one of two TYPED shapes (the bespoke sealed shape is retired):
// kind: "harm_context" — the §3A.2 post-confirmation excerpt body
// kind: "verdict" — the EXT-03 harm-signal-verdict.v1, severity + harm_class
// validated against the §4.4 closed enums BY CITATION
// rejected[] rows failed a vocabulary rule (H7/H8/H9/H11) and were rejected WHOLE —
// out-of-vocabulary is never coerced to a near match (§4.4 rule 2, §7.1).Post-decrypt conduct is the recipient's §4.6 duty: retention clock, purpose limitation, disposition receipt. Apparent CSAM never rides this lane in either direction — the census diverts it at the route step, and the parent-facing enum deliberately excludes it (report-and-preserve is the only path for that class).
Pricing & keys
This SDK is free and open (MIT). It performs all signing/verification locally and calls
the hosted Phosra census over RFC 9421. Metered census usage requires a Phosra API key —
billing is enforced server-side, never in this package. Provision a key via the Phosra
developer console (docs.phosra.com) or the REST endpoint
POST /api/v1/developers/orgs/{orgID}/keys.
The /protocol subpath
import { verifyDocument, verifyReceipt } from "@phosra/link/protocol"@phosra/link/protocol is a verbatim re-export of @openchildsafety/ocss — identical object identities.
It lets platform-side code verify signed documents and receipts without a separate @openchildsafety/ocss
install when you already depend on this package.
Stability
This package is 0.x — live-proven but still co-evolving with the OCSS spec. Under 0.x
semver, minor bumps (0.1 → 0.2) may include breaking changes with a one-minor deprecation
window. Exports marked @experimental are excluded from that window.
The following test/sandbox utilities are intentionally not exported from the public barrel
and are not part of the stable API: makeConfigFromEnv, deterministicTestSeed, scopeDigest,
resolveProviderEntry, startExampleProvider.
