@listen-fire/module-runtime 0.12.0-rc.7 → 0.12.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/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export * from './secrets/connect';
6
6
  export * from './secrets/family';
7
7
  export * from './secrets/memory';
8
8
  export * from './secrets/oauth2';
9
+ export * from './vendor_wait';
package/dist/index.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // bytes as a stream, the files slot, the secrets family (resolve a credential,
5
5
  // write it back at a version, refresh it once at a time) with the generic
6
6
  // OAuth 2 refresh and connect, the in-memory vault an embedder starts from,
7
- // the browser slot, and a bounded concurrency queue.
7
+ // the browser slot, a bounded concurrency queue, and how long a
8
+ // rate-limited vendor asked to wait.
8
9
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
10
  if (k2 === undefined) k2 = k;
10
11
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -28,3 +29,4 @@ __exportStar(require("./secrets/connect"), exports);
28
29
  __exportStar(require("./secrets/family"), exports);
29
30
  __exportStar(require("./secrets/memory"), exports);
30
31
  __exportStar(require("./secrets/oauth2"), exports);
32
+ __exportStar(require("./vendor_wait"), exports);
@@ -2,3 +2,4 @@ export * from './module_wire/wire';
2
2
  export * from './module_wire/conformance';
3
3
  export * from './module_wire/browser';
4
4
  export * from './module_wire/scanned';
5
+ export * from './module_wire/secrets';
@@ -24,3 +24,4 @@ __exportStar(require("./module_wire/wire"), exports);
24
24
  __exportStar(require("./module_wire/conformance"), exports);
25
25
  __exportStar(require("./module_wire/browser"), exports);
26
26
  __exportStar(require("./module_wire/scanned"), exports);
27
+ __exportStar(require("./module_wire/secrets"), exports);
@@ -33,3 +33,20 @@ export interface WakeConformanceCase {
33
33
  }
34
34
  /** The wake slot (docs/contracts/wake-slot.md). */
35
35
  export declare const wakeSlotCases: WakeConformanceCase[];
36
+ /** The secrets slot's own cases (docs/contracts/secrets-slot.md): the list,
37
+ * the lease and whole entries. `client` reaches a module that can change its
38
+ * entries; the family's cases (`secretsContractCases`) hold its resolve and
39
+ * write-back to the meaning every store has. */
40
+ export declare const secretsSlotCases: SlotConformanceCase[];
41
+ /** What a module that keeps its credentials where lf cannot change them
42
+ * answers: `client` reaches one. Entries are refused by name with a reason;
43
+ * everything a refresh needs still works. */
44
+ export declare const secretsReadOnlySlotCases: SlotConformanceCase[];
45
+ /** The movements slot's own cases (docs/contracts/movements-slot.md): the
46
+ * list, a read of nothing, writes by compare-and-swap on the version, the
47
+ * path rule. `client` reaches a module that can change its files. */
48
+ export declare const movementsSlotCases: SlotConformanceCase[];
49
+ /** What a module that keeps the project's movements where lf cannot change
50
+ * them answers: `client` reaches one. A write is refused by name with a
51
+ * reason; listing and reading still work. */
52
+ export declare const movementsReadOnlySlotCases: SlotConformanceCase[];
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  // The conformance suites of the slots with no family contract of their own
3
- // (models, judge, files, wake, ocr), and the cases every slot module answers alike (its
3
+ // (models, judge, files, wake, ocr), the movements slot's, and the secrets slot's own cases beside
4
+ // the family's (`services/secrets/contract.ts`), and the cases every slot module answers alike (its
4
5
  // manifest, the envelope's refusals). Each runs against any server over the
5
6
  // wire: written once here, they hold lf's built-ins (`lf --contract-<slot>`)
6
7
  // and a third-party module in any language to the same meaning. The store,
@@ -9,12 +10,14 @@
9
10
  // `loop_guard/contract.ts`) driven over this same wire; the browser to
10
11
  // `services/browser/contract.ts` through its wire client.
11
12
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.wakeSlotCases = exports.ocrSlotCases = exports.filesSlotCases = exports.judgeSlotCases = exports.modelsSlotCases = void 0;
13
+ exports.movementsReadOnlySlotCases = exports.movementsSlotCases = exports.secretsReadOnlySlotCases = exports.secretsSlotCases = exports.wakeSlotCases = exports.ocrSlotCases = exports.filesSlotCases = exports.judgeSlotCases = exports.modelsSlotCases = void 0;
13
14
  exports.commonSlotCases = commonSlotCases;
14
15
  const node_assert_1 = require("node:assert");
16
+ const node_crypto_1 = require("node:crypto");
15
17
  const engine_contracts_1 = require("@listen-fire/engine-contracts");
16
18
  const slots_1 = require("@listen-fire/engine-contracts/slots");
17
19
  const scanned_1 = require("./scanned");
20
+ const zod_1 = require("zod");
18
21
  const wire_1 = require("./wire");
19
22
  async function refusedWith(code, call) {
20
23
  try {
@@ -314,3 +317,263 @@ exports.wakeSlotCases = [
314
317
  },
315
318
  },
316
319
  ];
