> ## 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.

# Provider Quickstart · createLink

> Integrate Phosra Link in ~15 lines with createLink({ census, writerSeed, writerKeyId, db }) — the connect flow, create-and-link, and signed rule delivery, with no shared secret.

`createLink()` is the ergonomic front door to `@phosra/link`. It derives the whole
hand-built `LinkConfig` (writer DID, router DID, router payload key, the HKDF-derived
household and parent keys) from **one Ed25519 writer key you already hold**, ships and
migrates its own product tables, and delivers rules to platforms by **signing each
delivery to the OCSS root** — so there is no shared HMAC secret to mint, hand off, store,
sync, or rotate.

<Note>
  This page is the ergonomic quickstart. The lower-level primitives it wraps —
  `createLinkSession`, `completeLink`, `runConnectCeremony`, `directive`,
  `provisionProfiles`, `deliverLabelToPlatform` — are **unchanged and still exported**; see
  the [`@phosra/link` reference](/sdks/link). New code should start here.
</Note>

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

<Note>
  **`@phosra/link` is ESM-only.** Import it from an ESM module (`"type": "module"`, a `.mjs`
  file, or TypeScript compiled to ESM). From CommonJS, load it with a dynamic
  `await import('@phosra/link')` — not `require`.
</Note>

***

## Integrate in \~15 lines

```ts theme={null}
import { createLink } from "@phosra/link"
import pg from "pg"

export const link = createLink({
  census:      "https://phosra-api-sandbox-production.up.railway.app", // pinned root shipped by the SDK
  writerSeed:   process.env.OCSS_WRITER_SEED!,       // base64url Ed25519 seed — your accreditation key
  writerKeyId: "did:ocss:custo#2026-07",             // did:ocss:<slug>#<kid> — your published key id
  db:           new pg.Pool({ connectionString: process.env.LINK_DB_URL }),
})

await link.ready()   // migrates the product tables + self-checks accreditation to the root
```

That is the entire setup. `writerSeed` is the **only** secret, and it is shared with **no
platform** — the same key signs your census writes *and* your platform deliveries.

### What each field does

| Field                 | Required    | Meaning                                                                                                                             |
| --------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `census`              | yes         | Census base URL. If it is a pinned host, the SDK ships the matching trust root — no TOFU, no `trustRoot`.                           |
| `writerSeed`          | yes         | Your Ed25519 signing key: a base64url seed, raw bytes, or a `SenderKey` `{ seed, keyID }`.                                          |
| `writerKeyId`         | yes¹        | Your full key id, `did:ocss:<slug>#<kid>`. ¹Omit **only** when `writerSeed` is already a `SenderKey` carrying its `keyID`.          |
| `db`                  | yes         | A `pg.Pool` or a connection string. The SDK owns and migrates its tables here.                                                      |
| `trustRoot`           | conditional | **Required** for a census host that is not in the SDK's pin set (no trust-on-first-use); overrides a pin otherwise.                 |
| `developerOrgId`      | no          | Billing attribution. Omit and the family lands in the anonymous/self-host bucket — no invoice, no SLA. Never gates the safety path. |
| `verifyAccreditation` | no          | Default `true`; gates writes on a Trust-List self-check. Set `false` to skip it (not recommended).                                  |

