@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.createRelayRouterhandles this; a Hub with its own routing must callforwardedPayloaditself. - It settles
session.linked.sealMode, and only to'e2e'when both peers offeredcapabilities.sealand 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 search → get_price →
book_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). Callrotate(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 stableauditkey, so saved queries survive upgrades.
What each event evidences
| Event | Evidences | Regulatory hook |
|---|---|---|
session.open / session.close | the lifetime over which logging ran, and why it ended | EU AI Act Art. 12 — automatic recording of events over the system's lifetime |
pair.start / pair.claim / pair.approve / pair.deny / pair.revoke | who was delegated access, from which device, attested or not, and on whose approval | EU AI Act Art. 12; FINMA 08/2024 — accountability for who acted with what authority |
companion.link / companion.takeover | which device held the session, and that no takeover happened without Surface consent | FINMA 08/2024 — inventory and documentation of system access |
tool.invoke | every action attempt and its authorization decision, denials included | EU AI Act Art. 12; Art. 26 — deployer oversight of use |
confirm.resolve | human 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 above | the 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-definedprincipalthe protocol never inspects, and atenantIdwhose 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.
| Control | What 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,
createMemoryQuotaStore — refuse 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.
| Control | What 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 claims | claim 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 registry | A 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.