@hearthlog/ledger 0.0.0-stage → 0.1.0
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/LICENSE +235 -0
- package/README.md +173 -2
- package/contracts/append-hash.json +30 -0
- package/docs/database-structure.md +176 -0
- package/docs/event-store-service-design.md +124 -0
- package/docs/event-store-service.http +182 -0
- package/docs/event-store-service.openapi.yaml +1651 -0
- package/package.json +43 -3
- package/scripts/logical-backup.ts +656 -0
- package/src/action-batches.ts +65 -0
- package/src/app-registry.ts +479 -0
- package/src/auth.ts +186 -0
- package/src/contracts.ts +251 -0
- package/src/domain.ts +245 -0
- package/src/errors.ts +50 -0
- package/src/event-validation.ts +181 -0
- package/src/fence.ts +76 -0
- package/src/http.ts +656 -0
- package/src/index.ts +3 -0
- package/src/ledger-app.ts +2057 -0
- package/src/rpc.ts +42 -0
- package/src/schema.ts +236 -0
- package/src/schemas.ts +429 -0
- package/src/sqlite-export.ts +136 -0
- package/src/transfer.ts +219 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AccessClaims,
|
|
3
|
+
parseAccessClaims,
|
|
4
|
+
} from "./contracts";
|
|
5
|
+
|
|
6
|
+
const ACCESS_PREFIX = "ledger_v1";
|
|
7
|
+
|
|
8
|
+
export const SOCKET_PROTOCOL = "ledger.v1";
|
|
9
|
+
|
|
10
|
+
export function bearerToken(request: Request): string {
|
|
11
|
+
const value = request.headers.get("authorization");
|
|
12
|
+
if (!value?.startsWith("Bearer ") || value.length === "Bearer ".length) {
|
|
13
|
+
throw new Error("missing bearer token");
|
|
14
|
+
}
|
|
15
|
+
return value.slice("Bearer ".length);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function authorizeAdmin(
|
|
19
|
+
request: Request,
|
|
20
|
+
env: Env,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const supplied = bearerToken(request);
|
|
23
|
+
if (!(await constantTimeStrings(supplied, env.ADMIN_TOKEN))) {
|
|
24
|
+
throw new Error("invalid administrator token");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function issueAccess(
|
|
29
|
+
env: Env,
|
|
30
|
+
appId: string,
|
|
31
|
+
principalId: string,
|
|
32
|
+
generation: number,
|
|
33
|
+
): Promise<string> {
|
|
34
|
+
const claims: AccessClaims = {
|
|
35
|
+
version: 1,
|
|
36
|
+
kind: "access",
|
|
37
|
+
appId,
|
|
38
|
+
principalId,
|
|
39
|
+
generation,
|
|
40
|
+
keyId: currentKeyId(env),
|
|
41
|
+
};
|
|
42
|
+
return sign(env, ACCESS_PREFIX, claims);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function verifyAccess(
|
|
46
|
+
env: Env,
|
|
47
|
+
token: string,
|
|
48
|
+
): Promise<AccessClaims> {
|
|
49
|
+
return parseAccessClaims(await verify(env, ACCESS_PREFIX, token));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function requestOrigin(request: Request): string | null {
|
|
53
|
+
return request.headers.get("origin");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function originAllowed(env: Env, origin: string | null): boolean {
|
|
57
|
+
if (origin === null) return true;
|
|
58
|
+
return env.ALLOWED_ORIGINS.split(",")
|
|
59
|
+
.map((value) => value.trim())
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.includes(origin);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function constantTimeStrings(
|
|
65
|
+
left: string,
|
|
66
|
+
right: string,
|
|
67
|
+
): Promise<boolean> {
|
|
68
|
+
const encoder = new TextEncoder();
|
|
69
|
+
const [leftHash, rightHash] = await Promise.all([
|
|
70
|
+
crypto.subtle.digest("SHA-256", encoder.encode(left)),
|
|
71
|
+
crypto.subtle.digest("SHA-256", encoder.encode(right)),
|
|
72
|
+
]);
|
|
73
|
+
return crypto.subtle.timingSafeEqual(leftHash, rightHash);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function sign(
|
|
77
|
+
env: Env,
|
|
78
|
+
prefix: string,
|
|
79
|
+
claims: AccessClaims,
|
|
80
|
+
): Promise<string> {
|
|
81
|
+
const payload = encodeBase64Url(
|
|
82
|
+
new TextEncoder().encode(JSON.stringify(claims)),
|
|
83
|
+
);
|
|
84
|
+
const message = `${prefix}.${payload}`;
|
|
85
|
+
const signature = await hmac(currentKey(env), message);
|
|
86
|
+
return `${message}.${encodeBase64Url(signature)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function verify(
|
|
90
|
+
env: Env,
|
|
91
|
+
prefix: string,
|
|
92
|
+
token: string,
|
|
93
|
+
): Promise<unknown> {
|
|
94
|
+
const parts = token.split(".");
|
|
95
|
+
if (
|
|
96
|
+
parts.length !== 3 ||
|
|
97
|
+
parts[0] !== prefix ||
|
|
98
|
+
!parts[1] ||
|
|
99
|
+
!parts[2]
|
|
100
|
+
) {
|
|
101
|
+
throw new Error("invalid signed token");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const payloadBytes = decodeBase64Url(parts[1]);
|
|
105
|
+
const value: unknown = JSON.parse(new TextDecoder().decode(payloadBytes));
|
|
106
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
107
|
+
throw new Error("invalid token payload");
|
|
108
|
+
}
|
|
109
|
+
const keyId = (value as Record<string, unknown>).keyId;
|
|
110
|
+
if (typeof keyId !== "string") throw new Error("missing token key id");
|
|
111
|
+
|
|
112
|
+
const expected = await hmac(verificationKey(env, keyId), `${prefix}.${parts[1]}`);
|
|
113
|
+
const supplied = decodeBase64Url(parts[2]);
|
|
114
|
+
if (
|
|
115
|
+
supplied.byteLength !== expected.byteLength ||
|
|
116
|
+
!crypto.subtle.timingSafeEqual(supplied, expected)
|
|
117
|
+
) {
|
|
118
|
+
throw new Error("invalid token signature");
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function hmac(key: string, message: string): Promise<Uint8Array> {
|
|
124
|
+
const encoder = new TextEncoder();
|
|
125
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
126
|
+
"raw",
|
|
127
|
+
encoder.encode(key),
|
|
128
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
129
|
+
false,
|
|
130
|
+
["sign"],
|
|
131
|
+
);
|
|
132
|
+
return new Uint8Array(
|
|
133
|
+
await crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(message)),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function currentKey(env: Env): string {
|
|
138
|
+
if (new TextEncoder().encode(env.TOKEN_SIGNING_KEY_CURRENT).byteLength < 32) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
"TOKEN_SIGNING_KEY_CURRENT must contain at least 32 bytes",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return env.TOKEN_SIGNING_KEY_CURRENT;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function currentKeyId(env: Env): string {
|
|
147
|
+
const keyId = env.TOKEN_SIGNING_KEY_ID.trim();
|
|
148
|
+
if (!keyId) throw new Error("TOKEN_SIGNING_KEY_ID is empty");
|
|
149
|
+
return keyId;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function verificationKey(env: Env, keyId: string): string {
|
|
153
|
+
if (keyId === currentKeyId(env)) return currentKey(env);
|
|
154
|
+
if (
|
|
155
|
+
env.TOKEN_SIGNING_PREVIOUS_KEY_ID &&
|
|
156
|
+
keyId === env.TOKEN_SIGNING_PREVIOUS_KEY_ID &&
|
|
157
|
+
env.TOKEN_SIGNING_KEY_PREVIOUS
|
|
158
|
+
) {
|
|
159
|
+
return env.TOKEN_SIGNING_KEY_PREVIOUS;
|
|
160
|
+
}
|
|
161
|
+
throw new Error("unknown signing key id");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function encodeBase64Url(bytes: Uint8Array): string {
|
|
165
|
+
let binary = "";
|
|
166
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
167
|
+
return btoa(binary)
|
|
168
|
+
.replaceAll("+", "-")
|
|
169
|
+
.replaceAll("/", "_")
|
|
170
|
+
.replace(/=+$/, "");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function decodeBase64Url(value: string): Uint8Array {
|
|
174
|
+
try {
|
|
175
|
+
const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
176
|
+
const padded = normalized.padEnd(
|
|
177
|
+
normalized.length + ((4 - (normalized.length % 4)) % 4),
|
|
178
|
+
"=",
|
|
179
|
+
);
|
|
180
|
+
return Uint8Array.from(atob(padded), (character) =>
|
|
181
|
+
character.charCodeAt(0),
|
|
182
|
+
);
|
|
183
|
+
} catch {
|
|
184
|
+
throw new Error("invalid base64url value");
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Value } from "@sinclair/typebox/value";
|
|
2
|
+
import type { Static, TSchema } from "@sinclair/typebox";
|
|
3
|
+
|
|
4
|
+
import { validationError } from "./errors";
|
|
5
|
+
import {
|
|
6
|
+
apiModels,
|
|
7
|
+
type AppendEventsResponse,
|
|
8
|
+
type StoredEvent,
|
|
9
|
+
} from "./schemas";
|
|
10
|
+
|
|
11
|
+
export interface AccessClaims {
|
|
12
|
+
version: number;
|
|
13
|
+
kind: "access";
|
|
14
|
+
appId: string;
|
|
15
|
+
principalId: string;
|
|
16
|
+
generation: number;
|
|
17
|
+
keyId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const INTERNAL_SOCKET_ACCESS_HEADER = "x-ledger-internal-access";
|
|
21
|
+
|
|
22
|
+
export interface SocketAttachment {
|
|
23
|
+
replayVersion?: number;
|
|
24
|
+
logId: string;
|
|
25
|
+
cursor: number;
|
|
26
|
+
principalId: string;
|
|
27
|
+
generation: number;
|
|
28
|
+
connectionId?: string;
|
|
29
|
+
connectedAt?: string;
|
|
30
|
+
lastSentAt?: string;
|
|
31
|
+
client?: {
|
|
32
|
+
userAgent: string | null;
|
|
33
|
+
ip: string | null;
|
|
34
|
+
country: string | null;
|
|
35
|
+
colo: string | null;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type SocketFrame =
|
|
40
|
+
| { type: "event"; event: StoredEvent }
|
|
41
|
+
| { type: "ready"; headVersion: number }
|
|
42
|
+
| {
|
|
43
|
+
type: "resync_required";
|
|
44
|
+
headVersion: number;
|
|
45
|
+
reason: "gap_too_large" | "cursor_ahead";
|
|
46
|
+
}
|
|
47
|
+
| { type: "error"; code: string };
|
|
48
|
+
|
|
49
|
+
export interface InitializeRequest {
|
|
50
|
+
appId: string;
|
|
51
|
+
name: string;
|
|
52
|
+
principalId: string;
|
|
53
|
+
generation: number;
|
|
54
|
+
createdAt: string;
|
|
55
|
+
/** The application's event policy, as JSON; absent for none. */
|
|
56
|
+
eventPolicy?: string;
|
|
57
|
+
/** The application is being filled by an identity-preserving import and serves nobody yet. */
|
|
58
|
+
importing?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RotateRequest {
|
|
62
|
+
principalId: string;
|
|
63
|
+
generation: number;
|
|
64
|
+
createdAt: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RevokeRequest {
|
|
68
|
+
revokedAt: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const UUID_PATTERN =
|
|
72
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
73
|
+
|
|
74
|
+
export function parseUuid(value: unknown, label: string): string {
|
|
75
|
+
if (typeof value !== "string" || !UUID_PATTERN.test(value)) {
|
|
76
|
+
throw validationError(`${label} must be a UUID`);
|
|
77
|
+
}
|
|
78
|
+
return value.toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseNonnegativeInteger(value: unknown, label: string): number {
|
|
82
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
83
|
+
throw validationError(`${label} must be a non-negative safe integer`);
|
|
84
|
+
}
|
|
85
|
+
return value as number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parsePositiveInteger(value: unknown, label: string): number {
|
|
89
|
+
const parsed = parseNonnegativeInteger(value, label);
|
|
90
|
+
if (parsed < 1) throw validationError(`${label} must be positive`);
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseIsoTimestamp(value: unknown, label: string): string {
|
|
95
|
+
if (
|
|
96
|
+
typeof value !== "string" ||
|
|
97
|
+
value.trim() === "" ||
|
|
98
|
+
!Number.isFinite(Date.parse(value))
|
|
99
|
+
) {
|
|
100
|
+
throw validationError(`${label} must be an ISO-8601 timestamp`);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseInitializeRequest(value: unknown): InitializeRequest {
|
|
106
|
+
const record = strictRecord(
|
|
107
|
+
value,
|
|
108
|
+
["appId", "name", "principalId", "generation", "createdAt", "eventPolicy", "importing"],
|
|
109
|
+
"initialize request",
|
|
110
|
+
);
|
|
111
|
+
return {
|
|
112
|
+
...(typeof record.eventPolicy === "string" ? { eventPolicy: record.eventPolicy } : {}),
|
|
113
|
+
...(record.importing === true ? { importing: true } : {}),
|
|
114
|
+
appId: parseUuid(record.appId, "appId"),
|
|
115
|
+
name: requiredString(record.name, "name"),
|
|
116
|
+
principalId: parseUuid(record.principalId, "principalId"),
|
|
117
|
+
generation: parsePositiveInteger(record.generation, "generation"),
|
|
118
|
+
createdAt: parseIsoTimestamp(record.createdAt, "createdAt"),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parseRotateRequest(value: unknown): RotateRequest {
|
|
123
|
+
const record = strictRecord(
|
|
124
|
+
value,
|
|
125
|
+
["principalId", "generation", "createdAt"],
|
|
126
|
+
"rotate request",
|
|
127
|
+
);
|
|
128
|
+
return {
|
|
129
|
+
principalId: parseUuid(record.principalId, "principalId"),
|
|
130
|
+
generation: parsePositiveInteger(record.generation, "generation"),
|
|
131
|
+
createdAt: parseIsoTimestamp(record.createdAt, "createdAt"),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseRevokeRequest(value: unknown): RevokeRequest {
|
|
136
|
+
const record = strictRecord(value, ["revokedAt"], "revoke request");
|
|
137
|
+
return {
|
|
138
|
+
revokedAt: parseIsoTimestamp(record.revokedAt, "revokedAt"),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseAccessClaims(value: unknown): AccessClaims {
|
|
143
|
+
const record = recordValue(value, "access token claims");
|
|
144
|
+
if (record.version !== 1 || record.kind !== "access") {
|
|
145
|
+
throw validationError("invalid access token claims");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
version: 1,
|
|
149
|
+
kind: "access",
|
|
150
|
+
appId: parseUuid(record.appId, "appId"),
|
|
151
|
+
principalId: parseUuid(record.principalId, "principalId"),
|
|
152
|
+
generation: parsePositiveInteger(record.generation, "generation"),
|
|
153
|
+
keyId: requiredString(record.keyId, "keyId"),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function parseSocketAttachment(value: unknown): SocketAttachment {
|
|
158
|
+
const record = recordValue(value, "socket attachment");
|
|
159
|
+
return {
|
|
160
|
+
logId: parseUuid(record.logId, "logId"),
|
|
161
|
+
...(record.replayVersion === undefined ? {} : { replayVersion: parseNonnegativeInteger(record.replayVersion, "replayVersion") }),
|
|
162
|
+
cursor: parseNonnegativeInteger(record.cursor, "cursor"),
|
|
163
|
+
principalId: parseUuid(record.principalId, "principalId"),
|
|
164
|
+
generation: parsePositiveInteger(record.generation, "generation"),
|
|
165
|
+
...(record.connectionId === undefined
|
|
166
|
+
? {}
|
|
167
|
+
: { connectionId: parseUuid(record.connectionId, "connectionId") }),
|
|
168
|
+
...(record.connectedAt === undefined
|
|
169
|
+
? {}
|
|
170
|
+
: {
|
|
171
|
+
connectedAt: parseIsoTimestamp(
|
|
172
|
+
record.connectedAt,
|
|
173
|
+
"connectedAt",
|
|
174
|
+
),
|
|
175
|
+
}),
|
|
176
|
+
...(record.lastSentAt === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: {
|
|
179
|
+
lastSentAt: parseIsoTimestamp(
|
|
180
|
+
record.lastSentAt,
|
|
181
|
+
"lastSentAt",
|
|
182
|
+
),
|
|
183
|
+
}),
|
|
184
|
+
...(record.client === undefined
|
|
185
|
+
? {}
|
|
186
|
+
: { client: parseSocketClient(record.client) }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseSocketClient(
|
|
191
|
+
value: unknown,
|
|
192
|
+
): NonNullable<SocketAttachment["client"]> {
|
|
193
|
+
const record = recordValue(value, "socket client");
|
|
194
|
+
const field = (name: string, limit: number) =>
|
|
195
|
+
typeof record[name] === "string" ? record[name].slice(0, limit) : null;
|
|
196
|
+
return {
|
|
197
|
+
userAgent: field("userAgent", 512),
|
|
198
|
+
ip: field("ip", 64),
|
|
199
|
+
country: field("country", 16),
|
|
200
|
+
colo: field("colo", 16),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function parseStoredAppendEventsResponse(
|
|
205
|
+
value: unknown,
|
|
206
|
+
): AppendEventsResponse {
|
|
207
|
+
if (!Value.Check(apiModels.AppendEventsResponse, value)) {
|
|
208
|
+
throw new Error("invalid persisted idempotency response");
|
|
209
|
+
}
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function decodeRpc<T extends TSchema>(
|
|
214
|
+
schema: T,
|
|
215
|
+
value: unknown,
|
|
216
|
+
label: string,
|
|
217
|
+
): Static<T> {
|
|
218
|
+
if (!Value.Check(schema, value)) {
|
|
219
|
+
throw validationError(`invalid ${label}`);
|
|
220
|
+
}
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function strictRecord(
|
|
225
|
+
value: unknown,
|
|
226
|
+
fields: readonly string[],
|
|
227
|
+
label: string,
|
|
228
|
+
): Record<string, unknown> {
|
|
229
|
+
const record = recordValue(value, label);
|
|
230
|
+
const allowed = new Set(fields);
|
|
231
|
+
for (const key of Object.keys(record)) {
|
|
232
|
+
if (!allowed.has(key)) {
|
|
233
|
+
throw validationError(`${label} contains unknown field ${key}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return record;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function recordValue(value: unknown, label: string): Record<string, unknown> {
|
|
240
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
241
|
+
throw validationError(`${label} must be a JSON object`);
|
|
242
|
+
}
|
|
243
|
+
return value as Record<string, unknown>;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function requiredString(value: unknown, label: string): string {
|
|
247
|
+
if (typeof value !== "string") {
|
|
248
|
+
throw validationError(`${label} must be a string`);
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppendEventsRequest,
|
|
3
|
+
IncomingEvent,
|
|
4
|
+
StoredEvent,
|
|
5
|
+
} from "./schemas";
|
|
6
|
+
import {
|
|
7
|
+
invalidEvent,
|
|
8
|
+
LedgerError,
|
|
9
|
+
validationError,
|
|
10
|
+
} from "./errors";
|
|
11
|
+
import { parseUuid } from "./contracts";
|
|
12
|
+
import { FILE_REFERENCES, type FileReference } from "#generated/event-schemas";
|
|
13
|
+
|
|
14
|
+
/** The largest millisecond timestamp a JavaScript Date can represent. */
|
|
15
|
+
const MAX_EVENT_TIMESTAMP = 8_640_000_000_000_000;
|
|
16
|
+
|
|
17
|
+
export async function hashAppendRequest(
|
|
18
|
+
logId: string,
|
|
19
|
+
request: AppendEventsRequest,
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const normalized = canonicalJson({
|
|
22
|
+
logId,
|
|
23
|
+
...(request.actionId ? { actionId: request.actionId } : {}),
|
|
24
|
+
...(request.expectedLogVersion !== undefined ? { expectedLogVersion: request.expectedLogVersion } : {}),
|
|
25
|
+
events: request.events.map((event) => ({
|
|
26
|
+
id: event.id ?? null,
|
|
27
|
+
type: event.type,
|
|
28
|
+
timestamp: event.timestamp ?? null,
|
|
29
|
+
entityId: event.entityId,
|
|
30
|
+
baseEntityVersion: event.baseEntityVersion ?? null,
|
|
31
|
+
payload: event.payload,
|
|
32
|
+
})),
|
|
33
|
+
});
|
|
34
|
+
const bytes = new TextEncoder().encode(normalized);
|
|
35
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
36
|
+
return [...new Uint8Array(digest)]
|
|
37
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
38
|
+
.join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function validateIncomingEvents(
|
|
42
|
+
events: readonly IncomingEvent[],
|
|
43
|
+
references: Readonly<Record<string, FileReference>> = FILE_REFERENCES,
|
|
44
|
+
): void {
|
|
45
|
+
const eventIds = new Set<string>();
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
validateIncomingEvent(event, references);
|
|
48
|
+
if (!event.id) continue;
|
|
49
|
+
if (eventIds.has(event.id)) {
|
|
50
|
+
throw invalidEvent("event ids must be unique within a request");
|
|
51
|
+
}
|
|
52
|
+
eventIds.add(event.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateIncomingEvent(
|
|
57
|
+
event: IncomingEvent,
|
|
58
|
+
references: Readonly<Record<string, FileReference>> = FILE_REFERENCES,
|
|
59
|
+
): void {
|
|
60
|
+
if (event.type.trim() === "") {
|
|
61
|
+
throw invalidEvent("event type must not be empty");
|
|
62
|
+
}
|
|
63
|
+
if (event.entityId.trim() === "") {
|
|
64
|
+
throw invalidEvent("entityId must not be empty");
|
|
65
|
+
}
|
|
66
|
+
if (event.type === "EVENTS_EXCISED") {
|
|
67
|
+
validateExcisionPayload(event);
|
|
68
|
+
}
|
|
69
|
+
fileReference(event, references);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An accepted event that names an immutable file version grants its log that version. An add must
|
|
74
|
+
* name one; an update does so only when it replaces the file. The entity id must be `<entity>:<id>`.
|
|
75
|
+
* The references come from the installed modules' entity definitions (`fileReference`).
|
|
76
|
+
*/
|
|
77
|
+
export function fileReference(
|
|
78
|
+
event: IncomingEvent,
|
|
79
|
+
references: Readonly<Record<string, FileReference>> = FILE_REFERENCES,
|
|
80
|
+
): { ownerId: string; fileVersionId: string } | null {
|
|
81
|
+
const reference = references[event.type];
|
|
82
|
+
if (!reference) return null;
|
|
83
|
+
if (!reference.requiredOnAdd && event.payload[reference.versionKey] === undefined) return null;
|
|
84
|
+
let ownerId: string;
|
|
85
|
+
let fileVersionId: string;
|
|
86
|
+
try {
|
|
87
|
+
ownerId = parseUuid(event.payload.id, `${reference.entity} id`);
|
|
88
|
+
fileVersionId = parseUuid(event.payload[reference.versionKey], `${reference.entity} file version id`);
|
|
89
|
+
} catch {
|
|
90
|
+
throw invalidEvent(`${reference.entity} file reference requires UUID id and ${reference.versionKey}`);
|
|
91
|
+
}
|
|
92
|
+
if (event.entityId !== `${reference.entity}:${ownerId}`) {
|
|
93
|
+
throw invalidEvent(`${reference.entity} entityId must match payload id`);
|
|
94
|
+
}
|
|
95
|
+
return { ownerId, fileVersionId };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const MAX_EXCISION_TARGETS = 200;
|
|
99
|
+
|
|
100
|
+
function validateExcisionPayload(event: IncomingEvent): void {
|
|
101
|
+
const eventIds = event.payload.eventIds;
|
|
102
|
+
if (!Array.isArray(eventIds) || eventIds.length === 0) {
|
|
103
|
+
throw invalidEvent("excision requires eventIds");
|
|
104
|
+
}
|
|
105
|
+
if (eventIds.length > MAX_EXCISION_TARGETS) {
|
|
106
|
+
throw invalidEvent(`excision is limited to ${MAX_EXCISION_TARGETS} events`);
|
|
107
|
+
}
|
|
108
|
+
const unique = new Set<string>();
|
|
109
|
+
for (const target of eventIds) {
|
|
110
|
+
if (typeof target !== "string" || target.trim() === "") {
|
|
111
|
+
throw invalidEvent("excision targets must be non-empty event ids");
|
|
112
|
+
}
|
|
113
|
+
if (unique.has(target)) {
|
|
114
|
+
throw invalidEvent("excision targets must be unique");
|
|
115
|
+
}
|
|
116
|
+
unique.add(target);
|
|
117
|
+
}
|
|
118
|
+
if (event.id && unique.has(event.id)) {
|
|
119
|
+
throw invalidEvent("an excision cannot remove itself");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function validateEntityChain(
|
|
124
|
+
entityId: string,
|
|
125
|
+
events: readonly IncomingEvent[],
|
|
126
|
+
currentEntityVersion: number | undefined,
|
|
127
|
+
currentLogVersion: number,
|
|
128
|
+
): void {
|
|
129
|
+
if (events.length === 0) return;
|
|
130
|
+
|
|
131
|
+
if (currentEntityVersion !== undefined) {
|
|
132
|
+
events.forEach((event, offset) => {
|
|
133
|
+
const expected = currentEntityVersion + offset;
|
|
134
|
+
const actual = event.baseEntityVersion ?? undefined;
|
|
135
|
+
if (offset === 0 && actual !== expected) {
|
|
136
|
+
if (actual === undefined) {
|
|
137
|
+
throw invalidEntityVersionChain(
|
|
138
|
+
entityId,
|
|
139
|
+
expected,
|
|
140
|
+
undefined,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
throw new LedgerError(409, {
|
|
144
|
+
code: "entity_conflict",
|
|
145
|
+
entityId,
|
|
146
|
+
currentEntityVersion,
|
|
147
|
+
currentLogVersion,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (offset > 0 && actual !== expected) {
|
|
151
|
+
throw invalidEntityVersionChain(
|
|
152
|
+
entityId,
|
|
153
|
+
expected,
|
|
154
|
+
actual,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
events.forEach((event, offset) => {
|
|
162
|
+
const expected = offset > 0 ? offset : undefined;
|
|
163
|
+
const actual = event.baseEntityVersion ?? undefined;
|
|
164
|
+
if (actual !== expected) {
|
|
165
|
+
throw invalidEntityVersionChain(
|
|
166
|
+
entityId,
|
|
167
|
+
expected,
|
|
168
|
+
actual,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function buildStoredEvent(
|
|
175
|
+
incoming: IncomingEvent,
|
|
176
|
+
version: number,
|
|
177
|
+
entityVersion: number,
|
|
178
|
+
): StoredEvent {
|
|
179
|
+
const timestamp = incoming.timestamp ?? Date.now();
|
|
180
|
+
if (
|
|
181
|
+
!Number.isSafeInteger(timestamp) ||
|
|
182
|
+
timestamp < 0 ||
|
|
183
|
+
timestamp > MAX_EVENT_TIMESTAMP
|
|
184
|
+
) {
|
|
185
|
+
throw invalidEvent("timestamp out of range");
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
version,
|
|
189
|
+
id: incoming.id ?? crypto.randomUUID(),
|
|
190
|
+
type: incoming.type,
|
|
191
|
+
entityId: incoming.entityId.trim(),
|
|
192
|
+
entityVersion,
|
|
193
|
+
timestamp,
|
|
194
|
+
payload: incoming.payload,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The identity a gateway stamped on a request. Anything that is not a short
|
|
200
|
+
* printable string is ignored rather than stored, so a malformed header can
|
|
201
|
+
* never fail an append.
|
|
202
|
+
*/
|
|
203
|
+
export function normalizeActor(value: unknown): string | null {
|
|
204
|
+
if (typeof value !== "string") return null;
|
|
205
|
+
const actor = value.trim();
|
|
206
|
+
if (actor.length === 0 || actor.length > 320) return null;
|
|
207
|
+
// eslint-disable-next-line no-control-regex
|
|
208
|
+
return /[\u0000-\u001f\u007f]/.test(actor) ? null : actor;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function canonicalJson(value: unknown): string {
|
|
212
|
+
if (Array.isArray(value)) {
|
|
213
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
214
|
+
}
|
|
215
|
+
if (value !== null && typeof value === "object") {
|
|
216
|
+
const record = value as Record<string, unknown>;
|
|
217
|
+
return `{${Object.keys(record)
|
|
218
|
+
.sort()
|
|
219
|
+
.map(
|
|
220
|
+
(key) =>
|
|
221
|
+
`${JSON.stringify(key)}:${canonicalJson(record[key])}`,
|
|
222
|
+
)
|
|
223
|
+
.join(",")}}`;
|
|
224
|
+
}
|
|
225
|
+
const serialized = JSON.stringify(value);
|
|
226
|
+
if (serialized === undefined) {
|
|
227
|
+
throw validationError("value is not JSON serializable");
|
|
228
|
+
}
|
|
229
|
+
return serialized;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function invalidEntityVersionChain(
|
|
233
|
+
entityId: string,
|
|
234
|
+
expected: number | undefined,
|
|
235
|
+
actual: number | undefined,
|
|
236
|
+
): LedgerError {
|
|
237
|
+
return new LedgerError(409, {
|
|
238
|
+
error: "ValidationError",
|
|
239
|
+
code: "invalid_entity_version_chain",
|
|
240
|
+
message: "baseEntityVersion does not continue the entity version chain",
|
|
241
|
+
entityId,
|
|
242
|
+
expectedBaseEntityVersion: expected ?? null,
|
|
243
|
+
actualBaseEntityVersion: actual ?? null,
|
|
244
|
+
});
|
|
245
|
+
}
|