Provider Quickstart · createLink
createLink() is the ergonomic front door to @phosra/link. It derives the whole
hand-built LinkConfig (writer DID, router DID, router payload key, the HKDF-derived
household and parent keys) from one Ed25519 writer key you already hold, ships and
migrates its own product tables, and delivers rules to platforms by signing each
delivery to the OCSS root — so there is no shared HMAC secret to mint, hand off, store,
sync, or rotate.
This page is the ergonomic quickstart. The lower-level primitives it wraps —
createLinkSession, completeLink, runConnectCeremony, directive,
provisionProfiles, deliverLabelToPlatform — are unchanged and still exported; see
the @phosra/link reference. New code should start here.
npm install @phosra/[email protected] pg@phosra/link is ESM-only. Import it from an ESM module ("type": "module", a .mjs
file, or TypeScript compiled to ESM). From CommonJS, load it with a dynamic
await import('@phosra/link') — not require.
Integrate in ~15 lines
import { createLink } from "@phosra/link"
import pg from "pg"
export const link = createLink({
census: "https://phosra-api-sandbox-production.up.railway.app", // pinned root shipped by the SDK
writerSeed: process.env.OCSS_WRITER_SEED!, // fresh sandbox-only Ed25519 seed
writerKeyId: "did:ocss:custo#2026-07", // did:ocss:<slug>#<kid> — your published key id
db: new pg.Pool({ connectionString: process.env.LINK_DB_URL }),
})
await link.ready() // migrates local tables + checks synthetic sandbox standingThat is the entire setup. writerSeed is the only secret, and it is shared with no
platform — the same key signs your census writes and your platform deliveries.
What each field does
| Field | Required | Meaning |
|---|---|---|
census | yes | Census base URL. If it is a pinned host, the SDK ships the matching trust root — no TOFU, no trustRoot. |
writerSeed | yes | Your Ed25519 signing key: a base64url seed, raw bytes, or a SenderKey { seed, keyID }. |
writerKeyId | yes¹ | Your full key id, did:ocss:<slug>#<kid>. ¹Omit only when writerSeed is already a SenderKey carrying its keyID. |
db | yes | A pg.Pool or a connection string. The SDK owns and migrates its tables here. |
trustRoot | conditional | Required for a census host that is not in the SDK's pin set (no trust-on-first-use); overrides a pin otherwise. |
developerOrgId | no | Optional evaluation metadata; it does not create billing or an SLA. |
verifyAccreditation | no | Legacy option name for the sandbox registry/standing self-check; it is not independent accreditation. |
writerKeyId is the one field the blueprint's 3-field pitch elides. createLink needs your key id to
sign — did:ocss:<slug>#<kid> — unless you pass writerSeed as a full SenderKey
{ seed, keyID }, in which case it is read from there. For a self-registered DID the <kid>
is the YYYY-MM the sandbox assigned at registration — read it back,
don't invent one.
Trust root: pinned vs. explicit. For a census in PINNED_TRUST_ROOTS
(resolvePinnedTrustRoot(census) returns it), the SDK verifies against the bundled pin and
you pass nothing. For any other host — a self-hosted or one-off census — pass trustRoot
explicitly. Root verification exists precisely so you don't fetch the root from the census
you're about to verify.
link.ready() runs ensureSchema and the SDK's sandbox standing check
(root signature + writer-public-key match + fixture role). Prefer to control timing?
Call link.migrate() and link.verifyAccreditation() yourself.
Sandbox credentials create test standing only. The evaluation flow writes the credential's
test Link public key and enforcement-agent fixture role to the sandbox Trust List source. It is
not a production credential or independent accreditation. A future production issuance flow is
part of the hosted-routing roadmap.
The connect flow
The connect flow links a parent's account on a platform to a child's OCSS policy. It is
three legs on the link.connect sub-object. The parent authenticates with your auth —
Phosra never sees parent credentials; parentSessionRef is the server-derived binding
between that login and the ceremony (never accept it from the client).
// 1 — start: build the platform authorize URL
const { authorizeUrl, state, sessionId } = await link.connect.start({
platformDid: "did:ocss:notflix",
redirectUri: "https://custo.app/phosra/callback",
parentSessionRef, // your server-side session identifier
childHint: childId, // optional login_hint
})
// → redirect the parent's browser to authorizeUrl
// 2 — resume: on the OAuth callback, exchange the code for the child-profile list
const { childProfiles } = await link.connect.resume({
code, state, parentSessionRef,
})
// → present childProfiles to the parent
// 3 — finish: record the confirmed pick, mint the grant, ingest consent, deliver the label
const r = await link.connect.finish({
sessionId,
platformChildProfileId: childProfiles[0].id,
childId: "child:a11ce0fa-...",
granted_scope: ["addictive_pattern_block", "content_rating"],
ageHint: "13_15", // defaults to "under_13"
state,
})
// r: { grant_id, endpoint_id_label, idemKey, delivered, deliveryStatus?, deliveryScheme? }connect.finish does everything the old four-step ceremony did — derives the unlinkable
household hash, mints the grant, ingests the §8.3.2 parent-consent attestation, binds the
enforcement endpoint, and delivers the label to the platform, signed to the OCSS root.
deliveryScheme tells you which lane carried the label. On a secret-free platform it is
"ed25519-did" (signed). It is "hmac" only if you passed a legacy __legacyConnectSecret
during a migration drain window — see Migrating HMAC → Signed.
delivered + deliveryStatus report the receiver's HTTP result.
Two connect vocabularies, one flow. The factory's legs are start / resume /
finish; the embeddable @phosra/connect transport names the
same three legs init / complete / bind. They map 1:1 — start↔init,
resume↔complete, finish↔bind — so your BFF wraps the factory to serve the modal.
Create-and-link (one parent action → N banded profiles)
When the platform advertises batch provisioning, a single signed call creates the child
profiles on the platform and binds them — the create-and-link path
(ingestConsentAttestation → mintEnforcementEndpoint(standingRef) → signed
provisionProfiles, all wrapped):
const out = await link.provision("did:ocss:notflix", {
state, // from link.connect.start
children: [
{ ageHint: "under_13", displayName: "Leo" },
{ ageHint: "13_15", displayName: "Mia" },
{ ageHint: "16_17", displayName: "Ava" },
],
adultPinAutoSet: true, // optional
})
// out: ProvisionOutcome — provisioned profiles + their grant idsEvery profile is created under one signed, audience-bound delivery to the platform — no shared secret, no per-child round trip. The platform side is a single receiver route; see Platform Quickstart · createConnectReceiver.
Write a rule — link.enforce
Once a grant is active, link.enforce signs and posts the rule write to the census.
// Toggle category
await link.enforce(r.grant_id, "addictive_pattern_block", "child:a11ce0fa-...", {
decision: "block",
})
// Rating-threshold category
await link.enforce(r.grant_id, "content_rating", "child:a11ce0fa-...", {
decision: "block",
params: { family: "numeric_threshold", scale: "ratings_age", max_allowed: 13 },
})
// In-place re-write of an existing rule (same standing, fresh idempotency key)
await link.enforce(r.grant_id, "content_rating", "child:a11ce0fa-...", {
decision: "block",
params: { family: "numeric_threshold", scale: "ratings_age", max_allowed: 10 },
revision: true,
})The write is scope-checked before signing: a category outside the grant's granted_scope
throws before any network call. The standing (consent:attestation:<ref>) is supplied
automatically from the grant.
Manage grants
const grants = await link.listGrants({ childId: "child:a11ce0fa-..." })
await link.revoke(r.grant_id) // ref-counted teardown; census records consent.revokedTyped errors
@phosra/link throws typed LinkError subclasses so you can turn a raw census status into
an actionable message. Guard with isLinkError(e) and branch on e.code.
import {
isLinkError,
NotAccreditedError, // your DID isn't an active enforcement-agent on the Trust List
PlatformNotConnectableError, // the target platform has no connect config (404)
LaneInactiveError, // the census lane the write needs is inactive
DeliveryFailedError, // the platform receiver rejected the signed delivery (.status, .hint)
BandMismatchError, // age band vs capability band mismatch
StateExpiredError,
ParentSessionMismatchError,
SchemaNotReadyError,
} from "@phosra/link"
try {
await link.connect.finish({ /* … */ })
} catch (e) {
if (isLinkError(e) && e instanceof DeliveryFailedError) {
// e.status === 401 → the platform hasn't added your DID to its allowlist
console.error(e.hint)
} else {
throw e
}
}A DeliveryFailedError with .status === 401 almost always means the platform has not yet
added your provider DID to its createConnectReceiver({ authorize }) allowlist — the one
trust config on the platform side. That is the signed-delivery analogue of "wrong HMAC
secret," and the fix is a one-line allowlist edit on the platform, not a secret exchange.
What the SDK owns (and never sends)
| Operation | Where it runs |
|---|---|
| Ed25519 signing (every write + every delivery) | Your process — the key never leaves |
| Household hash derivation | Your process |
| Scope pre-check | Your process |
| Product schema (grants + sessions) | Your db — link.migrate() |
| Trust List fetch + verify-to-root | Census: GET /.well-known/ocss/trust-list |
| Platform connect-config fetch | Census: GET /api/v1/providers/{did}/connect |
| Consent attestation ingest | Census |
| Endpoint binding | Census: POST /api/v1/enforcement-endpoints |
| Rule write | Census: POST /api/v1/policies/{policyId}/rules |
| Label delivery to the platform | Direct provider → platform, Ed25519-signed (census has no role) |
Signing and verification are local; billing is enforced server-side against your org, never inside the package and never on the safety path.
What the platform (your partner) must do
The signed model shifts almost all of the work to your side. To receive your deliveries, the platform implements one route and adds one allowlist line — no shared secret:
- Install
@phosra/gatekeeperand addPOST /api/ocss/connectviacreateConnectReceiver({ env, did, seed, authorize: ["did:ocss:<you>"], store, onBound })— see Platform Quickstart. - Put your provider DID in that
authorizeallowlist. That is the entire trust configuration; there is nothing to mint, store, sync, or rotate. - Nothing else. The platform never holds a Phosra secret; your signature to the pinned root is the auth.
Next
- Platform Quickstart · createConnectReceiver — the one-route receiver
- Migrating HMAC → Signed — retire the shared connect secret
- PhosraConnect component — the Plaid-grade in-app parent modal
@phosra/linkreference — the low-level primitives and the reference BFF