320
+ // ── The secrets slot ────────────────────────────────────────────────────────
321
+ const NEW_REF = () => `conf_${(0, node_crypto_1.randomUUID)().replaceAll('-', '').slice(0, 12)}`;
322
+ const SecretsList = zod_1.z.array(slots_1.SecretsListed);
323
+ async function listed(client) {
324
+ return client.read('secrets.list', {}, SecretsList);
325
+ }
326
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
327
+ /** The secrets slot's own cases (docs/contracts/secrets-slot.md): the list,
328
+ * the lease and whole entries. `client` reaches a module that can change its
329
+ * entries; the family's cases (`secretsContractCases`) hold its resolve and
330
+ * write-back to the meaning every store has. */
331
+ exports.secretsSlotCases = [
332
+ {
333
+ name: 'secrets: list names each credential with its external, binding and the version resolve answers',
334
+ async run(client) {
335
+ const ref = NEW_REF();
336
+ const binding = { credential: 'oauth' };
337
+ await client.read('secrets.create', { ref, external: 'acme_crm', binding, fields: { access_token: 'a1', workspace: 'w1' } }, slots_1.SecretsEntryAnswer);
338
+ const one = (await listed(client)).find((entry) => entry.ref === ref);
339
+ node_assert_1.strict.ok(one !== undefined, 'a created entry is listed');
340
+ node_assert_1.strict.equal(one.external, 'acme_crm');
341
+ node_assert_1.strict.deepEqual(one.binding, binding);
342
+ node_assert_1.strict.equal(one.version, (await client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable()))?.version);
343
+ await client.call('secrets.delete', { ref });
344
+ },
345
+ },
346
+ {
347
+ name: 'secrets: create, replace and delete change whole entries, each answered null',
348
+ async run(client) {
349
+ const ref = NEW_REF();
350
+ node_assert_1.strict.equal(await client.call('secrets.create', { ref, external: 'acme_crm', binding: 'legacy', fields: { api_key: 'k1' } }), null);
351
+ node_assert_1.strict.deepEqual((await client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable()))?.fields, { api_key: 'k1' });
352
+ node_assert_1.strict.equal(await client.call('secrets.replace', { ref, external: 'acme_crm', binding: 'install', fields: { api_key: 'k2', workspace: 'w2' } }), null);
353
+ node_assert_1.strict.deepEqual((await client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable()))?.fields, { api_key: 'k2', workspace: 'w2' }, 'replace is the whole entry, not a merge');
354
+ node_assert_1.strict.equal((await listed(client)).find((entry) => entry.ref === ref)?.binding, 'install');
355
+ node_assert_1.strict.equal(await client.call('secrets.delete', { ref }), null);
356
+ node_assert_1.strict.equal(await client.call('secrets.resolve', { ref }), null);
357
+ node_assert_1.strict.equal((await listed(client)).find((entry) => entry.ref === ref), undefined, 'a deleted entry is not listed');
358
+ },
359
+ },
360
+ {
361
+ name: 'secrets: create of an entry that exists, and delete of one that does not, are refused',
362
+ async run(client) {
363
+ const ref = NEW_REF();
364
+ const entry = { ref, external: 'acme_crm', binding: 'legacy', fields: { api_key: 'k1' } };
365
+ await client.call('secrets.create', entry);
366
+ await node_assert_1.strict.rejects(client.call('secrets.create', { ...entry, fields: { api_key: 'k9' } }), wire_1.SlotRefused);
367
+ node_assert_1.strict.equal((await client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable()))?.fields.api_key, 'k1', 'the refused create changed nothing');
368
+ await client.call('secrets.delete', { ref });
369
+ await node_assert_1.strict.rejects(client.call('secrets.delete', { ref }), wire_1.SlotRefused);
370
+ },
371
+ },
372
+ {
373
+ name: 'secrets: a second lock on a credential while one is held answers null; unlock frees it',
374
+ async run(client) {
375
+ const ref = NEW_REF();
376
+ const held = await client.read('secrets.lock', { ref, ttlMs: 30_000 }, slots_1.SecretsLockAnswer);
377
+ node_assert_1.strict.ok(held !== null, 'a free credential is locked');
378
+ node_assert_1.strict.equal(await client.call('secrets.lock', { ref, ttlMs: 30_000 }), null);
379
+ node_assert_1.strict.equal(await client.call('secrets.unlock', { ref, token: `${held.token}-not` }), null, 'a token that is not the lease\'s unlocks nothing');
380
+ node_assert_1.strict.equal(await client.call('secrets.lock', { ref, ttlMs: 30_000 }), null);
381
+ node_assert_1.strict.equal(await client.call('secrets.unlock', { ref, token: held.token }), null);
382
+ const again = await client.read('secrets.lock', { ref, ttlMs: 30_000 }, slots_1.SecretsLockAnswer);
383
+ node_assert_1.strict.ok(again !== null && again.token !== held.token, 'an unlocked credential is locked afresh, under a new token');
384
+ await client.call('secrets.unlock', { ref, token: again.token });
385
+ },
386
+ },
387
+ {
388
+ name: 'secrets: a lease whose ttl has lapsed is free, without an unlock',
389
+ async run(client) {
390
+ const ref = NEW_REF();
391
+ node_assert_1.strict.ok((await client.read('secrets.lock', { ref, ttlMs: 150 }, slots_1.SecretsLockAnswer)) !== null);
392
+ node_assert_1.strict.equal(await client.call('secrets.lock', { ref, ttlMs: 150 }), null, 'held until its ttl');
393
+ await sleep(400);
394
+ const taken = await client.read('secrets.lock', { ref, ttlMs: 30_000 }, slots_1.SecretsLockAnswer);
395
+ node_assert_1.strict.ok(taken !== null, 'a lapsed lease is taken over');
396
+ await client.call('secrets.unlock', { ref, token: taken.token });
397
+ },
398
+ },
399
+ {
400
+ name: 'secrets: assertWritable of a module that changes its entries answers null',
401
+ async run(client) {
402
+ node_assert_1.strict.equal(await client.call('secrets.assertWritable', { ref: NEW_REF() }), null);
403
+ },
404
+ },
405
+ {
406
+ name: 'secrets: a request without its ref, a lock without its ttl and an entry without its binding are refused as bad_request',
407
+ async run(client) {
408
+ await refusedWith('bad_request', client.call('secrets.resolve', {}));
409
+ await refusedWith('bad_request', client.call('secrets.lock', { ref: NEW_REF() }));
410
+ await refusedWith('bad_request', client.call('secrets.create', { ref: NEW_REF(), external: 'acme_crm', fields: {} }));
411
+ },
412
+ },
413
+ ];
414
+ /** What a module that keeps its credentials where lf cannot change them
415
+ * answers: `client` reaches one. Entries are refused by name with a reason;
416
+ * everything a refresh needs still works. */
417
+ exports.secretsReadOnlySlotCases = [
418
+ ...['secrets.create', 'secrets.replace'].map((method) => ({
419
+ name: `secrets: a read-only module answers ${method} with read_only and its reason, and keeps nothing`,
420
+ async run(client) {
421
+ const ref = NEW_REF();
422
+ const answer = await client.read(method, { ref, external: 'acme_crm', binding: 'legacy', fields: { api_key: 'k1' } }, slots_1.SecretsEntryAnswer);
423
+ node_assert_1.strict.ok(answer !== null && answer.kind === 'read_only' && answer.reason.length > 0, `${method} answered ${JSON.stringify(answer)}`);
424
+ node_assert_1.strict.equal(await client.call('secrets.resolve', { ref }), null);
425
+ node_assert_1.strict.equal((await listed(client)).find((entry) => entry.ref === ref), undefined);
426
+ },
427
+ })),
428
+ {
429
+ name: 'secrets: a read-only module answers secrets.delete with read_only and its reason',
430
+ async run(client) {
431
+ const answer = await client.read('secrets.delete', { ref: NEW_REF() }, slots_1.SecretsEntryAnswer);
432
+ node_assert_1.strict.ok(answer !== null && answer.kind === 'read_only' && answer.reason.length > 0, `secrets.delete answered ${JSON.stringify(answer)}`);
433
+ },
434
+ },
435
+ {
436
+ name: 'secrets: a read-only module answers secrets.assertWritable with read_only and its reason',
437
+ async run(client) {
438
+ const answer = await client.read('secrets.assertWritable', { ref: NEW_REF() }, slots_1.SecretsEntryAnswer);
439
+ node_assert_1.strict.ok(answer !== null && answer.kind === 'read_only' && answer.reason.length > 0, `secrets.assertWritable answered ${JSON.stringify(answer)}`);
440
+ },
441
+ },
442
+ {
443
+ name: 'secrets: a read-only module still lists, resolves and leases',
444
+ async run(client) {
445
+ const ref = NEW_REF();
446
+ node_assert_1.strict.ok(Array.isArray(await client.call('secrets.list', {})));
447
+ node_assert_1.strict.equal(await client.call('secrets.resolve', { ref }), null);
448
+ const held = await client.read('secrets.lock', { ref, ttlMs: 30_000 }, slots_1.SecretsLockAnswer);
449
+ node_assert_1.strict.ok(held !== null);
450
+ node_assert_1.strict.equal(await client.call('secrets.unlock', { ref, token: held.token }), null);
451
+ },
452
+ },
453
+ ];
454
+ // ── The movements slot ──────────────────────────────────────────────────────
455
+ const NEW_MOVEMENT = () => `conformance/${(0, node_crypto_1.randomUUID)()}.mvt`;
456
+ async function listedMovements(client) {
457
+ return client.read('movements.list', {}, slots_1.MovementsListAnswer);
458
+ }
459
+ const writeMovement = (client, path, text, expectedVersion) => client.read('movements.write', { path, text, expectedVersion }, slots_1.MovementsWriteAnswer);
460
+ /** Creates a file and returns the version it was written at. */
461
+ async function createdMovement(client, path, text) {
462
+ const made = await writeMovement(client, path, text, null);
463
+ node_assert_1.strict.equal(made.kind, 'written', `creating ${path} answered ${JSON.stringify(made)}`);
464
+ return made.kind === 'written' ? made.version : '';
465
+ }
466
+ /** The movements slot's own cases (docs/contracts/movements-slot.md): the
467
+ * list, a read of nothing, writes by compare-and-swap on the version, the
468
+ * path rule. `client` reaches a module that can change its files. */
469
+ exports.movementsSlotCases = [
470
+ {
471
+ name: 'movements: list names a created file, and its version is the one read answers',
472
+ async run(client) {
473
+ const path = NEW_MOVEMENT();
474
+ const version = await createdMovement(client, path, 'movement hello {}\n');
475
+ const one = (await listedMovements(client)).find((file) => file.path === path);
476
+ node_assert_1.strict.ok(one !== undefined, 'a created file is listed');
477
+ node_assert_1.strict.ok(one.version === undefined || one.version === version, 'a listed version is the one the write answered');
478
+ node_assert_1.strict.deepEqual(await client.read('movements.read', { path }, slots_1.MovementsReadAnswer), { text: 'movement hello {}\n', version });
479
+ },
480
+ },
481
+ {
482
+ name: 'movements: read of a path with nothing at it answers null',
483
+ async run(client) {
484
+ node_assert_1.strict.equal(await client.call('movements.read', { path: NEW_MOVEMENT() }), null);
485
+ },
486
+ },
487
+ {
488
+ name: 'movements: create with a null expectedVersion writes; a second create answers conflict with the current version and changes nothing',
489
+ async run(client) {
490
+ const path = NEW_MOVEMENT();
491
+ const version = await createdMovement(client, path, 'one');
492
+ node_assert_1.strict.deepEqual(await writeMovement(client, path, 'two', null), { kind: 'conflict', current: version });
493
+ node_assert_1.strict.equal((await client.read('movements.read', { path }, slots_1.MovementsReadAnswer))?.text, 'one', 'the refused create changed nothing');
494
+ },
495
+ },
496
+ {
497
+ name: 'movements: replace with the current version answers a new version, and the text reads back byte for byte',
498
+ async run(client) {
499
+ const path = NEW_MOVEMENT();
500
+ const first = await createdMovement(client, path, 'one');
501
+ const text = 'movement caf\u00e9 {}\n\u00fcber \u2713\n';
502
+ const replaced = await writeMovement(client, path, text, first);
503
+ node_assert_1.strict.equal(replaced.kind, 'written');
504
+ node_assert_1.strict.ok(replaced.kind === 'written' && replaced.version !== first, 'a replace is a new version');
505
+ const read = await client.read('movements.read', { path }, slots_1.MovementsReadAnswer);
506
+ node_assert_1.strict.equal(read?.text, text);
507
+ node_assert_1.strict.equal(read?.version, replaced.kind === 'written' ? replaced.version : undefined);
508
+ },
509
+ },
510
+ {
511
+ name: 'movements: a stale version answers conflict with the current one and changes nothing; so does a version for a path with nothing at it',
512
+ async run(client) {
513
+ const path = NEW_MOVEMENT();
514
+ const first = await createdMovement(client, path, 'one');
515
+ const second = await writeMovement(client, path, 'two', first);
516
+ node_assert_1.strict.equal(second.kind, 'written');
517
+ const current = second.kind === 'written' ? second.version : '';
518
+ node_assert_1.strict.deepEqual(await writeMovement(client, path, 'three', first), { kind: 'conflict', current });
519
+ node_assert_1.strict.equal((await client.read('movements.read', { path }, slots_1.MovementsReadAnswer))?.text, 'two');
520
+ node_assert_1.strict.deepEqual(await writeMovement(client, NEW_MOVEMENT(), 'x', 'no-such-version'), { kind: 'conflict', current: null });
521
+ },
522
+ },
523
+ {
524
+ name: 'movements: the same text listed twice keeps its version; a changed text changes it',
525
+ async run(client) {
526
+ const path = NEW_MOVEMENT();
527
+ await createdMovement(client, path, 'one');
528
+ const versionOf = async () => (await client.read('movements.read', { path }, slots_1.MovementsReadAnswer))?.version;
529
+ const before = await versionOf();
530
+ node_assert_1.strict.equal(await versionOf(), before, 'reading twice does not change the version');
531
+ const listedVersion = async () => (await listedMovements(client)).find((file) => file.path === path)?.version;
532
+ node_assert_1.strict.equal(await listedVersion(), await listedVersion(), 'listing twice does not change it');
533
+ node_assert_1.strict.ok(before !== undefined);
534
+ await writeMovement(client, path, 'two', before);
535
+ node_assert_1.strict.notEqual(await versionOf(), before, 'a changed text is a new version');
536
+ },
537
+ },
538
+ {
539
+ name: 'movements: a path that is not relative, `/`-separated and ending .mvt is refused as bad_request on read and write',
540
+ async run(client) {
541
+ for (const path of ['/a.mvt', 'a/../b.mvt', './a.mvt', 'a//b.mvt', 'a\\b.mvt', 'a.txt', 'a']) {
542
+ await refusedWith('bad_request', client.call('movements.read', { path }));
543
+ await refusedWith('bad_request', client.call('movements.write', { path, text: 'x', expectedVersion: null }));
544
+ }
545
+ await refusedWith('bad_request', client.call('movements.read', {}));
546
+ },
547
+ },
548
+ {
549
+ name: 'movements: a write without its expectedVersion, or without its text, is refused as bad_request and writes nothing',
550
+ async run(client) {
551
+ const path = NEW_MOVEMENT();
552
+ await refusedWith('bad_request', client.call('movements.write', { path, text: 'x' }));
553
+ await refusedWith('bad_request', client.call('movements.write', { path, expectedVersion: null }));
554
+ node_assert_1.strict.equal(await client.call('movements.read', { path }), null);
555
+ },
556
+ },
557
+ ];
558
+ /** What a module that keeps the project's movements where lf cannot change
559
+ * them answers: `client` reaches one. A write is refused by name with a
560
+ * reason; listing and reading still work. */
561
+ exports.movementsReadOnlySlotCases = [
562
+ {
563
+ name: 'movements: a read-only module answers movements.write with read_only and its reason, and keeps nothing',
564
+ async run(client) {
565
+ const path = NEW_MOVEMENT();
566
+ const answer = await writeMovement(client, path, 'one', null);
567
+ node_assert_1.strict.ok(answer.kind === 'read_only' && answer.reason.length > 0, `movements.write answered ${JSON.stringify(answer)}`);
568
+ node_assert_1.strict.equal(await client.call('movements.read', { path }), null);
569
+ node_assert_1.strict.equal((await listedMovements(client)).find((file) => file.path === path), undefined);
570
+ },
571
+ },
572
+ {
573
+ name: 'movements: a read-only module still lists and reads',
574
+ async run(client) {
575
+ node_assert_1.strict.ok(Array.isArray(await client.call('movements.list', {})));
576
+ node_assert_1.strict.equal(await client.call('movements.read', { path: NEW_MOVEMENT() }), null);
577
+ },
578
+ },
579
+ ];
@@ -0,0 +1,14 @@
1
+ import type { SecretFields, SecretRecord, SecretsBackend, WriteBackOutcome } from '../secrets/family';
2
+ import { type SlotAddress } from './wire';
3
+ export declare class SecretsSlotBackend implements SecretsBackend {
4
+ private readonly client;
5
+ constructor(address: SlotAddress, settings?: Record<string, string>);
6
+ resolve(ref: string): Promise<SecretRecord | null>;
7
+ writeBack(ref: string, fields: SecretFields, expectedVersion: string): Promise<WriteBackOutcome>;
8
+ /** The module's lease, asked for again until it is free, held for `body`. */
9
+ exclusive<T>(ref: string, body: (store: SecretsBackend) => Promise<T>): Promise<T>;
10
+ /** A write-back is not what `read_only` answers for: only a refusal fails here. */
11
+ assertWritable(ref: string): Promise<void>;
12
+ /** Store a new credential (a legacy entry bound to `external`). */
13
+ seed(ref: string, external: string, fields: SecretFields): Promise<void>;
14
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ // A secrets module spoken to over the wire as the secrets family's store
3
+ // (`SecretsBackend`, secrets/family.ts): how the family's contract cases
4
+ // (movement-engine services/secrets/contract.ts) hold any module of the
5
+ // secrets slot to the meaning every store has, whether the module is served
6
+ // by lf (`lf --contract-secrets-slot`) or is a third party's.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.SecretsSlotBackend = void 0;
9
+ const slots_1 = require("@listen-fire/engine-contracts/slots");
10
+ const wire_1 = require("./wire");
11
+ const LOCK_POLL_MS = 20;
12
+ const LEASE_TTL_MS = 30_000;
13
+ const LOCK_WAIT_MS = 10_000;
14
+ class SecretsSlotBackend {
15
+ client;
16
+ constructor(address, settings = {}) {
17
+ this.client = new wire_1.SlotClient(address, settings);
18
+ }
19
+ resolve(ref) {
20
+ return this.client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable());
21
+ }
22
+ async writeBack(ref, fields, expectedVersion) {
23
+ return this.client.read('secrets.writeBack', { ref, fields, expectedVersion }, slots_1.SecretsWriteBackAnswer);
24
+ }
25
+ /** The module's lease, asked for again until it is free, held for `body`. */
26
+ async exclusive(ref, body) {
27
+ const until = Date.now() + LOCK_WAIT_MS;
28
+ for (;;) {
29
+ const lease = await this.client.read('secrets.lock', { ref, ttlMs: LEASE_TTL_MS }, slots_1.SecretsLockAnswer);
30
+ if (lease !== null) {
31
+ try {
32
+ return await body(this);
33
+ }
34
+ finally {
35
+ await this.client.call('secrets.unlock', { ref, token: lease.token });
36
+ }
37
+ }
38
+ if (Date.now() > until)
39
+ throw new Error(`the secrets module kept credential "${ref}" locked for ${LOCK_WAIT_MS / 1000}s`);
40
+ await new Promise((done) => setTimeout(done, LOCK_POLL_MS));
41
+ }
42
+ }
43
+ /** A write-back is not what `read_only` answers for: only a refusal fails here. */
44
+ async assertWritable(ref) {
45
+ await this.client.call('secrets.assertWritable', { ref });
46
+ }
47
+ /** Store a new credential (a legacy entry bound to `external`). */
48
+ async seed(ref, external, fields) {
49
+ await this.client.call('secrets.create', { ref, external, binding: 'legacy', fields });
50
+ }
51
+ }
52
+ exports.SecretsSlotBackend = SecretsSlotBackend;
@@ -60,6 +60,15 @@ export declare class SlotRefusal extends Error {
60
60
  readonly data?: unknown;
61
61
  constructor(code: string, message: string, data?: unknown);
62
62
  }
