@phosra/gatekeeper
v0.7.0
Published
Platform-side SDK for the OCSS (Open Child Safety Specification). Verifies signed enforcement profiles, runs the local decision engine, sends §8.3.8 confirmation receipts, and reports parent-side rating changes back to the Phosra census.
Readme
@phosra/gatekeeper
Platform-side SDK for the OCSS (Open Child Safety Specification). Verifies signed enforcement profiles, runs the local decision engine, sends §8.3.8 confirmation receipts, and reports parent-side rating changes back to the Phosra census.
Deploying? The full env contract — the six
PHOSRA_*vars this SDK's config maps onto, plus how to mint your endpoint and receive theconnect_secret— is documented at docs.phosra.com/integration/platform-registration.
Install
npm install @phosra/gatekeeperQuickstart
import { createGatekeeper } from "@phosra/gatekeeper"
import type { GatekeeperConfig, ReportParentChangeArgs } from "@phosra/gatekeeper"
// 1. Construct the gatekeeper with your platform's signing key + 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: GatekeeperConfig = {
platformDid: "did:ocss:your-platform",
platformKeyId: "did:ocss:your-platform#key-2026",
gatekeeperSigningKey: { seed: new Uint8Array(32) /* real Ed25519 seed */, keyID: "did:ocss:your-platform#key-2026" },
censusBaseUrl: "https://phosra-api-sandbox-production.up.railway.app", // the one canonical sandbox host
trustRootXB64Url: process.env.PHOSRA_TRUST_ROOT_X!, // public root pubkey X, pinned out-of-band
endpointId: "endpoint-mia-01", // the §9.3(b) bound resolver label
ratingMappings: [
// myField is the key you supply in the signal arg to gk.check() / gk.isAllowed().
{ ocssCategory: "content_rating", myField: "maturity", vocabulary: "mpaa" },
],
}
const gk = createGatekeeper(config)
// 2. Decide whether to allow a piece of content.
// For rating-mapped categories, pass the native value under the key declared
// in myField ("maturity" above). For non-rating categories, signal is optional.
const verdict = gk.check("content_rating", { maturity: "PG-13" })
if (verdict.decision === "block") {
// block the content
}
// 3. Confirm enforcement outcome (§8.3.8 receipt).
await verdict.confirm("applied")
// 4. Report a parent-side rating change.
const args: ReportParentChangeArgs = {
ocssCategory: "content_rating",
nativeValue: "G",
changeScope: "family_wide",
}
const result = await gk.reportParentChange(args)
// 5. Clean up timers when done.
gk.destroy()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 |
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 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/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.1.0 — 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.
