> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phosra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# @phosra/link

> Writer-plane SDK for providers — signed consent ceremonies, rule directives, and the platform connect flow

`@phosra/link` is the writer-plane SDK. It runs in a **provider's backend** (a
parental-controls app's server), signs everything locally over RFC 9421, and posts
to the hosted OCSS census. If you are integrating end-to-end, start with the
[Provider quickstart](/integration/provider) — this page is the package reference
for the pieces the quickstart doesn't cover in depth: platform resolution and
parent sessions.

```bash theme={null}
npm install @phosra/link
```

## LinkConfig

```typescript theme={null}
import type { LinkConfig } from '@phosra/link';
import pg from 'pg';

const config: LinkConfig = {
  censusBaseUrl:    'https://phosra-api-sandbox-production.up.railway.app', // production sandbox — canonical testing endpoint
  trustRootXB64Url: 'CMHWy3vUAiEcYDdE_bDvkRuEqwxkklS0tV-TYHJTlWU', // sandbox trust-root public key X (see note below)
  parentKey:        { seed: parentSeed, keyID: 'did:ocss:household-acme#parent-key-2026' },
  writerKey:        { seed: writerSeed, keyID: 'did:ocss:your-org#writer-key-2026' },
  writerDid:        'did:ocss:your-org',
  routerDid:        'did:ocss:phosra-router',
  householdSecret:  process.env.HOUSEHOLD_SECRET!,  // never sent to the census
  parentSessionSecret: process.env.PARENT_SESSION_SECRET, // enables verified parent sessions
  pool: new pg.Pool({ connectionString: process.env.LINK_DB_URL }),
  developerOrgId:   process.env.PHOSRA_DEV_ORG_ID,  // billing attribution — optional, see note below
};
```

<Note>
  `trustRootXB64Url` above (`CMHWy3vUAiEcYDdE_bDvkRuEqwxkklS0tV-TYHJTlWU`) is a **PUBLIC** key,
  pinned here out-of-band — that's the point of root verification: you don't fetch the trust
  root from the same census you're about to verify. It's safe to hardcode or check into your
  own config. `developerOrgId` (a `phosra_live_...`-issued org) is **billing-attribution
  only** — the quickstart above runs correctly without one; omit it and the family lands in
  the anonymous/self-host bucket (no invoice, no SLA).
</Note>

The SDK is free and open (MIT). Signing and verification happen locally; billing is
enforced server-side against your org — never inside this package, and never on the
safety path.

## How platforms are resolved

Before `initPlatformOAuth` builds an authorize URL, the SDK resolves the target
platform in two layers:

1. **OCSS Trust List — the trust gate.** The signed trust list at
   `/.well-known/ocss/trust-list` is fetched and verified against the trust root.
   The platform's DID must be an `active`, unexpired entry. Absent, suspended,
   revoked, or expired all **fail closed** — you cannot connect a child to a
   non-accredited platform, and the next layer is never consulted.
2. **Phosra connect registry — the mechanics.** The platform's OAuth
   `authorize`/`token`/`profiles` endpoints come from
   [`GET /providers/{did}/connect`](/api-reference/providers/connect), a Phosra
   product endpoint. It is deliberately **not** part of the signed OCSS document:
   the standard stays vendor-neutral (trust, keys, accreditation); connection
   mechanics are Phosra's concern as the OCSS implementor.

Both layers run inside `initPlatformOAuth`/`completePlatformOAuth`; you don't call
them directly. A `404` from the connect registry surfaces as
`link/provider: no Phosra connect config for <did>`.

<Note>
  **Which DID to test against.** `did:ocss:snaptr` in these examples is illustrative.
  Against the production sandbox census
  (`https://phosra-api-sandbox-production.up.railway.app`), the DID with a live connect
  config today is **`did:ocss:loopline`** — `GET /api/v1/providers/did:ocss:loopline/connect`
  returns its `authorize`/`token`/`profiles` endpoints, and `initPlatformOAuth` will build
  a valid authorize URL for it. Most other trust-listed DIDs are accredited but not yet
  connect-configured (that intentional 404 is the "accredited-but-unconfigured" case). To
  have a connect config published for a provider you operate, email
  [developers@phosra.com](mailto:developers@phosra.com) with the DID and its OAuth endpoints.
