@forgezero/runtime 0.1.24 → 0.1.26

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. 32 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. 33 public modules, each imported on its own.
11
11
 
12
12
  ## Package overview
13
13
 
@@ -62,6 +62,7 @@ These are supported consumer entry points, not every internal module shipped for
62
62
  | @forgezero/runtime/finance/tax | Which jurisdiction may tax a sale, who accounts for it, and the three ways to be zero that report differently. Rates are data. | portable | [Reference + usage](#forgezero-runtime-finance-tax) |
63
63
  | @forgezero/runtime/finance/storage | Write an amount so it is both exact and sortable: an authoritative string, and a number that is only an index. | portable | [Reference + usage](#forgezero-runtime-finance-storage) |
64
64
  | @forgezero/runtime/realtime | Provider-neutral realtime audience, shard, event and delivery contracts. | portable | [Reference + usage](#forgezero-runtime-realtime) |
65
+ | @forgezero/runtime/operation | Revisioned surface, operation-state and bounded redacted operation-log contracts. | portable | [Reference + usage](#forgezero-runtime-operation) |
65
66
  | @forgezero/runtime/passkey-hybrid | Versioned WebAuthn PRF plus ML-DSA companion proof construction and verification. | portable | [Reference + usage](#forgezero-runtime-passkey-hybrid) |
66
67
  | @forgezero/runtime/otpauth | Parse and render otpauth URIs without binding enrolment to a UI framework. | portable | [Reference + usage](#forgezero-runtime-otpauth) |
67
68
  | @forgezero/runtime/pipeline | Typed ordered application-pipeline execution with explicit evidence. | portable | [Reference + usage](#forgezero-runtime-pipeline) |
@@ -722,7 +723,7 @@ Provider-neutral realtime audience, shard, event and delivery contracts. This is
722
723
 
723
724
  ```text
724
725
  import {
725
- REALTIME_MAX_BATCH_BYTES,
726
+ REALTIME_FLUSH_BATCH_BYTES,
726
727
  } from '@forgezero/runtime/realtime';
727
728
  ```
728
729
 
@@ -732,10 +733,33 @@ This minimal executable use imports one concrete value from this exact entry poi
732
733
 
733
734
  ```text
734
735
  import {
735
- REALTIME_MAX_BATCH_BYTES,
736
+ REALTIME_FLUSH_BATCH_BYTES,
736
737
  } from '@forgezero/runtime/realtime';
737
738
 
738
- export const selectedCapability = REALTIME_MAX_BATCH_BYTES;
739
+ export const selectedCapability = REALTIME_FLUSH_BATCH_BYTES;
740
+ ```
741
+
742
+ <a id="forgezero-runtime-operation"></a>
743
+ ## @forgezero/runtime/operation
744
+
745
+ Revisioned surface, operation-state and bounded redacted operation-log contracts. 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.
746
+
747
+ ```text
748
+ import {
749
+ OPERATION_MAX_EVENTS,
750
+ } from '@forgezero/runtime/operation';
751
+ ```
752
+
753
+ ## @forgezero/runtime/operation — Use this entry point
754
+
755
+ 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.
756
+
757
+ ```text
758
+ import {
759
+ OPERATION_MAX_EVENTS,
760
+ } from '@forgezero/runtime/operation';
761
+
762
+ export const selectedCapability = OPERATION_MAX_EVENTS;
739
763
  ```
740
764
 
741
765
  <a id="forgezero-runtime-passkey-hybrid"></a>
@@ -814,7 +838,7 @@ Threshold-share parsing, validation and reconstruction. This is a supported entr
814
838
 
815
839
  ```text
816
840
  import {
817
- PROBE_BYTES,
841
+ CUSTODY_ENVELOPE_SUITE,
818
842
  } from '@forgezero/runtime/custody-share';
819
843
  ```
820
844
 
@@ -824,10 +848,10 @@ This minimal executable use imports one concrete value from this exact entry poi
824
848
 
825
849
  ```text
826
850
  import {
827
- PROBE_BYTES,
851
+ CUSTODY_ENVELOPE_SUITE,
828
852
  } from '@forgezero/runtime/custody-share';
829
853
 
830
- export const selectedCapability = PROBE_BYTES;
854
+ export const selectedCapability = CUSTODY_ENVELOPE_SUITE;
831
855
  ```
832
856
 
833
857
  <a id="forgezero-runtime-custody-crypto"></a>
@@ -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
  };
@@ -0,0 +1,64 @@
1
+ /** A revisioned read model returned by one bounded application surface. */
2
+ export interface SurfaceSnapshot<T> {
3
+ revision: string;
4
+ generatedAtTs: number;
5
+ data: T;
6
+ }
7
+ export declare const OPERATION_STATUSES: readonly ["queued", "running", "stalled", "failed", "succeeded", "cancelled", "compensating", "compensated"];
8
+ export type OperationStatus = (typeof OPERATION_STATUSES)[number];
9
+ export type OperationEventOrigin = 'platform-api' | 'bootstrap-runner' | 'metal-agent' | 'compute-agent' | 'worker';
10
+ export interface OperationFailure {
11
+ code: string;
12
+ detail: string;
13
+ retryable: boolean;
14
+ }
15
+ export interface OperationEvent {
16
+ sequence: number;
17
+ stage: string;
18
+ status: OperationStatus;
19
+ atTs: number;
20
+ attempt: number;
21
+ origin: OperationEventOrigin;
22
+ nodeKey?: string;
23
+ failure?: OperationFailure;
24
+ }
25
+ export interface OperationSnapshot {
26
+ operationKey: string;
27
+ kind: string;
28
+ correlationId: string;
29
+ parentOperationKey?: string;
30
+ childOperationKeys: readonly string[];
31
+ stage: string;
32
+ status: OperationStatus;
33
+ attempt: number;
34
+ revision: number;
35
+ createdAtTs: number;
36
+ updatedAtTs: number;
37
+ lastHeartbeatAtTs?: number;
38
+ deadlineAtTs?: number;
39
+ leaseOwner?: string;
40
+ failure?: OperationFailure;
41
+ events: readonly OperationEvent[];
42
+ }
43
+ export interface OperationLogLine {
44
+ sequence: number;
45
+ atTs: number;
46
+ stream: 'stdout' | 'stderr' | 'system';
47
+ text: string;
48
+ }
49
+ export interface OperationLogBatch {
50
+ operationKey: string;
51
+ fromSequence: number;
52
+ toSequence: number;
53
+ terminal: boolean;
54
+ lines: readonly OperationLogLine[];
55
+ }
56
+ export declare const OPERATION_MAX_EVENTS = 128;
57
+ export declare const OPERATION_MAX_LOG_LINES = 100;
58
+ export declare const OPERATION_MAX_LOG_TEXT_BYTES: number;
59
+ /** Redact common credential forms before an operation line reaches storage or a socket. */
60
+ export declare function redactOperationLogText(input: string): string;
61
+ export declare function validateSurfaceSnapshot<T>(input: unknown, validateData: (value: unknown) => value is T): SurfaceSnapshot<T>;
62
+ export declare function validateOperationFailure(input: unknown): OperationFailure;
63
+ export declare function validateOperationSnapshot(input: unknown): OperationSnapshot;
64
+ export declare function validateOperationLogBatch(input: unknown): OperationLogBatch;
@@ -0,0 +1,107 @@
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/operation.ts
10
+ var OPERATION_STATUSES = [
11
+ "queued",
12
+ "running",
13
+ "stalled",
14
+ "failed",
15
+ "succeeded",
16
+ "cancelled",
17
+ "compensating",
18
+ "compensated"
19
+ ];
20
+ var ATOM = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/;
21
+ var FAILURE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
22
+ var OPERATION_MAX_EVENTS = 128;
23
+ var OPERATION_MAX_LOG_LINES = 100;
24
+ var OPERATION_MAX_LOG_TEXT_BYTES = 8 * 1024;
25
+ function redactOperationLogText(input) {
26
+ return input.replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/gi, "[REDACTED_PRIVATE_KEY]").replace(/\b(?:Bearer\s+)?(?:gh[opsu]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|fze_[A-Za-z0-9_-]{16,})\b/gi, "[REDACTED_TOKEN]").replace(/\b(password|secret|token|api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi, "$1=[REDACTED]").slice(0, OPERATION_MAX_LOG_TEXT_BYTES);
27
+ }
28
+ var finiteEpoch = (value) => Number.isSafeInteger(value) && Number(value) >= 0;
29
+ var atom = (value) => typeof value === "string" && ATOM.test(value);
30
+ function validateSurfaceSnapshot(input, validateData) {
31
+ if (!input || typeof input !== "object" || Array.isArray(input))
32
+ throw new Error("Surface snapshot must be an object.");
33
+ const row = input;
34
+ if (!atom(row.revision) || !finiteEpoch(row.generatedAtTs) || !validateData(row.data)) {
35
+ throw new Error("Surface snapshot is invalid.");
36
+ }
37
+ return { revision: row.revision, generatedAtTs: row.generatedAtTs, data: row.data };
38
+ }
39
+ function validateOperationFailure(input) {
40
+ if (!input || typeof input !== "object" || Array.isArray(input))
41
+ throw new Error("Operation failure must be an object.");
42
+ const row = input;
43
+ if (typeof row.code !== "string" || !FAILURE_CODE.test(row.code) || typeof row.detail !== "string" || !row.detail.trim() || new TextEncoder().encode(row.detail).byteLength > 2000 || typeof row.retryable !== "boolean") {
44
+ throw new Error("Operation failure is invalid.");
45
+ }
46
+ return { code: row.code, detail: row.detail.trim(), retryable: row.retryable };
47
+ }
48
+ function validateOperationSnapshot(input) {
49
+ if (!input || typeof input !== "object" || Array.isArray(input))
50
+ throw new Error("Operation snapshot must be an object.");
51
+ const row = input;
52
+ if (!atom(row.operationKey) || !atom(row.kind) || !atom(row.correlationId) || row.parentOperationKey !== undefined && !atom(row.parentOperationKey) || !Array.isArray(row.childOperationKeys) || row.childOperationKeys.length > 128 || row.childOperationKeys.some((key) => !atom(key)) || !atom(row.stage) || !OPERATION_STATUSES.includes(row.status) || !Number.isSafeInteger(row.attempt) || row.attempt < 1 || !Number.isSafeInteger(row.revision) || row.revision < 1 || !finiteEpoch(row.createdAtTs) || !finiteEpoch(row.updatedAtTs) || row.updatedAtTs < row.createdAtTs || row.lastHeartbeatAtTs !== undefined && !finiteEpoch(row.lastHeartbeatAtTs) || row.deadlineAtTs !== undefined && !finiteEpoch(row.deadlineAtTs) || row.leaseOwner !== undefined && !atom(row.leaseOwner) || !Array.isArray(row.events) || row.events.length < 1 || row.events.length > OPERATION_MAX_EVENTS) {
53
+ throw new Error("Operation snapshot coordinates are invalid.");
54
+ }
55
+ const events = row.events.map((event, index) => {
56
+ if (!event || typeof event !== "object" || Array.isArray(event))
57
+ throw new Error("Operation event is invalid.");
58
+ const value = event;
59
+ if (value.sequence !== index + 1 || !atom(value.stage) || !OPERATION_STATUSES.includes(value.status) || !finiteEpoch(value.atTs) || !Number.isSafeInteger(value.attempt) || value.attempt < 1 || !["platform-api", "bootstrap-runner", "metal-agent", "compute-agent", "worker"].includes(String(value.origin)) || value.nodeKey !== undefined && !atom(value.nodeKey))
60
+ throw new Error("Operation event is invalid.");
61
+ return { ...value, ...value.failure ? { failure: validateOperationFailure(value.failure) } : {} };
62
+ });
63
+ return {
64
+ ...row,
65
+ childOperationKeys: [...row.childOperationKeys],
66
+ ...row.failure ? { failure: validateOperationFailure(row.failure) } : {},
67
+ events
68
+ };
69
+ }
70
+ function validateOperationLogBatch(input) {
71
+ if (!input || typeof input !== "object" || Array.isArray(input))
72
+ throw new Error("Operation log batch must be an object.");
73
+ const row = input;
74
+ if (!atom(row.operationKey) || !Number.isSafeInteger(row.fromSequence) || !Number.isSafeInteger(row.toSequence) || row.fromSequence < 1 || row.toSequence < row.fromSequence || typeof row.terminal !== "boolean" || !Array.isArray(row.lines) || row.lines.length < 1 || row.lines.length > OPERATION_MAX_LOG_LINES) {
75
+ throw new Error("Operation log batch coordinates are invalid.");
76
+ }
77
+ const lines = row.lines.map((line, index) => {
78
+ if (!line || typeof line !== "object" || Array.isArray(line))
79
+ throw new Error("Operation log line is invalid.");
80
+ const value = line;
81
+ const expected = row.fromSequence + index;
82
+ if (value.sequence !== expected || !finiteEpoch(value.atTs) || !["stdout", "stderr", "system"].includes(String(value.stream)) || typeof value.text !== "string" || !value.text || new TextEncoder().encode(value.text).byteLength > OPERATION_MAX_LOG_TEXT_BYTES) {
83
+ throw new Error("Operation log line is invalid or non-contiguous.");
84
+ }
85
+ return { sequence: value.sequence, atTs: value.atTs, stream: value.stream, text: redactOperationLogText(value.text) };
86
+ });
87
+ if (lines.at(-1).sequence !== row.toSequence)
88
+ throw new Error("Operation log range does not match its lines.");
89
+ return {
90
+ operationKey: row.operationKey,
91
+ fromSequence: row.fromSequence,
92
+ toSequence: row.toSequence,
93
+ terminal: row.terminal,
94
+ lines
95
+ };
96
+ }
97
+ export {
98
+ validateSurfaceSnapshot,
99
+ validateOperationSnapshot,
100
+ validateOperationLogBatch,
101
+ validateOperationFailure,
102
+ redactOperationLogText,
103
+ OPERATION_STATUSES,
104
+ OPERATION_MAX_LOG_TEXT_BYTES,
105
+ OPERATION_MAX_LOG_LINES,
106
+ OPERATION_MAX_EVENTS
107
+ };
@@ -1,13 +1,80 @@
1
1
  export declare const REALTIME_MAX_EVENTS = 100;
2
2
  export declare const REALTIME_MAX_EVENT_BYTES: number;
3
3
  export declare const REALTIME_MAX_BATCH_BYTES: number;
4
+ export declare const REALTIME_FLUSH_BATCH_BYTES: number;
4
5
  export declare const REALTIME_MAX_SOCKETS_PER_SHARD = 1000;
5
6
  export declare const REALTIME_MAX_SHARDS_PER_TOPIC = 1024;
6
- export interface RealtimeEvent {
7
+ export type RealtimeResourceScope = Readonly<{
8
+ realmId: string;
9
+ projectKey?: string;
10
+ }>;
11
+ export type RealtimeEvent = Readonly<{
12
+ id: string;
13
+ type: 'resource.patch';
14
+ payload: {
15
+ scope: RealtimeResourceScope;
16
+ resourceKind: string;
17
+ resourceKey: string;
18
+ previousRevision?: string;
19
+ revision: string;
20
+ projection: unknown;
21
+ };
22
+ }> | Readonly<{
23
+ id: string;
24
+ type: 'operation.state';
25
+ payload: {
26
+ operationKey: string;
27
+ parentOperationKey?: string;
28
+ kind: string;
29
+ stage: string;
30
+ status: string;
31
+ attempt: number;
32
+ revision: number;
33
+ updatedAtTs: number;
34
+ failure?: {
35
+ code: string;
36
+ detail: string;
37
+ retryable: boolean;
38
+ };
39
+ };
40
+ }> | Readonly<{
41
+ id: string;
42
+ type: 'operation.log';
43
+ payload: {
44
+ operationKey: string;
45
+ fromSequence: number;
46
+ toSequence: number;
47
+ terminal: boolean;
48
+ lines: readonly {
49
+ sequence: number;
50
+ atTs: number;
51
+ stream: 'stdout' | 'stderr' | 'system';
52
+ text: string;
53
+ }[];
54
+ };
55
+ }> | Readonly<{
56
+ id: string;
57
+ type: 'notification.summary';
58
+ payload: {
59
+ unread: number;
60
+ latest?: {
61
+ key: string;
62
+ title: string;
63
+ message: string;
64
+ tone: 'warning' | 'error' | 'info';
65
+ };
66
+ };
67
+ }> | Readonly<{
68
+ id: string;
69
+ type: 'session.changed';
70
+ payload: {
71
+ revision: string;
72
+ };
73
+ }> | Readonly<{
7
74
  id: string;
8
75
  type: string;
9
76
  payload: unknown;
10
- }
77
+ }>;
11
78
  export type RealtimePrincipalKind = 'user' | 'node' | 'api-key' | 'service' | 'header';
12
79
  export type RealtimeAudience = Readonly<{
13
80
  kind: 'public';
@@ -29,7 +96,7 @@ export type RealtimeAudience = Readonly<{
29
96
  capability: string;
30
97
  }>;
31
98
  export interface RealtimeBatch {
32
- version: 2;
99
+ version: 2 | 3;
33
100
  batchId: string;
34
101
  topic: string;
35
102
  audience: RealtimeAudience;
@@ -40,7 +107,7 @@ export declare function validateRealtimeAudience(input: unknown): RealtimeAudien
40
107
  export declare function validateRealtimeBatch(input: unknown): RealtimeBatch;
41
108
  export declare const realtimeBatchBytes: (input: unknown) => string;
42
109
  export interface RealtimeSubscriptionTicket {
43
- version: 2;
110
+ version: 2 | 3;
44
111
  topic: string;
45
112
  principal: Readonly<{
46
113
  kind: RealtimePrincipalKind;
@@ -50,9 +117,20 @@ export interface RealtimeSubscriptionTicket {
50
117
  projectKeys: readonly string[];
51
118
  groups: readonly string[];
52
119
  capabilities: readonly string[];
120
+ subscriptions?: readonly RealtimeSubscription[];
53
121
  expiresAtSec: number;
54
122
  nonce: string;
55
123
  }
124
+ export type RealtimeSubscription = Readonly<{
125
+ kind: 'notifications';
126
+ }> | Readonly<{
127
+ kind: 'project';
128
+ projectKey: string;
129
+ }> | Readonly<{
130
+ kind: 'operation';
131
+ operationKey: string;
132
+ logs: boolean;
133
+ }>;
56
134
  export declare function validateRealtimeSubscriptionTicket(input: unknown, nowSec?: number): RealtimeSubscriptionTicket;
57
135
  /**
58
136
  * The edge has no database authority. It may deliver only when the short-lived,
@@ -61,7 +139,11 @@ export declare function validateRealtimeSubscriptionTicket(input: unknown, nowSe
61
139
  * decision.
62
140
  */
63
141
  export declare function canReceiveRealtimeAudience(ticket: RealtimeSubscriptionTicket, audience: RealtimeAudience): boolean;
142
+ /** v3 tickets authorize the logical stream in addition to its audience. */
143
+ export declare function canReceiveRealtimeEvent(ticket: RealtimeSubscriptionTicket, event: RealtimeEvent): boolean;
64
144
  export declare const realtimeShardKey: (topic: string, shard: number) => string;
145
+ /** Stable principal bucket. No directory read is needed to route a principal event. */
146
+ export declare function realtimePrincipalTopic(realmId: string, principalKey: string, buckets?: number): string;
65
147
  export declare function realtimeHmac(secret: string, message: string): Promise<string>;
66
148
  export declare function verifyRealtimeHmac(secret: string, message: string, signature: string): Promise<boolean>;
67
149
  export declare function issueRealtimeSubscriptionTicket(secret: string, ticket: RealtimeSubscriptionTicket, nowSec?: number): Promise<string>;
package/dist/realtime.js CHANGED
@@ -6,15 +6,105 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  throw Error('Dynamic require of "' + x + '" is not supported');
7
7
  });
8
8
 
9
+ // src/operation.ts
10
+ var OPERATION_STATUSES = [
11
+ "queued",
12
+ "running",
13
+ "stalled",
14
+ "failed",
15
+ "succeeded",
16
+ "cancelled",
17
+ "compensating",
18
+ "compensated"
19
+ ];
20
+ var ATOM = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/;
21
+ var FAILURE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
22
+ var OPERATION_MAX_EVENTS = 128;
23
+ var OPERATION_MAX_LOG_LINES = 100;
24
+ var OPERATION_MAX_LOG_TEXT_BYTES = 8 * 1024;
25
+ function redactOperationLogText(input) {
26
+ return input.replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/gi, "[REDACTED_PRIVATE_KEY]").replace(/\b(?:Bearer\s+)?(?:gh[opsu]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|fze_[A-Za-z0-9_-]{16,})\b/gi, "[REDACTED_TOKEN]").replace(/\b(password|secret|token|api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi, "$1=[REDACTED]").slice(0, OPERATION_MAX_LOG_TEXT_BYTES);
27
+ }
28
+ var finiteEpoch = (value) => Number.isSafeInteger(value) && Number(value) >= 0;
29
+ var atom = (value) => typeof value === "string" && ATOM.test(value);
30
+ function validateSurfaceSnapshot(input, validateData) {
31
+ if (!input || typeof input !== "object" || Array.isArray(input))
32
+ throw new Error("Surface snapshot must be an object.");
33
+ const row = input;
34
+ if (!atom(row.revision) || !finiteEpoch(row.generatedAtTs) || !validateData(row.data)) {
35
+ throw new Error("Surface snapshot is invalid.");
36
+ }
37
+ return { revision: row.revision, generatedAtTs: row.generatedAtTs, data: row.data };
38
+ }
39
+ function validateOperationFailure(input) {
40
+ if (!input || typeof input !== "object" || Array.isArray(input))
41
+ throw new Error("Operation failure must be an object.");
42
+ const row = input;
43
+ if (typeof row.code !== "string" || !FAILURE_CODE.test(row.code) || typeof row.detail !== "string" || !row.detail.trim() || new TextEncoder().encode(row.detail).byteLength > 2000 || typeof row.retryable !== "boolean") {
44
+ throw new Error("Operation failure is invalid.");
45
+ }
46
+ return { code: row.code, detail: row.detail.trim(), retryable: row.retryable };
47
+ }
48
+ function validateOperationSnapshot(input) {
49
+ if (!input || typeof input !== "object" || Array.isArray(input))
50
+ throw new Error("Operation snapshot must be an object.");
51
+ const row = input;
52
+ if (!atom(row.operationKey) || !atom(row.kind) || !atom(row.correlationId) || row.parentOperationKey !== undefined && !atom(row.parentOperationKey) || !Array.isArray(row.childOperationKeys) || row.childOperationKeys.length > 128 || row.childOperationKeys.some((key) => !atom(key)) || !atom(row.stage) || !OPERATION_STATUSES.includes(row.status) || !Number.isSafeInteger(row.attempt) || row.attempt < 1 || !Number.isSafeInteger(row.revision) || row.revision < 1 || !finiteEpoch(row.createdAtTs) || !finiteEpoch(row.updatedAtTs) || row.updatedAtTs < row.createdAtTs || row.lastHeartbeatAtTs !== undefined && !finiteEpoch(row.lastHeartbeatAtTs) || row.deadlineAtTs !== undefined && !finiteEpoch(row.deadlineAtTs) || row.leaseOwner !== undefined && !atom(row.leaseOwner) || !Array.isArray(row.events) || row.events.length < 1 || row.events.length > OPERATION_MAX_EVENTS) {
53
+ throw new Error("Operation snapshot coordinates are invalid.");
54
+ }
55
+ const events = row.events.map((event, index) => {
56
+ if (!event || typeof event !== "object" || Array.isArray(event))
57
+ throw new Error("Operation event is invalid.");
58
+ const value = event;
59
+ if (value.sequence !== index + 1 || !atom(value.stage) || !OPERATION_STATUSES.includes(value.status) || !finiteEpoch(value.atTs) || !Number.isSafeInteger(value.attempt) || value.attempt < 1 || !["platform-api", "bootstrap-runner", "metal-agent", "compute-agent", "worker"].includes(String(value.origin)) || value.nodeKey !== undefined && !atom(value.nodeKey))
60
+ throw new Error("Operation event is invalid.");
61
+ return { ...value, ...value.failure ? { failure: validateOperationFailure(value.failure) } : {} };
62
+ });
63
+ return {
64
+ ...row,
65
+ childOperationKeys: [...row.childOperationKeys],
66
+ ...row.failure ? { failure: validateOperationFailure(row.failure) } : {},
67
+ events
68
+ };
69
+ }
70
+ function validateOperationLogBatch(input) {
71
+ if (!input || typeof input !== "object" || Array.isArray(input))
72
+ throw new Error("Operation log batch must be an object.");
73
+ const row = input;
74
+ if (!atom(row.operationKey) || !Number.isSafeInteger(row.fromSequence) || !Number.isSafeInteger(row.toSequence) || row.fromSequence < 1 || row.toSequence < row.fromSequence || typeof row.terminal !== "boolean" || !Array.isArray(row.lines) || row.lines.length < 1 || row.lines.length > OPERATION_MAX_LOG_LINES) {
75
+ throw new Error("Operation log batch coordinates are invalid.");
76
+ }
77
+ const lines = row.lines.map((line, index) => {
78
+ if (!line || typeof line !== "object" || Array.isArray(line))
79
+ throw new Error("Operation log line is invalid.");
80
+ const value = line;
81
+ const expected = row.fromSequence + index;
82
+ if (value.sequence !== expected || !finiteEpoch(value.atTs) || !["stdout", "stderr", "system"].includes(String(value.stream)) || typeof value.text !== "string" || !value.text || new TextEncoder().encode(value.text).byteLength > OPERATION_MAX_LOG_TEXT_BYTES) {
83
+ throw new Error("Operation log line is invalid or non-contiguous.");
84
+ }
85
+ return { sequence: value.sequence, atTs: value.atTs, stream: value.stream, text: redactOperationLogText(value.text) };
86
+ });
87
+ if (lines.at(-1).sequence !== row.toSequence)
88
+ throw new Error("Operation log range does not match its lines.");
89
+ return {
90
+ operationKey: row.operationKey,
91
+ fromSequence: row.fromSequence,
92
+ toSequence: row.toSequence,
93
+ terminal: row.terminal,
94
+ lines
95
+ };
96
+ }
97
+
9
98
  // src/realtime.ts
10
99
  var REALTIME_MAX_EVENTS = 100;
11
100
  var REALTIME_MAX_EVENT_BYTES = 64 * 1024;
12
101
  var REALTIME_MAX_BATCH_BYTES = 512 * 1024;
102
+ var REALTIME_FLUSH_BATCH_BYTES = 256 * 1024;
13
103
  var REALTIME_MAX_SOCKETS_PER_SHARD = 1000;
14
104
  var REALTIME_MAX_SHARDS_PER_TOPIC = 1024;
15
- var ATOM = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/;
105
+ var ATOM2 = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/;
16
106
  var PRINCIPAL_KINDS = new Set(["user", "node", "api-key", "service", "header"]);
17
- var atom = (value) => typeof value === "string" && ATOM.test(value);
107
+ var atom2 = (value) => typeof value === "string" && ATOM2.test(value);
18
108
  function validateRealtimeAudience(input) {
19
109
  if (!input || typeof input !== "object" || Array.isArray(input))
20
110
  throw new Error("Realtime audience must be an object.");
@@ -24,9 +114,9 @@ function validateRealtimeAudience(input) {
24
114
  throw new Error("Public realtime audience has no additional coordinates.");
25
115
  return { kind: "public" };
26
116
  }
27
- if (!atom(row.realmId) || !atom(row.capability))
117
+ if (!atom2(row.realmId) || !atom2(row.capability))
28
118
  throw new Error("Realtime audience scope is invalid.");
29
- if (row.kind === "principal" && PRINCIPAL_KINDS.has(row.principalKind) && atom(row.principalKey)) {
119
+ if (row.kind === "principal" && PRINCIPAL_KINDS.has(row.principalKind) && atom2(row.principalKey)) {
30
120
  return {
31
121
  kind: "principal",
32
122
  realmId: row.realmId,
@@ -35,10 +125,10 @@ function validateRealtimeAudience(input) {
35
125
  capability: row.capability
36
126
  };
37
127
  }
38
- if (row.kind === "project" && atom(row.projectKey)) {
128
+ if (row.kind === "project" && atom2(row.projectKey)) {
39
129
  return { kind: "project", realmId: row.realmId, projectKey: row.projectKey, capability: row.capability };
40
130
  }
41
- if (row.kind === "group" && atom(row.group)) {
131
+ if (row.kind === "group" && atom2(row.group)) {
42
132
  return { kind: "group", realmId: row.realmId, group: row.group, capability: row.capability };
43
133
  }
44
134
  throw new Error("Realtime audience coordinates are invalid.");
@@ -47,25 +137,34 @@ function validateRealtimeBatch(input) {
47
137
  if (!input || typeof input !== "object" || Array.isArray(input))
48
138
  throw new Error("Realtime batch must be an object.");
49
139
  const row = input;
50
- if (row.version !== 2 || !ATOM.test(row.batchId ?? "") || !ATOM.test(row.topic ?? "") || !Number.isSafeInteger(row.publishedAtMs) || row.publishedAtMs < 0 || !Array.isArray(row.events) || row.events.length < 1 || row.events.length > REALTIME_MAX_EVENTS)
140
+ if (row.version !== 2 && row.version !== 3 || !ATOM2.test(row.batchId ?? "") || !ATOM2.test(row.topic ?? "") || !Number.isSafeInteger(row.publishedAtMs) || row.publishedAtMs < 0 || !Array.isArray(row.events) || row.events.length < 1 || row.events.length > REALTIME_MAX_EVENTS)
51
141
  throw new Error("Realtime batch coordinates are invalid.");
52
142
  const audience = validateRealtimeAudience(row.audience);
53
143
  const events = row.events.map((event) => {
54
144
  if (!event || typeof event !== "object" || Array.isArray(event))
55
145
  throw new Error("Realtime event must be an object.");
56
146
  const value = event;
57
- if (!ATOM.test(value.id ?? "") || !ATOM.test(value.type ?? ""))
147
+ if (!ATOM2.test(value.id ?? "") || !ATOM2.test(value.type ?? ""))
58
148
  throw new Error("Realtime event id/type is invalid.");
59
149
  const encoded = JSON.stringify(value.payload);
60
150
  if (encoded === undefined || new TextEncoder().encode(encoded).byteLength > REALTIME_MAX_EVENT_BYTES) {
61
151
  throw new Error("Realtime event payload is too large or not JSON serializable.");
62
152
  }
153
+ if (row.version === 3 && value.type === "operation.log")
154
+ validateOperationLogBatch(value.payload);
155
+ if (row.version === 3 && value.type === "resource.patch") {
156
+ const payload = value.payload;
157
+ const scope = payload.scope;
158
+ if (!scope || !atom2(scope.realmId) || scope.projectKey !== undefined && !atom2(scope.projectKey) || !atom2(payload.resourceKind) || !atom2(payload.resourceKey) || !atom2(payload.revision) || payload.previousRevision !== undefined && !atom2(payload.previousRevision)) {
159
+ throw new Error("Realtime resource patch is invalid.");
160
+ }
161
+ }
63
162
  return { id: value.id, type: value.type, payload: value.payload };
64
163
  });
65
164
  if (new Set(events.map(({ id }) => id)).size !== events.length)
66
165
  throw new Error("Realtime event ids must be unique in a batch.");
67
166
  const batch = {
68
- version: 2,
167
+ version: row.version,
69
168
  batchId: row.batchId,
70
169
  topic: row.topic,
71
170
  audience,
@@ -78,8 +177,26 @@ function validateRealtimeBatch(input) {
78
177
  return batch;
79
178
  }
80
179
  var realtimeBatchBytes = (input) => JSON.stringify(validateRealtimeBatch(input));
180
+ function validateSubscriptions(value) {
181
+ if (!Array.isArray(value) || value.length > 64)
182
+ throw new Error("Realtime ticket subscriptions are invalid.");
183
+ return value.map((item) => {
184
+ if (!item || typeof item !== "object" || Array.isArray(item))
185
+ throw new Error("Realtime subscription is invalid.");
186
+ const row = item;
187
+ if (row.kind === "notifications" && Object.keys(row).length === 1)
188
+ return { kind: "notifications" };
189
+ if (row.kind === "project" && atom2(row.projectKey) && Object.keys(row).length === 2) {
190
+ return { kind: "project", projectKey: row.projectKey };
191
+ }
192
+ if (row.kind === "operation" && atom2(row.operationKey) && typeof row.logs === "boolean" && Object.keys(row).length === 3) {
193
+ return { kind: "operation", operationKey: row.operationKey, logs: row.logs };
194
+ }
195
+ throw new Error("Realtime subscription is invalid.");
196
+ });
197
+ }
81
198
  function boundedUniqueAtoms(value, maximum, label) {
82
- if (!Array.isArray(value) || value.length > maximum || value.some((item) => !atom(item)) || new Set(value).size !== value.length)
199
+ if (!Array.isArray(value) || value.length > maximum || value.some((item) => !atom2(item)) || new Set(value).size !== value.length)
83
200
  throw new Error(`Realtime ticket ${label} are invalid.`);
84
201
  return [...value];
85
202
  }
@@ -87,16 +204,17 @@ function validateRealtimeSubscriptionTicket(input, nowSec = Math.floor(Date.now(
87
204
  if (!input || typeof input !== "object" || Array.isArray(input))
88
205
  throw new Error("Realtime ticket must be an object.");
89
206
  const row = input;
90
- if (row.version !== 2 || !atom(row.topic) || !atom(row.realmId) || !atom(row.nonce) || !row.principal || !PRINCIPAL_KINDS.has(row.principal.kind) || !atom(row.principal.key) || !Number.isSafeInteger(row.expiresAtSec) || row.expiresAtSec <= nowSec || row.expiresAtSec > nowSec + 300)
207
+ if (row.version !== 2 && row.version !== 3 || !atom2(row.topic) || !atom2(row.realmId) || !atom2(row.nonce) || !row.principal || !PRINCIPAL_KINDS.has(row.principal.kind) || !atom2(row.principal.key) || !Number.isSafeInteger(row.expiresAtSec) || row.expiresAtSec <= nowSec || row.expiresAtSec > nowSec + 300 || row.version === 3 && !Array.isArray(row.subscriptions))
91
208
  throw new Error("Realtime ticket is invalid or expired.");
92
209
  return {
93
- version: 2,
210
+ version: row.version,
94
211
  topic: row.topic,
95
212
  principal: { kind: row.principal.kind, key: row.principal.key },
96
213
  realmId: row.realmId,
97
214
  projectKeys: boundedUniqueAtoms(row.projectKeys, 32, "project keys"),
98
215
  groups: boundedUniqueAtoms(row.groups, 16, "groups"),
99
216
  capabilities: boundedUniqueAtoms(row.capabilities, 64, "capabilities"),
217
+ ...row.version === 3 ? { subscriptions: validateSubscriptions(row.subscriptions) } : {},
100
218
  expiresAtSec: row.expiresAtSec,
101
219
  nonce: row.nonce
102
220
  };
@@ -113,12 +231,40 @@ function canReceiveRealtimeAudience(ticket, audience) {
113
231
  return ticket.projectKeys.includes(audience.projectKey);
114
232
  return ticket.groups.includes(audience.group);
115
233
  }
234
+ function canReceiveRealtimeEvent(ticket, event) {
235
+ if (ticket.version === 2)
236
+ return true;
237
+ const subscriptions = ticket.subscriptions ?? [];
238
+ if (event.type === "notification.summary") {
239
+ return subscriptions.some(({ kind }) => kind === "notifications");
240
+ }
241
+ if (event.type === "operation.state" || event.type === "operation.log") {
242
+ const operationKey = event.payload.operationKey;
243
+ return typeof operationKey === "string" && subscriptions.some((subscription) => subscription.kind === "operation" && subscription.operationKey === operationKey && (event.type !== "operation.log" || subscription.logs));
244
+ }
245
+ if (event.type === "resource.patch") {
246
+ const projectKey = event.payload.scope?.projectKey;
247
+ return typeof projectKey !== "string" || subscriptions.some((subscription) => subscription.kind === "project" && subscription.projectKey === projectKey);
248
+ }
249
+ return true;
250
+ }
116
251
  var realtimeShardKey = (topic, shard) => {
117
- if (!ATOM.test(topic) || !Number.isSafeInteger(shard) || shard < 0 || shard >= REALTIME_MAX_SHARDS_PER_TOPIC) {
252
+ if (!ATOM2.test(topic) || !Number.isSafeInteger(shard) || shard < 0 || shard >= REALTIME_MAX_SHARDS_PER_TOPIC) {
118
253
  throw new Error("Realtime shard coordinates are invalid.");
119
254
  }
120
255
  return `rt:${topic}:${shard.toString().padStart(4, "0")}`;
121
256
  };
257
+ function realtimePrincipalTopic(realmId, principalKey, buckets = 256) {
258
+ if (!atom2(realmId) || !atom2(principalKey) || !Number.isSafeInteger(buckets) || buckets < 1 || buckets > 4096) {
259
+ throw new Error("Realtime principal partition coordinates are invalid.");
260
+ }
261
+ let hash = 2166136261;
262
+ for (const byte of new TextEncoder().encode(`${realmId}\x00${principalKey}`)) {
263
+ hash ^= byte;
264
+ hash = Math.imul(hash, 16777619) >>> 0;
265
+ }
266
+ return `principal:${realmId}:${hash % buckets}`;
267
+ }
122
268
  var bytesToHex = (bytes) => [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
123
269
  var timingEqual = (left, right) => {
124
270
  if (left.length !== right.length)
@@ -172,13 +318,16 @@ export {
172
318
  validateRealtimeBatch,
173
319
  validateRealtimeAudience,
174
320
  realtimeShardKey,
321
+ realtimePrincipalTopic,
175
322
  realtimeHmac,
176
323
  realtimeBatchBytes,
177
324
  issueRealtimeSubscriptionTicket,
325
+ canReceiveRealtimeEvent,
178
326
  canReceiveRealtimeAudience,
179
327
  REALTIME_MAX_SOCKETS_PER_SHARD,
180
328
  REALTIME_MAX_SHARDS_PER_TOPIC,
181
329
  REALTIME_MAX_EVENT_BYTES,
182
330
  REALTIME_MAX_EVENTS,
183
- REALTIME_MAX_BATCH_BYTES
331
+ REALTIME_MAX_BATCH_BYTES,
332
+ REALTIME_FLUSH_BATCH_BYTES
184
333
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgezero/runtime",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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
+ "./operation": {
55
+ "types": "./dist/operation.d.ts",
56
+ "default": "./dist/operation.js"
57
+ },
54
58
  "./lifecycle": {
55
59
  "types": "./dist/lifecycle.d.ts",
56
60
  "default": "./dist/lifecycle.js"