@phosra/gatekeeper
v0.8.73
Published
Platform-side SDK for Phosra Link and the OCSS (Open Child Safety Specification). It owns the signed linking ceremony, Trust List verification, durable retries, enforcement-profile verification, read-back evidence, and confirmation events.
Readme
@phosra/gatekeeper
Platform-side SDK for Phosra Link and the OCSS (Open Child Safety Specification). It owns the signed linking ceremony, Trust List verification, durable retries, enforcement-profile verification, read-back evidence, and confirmation events.
The golden path has one Phosra secret: PHOSRA_CREDENTIAL. You do not wire raw
signing keys, service URLs, fetch, trust resolvers, delivery transports, or ACK
verification. V3 credentials also carry the public environment-manifest and root
authority, so applications do not copy pins or service coordinates into source.
Install
npm install @phosra/gatekeeperQuickstart: receive Phosra Link in a platform
// src/lib/phosra.ts — the one process-local Phosra singleton
import { createPlatform } from "@phosra/gatekeeper"
import { pool } from "@/db"
export const phosra = createPlatform({
credential: process.env.PHOSRA_CREDENTIAL!,
// Optional: safe for hosted logs. Diagnostics contain a stable phase code
// and random correlation ID, never account/profile IDs or dependency text.
onDiagnostic: (diagnostic) => console.error(JSON.stringify(diagnostic)),
humanRecovery: {
signInPath: "/sign-in",
createProfilePath: "/profiles/new",
},
adapter: {
database: pool,
authorizedProviders: ["did:ocss:custo"],
// Map the already-signed-in platform request to real child profiles.
resolveAccount: async (request) => {
const session = await sessionFrom(request)
if (!session) return null
return {
accountId: session.accountId,
profiles: await childrenFor(session.accountId),
// Each profile is { id, displayName, displayHint? }.
// displayHint (for example "Age 10") safely distinguishes same-name children.
}
},
// Apply is a command. Its return value is deliberately not trusted as evidence.
apply: async ({ selectedProfileId, profile, idempotencyKey }) => {
await applyParentalRules({ selectedProfileId, profile, idempotencyKey })
},
// Observe independently reads back the real persisted platform effect.
// The SDK reporter owns OCSS rule refs, hashes, timestamps, and reason codes.
observe: async ({ selectedProfileId, profile }, evidence) => {
const controls = await readAppliedRules(selectedProfileId)
return profile.rules.map((rule) => {
const control = controls.get(rule.category)
if (!control) return evidence.refused(rule, "unsupported")
if (!control.enabled) return evidence.degraded(rule, "platform-conflict")
return evidence.applied(rule, {
method: "gate",
sideEffectId: control.id, // a stable row/control ID; never sent in cleartext
})
})
},
// Disconnect is also command + independent read-back. A command return
// alone can never make Phosra claim that controls were removed.
release: async ({ selectedProfileId, idempotencyKey }) => {
await removePhosraOverlay({ selectedProfileId, idempotencyKey })
},
observeRelease: async ({ selectedProfileId }, evidence) => {
const removal = await readOverlayRemoval(selectedProfileId)
return evidence.released({ sideEffectId: removal.id })
},
},
})onDiagnostic reports both startup failures (operation: "platform_ready") and
bounded enforcement failures (operation: "materialization"). Materialization
subcodes distinguish profile retrieval, profile verification, shared-target
reservation, platform enforcement, confirmation, and outcome recording. Hook
errors are isolated and never change retries or enforcement.
Put the HTTP route and worker in separate files. The route is safe for Next.js
to import during a build: constructing the singleton and exporting its handlers
performs no network, database, migration, or readiness work. The first real,
recognized protocol request lazily runs one SDK-owned readiness gate. Concurrent
requests coalesce; success is reused; a failure returns a redacted 503 and is
retried only after bounded backoff. The route never starts the long-running worker:
// app/api/phosra/[...phosra]/route.ts — the entire HTTP integration.
import { phosra } from "@/lib/phosra"
export const { GET, POST } = phosra.next.handlers()That single catch-all owns /api/phosra/par, /api/phosra/authorize,
/api/phosra/token, /api/phosra/delivery, /api/phosra/retry, and
/api/phosra/status. The retry
endpoint accepts only the exact signed-request media type (maximum 64 KiB) and
returns only the exact platform-signed result bytes persisted with the durable
retry decision. A duplicate still being processed receives 202 with
Retry-After; crossed idempotency receives 409; no HTTP success or adapter
return is ever promoted into enforcement evidence.
After a valid retry is durably accepted, Gatekeeper runs one bounded worker pass before returning the persisted response. This fast path lets an interactive Phosra Link disconnect normally apply and observe the platform release, then dispatch its signed release event, without waiting for the standalone worker's next polling cycle. It grants no new authority and applications do not add a special disconnect endpoint. If the bounded pass fails, the committed retry remains queued for the standalone worker and the original signed retry response is returned unchanged.
Gatekeeper 0.8.19 also handles a disconnect that was signed against the last known revoked generation while the platform's current lifecycle has advanced into an exhausted degraded or stale generation. Recovery is allowed only when the delivery, delivery attempt, authority digest, provider, and platform still match exactly. Gatekeeper records the decision in both immutable retry journals and reopens only that profile lifecycle; every other historical-stage request remains fail-closed.
GET /api/phosra/status — which precondition is outstanding (0.8.68)
Every /api/phosra/* protocol route answers the same opaque
503 PHOSRA_NOT_READY for every fault. That is correct for a protocol surface
and useless for an operator: a missing credential, a lapsed accreditation, and a
credential whose signing key has diverged from the key the census publishes are
indistinguishable from outside. status answers the question the 503 cannot.
curl -s https://your-app.example.com/api/phosra/status | jq{
"ok": true,
"ready": false,
"precondition": "PLATFORM_SIGNING_KEY_MISMATCH",
"summary": "The credential's active signing key is NOT the key the census publishes under that key id. The credential and the trust list have diverged.",
"remedy": "Compare signingKey.credentialPublicKey with signingKey.censusPublicKey below — …",
"platform": { "did": "did:ocss:example", "environment": "production", "credentialVersion": 3 },
"census": { "origin": "https://prodapi.phosra.com", "trustListIssue": 41 },
"signingKey": {
"kid": "did:ocss:example#link-1",
"credentialPublicKey": "AZMIh9qxtx0nN4bXfj2f60_mGhl0cryDS5vCrKBZew4",
"censusPublicKey": "47tjRF8D8DFYQ9Kl7F3hg7qEA9PnliCuDs4xhlSuUQM",
"matches": false
},
"standing": { "did": "did:ocss:example", "status": "active", "tier": "accredited", … }
}Named preconditions: PLATFORM_READY, CREDENTIAL_MISSING,
CREDENTIAL_UNPARSEABLE, ENVIRONMENT_MANIFEST_UNAVAILABLE,
TRUST_LIST_UNVERIFIABLE, PLATFORM_SIGNING_KEY_UNPUBLISHED,
PLATFORM_SIGNING_KEY_MISMATCH, PLATFORM_STANDING_NOT_ACCREDITED,
AUTHORIZED_PARTY_NOT_ACCREDITED, PLATFORM_DIRECTORY_UNAVAILABLE,
PLATFORM_COMPOSITION_FAILED, PLATFORM_DATABASE_NOT_READY,
PLATFORM_GENERATION_INACTIVE.
Three properties are load-bearing:
- It always answers
200, including — especially — when the platform is not ready. It is a report ABOUT availability, not the thing being gated, and it is deliberately not behind the readiness gate. A diagnostic that 503s under the fault it explains is not a diagnostic. - It is unauthenticated. Every field is already public: DIDs are public
identifiers, the trust list is world-readable at
/.well-known/ocss/trust-list, and a signing key's PUBLIC half is exactly the thing that is meant to be published. Gating it would require distributing a new shared secret — the friction class Phosra Link exists to remove — and would withhold the answer from the person most likely to need it: a partner debugging from outside the deployment. It reports no seeds, private keys, or credential bytes;publicSigningKeyFactsrefuses outright if the bytes it is about to publish are byte-identical to the private seed. - It cannot hammer the census. It reuses the same generation coordinator the
protocol routes use, so a healthy platform answers with no network I/O and an
unhealthy one respects the coordinator's bounded backoff. Add
?probe=0for a strictly non-triggering read of the current state.
Return signed-out parents to Link safely
humanRecovery applies only to GET /api/phosra/authorize. A signed-out parent
receives a 303 to signInPath; a signed-in account with no child profiles receives
a 303 to createProfilePath. Gatekeeper accepts only static, single-origin paths
with no scheme, host, query, fragment, backslash, control character, or dot segment.
It appends one phosra_return_to value containing the canonical authorize path and
opaque request_uri. PAR, authorize POST, token, delivery, and retry never use these
redirects. Omitting humanRecovery preserves the JSON AUTH_REQUIRED and
NO_PROFILE_AVAILABLE responses.
Shared profile confirmation is SDK-owned (0.8.1)
When a parent selects a platform profile that is already linked to another child in the same verified family, the Gatekeeper authorize handler owns the decision screen. Platform applications do not add a custom route, modal, or confirmation callback.
The screen recommends choosing or creating a separate profile because separate profiles keep viewing history, recommendations, screen time, and controls individual. Its actions appear in the fixed order Choose another profile, Confirm sharing, and Cancel and return. Before confirmation it explains that a shared profile receives the strictest protections required by any linked child and that activity on the profile cannot be reliably attributed to one child.
Gatekeeper renders only the authenticated platform catalog label recovered from its server-side sealed catalog and the same-family member count. A separately signed selected-profile presentation is issued later, during token exchange; it is not the source of the pre-exchange confirmation label. The confirmation form uses bounded opaque tokens but does not expose raw account or profile IDs, child commitments, family authority, aggregate IDs, or policy parameters. The initial release does not render member names or numeric ages; those require a future provider-signed member-display resolver and must never be inferred from internal authority.
If the target is changing, the handler offers an actionable retry or a return to profile choice. If family authority is missing or different, sharing is not offered and the parent is directed to choose a separate profile. Rules outside the built-in merge registry fail closed during server-side aggregate processing; 0.8.1 does not expose a dedicated public incompatibility code or capability manifest. Do not replace, restyle, or bypass these SDK-owned decisions.
The 0.8.1 handler copy and action order are fixed:
| State | Parent copy | Actions, in order |
|---|---|---|
| Confirmation | Use {profile} for another child? and Recommended: choose a separate profile | Choose another profile, Confirm sharing, Cancel and return to {provider} |
| Target changing | This profile is finishing another link and Wait a moment and try again, or choose another profile now. | Choose another profile, Try again, Cancel and return |
| Family sharing unavailable | Choose a separate profile and This profile can’t be shared for this family link. Choose another profile, or create a separate one in this platform. | Choose another profile, Cancel and return |
Your sign-in, sign-up, and profile-creation pages must preserve the parameter and
validate it again before redirecting. Never pass an arbitrary query value directly
to redirect():
import { redirect } from "next/navigation"
function validatedPhosraReturnTo(value: string | null): string {
if (value === null || value.length > 2_048) return "/"
let parsed: URL
try {
parsed = new URL(value, "https://phosra-return.invalid")
} catch {
return "/"
}
const entries = [...parsed.searchParams.entries()]
const requestUri = parsed.searchParams.get("request_uri")
if (parsed.origin !== "https://phosra-return.invalid"
|| parsed.pathname !== "/api/phosra/authorize"
|| parsed.hash !== ""
|| entries.length !== 1
|| entries[0]?.[0] !== "request_uri"
|| !/^urn:ietf:params:oauth:request_uri:[A-Za-z0-9_-]{43}$/.test(requestUri ?? "")) {
return "/"
}
return `${parsed.pathname}?request_uri=${encodeURIComponent(requestUri!)}`
}
// Use the same final step after sign-in, sign-up, and child-profile creation.
export async function finishSignIn(searchParams: { phosra_return_to?: string }) {
await completeYourSignIn()
redirect(validatedPhosraReturnTo(searchParams.phosra_return_to ?? null))
}
export async function finishSignUp(searchParams: { phosra_return_to?: string }) {
await completeYourSignUp()
redirect(validatedPhosraReturnTo(searchParams.phosra_return_to ?? null))
}
export async function finishProfileCreation(searchParams: { phosra_return_to?: string }) {
await createYourChildProfile()
redirect(validatedPhosraReturnTo(searchParams.phosra_return_to ?? null))
}Run the worker from its own process entry point (for example, a Railway worker service). Only this process intentionally waits for the worker until shutdown:
// phosra-worker.ts — explicit long-running worker entry point.
// Nothing starts merely because the Gatekeeper package is imported.
import { phosra } from "@/lib/phosra"
await phosra.ready()
const shutdown = new AbortController()
process.once("SIGTERM", () => shutdown.abort())
process.once("SIGINT", () => shutdown.abort())
const worker = phosra.worker.start({
signal: shutdown.signal,
onError: (error) => console.error("Phosra worker pass failed", error),
})
await worker.drain()phosra.worker serializes every pass, waits when the queue is idle, applies
bounded exponential backoff after an error, and never overlaps work. Direct
runWorkerOnce() calls and the background loop share that same pass boundary;
concurrent calls coalesce onto the in-flight result instead of applying twice. stop()
requests shutdown and drain() waits for the current bounded pass to finish.
status() exposes healthy, state, pass/failure counters, the next pass time,
and signed-authority expiry for your health endpoint; healthy turns false as
soon as that signed window expires. Calling start() again
while active returns the same controller; after stop + drain it starts cleanly.
If host adapter code accidentally calls runWorkerOnce() from the active pass or
environment build, it rejects with PLATFORM_WORKER_REENTRANT instead of waiting
on itself.
If your platform already has a durable scheduler, call await phosra.runWorkerOnce()
from that scheduler instead. Do not combine both modes in one process.
The explicit composition kernel remains available at
@phosra/gatekeeper/platform for advanced infrastructure, migrations, and
conformance testing. Most applications should not need it.
Custom PlatformStore adapters must return
BeginAuthorizationResult.redirectUri from the callback in the consumed,
verified request object. Preserve that URL byte-for-byte: do not reconstruct it
from browser headers, the authorization-page query string, or application
defaults. Gatekeeper validates the callback again and projects only its canonical
origin into the authorization page's form-action policy. The packaged Postgres
store handles this automatically.
Legacy v1/v2 credentials remain supported with an explicit public bootstrap.preset.
Download a v3 credential to use the one-variable quickstart above.
Environment refresh is anti-rollback: a replacement manifest must have a signed
expiresAt strictly later than the active generation. When rotating a credential,
signing key, or environment manifest, issue a later expiry too; a different
same-expiry manifest is deliberately rejected as rollback/equivocation.
Legacy local decision engine
createGatekeeper(...) remains available for applications using the earlier
poll-and-check API. New Phosra Link integrations should start with
createPlatform(...) above.
Real signed profiles in unit tests
@phosra/gatekeeper/testing builds a real in-process root, Trust List, router key,
and router-signed enforcement profile without external network access. Production
profiles always require a valid, unique rule_ref on every category. For fixture
convenience only, setProfile() deterministically fills an omitted rule_ref with
a valid opaque 64-character reference before signing; an explicit reference is
preserved exactly. This does not relax production profile verification.
import { makeSignedStack } from "@phosra/gatekeeper/testing"
const stack = makeSignedStack()
stack.setProfile([{
category: "content_rating",
decision: "warn",
fail_mode: "closed",
rule_slug: "content_rating",
params: { max_allowed: 10 },
// rule_ref may be omitted in this testing helper only.
}])The connect receiver — @phosra/gatekeeper/next (one route, no secret)
A platform's entire /api/ocss/connect route is one export. It dispatches the three
delivery shapes (signed provision batch / signed §3.6 connect bind / legacy HMAC) to the
right verify primitive, and fires your materialize hook once the census-verified binding
lands.
// app/api/ocss/connect/route.ts — the whole receiver
import { createConnectReceiver } from "@phosra/gatekeeper/next"
import { store } from "@/lib/ocss/store" // a ConnectSessionStore over YOUR child table
import { onBound } from "@/lib/ocss/apply" // materialize: pull the profile / create the profile
export const { POST } = createConnectReceiver({
env: "production", // → census URL + PINNED trust root (bundled, no TOFU)
did: "did:ocss:your-platform",
seed: process.env.OCSS_SENDER_SEED_B64URL!, // your Ed25519 seed (base64url-raw, 32 bytes)
authorize: ["did:ocss:custo"], // REQUIRED provider allowlist — revoke = remove a DID
store,
onBound, // (label, childRef, provisionCtx?) => materialize
onError: (err, ctx) => log.warn("ocss ingest failed", ctx, err), // optional
})Signed by default. The provider's sender-DID-signed-to-root signature IS the auth —
there is no shared HMAC secret to mint, store, sync, or rotate (OCSS §8.1 clause 6 bans
bearer secrets). Audience-binding + created freshness + the authorizedConnectDids gate
are inherited from the verified envelope. assertConfig() throws the exact missing field
at construction. To migrate an existing HMAC receiver, pass legacyHmacSecret (opt-in;
flips the posture to accept both lanes) and drain to signed-only, then delete the secret.
onBound is fired post-2xx, once per bound label, and MUST be idempotent
(create-or-adopt keyed by label):
- connect bind →
onBound(label, childProfileId)— pull the signed profile + apply caps; - provision →
onBound(label, label, { age_band, display_hint, state, adult_pin_auto_set })— create-or-adopt a profile for the child (band → age).
Boot-time config assert — assertGatekeeperConfig (kills the AuthKit-matcher trap)
The #1 integrator footgun is a Next middleware.ts whose config.matcher excludes the
OCSS connect routes (the classic /((?!…|api).*) negative-lookahead drops all of /api), so
the AuthKit/proxy middleware never runs on the connect route and the ceremony returns 500.
This exact gap regressed one integrator three times. Call the assert once, in middleware.ts,
and a matcher gap fails loudly at boot instead of silently mid-ceremony:
// middleware.ts
import { assertGatekeeperConfig } from "@phosra/gatekeeper"
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico).*)", // your app routes (does NOT exclude api)
"/api/phosra/:path*", // ← the OCSS connect family MUST be covered
],
}
assertGatekeeperConfig({ matcher: config.matcher }) // throws GatekeeperConfigError if uncoveredThe error names the uncovered route and the one-line fix. Mount elsewhere (e.g. /api/ocss)?
Pass routes: gatekeeperConnectRoutes("/api/ocss").
The OCSS protocol layer — send + receive (no bespoke lib/ocss/)
The delivery/enforcement primitives every platform used to hand-roll are exported directly, so
your lib/ocss/ shrinks to your own persistence + classifier:
import {
loadSenderKey, TrustListCache, makePhosraTrustListResolver,
sendHarmContext, openInboundEnvelope, buildAbuseSignal, boundedExcerpt,
} from "@phosra/gatekeeper"
// Load your Ed25519 identity from env (OCSS_SENDER_SEED_B64URL + OCSS_DID + OCSS_SIGNING_KEY_ID).
const senderKey = loadSenderKey()
const trustList = makePhosraTrustListResolver("sandbox")
// SEND (monitored platform → parental app): seal to the recipient's payload key, sign the
// outer §4.2 envelope + the RFC-9421 request, POST the router-blind harm-context lane. Never throws.
await trustList.ensure()
const res = await sendHarmContext({
senderKey,
receiverDid: "did:ocss:household-acme",
receiverPayloadJwk: trustList.payloadKey("did:ocss:household-acme"), // root-verified
resource: "endpoint:mia",
content: new TextEncoder().encode(JSON.stringify(
buildAbuseSignal({ harmClass: "grooming", severity: "severe", sessionScope: "thread:t1",
eventRef: "message:m1", emitterId: senderKey.keyID.split("#")[0], appealPath: "https://you/appeal" }),
)),
censusUrl: "https://phosra-api-sandbox-production.up.railway.app",
})
// RECEIVE (parental app): verify the outer signature to the Trust-List ROOT + open the inner
// sealed payload — one fail-closed call.
const opened = await openInboundEnvelope({
envelope, // the parsed inbound { outer, inner }
trustList, // resolve + root-verify the signer
receiverPayloadJwk: myPayloadPrivateJwk, // your private P-256 key
expectedReceiver: "did:ocss:household-acme",
})
// opened.signerDid is verified-to-root; opened.payload is the decrypted bytes.Disconnection — the revoked tombstone (§8.3.6 / §3.1)
A connect is only as honest as its reverse. When a parent withdraws the last standing for
a child, the census stops serving rules and serves one final router-signed §8.3.6
tombstone with top-level status: "revoked". This is a positive, signed, terminal
state — never an absence — so a fail-closed platform cannot mistake a disconnect for
an outage and keep enforcing revoked rules from cache forever.
gk.endpointState() is the first-class signal ("active" | "revoked" | "stale" | "absent"):
| endpointState | wire cause | what the platform does |
|---------------|------------------------------------|-----------------------------------|
| active | 200 + valid in-window profile | enforce the tighten-only overlay |
| revoked | 200 + status:"revoked" tombstone | release-to-native, stop polling |
| stale | 401/5xx/timeout, or aged/expired | keep last-known-good, fail-closed |
| absent | 404 / never fetched / not yours | fail-closed, alert operator |
The golden createPlatform() runtime closes this loop with durable, cross-side
proof. On a fresh terminal pull, Gatekeeper first records the exact accepted
authority with Phosra over a signed, idempotent
PUT /api/v1/enforcement-endpoints/{binding_id}/revocation-authority; it then
fetches the final profile. This exchange is SDK-managed—host applications do
not construct or call it. Before Gatekeeper calls the host's release adapter,
the router-signed tombstone
must bind all six values from the accepted connection: link_id,
child_commitment, profile_commitment, lifecycle_generation,
predecessor_profile_ref, and predecessor_profile_artifact_digest. A crossed
value refuses the release call.
After the host independently reads back released, Gatekeeper signs a
PlatformLinkReleaseEventV1. In one PostgreSQL transaction it persists those
exact bytes in the release outbox, advances the lifecycle to revoked, cancels
every pending ordinary Link event for the delivery, and erases the sealed
lifecycle secrets. The worker retries byte-identically after a crash or timeout
and records completion only after verifying the provider-signed release ACK.
The signed event and ACK keep a bounded five-minute issuance shape, but terminal
proof does not become invalid merely because a provider outage lasts longer;
future-dated, crossed, or tampered evidence still fails closed. If the accepted
environment no longer matches the running platform or the provider is removed
from current policy, the pending release dispatch is cancelled before transport
with LINK_AUTHORITY_REVOKED instead of retrying or contacting that provider.
This is the recommended integration path for an auditable Phosra Link unlink.
Hook release-to-native with the onRevoked callback. Once revoked, the state is terminal:
the tombstone is never overwritten by a later stale profile, and refreshProfile()
short-circuits (polling stops).
const gk = createGatekeeper({
...config,
onRevoked: ({ childRef, endpointLabel }) => {
// Release-to-native: drop the OCSS overlay, remove the "Managed via Phosra" badge.
// Release is safe by construction — the overlay only TIGHTENS, so lifting it never
// loosens anything the platform's own parent set. For OCSS-created artifacts (e.g. a
// kid profile) convert to natively-managed: keep the data, clear the managed flag.
releaseOverlay(childRef)
},
})
// A poller stops the moment the endpoint is revoked (do NOT release on "stale"):
if (gk.endpointState() === "revoked") stopPolling()A withdrawn standing is never un-withdrawn — re-connect is a fresh ceremony.
Error classes
import { RuleRefRequired, NoRatingMappingError, UnmappedRatingValueError } from "@phosra/gatekeeper"
try {
await gk.reportParentChange({ ocssCategory: "unknown_cat", nativeValue: "PG", changeScope: "platform_local" })
} catch (e) {
if (e instanceof NoRatingMappingError) { /* no mapping declared */ }
if (e instanceof UnmappedRatingValueError) { /* nativeValue not in crosswalk */ }
}Pricing & keys
This SDK is free and open (MIT). It performs signing and verification locally
and calls hosted Phosra services over signed OCSS protocols. The golden runtime
above takes no second API-key variable: its one Phosra secret is
PHOSRA_CREDENTIAL. Credentials used to operate dashboard or management REST
APIs are separate control-plane concerns, not Gatekeeper runtime configuration.
The /protocol subpath
import { verifyDocument, verifyReceipt } from "@phosra/gatekeeper/protocol"@phosra/gatekeeper/protocol is a verbatim re-export of @openchildsafety/ocss — identical object
identities. It lets you verify signed documents and receipts without a separate @openchildsafety/ocss
install when you already depend on this package.
Stability
This package is 0.8.5 and is still co-evolving with the OCSS spec. Under 0.x
semver, minor bumps (0.8 → 0.9) may include breaking changes with a one-minor deprecation
window. Exports marked @experimental are excluded from that window.