63
+ /** The envelope rules for one request whose body is already read as JSON,
64
+ * without a server: the manifest answer, the protocol version refusal, an
65
+ * unknown method and a module's `SlotRefusal` codes, a malformed body.
66
+ * `serveSlot` answers every request through it, and a host router (the
67
+ * platform's routes) applies the same rules by calling it. */
68
+ export declare function answerSlotRequest(manifest: WireSlotManifestShape, answer: SlotAnswer, body: unknown): Promise<{
69
+ status: number;
70
+ body: unknown;
71
+ }>;
63
72
  export interface ServedSlot {
64
73
  address: SlotAddress;
65
74
  close(): Promise<void>;
@@ -10,6 +10,7 @@ exports.postTo = postTo;
10
10
  exports.listeningAddress = listeningAddress;
11
11
  exports.freshSocketPath = freshSocketPath;
12
12
  exports.startModule = startModule;
13
+ exports.answerSlotRequest = answerSlotRequest;
13
14
  exports.serveSlot = serveSlot;
14
15
  const node_child_process_1 = require("node:child_process");
15
16
  const node_crypto_1 = require("node:crypto");
@@ -191,6 +192,41 @@ class SlotRefusal extends Error {
191
192
  }
192
193
  }
193
194
  exports.SlotRefusal = SlotRefusal;
