@zgeoff/atc 2.19.0 → 2.22.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 (74) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +5 -0
  3. package/src/agents/gateway-adapter.ts +25 -2
  4. package/src/cli.ts +187 -113
  5. package/src/client/collect-agent-picks.ts +5 -2
  6. package/src/client/daemon-client.ts +29 -18
  7. package/src/daemon/build-agent-list.ts +8 -6
  8. package/src/daemon/build-auth-binding.ts +58 -0
  9. package/src/daemon/build-imp-name.ts +11 -0
  10. package/src/daemon/build-imp-provider.ts +25 -2
  11. package/src/daemon/build-payload-hash.ts +4 -3
  12. package/src/daemon/daemon-connection.ts +277 -20
  13. package/src/daemon/daemon-context.ts +8 -0
  14. package/src/daemon/daemon.ts +186 -32
  15. package/src/daemon/find-token-fingerprint.ts +23 -0
  16. package/src/daemon/handshake-throttle.ts +47 -0
  17. package/src/daemon/idempotency-ledger.ts +20 -2
  18. package/src/daemon/imp-port.ts +4 -2
  19. package/src/daemon/imp-provider.ts +24 -19
  20. package/src/daemon/is-allowed-listen-host.ts +63 -0
  21. package/src/daemon/load-listener-tokens.ts +46 -0
  22. package/src/daemon/parse-listen-address.ts +33 -0
  23. package/src/daemon/restore-fleet.ts +2 -1
  24. package/src/daemon/sessions.ts +6 -0
  25. package/src/daemon/start-tcp-listener.ts +165 -0
  26. package/src/federation/build-binding-payload-hash.ts +34 -0
  27. package/src/federation/build-daemon-outdated-error.ts +14 -0
  28. package/src/federation/build-events-filter-hash.ts +16 -0
  29. package/src/federation/build-gateway-error.ts +45 -0
  30. package/src/federation/build-gateway-id.ts +14 -0
  31. package/src/federation/build-gateway-result.ts +31 -0
  32. package/src/federation/build-ruled-value.ts +53 -0
  33. package/src/federation/collect-unruled-id-paths.ts +46 -0
  34. package/src/federation/daemon-caller.ts +473 -0
  35. package/src/federation/daemon-pool.ts +56 -0
  36. package/src/federation/decode-gateway-cursor.ts +73 -0
  37. package/src/federation/encode-gateway-cursor.ts +15 -0
  38. package/src/federation/gateway-error.ts +25 -0
  39. package/src/federation/gateway-store.ts +253 -0
  40. package/src/federation/id-rules.ts +85 -0
  41. package/src/federation/load-gateway-registry.ts +29 -0
  42. package/src/federation/max-events-cursor-bytes.ts +4 -0
  43. package/src/federation/max-registry-daemons.ts +26 -0
  44. package/src/federation/merge-event-pages.ts +228 -0
  45. package/src/federation/open-gateway-caller.ts +55 -0
  46. package/src/federation/parse-gateway-id.ts +28 -0
  47. package/src/federation/parse-gateway-registry.ts +123 -0
  48. package/src/federation/pick-daemon-state.ts +46 -0
  49. package/src/federation/plan-event-reads.ts +54 -0
  50. package/src/federation/read-fleet-events.ts +279 -0
  51. package/src/federation/require-serving-daemon.ts +27 -0
  52. package/src/federation/resolve-daemon-request.ts +59 -0
  53. package/src/federation/routing-caller.ts +450 -0
  54. package/src/federation/types.ts +33 -0
  55. package/src/federation/wait-for-outcome.ts +38 -0
  56. package/src/mcp/answer-rpc-request.ts +14 -1
  57. package/src/mcp/build-tool-list.ts +6 -5
  58. package/src/mcp/mcp-tools.ts +41 -8
  59. package/src/mcp/require-daemon-features.ts +2 -0
  60. package/src/mcp/run-tool.ts +15 -1
  61. package/src/mcp/start-mcp-http-server.ts +53 -9
  62. package/src/mcp/types.ts +8 -1
  63. package/src/protocol/daemon-features.ts +9 -0
  64. package/src/protocol/protocol.ts +2 -0
  65. package/src/protocol/request-param-schemas.ts +6 -0
  66. package/src/run-daemon-id.ts +52 -0
  67. package/src/shared/collect-auth-profiles.ts +122 -0
  68. package/src/shared/collect-gateways.ts +223 -6
  69. package/src/shared/config.ts +32 -4
  70. package/src/shared/find-daemon-record.ts +8 -3
  71. package/src/shared/resolve-auth-profiles.ts +186 -0
  72. package/src/store/run-migrations.ts +77 -0
  73. package/src/store/runtime-auth-binding.ts +110 -0
  74. package/src/store/state-store.ts +202 -0
