The platform SDK
@phosra/gatekeeper is the platform half of Phosra Link. A platform enforces rules it
receives; it never writes them. createPlatform owns the whole protocol surface — PAR,
authorize, token, delivery, retry, signing, storage, migrations, retries — and asks your app for
four real seams: who the signed-in account is, how to apply a rule, how to independently
observe that it stuck, and how to release it.
Current published version:
0.8.68. Both shipped reference platforms run it. Install
unpinned so a 0.8.x fix reaches you, but know the floors: GET /api/phosra/status — the
diagnostic every other page sends you to — needs ≥ 0.8.68, and a ^0.6.0 range will not
resolve it, because a caret on a 0.x version pins the minor. The production environment
manifest declares a separate protocol floor of sdk_minimum.gatekeeper: 0.8.6; that is the
protocol floor, not the diagnostic floor.What you configure
The Golden platform is often described as having “one Phosra input.” In practice a running platform needs three, and two of them cause first-boot failures when they are missed.Construction does no network or database work.
createPlatform snapshots the config and
builds a lazy bootstrap coordinator; the first request (or your first ready()) drives the
actual work. That is why the module is safe to import from a route file during next build.
This guarantee is specific to the platform factory — createGoldenLinkServer on the
provider side does network and schema work before it resolves. Do not carry one page’s promise
across to the other.PHOSRA_CREDENTIAL is absent rather than serve a
503 on every route. That is a reasonable local policy; the SDK itself tolerates the missing value
and reports CREDENTIAL_MISSING from the readiness endpoint instead.
The four preconditions that gate boot
createPlatform refuses to serve the protocol routes until every precondition passes, and the
routes then return a deliberately uniform 503 PHOSRA_NOT_READY. Two of these preconditions are
things you publish. One is a route you must write yourself. One is not yours at all.
Read GET /api/phosra/status to find out which one is
outstanding — it is the only route that answers while the rest are 503.
1. Your own directory row
Before it will boot, the SDK fetches its own entry from the census (GET /api/v1/providers/{yourDid}/connect) and compares it to your credential. Six values are
checked, five of them for exact, byte-for-byte equality. Any one of them off is
PLATFORM_DIRECTORY_UNAVAILABLE, and every /api/phosra/* route then serves an opaque 503
forever — a correct credential, correct standing and correct origin are not enough.
Two more rules catch people out:
- The row is a closed set. Beyond the six required members above, only
scopes,icon_url,connect_url,provisioning_formandprofile_management_urlare permitted. An unexpected member fails the whole check — the row is rejected, not ignored. - The three protocol paths are fixed.
/api/phosra/authorize,/api/phosra/parand/api/phosra/tokenare compared as literal strings. You cannot mount the catch-all somewhere else and publish where you actually put it.
connect_url:
name against your credential’s display name by eye — that comparison is a string
equality the SDK performs and no jq here can do for you.
Publishing the row: PATCH /api/v1/platforms/{did}/connect
The row is self-declared. PATCH /api/v1/platforms/{did}/connect is the operation that
publishes it:
- RFC-9421 signed, with your own census signing key. It is
401without a signature, on both the production and the staging sandbox census. - Self-scoped — you may only PATCH your own DID.
- JSON-merge semantics. Send only the members you are changing;
nulldeletes a member. - It never rotates endpoint labels or connect secrets, and it is idempotent.
2. The profiles endpoint you must serve
profiles_url is in the required set, and a provider reads your child profiles from it during
the connect ceremony. The catch-all does not serve it. The createPlatform router answers
exactly six paths — status, par, authorize, token, delivery, retry — and 404s
everything else. You have to write this route yourself.
The contract, as the shipped reference implements it:
- Bearer-scoped. The access token minted by your
/api/phosra/tokenleg identifies the parent account. No token, or an unknown one, is401. - A bare JSON array, not an object wrapper — the provider SDK consumes the body directly as a list.
- Child profiles only. Never the account holder, never a placeholder. An account with no
children returns exactly
[]. - On the same origin as your credential’s application origin, with no query string.
profiles_url: ".../api/ocss/profiles" — a different path from its
/api/phosra/* protocol routes, which is fine: only the origin is constrained. Verify it the
way anyone else will:
3. authorizedProviders
adapter.authorizedProviders is a required, non-empty allowlist of provider DIDs. Every DID
on it must be status: active, tier: accredited and role: enforcement-agent on the
census trust list, or boot fails with AUTHORIZED_PARTY_NOT_ACCREDITED.
This is authorization, not discovery. Adding a DID says “this company may write enforcement
rules into my product.” It follows a commercial or integration agreement; it is not a config
default to copy from an example.
To see the candidates the census actually publishes:
document member is served as a JSON string — it is the exact byte sequence the root
signature covers, which is why the jq needs fromjson. Parsing it any other way changes the
bytes and breaks verification.
4. The preconditions Phosra owns
Two preconditions are not yours, are invisible from your configuration, and are fatal.The operating router must be accredited
createPlatform resolves the census’s operating router — routing.operating_router_did in
the signed environment manifest, did:ocss:phosra-router on every current census — and hard-fails
boot if that entry is not active + accredited. The router is the signer whose signature is on
every enforcement profile you consume; if it lapses, nothing can be trusted, so nothing boots.
You do not configure this value, and you cannot fix it. It surfaces as
AUTHORIZED_PARTY_NOT_ACCREDITED — the same code as a lapsed provider — with your credential
perfectly healthy.
The router carries no
role. Do not apply the enforcement-agent check to it — only
status and tier are read. And if the router is the failing party, that is a Phosra-side
condition: report it, do not change your configuration.Your DID must be on the census’s consent-attestation roster
Before a provider can complete a connect ceremony against your platform, it lands a consent attestation on the census naming your app. The census checks that name against an operator-declared roster of apps it ingests for (OCSS_CONSENT_ATTESTATION_APPS). A DID that is
not on the roster is refused as a scope failure:
/api/phosra/status will happily report PLATFORM_READY —
and it is not something you can set. It is a census-side operator setting, requested once at
onboarding. If your platform boots cleanly but no ceremony ever reaches your delivery route, ask
Phosra to confirm your DID is on the roster of the census your credential names.
Mounting
Next.js
Any other framework
phosra.next is a convenience wrapper, not a dependency. The real surface is
phosra.handlers, a set of plain WHATWG Fetch handlers — (Request) => Promise<Response> —
usable from Fastify, Express, Hono, Koa or bare node:http:
services/phosra/src/plugin.ts):
Next.js middleware matchers
If you run a Nextmiddleware.ts, its config.matcher decides whether your auth/proxy layer
runs on a route. Get it wrong in either direction and you get a bad failure:
- Matcher covers
/api/phosra/*with a redirecting auth middleware → protocol routes are redirected to your login page and never reach the SDK. - Matcher excludes the routes a session-dependent connect leg needs → the middleware never refreshes the session and the ceremony fails mid-flight with a 500 and no error code.
Know what it checks. By default
assertGatekeeperConfig asserts coverage of the
createConnectReceiver connect family — /api/phosra/connect and /api/phosra/connect/init —
the exact gap that regressed one integrator three times. createPlatform does not serve those
paths. Pass the routes you actually care about (routes: ["/api/phosra/authorize"]), or
gatekeeperConnectRoutes("/api/ocss") if you mount the receiver elsewhere. The failure it
prevents is a silent 500 mid-ceremony, which is the worst diagnostic you can be handed.ready() returns a report — it does not throw
ready() verifies environment, trust and directory identity and applies the package-owned
schema. It reports; it does not throw. A worker that calls it and ignores the result starts
cleanly against an unmigrated database, logs nothing, and does no work — which looks exactly
like the “the parent finished but nothing happened” failure.
missing names the exact package-owned schema objects still absent. It is the same data
/api/phosra/status reports as database.missing.
The worker is not optional
phosra.worker.start(...) loop or
phosra.runWorkerOnce() from a scheduler. Never both. It is a long-lived process, so a
serverless-only deployment cannot host it.
Two deployment shapes both work, and the reference platforms use one each.
lib/phosra/worker-server-only.ts in the Next shape is a one-line stub that exists purely to
neutralise the server-only import outside the Next runtime.
phosra.worker.status() reports state, healthy, passesCompleted, consecutiveFailures
and authorityExpiresAt — useful for a worker health endpoint.
The onDiagnostic hook
operation, subcode, retryable,
correlationId, causeClass. It carries three operations:
Wire it. The readiness endpoint never reports the last two, so without this hook a delivery that
fails in the worker leaves no diagnosable trace — and
correlationId is the value support asks
for when PLATFORM_COMPOSITION_FAILED says the fault is ours.
The rules you receive
Yourapply and observe see the verified profile’s categories[]. Each entry is:
parameters is where the threshold lives, and it is untyped — a platform must validate it
before acting. A real content_rating rule as Notflix consumes it:
A numeric threshold carries
allow, not block — content at or below max_allowed is
admitted and the threshold blocks everything above it. Treat an unrecognised family, scale
or category as unenforceable and report it with the reporter’s refused(rule, "unsupported")
rather than guessing. An overlay may tighten what the account owner chose; it must never loosen
it.apply is a command — its return value is never evidence. observe is an independent
read-back, and the SDK only emits an event for a rule whose observation carries a concrete
sideEffectId naming the real persisted platform effect.
recoverSelectedProfileForRemoval
An optional adapter member, and the one migration seam on the platform surface:
Origins, and local development
Your credential pinsapplication.origin. Every protocol route and the directory check are
validated against it, so a production credential cannot be used from localhost — the
origin comparison fails and the platform never boots.
- Production credentials are canonical-HTTPS-origin only. Lowercase host; port 443 not spelled out.
-
A sandbox credential may carry
http://localhost[:port]orhttp://127.0.0.1[:port]. The SDK admits thehttp:scheme only when the environment issandboxand the host islocalhostor127.0.0.1, and the census only publishes the loopback allowance on a sandbox environment manifest:The production manifest returnsallow_http_loopback: falsewith an empty host list. - Ports are part of the origin. Changing your dev port means a new credential; an issued one cannot be edited.
Getting a platform credential
What you receive is a singlephosra_cred_v3. envelope. It is a secret — it carries private
seeds. Set it as PHOSRA_CREDENTIAL and nothing else; the census origin, trust root, manifest
pins, DID, display name and application origin all ride inside it.
Onboarding also seeds your directory row to match. If either ever needs to change, both change
together.
Rotation. A re-issue bumps the key generation (
#link-1 → #link-2) and publishes the new
public halves on your trust-list entry. Old key ids stay published until explicitly retired, so a
rotation does not break an in-flight deploy.Verify a deployment
The readiness contract — every precondition and its fix
What each of the thirteen
precondition codes means, what causes it, and what you do about it.Platform Quickstart
The end-to-end walkthrough: singleton, catch-all route, worker, enforcement adapter.
Credential divergence
Why a credential can drift from the census, how to detect it, and why an SDK upgrade cannot fix it.