@schwaizer/ocp-hub

Building blocks for an OCP Hub, in two profiles with two entry points.

import {
  createRedisRateLimiter,
  loadOrCreateKeys,
  OcpPairingService,
  OcpSessionService,
} from '@schwaizer/ocp-hub';
import { createTurnHandler, createWidgetHandler } from '@schwaizer/ocp-hub/embed';

They are separate entry points because their dependencies differ sharply. The WSS profile needs Redis, jose and ws; the embed profile needs nothing at runtime beyond the protocol package. A site that only wants browser chat should not have to install a Redis client to get it, so those three are optional peer dependencies and only the root export requires them.

Neither is a server. There is no HTTP framework here, no route table, and no model provider — a Hub composes these with its own transport and its own answer source. That separation is the point: the same protocol has to serve a workspace assistant whose tenant pays for inference, and a public website that pays for none, without either inheriting the other's assumptions.

Root export — the WSS profile

ES256 signing keys, delegated QR pairing with proof-of-possession, and the session lifecycle machine (SPEC/core.md §5, SPEC/companion.md §2). This is the half that supports a paired Companion, and therefore the only one that can carry the QR flow — the minimal profile excludes pairing by specification.

surface authed ──► pairing.start(sid) ──► QR (15 s, single-use nonce)
                                            │
companion scans ──► pairing.claim({jws, devicePubKey, device})
                      • verifies the Hub signature
                      • burns the nonce atomically (one claim per QR)
                      • binds the device public key (PoP for every later token)
                                            │
surface approves ──► pairing.resolveClaim(claimId, true)   ← a human, always
                                            │
                    pairing.mintCompanionToken(claim, tier, scopes)
                                            │
companion connects ──► pairing.verifyCompanion(token, popJws, devicePubKey)

Every pairing failure returns the same PairDeniedError. That is deliberate: the caller must not learn whether a token was expired, replayed, or forged.

The claim endpoint is unauthenticated and pollable, so it needs a budget per client — see Storage and scale for why that budget has to be the fleet's rather than each instance's:

const claims = createRedisRateLimiter(redis, {
  limit: 30,
  windowMs: 60_000,
  namespace: 'pair.claim',
});

if (!(await claims.take(clientIp))) return reply.status(429).send({ error: 'rate_limited' });

Serve keys.publicJwk at /.well-known/ocp-jwks.json so Companions can verify a QR offline, and advertise the Hub in /.well-known/ocp.json.

Sealing — relaying what you cannot read

/server carries the Hub half of the RETIRED sealing profile (SPEC/retired/sealing.md); nothing negotiates it. It is negotiated and optional; a session that does not settle on it behaves exactly as it did before the profile existed.

Two things this package does, and one it deliberately does not:

  • It forwards the payload it received, not the one it parsed. Zod payload objects are strip-mode, so a broker that re-serialises its own parse deletes every field its schemas predate — including sealed — and forwards a frame that is still valid and no longer carries the content. createRelayRouter handles this; a Hub with its own routing must call forwardedPayload itself.
  • It settles session.linked.sealMode, and only to 'e2e' when both peers offered capabilities.seal and the session answers on the Companion. A Hub that answers has to read the question.
  • It has no sealing switch. Policy lives on the Surface (seal: 'require' | 'offer' | 'off'), because a Hub-side switch is a downgrade oracle held by the party sealing protects against. This process cannot strip sealing without the peers noticing, and that is a property of where the keys came from — the Surface's ephemeral key reaches the phone by camera, never through here.

Everything the relay does still works, because none of it ever needed plaintext: it mirrors by type, tracks the open turn by turnId, re-authorises by tool name, gates writes on the advertised write flag, and orders context by rev.

What a sealed session still discloses to you

Tell your site operators this, because they will otherwise assume the opposite, and on a medical, legal or financial site the sequence searchget_pricebook_appointment at known times is a disclosure whatever the arguments say:

