Implement a Hub

The Hub is the trust broker: it owns sessions, pairing, turns, tool routing, confirms, and audit. Everything else (Surfaces, Companions, the Provider) talks through it and only through it.

Decide first which Hub you are building, because the two have opposite privacy properties and the protocol distinguishes them on the wire so that your users can be told which one they are talking to (SPEC/core.md §5.2):

  • A relay. It runs no model. The visitor's paired phone answers, on-device or through a provider under the visitor's own key, and the Hub settles answerMode: 'companion'. Questions cross it, in plaintext unless the session seals. examples/hub-server is this Hub, complete.
  • A Hub that answers. It settles answerMode: 'hub' and reads every question, deliberately, because it is paying for the inference. This is a legitimate deployment — a workspace product answering for its own members — and it cannot be a sealing Hub for those turns, since a Hub that answers has to read the question.

State which you are. A Hub that relays and a Hub that answers are indistinguishable to a visitor who is not told.

npm install @schwaizer/ocp-protocol

The contract you are implementing

@schwaizer/ocp-protocol gives you the wire truth: classifyMessage() (with the forward-compat rule built in — an unknown type is ignore, never a rejection; a KNOWN type whose payload fails validation is reject, never a silent ignore), every message schema, and the golden conformance vectors. Your Hub is conformant when the vector suite passes against it.

parseMessage() says the same three things by returning null and throwing. Use it in a client, where one frame is in hand and try/catch is honest; prefer classifyMessage() on a server. A throw inside a frame handler your socket listener dispatched without awaiting is an unhandled rejection, and Node exits the process on one — which ends every session you are serving, not just the one that sent the bad frame. This Hub shipped that bug; see the CHANGELOG.

Minimum viable Hub, in order

  1. Discovery — serve /.well-known/ocp.json (issuer, jwks_uri, ws_endpoint, profiles) and your signing keys at the JWKS URL.

  2. Surface leg — authenticated WS; session.open → create a session, answer session.linked with the negotiated capabilities and a sid.

  3. PairingPOST /ocp/pair/start (mint the ≤90 s single-use QR JWS) → POST /ocp/pair/claim (verify, burn the nonce ATOMICALLY, hold a pending claim) → pair.approval_request to the Surface → on approval mint the companion token cnf-bound to the device key → pair/result hands it over. Every failure is PAIR_DENIED, identically shaped. Optionally also POST /ocp/pair/claim-by-code (SPEC/companion.md §2.1): mint a short code beside the deeplink, resolve it to the SAME jws, and reuse the claim path unchanged. It is a second way to reach one token, never a second kind of token — so it inherits the nonce burn, the PoP and the human approval rather than needing its own.

    Do not implement sealing. This step used to describe how to make a code SEAL — storing sealCommit at mint, serving it from pair/resolve, relaying pair.reveal. That is REMOVED, not corrected: the profile is retired and NOT IMPLEMENTED (SPEC/retired/sealing.md), and a Hub that builds it implements something no other peer completes. The frames stay registered in wire 1.0 so golden vectors keep passing; that is a parsing guarantee, not an invitation. A deployment that needs content withheld from the operator uses the Companion's phone-only transcript mode — see SPEC/companion-provider.md §5, including the tool path it does not cover.

  4. Companion leg — verify token + fresh device-key PoP on connect; attach or resume (resume = key + PoP again); emit pair.linked / session.resumed to the Surface.

  5. Turns — one in-flight per session; barge-in aborts with turn.error{TURN_ABORTED}; stream your Provider's output as turn.delta / turn.citation; finish with turn.done{contextRev}. Mirror turn.input to the other peer. Relay turn.cancel too (0.5.0): forward it to the other peer, close the turn for BOTH with turn.error{TURN_ABORTED}, and drop a cancel for any turn that is not the open one silently — an error back would let a probing peer discover live turn ids. A Hub that drops this frame turns every stop button into a stop-this-device-only button while the other peer keeps generating, and paying.

  6. Tools + confirms — store tools.advertise as hints; re-authorize per call; gate write: true behind ui.request broadcast to both peers, first answer wins, EXPIRY IS DENY; dispatch action.request to the surface and feed action.result back into the turn. ROUTE BOTH HALVES (0.9.0): a Hub that dispatches a call and drops its result has absorbed the call rather than relayed it, and the visitor watches their phone time out on an answer the page produced in a second. The executor addresses its result to the SESSION — it never sees the peer that asked — so hold the call by actionId until the result arrives, hand it to the peer that ASKED (either side may ask), and drop a result for a call you are not holding open: it is unsolicited, a second answer to a closed call, or from the peer that asked rather than the one that was asked.

  7. Lifecycle discipline — heartbeats both ways, 60 s companion grace, surface drop closes immediately, TTLs 30 min idle / 4 h absolute, token refresh (session.refreshsession.refreshed) never extends session TTLs, and session close resolves pending confirms to deny.