</Note>

## Parent sessions

The parent authenticates with **your app's own auth** — Phosra never sees parent
credentials. What the SDK needs is a trustworthy binding between your
authenticated parent session and the OAuth ceremony, so one parent's state can
never be completed by another parent, and an expired login cannot finish a
ceremony it started.

**Verified mode (recommended).** Set `parentSessionSecret` in `LinkConfig`. After
your login succeeds, issue a signed, expiring token server-side and pass it as
`parentSessionRef` on all three ceremony legs (an `HttpOnly; Secure` cookie works
well):

```typescript theme={null}
import { issueParentSession } from '@phosra/link';

// at login time, server-side:
const token = issueParentSession(process.env.PARENT_SESSION_SECRET!, {
  parentId: user.id,          // your stable parent identifier
  ttlMs: 30 * 60 * 1000,      // default 30 minutes
});
```

The SDK verifies the token (HMAC + expiry, fail closed) on `initPlatformOAuth`,
`completePlatformOAuth`, **and** `bindProfile`, and binds the ceremony to the
verified session id — a re-issued token for the same session still completes, an
expired one does not, and another parent's valid token is rejected.

**BYO mode.** Leave `parentSessionSecret` unset and pass your own server-side
session identifier as `parentSessionRef` (as the
[Provider quickstart](/integration/provider) shows with `req.session.id`). The
contract: derive it server-side from your authenticated session; never accept it
from the client.

## Reference BFF