sid, turnId, actionId, message type and direction, envelope id/ts · which tools were invoked, in what order, how often and when · the site's full tool catalogue · every confirm and every approve/deny, and which peer answered · turn start and end, think-time, tool latency, terminal frame type · contextRev bumps · padded ciphertext byte counts · turn.done.answeredBy, i.e. the model vendor · site key, rotating visitor id, IP, user agent, and the device name and platform from pair/claim.

Sealing does not defend a site against its own Hub operator. The Hub serves the Surface's widget document and chooses its script, so a Hub that ships one visitor modified code can read that visitor's conversation, and the six-digit check cannot detect it — the same bundle renders the digits. A deployment where the Hub operator and the site operator are the same party gets no protection from that party and must say so. Do not describe a sealed session with the sentence "the Hub cannot see your conversation".

Operationally: a Hub MUST NOT archive sealed frames, and MUST NOT log the SAS, sealPubKey, or any derived key material. This package logs field NAMES (seal.clear_content) and never values; relay.sealModeOf(sid) gives you a content-free flag for your own events.

/server export — the relay, and what it returns

createRelayRouter().handle(session, from, frame) answers a verdict rather than throwing, and the distinction is not cosmetic. It returns:

  • 'handled' — routed, recorded, or answered here;
  • 'ignored' — an unknown type (SPEC/core.md §2 forbids rejecting one) or a known frame this relay does not route;
  • 'malformed' — a known type whose payload failed validation, or a frame that is not an envelope. §2 says reject it: close the socket (both reference Hubs use 4002). Nothing was relayed.

Your dispatch seam must not let a rejection escape. A socket handler that calls an async method without awaiting it turns any throw into an unhandled rejection, and Node exits the process on one — so one peer's bad frame ends every session you are serving. That is not theoretical: it is what this relay's earlier throwing parse did to the reference Hub. Await the verdict, or attach a .catch() that closes the one socket that caused it.

socket.on('message', (raw) => {
  void handle(raw).catch((err) => {
    log.error({ err }, 'ocp: frame handling failed');
    socket.close(1011, 'internal'); // ours, not theirs — 4002 is theirs
  });
});

A call has two halves

action.request and action.result are one exchange, and handle routes both — since 0.9.0. Before it, this relay dispatched the request and answered 'ignored' to the result, and the reference relay Hub inherited a Hub whose visitors watched a paired phone time out after 30 s on an answer their page had produced in a second. The executor addresses its result to the SESSION — it never sees the peer that asked — so the Hub that dispatched the call is the only party that still knows who is waiting.

The router holds each dispatched call by actionId and hands the result to the peer that ASKED, by role: either side may ask, and a Companion that resumed inside its grace window is on a new socket by the time a confirmed write comes back. A result for a call it is not holding open is dropped silently — unsolicited, a second answer to a closed call, or sent by the peer that asked rather than the one that was asked. An error back would tell a probing peer which actionIds are live; the asker's own timeout is what covers an answer that never comes.

If you route frames yourself, call the two halves rather than restating them: dispatchAction(session, from, payload) and deliverActionResult(session, from, payload), both taking the payload AS IT ARRIVED. Do not do both jobs at once — a host that forwards action.result itself AND passes the same frame to handle will deliver it twice.

Confirms: expiry, gates, and the third seam

createConfirmRegistry owns Core §10. request() gives you the result; requestOutcome() also says HOW it settled, which matters because expiry-to-deny and a person saying no both produce 'deny' and §10.2 asks you to tell a caller apart. The relay uses the second form to set action.result.reason.

mode: 'gate' (§10.1) is a decision that must not answer itself. It arms no timer, is REFUSED if you give it an actionId — a gate that gated a call in flight would reintroduce the unbounded pending write expiry exists to prevent — and denyAll() leaves it open, because a session closing is not the human answering. Two hooks come with it: reissue() puts a fresh view of every open gate on the wire (call it when a device re-pairs; the decision underneath is untouched and answering either copy settles it once), and openGates() lists them so you can persist them. This registry is in-memory: gates survive a disconnection, as §10.1 requires, and not a process restart. If you put gates in front of signatures, write them somewhere durable.

