@c956180462/awbs 0.0.1
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/AWBS_CORE_DESIGN.md +983 -0
- package/AWBS_CURRENT_FEATURES.md +463 -0
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/TASK_001_VIEW_AUTHORITY.md +446 -0
- package/TASK_003_AUTHORITY_LEDGER_AND_DB_AUDIT.md +268 -0
- package/TASK_004_TRUSTED_AUTHORITY_LAYER.md +547 -0
- package/TASK_005_AUTHORITY_SESSION.md +218 -0
- package/TASK_006_TRUST_BOUNDARY_HARDENING.md +381 -0
- package/TASK_007_TRUSTED_OPERATION_ENTRY.md +129 -0
- package/bin/awbs.js +2 -0
- package/docs/DEVELOPMENT_LEARNING.md +319 -0
- package/docs/FULL_CHAIN.md +295 -0
- package/docs/PRODUCT.md +188 -0
- package/docs/USAGE.md +294 -0
- package/package.json +45 -0
- package/src/adapters/file-summary-store.ts +88 -0
- package/src/adapters/git-cli.ts +107 -0
- package/src/adapters/local-authority-session.ts +606 -0
- package/src/adapters/local-file-database.ts +199 -0
- package/src/adapters/sealed-authority.ts +725 -0
- package/src/adapters/session-authority-client.ts +176 -0
- package/src/adapters/sqlite-index-store.ts +176 -0
- package/src/cli.ts +491 -0
- package/src/domain/authority-types.ts +194 -0
- package/src/domain/constants.ts +11 -0
- package/src/domain/errors.ts +6 -0
- package/src/domain/hash.ts +27 -0
- package/src/domain/path-policy.ts +36 -0
- package/src/domain/paths.ts +65 -0
- package/src/domain/session-proof.ts +140 -0
- package/src/domain/session-types.ts +101 -0
- package/src/domain/types.ts +94 -0
- package/src/ports/authority-session.ts +8 -0
- package/src/ports/authority.ts +26 -0
- package/src/ports/file-database.ts +18 -0
- package/src/ports/git.ts +23 -0
- package/src/ports/index-store.ts +7 -0
- package/src/ports/summary-store.ts +16 -0
- package/src/runtime.ts +56 -0
- package/src/session-entry.ts +1 -0
- package/src/usecases/authority.ts +53 -0
- package/src/usecases/changeset.ts +437 -0
- package/src/usecases/db.ts +192 -0
- package/src/usecases/index.ts +136 -0
- package/src/usecases/init.ts +48 -0
- package/src/usecases/ledger.ts +146 -0
- package/src/usecases/session.ts +48 -0
- package/src/usecases/trusted-chain.ts +56 -0
- package/src/usecases/view.ts +166 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, scryptSync } from "node:crypto";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { AwbsError } from "../domain/errors.ts";
|
|
4
|
+
import type {
|
|
5
|
+
AuthorityCatalog,
|
|
6
|
+
AuthorityChangesetApplyOperation,
|
|
7
|
+
AuthorityChangesetReceipt,
|
|
8
|
+
AuthorityEvent,
|
|
9
|
+
AuthorityLedger,
|
|
10
|
+
AuthorityLedgerEntry,
|
|
11
|
+
AuthorityLocal,
|
|
12
|
+
AuthorityPayloadType,
|
|
13
|
+
AuthorityReceipt,
|
|
14
|
+
AuthorityRepairReport,
|
|
15
|
+
AuthorityRepo,
|
|
16
|
+
AuthorityResource,
|
|
17
|
+
AuthorityVerifyReport,
|
|
18
|
+
AuthorityViewContract,
|
|
19
|
+
AuthorityViewSource,
|
|
20
|
+
SealEnvelope
|
|
21
|
+
} from "../domain/authority-types.ts";
|
|
22
|
+
import type { FileDatabasePort } from "../ports/file-database.ts";
|
|
23
|
+
import type { AuthorityPort } from "../ports/authority.ts";
|
|
24
|
+
import type { ChangesetManifest } from "../domain/types.ts";
|
|
25
|
+
|
|
26
|
+
const RUNTIME_PEPPER = "awbs-authority-runtime-context-v1";
|
|
27
|
+
|
|
28
|
+
export class SealedAuthorityAdapter implements AuthorityPort {
|
|
29
|
+
private readonly files: FileDatabasePort;
|
|
30
|
+
private readonly memoryLocal: AuthorityLocal | null;
|
|
31
|
+
|
|
32
|
+
constructor(files: FileDatabasePort, options: { memoryLocal?: AuthorityLocal } = {}) {
|
|
33
|
+
this.files = files;
|
|
34
|
+
this.memoryLocal = options.memoryLocal ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ensureInitialized(root: string): void {
|
|
38
|
+
this.files.ensureDir(join(root, ".awbs", "authority", "views"));
|
|
39
|
+
this.files.ensureDir(join(root, ".awbs", "private"));
|
|
40
|
+
|
|
41
|
+
const repoPath = this.repoPath(root);
|
|
42
|
+
const localPath = this.localPath(root);
|
|
43
|
+
const eventsPath = this.eventsPath(root);
|
|
44
|
+
const ledgerEventsPath = this.ledgerEventsPath(root);
|
|
45
|
+
|
|
46
|
+
if (!this.files.pathExists(repoPath)) {
|
|
47
|
+
const repo: AuthorityRepo = {
|
|
48
|
+
schemaVersion: 1,
|
|
49
|
+
repoId: randomUUID(),
|
|
50
|
+
authoritySalt: randomBytes(24).toString("base64"),
|
|
51
|
+
algorithm: "AWBS-AES-256-GCM-v1",
|
|
52
|
+
kdf: "scrypt-repo-local-runtime-v1",
|
|
53
|
+
trustMode: "ephemeral-local-key-v1",
|
|
54
|
+
createdAt: new Date().toISOString()
|
|
55
|
+
};
|
|
56
|
+
this.files.writeJson(repoPath, repo);
|
|
57
|
+
} else {
|
|
58
|
+
this.assertRepoTrustMode(root);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!this.memoryLocal && !this.files.pathExists(localPath)) {
|
|
62
|
+
const local: AuthorityLocal = {
|
|
63
|
+
schemaVersion: 1,
|
|
64
|
+
installationId: randomUUID(),
|
|
65
|
+
localSealSeed: randomBytes(32).toString("base64"),
|
|
66
|
+
createdAt: new Date().toISOString()
|
|
67
|
+
};
|
|
68
|
+
this.files.writeJson(localPath, local);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!this.files.pathExists(eventsPath)) {
|
|
72
|
+
this.files.writeText(eventsPath, "");
|
|
73
|
+
}
|
|
74
|
+
if (!this.files.pathExists(ledgerEventsPath)) {
|
|
75
|
+
this.files.writeText(ledgerEventsPath, "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const catalogSealPath = this.catalogSealPath(root);
|
|
79
|
+
if (!this.files.pathExists(catalogSealPath)) {
|
|
80
|
+
const now = new Date().toISOString();
|
|
81
|
+
const repo = this.readRepo(root);
|
|
82
|
+
const catalog: AuthorityCatalog = {
|
|
83
|
+
schemaVersion: 1,
|
|
84
|
+
repoId: repo.repoId,
|
|
85
|
+
catalogVersion: 1,
|
|
86
|
+
createdAt: now,
|
|
87
|
+
updatedAt: now,
|
|
88
|
+
resources: [],
|
|
89
|
+
views: [],
|
|
90
|
+
ext: {}
|
|
91
|
+
};
|
|
92
|
+
this.writeCatalog(root, catalog);
|
|
93
|
+
this.appendEvent(root, {
|
|
94
|
+
schemaVersion: 1,
|
|
95
|
+
event: "AUTHORITY_INITIALIZED",
|
|
96
|
+
eventId: randomUUID(),
|
|
97
|
+
createdAt: now,
|
|
98
|
+
details: { repoId: repo.repoId }
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
this.readCatalog(root);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
createView(root: string, contract: AuthorityViewContract): AuthorityViewContract {
|
|
106
|
+
this.ensureInitialized(root);
|
|
107
|
+
const viewDir = this.viewDir(root, contract.viewId);
|
|
108
|
+
if (this.files.pathExists(join(viewDir, "contract.seal.json"))) {
|
|
109
|
+
throw new AwbsError(`View already exists: ${contract.viewId}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.files.ensureDir(viewDir);
|
|
113
|
+
this.writeViewContract(root, contract);
|
|
114
|
+
|
|
115
|
+
const catalog = this.readCatalog(root);
|
|
116
|
+
const nextCatalog: AuthorityCatalog = {
|
|
117
|
+
...catalog,
|
|
118
|
+
catalogVersion: catalog.catalogVersion + 1,
|
|
119
|
+
updatedAt: new Date().toISOString(),
|
|
120
|
+
resources: mergeResources(catalog.resources, contract.sources),
|
|
121
|
+
views: [
|
|
122
|
+
...catalog.views,
|
|
123
|
+
{
|
|
124
|
+
viewId: contract.viewId,
|
|
125
|
+
status: "active",
|
|
126
|
+
baseCommit: contract.baseCommit,
|
|
127
|
+
readPaths: contract.readPaths,
|
|
128
|
+
writePaths: contract.writePaths,
|
|
129
|
+
createdAt: contract.createdAt,
|
|
130
|
+
ext: {}
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
};
|
|
134
|
+
this.writeCatalog(root, nextCatalog);
|
|
135
|
+
this.appendEvent(root, {
|
|
136
|
+
schemaVersion: 1,
|
|
137
|
+
event: "VIEW_CREATED",
|
|
138
|
+
eventId: randomUUID(),
|
|
139
|
+
createdAt: new Date().toISOString(),
|
|
140
|
+
viewId: contract.viewId,
|
|
141
|
+
details: { readPaths: contract.readPaths, writePaths: contract.writePaths }
|
|
142
|
+
});
|
|
143
|
+
return contract;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getViewContract(root: string, viewId: string, options: { allowRevoked?: boolean } = {}): AuthorityViewContract {
|
|
147
|
+
this.ensureInitialized(root);
|
|
148
|
+
const catalog = this.readCatalog(root);
|
|
149
|
+
const catalogView = catalog.views.find((view) => view.viewId === viewId);
|
|
150
|
+
if (!catalogView) {
|
|
151
|
+
throw new AwbsError(`View is not registered in authority catalog: ${viewId}`);
|
|
152
|
+
}
|
|
153
|
+
if (catalogView.status === "revoked" && !options.allowRevoked) {
|
|
154
|
+
throw new AwbsError(`View has been revoked: ${viewId}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const contract = this.openViewContract(root, viewId);
|
|
158
|
+
if (contract.viewId !== viewId) {
|
|
159
|
+
throw new AwbsError(`View contract id mismatch for ${viewId}`);
|
|
160
|
+
}
|
|
161
|
+
return contract;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
revokeView(root: string, viewId: string): AuthorityViewContract {
|
|
165
|
+
const contract = this.getViewContract(root, viewId, { allowRevoked: true });
|
|
166
|
+
const catalog = this.readCatalog(root);
|
|
167
|
+
const existing = catalog.views.find((view) => view.viewId === viewId);
|
|
168
|
+
if (!existing) {
|
|
169
|
+
throw new AwbsError(`View is not registered in authority catalog: ${viewId}`);
|
|
170
|
+
}
|
|
171
|
+
if (existing.status === "revoked") {
|
|
172
|
+
return contract;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const now = new Date().toISOString();
|
|
176
|
+
const nextCatalog: AuthorityCatalog = {
|
|
177
|
+
...catalog,
|
|
178
|
+
catalogVersion: catalog.catalogVersion + 1,
|
|
179
|
+
updatedAt: now,
|
|
180
|
+
views: catalog.views.map((view) => (view.viewId === viewId ? { ...view, status: "revoked", revokedAt: now } : view))
|
|
181
|
+
};
|
|
182
|
+
this.writeCatalog(root, nextCatalog);
|
|
183
|
+
this.appendEvent(root, {
|
|
184
|
+
schemaVersion: 1,
|
|
185
|
+
event: "VIEW_REVOKED",
|
|
186
|
+
eventId: randomUUID(),
|
|
187
|
+
createdAt: now,
|
|
188
|
+
viewId
|
|
189
|
+
});
|
|
190
|
+
return contract;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
verify(root: string): AuthorityVerifyReport {
|
|
194
|
+
const errors: string[] = [];
|
|
195
|
+
const mirrorMismatches: string[] = [];
|
|
196
|
+
let catalog: AuthorityCatalog | null = null;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
this.ensureInitializedNoCatalogRead(root);
|
|
200
|
+
catalog = this.openSeal<AuthorityCatalog>(root, this.catalogSealPath(root), "authority.catalog");
|
|
201
|
+
if (this.readOptionalText(this.catalogMirrorPath(root)) !== mirrorText(catalog)) {
|
|
202
|
+
mirrorMismatches.push("catalog.mirror.json");
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (catalog) {
|
|
209
|
+
for (const view of catalog.views) {
|
|
210
|
+
try {
|
|
211
|
+
const contract = this.openSeal<AuthorityViewContract>(root, this.viewSealPath(root, view.viewId), "authority.viewContract");
|
|
212
|
+
if (this.readOptionalText(this.viewMirrorPath(root, view.viewId)) !== mirrorText(contract)) {
|
|
213
|
+
mirrorMismatches.push(`views/${view.viewId}/mirror.json`);
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (this.hasLedger(root)) {
|
|
222
|
+
try {
|
|
223
|
+
const ledger = this.openSeal<AuthorityLedger>(root, this.ledgerSealPath(root), "authority.ledger");
|
|
224
|
+
if (this.readOptionalText(this.ledgerMirrorPath(root)) !== mirrorText(ledger)) {
|
|
225
|
+
mirrorMismatches.push("ledger.mirror.json");
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
ok: errors.length === 0 && mirrorMismatches.length === 0,
|
|
234
|
+
mirrorMismatches,
|
|
235
|
+
errors,
|
|
236
|
+
catalog: {
|
|
237
|
+
views: catalog?.views.length ?? 0,
|
|
238
|
+
resources: catalog?.resources.length ?? 0
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
repairMirrors(root: string): AuthorityRepairReport {
|
|
244
|
+
this.ensureInitialized(root);
|
|
245
|
+
const repairedMirrors: string[] = [];
|
|
246
|
+
|
|
247
|
+
const catalogBefore = this.readOptionalText(this.catalogMirrorPath(root));
|
|
248
|
+
const catalog = this.openSeal<AuthorityCatalog>(root, this.catalogSealPath(root), "authority.catalog");
|
|
249
|
+
this.writeMirror(this.catalogMirrorPath(root), catalog);
|
|
250
|
+
if (catalogBefore !== this.readOptionalText(this.catalogMirrorPath(root))) {
|
|
251
|
+
repairedMirrors.push("catalog.mirror.json");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const view of catalog.views) {
|
|
255
|
+
const mirrorPath = this.viewMirrorPath(root, view.viewId);
|
|
256
|
+
const before = this.readOptionalText(mirrorPath);
|
|
257
|
+
const contract = this.openSeal<AuthorityViewContract>(root, this.viewSealPath(root, view.viewId), "authority.viewContract");
|
|
258
|
+
this.writeMirror(mirrorPath, contract);
|
|
259
|
+
if (before !== this.readOptionalText(mirrorPath)) {
|
|
260
|
+
repairedMirrors.push(`views/${view.viewId}/mirror.json`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (this.hasLedger(root)) {
|
|
265
|
+
const ledgerBefore = this.readOptionalText(this.ledgerMirrorPath(root));
|
|
266
|
+
const ledger = this.openSeal<AuthorityLedger>(root, this.ledgerSealPath(root), "authority.ledger");
|
|
267
|
+
this.writeMirror(this.ledgerMirrorPath(root), ledger);
|
|
268
|
+
if (ledgerBefore !== this.readOptionalText(this.ledgerMirrorPath(root))) {
|
|
269
|
+
repairedMirrors.push("ledger.mirror.json");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (repairedMirrors.length > 0) {
|
|
274
|
+
this.appendEvent(root, {
|
|
275
|
+
schemaVersion: 1,
|
|
276
|
+
event: "MIRROR_REBUILT",
|
|
277
|
+
eventId: randomUUID(),
|
|
278
|
+
createdAt: new Date().toISOString(),
|
|
279
|
+
details: { repairedMirrors }
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { repairedMirrors };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
readCatalog(root: string): AuthorityCatalog {
|
|
287
|
+
this.ensureInitializedNoCatalogRead(root);
|
|
288
|
+
return this.openSeal<AuthorityCatalog>(root, this.catalogSealPath(root), "authority.catalog");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
hasLedger(root: string): boolean {
|
|
292
|
+
return this.files.pathExists(this.ledgerSealPath(root));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
bootstrapLedger(root: string, parentTrustedCommit: string): AuthorityLedger {
|
|
296
|
+
this.ensureInitialized(root);
|
|
297
|
+
if (this.hasLedger(root)) {
|
|
298
|
+
throw new AwbsError("Trusted ledger is already bootstrapped.");
|
|
299
|
+
}
|
|
300
|
+
const now = new Date().toISOString();
|
|
301
|
+
const repo = this.readRepo(root);
|
|
302
|
+
const entryBase: Omit<AuthorityLedgerEntry, "entryHash"> = {
|
|
303
|
+
schemaVersion: 1,
|
|
304
|
+
entryId: randomUUID(),
|
|
305
|
+
kind: "bootstrap",
|
|
306
|
+
previousEntryHash: null,
|
|
307
|
+
parentTrustedCommit,
|
|
308
|
+
baseCommit: parentTrustedCommit,
|
|
309
|
+
changesetId: null,
|
|
310
|
+
viewId: null,
|
|
311
|
+
createdAt: now,
|
|
312
|
+
appliedPaths: [],
|
|
313
|
+
changesetManifestHash: null,
|
|
314
|
+
changesetPayloadHash: null,
|
|
315
|
+
authorityContractHash: null,
|
|
316
|
+
operationHash: contentHash({
|
|
317
|
+
kind: "bootstrap",
|
|
318
|
+
parentTrustedCommit,
|
|
319
|
+
repoId: repo.repoId,
|
|
320
|
+
createdAt: now
|
|
321
|
+
}),
|
|
322
|
+
ext: {}
|
|
323
|
+
};
|
|
324
|
+
const entry: AuthorityLedgerEntry = {
|
|
325
|
+
...entryBase,
|
|
326
|
+
entryHash: ledgerEntryHash(entryBase)
|
|
327
|
+
};
|
|
328
|
+
const ledger: AuthorityLedger = {
|
|
329
|
+
schemaVersion: 1,
|
|
330
|
+
repoId: repo.repoId,
|
|
331
|
+
ledgerVersion: 1,
|
|
332
|
+
createdAt: now,
|
|
333
|
+
updatedAt: now,
|
|
334
|
+
headEntryId: entry.entryId,
|
|
335
|
+
entries: [entry],
|
|
336
|
+
ext: {}
|
|
337
|
+
};
|
|
338
|
+
this.writeLedger(root, ledger);
|
|
339
|
+
this.appendLedgerEvent(root, {
|
|
340
|
+
schemaVersion: 1,
|
|
341
|
+
event: "LEDGER_BOOTSTRAPPED",
|
|
342
|
+
eventId: randomUUID(),
|
|
343
|
+
createdAt: now,
|
|
344
|
+
details: { parentTrustedCommit, entryId: entry.entryId }
|
|
345
|
+
});
|
|
346
|
+
return ledger;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
readLedger(root: string): AuthorityLedger {
|
|
350
|
+
this.ensureInitializedNoCatalogRead(root);
|
|
351
|
+
return this.openSeal<AuthorityLedger>(root, this.ledgerSealPath(root), "authority.ledger");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
recordChangesetApply(root: string, operation: AuthorityChangesetApplyOperation): AuthorityLedgerEntry {
|
|
355
|
+
const ledger = this.readLedger(root);
|
|
356
|
+
if (operation.schemaVersion !== 1) {
|
|
357
|
+
throw new AwbsError("Invalid changeset apply operation schema.");
|
|
358
|
+
}
|
|
359
|
+
const entryId = randomUUID();
|
|
360
|
+
const createdAt = operation.createdAt ?? new Date().toISOString();
|
|
361
|
+
const headEntry = ledger.entries.find((existing) => existing.entryId === ledger.headEntryId);
|
|
362
|
+
if (!headEntry) {
|
|
363
|
+
throw new AwbsError("Trusted ledger head entry is missing.");
|
|
364
|
+
}
|
|
365
|
+
const entryBase: Omit<AuthorityLedgerEntry, "entryHash"> = {
|
|
366
|
+
schemaVersion: 1,
|
|
367
|
+
entryId,
|
|
368
|
+
kind: "changeset",
|
|
369
|
+
previousEntryHash: headEntry.entryHash,
|
|
370
|
+
parentTrustedCommit: operation.parentTrustedCommit,
|
|
371
|
+
baseCommit: operation.baseCommit,
|
|
372
|
+
changesetId: operation.changesetId,
|
|
373
|
+
viewId: operation.viewId,
|
|
374
|
+
createdAt,
|
|
375
|
+
appliedPaths: operation.appliedPaths,
|
|
376
|
+
changesetManifestHash: operation.changesetManifestHash,
|
|
377
|
+
changesetPayloadHash: operation.changesetPayloadHash,
|
|
378
|
+
authorityContractHash: operation.authorityContractHash,
|
|
379
|
+
operationHash: contentHash({
|
|
380
|
+
kind: "changeset",
|
|
381
|
+
parentTrustedCommit: operation.parentTrustedCommit,
|
|
382
|
+
baseCommit: operation.baseCommit,
|
|
383
|
+
changesetId: operation.changesetId,
|
|
384
|
+
viewId: operation.viewId,
|
|
385
|
+
appliedPaths: operation.appliedPaths,
|
|
386
|
+
changesetManifestHash: operation.changesetManifestHash,
|
|
387
|
+
changesetPayloadHash: operation.changesetPayloadHash,
|
|
388
|
+
authorityContractHash: operation.authorityContractHash
|
|
389
|
+
}),
|
|
390
|
+
ext: operation.ext
|
|
391
|
+
};
|
|
392
|
+
const entry: AuthorityLedgerEntry = {
|
|
393
|
+
...entryBase,
|
|
394
|
+
entryHash: ledgerEntryHash(entryBase)
|
|
395
|
+
};
|
|
396
|
+
const now = new Date().toISOString();
|
|
397
|
+
const nextLedger: AuthorityLedger = {
|
|
398
|
+
...ledger,
|
|
399
|
+
ledgerVersion: ledger.ledgerVersion + 1,
|
|
400
|
+
updatedAt: now,
|
|
401
|
+
headEntryId: entry.entryId,
|
|
402
|
+
entries: [...ledger.entries, entry]
|
|
403
|
+
};
|
|
404
|
+
this.writeLedger(root, nextLedger);
|
|
405
|
+
this.appendLedgerEvent(root, {
|
|
406
|
+
schemaVersion: 1,
|
|
407
|
+
event: "LEDGER_ENTRY_APPENDED",
|
|
408
|
+
eventId: randomUUID(),
|
|
409
|
+
createdAt: now,
|
|
410
|
+
viewId: entry.viewId ?? undefined,
|
|
411
|
+
details: {
|
|
412
|
+
entryId: entry.entryId,
|
|
413
|
+
changesetId: entry.changesetId,
|
|
414
|
+
parentTrustedCommit: entry.parentTrustedCommit
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
return entry;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
sealChangesetReceipt(root: string, changesetRoot: string, receipt: AuthorityChangesetReceipt): AuthorityChangesetReceipt {
|
|
421
|
+
this.ensureInitializedNoCatalogRead(root);
|
|
422
|
+
this.assertChangesetReceiptScope(root, changesetRoot, receipt);
|
|
423
|
+
const receiptPath = this.changesetReceiptPath(changesetRoot);
|
|
424
|
+
this.writeSeal(root, receiptPath, "authority.changesetReceipt", receipt, {
|
|
425
|
+
changesetId: receipt.changesetId,
|
|
426
|
+
viewId: receipt.viewId,
|
|
427
|
+
baseCommit: receipt.baseCommit
|
|
428
|
+
});
|
|
429
|
+
return receipt;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
openChangesetReceipt(root: string, changesetRoot: string): AuthorityChangesetReceipt {
|
|
433
|
+
this.ensureInitializedNoCatalogRead(root);
|
|
434
|
+
const manifest = this.files.readJson<ChangesetManifest>(join(changesetRoot, "manifest.json"));
|
|
435
|
+
const receipt = this.openSeal<AuthorityChangesetReceipt>(root, this.changesetReceiptPath(changesetRoot), "authority.changesetReceipt");
|
|
436
|
+
this.assertChangesetReceiptScope(root, changesetRoot, receipt, manifest);
|
|
437
|
+
return receipt;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private ensureInitializedNoCatalogRead(root: string): void {
|
|
441
|
+
this.files.ensureDir(join(root, ".awbs", "authority", "views"));
|
|
442
|
+
this.files.ensureDir(join(root, ".awbs", "private"));
|
|
443
|
+
if (!this.files.pathExists(this.repoPath(root))) {
|
|
444
|
+
throw new AwbsError("Authority repo is not initialized. Run `awbs init` first.");
|
|
445
|
+
}
|
|
446
|
+
if (!this.hasLocalMaterial(root)) {
|
|
447
|
+
throw new AwbsError("Authority local material is missing. Run `awbs init` or restore .awbs/private/local.json.");
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private writeCatalog(root: string, catalog: AuthorityCatalog): void {
|
|
452
|
+
this.writeSeal(root, this.catalogSealPath(root), "authority.catalog", catalog, {
|
|
453
|
+
repoId: catalog.repoId,
|
|
454
|
+
schemaVersion: catalog.schemaVersion,
|
|
455
|
+
catalogVersion: catalog.catalogVersion
|
|
456
|
+
});
|
|
457
|
+
this.writeMirror(this.catalogMirrorPath(root), catalog);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private writeLedger(root: string, ledger: AuthorityLedger): void {
|
|
461
|
+
this.writeSeal(root, this.ledgerSealPath(root), "authority.ledger", ledger, {
|
|
462
|
+
repoId: ledger.repoId,
|
|
463
|
+
schemaVersion: ledger.schemaVersion,
|
|
464
|
+
ledgerVersion: ledger.ledgerVersion,
|
|
465
|
+
headEntryId: ledger.headEntryId
|
|
466
|
+
});
|
|
467
|
+
this.writeMirror(this.ledgerMirrorPath(root), ledger);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private writeViewContract(root: string, contract: AuthorityViewContract): void {
|
|
471
|
+
this.writeSeal(root, this.viewSealPath(root, contract.viewId), "authority.viewContract", contract, {
|
|
472
|
+
viewId: contract.viewId,
|
|
473
|
+
schemaVersion: contract.schemaVersion,
|
|
474
|
+
baseCommit: contract.baseCommit
|
|
475
|
+
});
|
|
476
|
+
this.writeMirror(this.viewMirrorPath(root, contract.viewId), contract);
|
|
477
|
+
this.files.writeJson(this.viewReceiptPath(root, contract.viewId), {
|
|
478
|
+
schemaVersion: 1,
|
|
479
|
+
viewId: contract.viewId,
|
|
480
|
+
payloadType: "authority.viewContract",
|
|
481
|
+
algorithm: "AWBS-AES-256-GCM-v1",
|
|
482
|
+
contentHash: contentHash(contract),
|
|
483
|
+
createdAt: contract.createdAt,
|
|
484
|
+
ext: {}
|
|
485
|
+
} satisfies AuthorityReceipt);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private openViewContract(root: string, viewId: string): AuthorityViewContract {
|
|
489
|
+
return this.openSeal<AuthorityViewContract>(root, this.viewSealPath(root, viewId), "authority.viewContract");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private writeSeal(root: string, path: string, payloadType: "authority.catalog", payload: AuthorityCatalog, aad: Record<string, unknown>): void;
|
|
493
|
+
private writeSeal(root: string, path: string, payloadType: "authority.viewContract", payload: AuthorityViewContract, aad: Record<string, unknown>): void;
|
|
494
|
+
private writeSeal(root: string, path: string, payloadType: "authority.ledger", payload: AuthorityLedger, aad: Record<string, unknown>): void;
|
|
495
|
+
private writeSeal(root: string, path: string, payloadType: "authority.changesetReceipt", payload: AuthorityChangesetReceipt, aad: Record<string, unknown>): void;
|
|
496
|
+
private writeSeal(
|
|
497
|
+
root: string,
|
|
498
|
+
path: string,
|
|
499
|
+
payloadType: AuthorityPayloadType,
|
|
500
|
+
payload: AuthorityCatalog | AuthorityViewContract | AuthorityLedger | AuthorityChangesetReceipt,
|
|
501
|
+
aad: Record<string, unknown>
|
|
502
|
+
): void {
|
|
503
|
+
const key = this.deriveKey(root);
|
|
504
|
+
const nonce = randomBytes(12);
|
|
505
|
+
const plaintext = Buffer.from(canonicalJson(payload), "utf8");
|
|
506
|
+
const aadText = canonicalJson({ payloadType, ...aad });
|
|
507
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
508
|
+
cipher.setAAD(Buffer.from(aadText, "utf8"));
|
|
509
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
510
|
+
const envelope: SealEnvelope = {
|
|
511
|
+
schemaVersion: 1,
|
|
512
|
+
sealType: "awbs.seal.v1",
|
|
513
|
+
payloadType,
|
|
514
|
+
aad: { payloadType, ...aad },
|
|
515
|
+
nonce: nonce.toString("base64"),
|
|
516
|
+
ciphertext: ciphertext.toString("base64"),
|
|
517
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
518
|
+
contentHash: sha256String(plaintext.toString("utf8"))
|
|
519
|
+
};
|
|
520
|
+
this.files.writeJson(path, envelope);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private openSeal<T>(root: string, path: string, expectedPayloadType: AuthorityPayloadType): T {
|
|
524
|
+
if (!this.files.pathExists(path)) {
|
|
525
|
+
throw new AwbsError(`Sealed authority payload not found: ${path}`);
|
|
526
|
+
}
|
|
527
|
+
const envelope = this.files.readJson<SealEnvelope>(path);
|
|
528
|
+
if (envelope.sealType !== "awbs.seal.v1" || envelope.payloadType !== expectedPayloadType) {
|
|
529
|
+
throw new AwbsError(`Invalid authority seal envelope: ${path}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
const key = this.deriveKey(root);
|
|
534
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.nonce, "base64"));
|
|
535
|
+
decipher.setAAD(Buffer.from(canonicalJson(envelope.aad), "utf8"));
|
|
536
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
|
|
537
|
+
const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]).toString("utf8");
|
|
538
|
+
if (sha256String(plaintext) !== envelope.contentHash) {
|
|
539
|
+
throw new AwbsError(`Authority content hash mismatch: ${path}`);
|
|
540
|
+
}
|
|
541
|
+
return JSON.parse(plaintext) as T;
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (error instanceof AwbsError) {
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
throw new AwbsError(`Failed to open sealed authority payload: ${path}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private deriveKey(root: string): Buffer {
|
|
551
|
+
const repo = this.readRepo(root);
|
|
552
|
+
const local = this.readLocal(root);
|
|
553
|
+
const password = `${local.localSealSeed}:${local.installationId}:${repo.repoId}:${RUNTIME_PEPPER}`;
|
|
554
|
+
return scryptSync(password, Buffer.from(repo.authoritySalt, "base64"), 32, { N: 16384, r: 8, p: 1 });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private readRepo(root: string): AuthorityRepo {
|
|
558
|
+
return this.files.readJson<AuthorityRepo>(this.repoPath(root));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private assertRepoTrustMode(root: string): void {
|
|
562
|
+
const repo = this.readRepo(root);
|
|
563
|
+
if (repo.trustMode !== "ephemeral-local-key-v1") {
|
|
564
|
+
throw new AwbsError("Authority repo trustMode is not ephemeral-local-key-v1. Reinitialize this development database with the current AWBS version.");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private readLocal(root: string): AuthorityLocal {
|
|
569
|
+
if (this.memoryLocal) {
|
|
570
|
+
return this.memoryLocal;
|
|
571
|
+
}
|
|
572
|
+
return this.files.readJson<AuthorityLocal>(this.localPath(root));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private hasLocalMaterial(root: string): boolean {
|
|
576
|
+
return Boolean(this.memoryLocal) || this.files.pathExists(this.localPath(root));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private appendEvent(root: string, event: AuthorityEvent): void {
|
|
580
|
+
const path = this.eventsPath(root);
|
|
581
|
+
const existing = this.files.pathExists(path) ? this.files.readText(path) : "";
|
|
582
|
+
this.files.writeText(path, `${existing}${JSON.stringify(event)}\n`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private appendLedgerEvent(root: string, event: AuthorityEvent): void {
|
|
586
|
+
const path = this.ledgerEventsPath(root);
|
|
587
|
+
const existing = this.files.pathExists(path) ? this.files.readText(path) : "";
|
|
588
|
+
this.files.writeText(path, `${existing}${JSON.stringify(event)}\n`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private writeMirror(path: string, value: unknown): void {
|
|
592
|
+
this.files.writeJson(path, value);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private readOptionalText(path: string): string | null {
|
|
596
|
+
return this.files.pathExists(path) ? this.files.readText(path) : null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private repoPath(root: string): string {
|
|
600
|
+
return join(root, ".awbs", "authority", "repo.json");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private localPath(root: string): string {
|
|
604
|
+
return join(root, ".awbs", "private", "local.json");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private eventsPath(root: string): string {
|
|
608
|
+
return join(root, ".awbs", "authority", "view-events.jsonl");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private ledgerEventsPath(root: string): string {
|
|
612
|
+
return join(root, ".awbs", "authority", "ledger-events.jsonl");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private catalogSealPath(root: string): string {
|
|
616
|
+
return join(root, ".awbs", "authority", "catalog.seal.json");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private catalogMirrorPath(root: string): string {
|
|
620
|
+
return join(root, ".awbs", "authority", "catalog.mirror.json");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private ledgerSealPath(root: string): string {
|
|
624
|
+
return join(root, ".awbs", "authority", "ledger.seal.json");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private ledgerMirrorPath(root: string): string {
|
|
628
|
+
return join(root, ".awbs", "authority", "ledger.mirror.json");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private viewDir(root: string, viewId: string): string {
|
|
632
|
+
return join(root, ".awbs", "authority", "views", viewId);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private viewSealPath(root: string, viewId: string): string {
|
|
636
|
+
return join(this.viewDir(root, viewId), "contract.seal.json");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private viewMirrorPath(root: string, viewId: string): string {
|
|
640
|
+
return join(this.viewDir(root, viewId), "mirror.json");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private viewReceiptPath(root: string, viewId: string): string {
|
|
644
|
+
return join(this.viewDir(root, viewId), "receipt.json");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private changesetReceiptPath(changesetRoot: string): string {
|
|
648
|
+
return join(changesetRoot, "receipt.seal.json");
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private assertChangesetReceiptScope(root: string, changesetRoot: string, receipt: AuthorityChangesetReceipt, manifest?: ChangesetManifest): void {
|
|
652
|
+
const expectedRoot = resolve(root, ".awbs", "changesets", receipt.changesetId);
|
|
653
|
+
if (resolve(changesetRoot) !== expectedRoot) {
|
|
654
|
+
throw new AwbsError(`Changeset receipt path does not match changeset id: ${receipt.changesetId}`);
|
|
655
|
+
}
|
|
656
|
+
const actualManifest = manifest ?? this.files.readJson<ChangesetManifest>(join(changesetRoot, "manifest.json"));
|
|
657
|
+
if (
|
|
658
|
+
actualManifest.changesetId !== receipt.changesetId ||
|
|
659
|
+
actualManifest.viewId !== receipt.viewId ||
|
|
660
|
+
actualManifest.baseCommit !== receipt.baseCommit ||
|
|
661
|
+
actualManifest.payloadHash !== receipt.payloadHash ||
|
|
662
|
+
actualManifest.operationHash !== receipt.operationHash ||
|
|
663
|
+
contentHash(actualManifest) !== receipt.manifestHash
|
|
664
|
+
) {
|
|
665
|
+
throw new AwbsError(`Changeset receipt does not match manifest: ${receipt.changesetId}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function mergeResources(existing: AuthorityResource[], sources: AuthorityViewSource[]): AuthorityResource[] {
|
|
671
|
+
const byPath = new Map(existing.map((resource) => [resource.path, resource]));
|
|
672
|
+
for (const source of sources) {
|
|
673
|
+
if (!byPath.has(source.path)) {
|
|
674
|
+
byPath.set(source.path, {
|
|
675
|
+
resourceId: `res_${sha256String(source.path).slice("sha256:".length, "sha256:".length + 12)}`,
|
|
676
|
+
path: source.path,
|
|
677
|
+
kind: source.kind,
|
|
678
|
+
parent: parentPath(source.path),
|
|
679
|
+
defaultMode: "read",
|
|
680
|
+
ext: {}
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function parentPath(path: string): string | null {
|
|
688
|
+
const index = path.lastIndexOf("/");
|
|
689
|
+
return index <= 0 ? null : path.slice(0, index);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function contentHash(value: unknown): string {
|
|
693
|
+
return sha256String(canonicalJson(value));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function ledgerEntryHash(entry: AuthorityLedgerEntry | Omit<AuthorityLedgerEntry, "entryHash">): string {
|
|
697
|
+
const { entryHash: _entryHash, ...hashable } = entry as AuthorityLedgerEntry;
|
|
698
|
+
return contentHash(hashable);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function mirrorText(value: unknown): string {
|
|
702
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function sha256String(value: string): string {
|
|
706
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function canonicalJson(value: unknown): string {
|
|
710
|
+
return JSON.stringify(sortForCanonicalJson(value));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function sortForCanonicalJson(value: unknown): unknown {
|
|
714
|
+
if (Array.isArray(value)) {
|
|
715
|
+
return value.map(sortForCanonicalJson);
|
|
716
|
+
}
|
|
717
|
+
if (value && typeof value === "object") {
|
|
718
|
+
const result: Record<string, unknown> = {};
|
|
719
|
+
for (const key of Object.keys(value).sort()) {
|
|
720
|
+
result[key] = sortForCanonicalJson((value as Record<string, unknown>)[key]);
|
|
721
|
+
}
|
|
722
|
+
return result;
|
|
723
|
+
}
|
|
724
|
+
return value;
|
|
725
|
+
}
|