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.
- package/DISCLOSURE +47 -0
- package/LICENSE +64 -0
- package/README.md +44 -0
- package/bin/operator.mjs +10 -0
- package/package.json +58 -0
- package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
- package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
- package/runtime/app/apps/local-agent/src/cli.js +237 -0
- package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
- package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
- package/runtime/app/apps/local-agent/src/main.js +311 -0
- package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
- package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
- package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
- package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
- package/runtime/app/apps/local-agent/src/remote.js +130 -0
- package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
- package/runtime/app/apps/local-agent/src/server.js +578 -0
- package/runtime/app/package.json +4 -0
- package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
- package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
- package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
- package/runtime/app/src/capabilities/browser-cdp.js +384 -0
- package/runtime/app/src/capabilities/browser-managed.js +345 -0
- package/runtime/app/src/capabilities/docker.js +448 -0
- package/runtime/app/src/capabilities/filesystem.js +264 -0
- package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
- package/runtime/app/src/capabilities/git-write.js +321 -0
- package/runtime/app/src/capabilities/git.js +252 -0
- package/runtime/app/src/capabilities/index.js +16 -0
- package/runtime/app/src/capabilities/path-scope.js +86 -0
- package/runtime/app/src/capabilities/postgres.js +566 -0
- package/runtime/app/src/capabilities/process.js +258 -0
- package/runtime/app/src/capabilities/project-command.js +599 -0
- package/runtime/app/src/capabilities/project-transaction.js +215 -0
- package/runtime/app/src/capabilities/project.js +67 -0
- package/runtime/app/src/capabilities/system.js +40 -0
- package/runtime/app/src/capabilities/vscode.js +312 -0
- package/runtime/app/src/capabilities/windows-uia.js +331 -0
- package/runtime/app/src/core/account-device-registry.js +604 -0
- package/runtime/app/src/core/action-identity.js +44 -0
- package/runtime/app/src/core/audit.js +295 -0
- package/runtime/app/src/core/capability-policy.js +58 -0
- package/runtime/app/src/core/child-environment.js +29 -0
- package/runtime/app/src/core/device-enrollment.js +417 -0
- package/runtime/app/src/core/device-identity.js +436 -0
- package/runtime/app/src/core/device-registry.js +477 -0
- package/runtime/app/src/core/device-reset.js +178 -0
- package/runtime/app/src/core/device-routing.js +305 -0
- package/runtime/app/src/core/durable-state.js +306 -0
- package/runtime/app/src/core/errors.js +20 -0
- package/runtime/app/src/core/evidence.js +9 -0
- package/runtime/app/src/core/index.js +17 -0
- package/runtime/app/src/core/machine-state.js +160 -0
- package/runtime/app/src/core/network-authority.js +20 -0
- package/runtime/app/src/core/policy.js +67 -0
- package/runtime/app/src/core/product-identity.js +7 -0
- package/runtime/app/src/core/provenance.js +18 -0
- package/runtime/app/src/core/provider-learning.js +274 -0
- package/runtime/app/src/core/public-plugin-surface.js +25 -0
- package/runtime/app/src/core/public-restricted-data.js +105 -0
- package/runtime/app/src/core/rate-limit.js +118 -0
- package/runtime/app/src/core/relay-client.js +568 -0
- package/runtime/app/src/core/relay-delivery-store.js +512 -0
- package/runtime/app/src/core/relay-result-store.js +321 -0
- package/runtime/app/src/core/release-update.js +253 -0
- package/runtime/app/src/core/router.js +96 -0
- package/runtime/app/src/core/runtime.js +136 -0
- package/runtime/app/src/core/scoped-path-syntax.js +16 -0
- package/runtime/app/src/core/session-token.js +498 -0
- package/runtime/app/src/core/task-orchestrator.js +1148 -0
- package/runtime/app/src/core/task-store.js +517 -0
- package/runtime/app/src/core/task.js +142 -0
- package/runtime/app/src/core/trusted-executable.js +124 -0
- package/runtime/app/src/core/types.js +83 -0
- package/runtime/app/src/core/windows-path-lease.js +138 -0
- package/runtime/native/operator-windows-dpapi.exe +0 -0
- package/runtime/native/operator-windows-path-lease.exe +0 -0
- package/runtime/native/operator-windows-uia.exe +0 -0
- package/runtime/runtime-manifest.json +380 -0
- package/src/cli.mjs +345 -0
- package/src/publish-check.mjs +22 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DeviceRegistryStore } from './device-registry.js';
|
|
5
|
+
import { OperatorError } from './errors.js';
|
|
6
|
+
import { readDurableStateText, writeDurableStateText } from './durable-state.js';
|
|
7
|
+
|
|
8
|
+
const MAX_ACCOUNTS = 100_000;
|
|
9
|
+
const MAX_MEMBERSHIPS = 500_000;
|
|
10
|
+
export const MAX_ACTIVE_DEVICES_PER_ACCOUNT = 32;
|
|
11
|
+
export const MAX_KNOWN_DEVICES_PER_ACCOUNT = 128;
|
|
12
|
+
const MAX_AUTH_FIELD = 1024;
|
|
13
|
+
const ERASURE_TOMBSTONE_RETENTION_MS = 24 * 60 * 60_000;
|
|
14
|
+
|
|
15
|
+
|
|
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
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
export class AccountDeviceRegistry {
|
|
71
|
+
#file ;
|
|
72
|
+
#stateDir ;
|
|
73
|
+
#devices ;
|
|
74
|
+
#clock ;
|
|
75
|
+
#onReleaseDevice ;
|
|
76
|
+
#onErasurePhase ;
|
|
77
|
+
#afterErasurePhase ;
|
|
78
|
+
#queue = Promise.resolve();
|
|
79
|
+
|
|
80
|
+
constructor(stateDir , devices , options = {}) {
|
|
81
|
+
this.#stateDir = path.resolve(stateDir);
|
|
82
|
+
this.#file = path.join(this.#stateDir, 'account-devices.json');
|
|
83
|
+
this.#devices = devices;
|
|
84
|
+
this.#clock = options.clock ?? (() => new Date());
|
|
85
|
+
this.#onReleaseDevice = options.onReleaseDevice;
|
|
86
|
+
this.#onErasurePhase = options.onErasurePhase;
|
|
87
|
+
this.#afterErasurePhase = options.afterErasurePhase;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async resolveOrCreateAccount(principalInput ) {
|
|
91
|
+
const principalHash = hashPrincipal(principalInput);
|
|
92
|
+
return await this.#mutate((state) => {
|
|
93
|
+
const existing = state.accounts.find((account) => account.principalHash === principalHash);
|
|
94
|
+
if (existing) {
|
|
95
|
+
if (existing.status === 'erasing') throw new OperatorError('ACCOUNT_ERASING', 'Operator account erasure is in progress.');
|
|
96
|
+
if (existing.status !== 'active') throw new OperatorError('ACCOUNT_DISABLED', 'Operator account is disabled.');
|
|
97
|
+
return cloneAccount(existing);
|
|
98
|
+
}
|
|
99
|
+
if (state.accounts.length >= MAX_ACCOUNTS) throw new OperatorError('ACCOUNT_LIMIT', `At most ${MAX_ACCOUNTS} accounts may be stored.`);
|
|
100
|
+
const account = {
|
|
101
|
+
accountId: crypto.randomUUID(),
|
|
102
|
+
principalHash,
|
|
103
|
+
status: 'active',
|
|
104
|
+
createdAt: this.#clock().toISOString()
|
|
105
|
+
};
|
|
106
|
+
state.accounts.push(account);
|
|
107
|
+
return cloneAccount(account);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async getAccount(principalInput ) {
|
|
112
|
+
const principalHash = hashPrincipal(principalInput);
|
|
113
|
+
const state = await this.#read();
|
|
114
|
+
const account = state.accounts.find((candidate) => candidate.principalHash === principalHash);
|
|
115
|
+
return account ? cloneAccount(account) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async disableAccount(accountIdInput , reasonInput ) {
|
|
119
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
120
|
+
const reason = boundedReason(reasonInput, 'account disable reason');
|
|
121
|
+
return await this.#withQueue(async () => {
|
|
122
|
+
const state = await this.#read();
|
|
123
|
+
pruneCompletedErasures(state, this.#clock().getTime());
|
|
124
|
+
const account = state.accounts.find((candidate) => candidate.accountId === accountId);
|
|
125
|
+
if (!account) throw new OperatorError('ACCOUNT_NOT_FOUND', 'Operator account was not found.');
|
|
126
|
+
if (account.status === 'erasing') throw new OperatorError('ACCOUNT_ERASING', 'Operator account erasure is in progress.');
|
|
127
|
+
if (account.status !== 'disabled') {
|
|
128
|
+
const at = this.#clock().toISOString();
|
|
129
|
+
account.status = 'disabled'; account.disabledAt = at; account.disabledReason = reason;
|
|
130
|
+
for (const membership of state.memberships.filter((m) => m.accountId === accountId && m.status === 'active')) {
|
|
131
|
+
membership.status = 'removed'; membership.removedAt = at; membership.removedReason = 'account-disabled'; membership.releasePendingReason = 'disabled';
|
|
132
|
+
}
|
|
133
|
+
await this.#write(state);
|
|
134
|
+
}
|
|
135
|
+
await this.#drainPendingReleasesLocked(state, { accountId });
|
|
136
|
+
return cloneAccount(account);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async assertCanBindDevice(accountIdInput , deviceIdInput ) {
|
|
141
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
142
|
+
const deviceId = deviceIdInput === undefined ? undefined : validUuid(deviceIdInput, 'deviceId');
|
|
143
|
+
const state = await this.#read();
|
|
144
|
+
requireActiveAccount(state, accountId);
|
|
145
|
+
if (deviceId && state.memberships.some((m) => m.accountId === accountId && m.deviceId === deviceId && m.status === 'active')) return;
|
|
146
|
+
const accountMemberships = state.memberships.filter((m) => m.accountId === accountId);
|
|
147
|
+
const activeCount = accountMemberships.filter((m) => m.status === 'active').length;
|
|
148
|
+
if (activeCount >= MAX_ACTIVE_DEVICES_PER_ACCOUNT) {
|
|
149
|
+
throw new OperatorError('ACCOUNT_DEVICE_QUOTA', `An account may have at most ${MAX_ACTIVE_DEVICES_PER_ACCOUNT} active devices.`);
|
|
150
|
+
}
|
|
151
|
+
const knownDeviceIds = new Set(accountMemberships.map((m) => m.deviceId));
|
|
152
|
+
if (deviceId && !knownDeviceIds.has(deviceId) && knownDeviceIds.size >= MAX_KNOWN_DEVICES_PER_ACCOUNT) {
|
|
153
|
+
throw new OperatorError('ACCOUNT_DEVICE_QUOTA', `An account may retain at most ${MAX_KNOWN_DEVICES_PER_ACCOUNT} distinct device identities.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async bindDevice(accountIdInput , deviceIdInput ) {
|
|
158
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
159
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
160
|
+
const device = (await this.#devices.listDevices()).find((candidate) => candidate.deviceId === deviceId);
|
|
161
|
+
if (!device) throw new OperatorError('DEVICE_NOT_FOUND', 'Cannot bind an unpaired device to an account.');
|
|
162
|
+
if (device.status !== 'active') throw new OperatorError('DEVICE_REVOKED', 'Cannot bind a revoked device to an account.');
|
|
163
|
+
return await this.#mutate(async (state) => {
|
|
164
|
+
requireActiveAccount(state, accountId);
|
|
165
|
+
const active = state.memberships.find((m) => m.deviceId === deviceId && m.status === 'active');
|
|
166
|
+
if (active && active.accountId !== accountId) throw new OperatorError('DEVICE_ACCOUNT_CONFLICT', 'Device is already bound to a different active account.');
|
|
167
|
+
if (active) return cloneMembership(active);
|
|
168
|
+
const accountMemberships = state.memberships.filter((m) => m.accountId === accountId);
|
|
169
|
+
const activeCount = accountMemberships.filter((m) => m.status === 'active').length;
|
|
170
|
+
if (activeCount >= MAX_ACTIVE_DEVICES_PER_ACCOUNT) throw new OperatorError('ACCOUNT_DEVICE_QUOTA', `An account may have at most ${MAX_ACTIVE_DEVICES_PER_ACCOUNT} active devices.`);
|
|
171
|
+
const knownDeviceIds = new Set(accountMemberships.map((m) => m.deviceId));
|
|
172
|
+
if (!knownDeviceIds.has(deviceId) && knownDeviceIds.size >= MAX_KNOWN_DEVICES_PER_ACCOUNT) {
|
|
173
|
+
throw new OperatorError('ACCOUNT_DEVICE_QUOTA', `An account may retain at most ${MAX_KNOWN_DEVICES_PER_ACCOUNT} distinct device identities.`);
|
|
174
|
+
}
|
|
175
|
+
if (state.memberships.some((m) => m.deviceId === deviceId && m.releasePendingReason)) {
|
|
176
|
+
throw new OperatorError('DEVICE_RELEASE_PENDING', 'Device cleanup from its previous authority has not completed yet.');
|
|
177
|
+
}
|
|
178
|
+
const priorOwners = [...new Set(state.memberships.filter((m) => m.deviceId === deviceId && m.status === 'removed').map((m) => m.accountId))];
|
|
179
|
+
for (const priorAccountId of priorOwners) await this.#releaseDevice(deviceId, priorAccountId, 'rebind');
|
|
180
|
+
const currentDevice = (await this.#devices.listDevices()).find((candidate) => candidate.deviceId === deviceId);
|
|
181
|
+
if (!currentDevice) throw new OperatorError('DEVICE_NOT_FOUND', 'Cannot bind a device whose cryptographic registration was removed during authority transfer.');
|
|
182
|
+
if (currentDevice.status !== 'active') throw new OperatorError('DEVICE_REVOKED', 'Cannot bind a revoked device to an account.');
|
|
183
|
+
if (state.memberships.length >= MAX_MEMBERSHIPS) throw new OperatorError('ACCOUNT_DEVICE_LIMIT', `At most ${MAX_MEMBERSHIPS} account-device memberships may be stored.`);
|
|
184
|
+
const authorityGeneration = Math.max(0, ...state.memberships.filter((m) => m.deviceId === deviceId).map((m) => m.authorityGeneration ?? 1)) + 1;
|
|
185
|
+
const membership = { accountId, deviceId, status: 'active', addedAt: this.#clock().toISOString(), authorityGeneration };
|
|
186
|
+
state.memberships.push(membership);
|
|
187
|
+
return cloneMembership(membership);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async removeDevice(accountIdInput , deviceIdInput , reasonInput ) {
|
|
192
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
193
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
194
|
+
const reason = boundedReason(reasonInput, 'device removal reason');
|
|
195
|
+
return await this.#withQueue(async () => {
|
|
196
|
+
const state = await this.#read();
|
|
197
|
+
pruneCompletedErasures(state, this.#clock().getTime());
|
|
198
|
+
requireActiveAccount(state, accountId);
|
|
199
|
+
let membership = state.memberships.find((m) => m.accountId === accountId && m.deviceId === deviceId && m.status === 'active');
|
|
200
|
+
if (membership) {
|
|
201
|
+
membership.status = 'removed'; membership.removedAt = this.#clock().toISOString(); membership.removedReason = reason; membership.releasePendingReason = 'removed';
|
|
202
|
+
await this.#write(state);
|
|
203
|
+
} else {
|
|
204
|
+
membership = state.memberships.find((m) => m.accountId === accountId && m.deviceId === deviceId && m.status === 'removed' && m.releasePendingReason === 'removed');
|
|
205
|
+
if (!membership) throw new OperatorError('ACCOUNT_DEVICE_NOT_FOUND', 'Active account-device membership was not found.');
|
|
206
|
+
}
|
|
207
|
+
await this.#drainPendingReleasesLocked(state, { accountId, deviceId });
|
|
208
|
+
return cloneMembership(membership);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async eraseAccount(accountIdInput ) {
|
|
213
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
214
|
+
const started = await this.#withQueue(async () => {
|
|
215
|
+
const state = await this.#read();
|
|
216
|
+
pruneCompletedErasures(state, this.#clock().getTime());
|
|
217
|
+
const existing = state.erasures.find((entry) => entry.accountId === accountId && entry.phase !== 'COMPLETE');
|
|
218
|
+
await this.#drainPendingReleasesLocked(state, { accountId });
|
|
219
|
+
if (existing) return { journal: cloneErasure(existing), created: false };
|
|
220
|
+
const account = state.accounts.find((candidate) => candidate.accountId === accountId);
|
|
221
|
+
if (!account) throw new OperatorError('ACCOUNT_NOT_FOUND', 'Operator account was not found.');
|
|
222
|
+
const now = this.#clock().toISOString();
|
|
223
|
+
const deviceIds = [...new Set(state.memberships.filter((m) => m.accountId === accountId && m.status === 'active').map((m) => m.deviceId))].sort();
|
|
224
|
+
account.status = 'erasing';
|
|
225
|
+
const created = { erasureId: crypto.randomUUID(), accountId, deviceIds, phase: 'REQUESTED', requestedAt: now, updatedAt: now };
|
|
226
|
+
state.erasures.push(created);
|
|
227
|
+
await this.#write(state);
|
|
228
|
+
return { journal: cloneErasure(created), created: true };
|
|
229
|
+
});
|
|
230
|
+
if (started.created) await this.#afterErasurePhase?.('REQUESTED', accountId);
|
|
231
|
+
return await this.#resumeErasure(started.journal.erasureId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async recoverReleases() {
|
|
235
|
+
return await this.#withQueue(async () => {
|
|
236
|
+
const state = await this.#read();
|
|
237
|
+
pruneCompletedErasures(state, this.#clock().getTime());
|
|
238
|
+
return await this.#drainPendingReleasesLocked(state);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async recoverErasures() {
|
|
243
|
+
await this.recoverReleases();
|
|
244
|
+
const state = await this.#read();
|
|
245
|
+
const pending = state.erasures.filter((entry) => entry.phase !== 'COMPLETE').map((entry) => entry.erasureId);
|
|
246
|
+
for (const erasureId of pending) await this.#resumeErasure(erasureId);
|
|
247
|
+
return pending.length;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async erasePrincipal(principalInput ) {
|
|
251
|
+
const account = await this.getAccount(principalInput);
|
|
252
|
+
if (!account) return { erased: false, releasedDeviceIds: [] };
|
|
253
|
+
const erased = await this.eraseAccount(account.accountId);
|
|
254
|
+
return { erased: true, ...erased };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async listDevices(accountIdInput ) {
|
|
258
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
259
|
+
const state = await this.#read();
|
|
260
|
+
requireActiveAccount(state, accountId);
|
|
261
|
+
const cryptoDevices = new Map((await this.#devices.listDevices()).map((device) => [device.deviceId, device.status]));
|
|
262
|
+
return state.memberships
|
|
263
|
+
.filter((membership) => membership.accountId === accountId && membership.status === 'active' && cryptoDevices.get(membership.deviceId) === 'active')
|
|
264
|
+
.map(cloneMembership);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async activeMembershipForDevice(deviceIdInput ) {
|
|
268
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
269
|
+
const state = await this.#read();
|
|
270
|
+
const membership = state.memberships.find((candidate) => candidate.deviceId === deviceId && candidate.status === 'active');
|
|
271
|
+
if (!membership) return null;
|
|
272
|
+
const account = state.accounts.find((candidate) => candidate.accountId === membership.accountId);
|
|
273
|
+
if (!account || account.status !== 'active') return null;
|
|
274
|
+
const device = (await this.#devices.listDevices()).find((candidate) => candidate.deviceId === deviceId);
|
|
275
|
+
return device?.status === 'active' ? cloneMembership(membership) : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async ownsDevice(accountIdInput , deviceIdInput ) {
|
|
279
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
280
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
281
|
+
const state = await this.#read();
|
|
282
|
+
const account = state.accounts.find((candidate) => candidate.accountId === accountId);
|
|
283
|
+
if (!account || account.status !== 'active') return false;
|
|
284
|
+
const membership = state.memberships.find((candidate) => candidate.accountId === accountId && candidate.deviceId === deviceId && candidate.status === 'active');
|
|
285
|
+
if (!membership) return false;
|
|
286
|
+
const device = (await this.#devices.listDevices()).find((candidate) => candidate.deviceId === deviceId);
|
|
287
|
+
return device?.status === 'active';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async withActiveAuthorityLease (authorityInput , work ) {
|
|
291
|
+
const accountId = validUuid(authorityInput?.accountId, 'authority accountId');
|
|
292
|
+
const deviceId = validUuid(authorityInput?.deviceId, 'authority deviceId');
|
|
293
|
+
const generation = Number(authorityInput?.generation);
|
|
294
|
+
if (!Number.isSafeInteger(generation) || generation < 1) throw new OperatorError('ACCOUNT_AUTHORITY_INVALID', 'Account-device authority generation is invalid.');
|
|
295
|
+
if (typeof work !== 'function') throw new OperatorError('ACCOUNT_AUTHORITY_INVALID', 'Account-device authority lease work is invalid.');
|
|
296
|
+
return await this.#withQueue(async () => {
|
|
297
|
+
const state = await this.#read();
|
|
298
|
+
const account = state.accounts.find((candidate) => candidate.accountId === accountId);
|
|
299
|
+
const membership = state.memberships.find((candidate) => candidate.accountId === accountId && candidate.deviceId === deviceId && candidate.status === 'active');
|
|
300
|
+
if (!account || account.status !== 'active' || !membership || membership.authorityGeneration !== generation) {
|
|
301
|
+
throw new OperatorError('ACCOUNT_AUTHORITY_REVOKED', 'Account-device authority is no longer active.');
|
|
302
|
+
}
|
|
303
|
+
const device = (await this.#devices.listDevices()).find((candidate) => candidate.deviceId === deviceId);
|
|
304
|
+
if (!device || device.status !== 'active') throw new OperatorError('ACCOUNT_AUTHORITY_REVOKED', 'Device cryptographic authority is no longer active.');
|
|
305
|
+
return await work();
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async #resumeErasure(erasureIdInput ) {
|
|
310
|
+
const erasureId = validUuid(erasureIdInput, 'erasureId');
|
|
311
|
+
while (true) {
|
|
312
|
+
const state = await this.#read();
|
|
313
|
+
const journal = state.erasures.find((entry) => entry.erasureId === erasureId);
|
|
314
|
+
if (!journal) throw new OperatorError('ACCOUNT_ERASURE_NOT_FOUND', 'Account erasure journal was not found.');
|
|
315
|
+
const accountId = journal.accountId;
|
|
316
|
+
const deviceIds = [...journal.deviceIds];
|
|
317
|
+
if (journal.phase === 'COMPLETE') return { accountId, releasedDeviceIds: deviceIds };
|
|
318
|
+
|
|
319
|
+
if (journal.phase === 'REQUESTED') {
|
|
320
|
+
await this.#advanceErasure(erasureId, 'REQUESTED', 'AUTHORITY_REVOKED');
|
|
321
|
+
await this.#afterErasurePhase?.('AUTHORITY_REVOKED', accountId);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (journal.phase === 'AUTHORITY_REVOKED') {
|
|
325
|
+
if (this.#onErasurePhase) await this.#onErasurePhase('LIVE_CONNECTIONS_CLOSED', accountId, deviceIds);
|
|
326
|
+
else for (const deviceId of deviceIds) await this.#releaseDevice(deviceId, accountId, 'erased');
|
|
327
|
+
await this.#advanceErasure(erasureId, 'AUTHORITY_REVOKED', 'LIVE_CONNECTIONS_CLOSED');
|
|
328
|
+
await this.#afterErasurePhase?.('LIVE_CONNECTIONS_CLOSED', accountId);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (journal.phase === 'LIVE_CONNECTIONS_CLOSED') {
|
|
332
|
+
await this.#onErasurePhase?.('ROUTING_DISABLED', accountId, deviceIds);
|
|
333
|
+
await this.#advanceErasure(erasureId, 'LIVE_CONNECTIONS_CLOSED', 'ROUTING_DISABLED');
|
|
334
|
+
await this.#afterErasurePhase?.('ROUTING_DISABLED', accountId);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (journal.phase === 'ROUTING_DISABLED') {
|
|
338
|
+
await this.#onErasurePhase?.('DELIVERY_SESSION_RESULT_PURGE', accountId, deviceIds);
|
|
339
|
+
await this.#advanceErasure(erasureId, 'ROUTING_DISABLED', 'DELIVERY_SESSION_RESULT_PURGE');
|
|
340
|
+
await this.#afterErasurePhase?.('DELIVERY_SESSION_RESULT_PURGE', accountId);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (journal.phase === 'DELIVERY_SESSION_RESULT_PURGE') {
|
|
344
|
+
const accountDir = await safeAccountEraseTarget(this.#stateDir, accountId);
|
|
345
|
+
await fs.rm(accountDir, { recursive: true, force: true });
|
|
346
|
+
await this.#advanceErasure(erasureId, 'DELIVERY_SESSION_RESULT_PURGE', 'ACCOUNT_STORAGE_PURGE');
|
|
347
|
+
await this.#afterErasurePhase?.('ACCOUNT_STORAGE_PURGE', accountId);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (journal.phase === 'ACCOUNT_STORAGE_PURGE') {
|
|
351
|
+
await this.#withQueue(async () => {
|
|
352
|
+
const current = await this.#read();
|
|
353
|
+
const entry = requireErasure(current, erasureId, 'ACCOUNT_STORAGE_PURGE');
|
|
354
|
+
const ownedHistory = [...new Set(current.memberships.filter((membership) => membership.accountId === accountId).map((membership) => membership.deviceId))];
|
|
355
|
+
for (const deviceId of ownedHistory) {
|
|
356
|
+
const activeElsewhere = current.memberships.some((membership) => membership.deviceId === deviceId && membership.accountId !== accountId && membership.status === 'active');
|
|
357
|
+
if (!activeElsewhere) await this.#devices.unregisterActiveDevice(deviceId);
|
|
358
|
+
}
|
|
359
|
+
current.memberships = current.memberships.filter((membership) => membership.accountId !== accountId);
|
|
360
|
+
current.accounts = current.accounts.filter((candidate) => candidate.accountId !== accountId);
|
|
361
|
+
entry.phase = 'REGISTRY_REMOVED'; entry.updatedAt = this.#clock().toISOString();
|
|
362
|
+
await this.#write(current);
|
|
363
|
+
});
|
|
364
|
+
await this.#afterErasurePhase?.('REGISTRY_REMOVED', accountId);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (journal.phase === 'REGISTRY_REMOVED') {
|
|
368
|
+
await this.#mutate((current) => {
|
|
369
|
+
const entry = requireErasure(current, erasureId, 'REGISTRY_REMOVED');
|
|
370
|
+
const now = this.#clock().toISOString();
|
|
371
|
+
entry.phase = 'COMPLETE'; entry.updatedAt = now; entry.completedAt = now;
|
|
372
|
+
});
|
|
373
|
+
await this.#afterErasurePhase?.('COMPLETE', accountId);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
throw new OperatorError('ACCOUNT_ERASURE_STATE_CORRUPT', 'Account erasure journal phase is invalid.');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async #advanceErasure(erasureId , expected , next ) {
|
|
381
|
+
await this.#mutate((state) => {
|
|
382
|
+
const entry = requireErasure(state, erasureId, expected);
|
|
383
|
+
entry.phase = next;
|
|
384
|
+
entry.updatedAt = this.#clock().toISOString();
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
async #drainPendingReleasesLocked(state , filter = {}) {
|
|
388
|
+
const pending = state.memberships
|
|
389
|
+
.filter((membership) => membership.status === 'removed' && membership.releasePendingReason
|
|
390
|
+
&& (!filter.accountId || membership.accountId === filter.accountId)
|
|
391
|
+
&& (!filter.deviceId || membership.deviceId === filter.deviceId))
|
|
392
|
+
.sort((a, b) => a.deviceId.localeCompare(b.deviceId) || a.authorityGeneration - b.authorityGeneration);
|
|
393
|
+
let completed = 0;
|
|
394
|
+
for (const target of pending) {
|
|
395
|
+
const reason = target.releasePendingReason ;
|
|
396
|
+
await this.#releaseDevice(target.deviceId, target.accountId, reason);
|
|
397
|
+
const current = state.memberships.find((membership) => membership.accountId === target.accountId && membership.deviceId === target.deviceId && membership.authorityGeneration === target.authorityGeneration);
|
|
398
|
+
if (current?.releasePendingReason === reason) {
|
|
399
|
+
current.releasePendingReason = undefined;
|
|
400
|
+
await this.#write(state);
|
|
401
|
+
}
|
|
402
|
+
completed += 1;
|
|
403
|
+
}
|
|
404
|
+
return completed;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async #releaseDevice(deviceId , accountId , reason ) {
|
|
408
|
+
await this.#onReleaseDevice?.(deviceId, accountId, reason);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async #read() {
|
|
412
|
+
try {
|
|
413
|
+
const text = await readDurableStateText(this.#file, {
|
|
414
|
+
maxBytes: 32 * 1024 * 1024,
|
|
415
|
+
errorCode: 'ACCOUNT_STATE_CORRUPT',
|
|
416
|
+
invalidMessage: 'Account-device registry is invalid.'
|
|
417
|
+
});
|
|
418
|
+
const parsed = JSON.parse(text) ;
|
|
419
|
+
if (parsed?.version === 1) return validateState({ version: 2, accounts: parsed.accounts, memberships: parsed.memberships, erasures: [] });
|
|
420
|
+
return validateState(parsed );
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if ((error ).code === 'ENOENT') return { version: 2, accounts: [], memberships: [], erasures: [] };
|
|
423
|
+
if (error instanceof OperatorError) throw error;
|
|
424
|
+
throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account-device registry could not be read.');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async #write(stateInput ) {
|
|
429
|
+
const state = validateState(stateInput);
|
|
430
|
+
await writeDurableStateText(this.#file, JSON.stringify(state, null, 2), {
|
|
431
|
+
maxBytes: 32 * 1024 * 1024,
|
|
432
|
+
errorCode: 'ACCOUNT_STATE_CORRUPT',
|
|
433
|
+
invalidMessage: 'Account-device registry is invalid.'
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async #mutate (mutator ) {
|
|
438
|
+
return await this.#withQueue(async () => {
|
|
439
|
+
const state = await this.#read();
|
|
440
|
+
pruneCompletedErasures(state, this.#clock().getTime());
|
|
441
|
+
const result = await mutator(state);
|
|
442
|
+
await this.#write(state);
|
|
443
|
+
return result;
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async #withQueue (work ) {
|
|
448
|
+
let release ;
|
|
449
|
+
const previous = this.#queue;
|
|
450
|
+
this.#queue = new Promise ((resolve) => { release = resolve; });
|
|
451
|
+
await previous;
|
|
452
|
+
try { return await work(); }
|
|
453
|
+
finally { release(); }
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function hashPrincipal(principalInput ) {
|
|
458
|
+
if (!principalInput || typeof principalInput !== 'object') throw new OperatorError('ACCOUNT_PRINCIPAL_INVALID', 'Account principal is invalid.');
|
|
459
|
+
const issuer = boundedAuthField(principalInput.issuer, 'issuer');
|
|
460
|
+
const subject = boundedAuthField(principalInput.subject, 'subject');
|
|
461
|
+
return crypto.createHash('sha256').update(JSON.stringify({ issuer, subject }), 'utf8').digest('base64url');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function requireActiveAccount(state , accountId ) {
|
|
465
|
+
const account = state.accounts.find((candidate) => candidate.accountId === accountId);
|
|
466
|
+
if (!account) throw new OperatorError('ACCOUNT_NOT_FOUND', 'Operator account was not found.');
|
|
467
|
+
if (account.status === 'erasing') throw new OperatorError('ACCOUNT_ERASING', 'Operator account erasure is in progress.');
|
|
468
|
+
if (account.status !== 'active') throw new OperatorError('ACCOUNT_DISABLED', 'Operator account is disabled.');
|
|
469
|
+
return account;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function validateState(input ) {
|
|
473
|
+
if (!input || typeof input !== 'object' || input.version !== 2 || !Array.isArray(input.accounts) || !Array.isArray(input.memberships) || !Array.isArray(input.erasures) || input.accounts.length > MAX_ACCOUNTS || input.memberships.length > MAX_MEMBERSHIPS || input.erasures.length > MAX_ACCOUNTS) {
|
|
474
|
+
throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account-device registry structure is invalid.');
|
|
475
|
+
}
|
|
476
|
+
const accountIds = new Set ();
|
|
477
|
+
const principalHashes = new Set ();
|
|
478
|
+
const accounts = input.accounts.map((raw) => {
|
|
479
|
+
const accountId = validUuid(raw.accountId, 'accountId');
|
|
480
|
+
const principalHash = String(raw.principalHash ?? '');
|
|
481
|
+
if (!/^[A-Za-z0-9_-]{43,64}$/.test(principalHash)) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account principal hash is invalid.');
|
|
482
|
+
if (accountIds.has(accountId) || principalHashes.has(principalHash)) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account registry contains duplicate identity bindings.');
|
|
483
|
+
accountIds.add(accountId); principalHashes.add(principalHash);
|
|
484
|
+
const status = raw.status === 'active' ? 'active' : raw.status === 'disabled' ? 'disabled' : raw.status === 'erasing' ? 'erasing' : null;
|
|
485
|
+
if (!status) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account status is invalid.');
|
|
486
|
+
const createdAt = validIso(raw.createdAt, 'createdAt');
|
|
487
|
+
const disabledAt = raw.disabledAt === undefined ? undefined : validIso(raw.disabledAt, 'disabledAt');
|
|
488
|
+
const disabledReason = raw.disabledReason === undefined ? undefined : boundedReason(raw.disabledReason, 'disabledReason');
|
|
489
|
+
if (status === 'active' && (disabledAt || disabledReason)) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Active account cannot contain disable metadata.');
|
|
490
|
+
return { accountId, principalHash, status, createdAt, disabledAt, disabledReason } ;
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const activeDevices = new Set ();
|
|
494
|
+
const membershipKeys = new Set ();
|
|
495
|
+
const memberships = input.memberships.map((raw) => {
|
|
496
|
+
const accountId = validUuid(raw.accountId, 'membership accountId');
|
|
497
|
+
if (!accountIds.has(accountId)) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Membership references an unknown account.');
|
|
498
|
+
const deviceId = validUuid(raw.deviceId, 'membership deviceId');
|
|
499
|
+
const key = `${accountId}:${deviceId}:${raw.addedAt}`;
|
|
500
|
+
if (membershipKeys.has(key)) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Duplicate account-device membership record.');
|
|
501
|
+
membershipKeys.add(key);
|
|
502
|
+
const status = raw.status === 'active' ? 'active' : raw.status === 'removed' ? 'removed' : null;
|
|
503
|
+
if (!status) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Membership status is invalid.');
|
|
504
|
+
const addedAt = validIso(raw.addedAt, 'addedAt');
|
|
505
|
+
const authorityGeneration = raw.authorityGeneration === undefined ? 1 : Number(raw.authorityGeneration);
|
|
506
|
+
if (!Number.isSafeInteger(authorityGeneration) || authorityGeneration < 1) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Membership authority generation is invalid.');
|
|
507
|
+
const removedAt = raw.removedAt === undefined ? undefined : validIso(raw.removedAt, 'removedAt');
|
|
508
|
+
const removedReason = raw.removedReason === undefined ? undefined : boundedReason(raw.removedReason, 'removedReason');
|
|
509
|
+
const releasePendingReason = raw.releasePendingReason === undefined ? undefined
|
|
510
|
+
: raw.releasePendingReason === 'removed' || raw.releasePendingReason === 'disabled' ? raw.releasePendingReason : null;
|
|
511
|
+
if (releasePendingReason === null) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Membership pending release reason is invalid.');
|
|
512
|
+
if (status === 'active') {
|
|
513
|
+
if (removedAt || removedReason || releasePendingReason) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Active membership cannot contain removal metadata.');
|
|
514
|
+
if (activeDevices.has(deviceId)) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'One device cannot have multiple active account memberships.');
|
|
515
|
+
activeDevices.add(deviceId);
|
|
516
|
+
} else if (releasePendingReason && (!removedAt || !removedReason)) {
|
|
517
|
+
throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Pending release cleanup requires removal metadata.');
|
|
518
|
+
}
|
|
519
|
+
return { accountId, deviceId, status, addedAt, authorityGeneration, removedAt, removedReason, releasePendingReason: releasePendingReason ?? undefined } ;
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const erasureIds = new Set ();
|
|
523
|
+
const erasureAccounts = new Set ();
|
|
524
|
+
const phases = new Set (['REQUESTED', 'AUTHORITY_REVOKED', 'LIVE_CONNECTIONS_CLOSED', 'ROUTING_DISABLED', 'DELIVERY_SESSION_RESULT_PURGE', 'ACCOUNT_STORAGE_PURGE', 'REGISTRY_REMOVED', 'COMPLETE']);
|
|
525
|
+
const erasures = input.erasures.map((raw) => {
|
|
526
|
+
const erasureId = validUuid(raw.erasureId, 'erasureId');
|
|
527
|
+
const accountId = validUuid(raw.accountId, 'erasure accountId');
|
|
528
|
+
if (erasureIds.has(erasureId) || erasureAccounts.has(accountId)) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account erasure journal contains duplicate identifiers.');
|
|
529
|
+
erasureIds.add(erasureId); erasureAccounts.add(accountId);
|
|
530
|
+
if (!phases.has(raw.phase)) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account erasure phase is invalid.');
|
|
531
|
+
if (!Array.isArray(raw.deviceIds) || raw.deviceIds.length > MAX_MEMBERSHIPS) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account erasure device list is invalid.');
|
|
532
|
+
const deviceIds = raw.deviceIds.map((deviceId) => validUuid(deviceId, 'erasure deviceId')).sort();
|
|
533
|
+
if (new Set(deviceIds).size !== deviceIds.length) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account erasure device list contains duplicates.');
|
|
534
|
+
const requestedAt = validIso(raw.requestedAt, 'erasure requestedAt');
|
|
535
|
+
const updatedAt = validIso(raw.updatedAt, 'erasure updatedAt');
|
|
536
|
+
const completedAt = raw.completedAt === undefined ? undefined : validIso(raw.completedAt, 'erasure completedAt');
|
|
537
|
+
if (raw.phase === 'COMPLETE' ? !completedAt : Boolean(completedAt)) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Account erasure completion metadata is inconsistent.');
|
|
538
|
+
if (!['REGISTRY_REMOVED', 'COMPLETE'].includes(raw.phase)) {
|
|
539
|
+
const account = accounts.find((candidate) => candidate.accountId === accountId);
|
|
540
|
+
if (!account || account.status !== 'erasing') throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'In-progress erasure must retain an erasing account tombstone.');
|
|
541
|
+
}
|
|
542
|
+
return { erasureId, accountId, deviceIds, phase: raw.phase, requestedAt, updatedAt, completedAt } ;
|
|
543
|
+
});
|
|
544
|
+
for (const account of accounts.filter((candidate) => candidate.status === 'erasing')) {
|
|
545
|
+
const journal = erasures.find((entry) => entry.accountId === account.accountId && !['REGISTRY_REMOVED', 'COMPLETE'].includes(entry.phase));
|
|
546
|
+
if (!journal) throw new OperatorError('ACCOUNT_STATE_CORRUPT', 'Erasing account is missing its resumable erasure journal.');
|
|
547
|
+
}
|
|
548
|
+
return { version: 2, accounts, memberships, erasures };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function requireErasure(state , erasureId , expected ) {
|
|
552
|
+
const entry = state.erasures.find((candidate) => candidate.erasureId === erasureId);
|
|
553
|
+
if (!entry) throw new OperatorError('ACCOUNT_ERASURE_NOT_FOUND', 'Account erasure journal was not found.');
|
|
554
|
+
if (entry.phase !== expected) throw new OperatorError('ACCOUNT_ERASURE_PHASE_CHANGED', 'Account erasure phase changed during recovery.');
|
|
555
|
+
return entry;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function cloneErasure(entry ) { return { ...entry, deviceIds: [...entry.deviceIds] }; }
|
|
559
|
+
function pruneCompletedErasures(state , now ) {
|
|
560
|
+
state.erasures = state.erasures.filter((entry) => entry.phase !== 'COMPLETE' || !entry.completedAt || Date.parse(entry.completedAt) > now - ERASURE_TOMBSTONE_RETENTION_MS);
|
|
561
|
+
}
|
|
562
|
+
function cloneAccount(account ) { return { ...account }; }
|
|
563
|
+
function cloneMembership(membership ) { return { ...membership }; }
|
|
564
|
+
|
|
565
|
+
function boundedAuthField(value , label ) {
|
|
566
|
+
const text = String(value ?? '').trim();
|
|
567
|
+
if (!text || text.length > MAX_AUTH_FIELD || /[\0\r\n]/.test(text)) throw new OperatorError('ACCOUNT_PRINCIPAL_INVALID', `Account ${label} is invalid.`);
|
|
568
|
+
return text;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function boundedReason(value , label ) {
|
|
572
|
+
const text = String(value ?? '').trim();
|
|
573
|
+
if (!text || text.length > 512 || /\0/.test(text)) throw new OperatorError('ACCOUNT_REASON_INVALID', `${label} is invalid.`);
|
|
574
|
+
return text;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function validUuid(value , label ) {
|
|
578
|
+
const text = String(value ?? '');
|
|
579
|
+
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(text)) throw new OperatorError('ACCOUNT_ID_INVALID', `${label} must be a UUID.`);
|
|
580
|
+
return text.toLowerCase();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function validIso(value , label ) {
|
|
584
|
+
const text = String(value ?? '');
|
|
585
|
+
const time = Date.parse(text);
|
|
586
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== text) throw new OperatorError('ACCOUNT_STATE_CORRUPT', `${label} must be an ISO timestamp.`);
|
|
587
|
+
return text;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function safeAccountEraseTarget(stateDir , accountId ) {
|
|
591
|
+
const root = path.resolve(stateDir);
|
|
592
|
+
const accountsDir = path.join(root, 'accounts');
|
|
593
|
+
const target = path.join(accountsDir, accountId);
|
|
594
|
+
for (const [candidate, label] of [[root, 'state root'], [accountsDir, 'accounts directory'], [target, 'account directory']] ) {
|
|
595
|
+
try {
|
|
596
|
+
const stat = await fs.lstat(candidate);
|
|
597
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new OperatorError('ACCOUNT_ERASURE_PATH_INVALID', `Refusing account erasure through an unsafe ${label}.`);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if ((error ).code === 'ENOENT' && candidate !== root) continue;
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return target;
|
|
604
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export function canonicalJson(value ) {
|
|
5
|
+
return JSON.stringify(canonicalize(value));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function actionHash(action ) {
|
|
9
|
+
return crypto.createHash('sha256').update(canonicalJson({
|
|
10
|
+
capability: action.capability,
|
|
11
|
+
risk: action.risk,
|
|
12
|
+
input: action.input,
|
|
13
|
+
provenance: action.provenance,
|
|
14
|
+
target: action.target ?? null
|
|
15
|
+
}), 'utf8').digest('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stableActionId(
|
|
19
|
+
capability ,
|
|
20
|
+
risk ,
|
|
21
|
+
input ,
|
|
22
|
+
target
|
|
23
|
+
) {
|
|
24
|
+
const digest = crypto.createHash('sha256').update(canonicalJson({
|
|
25
|
+
capability,
|
|
26
|
+
risk,
|
|
27
|
+
input,
|
|
28
|
+
provenance: { kind: 'chatgpt' },
|
|
29
|
+
target: target ?? null
|
|
30
|
+
}), 'utf8').digest('hex');
|
|
31
|
+
return `mcp-${digest}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function canonicalize(value ) {
|
|
35
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
36
|
+
if (!value || typeof value !== 'object') return value;
|
|
37
|
+
const input = value ;
|
|
38
|
+
const output = {};
|
|
39
|
+
for (const key of Object.keys(input).sort()) {
|
|
40
|
+
const item = input[key];
|
|
41
|
+
if (item !== undefined) output[key] = canonicalize(item);
|
|
42
|
+
}
|
|
43
|
+
return output;
|
|
44
|
+
}
|