@forgezero/runtime 0.1.22 → 0.1.25

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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  # Platform runtime
9
9
 
10
- The machinery behind a request handler — jobs, queues, an outbox, a hash-chained audit trail, mail, backups, schema validation and exact money. 31 public modules, each imported on its own.
10
+ The machinery behind a request handler — jobs, queues, an outbox, a hash-chained audit trail, mail, backups, schema validation and exact money. 32 public modules, each imported on its own.
11
11
 
12
12
  ## Package overview
13
13
 
@@ -54,6 +54,7 @@ These are supported consumer entry points, not every internal module shipped for
54
54
  | @forgezero/runtime/slip10 | SLIP-0010 derivation for ed25519, hardened-only — BIP-32 does not work on this curve and produces halves that do not correspond. | portable | [Reference + usage](#forgezero-runtime-slip10) |
55
55
  | @forgezero/runtime/identity | Hybrid Ed25519 + ML-DSA-65 request signing. One canonical string, so the compute agent that signs inside a guest and the API that verifies cannot drift — which two implementations of it certainly would. | portable | [Reference + usage](#forgezero-runtime-identity) |
56
56
  | @forgezero/runtime/vault-runtime | Provider-neutral algorithms for non-exportable Vault runtime records: passwords, DEK/KEK operations, HMAC/JWS, Ed25519/secp256k1 and Ethereum/Solana address encoding. | portable | [Reference + usage](#forgezero-runtime-vault-runtime) |
57
+ | @forgezero/runtime/lifecycle | Framework-neutral blue/green drain coordination with exact release fencing and separately proved HTTP, WebSocket, durable-job and schedule counters. | portable | [Reference + usage](#forgezero-runtime-lifecycle) |
57
58
  | @forgezero/runtime/schema | Validate against JSON Schema, restrict what a caller may declare, and describe a schema as a form. | portable | [Reference + usage](#forgezero-runtime-schema) |
58
59
  | @forgezero/runtime/schema/typebox | The TypeBox validator behind that interface. | portable | [Reference + usage](#forgezero-runtime-schema-typebox) |
59
60
  | @forgezero/runtime/finance/discounts | Promotions as arithmetic over integer minor units. They never stack — one winner — and a percentage rounds down, because rounding a discount up gives away a unit of currency per invoice forever. | portable | [Reference + usage](#forgezero-runtime-finance-discounts) |
@@ -149,7 +150,7 @@ Memory-only keyed work queue — awaited results, parallel across keys and stric
149
150
 
150
151
  ```text
151
152
  import {
152
- QueueKeyStoppedError,
153
+ QueueIntakePausedError,
153
154
  } from '@forgezero/runtime/queue';
154
155
  ```
155
156
 
@@ -159,10 +160,10 @@ This minimal executable use imports one concrete value from this exact entry poi
159
160
 
160
161
  ```text
161
162
  import {
162
- QueueKeyStoppedError,
163
+ QueueIntakePausedError,
163
164
  } from '@forgezero/runtime/queue';
164
165
 
165
- export const selectedCapability = QueueKeyStoppedError;
166
+ export const selectedCapability = QueueIntakePausedError;
166
167
  ```
167
168
 
168
169
  <a id="forgezero-runtime-outbox"></a>
@@ -533,6 +534,29 @@ import {
533
534
  export const selectedCapability = DEFAULT_PASSWORD_ALPHABET;
534
535
  ```
535
536
 
537
+ <a id="forgezero-runtime-lifecycle"></a>
538
+ ## @forgezero/runtime/lifecycle
539
+
540
+ Framework-neutral blue/green drain coordination with exact release fencing and separately proved HTTP, WebSocket, durable-job and schedule counters. This is a supported entry point. Import only the named values used by the adjacent task example; the declaration file remains the complete API reference.
541
+
542
+ ```text
543
+ import {
544
+ RUNTIME_DRAIN_BEGIN_PATH,
545
+ } from '@forgezero/runtime/lifecycle';
546
+ ```
547
+
548
+ ## @forgezero/runtime/lifecycle — Use this entry point
549
+
550
+ This minimal executable use imports one concrete value from this exact entry point without a wildcard or package-root detour. Keep the value or values the application actually needs; the full named inventory remains directly above it.
551
+
552
+ ```text
553
+ import {
554
+ RUNTIME_DRAIN_BEGIN_PATH,
555
+ } from '@forgezero/runtime/lifecycle';
556
+
557
+ export const selectedCapability = RUNTIME_DRAIN_BEGIN_PATH;
558
+ ```
559
+
536
560
  <a id="forgezero-runtime-schema"></a>
537
561
  ## @forgezero/runtime/schema
538
562
 
@@ -790,7 +814,7 @@ Threshold-share parsing, validation and reconstruction. This is a supported entr
790
814
 
791
815
  ```text
792
816
  import {
793
- PROBE_BYTES,
817
+ CUSTODY_ENVELOPE_SUITE,
794
818
  } from '@forgezero/runtime/custody-share';
795
819
  ```
796
820
 
@@ -800,10 +824,10 @@ This minimal executable use imports one concrete value from this exact entry poi
800
824
 
801
825
  ```text
802
826
  import {
803
- PROBE_BYTES,
827
+ CUSTODY_ENVELOPE_SUITE,
804
828
  } from '@forgezero/runtime/custody-share';
805
829
 
806
- export const selectedCapability = PROBE_BYTES;
830
+ export const selectedCapability = CUSTODY_ENVELOPE_SUITE;
807
831
  ```
808
832
 
809
833
  <a id="forgezero-runtime-custody-crypto"></a>
package/dist/audit.js CHANGED
@@ -14,6 +14,13 @@ class QueueStoppedError extends Error {
14
14
  }
15
15
  }
16
16
 
17
+ class QueueIntakePausedError extends Error {
18
+ constructor() {
19
+ super("queue: intake is paused for a runtime handoff");
20
+ this.name = "QueueIntakePausedError";
21
+ }
22
+ }
23
+
17
24
  class QueueKeyStoppedError extends Error {
18
25
  key;
19
26
  constructor(key) {
@@ -81,6 +88,7 @@ function createQueue(options = {}) {
81
88
  let sequence = 0;
82
89
  let globallyPaused = false;
83
90
  let accepting = true;
91
+ let intakePaused = false;
84
92
  let aborted = false;
85
93
  let completed = 0;
86
94
  let failed = 0;
@@ -161,8 +169,8 @@ function createQueue(options = {}) {
161
169
  return {
162
170
  run(key, handler, ...args) {
163
171
  const id = `q_${++sequence}`;
164
- if (!accepting || stoppedKeys.has(key)) {
165
- const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
172
+ if (!accepting || intakePaused || stoppedKeys.has(key)) {
173
+ const refused = Promise.reject(!accepting ? new QueueStoppedError : intakePaused ? new QueueIntakePausedError : new QueueKeyStoppedError(key));
166
174
  refused.catch(() => {
167
175
  return;
168
176
  });
@@ -242,6 +250,15 @@ function createQueue(options = {}) {
242
250
  globallyPaused = false;
243
251
  pump();
244
252
  },
253
+ pauseIntake() {
254
+ intakePaused = true;
255
+ },
256
+ resumeIntake() {
257
+ if (!accepting)
258
+ return;
259
+ intakePaused = false;
260
+ pump();
261
+ },
245
262
  snapshot() {
246
263
  let queued = 0;
247
264
  for (const lane of lanes.values())
@@ -252,6 +269,7 @@ function createQueue(options = {}) {
252
269
  queued,
253
270
  keys: lanes.size,
254
271
  paused: globallyPaused,
272
+ intakePaused,
255
273
  pausedKeys: [...paused],
256
274
  stoppedKeys: [...stoppedKeys],
257
275
  completed,
@@ -1,8 +1,55 @@
1
1
  import { type CipherBox, type SealedToKey } from './custody-crypto';
2
+ export declare const CUSTODY_ENVELOPE_VERSION: 3;
3
+ export declare const CUSTODY_ENVELOPE_SUITE: "ml-kem-768+x25519+hkdf-sha256+aes-256-gcm+ml-dsa-65";
4
+ export declare const CUSTODY_FACTOR_PROOF_VERSION: 1;
5
+ export type CustodyCredentialPurpose = 'custody';
6
+ export type CustodyCredentialLifecycle = 'pending' | 'active' | 'retired';
7
+ export type CustodyFactor = 'ceremony_passkey' | 'recovery_phrase';
8
+ export type CustodyEnvelopeGeneration = number;
9
+ export type CustodyTransactionOutcome = 'completed' | 'factor_mismatch' | 'credential_mismatch' | 'replayed' | 'expired' | 'batch_mismatch' | 'seed_fingerprint_mismatch';
10
+ export interface CustodyFactorBinding {
11
+ version: typeof CUSTODY_FACTOR_PROOF_VERSION;
12
+ realmId: string;
13
+ ceremonyKey: string;
14
+ custodianKey: string;
15
+ factor: CustodyFactor;
16
+ requestKey: string;
17
+ method: 'POST';
18
+ path: string;
19
+ payloadDigest: string;
20
+ challenge: string;
21
+ expiresAtSec: number;
22
+ }
23
+ export interface CustodyPasskeyProofV1 {
24
+ version: typeof CUSTODY_FACTOR_PROOF_VERSION;
25
+ factor: 'ceremony_passkey';
26
+ credentialId: string;
27
+ signature: string;
28
+ }
29
+ export interface CustodyPhraseProofV1 {
30
+ version: typeof CUSTODY_FACTOR_PROOF_VERSION;
31
+ factor: 'recovery_phrase';
32
+ signature: string;
33
+ }
34
+ export type CustodyFactorProof = CustodyPasskeyProofV1 | CustodyPhraseProofV1;
35
+ export interface CustodyActionRequest {
36
+ _key: string;
37
+ ceremonyKey: string;
38
+ ownerUserKey: string;
39
+ operation: 'seal' | 'verify' | 'reshare' | 'unlock' | 'rotate' | 'verify_rotation';
40
+ allowedFactors: CustodyFactor[];
41
+ payloadDigest: string;
42
+ challenge: string;
43
+ status: 'open' | 'consumed' | 'expired';
44
+ expiresAtSec: number;
45
+ revision: number;
46
+ }
2
47
  export interface SealedShare {
3
48
  shareIndex: number;
4
- /** Account credential whose PRF deterministically derives this passkey key. */
49
+ /** Dedicated custody credential whose PRF deterministically derives this passkey key. */
5
50
  passkeyCredentialId?: string;
51
+ /** Base64url ceremony salt persisted with that exact custody credential. */
52
+ passkeyPrfSalt?: string;
6
53
  passkeyEnvelope: CipherBox;
7
54
  phraseEnvelope: CipherBox;
8
55
  /** base64 — the HKDF salt for the phrase key and the verifier. */
@@ -10,6 +57,22 @@ export interface SealedShare {
10
57
  /** hex — proves the right phrase without being able to rebuild it. */
11
58
  phraseVerifier: string;
12
59
  }
60
+ /** New writes use this shape. V2 is retained only for bounded unlock-and-rotate migration. */
61
+ export interface CustodyEnvelopeV3 extends SealedShare {
62
+ version: typeof CUSTODY_ENVELOPE_VERSION;
63
+ suite: typeof CUSTODY_ENVELOPE_SUITE;
64
+ generation: CustodyEnvelopeGeneration;
65
+ passkeyGeneration?: CustodyEnvelopeGeneration;
66
+ phraseGeneration?: CustodyEnvelopeGeneration;
67
+ realmId: string;
68
+ ceremonyKey: string;
69
+ custodianKey: string;
70
+ passkeyCredentialId: string;
71
+ passkeyPrfSalt: string;
72
+ passkeyActionPublicKey: string;
73
+ phraseActionPublicKey: string;
74
+ }
75
+ export declare function isCustodyEnvelopeV3(value: SealedShare): value is CustodyEnvelopeV3;
13
76
  type EnvelopeKind = 'passkey' | 'phrase';
14
77
  /** The x-coordinate a share was cut at — its custodian-facing share number. */
15
78
  export declare function shareIndexOf(share: Uint8Array): number;
@@ -44,12 +107,15 @@ export declare function openShareWithPhrase(sealed: SealedShare, custodianKey: s
44
107
  * ever sent.
45
108
  */
46
109
  export interface WrappingKeys {
47
- /** The verified, PRF-capable account credential selected by the browser. */
110
+ /** The verified, PRF-capable custody credential selected by the browser. */
48
111
  passkeyCredentialId: string;
112
+ passkeyPrfSalt: string;
49
113
  passkeyPublicKey: string;
50
114
  phrasePublicKey: string;
51
115
  phraseSalt: string;
52
116
  phraseVerifier: string;
117
+ passkeyActionPublicKey: string;
118
+ phraseActionPublicKey: string;
53
119
  }
54
120
  /** The private half for one factor. Never leaves the machine that made it. */
55
121
  export declare function passkeyWrappingKey(custodianKey: string, passkeyPrfOutput: Uint8Array): {
@@ -65,9 +131,25 @@ export declare function phraseWrappingKey(custodianKey: string, phraseWords: str
65
131
  export declare function wrappingKeysFor(args: {
66
132
  custodianKey: string;
67
133
  passkeyCredentialId: string;
134
+ passkeyPrfSalt: string;
68
135
  passkeyPrfOutput: Uint8Array;
69
136
  phraseWords: string[];
70
137
  }): WrappingKeys;
138
+ export declare function custodyFactorActionPublicKey(factor: CustodyFactor, custodianKey: string, factorMaterial: Uint8Array): string;
139
+ /** Stable browser/API digest used to bind a custody proof to one exact payload. */
140
+ export declare function custodyActionPayloadDigest(value: unknown): string;
141
+ export declare function createCustodyFactorProof(args: {
142
+ binding: CustodyFactorBinding;
143
+ factorMaterial: Uint8Array;
144
+ credentialId?: string;
145
+ }): CustodyFactorProof;
146
+ export declare function createCustodyPasskeyProof(binding: CustodyFactorBinding, passkeyPrfOutput: Uint8Array, credentialId: string): CustodyPasskeyProofV1;
147
+ export declare function createCustodyPhraseProof(binding: CustodyFactorBinding, phraseWords: string[], phraseSalt: string): CustodyPhraseProofV1;
148
+ export declare function verifyCustodyFactorProof(args: {
149
+ binding: CustodyFactorBinding;
150
+ proof: CustodyFactorProof;
151
+ publicKey: string;
152
+ }): boolean;
71
153
  /** Open something the server sealed to one of those public keys. */
72
154
  export declare function openSealedToFactor(args: {
73
155
  custodianKey: string;
@@ -76,9 +158,12 @@ export declare function openSealedToFactor(args: {
76
158
  passkeyPrfOutput?: Uint8Array;
77
159
  phraseWords?: string[];
78
160
  phraseSalt?: string;
161
+ aad?: string;
79
162
  }): Uint8Array;
80
163
  /** The aad the server must use when sealing to a custodian's factor key. */
81
164
  export declare const factorWrapAad: (custodianKey: string, factor: EnvelopeKind) => string;
165
+ /** Canonical AAD for V3. Length-prefixing prevents ambiguous concatenation. */
166
+ export declare function custodyEnvelopeV3Aad(envelope: Pick<CustodyEnvelopeV3, 'version' | 'suite' | 'realmId' | 'ceremonyKey' | 'custodianKey' | 'shareIndex' | 'passkeyCredentialId' | 'generation' | 'passkeyGeneration' | 'phraseGeneration'>, factor: EnvelopeKind): string;
82
167
  /**
83
168
  * A share plus a probe, sealed together.
84
169
  *
@@ -174,9 +174,18 @@ function phraseVerifier(words, salt) {
174
174
  }
175
175
 
176
176
  // src/custody-share.ts
177
+ import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
178
+ import { hkdf as hkdf3 } from "@noble/hashes/hkdf.js";
179
+ import { sha256 as sha2563 } from "@noble/hashes/sha2.js";
180
+ var CUSTODY_ENVELOPE_VERSION = 3;
181
+ var CUSTODY_ENVELOPE_SUITE = "ml-kem-768+x25519+hkdf-sha256+aes-256-gcm+ml-dsa-65";
182
+ var CUSTODY_FACTOR_PROOF_VERSION = 1;
177
183
  var KEY_BYTES3 = 32;
178
184
  var MIN_PRF_BYTES = 32;
179
185
  var PASSKEY_KEY_SALT = "forgezero:custodian:passkey:v1";
186
+ function isCustodyEnvelopeV3(value) {
187
+ return value.version === CUSTODY_ENVELOPE_VERSION && value.suite === CUSTODY_ENVELOPE_SUITE;
188
+ }
180
189
  var utf82 = (value) => new TextEncoder().encode(value);
181
190
  var toBase642 = (bytes) => {
182
191
  let binary = "";
@@ -283,31 +292,167 @@ function phraseWrappingKey(custodianKey, phraseWords, salt) {
283
292
  function wrappingKeysFor(args) {
284
293
  if (!args.passkeyCredentialId)
285
294
  throw new Error("custody-share: passkey credential id is required");
295
+ if (!/^[A-Za-z0-9_-]{43}$/.test(args.passkeyPrfSalt)) {
296
+ throw new Error("custody-share: ceremony passkey PRF salt is required");
297
+ }
286
298
  const salt = newSalt();
287
299
  const passkey = passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput);
288
300
  const phrase = phraseWrappingKey(args.custodianKey, args.phraseWords, salt);
301
+ const passkeyAction = custodyFactorActionKeyPair("ceremony_passkey", args.custodianKey, args.passkeyPrfOutput);
302
+ const phraseMaterial = phraseToKey(args.phraseWords, salt);
303
+ const phraseAction = custodyFactorActionKeyPair("recovery_phrase", args.custodianKey, phraseMaterial);
289
304
  try {
290
305
  return {
291
306
  passkeyCredentialId: args.passkeyCredentialId,
307
+ passkeyPrfSalt: args.passkeyPrfSalt,
292
308
  passkeyPublicKey: toBase642(passkey.publicKey),
293
309
  phrasePublicKey: toBase642(phrase.publicKey),
294
310
  phraseSalt: toBase642(salt),
295
- phraseVerifier: phraseVerifier(args.phraseWords, salt)
311
+ phraseVerifier: phraseVerifier(args.phraseWords, salt),
312
+ passkeyActionPublicKey: toBase642(passkeyAction.publicKey),
313
+ phraseActionPublicKey: toBase642(phraseAction.publicKey)
296
314
  };
297
315
  } finally {
298
316
  passkey.secretKey.fill(0);
299
317
  phrase.secretKey.fill(0);
318
+ passkeyAction.secretKey.fill(0);
319
+ phraseAction.secretKey.fill(0);
320
+ phraseMaterial.fill(0);
321
+ }
322
+ }
323
+ var actionInfo = (factor, custodianKey) => `forgezero:custody:factor-action:ml-dsa-65:v1:${factor}:${custodianKey.length}:${custodianKey}`;
324
+ function custodyFactorActionKeyPair(factor, custodianKey, factorMaterial) {
325
+ assertCustodianKey(custodianKey);
326
+ if (factorMaterial.length < 32)
327
+ throw new Error("custody-share: action factor must be at least 32 bytes");
328
+ const seed = hkdf3(sha2563, factorMaterial, utf82("forgezero:custody:factor-action:ml-dsa-65:v1"), utf82(actionInfo(factor, custodianKey)), 32);
329
+ try {
330
+ const pair = ml_dsa65.keygen(seed);
331
+ return { publicKey: Uint8Array.from(pair.publicKey), secretKey: Uint8Array.from(pair.secretKey) };
332
+ } finally {
333
+ seed.fill(0);
334
+ }
335
+ }
336
+ function custodyFactorActionPublicKey(factor, custodianKey, factorMaterial) {
337
+ const pair = custodyFactorActionKeyPair(factor, custodianKey, factorMaterial);
338
+ try {
339
+ return toBase642(pair.publicKey);
340
+ } finally {
341
+ pair.secretKey.fill(0);
342
+ }
343
+ }
344
+ var proofBytes = (binding) => {
345
+ const values = [
346
+ "forgezero-custody-factor-proof-v1",
347
+ String(binding.version),
348
+ binding.realmId,
349
+ binding.ceremonyKey,
350
+ binding.custodianKey,
351
+ binding.factor,
352
+ binding.requestKey,
353
+ binding.method,
354
+ binding.path,
355
+ binding.payloadDigest,
356
+ binding.challenge,
357
+ String(binding.expiresAtSec)
358
+ ];
359
+ return utf82(values.map((value) => `${utf82(value).length}:${value}`).join(`
360
+ `));
361
+ };
362
+ function canonicalJson(value) {
363
+ if (value === null || typeof value === "boolean" || typeof value === "string")
364
+ return JSON.stringify(value);
365
+ if (typeof value === "number") {
366
+ if (!Number.isFinite(value))
367
+ throw new Error("custody-share: non-finite action payload number");
368
+ return JSON.stringify(value);
369
+ }
370
+ if (Array.isArray(value))
371
+ return `[${value.map(canonicalJson).join(",")}]`;
372
+ if (typeof value === "object") {
373
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
374
+ }
375
+ throw new Error("custody-share: unsupported action payload value");
376
+ }
377
+ function custodyActionPayloadDigest(value) {
378
+ return Array.from(sha2563(utf82(canonicalJson(value))), (byte) => byte.toString(16).padStart(2, "0")).join("");
379
+ }
380
+ function createCustodyFactorProof(args) {
381
+ if (args.binding.version !== CUSTODY_FACTOR_PROOF_VERSION) {
382
+ throw new Error("custody-share: unsupported factor proof version");
383
+ }
384
+ const pair = custodyFactorActionKeyPair(args.binding.factor, args.binding.custodianKey, args.factorMaterial);
385
+ try {
386
+ const signature = toBase642(ml_dsa65.sign(proofBytes(args.binding), pair.secretKey));
387
+ return args.binding.factor === "ceremony_passkey" ? {
388
+ version: CUSTODY_FACTOR_PROOF_VERSION,
389
+ factor: "ceremony_passkey",
390
+ credentialId: args.credentialId ?? "",
391
+ signature
392
+ } : { version: CUSTODY_FACTOR_PROOF_VERSION, factor: "recovery_phrase", signature };
393
+ } finally {
394
+ pair.secretKey.fill(0);
395
+ }
396
+ }
397
+ function createCustodyPasskeyProof(binding, passkeyPrfOutput, credentialId) {
398
+ if (binding.factor !== "ceremony_passkey") {
399
+ throw new Error("custody-share: passkey proof requires ceremony_passkey binding");
400
+ }
401
+ return createCustodyFactorProof({
402
+ binding,
403
+ factorMaterial: passkeyPrfOutput,
404
+ credentialId
405
+ });
406
+ }
407
+ function createCustodyPhraseProof(binding, phraseWords, phraseSalt) {
408
+ if (binding.factor !== "recovery_phrase") {
409
+ throw new Error("custody-share: phrase proof requires recovery_phrase binding");
410
+ }
411
+ if (!validatePhrase(phraseWords))
412
+ throw new Error("INVALID_PHRASE");
413
+ const material = phraseToKey(phraseWords, fromBase642(phraseSalt));
414
+ try {
415
+ return createCustodyFactorProof({ binding, factorMaterial: material });
416
+ } finally {
417
+ material.fill(0);
418
+ }
419
+ }
420
+ function verifyCustodyFactorProof(args) {
421
+ try {
422
+ if (args.proof.version !== CUSTODY_FACTOR_PROOF_VERSION || args.proof.factor !== args.binding.factor)
423
+ return false;
424
+ const publicKey = fromBase642(args.publicKey);
425
+ const signature = fromBase642(args.proof.signature);
426
+ return publicKey.length === ml_dsa65.lengths.publicKey && signature.length === ml_dsa65.lengths.signature && ml_dsa65.verify(signature, proofBytes(args.binding), publicKey);
427
+ } catch {
428
+ return false;
300
429
  }
301
430
  }
302
431
  function openSealedToFactor(args) {
303
432
  const pair = args.factor === "passkey" ? passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput) : phraseWrappingKey(args.custodianKey, args.phraseWords, fromBase642(args.phraseSalt));
304
433
  try {
305
- return openFromKey(pair.secretKey, args.box, wrapInfo(args.custodianKey, args.factor));
434
+ return openFromKey(pair.secretKey, args.box, args.aad ?? wrapInfo(args.custodianKey, args.factor));
306
435
  } finally {
307
436
  pair.secretKey.fill(0);
308
437
  }
309
438
  }
310
439
  var factorWrapAad = (custodianKey, factor) => wrapInfo(custodianKey, factor);
440
+ function custodyEnvelopeV3Aad(envelope, factor) {
441
+ const values = [
442
+ "forgezero-custody-envelope-aad",
443
+ String(envelope.version),
444
+ envelope.suite,
445
+ envelope.realmId,
446
+ envelope.ceremonyKey,
447
+ envelope.custodianKey,
448
+ String(envelope.shareIndex),
449
+ factor,
450
+ factor === "passkey" ? envelope.passkeyCredentialId : "recovery_phrase",
451
+ String(factor === "passkey" ? envelope.passkeyGeneration ?? envelope.generation : envelope.phraseGeneration ?? envelope.generation)
452
+ ];
453
+ return values.map((value) => `${utf82(value).length}:${value}`).join(`
454
+ `);
455
+ }
311
456
  var PROBE_BYTES = 32;
312
457
  function joinProbeAndShare(probe, share) {
313
458
  if (probe.length !== PROBE_BYTES)
@@ -324,17 +469,20 @@ function splitProbeAndShare(opened) {
324
469
  }
325
470
  function openFactorEnvelope(args) {
326
471
  const box = args.factor === "passkey" ? args.sealed.passkeyEnvelope : args.sealed.phraseEnvelope;
472
+ const aad = isCustodyEnvelopeV3(args.sealed) ? custodyEnvelopeV3Aad(args.sealed, args.factor) : undefined;
327
473
  return splitProbeAndShare(openSealedToFactor({
328
474
  custodianKey: args.custodianKey,
329
475
  factor: args.factor,
330
476
  box,
331
477
  passkeyPrfOutput: args.passkeyPrfOutput,
332
478
  phraseWords: args.phraseWords,
333
- phraseSalt: args.sealed.phraseSalt
479
+ phraseSalt: args.sealed.phraseSalt,
480
+ aad
334
481
  }));
335
482
  }
336
483
  export {
337
484
  wrappingKeysFor,
485
+ verifyCustodyFactorProof,
338
486
  splitProbeAndShare,
339
487
  shareIndexOf,
340
488
  sealShare,
@@ -345,6 +493,16 @@ export {
345
493
  openSealedToFactor,
346
494
  openFactorEnvelope,
347
495
  joinProbeAndShare,
496
+ isCustodyEnvelopeV3,
348
497
  factorWrapAad,
349
- PROBE_BYTES
498
+ custodyFactorActionPublicKey,
499
+ custodyEnvelopeV3Aad,
500
+ custodyActionPayloadDigest,
501
+ createCustodyPhraseProof,
502
+ createCustodyPasskeyProof,
503
+ createCustodyFactorProof,
504
+ PROBE_BYTES,
505
+ CUSTODY_FACTOR_PROOF_VERSION,
506
+ CUSTODY_ENVELOPE_VERSION,
507
+ CUSTODY_ENVELOPE_SUITE
350
508
  };
package/dist/jobs.js CHANGED
@@ -14,6 +14,13 @@ class QueueStoppedError extends Error {
14
14
  }
15
15
  }
16
16
 
17
+ class QueueIntakePausedError extends Error {
18
+ constructor() {
19
+ super("queue: intake is paused for a runtime handoff");
20
+ this.name = "QueueIntakePausedError";
21
+ }
22
+ }
23
+
17
24
  class QueueKeyStoppedError extends Error {
18
25
  key;
19
26
  constructor(key) {
@@ -81,6 +88,7 @@ function createQueue(options = {}) {
81
88
  let sequence = 0;
82
89
  let globallyPaused = false;
83
90
  let accepting = true;
91
+ let intakePaused = false;
84
92
  let aborted = false;
85
93
  let completed = 0;
86
94
  let failed = 0;
@@ -161,8 +169,8 @@ function createQueue(options = {}) {
161
169
  return {
162
170
  run(key, handler, ...args) {
163
171
  const id = `q_${++sequence}`;
164
- if (!accepting || stoppedKeys.has(key)) {
165
- const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
172
+ if (!accepting || intakePaused || stoppedKeys.has(key)) {
173
+ const refused = Promise.reject(!accepting ? new QueueStoppedError : intakePaused ? new QueueIntakePausedError : new QueueKeyStoppedError(key));
166
174
  refused.catch(() => {
167
175
  return;
168
176
  });
@@ -242,6 +250,15 @@ function createQueue(options = {}) {
242
250
  globallyPaused = false;
243
251
  pump();
244
252
  },
253
+ pauseIntake() {
254
+ intakePaused = true;
255
+ },
256
+ resumeIntake() {
257
+ if (!accepting)
258
+ return;
259
+ intakePaused = false;
260
+ pump();
261
+ },
245
262
  snapshot() {
246
263
  let queued = 0;
247
264
  for (const lane of lanes.values())
@@ -252,6 +269,7 @@ function createQueue(options = {}) {
252
269
  queued,
253
270
  keys: lanes.size,
254
271
  paused: globallyPaused,
272
+ intakePaused,
255
273
  pausedKeys: [...paused],
256
274
  stoppedKeys: [...stoppedKeys],
257
275
  completed,
@@ -0,0 +1,115 @@
1
+ /** Generic, framework-neutral runtime drain contract used by native and OCI workloads. */
2
+ export declare const RUNTIME_DRAIN_BEGIN_PATH = "/.forgezero/runtime/drain/v1/begin";
3
+ export declare const RUNTIME_DRAIN_STATUS_PATH = "/.forgezero/runtime/drain/v1/status";
4
+ export interface RuntimeWorkCounters {
5
+ http: number;
6
+ websocket: number;
7
+ jobs: number;
8
+ schedules: number;
9
+ }
10
+ export interface RuntimeDrainDependencies {
11
+ /** Atomically refuse all new HTTP, WebSocket, job and schedule intake. */
12
+ drainRequests(): void;
13
+ /** Resolve only after all already-admitted HTTP and WebSocket work is closed. */
14
+ waitForRequests(): Promise<void>;
15
+ /** Stop schedule intake and wait for already-leased durable jobs. */
16
+ stopJobs(timeoutMs: number): Promise<void>;
17
+ /** A live, non-secret snapshot. Every counter must reach zero before acknowledgement. */
18
+ workCounters(): RuntimeWorkCounters;
19
+ /** Injectable monotonic wait used for the bounded deadline. */
20
+ sleep(ms: number): Promise<void>;
21
+ }
22
+ export interface RuntimeDrainRequest {
23
+ protocol: 'forgezero-drain/v1';
24
+ nonce: string;
25
+ sourceRevision: string;
26
+ targetRevision: string;
27
+ timeoutMs: number;
28
+ }
29
+ export interface RuntimeDrainStatus extends RuntimeDrainRequest {
30
+ status: 'draining' | 'drained' | 'failed';
31
+ inFlight: RuntimeWorkCounters;
32
+ failure?: string;
33
+ }
34
+ /**
35
+ * One process owns one controller. A second nonce cannot replace an in-flight
36
+ * drain, while an exact retry is idempotently acknowledged.
37
+ */
38
+ export declare class RuntimeDrainController {
39
+ #private;
40
+ private readonly dependencies;
41
+ constructor(dependencies: RuntimeDrainDependencies);
42
+ begin(request: RuntimeDrainRequest): {
43
+ accepted: true;
44
+ duplicate: boolean;
45
+ };
46
+ status(nonce: string): RuntimeDrainStatus;
47
+ }
48
+ export interface RuntimeStateAdapter<T> {
49
+ protocol: string;
50
+ schemaVersion: number;
51
+ maximumBytes: number;
52
+ validate(value: unknown): value is T;
53
+ exportState(): Promise<{
54
+ cursor: string;
55
+ value: T;
56
+ }>;
57
+ importState(value: T, context: {
58
+ sourceRevision: string;
59
+ targetRevision: string;
60
+ cursor: string;
61
+ digest: `sha256:${string}`;
62
+ }): Promise<void>;
63
+ }
64
+ export interface RuntimeStateHandoffRequest {
65
+ protocol: string;
66
+ nonce: string;
67
+ schemaVersion: number;
68
+ sourceRevision: string;
69
+ targetRevision: string;
70
+ source: {
71
+ url: string;
72
+ authorization: string;
73
+ };
74
+ }
75
+ export interface RuntimeStateEnvelope<T = unknown> {
76
+ protocol: string;
77
+ schemaVersion: number;
78
+ sourceRevision: string;
79
+ targetRevision: string;
80
+ cursor: string;
81
+ digest: `sha256:${string}`;
82
+ value: T;
83
+ }
84
+ export interface RuntimeStateHandoffStatus {
85
+ protocol: string;
86
+ nonce: string;
87
+ schemaVersion: number;
88
+ sourceRevision: string;
89
+ targetRevision: string;
90
+ status: 'pending' | 'acknowledged' | 'failed';
91
+ cursor?: string;
92
+ digest?: `sha256:${string}`;
93
+ failure?: string;
94
+ }
95
+ export type RuntimeStateFetch = (input: string, init: {
96
+ method: 'GET';
97
+ headers: Readonly<Record<string, string>>;
98
+ signal: AbortSignal;
99
+ }) => Promise<Response>;
100
+ /**
101
+ * Bounded typed state transfer. The runtime owns schema validation and value
102
+ * import; the Agent sees only cursor/digest acknowledgement metadata.
103
+ */
104
+ export declare class RuntimeStateHandoffController<T> {
105
+ #private;
106
+ private readonly adapter;
107
+ private readonly fetchState;
108
+ constructor(adapter: RuntimeStateAdapter<T>, fetchState?: RuntimeStateFetch);
109
+ exportState(sourceRevision: string, targetRevision: string): Promise<RuntimeStateEnvelope<T>>;
110
+ begin(request: RuntimeStateHandoffRequest, timeoutMs: number): {
111
+ accepted: true;
112
+ duplicate: boolean;
113
+ };
114
+ status(nonce: string): RuntimeStateHandoffStatus;
115
+ }
@@ -0,0 +1,243 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/lifecycle.ts
10
+ var RUNTIME_DRAIN_BEGIN_PATH = "/.forgezero/runtime/drain/v1/begin";
11
+ var RUNTIME_DRAIN_STATUS_PATH = "/.forgezero/runtime/drain/v1/status";
12
+ var REVISION = /^[a-f0-9]{40}$/;
13
+ var NONCE = /^[A-Za-z0-9_-]{43}$/;
14
+ var counter = (value) => Number.isSafeInteger(value) && value >= 0;
15
+ var validateCounters = (value) => {
16
+ if (![value.http, value.websocket, value.jobs, value.schedules].every(counter)) {
17
+ throw new Error("RUNTIME_DRAIN_COUNTERS_INVALID");
18
+ }
19
+ return { ...value };
20
+ };
21
+ var idle = (value) => value.http === 0 && value.websocket === 0 && value.jobs === 0 && value.schedules === 0;
22
+
23
+ class RuntimeDrainController {
24
+ dependencies;
25
+ #request = null;
26
+ #status = "draining";
27
+ #failure;
28
+ constructor(dependencies) {
29
+ this.dependencies = dependencies;
30
+ }
31
+ begin(request) {
32
+ if (request.protocol !== "forgezero-drain/v1" || !NONCE.test(request.nonce) || !REVISION.test(request.sourceRevision) || !REVISION.test(request.targetRevision) || !Number.isSafeInteger(request.timeoutMs) || request.timeoutMs < 100 || request.timeoutMs > 600000) {
33
+ throw new Error("RUNTIME_DRAIN_REQUEST_INVALID");
34
+ }
35
+ if (this.#request) {
36
+ const duplicate = this.#request.nonce === request.nonce && this.#request.sourceRevision === request.sourceRevision && this.#request.targetRevision === request.targetRevision;
37
+ if (!duplicate)
38
+ throw new Error("RUNTIME_DRAIN_ALREADY_STARTED");
39
+ return { accepted: true, duplicate: true };
40
+ }
41
+ this.#request = { ...request };
42
+ this.#status = "draining";
43
+ this.dependencies.drainRequests();
44
+ const work = Promise.all([
45
+ this.dependencies.waitForRequests(),
46
+ this.dependencies.stopJobs(request.timeoutMs)
47
+ ]);
48
+ Promise.race([
49
+ work.then(() => "completed"),
50
+ this.dependencies.sleep(request.timeoutMs).then(() => "timeout")
51
+ ]).then((result) => {
52
+ if (result === "timeout")
53
+ throw new Error("RUNTIME_DRAIN_TIMEOUT");
54
+ if (!idle(validateCounters(this.dependencies.workCounters())))
55
+ throw new Error("RUNTIME_DRAIN_NOT_IDLE");
56
+ this.#status = "drained";
57
+ }).catch((cause) => {
58
+ this.#status = "failed";
59
+ this.#failure = cause instanceof Error ? cause.message.slice(0, 128) : "RUNTIME_DRAIN_FAILED";
60
+ });
61
+ return { accepted: true, duplicate: false };
62
+ }
63
+ status(nonce) {
64
+ if (!this.#request || !NONCE.test(nonce) || nonce !== this.#request.nonce) {
65
+ throw new Error("RUNTIME_DRAIN_NOT_FOUND");
66
+ }
67
+ return {
68
+ ...this.#request,
69
+ status: this.#status,
70
+ inFlight: validateCounters(this.dependencies.workCounters()),
71
+ ...this.#failure ? { failure: this.#failure } : {}
72
+ };
73
+ }
74
+ }
75
+ var HANDOFF_TEXT = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$/;
76
+ var CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.:@/+\-=]{0,511}$/;
77
+ var DIGEST = /^sha256:[a-f0-9]{64}$/;
78
+ var AUTHORIZATION = /^Bearer [A-Za-z0-9_-]{43}$/;
79
+ var canonicalJson = (value, seen = new Set) => {
80
+ if (value === null)
81
+ return "null";
82
+ if (typeof value === "string")
83
+ return JSON.stringify(value);
84
+ if (typeof value === "boolean")
85
+ return value ? "true" : "false";
86
+ if (typeof value === "number") {
87
+ if (!Number.isFinite(value))
88
+ throw new Error("RUNTIME_STATE_VALUE_INVALID");
89
+ return JSON.stringify(value);
90
+ }
91
+ if (typeof value !== "object" || seen.has(value))
92
+ throw new Error("RUNTIME_STATE_VALUE_INVALID");
93
+ seen.add(value);
94
+ try {
95
+ if (Array.isArray(value))
96
+ return `[${value.map((entry) => canonicalJson(entry, seen)).join(",")}]`;
97
+ const object = value;
98
+ const entries = Object.keys(object).sort().map((key) => {
99
+ if (object[key] === undefined)
100
+ throw new Error("RUNTIME_STATE_VALUE_INVALID");
101
+ return `${JSON.stringify(key)}:${canonicalJson(object[key], seen)}`;
102
+ });
103
+ return `{${entries.join(",")}}`;
104
+ } finally {
105
+ seen.delete(value);
106
+ }
107
+ };
108
+ var stateDigest = async (input) => {
109
+ const bytes = new TextEncoder().encode(canonicalJson(input));
110
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
111
+ return `sha256:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
112
+ };
113
+ var assertAdapter = (adapter) => {
114
+ if (!HANDOFF_TEXT.test(adapter.protocol) || !Number.isSafeInteger(adapter.schemaVersion) || adapter.schemaVersion < 1 || adapter.schemaVersion > 65535 || !Number.isSafeInteger(adapter.maximumBytes) || adapter.maximumBytes < 1 || adapter.maximumBytes > 1048576) {
115
+ throw new Error("RUNTIME_STATE_ADAPTER_INVALID");
116
+ }
117
+ };
118
+ var loopbackSource = (raw) => {
119
+ const value = new URL(raw);
120
+ if (value.protocol !== "http:" || value.username || value.password || value.hash || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(value.hostname)) {
121
+ throw new Error("RUNTIME_STATE_SOURCE_INVALID");
122
+ }
123
+ return value.toString();
124
+ };
125
+
126
+ class RuntimeStateHandoffController {
127
+ adapter;
128
+ fetchState;
129
+ #request = null;
130
+ #status = null;
131
+ constructor(adapter, fetchState = (input, init) => fetch(input, init)) {
132
+ this.adapter = adapter;
133
+ this.fetchState = fetchState;
134
+ assertAdapter(adapter);
135
+ }
136
+ async exportState(sourceRevision, targetRevision) {
137
+ if (!REVISION.test(sourceRevision) || !REVISION.test(targetRevision))
138
+ throw new Error("RUNTIME_STATE_REVISION_INVALID");
139
+ const exported = await this.adapter.exportState();
140
+ if (!CURSOR.test(exported.cursor) || !this.adapter.validate(exported.value)) {
141
+ throw new Error("RUNTIME_STATE_EXPORT_INVALID");
142
+ }
143
+ const unsigned = {
144
+ protocol: this.adapter.protocol,
145
+ schemaVersion: this.adapter.schemaVersion,
146
+ sourceRevision,
147
+ targetRevision,
148
+ cursor: exported.cursor,
149
+ value: exported.value
150
+ };
151
+ if (new TextEncoder().encode(canonicalJson(unsigned)).byteLength > this.adapter.maximumBytes) {
152
+ throw new Error("RUNTIME_STATE_EXPORT_TOO_LARGE");
153
+ }
154
+ return { ...unsigned, digest: await stateDigest(unsigned) };
155
+ }
156
+ begin(request, timeoutMs) {
157
+ if (!NONCE.test(request.nonce) || request.protocol !== this.adapter.protocol || request.schemaVersion !== this.adapter.schemaVersion || !REVISION.test(request.sourceRevision) || !REVISION.test(request.targetRevision) || !AUTHORIZATION.test(request.source.authorization) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 600000) {
158
+ throw new Error("RUNTIME_STATE_REQUEST_INVALID");
159
+ }
160
+ const sourceUrl = loopbackSource(request.source.url);
161
+ if (this.#request) {
162
+ const duplicate = canonicalJson(this.#request) === canonicalJson(request);
163
+ if (!duplicate)
164
+ throw new Error("RUNTIME_STATE_ALREADY_STARTED");
165
+ return { accepted: true, duplicate: true };
166
+ }
167
+ this.#request = structuredClone(request);
168
+ this.#status = {
169
+ protocol: request.protocol,
170
+ nonce: request.nonce,
171
+ schemaVersion: request.schemaVersion,
172
+ sourceRevision: request.sourceRevision,
173
+ targetRevision: request.targetRevision,
174
+ status: "pending"
175
+ };
176
+ this.fetchState(sourceUrl, {
177
+ method: "GET",
178
+ headers: { Authorization: request.source.authorization },
179
+ signal: AbortSignal.timeout(timeoutMs)
180
+ }).then(async (response) => {
181
+ if (!response.ok)
182
+ throw new Error(`RUNTIME_STATE_SOURCE_HTTP_${response.status}`);
183
+ const length = Number(response.headers.get("content-length") ?? 0);
184
+ if (length > this.adapter.maximumBytes)
185
+ throw new Error("RUNTIME_STATE_SOURCE_TOO_LARGE");
186
+ const bytes = new Uint8Array(await response.arrayBuffer());
187
+ if (bytes.byteLength > this.adapter.maximumBytes)
188
+ throw new Error("RUNTIME_STATE_SOURCE_TOO_LARGE");
189
+ const envelope = JSON.parse(new TextDecoder().decode(bytes));
190
+ if (envelope.protocol !== request.protocol || envelope.schemaVersion !== request.schemaVersion || envelope.sourceRevision !== request.sourceRevision || envelope.targetRevision !== request.targetRevision || !CURSOR.test(envelope.cursor) || !DIGEST.test(envelope.digest) || !this.adapter.validate(envelope.value)) {
191
+ throw new Error("RUNTIME_STATE_ENVELOPE_INVALID");
192
+ }
193
+ const expected = await stateDigest({
194
+ protocol: envelope.protocol,
195
+ schemaVersion: envelope.schemaVersion,
196
+ sourceRevision: envelope.sourceRevision,
197
+ targetRevision: envelope.targetRevision,
198
+ cursor: envelope.cursor,
199
+ value: envelope.value
200
+ });
201
+ if (expected !== envelope.digest)
202
+ throw new Error("RUNTIME_STATE_DIGEST_MISMATCH");
203
+ await this.adapter.importState(envelope.value, {
204
+ sourceRevision: request.sourceRevision,
205
+ targetRevision: request.targetRevision,
206
+ cursor: envelope.cursor,
207
+ digest: envelope.digest
208
+ });
209
+ this.#status = {
210
+ protocol: request.protocol,
211
+ nonce: request.nonce,
212
+ schemaVersion: request.schemaVersion,
213
+ sourceRevision: request.sourceRevision,
214
+ targetRevision: request.targetRevision,
215
+ status: "acknowledged",
216
+ cursor: envelope.cursor,
217
+ digest: envelope.digest
218
+ };
219
+ }).catch((cause) => {
220
+ this.#status = {
221
+ protocol: request.protocol,
222
+ nonce: request.nonce,
223
+ schemaVersion: request.schemaVersion,
224
+ sourceRevision: request.sourceRevision,
225
+ targetRevision: request.targetRevision,
226
+ status: "failed",
227
+ failure: cause instanceof Error ? cause.message.slice(0, 128) : "RUNTIME_STATE_FAILED"
228
+ };
229
+ });
230
+ return { accepted: true, duplicate: false };
231
+ }
232
+ status(nonce) {
233
+ if (!this.#status || !NONCE.test(nonce) || nonce !== this.#status.nonce)
234
+ throw new Error("RUNTIME_STATE_NOT_FOUND");
235
+ return structuredClone(this.#status);
236
+ }
237
+ }
238
+ export {
239
+ RuntimeStateHandoffController,
240
+ RuntimeDrainController,
241
+ RUNTIME_DRAIN_STATUS_PATH,
242
+ RUNTIME_DRAIN_BEGIN_PATH
243
+ };
package/dist/queue.d.ts CHANGED
@@ -80,6 +80,10 @@ export interface DrainReport {
80
80
  export declare class QueueStoppedError extends Error {
81
81
  constructor();
82
82
  }
83
+ /** A reversible handoff fence refused new work while existing work drained. */
84
+ export declare class QueueIntakePausedError extends Error {
85
+ constructor();
86
+ }
83
87
  export declare class QueueKeyStoppedError extends Error {
84
88
  readonly key: string;
85
89
  constructor(key: string);
@@ -116,6 +120,12 @@ export declare function createQueue(options?: QueueOptions): {
116
120
  /** Hold everything. New submissions are accepted and wait. */
117
121
  pause(): void;
118
122
  resume(): void;
123
+ /**
124
+ * Refuse new submissions without stopping the queue. Already accepted work
125
+ * continues, so `whenIdle()` is an exact reversible handoff boundary.
126
+ */
127
+ pauseIntake(): void;
128
+ resumeIntake(): void;
119
129
  /** How much is outstanding, for a health endpoint or a drain decision. */
120
130
  snapshot(): {
121
131
  width: number;
@@ -123,6 +133,7 @@ export declare function createQueue(options?: QueueOptions): {
123
133
  queued: number;
124
134
  keys: number;
125
135
  paused: boolean;
136
+ intakePaused: boolean;
126
137
  pausedKeys: string[];
127
138
  stoppedKeys: string[];
128
139
  completed: number;
package/dist/queue.js CHANGED
@@ -14,6 +14,13 @@ class QueueStoppedError extends Error {
14
14
  }
15
15
  }
16
16
 
17
+ class QueueIntakePausedError extends Error {
18
+ constructor() {
19
+ super("queue: intake is paused for a runtime handoff");
20
+ this.name = "QueueIntakePausedError";
21
+ }
22
+ }
23
+
17
24
  class QueueKeyStoppedError extends Error {
18
25
  key;
19
26
  constructor(key) {
@@ -81,6 +88,7 @@ function createQueue(options = {}) {
81
88
  let sequence = 0;
82
89
  let globallyPaused = false;
83
90
  let accepting = true;
91
+ let intakePaused = false;
84
92
  let aborted = false;
85
93
  let completed = 0;
86
94
  let failed = 0;
@@ -161,8 +169,8 @@ function createQueue(options = {}) {
161
169
  return {
162
170
  run(key, handler, ...args) {
163
171
  const id = `q_${++sequence}`;
164
- if (!accepting || stoppedKeys.has(key)) {
165
- const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
172
+ if (!accepting || intakePaused || stoppedKeys.has(key)) {
173
+ const refused = Promise.reject(!accepting ? new QueueStoppedError : intakePaused ? new QueueIntakePausedError : new QueueKeyStoppedError(key));
166
174
  refused.catch(() => {
167
175
  return;
168
176
  });
@@ -242,6 +250,15 @@ function createQueue(options = {}) {
242
250
  globallyPaused = false;
243
251
  pump();
244
252
  },
253
+ pauseIntake() {
254
+ intakePaused = true;
255
+ },
256
+ resumeIntake() {
257
+ if (!accepting)
258
+ return;
259
+ intakePaused = false;
260
+ pump();
261
+ },
245
262
  snapshot() {
246
263
  let queued = 0;
247
264
  for (const lane of lanes.values())
@@ -252,6 +269,7 @@ function createQueue(options = {}) {
252
269
  queued,
253
270
  keys: lanes.size,
254
271
  paused: globallyPaused,
272
+ intakePaused,
255
273
  pausedKeys: [...paused],
256
274
  stoppedKeys: [...stoppedKeys],
257
275
  completed,
@@ -312,5 +330,6 @@ export {
312
330
  createQueue,
313
331
  TaskCancelledError,
314
332
  QueueStoppedError,
315
- QueueKeyStoppedError
333
+ QueueKeyStoppedError,
334
+ QueueIntakePausedError
316
335
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgezero/runtime",
3
- "version": "0.1.22",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -51,6 +51,10 @@
51
51
  "types": "./dist/realtime.d.ts",
52
52
  "default": "./dist/realtime.js"
53
53
  },
54
+ "./lifecycle": {
55
+ "types": "./dist/lifecycle.d.ts",
56
+ "default": "./dist/lifecycle.js"
57
+ },
54
58
  "./calendar": {
55
59
  "types": "./dist/calendar.d.ts",
56
60
  "default": "./dist/calendar.js"