@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 +31 -7
- package/dist/audit.js +20 -2
- package/dist/custody-share.d.ts +87 -2
- package/dist/custody-share.js +162 -4
- package/dist/jobs.js +20 -2
- package/dist/lifecycle.d.ts +115 -0
- package/dist/lifecycle.js +243 -0
- package/dist/queue.d.ts +11 -0
- package/dist/queue.js +22 -3
- package/package.json +5 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
QueueIntakePausedError,
|
|
163
164
|
} from '@forgezero/runtime/queue';
|
|
164
165
|
|
|
165
|
-
export const selectedCapability =
|
|
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
|
-
|
|
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
|
-
|
|
827
|
+
CUSTODY_ENVELOPE_SUITE,
|
|
804
828
|
} from '@forgezero/runtime/custody-share';
|
|
805
829
|
|
|
806
|
-
export const selectedCapability =
|
|
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
|
|
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,
|
package/dist/custody-share.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
*
|
package/dist/custody-share.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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"
|