@byok-sdk/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ancienttwo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # @byok-sdk/server
2
+
3
+ The self-hosted SaaS-side reference coordinator: pairing, authenticated device
4
+ HTTP/WebSocket/long-poll transport, task leasing, approvals, and in-memory
5
+ stores over the frozen v1 protocol.
6
+
7
+ Use `@byok-sdk/cloud` plus `@byok-sdk/cloud-postgres` for the durable hosted
8
+ composition.
9
+
10
+ MIT licensed. Node.js 20 or newer.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Auth v2 (docs/protocol.md §6): device identity (Ed25519 keypair, public
3
+ * half registered at pairing time), single-use nonce challenge/response for
4
+ * token renewal, and JWT access tokens. Kept separate from `pairing.ts`
5
+ * (which now only owns the one-time pairing-code lifecycle) because these
6
+ * concerns span every authed surface (WSS upgrade, blob routes, events
7
+ * long-poll), not just `POST /byok/pair`.
8
+ */
9
+ /** Access tokens are JWTs with a ~1h lifetime (docs/protocol.md §6.1/§6.2). */
10
+ export declare const ACCESS_TOKEN_TTL_SECONDS: number;
11
+ /**
12
+ * S1 (GAP-004): the domain-separation prefix a device signs along with a
13
+ * challenge nonce. The device key is a long-lived identity key that later
14
+ * planes (S6 device proof) will also sign structured messages with; without a
15
+ * domain tag, a signature produced for one purpose is a valid signature for
16
+ * another, and an attacker who can get a device to sign anything shaped like
17
+ * a nonce holds a token-renewal credential.
18
+ *
19
+ * The client signs the same literal (`packages/client/src/daemon/device-keys.ts`).
20
+ * There is deliberately no dual mode: a raw, unprefixed nonce signature is
21
+ * simply invalid here, with no flag, fallback, or grace window that would
22
+ * make the old encoding acceptable again. Because the four packages have no
23
+ * published compatibility contract yet, the recovery path for a device on
24
+ * the old encoding is a re-pair, not a server-side shim.
25
+ */
26
+ export declare const NONCE_SIGNING_DOMAIN = "byok-nonce-v1\n";
27
+ /**
28
+ * S1: server-local tenant identifier. A plain string alias for now — S2 moves
29
+ * the branded/shared form into `@byok-sdk/core`, which does not exist yet, and
30
+ * depending on an unbuilt package would be worse than naming the concept
31
+ * here. What matters at this stage is that every identity-carrying shape in
32
+ * this package names the tenant explicitly and required, never optional.
33
+ */
34
+ export type TenantId = string;
35
+ /**
36
+ * S1: an access token binds a device to the tenant AND product its row was
37
+ * paired into. All three are required — there is no tenant-less token shape.
38
+ * These are LOOKUP KEYS, not authority: `authenticateBearer` resolves them
39
+ * against the device registry and answers with the ROW's identity (see
40
+ * {@link AuthenticatedDevice}).
41
+ */
42
+ export interface AccessTokenClaims {
43
+ deviceId: string;
44
+ tenantId: TenantId;
45
+ productId: string;
46
+ }
47
+ export interface TokenSigner {
48
+ sign(claims: AccessTokenClaims, expiresInSeconds: number): Promise<string>;
49
+ /** Returns the claims for a valid, unexpired token, or `undefined` if invalid/expired/malformed. */
50
+ verify(token: string): Promise<AccessTokenClaims | undefined>;
51
+ }
52
+ /** Default {@link TokenSigner}: HS256 over a random 32-byte secret held in memory for this process's lifetime. */
53
+ export declare function createHmacTokenSigner(secret?: Uint8Array): TokenSigner;
54
+ /** Mint a fresh access token + its ISO-8601 expiry, per {@link ACCESS_TOKEN_TTL_SECONDS}. */
55
+ export declare function mintAccessToken(signer: TokenSigner, claims: AccessTokenClaims): Promise<{
56
+ accessToken: string;
57
+ expiresAt: string;
58
+ }>;
59
+ export interface DeviceRecord {
60
+ /** S1: the tenant this device was paired into. Comes from the pairing-code claims and is never client-supplied. */
61
+ tenantId: TenantId;
62
+ /** S1: the product this device was paired into — checked against `conn.hello.productId` and against every token's claims. */
63
+ productId: string;
64
+ deviceId: string;
65
+ deviceName: string;
66
+ /** Ed25519 public key, base64url-encoded (JWK `x` form — see {@link verifyEd25519Signature}). */
67
+ devicePublicKey: string;
68
+ revoked: boolean;
69
+ }
70
+ /** Everything `POST /byok/pair` knows at registration time; `revoked` is the registry's own to set. */
71
+ export type DeviceRegistration = Omit<DeviceRecord, 'revoked'>;
72
+ export declare class DeviceRegistry {
73
+ /** Keyed by {@link DeviceRegistry.key} — `(tenantId, deviceId)`. */
74
+ private readonly devices;
75
+ /**
76
+ * Secondary index over the SAME record objects, for the two pre-tenant
77
+ * endpoints only (see {@link resolveByDeviceId}). Holding the same object
78
+ * reference means a revocation applied through the composite key is
79
+ * immediately visible here too — there is no second copy to keep in sync.
80
+ */
81
+ private readonly byDeviceId;
82
+ private static key;
83
+ /**
84
+ * Write a device row. Every identity field is required by
85
+ * {@link DeviceRegistration}, so a row with no tenant cannot be constructed
86
+ * — which is the whole point of S1.
87
+ */
88
+ register(device: DeviceRegistration): void;
89
+ /** The row `tenantId` owns under `deviceId`, or `undefined` — including when the device exists under a DIFFERENT tenant. */
90
+ get(tenantId: TenantId, deviceId: string): DeviceRecord | undefined;
91
+ /**
92
+ * Revoke a device (public API via `createByokServer(...).devices.revoke`).
93
+ * Its next `/byok/challenge`, `/byok/token`, WSS connect, or authed HTTP
94
+ * call gets a 401; the daemon's only recourse is to re-run `/byok/pair`
95
+ * (docs/protocol.md §6.3). A tenant can only revoke its own devices: a
96
+ * (tenantId, deviceId) pair it does not own resolves to nothing and this is
97
+ * a no-op.
98
+ */
99
+ revoke(tenantId: TenantId, deviceId: string): void;
100
+ /** Every known device row, across tenants — the in-process read model behind `ByokServer.machines.list()`. */
101
+ list(): DeviceRecord[];
102
+ /**
103
+ * Resolve a device by its globally-unique id alone, WITHOUT a tenant in
104
+ * scope. Exists for exactly two callers — `POST /byok/challenge` and
105
+ * `POST /byok/token` — because those two carry no tenant at all: their
106
+ * request DTOs are the pinned wire contract (docs/protocol.md §6.2), the
107
+ * device authenticates by key possession, and the row itself is what tells
108
+ * the server which tenant to mint the next token for. Everything with a
109
+ * token (and therefore a tenant) in scope goes through {@link get}.
110
+ *
111
+ * Deliberately NOT re-exported from this package's entry point (`index.ts`
112
+ * exports no naked-lookup surface at all), so no embedder can turn it into
113
+ * a cross-tenant device oracle: the only reachable public device surface is
114
+ * tenant-first.
115
+ */
116
+ resolveByDeviceId(deviceId: string): DeviceRecord | undefined;
117
+ }
118
+ export declare class NonceStore {
119
+ private readonly nonces;
120
+ /** Number of nonce records currently held (post-sweep). Exposed for tests only. */
121
+ get size(): number;
122
+ /**
123
+ * Drop every used or expired record. A long-lived server never calls this
124
+ * on a timer, so `issue()` sweeps inline — a full-Map scan is fine at
125
+ * reference-impl scale (single-digit nonces per device, ~5min TTL).
126
+ */
127
+ private sweep;
128
+ issue(deviceId: string): string;
129
+ /** `true` iff `nonce` exists, belongs to `deviceId`, is unexpired, and hasn't been consumed yet. Does not mutate. */
130
+ validate(deviceId: string, nonce: string): boolean;
131
+ /** Mark `nonce` consumed so a replay of the same (deviceId, nonce, signature) is rejected. */
132
+ markUsed(nonce: string): void;
133
+ }
134
+ /**
135
+ * The ONLY nonce-signature check on this server (§6.2): the signed message is
136
+ * {@link NONCE_SIGNING_DOMAIN} followed by the nonce. Applying the domain here
137
+ * rather than at the call site is the point — there is one place that decides
138
+ * what a device signature over a nonce means, so no route can be written that
139
+ * accepts the undomained form.
140
+ */
141
+ export declare function verifyNonceSignature(devicePublicKey: string, nonce: string, signature: string): boolean;
142
+ export declare function extractBearerToken(header: string | undefined): string | undefined;
143
+ export interface AuthDeps {
144
+ tokenSigner: TokenSigner;
145
+ devices: DeviceRegistry;
146
+ }
147
+ /**
148
+ * S1: the authenticated principal every authed surface works with. Built from
149
+ * the DEVICE ROW, never from the token payload — the token's claims are only
150
+ * the keys used to find that row (see {@link authenticateBearer}). A caller
151
+ * holding one of these is holding identity the registry vouched for.
152
+ */
153
+ export interface AuthenticatedDevice {
154
+ deviceId: string;
155
+ tenantId: TenantId;
156
+ productId: string;
157
+ }
158
+ /**
159
+ * Resolve an `Authorization: Bearer <jwt>` header to an {@link AuthenticatedDevice},
160
+ * or `undefined` — the single check every authed HTTP route and the WSS
161
+ * upgrade share.
162
+ *
163
+ * S1 shape: the token's `(tenantId, deviceId)` are LOOKUP KEYS into the
164
+ * registry, and the row that comes back is the authority. A token for a
165
+ * device that no longer exists, one whose tenant does not own that device,
166
+ * one whose product disagrees with the row, and one for a revoked device all
167
+ * fail identically here and are indistinguishable to the caller — there is
168
+ * deliberately no "which of those was it" signal to hand back, so no route
169
+ * can turn a 401 into a cross-tenant existence oracle.
170
+ */
171
+ export declare function authenticateBearer(header: string | undefined, deps: AuthDeps): Promise<AuthenticatedDevice | undefined>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Blob flows (docs/protocol.md §7): `POST /byok/blobs` declares a blob and
3
+ * gets back a presigned upload URL; the caller `PUT`s the bytes there
4
+ * directly (no bearer auth on that URL — the HMAC signature + expiry *is*
5
+ * the auth); `GET /byok/blobs/:id/url` mints a presigned download URL the
6
+ * same way. `BlobRef` itself (`@byok-sdk/protocol`'s `blob.ts`) is unchanged;
7
+ * this module is what produces the URLs a `BlobRef` points at.
8
+ *
9
+ * `BlobStore` is interface-shaped so a SaaS can swap in a real object-store
10
+ * (S3/GCS/R2 presigned URLs) later; {@link LocalDiskBlobStore} is the M1
11
+ * reference implementation (single-process, in-memory metadata + files on
12
+ * disk) — good enough for local dev and the SDK's own tests, not meant to
13
+ * survive a restart or run multi-process.
14
+ */
15
+ export interface CreateUploadInput {
16
+ size: number;
17
+ contentType: string;
18
+ /** Content-addressed hash the server verifies the uploaded bytes against (§7). Reference impl assumes hex-encoded SHA-256. */
19
+ contentHash: string;
20
+ }
21
+ export type WriteContentResult = {
22
+ ok: true;
23
+ } | {
24
+ ok: false;
25
+ reason: string;
26
+ };
27
+ export interface ReadContentResult {
28
+ data: Buffer;
29
+ contentType: string;
30
+ }
31
+ export declare class BlobDeclarationConflictError extends Error {
32
+ constructor(blobId: string);
33
+ }
34
+ export interface BlobStore {
35
+ /** Declare a blob before upload; an explicit id makes the declaration idempotent across host restart. */
36
+ createUpload(input: CreateUploadInput, blobId?: string): Promise<{
37
+ blobId: string;
38
+ uploadUrl: string;
39
+ }>;
40
+ /** A presigned GET URL for a blob that has finished uploading, or `undefined` if unknown/not yet uploaded. */
41
+ getDownloadUrl(blobId: string): Promise<string | undefined>;
42
+ /** Whether `blobId` is known *and* has finished uploading. */
43
+ exists(blobId: string): Promise<boolean>;
44
+ /** Verify a presigned content URL's `sig`/`exp` query params for `action`. */
45
+ verifySignedUrl(blobId: string, action: 'put' | 'get', sig: string, exp: number): boolean;
46
+ /** Accept uploaded bytes; rejects (without storing) on size/hash mismatch against the `createUpload` declaration. */
47
+ writeContent(blobId: string, data: Buffer): Promise<WriteContentResult>;
48
+ /** Read back previously-uploaded bytes, or `undefined` if unknown/not yet uploaded. */
49
+ readContent(blobId: string): Promise<ReadContentResult | undefined>;
50
+ }
51
+ export interface LocalDiskBlobStoreOptions {
52
+ /** Directory blob content is written under. Defaults to a fresh OS temp dir. */
53
+ directory?: string;
54
+ /** How long a presigned upload/download URL stays valid, ms. Default 15 minutes. */
55
+ urlTtlMs?: number;
56
+ }
57
+ /** Local-disk reference {@link BlobStore}: in-memory metadata, content on disk, HMAC-signed expiring URLs. */
58
+ export declare class LocalDiskBlobStore implements BlobStore {
59
+ private readonly secret;
60
+ private readonly directory;
61
+ private readonly urlTtlMs;
62
+ private readonly blobs;
63
+ private readonly ready;
64
+ constructor(opts?: LocalDiskBlobStoreOptions);
65
+ createUpload(input: CreateUploadInput, requestedBlobId?: string): Promise<{
66
+ blobId: string;
67
+ uploadUrl: string;
68
+ }>;
69
+ getDownloadUrl(blobId: string): Promise<string | undefined>;
70
+ exists(blobId: string): Promise<boolean>;
71
+ verifySignedUrl(blobId: string, action: 'put' | 'get', sig: string, exp: number): boolean;
72
+ writeContent(blobId: string, data: Buffer): Promise<WriteContentResult>;
73
+ readContent(blobId: string): Promise<ReadContentResult | undefined>;
74
+ private pathFor;
75
+ private computeSig;
76
+ private signUrl;
77
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A tiny append-only, multi-reader async queue. `push` never blocks; `close`
3
+ * marks the queue done. `subscribe()` returns a fresh async iterator that
4
+ * always replays from the beginning of the buffer, so a consumer that calls
5
+ * `events()` at any point still "sees everything" for that task's lifetime.
6
+ *
7
+ * Framework-agnostic on purpose (no Node/WS/Hono types here) so it can be
8
+ * unit-tested and reused regardless of transport.
9
+ */
10
+ export declare class AsyncEventQueue<T> {
11
+ private readonly buffer;
12
+ private closed;
13
+ private waiters;
14
+ push(value: T): void;
15
+ close(): void;
16
+ private wake;
17
+ private waitForMore;
18
+ /** Async-iterate the buffer from index 0, waiting for new pushes until closed. */
19
+ subscribe(): AsyncIterable<T>;
20
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * WS-native ping/pong liveness check (pinned here per the M1-2 task brief,
3
+ * not in docs/protocol.md — this is a server implementation detail, not a
4
+ * wire message). The server pings every `intervalMs` (default 30s);
5
+ * `ws`'s WebSocket automatically replies to a protocol-level ping with a
6
+ * protocol-level pong on any spec-compliant peer, so a healthy connection
7
+ * just keeps ticking. After `maxMissedPongs` (default 2) consecutive
8
+ * unanswered pings, the connection is terminated.
9
+ *
10
+ * Deliberately decoupled from the real `ws` package's `WebSocket` type (only
11
+ * the four methods below are used) so the scheduling logic can be unit
12
+ * tested with a plain stub + fake timers instead of a real socket — a real
13
+ * peer auto-pongs, so there's no way to exercise "missed pong" through an
14
+ * actual WS round-trip in a test.
15
+ */
16
+ export interface HeartbeatSocket {
17
+ ping(): void;
18
+ terminate(): void;
19
+ on(event: 'pong', listener: () => void): unknown;
20
+ off(event: 'pong', listener: () => void): unknown;
21
+ }
22
+ export interface HeartbeatOptions {
23
+ intervalMs?: number;
24
+ maxMissedPongs?: number;
25
+ }
26
+ export interface Heartbeat {
27
+ /** Stop the ping timer and detach the pong listener (e.g. on normal connection close). */
28
+ stop(): void;
29
+ }
30
+ export declare function startHeartbeat(ws: HeartbeatSocket, opts?: HeartbeatOptions): Heartbeat;
package/dist/http.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { Hono } from 'hono';
2
+ import { type AuthDeps, type NonceStore } from './auth';
3
+ import { type BlobStore } from './blob-store';
4
+ import type { ConnectionHub } from './hub';
5
+ import { type PairingManager } from './pairing';
6
+ export interface HttpDeps extends AuthDeps {
7
+ pairing: PairingManager;
8
+ nonces: NonceStore;
9
+ blobStore: BlobStore;
10
+ hub: ConnectionHub;
11
+ /** Per-product blob size ceiling in bytes (§7). */
12
+ maxBlobSizeBytes: number;
13
+ /** How long `GET /byok/events` holds an empty poll open before returning, ms (§8). */
14
+ longPollHoldMs: number;
15
+ /** M4 Phase 4 (part B.2): opt-in `GET /healthz` liveness route — see `CreateByokServerOptions.healthzRoute`'s doc comment (`types.ts`) for the full contract. Default `false` (no route mounted). */
16
+ healthzRoute?: boolean;
17
+ }
18
+ /**
19
+ * The HTTP half of the pinned wire contract: pairing, token renewal, blob
20
+ * flows, and the long-poll events fallback (docs/protocol.md §6-§8). WS
21
+ * upgrade handling lives in `ws-server.ts` (raw Node `http.Server` upgrade,
22
+ * not routable through Hono's fetch handler).
23
+ */
24
+ export declare function buildHonoApp(deps: HttpDeps): Hono;