@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,124 @@
1
+ # Ledger service design
2
+
3
+ Ledger is the durable write-side service for the offline-first Hearthlog application.
4
+ Clients keep local read models, submit domain events when online, replay
5
+ missing events after reconnecting, and receive ordered realtime events through
6
+ gateway-authenticated WebSockets.
7
+
8
+ ## Runtime boundary
9
+
10
+ The deployed runtime is a TypeScript Cloudflare Worker:
11
+
12
+ - Elysia owns stateless HTTP routing and validation through the experimental
13
+ `CloudflareAdapter`.
14
+ - Schemas in `src/schemas.ts` define HTTP request DTOs and registered response
15
+ models.
16
+ - Route-scoped Elysia resolvers authenticate administrator and app routes and
17
+ inject verified claims.
18
+ - Durable Object RPC carries plain structured-clone payloads and a
19
+ discriminated `RpcResult` for expected domain failures.
20
+ - `LedgerApp.fetch()` is reserved for the exact WebSocket upgrade route.
21
+
22
+ Unknown fields and non-canonical query names are rejected. `afterVersion` and
23
+ `limit` accept non-negative or positive safe integers as appropriate. Page
24
+ sizes above 512 are clamped to 512.
25
+
26
+ Invalid input returns an explicit `LedgerError`, usually `400
27
+ validation_error`. Every other exception is a server fault and returns `500`,
28
+ so clients retry it instead of discarding a change.
29
+
30
+ ## Durable Object topology
31
+
32
+ `AppRegistry` is addressed deterministically as `registry-v1`. It owns
33
+ application names, provisioning state, and credential lifecycle metadata. It
34
+ calls the target `LedgerApp` through its namespace binding; namespace access is
35
+ the internal capability boundary.
36
+
37
+ Each application UUID addresses one `LedgerApp`. That object owns the
38
+ application metadata, logs, events, entity claims, idempotency records, and
39
+ active hibernating WebSockets. This makes an application the coordination atom
40
+ while avoiding a global event-write bottleneck.
41
+
42
+ Both classes extend `DurableObject<Env>`, use generated Wrangler environment
43
+ types, and initialize ordered SQLite migrations inside
44
+ `blockConcurrencyWhile`.
45
+
46
+ ## Authentication
47
+
48
+ The operator endpoints use `ADMIN_TOKEN`, compared in constant time after both
49
+ values are hashed to a fixed length.
50
+
51
+ Creating or rotating an application issues an HMAC-signed app token containing
52
+ the application UUID, principal UUID, generation, and signing-key ID. Every app
53
+ operation verifies the signature at the HTTP boundary and checks the claims
54
+ against `app_metadata` inside the application object. Rotation and revocation
55
+ therefore invalidate old tokens without a distributed token list.
56
+
57
+ The production browser never receives the long-lived app token. It calls the
58
+ same-origin `/api/ledger` gateway, whose Worker injects
59
+ `LEDGER_AUTH_TOKEN` and forwards through a service binding. Cloudflare Access
60
+ remains in front of the public deployment.
61
+
62
+ ## Append and replay
63
+
64
+ An append request contains a UUID `requestId` and 1 to 512 events. Ledger:
65
+
66
+ 1. validates event envelopes, including unique event IDs within the request
67
+ and timestamps that a JavaScript `Date` can represent;
68
+ 2. hashes a canonical representation of the log ID and event content;
69
+ 3. checks any existing idempotency record;
70
+ 4. rejects an action or event ID the log already accepted
71
+ (`409 action_already_accepted` or `409 event_already_accepted`) and
72
+ validates each entity's version chain;
73
+ 5. assigns contiguous log and entity versions and rejects a response too large
74
+ for one SQLite row (`400 request_too_large`);
75
+ 6. inserts events and updates entity claims, Gallery photo references, and the log head;
76
+ 7. persists the successful response for exact replay.
77
+
78
+ Steps 3 through 7 run in one synchronous SQLite transaction. A matching retry
79
+ returns the stored response after runtime validation. A different request body
80
+ under the same request ID returns `409 idempotency_mismatch`.
81
+
82
+ Realtime delivery runs after the transaction commits and is best effort. A
83
+ delivery failure is logged and never changes the append response; sockets
84
+ recover through HTTP replay.
85
+
86
+ HTTP replay is ordered and authoritative. `GET /logs/{logId}/events` pages by
87
+ global log version.
88
+
89
+ File originals (today Gallery's photos) are immutable app-scoped objects outside Ledger. An entity
90
+ whose definition declares a `fileReference` names its file version in a payload key (generated into
91
+ `FILE_REFERENCES`); a valid add, or an update that names a new version, grants only its own id and
92
+ file version to the current log. The reference is copied into a fork only when the event is
93
+ within the accepted fork prefix. The private file gateway checks the exact
94
+ reference through `GET /logs/{logId}/gallery-photos/{photoId}/files/{fileVersionId}/reference`
95
+ before accessing R2; an application or log check alone is insufficient.
96
+
97
+ ## Realtime delivery
98
+
99
+ The browser opens the same-origin socket URL with its last applied log
100
+ version. The frontend Worker enforces same-origin access, injects its
101
+ server-side app token, and forwards the upgrade through a service binding.
102
+ Elysia verifies the token once, overwrites the internal claims header, and
103
+ routes the request to the application object. Durable Object namespace access
104
+ is the capability boundary for those internal claims.
105
+
106
+ The socket uses `ledger.v1` and is server-to-client only. Like HTTP replay, it
107
+ refuses a fork that is still being prepared (`409 fork_preparing`) and a client
108
+ whose replay version is too old for the log (`409 client_upgrade_required`). Catch-up emits
109
+ ordered `event` frames and then `ready`. A gap greater than 512 or a cursor
110
+ ahead of the log head produces `resync_required`; the client returns to HTTP
111
+ replay and reconnects from the new cursor. Attachments persist the cursor,
112
+ principal, and generation across hibernation.
113
+
114
+ ## Recovery
115
+
116
+ The logical backup utility snapshots each log head, exports ordered NDJSON
117
+ pages, and records SHA-256 checksums in a versioned manifest. Restore validates
118
+ all files before provisioning a fresh application, replays and verifies every
119
+ page, rotates the bootstrap token, and revokes a partially restored target on
120
+ failure.
121
+
122
+ Deployment validation separates generated-type checks, Worker and Node
123
+ TypeScript checks, Durable Object contracts, bundle dry-run, startup profiling,
124
+ and browser-level HTTP/WebSocket/backup contracts.
@@ -0,0 +1,182 @@
1
+ @host = http://127.0.0.1:8787
2
+ @appToken = replace-with-app-token
3
+ @adminToken = replace-with-admin-token
4
+ @appId = 11111111-1111-1111-1111-111111111111
5
+ @logId = 22222222-2222-2222-2222-222222222222
6
+
7
+ ###
8
+ # Public database readiness check
9
+ GET {{host}}/health
10
+
11
+ ###
12
+ # Runtime: resolve the current app and principal identity
13
+ GET {{host}}/session
14
+ Authorization: Bearer {{appToken}}
15
+
16
+ ###
17
+ # Admin: create an app. Save the returned token; it is shown only once.
18
+ POST {{host}}/admin/v1/apps
19
+ Content-Type: application/json
20
+ Authorization: Bearer {{adminToken}}
21
+
22
+ {
23
+ "name": "stor"
24
+ }
25
+
26
+ ###
27
+ # Admin: list apps
28
+ GET {{host}}/admin/v1/apps
29
+ Authorization: Bearer {{adminToken}}
30
+
31
+ ###
32
+ # Admin: read, then replace, an app's event policy
33
+ GET {{host}}/admin/v1/apps/{{appId}}/policy
34
+ Authorization: Bearer {{adminToken}}
35
+
36
+ ###
37
+ PUT {{host}}/admin/v1/apps/{{appId}}/policy
38
+ Authorization: Bearer {{adminToken}}
39
+ Content-Type: application/json
40
+
41
+ { "contracts": ["org.stor.health"], "dynamicEvents": false, "validation": "enforce" }
42
+
43
+ ###
44
+ # Admin: rotate an app token
45
+ POST {{host}}/admin/v1/apps/{{appId}}/rotate-token
46
+ Authorization: Bearer {{adminToken}}
47
+
48
+ ###
49
+ # Admin: revoke an app
50
+ POST {{host}}/admin/v1/apps/{{appId}}/revoke
51
+ Authorization: Bearer {{adminToken}}
52
+
53
+ ###
54
+ # Runtime: list logs in the token's app
55
+ GET {{host}}/logs
56
+ Authorization: Bearer {{appToken}}
57
+
58
+ ###
59
+ # Runtime: create an empty log
60
+ POST {{host}}/logs
61
+ Content-Type: application/json
62
+ Authorization: Bearer {{appToken}}
63
+
64
+ {
65
+ "name": "personal-ledger"
66
+ }
67
+
68
+ ###
69
+ # Runtime: fetch log metadata
70
+ GET {{host}}/logs/{{logId}}
71
+ Authorization: Bearer {{appToken}}
72
+
73
+ ###
74
+ # Runtime: create an entity with its first event
75
+ POST {{host}}/logs/{{logId}}/events
76
+ Content-Type: application/json
77
+ Authorization: Bearer {{appToken}}
78
+
79
+ {
80
+ "requestId": "33333333-3333-3333-3333-333333333333",
81
+ "events": [
82
+ {
83
+ "id": "44444444-4444-4444-4444-444444444444",
84
+ "type": "TRANSACTION_ADDED",
85
+ "entityId": "transaction-1",
86
+ "timestamp": 1717323612903,
87
+ "payload": {
88
+ "id": "55555555-5555-5555-5555-555555555555",
89
+ "name": "Rent",
90
+ "amount": 1200,
91
+ "transaction_status": "active",
92
+ "transaction_category": "housing"
93
+ }
94
+ }
95
+ ]
96
+ }
97
+
98
+ ###
99
+ # Runtime: update an existing entity currently at entityVersion 1
100
+ POST {{host}}/logs/{{logId}}/events
101
+ Content-Type: application/json
102
+ Authorization: Bearer {{appToken}}
103
+
104
+ {
105
+ "requestId": "66666666-6666-6666-6666-666666666666",
106
+ "events": [
107
+ {
108
+ "type": "TRANSACTION_UPDATED",
109
+ "entityId": "transaction-1",
110
+ "baseEntityVersion": 1,
111
+ "payload": {
112
+ "transaction_status": "completed"
113
+ }
114
+ }
115
+ ]
116
+ }
117
+
118
+ ###
119
+ # Runtime: append several events to one entity in a single atomic version chain
120
+ POST {{host}}/logs/{{logId}}/events
121
+ Content-Type: application/json
122
+ Authorization: Bearer {{appToken}}
123
+
124
+ {
125
+ "requestId": "88888888-8888-4888-8888-888888888888",
126
+ "events": [
127
+ {
128
+ "type": "TRANSACTION_UPDATED",
129
+ "entityId": "transaction-1",
130
+ "baseEntityVersion": 2,
131
+ "payload": { "amount": 1250 }
132
+ },
133
+ {
134
+ "type": "TRANSACTION_COMPLETED",
135
+ "entityId": "transaction-1",
136
+ "baseEntityVersion": 3,
137
+ "payload": { "completed": true }
138
+ }
139
+ ]
140
+ }
141
+
142
+ ###
143
+ # Runtime: append atomically to two existing entities
144
+ POST {{host}}/logs/{{logId}}/events
145
+ Content-Type: application/json
146
+ Authorization: Bearer {{appToken}}
147
+
148
+ {
149
+ "requestId": "77777777-7777-7777-7777-777777777777",
150
+ "events": [
151
+ {
152
+ "type": "TRANSACTION_UPDATED",
153
+ "entityId": "transaction-1",
154
+ "baseEntityVersion": 2,
155
+ "payload": { "amount": 1250 }
156
+ },
157
+ {
158
+ "type": "TRANSACTION_UPDATED",
159
+ "entityId": "transaction-2",
160
+ "baseEntityVersion": 1,
161
+ "payload": { "amount": 75 }
162
+ }
163
+ ]
164
+ }
165
+
166
+ ###
167
+ # Runtime: replay the log after a log version
168
+ GET {{host}}/logs/{{logId}}/events?afterVersion=0
169
+ Authorization: Bearer {{appToken}}
170
+
171
+ ###
172
+ # Runtime: upgrade to a ledger.v1 WebSocket after the local log head
173
+ GET {{host}}/logs/{{logId}}/events/socket?afterVersion=0
174
+ Authorization: Bearer {{appToken}}
175
+ Connection: Upgrade
176
+ Upgrade: websocket
177
+ Sec-WebSocket-Protocol: ledger.v1
178
+
179
+ ###
180
+ # Runtime: delete a log and all child records
181
+ DELETE {{host}}/logs/{{logId}}
182
+ Authorization: Bearer {{appToken}}