deliverResolution(session, outcome) is the third seam beside the two above, and the one a host is most likely to skip. Wire it to the registry's settled hook: a confirm is satisfiable from any peer, so the Surface — the party that renders approvals and therefore has to record them — frequently never learns what was decided. It delivers to the peers that did NOT answer, whether or not the turn that asked is still open, carrying record: a stable id for the resolution, a JOIN and not a payload. What the decision meant stays in your system under that id, because the §14 trail is content-free by construction.

/embed export — the minimal HTTP+SSE profile

A single POST-to-SSE turn endpoint plus the widget iframe document, with site-key and origin enforcement and quotas. Answers come from an adapter you supply, so no model provider is bundled. This profile has no pairing and no host-page tools; both need a back-channel the transport does not have.

Audit — one shape for the §14 trail

Core §14 names the events a Hub MUST be able to account for — pair start/claim/approve/deny/revoke, companion link and takeover, tool invocation, confirm resolution — and until now every deployment invented its own shape for them. createAuditLog is the canonical one:

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

const audit = createAuditLog({
  sink: new JsonlFileSink({ dir: '/var/lib/ocp/audit' }),
});

audit.emit({
  type: 'pair.claim',
  sid,
  device: { name: claim.device.name, platform: claim.device.platform },
  attestation: claim.device.attested ? 'verified' : 'unverified',
  via: claim.via,
});

The AuditEvent union covers the §14 list plus session.open/session.close, because a trail that cannot bracket a session's lifetime cannot answer the first question an auditor asks. It is content-free by construction: no event has a field that could carry a question or an answer, and there is no event type for a turn at all. The only free strings name things — a tool, a device — none quote what was said. That is enforced by the type system rather than by a logging guideline, because the trail and the transcript sit under opposite retention pressures: content wants deleting as early as policy allows, the trail wants keeping for years, and one record cannot obey both schedules.

Two sinks ship, both dependency-free:

  • JsonlFileSink — one JSON line per event, one file per UTC day (audit-YYYY-MM-DD.jsonl). Call rotate(now) on a timer (hourly is fine); it releases the previous day's file and sweeps files past retention. Retention defaults to 400 days: EU AI Act Art. 26 sets a six-month FLOOR for the logs a deployer keeps, FINMA documentation expectations favor materially longer, and 400 days covers a full annual audit cycle with slack while remaining a bounded, stated period rather than "forever".
  • LogSink — adapts any pino-like logger (logger.info(obj, msg)), for deployments that already ship structured logs to a SIEM. The event rides under the stable audit key, so saved queries survive upgrades.

What each event evidences

EventEvidencesRegulatory hook
session.open / session.closethe lifetime over which logging ran, and why it endedEU AI Act Art. 12 — automatic recording of events over the system's lifetime
pair.start / pair.claim / pair.approve / pair.deny / pair.revokewho was delegated access, from which device, attested or not, and on whose approvalEU AI Act Art. 12; FINMA 08/2024 — accountability for who acted with what authority
companion.link / companion.takeoverwhich device held the session, and that no takeover happened without Surface consentFINMA 08/2024 — inventory and documentation of system access
tool.invokeevery action attempt and its authorization decision, denials includedEU AI Act Art. 12; Art. 26 — deployer oversight of use
confirm.resolvehuman oversight actually exercised: who answered, and that silence denied (by: 'expiry')EU AI Act Art. 26 — deployer keeps automatically generated logs ≥ 6 months
all of the abovethe processing record a DPIA draws on (docs/dpia-template.md)nFADP Art. 22

This mapping is an engineering aid — it says which record answers which obligation. It is not legal advice, and emitting these events does not make a deployment compliant: retention policy, access control on the trail itself, and the DPIA remain the deployer's work.

