@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.
Files changed (50) hide show
  1. package/AWBS_CORE_DESIGN.md +983 -0
  2. package/AWBS_CURRENT_FEATURES.md +463 -0
  3. package/LICENSE +21 -0
  4. package/README.md +265 -0
  5. package/TASK_001_VIEW_AUTHORITY.md +446 -0
  6. package/TASK_003_AUTHORITY_LEDGER_AND_DB_AUDIT.md +268 -0
  7. package/TASK_004_TRUSTED_AUTHORITY_LAYER.md +547 -0
  8. package/TASK_005_AUTHORITY_SESSION.md +218 -0
  9. package/TASK_006_TRUST_BOUNDARY_HARDENING.md +381 -0
  10. package/TASK_007_TRUSTED_OPERATION_ENTRY.md +129 -0
  11. package/bin/awbs.js +2 -0
  12. package/docs/DEVELOPMENT_LEARNING.md +319 -0
  13. package/docs/FULL_CHAIN.md +295 -0
  14. package/docs/PRODUCT.md +188 -0
  15. package/docs/USAGE.md +294 -0
  16. package/package.json +45 -0
  17. package/src/adapters/file-summary-store.ts +88 -0
  18. package/src/adapters/git-cli.ts +107 -0
  19. package/src/adapters/local-authority-session.ts +606 -0
  20. package/src/adapters/local-file-database.ts +199 -0
  21. package/src/adapters/sealed-authority.ts +725 -0
  22. package/src/adapters/session-authority-client.ts +176 -0
  23. package/src/adapters/sqlite-index-store.ts +176 -0
  24. package/src/cli.ts +491 -0
  25. package/src/domain/authority-types.ts +194 -0
  26. package/src/domain/constants.ts +11 -0
  27. package/src/domain/errors.ts +6 -0
  28. package/src/domain/hash.ts +27 -0
  29. package/src/domain/path-policy.ts +36 -0
  30. package/src/domain/paths.ts +65 -0
  31. package/src/domain/session-proof.ts +140 -0
  32. package/src/domain/session-types.ts +101 -0
  33. package/src/domain/types.ts +94 -0
  34. package/src/ports/authority-session.ts +8 -0
  35. package/src/ports/authority.ts +26 -0
  36. package/src/ports/file-database.ts +18 -0
  37. package/src/ports/git.ts +23 -0
  38. package/src/ports/index-store.ts +7 -0
  39. package/src/ports/summary-store.ts +16 -0
  40. package/src/runtime.ts +56 -0
  41. package/src/session-entry.ts +1 -0
  42. package/src/usecases/authority.ts +53 -0
  43. package/src/usecases/changeset.ts +437 -0
  44. package/src/usecases/db.ts +192 -0
  45. package/src/usecases/index.ts +136 -0
  46. package/src/usecases/init.ts +48 -0
  47. package/src/usecases/ledger.ts +146 -0
  48. package/src/usecases/session.ts +48 -0
  49. package/src/usecases/trusted-chain.ts +56 -0
  50. package/src/usecases/view.ts +166 -0
