Platform Quickstart · createConnectReceiver
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/[email protected]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.
Publish the receiver before the first parent links
Adding the route to your app does not make it discoverable by Phosra Link. Complete the platform-registration step in the Phosra developer console (or with the signed registry API) before testing a parent flow:
- Publish the OAuth surface (
authorize_url,par_url,token_url,profiles_url, scopes, and optional profile-management URL) through the platform connect-metadata operation. - Publish the delivery receiver through the separate platform endpoint-registration
operation. Do not put
connect_urlin the connect-metadata request; the two operations are deliberately separate so editing OAuth copy or URLs cannot silently rotate delivery credentials. - Store the endpoint-registration response in a secret manager immediately. It contains
one-time credential fields for compatibility lanes; never print the response, commit it, or
paste it into a build log. A signed-only
createConnectReceiverdoes not use the legacy HMAC secret at runtime, but registration still treats every returned credential as sensitive.
The published connect_url must be the exact public POST destination owned by your chosen
integration surface. For the low-level receiver on this page that is normally
https://your-app.example/api/ocss/connect; a platform using the Phosra Link Next.js facade may
instead expose a facade-owned route such as /api/phosra/delivery. Do not guess or copy another
platform's path—use the route exported by the SDK surface you installed.
Verify discovery before opening the parent UI:
const discovery = await fetch(
`${census}/api/v1/providers/${encodeURIComponent(platformDid)}/connect`,
).then((response) => {
if (!response.ok) throw new Error(`Platform discovery failed (${response.status})`)
return response.json()
})
if (discovery.connect_url !== expectedPublicReceiverUrl) {
throw new Error("Phosra Link delivery receiver is not registered")
}If OAuth approval succeeds but the parent app fails while saving the connection, check this
discovery response first. A missing connect_url means the platform authenticated the parent
but never completed delivery registration; retrying the browser flow cannot repair registry
configuration.
Config
| Field | Required | Meaning |
|---|---|---|
env | yes | "sandbox" | "production" — selects the bundled census URL and the pinned trust root (no trust-on-first-use). |
did | yes | Your platform DID. Also the RFC 8707 audience_did deliveries must bind to (replay to a different receiver fails). |
seed | yes | Your platform Ed25519 seed, base64url-raw (32 bytes) — the OCSS_SENDER_SEED_B64URL you registered. |
authorize | yes | The provider-DID allowlist. Required by construction so "signed with no allowlist" is unrepresentable — a sandbox role field alone is never the ship state. |
store | yes | Your ConnectSessionStore — the §3.6 state machine over your own child/session table. |
onBound | yes | (label, childRef, provision?) => void | Promise<void> — fires after each 2xx per bound label; materialize the enforcement caps here. |
onError | no | (err, ctx) => void — surfaces a throwing onBound without failing the delivery. |
legacyHmacSecret | no | Opt-in HMAC lane → posture "both". Absent = signed-only (the default). Only for a migration drain window — see Migration. |
censusUrl / trustRootXB64Url | no | Advanced overrides for the env preset (pass together). |
keyId | no | Writer key-id fragment; defaults to the current YYYY-MM. |
createdSkewSec | no | Signed-delivery freshness window in seconds (default 300). |
authorize is not optional and it is the whole trust surface. In sandbox, a signed delivery
from an enforcement-agent fixture can be authenticated to the test root, but only a DID on your
authorize list is authorized. Making the field required prevents a receiver from trusting every
synthetic Trust List entry.
How a delivery is verified — nothing you write
createConnectReceiver reads the raw request body once and dispatches by shape:
- Signed provision delivery (
isSignedProvisionDelivery(body)) →verifyProvisionDelivery→ create-or-adopt N age-banded profiles, thenonBoundper profile. This is the create-and-link fan-out from the provider. - Signed connect envelope /
{ endpoint_id_label, state }→handleConnect→ bind the single label, thenonBound(label, childRef). - Legacy
ocss-ext01/provision.v1HMAC batch → the HMAC lane — only whenlegacyHmacSecretis 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 receiptThe full profile → isAllowed → confirm loop (including the two-endpoint_id_label gotcha
and the fail-closed rules) is documented in the
gatekeeper Platform Quickstart.
Trust-list liveness
In sandbox, signed verification role-gates the signer to an active
enforcement-agent test fixture and verifies to the documented test root. That makes the
evaluation connect leg depend on sandbox Trust-List availability and fixture freshness — a
coupling the old HMAC path never had. Cache the last-known-good sandbox list so a transient
census blip does not invalidate an otherwise valid test. See
Migration → Liveness.
A production Link-credential issuance flow is roadmap. The sandbox path publishes a test public key and fixture role atomically; that behavior must not be cited as production standing.
Branding is an assessed conformance item
Two parts are reviewed in a design-partner pilot: (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:
- Add your platform DID to their
createLinkconnect target (they calllink.connect.finish/link.provisionwithplatformDid: "did:ocss:<you>"). - Deliver with their own writer key — no secret from you. If their delivery
401s, the fix is you adding their DID toauthorize, not a secret exchange. - That's it. See Provider Quickstart.
Next
- Provider Quickstart · createLink — the sending side
- Migrating HMAC → Signed — retire your connect secret safely
- gatekeeper Platform Quickstart — the enforce/confirm loop