What is deliberately absent

  • Answer generation. The Hub either runs a provider or relays to a paired Companion that runs one. Which happens is the host's decision.
  • Tool execution. The Hub brokers and authorizes; it does not execute.
  • Identity. OcpSession<P> carries a host-defined principal the protocol never inspects, and a tenantId whose meaning is yours.

Attribution and long sessions

Both are off unless you wire them, and both are easy to half-implement.

PrincipalPolicy (§7) is shaped exactly like AttestationPolicy, for the same reason: this package cannot know your identity provider, so the check is yours. A principal on a claim is an assertion by an unauthenticated caller — with no verifyPrincipal wired it is DROPPED, and the pairing succeeds as the anonymous device pairing it actually is. requirePrincipal is how a deployment that needs attribution says so, at the QR door and the typed-code door alike. The verified subject is minted into the companion token, so it survives a reconnect and a refresh and is read back off your own signature; pass it to refreshCompanionToken, or a session de-attributes itself at the fifteen-minute mark.

session.longSession (§5.4) moves the four-hour bound rather than raising it. Set it from relay.negotiate(), which settles it only when BOTH peers offered. The sweeper then treats four hours as the life of an epoch: it drops the companion socket and requires a reconnect, which is what runs a fresh PoP over the current token hash — your connect path already does this, so there is no second code path to keep honest. No re-proof inside the grace window closes the session. The sid survives, which is the entire purpose: an audit trail that fragments every four hours records this protocol's bookkeeping rather than the work.

Storage and scale

Every control here is either backed by a shared store or scoped to one process, and the difference is not cosmetic: a per-process control behind a load balancer raises no error and logs nothing — it simply stops holding, and the fleet size becomes part of your security posture.

Shared (Redis) — must be, and are.

ControlWhat breaks per-process
Signing keys (loadOrCreateKeys)Each instance mints its own keypair, so a QR signed by one fails verification against the JWKS served by another. Pairing fails for roughly 1 − 1/N of scans, and /.well-known/ocp-jwks.json publishes whichever key the answering instance happens to hold.
Nonce burn (OcpPairingService.claim)The QR stops being single-use: a photographed code can be claimed once per instance, because each burn is a compare-and-set against a store only that instance can see.
Pairing rate limit (createRedisRateLimiter)The budget is enforced N times over, so the real ceiling is N × what you configured and it moves whenever the fleet scales. This is the nonce-guessing control as much as the DoS one — a 144-bit nonce is only unguessable per attempt.
Embed quotas (QuotaStore)A visitor gets N × their hourly turns, and the site owner finds out from the bill.

createRedisRateLimiter runs INCR and a conditional PEXPIRE in one script, so the two cannot interleave with another instance's; the nonce burn relies on the same atomicity for its compare-and-set. Redis' own key expiry is the window boundary, so no instance's clock has to agree with any other's.

The in-memory implementations — createMemoryRateLimiter, createMemoryQuotaStorerefuse to construct when NODE_ENV is production (or production: true is passed) unless the deployment also passes singleNode: true. Single-node Hubs are a legitimate deployment; the flag only makes it a decision rather than an accident. Runtimes without a node-style env report nothing to the default check, so a public deployment on Workers or Deno should pass production: true explicitly.

Per-process — and staying that way.

ControlWhat that costs you
Session state (OcpSessionService)It holds live WebSocket objects, which cannot be moved between processes. Resume after a companion drop only works against the instance that owns the socket, so a multi-instance Hub needs sticky routing by sid.
Pending pairing claimsclaim and resolveClaim must reach the same instance, since approval is pushed over the Surface's live socket. Sticky routing by sid covers this too; alternatively, publish the approval outcome to Redis and let the Companion poll any instance for it.
Embed barge-in registryA second turn only aborts the first when both land on the same instance. Without sticky routing the visitor pays for an abandoned stream instead of cancelling it.

Status

The WSS half was extracted from a Hub running in production, so the code is exercised; the package boundary is new. OcpSession.tenantId and OcpSession.principal are the generalisations made during that extraction — they were one product's workspace and ACL fields.

The embed half has unit coverage but has never been exercised in a real browser.