195
+ /** The envelope rules for one request whose body is already read as JSON,
196
+ * without a server: the manifest answer, the protocol version refusal, an
197
+ * unknown method and a module's `SlotRefusal` codes, a malformed body.
198
+ * `serveSlot` answers every request through it, and a host router (the
199
+ * platform's routes) applies the same rules by calling it. */
200
+ async function answerSlotRequest(manifest, answer, body) {
201
+ const envelope = slots_1.SlotRequestEnvelope.safeParse(body);
202
+ if (!envelope.success) {
203
+ const version = body !== null && typeof body === 'object' ? Reflect.get(body, 'protocolVersion') : undefined;
204
+ if (version !== undefined && version !== engine_contracts_1.PROTOCOL_VERSION) {
205
+ return { status: 200, body: { ok: false, error: { code: 'protocol_version', message: `this module speaks protocol ${engine_contracts_1.PROTOCOL_VERSION}`, retryable: false } } };
206
+ }
207
+ return { status: 400, body: { error: 'bad_request' } };
208
+ }
209
+ const { method, params, settings } = envelope.data;
210
+ try {
211
+ const result = method === 'manifest' ? manifest : await answer(method, params, settings ?? {});
212
+ return { status: 200, body: { ok: true, result: result ?? null } };
213
+ }
214
+ catch (error) {
215
+ const refusal = error instanceof SlotRefusal ? error : undefined;
216
+ return {
217
+ status: 200,
218
+ body: {
219
+ ok: false,
220
+ error: {
221
+ code: refusal?.code ?? 'module_error',
222
+ message: error instanceof Error ? error.message : String(error),
223
+ retryable: refusal === undefined,
224
+ ...(refusal?.data !== undefined ? { data: refusal.data } : {}),
225
+ },
226
+ },
227
+ };
228
+ }
229
+ }
194
230
  /** Serve `answer` as a module of `manifest`'s slot. `listen`: a socket path
195
231
  * or a port; default `MODULE_SOCKET`, else `PORT`, else a fresh socket.
196
232
  * `MODULE_TOKEN` (or `token`) is required on every request when set. */
