@wtfalch/foundry 0.76.0 → 0.77.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.
Files changed (127) hide show
  1. package/dist/app-canary-worker.d.ts +35 -0
  2. package/dist/app-canary-worker.js +56 -0
  3. package/dist/audit/connector.d.ts +6 -2
  4. package/dist/audit/connector.js +3 -2
  5. package/dist/audit/evidence.d.ts +4 -0
  6. package/dist/audit/evidence.js +14 -2
  7. package/dist/audit/host-store.d.ts +58 -0
  8. package/dist/audit/host-store.js +147 -0
  9. package/dist/audit/host.d.ts +53 -0
  10. package/dist/audit/host.js +294 -0
  11. package/dist/audit/index.d.ts +2 -0
  12. package/dist/audit/index.js +2 -0
  13. package/dist/audit/registry.d.ts +14 -0
  14. package/dist/audit/sealer-role.d.ts +12 -0
  15. package/dist/audit/sealer-role.js +26 -2
  16. package/dist/audit/tick.d.ts +10 -2
  17. package/dist/audit/tick.js +15 -2
  18. package/dist/catalogue/copy-en.js +5 -0
  19. package/dist/catalogue/integrations.js +5 -0
  20. package/dist/entry-rules.d.ts +35 -0
  21. package/dist/entry-rules.js +35 -0
  22. package/dist/hosted-label.js +2 -1
  23. package/dist/index.d.ts +11 -3
  24. package/dist/index.js +6 -1
  25. package/dist/migrations/0072_audit_host.sql +46 -0
  26. package/dist/migrations/0074_platform_app_canary.sql +11 -0
  27. package/dist/modules/ai.js +15 -1
  28. package/dist/modules/assistants.js +3 -0
  29. package/dist/modules/email.d.ts +6 -1
  30. package/dist/modules/email.js +88 -5
  31. package/dist/modules/files.js +79 -2
  32. package/dist/modules/index.js +7 -0
  33. package/dist/modules/jobs.d.ts +5 -1
  34. package/dist/modules/jobs.js +74 -4
  35. package/dist/modules/reporting.d.ts +9 -0
  36. package/dist/modules/reporting.js +29 -0
  37. package/dist/modules/sign-in.js +20 -2
  38. package/dist/modules/types.d.ts +2 -0
  39. package/dist/modules/workspaces.js +3 -0
  40. package/dist/org.d.ts +114 -13
  41. package/dist/org.js +233 -20
  42. package/dist/plan/ai-boule-key-apply.d.ts +65 -0
  43. package/dist/plan/ai-boule-key-apply.js +169 -0
  44. package/dist/plan/ai-boule-key.d.ts +89 -0
  45. package/dist/plan/ai-boule-key.js +195 -0
  46. package/dist/plan/analytics.d.ts +8 -1
  47. package/dist/plan/analytics.js +8 -1
  48. package/dist/plan/app-canary-apply.d.ts +49 -0
  49. package/dist/plan/app-canary-apply.js +232 -0
  50. package/dist/plan/app-canary.d.ts +44 -0
  51. package/dist/plan/app-canary.js +110 -0
  52. package/dist/plan/blue-green-apply.d.ts +6 -2
  53. package/dist/plan/blue-green-apply.js +14 -3
  54. package/dist/plan/broker-entries.js +20 -5
  55. package/dist/plan/company-backups.js +2 -0
  56. package/dist/plan/desktop-auth.d.ts +17 -4
  57. package/dist/plan/desktop-auth.js +22 -5
  58. package/dist/plan/diff.d.ts +22 -0
  59. package/dist/plan/diff.js +231 -18
  60. package/dist/plan/entry-files-key.d.ts +39 -3
  61. package/dist/plan/entry-files-key.js +103 -6
  62. package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
  63. package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
  64. package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
  65. package/dist/plan/entry-jobs-boule-key.js +141 -0
  66. package/dist/plan/entry-jobs.d.ts +2 -0
  67. package/dist/plan/entry-reporting-key.d.ts +36 -0
  68. package/dist/plan/entry-reporting-key.js +136 -0
  69. package/dist/plan/executor.d.ts +108 -1
  70. package/dist/plan/executor.js +676 -24
  71. package/dist/plan/files-boule-key-apply.d.ts +73 -0
  72. package/dist/plan/files-boule-key-apply.js +223 -0
  73. package/dist/plan/files-boule-key.d.ts +92 -0
  74. package/dist/plan/files-boule-key.js +175 -0
  75. package/dist/plan/hostnames.js +4 -0
  76. package/dist/plan/index.d.ts +4 -0
  77. package/dist/plan/index.js +2 -0
  78. package/dist/plan/jobs-scheduler-http.d.ts +13 -0
  79. package/dist/plan/jobs-scheduler-http.js +39 -0
  80. package/dist/plan/live.d.ts +1 -1
  81. package/dist/plan/locked-hostnames.d.ts +1 -1
  82. package/dist/plan/locked-hostnames.js +4 -1
  83. package/dist/plan/login-host.js +2 -2
  84. package/dist/plan/org-names.js +3 -0
  85. package/dist/plan/own-diff.js +3 -2
  86. package/dist/plan/own-jobs-route.d.ts +5 -2
  87. package/dist/plan/own-jobs-route.js +6 -2
  88. package/dist/plan/own-limits.d.ts +6 -0
  89. package/dist/plan/own-limits.js +1 -0
  90. package/dist/plan/plan-apply.d.ts +24 -0
  91. package/dist/plan/plan-apply.js +39 -4
  92. package/dist/plan/platform-diff.d.ts +2 -0
  93. package/dist/plan/platform-diff.js +15 -1
  94. package/dist/plan/platform-providers.d.ts +9 -0
  95. package/dist/plan/platform-providers.js +21 -0
  96. package/dist/plan/providers-readers.js +25 -1
  97. package/dist/plan/registered-app-env.d.ts +31 -0
  98. package/dist/plan/registered-app-env.js +63 -0
  99. package/dist/plan/registered-app.d.ts +121 -0
  100. package/dist/plan/registered-app.js +262 -0
  101. package/dist/plan/release-host.js +2 -1
  102. package/dist/plan/reporting-boule.d.ts +18 -0
  103. package/dist/plan/reporting-boule.js +44 -0
  104. package/dist/plan/reporting.d.ts +90 -0
  105. package/dist/plan/reporting.js +135 -0
  106. package/dist/plan/stage-env.js +1 -0
  107. package/dist/plan/stage-isolation.d.ts +3 -1
  108. package/dist/plan/stage-isolation.js +20 -2
  109. package/dist/plan/stage-services.d.ts +3 -1
  110. package/dist/plan/stage-services.js +8 -1
  111. package/dist/plan/stage-sink.d.ts +2 -2
  112. package/dist/plan/stage-sink.js +2 -2
  113. package/dist/plan/workspace-client.d.ts +1 -1
  114. package/dist/plan/workspace-client.js +2 -1
  115. package/dist/plan/workspace-sign-in.d.ts +1 -1
  116. package/dist/plan/workspace-sign-in.js +39 -7
  117. package/dist/platform.d.ts +25 -0
  118. package/dist/platform.js +20 -0
  119. package/dist/service-labels.d.ts +8 -0
  120. package/dist/service-labels.js +8 -0
  121. package/dist/slug.d.ts +1 -1
  122. package/dist/slug.js +5 -2
  123. package/dist/store/postgres.js +13 -0
  124. package/dist/store/schema.d.ts +485 -2
  125. package/dist/store/schema.js +38 -1
  126. package/dist/worker-app-config.js +2 -2
  127. package/package.json +1 -1
