@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 +28 -4
- package/dist/operation.d.ts +64 -0
- package/dist/operation.js +107 -0
- package/dist/realtime.d.ts +86 -4
- package/dist/realtime.js +163 -14
- package/package.json +6 -2
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. 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
|
-
|
|
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
|
-
|
|
736
|
+
REALTIME_FLUSH_BATCH_BYTES,
|
|
736
737
|
} from '@forgezero/runtime/realtime';
|
|
737
738
|
|
|
738
|
-
export const selectedCapability =
|
|
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
|
+
};
|
package/dist/realtime.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 (!
|
|
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) &&
|
|
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" &&
|
|
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" &&
|
|
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 || !
|
|
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 (!
|
|
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:
|
|
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) => !
|
|
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 || !
|
|
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:
|
|
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 (!
|
|
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.
|
|
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.
|
|
149
|
+
"@forgezero/access": "^0.1.15"
|
|
146
150
|
},
|
|
147
151
|
"peerDependencies": {
|
|
148
152
|
"@noble/ciphers": "^2.2.0",
|