@forgezero/runtime 0.1.4 → 0.1.6
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 +24 -2
- package/dist/backup.d.ts +19 -4
- package/dist/backup.js +252 -45
- package/dist/custody-crypto.d.ts +7 -5
- package/dist/custody-crypto.js +43 -21
- package/dist/custody-share.d.ts +5 -0
- package/dist/custody-share.js +46 -21
- package/dist/finance/chain-deposits.js +5 -1
- package/dist/finance/chain-withdrawals.js +5 -1
- package/dist/finance/commission.js +5 -1
- package/dist/finance/ledger.d.ts +8 -2
- package/dist/finance/ledger.js +6 -1
- package/dist/identity.d.ts +11 -3
- package/dist/identity.js +109 -14
- package/dist/jobs.d.ts +2 -0
- package/dist/jobs.js +9 -2
- package/dist/passkey-hybrid.d.ts +37 -0
- package/dist/passkey-hybrid.js +111 -0
- package/dist/query.d.ts +47 -0
- package/dist/query.js +51 -0
- package/dist/realtime.d.ts +68 -0
- package/dist/realtime.js +184 -0
- package/dist/schema-typebox.d.ts +3 -0
- package/dist/schema-typebox.js +32 -2
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +15 -2
- package/package.json +18 -10
- package/dist/ssh-agent.d.ts +0 -83
- package/dist/ssh-agent.js +0 -147
|
@@ -0,0 +1,111 @@
|
|
|
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/passkey-hybrid.ts
|
|
10
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
11
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
12
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
13
|
+
var PASSKEY_HYBRID_VERSION = 1;
|
|
14
|
+
var PASSKEY_HYBRID_SUITE = "webauthn+prf-ml-dsa-65";
|
|
15
|
+
var PASSKEY_PRF_SALT = new TextEncoder().encode("forgezero:passkey:hybrid-auth:prf:v1");
|
|
16
|
+
var utf8 = (value) => new TextEncoder().encode(value);
|
|
17
|
+
var b64 = (bytes) => {
|
|
18
|
+
let binary = "";
|
|
19
|
+
for (const byte of bytes)
|
|
20
|
+
binary += String.fromCharCode(byte);
|
|
21
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
};
|
|
23
|
+
var un64 = (value) => {
|
|
24
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
25
|
+
throw new Error("passkey-hybrid: non-canonical base64url");
|
|
26
|
+
const normal = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
27
|
+
const binary = atob(normal.padEnd(Math.ceil(normal.length / 4) * 4, "="));
|
|
28
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
29
|
+
};
|
|
30
|
+
function field(value) {
|
|
31
|
+
if (value.length > 2048 || /[\0\r\n]/.test(value)) {
|
|
32
|
+
throw new Error("passkey-hybrid: invalid binding field");
|
|
33
|
+
}
|
|
34
|
+
return `${utf8(value).length}:${value}`;
|
|
35
|
+
}
|
|
36
|
+
function passkeyHybridMessage(binding, credentialId) {
|
|
37
|
+
if (binding.version !== PASSKEY_HYBRID_VERSION || binding.suite !== PASSKEY_HYBRID_SUITE) {
|
|
38
|
+
throw new Error("passkey-hybrid: unsupported protocol");
|
|
39
|
+
}
|
|
40
|
+
if (!/^[A-Za-z0-9_-]{16,1024}$/.test(credentialId)) {
|
|
41
|
+
throw new Error("passkey-hybrid: invalid credential id");
|
|
42
|
+
}
|
|
43
|
+
return utf8([
|
|
44
|
+
"forgezero-passkey-hybrid-v1",
|
|
45
|
+
binding.suite,
|
|
46
|
+
binding.purpose,
|
|
47
|
+
binding.rpId,
|
|
48
|
+
binding.origin,
|
|
49
|
+
binding.challenge,
|
|
50
|
+
credentialId,
|
|
51
|
+
binding.userKey,
|
|
52
|
+
binding.sessionKey,
|
|
53
|
+
binding.actionRequestKey
|
|
54
|
+
].map(field).join(`
|
|
55
|
+
`));
|
|
56
|
+
}
|
|
57
|
+
function keys(prfOutput, credentialId) {
|
|
58
|
+
if (prfOutput.length !== 32)
|
|
59
|
+
throw new Error("passkey-hybrid: PRF output must be exactly 32 bytes");
|
|
60
|
+
const seed = hkdf(sha256, prfOutput, utf8("forgezero:passkey:hybrid-auth:ml-dsa-65:v1"), utf8(`credential:${credentialId.length}:${credentialId}`), 32);
|
|
61
|
+
try {
|
|
62
|
+
const pair = ml_dsa65.keygen(seed);
|
|
63
|
+
return {
|
|
64
|
+
publicKey: Uint8Array.from(pair.publicKey),
|
|
65
|
+
secretKey: Uint8Array.from(pair.secretKey)
|
|
66
|
+
};
|
|
67
|
+
} finally {
|
|
68
|
+
seed.fill(0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function createPasskeyHybridProof(prfOutput, binding, credentialId) {
|
|
72
|
+
const pair = keys(prfOutput, credentialId);
|
|
73
|
+
try {
|
|
74
|
+
return {
|
|
75
|
+
version: PASSKEY_HYBRID_VERSION,
|
|
76
|
+
suite: PASSKEY_HYBRID_SUITE,
|
|
77
|
+
credentialId,
|
|
78
|
+
publicKey: b64(pair.publicKey),
|
|
79
|
+
signature: b64(ml_dsa65.sign(passkeyHybridMessage(binding, credentialId), pair.secretKey))
|
|
80
|
+
};
|
|
81
|
+
} finally {
|
|
82
|
+
pair.secretKey.fill(0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function verifyPasskeyHybridProof(args) {
|
|
86
|
+
try {
|
|
87
|
+
if (args.proof.version !== PASSKEY_HYBRID_VERSION || args.proof.suite !== PASSKEY_HYBRID_SUITE || !Object.keys(args.proof).every((key) => ["version", "suite", "credentialId", "signature"].includes(key)))
|
|
88
|
+
return false;
|
|
89
|
+
const publicKey = un64(args.publicKey);
|
|
90
|
+
const signature = un64(args.proof.signature);
|
|
91
|
+
if (publicKey.length !== ml_dsa65.lengths.publicKey || signature.length !== ml_dsa65.lengths.signature)
|
|
92
|
+
return false;
|
|
93
|
+
if (b64(publicKey) !== args.publicKey || b64(signature) !== args.proof.signature)
|
|
94
|
+
return false;
|
|
95
|
+
return ml_dsa65.verify(signature, passkeyHybridMessage(args.binding, args.proof.credentialId), publicKey);
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
var PASSKEY_ML_DSA_PUBLIC_KEY_BYTES = ml_dsa65.lengths.publicKey;
|
|
101
|
+
var PASSKEY_ML_DSA_SIGNATURE_BYTES = ml_dsa65.lengths.signature;
|
|
102
|
+
export {
|
|
103
|
+
verifyPasskeyHybridProof,
|
|
104
|
+
passkeyHybridMessage,
|
|
105
|
+
createPasskeyHybridProof,
|
|
106
|
+
PASSKEY_PRF_SALT,
|
|
107
|
+
PASSKEY_ML_DSA_SIGNATURE_BYTES,
|
|
108
|
+
PASSKEY_ML_DSA_PUBLIC_KEY_BYTES,
|
|
109
|
+
PASSKEY_HYBRID_VERSION,
|
|
110
|
+
PASSKEY_HYBRID_SUITE
|
|
111
|
+
};
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Provider-neutral, function-based typed query contracts. */
|
|
2
|
+
export type QueryIssue = Readonly<{
|
|
3
|
+
path: string;
|
|
4
|
+
message: string;
|
|
5
|
+
}>;
|
|
6
|
+
export type QueryDecode<T> = Readonly<{
|
|
7
|
+
ok: true;
|
|
8
|
+
value: T;
|
|
9
|
+
}> | Readonly<{
|
|
10
|
+
ok: false;
|
|
11
|
+
issues: readonly QueryIssue[];
|
|
12
|
+
}>;
|
|
13
|
+
/** Inputs may coerce wire values; outputs must validate strictly. */
|
|
14
|
+
export interface QueryCodec<T> {
|
|
15
|
+
readonly schema?: unknown;
|
|
16
|
+
decode(value: unknown): QueryDecode<T>;
|
|
17
|
+
encode(value: unknown): QueryDecode<T>;
|
|
18
|
+
}
|
|
19
|
+
export interface QueryContract<Name extends string, Input, Output> {
|
|
20
|
+
readonly name: Name;
|
|
21
|
+
readonly input: QueryCodec<Input>;
|
|
22
|
+
readonly output: QueryCodec<Output>;
|
|
23
|
+
}
|
|
24
|
+
export declare class QueryContractError extends Error {
|
|
25
|
+
readonly query: string;
|
|
26
|
+
readonly phase: 'input' | 'output' | 'aborted';
|
|
27
|
+
readonly issues: readonly QueryIssue[];
|
|
28
|
+
constructor(query: string, phase: 'input' | 'output' | 'aborted', issues?: readonly QueryIssue[]);
|
|
29
|
+
}
|
|
30
|
+
export declare function defineQuery<const Name extends string, Input, Output>(definition: {
|
|
31
|
+
name: Name;
|
|
32
|
+
input: QueryCodec<Input>;
|
|
33
|
+
output: QueryCodec<Output>;
|
|
34
|
+
}): QueryContract<Name, Input, Output>;
|
|
35
|
+
export interface QueryExecution {
|
|
36
|
+
readonly signal: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
export interface QueryImplementation<Context, Output> {
|
|
39
|
+
execute(context: Context, input: unknown, options?: {
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}): Promise<Output>;
|
|
42
|
+
}
|
|
43
|
+
export declare function implementQuery<Context, Name extends string, Input, Output>(contract: QueryContract<Name, Input, Output>, handler: (context: Context, input: Input, execution: QueryExecution) => Output | Promise<Output>): QueryImplementation<Context, Output> & {
|
|
44
|
+
readonly contract: typeof contract;
|
|
45
|
+
};
|
|
46
|
+
export type QueryInput<Q> = Q extends QueryContract<string, infer Input, unknown> ? Input : never;
|
|
47
|
+
export type QueryOutput<Q> = Q extends QueryContract<string, unknown, infer Output> ? Output : never;
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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/query.ts
|
|
10
|
+
class QueryContractError extends Error {
|
|
11
|
+
query;
|
|
12
|
+
phase;
|
|
13
|
+
issues;
|
|
14
|
+
constructor(query, phase, issues = []) {
|
|
15
|
+
super(`Query ${query} failed ${phase} validation`);
|
|
16
|
+
this.query = query;
|
|
17
|
+
this.phase = phase;
|
|
18
|
+
this.issues = issues;
|
|
19
|
+
this.name = "QueryContractError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function defineQuery(definition) {
|
|
23
|
+
if (!definition.name.trim())
|
|
24
|
+
throw new Error("A query contract requires a stable name");
|
|
25
|
+
return Object.freeze({ ...definition });
|
|
26
|
+
}
|
|
27
|
+
function implementQuery(contract, handler) {
|
|
28
|
+
return {
|
|
29
|
+
contract,
|
|
30
|
+
async execute(context, rawInput, options = {}) {
|
|
31
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
32
|
+
if (signal.aborted)
|
|
33
|
+
throw new QueryContractError(contract.name, "aborted");
|
|
34
|
+
const decoded = contract.input.decode(rawInput);
|
|
35
|
+
if (!decoded.ok)
|
|
36
|
+
throw new QueryContractError(contract.name, "input", decoded.issues);
|
|
37
|
+
const rawOutput = await handler(context, decoded.value, { signal });
|
|
38
|
+
if (signal.aborted)
|
|
39
|
+
throw new QueryContractError(contract.name, "aborted");
|
|
40
|
+
const encoded = contract.output.encode(rawOutput);
|
|
41
|
+
if (!encoded.ok)
|
|
42
|
+
throw new QueryContractError(contract.name, "output", encoded.issues);
|
|
43
|
+
return encoded.value;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
implementQuery,
|
|
49
|
+
defineQuery,
|
|
50
|
+
QueryContractError
|
|
51
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export declare const REALTIME_MAX_EVENTS = 100;
|
|
2
|
+
export declare const REALTIME_MAX_EVENT_BYTES: number;
|
|
3
|
+
export declare const REALTIME_MAX_BATCH_BYTES: number;
|
|
4
|
+
export declare const REALTIME_MAX_SOCKETS_PER_SHARD = 1000;
|
|
5
|
+
export declare const REALTIME_MAX_SHARDS_PER_TOPIC = 1024;
|
|
6
|
+
export interface RealtimeEvent {
|
|
7
|
+
id: string;
|
|
8
|
+
type: string;
|
|
9
|
+
payload: unknown;
|
|
10
|
+
}
|
|
11
|
+
export type RealtimePrincipalKind = 'user' | 'node' | 'api-key' | 'service' | 'header';
|
|
12
|
+
export type RealtimeAudience = Readonly<{
|
|
13
|
+
kind: 'public';
|
|
14
|
+
}> | Readonly<{
|
|
15
|
+
kind: 'principal';
|
|
16
|
+
realmId: string;
|
|
17
|
+
principalKind: RealtimePrincipalKind;
|
|
18
|
+
principalKey: string;
|
|
19
|
+
capability: string;
|
|
20
|
+
}> | Readonly<{
|
|
21
|
+
kind: 'project';
|
|
22
|
+
realmId: string;
|
|
23
|
+
projectKey: string;
|
|
24
|
+
capability: string;
|
|
25
|
+
}> | Readonly<{
|
|
26
|
+
kind: 'group';
|
|
27
|
+
realmId: string;
|
|
28
|
+
group: string;
|
|
29
|
+
capability: string;
|
|
30
|
+
}>;
|
|
31
|
+
export interface RealtimeBatch {
|
|
32
|
+
version: 2;
|
|
33
|
+
batchId: string;
|
|
34
|
+
topic: string;
|
|
35
|
+
audience: RealtimeAudience;
|
|
36
|
+
publishedAtMs: number;
|
|
37
|
+
events: RealtimeEvent[];
|
|
38
|
+
}
|
|
39
|
+
export declare function validateRealtimeAudience(input: unknown): RealtimeAudience;
|
|
40
|
+
export declare function validateRealtimeBatch(input: unknown): RealtimeBatch;
|
|
41
|
+
export declare const realtimeBatchBytes: (input: unknown) => string;
|
|
42
|
+
export interface RealtimeSubscriptionTicket {
|
|
43
|
+
version: 2;
|
|
44
|
+
topic: string;
|
|
45
|
+
principal: Readonly<{
|
|
46
|
+
kind: RealtimePrincipalKind;
|
|
47
|
+
key: string;
|
|
48
|
+
}>;
|
|
49
|
+
realmId: string;
|
|
50
|
+
projectKeys: readonly string[];
|
|
51
|
+
groups: readonly string[];
|
|
52
|
+
capabilities: readonly string[];
|
|
53
|
+
expiresAtSec: number;
|
|
54
|
+
nonce: string;
|
|
55
|
+
}
|
|
56
|
+
export declare function validateRealtimeSubscriptionTicket(input: unknown, nowSec?: number): RealtimeSubscriptionTicket;
|
|
57
|
+
/**
|
|
58
|
+
* The edge has no database authority. It may deliver only when the short-lived,
|
|
59
|
+
* API-issued connection capability contains every coordinate named by the
|
|
60
|
+
* event audience. Topic equality is handled by the shard; it is not an access
|
|
61
|
+
* decision.
|
|
62
|
+
*/
|
|
63
|
+
export declare function canReceiveRealtimeAudience(ticket: RealtimeSubscriptionTicket, audience: RealtimeAudience): boolean;
|
|
64
|
+
export declare const realtimeShardKey: (topic: string, shard: number) => string;
|
|
65
|
+
export declare function realtimeHmac(secret: string, message: string): Promise<string>;
|
|
66
|
+
export declare function verifyRealtimeHmac(secret: string, message: string, signature: string): Promise<boolean>;
|
|
67
|
+
export declare function issueRealtimeSubscriptionTicket(secret: string, ticket: RealtimeSubscriptionTicket, nowSec?: number): Promise<string>;
|
|
68
|
+
export declare function verifyRealtimeSubscriptionToken(secret: string, token: string, nowSec?: number): Promise<RealtimeSubscriptionTicket | null>;
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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/realtime.ts
|
|
10
|
+
var REALTIME_MAX_EVENTS = 100;
|
|
11
|
+
var REALTIME_MAX_EVENT_BYTES = 64 * 1024;
|
|
12
|
+
var REALTIME_MAX_BATCH_BYTES = 512 * 1024;
|
|
13
|
+
var REALTIME_MAX_SOCKETS_PER_SHARD = 1000;
|
|
14
|
+
var REALTIME_MAX_SHARDS_PER_TOPIC = 1024;
|
|
15
|
+
var ATOM = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/;
|
|
16
|
+
var PRINCIPAL_KINDS = new Set(["user", "node", "api-key", "service", "header"]);
|
|
17
|
+
var atom = (value) => typeof value === "string" && ATOM.test(value);
|
|
18
|
+
function validateRealtimeAudience(input) {
|
|
19
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
20
|
+
throw new Error("Realtime audience must be an object.");
|
|
21
|
+
const row = input;
|
|
22
|
+
if (row.kind === "public") {
|
|
23
|
+
if (Object.keys(row).length !== 1)
|
|
24
|
+
throw new Error("Public realtime audience has no additional coordinates.");
|
|
25
|
+
return { kind: "public" };
|
|
26
|
+
}
|
|
27
|
+
if (!atom(row.realmId) || !atom(row.capability))
|
|
28
|
+
throw new Error("Realtime audience scope is invalid.");
|
|
29
|
+
if (row.kind === "principal" && PRINCIPAL_KINDS.has(row.principalKind) && atom(row.principalKey)) {
|
|
30
|
+
return {
|
|
31
|
+
kind: "principal",
|
|
32
|
+
realmId: row.realmId,
|
|
33
|
+
principalKind: row.principalKind,
|
|
34
|
+
principalKey: row.principalKey,
|
|
35
|
+
capability: row.capability
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (row.kind === "project" && atom(row.projectKey)) {
|
|
39
|
+
return { kind: "project", realmId: row.realmId, projectKey: row.projectKey, capability: row.capability };
|
|
40
|
+
}
|
|
41
|
+
if (row.kind === "group" && atom(row.group)) {
|
|
42
|
+
return { kind: "group", realmId: row.realmId, group: row.group, capability: row.capability };
|
|
43
|
+
}
|
|
44
|
+
throw new Error("Realtime audience coordinates are invalid.");
|
|
45
|
+
}
|
|
46
|
+
function validateRealtimeBatch(input) {
|
|
47
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
48
|
+
throw new Error("Realtime batch must be an object.");
|
|
49
|
+
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)
|
|
51
|
+
throw new Error("Realtime batch coordinates are invalid.");
|
|
52
|
+
const audience = validateRealtimeAudience(row.audience);
|
|
53
|
+
const events = row.events.map((event) => {
|
|
54
|
+
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
55
|
+
throw new Error("Realtime event must be an object.");
|
|
56
|
+
const value = event;
|
|
57
|
+
if (!ATOM.test(value.id ?? "") || !ATOM.test(value.type ?? ""))
|
|
58
|
+
throw new Error("Realtime event id/type is invalid.");
|
|
59
|
+
const encoded = JSON.stringify(value.payload);
|
|
60
|
+
if (encoded === undefined || new TextEncoder().encode(encoded).byteLength > REALTIME_MAX_EVENT_BYTES) {
|
|
61
|
+
throw new Error("Realtime event payload is too large or not JSON serializable.");
|
|
62
|
+
}
|
|
63
|
+
return { id: value.id, type: value.type, payload: value.payload };
|
|
64
|
+
});
|
|
65
|
+
if (new Set(events.map(({ id }) => id)).size !== events.length)
|
|
66
|
+
throw new Error("Realtime event ids must be unique in a batch.");
|
|
67
|
+
const batch = {
|
|
68
|
+
version: 2,
|
|
69
|
+
batchId: row.batchId,
|
|
70
|
+
topic: row.topic,
|
|
71
|
+
audience,
|
|
72
|
+
publishedAtMs: row.publishedAtMs,
|
|
73
|
+
events
|
|
74
|
+
};
|
|
75
|
+
if (new TextEncoder().encode(JSON.stringify(batch)).byteLength > REALTIME_MAX_BATCH_BYTES) {
|
|
76
|
+
throw new Error("Realtime batch is too large.");
|
|
77
|
+
}
|
|
78
|
+
return batch;
|
|
79
|
+
}
|
|
80
|
+
var realtimeBatchBytes = (input) => JSON.stringify(validateRealtimeBatch(input));
|
|
81
|
+
function boundedUniqueAtoms(value, maximum, label) {
|
|
82
|
+
if (!Array.isArray(value) || value.length > maximum || value.some((item) => !atom(item)) || new Set(value).size !== value.length)
|
|
83
|
+
throw new Error(`Realtime ticket ${label} are invalid.`);
|
|
84
|
+
return [...value];
|
|
85
|
+
}
|
|
86
|
+
function validateRealtimeSubscriptionTicket(input, nowSec = Math.floor(Date.now() / 1000)) {
|
|
87
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
88
|
+
throw new Error("Realtime ticket must be an object.");
|
|
89
|
+
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)
|
|
91
|
+
throw new Error("Realtime ticket is invalid or expired.");
|
|
92
|
+
return {
|
|
93
|
+
version: 2,
|
|
94
|
+
topic: row.topic,
|
|
95
|
+
principal: { kind: row.principal.kind, key: row.principal.key },
|
|
96
|
+
realmId: row.realmId,
|
|
97
|
+
projectKeys: boundedUniqueAtoms(row.projectKeys, 32, "project keys"),
|
|
98
|
+
groups: boundedUniqueAtoms(row.groups, 16, "groups"),
|
|
99
|
+
capabilities: boundedUniqueAtoms(row.capabilities, 64, "capabilities"),
|
|
100
|
+
expiresAtSec: row.expiresAtSec,
|
|
101
|
+
nonce: row.nonce
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function canReceiveRealtimeAudience(ticket, audience) {
|
|
105
|
+
if (audience.kind === "public")
|
|
106
|
+
return true;
|
|
107
|
+
if (ticket.realmId !== audience.realmId || !ticket.capabilities.includes(audience.capability))
|
|
108
|
+
return false;
|
|
109
|
+
if (audience.kind === "principal") {
|
|
110
|
+
return ticket.principal.kind === audience.principalKind && ticket.principal.key === audience.principalKey;
|
|
111
|
+
}
|
|
112
|
+
if (audience.kind === "project")
|
|
113
|
+
return ticket.projectKeys.includes(audience.projectKey);
|
|
114
|
+
return ticket.groups.includes(audience.group);
|
|
115
|
+
}
|
|
116
|
+
var realtimeShardKey = (topic, shard) => {
|
|
117
|
+
if (!ATOM.test(topic) || !Number.isSafeInteger(shard) || shard < 0 || shard >= REALTIME_MAX_SHARDS_PER_TOPIC) {
|
|
118
|
+
throw new Error("Realtime shard coordinates are invalid.");
|
|
119
|
+
}
|
|
120
|
+
return `rt:${topic}:${shard.toString().padStart(4, "0")}`;
|
|
121
|
+
};
|
|
122
|
+
var bytesToHex = (bytes) => [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
123
|
+
var timingEqual = (left, right) => {
|
|
124
|
+
if (left.length !== right.length)
|
|
125
|
+
return false;
|
|
126
|
+
let difference = 0;
|
|
127
|
+
for (let index = 0;index < left.length; index += 1)
|
|
128
|
+
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
129
|
+
return difference === 0;
|
|
130
|
+
};
|
|
131
|
+
async function realtimeHmac(secret, message) {
|
|
132
|
+
if (new TextEncoder().encode(secret).byteLength < 32)
|
|
133
|
+
throw new Error("Realtime secret must contain at least 32 bytes.");
|
|
134
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
135
|
+
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message))));
|
|
136
|
+
}
|
|
137
|
+
async function verifyRealtimeHmac(secret, message, signature) {
|
|
138
|
+
return /^[a-f0-9]{64}$/.test(signature) && timingEqual(await realtimeHmac(secret, message), signature);
|
|
139
|
+
}
|
|
140
|
+
var base64url = (value) => {
|
|
141
|
+
const bytes = new TextEncoder().encode(value);
|
|
142
|
+
let binary = "";
|
|
143
|
+
for (const byte of bytes)
|
|
144
|
+
binary += String.fromCharCode(byte);
|
|
145
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
146
|
+
};
|
|
147
|
+
var fromBase64url = (value) => {
|
|
148
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
149
|
+
const binary = atob(normalized + "=".repeat((4 - normalized.length % 4) % 4));
|
|
150
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
|
|
151
|
+
};
|
|
152
|
+
async function issueRealtimeSubscriptionTicket(secret, ticket, nowSec = Math.floor(Date.now() / 1000)) {
|
|
153
|
+
const body = base64url(JSON.stringify(validateRealtimeSubscriptionTicket(ticket, nowSec)));
|
|
154
|
+
return `${body}.${await realtimeHmac(secret, `ticket
|
|
155
|
+
${body}`)}`;
|
|
156
|
+
}
|
|
157
|
+
async function verifyRealtimeSubscriptionToken(secret, token, nowSec = Math.floor(Date.now() / 1000)) {
|
|
158
|
+
const [body, signature, ...extra] = token.split(".");
|
|
159
|
+
if (!body || !signature || extra.length || !await verifyRealtimeHmac(secret, `ticket
|
|
160
|
+
${body}`, signature))
|
|
161
|
+
return null;
|
|
162
|
+
try {
|
|
163
|
+
return validateRealtimeSubscriptionTicket(JSON.parse(fromBase64url(body)), nowSec);
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export {
|
|
169
|
+
verifyRealtimeSubscriptionToken,
|
|
170
|
+
verifyRealtimeHmac,
|
|
171
|
+
validateRealtimeSubscriptionTicket,
|
|
172
|
+
validateRealtimeBatch,
|
|
173
|
+
validateRealtimeAudience,
|
|
174
|
+
realtimeShardKey,
|
|
175
|
+
realtimeHmac,
|
|
176
|
+
realtimeBatchBytes,
|
|
177
|
+
issueRealtimeSubscriptionTicket,
|
|
178
|
+
canReceiveRealtimeAudience,
|
|
179
|
+
REALTIME_MAX_SOCKETS_PER_SHARD,
|
|
180
|
+
REALTIME_MAX_SHARDS_PER_TOPIC,
|
|
181
|
+
REALTIME_MAX_EVENT_BYTES,
|
|
182
|
+
REALTIME_MAX_EVENTS,
|
|
183
|
+
REALTIME_MAX_BATCH_BYTES
|
|
184
|
+
};
|
package/dist/schema-typebox.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TSchema, Static } from '@sinclair/typebox';
|
|
2
|
+
import type { QueryCodec } from './query';
|
|
2
3
|
import { type SchemaValidator } from './schema';
|
|
3
4
|
/**
|
|
4
5
|
* TypeBox implementation of `SchemaValidator`.
|
|
@@ -20,5 +21,7 @@ export declare const typebox: SchemaValidator<TSchema>;
|
|
|
20
21
|
* refusing to start.
|
|
21
22
|
*/
|
|
22
23
|
export declare function parse<T extends TSchema>(schema: T, value: unknown): Static<T>;
|
|
24
|
+
/** TypeBox codec for `@forgezero/runtime/query`. */
|
|
25
|
+
export declare function typeboxQueryCodec<T extends TSchema>(schema: T): QueryCodec<Static<T>>;
|
|
23
26
|
export { Type as T } from '@sinclair/typebox';
|
|
24
27
|
export type { TSchema, Static } from '@sinclair/typebox';
|
package/dist/schema-typebox.js
CHANGED
|
@@ -21,6 +21,7 @@ var DEFAULT_RESTRICTIONS = {
|
|
|
21
21
|
maxDepth: 4,
|
|
22
22
|
maxFields: 100,
|
|
23
23
|
maxBytes: 64 * 1024,
|
|
24
|
+
maxArrayItems: 1000,
|
|
24
25
|
forbidden: ["$ref", "$id", "$dynamicRef", "$dynamicAnchor", "$schema", "definitions", "$defs"]
|
|
25
26
|
};
|
|
26
27
|
function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
|
|
@@ -59,8 +60,20 @@ function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
|
|
|
59
60
|
walk(properties[name], depth + 1, path ? `${path}.${name}` : name);
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
|
-
if (record.type === "array"
|
|
63
|
-
|
|
63
|
+
if (record.type === "array") {
|
|
64
|
+
const maximum = record.maxItems;
|
|
65
|
+
if (!Number.isInteger(maximum) || maximum < 0) {
|
|
66
|
+
throw new SchemaError("SCHEMA_ARRAY_UNBOUNDED", "Arrays must declare a finite non-negative integer maxItems.", path || "(root)");
|
|
67
|
+
}
|
|
68
|
+
if (maximum > limits.maxArrayItems) {
|
|
69
|
+
throw new SchemaError("SCHEMA_ARRAY_TOO_LARGE", `Array maxItems ${maximum} exceeds the platform limit of ${limits.maxArrayItems}.`, path || "(root)");
|
|
70
|
+
}
|
|
71
|
+
const minimum = record.minItems;
|
|
72
|
+
if (minimum !== undefined && (!Number.isInteger(minimum) || minimum < 0 || minimum > maximum)) {
|
|
73
|
+
throw new SchemaError("SCHEMA_ARRAY_BOUNDS_INVALID", "minItems must be a non-negative integer no larger than maxItems.", path || "(root)");
|
|
74
|
+
}
|
|
75
|
+
if (record.items)
|
|
76
|
+
walk(record.items, depth + 1, `${path}[]`);
|
|
64
77
|
}
|
|
65
78
|
};
|
|
66
79
|
walk(schema, 1, "");
|
|
@@ -194,7 +207,24 @@ function parse(schema, value) {
|
|
|
194
207
|
const first = result.errors[0];
|
|
195
208
|
throw new SchemaError("SCHEMA_INVALID", first?.message ?? "Value does not match schema", first?.path);
|
|
196
209
|
}
|
|
210
|
+
function typeboxQueryCodec(schema) {
|
|
211
|
+
const issues = (value) => [...Value.Errors(schema, value)].map((error) => ({
|
|
212
|
+
path: error.path || "(root)",
|
|
213
|
+
message: error.message
|
|
214
|
+
}));
|
|
215
|
+
return {
|
|
216
|
+
schema,
|
|
217
|
+
decode(value) {
|
|
218
|
+
const converted = Value.Convert(schema, value);
|
|
219
|
+
return Value.Check(schema, converted) ? { ok: true, value: Value.Clean(schema, converted) } : { ok: false, issues: issues(converted) };
|
|
220
|
+
},
|
|
221
|
+
encode(value) {
|
|
222
|
+
return Value.Check(schema, value) ? { ok: true, value: Value.Clean(schema, value) } : { ok: false, issues: issues(value) };
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
197
226
|
export {
|
|
227
|
+
typeboxQueryCodec,
|
|
198
228
|
typebox,
|
|
199
229
|
parse,
|
|
200
230
|
Type as T
|
package/dist/schema.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface Restrictions {
|
|
|
38
38
|
maxDepth: number;
|
|
39
39
|
maxFields: number;
|
|
40
40
|
maxBytes: number;
|
|
41
|
+
/** Largest caller-authored array a persisted value may contain. */
|
|
42
|
+
maxArrayItems: number;
|
|
41
43
|
/** Keywords refused outright, wherever they appear. */
|
|
42
44
|
forbidden: readonly string[];
|
|
43
45
|
}
|
package/dist/schema.js
CHANGED
|
@@ -21,6 +21,7 @@ var DEFAULT_RESTRICTIONS = {
|
|
|
21
21
|
maxDepth: 4,
|
|
22
22
|
maxFields: 100,
|
|
23
23
|
maxBytes: 64 * 1024,
|
|
24
|
+
maxArrayItems: 1000,
|
|
24
25
|
forbidden: ["$ref", "$id", "$dynamicRef", "$dynamicAnchor", "$schema", "definitions", "$defs"]
|
|
25
26
|
};
|
|
26
27
|
function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
|
|
@@ -59,8 +60,20 @@ function restrictJsonSchema(schema, limits = DEFAULT_RESTRICTIONS) {
|
|
|
59
60
|
walk(properties[name], depth + 1, path ? `${path}.${name}` : name);
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
|
-
if (record.type === "array"
|
|
63
|
-
|
|
63
|
+
if (record.type === "array") {
|
|
64
|
+
const maximum = record.maxItems;
|
|
65
|
+
if (!Number.isInteger(maximum) || maximum < 0) {
|
|
66
|
+
throw new SchemaError("SCHEMA_ARRAY_UNBOUNDED", "Arrays must declare a finite non-negative integer maxItems.", path || "(root)");
|
|
67
|
+
}
|
|
68
|
+
if (maximum > limits.maxArrayItems) {
|
|
69
|
+
throw new SchemaError("SCHEMA_ARRAY_TOO_LARGE", `Array maxItems ${maximum} exceeds the platform limit of ${limits.maxArrayItems}.`, path || "(root)");
|
|
70
|
+
}
|
|
71
|
+
const minimum = record.minItems;
|
|
72
|
+
if (minimum !== undefined && (!Number.isInteger(minimum) || minimum < 0 || minimum > maximum)) {
|
|
73
|
+
throw new SchemaError("SCHEMA_ARRAY_BOUNDS_INVALID", "minItems must be a non-negative integer no larger than maxItems.", path || "(root)");
|
|
74
|
+
}
|
|
75
|
+
if (record.items)
|
|
76
|
+
walk(record.items, depth + 1, `${path}[]`);
|
|
64
77
|
}
|
|
65
78
|
};
|
|
66
79
|
walk(schema, 1, "");
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
|
|
3
2
|
"name": "@forgezero/runtime",
|
|
4
|
-
|
|
3
|
+
"version": "0.1.6",
|
|
5
4
|
"type": "module",
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public",
|
|
7
|
+
"provenance": true
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
"./jobs": {
|
|
@@ -43,6 +43,14 @@
|
|
|
43
43
|
"types": "./dist/schema-typebox.d.ts",
|
|
44
44
|
"default": "./dist/schema-typebox.js"
|
|
45
45
|
},
|
|
46
|
+
"./query": {
|
|
47
|
+
"types": "./dist/query.d.ts",
|
|
48
|
+
"default": "./dist/query.js"
|
|
49
|
+
},
|
|
50
|
+
"./realtime": {
|
|
51
|
+
"types": "./dist/realtime.d.ts",
|
|
52
|
+
"default": "./dist/realtime.js"
|
|
53
|
+
},
|
|
46
54
|
"./calendar": {
|
|
47
55
|
"types": "./dist/calendar.d.ts",
|
|
48
56
|
"default": "./dist/calendar.js"
|
|
@@ -51,6 +59,10 @@
|
|
|
51
59
|
"types": "./dist/identity.d.ts",
|
|
52
60
|
"default": "./dist/identity.js"
|
|
53
61
|
},
|
|
62
|
+
"./passkey-hybrid": {
|
|
63
|
+
"types": "./dist/passkey-hybrid.d.ts",
|
|
64
|
+
"default": "./dist/passkey-hybrid.js"
|
|
65
|
+
},
|
|
54
66
|
"./totp": {
|
|
55
67
|
"types": "./dist/totp.d.ts",
|
|
56
68
|
"default": "./dist/totp.js"
|
|
@@ -151,10 +163,6 @@
|
|
|
151
163
|
"types": "./dist/phrase.d.ts",
|
|
152
164
|
"default": "./dist/phrase.js"
|
|
153
165
|
},
|
|
154
|
-
"./ssh-agent": {
|
|
155
|
-
"types": "./dist/ssh-agent.d.ts",
|
|
156
|
-
"default": "./dist/ssh-agent.js"
|
|
157
|
-
},
|
|
158
166
|
"./slip10": {
|
|
159
167
|
"types": "./dist/slip10.d.ts",
|
|
160
168
|
"default": "./dist/slip10.js"
|
|
@@ -179,7 +187,7 @@
|
|
|
179
187
|
"scripts": {
|
|
180
188
|
"check": "tsc --noEmit",
|
|
181
189
|
"prebuild": "rm -rf dist",
|
|
182
|
-
"build": "bun build src/jobs.ts src/queue.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/
|
|
190
|
+
"build": "bun build src/query.ts src/realtime.ts src/jobs.ts src/queue.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/passkey-hybrid.ts src/custody-crypto.ts src/custody-share.ts src/phrase.ts src/schema.ts src/schema-typebox.ts src/finance/discounts.ts src/finance/money.ts src/finance/storage.ts src/finance/custody.ts src/finance/tax.ts src/finance/derive.ts src/finance/venues.ts src/finance/ledger.ts src/finance/rates.ts src/finance/transfers.ts src/finance/chain.ts src/finance/chain-addresses.ts src/finance/chain-deposits.ts src/finance/chain-withdrawals.ts src/finance/chain-reconcile.ts src/finance/market.ts src/finance/commission.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
183
191
|
"prepublishOnly": "bun run check && bun run build"
|
|
184
192
|
},
|
|
185
193
|
"dependencies": {
|
|
@@ -242,7 +250,7 @@
|
|
|
242
250
|
"repository": {
|
|
243
251
|
"type": "git",
|
|
244
252
|
"url": "git+https://github.com/forgezero-net/packages.git",
|
|
245
|
-
"directory": "
|
|
253
|
+
"directory": "runtime"
|
|
246
254
|
},
|
|
247
255
|
"bugs": "https://github.com/forgezero-net/packages/issues",
|
|
248
256
|
"sideEffects": false,
|