Four things that are optional, and wrong if you half-implement them

None of these are needed for a conformant Hub. Each exists because a deployment asked for it, and each has a failure mode where the feature appears to work while the property it was added for is gone.

Gates (ui.request.mode: 'gate', Core §10.1). A decision that must not answer itself: a signature, a valuation scope, a payment release. Auto-denying one is as wrong as auto-allowing it — both invent an answer the accountable person never gave. So a gate arms NO deadline, and three rules keep that from weakening §10: it carries no actionId (a gate that gated a call in flight would reintroduce exactly the unbounded pending write expiry exists to prevent), its expiresAt bounds the VIEW rather than the decision, and a closing session must not deny it — the session ending is not the human answering. Re-issue the view when a device re-pairs; the decision underneath is untouched, and answering either copy settles it once. Persist them. A gate that evaporates because your process restarted is a decision the deployment cannot rely on; ConfirmRegistry.openGates() exists to be written somewhere durable. If you do not offer gates, treat mode: 'gate' as 'expire' — a degradation the caller can detect from the resolution it gets.

Delivering resolutions (§10.2). A confirm is satisfiable from ANY peer, so the peer that ASKED is frequently not the one that answered. Deliver the resolution to the Surface even when the turn that requested it has ended — a screen that renders approvals is the party that has to record them. Include record, a stable id for the resolution: a JOIN, not a payload. What the decision MEANT — the evidence on screen, the reviewer, the document version — stays in your own system under that id, because the §14 trail is content-free by construction and an evidence blob here would end that.

action.result.reason. Where you know the difference, say it: denied is a person saying no, expired is nobody answering, unauthorized is your policy refusing before any human was asked. A caller told only that a call failed cannot tell a decision from an absence, and re-asking someone who already refused is its own failure.

A principal (§7) and long sessions (§5.4). Pairing binds a DEVICE, which is right for a kiosk and not enough where a Surface must record that a named person approved a write. A claim MAY carry a principal — and it is an assertion by an unauthenticated caller, so verify it yourself or drop it. Carrying it through unverified turns your Hub into a laundering service that converts a payload field into transport-established identity, which is worse than no attribution, because the Surface will print it. Put the verified subject on the token you mint, not in a side table: it then survives a reconnect and a refresh, and reading it back is you reading your own signature.

For longSession, resist the request as it usually arrives — "make the TTL longer". Four hours exists because session.refresh renews a token WITHOUT re-proving possession, so raising it does nothing but lengthen a stolen resume key's life. Move the bound instead: four hours becomes the life of an epoch, and crossing one costs a fresh device-key PoP. The cheapest correct implementation is to drop the companion socket and require a reconnect, because your connect path already runs the proof. Close a peer that cannot re-prove; keep the sid, which is the whole point.

If you only need the Embed widget, you need almost none of the above

The list above is the full Hub. A site that just wants a chat widget answering about its own pages can implement the Embed minimal profile instead — one POST returning SSE, no WebSocket, no pairing, no companion, no tool routing — and @schwaizer/ocp-hub/embed is that profile already written:

import { createTurnHandler, createWidgetHandler, createMemoryQuotaStore } from '@schwaizer/ocp-hub/embed';

Note what that trade costs: with no pairing there is no Companion, so there is no phone answering for this screen and no answerMode: 'companion'. The minimal profile is a Hub that answers, and it will be billed accordingly.

You supply an adapter that answers from your backend; it owns the invariants that are easy to miss — one terminal frame per turn, sid, contextRev, barge-in, pre-stream refusals as real HTTP statuses, and the widget document's frame-ancestors. See SPEC/embed.md for what the profile does and does not carry, and that package's README for what it does not yet do.

Say what your deployment is

Two Hubs with identical wire behaviour can have opposite privacy properties, and a visitor cannot tell them apart by watching frames. So publish a deployment block in .well-known/ocp.json (SPEC/companion.md §1.1):

wellKnownDocument({
  origin,
  publicJwk: keys.publicJwk,
  deployment: {
    answerMode: 'companion',        // or 'hub' — this one is not optional to get right
    operator: 'Example AG',
    dataResidency: 'CH',
    retention: { transcripts: 'none', audit: 'P400D' },
  },
});

