@byok-sdk/server 0.10.1 → 0.11.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 +40 -15
- package/dist/hub.d.ts +20 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +76 -16
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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
|
-
/**
|
|
76
|
-
|
|
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}).
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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):
|
|
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
|
|
180
|
-
*
|
|
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
|
|
90
|
-
* authed HTTP call
|
|
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}).
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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();
|