Skip to main content

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 factorycreateGoldenLinkServer on the provider side does network and schema work before it resolves. Do not carry one page’s promise across to the other.
Castle+ chooses to throw at its own boot when 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_form and profile_management_url are 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/par and /api/phosra/token are compared as literal strings. You cannot mount the catch-all somewhere else and publish where you actually put it.
Verify a live row — this is Castle+‘s, on the production census:
Assert every field, not just connect_url:
Then check 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 401 without 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; null deletes a member.
  • It never rotates endpoint labels or connect secrets, and it is idempotent.
Operator seeding is the bootstrap fallback, and it is what both live platforms actually got. Castle+ and Notflix both satisfy the directory check because an operator seeded the row to match the credential at onboarding. If your app moves origin or changes display name, the credential and the directory row must move together — re-issue the credential and PATCH the row. Changing one alone is a permanent 503.

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.
Readiness cannot catch a broken profiles endpoint. The boot check verifies only the URL’s origin, never that it answers. A platform can report PLATFORM_READY while its published profiles_url is a hard 404 PHOSRA_ROUTE_NOT_FOUND — and at least one production deployment is in exactly that state right now, because the path looks like a /api/phosra/* route and is assumed to come from the facade. It does not. The failure then surfaces mid-ceremony, in the provider’s app, to a parent. Curl your own profiles_url before you call an integration done.
The contract, as the shipped reference implements it:
  • Bearer-scoped. The access token minted by your /api/phosra/token leg identifies the parent account. No token, or an unknown one, is 401.
  • 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.
Notflix publishes 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:
role: enforcement-agent does not separate providers from platforms. On the production census that filter today returns did:ocss:bloxby, did:ocss:castle-plus, did:ocss:notflix, did:ocss:pixagram, did:ocss:propagate, did:ocss:snaptr and did:ocss:xfanity-dns alongside did:ocss:custo — most of those are platforms, like you. The trust list carries no flag that tells the two apart. Put a DID on your allowlist because you have an agreement with that company, not because a filter returned it.
The 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 routerrouting.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.
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:
This does not affect your boot — /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:
A Fastify bridge, condensed from the one Castle+ ships (services/phosra/src/plugin.ts):
Two requirements that are easy to miss and both fail confusingly:
Hand the SDK the raw body. Signatures cover the exact bytes. A framework that parses JSON for you (Express express.json(), Fastify’s default parser, a Next middleware that reads the body) breaks every signed delivery with no useful error.Exempt /api/phosra/* from your app’s global auth. The protocol routes carry their own authentication — RFC-9421 request signatures on delivery, OAuth/PAR on the connect leg. A global bearer hook 401s them before the SDK ever sees the request; a redirect-to-login middleware turns /api/phosra/status into a 307 and makes the whole platform look unmounted. Castle+ marks the scope public with an onRoute hook; Notflix’s Next matcher excludes api wholesale.

Next.js middleware matchers

If you run a Next middleware.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.
The SDK ships a boot-time assertion for the second class:
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

Without a running worker, nothing is ever delivered. Materialization, read-back and evidence dispatch all happen in the worker pass, never in the request path. Mount the catch-all without running a worker and the ceremony appears to succeed: the parent completes the flow, your route acknowledges, and the connection sits pre-delivery indefinitely with no error to search for.
Run exactly one mode: the long-lived 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

The payload is content-free and correlation-safe — 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

Your apply 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:
It exists to remove an overlay that was admitted before retained credential keyrings — i.e. it is what lets a platform that bound profiles under an older SDK generation still disconnect them. It may resolve only the exact prior apply operation. Omit it and removal for those pre-existing bindings silently cannot resolve the prior apply. Notflix implements it in five lines against its overlay store.

Origins, and local development

Your credential pins application.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] or http://127.0.0.1[:port]. The SDK admits the http: scheme only when the environment is sandbox and the host is localhost or 127.0.0.1, and the census only publishes the loopback allowance on a sandbox environment manifest:
    The production manifest returns allow_http_loopback: false with an empty host list.
  • Ports are part of the origin. Changing your dev port means a new credential; an issued one cannot be edited.
Every app on http://localhost shares one cookie jar — cookies ignore the port. If your platform and the provider you are testing against both run on localhost and both use the same session cookie name (the WorkOS default wos-session is the common collision), signing in at one evicts the other’s session and the callback returns UNAUTHENTICATED. Give each app its own cookie name — WORKOS_COOKIE_NAME=<app>-wos-session — before you spend an afternoon on it.

Getting a platform credential

Platform credentials are operator-issued. There is no host on which you can mint one yourself today. Issuance runs through POST /api/v1/admin-ops/link/credentials, which is super-admin gated on the production census and compiled out entirely on the sandbox censuses (404 under SANDBOX_MODE). The self-serve lanes — POST /developers/orgs/{orgId}/link/credentials and POST /developers/orgs/{orgId}/apps — are live only on the Phosra-internal staging sandbox census, and even there a freshly self-registered DID is provisional, while issuance requires an accredited trust-list entry.To get one, email developers@phosra.com with your DID (or the display name you want derived), your canonical application origin, and whether you need sandbox or production. See Create an app for the shape of the funnel that will eventually replace this, and Production accreditation for what admission requires.
What you receive is a single phosra_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

Then the three things readiness does not check:

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.