mecord-connect 1.0.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.
Files changed (82) hide show
  1. package/DISCLOSURE +47 -0
  2. package/LICENSE +64 -0
  3. package/README.md +44 -0
  4. package/bin/operator.mjs +10 -0
  5. package/package.json +58 -0
  6. package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
  7. package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
  8. package/runtime/app/apps/local-agent/src/cli.js +237 -0
  9. package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
  10. package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
  11. package/runtime/app/apps/local-agent/src/main.js +311 -0
  12. package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
  13. package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
  14. package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
  15. package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
  16. package/runtime/app/apps/local-agent/src/remote.js +130 -0
  17. package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
  18. package/runtime/app/apps/local-agent/src/server.js +578 -0
  19. package/runtime/app/package.json +4 -0
  20. package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
  21. package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
  22. package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
  23. package/runtime/app/src/capabilities/browser-cdp.js +384 -0
  24. package/runtime/app/src/capabilities/browser-managed.js +345 -0
  25. package/runtime/app/src/capabilities/docker.js +448 -0
  26. package/runtime/app/src/capabilities/filesystem.js +264 -0
  27. package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
  28. package/runtime/app/src/capabilities/git-write.js +321 -0
  29. package/runtime/app/src/capabilities/git.js +252 -0
  30. package/runtime/app/src/capabilities/index.js +16 -0
  31. package/runtime/app/src/capabilities/path-scope.js +86 -0
  32. package/runtime/app/src/capabilities/postgres.js +566 -0
  33. package/runtime/app/src/capabilities/process.js +258 -0
  34. package/runtime/app/src/capabilities/project-command.js +599 -0
  35. package/runtime/app/src/capabilities/project-transaction.js +215 -0
  36. package/runtime/app/src/capabilities/project.js +67 -0
  37. package/runtime/app/src/capabilities/system.js +40 -0
  38. package/runtime/app/src/capabilities/vscode.js +312 -0
  39. package/runtime/app/src/capabilities/windows-uia.js +331 -0
  40. package/runtime/app/src/core/account-device-registry.js +604 -0
  41. package/runtime/app/src/core/action-identity.js +44 -0
  42. package/runtime/app/src/core/audit.js +295 -0
  43. package/runtime/app/src/core/capability-policy.js +58 -0
  44. package/runtime/app/src/core/child-environment.js +29 -0
  45. package/runtime/app/src/core/device-enrollment.js +417 -0
  46. package/runtime/app/src/core/device-identity.js +436 -0
  47. package/runtime/app/src/core/device-registry.js +477 -0
  48. package/runtime/app/src/core/device-reset.js +178 -0
  49. package/runtime/app/src/core/device-routing.js +305 -0
  50. package/runtime/app/src/core/durable-state.js +306 -0
  51. package/runtime/app/src/core/errors.js +20 -0
  52. package/runtime/app/src/core/evidence.js +9 -0
  53. package/runtime/app/src/core/index.js +17 -0
  54. package/runtime/app/src/core/machine-state.js +160 -0
  55. package/runtime/app/src/core/network-authority.js +20 -0
  56. package/runtime/app/src/core/policy.js +67 -0
  57. package/runtime/app/src/core/product-identity.js +7 -0
  58. package/runtime/app/src/core/provenance.js +18 -0
  59. package/runtime/app/src/core/provider-learning.js +274 -0
  60. package/runtime/app/src/core/public-plugin-surface.js +25 -0
  61. package/runtime/app/src/core/public-restricted-data.js +105 -0
  62. package/runtime/app/src/core/rate-limit.js +118 -0
  63. package/runtime/app/src/core/relay-client.js +568 -0
  64. package/runtime/app/src/core/relay-delivery-store.js +512 -0
  65. package/runtime/app/src/core/relay-result-store.js +321 -0
  66. package/runtime/app/src/core/release-update.js +253 -0
  67. package/runtime/app/src/core/router.js +96 -0
  68. package/runtime/app/src/core/runtime.js +136 -0
  69. package/runtime/app/src/core/scoped-path-syntax.js +16 -0
  70. package/runtime/app/src/core/session-token.js +498 -0
  71. package/runtime/app/src/core/task-orchestrator.js +1148 -0
  72. package/runtime/app/src/core/task-store.js +517 -0
  73. package/runtime/app/src/core/task.js +142 -0
  74. package/runtime/app/src/core/trusted-executable.js +124 -0
  75. package/runtime/app/src/core/types.js +83 -0
  76. package/runtime/app/src/core/windows-path-lease.js +138 -0
  77. package/runtime/native/operator-windows-dpapi.exe +0 -0
  78. package/runtime/native/operator-windows-path-lease.exe +0 -0
  79. package/runtime/native/operator-windows-uia.exe +0 -0
  80. package/runtime/runtime-manifest.json +380 -0
  81. package/src/cli.mjs +345 -0
  82. package/src/publish-check.mjs +22 -0