A complete, framework-free reference implementation of the three connect routes —
including the login-cookie wiring — is reproduced **in full below**. It is the exact
PCA-side server the iOS `PhosraLinkKit` and the web `@phosra/connect` transport expect.
Copy it verbatim; the only production changes are called out under
[Wiring it for production](#wiring-it-for-production).

The three routes, and the `@phosra/link` call each wraps:

| Route                    | Wraps                           | Returns                              |
| ------------------------ | ------------------------------- | ------------------------------------ |
| `POST /connect/init`     | `initPlatformOAuth`             | `{ authorizeUrl, state, sessionId }` |
| `POST /connect/complete` | `completePlatformOAuth`         | `{ sessionId, childProfiles }`       |
| `POST /connect/bind`     | `bindProfile` → your `ceremony` | `{ grant_id }`                       |

A fourth route, `POST /auth/login`, is a **demo stand-in** for the PCA's own login: a
real PCA authenticates the parent first (password / passkey / SSO), then issues the
`phosra_parent` cookie. What matters is that after login the BFF holds an HMAC-signed,
expiring parent-session token and passes the **raw** cookie token to `@phosra/link` on
every connect leg — the client never chooses its own `parentSessionRef`.

### server.ts

```typescript theme={null}
// The PCA-side wiring @phosra/connect's transport (and PhosraLinkKit) expects.
// Framework-free (node:http). The parent authenticates with the PCA's OWN auth
// (Plaid model); after login the BFF issues an HMAC-signed, expiring parent-session
// token as an HttpOnly cookie, and every connect route passes the RAW cookie token
// to @phosra/link, which verifies it against cfg.parentSessionSecret and binds the
// ceremony to the verified sid.
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
import {
  issueParentSession,
  initPlatformOAuth,
  completePlatformOAuth,
  bindProfile,
  type PlatformSessionStore,
  type LinkConfig,
  type LinkSession,
} from "@phosra/link"

const COOKIE = "phosra_parent"

function readCookie(req: IncomingMessage): string | null {
  const header = req.headers.cookie ?? ""
  for (const part of header.split(";")) {
    const [k, ...v] = part.trim().split("=")
    if (k === COOKIE) return v.join("=")
  }
  return null
}

function readBody(req: IncomingMessage): Promise<any> {
  return new Promise((resolve, reject) => {
    let data = ""
    req.on("data", (c) => (data += c))
    req.on("end", () => {
      try { resolve(data ? JSON.parse(data) : {}) } catch (e) { reject(e) }
    })
    req.on("error", reject)
  })
}

function sendJson(res: ServerResponse, status: number, body: unknown): void {
  res.writeHead(status, { "content-type": "application/json" })
  res.end(JSON.stringify(body))
}

export function createReferenceBff(
  cfg: LinkConfig,
  deps: {
    store: PlatformSessionStore
    fetchImpl?: typeof fetch
    // Runs the post-bind ceremony (completeLink + label delivery). Injected so the
    // example stays census-agnostic; production wires runConnectCeremony here.
    ceremony: (session: LinkSession) => Promise<{ grant_id: string }>
  },
): Server {
  return createServer(async (req, res) => {
    try {
      if (req.method !== "POST") return sendJson(res, 404, { error: "not found" })
      const body = await readBody(req)

      if (req.url === "/auth/login") {
        // DEMO stand-in for the PCA's real login. A real PCA authenticates the
        // parent first (password/passkey/SSO), THEN issues this token.
        if (!body.parentId) return sendJson(res, 400, { error: "parentId required" })
        const token = issueParentSession(cfg.parentSessionSecret!, { parentId: body.parentId })
        res.writeHead(200, {
          "content-type": "application/json",
          "set-cookie": `${COOKIE}=${token}; HttpOnly; Secure; Path=/; SameSite=Lax`,
        })
        return res.end(JSON.stringify({ ok: true }))
      }

      const CONNECT_ROUTES = new Set(["/connect/init", "/connect/complete", "/connect/bind"])
      if (!CONNECT_ROUTES.has(req.url ?? "")) return sendJson(res, 404, { error: "not found" })

      // Every connect route requires the session cookie; @phosra/link verifies it.
      const token = readCookie(req)
      if (!token) return sendJson(res, 401, { error: "not logged in" })

      if (req.url === "/connect/init") {
        const out = await initPlatformOAuth(
          cfg,
          { platformDid: body.platformDid, redirectUri: body.redirectUri, parentSessionRef: token, childHint: body.childHint },
          { store: deps.store, fetchImpl: deps.fetchImpl },
        )
        return sendJson(res, 200, out)
      }
      if (req.url === "/connect/complete") {
        const out = await completePlatformOAuth(
          cfg,
          { code: body.code, state: body.state, parentSessionRef: token },
          { store: deps.store, fetchImpl: deps.fetchImpl },
        )
        return sendJson(res, 200, out)
      }
      if (req.url === "/connect/bind") {
        const session = await bindProfile(
          cfg,
          {
            sessionId: body.sessionId, platformChildProfileId: body.platformChildProfileId,
            childId: body.childId, granted_scope: body.grantedScope, ageHint: body.ageHint,
            parentSessionRef: token,
          },
          { store: deps.store },
        )
        const { grant_id } = await deps.ceremony(session)
        return sendJson(res, 200, { grant_id })
      }
      return sendJson(res, 404, { error: "not found" })
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e)
      // link/session: * = auth-plane failures → 401; everything else → 400.
      return sendJson(res, msg.startsWith("link/session:") ? 401 : 400, { error: msg })
    }
  })
}
```

### Database schema

The BFF needs a Postgres database with **two tables** — the OAuth ceremony session
store and the product-side grant index. Both are product-plane; the OCSS census never
reads either (the §12.3 cut-test). Apply this DDL to the database you pass as
`LinkConfig.pool`:

```sql theme={null}
-- Product-side OAuth ceremony session store. Holds the BFF-only PKCE secret
-- (code_verifier never leaves the server; only the S256 code_challenge goes into
-- the authorize URL) and a single-use, session-bound state.
CREATE TABLE IF NOT EXISTS phosra_link_platform_sessions (
  session_id                 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  audience_did               text NOT NULL,
  platform_did               text NOT NULL,
  redirect_uri               text NOT NULL,
  code_verifier              text NOT NULL,
  code_challenge             text NOT NULL,
  state                      text NOT NULL,
  parent_session_ref         text NOT NULL,
  status                     text NOT NULL DEFAULT 'pending'
                                 CHECK (status IN ('pending','authed','bound')),
  child_profiles             jsonb,
  platform_child_profile_ref text,
  target_ref                 text,
  age_hint                   text CHECK (age_hint IN ('under_13','13_15','16_17')),
  granted_scope              text[],
  created_at                 timestamptz NOT NULL DEFAULT now(),
  expires_at                 timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_link_platform_session_state
  ON phosra_link_platform_sessions (state);

-- Product-side grant index: maps a parent consent grant to its §8.3.2 attestation
-- idem key once the ceremony completes. Single-active invariant: at most one active
-- grant per (audience_did, subject_ref, target_ref).
CREATE TABLE IF NOT EXISTS phosra_link_grants (
  grant_id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  audience_did          text NOT NULL,
  subject_ref           text NOT NULL,
  target_ref            text NOT NULL,
  granted_scope         text[] NOT NULL,
  attestation_idem_key  text,
  status                text NOT NULL DEFAULT 'active'
                            CHECK (status IN ('active','revoked')),
  created_at            timestamptz NOT NULL DEFAULT now(),
  revoked_at            timestamptz
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_link_grant_active
  ON phosra_link_grants (audience_did, subject_ref, target_ref)
  WHERE status = 'active';
CREATE INDEX IF NOT EXISTS ix_link_grant_idem
  ON phosra_link_grants (attestation_idem_key);
```

`gen_random_uuid()` requires the `pgcrypto` extension (`CREATE EXTENSION IF NOT
EXISTS pgcrypto;`) on older Postgres; it is built in from Postgres 13+.

### Wiring it for production

`createReferenceBff` above is complete and framework-free. To take it to production:

* **Real login.** Replace `/auth/login` with your app's actual authentication; issue
  the `phosra_parent` cookie only *after* the parent is authenticated.
* **Postgres session store.** Build the store over your pool with the exported
  `makePlatformSessionStore(cfg.pool)` (against the `phosra_link_platform_sessions`
  table above) rather than an in-memory stub, so ceremonies survive restarts.
* **Real ceremony.** Inject `runConnectCeremony` as `deps.ceremony` — it calls
  `completeLink` (which signs the §8.3.2 consent attestation with your OCSS key, posts
  it to the census, and writes the `phosra_link_grants` row) and delivers the rule
  labels to the platform. **It must return a `grant_id` only after the consent is
  minted *and* verified to the OCSS root** — the sheet's green "Verified" rests on that
  signal alone.
* **Cookie hardening.** The `set-cookie` already sets `HttpOnly; Secure`; add a
  `Max-Age` matched to the parent-session token's TTL.

<Note>
  The same source, an end-to-end test (`test/reference-bff.e2e.test.ts`, which boots this
  BFF plus an in-repo example provider and drives the full ceremony over real HTTP with
  real cookies), and the migrations above also ship **inside the published `@phosra/link`
  tarball** under `node_modules/@phosra/link/examples/reference-bff/` — so you never need
  repo access. If your installed copy predates this, the source on this page is
  authoritative and self-contained.
</Note>

## Beyond connect

The ceremony, rule writes (`directive`), and family-wide fan-out
(`convergeFamily`) are covered step-by-step in the
[Provider quickstart](/integration/provider). For the platform side of the
handshake, see the [Platform quickstart](/integration/platform). The protocol
primitives themselves are `@openchildsafety/ocss`, re-exported by the
[Phosra Developer SDK](/sdks/phosra-developer-sdk) — Phosra implements the
standard; it does not own it.
