@phosra/link
v0.6.1
Published
Writer-plane SDK for the OCSS (Open Child Safety Specification). Performs the consent attestation ceremony, rule-write directives, and platform binding — all signed locally over RFC 9421, posted to the Phosra hosted census.
Readme
@phosra/link
Writer-plane SDK for the OCSS (Open Child Safety Specification). Performs the consent attestation ceremony, rule-write directives, and platform binding — all signed locally over RFC 9421, posted to the Phosra hosted census.
Install
npm install @phosra/linkpg (node-postgres) is a runtime dependency — the grant store is Postgres-backed.
Quickstart with createLink (recommended, 0.5.0+)
createLink collapses the 8-field LinkConfig to three and returns a
signed-by-default, self-migrating client. One key (the writer key you already
hold), one census URL, one database — no shared connect secret.
import { createLink } from "@phosra/link"
import pg from "pg"
const link = createLink({
census: "https://census.phosra.com", // pinned Trust-List root ships with the SDK
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 own schema.
link.migrate()(ormigrateLinkSchema(pool)/printLinkSchema()) provisions the tables; the stores also self-heal a missing table on first use, so the silent "relation does not exist" crash is gone. - 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.
Quickstart (low-level API)
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
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).
// 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.
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.