@@ -0,0 +1,473 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import type { DaemonFeature } from '../protocol/daemon-features';
3
+ import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
4
+ import { buildDaemonOutdatedError } from './build-daemon-outdated-error';
5
+ import { GatewayError } from './gateway-error';
6
+ import type { RegistryDaemon } from './types';
7
+
8
+ /**
9
+ * One open protocol connection to a daemon, as the caller drives it:
10
+ * correlated requests, a callback for when the connection ends, and closing
11
+ * it. A request that rejects after the callback fired got no response.
12
+ */
13
+ export interface GatewayChannel {
14
+ onClose: () => void;
15
+ readonly sendRequest: (
16
+ m: string,
17
+ p?: Readonly<Record<string, unknown>>,
18
+ as?: string,
19
+ ) => Promise<Readonly<Record<string, unknown>>>;
20
+ readonly stop: () => void;
21
+ }
22
+
23
+ /**
24
+ * What a daemon's handshake told the gateway: its build, its state
25
+ * identity, the features it serves, and how long it keeps a completed
26
+ * idempotency key, null when it announced none.
27
+ */
28
+ export interface DaemonHello {
29
+ readonly build: string;
30
+ readonly daemonID: string;
31
+ readonly features: ReadonlySet<DaemonFeature>;
32
+ readonly retentionMs: number | null;
33
+ }
34
+
35
+ interface DaemonCallerOptions {
36
+ readonly daemon: RegistryDaemon;
37
+ readonly build: string;
38
+
39
+ // Connects to the daemon's TCP address.
40
+ readonly openChannel: (address: RegistryDaemon['address']) => Promise<GatewayChannel>;
41
+
42
+ // How long the connect and the handshake may take together, and how long
43
+ // a sent request may wait for its response.
44
+ readonly connectTimeoutMs?: number;
45
+ readonly responseTimeoutMs?: number;
46
+ }
47
+
48
+ // One handshaken connection and what its handshake returned.
49
+ interface OpenConnection {
50
+ readonly channel: GatewayChannel;
51
+ readonly hello: DaemonHello;
52
+ }
53
+
54
+ /**
55
+ * Turns a request's params into the params one write of it sends, right
56
+ * before that write.
57
+ */
58
+ export type ParamsPreparer = (
59
+ params: Readonly<Record<string, unknown>>,
60
+ ) => Readonly<Record<string, unknown>>;
61
+
62
+ // The requests that only read, so a second run is harmless.
63
+ const READ_ONLY_METHODS: ReadonlySet<string> = new Set([
64
+ 'agents.list',
65
+ 'dirs.list',
66
+ 'events.read',
67
+ 'message.get',
68
+ 'session.get',
69
+ 'session.list',
70
+ 'session.read',
71
+ 'session.resumeCommand',
72
+ 'session.screen',
73
+ ]);
74
+
75
+ // The requests a daemon runs at most once under an idempotency key, and the
76
+ // feature a daemon announces when it does.
77
+ const KEYED_METHODS: ReadonlyMap<string, DaemonFeature> = new Map<string, DaemonFeature>([
78
+ ['session.spawn', 'spawn.idempotency'],
79
+ ['session.message', 'message.idempotency'],
80
+ ]);
81
+
82
+ const CONNECT_TIMEOUT_MS = 10_000;
83
+ const RESPONSE_TIMEOUT_MS = 30_000;
84
+
85
+ /**
86
+ * The gateway's caller for one named daemon: it dials the daemon's TCP
87
+ * address with the daemon's bearer token, keeps one connection open, and
88
+ * sends nothing to a daemon whose handshake returns another `daemonID`
89
+ * than the registry pins. A failure before the request leaves is
90
+ * `daemon_unavailable`, or `daemon_unauthorized` for a refused token. A
91
+ * request sent whose response never arrives, because the connection ended
92
+ * or 30 s passed beyond the request's own `waitMs`, is never a failure: a
93
+ * keyed or read-only request is sent once more on a fresh connection to
94
+ * the same daemon. A keyed retry goes out replay-only, under the same key,
95
+ * and only while that connection announces both the key's feature and
96
+ * replay-only requests; a daemon that holds no such key answers it
97
+ * `idempotency_key_unknown`, which ends as `outcome_unknown`.
98
+ * Any other request, a reconnect without that feature, or a second loss is
99
+ * `outcome_unknown`. A daemon's
100
+ * own error passes through as it came. `prepareParams` runs right before
101
+ * each write of the request, first send and retry alike, and returns the
102
+ * params that go out; params it marks `replayOnly` never reach a connection
103
+ * that does not announce replay-only requests, which ends as
104
+ * `outcome_unknown` with nothing sent.
105
+ */
106
+ export class DaemonCaller {
107
+ private readonly opts: DaemonCallerOptions;
108
+
109
+ private connection: Promise<OpenConnection> | null = null;
110
+
111
+ // The channel of the connection later requests reuse, once it is open.
112
+ private current: GatewayChannel | null = null;
113
+
114
+ private readonly closed = new WeakSet<GatewayChannel>();
115
+
116
+ constructor(opts: DaemonCallerOptions) {
117
+ this.opts = opts;
118
+ }
119
+
120
+ async sendRequest(
121
+ m: string,
122
+ p: Readonly<Record<string, unknown>> = {},
123
+ as?: string,
124
+ required: readonly DaemonFeature[] = [],
125
+ prepareParams: ParamsPreparer = (params) => params,
126
+ ): Promise<Readonly<Record<string, unknown>>> {
127
+ // A request that acts as a principal relies on the daemon honouring it.
128
+ const needed: readonly DaemonFeature[] =
129
+ as === undefined ? required : [...required, 'request.principal'];
130
+
131
+ const opened = await this.openConnection();
132
+
133
+ const unserved = needed.find((feature) => !opened.hello.features.has(feature));
134
+
135
+ if (unserved !== undefined) {
136
+ throw buildDaemonOutdatedError(this.opts.daemon.name, unserved);
137
+ }
138
+
139
+ const first = await this.trySend(
140
+ opened.channel,
141
+ opened.hello.features,
142
+ m,
143
+ p,
144
+ as,
145
+ prepareParams,
146
+ );
147
+
148
+ if (first.kind === 'answered') {
149
+ return first.ok;
150
+ }
151
+
152
+ const keyFeature = typeof p['idempotencyKey'] === 'string' ? KEYED_METHODS.get(m) : undefined;
153
+ const repeatable = READ_ONLY_METHODS.has(m) || keyFeature !== undefined;
154
+
155
+ if (!repeatable) {
156
+ throw this.buildOutcomeUnknown(m);
157
+ }
158
+
159
+ let fresh: OpenConnection;
160
+
161
+ try {
162
+ fresh = await this.openConnection();
163
+ } catch {
164
+ throw this.buildOutcomeUnknown(m);
165
+ }
166
+
167
+ // A keyed retry only ever replays: it goes out replay-only, and only to
168
+ // a daemon that announces it takes the key and replay-only requests, so
169
+ // a daemon whose first send never arrived, or that has dropped the key
170
+ // since, runs nothing a second time.
171
+ const replays =
172
+ keyFeature !== undefined &&
173
+ fresh.hello.features.has(keyFeature) &&
174
+ fresh.hello.features.has('idempotency.replayOnly');
175
+
176
+ if (keyFeature !== undefined && !replays) {
177
+ throw this.buildOutcomeUnknown(m);
178
+ }
179
+
180
+ // Nor does a retry reach a connection whose daemon would ignore what
181
+ // the request relies on, such as the principal it acts as.
182
+ if (needed.some((feature) => !fresh.hello.features.has(feature))) {
183
+ throw this.buildOutcomeUnknown(m);
184
+ }
185
+
186
+ const second = await this.trySend(fresh.channel, fresh.hello.features, m, p, as, (params) =>
187
+ replays ? { ...prepareParams(params), replayOnly: true } : prepareParams(params),
188
+ );
189
+
190
+ if (second.kind === 'answered') {
191
+ return second.ok;
192
+ }
193
+
194
+ throw this.buildOutcomeUnknown(m);
195
+ }
196
+
197
+ /**
198
+ * The handshake of the connection the next request rides, opening one
199
+ * when none is open.
200
+ */
201
+ async readHello(): Promise<DaemonHello> {
202
+ const opened = await this.openConnection();
203
+
204
+ return opened.hello;
205
+ }
206
+
207
+ async stop(): Promise<void> {
208
+ const current = this.connection;
209
+
210
+ this.connection = null;
211
+
212
+ if (current === null) {
213
+ return;
214
+ }
215
+
216
+ try {
217
+ const opened = await current;
218
+
219
+ opened.channel.stop();
220
+ } catch {
221
+ // A connection that never opened has nothing to close.
222
+ }
223
+ }
224
+
225
+ // Sends one request, answering with the daemon's answer, or with `lost`
226
+ // when no response arrived. A daemon's error rejects as it came, except
227
+ // `idempotency_key_unknown`, which a replay-only request gets for a key
228
+ // the daemon never held or has dropped, and which ends as
229
+ // `outcome_unknown`.
230
+ private async trySend(
231
+ channel: Readonly<GatewayChannel>,
232
+ features: DaemonHello['features'],
233
+ m: string,
234
+ p: Readonly<Record<string, unknown>>,
235
+ as: string | undefined,
236
+ prepareParams: ParamsPreparer,
237
+ ): Promise<
238
+ | { readonly kind: 'answered'; readonly ok: Readonly<Record<string, unknown>> }
239
+ | { readonly kind: 'lost' }
240
+ > {
241
+ // Runs in the same synchronous step as the write below, after the
242
+ // connection's handshake, so whatever it decides still holds when the
243
+ // request leaves.
244
+ const sent = prepareParams(p);
245
+
246
+ if (sent['replayOnly'] === true && !features.has('idempotency.replayOnly')) {
247
+ throw this.buildOutcomeUnknown(m);
248
+ }
249
+
250
+ const timeout = Promise.withResolvers<'timeout'>();
251
+
252
+ // A request that waits for a change on the daemon, such as a long poll,
253
+ // gets its own wait on top of the response time.
254
+ const waitMs = typeof sent['waitMs'] === 'number' && sent['waitMs'] > 0 ? sent['waitMs'] : 0;
255
+
256
+ const timer = setTimeout(
257
+ () => {
258
+ timeout.resolve('timeout');
259
+ },
260
+ (this.opts.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS) + waitMs,
261
+ );
262
+
263
+ // Settles as a value either way, so a response that rejects after the
264
+ // timeout won never goes unhandled.
265
+ const settled = (async () => {
266
+ try {
267
+ return { kind: 'ok' as const, ok: await channel.sendRequest(m, sent, as) };
268
+ } catch (error) {
269
+ return { kind: 'error' as const, error };
270
+ }
271
+ })();
272
+
273
+ const raced = await Promise.race([settled, timeout.promise]);
274
+
275
+ clearTimeout(timer);
276
+
277
+ if (raced === 'timeout') {
278
+ this.closed.add(channel);
279
+ this.resetCurrentConnection(channel);
280
+ channel.stop();
281
+
282
+ return { kind: 'lost' };
283
+ }
284
+
285
+ if (raced.kind === 'ok') {
286
+ return { kind: 'answered', ok: raced.ok };
287
+ }
288
+
289
+ if (this.closed.has(channel)) {
290
+ return { kind: 'lost' };
291
+ }
292
+
293
+ if (raced.error instanceof DaemonError && raced.error.code === 'idempotency_key_unknown') {
294
+ throw this.buildOutcomeUnknown(m);
295
+ }
296
+
297
+ throw raced.error;
298
+ }
299
+
300
+ // Stops later requests from reusing the channel's connection, which a
301
+ // timed-out request leaves with a response that may still arrive.
302
+ private resetCurrentConnection(channel: Readonly<GatewayChannel>): void {
303
+ if (this.current === channel) {
304
+ this.current = null;
305
+ this.connection = null;
306
+ }
307
+ }
308
+
309
+ private buildOutcomeUnknown(m: string): DaemonError {
310
+ return new DaemonError(
311
+ 'outcome_unknown',
312
+ `daemon '${this.opts.daemon.name}' never answered ${m}; check what it did before sending it again`,
313
+ { daemon: this.opts.daemon.name },
314
+ );
315
+ }
316
+
317
+ private openConnection(): Promise<OpenConnection> {
318
+ if (this.connection === null) {
319
+ const opening: Promise<OpenConnection> = this.openFreshConnection(
320
+ () => this.connection === opening,
321
+ );
322
+
323
+ this.connection = opening;
324
+ }
325
+
326
+ return this.connection;
327
+ }
328
+
329
+ // isCurrent returns true while this connection is the one later requests
330
+ // reuse: a connection that ends after a newer one replaced it leaves the
331
+ // newer one in place.
332
+ private async openFreshConnection(isCurrent: () => boolean): Promise<OpenConnection> {
333
+ const daemon = this.opts.daemon;
334
+
335
+ const resetConnection = () => {
336
+ if (isCurrent()) {
337
+ this.connection = null;
338
+ }
339
+ };
340
+
341
+ const timeout = Promise.withResolvers<'timeout'>();
342
+
343
+ const timer = setTimeout(() => {
344
+ timeout.resolve('timeout');
345
+ }, this.opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS);
346
+
347
+ let timedOut = false;
348
+
349
+ // Settles as a value either way, and stops a connection that opens
350
+ // after the timeout won, so a late open never leaks.
351
+ const handshaken = (async () => {
352
+ try {
353
+ const late = await this.openHandshaken();
354
+
355
+ if (timedOut) {
356
+ late.channel.stop();
357
+ }
358
+
359
+ return { kind: 'opened' as const, opened: late };
360
+ } catch (error) {
361
+ return { kind: 'failed' as const, error };
362
+ }
363
+ })();
364
+
365
+ try {
366
+ const raced = await Promise.race([handshaken, timeout.promise]);
367
+
368
+ if (raced === 'timeout') {
369
+ timedOut = true;
370
+
371
+ throw new GatewayError(
372
+ 'daemon_unavailable',
373
+ `daemon '${daemon.name}' did not answer the connection in time`,
374
+ { daemon: daemon.name },
375
+ );
376
+ }
377
+
378
+ if (raced.kind === 'failed') {
379
+ throw raced.error;
380
+ }
381
+
382
+ const opened = raced.opened;
383
+
384
+ opened.channel.onClose = () => {
385
+ this.closed.add(opened.channel);
386
+
387
+ resetConnection();
388
+ };
389
+
390
+ if (isCurrent()) {
391
+ this.current = opened.channel;
392
+ }
393
+
394
+ return opened;
395
+ } catch (error) {
396
+ resetConnection();
397
+ throw error;
398
+ } finally {
399
+ clearTimeout(timer);
400
+ }
401
+ }
402
+
403
+ // Connects and handshakes, refusing a daemon behind another state
404
+ // identity than the pin before anything is sent to it.
405
+ private async openHandshaken(): Promise<OpenConnection> {
406
+ const daemon = this.opts.daemon;
407
+ let channel: GatewayChannel;
408
+
409
+ try {
410
+ channel = await this.opts.openChannel(daemon.address);
411
+ } catch {
412
+ throw new GatewayError('daemon_unavailable', `daemon '${daemon.name}' is unreachable`, {
413
+ daemon: daemon.name,
414
+ });
415
+ }
416
+
417
+ let answer: Readonly<Record<string, unknown>>;
418
+
419
+ try {
420
+ answer = await channel.sendRequest('daemon.hello', {
421
+ client: this.opts.build,
422
+ auth: { scheme: 'bearer', token: daemon.token },
423
+ });
424
+ } catch (error) {
425
+ channel.stop();
426
+
427
+ if (error instanceof DaemonError && error.code === 'unauthorized') {
428
+ throw new GatewayError(
429
+ 'daemon_unauthorized',
430
+ `daemon '${daemon.name}' refused the gateway's token`,
431
+ { daemon: daemon.name },
432
+ );
433
+ }
434
+
435
+ throw new GatewayError(
436
+ 'daemon_unavailable',
437
+ `daemon '${daemon.name}' did not complete the handshake`,
438
+ { daemon: daemon.name },
439
+ );
440
+ }
441
+
442
+ if (answer['daemonID'] !== daemon.daemonID) {
443
+ channel.stop();
444
+
445
+ throw new GatewayError(
446
+ 'daemon_unavailable',
447
+ `daemon '${daemon.name}' answers with another state identity than the registry pins`,
448
+ { daemon: daemon.name, reason: 'daemon_changed' },
449
+ );
450
+ }
451
+
452
+ return { channel, hello: buildDaemonHello(answer, daemon.daemonID) };
453
+ }
454
+ }
455
+
456
+ function buildDaemonHello(
457
+ answer: Readonly<Record<string, unknown>>,
458
+ daemonID: string,
459
+ ): DaemonHello {
460
+ const idempotency = answer['idempotency'];
461
+
462
+ const retention: unknown =
463
+ typeof idempotency === 'object' && idempotency !== null
464
+ ? Reflect.get(idempotency, 'completedRetentionMs')
465
+ : undefined;
466
+
467
+ return {
468
+ build: typeof answer['daemon'] === 'string' ? answer['daemon'] : 'unknown',
469
+ daemonID,
470
+ features: parseDaemonFeatures(answer),
471
+ retentionMs: typeof retention === 'number' && retention > 0 ? retention : null,
472
+ };
473
+ }
@@ -0,0 +1,56 @@
1
+ import { DaemonCaller } from './daemon-caller';
2
+ import type { GatewayChannel } from './daemon-caller';
3
+ import type { GatewayRegistry, RegistryDaemon } from './types';
4
+
5
+ interface DaemonPoolOptions {
6
+ readonly registry: GatewayRegistry;
7
+ readonly build: string;
8
+ readonly openChannel: (address: RegistryDaemon['address']) => Promise<GatewayChannel>;
9
+ readonly connectTimeoutMs?: number;
10
+ readonly responseTimeoutMs?: number;
11
+ }
12
+
13
+ /**
14
+ * One caller per registry daemon, each with its own connection, token, and
15
+ * pin, so a call routed to one daemon never rides another's connection.
16
+ */
17
+ export class DaemonPool {
18
+ private readonly callers: ReadonlyMap<string, DaemonCaller>;
19
+
20
+ constructor(opts: DaemonPoolOptions) {
21
+ this.callers = new Map(
22
+ [...opts.registry.daemons.values()].map((daemon) => [
23
+ daemon.name,
24
+ new DaemonCaller({
25
+ daemon,
26
+ build: opts.build,
27
+ openChannel: opts.openChannel,
28
+ ...(opts.connectTimeoutMs === undefined
29
+ ? {}
30
+ : { connectTimeoutMs: opts.connectTimeoutMs }),
31
+ ...(opts.responseTimeoutMs === undefined
32
+ ? {}
33
+ : { responseTimeoutMs: opts.responseTimeoutMs }),
34
+ }),
35
+ ]),
36
+ );
37
+ }
38
+
39
+ /**
40
+ * The caller of a registry daemon. Throws for a name the registry does
41
+ * not hold, which a route resolved through the registry never gives.
42
+ */
43
+ getCaller(name: string): DaemonCaller {
44
+ const caller = this.callers.get(name);
45
+
46
+ if (caller === undefined) {
47
+ throw new Error(`no daemon '${name}' in the registry`);
48
+ }
49
+
50
+ return caller;
51
+ }
52
+
53
+ async stop(): Promise<void> {
54
+ await Promise.all([...this.callers.values()].map((caller) => caller.stop()));
55
+ }
56
+ }
@@ -0,0 +1,73 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import { isRecord } from '../shared/report';
3
+ import { MAX_EVENTS_CURSOR_BYTES } from './max-events-cursor-bytes';
4
+ import type { GatewayRegistry } from './types';
5
+
6
+ /**
7
+ * Each registry daemon's position in a gateway events cursor, by daemon
8
+ * name: its own cursor, or null for a daemon that has not answered since the
9
+ * read that started the cursor. A daemon the cursor leaves out is absent. The gateway refuses with `bad_args` a
10
+ * cursor over 4 KiB, one it cannot decode, one with a version other than
11
+ * 1, one read under another filter, and one whose part for a daemon holds
12
+ * another incarnation than the registry pins. A part for a name the
13
+ * registry no longer lists is dropped.
14
+ */
15
+ export function decodeGatewayCursor(
16
+ raw: string,
17
+ filter: string,
18
+ registry: GatewayRegistry,
19
+ ): ReadonlyMap<string, string | null> {
20
+ if (Buffer.byteLength(raw) > MAX_EVENTS_CURSOR_BYTES) {
21
+ throw new DaemonError('bad_args', `cursor exceeds ${MAX_EVENTS_CURSOR_BYTES} bytes`);
22
+ }
23
+
24
+ let wire: unknown;
25
+
26
+ try {
27
+ wire = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
28
+ } catch {
29
+ throw new DaemonError('bad_args', `'${raw}' is not an events cursor`);
30
+ }
31
+
32
+ if (!isRecord(wire) || !isRecord(wire['daemons'])) {
33
+ throw new DaemonError('bad_args', `'${raw}' is not an events cursor`);
34
+ }
35
+
36
+ if (wire['v'] !== 1) {
37
+ throw new DaemonError('bad_args', 'the events cursor has a version this gateway does not read');
38
+ }
39
+
40
+ if (wire['filter'] !== filter) {
41
+ throw new DaemonError('bad_args', 'the events cursor was read under other filters');
42
+ }
43
+
44
+ const parts = new Map<string, string | null>();
45
+
46
+ for (const [key, position] of Object.entries(wire['daemons'])) {
47
+ const dot = key.indexOf('.');
48
+
49
+ if (dot === -1) {
50
+ throw new DaemonError('bad_args', `'${raw}' is not an events cursor`);
51
+ }
52
+
53
+ const daemon = registry.daemons.get(key.slice(0, dot));
54
+
55
+ if (daemon === undefined) {
56
+ continue;
57
+ }
58
+
59
+ if (
60
+ key.slice(dot + 1) !== daemon.incarnation ||
61
+ (typeof position !== 'string' && position !== null)
62
+ ) {
63
+ throw new DaemonError(
64
+ 'bad_args',
65
+ `the events cursor holds a stale position for daemon '${daemon.name}'`,
66
+ );
67
+ }
68
+
69
+ parts.set(daemon.name, position);
70
+ }
71
+
72
+ return parts;
73
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A gateway events cursor: base64url JSON of `{ v: 1, filter, daemons }`,
3
+ * where `daemons` maps `<name>.<incarnation>` to that daemon's own cursor,
4
+ * always a concrete position, or to null for a daemon that has not answered
5
+ * since the read that started the cursor. The decoder refuses a cursor
6
+ * whose version, filter, or incarnations do not match.
7
+ */
8
+ export function encodeGatewayCursor(
9
+ filter: string,
10
+ daemons: ReadonlyMap<string, string | null>,
11
+ ): string {
12
+ const wire = { v: 1, filter, daemons: Object.fromEntries(daemons) };
13
+
14
+ return Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url');
15
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A refusal the gateway makes about a daemon itself, as opposed to an
3
+ * answer a daemon gave: `daemon_unavailable` when the daemon never received
4
+ * the request, `daemon_unauthorized` when it refused the gateway's token,
5
+ * and `daemon_outdated` when it lacks a feature the request needs. `data`
6
+ * holds the daemon's name, and for a pin mismatch the reason
7
+ * `daemon_changed`.
8
+ */
9
+ export class GatewayError extends Error {
10
+ readonly code: 'daemon_unavailable' | 'daemon_unauthorized' | 'daemon_outdated';
11
+
12
+ readonly data: Readonly<Record<string, unknown>>;
13
+
14
+ constructor(
15
+ code: 'daemon_unavailable' | 'daemon_unauthorized' | 'daemon_outdated',
16
+ msg: string,
17
+ data: Readonly<Record<string, unknown>>,
18
+ ) {
19
+ super(msg);
20
+
21
+ this.code = code;
22
+ this.data = data;
23
+ this.name = 'GatewayError';
24
+ }
25
+ }