Skip to main content
createConnectReceiver (from @phosra/gatekeeper/next, v0.6.0+) collapses the OCSS connect receiver to one route file and one allowlist. It verifies every delivery to the pinned OCSS root — the same root your gatekeeper already pins to fetch enforcement profiles — so the provider’s signature is the authentication. There is no shared HMAC secret to mint, store, sync, or rotate.
This is purely additive. The low-level createGatekeeper() and every existing gatekeeper export are unchanged — see the Platform Quickstart (gatekeeper) for the enforce/confirm loop. This page covers the connect leg the receiver handles.
npm install @phosra/gatekeeper

The entire receiver

// app/api/ocss/connect/route.ts — the whole thing
import { createConnectReceiver } from "@phosra/gatekeeper/next"
import { store }   from "@/lib/ocss/store"   // your ConnectSessionStore over YOUR child table
import { onBound } from "@/lib/ocss/apply"   // materialize the bound profile into your product

export const { POST } = createConnectReceiver({
  env:       "production",                    // bundled census URL + PINNED trust root (no TOFU)
  did:       "did:ocss:notflix",             // your platform DID (= the RFC 8707 delivery audience)
  seed:      process.env.OCSS_SENDER_SEED_B64URL!,  // your Ed25519 seed (base64url-raw, 32B)
  authorize: ["did:ocss:custo"],             // provider-DID allowlist — the ONLY trust config
  store, onBound,
})
That is the complete /api/ocss/connect route. The provider’s Ed25519 signature, verified to the root, is the auth; the allowlist is the authorization. Revoke a provider by removing its DID from authorize — no key exchange, no rotation.

Config

FieldRequiredMeaning
envyes"sandbox" | "production" — selects the bundled census URL and the pinned trust root (no trust-on-first-use).
didyesYour platform DID. Also the RFC 8707 audience_did deliveries must bind to (replay to a different receiver fails).
seedyesYour platform Ed25519 seed, base64url-raw (32 bytes) — the OCSS_SENDER_SEED_B64URL you registered.
authorizeyesThe provider-DID allowlist. Required by construction so “signed with no allowlist” is unrepresentable — role-gating alone (any accredited enforcement-agent) is never the ship state.
storeyesYour ConnectSessionStore — the §3.6 state machine over your own child/session table.
onBoundyes(label, childRef, provision?) => void | Promise<void> — fires after each 2xx per bound label; materialize the enforcement caps here.
onErrorno(err, ctx) => void — surfaces a throwing onBound without failing the delivery.
legacyHmacSecretnoOpt-in HMAC lane → posture "both". Absent = signed-only (the default). Only for a migration drain window — see Migration.
censusUrl / trustRootXB64UrlnoAdvanced overrides for the env preset (pass together).
keyIdnoWriter key-id fragment; defaults to the current YYYY-MM.
createdSkewSecnoSigned-delivery freshness window in seconds (default 300).
authorize is not optional and it is the whole trust surface. A signed delivery from any accredited enforcement-agent is authenticated, but only a DID on your authorize list is authorized. Making the field required means you cannot accidentally ship a receiver that trusts every accredited provider on the Trust List.

How a delivery is verified — nothing you write

createConnectReceiver reads the raw request body once and dispatches by shape:
  1. Signed provision delivery (isSignedProvisionDelivery(body)) → verifyProvisionDelivery → create-or-adopt N age-banded profiles, then onBound per profile. This is the create-and-link fan-out from the provider.
  2. Signed connect envelope / { endpoint_id_label, state }handleConnect → bind the single label, then onBound(label, childRef).
  3. Legacy ocss-ext01/provision.v1 HMAC batch → the HMAC lane — only when legacyHmacSecret is set.
For (1) and (2), the SDK inherits audience binding (the delivery must name your did), created-freshness (within createdSkewSec), and the authorize gate from the verified envelope — all before your onBound runs. You verify nothing by hand.
// @phosra/gatekeeper/next — the types your store and onBound implement
export type AgeBand = "under_13" | "13_15" | "16_17" | "adult"

export interface ProvisionContext {
  age_band:           AgeBand
  display_hint?:      string
  state:              string
  adult_pin_auto_set: boolean
}

export type OnBound = (
  label: string,
  childRef: string,
  provision?: ProvisionContext,   // present on the batch-provision path
) => void | Promise<void>
onBound fires post-2xx, per bound label. On the batch path each call carries a ProvisionContext so you know the age band and whether to auto-set an adult PIN; on the single-bind path provision is undefined. A throwing onBound is routed to onError and does not fail the delivery (the binding already landed).

After the connect leg — enforce

Receiving the connection is only the connect leg. The bound endpoint_id_label is your profile-poll target: fetch and verify the signed enforcement profile, decide locally, and confirm — all with the low-level @phosra/gatekeeper:
// in onBound (or a background poller keyed by the stored label)
await gk.refreshProfile()                       // GET + Ed25519-verify to the Trust List root
const verdict = gk.isAllowed({ category: "addictive_pattern_block" })
if (verdict.decision === "block") blockFeed()
await verdict.confirm("applied")                // §8.3.8 receipt
The full profile → isAllowedconfirm loop (including the two-endpoint_id_label gotcha and the fail-closed rules) is documented in the gatekeeper Platform Quickstart.

Trust-list liveness

Signed verification role-gates the signer to an active accredited enforcement-agent and verifies to root, so the connect leg now depends on Trust-List availability and accreditation freshness (the 7-day attestation TTL) — a coupling the old HMAC path never had. This is a deliberate trade, not a surprise: cache the last-known-good Trust List so a transient census blip doesn’t reject a legitimate, still-accredited provider. Name it in your ops runbook. See Migration → Liveness.

Branding is an assessed conformance item

Two parts, both checked at accreditation: (1) co-brand your OAuth leg — the authorize page the parent hits during the ceremony MUST read Phosra Link · <Platform>, never a bare auth form; (2) provenance — once a profile is bound, surface a persistent “Managed via Phosra” label on the managed account. See the Phosra Link Branding Requirement.

What the provider (your partner) must do

Almost nothing new lands on the provider — the signed model is symmetric with what they already sign:
  1. Add your platform DID to their createLink connect target (they call link.connect.finish / link.provision with platformDid: "did:ocss:<you>").
  2. Deliver with their own writer key — no secret from you. If their delivery 401s, the fix is you adding their DID to authorize, not a secret exchange.
  3. That’s it. See Provider Quickstart.

Next