@@ -214,31 +250,8 @@ function serveSlot(options) {
214
250
  catch {
215
251
  return reply(response, 400, { error: 'bad_request' });
216
252
  }
217
- const envelope = slots_1.SlotRequestEnvelope.safeParse(json);
218
- if (!envelope.success) {
219
- const version = json !== null && typeof json === 'object' ? Reflect.get(json, 'protocolVersion') : undefined;
220
- if (version !== undefined && version !== engine_contracts_1.PROTOCOL_VERSION) {
221
- return reply(response, 200, { ok: false, error: { code: 'protocol_version', message: `this module speaks protocol ${engine_contracts_1.PROTOCOL_VERSION}`, retryable: false } });
222
- }
223
- return reply(response, 400, { error: 'bad_request' });
224
- }
225
- const { method, params, settings } = envelope.data;
226
- try {
227
- const result = method === 'manifest' ? options.manifest : await options.answer(method, params, settings ?? {});
228
- reply(response, 200, { ok: true, result: result ?? null });
229
- }
230
- catch (error) {
231
- const refusal = error instanceof SlotRefusal ? error : undefined;
232
- reply(response, 200, {
233
- ok: false,
234
- error: {
235
- code: refusal?.code ?? 'module_error',
236
- message: error instanceof Error ? error.message : String(error),
237
- retryable: refusal === undefined,
238
- ...(refusal?.data !== undefined ? { data: refusal.data } : {}),
239
- },
240
- });
241
- }
253
+ const answered = await answerSlotRequest(options.manifest, options.answer, json);
254
+ reply(response, answered.status, answered.body);
242
255
  };
