@forgezero/runtime 0.1.25 → 0.1.27

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>
@@ -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.25",
3
+ "version": "0.1.27",
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"
@@ -142,7 +146,7 @@
142
146
  "prepublishOnly": "bun ../tools/package-task.ts prepublish runtime"
143
147
  },
144
148
  "dependencies": {
145
- "@forgezero/access": "^0.1.14"
149
+ "@forgezero/access": "^0.1.15"
146
150
  },
147
151
  "peerDependencies": {
148
152
  "@noble/ciphers": "^2.2.0",