wellKnownDocument parses what you pass, so a malformed declaration fails your boot rather than a phone's parser. Declaring nothing is conformant; declaring something false is not — and the field a deployment is most tempted to get wrong is the one a visitor most needs, because hub means the operator reads the question.

Write the trail down

Core §14 says pair start/claim/approve/deny/revoke, companion link/takeover, tool invocation and confirm resolution MUST be auditable. That is a requirement about what your deployment can later be asked, so it is worth wiring early: it is much harder to reconstruct who approved which device than to have recorded it.

@schwaizer/ocp-hub ships the shape so every Hub's trail reads the same:

import { createAuditLog, LogSink, JsonlFileSink } from '@schwaizer/ocp-hub';

// Already shipping logs somewhere? Then the trail goes with them.
const audit = createAuditLog({ sink: new LogSink(app.log) });

// Or write files, rotated by UTC day and swept past a retention bound.
const audit = createAuditLog({
  sink: new JsonlFileSink({ dir: '/var/lib/ocp/audit', retentionDays: 400 }),
});

The event union has no field a question, an answer or a tool argument would fit in. That is deliberate and it is the property that lets the trail outlive the conversation: there is no turn event at all, and a confirm records allow/deny rather than which option was chosen, because an option id is content by another name. A trail and a transcript have opposite retention pressures — the DPIA template (docs/dpia-template.md) asks you to justify keeping the second, and this one is built so the first never contains it.

400 days is the default because EU AI Act Art. 26 sets six months as a FLOOR for a deployer and FINMA documentation expectations argue for longer; whatever you choose, retention.audit in your .well-known declaration should say the same number your sink actually enforces.

Where the events go

Ten of the eleven are one line beside the decision they record:

EmitWhere
pair.startthe pair/start route, once a token is minted
pair.claima claim arrives — carries device name/platform, attestation, and via: 'qr' | 'code'
pair.approve / pair.denythe human decision resolves, and every path that refuses one
pair.revokea Surface revokes, and grace expiry
companion.linka companion attaches, including a resume
tool.invokeinside your authorize seam — record decision: 'allowed' | 'denied', both
confirm.resolvethe confirm registry settles, with by: 'surface' | 'companion' | 'expiry'
session.opensession creation

session.close is the one that is not a call site. Not every close comes from your code — the session service closes idle and expired sessions from its own timer — so a Hub that emits on the closes it can see records only the sessions that ended politely. Override the one door instead:

class AuditedSessions extends OcpSessionService<Principal> {
  override close(session: OcpSession<Principal>, reason: OcpCloseReason): void {
    const open = !session.closed;
    super.close(session, reason);
    // The was-open check keeps a repeated close from minting a second event.
    if (open) audit.emit({ type: 'session.close', sid: session.sid, reason });
  }
}

That makes the trail a bracket rather than a sample: every session that opened has the line that says how it ended, including the ones nobody watched.

examples/hub-server and examples/hub-answerer both do all of the above, and the answerer additionally demonstrates the file sink — OCP_AUDIT_DIR and OCP_AUDIT_RETENTION_DAYS — if you would rather read a working one than a table. docs/compliance-mapping.md maps each event to the duty it serves.

Hardening checklist (Core §14)

Rate-limit pair/claim per IP · uniform PAIR_DENIED on every pairing failure path · takeover of a live companion needs a SURFACE-ONLY confirm · audit pair/link/takeover/tool/confirm events · attestation hook if your deployment requires verified devices.

If you implement the spoken code, three of those are not optional and one is new. Share the CLAIM limiter rather than giving the code its own — two buckets double an attacker's budget for the same door. Return the identical PAIR_DENIED whether the code resolved or the claim failed, or the endpoint becomes an oracle that separates "no such code" from "wrong claim". Do NOT burn the code on resolution: the nonce is the single-use part, and burning here lets anyone who can reach the endpoint invalidate a code they cannot use, by guessing it once. And refuse a sealPubKey on that route entirely — a sealed pairing the Hub mediates is the thing the camera-only rule exists to prevent.

Verify all of it against someone else's implementation with npx ocp-conform --exec "<your adapter>", which runs the golden vectors over stdin/stdout and knows no schema of its own.

These map 1:1 onto the integration suites of a full WSS Hub — which the /embed export is NOT: it implements the Embed minimal profile only, where pairing, companions and tool routing do not exist to be hardened.