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,512 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { OperatorError } from './errors.js';
|
|
4
|
+
import { readDurableStateText, writeDurableStateText } from './durable-state.js';
|
|
5
|
+
|
|
6
|
+
const MAX_STREAMS = 10_000;
|
|
7
|
+
const MAX_DELIVERIES_PER_STREAM = 10_000;
|
|
8
|
+
const MAX_PAYLOAD_BYTES = 128 * 1024;
|
|
9
|
+
const MAX_KIND = 128;
|
|
10
|
+
const MAX_PENDING_RETURN = 500;
|
|
11
|
+
const MAX_REQUIRED_CAPABILITIES = 128;
|
|
12
|
+
const DEFAULT_RETENTION_MS = 24 * 60 * 60_000;
|
|
13
|
+
const MAX_RETENTION_MS = 30 * 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
|
+
export class RelayDeliveryStore {
|
|
53
|
+
#file ;
|
|
54
|
+
#clock ;
|
|
55
|
+
#retentionMs ;
|
|
56
|
+
#queue = Promise.resolve();
|
|
57
|
+
|
|
58
|
+
constructor(stateDir , options = {}) {
|
|
59
|
+
this.#file = path.join(path.resolve(stateDir), 'relay-deliveries.json');
|
|
60
|
+
this.#clock = options.clock ?? (() => new Date());
|
|
61
|
+
this.#retentionMs = boundedRetention(options.retentionMs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async enqueue(deviceIdInput , kindInput , payloadInput , authorityInput , idempotencyKeyInput , requiredCapabilitiesInput = []) {
|
|
65
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
66
|
+
const kind = validKind(kindInput);
|
|
67
|
+
const payload = safePayload(payloadInput);
|
|
68
|
+
const authority = authorityInput === undefined ? undefined : safeAuthority(authorityInput, deviceId);
|
|
69
|
+
const idempotencyKey = idempotencyKeyInput === undefined ? undefined : validIdempotencyKey(idempotencyKeyInput);
|
|
70
|
+
const requiredCapabilities = safeRequiredCapabilities(requiredCapabilitiesInput);
|
|
71
|
+
return await this.#mutate((state) => {
|
|
72
|
+
expirePending(state, this.#clock().getTime(), this.#retentionMs);
|
|
73
|
+
if (idempotencyKey) {
|
|
74
|
+
for (const existingStream of state.streams) {
|
|
75
|
+
const existing = existingStream.deliveries.find((delivery) => delivery.idempotencyKey === idempotencyKey && !delivery.idempotencyReleasedAt);
|
|
76
|
+
if (!existing) continue;
|
|
77
|
+
if (existingStream.deviceId !== deviceId) throw new OperatorError('RELAY_IDEMPOTENCY_ROUTE_CHANGED', 'An unacknowledged action retry resolved to a different device.');
|
|
78
|
+
if (existing.status === 'pending' && (existing.requiredCapabilities === undefined || !sameCapabilities(existing.requiredCapabilities, requiredCapabilities))) {
|
|
79
|
+
throw new OperatorError('RELAY_IDEMPOTENCY_CAPABILITY_CHANGED', 'An unacknowledged action retry changed its durable capability requirements.');
|
|
80
|
+
}
|
|
81
|
+
return cloneDelivery(existing);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const stream = getOrCreateStream(state, deviceId);
|
|
85
|
+
if (stream.deliveries.length >= MAX_DELIVERIES_PER_STREAM) {
|
|
86
|
+
throw new OperatorError('RELAY_QUEUE_LIMIT', `Device relay queue has reached ${MAX_DELIVERIES_PER_STREAM} retained deliveries.`);
|
|
87
|
+
}
|
|
88
|
+
const delivery = {
|
|
89
|
+
seq: stream.nextSeq,
|
|
90
|
+
id: crypto.randomUUID(),
|
|
91
|
+
kind,
|
|
92
|
+
payload,
|
|
93
|
+
requiredCapabilities,
|
|
94
|
+
authority,
|
|
95
|
+
idempotencyKey,
|
|
96
|
+
createdAt: this.#clock().toISOString(),
|
|
97
|
+
status: 'pending'
|
|
98
|
+
};
|
|
99
|
+
stream.nextSeq += 1;
|
|
100
|
+
stream.deliveries.push(delivery);
|
|
101
|
+
return cloneDelivery(delivery);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async findIdempotent(idempotencyKeyInput ) {
|
|
106
|
+
const idempotencyKey = validIdempotencyKey(idempotencyKeyInput);
|
|
107
|
+
return await this.#mutate((state) => {
|
|
108
|
+
expirePending(state, this.#clock().getTime(), this.#retentionMs);
|
|
109
|
+
let found = null;
|
|
110
|
+
for (const stream of state.streams) {
|
|
111
|
+
for (const delivery of stream.deliveries) {
|
|
112
|
+
if (delivery.idempotencyKey !== idempotencyKey || delivery.idempotencyReleasedAt) continue;
|
|
113
|
+
if (found) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay idempotency authority is duplicated across retained deliveries.');
|
|
114
|
+
found = { deviceId: stream.deviceId, delivery: cloneDelivery(delivery) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async retained(deviceIdInput , seqInput ) {
|
|
122
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
123
|
+
const seq = validSeq(seqInput);
|
|
124
|
+
return await this.#mutate((state) => {
|
|
125
|
+
expirePending(state, this.#clock().getTime(), this.#retentionMs);
|
|
126
|
+
const delivery = state.streams.find((stream) => stream.deviceId === deviceId)?.deliveries.find((entry) => entry.seq === seq);
|
|
127
|
+
return delivery ? cloneDelivery(delivery) : null;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async pending(deviceIdInput , limitInput = 100) {
|
|
132
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
133
|
+
const limit = boundedLimit(limitInput);
|
|
134
|
+
return await this.#mutate((state) => {
|
|
135
|
+
expirePending(state, this.#clock().getTime(), this.#retentionMs);
|
|
136
|
+
const stream = state.streams.find((candidate) => candidate.deviceId === deviceId);
|
|
137
|
+
if (!stream) return [];
|
|
138
|
+
return stream.deliveries.filter((delivery) => delivery.seq > stream.lastAckedSeq && delivery.status === 'pending').slice(0, limit).map(cloneDelivery);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async cursor(deviceIdInput ) {
|
|
143
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
144
|
+
return await this.#mutate((state) => {
|
|
145
|
+
expirePending(state, this.#clock().getTime(), this.#retentionMs);
|
|
146
|
+
const stream = state.streams.find((candidate) => candidate.deviceId === deviceId);
|
|
147
|
+
return stream ? { lastAckedSeq: stream.lastAckedSeq, highestEnqueuedSeq: stream.nextSeq - 1 } : { lastAckedSeq: 0, highestEnqueuedSeq: 0 };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async acknowledge(deviceIdInput , seqInput , deliveryIdInput ) {
|
|
152
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
153
|
+
const seq = validSeq(seqInput);
|
|
154
|
+
const deliveryId = validUuid(deliveryIdInput, 'deliveryId');
|
|
155
|
+
return await this.#mutate((state) => {
|
|
156
|
+
const stream = state.streams.find((candidate) => candidate.deviceId === deviceId);
|
|
157
|
+
if (!stream) throw new OperatorError('RELAY_ACK_UNKNOWN_STREAM', 'Cannot acknowledge a delivery for an unknown device stream.');
|
|
158
|
+
const delivery = stream.deliveries.find((candidate) => candidate.seq === seq);
|
|
159
|
+
if (!delivery || delivery.id !== deliveryId) throw new OperatorError('RELAY_ACK_MISMATCH', 'Relay acknowledgement does not match the stored delivery sequence and ID.');
|
|
160
|
+
if (seq <= stream.lastAckedSeq) {
|
|
161
|
+
if (!['acked', 'expired'].includes(delivery.status)) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Terminal cursor references a non-terminal retained delivery.');
|
|
162
|
+
return { lastAckedSeq: stream.lastAckedSeq, duplicate: true };
|
|
163
|
+
}
|
|
164
|
+
if (seq !== stream.lastAckedSeq + 1) {
|
|
165
|
+
throw new OperatorError('RELAY_ACK_GAP', `Expected acknowledgement for sequence ${stream.lastAckedSeq + 1} before sequence ${seq}.`);
|
|
166
|
+
}
|
|
167
|
+
delivery.status = 'acked';
|
|
168
|
+
delivery.ackedAt = this.#clock().toISOString();
|
|
169
|
+
delivery.payload = {};
|
|
170
|
+
delivery.requiredCapabilities = undefined;
|
|
171
|
+
delivery.authority = undefined;
|
|
172
|
+
stream.lastAckedSeq = seq;
|
|
173
|
+
return { lastAckedSeq: stream.lastAckedSeq, duplicate: false };
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async reconcileClientCursor(deviceIdInput , clientSeqInput ) {
|
|
178
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
179
|
+
const clientSeq = validNonNegativeSeq(clientSeqInput);
|
|
180
|
+
return await this.#mutate((state) => {
|
|
181
|
+
expirePending(state, this.#clock().getTime(), this.#retentionMs);
|
|
182
|
+
const stream = state.streams.find((candidate) => candidate.deviceId === deviceId);
|
|
183
|
+
if (!stream) {
|
|
184
|
+
if (clientSeq === 0) return { lastAckedSeq: 0, advanced: 0 };
|
|
185
|
+
throw new OperatorError('RELAY_RESUME_AHEAD', 'Client resume cursor references deliveries the server has never enqueued.');
|
|
186
|
+
}
|
|
187
|
+
if (clientSeq < stream.lastAckedSeq) {
|
|
188
|
+
const crossed = stream.deliveries.filter((delivery) => delivery.seq > clientSeq && delivery.seq <= stream.lastAckedSeq);
|
|
189
|
+
if (crossed.length === stream.lastAckedSeq - clientSeq && crossed.every((delivery) => delivery.status === 'expired' && Object.keys(delivery.payload).length === 0 && delivery.authority === undefined)) {
|
|
190
|
+
return { lastAckedSeq: stream.lastAckedSeq, advanced: stream.lastAckedSeq - clientSeq, expiredThroughSeq: stream.lastAckedSeq };
|
|
191
|
+
}
|
|
192
|
+
throw new OperatorError('RELAY_RESUME_BEHIND', 'Client resume cursor is behind executed relay history; automatic replay is unsafe.', {
|
|
193
|
+
details: { clientSeq, serverSeq: stream.lastAckedSeq }
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const highest = stream.nextSeq - 1;
|
|
197
|
+
if (clientSeq > highest) {
|
|
198
|
+
throw new OperatorError('RELAY_RESUME_AHEAD', 'Client resume cursor is ahead of the highest server delivery.', {
|
|
199
|
+
details: { clientSeq, highestEnqueuedSeq: highest }
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (clientSeq === stream.lastAckedSeq) return { lastAckedSeq: stream.lastAckedSeq, advanced: 0 };
|
|
203
|
+
|
|
204
|
+
const from = stream.lastAckedSeq + 1;
|
|
205
|
+
for (let seq = from; seq <= clientSeq; seq += 1) {
|
|
206
|
+
const delivery = stream.deliveries.find((candidate) => candidate.seq === seq);
|
|
207
|
+
if (!delivery) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay queue is missing a delivery needed to reconcile the client cursor.');
|
|
208
|
+
delivery.status = 'acked';
|
|
209
|
+
delivery.ackedAt = this.#clock().toISOString();
|
|
210
|
+
delivery.payload = {};
|
|
211
|
+
delivery.requiredCapabilities = undefined;
|
|
212
|
+
delivery.authority = undefined;
|
|
213
|
+
}
|
|
214
|
+
stream.lastAckedSeq = clientSeq;
|
|
215
|
+
return { lastAckedSeq: clientSeq, advanced: clientSeq - from + 1 };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async expireUnroutableHeads(
|
|
220
|
+
deviceIdInput ,
|
|
221
|
+
supportedCapabilitiesInput ,
|
|
222
|
+
canCommit = () => true
|
|
223
|
+
) {
|
|
224
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
225
|
+
const supported = new Set(safeRequiredCapabilities([...supportedCapabilitiesInput]));
|
|
226
|
+
return await this.#mutate((state) => {
|
|
227
|
+
expirePending(state, this.#clock().getTime(), this.#retentionMs);
|
|
228
|
+
const stream = state.streams.find((candidate) => candidate.deviceId === deviceId);
|
|
229
|
+
if (!stream || !canCommit()) return 0;
|
|
230
|
+
let expired = 0;
|
|
231
|
+
const expiredAt = this.#clock().toISOString();
|
|
232
|
+
while (true) {
|
|
233
|
+
const next = stream.deliveries.find((delivery) => delivery.seq === stream.lastAckedSeq + 1);
|
|
234
|
+
if (!next || next.status !== 'pending') break;
|
|
235
|
+
const routable = next.authority !== undefined
|
|
236
|
+
&& next.requiredCapabilities !== undefined
|
|
237
|
+
&& next.requiredCapabilities.every((capability) => supported.has(capability));
|
|
238
|
+
if (routable) break;
|
|
239
|
+
expireDelivery(next, expiredAt);
|
|
240
|
+
stream.lastAckedSeq = next.seq;
|
|
241
|
+
expired += 1;
|
|
242
|
+
}
|
|
243
|
+
return expired;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async expirePending() {
|
|
248
|
+
return await this.#mutate((state) => expirePending(state, this.#clock().getTime(), this.#retentionMs));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async purgeDevice(deviceIdInput ) {
|
|
252
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
253
|
+
return await this.#mutate((state) => {
|
|
254
|
+
const stream = state.streams.find((item) => item.deviceId === deviceId);
|
|
255
|
+
if (!stream) return 0;
|
|
256
|
+
const scrubbedAt = this.#clock().toISOString();
|
|
257
|
+
const scrubbed = stream.deliveries.length;
|
|
258
|
+
for (const delivery of stream.deliveries) {
|
|
259
|
+
delivery.status = 'expired';
|
|
260
|
+
delivery.expiredAt = scrubbedAt;
|
|
261
|
+
delivery.ackedAt = undefined;
|
|
262
|
+
delivery.payload = {};
|
|
263
|
+
delivery.requiredCapabilities = undefined;
|
|
264
|
+
delivery.authority = undefined;
|
|
265
|
+
delivery.replayAuthority = undefined;
|
|
266
|
+
delivery.idempotencyKey = undefined;
|
|
267
|
+
delivery.idempotencyReleasedAt = undefined;
|
|
268
|
+
}
|
|
269
|
+
stream.lastAckedSeq = stream.nextSeq - 1;
|
|
270
|
+
return scrubbed;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async #read() {
|
|
275
|
+
try {
|
|
276
|
+
const text = await readDurableStateText(this.#file, {
|
|
277
|
+
maxBytes: 128 * 1024 * 1024,
|
|
278
|
+
errorCode: 'RELAY_QUEUE_CORRUPT',
|
|
279
|
+
invalidMessage: 'Relay delivery state is invalid.'
|
|
280
|
+
});
|
|
281
|
+
return validateState(JSON.parse(text));
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if ((error ).code === 'ENOENT') return { version: 1, streams: [] };
|
|
284
|
+
if (error instanceof OperatorError) throw error;
|
|
285
|
+
throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay delivery state could not be read.');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async #write(stateInput ) {
|
|
290
|
+
const state = validateState(stateInput);
|
|
291
|
+
await writeDurableStateText(this.#file, JSON.stringify(state, null, 2), {
|
|
292
|
+
maxBytes: 128 * 1024 * 1024,
|
|
293
|
+
errorCode: 'RELAY_QUEUE_CORRUPT',
|
|
294
|
+
invalidMessage: 'Relay delivery state is invalid.'
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async #mutate (mutator ) {
|
|
299
|
+
let release ;
|
|
300
|
+
const previous = this.#queue;
|
|
301
|
+
this.#queue = new Promise ((resolve) => { release = resolve; });
|
|
302
|
+
await previous;
|
|
303
|
+
try {
|
|
304
|
+
const state = await this.#read();
|
|
305
|
+
const result = await mutator(state);
|
|
306
|
+
await this.#write(state);
|
|
307
|
+
return result;
|
|
308
|
+
} finally {
|
|
309
|
+
release();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function boundedRetention(value ) {
|
|
315
|
+
if (value === undefined) return DEFAULT_RETENTION_MS;
|
|
316
|
+
if (!Number.isFinite(value) || value < 60_000 || value > MAX_RETENTION_MS) {
|
|
317
|
+
throw new OperatorError('RELAY_DELIVERY_RETENTION_INVALID', `Relay delivery retention must be between 60000 and ${MAX_RETENTION_MS} ms.`);
|
|
318
|
+
}
|
|
319
|
+
return Math.trunc(value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function expirePending(state , now , retentionMs ) {
|
|
323
|
+
let expired = 0;
|
|
324
|
+
const expiredAt = new Date(now).toISOString();
|
|
325
|
+
for (const stream of state.streams) {
|
|
326
|
+
while (true) {
|
|
327
|
+
const next = stream.deliveries.find((delivery) => delivery.seq === stream.lastAckedSeq + 1);
|
|
328
|
+
if (!next || next.status !== 'pending' || Date.parse(next.createdAt) > now - retentionMs) break;
|
|
329
|
+
expireDelivery(next, expiredAt);
|
|
330
|
+
stream.lastAckedSeq = next.seq;
|
|
331
|
+
expired += 1;
|
|
332
|
+
}
|
|
333
|
+
for (const delivery of stream.deliveries) {
|
|
334
|
+
if (delivery.status === 'acked' && delivery.idempotencyKey && delivery.ackedAt && Date.parse(delivery.ackedAt) <= now - retentionMs) { delivery.idempotencyKey = undefined; delivery.idempotencyReleasedAt = undefined; }
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return expired;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function expireDelivery(delivery , expiredAt ) {
|
|
341
|
+
delivery.status = 'expired';
|
|
342
|
+
delivery.expiredAt = expiredAt;
|
|
343
|
+
delivery.ackedAt = undefined;
|
|
344
|
+
delivery.payload = {};
|
|
345
|
+
delivery.requiredCapabilities = undefined;
|
|
346
|
+
delivery.replayAuthority = delivery.idempotencyKey && delivery.authority ? { ...delivery.authority } : undefined;
|
|
347
|
+
delivery.authority = undefined;
|
|
348
|
+
delivery.idempotencyReleasedAt = undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function getOrCreateStream(state , deviceId ) {
|
|
352
|
+
const existing = state.streams.find((stream) => stream.deviceId === deviceId);
|
|
353
|
+
if (existing) return existing;
|
|
354
|
+
if (state.streams.length >= MAX_STREAMS) throw new OperatorError('RELAY_STREAM_LIMIT', `At most ${MAX_STREAMS} device streams may be stored.`);
|
|
355
|
+
const stream = { deviceId, nextSeq: 1, lastAckedSeq: 0, deliveries: [] };
|
|
356
|
+
state.streams.push(stream);
|
|
357
|
+
state.streams.sort((a, b) => a.deviceId.localeCompare(b.deviceId));
|
|
358
|
+
return stream;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function validateState(input ) {
|
|
362
|
+
if (!input || typeof input !== 'object' || input.version !== 1 || !Array.isArray(input.streams) || input.streams.length > MAX_STREAMS) {
|
|
363
|
+
throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay delivery state structure is invalid.');
|
|
364
|
+
}
|
|
365
|
+
const devices = new Set ();
|
|
366
|
+
const streams = input.streams.map((raw) => {
|
|
367
|
+
const deviceId = validUuid(raw.deviceId, 'stream deviceId');
|
|
368
|
+
if (devices.has(deviceId)) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay delivery state contains duplicate device streams.');
|
|
369
|
+
devices.add(deviceId);
|
|
370
|
+
const nextSeq = validPositiveSeq(raw.nextSeq);
|
|
371
|
+
const lastAckedSeq = validNonNegativeSeq(raw.lastAckedSeq);
|
|
372
|
+
if (lastAckedSeq >= nextSeq) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay stream acknowledgement cursor must be lower than next sequence.');
|
|
373
|
+
if (!Array.isArray(raw.deliveries) || raw.deliveries.length > MAX_DELIVERIES_PER_STREAM) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay stream deliveries exceed the bounded limit.');
|
|
374
|
+
const seenSeq = new Set ();
|
|
375
|
+
const seenIds = new Set ();
|
|
376
|
+
const deliveries = raw.deliveries.map((entry) => {
|
|
377
|
+
const seq = validSeq(entry.seq);
|
|
378
|
+
const id = validUuid(entry.id, 'deliveryId');
|
|
379
|
+
if (seenSeq.has(seq) || seenIds.has(id)) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay stream contains duplicate sequence or delivery ID.');
|
|
380
|
+
seenSeq.add(seq); seenIds.add(id);
|
|
381
|
+
if (seq >= nextSeq) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Retained delivery sequence must be lower than next sequence.');
|
|
382
|
+
const kind = validKind(entry.kind);
|
|
383
|
+
const payload = safePayload(entry.payload);
|
|
384
|
+
const authority = entry.authority === undefined ? undefined : safeAuthority(entry.authority, deviceId);
|
|
385
|
+
const replayAuthority = entry.replayAuthority === undefined ? undefined : safeAuthority(entry.replayAuthority, deviceId);
|
|
386
|
+
const idempotencyKey = entry.idempotencyKey === undefined ? undefined : validIdempotencyKey(entry.idempotencyKey);
|
|
387
|
+
const idempotencyReleasedAt = entry.idempotencyReleasedAt === undefined ? undefined : validIso(entry.idempotencyReleasedAt, 'idempotencyReleasedAt');
|
|
388
|
+
if (idempotencyReleasedAt && !idempotencyKey) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Released idempotency authority is missing its key.');
|
|
389
|
+
const createdAt = validIso(entry.createdAt, 'createdAt');
|
|
390
|
+
const status = entry.status === 'pending' ? 'pending' : entry.status === 'acked' ? 'acked' : entry.status === 'expired' ? 'expired' : null;
|
|
391
|
+
if (!status) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay delivery status is invalid.');
|
|
392
|
+
const requiredCapabilities = entry.requiredCapabilities === undefined
|
|
393
|
+
? legacyRequiredCapabilities(kind, payload, status, authority)
|
|
394
|
+
: safeRequiredCapabilities(entry.requiredCapabilities);
|
|
395
|
+
const ackedAt = entry.ackedAt === undefined ? undefined : validIso(entry.ackedAt, 'ackedAt');
|
|
396
|
+
const expiredAt = entry.expiredAt === undefined ? undefined : validIso(entry.expiredAt, 'expiredAt');
|
|
397
|
+
if (status === 'pending' && (ackedAt || expiredAt || replayAuthority)) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Pending delivery cannot contain terminal timestamps or replay authority.');
|
|
398
|
+
if (status === 'acked' && (!ackedAt || expiredAt || replayAuthority)) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Acknowledged delivery must contain only an acknowledgement timestamp.');
|
|
399
|
+
if (status === 'expired' && (!expiredAt || ackedAt || Object.keys(payload).length !== 0 || authority || idempotencyReleasedAt || (replayAuthority && !idempotencyKey))) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Expired delivery must be a payload-free live-authority-free tombstone.');
|
|
400
|
+
if (seq <= lastAckedSeq && !['acked', 'expired'].includes(status)) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Delivery at/below the terminal cursor must be terminal.');
|
|
401
|
+
if (seq > lastAckedSeq && status !== 'pending') throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Delivery above the acknowledgement cursor must remain pending.');
|
|
402
|
+
return { seq, id, kind, payload, requiredCapabilities, authority, replayAuthority, idempotencyKey, idempotencyReleasedAt, createdAt, status, ackedAt, expiredAt } ;
|
|
403
|
+
}).sort((a, b) => a.seq - b.seq);
|
|
404
|
+
for (let seq = 1; seq < nextSeq; seq += 1) {
|
|
405
|
+
if (!seenSeq.has(seq)) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay stream contains a sequence gap.');
|
|
406
|
+
}
|
|
407
|
+
return { deviceId, nextSeq, lastAckedSeq, deliveries };
|
|
408
|
+
});
|
|
409
|
+
return { version: 1, streams };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function cloneDelivery(delivery ) {
|
|
413
|
+
return {
|
|
414
|
+
...delivery,
|
|
415
|
+
payload: structuredClone(delivery.payload),
|
|
416
|
+
requiredCapabilities: delivery.requiredCapabilities ? [...delivery.requiredCapabilities] : undefined,
|
|
417
|
+
authority: delivery.authority ? { ...delivery.authority } : undefined,
|
|
418
|
+
replayAuthority: delivery.replayAuthority ? { ...delivery.replayAuthority } : undefined
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function safeRequiredCapabilities(input ) {
|
|
423
|
+
if (!Array.isArray(input) || input.length > MAX_REQUIRED_CAPABILITIES) {
|
|
424
|
+
throw new OperatorError('RELAY_CAPABILITY_REQUIREMENTS_INVALID', `Relay capability requirements must be an array of at most ${MAX_REQUIRED_CAPABILITIES} entries.`);
|
|
425
|
+
}
|
|
426
|
+
const output = [];
|
|
427
|
+
const seen = new Set ();
|
|
428
|
+
for (const raw of input) {
|
|
429
|
+
if (typeof raw !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/*-]{0,127}$/.test(raw)) {
|
|
430
|
+
throw new OperatorError('RELAY_CAPABILITY_REQUIREMENTS_INVALID', 'Relay capability requirement is invalid.');
|
|
431
|
+
}
|
|
432
|
+
if (!seen.has(raw)) { seen.add(raw); output.push(raw); }
|
|
433
|
+
}
|
|
434
|
+
return output.sort();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function legacyRequiredCapabilities(kind , payload , status , authority ) {
|
|
438
|
+
if (status !== 'pending' || !authority || kind !== 'action') return undefined;
|
|
439
|
+
const action = payload.action;
|
|
440
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) return undefined;
|
|
441
|
+
const capability = (action ).capability;
|
|
442
|
+
return typeof capability === 'string' ? safeRequiredCapabilities([capability]) : undefined;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function sameCapabilities(left , right ) {
|
|
446
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function safeAuthority(input , expectedDeviceId ) {
|
|
450
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay delivery authority is invalid.');
|
|
451
|
+
const raw = input ;
|
|
452
|
+
const accountId = validUuid(String(raw.accountId ?? ''), 'authority accountId');
|
|
453
|
+
const deviceId = validUuid(String(raw.deviceId ?? ''), 'authority deviceId');
|
|
454
|
+
const generation = Number(raw.generation);
|
|
455
|
+
if (deviceId !== expectedDeviceId) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay delivery authority device does not match its stream.');
|
|
456
|
+
if (!Number.isSafeInteger(generation) || generation < 1) throw new OperatorError('RELAY_QUEUE_CORRUPT', 'Relay delivery authority generation is invalid.');
|
|
457
|
+
return { accountId, deviceId, generation };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function safePayload(input ) {
|
|
461
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new OperatorError('RELAY_PAYLOAD_INVALID', 'Relay delivery payload must be a JSON object.');
|
|
462
|
+
let text ;
|
|
463
|
+
try { text = JSON.stringify(input); } catch { throw new OperatorError('RELAY_PAYLOAD_INVALID', 'Relay delivery payload is not JSON serializable.'); }
|
|
464
|
+
if (!text || Buffer.byteLength(text, 'utf8') > MAX_PAYLOAD_BYTES) throw new OperatorError('RELAY_PAYLOAD_TOO_LARGE', `Relay delivery payload exceeds ${MAX_PAYLOAD_BYTES} bytes.`);
|
|
465
|
+
let parsed ;
|
|
466
|
+
try { parsed = JSON.parse(text); } catch { throw new OperatorError('RELAY_PAYLOAD_INVALID', 'Relay delivery payload is invalid JSON.'); }
|
|
467
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new OperatorError('RELAY_PAYLOAD_INVALID', 'Relay delivery payload must remain an object after serialization.');
|
|
468
|
+
return parsed ;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function validKind(value ) {
|
|
472
|
+
const kind = String(value ?? '');
|
|
473
|
+
if (!kind || kind.length > MAX_KIND || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(kind)) throw new OperatorError('RELAY_KIND_INVALID', 'Relay delivery kind is invalid.');
|
|
474
|
+
return kind;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function validIdempotencyKey(value ) {
|
|
478
|
+
const key = String(value ?? '').toLowerCase();
|
|
479
|
+
if (!/^[0-9a-f]{64}$/.test(key)) throw new OperatorError('RELAY_IDEMPOTENCY_INVALID', 'Relay idempotency key is invalid.');
|
|
480
|
+
return key;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function boundedLimit(value ) {
|
|
484
|
+
const limit = Number(value);
|
|
485
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PENDING_RETURN) throw new OperatorError('RELAY_LIMIT_INVALID', `Relay delivery limit must be between 1 and ${MAX_PENDING_RETURN}.`);
|
|
486
|
+
return limit;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function validSeq(value ) {
|
|
490
|
+
const seq = Number(value);
|
|
491
|
+
if (!Number.isSafeInteger(seq) || seq < 1) throw new OperatorError('RELAY_SEQUENCE_INVALID', 'Relay sequence must be a positive safe integer.');
|
|
492
|
+
return seq;
|
|
493
|
+
}
|
|
494
|
+
function validPositiveSeq(value ) { return validSeq(value); }
|
|
495
|
+
function validNonNegativeSeq(value ) {
|
|
496
|
+
const seq = Number(value);
|
|
497
|
+
if (!Number.isSafeInteger(seq) || seq < 0) throw new OperatorError('RELAY_SEQUENCE_INVALID', 'Relay sequence must be a non-negative safe integer.');
|
|
498
|
+
return seq;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function validUuid(value , label ) {
|
|
502
|
+
const text = String(value ?? '');
|
|
503
|
+
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('RELAY_ID_INVALID', `${label} must be a UUID.`);
|
|
504
|
+
return text.toLowerCase();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function validIso(value , label ) {
|
|
508
|
+
const text = String(value ?? '');
|
|
509
|
+
const time = Date.parse(text);
|
|
510
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== text) throw new OperatorError('RELAY_QUEUE_CORRUPT', `${label} must be an ISO timestamp.`);
|
|
511
|
+
return text;
|
|
512
|
+
}
|