@byok-sdk/server 0.12.0 → 0.14.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/dist/auth.d.ts DELETED
@@ -1,218 +0,0 @@
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 literal lives in `@byok-sdk/core` (`src/pairing.ts`) and the client signs
20
- * the same binding (`packages/client/src/daemon/device-keys.ts`) — it used to
21
- * be three copies, each commented as byte-identical to the other two, which is
22
- * an agreement that holds only until someone edits one. Re-exported here so
23
- * this module's public surface is unchanged.
24
- *
25
- * There is deliberately no dual mode: a raw, unprefixed nonce signature is
26
- * simply invalid here, with no flag, fallback, or grace window that would
27
- * make the old encoding acceptable again. Because the four packages have no
28
- * published compatibility contract yet, the recovery path for a device on
29
- * the old encoding is a re-pair, not a server-side shim.
30
- */
31
- export { NONCE_SIGNING_DOMAIN } from '@byok-sdk/core';
32
- /**
33
- * S1: server-local tenant identifier. A plain string alias for now — S2 moves
34
- * the branded/shared form into `@byok-sdk/core`, which does not exist yet, and
35
- * depending on an unbuilt package would be worse than naming the concept
36
- * here. What matters at this stage is that every identity-carrying shape in
37
- * this package names the tenant explicitly and required, never optional.
38
- */
39
- export type TenantId = string;
40
- /**
41
- * S1: an access token binds a device to the tenant AND product its row was
42
- * paired into. All three are required — there is no tenant-less token shape.
43
- * These are LOOKUP KEYS, not authority: `authenticateBearer` resolves them
44
- * against the device registry and answers with the ROW's identity (see
45
- * {@link AuthenticatedDevice}).
46
- */
47
- export interface AccessTokenClaims {
48
- deviceId: string;
49
- tenantId: TenantId;
50
- productId: string;
51
- }
52
- export interface TokenSigner {
53
- sign(claims: AccessTokenClaims, expiresInSeconds: number): Promise<string>;
54
- /** Returns the claims for a valid, unexpired token, or `undefined` if invalid/expired/malformed. */
55
- verify(token: string): Promise<AccessTokenClaims | undefined>;
56
- }
57
- /** Default {@link TokenSigner}: HS256 over a random 32-byte secret held in memory for this process's lifetime. */
58
- export declare function createHmacTokenSigner(secret?: Uint8Array): TokenSigner;
59
- /** Mint a fresh access token + its ISO-8601 expiry, per {@link ACCESS_TOKEN_TTL_SECONDS}. */
60
- export declare function mintAccessToken(signer: TokenSigner, claims: AccessTokenClaims): Promise<{
61
- accessToken: string;
62
- expiresAt: string;
63
- }>;
64
- export interface DeviceRecord {
65
- /** S1: the tenant this device was paired into. Comes from the pairing-code claims and is never client-supplied. */
66
- tenantId: TenantId;
67
- /** S1: the product this device was paired into — checked against `conn.hello.productId` and against every token's claims. */
68
- productId: string;
69
- deviceId: string;
70
- deviceName: string;
71
- /** Ed25519 public key, base64url-encoded (JWK `x` form — see {@link verifyEd25519Signature}). */
72
- devicePublicKey: string;
73
- }
74
- /**
75
- * Everything `POST /byok/pair` knows at registration time — which is the whole
76
- * row. Revocation DELETES the registration (§6.3), so there is no lifecycle
77
- * flag for the registry to own on top of what pairing supplies.
78
- */
79
- export type DeviceRegistration = DeviceRecord;
80
- export declare class DeviceRegistry {
81
- /** Keyed by {@link DeviceRegistry.key} — `(tenantId, deviceId)`. */
82
- private readonly devices;
83
- /**
84
- * Secondary index over the SAME record objects, for the two pre-tenant
85
- * endpoints only (see {@link resolveByDeviceId}). It holds the same record
86
- * objects rather than copies, and {@link revoke} removes the entry here in
87
- * the same call that removes the composite-key one — a stale entry left
88
- * behind would be a deleted device that can still get a token.
89
- */
90
- private readonly byDeviceId;
91
- private static key;
92
- /**
93
- * Write a device row. Every identity field is required by
94
- * {@link DeviceRegistration}, so a row with no tenant cannot be constructed
95
- * — which is the whole point of S1.
96
- */
97
- register(device: DeviceRegistration): void;
98
- /** The row `tenantId` owns under `deviceId`, or `undefined` — including when the device exists under a DIFFERENT tenant. */
99
- get(tenantId: TenantId, deviceId: string): DeviceRecord | undefined;
100
- /**
101
- * Revoke a device (public API via `createByokServer(...).devices.revoke`).
102
- * Revocation DELETES the registration (docs/protocol.md §6.3): afterwards
103
- * the device id is byte-for-byte one that was never registered — absent
104
- * from {@link list}, resolving to nothing on the pre-tenant
105
- * `/byok/challenge` and `/byok/token` paths, and a 401 on every authed
106
- * surface. The daemon's only recourse is to re-run `/byok/pair`.
107
- *
108
- * There is deliberately no retained `revoked` row: a flag every read path
109
- * has to remember to exclude is a second way to represent "not a
110
- * principal", and the first read path that forgets it is a live credential.
111
- *
112
- * A tenant can only revoke its own devices: a (tenantId, deviceId) pair it
113
- * does not own resolves to nothing, deletes nothing, and is silently
114
- * indistinguishable from revoking one that never existed.
115
- *
116
- * Returns whether a row was actually removed — the composition in
117
- * `index.ts` uses it to delete the device-scoped state that only existed to
118
- * serve that row (nonces, presence, dedup), so a no-op revoke touches
119
- * nothing at all.
120
- */
121
- revoke(tenantId: TenantId, deviceId: string): boolean;
122
- /** Every known device row, across tenants — the in-process read model behind `ByokServer.machines.list()`. */
123
- list(): DeviceRecord[];
124
- /**
125
- * Resolve a device by its globally-unique id alone, WITHOUT a tenant in
126
- * scope. Exists for exactly two callers — `POST /byok/challenge` and
127
- * `POST /byok/token` — because those two carry no tenant at all: their
128
- * request DTOs are the pinned wire contract (docs/protocol.md §6.2), the
129
- * device authenticates by key possession, and the row itself is what tells
130
- * the server which tenant to mint the next token for. Everything with a
131
- * token (and therefore a tenant) in scope goes through {@link get}.
132
- *
133
- * Deliberately NOT re-exported from this package's entry point (`index.ts`
134
- * exports no naked-lookup surface at all), so no embedder can turn it into
135
- * a cross-tenant device oracle: the only reachable public device surface is
136
- * tenant-first.
137
- */
138
- resolveByDeviceId(deviceId: string): DeviceRecord | undefined;
139
- }
140
- export declare class NonceStore {
141
- private readonly nonces;
142
- /** Number of nonce records currently held (post-sweep). Exposed for tests only. */
143
- get size(): number;
144
- /**
145
- * Drop every used or expired record. A long-lived server never calls this
146
- * on a timer, so `issue()` sweeps inline — a full-Map scan is fine at
147
- * reference-impl scale (single-digit nonces per device, ~5min TTL).
148
- */
149
- private sweep;
150
- issue(deviceId: string): string;
151
- /** `true` iff `nonce` exists, belongs to `deviceId`, is unexpired, and hasn't been consumed yet. Does not mutate. */
152
- validate(deviceId: string, nonce: string): boolean;
153
- /**
154
- * Drop every nonce outstanding for `deviceId` — called when the device's
155
- * registration is deleted (§6.3 revocation). An unspent challenge is state
156
- * that only existed to serve a row the directory can no longer name, so it
157
- * goes with the row rather than sitting until its TTL sweeps it.
158
- */
159
- deleteForDevice(deviceId: string): void;
160
- /** Mark `nonce` consumed so a replay of the same (deviceId, nonce, signature) is rejected. */
161
- markUsed(nonce: string): void;
162
- }
163
- /**
164
- * The ONLY nonce-signature check on this server (§6.2): the signed message is
165
- * core's `nonceSigningBytes` — the domain followed by the nonce. Applying the
166
- * domain here rather than at the call site is the point — there is one place
167
- * that decides what a device signature over a nonce means, so no route can be
168
- * written that accepts the undomained form.
169
- */
170
- export declare function verifyNonceSignature(devicePublicKey: string, nonce: string, signature: string): boolean;
171
- export declare function extractBearerToken(header: string | undefined): string | undefined;
172
- export interface AuthDeps {
173
- tokenSigner: TokenSigner;
174
- devices: DeviceRegistry;
175
- /**
176
- * The product THIS server instance serves (`createByokServer`'s
177
- * `productId`). Part of authentication, not of routing: a device row paired
178
- * into another product is not a principal here at all — see
179
- * {@link authenticateBearer}.
180
- */
181
- productId: string;
182
- }
183
- /**
184
- * S1: the authenticated principal every authed surface works with. Built from
185
- * the DEVICE ROW, never from the token payload — the token's claims are only
186
- * the keys used to find that row (see {@link authenticateBearer}). A caller
187
- * holding one of these is holding identity the registry vouched for.
188
- */
189
- export interface AuthenticatedDevice {
190
- deviceId: string;
191
- tenantId: TenantId;
192
- productId: string;
193
- }
194
- /**
195
- * Resolve an `Authorization: Bearer <jwt>` header to an {@link AuthenticatedDevice},
196
- * or `undefined` — the single check every authed HTTP route and the WSS
197
- * upgrade share.
198
- *
199
- * S1 shape: the token's `(tenantId, deviceId)` are LOOKUP KEYS into the
200
- * registry, and the row that comes back is the authority. A token for a
201
- * device that no longer exists, one whose tenant does not own that device,
202
- * one whose product disagrees with the row, and one whose row belongs to a
203
- * different product than this instance serves all fail identically here and
204
- * are indistinguishable to the caller — a revoked device is exactly the first
205
- * of those, since revocation deleted its row (§6.3) — there
206
- * is deliberately no "which of those was it" signal to hand back, so no route
207
- * can turn a 401 into a cross-tenant (or cross-product) existence oracle.
208
- *
209
- * The last two checks are different facts and both are needed. Row vs claims
210
- * says "the token belongs to this row"; row vs instance says "this row
211
- * belongs to the product this server serves" — a single server can mint
212
- * pairing codes for any product (`createPairingCode` takes the claims per
213
- * code), so a row from another product is a real row holding a real token
214
- * and is still not a principal here. `conn.hello`'s own product checks
215
- * (`ws-server.ts`) validate the client's ANNOUNCEMENT, which is a third fact
216
- * and stays where it is.
217
- */
218
- export declare function authenticateBearer(header: string | undefined, deps: AuthDeps): Promise<AuthenticatedDevice | undefined>;
@@ -1,89 +0,0 @@
1
- import type { TenantId } from './auth';
2
- /**
3
- * Blob flows (docs/protocol.md §7): `POST /byok/blobs` declares a blob and
4
- * gets back a presigned upload URL; the caller `PUT`s the bytes there
5
- * directly (no bearer auth on that URL — the HMAC signature + expiry *is*
6
- * the auth); `GET /byok/blobs/:id/url` mints a presigned download URL the
7
- * same way. `BlobRef` itself (`@byok-sdk/protocol`'s `blob.ts`) is unchanged;
8
- * this module is what produces the URLs a `BlobRef` points at.
9
- *
10
- * `BlobStore` is interface-shaped so a SaaS can swap in a real object-store
11
- * (S3/GCS/R2 presigned URLs) later; {@link LocalDiskBlobStore} is the M1
12
- * reference implementation (single-process, persisted metadata + files on
13
- * disk) — good enough for local dev and the SDK's own tests, including a
14
- * restart of the same directory; it is not multi-process storage.
15
- */
16
- export interface CreateUploadInput {
17
- size: number;
18
- contentType: string;
19
- /** Content-addressed hash the server verifies the uploaded bytes against (§7). Reference impl assumes hex-encoded SHA-256. */
20
- contentHash: string;
21
- }
22
- export type WriteContentResult = {
23
- ok: true;
24
- } | {
25
- ok: false;
26
- reason: string;
27
- };
28
- export interface ReadContentResult {
29
- data: Buffer;
30
- contentType: string;
31
- }
32
- /** The upload reservation HTTP must resolve before it starts retaining bytes. */
33
- export interface BlobUploadReservation {
34
- size: number;
35
- }
36
- export declare class BlobDeclarationConflictError extends Error {
37
- constructor(blobId: string);
38
- }
39
- export interface BlobStore {
40
- /** Declare a blob before upload; an explicit id makes the declaration idempotent across host restart. */
41
- createUpload(tenantId: TenantId, input: CreateUploadInput, blobId?: string): Promise<{
42
- blobId: string;
43
- uploadUrl: string;
44
- }>;
45
- /** A presigned GET URL for a blob that has finished uploading, or `undefined` if unknown/not yet uploaded. */
46
- getDownloadUrl(tenantId: TenantId, blobId: string): Promise<string | undefined>;
47
- /** Whether `blobId` is known *and* has finished uploading. */
48
- exists(tenantId: TenantId, blobId: string): Promise<boolean>;
49
- /** Resolve a capability URL's immutable declared size before consuming its body. */
50
- getUploadReservation(blobId: string): Promise<BlobUploadReservation | undefined>;
51
- /** Verify a presigned content URL's `sig`/`exp` query params for `action`. */
52
- verifySignedUrl(blobId: string, action: 'put' | 'get', sig: string, exp: number): boolean;
53
- /** Accept uploaded bytes; rejects (without storing) on size/hash mismatch against the `createUpload` declaration. */
54
- writeContent(blobId: string, data: Buffer): Promise<WriteContentResult>;
55
- /** Read back previously-uploaded bytes, or `undefined` if unknown/not yet uploaded. */
56
- readContent(blobId: string): Promise<ReadContentResult | undefined>;
57
- }
58
- export interface LocalDiskBlobStoreOptions {
59
- /** Directory blob content is written under. Defaults to a fresh OS temp dir. */
60
- directory?: string;
61
- /** How long a presigned upload/download URL stays valid, ms. Default 15 minutes. */
62
- urlTtlMs?: number;
63
- }
64
- /** Local-disk reference {@link BlobStore}: persisted metadata/content and HMAC-signed expiring URLs. */
65
- export declare class LocalDiskBlobStore implements BlobStore {
66
- private secret;
67
- private readonly directory;
68
- private readonly metadataPath;
69
- private readonly urlTtlMs;
70
- private readonly blobs;
71
- private readonly ready;
72
- private metadataWriteTail;
73
- constructor(opts?: LocalDiskBlobStoreOptions);
74
- createUpload(tenantId: TenantId, input: CreateUploadInput, requestedBlobId?: string): Promise<{
75
- blobId: string;
76
- uploadUrl: string;
77
- }>;
78
- getDownloadUrl(tenantId: TenantId, blobId: string): Promise<string | undefined>;
79
- exists(tenantId: TenantId, blobId: string): Promise<boolean>;
80
- getUploadReservation(blobId: string): Promise<BlobUploadReservation | undefined>;
81
- verifySignedUrl(blobId: string, action: 'put' | 'get', sig: string, exp: number): boolean;
82
- writeContent(blobId: string, data: Buffer): Promise<WriteContentResult>;
83
- readContent(blobId: string): Promise<ReadContentResult | undefined>;
84
- private pathFor;
85
- private loadMetadata;
86
- private persistMetadata;
87
- private computeSig;
88
- private signUrl;
89
- }
@@ -1,30 +0,0 @@
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 DELETED
@@ -1,24 +0,0 @@
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;