<Warning>
  **`writerKeyId` is the one field the [blueprint's 3-field pitch](/integration/link-migration#the-one-move) elides.** `createLink` needs your key id to
  sign — `did:ocss:<slug>#<kid>` — unless you pass `writerSeed` as a full `SenderKey`
  `{ seed, keyID }`, in which case it is read from there. For a self-registered DID the `<kid>`
  is the `YYYY-MM` the census assigned at registration — [read it back](/ocss/onboarding#3-read-back-your-bound-kid),
  don't invent one.
</Warning>

<Note>
  **Trust root: pinned vs. explicit.** For a census in `PINNED_TRUST_ROOTS`
  (`resolvePinnedTrustRoot(census)` returns it), the SDK verifies against the bundled pin and
  you pass nothing. For any other host — a self-hosted or one-off census — pass `trustRoot`
  explicitly. Root verification exists precisely so you don't fetch the root from the census
  you're about to verify.
</Note>

`link.ready()` runs `ensureSchema` (the idempotent product DDL) and `verifyAccreditation`
(root-verify + writer-pub match + active enforcement-agent role). Prefer to control timing?
Call `link.migrate()` and `link.verifyAccreditation()` yourself.

***

## The connect flow

The connect flow links a parent's account on a platform to a child's OCSS policy. It is
three legs on the `link.connect` sub-object. The parent authenticates with **your** auth —
Phosra never sees parent credentials; `parentSessionRef` is the server-derived binding
between that login and the ceremony (never accept it from the client).

```ts theme={null}
// 1 — start: build the platform authorize URL
const { authorizeUrl, state, sessionId } = await link.connect.start({
  platformDid:      "did:ocss:notflix",
  redirectUri:      "https://custo.app/phosra/callback",
  parentSessionRef,                 // your server-side session identifier
  childHint:        childId,        // optional login_hint
})
// → redirect the parent's browser to authorizeUrl

// 2 — resume: on the OAuth callback, exchange the code for the child-profile list
const { childProfiles } = await link.connect.resume({
  code, state, parentSessionRef,
})
// → present childProfiles to the parent

// 3 — finish: record the confirmed pick, mint the grant, ingest consent, deliver the label
const r = await link.connect.finish({
  sessionId,
  platformChildProfileId: childProfiles[0].id,
  childId:                "child:a11ce0fa-...",
  granted_scope:          ["addictive_pattern_block", "content_rating"],
  ageHint:                "13_15",   // defaults to "under_13"
  state,
})
// r: { grant_id, endpoint_id_label, idemKey, delivered, deliveryStatus?, deliveryScheme? }
```

`connect.finish` does everything the old four-step ceremony did — derives the unlinkable
household hash, mints the grant, ingests the §8.3.2 parent-consent attestation, binds the
enforcement endpoint, and **delivers the label to the platform, signed to the OCSS root**.

<Note>
  **`deliveryScheme` tells you which lane carried the label.** On a secret-free platform it is
  `"ed25519-did"` (signed). It is `"hmac"` only if you passed a legacy `__legacyConnectSecret`
  during a migration drain window — see [Migrating HMAC → Signed](/integration/link-migration).
  `delivered` + `deliveryStatus` report the receiver's HTTP result.
</Note>

<Note>
  **Two connect vocabularies, one flow.** The factory's legs are `start` / `resume` /
  `finish`; the embeddable [`@phosra/connect` transport](/sdks/connect-component) names the
  same three legs `init` / `complete` / `bind`. They map 1:1 — `start`↔`init`,
  `resume`↔`complete`, `finish`↔`bind` — so your BFF wraps the factory to serve the modal.
</Note>

***

## Create-and-link (one parent action → N banded profiles)

When the platform advertises batch provisioning, a single signed call creates the child
profiles on the platform **and** binds them — the create-and-link path
(`ingestConsentAttestation` → `mintEnforcementEndpoint(standingRef)` → signed
`provisionProfiles`, all wrapped):

```ts theme={null}
const out = await link.provision("did:ocss:notflix", {
  state,                              // from link.connect.start
  children: [
    { ageHint: "under_13", displayName: "Leo" },
    { ageHint: "13_15",    displayName: "Mia" },
    { ageHint: "16_17",    displayName: "Ava" },
  ],
  adultPinAutoSet: true,              // optional
})
// out: ProvisionOutcome — provisioned profiles + their grant ids
```

Every profile is created under one signed, audience-bound delivery to the platform — no
shared secret, no per-child round trip. The platform side is a single receiver route; see
[Platform Quickstart · createConnectReceiver](/integration/link-platform).

***

## Write a rule — `link.enforce`

Once a grant is active, `link.enforce` signs and posts the rule write to the census.

```ts theme={null}
// Toggle category
await link.enforce(r.grant_id, "addictive_pattern_block", "child:a11ce0fa-...", {
  decision: "block",
})

// Rating-threshold category
await link.enforce(r.grant_id, "content_rating", "child:a11ce0fa-...", {
  decision: "block",
  params: { family: "numeric_threshold", scale: "ratings_age", max_allowed: 13 },
})

// In-place re-write of an existing rule (same standing, fresh idempotency key)
await link.enforce(r.grant_id, "content_rating", "child:a11ce0fa-...", {
  decision: "block",
  params: { family: "numeric_threshold", scale: "ratings_age", max_allowed: 10 },
  revision: true,
})
```

The write is scope-checked before signing: a category outside the grant's `granted_scope`
throws before any network call. The standing (`consent:attestation:<ref>`) is supplied
automatically from the grant.

### Manage grants

```ts theme={null}
const grants = await link.listGrants({ childId: "child:a11ce0fa-..." })
await link.revoke(r.grant_id)   // ref-counted teardown; census records consent.revoked
```

***

## Typed errors

`@phosra/link` throws typed `LinkError` subclasses so you can turn a raw census status into
an actionable message. Guard with `isLinkError(e)` and branch on `e.code`.

```ts theme={null}
import {
  isLinkError,
  NotAccreditedError,          // your DID isn't an active enforcement-agent on the Trust List
  PlatformNotConnectableError, // the target platform has no connect config (404)
  LaneInactiveError,           // the census lane the write needs is inactive
  DeliveryFailedError,         // the platform receiver rejected the signed delivery (.status, .hint)
  BandMismatchError,           // age band vs capability band mismatch
  StateExpiredError,
  ParentSessionMismatchError,
  SchemaNotReadyError,
} from "@phosra/link"

try {
  await link.connect.finish({ /* … */ })
} catch (e) {
  if (isLinkError(e) && e instanceof DeliveryFailedError) {
    // e.status === 401 → the platform hasn't added your DID to its allowlist
    console.error(e.hint)
  } else {
    throw e
  }
}
```

A `DeliveryFailedError` with `.status === 401` almost always means the platform has not yet
added your provider DID to its `createConnectReceiver({ authorize })` allowlist — the one
trust config on the platform side. That is the signed-delivery analogue of "wrong HMAC
secret," and the fix is a one-line allowlist edit on the platform, not a secret exchange.

***

## What the SDK owns (and never sends)

| Operation                                      | Where it runs                                                       |
| ---------------------------------------------- | ------------------------------------------------------------------- |
| Ed25519 signing (every write + every delivery) | Your process — the key never leaves                                 |
| Household hash derivation                      | Your process                                                        |
| Scope pre-check                                | Your process                                                        |
| Product schema (grants + sessions)             | Your `db` — `link.migrate()`                                        |
| Trust List fetch + verify-to-root              | Census: `GET /.well-known/ocss/trust-list`                          |
| Platform connect-config fetch                  | Census: `GET /api/v1/providers/{did}/connect`                       |
| Consent attestation ingest                     | Census                                                              |
| Endpoint binding                               | Census: `POST /api/v1/enforcement-endpoints`                        |
| Rule write                                     | Census: `POST /api/v1/policies/{policyId}/rules`                    |
| Label delivery to the platform                 | **Direct provider → platform, Ed25519-signed** (census has no role) |

Signing and verification are local; billing is enforced server-side against your org, never
inside the package and never on the safety path.

***

## What the platform (your partner) must do

The signed model shifts almost all of the work to your side. To receive your deliveries, the
platform implements **one route** and adds **one allowlist line** — no shared secret:

1. Install `@phosra/gatekeeper` and add `POST /api/ocss/connect` via
   `createConnectReceiver({ env, did, seed, authorize: ["did:ocss:<you>"], store, onBound })`
   — see [Platform Quickstart](/integration/link-platform).
2. Put **your provider DID** in that `authorize` allowlist. That is the entire trust
   configuration; there is nothing to mint, store, sync, or rotate.
3. Nothing else. The platform never holds a Phosra secret; your signature to the pinned root
   is the auth.

***

## Next

* [Platform Quickstart · createConnectReceiver](/integration/link-platform) — the one-route receiver
* [Migrating HMAC → Signed](/integration/link-migration) — retire the shared connect secret
* [PhosraConnect component](/sdks/connect-component) — the Plaid-grade in-app parent modal
* [`@phosra/link` reference](/sdks/link) — the low-level primitives and the reference BFF