@@ -0,0 +1,35 @@
1
+ /**
2
+ * foundry#1133: the app-layer canary, the whole Worker. Archon's uptime
3
+ * Worker probes `GET /` on it (archon#812, service `app-layer`), so a red
4
+ * check means the layer every company's managed app runs on
5
+ * (`<label>.companyincluded.app`, edge's Worker routes) is down, not the
6
+ * canary. It has no bindings, no secrets and no data: `/` answers 200 with an
7
+ * empty body, anything else 404, and nothing is cached.
8
+ *
9
+ * This string is the only source and the only build: the package ships it,
10
+ * so a host serves the pinned artifact from it ({@link fetchAppCanaryArtifact})
11
+ * the way Archon serves `archon-uptime-worker.js` from its own image. Nothing
12
+ * is published at {@link import('./platform.js').appCanaryArtifactUrl}; the
13
+ * URL only names the pin at the `fetchArtifact(url, sha256)` boundary.
14
+ */
15
+ export declare const APP_CANARY_WORKER_SOURCE = "export default {\n fetch(request) {\n const { pathname } = new URL(request.url);\n return new Response(null, {\n status: pathname === '/' ? 200 : 404,\n headers: { 'cache-control': 'no-store' },\n });\n },\n};\n";
16
+ /** Lower-case hex SHA-256 of {@link APP_CANARY_WORKER_SOURCE}'s UTF-8 bytes. */
17
+ export declare const APP_CANARY_WORKER_SHA256: string;
18
+ /**
19
+ * The pin of the shipped canary: its digest, and a tag made from it, so the
20
+ * tag changes exactly when the script does. A host declares
21
+ * `platform.appCanary = { enabled: true, ...appCanaryShippedPin() }`.
22
+ */
23
+ export declare function appCanaryShippedPin(): {
24
+ tag: string;
25
+ sha256: string;
26
+ };
27
+ /** The file name the artifact URL ends in. */
28
+ export declare const APP_CANARY_ARTIFACT_FILE = "app-canary-worker.js";
29
+ /**
30
+ * A `fetchArtifact` for the canary step: the shipped bytes, no network and no
31
+ * credential. Refuses any URL that is not a canary artifact URL, and a digest
32
+ * that is not the shipped one (the pin names another build than this package
33
+ * holds), in fixed words.
34
+ */
35
+ export declare function fetchAppCanaryArtifact(url: string, sha256: string): Promise<Uint8Array>;
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * foundry#1133: the app-layer canary, the whole Worker. Archon's uptime
4
+ * Worker probes `GET /` on it (archon#812, service `app-layer`), so a red
5
+ * check means the layer every company's managed app runs on
6
+ * (`<label>.companyincluded.app`, edge's Worker routes) is down, not the
7
+ * canary. It has no bindings, no secrets and no data: `/` answers 200 with an
8
+ * empty body, anything else 404, and nothing is cached.
9
+ *
10
+ * This string is the only source and the only build: the package ships it,
11
+ * so a host serves the pinned artifact from it ({@link fetchAppCanaryArtifact})
12
+ * the way Archon serves `archon-uptime-worker.js` from its own image. Nothing
13
+ * is published at {@link import('./platform.js').appCanaryArtifactUrl}; the
14
+ * URL only names the pin at the `fetchArtifact(url, sha256)` boundary.
15
+ */
16
+ export const APP_CANARY_WORKER_SOURCE = `export default {
17
+ fetch(request) {
18
+ const { pathname } = new URL(request.url);
19
+ return new Response(null, {
20
+ status: pathname === '/' ? 200 : 404,
21
+ headers: { 'cache-control': 'no-store' },
22
+ });
23
+ },
24
+ };
25
+ `;
26
+ /** Lower-case hex SHA-256 of {@link APP_CANARY_WORKER_SOURCE}'s UTF-8 bytes. */
27
+ export const APP_CANARY_WORKER_SHA256 = createHash('sha256')
28
+ .update(APP_CANARY_WORKER_SOURCE)
29
+ .digest('hex');
30
+ /**
31
+ * The pin of the shipped canary: its digest, and a tag made from it, so the
32
+ * tag changes exactly when the script does. A host declares
33
+ * `platform.appCanary = { enabled: true, ...appCanaryShippedPin() }`.
34
+ */
35
+ export function appCanaryShippedPin() {
36
+ return { tag: `v0-${APP_CANARY_WORKER_SHA256.slice(0, 12)}`, sha256: APP_CANARY_WORKER_SHA256 };
37
+ }
38
+ /** The file name the artifact URL ends in. */
39
+ export const APP_CANARY_ARTIFACT_FILE = 'app-canary-worker.js';
40
+ /** `appCanaryArtifactUrl(tag)` for any release-shaped tag: the only URL {@link fetchAppCanaryArtifact} serves. */
41
+ const APP_CANARY_ARTIFACT_URL = /^https:\/\/github\.com\/wtfalch\/foundry\/releases\/download\/v[0-9A-Za-z][0-9A-Za-z.+_-]*\/app-canary-worker\.js$/;
42
+ /**
43
+ * A `fetchArtifact` for the canary step: the shipped bytes, no network and no
44
+ * credential. Refuses any URL that is not a canary artifact URL, and a digest
45
+ * that is not the shipped one (the pin names another build than this package
46
+ * holds), in fixed words.
47
+ */
48
+ export async function fetchAppCanaryArtifact(url, sha256) {
49
+ if (!APP_CANARY_ARTIFACT_URL.test(url)) {
50
+ throw new Error('the app canary is served only from its own artifact URL');
51
+ }
52
+ if (sha256.toLowerCase() !== APP_CANARY_WORKER_SHA256) {
53
+ throw new Error("the pinned app canary digest is not the one this foundry ships; pin appCanaryShippedPin()'s values");
54
+ }
55
+ return new TextEncoder().encode(APP_CANARY_WORKER_SOURCE);
56
+ }
@@ -31,12 +31,16 @@ export interface LedgerConnectorDeps {
31
31
  };
32
32
  /** Registers a value so no log, error or result carries it. */
33
33
  secret?: (value: string) => void;
34
- /** The host's driver: reaches the app database (over its Access-protected route) as the given role. */
34
+ /**
35
+ * The host's driver: reaches the app database (over its Access-protected route) as the given role. For a ledger
36
+ * a company's Boule registered, `login.ledger.database` says where the database is; the driver connects there.
37
+ */
35
38
  connect(login: SealerLogin): Promise<LedgerConnection | LedgerUnavailable>;
36
39
  }
37
40
  /**
38
41
  * A {@link LedgerConnector} built from the sealing role: it reads the role's name from the ledger (recorded by the
39
- * Worker app database apply) and its password from the held keys, and hands both to the host's driver. A ledger
42
+ * Worker app database apply, or from the registration) and its password from the held keys (`ledger.passwordRef`
43
+ * for a registered ledger), and hands both to the host's driver. A ledger
40
44
  * whose role or password is not there yet is unavailable with the reason, and nothing is opened. It never falls
41
45
  * back to another credential: the app's runtime role cannot seal, and the owner is not used to seal.
42
46
  */
@@ -2,7 +2,8 @@ import { auditSealerRef } from './sealer-role.js';
2
2
  export const isUnavailable = (value) => 'unavailable' in value;
3
3
  /**
4
4
  * A {@link LedgerConnector} built from the sealing role: it reads the role's name from the ledger (recorded by the
5
- * Worker app database apply) and its password from the held keys, and hands both to the host's driver. A ledger
5
+ * Worker app database apply, or from the registration) and its password from the held keys (`ledger.passwordRef`
6
+ * for a registered ledger), and hands both to the host's driver. A ledger
6
7
  * whose role or password is not there yet is unavailable with the reason, and nothing is opened. It never falls
7
8
  * back to another credential: the app's runtime role cannot seal, and the owner is not used to seal.
8
9
  */
@@ -12,7 +13,7 @@ export function createLedgerConnector(deps) {
12
13
  if (!ledger.sealerRole) {
13
14
  return { unavailable: 'the sealing role has not been made for it yet' };
14
15
  }
15
- const password = await deps.heldKeys.resolve(auditSealerRef(ledger.orgId, ledger.entryKey));
16
+ const password = await deps.heldKeys.resolve(ledger.passwordRef ?? auditSealerRef(ledger.orgId, ledger.entryKey));
16
17
  if (!password)
17
18
  return { unavailable: 'the sealing credential is not held' };
18
19
  deps.secret?.(password);
@@ -26,13 +26,17 @@ export interface EvidenceBundle {
26
26
  /** File name -> text. Written to a folder as is, it is what `audit-verify-bundle` reads. */
27
27
  files: Record<string, string>;
28
28
  }
29
+ /** `refused`: the request itself cannot be answered (a bundle range not on checkpoints), not the ledger being down. */
29
30
  export type EvidenceResult<T> = {
30
31
  ok: true;
31
32
  value: T;
32
33
  } | {
33
34
  ok: false;
34
35
  why: string;
36
+ refused?: true;
35
37
  };
38
+ /** Why a bundle range was refused, in words for Boule to show. */
39
+ export declare const BUNDLE_RANGE_REFUSED = "Pick a range that starts at 1 or just after a checkpoint, and ends on a checkpoint.";
36
40
  export interface AuditEvidence {
37
41
  status(ledger: AppLedger): Promise<EvidenceResult<LedgerStatus>>;
38
42
  checkpoints(ledger: AppLedger): Promise<EvidenceResult<Checkpoint[]>>;
@@ -1,6 +1,8 @@
1
- import { BUNDLE_FORMAT, auditAnchors, buildBundle, listCheckpoints, } from '@wtfalch/audit-sealing';
1
+ import { BUNDLE_FORMAT, BundleRefusal, auditAnchors, buildBundle, listCheckpoints, } from '@wtfalch/audit-sealing';
2
2
  import { isUnavailable } from './connector.js';
3
3
  import { describeRetention } from './registry.js';
4
+ /** Why a bundle range was refused, in words for Boule to show. */
5
+ export const BUNDLE_RANGE_REFUSED = 'Pick a range that starts at 1 or just after a checkpoint, and ends on a checkpoint.';
4
6
  async function readAnchors(handle) {
5
7
  const rows = await handle.select().from(auditAnchors);
6
8
  return rows
@@ -25,7 +27,17 @@ export function createAuditEvidence(deps) {
25
27
  try {
26
28
  return { ok: true, value: await read(connection.handle) };
27
29
  }
28
- catch {
30
+ catch (error) {
31
+ if (error instanceof BundleRefusal) {
32
+ const nothing = /no checkpoint/.test(error.message);
33
+ return {
34
+ ok: false,
35
+ why: nothing
36
+ ? 'Nothing in this log is sealed yet, so there is nothing to export.'
37
+ : BUNDLE_RANGE_REFUSED,
38
+ refused: true,
39
+ };
40
+ }
29
41
  return { ok: false, why: 'The log could not be read. Try again in a minute.' };
30
42
  }
31
43
  finally {
@@ -0,0 +1,58 @@
1
+ import type { DeclaredOrg, OrgId } from '../org.js';
2
+ import type { FoundryStoreDb } from '../store/postgres.js';
3
+ import { type AppLedger, type AuditPolicy, type LedgerAddress } from './registry.js';
4
+ import type { SealOutcome, SealResult } from './seal.js';
5
+ /**
6
+ * What the audit host keeps (foundry#1110, migration 0072): each company's key (its sha256 only), the ledgers a
7
+ * company's Boule registered, and every seal tick's result for the last {@link SEAL_RESULTS_KEPT_HOURS} hours.
8
+ * No password is kept here: a registration names the held-key ref its password was put at.
9
+ */
10
+ /** How long a seal result is kept, and so how far back `status` shows the seals. */
11
+ export declare const SEAL_RESULTS_KEPT_HOURS = 12;
12
+ /** A company's audit host key: `fah_` and 64 lowercase hex characters. */
13
+ export declare const AUDIT_HOST_KEY_PATTERN: RegExp;
14
+ export interface LedgerRegistration {
15
+ orgId: OrgId;
16
+ entryKey: string;
17
+ copy: 'live';
18
+ database: LedgerAddress;
19
+ /** The sealing role the ledger is opened as. */
20
+ user: string;
21
+ /** The held-key ref the sealing role's password was put at ({@link auditLedgerPasswordRef}). */
22
+ passwordRef: string;
23
+ }
24
+ /** One kept seal result, as `status` shows it. */
25
+ export interface SealRecord {
26
+ at: string;
27
+ outcome: SealOutcome;
28
+ /** The new checkpoint's tree size, when one was sealed. */
29
+ treeSize?: number;
30
+ anchored: number;
31
+ erasures: number;
32
+ /** Plain words on why a step did not happen (`SealResult.reason`). */
33
+ reason?: string;
34
+ }
35
+ export interface AuditHostStore {
36
+ /** The company a key belongs to, or `null`. A key of the wrong shape is `null` without a read. */
37
+ companyOfKey(key: string): Promise<OrgId | null>;
38
+ /** Makes the company a new key and returns it, once; the company's previous key stops working. */
39
+ mintKey(orgId: OrgId): Promise<string>;
40
+ /** Keeps a registration, replacing the company's previous one for the app. */
41
+ putRegistration(registration: LedgerRegistration): Promise<'created' | 'replaced'>;
42
+ registrations(orgId: OrgId): Promise<LedgerRegistration[]>;
43
+ /** Keeps a tick's results and drops the ones older than {@link SEAL_RESULTS_KEPT_HOURS}. */
44
+ recordSeals(results: readonly SealResult[], now?: Date): Promise<void>;
45
+ /** The kept results of one ledger, newest first. */
46
+ seals(orgId: OrgId, entryKey: string, now?: Date): Promise<SealRecord[]>;
47
+ }
48
+ /** The held-key ref of a registered ledger's sealing password (apart from foundry's own `auditSealerRef`). */
49
+ export declare const auditLedgerPasswordRef: (orgId: OrgId, entryKey: string) => string;
50
+ export declare function createPostgresAuditHostStore(db: FoundryStoreDb): AuditHostStore;
51
+ /** A registration as the ledger it seals: `<org slug>-<app>`, opened as its own role at its own address. */
52
+ export declare function registeredLedgerOf(org: Pick<DeclaredOrg, 'id' | 'slug'>, registration: LedgerRegistration, policy: AuditPolicy): AppLedger;
53
+ /**
54
+ * Every ledger a company has: the ones its own plan registered (`registeredAppLedgers`), then the ones its
55
+ * Boule registered at the audit host. An app key both name is the plan's: a registration never takes a ledger
56
+ * the plan seals.
57
+ */
58
+ export declare function companyAuditLedgers(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, policy: AuditPolicy, store?: Pick<AuditHostStore, 'registrations'>): Promise<AppLedger[]>;
@@ -0,0 +1,147 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
3
+ import { foundryAuditHostKeys, foundryAuditLedgerRegistrations, foundryAuditSealResults, } from '../store/schema.js';
4
+ import { appLedgerName, registeredAppLedgers, retentionOf, } from './registry.js';
5
+ /**
6
+ * What the audit host keeps (foundry#1110, migration 0072): each company's key (its sha256 only), the ledgers a
7
+ * company's Boule registered, and every seal tick's result for the last {@link SEAL_RESULTS_KEPT_HOURS} hours.
8
+ * No password is kept here: a registration names the held-key ref its password was put at.
9
+ */
10
+ /** How long a seal result is kept, and so how far back `status` shows the seals. */
11
+ export const SEAL_RESULTS_KEPT_HOURS = 12;
12
+ /** A company's audit host key: `fah_` and 64 lowercase hex characters. */
13
+ export const AUDIT_HOST_KEY_PATTERN = /^fah_[0-9a-f]{64}$/;
14
+ /** The held-key ref of a registered ledger's sealing password (apart from foundry's own `auditSealerRef`). */
15
+ export const auditLedgerPasswordRef = (orgId, entryKey) => `foundry:audit-ledger:${orgId}:${entryKey}`;
16
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
17
+ const keptSince = (now) => new Date(now.getTime() - SEAL_RESULTS_KEPT_HOURS * 3600_000);
18
+ const MAX_REASON = 1000;
19
+ const OUTCOMES = ['sealed', 'unchanged', 'skipped', 'failed'];
20
+ export function createPostgresAuditHostStore(db) {
21
+ return {
22
+ async companyOfKey(key) {
23
+ if (!AUDIT_HOST_KEY_PATTERN.test(key))
24
+ return null;
25
+ const [row] = await db
26
+ .select({ orgId: foundryAuditHostKeys.orgId })
27
+ .from(foundryAuditHostKeys)
28
+ .where(eq(foundryAuditHostKeys.keySha256, sha256(key)))
29
+ .limit(1);
30
+ return row?.orgId ?? null;
31
+ },
32
+ async mintKey(orgId) {
33
+ const key = `fah_${randomBytes(32).toString('hex')}`;
34
+ await db
35
+ .insert(foundryAuditHostKeys)
36
+ .values({ orgId, keySha256: sha256(key) })
37
+ .onConflictDoUpdate({
38
+ target: foundryAuditHostKeys.orgId,
39
+ set: { keySha256: sha256(key), createdAt: sql `now()` },
40
+ });
41
+ return key;
42
+ },
43
+ async putRegistration(r) {
44
+ const values = {
45
+ copy: r.copy,
46
+ dbHost: r.database.host,
47
+ dbPort: r.database.port,
48
+ dbName: r.database.database,
49
+ dbUser: r.user,
50
+ sslmode: r.database.sslmode ?? null,
51
+ passwordRef: r.passwordRef,
52
+ };
53
+ const [row] = await db
54
+ .insert(foundryAuditLedgerRegistrations)
55
+ .values({ orgId: r.orgId, entryKey: r.entryKey, ...values })
56
+ .onConflictDoUpdate({
57
+ target: [foundryAuditLedgerRegistrations.orgId, foundryAuditLedgerRegistrations.entryKey],
58
+ set: { ...values, updatedAt: sql `now()` },
59
+ })
60
+ .returning({ created: sql `(xmax = 0)` });
61
+ return row?.created ? 'created' : 'replaced';
62
+ },
63
+ async registrations(orgId) {
64
+ const rows = await db
65
+ .select()
66
+ .from(foundryAuditLedgerRegistrations)
67
+ .where(eq(foundryAuditLedgerRegistrations.orgId, orgId))
68
+ .orderBy(foundryAuditLedgerRegistrations.entryKey);
69
+ return rows.map((row) => ({
70
+ orgId: row.orgId,
71
+ entryKey: row.entryKey,
72
+ copy: 'live',
73
+ database: {
74
+ host: row.dbHost,
75
+ port: row.dbPort,
76
+ database: row.dbName,
77
+ ...(row.sslmode ? { sslmode: row.sslmode } : {}),
78
+ },
79
+ user: row.dbUser,
80
+ passwordRef: row.passwordRef,
81
+ }));
82
+ },
83
+ async recordSeals(results, now = new Date()) {
84
+ if (results.length > 0) {
85
+ await db.insert(foundryAuditSealResults).values(results.map((r) => ({
86
+ orgId: r.orgId,
87
+ entryKey: r.entryKey,
88
+ ledger: r.ledger,
89
+ outcome: r.outcome,
90
+ treeSize: r.treeSize ?? null,
91
+ anchored: r.anchored,
92
+ erasures: r.erasures,
93
+ reason: r.reason ? r.reason.slice(0, MAX_REASON) : null,
94
+ at: new Date(r.at),
95
+ })));
96
+ }
97
+ await db
98
+ .delete(foundryAuditSealResults)
99
+ .where(lt(foundryAuditSealResults.at, keptSince(now)));
100
+ },
101
+ async seals(orgId, entryKey, now = new Date()) {
102
+ const rows = await db
103
+ .select()
104
+ .from(foundryAuditSealResults)
105
+ .where(and(eq(foundryAuditSealResults.orgId, orgId), eq(foundryAuditSealResults.entryKey, entryKey), gte(foundryAuditSealResults.at, keptSince(now))))
106
+ .orderBy(desc(foundryAuditSealResults.at), desc(foundryAuditSealResults.id));
107
+ return rows.map((row) => ({
108
+ at: row.at.toISOString(),
109
+ outcome: OUTCOMES.includes(row.outcome)
110
+ ? row.outcome
111
+ : 'failed',
112
+ ...(row.treeSize === null ? {} : { treeSize: row.treeSize }),
113
+ anchored: row.anchored,
114
+ erasures: row.erasures,
115
+ ...(row.reason ? { reason: row.reason } : {}),
116
+ }));
117
+ },
118
+ };
119
+ }
120
+ /** A registration as the ledger it seals: `<org slug>-<app>`, opened as its own role at its own address. */
121
+ export function registeredLedgerOf(org, registration, policy) {
122
+ return {
123
+ orgId: org.id,
124
+ orgSlug: org.slug,
125
+ entryKey: registration.entryKey,
126
+ ledger: appLedgerName(org, registration.entryKey),
127
+ retention: retentionOf(org, policy),
128
+ sealerRole: registration.user,
129
+ database: registration.database,
130
+ passwordRef: registration.passwordRef,
131
+ };
132
+ }
133
+ /**
134
+ * Every ledger a company has: the ones its own plan registered (`registeredAppLedgers`), then the ones its
135
+ * Boule registered at the audit host. An app key both name is the plan's: a registration never takes a ledger
136
+ * the plan seals.
137
+ */
138
+ export async function companyAuditLedgers(org, recordedIds, policy, store) {
139
+ const own = registeredAppLedgers(org, recordedIds, policy);
140
+ if (!store)
141
+ return own;
142
+ const taken = new Set(own.map((ledger) => ledger.entryKey));
143
+ const registered = (await store.registrations(org.id))
144
+ .filter((r) => !taken.has(r.entryKey))
145
+ .map((r) => registeredLedgerOf(org, r, policy));
146
+ return [...own, ...registered];
147
+ }
@@ -0,0 +1,53 @@
1
+ import type { DeclaredOrgStore } from '../store.js';
2
+ import type { LedgerConnector } from './connector.js';
3
+ import { type AuditHostStore } from './host-store.js';
4
+ import { type AuditPolicy } from './registry.js';
5
+ /**
6
+ * The audit host (foundry#1110): `@wtfalch/foundry/audit` served over HTTP, for a host (Archon) to mount at the
7
+ * address Boule calls `AUDIT_HOST_URL`. It answers exactly the routes Boule reads (`src/lib/apps/audit/host.ts`,
8
+ * the shapes its `fakeAuditHost` serves) and the registration Boule's managed install makes (boule#656):
9
+ *
10
+ * GET /v1/audit/ledgers 200 { ledgers: [{ app, ledger, retention, retentionMonths, legalHold }] }
11
+ * GET /v1/audit/ledgers/:app/status 200 LedgerStatus + { seals: SealRecord[] } (the last 12 hours, newest first)
12
+ * GET /v1/audit/ledgers/:app/checkpoints 200 { checkpoints: Checkpoint[] } oldest first
13
+ * GET /v1/audit/ledgers/:app/anchors 200 { anchors: AnchorView[] } oldest first
14
+ * GET /v1/audit/ledgers/:app/bundle?from&to 200 { format, files } both optional whole numbers >= 1
15
+ * PUT /v1/audit/ledgers/:app { copy: 'live', database: { host, port, database, user, password, sslmode? } }
16
+ * 201 (new) or 200 (replaced) { ledger }
17
+ *
18
+ * Every answer is JSON with `Cache-Control: no-store`. `Authorization: Bearer <key>` is a company's key
19
+ * ({@link AuditHostStore.mintKey}); the company comes from the key, never from a parameter, and every read and the
20
+ * registration see only that company's ledgers. An `:app` with no ledger for the company is 404, never another
21
+ * company's. Errors are `{ error: { code, message } }` with edge's codes: 400 `invalid_request`, 401
22
+ * `unauthorized`, 403 `forbidden`, 404 `not_found`, 405 `method_not_allowed`, 409 `conflict`, 413
23
+ * `invalid_request` (a body over 16 KB), 503 `unavailable` (foundry's `{ ok: false, why }`, `why` as the message).
24
+ *
25
+ * The registration's password is put in the host's held keys at {@link auditLedgerPasswordRef} and registered
26
+ * with `secret`; it is never logged, stored in a table, put in an error or answered. Nothing a request carries
27
+ * reaches a log line: a line names the route, the company's slug and the app at most.
28
+ */
29
+ export interface AuditHostOptions {
30
+ store: AuditHostStore;
31
+ /** The declared orgs and their applied ids (foundry's `DeclaredOrgStore`). */
32
+ orgs: Pick<DeclaredOrgStore, 'get' | 'getAppliedState'>;
33
+ /** The host's retention policy, the same one the seal tick gets. */
34
+ policy: AuditPolicy;
35
+ /** Opens a ledger as its sealing role: `createLedgerConnector`, the same one the seal tick gets. */
36
+ connector: LedgerConnector;
37
+ /** The platform signer's seed is read from it (`AUDIT_SIGNER_REF`); a registration's password is put in it. */
38
+ heldKeys: {
39
+ resolve(ref: string): Promise<string | null | undefined>;
40
+ put(ref: string, value: string): Promise<void>;
41
+ };
42
+ /** Registers a value so no log, error or result carries it. */
43
+ secret?: (value: string) => void;
44
+ /** The path the host mounts the handler at, e.g. `/api/audit`. Requests outside it are 404. */
45
+ basePath?: string;
46
+ now?: () => Date;
47
+ /** One plain line per refused or failed request. Defaults to `console.info`. */
48
+ log?: (line: string) => void;
49
+ }
50
+ export declare const AUDIT_HOST_MAX_LEDGERS = 500;
51
+ export declare const AUDIT_HOST_MAX_BUNDLE_BYTES = 10000000;
52
+ /** The handler a host mounts: a web `Request` in, a `Response` out (a Next.js route handler, a Worker, `node:http` through an adapter). */
53
+ export declare function createAuditHostHandler(options: AuditHostOptions): (request: Request) => Promise<Response>;