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,136 @@
1
+
2
+ import { PolicyEngine } from './policy.js';
3
+ import { CapabilityRouter } from './router.js';
4
+ import { evidence } from './evidence.js';
5
+ import { OperatorError } from './errors.js';
6
+ import { assertCanonicalRisk, capabilityRiskRule } from './capability-policy.js';
7
+
8
+
9
+ export class OperatorRuntime {
10
+ router ;
11
+ policy = new PolicyEngine();
12
+
13
+ constructor(options = {}) {
14
+ this.router = new CapabilityRouter({ learning: options.learning });
15
+ }
16
+
17
+ register(provider ) {
18
+ this.router.register(provider);
19
+ return this;
20
+ }
21
+
22
+ async supportedCapabilities(capabilities ) {
23
+ const supported = [];
24
+ for (const capability of [...new Set(capabilities)].sort()) {
25
+ const action = {
26
+ id: `capability-probe:${capability}`,
27
+ capability,
28
+ risk: 'read',
29
+ input: {},
30
+ provenance: { kind: 'runtime' }
31
+ };
32
+ if (await this.router.advertises(action)) supported.push(capability);
33
+ }
34
+ return supported;
35
+ }
36
+
37
+ async execute(action , permissions , context = {}) {
38
+ const start = performance.now();
39
+ if (context.signal?.aborted) return abortedResult(action, start);
40
+ let canonicalAction = action;
41
+ try {
42
+ this.policy.authorizeBase(action, permissions);
43
+ const rule = capabilityRiskRule(action.capability);
44
+ const canonicalRisk = rule === 'dynamic' ? await this.router.resolveRisk(action) : rule;
45
+ assertCanonicalRisk(action, canonicalRisk);
46
+ canonicalAction = { ...action, risk: canonicalRisk };
47
+ this.policy.authorizeRisk(canonicalAction, permissions);
48
+ } catch (error) {
49
+ const op = error instanceof OperatorError ? error : new OperatorError('POLICY_ERROR', String(error));
50
+ return {
51
+ ok: false,
52
+ capability: action.capability,
53
+ provider: 'policy',
54
+ evidence: [evidence('policy', 'fail', op.message, { code: op.code })],
55
+ error: { code: op.code, message: op.message, retryable: false },
56
+ durationMs: Math.round(performance.now() - start)
57
+ };
58
+ }
59
+
60
+ let ranked ;
61
+ try {
62
+ ranked = await this.router.rank(canonicalAction, context.learningContext ?? 'global');
63
+ } catch (error) {
64
+ const op = error instanceof OperatorError
65
+ ? error
66
+ : new OperatorError('ROUTING_STATE_ERROR', error instanceof Error ? error.message : String(error));
67
+ return {
68
+ ok: false,
69
+ capability: action.capability,
70
+ provider: 'router',
71
+ evidence: [evidence('routing', 'fail', 'Provider ranking failed closed before execution.', { code: op.code })],
72
+ error: { code: op.code, message: op.message, retryable: false },
73
+ durationMs: Math.round(performance.now() - start)
74
+ };
75
+ }
76
+ if (ranked.length === 0) {
77
+ return {
78
+ ok: false,
79
+ capability: action.capability,
80
+ provider: 'router',
81
+ evidence: [evidence('routing', 'fail', `No provider supports ${action.capability}.`)],
82
+ error: { code: 'CAPABILITY_UNAVAILABLE', message: `No provider supports ${action.capability}.`, retryable: false },
83
+ durationMs: Math.round(performance.now() - start)
84
+ };
85
+ }
86
+
87
+ const failures = [];
88
+ for (const { provider, score, baseScore, learnedAdjustment } of ranked) {
89
+ try {
90
+ if (context.signal?.aborted) return abortedResult(canonicalAction, start);
91
+ const result = await provider.execute(canonicalAction, context);
92
+ result.evidence.unshift(evidence('routing', 'info', `Selected ${provider.name}.`, { score, baseScore, learnedAdjustment }));
93
+ result.durationMs = Math.round(performance.now() - start);
94
+ if (result.ok) return result;
95
+ failures.push(result.error ?? { code: 'PROVIDER_FAILED', message: `${provider.name} failed.` });
96
+ } catch (error) {
97
+ if (context.signal?.aborted || isAbortError(error)) return abortedResult(canonicalAction, start);
98
+ const op = error instanceof OperatorError
99
+ ? error
100
+ : new OperatorError('PROVIDER_EXCEPTION', error instanceof Error ? error.message : String(error), { retryable: true });
101
+ failures.push({ code: op.code, message: op.message, retryable: op.retryable });
102
+ if (!op.retryable) break;
103
+ }
104
+ }
105
+
106
+ const last = failures.at(-1) ?? { code: 'EXECUTION_FAILED', message: 'Execution failed.' };
107
+ return {
108
+ ok: false,
109
+ capability: action.capability,
110
+ provider: ranked[0].provider.name,
111
+ evidence: [evidence('execution', 'fail', last.message, { failures })],
112
+ error: last,
113
+ durationMs: Math.round(performance.now() - start)
114
+ };
115
+ }
116
+
117
+ async close() {
118
+ await this.router.closeAll();
119
+ }
120
+ }
121
+
122
+
123
+ function isAbortError(error ) {
124
+ return error instanceof Error && (error.name === 'AbortError' || error.message === 'The operation was aborted');
125
+ }
126
+
127
+ function abortedResult(action , start ) {
128
+ return {
129
+ ok: false,
130
+ capability: action.capability,
131
+ provider: 'runtime',
132
+ evidence: [evidence('execution', 'fail', 'Execution was cancelled before completion.', { code: 'EXECUTION_ABORTED' })],
133
+ error: { code: 'EXECUTION_ABORTED', message: 'Execution was cancelled before completion.', retryable: false },
134
+ durationMs: Math.round(performance.now() - start)
135
+ };
136
+ }
@@ -0,0 +1,16 @@
1
+ import path from 'node:path';
2
+
3
+
4
+
5
+
6
+
7
+
8
+ export function normalizeScopedPathSyntax(inputPath ) {
9
+ if (path.isAbsolute(inputPath)) {
10
+ return { kind: 'native-absolute', value: path.resolve(inputPath) };
11
+ }
12
+ if (process.platform !== 'win32' && path.win32.isAbsolute(inputPath)) {
13
+ return { kind: 'foreign-windows-absolute', value: inputPath };
14
+ }
15
+ return { kind: 'relative', value: inputPath.replace(/\\/g, path.sep) };
16
+ }
@@ -0,0 +1,498 @@
1
+ import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { DeviceIdentityStore } from './device-identity.js';
4
+ import { DeviceRegistryStore, } from './device-registry.js';
5
+ import { OperatorError } from './errors.js';
6
+ import { readDurableStateText, writeDurableStateText } from './durable-state.js';
7
+
8
+ const PURPOSE = 'operator-session-v1';
9
+ const MAX_TOKEN_BYTES = 16 * 1024;
10
+ const MAX_PAYLOAD_BYTES = 8 * 1024;
11
+ const DEFAULT_TTL_MS = 30 * 60_000;
12
+ const MAX_TTL_MS = 30 * 60_000;
13
+ const MIN_TTL_MS = 30_000;
14
+ const MAX_SCOPES = 64;
15
+ const MAX_RECORDS = 4096;
16
+ const RETENTION_MS = 24 * 60 * 60_000;
17
+ const RESET_RECEIPT_RECOVERY_MS = 24 * 60 * 60_000;
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
+ export class DeviceSessionTokenStore {
45
+ #file ;
46
+ #identity ;
47
+ #registry ;
48
+ #clock ;
49
+ #onRevoke ;
50
+ #queue = Promise.resolve();
51
+
52
+ constructor(stateDir , identity , registry , options = {}) {
53
+ this.#file = path.join(path.resolve(stateDir), 'device-sessions.json');
54
+ this.#identity = identity;
55
+ this.#registry = registry;
56
+ this.#clock = options.clock ?? (() => new Date());
57
+ this.#onRevoke = options.onRevoke;
58
+ }
59
+
60
+ async issue(options ) {
61
+ const local = await this.#identity.loadOrCreate();
62
+ const peer = await this.#activePeer(options.subjectDeviceId);
63
+ const audience = validAudience(options.audience);
64
+ const scopes = validScopes(options.scopes);
65
+ const ttlMs = boundedTtl(options.ttlMs ?? DEFAULT_TTL_MS);
66
+ const now = this.#clock();
67
+ const payload = {
68
+ version: 1,
69
+ purpose: PURPOSE,
70
+ jti: crypto.randomUUID(),
71
+ issuerDeviceId: local.deviceId,
72
+ issuerFingerprint: local.fingerprint,
73
+ subjectDeviceId: peer.deviceId,
74
+ subjectFingerprint: peer.fingerprint,
75
+ audience,
76
+ scopes,
77
+ issuedAt: now.toISOString(),
78
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString()
79
+ };
80
+ const payloadBytes = encodePayload(payload);
81
+ const signature = await this.#identity.sign(payloadBytes);
82
+ const token = `${payloadBytes.toString('base64url')}.${signature}`;
83
+ if (Buffer.byteLength(token, 'utf8') > MAX_TOKEN_BYTES) throw new OperatorError('SESSION_TOKEN_TOO_LARGE', 'Generated session token exceeded the bounded size.');
84
+
85
+ await this.#mutate((state) => {
86
+ prune(state, now.getTime());
87
+ if (state.issued.length >= MAX_RECORDS) throw new OperatorError('SESSION_RECORD_LIMIT', `At most ${MAX_RECORDS} session records may be retained.`);
88
+ state.issued.push(recordFrom(payload));
89
+ });
90
+ return { token, payload };
91
+ }
92
+
93
+ async issueOrRecover(options ) {
94
+ const jti = validUuid(options.jti, 'jti');
95
+ const local = await this.#identity.loadOrCreate();
96
+ const peer = await this.#activePeer(options.subjectDeviceId);
97
+ const audience = validAudience(options.audience);
98
+ const scopes = validScopes(options.scopes);
99
+ const ttlMs = boundedTtl(options.ttlMs ?? DEFAULT_TTL_MS);
100
+ const record = await this.#mutate((state) => {
101
+ const now = this.#clock();
102
+ prune(state, now.getTime());
103
+ const existing = state.issued.find((candidate) => candidate.jti === jti);
104
+ if (existing) {
105
+ if (existing.status !== 'active' || Date.parse(existing.expiresAt) <= now.getTime()) throw new OperatorError('SESSION_REVOKED', 'Enrollment session is no longer active.');
106
+ if (existing.issuerDeviceId !== local.deviceId || existing.subjectDeviceId !== peer.deviceId || existing.audience !== audience || JSON.stringify(existing.scopes) !== JSON.stringify(scopes)) {
107
+ throw new OperatorError('SESSION_STATE_MISMATCH', 'Enrollment session identity or scope changed.');
108
+ }
109
+ return { ...existing, scopes: [...existing.scopes] };
110
+ }
111
+ if (state.issued.length >= MAX_RECORDS) throw new OperatorError('SESSION_RECORD_LIMIT', `At most ${MAX_RECORDS} session records may be retained.`);
112
+ const created = {
113
+ jti, issuerDeviceId: local.deviceId, subjectDeviceId: peer.deviceId, audience, scopes: [...scopes],
114
+ issuedAt: now.toISOString(), expiresAt: new Date(now.getTime() + ttlMs).toISOString(), status: 'active'
115
+ };
116
+ state.issued.push(created);
117
+ return { ...created, scopes: [...created.scopes] };
118
+ });
119
+ return await this.#reissue(record);
120
+ }
121
+
122
+ async rotate(jtiInput , options = {}) {
123
+ const jti = validUuid(jtiInput, 'jti');
124
+ const snapshotState = await this.#read();
125
+ const snapshot = snapshotState.issued.find((candidate) => candidate.jti === jti);
126
+ if (!snapshot) throw new OperatorError('SESSION_NOT_FOUND', 'Issued session was not found.');
127
+ const now = this.#clock();
128
+ if (snapshot.status !== 'active') {
129
+ const successorJti = rotatedSuccessorJti(snapshot.revokedReason);
130
+ const successor = successorJti ? snapshotState.issued.find((candidate) => candidate.jti === successorJti) : undefined;
131
+ if (!successor || successor.status !== 'active' || Date.parse(successor.expiresAt) <= now.getTime()) {
132
+ throw new OperatorError('SESSION_REVOKED', 'Issued session is revoked.');
133
+ }
134
+ return await this.#reissue(successor);
135
+ }
136
+ if (Date.parse(snapshot.expiresAt) <= now.getTime()) throw new OperatorError('SESSION_EXPIRED', 'Issued session has expired.');
137
+
138
+ const local = await this.#identity.loadOrCreate();
139
+ const peer = await this.#activePeer(snapshot.subjectDeviceId);
140
+ const ttlMs = boundedTtl(options.ttlMs ?? DEFAULT_TTL_MS);
141
+ const payload = {
142
+ version: 1,
143
+ purpose: PURPOSE,
144
+ jti: crypto.randomUUID(),
145
+ issuerDeviceId: local.deviceId,
146
+ issuerFingerprint: local.fingerprint,
147
+ subjectDeviceId: peer.deviceId,
148
+ subjectFingerprint: peer.fingerprint,
149
+ audience: snapshot.audience,
150
+ scopes: [...snapshot.scopes],
151
+ issuedAt: now.toISOString(),
152
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString()
153
+ };
154
+ const payloadBytes = encodePayload(payload);
155
+ const signature = await this.#identity.sign(payloadBytes);
156
+ const token = `${payloadBytes.toString('base64url')}.${signature}`;
157
+ if (Buffer.byteLength(token, 'utf8') > MAX_TOKEN_BYTES) throw new OperatorError('SESSION_TOKEN_TOO_LARGE', 'Generated session token exceeded the bounded size.');
158
+
159
+ await this.#mutate((state) => {
160
+ prune(state, now.getTime());
161
+ const current = state.issued.find((candidate) => candidate.jti === jti);
162
+ if (!current) throw new OperatorError('SESSION_NOT_FOUND', 'Issued session was not found during rotation.');
163
+ if (current.status !== 'active') throw new OperatorError('SESSION_REVOKED', 'Issued session was revoked during rotation.');
164
+ if (Date.parse(current.expiresAt) <= now.getTime()) throw new OperatorError('SESSION_EXPIRED', 'Issued session expired during rotation.');
165
+ if (current.subjectDeviceId !== snapshot.subjectDeviceId || current.audience !== snapshot.audience || JSON.stringify(current.scopes) !== JSON.stringify(snapshot.scopes)) {
166
+ throw new OperatorError('SESSION_STATE_MISMATCH', 'Issued session changed during rotation.');
167
+ }
168
+ if (state.issued.length >= MAX_RECORDS) throw new OperatorError('SESSION_RECORD_LIMIT', `At most ${MAX_RECORDS} session records may be retained.`);
169
+ current.status = 'revoked';
170
+ current.revokedAt = now.toISOString();
171
+ current.revokedReason = `rotated:${payload.jti}`;
172
+ state.issued.push(recordFrom(payload));
173
+ });
174
+ await this.#onRevoke?.(jti, snapshot.subjectDeviceId, `rotated:${payload.jti}`);
175
+ return { token, payload };
176
+ }
177
+
178
+ async revoke(jtiInput , reasonInput ) {
179
+ const jti = validUuid(jtiInput, 'jti');
180
+ const reason = reasonInput === undefined ? undefined : validReason(reasonInput);
181
+ const outcome = await this.#mutate((state) => {
182
+ const record = state.issued.find((candidate) => candidate.jti === jti);
183
+ if (!record) throw new OperatorError('SESSION_NOT_FOUND', 'Issued session was not found.');
184
+ if (record.status === 'revoked') return { record: { ...record, scopes: [...record.scopes] }, changed: false };
185
+ record.status = 'revoked';
186
+ record.revokedAt = this.#clock().toISOString();
187
+ record.revokedReason = reason;
188
+ return { record: { ...record, scopes: [...record.scopes] }, changed: true };
189
+ });
190
+ if (outcome.changed) await this.#onRevoke?.(jti, outcome.record.subjectDeviceId, reason);
191
+ return outcome.record;
192
+ }
193
+
194
+ async listIssued(limit = 100) {
195
+ const parsedLimit = Number(limit);
196
+ const bounded = Number.isFinite(parsedLimit) ? Math.min(Math.max(Math.trunc(parsedLimit), 1), 500) : 100;
197
+ const state = await this.#read();
198
+ return state.issued.slice(-bounded).reverse().map((record) => ({ ...record, scopes: [...record.scopes] }));
199
+ }
200
+
201
+ async verify(tokenInput , options ) {
202
+ return await this.#verifyToken(tokenInput, options, false);
203
+ }
204
+
205
+ async verifyForRotation(tokenInput , options ) {
206
+ return await this.#verifyToken(tokenInput, options, true);
207
+ }
208
+
209
+ async #verifyToken(tokenInput , options , allowRotatedRetry ) {
210
+ const { payload, payloadBytes, signature } = parseToken(tokenInput);
211
+ const audience = validAudience(options.audience);
212
+ if (payload.audience !== audience) throw new OperatorError('SESSION_AUDIENCE_MISMATCH', 'Session token audience does not match this service.');
213
+ const requiredScopes = validScopes(options.requiredScopes ?? []);
214
+ for (const scope of requiredScopes) {
215
+ if (!payload.scopes.includes(scope)) throw new OperatorError('SESSION_SCOPE_DENIED', `Session token is missing required scope ${scope}.`);
216
+ }
217
+ if (options.expectedSubjectDeviceId && payload.subjectDeviceId !== validUuid(options.expectedSubjectDeviceId, 'expectedSubjectDeviceId')) {
218
+ throw new OperatorError('SESSION_SUBJECT_MISMATCH', 'Session token subject does not match the expected device.');
219
+ }
220
+ const now = this.#clock().getTime();
221
+ if (Date.parse(payload.issuedAt) > now + 30_000) throw new OperatorError('SESSION_NOT_YET_VALID', 'Session token issue time is too far in the future.');
222
+ if (Date.parse(payload.expiresAt) <= now) throw new OperatorError('SESSION_EXPIRED', 'Session token has expired.');
223
+
224
+ const local = await this.#identity.loadOrCreate();
225
+ let verified = false;
226
+ if (payload.issuerDeviceId === local.deviceId) {
227
+ if (payload.issuerFingerprint !== local.fingerprint) throw new OperatorError('SESSION_ISSUER_MISMATCH', 'Local issuer fingerprint does not match this device.');
228
+ verified = await this.#identity.verify(payloadBytes, signature);
229
+ const record = (await this.#read()).issued.find((candidate) => candidate.jti === payload.jti);
230
+ if (!record) throw new OperatorError('SESSION_NOT_FOUND', 'Locally issued session is not present in the session registry.');
231
+ if (record.status !== 'active' && !(allowRotatedRetry && rotatedSuccessorJti(record.revokedReason))) {
232
+ throw new OperatorError('SESSION_REVOKED', 'Session token has been revoked.');
233
+ }
234
+ if (!sameRecord(record, payload)) throw new OperatorError('SESSION_STATE_MISMATCH', 'Session token no longer matches its issued registry record.');
235
+ } else {
236
+ const issuer = await this.#activePeer(payload.issuerDeviceId);
237
+ if (issuer.fingerprint !== payload.issuerFingerprint) throw new OperatorError('SESSION_ISSUER_MISMATCH', 'Remote issuer fingerprint does not match the paired device.');
238
+ verified = await this.#registry.verifyDeviceSignature(payload.issuerDeviceId, payloadBytes, signature);
239
+ }
240
+ if (!verified) throw new OperatorError('SESSION_SIGNATURE_INVALID', 'Session token signature could not be verified.');
241
+
242
+ const subject = payload.subjectDeviceId === local.deviceId ? local : await this.#activePeer(payload.subjectDeviceId);
243
+ if (subject.fingerprint !== payload.subjectFingerprint) throw new OperatorError('SESSION_SUBJECT_MISMATCH', 'Session token subject fingerprint does not match the registered device.');
244
+ return payload;
245
+ }
246
+
247
+ async verifyForResetReceipt(tokenInput , options ) {
248
+ const { payload, payloadBytes, signature } = parseToken(tokenInput);
249
+ const audience = validAudience(options.audience);
250
+ if (payload.audience !== audience) throw new OperatorError('SESSION_AUDIENCE_MISMATCH', 'Session token audience does not match reset receipt authority.');
251
+ for (const scope of validScopes(options.requiredScopes ?? [])) {
252
+ if (!payload.scopes.includes(scope)) throw new OperatorError('SESSION_SCOPE_DENIED', `Session token is missing required scope ${scope}.`);
253
+ }
254
+ const now = this.#clock().getTime();
255
+ if (Date.parse(payload.issuedAt) > now + 30_000 || now - Date.parse(payload.expiresAt) > RESET_RECEIPT_RECOVERY_MS) {
256
+ throw new OperatorError('SESSION_RESET_RECEIPT_EXPIRED', 'Session token is outside the reset receipt recovery window.');
257
+ }
258
+ const local = await this.#identity.loadOrCreate();
259
+ if (payload.issuerDeviceId !== local.deviceId || payload.issuerFingerprint !== local.fingerprint) throw new OperatorError('SESSION_ISSUER_MISMATCH', 'Reset receipt token issuer does not match this relay.');
260
+ if (!(await this.#identity.verify(payloadBytes, signature))) throw new OperatorError('SESSION_SIGNATURE_INVALID', 'Reset receipt token signature could not be verified.');
261
+ return payload;
262
+ }
263
+
264
+ async isActive(jtiInput , expectedSubjectDeviceId ) {
265
+ const jti = validUuid(jtiInput, 'jti');
266
+ const expectedSubject = expectedSubjectDeviceId === undefined ? undefined : validUuid(expectedSubjectDeviceId, 'expectedSubjectDeviceId');
267
+ const record = (await this.#read()).issued.find((candidate) => candidate.jti === jti);
268
+ if (!record || record.status !== 'active' || Date.parse(record.expiresAt) <= this.#clock().getTime()) return false;
269
+ if (expectedSubject && record.subjectDeviceId !== expectedSubject) return false;
270
+ try {
271
+ await this.#activePeer(record.subjectDeviceId);
272
+ } catch (error) {
273
+ if (error instanceof OperatorError && ['DEVICE_NOT_FOUND', 'DEVICE_REVOKED'].includes(error.code)) return false;
274
+ throw error;
275
+ }
276
+ return true;
277
+ }
278
+
279
+ async purgeForDevice(deviceIdInput ) {
280
+ const deviceId = validUuid(deviceIdInput, 'deviceId');
281
+ return await this.#mutate((state) => {
282
+ const before = state.issued.length;
283
+ state.issued = state.issued.filter((record) => record.subjectDeviceId !== deviceId && record.issuerDeviceId !== deviceId);
284
+ return before - state.issued.length;
285
+ });
286
+ }
287
+
288
+ async #reissue(record ) {
289
+ const local = await this.#identity.loadOrCreate();
290
+ if (record.issuerDeviceId !== local.deviceId) throw new OperatorError('SESSION_STATE_MISMATCH', 'Rotated session issuer no longer matches this authority.');
291
+ const peer = await this.#activePeer(record.subjectDeviceId);
292
+ const payload = {
293
+ version: 1, purpose: PURPOSE, jti: record.jti, issuerDeviceId: local.deviceId, issuerFingerprint: local.fingerprint,
294
+ subjectDeviceId: peer.deviceId, subjectFingerprint: peer.fingerprint, audience: record.audience, scopes: [...record.scopes],
295
+ issuedAt: record.issuedAt, expiresAt: record.expiresAt
296
+ };
297
+ const payloadBytes = encodePayload(payload);
298
+ const signature = await this.#identity.sign(payloadBytes);
299
+ const token = `${payloadBytes.toString('base64url')}.${signature}`;
300
+ if (Buffer.byteLength(token, 'utf8') > MAX_TOKEN_BYTES) throw new OperatorError('SESSION_TOKEN_TOO_LARGE', 'Generated session token exceeded the bounded size.');
301
+ return { token, payload };
302
+ }
303
+
304
+ async #activePeer(deviceIdInput ) {
305
+ const deviceId = validUuid(deviceIdInput, 'deviceId');
306
+ const peer = (await this.#registry.listDevices()).find((candidate) => candidate.deviceId === deviceId);
307
+ if (!peer) throw new OperatorError('DEVICE_NOT_FOUND', 'Session peer device is not paired.');
308
+ if (peer.status !== 'active') throw new OperatorError('DEVICE_REVOKED', 'Session peer device is revoked.');
309
+ return peer;
310
+ }
311
+
312
+ async #read() {
313
+ try {
314
+ const text = await readDurableStateText(this.#file, {
315
+ maxBytes: 2 * 1024 * 1024,
316
+ errorCode: 'SESSION_STATE_CORRUPT',
317
+ invalidMessage: 'Session registry is invalid.'
318
+ });
319
+ return validateState(JSON.parse(text));
320
+ } catch (error) {
321
+ if ((error ).code === 'ENOENT') return { version: 1, issued: [] };
322
+ if (error instanceof OperatorError) throw error;
323
+ throw new OperatorError('SESSION_STATE_CORRUPT', 'Session registry could not be read.');
324
+ }
325
+ }
326
+
327
+ async #write(stateInput ) {
328
+ const state = validateState(stateInput);
329
+ await writeDurableStateText(this.#file, JSON.stringify(state, null, 2), {
330
+ maxBytes: 2 * 1024 * 1024,
331
+ errorCode: 'SESSION_STATE_CORRUPT',
332
+ invalidMessage: 'Session registry is invalid.'
333
+ });
334
+ }
335
+
336
+ async #mutate (mutator ) {
337
+ let release ;
338
+ const previous = this.#queue;
339
+ this.#queue = new Promise ((resolve) => { release = resolve; });
340
+ await previous;
341
+ try {
342
+ const state = await this.#read();
343
+ const result = await mutator(state);
344
+ await this.#write(state);
345
+ return result;
346
+ } finally {
347
+ release();
348
+ }
349
+ }
350
+ }
351
+
352
+ function encodePayload(payloadInput ) {
353
+ const payload = validatePayload(payloadInput);
354
+ const bytes = Buffer.from(JSON.stringify({
355
+ version: 1,
356
+ purpose: PURPOSE,
357
+ jti: payload.jti,
358
+ issuerDeviceId: payload.issuerDeviceId,
359
+ issuerFingerprint: payload.issuerFingerprint,
360
+ subjectDeviceId: payload.subjectDeviceId,
361
+ subjectFingerprint: payload.subjectFingerprint,
362
+ audience: payload.audience,
363
+ scopes: payload.scopes,
364
+ issuedAt: payload.issuedAt,
365
+ expiresAt: payload.expiresAt
366
+ }), 'utf8');
367
+ if (bytes.byteLength > MAX_PAYLOAD_BYTES) throw new OperatorError('SESSION_TOKEN_TOO_LARGE', 'Session token payload exceeds the bounded size.');
368
+ return bytes;
369
+ }
370
+
371
+ function parseToken(tokenInput ) {
372
+ const token = String(tokenInput ?? '');
373
+ if (!token || Buffer.byteLength(token, 'utf8') > MAX_TOKEN_BYTES) throw new OperatorError('SESSION_TOKEN_INVALID', 'Session token is missing or too large.');
374
+ const parts = token.split('.');
375
+ if (parts.length !== 2 || !parts[0] || !/^[A-Za-z0-9_-]{40,256}$/.test(parts[1])) throw new OperatorError('SESSION_TOKEN_INVALID', 'Session token format is invalid.');
376
+ let payloadBytes ;
377
+ let parsed ;
378
+ try {
379
+ payloadBytes = Buffer.from(parts[0], 'base64url');
380
+ if (payloadBytes.byteLength > MAX_PAYLOAD_BYTES) throw new Error('large');
381
+ parsed = JSON.parse(payloadBytes.toString('utf8'));
382
+ } catch { throw new OperatorError('SESSION_TOKEN_INVALID', 'Session token payload is invalid.'); }
383
+ const payload = validatePayload(parsed );
384
+ if (!payloadBytes.equals(encodePayload(payload))) throw new OperatorError('SESSION_TOKEN_INVALID', 'Session token payload is not in canonical form.');
385
+ return { payload, payloadBytes, signature: parts[1] };
386
+ }
387
+
388
+ function validatePayload(input ) {
389
+ if (!input || typeof input !== 'object' || input.version !== 1 || input.purpose !== PURPOSE) throw new OperatorError('SESSION_TOKEN_INVALID', 'Session token payload version or purpose is invalid.');
390
+ const jti = validUuid(String(input.jti ?? ''), 'jti');
391
+ const issuerDeviceId = validUuid(String(input.issuerDeviceId ?? ''), 'issuerDeviceId');
392
+ const subjectDeviceId = validUuid(String(input.subjectDeviceId ?? ''), 'subjectDeviceId');
393
+ const issuerFingerprint = validFingerprint(String(input.issuerFingerprint ?? ''), 'issuerFingerprint');
394
+ const subjectFingerprint = validFingerprint(String(input.subjectFingerprint ?? ''), 'subjectFingerprint');
395
+ const audience = validAudience(String(input.audience ?? ''));
396
+ const scopes = validScopes(input.scopes);
397
+ const issuedAt = validIso(String(input.issuedAt ?? ''), 'issuedAt');
398
+ const expiresAt = validIso(String(input.expiresAt ?? ''), 'expiresAt');
399
+ const lifetime = Date.parse(expiresAt) - Date.parse(issuedAt);
400
+ if (lifetime < MIN_TTL_MS || lifetime > MAX_TTL_MS) throw new OperatorError('SESSION_TOKEN_INVALID', 'Session token lifetime is outside the allowed range.');
401
+ return { version: 1, purpose: PURPOSE, jti, issuerDeviceId, issuerFingerprint, subjectDeviceId, subjectFingerprint, audience, scopes, issuedAt, expiresAt };
402
+ }
403
+
404
+ function validateState(input ) {
405
+ if (!input || typeof input !== 'object' || input.version !== 1 || !Array.isArray(input.issued) || input.issued.length > MAX_RECORDS) throw new OperatorError('SESSION_STATE_CORRUPT', 'Session registry structure is invalid.');
406
+ const seen = new Set ();
407
+ const issued = input.issued.map((raw) => {
408
+ const jti = validUuid(String(raw.jti ?? ''), 'jti');
409
+ if (seen.has(jti)) throw new OperatorError('SESSION_STATE_CORRUPT', 'Session registry contains duplicate token IDs.');
410
+ seen.add(jti);
411
+ const issuerDeviceId = validUuid(String(raw.issuerDeviceId ?? ''), 'issuerDeviceId');
412
+ const subjectDeviceId = validUuid(String(raw.subjectDeviceId ?? ''), 'subjectDeviceId');
413
+ const audience = validAudience(String(raw.audience ?? ''));
414
+ const scopes = validScopes(raw.scopes);
415
+ const issuedAt = validIso(String(raw.issuedAt ?? ''), 'issuedAt');
416
+ const expiresAt = validIso(String(raw.expiresAt ?? ''), 'expiresAt');
417
+ const status = raw.status === 'active' ? 'active' : raw.status === 'revoked' ? 'revoked' : null;
418
+ if (!status) throw new OperatorError('SESSION_STATE_CORRUPT', 'Session status is invalid.');
419
+ const revokedAt = raw.revokedAt === undefined ? undefined : validIso(String(raw.revokedAt), 'revokedAt');
420
+ const revokedReason = raw.revokedReason === undefined ? undefined : validReason(String(raw.revokedReason));
421
+ if (status === 'active' && revokedAt) throw new OperatorError('SESSION_STATE_CORRUPT', 'Active session cannot have a revocation timestamp.');
422
+ return { jti, issuerDeviceId, subjectDeviceId, audience, scopes, issuedAt, expiresAt, status, revokedAt, revokedReason } ;
423
+ });
424
+ return { version: 1, issued };
425
+ }
426
+
427
+ function recordFrom(payload ) {
428
+ return {
429
+ jti: payload.jti,
430
+ issuerDeviceId: payload.issuerDeviceId,
431
+ subjectDeviceId: payload.subjectDeviceId,
432
+ audience: payload.audience,
433
+ scopes: [...payload.scopes],
434
+ issuedAt: payload.issuedAt,
435
+ expiresAt: payload.expiresAt,
436
+ status: 'active'
437
+ };
438
+ }
439
+
440
+ function sameRecord(record , payload ) {
441
+ return record.jti === payload.jti && record.issuerDeviceId === payload.issuerDeviceId && record.subjectDeviceId === payload.subjectDeviceId && record.audience === payload.audience && record.issuedAt === payload.issuedAt && record.expiresAt === payload.expiresAt && JSON.stringify(record.scopes) === JSON.stringify(payload.scopes);
442
+ }
443
+
444
+ function prune(state , nowMs ) {
445
+ state.issued = state.issued.filter((record) => {
446
+ if (record.status === 'revoked' && rotatedSuccessorJti(record.revokedReason)) {
447
+ return Date.parse(record.expiresAt) > nowMs;
448
+ }
449
+ return nowMs - Date.parse(record.expiresAt) <= RETENTION_MS;
450
+ });
451
+ }
452
+
453
+ function boundedTtl(value ) {
454
+ if (!Number.isInteger(value) || value < MIN_TTL_MS || value > MAX_TTL_MS) throw new OperatorError('SESSION_TTL_INVALID', `Session TTL must be between ${MIN_TTL_MS} and ${MAX_TTL_MS} ms.`);
455
+ return value;
456
+ }
457
+
458
+ function validScopes(input ) {
459
+ if (!Array.isArray(input) || input.length > MAX_SCOPES) throw new OperatorError('SESSION_SCOPE_INVALID', `Session scopes must contain at most ${MAX_SCOPES} entries.`);
460
+ const scopes = input.map((scope) => String(scope));
461
+ for (const scope of scopes) {
462
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/*-]{0,127}$/.test(scope)) throw new OperatorError('SESSION_SCOPE_INVALID', `Invalid session scope ${scope}.`);
463
+ }
464
+ return [...new Set(scopes)].sort();
465
+ }
466
+
467
+ function validAudience(value ) {
468
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) throw new OperatorError('SESSION_AUDIENCE_INVALID', 'Session audience is invalid.');
469
+ return value;
470
+ }
471
+
472
+ function validFingerprint(value , label ) {
473
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(value)) throw new OperatorError('SESSION_TOKEN_INVALID', `${label} is invalid.`);
474
+ return value;
475
+ }
476
+
477
+ function validUuid(value , label ) {
478
+ 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)) throw new OperatorError('SESSION_TOKEN_INVALID', `${label} must be a UUID.`);
479
+ return value.toLowerCase();
480
+ }
481
+
482
+ function validIso(value , label ) {
483
+ const time = Date.parse(value);
484
+ if (!Number.isFinite(time) || new Date(time).toISOString() !== value) throw new OperatorError('SESSION_TOKEN_INVALID', `${label} must be an ISO timestamp.`);
485
+ return value;
486
+ }
487
+
488
+ function rotatedSuccessorJti(reason ) {
489
+ if (!reason?.startsWith('rotated:')) return undefined;
490
+ const candidate = reason.slice('rotated:'.length);
491
+ return /^[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(candidate) ? candidate.toLowerCase() : undefined;
492
+ }
493
+
494
+ function validReason(value ) {
495
+ const trimmed = value.trim();
496
+ if (!trimmed || trimmed.length > 512 || /\0/.test(trimmed)) throw new OperatorError('SESSION_REVOCATION_REASON_INVALID', 'Session revocation reason is invalid.');
497
+ return trimmed;
498
+ }