Skip to main content
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/link 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!,       // base64url Ed25519 seed — your accreditation key
  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 the product tables + self-checks accreditation to the root
That 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

FieldRequiredMeaning
censusyesCensus base URL. If it is a pinned host, the SDK ships the matching trust root — no TOFU, no trustRoot.
writerSeedyesYour Ed25519 signing key: a base64url seed, raw bytes, or a SenderKey { seed, keyID }.
writerKeyIdyes¹Your full key id, did:ocss:<slug>#<kid>. ¹Omit only when writerSeed is already a SenderKey carrying its keyID.
dbyesA pg.Pool or a connection string. The SDK owns and migrates its tables here.
trustRootconditionalRequired for a census host that is not in the SDK’s pin set (no trust-on-first-use); overrides a pin otherwise.
developerOrgIdnoBilling attribution. Omit and the family lands in the anonymous/self-host bucket — no invoice, no SLA. Never gates the safety path.
verifyAccreditationnoDefault true; gates writes on a Trust-List self-check. Set false to skip it (not recommended).
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 census 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 (the idempotent product DDL) and verifyAccreditation (root-verify + writer-pub match + active enforcement-agent role). Prefer to control timing? Call link.migrate() and link.verifyAccreditation() yourself.

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 — startinit, resumecomplete, finishbind — so your BFF wraps the factory to serve the modal.

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 (ingestConsentAttestationmintEnforcementEndpoint(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 ids
Every 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.
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.revoked

Typed 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)

OperationWhere it runs
Ed25519 signing (every write + every delivery)Your process — the key never leaves
Household hash derivationYour process
Scope pre-checkYour process
Product schema (grants + sessions)Your dblink.migrate()
Trust List fetch + verify-to-rootCensus: GET /.well-known/ocss/trust-list
Platform connect-config fetchCensus: GET /api/v1/providers/{did}/connect
Consent attestation ingestCensus
Endpoint bindingCensus: POST /api/v1/enforcement-endpoints
Rule writeCensus: POST /api/v1/policies/{policyId}/rules
Label delivery to the platformDirect 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:
  1. Install @phosra/gatekeeper and add POST /api/ocss/connect via createConnectReceiver({ env, did, seed, authorize: ["did:ocss:<you>"], store, onBound }) — see Platform Quickstart.
  2. Put your provider DID in that authorize allowlist. That is the entire trust configuration; there is nothing to mint, store, sync, or rotate.
  3. Nothing else. The platform never holds a Phosra secret; your signature to the pinned root is the auth.

Next