@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.
@@ -0,0 +1,65 @@
1
+ import type { StoredEvent, AppendEventsRequest } from './schemas.js';
2
+
3
+ /** Replay an exported batch at the same positions in an empty destination log. */
4
+ export function restoreBatchRequest(events: readonly StoredEvent[], requestId: string): AppendEventsRequest {
5
+ if (!events.length) throw new Error('empty exported batch');
6
+ return {
7
+ requestId,
8
+ ...(events.some(event => event.type === 'WORKSPACE_RESTORED') ? { expectedLogVersion: events[0]!.version - 1 } : {}),
9
+ ...(events[0]?.action ? { actionId: events[0].action.id } : {}),
10
+ events: events.map(event => ({
11
+ id: event.id, type: event.type, entityId: event.entityId,
12
+ ...(event.entityVersion > 1 ? { baseEntityVersion: event.entityVersion - 1 } : {}),
13
+ timestamp: event.timestamp, payload: event.payload,
14
+ })),
15
+ };
16
+ }
17
+
18
+ /** Reassemble complete actions when an export page splits their events. */
19
+ export function* actionBatches(events: readonly StoredEvent[]): Generator<StoredEvent[]> {
20
+ const seen = new Set<string>();
21
+ for (let offset = 0; offset < events.length;) {
22
+ const first = events[offset]!;
23
+ const action = first.action;
24
+ if (!action) {
25
+ const batch: StoredEvent[] = [];
26
+ while (offset < events.length && !events[offset]!.action && batch.length < 512) {
27
+ batch.push(events[offset++]!);
28
+ }
29
+ yield batch;
30
+ continue;
31
+ }
32
+ if (seen.has(action.id) || action.index !== 0 || action.size < 1 || !Number.isSafeInteger(action.size)) {
33
+ throw new Error('invalid or repeated exported action');
34
+ }
35
+ seen.add(action.id);
36
+ const batch = events.slice(offset, offset + action.size);
37
+ if (batch.length !== action.size || batch.some((event, index) =>
38
+ event.action?.id !== action.id || event.action.index !== index || event.action.size !== action.size)) {
39
+ throw new Error('incomplete exported action');
40
+ }
41
+ yield batch;
42
+ offset += action.size;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Split a batch so each replayed request has one author. A grouped action is
48
+ * one original request and stays whole; legacy events were only batched for
49
+ * convenience and may come from different people.
50
+ */
51
+ export function* authorRuns(batch: readonly StoredEvent[]): Generator<StoredEvent[]> {
52
+ if (batch[0]?.action) {
53
+ yield [...batch];
54
+ return;
55
+ }
56
+ let run: StoredEvent[] = [];
57
+ for (const event of batch) {
58
+ if (run.length && run[0]!.actor !== event.actor) {
59
+ yield run;
60
+ run = [];
61
+ }
62
+ run.push(event);
63
+ }
64
+ if (run.length) yield run;
65
+ }
@@ -0,0 +1,479 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+
3
+ import { issueAccess } from "./auth";
4
+ import { decodeRpc, parseUuid } from "./contracts";
5
+ import { LedgerError, normalizeError, validationError } from "./errors";
6
+ import { type RpcResult, type RpcSuccess, rpcCall, rpcSuccess } from "./rpc";
7
+ import { NEW_APPLICATION_POLICY, type EventPolicy } from "./event-validation";
8
+ import type { Fence, FenceRequest } from "./fence";
9
+ import { migrateAppRegistry } from "./schema";
10
+ import { sqliteExport } from "./sqlite-export";
11
+ import {
12
+ type ApplicationSummary,
13
+ type ApplicationTokenResponse,
14
+ type CreateApplicationRequest,
15
+ createApplicationBody,
16
+ } from "./schemas";
17
+
18
+ interface ApplicationRow extends Record<string, SqlStorageValue> {
19
+ id: string;
20
+ name: string;
21
+ created_at: string;
22
+ revoked_at: string | null;
23
+ active_principal_id: string;
24
+ principal_generation: number;
25
+ active_token_created_at: string | null;
26
+ provisioning_state: string;
27
+ }
28
+
29
+ export class AppRegistry extends DurableObject<Env> {
30
+ constructor(ctx: DurableObjectState, env: Env) {
31
+ super(ctx, env);
32
+ this.ctx.blockConcurrencyWhile(async () => {
33
+ migrateAppRegistry(this.ctx.storage);
34
+ });
35
+ }
36
+
37
+ async health(): Promise<RpcResult<null>> {
38
+ return rpcCall(() => {
39
+ this.ctx.storage.sql.exec("SELECT 1");
40
+ return rpcSuccess(null, 204);
41
+ });
42
+ }
43
+
44
+ async administratorExportSqlite(): Promise<Response> {
45
+ return sqliteExport(
46
+ this.ctx.storage,
47
+ `ledger-registry-${new Date().toISOString().replaceAll(":", "-")}`,
48
+ );
49
+ }
50
+
51
+ async listApplications(): Promise<
52
+ RpcResult<{ apps: ApplicationSummary[] }>
53
+ > {
54
+ return rpcCall(() => rpcSuccess(this.list()));
55
+ }
56
+
57
+ async getApplication(
58
+ appId: string,
59
+ ): Promise<RpcResult<ApplicationSummary>> {
60
+ return rpcCall(() => {
61
+ const current = this.get(parseUuid(appId, "application id"));
62
+ if (!current || current.provisioning_state !== "active") {
63
+ throw new LedgerError(404, {
64
+ error: "application not found",
65
+ code: "not_found",
66
+ });
67
+ }
68
+ return rpcSuccess(rowSummary(current));
69
+ });
70
+ }
71
+
72
+ async createApplication(
73
+ payload: CreateApplicationRequest,
74
+ ): Promise<RpcResult<ApplicationTokenResponse>> {
75
+ return rpcCall(() =>
76
+ this.create(
77
+ decodeRpc(
78
+ createApplicationBody,
79
+ payload,
80
+ "create application request",
81
+ ),
82
+ ),
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Starts an identity-preserving import: the application is created under
88
+ * its original ID, empty, with nobody able to use it (no token exists) until
89
+ * `finishImport`. Only an empty Ledger accepts one.
90
+ */
91
+ async beginImport(payload: {
92
+ id: string;
93
+ name: string;
94
+ createdAt: string;
95
+ policy: string | null;
96
+ }): Promise<RpcResult<{ id: string; name: string }>> {
97
+ return rpcCall(async () => {
98
+ const appId = parseUuid(payload.id, "application id");
99
+ const name = payload.name.trim();
100
+ if (!name) throw validationError("name must not be empty");
101
+ const existing = this.ctx.storage.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM applications").toArray()[0]!.n;
102
+ if (existing > 0) {
103
+ throw new LedgerError(409, {
104
+ error: "Conflict",
105
+ code: "ledger_not_empty",
106
+ message: "An identity-preserving import needs an empty Ledger",
107
+ });
108
+ }
109
+ const principalId = crypto.randomUUID();
110
+ this.ctx.storage.sql.exec(
111
+ `INSERT INTO applications (id, name, created_at, active_principal_id, principal_generation,
112
+ active_token_created_at, provisioning_state)
113
+ VALUES (?, ?, ?, ?, 1, ?, 'importing')`,
114
+ appId, name, payload.createdAt, principalId, payload.createdAt,
115
+ );
116
+ const response = await this.env.LEDGER_APP.getByName(appId).initializeApplication({
117
+ appId, name, principalId, generation: 1, createdAt: payload.createdAt,
118
+ ...(payload.policy ? { eventPolicy: payload.policy } : {}),
119
+ importing: true,
120
+ });
121
+ if (!response.ok) {
122
+ this.ctx.storage.sql.exec("DELETE FROM applications WHERE id = ?", appId);
123
+ throw new LedgerError(500, { error: "import could not start", code: "import_failed" });
124
+ }
125
+ return rpcSuccess({ id: appId, name }, 201);
126
+ });
127
+ }
128
+
129
+ /** The application, if it is still waiting for its import. */
130
+ async importingApplication(appId: string): Promise<RpcResult<{ id: string }>> {
131
+ return rpcCall(() => {
132
+ const current = this.get(parseUuid(appId, "application id"));
133
+ if (!current || current.provisioning_state !== "importing") {
134
+ throw new LedgerError(404, { error: "no import in progress", code: "not_found" });
135
+ }
136
+ return rpcSuccess({ id: current.id });
137
+ });
138
+ }
139
+
140
+ /** Throws away a half-finished import, so the Ledger is empty again and the import can be retried. */
141
+ async abortImport(appId: string): Promise<RpcResult<null>> {
142
+ return rpcCall(async () => {
143
+ const id = parseUuid(appId, "application id");
144
+ const current = this.get(id);
145
+ if (!current || current.provisioning_state !== "importing") {
146
+ throw new LedgerError(404, { error: "no import in progress", code: "not_found" });
147
+ }
148
+ const response = await this.env.LEDGER_APP.getByName(id).abortImport(id);
149
+ if (!response.ok) throw new LedgerError(response.status, response.body);
150
+ this.ctx.storage.sql.exec("DELETE FROM applications WHERE id = ?", id);
151
+ return rpcSuccess(null, 204);
152
+ });
153
+ }
154
+
155
+ /** Opens an imported application once its history matches what the source reported. */
156
+ async finishImport(
157
+ appId: string,
158
+ expected: { logs: Array<{ id: string; headVersion: number }> },
159
+ ): Promise<RpcResult<{ logs: number; events: number }>> {
160
+ return rpcCall(async () => {
161
+ const id = parseUuid(appId, "application id");
162
+ const current = this.get(id);
163
+ if (!current || current.provisioning_state !== "importing") {
164
+ throw new LedgerError(404, { error: "no import in progress", code: "not_found" });
165
+ }
166
+ const response = await this.env.LEDGER_APP.getByName(id).finishImport(id, expected);
167
+ if (!response.ok) throw new LedgerError(response.status, response.body);
168
+ this.ctx.storage.sql.exec("UPDATE applications SET provisioning_state = 'active' WHERE id = ?", id);
169
+ return rpcSuccess(response.body);
170
+ });
171
+ }
172
+
173
+ async setApplicationFence(appId: string, request: FenceRequest): Promise<RpcResult<{ fence: Fence | null }>> {
174
+ return rpcCall(async () => {
175
+ this.activeApplication(parseUuid(appId, "application id"));
176
+ const response = await this.env.LEDGER_APP.getByName(appId).setFence(request);
177
+ if (!response.ok) throw new LedgerError(response.status, response.body);
178
+ return rpcSuccess(response.body);
179
+ });
180
+ }
181
+
182
+ async getApplicationFence(appId: string): Promise<RpcResult<{ fence: Fence | null }>> {
183
+ return rpcCall(async () => {
184
+ this.activeApplication(parseUuid(appId, "application id"));
185
+ const response = await this.env.LEDGER_APP.getByName(appId).getFence();
186
+ if (!response.ok) throw new LedgerError(response.status, response.body);
187
+ return rpcSuccess(response.body);
188
+ });
189
+ }
190
+
191
+ async setApplicationPolicy(
192
+ appId: string,
193
+ policy: EventPolicy,
194
+ ): Promise<RpcResult<EventPolicy>> {
195
+ return rpcCall(async () => {
196
+ this.activeApplication(parseUuid(appId, "application id"));
197
+ const response = await this.env.LEDGER_APP.getByName(appId).setEventPolicy(policy);
198
+ if (!response.ok) throw new LedgerError(500, { error: "policy update failed", code: "policy_failed" });
199
+ return rpcSuccess(policy);
200
+ });
201
+ }
202
+
203
+ async getApplicationPolicy(
204
+ appId: string,
205
+ ): Promise<RpcResult<{ policy: EventPolicy | null }>> {
206
+ return rpcCall(async () => {
207
+ this.activeApplication(parseUuid(appId, "application id"));
208
+ const response = await this.env.LEDGER_APP.getByName(appId).getEventPolicy();
209
+ if (!response.ok) throw new LedgerError(500, { error: "policy read failed", code: "policy_failed" });
210
+ return rpcSuccess(response.body);
211
+ });
212
+ }
213
+
214
+ async rotateApplicationToken(
215
+ appId: string,
216
+ ): Promise<RpcResult<ApplicationTokenResponse>> {
217
+ return rpcCall(() => this.rotate(parseUuid(appId, "application id")));
218
+ }
219
+
220
+ async revokeApplication(
221
+ appId: string,
222
+ ): Promise<RpcResult<ApplicationSummary>> {
223
+ return rpcCall(() => this.revoke(parseUuid(appId, "application id")));
224
+ }
225
+
226
+ private list(): { apps: ApplicationSummary[] } {
227
+ const apps = this.ctx.storage.sql
228
+ .exec<ApplicationRow>(
229
+ `SELECT id, name, created_at, revoked_at, active_principal_id,
230
+ principal_generation, active_token_created_at,
231
+ provisioning_state
232
+ FROM applications
233
+ WHERE provisioning_state = 'active'
234
+ ORDER BY created_at`,
235
+ )
236
+ .toArray()
237
+ .map(rowSummary);
238
+ return { apps };
239
+ }
240
+
241
+ private async create(
242
+ payload: CreateApplicationRequest,
243
+ ): Promise<RpcSuccess<ApplicationTokenResponse>> {
244
+ const name = payload.name.trim();
245
+ if (!name) {
246
+ throw validationError("name must not be empty");
247
+ }
248
+ const appId = crypto.randomUUID();
249
+ const principalId = crypto.randomUUID();
250
+ const createdAt = new Date().toISOString();
251
+ this.ctx.storage.transactionSync(() => {
252
+ const existing = this.ctx.storage.sql
253
+ .exec<{
254
+ provisioning_state: string;
255
+ }>(
256
+ "SELECT provisioning_state FROM applications WHERE name = ?",
257
+ name,
258
+ )
259
+ .toArray()[0];
260
+ if (existing && existing.provisioning_state !== "failed") {
261
+ throw new LedgerError(409, {
262
+ error: "application name already exists",
263
+ code: "app_name_conflict",
264
+ });
265
+ }
266
+ // A failed attempt never issued a usable credential; release its name.
267
+ if (existing) {
268
+ this.ctx.storage.sql.exec(
269
+ `DELETE FROM applications
270
+ WHERE name = ? AND provisioning_state = 'failed'`,
271
+ name,
272
+ );
273
+ }
274
+ this.ctx.storage.sql.exec(
275
+ `INSERT INTO applications (
276
+ id, name, created_at, active_principal_id,
277
+ principal_generation, active_token_created_at,
278
+ provisioning_state
279
+ ) VALUES (?, ?, ?, ?, 1, ?, 'provisioning')`,
280
+ appId,
281
+ name,
282
+ createdAt,
283
+ principalId,
284
+ createdAt,
285
+ );
286
+ });
287
+
288
+ let token: string;
289
+ try {
290
+ token = await issueAccess(this.env, appId, principalId, 1);
291
+ const response = await this.env.LEDGER_APP.getByName(
292
+ appId,
293
+ ).initializeApplication({
294
+ appId,
295
+ name,
296
+ principalId,
297
+ generation: 1,
298
+ createdAt,
299
+ eventPolicy: JSON.stringify(payload.policy ?? NEW_APPLICATION_POLICY),
300
+ });
301
+ if (!response.ok) {
302
+ throw new Error(
303
+ `application initialization returned ${response.status}`,
304
+ );
305
+ }
306
+ } catch (cause) {
307
+ this.ctx.storage.sql.exec(
308
+ `UPDATE applications SET provisioning_state = 'failed'
309
+ WHERE id = ?`,
310
+ appId,
311
+ );
312
+ console.error(
313
+ JSON.stringify({
314
+ message: "Ledger application provisioning failed",
315
+ appId,
316
+ error: normalizeError(cause).message,
317
+ }),
318
+ );
319
+ throw new LedgerError(500, {
320
+ error: "application provisioning failed",
321
+ code: "provisioning_failed",
322
+ });
323
+ }
324
+ this.ctx.storage.sql.exec(
325
+ `UPDATE applications SET provisioning_state = 'active'
326
+ WHERE id = ?`,
327
+ appId,
328
+ );
329
+ return rpcSuccess(
330
+ {
331
+ app: {
332
+ id: appId,
333
+ name,
334
+ createdAt,
335
+ revokedAt: null,
336
+ activeTokenCreatedAt: createdAt,
337
+ },
338
+ token,
339
+ },
340
+ 201,
341
+ );
342
+ }
343
+
344
+ private async rotate(
345
+ appId: string,
346
+ ): Promise<RpcSuccess<ApplicationTokenResponse>> {
347
+ const current = this.get(appId);
348
+ if (!current) {
349
+ throw new LedgerError(404, {
350
+ error: "application not found",
351
+ code: "not_found",
352
+ });
353
+ }
354
+ if (
355
+ current.revoked_at !== null ||
356
+ current.provisioning_state !== "active"
357
+ ) {
358
+ throw new LedgerError(409, {
359
+ error: "application is revoked",
360
+ code: "application_revoked",
361
+ });
362
+ }
363
+
364
+ const principalId = crypto.randomUUID();
365
+ const generation = current.principal_generation + 1;
366
+ const createdAt = new Date().toISOString();
367
+ // Reserve the generation before awaiting anything, so concurrent
368
+ // rotations get distinct generations. A failed rotation leaves the
369
+ // reservation behind; the application keeps its previous principal
370
+ // and the next rotation still moves forward.
371
+ this.ctx.storage.sql.exec(
372
+ `UPDATE applications
373
+ SET active_principal_id = ?, principal_generation = ?,
374
+ active_token_created_at = ?
375
+ WHERE id = ?`,
376
+ principalId,
377
+ generation,
378
+ createdAt,
379
+ appId,
380
+ );
381
+ // Sign first: a configuration error must not rotate out the only
382
+ // working credential without returning its replacement.
383
+ const token = await issueAccess(
384
+ this.env,
385
+ appId,
386
+ principalId,
387
+ generation,
388
+ );
389
+ const response = await this.env.LEDGER_APP.getByName(
390
+ appId,
391
+ ).rotateApplicationPrincipal({
392
+ principalId,
393
+ generation,
394
+ createdAt,
395
+ });
396
+ if (!response.ok) {
397
+ throw new LedgerError(500, {
398
+ error: "principal rotation failed",
399
+ code: "rotation_failed",
400
+ });
401
+ }
402
+ return rpcSuccess({
403
+ app: {
404
+ id: appId,
405
+ name: current.name,
406
+ createdAt: current.created_at,
407
+ revokedAt: null,
408
+ activeTokenCreatedAt: createdAt,
409
+ },
410
+ token,
411
+ });
412
+ }
413
+
414
+ private async revoke(
415
+ appId: string,
416
+ ): Promise<RpcSuccess<ApplicationSummary>> {
417
+ const current = this.get(appId);
418
+ if (!current) {
419
+ throw new LedgerError(404, {
420
+ error: "application not found",
421
+ code: "not_found",
422
+ });
423
+ }
424
+ const revokedAt = current.revoked_at ?? new Date().toISOString();
425
+ if (current.revoked_at === null) {
426
+ const response = await this.env.LEDGER_APP.getByName(
427
+ appId,
428
+ ).revokeApplicationState({ revokedAt });
429
+ if (!response.ok) {
430
+ throw new LedgerError(500, {
431
+ error: "application revocation failed",
432
+ code: "revocation_failed",
433
+ });
434
+ }
435
+ this.ctx.storage.sql.exec(
436
+ "UPDATE applications SET revoked_at = ? WHERE id = ?",
437
+ revokedAt,
438
+ appId,
439
+ );
440
+ }
441
+ return rpcSuccess({
442
+ id: appId,
443
+ name: current.name,
444
+ createdAt: current.created_at,
445
+ revokedAt,
446
+ activeTokenCreatedAt: current.active_token_created_at,
447
+ } satisfies ApplicationSummary);
448
+ }
449
+
450
+ private activeApplication(appId: string): ApplicationRow {
451
+ const current = this.get(appId);
452
+ if (!current || current.provisioning_state !== "active" || current.revoked_at !== null) {
453
+ throw new LedgerError(404, { error: "application not found", code: "not_found" });
454
+ }
455
+ return current;
456
+ }
457
+
458
+ private get(appId: string): ApplicationRow | undefined {
459
+ return this.ctx.storage.sql
460
+ .exec<ApplicationRow>(
461
+ `SELECT id, name, created_at, revoked_at, active_principal_id,
462
+ principal_generation, active_token_created_at,
463
+ provisioning_state
464
+ FROM applications WHERE id = ?`,
465
+ appId,
466
+ )
467
+ .toArray()[0];
468
+ }
469
+ }
470
+
471
+ function rowSummary(row: ApplicationRow): ApplicationSummary {
472
+ return {
473
+ id: parseUuid(row.id, "stored application id"),
474
+ name: row.name,
475
+ createdAt: row.created_at,
476
+ revokedAt: row.revoked_at,
477
+ activeTokenCreatedAt: row.active_token_created_at,
478
+ };
479
+ }