@@ -0,0 +1,437 @@
1
+ import { join, relative, resolve } from "node:path";
2
+ import { VIEW_MANIFEST, TRUSTED_REF } from "../domain/constants.ts";
3
+ import { AwbsError } from "../domain/errors.ts";
4
+ import { contentHash } from "../domain/hash.ts";
5
+ import { assertUserDataPath, isPathAllowedByWritePaths } from "../domain/path-policy.ts";
6
+ import { assertSafeRelativePath, filterIgnoredStatus, fromPosixPath, isPathUnderAny, makeId, toPosixPath } from "../domain/paths.ts";
7
+ import type { ChangeKind, ChangeRecord, ChangesetManifest, ViewManifest } from "../domain/types.ts";
8
+ import type { AuthorityCatalog, AuthorityChangesetReceipt } from "../domain/authority-types.ts";
9
+ import type { AuthorityPort } from "../ports/authority.ts";
10
+ import type { FileDatabasePort } from "../ports/file-database.ts";
11
+ import type { GitPort } from "../ports/git.ts";
12
+ import { requireTrustedCommit, withTrustedWorktree } from "./trusted-chain.ts";
13
+
14
+ export type ChangesetUseCases = {
15
+ collectChangeset(cwd: string, workspaceInput: string): ChangesetManifest;
16
+ inspectChangeset(cwd: string, changesetInput: string): ChangesetManifest;
17
+ applyChangeset(cwd: string, changesetInput: string, adapter: string): { commit: string | null; applied: number };
18
+ formatChangesetSummary(manifest: ChangesetManifest): string;
19
+ };
20
+
21
+ export function createChangesetUseCases(deps: { files: FileDatabasePort; git: GitPort; authority: AuthorityPort }): ChangesetUseCases {
22
+ return {
23
+ collectChangeset(cwd: string, workspaceInput: string): ChangesetManifest {
24
+ const workspacePath = resolve(cwd, workspaceInput);
25
+ const root = deps.files.findProjectRoot(cwd);
26
+ const viewManifestPath = join(workspacePath, VIEW_MANIFEST);
27
+ if (!deps.files.pathExists(viewManifestPath)) {
28
+ throw new AwbsError(`Workspace manifest not found: ${viewManifestPath}`);
29
+ }
30
+
31
+ const view = deps.files.readJson<ViewManifest>(viewManifestPath);
32
+ const contract = deps.authority.getViewContract(root, view.viewId);
33
+ assertWorkspaceMatchesContract(workspacePath, contract.ext.workspacePath);
34
+ const baselineRoot = join(root, ".awbs", "views", view.viewId, "baseline");
35
+ if (!deps.files.pathExists(baselineRoot)) {
36
+ throw new AwbsError(`Baseline not found for view ${view.viewId}`);
37
+ }
38
+
39
+ const baseline = deps.files.snapshotFiles(baselineRoot, { ignoreAwbsViewManifest: false });
40
+ const current = deps.files.snapshotFiles(workspacePath, { ignoreAwbsViewManifest: true });
41
+ const changesetId = makeId("changeset");
42
+ const changesetRoot = join(root, ".awbs", "changesets", changesetId);
43
+ const filesRoot = join(changesetRoot, "files");
44
+ deps.files.ensureDir(filesRoot);
45
+
46
+ const changes: ChangeRecord[] = [];
47
+ const allPaths = new Set<string>([...baseline.keys(), ...current.keys()]);
48
+ const sortedPaths = [...allPaths].sort((a, b) => a.localeCompare(b));
49
+
50
+ for (const relPath of sortedPaths) {
51
+ const before = baseline.get(relPath);
52
+ const after = current.get(relPath);
53
+ const kind = classifyChange(Boolean(before), before?.sha256 ?? null, Boolean(after), after?.sha256 ?? null);
54
+ if (!kind) {
55
+ continue;
56
+ }
57
+
58
+ const policyError = dataPathPolicyError(relPath);
59
+ const allowed = !policyError && isPathUnderAny(relPath, contract.writePaths);
60
+ const record: ChangeRecord = {
61
+ path: relPath,
62
+ kind,
63
+ allowed,
64
+ reason: allowed ? undefined : policyError ?? "Path is not within writePaths."
65
+ };
66
+
67
+ if (after && (kind === "add" || kind === "modify")) {
68
+ record.sha256 = after.sha256;
69
+ if (allowed) {
70
+ const source = join(workspacePath, fromPosixPath(relPath));
71
+ const destination = join(filesRoot, fromPosixPath(relPath));
72
+ deps.files.copyPath(source, destination);
73
+ record.file = toPosixPath(relative(changesetRoot, destination));
74
+ }
75
+ }
76
+
77
+ changes.push(record);
78
+ }
79
+
80
+ const violations = changes.filter((change) => !change.allowed);
81
+ const payloadHash = computeChangesetPayloadHash(deps.files, changesetRoot, changes);
82
+ const manifestBase = {
83
+ schemaVersion: 1,
84
+ changesetId,
85
+ viewId: view.viewId,
86
+ baseCommit: contract.baseCommit,
87
+ createdAt: new Date().toISOString(),
88
+ projectRoot: root,
89
+ workspacePath,
90
+ status: violations.length === 0 ? "valid" : "invalid",
91
+ readPaths: contract.readPaths,
92
+ writePaths: contract.writePaths,
93
+ changes,
94
+ violations,
95
+ summary: {
96
+ added: changes.filter((change) => change.kind === "add").length,
97
+ modified: changes.filter((change) => change.kind === "modify").length,
98
+ deleted: changes.filter((change) => change.kind === "delete").length,
99
+ violations: violations.length
100
+ },
101
+ payloadHash
102
+ } satisfies Omit<ChangesetManifest, "operationHash">;
103
+ const manifest: ChangesetManifest = {
104
+ ...manifestBase,
105
+ operationHash: computeChangesetOperationHash(manifestBase)
106
+ };
107
+
108
+ deps.files.writeJson(join(changesetRoot, "manifest.json"), manifest);
109
+ deps.files.writeText(join(changesetRoot, "diff.patch"), deps.git.diffNoIndex(baselineRoot, workspacePath));
110
+ deps.authority.sealChangesetReceipt(root, changesetRoot, createChangesetReceipt(manifest));
111
+ return manifest;
112
+ },
113
+
114
+ inspectChangeset(cwd: string, changesetInput: string): ChangesetManifest {
115
+ const changesetRoot = resolveChangesetPath(deps.files, cwd, changesetInput);
116
+ return deps.files.readJson<ChangesetManifest>(join(changesetRoot, "manifest.json"));
117
+ },
118
+
119
+ applyChangeset(cwd: string, changesetInput: string, adapter: string): { commit: string | null; applied: number } {
120
+ if (adapter !== "same-path") {
121
+ throw new AwbsError(`Unsupported adapter: ${adapter}. v0 only supports same-path.`);
122
+ }
123
+
124
+ const changesetRoot = resolveChangesetPath(deps.files, cwd, changesetInput);
125
+ const manifest = deps.files.readJson<ChangesetManifest>(join(changesetRoot, "manifest.json"));
126
+ assertChangesetIntegrity(deps.files, changesetRoot, manifest);
127
+ const root = manifest.projectRoot;
128
+ assertChangesetReceipt(deps.authority.openChangesetReceipt(root, changesetRoot), manifest);
129
+ const contract = deps.authority.getViewContract(root, manifest.viewId);
130
+ const authorityReport = deps.authority.verify(root);
131
+ if (authorityReport.errors.length > 0) {
132
+ throw new AwbsError(`Authority verification failed:\n${authorityReport.errors.join("\n")}`);
133
+ }
134
+
135
+ if (manifest.status !== "valid" || manifest.violations.length > 0) {
136
+ throw new AwbsError(`Changeset ${manifest.changesetId} is invalid and cannot be applied.`);
137
+ }
138
+ const forbiddenChanges = manifest.changes.filter((change) => !isPathAllowedByWritePaths(change.path, contract.writePaths));
139
+ if (forbiddenChanges.length > 0) {
140
+ throw new AwbsError(`Changeset ${manifest.changesetId} modifies read-only path(s) and cannot be applied.`);
141
+ }
142
+
143
+ if (manifest.baseCommit !== contract.baseCommit) {
144
+ throw new AwbsError(`Changeset base ${manifest.baseCommit} does not match sealed view contract base ${contract.baseCommit}.`);
145
+ }
146
+
147
+ const currentTrustedCommit = requireTrustedCommit(deps.git, root);
148
+ if (contract.baseCommit !== currentTrustedCommit) {
149
+ throw new AwbsError(`Stale view. Current trusted commit is ${currentTrustedCommit}, changeset base is ${contract.baseCommit}. Create a new view from the current trusted database.`);
150
+ }
151
+
152
+ const workspaceRel = toPosixPath(relative(root, manifest.workspacePath));
153
+ const dirty = filterIgnoredStatus(deps.git.statusPorcelain(root), root, [workspaceRel, ...preApplyAuthorityPaths(deps, root)]);
154
+ const head = deps.git.headCommit(root);
155
+ const canApplyInPlace = head === currentTrustedCommit && dirty.trim().length === 0;
156
+ const applyTargetRoot = canApplyInPlace ? root : null;
157
+ const applyInTarget = (targetRoot: string): { commit: string | null; applied: number } => {
158
+ if (targetRoot !== root) {
159
+ copyPreApplyAuthorityMaterial(deps, root, targetRoot);
160
+ }
161
+ const appliedPaths = applyFilesToTarget(deps, changesetRoot, manifest, targetRoot);
162
+ if (appliedPaths.length === 0) {
163
+ return { commit: null, applied: 0 };
164
+ }
165
+
166
+ const changesetManifestHash = contentHash(manifest);
167
+ const authorityContractHash = contentHash(contract);
168
+ const ledgerEntry = deps.authority.recordChangesetApply(targetRoot, {
169
+ schemaVersion: 1,
170
+ parentTrustedCommit: currentTrustedCommit,
171
+ baseCommit: contract.baseCommit,
172
+ changesetId: manifest.changesetId,
173
+ viewId: manifest.viewId,
174
+ appliedPaths,
175
+ changesetManifestHash,
176
+ changesetPayloadHash: manifest.payloadHash,
177
+ authorityContractHash,
178
+ ext: {}
179
+ });
180
+ deps.authority.repairMirrors(targetRoot);
181
+ deps.git.addAll(targetRoot, [...appliedPaths.map(fromPosixPath), ...authorityCommitPaths(deps, targetRoot)]);
182
+ deps.git.commit(
183
+ targetRoot,
184
+ [
185
+ `awbs: apply ${manifest.changesetId}`,
186
+ "",
187
+ `AWBS-Ledger-Entry: ${ledgerEntry.entryId}`,
188
+ `AWBS-Operation-Hash: ${ledgerEntry.operationHash}`,
189
+ `AWBS-Parent-Trusted-Commit: ${currentTrustedCommit}`
190
+ ].join("\n")
191
+ );
192
+ const nextTrustedCommit = deps.git.requireHeadCommit(targetRoot);
193
+ deps.git.updateRef(root, TRUSTED_REF, nextTrustedCommit);
194
+ return { commit: nextTrustedCommit, applied: appliedPaths.length };
195
+ };
196
+
197
+ if (applyTargetRoot) {
198
+ return applyInTarget(applyTargetRoot);
199
+ }
200
+
201
+ return withTrustedWorktree(deps, root, currentTrustedCommit, "awbs-apply-", applyInTarget);
202
+ },
203
+
204
+ formatChangesetSummary(manifest: ChangesetManifest): string {
205
+ const lines = [
206
+ `Changeset: ${manifest.changesetId}`,
207
+ `Status: ${manifest.status}`,
208
+ `View: ${manifest.viewId}`,
209
+ `Base commit: ${manifest.baseCommit}`,
210
+ `Added: ${manifest.summary.added}`,
211
+ `Modified: ${manifest.summary.modified}`,
212
+ `Deleted: ${manifest.summary.deleted}`,
213
+ `Violations: ${manifest.summary.violations}`
214
+ ];
215
+
216
+ if (manifest.changes.length > 0) {
217
+ lines.push("", "Changes:");
218
+ for (const change of manifest.changes) {
219
+ lines.push(` ${change.allowed ? " " : "!"} ${change.kind.padEnd(6)} ${change.path}`);
220
+ }
221
+ }
222
+
223
+ if (manifest.violations.length > 0) {
224
+ lines.push("", "Violations:");
225
+ for (const violation of manifest.violations) {
226
+ lines.push(` ${violation.path}: ${violation.reason ?? "not allowed"}`);
227
+ }
228
+ }
229
+
230
+ return lines.join("\n");
231
+ }
232
+ };
233
+ }
234
+
235
+ function classifyChange(hasBefore: boolean, beforeSha: string | null, hasAfter: boolean, afterSha: string | null): ChangeKind | null {
236
+ if (!hasBefore && hasAfter) {
237
+ return "add";
238
+ }
239
+ if (hasBefore && !hasAfter) {
240
+ return "delete";
241
+ }
242
+ if (hasBefore && hasAfter && beforeSha !== afterSha) {
243
+ return "modify";
244
+ }
245
+ return null;
246
+ }
247
+
248
+ function applyFilesToTarget(
249
+ deps: { files: FileDatabasePort },
250
+ changesetRoot: string,
251
+ manifest: ChangesetManifest,
252
+ targetRoot: string
253
+ ): string[] {
254
+ for (const change of manifest.changes) {
255
+ if (!change.allowed) {
256
+ continue;
257
+ }
258
+ assertUserDataPath(change.path, "apply changes to");
259
+ if (change.kind !== "delete") {
260
+ assertPayloadRecord(deps.files, changesetRoot, change);
261
+ }
262
+ }
263
+
264
+ const appliedPaths: string[] = [];
265
+ for (const change of manifest.changes) {
266
+ if (!change.allowed) {
267
+ continue;
268
+ }
269
+ const target = join(targetRoot, fromPosixPath(change.path));
270
+ if (change.kind === "delete") {
271
+ deps.files.removePath(target);
272
+ } else {
273
+ if (!change.file) {
274
+ throw new AwbsError(`Missing file payload for ${change.path}`);
275
+ }
276
+ const payload = join(changesetRoot, fromPosixPath(change.file));
277
+ deps.files.copyPath(payload, target);
278
+ }
279
+ appliedPaths.push(change.path);
280
+ }
281
+ return appliedPaths;
282
+ }
283
+
284
+ function dataPathPolicyError(path: string): string | null {
285
+ try {
286
+ assertUserDataPath(path, "include in a changeset");
287
+ return null;
288
+ } catch (error) {
289
+ return error instanceof Error ? error.message : String(error);
290
+ }
291
+ }
292
+
293
+ function assertChangesetIntegrity(files: FileDatabasePort, changesetRoot: string, manifest: ChangesetManifest): void {
294
+ for (const change of manifest.changes) {
295
+ assertUserDataPath(change.path, "apply changes to");
296
+ if (change.kind !== "delete" && change.allowed) {
297
+ assertPayloadRecord(files, changesetRoot, change);
298
+ }
299
+ }
300
+
301
+ const payloadHash = computeChangesetPayloadHash(files, changesetRoot, manifest.changes);
302
+ if (payloadHash !== manifest.payloadHash) {
303
+ throw new AwbsError(`Changeset payload hash mismatch for ${manifest.changesetId}.`);
304
+ }
305
+
306
+ const operationHash = computeChangesetOperationHash({
307
+ ...manifest,
308
+ operationHash: undefined
309
+ });
310
+ if (operationHash !== manifest.operationHash) {
311
+ throw new AwbsError(`Changeset operation hash mismatch for ${manifest.changesetId}.`);
312
+ }
313
+ }
314
+
315
+ function assertPayloadRecord(files: FileDatabasePort, changesetRoot: string, change: ChangeRecord): void {
316
+ if (!change.file) {
317
+ throw new AwbsError(`Missing file payload for ${change.path}`);
318
+ }
319
+ assertSafeRelativePath(change.file);
320
+ if (!change.file.startsWith("files/")) {
321
+ throw new AwbsError(`Invalid file payload path for ${change.path}`);
322
+ }
323
+ const payload = join(changesetRoot, fromPosixPath(change.file));
324
+ if (!files.pathExists(payload)) {
325
+ throw new AwbsError(`Missing file payload for ${change.path}`);
326
+ }
327
+ const actualSha = files.sha256File(payload);
328
+ if (actualSha !== change.sha256) {
329
+ throw new AwbsError(`Changeset payload sha256 mismatch for ${change.path}.`);
330
+ }
331
+ }
332
+
333
+ function computeChangesetPayloadHash(files: FileDatabasePort, changesetRoot: string, changes: ChangeRecord[]): string {
334
+ const payloads = changes
335
+ .filter((change) => change.allowed && change.kind !== "delete")
336
+ .map((change) => {
337
+ assertPayloadRecord(files, changesetRoot, change);
338
+ return {
339
+ path: change.path,
340
+ kind: change.kind,
341
+ file: change.file,
342
+ sha256: change.sha256
343
+ };
344
+ })
345
+ .sort((a, b) => a.path.localeCompare(b.path));
346
+ return contentHash(payloads);
347
+ }
348
+
349
+ function computeChangesetOperationHash(manifest: Omit<ChangesetManifest, "operationHash"> | (ChangesetManifest & { operationHash?: string | undefined })): string {
350
+ const { operationHash: _operationHash, ...operation } = manifest as ChangesetManifest & { operationHash?: string };
351
+ return contentHash(operation);
352
+ }
353
+
354
+ function resolveChangesetPath(files: FileDatabasePort, cwd: string, input: string): string {
355
+ const direct = resolve(cwd, input);
356
+ if (files.pathExists(join(direct, "manifest.json"))) {
357
+ return direct;
358
+ }
359
+ const root = files.findProjectRoot(cwd);
360
+ const byId = join(root, ".awbs", "changesets", input);
361
+ if (files.pathExists(join(byId, "manifest.json"))) {
362
+ return byId;
363
+ }
364
+ throw new AwbsError(`Changeset not found: ${input}`);
365
+ }
366
+
367
+ function createChangesetReceipt(manifest: ChangesetManifest): AuthorityChangesetReceipt {
368
+ return {
369
+ schemaVersion: 1,
370
+ changesetId: manifest.changesetId,
371
+ viewId: manifest.viewId,
372
+ baseCommit: manifest.baseCommit,
373
+ createdAt: new Date().toISOString(),
374
+ payloadHash: manifest.payloadHash,
375
+ operationHash: manifest.operationHash,
376
+ manifestHash: contentHash(manifest),
377
+ ext: {}
378
+ };
379
+ }
380
+
381
+ function assertChangesetReceipt(receipt: AuthorityChangesetReceipt, manifest: ChangesetManifest): void {
382
+ if (receipt.changesetId !== manifest.changesetId || receipt.viewId !== manifest.viewId || receipt.baseCommit !== manifest.baseCommit) {
383
+ throw new AwbsError(`Changeset receipt identity mismatch for ${manifest.changesetId}.`);
384
+ }
385
+ if (receipt.payloadHash !== manifest.payloadHash) {
386
+ throw new AwbsError(`Changeset receipt payload hash mismatch for ${manifest.changesetId}.`);
387
+ }
388
+ if (receipt.operationHash !== manifest.operationHash) {
389
+ throw new AwbsError(`Changeset receipt operation hash mismatch for ${manifest.changesetId}.`);
390
+ }
391
+ if (receipt.manifestHash !== contentHash(manifest)) {
392
+ throw new AwbsError(`Changeset receipt manifest hash mismatch for ${manifest.changesetId}.`);
393
+ }
394
+ }
395
+
396
+ function assertWorkspaceMatchesContract(workspacePath: string, contractWorkspacePath: unknown): void {
397
+ if (typeof contractWorkspacePath !== "string") {
398
+ return;
399
+ }
400
+ if (resolve(contractWorkspacePath) !== workspacePath) {
401
+ throw new AwbsError("Workspace path does not match the sealed view contract.");
402
+ }
403
+ }
404
+
405
+ function authorityCommitPaths(deps: { files: FileDatabasePort; authority: AuthorityPort }, root: string): string[] {
406
+ const catalog = deps.authority.readCatalog(root);
407
+ const candidates = [
408
+ ".awbs/authority/catalog.seal.json",
409
+ ".awbs/authority/catalog.mirror.json",
410
+ ".awbs/authority/view-events.jsonl",
411
+ ".awbs/authority/ledger-events.jsonl",
412
+ ".awbs/authority/ledger.seal.json",
413
+ ".awbs/authority/ledger.mirror.json",
414
+ ...viewAuthorityPaths(catalog)
415
+ ];
416
+ return candidates.filter((path) => deps.files.pathExists(join(root, fromPosixPath(path))));
417
+ }
418
+
419
+ function preApplyAuthorityPaths(deps: { files: FileDatabasePort; authority: AuthorityPort }, root: string): string[] {
420
+ const catalog = deps.authority.readCatalog(root);
421
+ const candidates = [".awbs/authority/catalog.seal.json", ".awbs/authority/catalog.mirror.json", ".awbs/authority/view-events.jsonl", ...viewAuthorityPaths(catalog)];
422
+ return candidates.filter((path) => deps.files.pathExists(join(root, fromPosixPath(path))));
423
+ }
424
+
425
+ function copyPreApplyAuthorityMaterial(deps: { files: FileDatabasePort; authority: AuthorityPort }, root: string, targetRoot: string): void {
426
+ for (const path of preApplyAuthorityPaths(deps, root)) {
427
+ deps.files.copyPath(join(root, fromPosixPath(path)), join(targetRoot, fromPosixPath(path)));
428
+ }
429
+ }
430
+
431
+ function viewAuthorityPaths(catalog: AuthorityCatalog): string[] {
432
+ return catalog.views.flatMap((view) => [
433
+ `.awbs/authority/views/${view.viewId}/contract.seal.json`,
434
+ `.awbs/authority/views/${view.viewId}/mirror.json`,
435
+ `.awbs/authority/views/${view.viewId}/receipt.json`
436
+ ]);
437
+ }
@@ -0,0 +1,192 @@
1
+ import { existsSync, readdirSync, renameSync, rmSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { TRUSTED_REF } from "../domain/constants.ts";
4
+ import { AwbsError } from "../domain/errors.ts";
5
+ import type { AuthorityPort } from "../ports/authority.ts";
6
+ import type { FileDatabasePort } from "../ports/file-database.ts";
7
+ import type { GitPort } from "../ports/git.ts";
8
+ import { inspectTrustedLedger } from "./ledger.ts";
9
+ import { requireTrustedCommit } from "./trusted-chain.ts";
10
+
11
+ export type DbAuditReport = {
12
+ ok: boolean;
13
+ root: string;
14
+ headCommit: string | null;
15
+ currentTrustedCommit: string | null;
16
+ headMatchesTrusted: boolean;
17
+ trustedIsAncestorOfHead: boolean | null;
18
+ externalCommits: string[];
19
+ workingTreeDirty: boolean;
20
+ workingTreeStatus: string;
21
+ authorityOk: boolean;
22
+ errors: string[];
23
+ };
24
+
25
+ export type DbCleanRebuildReport = {
26
+ trustedCommit: string;
27
+ backupPath: string;
28
+ cleanPath: string;
29
+ restoredPath: string;
30
+ swapped: boolean;
31
+ };
32
+
33
+ export type DbUseCases = {
34
+ auditDatabase(cwd: string): DbAuditReport;
35
+ cleanRebuild(cwd: string): DbCleanRebuildReport;
36
+ listBackups(cwd: string): string[];
37
+ formatAuditReport(report: DbAuditReport): string;
38
+ formatCleanRebuildReport(report: DbCleanRebuildReport): string;
39
+ };
40
+
41
+ export function createDbUseCases(deps: { files: FileDatabasePort; git: GitPort; authority: AuthorityPort }): DbUseCases {
42
+ return {
43
+ auditDatabase(cwd: string): DbAuditReport {
44
+ const root = deps.files.findProjectRoot(cwd);
45
+ const headCommit = deps.git.headCommit(root);
46
+ const currentTrustedCommit = deps.git.refCommit(root, TRUSTED_REF);
47
+ const ledgerReport = inspectTrustedLedger(deps, root);
48
+ const errors = [...ledgerReport.errors];
49
+ const workingTreeStatus = deps.git.statusPorcelain(root);
50
+
51
+ let trustedIsAncestorOfHead: boolean | null = null;
52
+ let externalCommits: string[] = [];
53
+ if (headCommit && currentTrustedCommit && headCommit !== currentTrustedCommit) {
54
+ trustedIsAncestorOfHead = deps.git.isAncestor(root, currentTrustedCommit, headCommit);
55
+ if (trustedIsAncestorOfHead) {
56
+ externalCommits = deps.git.revList(root, `${currentTrustedCommit}..${headCommit}`);
57
+ } else {
58
+ externalCommits = [headCommit];
59
+ errors.push("Git HEAD is not on top of the AWBS trusted chain.");
60
+ }
61
+ } else if (headCommit && currentTrustedCommit) {
62
+ trustedIsAncestorOfHead = true;
63
+ }
64
+
65
+ const report: DbAuditReport = {
66
+ ok: ledgerReport.ok && headCommit === currentTrustedCommit && workingTreeStatus.trim().length === 0,
67
+ root,
68
+ headCommit,
69
+ currentTrustedCommit,
70
+ headMatchesTrusted: Boolean(headCommit && currentTrustedCommit && headCommit === currentTrustedCommit),
71
+ trustedIsAncestorOfHead,
72
+ externalCommits,
73
+ workingTreeDirty: workingTreeStatus.trim().length > 0,
74
+ workingTreeStatus,
75
+ authorityOk: ledgerReport.ok,
76
+ errors
77
+ };
78
+ return report;
79
+ },
80
+
81
+ cleanRebuild(cwd: string): DbCleanRebuildReport {
82
+ const root = deps.files.findProjectRoot(cwd);
83
+ const trustedCommit = requireTrustedCommit(deps.git, root);
84
+ const parent = dirname(root);
85
+ const name = basename(root);
86
+ const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
87
+ const cleanPath = join(parent, `${name}.clean-${stamp}`);
88
+ const backupPath = join(parent, `${name}.backup-${stamp}`);
89
+
90
+ if (existsSync(cleanPath) || existsSync(backupPath)) {
91
+ throw new AwbsError("Clean rebuild target already exists. Try again later.");
92
+ }
93
+
94
+ deps.git.cloneAtCommit(root, cleanPath, trustedCommit);
95
+ copyPrivateMaterial(deps.files, root, cleanPath);
96
+ deps.git.updateRef(cleanPath, TRUSTED_REF, trustedCommit);
97
+ const authorityReport = deps.authority.verify(cleanPath);
98
+ if (authorityReport.errors.length > 0) {
99
+ rmSync(cleanPath, { recursive: true, force: true });
100
+ throw new AwbsError(`Clean clone authority verification failed:\n${authorityReport.errors.join("\n")}`);
101
+ }
102
+
103
+ const originalCwd = process.cwd();
104
+ if (isInsideOrEqual(root, originalCwd)) {
105
+ process.chdir(parent);
106
+ }
107
+
108
+ try {
109
+ renameSync(root, backupPath);
110
+ try {
111
+ renameSync(cleanPath, root);
112
+ } catch (error) {
113
+ renameSync(backupPath, root);
114
+ throw error;
115
+ }
116
+ } catch (error) {
117
+ throw new AwbsError(
118
+ `Clean rebuild swap failed. Clean clone remains at ${cleanPath}, planned backup path was ${backupPath}.\n${error instanceof Error ? error.message : String(error)}`
119
+ );
120
+ }
121
+
122
+ return {
123
+ trustedCommit,
124
+ backupPath,
125
+ cleanPath,
126
+ restoredPath: root,
127
+ swapped: true
128
+ };
129
+ },
130
+
131
+ listBackups(cwd: string): string[] {
132
+ const root = deps.files.findProjectRoot(cwd);
133
+ const parent = dirname(root);
134
+ const name = basename(root);
135
+ const prefix = `${name}.backup-`;
136
+ return readdirSync(parent)
137
+ .filter((entry) => entry.startsWith(prefix))
138
+ .map((entry) => join(parent, entry))
139
+ .sort((a, b) => a.localeCompare(b));
140
+ },
141
+
142
+ formatAuditReport(report: DbAuditReport): string {
143
+ const lines = [
144
+ `Database audit: ${report.ok ? "ok" : "attention required"}`,
145
+ `Root: ${report.root}`,
146
+ `HEAD: ${report.headCommit ?? "(none)"}`,
147
+ `Trusted: ${report.currentTrustedCommit ?? "(none)"}`,
148
+ `HEAD matches trusted: ${report.headMatchesTrusted ? "yes" : "no"}`,
149
+ `Working tree dirty: ${report.workingTreeDirty ? "yes" : "no"}`,
150
+ `External commits: ${report.externalCommits.length}`
151
+ ];
152
+ if (report.externalCommits.length > 0) {
153
+ lines.push("", "External commits:");
154
+ for (const commit of report.externalCommits) {
155
+ lines.push(` ${commit}`);
156
+ }
157
+ }
158
+ if (report.workingTreeStatus.trim().length > 0) {
159
+ lines.push("", "Working tree status:", report.workingTreeStatus.trimEnd());
160
+ }
161
+ if (report.errors.length > 0) {
162
+ lines.push("", "Errors:");
163
+ for (const error of report.errors) {
164
+ lines.push(` ${error}`);
165
+ }
166
+ }
167
+ return lines.join("\n");
168
+ },
169
+
170
+ formatCleanRebuildReport(report: DbCleanRebuildReport): string {
171
+ return [
172
+ "Database rebuilt from AWBS trusted chain.",
173
+ `Trusted commit: ${report.trustedCommit}`,
174
+ `Backup: ${report.backupPath}`,
175
+ `Restored: ${report.restoredPath}`
176
+ ].join("\n");
177
+ }
178
+ };
179
+ }
180
+
181
+ function copyPrivateMaterial(files: FileDatabasePort, root: string, cleanPath: string): void {
182
+ const source = join(root, ".awbs", "private");
183
+ const destination = join(cleanPath, ".awbs", "private");
184
+ if (existsSync(source)) {
185
+ files.copyPath(source, destination);
186
+ }
187
+ }
188
+
189
+ function isInsideOrEqual(root: string, target: string): boolean {
190
+ const rel = relative(resolve(root), resolve(target));
191
+ return rel === "" || (!rel.startsWith("..") && !rel.includes(":"));
192
+ }