@byok-sdk/server 0.10.1 → 0.10.2

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 CHANGED
@@ -70,18 +70,22 @@ export interface DeviceRecord {
70
70
  deviceName: string;
71
71
  /** Ed25519 public key, base64url-encoded (JWK `x` form — see {@link verifyEd25519Signature}). */
72
72
  devicePublicKey: string;
73
- revoked: boolean;
74
73
  }
75
- /** Everything `POST /byok/pair` knows at registration time; `revoked` is the registry's own to set. */
76
- export type DeviceRegistration = Omit<DeviceRecord, 'revoked'>;
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;
77
80
  export declare class DeviceRegistry {
78
81
  /** Keyed by {@link DeviceRegistry.key} — `(tenantId, deviceId)`. */
79
82
  private readonly devices;
80
83
  /**
81
84
  * Secondary index over the SAME record objects, for the two pre-tenant
82
- * endpoints only (see {@link resolveByDeviceId}). Holding the same object
83
- * reference means a revocation applied through the composite key is
84
- * immediately visible here too — there is no second copy to keep in sync.
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.
85
89
  */
86
90
  private readonly byDeviceId;
87
91
  private static key;
@@ -95,13 +99,26 @@ export declare class DeviceRegistry {
95
99
  get(tenantId: TenantId, deviceId: string): DeviceRecord | undefined;
96
100
  /**
97
101
  * Revoke a device (public API via `createByokServer(...).devices.revoke`).
98
- * Its next `/byok/challenge`, `/byok/token`, WSS connect, or authed HTTP
99
- * call gets a 401; the daemon's only recourse is to re-run `/byok/pair`
100
- * (docs/protocol.md §6.3). A tenant can only revoke its own devices: a
101
- * (tenantId, deviceId) pair it does not own resolves to nothing and this is
102
- * a no-op.
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.
103
120
  */
104
- revoke(tenantId: TenantId, deviceId: string): void;
121
+ revoke(tenantId: TenantId, deviceId: string): boolean;
105
122
  /** Every known device row, across tenants — the in-process read model behind `ByokServer.machines.list()`. */
106
123
  list(): DeviceRecord[];
107
124
  /**
@@ -133,6 +150,13 @@ export declare class NonceStore {
133
150
  issue(deviceId: string): string;
134
151
  /** `true` iff `nonce` exists, belongs to `deviceId`, is unexpired, and hasn't been consumed yet. Does not mutate. */
135
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;
136
160
  /** Mark `nonce` consumed so a replay of the same (deviceId, nonce, signature) is rejected. */
137
161
  markUsed(nonce: string): void;
138
162
  }
@@ -175,9 +199,10 @@ export interface AuthenticatedDevice {
175
199
  * S1 shape: the token's `(tenantId, deviceId)` are LOOKUP KEYS into the
176
200
  * registry, and the row that comes back is the authority. A token for a
177
201
  * device that no longer exists, one whose tenant does not own that device,
178
- * one whose product disagrees with the row, one whose row belongs to a
179
- * different product than this instance serves, and one for a revoked device
180
- * all fail identically here and are indistinguishable to the caller — there
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
181
206
  * is deliberately no "which of those was it" signal to hand back, so no route
182
207
  * can turn a 401 into a cross-tenant (or cross-product) existence oracle.
183
208
  *
package/dist/hub.d.ts CHANGED
@@ -333,6 +333,26 @@ export declare class ConnectionHub {
333
333
  * here (see the M1-2 report's contract-gap notes).
334
334
  */
335
335
  handleDisconnect(deviceId: string, ws: WebSocket): void;
336
+ /**
337
+ * Drop every piece of hub state keyed by `deviceId` — called when the
338
+ * device's registration is DELETED (§6.3 revocation, composed in
339
+ * `index.ts`). Presence, its undelivered outbox, its inbound-dedup ring,
340
+ * any held long-poll, and its rate-limit episode suppression only ever
341
+ * existed to serve a row the directory can no longer name; leaving them
342
+ * would keep a deleted device visible as live state and would let a
343
+ * re-paired device inherit the dead one's dedup window and seq cursor.
344
+ *
345
+ * What the device DID is not revocation's business and is untouched here:
346
+ * task records (`taskStore`), egress and content receipts, and projection
347
+ * facts are history, not credentials.
348
+ *
349
+ * A live socket is closed rather than left attached — a still-open WS whose
350
+ * next envelope would re-create the presence entry we just deleted is a
351
+ * half-applied revocation. The close is detached first so `handleDisconnect`
352
+ * sees no connection state and skips its disconnect bookkeeping: the device
353
+ * is not going dark, it is gone.
354
+ */
355
+ forgetDevice(deviceId: string): void;
336
356
  /**
337
357
  * Resolve immediately if there are already-relevant events past `cursor`;
338
358
  * otherwise hold for up to `holdMs` and resolve with an empty result if
package/dist/index.d.ts CHANGED
@@ -86,8 +86,12 @@ export interface ByokServer {
86
86
  };
87
87
  /**
88
88
  * Device revocation (§6.3) — server-side only, no wire message. Revoking a
89
- * device makes its next `/byok/challenge`, `/byok/token`, WSS connect, or
90
- * authed HTTP call get a 401; its only recourse is to re-run `/byok/pair`.
89
+ * device DELETES its registration, so its next `/byok/challenge`,
90
+ * `/byok/token`, WSS connect, or authed HTTP call gets a 401 — the same
91
+ * answer as for a device id that was never registered — and its only
92
+ * recourse is to re-run `/byok/pair`. The device-scoped state the row
93
+ * owned (outstanding challenge nonces, presence, inbound dedup) is deleted
94
+ * with it; what the device DID (tasks, receipts) is history and survives.
91
95
  *
92
96
  * S1: tenant-first, and a tenant can only revoke a device it owns — a
93
97
  * `(tenantId, deviceId)` pair belonging to someone else resolves to
package/dist/index.js CHANGED
@@ -41,9 +41,10 @@ var DeviceRegistry = class _DeviceRegistry {
41
41
  devices = /* @__PURE__ */ new Map();
42
42
  /**
43
43
  * Secondary index over the SAME record objects, for the two pre-tenant
44
- * endpoints only (see {@link resolveByDeviceId}). Holding the same object
45
- * reference means a revocation applied through the composite key is
46
- * immediately visible here too — there is no second copy to keep in sync.
44
+ * endpoints only (see {@link resolveByDeviceId}). It holds the same record
45
+ * objects rather than copies, and {@link revoke} removes the entry here in
46
+ * the same call that removes the composite-key one — a stale entry left
47
+ * behind would be a deleted device that can still get a token.
47
48
  */
48
49
  byDeviceId = /* @__PURE__ */ new Map();
49
50
  static key(tenantId, deviceId) {
@@ -55,7 +56,7 @@ var DeviceRegistry = class _DeviceRegistry {
55
56
  * — which is the whole point of S1.
56
57
  */
57
58
  register(device) {
58
- const record = { ...device, revoked: false };
59
+ const record = { ...device };
59
60
  this.devices.set(_DeviceRegistry.key(record.tenantId, record.deviceId), record);
60
61
  this.byDeviceId.set(record.deviceId, record);
61
62
  }
@@ -65,15 +66,31 @@ var DeviceRegistry = class _DeviceRegistry {
65
66
  }
66
67
  /**
67
68
  * Revoke a device (public API via `createByokServer(...).devices.revoke`).
68
- * Its next `/byok/challenge`, `/byok/token`, WSS connect, or authed HTTP
69
- * call gets a 401; the daemon's only recourse is to re-run `/byok/pair`
70
- * (docs/protocol.md §6.3). A tenant can only revoke its own devices: a
71
- * (tenantId, deviceId) pair it does not own resolves to nothing and this is
72
- * a no-op.
69
+ * Revocation DELETES the registration (docs/protocol.md §6.3): afterwards
70
+ * the device id is byte-for-byte one that was never registered — absent
71
+ * from {@link list}, resolving to nothing on the pre-tenant
72
+ * `/byok/challenge` and `/byok/token` paths, and a 401 on every authed
73
+ * surface. The daemon's only recourse is to re-run `/byok/pair`.
74
+ *
75
+ * There is deliberately no retained `revoked` row: a flag every read path
76
+ * has to remember to exclude is a second way to represent "not a
77
+ * principal", and the first read path that forgets it is a live credential.
78
+ *
79
+ * A tenant can only revoke its own devices: a (tenantId, deviceId) pair it
80
+ * does not own resolves to nothing, deletes nothing, and is silently
81
+ * indistinguishable from revoking one that never existed.
82
+ *
83
+ * Returns whether a row was actually removed — the composition in
84
+ * `index.ts` uses it to delete the device-scoped state that only existed to
85
+ * serve that row (nonces, presence, dedup), so a no-op revoke touches
86
+ * nothing at all.
73
87
  */
74
88
  revoke(tenantId, deviceId) {
75
89
  const record = this.get(tenantId, deviceId);
76
- if (record) record.revoked = true;
90
+ if (!record) return false;
91
+ this.devices.delete(_DeviceRegistry.key(tenantId, deviceId));
92
+ if (this.byDeviceId.get(deviceId) === record) this.byDeviceId.delete(deviceId);
93
+ return true;
77
94
  }
78
95
  /** Every known device row, across tenants — the in-process read model behind `ByokServer.machines.list()`. */
79
96
  list() {
@@ -131,6 +148,17 @@ var NonceStore = class {
131
148
  if (Date.now() > record.expiresAt) return false;
132
149
  return true;
133
150
  }
151
+ /**
152
+ * Drop every nonce outstanding for `deviceId` — called when the device's
153
+ * registration is deleted (§6.3 revocation). An unspent challenge is state
154
+ * that only existed to serve a row the directory can no longer name, so it
155
+ * goes with the row rather than sitting until its TTL sweeps it.
156
+ */
157
+ deleteForDevice(deviceId) {
158
+ for (const [nonce, record] of this.nonces) {
159
+ if (record.deviceId === deviceId) this.nonces.delete(nonce);
160
+ }
161
+ }
134
162
  /** Mark `nonce` consumed so a replay of the same (deviceId, nonce, signature) is rejected. */
135
163
  markUsed(nonce) {
136
164
  const record = this.nonces.get(nonce);
@@ -162,7 +190,7 @@ async function authenticateBearer(header, deps) {
162
190
  const claims = await deps.tokenSigner.verify(token);
163
191
  if (!claims) return void 0;
164
192
  const device = deps.devices.get(claims.tenantId, claims.deviceId);
165
- if (!device || device.revoked) return void 0;
193
+ if (!device) return void 0;
166
194
  if (device.productId !== claims.productId) return void 0;
167
195
  if (device.productId !== deps.productId) return void 0;
168
196
  return { deviceId: device.deviceId, tenantId: device.tenantId, productId: device.productId };
@@ -702,6 +730,34 @@ var ConnectionHub = class {
702
730
  this.serverEvents.push({ kind: "device.disconnected", deviceId, at: conn.lastSeen });
703
731
  this.settleLongPollWaiter(deviceId);
704
732
  }
733
+ /**
734
+ * Drop every piece of hub state keyed by `deviceId` — called when the
735
+ * device's registration is DELETED (§6.3 revocation, composed in
736
+ * `index.ts`). Presence, its undelivered outbox, its inbound-dedup ring,
737
+ * any held long-poll, and its rate-limit episode suppression only ever
738
+ * existed to serve a row the directory can no longer name; leaving them
739
+ * would keep a deleted device visible as live state and would let a
740
+ * re-paired device inherit the dead one's dedup window and seq cursor.
741
+ *
742
+ * What the device DID is not revocation's business and is untouched here:
743
+ * task records (`taskStore`), egress and content receipts, and projection
744
+ * facts are history, not credentials.
745
+ *
746
+ * A live socket is closed rather than left attached — a still-open WS whose
747
+ * next envelope would re-create the presence entry we just deleted is a
748
+ * half-applied revocation. The close is detached first so `handleDisconnect`
749
+ * sees no connection state and skips its disconnect bookkeeping: the device
750
+ * is not going dark, it is gone.
751
+ */
752
+ forgetDevice(deviceId) {
753
+ const conn = this.connections.get(deviceId);
754
+ this.connections.delete(deviceId);
755
+ this.outboxes.delete(deviceId);
756
+ this.dedupRings.delete(deviceId);
757
+ this.rateLimitEventEmittedFor.delete(deviceId);
758
+ this.settleLongPollWaiter(deviceId);
759
+ conn?.ws?.close(1e3, "device revoked");
760
+ }
705
761
  // ---------------------------------------------------------------------
706
762
  // long-poll fallback (§8) — GET /byok/events, called from http.ts
707
763
  // ---------------------------------------------------------------------
@@ -1889,7 +1945,7 @@ var ConnectionHub = class {
1889
1945
  const desired = this.agentHomeProjectionRequests.get(key);
1890
1946
  if (desired === void 0) return void 0;
1891
1947
  const device = this.devices.resolveByDeviceId(deviceId);
1892
- if (device === void 0 || device.revoked) return void 0;
1948
+ if (device === void 0) return void 0;
1893
1949
  return AgentHomeProjectionReadbackSchema.parse({
1894
1950
  tenantId: device.tenantId,
1895
1951
  deviceId,
@@ -1917,7 +1973,7 @@ var ConnectionHub = class {
1917
1973
  return existing;
1918
1974
  }
1919
1975
  const device = this.devices.resolveByDeviceId(deviceId);
1920
- if (device === void 0 || device.revoked) {
1976
+ if (device === void 0) {
1921
1977
  throw new AgentHomeProjectionCompletionError("not_found", "Agent-home projection device was not found");
1922
1978
  }
1923
1979
  const readback = AgentHomeProjectionReadbackSchema.parse({
@@ -2359,7 +2415,7 @@ function buildHonoApp(deps) {
2359
2415
  if (!parsed.success) return c.json({ error: "deviceId is required" }, 400);
2360
2416
  const { deviceId } = parsed.data;
2361
2417
  const device = deps.devices.resolveByDeviceId(deviceId);
2362
- if (!device || device.revoked) {
2418
+ if (!device) {
2363
2419
  return c.json({ error: "unknown or revoked device" }, 401);
2364
2420
  }
2365
2421
  const nonce = deps.nonces.issue(deviceId);
@@ -2371,7 +2427,7 @@ function buildHonoApp(deps) {
2371
2427
  if (!parsed.success) return c.json({ error: "deviceId, nonce, and signature are required" }, 400);
2372
2428
  const { deviceId, nonce, signature } = parsed.data;
2373
2429
  const device = deps.devices.resolveByDeviceId(deviceId);
2374
- if (!device || device.revoked) {
2430
+ if (!device) {
2375
2431
  return c.json({ error: "unknown or revoked device" }, 401);
2376
2432
  }
2377
2433
  if (!deps.nonces.validate(deviceId, nonce)) {
@@ -3270,7 +3326,11 @@ function createByokServer(opts) {
3270
3326
  subscribe: () => hub.subscribeServerEvents()
3271
3327
  },
3272
3328
  devices: {
3273
- revoke: (tenantId, deviceId) => devices.revoke(tenantId, deviceId)
3329
+ revoke: (tenantId, deviceId) => {
3330
+ if (!devices.revoke(tenantId, deviceId)) return;
3331
+ nonces.deleteForDevice(deviceId);
3332
+ hub.forgetDevice(deviceId);
3333
+ }
3274
3334
  },
3275
3335
  stop() {
3276
3336
  hub.stopLeaseReaper();