243
256
  const fromEnv = (process.env.MODULE_SOCKET || undefined) ?? (process.env.PORT ? Number(process.env.PORT) : undefined);
244
257
  const listen = options.listen ?? fromEnv ?? freshSocketPath() ?? 0;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * How long the vendor asked a rate-limited caller to wait, or undefined when
3
+ * it did not say. Read from `e.headers` (a plain object or `Headers`; a
4
+ * `Response` is one) or `e.response.headers`, else Google's `RetryInfo` in an
5
+ * error's message. Clamped to [1s, 120s] so a hostile or absurd value can
6
+ * neither spin nor stall a caller.
7
+ */
8
+ export declare function vendorWaitMs(e: unknown, now?: number): number | undefined;
9
+ /** What a vendor reports of its own budget on a success. */
10
+ export interface VendorBudget {
11
+ /** Calls (or tokens) the vendor says are left in its window. */
12
+ remaining: number;
13
+ /** Epoch ms when that window resets. */
14
+ resetAt: number;
15
+ }
16
+ /**
17
+ * The budget a vendor reported on a success, from its rate-limit headers
18
+ * (`headers`: a plain object or `Headers`): Anthropic's
19
+ * `anthropic-ratelimit-{requests,tokens}-{remaining,reset}` and OpenAI's
20
+ * `x-ratelimit-{remaining,reset}-{requests,tokens}`. When several limits
21
+ * report, the one that binds: an exhausted one (the latest reset among them),
22
+ * else the least remaining. Undefined when no family gave both a count and a
23
+ * reset. The reset is clamped to at most 120s ahead, as `vendorWaitMs` clamps
24
+ * a wait.
25
+ */
26
+ export declare function vendorBudget(headers: unknown, now?: number): VendorBudget | undefined;
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ // How long a rate-limited vendor asked its caller to wait. Here, below both
3
+ // the engine and the module kit, so the kit's limits door and the engine's
4
+ // limiter (`throttleAfter`, movement-engine/public/limiter) read one parser.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.vendorWaitMs = vendorWaitMs;
7
+ exports.vendorBudget = vendorBudget;
8
+ const SECOND = 1000;
9
+ const MIN_WAIT_MS = 1 * SECOND;
10
+ const MAX_WAIT_MS = 120 * SECOND;
11
+ function clamp(ms) {
12
+ return Math.min(MAX_WAIT_MS, Math.max(MIN_WAIT_MS, Math.round(ms)));
13
+ }
14
+ /** `Retry-After` is either whole seconds or an HTTP date. */
15
+ function fromHeaderValue(value, now) {
16
+ const trimmed = value.trim();
17
+ if (/^\d+(\.\d+)?$/.test(trimmed))
18
+ return Number(trimmed) * SECOND;
19
+ const date = Date.parse(trimmed);
20
+ return Number.isNaN(date) ? undefined : date - now;
21
+ }
22
+ function readHeader(headers, name) {
23
+ if (!headers || typeof headers !== 'object')
24
+ return undefined;
25
+ if ('get' in headers && typeof headers.get === 'function') {
26
+ const value = headers.get(name);
27
+ return typeof value === 'string' ? value : undefined;
28
+ }
29
+ for (const [key, value] of Object.entries(headers)) {
30
+ if (key.toLowerCase() === name && typeof value === 'string')
31
+ return value;
32
+ }
33
+ return undefined;
34
+ }
35
+ function field(value, name) {
36
+ return typeof value === 'object' && value !== null ? Reflect.get(value, name) : undefined;
37
+ }
38
+ /**
39
+ * Google's `google.rpc.RetryInfo` detail: `"retryDelay": "17s"` (or
40
+ * `"17.5s"`) on the wire, `{ seconds: 17 }` in a decoded form. The genai SDK's
41
+ * `ApiError` carries the JSON body only as text in its message.
42
+ */
43
+ function fromRetryInfo(text) {
44
+ const quoted = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(text);
45
+ if (quoted)
46
+ return Number(quoted[1]) * SECOND;
47
+ const object = /"retryDelay"\s*:\s*\{\s*"seconds"\s*:\s*"?(\d+)"?/.exec(text);
48
+ return object ? Number(object[1]) * SECOND : undefined;
49
+ }
50
+ /**
51
+ * How long the vendor asked a rate-limited caller to wait, or undefined when
52
+ * it did not say. Read from `e.headers` (a plain object or `Headers`; a
53
+ * `Response` is one) or `e.response.headers`, else Google's `RetryInfo` in an
54
+ * error's message. Clamped to [1s, 120s] so a hostile or absurd value can
55
+ * neither spin nor stall a caller.
56
+ */
57
+ function vendorWaitMs(e, now = Date.now()) {
58
+ const header = readHeader(field(e, 'headers'), 'retry-after') ?? readHeader(field(field(e, 'response'), 'headers'), 'retry-after');
59
+ const fromHeader = header === undefined ? undefined : fromHeaderValue(header, now);
60
+ if (fromHeader !== undefined)
61
+ return clamp(fromHeader);
62
+ const text = e instanceof Error ? e.message : undefined;
63
+ const fromBody = text === undefined ? undefined : fromRetryInfo(text);
64
+ return fromBody === undefined ? undefined : clamp(fromBody);
65
+ }
66
+ /** Each family of success headers: the remaining count, and when it resets. */
67
+ const BUDGET_FAMILIES = [
68
+ { remaining: 'anthropic-ratelimit-requests-remaining', reset: 'anthropic-ratelimit-requests-reset' },
69
+ { remaining: 'anthropic-ratelimit-tokens-remaining', reset: 'anthropic-ratelimit-tokens-reset' },
70
+ { remaining: 'x-ratelimit-remaining-requests', reset: 'x-ratelimit-reset-requests' },
71
+ { remaining: 'x-ratelimit-remaining-tokens', reset: 'x-ratelimit-reset-tokens' },
72
+ ];
73
+ const DURATION_UNIT_MS = { ms: 1, s: SECOND, m: 60 * SECOND, h: 3600 * SECOND };
74
+ /** OpenAI's reset form: a Go-style duration, `"1s"`, `"6m0s"`, `"20ms"`. */
75
+ function fromDuration(text) {
76
+ const parts = [...text.matchAll(/(\d+(?:\.\d+)?)(ms|h|m|s)/g)];
77
+ if (parts.length === 0 || parts.map((part) => part[0]).join('') !== text)
78
+ return undefined;
79
+ return parts.reduce((total, [, amount, unit]) => total + Number(amount) * (DURATION_UNIT_MS[unit] ?? 0), 0);
80
+ }
81
+ /** A reset as an epoch ms: an ISO timestamp (Anthropic), whole or fractional
82
+ * seconds from now, or a duration from now (OpenAI). */
83
+ function resetInstant(value, now) {
84
+ const text = value.trim();
85
+ if (/^\d+(\.\d+)?$/.test(text))
86
+ return now + Number(text) * SECOND;
87
+ const duration = fromDuration(text);
88
+ if (duration !== undefined)
89
+ return now + duration;
90
+ const date = Date.parse(text);
91
+ return Number.isNaN(date) ? undefined : date;
92
+ }
93
+ /**
94
+ * The budget a vendor reported on a success, from its rate-limit headers
95
+ * (`headers`: a plain object or `Headers`): Anthropic's
96
+ * `anthropic-ratelimit-{requests,tokens}-{remaining,reset}` and OpenAI's
97
+ * `x-ratelimit-{remaining,reset}-{requests,tokens}`. When several limits
98
+ * report, the one that binds: an exhausted one (the latest reset among them),
99
+ * else the least remaining. Undefined when no family gave both a count and a
100
+ * reset. The reset is clamped to at most 120s ahead, as `vendorWaitMs` clamps
101
+ * a wait.
102
+ */
103
+ function vendorBudget(headers, now = Date.now()) {
104
+ const reports = [];
105
+ for (const family of BUDGET_FAMILIES) {
106
+ const left = readHeader(headers, family.remaining)?.trim();
107
+ const reset = readHeader(headers, family.reset);
108
+ if (left === undefined || reset === undefined || !/^\d+$/.test(left))
109
+ continue;
110
+ const resetAt = resetInstant(reset, now);
111
+ if (resetAt === undefined)
112
+ continue;
113
+ reports.push({ remaining: Number(left), resetAt: Math.min(resetAt, now + MAX_WAIT_MS) });
114
+ }
115
+ const exhausted = reports.filter((report) => report.remaining === 0);
116
+ if (exhausted.length > 0)
117
+ return exhausted.reduce((latest, report) => (report.resetAt > latest.resetAt ? report : latest));
118
+ return reports.reduce((least, report) => (least === undefined || report.remaining < least.remaining ? report : least), undefined);
119
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@listen-fire/module-runtime",
3
3
  "license": "Apache-2.0",
4
- "version": "0.12.0-rc.7",
4
+ "version": "0.12.0",
5
5
  "description": "What a module and the host that loads it share at run time on Node, beside the runtime-neutral contracts (@listen-fire/engine-contracts): a live adapter's file bytes as a stream, the files slot, the secrets family with the generic OAuth 2 refresh and connect, the in-memory vault, the browser slot and a bounded concurrency queue. The engine and the module kit both depend on it, so the kit never depends on the engine",
6
6
  "engines": {
7
- "node": "22.20.0"
7
+ "node": ">=22.23.4 <23"
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
@@ -22,7 +22,7 @@
22
22
  "type": "commonjs",
23
23
  "dependencies": {
24
24
  "zod": "4.1.12",
25
- "@listen-fire/engine-contracts": "^0.12.0-rc.7"
25
+ "@listen-fire/engine-contracts": "^0.12.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "20.16.5",
@@ -37,8 +37,7 @@
37
37
  "dist"
38
38
  ],
39
39
  "publishConfig": {
40
- "access": "public",
41
- "tag": "next"
40
+ "access": "public"
42
41
  },
43
42
  "scripts": {
44
43
  "code:type-check": "tsc --pretty --noEmit && node ../../scripts/check-module-boundary.cjs"