@@ -0,0 +1,477 @@
1
+ import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { DeviceIdentityStore, } from './device-identity.js';
4
+ import { OperatorError } from './errors.js';
5
+ import { readDurableStateText, writeDurableStateText } from './durable-state.js';
6
+
7
+ const MAX_DEVICES = 1000;
8
+ const MAX_CHALLENGES = 2048;
9
+ const MAX_ACTIVE_CHALLENGES = 128;
10
+ const MAX_TTL_MS = 5 * 60_000;
11
+ const MIN_TTL_MS = 30_000;
12
+ const CHALLENGE_RETENTION_MS = 24 * 60 * 60_000;
13
+ const DEVICE_NAME_MAX = 128;
14
+ const REASON_MAX = 512;
15
+ const PURPOSE = 'operator-pairing-v1';
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+ export class DeviceRegistryStore {
54
+ #file ;
55
+ #clock ;
56
+ #onRevoke ;
57
+ #queue = Promise.resolve();
58
+
59
+ constructor(stateDir , options = {}) {
60
+ this.#file = path.join(path.resolve(stateDir), 'device-registry.json');
61
+ this.#clock = options.clock ?? (() => new Date());
62
+ this.#onRevoke = options.onRevoke;
63
+ }
64
+
65
+ async listDevices() {
66
+ const state = await this.#read();
67
+ return state.devices.map((device) => ({ ...device }));
68
+ }
69
+
70
+ async issuePairingChallenge(
71
+ issuer ,
72
+ options = {}
73
+ ) {
74
+ const normalizedIssuer = normalizePublicIdentity(issuer);
75
+ const ttlMs = boundedTtl(options.ttlMs ?? 120_000);
76
+ const expectedPeerDeviceId = options.expectedPeerDeviceId === undefined
77
+ ? undefined
78
+ : validUuid(options.expectedPeerDeviceId, 'expectedPeerDeviceId');
79
+ const now = this.#clock();
80
+ const challenge = {
81
+ version: 1,
82
+ purpose: PURPOSE,
83
+ challengeId: crypto.randomUUID(),
84
+ issuerDeviceId: normalizedIssuer.deviceId,
85
+ issuerFingerprint: normalizedIssuer.fingerprint,
86
+ nonce: crypto.randomBytes(32).toString('base64url'),
87
+ issuedAt: now.toISOString(),
88
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
89
+ expectedPeerDeviceId
90
+ };
91
+
92
+ await this.#mutate((state) => {
93
+ pruneChallenges(state, now.getTime());
94
+ const activeChallenges = state.challenges.filter((candidate) => !candidate.consumedAt && Date.parse(candidate.expiresAt) > now.getTime()).length;
95
+ if (activeChallenges >= MAX_ACTIVE_CHALLENGES) throw new OperatorError('PAIRING_CHALLENGE_LIMIT', `At most ${MAX_ACTIVE_CHALLENGES} active pairing challenges may be retained.`);
96
+ while (state.challenges.length >= MAX_CHALLENGES) {
97
+ const reclaimIndex = state.challenges.findIndex((candidate) => Boolean(candidate.consumedAt) || Date.parse(candidate.expiresAt) <= now.getTime());
98
+ if (reclaimIndex < 0) break;
99
+ state.challenges.splice(reclaimIndex, 1);
100
+ }
101
+ if (state.challenges.length >= MAX_CHALLENGES) throw new OperatorError('PAIRING_CHALLENGE_LIMIT', 'Pairing challenge retention limit reached.');
102
+ state.challenges.push(challenge);
103
+ });
104
+ return publicChallenge(challenge);
105
+ }
106
+
107
+ async verifyPairingForEnrollment(response , options = {}) {
108
+ if (!response || typeof response !== 'object') throw new OperatorError('PAIRING_RESPONSE_INVALID', 'Pairing response is invalid.');
109
+ const challengeId = validUuid(response.challengeId, 'challengeId');
110
+ const peer = normalizePublicIdentity(response.peer);
111
+ const signature = String(response.signature ?? '');
112
+ if (!/^[A-Za-z0-9_-]{40,256}$/.test(signature)) throw new OperatorError('PAIRING_SIGNATURE_INVALID', 'Pairing response signature is malformed.');
113
+ return await this.#mutate((state) => {
114
+ const now = this.#clock();
115
+ pruneChallenges(state, now.getTime());
116
+ const challenge = state.challenges.find((candidate) => candidate.challengeId === challengeId);
117
+ if (!challenge) throw new OperatorError('PAIRING_CHALLENGE_NOT_FOUND', 'Pairing challenge was not found or is no longer retained.');
118
+ if (!challenge.consumedAt && Date.parse(challenge.expiresAt) < now.getTime()) throw new OperatorError('PAIRING_CHALLENGE_EXPIRED', 'Pairing challenge has expired.');
119
+ if (challenge.expectedPeerDeviceId && challenge.expectedPeerDeviceId !== peer.deviceId) throw new OperatorError('PAIRING_PEER_MISMATCH', 'Pairing response came from a different device than the challenge expected.');
120
+ let verified = false;
121
+ try { verified = crypto.verify(null, pairingPayload(challenge, peer), peer.publicKeyPem, Buffer.from(signature, 'base64url')); } catch { verified = false; }
122
+ if (!verified) throw new OperatorError('PAIRING_SIGNATURE_INVALID', 'Pairing response signature could not be verified.');
123
+ if (challenge.consumedAt) {
124
+ const exact = challenge.consumedPeerDeviceId === peer.deviceId && challenge.consumedPeerFingerprint === peer.fingerprint;
125
+ if (options.allowExactReplay && exact) return { ...peer };
126
+ throw new OperatorError('PAIRING_CHALLENGE_REPLAY', 'Pairing challenge has already been consumed.');
127
+ }
128
+ challenge.consumedAt = now.toISOString();
129
+ challenge.consumedPeerDeviceId = peer.deviceId;
130
+ challenge.consumedPeerFingerprint = peer.fingerprint;
131
+ return { ...peer };
132
+ });
133
+ }
134
+ async registerVerifiedPeer(peerInput ) {
135
+ return (await this.registerVerifiedPeerTracked(peerInput)).device;
136
+ }
137
+
138
+ async registerVerifiedPeerTracked(peerInput ) {
139
+ const peer = normalizePublicIdentity(peerInput);
140
+ return await this.#mutate((state) => {
141
+ const sameId = state.devices.find((device) => device.deviceId === peer.deviceId);
142
+ const sameFingerprint = state.devices.find((device) => device.fingerprint === peer.fingerprint);
143
+ if (sameId && sameId.fingerprint !== peer.fingerprint) throw new OperatorError('DEVICE_IDENTITY_CONFLICT', 'The device ID is already registered with a different public key.');
144
+ if (sameFingerprint && sameFingerprint.deviceId !== peer.deviceId) throw new OperatorError('DEVICE_IDENTITY_CONFLICT', 'The public-key fingerprint is already registered to a different device ID.');
145
+ if (sameId?.status === 'revoked') throw new OperatorError('DEVICE_REVOKED', 'This device identity is revoked and cannot be silently reactivated.');
146
+ if (!sameId && state.devices.length >= MAX_DEVICES) throw new OperatorError('DEVICE_REGISTRY_LIMIT', `At most ${MAX_DEVICES} peer devices may be registered.`);
147
+ const pairedAt = this.#clock().toISOString();
148
+ const registered = sameId ?? { ...peer, status: 'active', pairedAt };
149
+ if (!sameId) state.devices.push(registered);
150
+ else {
151
+ sameId.deviceName = peer.deviceName;
152
+ sameId.createdAt = peer.createdAt;
153
+ sameId.publicKeyPem = peer.publicKeyPem;
154
+ sameId.pairedAt = pairedAt;
155
+ }
156
+ return { device: { ...registered }, created: !sameId };
157
+ });
158
+ }
159
+
160
+ async unregisterActiveDevice(deviceIdInput , expectedFingerprintInput ) {
161
+ const deviceId = validUuid(deviceIdInput, 'deviceId');
162
+ const expectedFingerprint = expectedFingerprintInput === undefined ? undefined : String(expectedFingerprintInput);
163
+ if (expectedFingerprint !== undefined && !/^[A-Za-z0-9_-]{32,128}$/.test(expectedFingerprint)) {
164
+ throw new OperatorError('DEVICE_IDENTITY_INVALID', 'Expected device fingerprint is invalid.');
165
+ }
166
+ return await this.#mutate((state) => {
167
+ const index = state.devices.findIndex((device) => device.deviceId === deviceId);
168
+ if (index < 0) return false;
169
+ const device = state.devices[index] ;
170
+ if (device.status === 'revoked') return false;
171
+ if (expectedFingerprint && device.fingerprint !== expectedFingerprint) {
172
+ throw new OperatorError('DEVICE_IDENTITY_CONFLICT', 'Device registration fingerprint changed before rollback.');
173
+ }
174
+ state.devices.splice(index, 1);
175
+ return true;
176
+ });
177
+ }
178
+
179
+ async completePairing(response , options = {}) {
180
+ if (!response || typeof response !== 'object') throw new OperatorError('PAIRING_RESPONSE_INVALID', 'Pairing response is invalid.');
181
+ const challengeId = validUuid(response.challengeId, 'challengeId');
182
+ const peer = normalizePublicIdentity(response.peer);
183
+ const signature = String(response.signature ?? '');
184
+ if (!/^[A-Za-z0-9_-]{40,256}$/.test(signature)) {
185
+ throw new OperatorError('PAIRING_SIGNATURE_INVALID', 'Pairing response signature is malformed.');
186
+ }
187
+
188
+ return await this.#mutate((state) => {
189
+ const now = this.#clock();
190
+ pruneChallenges(state, now.getTime());
191
+ const challenge = state.challenges.find((candidate) => candidate.challengeId === challengeId);
192
+ if (!challenge) throw new OperatorError('PAIRING_CHALLENGE_NOT_FOUND', 'Pairing challenge was not found or is no longer retained.');
193
+ if (!challenge.consumedAt && Date.parse(challenge.expiresAt) < now.getTime()) throw new OperatorError('PAIRING_CHALLENGE_EXPIRED', 'Pairing challenge has expired.');
194
+ if (challenge.expectedPeerDeviceId && challenge.expectedPeerDeviceId !== peer.deviceId) {
195
+ throw new OperatorError('PAIRING_PEER_MISMATCH', 'Pairing response came from a different device than the challenge expected.');
196
+ }
197
+
198
+ const payload = pairingPayload(challenge, peer);
199
+ let verified = false;
200
+ try {
201
+ verified = crypto.verify(null, payload, peer.publicKeyPem, Buffer.from(signature, 'base64url'));
202
+ } catch {
203
+ verified = false;
204
+ }
205
+ if (!verified) throw new OperatorError('PAIRING_SIGNATURE_INVALID', 'Pairing response signature could not be verified.');
206
+
207
+ const sameId = state.devices.find((device) => device.deviceId === peer.deviceId);
208
+ const sameFingerprint = state.devices.find((device) => device.fingerprint === peer.fingerprint);
209
+ if (challenge.consumedAt) {
210
+ if (options.allowExactReplay && sameId && sameId.status === 'active' && sameId.fingerprint === peer.fingerprint) return { ...sameId };
211
+ throw new OperatorError('PAIRING_CHALLENGE_REPLAY', 'Pairing challenge has already been consumed.');
212
+ }
213
+ if (sameId && sameId.fingerprint !== peer.fingerprint) {
214
+ throw new OperatorError('DEVICE_IDENTITY_CONFLICT', 'The device ID is already registered with a different public key.');
215
+ }
216
+ if (sameFingerprint && sameFingerprint.deviceId !== peer.deviceId) {
217
+ throw new OperatorError('DEVICE_IDENTITY_CONFLICT', 'The public-key fingerprint is already registered to a different device ID.');
218
+ }
219
+ if (sameId?.status === 'revoked') {
220
+ throw new OperatorError('DEVICE_REVOKED', 'This device identity is revoked and cannot be silently reactivated by pairing.');
221
+ }
222
+ if (!sameId && state.devices.length >= MAX_DEVICES) {
223
+ throw new OperatorError('DEVICE_REGISTRY_LIMIT', `At most ${MAX_DEVICES} peer devices may be registered.`);
224
+ }
225
+
226
+ const pairedAt = now.toISOString();
227
+ const registered = sameId ?? {
228
+ deviceId: peer.deviceId,
229
+ deviceName: peer.deviceName,
230
+ createdAt: peer.createdAt,
231
+ publicKeyPem: peer.publicKeyPem,
232
+ fingerprint: peer.fingerprint,
233
+ status: 'active',
234
+ pairedAt
235
+ };
236
+ if (!sameId) state.devices.push(registered);
237
+ else {
238
+ sameId.deviceName = peer.deviceName;
239
+ sameId.createdAt = peer.createdAt;
240
+ sameId.publicKeyPem = peer.publicKeyPem;
241
+ sameId.pairedAt = pairedAt;
242
+ }
243
+ challenge.consumedAt = pairedAt;
244
+ return { ...registered };
245
+ });
246
+ }
247
+
248
+ async revokeDevice(deviceIdInput , reasonInput ) {
249
+ const deviceId = validUuid(deviceIdInput, 'deviceId');
250
+ const reason = reasonInput === undefined ? undefined : boundedReason(reasonInput);
251
+ const outcome = await this.#mutate((state) => {
252
+ const device = state.devices.find((candidate) => candidate.deviceId === deviceId);
253
+ if (!device) throw new OperatorError('DEVICE_NOT_FOUND', 'Registered device was not found.');
254
+ if (device.status === 'revoked') return { device: { ...device }, changed: false };
255
+ device.status = 'revoked';
256
+ device.revokedAt = this.#clock().toISOString();
257
+ device.revokedReason = reason;
258
+ return { device: { ...device }, changed: true };
259
+ });
260
+ if (outcome.changed) await this.#onRevoke?.(deviceId, reason);
261
+ return outcome.device;
262
+ }
263
+
264
+ async verifyDeviceSignature(deviceIdInput , payload , signatureInput ) {
265
+ const deviceId = validUuid(deviceIdInput, 'deviceId');
266
+ const signature = String(signatureInput ?? '');
267
+ const state = await this.#read();
268
+ const device = state.devices.find((candidate) => candidate.deviceId === deviceId);
269
+ if (!device) throw new OperatorError('DEVICE_NOT_FOUND', 'Registered device was not found.');
270
+ if (device.status !== 'active') throw new OperatorError('DEVICE_REVOKED', 'Registered device is revoked.');
271
+ try {
272
+ return crypto.verify(null, payload, device.publicKeyPem, Buffer.from(signature, 'base64url'));
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+
278
+ async #read() {
279
+ try {
280
+ const text = await readDurableStateText(this.#file, {
281
+ maxBytes: 2 * 1024 * 1024,
282
+ errorCode: 'DEVICE_REGISTRY_CORRUPT',
283
+ invalidMessage: 'Device registry is invalid.'
284
+ });
285
+ return validateState(JSON.parse(text));
286
+ } catch (error) {
287
+ if ((error ).code === 'ENOENT') return { version: 1, devices: [], challenges: [] };
288
+ if (error instanceof OperatorError) throw error;
289
+ throw new OperatorError('DEVICE_REGISTRY_CORRUPT', 'Device registry could not be read.');
290
+ }
291
+ }
292
+
293
+ async #write(stateInput ) {
294
+ const state = validateState(stateInput);
295
+ await writeDurableStateText(this.#file, JSON.stringify(state, null, 2), {
296
+ maxBytes: 2 * 1024 * 1024,
297
+ errorCode: 'DEVICE_REGISTRY_CORRUPT',
298
+ invalidMessage: 'Device registry is invalid.'
299
+ });
300
+ }
301
+
302
+ async #mutate (mutator ) {
303
+ let resolveTurn ;
304
+ const previous = this.#queue;
305
+ this.#queue = new Promise ((resolve) => { resolveTurn = resolve; });
306
+ await previous;
307
+ try {
308
+ const state = await this.#read();
309
+ const result = await mutator(state);
310
+ await this.#write(state);
311
+ return result;
312
+ } finally {
313
+ resolveTurn();
314
+ }
315
+ }
316
+ }
317
+
318
+ export async function answerPairingChallenge(
319
+ challengeInput ,
320
+ identityStore ,
321
+ deviceName
322
+ ) {
323
+ const challenge = validateChallenge(challengeInput);
324
+ const peer = normalizePublicIdentity(await identityStore.loadOrCreate(deviceName));
325
+ if (challenge.expectedPeerDeviceId && challenge.expectedPeerDeviceId !== peer.deviceId) {
326
+ throw new OperatorError('PAIRING_PEER_MISMATCH', 'This pairing challenge is intended for a different device.');
327
+ }
328
+ const signature = await identityStore.sign(pairingPayload(challenge, peer));
329
+ return { challengeId: challenge.challengeId, peer, signature };
330
+ }
331
+
332
+ function pairingPayload(challengeInput , peerInput ) {
333
+ const challenge = validateChallenge(challengeInput);
334
+ const peer = normalizePublicIdentity(peerInput);
335
+ return Buffer.from(JSON.stringify({
336
+ version: 1,
337
+ purpose: PURPOSE,
338
+ challengeId: challenge.challengeId,
339
+ issuerDeviceId: challenge.issuerDeviceId,
340
+ issuerFingerprint: challenge.issuerFingerprint,
341
+ nonce: challenge.nonce,
342
+ issuedAt: challenge.issuedAt,
343
+ expiresAt: challenge.expiresAt,
344
+ expectedPeerDeviceId: challenge.expectedPeerDeviceId ?? null,
345
+ peerDeviceId: peer.deviceId,
346
+ peerDeviceName: peer.deviceName,
347
+ peerCreatedAt: peer.createdAt,
348
+ peerFingerprint: peer.fingerprint,
349
+ peerPublicKeyPem: peer.publicKeyPem
350
+ }), 'utf8');
351
+ }
352
+
353
+ function normalizePublicIdentity(input ) {
354
+ if (!input || typeof input !== 'object') throw new OperatorError('DEVICE_IDENTITY_INVALID', 'Public device identity is invalid.');
355
+ const deviceId = validUuid(String(input.deviceId ?? ''), 'deviceId');
356
+ const deviceName = boundedDeviceName(String(input.deviceName ?? ''));
357
+ const createdAt = validIsoDate(String(input.createdAt ?? ''), 'createdAt');
358
+ let publicKey ;
359
+ try { publicKey = crypto.createPublicKey(String(input.publicKeyPem ?? '')); }
360
+ catch { throw new OperatorError('DEVICE_IDENTITY_INVALID', 'Public device key is invalid.'); }
361
+ if (publicKey.asymmetricKeyType !== 'ed25519') throw new OperatorError('DEVICE_IDENTITY_INVALID', 'Public device key must be Ed25519.');
362
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
363
+ const fingerprint = crypto.createHash('sha256').update(publicKeyPem).digest('base64url');
364
+ if (fingerprint !== String(input.fingerprint ?? '')) {
365
+ throw new OperatorError('DEVICE_IDENTITY_INVALID', 'Public device fingerprint does not match the public key.');
366
+ }
367
+ return { deviceId, deviceName, createdAt, publicKeyPem, fingerprint };
368
+ }
369
+
370
+ function validateChallenge(input ) {
371
+ if (!input || typeof input !== 'object' || input.version !== 1 || input.purpose !== PURPOSE) {
372
+ throw new OperatorError('PAIRING_CHALLENGE_INVALID', 'Pairing challenge is invalid.');
373
+ }
374
+ const challengeId = validUuid(String(input.challengeId ?? ''), 'challengeId');
375
+ const issuerDeviceId = validUuid(String(input.issuerDeviceId ?? ''), 'issuerDeviceId');
376
+ const issuerFingerprint = String(input.issuerFingerprint ?? '');
377
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(issuerFingerprint)) throw new OperatorError('PAIRING_CHALLENGE_INVALID', 'Pairing issuer fingerprint is invalid.');
378
+ const nonce = String(input.nonce ?? '');
379
+ if (!/^[A-Za-z0-9_-]{40,128}$/.test(nonce)) throw new OperatorError('PAIRING_CHALLENGE_INVALID', 'Pairing nonce is invalid.');
380
+ const issuedAt = validIsoDate(String(input.issuedAt ?? ''), 'issuedAt');
381
+ const expiresAt = validIsoDate(String(input.expiresAt ?? ''), 'expiresAt');
382
+ const issued = Date.parse(issuedAt);
383
+ const expires = Date.parse(expiresAt);
384
+ if (expires <= issued || expires - issued > MAX_TTL_MS) throw new OperatorError('PAIRING_CHALLENGE_INVALID', 'Pairing challenge lifetime is invalid.');
385
+ const expectedPeerDeviceId = input.expectedPeerDeviceId === undefined ? undefined : validUuid(input.expectedPeerDeviceId, 'expectedPeerDeviceId');
386
+ return { version: 1, purpose: PURPOSE, challengeId, issuerDeviceId, issuerFingerprint, nonce, issuedAt, expiresAt, expectedPeerDeviceId };
387
+ }
388
+
389
+ function validateState(input ) {
390
+ if (!input || typeof input !== 'object' || input.version !== 1 || !Array.isArray(input.devices) || !Array.isArray(input.challenges)) {
391
+ throw new OperatorError('DEVICE_REGISTRY_CORRUPT', 'Device registry structure is invalid.');
392
+ }
393
+ if (input.devices.length > MAX_DEVICES || input.challenges.length > MAX_CHALLENGES) {
394
+ throw new OperatorError('DEVICE_REGISTRY_CORRUPT', 'Device registry exceeds bounded entry limits.');
395
+ }
396
+ const ids = new Set ();
397
+ const fingerprints = new Set ();
398
+ const devices = input.devices.map((raw) => {
399
+ const peer = normalizePublicIdentity(raw);
400
+ if (ids.has(peer.deviceId) || fingerprints.has(peer.fingerprint)) throw new OperatorError('DEVICE_REGISTRY_CORRUPT', 'Device registry contains duplicate identities.');
401
+ ids.add(peer.deviceId);
402
+ fingerprints.add(peer.fingerprint);
403
+ const status = raw.status === 'revoked' ? 'revoked' : raw.status === 'active' ? 'active' : null;
404
+ if (!status) throw new OperatorError('DEVICE_REGISTRY_CORRUPT', 'Registered device status is invalid.');
405
+ const pairedAt = validIsoDate(String(raw.pairedAt ?? ''), 'pairedAt');
406
+ const revokedAt = raw.revokedAt === undefined ? undefined : validIsoDate(String(raw.revokedAt), 'revokedAt');
407
+ const revokedReason = raw.revokedReason === undefined ? undefined : boundedReason(String(raw.revokedReason));
408
+ if (status === 'active' && revokedAt) throw new OperatorError('DEVICE_REGISTRY_CORRUPT', 'Active device cannot have a revocation timestamp.');
409
+ return { ...peer, status, pairedAt, revokedAt, revokedReason } ;
410
+ });
411
+ const challengeIds = new Set ();
412
+ const challenges = input.challenges.map((raw) => {
413
+ const challenge = validateChallenge(raw);
414
+ if (challengeIds.has(challenge.challengeId)) throw new OperatorError('DEVICE_REGISTRY_CORRUPT', 'Duplicate pairing challenge ID.');
415
+ challengeIds.add(challenge.challengeId);
416
+ const consumedAt = raw.consumedAt === undefined ? undefined : validIsoDate(String(raw.consumedAt), 'consumedAt');
417
+ const consumedPeerDeviceId = raw.consumedPeerDeviceId === undefined ? undefined : validUuid(String(raw.consumedPeerDeviceId), 'consumedPeerDeviceId');
418
+ const consumedPeerFingerprint = raw.consumedPeerFingerprint === undefined ? undefined : String(raw.consumedPeerFingerprint);
419
+ if (consumedPeerFingerprint !== undefined && !/^[A-Za-z0-9_-]{32,128}$/.test(consumedPeerFingerprint)) throw new OperatorError('DEVICE_REGISTRY_CORRUPT', 'Consumed pairing peer fingerprint is invalid.');
420
+ if (Boolean(consumedPeerDeviceId) !== Boolean(consumedPeerFingerprint) || (!consumedAt && consumedPeerDeviceId)) throw new OperatorError('DEVICE_REGISTRY_CORRUPT', 'Consumed pairing replay authority is inconsistent.');
421
+ return { ...challenge, consumedAt, consumedPeerDeviceId, consumedPeerFingerprint };
422
+ });
423
+ return { version: 1, devices, challenges };
424
+ }
425
+
426
+ function pruneChallenges(state , nowMs ) {
427
+ state.challenges = state.challenges.filter((challenge) => {
428
+ const reference = challenge.consumedAt ? Date.parse(challenge.consumedAt) : Date.parse(challenge.expiresAt);
429
+ return nowMs - reference <= CHALLENGE_RETENTION_MS;
430
+ });
431
+ }
432
+
433
+ function boundedTtl(value ) {
434
+ if (!Number.isInteger(value) || value < MIN_TTL_MS || value > MAX_TTL_MS) {
435
+ throw new OperatorError('PAIRING_TTL_INVALID', `Pairing TTL must be an integer between ${MIN_TTL_MS} and ${MAX_TTL_MS} ms.`);
436
+ }
437
+ return value;
438
+ }
439
+
440
+ function validUuid(value , label ) {
441
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
442
+ throw new OperatorError('DEVICE_IDENTITY_INVALID', `${label} must be a UUID.`);
443
+ }
444
+ return value.toLowerCase();
445
+ }
446
+
447
+ function boundedDeviceName(value ) {
448
+ const trimmed = value.trim();
449
+ if (!trimmed || trimmed.length > DEVICE_NAME_MAX || /[\0\r\n]/.test(trimmed)) throw new OperatorError('DEVICE_IDENTITY_INVALID', 'Device name is invalid.');
450
+ return trimmed;
451
+ }
452
+
453
+ function boundedReason(value ) {
454
+ const trimmed = value.trim();
455
+ if (!trimmed || trimmed.length > REASON_MAX || /\0/.test(trimmed)) throw new OperatorError('DEVICE_REVOCATION_REASON_INVALID', 'Revocation reason is invalid.');
456
+ return trimmed;
457
+ }
458
+
459
+ function validIsoDate(value , label ) {
460
+ const time = Date.parse(value);
461
+ if (!Number.isFinite(time) || new Date(time).toISOString() !== value) throw new OperatorError('DEVICE_IDENTITY_INVALID', `${label} must be an ISO timestamp.`);
462
+ return value;
463
+ }
464
+
465
+ function publicChallenge(challenge ) {
466
+ return {
467
+ version: 1,
468
+ purpose: PURPOSE,
469
+ challengeId: challenge.challengeId,
470
+ issuerDeviceId: challenge.issuerDeviceId,
471
+ issuerFingerprint: challenge.issuerFingerprint,
472
+ nonce: challenge.nonce,
473
+ issuedAt: challenge.issuedAt,
474
+ expiresAt: challenge.expiresAt,
475
+ expectedPeerDeviceId: challenge.expectedPeerDeviceId
476
+ };
477
+ }
@@ -0,0 +1,178 @@
1
+ import path from 'node:path';
2
+ import { OperatorError } from './errors.js';
3
+ import { readDurableStateText, writeDurableStateText } from './durable-state.js';
4
+
5
+ const MAX_RECORDS = 4096;
6
+ const RETENTION_MS = 24 * 60 * 60_000;
7
+ const MAX_STATE_BYTES = 2 * 1024 * 1024;
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+ export class DeviceResetStore {
25
+ #file ;
26
+ #clock ;
27
+ #queue = Promise.resolve();
28
+
29
+ constructor(stateDir , options = {}) {
30
+ this.#file = path.join(path.resolve(stateDir), 'device-resets.json');
31
+ this.#clock = options.clock ?? (() => new Date());
32
+ }
33
+
34
+ async get(sessionJtiInput ) {
35
+ const sessionJti = validUuid(sessionJtiInput, 'sessionJti');
36
+ const state = await this.#read();
37
+ const record = state.records.find((candidate) => candidate.sessionJti === sessionJti);
38
+ return record ? clone(record) : null;
39
+ }
40
+
41
+ async begin(input
42
+
43
+
44
+
45
+
46
+ ) {
47
+ const sessionJti = validUuid(input.sessionJti, 'sessionJti');
48
+ const deviceId = validUuid(input.deviceId, 'deviceId');
49
+ const accountId = validUuid(input.accountId, 'accountId');
50
+ const authorityGeneration = validGeneration(input.authorityGeneration);
51
+ return await this.#mutate((state) => {
52
+ const now = this.#clock();
53
+ prune(state, now.getTime());
54
+ const existing = state.records.find((candidate) => candidate.sessionJti === sessionJti);
55
+ if (existing) {
56
+ if (existing.deviceId !== deviceId || existing.accountId !== accountId || existing.authorityGeneration !== authorityGeneration) {
57
+ throw new OperatorError('DEVICE_RESET_STATE_MISMATCH', 'Device reset authority no longer matches the recorded reset request.');
58
+ }
59
+ return clone(existing);
60
+ }
61
+ if (state.records.length >= MAX_RECORDS) throw new OperatorError('DEVICE_RESET_LIMIT', `At most ${MAX_RECORDS} device reset records may be retained.`);
62
+ const created = {
63
+ sessionJti, deviceId, accountId, authorityGeneration,
64
+ phase: 'REQUESTED', requestedAt: now.toISOString(), updatedAt: now.toISOString()
65
+ };
66
+ state.records.push(created);
67
+ return clone(created);
68
+ });
69
+ }
70
+
71
+ async complete(sessionJtiInput ) {
72
+ const sessionJti = validUuid(sessionJtiInput, 'sessionJti');
73
+ return await this.#mutate((state) => {
74
+ const record = state.records.find((candidate) => candidate.sessionJti === sessionJti);
75
+ if (!record) throw new OperatorError('DEVICE_RESET_NOT_FOUND', 'Device reset request was not found.');
76
+ if (record.phase === 'COMPLETE') return clone(record);
77
+ const now = this.#clock().toISOString();
78
+ record.phase = 'COMPLETE';
79
+ record.updatedAt = now;
80
+ record.completedAt = now;
81
+ return clone(record);
82
+ });
83
+ }
84
+
85
+ async #read() {
86
+ try {
87
+ const text = await readDurableStateText(this.#file, {
88
+ maxBytes: MAX_STATE_BYTES,
89
+ errorCode: 'DEVICE_RESET_STATE_CORRUPT',
90
+ invalidMessage: 'Device reset state is invalid.'
91
+ });
92
+ return validateState(JSON.parse(text));
93
+ } catch (error) {
94
+ if ((error ).code === 'ENOENT') return { version: 1, records: [] };
95
+ if (error instanceof OperatorError) throw error;
96
+ throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', 'Device reset state could not be read.');
97
+ }
98
+ }
99
+ async #write(stateInput ) {
100
+ const state = validateState(stateInput);
101
+ await writeDurableStateText(this.#file, JSON.stringify(state, null, 2), {
102
+ maxBytes: MAX_STATE_BYTES,
103
+ errorCode: 'DEVICE_RESET_STATE_CORRUPT',
104
+ invalidMessage: 'Device reset state is invalid.'
105
+ });
106
+ }
107
+
108
+ async #mutate (mutator ) {
109
+ let release ;
110
+ const previous = this.#queue;
111
+ this.#queue = new Promise ((resolve) => { release = resolve; });
112
+ await previous;
113
+ try {
114
+ const state = await this.#read();
115
+ const result = await mutator(state);
116
+ await this.#write(state);
117
+ return result;
118
+ } finally {
119
+ release();
120
+ }
121
+ }
122
+ }
123
+
124
+ function validateState(input ) {
125
+ if (!input || typeof input !== 'object' || input.version !== 1 || !Array.isArray(input.records) || input.records.length > MAX_RECORDS) {
126
+ throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', 'Device reset state structure is invalid.');
127
+ }
128
+ const seen = new Set ();
129
+ const records = input.records.map((raw) => {
130
+ const sessionJti = validUuid(String(raw.sessionJti ?? ''), 'sessionJti');
131
+ if (seen.has(sessionJti)) throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', 'Device reset state contains duplicate session IDs.');
132
+ seen.add(sessionJti);
133
+ const deviceId = validUuid(String(raw.deviceId ?? ''), 'deviceId');
134
+ const accountId = validUuid(String(raw.accountId ?? ''), 'accountId');
135
+ const authorityGeneration = validGeneration(Number(raw.authorityGeneration));
136
+ const phase = raw.phase === 'REQUESTED' ? 'REQUESTED' : raw.phase === 'COMPLETE' ? 'COMPLETE' : null;
137
+ if (!phase) throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', 'Device reset phase is invalid.');
138
+ const requestedAt = validIso(String(raw.requestedAt ?? ''), 'requestedAt');
139
+ const updatedAt = validIso(String(raw.updatedAt ?? ''), 'updatedAt');
140
+ const completedAt = raw.completedAt === undefined ? undefined : validIso(String(raw.completedAt), 'completedAt');
141
+ if (phase === 'REQUESTED' && completedAt) throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', 'Pending device reset cannot have a completion timestamp.');
142
+ if (phase === 'COMPLETE' && !completedAt) throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', 'Completed device reset must have a completion timestamp.');
143
+ if (Date.parse(updatedAt) < Date.parse(requestedAt) || (completedAt && Date.parse(completedAt) < Date.parse(requestedAt))) {
144
+ throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', 'Device reset timestamps are inconsistent.');
145
+ }
146
+ return { sessionJti, deviceId, accountId, authorityGeneration, phase, requestedAt, updatedAt, completedAt } ;
147
+ });
148
+ return { version: 1, records };
149
+ }
150
+ function prune(state , nowMs ) {
151
+ state.records = state.records.filter((record) => record.phase !== 'COMPLETE' || nowMs - Date.parse(record.completedAt ) <= RETENTION_MS);
152
+ }
153
+
154
+ function clone(record ) {
155
+ return { ...record };
156
+ }
157
+
158
+ function validGeneration(value ) {
159
+ if (!Number.isSafeInteger(value) || value < 1) {
160
+ throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', 'Device reset authority generation is invalid.');
161
+ }
162
+ return value;
163
+ }
164
+
165
+ function validUuid(value , label ) {
166
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
167
+ throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', `${label} must be a UUID.`);
168
+ }
169
+ return value.toLowerCase();
170
+ }
171
+
172
+ function validIso(value , label ) {
173
+ const time = Date.parse(value);
174
+ if (!Number.isFinite(time) || new Date(time).toISOString() !== value) {
175
+ throw new OperatorError('DEVICE_RESET_STATE_CORRUPT', `${label} must be an ISO timestamp.`);
176
+ }
177
+ return value;
178
+ }