@wtfalch/foundry 0.60.0 → 0.62.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.
- package/dist/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +7 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/native-build.d.ts +70 -0
- package/dist/native-build.js +92 -0
- package/dist/org.d.ts +184 -24
- package/dist/org.js +444 -49
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +60 -8
- package/dist/plan/blue-green.js +115 -10
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +15 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +552 -104
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-access.js +1 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +231 -19
- package/dist/plan/executor.js +1648 -129
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +7 -4
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +15 -2
- package/dist/plan/org-postgres-migrate.js +1 -0
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +13 -2
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +12 -3
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/providers-readers.js +20 -1
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +13 -1
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +20 -7
- package/dist/store/postgres.js +25 -2
- package/dist/store/schema.d.ts +73 -1
- package/dist/store/schema.js +10 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +19 -2
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { WORKER_APP_FUNCTION_SIGNATURE, WORKER_APP_TABLE_NAME } from '../entry-rules.js';
|
|
3
|
+
import { WORKER_APP_MIGRATION_TIMEOUT_MS, parseMigrations, runWorkerAppMigrations, verifyMigrationsDigest, } from '../worker-app-migrations.js';
|
|
4
|
+
/** The ledger source the release's migrations are recorded under. */
|
|
5
|
+
export const RELEASE_MIGRATION_SOURCE = 'release';
|
|
6
|
+
const CALL_TIMEOUT_MS = 120_000;
|
|
7
|
+
const RUNTIME_USER = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
8
|
+
const RUNTIME_PASSWORD_MIN = 16;
|
|
9
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
10
|
+
const MIGRATION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
11
|
+
const LIST_MAX = 100;
|
|
12
|
+
const ABORT_WAIT_MS = 5_000;
|
|
13
|
+
const NO_TRANSACTION_MARKER = '-- db:no-transaction';
|
|
14
|
+
/** The comment a runtime role this function made carries: it names the database it was made for. */
|
|
15
|
+
export const runtimeRoleMarker = (database) => `@wtfalch/foundry worker app runtime role for database ${database}`;
|
|
16
|
+
/** The ledger's checksum of a migration's text, as `@wtfalch/db` writes it (CRLF read as LF). */
|
|
17
|
+
export const migrationChecksum = (sql) => createHash('sha256').update(sql.replaceAll('\r\n', '\n')).digest('hex');
|
|
18
|
+
const runsOutsideTransaction = (sql) => sql.split('\n', 1)[0]?.replace(/\r$/, '') === NO_TRANSACTION_MARKER;
|
|
19
|
+
/** A refusal or a failure, already in plain words. */
|
|
20
|
+
class Stop extends Error {
|
|
21
|
+
step;
|
|
22
|
+
constructor(step, message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.step = step;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Thrown by the default runner when the ledger holds another text for an applied id. */
|
|
28
|
+
const CHANGED = 'migration_changed';
|
|
29
|
+
const sqlState = (error) => {
|
|
30
|
+
const code = error?.code;
|
|
31
|
+
return typeof code === 'string' && /^[0-9A-Z]{5}$/.test(code) ? code : null;
|
|
32
|
+
};
|
|
33
|
+
function parseAddress(url, which) {
|
|
34
|
+
const refuse = () => new Stop('check', `The ${which} is not a Postgres address. It looks like postgres://user:password@host:5432/name.`);
|
|
35
|
+
if (typeof url !== 'string')
|
|
36
|
+
throw refuse();
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = new URL(url);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw refuse();
|
|
43
|
+
}
|
|
44
|
+
if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:')
|
|
45
|
+
throw refuse();
|
|
46
|
+
try {
|
|
47
|
+
const address = {
|
|
48
|
+
host: parsed.hostname,
|
|
49
|
+
port: parsed.port || '5432',
|
|
50
|
+
database: decodeURIComponent(parsed.pathname.replace(/^\//, '')),
|
|
51
|
+
user: decodeURIComponent(parsed.username),
|
|
52
|
+
password: decodeURIComponent(parsed.password),
|
|
53
|
+
};
|
|
54
|
+
if (!address.host || !address.database || !address.user || !address.password)
|
|
55
|
+
throw refuse();
|
|
56
|
+
return address;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw refuse();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function check(input) {
|
|
63
|
+
const owner = parseAddress(input.ownerUrl, 'owner address of the database');
|
|
64
|
+
const runtime = parseAddress(input.runtimeUrl, 'app’s database address');
|
|
65
|
+
if (owner.host !== runtime.host ||
|
|
66
|
+
owner.port !== runtime.port ||
|
|
67
|
+
owner.database !== runtime.database) {
|
|
68
|
+
throw new Stop('check', 'The owner address and the app’s address must name the same database on the same server.');
|
|
69
|
+
}
|
|
70
|
+
if (owner.user === runtime.user) {
|
|
71
|
+
throw new Stop('check', 'The app must not run as the database’s owner. Give the app its own user in its address.');
|
|
72
|
+
}
|
|
73
|
+
if (!RUNTIME_USER.test(runtime.user)) {
|
|
74
|
+
throw new Stop('check', 'The app’s database user must be lower-case letters, digits and _ (at most 63), starting with a letter or _.');
|
|
75
|
+
}
|
|
76
|
+
if (runtime.user === 'postgres' || runtime.user.startsWith('pg_')) {
|
|
77
|
+
throw new Stop('check', 'The app’s database user may not be postgres or start with pg_: those names belong to the server. Give the app its own user in its address.');
|
|
78
|
+
}
|
|
79
|
+
if (runtime.password.length < RUNTIME_PASSWORD_MIN) {
|
|
80
|
+
throw new Stop('check', `The app’s database password must be at least ${RUNTIME_PASSWORD_MIN} characters.`);
|
|
81
|
+
}
|
|
82
|
+
const appendOnly = input.appendOnly ?? [];
|
|
83
|
+
const grants = input.grants ?? [];
|
|
84
|
+
if (!Array.isArray(appendOnly) || appendOnly.length > LIST_MAX) {
|
|
85
|
+
throw new Stop('check', `At most ${LIST_MAX} append-only tables.`);
|
|
86
|
+
}
|
|
87
|
+
for (const table of appendOnly) {
|
|
88
|
+
if (typeof table !== 'string' || !WORKER_APP_TABLE_NAME.test(table)) {
|
|
89
|
+
throw new Stop('check', `${String(table).slice(0, 80)} is not a table name (letters, digits and _, optionally schema.table).`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(grants) || grants.length > LIST_MAX) {
|
|
93
|
+
throw new Stop('check', `At most ${LIST_MAX} granted functions.`);
|
|
94
|
+
}
|
|
95
|
+
for (const signature of grants) {
|
|
96
|
+
if (typeof signature !== 'string' || !WORKER_APP_FUNCTION_SIGNATURE.test(signature)) {
|
|
97
|
+
throw new Stop('check', `${String(signature).slice(0, 80)} is not a function signature (name(type, ...) or schema.name(type, ...)).`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (input.migrationsSha256 !== undefined && !SHA256.test(input.migrationsSha256)) {
|
|
101
|
+
throw new Stop('check', 'The migrations checksum in build.json is not a sha256 (64 lower-case hex characters).');
|
|
102
|
+
}
|
|
103
|
+
// A checksum names a file: without the file the release's database changes would be skipped.
|
|
104
|
+
if (input.migrationsJson === null && input.migrationsSha256 !== undefined) {
|
|
105
|
+
throw new Stop('check', 'The build says it has database changes, but its migrations.json could not be read.');
|
|
106
|
+
}
|
|
107
|
+
// The file before any connection: the same pure checks the shared run makes, in words.
|
|
108
|
+
try {
|
|
109
|
+
verifyMigrationsDigest(input.migrationsJson, input.migrationsSha256);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new Stop('check', input.migrationsSha256
|
|
113
|
+
? 'The build’s migrations.json is not the one build.json names (its checksum differs). Publish the release again.'
|
|
114
|
+
: 'The build has a migrations.json, but its build.json names no checksum for it (database.migrationsSha256). Publish the release again with it.');
|
|
115
|
+
}
|
|
116
|
+
let migrations;
|
|
117
|
+
try {
|
|
118
|
+
migrations = parseMigrations(input.migrationsJson);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
// parseMigrations names the file and an entry index, never SQL.
|
|
122
|
+
throw new Stop('check', `The build’s ${error.message}.`);
|
|
123
|
+
}
|
|
124
|
+
return { owner, runtime, migrations };
|
|
125
|
+
}
|
|
126
|
+
function scrub(text, hidden) {
|
|
127
|
+
let out = text;
|
|
128
|
+
for (const value of hidden)
|
|
129
|
+
if (value.length >= 4)
|
|
130
|
+
out = out.split(value).join('[hidden]');
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Runs the release's database changes into the company's Postgres and makes the app's role
|
|
135
|
+
* true. Rerunning is safe: applied migrations are skipped by the ledger.
|
|
136
|
+
*/
|
|
137
|
+
export async function applyWorkerAppMigrations(input, deps = {}) {
|
|
138
|
+
const hidden = [];
|
|
139
|
+
const steps = [];
|
|
140
|
+
const ran = [];
|
|
141
|
+
try {
|
|
142
|
+
for (const value of [input.ownerUrl, input.runtimeUrl]) {
|
|
143
|
+
if (typeof value === 'string')
|
|
144
|
+
hidden.push(value);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// An input we cannot read is refused below.
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const { owner, runtime, migrations } = check(input);
|
|
152
|
+
hidden.push(owner.password, runtime.password);
|
|
153
|
+
const outsideTransaction = new Set(migrations.filter((m) => runsOutsideTransaction(m.sql)).map((m) => m.id));
|
|
154
|
+
const sql = deps.sql ?? postgresMigrationRunner();
|
|
155
|
+
const ms = deps.timeoutMs ?? CALL_TIMEOUT_MS;
|
|
156
|
+
const ownerAddress = { url: input.ownerUrl };
|
|
157
|
+
const appendOnly = input.appendOnly ?? [];
|
|
158
|
+
const grants = input.grants ?? [];
|
|
159
|
+
const call = async (label, task, over) => {
|
|
160
|
+
const step = label.startsWith('apply migration')
|
|
161
|
+
? 'migrations'
|
|
162
|
+
: label.startsWith('ensure')
|
|
163
|
+
? 'role'
|
|
164
|
+
: 'database';
|
|
165
|
+
const id = /'([^']+)'/.exec(label)?.[1] ?? null;
|
|
166
|
+
let timer;
|
|
167
|
+
try {
|
|
168
|
+
const running = task();
|
|
169
|
+
running.catch(() => { });
|
|
170
|
+
return await Promise.race([
|
|
171
|
+
running,
|
|
172
|
+
new Promise((_, reject) => {
|
|
173
|
+
timer = setTimeout(() => reject(new Error('timeout')), over ?? ms);
|
|
174
|
+
}),
|
|
175
|
+
]);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error instanceof Error && error.message === 'timeout') {
|
|
179
|
+
// The work may still be running: stop it on the server, so it ends (or commits) now.
|
|
180
|
+
let wait;
|
|
181
|
+
await Promise.race([
|
|
182
|
+
sql.abort().catch(() => { }),
|
|
183
|
+
new Promise((resolve) => {
|
|
184
|
+
wait = setTimeout(resolve, ABORT_WAIT_MS);
|
|
185
|
+
}),
|
|
186
|
+
]);
|
|
187
|
+
clearTimeout(wait);
|
|
188
|
+
}
|
|
189
|
+
throw failed(step, id, error, id !== null && outsideTransaction.has(id));
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
// A role someone else made would be handed this app's password and grants.
|
|
196
|
+
const origin = await call('ensure: read the app’s database user', () => sql.runtimeRoleOrigin(ownerAddress, runtime.user));
|
|
197
|
+
if (origin === 'other') {
|
|
198
|
+
throw new Stop('role', `The database already has a user ${runtime.user} that was not made for this app. Give the app another user in its address, or remove that user from the database, then try again.`);
|
|
199
|
+
}
|
|
200
|
+
// A change that ran is never edited: the ledger's checksum is the text it ran with.
|
|
201
|
+
const checksums = await call('read the applied migrations', () => sql.appliedChecksums(ownerAddress));
|
|
202
|
+
for (const migration of migrations) {
|
|
203
|
+
const stored = checksums[migration.id];
|
|
204
|
+
if (stored && stored !== migrationChecksum(migration.sql)) {
|
|
205
|
+
throw new Stop('migrations', editedMessage(migration.id));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const result = await runWorkerAppMigrations(sql, ownerAddress, input.migrationsJson, {
|
|
209
|
+
appendOnly,
|
|
210
|
+
grants,
|
|
211
|
+
...(input.migrationsSha256 ? { migrationsSha256: input.migrationsSha256 } : {}),
|
|
212
|
+
}, { user: runtime.user, password: runtime.password }, {
|
|
213
|
+
at: 'database changes',
|
|
214
|
+
call,
|
|
215
|
+
migrationTimeoutMs: deps.migrationTimeoutMs ?? WORKER_APP_MIGRATION_TIMEOUT_MS,
|
|
216
|
+
onApplied: async (done) => {
|
|
217
|
+
const id = done.at(-1);
|
|
218
|
+
if (id) {
|
|
219
|
+
ran.push(id);
|
|
220
|
+
steps.push(`Ran the database change ${id}`);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
}).catch((error) => {
|
|
224
|
+
if (error instanceof Stop)
|
|
225
|
+
throw error;
|
|
226
|
+
// The only check left after `check`: an applied migration moved or left the file.
|
|
227
|
+
throw new Stop('migrations', 'A database change that already ran is no longer in the same place in migrations.json. Changes are only ever added at the end; restore the file’s earlier order in a new release.');
|
|
228
|
+
});
|
|
229
|
+
const held = await call('ensure: check the app’s database user', () => sql.runtimeRolePrivileges(ownerAddress, { user: runtime.user, appendOnly, grants }));
|
|
230
|
+
proveRole(held, appendOnly, grants);
|
|
231
|
+
if (result.ran.length === 0)
|
|
232
|
+
steps.push('The database already had every change of this build');
|
|
233
|
+
steps.push(`Set up the app’s database user ${runtime.user}`);
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
applied: result.applied,
|
|
237
|
+
ran: result.ran,
|
|
238
|
+
steps: steps.map((s) => scrub(s, hidden)),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
const stop = error instanceof Stop
|
|
243
|
+
? error
|
|
244
|
+
: new Stop('check', 'Something went wrong while changing the database. Try again.');
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
step: stop.step,
|
|
248
|
+
message: scrub(stop.message, hidden),
|
|
249
|
+
ran,
|
|
250
|
+
steps: steps.map((s) => scrub(s, hidden)),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const named = (id) => (id && MIGRATION_ID.test(id) ? ` ${id}` : '');
|
|
255
|
+
const editedMessage = (id) => `The database change${named(id)} was edited after it ran. A change that ran is never edited: add a new one in a new release.`;
|
|
256
|
+
/** The role as read back must be what build.json asks for; else the `role` step fails, naming what. */
|
|
257
|
+
function proveRole(held, appendOnly, grants) {
|
|
258
|
+
for (const name of appendOnly) {
|
|
259
|
+
const table = held.tables.find((t) => t.name === name);
|
|
260
|
+
if (!table?.exists) {
|
|
261
|
+
throw new Stop('role', `The append-only table ${name} is not in the database after its changes ran. Write it in build.json (database.appendOnly) as the table is named, then publish the release again.`);
|
|
262
|
+
}
|
|
263
|
+
if (table.update || table.delete || table.truncate) {
|
|
264
|
+
throw new Stop('role', `The app’s database user can still change or delete rows of ${name}, which build.json says only grows. Write the name in build.json as the table is named, and remove any grant on it to PUBLIC or to a role that user belongs to; then try again.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const signature of grants) {
|
|
268
|
+
const fn = held.functions.find((f) => f.signature === signature);
|
|
269
|
+
if (!fn?.exists) {
|
|
270
|
+
throw new Stop('role', `The function ${signature} that build.json grants (database.grants) is not in the database after its changes ran.`);
|
|
271
|
+
}
|
|
272
|
+
if (!fn.execute) {
|
|
273
|
+
throw new Stop('role', `The app’s database user could not be allowed to run ${signature}. The owner address must be able to grant it; check it under Database, then try again.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function failed(step, id, error, outsideTransaction = false) {
|
|
278
|
+
const timedOut = error instanceof Error && error.message === 'timeout';
|
|
279
|
+
const state = sqlState(error);
|
|
280
|
+
const tail = state ? ` (the database answered ${state})` : '';
|
|
281
|
+
if (step === 'migrations') {
|
|
282
|
+
if (error?.code === CHANGED) {
|
|
283
|
+
return new Stop(step, editedMessage(id));
|
|
284
|
+
}
|
|
285
|
+
if (outsideTransaction) {
|
|
286
|
+
return new Stop(step, `${timedOut
|
|
287
|
+
? `The database change${named(id)} did not finish in time, so we asked the database to stop it.`
|
|
288
|
+
: `The database change${named(id)} failed${tail}.`} It runs outside a transaction (-- db:no-transaction), so part of it may have been kept; the changes before it stay. Check the database before you try again.`);
|
|
289
|
+
}
|
|
290
|
+
return new Stop(step, timedOut
|
|
291
|
+
? `The database change${named(id)} did not finish in time, so we asked the database to stop it. It may or may not have finished; running this again is safe, a change that finished is not run twice.`
|
|
292
|
+
: `The database change${named(id)} failed${tail}. Nothing of it was kept and the changes before it stay. Fix it in a new release, then try again.`);
|
|
293
|
+
}
|
|
294
|
+
if (step === 'role') {
|
|
295
|
+
return new Stop(step, timedOut
|
|
296
|
+
? 'The database did not answer in time while we set up the app’s database user. It may or may not have finished; running this again is safe.'
|
|
297
|
+
: `We could not set up the app’s database user${tail}. The owner address must be able to create roles and grant on the tables; check it under Database, then try again.`);
|
|
298
|
+
}
|
|
299
|
+
return new Stop(step, timedOut
|
|
300
|
+
? 'The database did not answer in time. Check that it takes connections from Boule, then try again.'
|
|
301
|
+
: `We could not read the database as its owner${tail}. Check the owner address under Database, then try again.`);
|
|
302
|
+
}
|
|
303
|
+
/** The default runner, on `@wtfalch/db` (loaded on first use, so only a caller of it needs the package). */
|
|
304
|
+
export function postgresMigrationRunner() {
|
|
305
|
+
/** Each connection open now, with the address it was opened on and its server process. */
|
|
306
|
+
const open = new Map();
|
|
307
|
+
const runtimeUrlFor = (ownerUrl, user, password) => {
|
|
308
|
+
const url = new URL(ownerUrl);
|
|
309
|
+
url.username = encodeURIComponent(user);
|
|
310
|
+
url.password = encodeURIComponent(password);
|
|
311
|
+
return url.toString();
|
|
312
|
+
};
|
|
313
|
+
/** One connection as the owner for `work`, closed after; `abort` closes it sooner. */
|
|
314
|
+
const withOwner = async (url, work) => {
|
|
315
|
+
const { createDatabase } = await import('@wtfalch/db/postgres');
|
|
316
|
+
const owner = createDatabase({
|
|
317
|
+
url,
|
|
318
|
+
max: 1,
|
|
319
|
+
connectTimeoutMs: 15_000,
|
|
320
|
+
applicationName: 'foundry-app-migrations',
|
|
321
|
+
});
|
|
322
|
+
const entry = { url, pid: null };
|
|
323
|
+
open.set(owner, entry);
|
|
324
|
+
try {
|
|
325
|
+
const [backend] = await owner.database.query('select pg_backend_pid() as pid');
|
|
326
|
+
entry.pid = backend?.pid ?? null;
|
|
327
|
+
return await work(owner);
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
open.delete(owner);
|
|
331
|
+
await owner.close().catch(() => { });
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const ledger = async (owner) => {
|
|
335
|
+
const [table] = await owner.database.query(`select to_regclass('_migrations_sources') is not null as present`);
|
|
336
|
+
if (!table?.present)
|
|
337
|
+
return [];
|
|
338
|
+
return owner.database.query('select filename, checksum from _migrations_sources where source = $1 order by applied_at, filename', [RELEASE_MIGRATION_SOURCE]);
|
|
339
|
+
};
|
|
340
|
+
return {
|
|
341
|
+
async appliedMigrationIds({ url }) {
|
|
342
|
+
return withOwner(url, async (owner) => (await ledger(owner)).map((row) => row.filename));
|
|
343
|
+
},
|
|
344
|
+
async appliedChecksums({ url }) {
|
|
345
|
+
return withOwner(url, async (owner) => Object.fromEntries((await ledger(owner)).map((row) => [row.filename, row.checksum])));
|
|
346
|
+
},
|
|
347
|
+
async applyMigration({ url }, migration) {
|
|
348
|
+
const { runMigrationSources } = await import('@wtfalch/db/migrate');
|
|
349
|
+
try {
|
|
350
|
+
// On our own connection, so a timeout can close it (`abort`).
|
|
351
|
+
await withOwner(url, (owner) => runMigrationSources({
|
|
352
|
+
owner,
|
|
353
|
+
sources: [
|
|
354
|
+
{
|
|
355
|
+
name: RELEASE_MIGRATION_SOURCE,
|
|
356
|
+
steps: [{ filename: migration.id, sql: migration.sql }],
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
log: () => { },
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
if (/^migrate: checksum mismatch/.test(error instanceof Error ? error.message : '')) {
|
|
364
|
+
throw Object.assign(new Error('changed'), { code: CHANGED });
|
|
365
|
+
}
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
async runtimeRoleOrigin({ url }, user) {
|
|
370
|
+
return withOwner(url, async (owner) => {
|
|
371
|
+
const [row] = await owner.database.query(`select shobj_description(oid, 'pg_authid') as comment from pg_roles where rolname = $1`, [user]);
|
|
372
|
+
if (!row)
|
|
373
|
+
return 'absent';
|
|
374
|
+
const database = decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
|
|
375
|
+
return row.comment === runtimeRoleMarker(database) ? 'ours' : 'other';
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
async ensureRuntimeRole({ owner, runtime, appendOnly, grants }) {
|
|
379
|
+
const { ensureRuntimeRole } = await import('@wtfalch/db/runtime-role');
|
|
380
|
+
// Not abortable: `@wtfalch/db` opens its own connection. Every statement of it is idempotent.
|
|
381
|
+
await ensureRuntimeRole({
|
|
382
|
+
ownerUrl: owner.url,
|
|
383
|
+
runtimeUrl: runtimeUrlFor(owner.url, runtime.user, runtime.password),
|
|
384
|
+
appendOnly,
|
|
385
|
+
grants,
|
|
386
|
+
log: () => { },
|
|
387
|
+
});
|
|
388
|
+
await withOwner(owner.url, async (db) => {
|
|
389
|
+
const database = decodeURIComponent(new URL(owner.url).pathname.replace(/^\//, ''));
|
|
390
|
+
const [quoted] = await db.database.query('select quote_ident($1::text) as role, quote_literal($2::text) as note', [runtime.user, runtimeRoleMarker(database)]);
|
|
391
|
+
if (!quoted)
|
|
392
|
+
throw new Error('quote failed');
|
|
393
|
+
await db.database.query(`comment on role ${quoted.role} is ${quoted.note}`);
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
async runtimeRolePrivileges({ url }, { user, appendOnly, grants }) {
|
|
397
|
+
return withOwner(url, async (owner) => {
|
|
398
|
+
// A right the role holds itself, through PUBLIC, or through a role it belongs to (a
|
|
399
|
+
// NOINHERIT member can still SET ROLE to it).
|
|
400
|
+
const holds = (privilege) => `(
|
|
401
|
+
has_table_privilege($1::name, c.oid, '${privilege}') or exists (
|
|
402
|
+
select 1 from pg_roles r
|
|
403
|
+
where r.rolname <> $1::name and pg_has_role($1::name, r.oid, 'MEMBER')
|
|
404
|
+
and has_table_privilege(r.oid, c.oid, '${privilege}')))`;
|
|
405
|
+
const tables = [];
|
|
406
|
+
for (const name of appendOnly) {
|
|
407
|
+
const [row] = await owner.database.query(`select c.oid is not null as exists,
|
|
408
|
+
coalesce(${holds('UPDATE')}, false) as update,
|
|
409
|
+
coalesce(${holds('DELETE')}, false) as delete,
|
|
410
|
+
coalesce(${holds('TRUNCATE')}, false) as truncate
|
|
411
|
+
from (select to_regclass($2::text) as oid) c`, [user, name]);
|
|
412
|
+
tables.push({
|
|
413
|
+
name,
|
|
414
|
+
exists: row?.exists ?? false,
|
|
415
|
+
update: row?.update ?? true,
|
|
416
|
+
delete: row?.delete ?? true,
|
|
417
|
+
truncate: row?.truncate ?? true,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const functions = [];
|
|
421
|
+
for (const signature of grants) {
|
|
422
|
+
const [row] = await owner.database.query(`select p.oid is not null as exists,
|
|
423
|
+
coalesce(has_function_privilege($1::name, p.oid, 'EXECUTE'), false) as execute
|
|
424
|
+
from (select to_regprocedure($2::text) as oid) p`, [user, signature]);
|
|
425
|
+
functions.push({
|
|
426
|
+
signature,
|
|
427
|
+
exists: row?.exists ?? false,
|
|
428
|
+
execute: row?.execute ?? false,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return { tables, functions };
|
|
432
|
+
});
|
|
433
|
+
},
|
|
434
|
+
async abort() {
|
|
435
|
+
// A closed socket alone does not stop a running statement (the server notices only when it
|
|
436
|
+
// next writes), and ending the connection under a transaction makes the driver (postgres
|
|
437
|
+
// 3.4.9) write its ROLLBACK to a null socket, a throw outside any promise. So the running
|
|
438
|
+
// statement is cancelled, from a connection of its own: the work rejects (57014), the
|
|
439
|
+
// transaction rolls back on its live connection, and `withOwner` closes it as usual.
|
|
440
|
+
// A connection whose process is not known yet, or that cannot be reached, is left to finish.
|
|
441
|
+
const { createDatabase } = await import('@wtfalch/db/postgres');
|
|
442
|
+
await Promise.all([...open.values()].map(async ({ url, pid }) => {
|
|
443
|
+
if (pid === null)
|
|
444
|
+
return;
|
|
445
|
+
const killer = createDatabase({ url, max: 1, connectTimeoutMs: 5_000 });
|
|
446
|
+
try {
|
|
447
|
+
await killer.database.query('select pg_cancel_backend($1::int)', [pid]);
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
// Left to finish: the message already says it may or may not have.
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
await killer.close().catch(() => { });
|
|
454
|
+
}
|
|
455
|
+
}));
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Handle } from '@wtfalch/audit-sealing';
|
|
2
|
+
import type { AppLedger } from './registry.js';
|
|
3
|
+
/** An open connection to one app ledger's database, as the sealing role. */
|
|
4
|
+
export interface LedgerConnection {
|
|
5
|
+
handle: Handle;
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/** The ledger cannot be reached now; `why` is plain words and holds no address or credential. */
|
|
9
|
+
export interface LedgerUnavailable {
|
|
10
|
+
unavailable: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* How the sealing service reaches an app ledger. Host-implemented, like the other ports: the host holds the
|
|
14
|
+
* app database's owner address (the one `applyWorkerAppMigrations` uses) and opens it as a role that may run
|
|
15
|
+
* `audit_chain_leaves`, `audit_retire_signing_key` and insert into `audit_checkpoints`, `audit_signing_keys` and
|
|
16
|
+
* `audit_anchors` (`AUDIT_SEALER_GRANTS`). It is never the app's runtime role and never the company's developers'.
|
|
17
|
+
*/
|
|
18
|
+
export interface LedgerConnector {
|
|
19
|
+
open(ledger: AppLedger): Promise<LedgerConnection | LedgerUnavailable>;
|
|
20
|
+
}
|
|
21
|
+
export declare const isUnavailable: (value: LedgerConnection | LedgerUnavailable) => value is LedgerUnavailable;
|
|
22
|
+
/** What the host's SQL driver needs to connect as one ledger's sealing role. The password is never logged or returned. */
|
|
23
|
+
export interface SealerLogin {
|
|
24
|
+
ledger: AppLedger;
|
|
25
|
+
user: string;
|
|
26
|
+
password: string;
|
|
27
|
+
}
|
|
28
|
+
export interface LedgerConnectorDeps {
|
|
29
|
+
heldKeys: {
|
|
30
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
31
|
+
};
|
|
32
|
+
/** Registers a value so no log, error or result carries it. */
|
|
33
|
+
secret?: (value: string) => void;
|
|
34
|
+
/** The host's driver: reaches the app database (over its Access-protected route) as the given role. */
|
|
35
|
+
connect(login: SealerLogin): Promise<LedgerConnection | LedgerUnavailable>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 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
|
|
40
|
+
* whose role or password is not there yet is unavailable with the reason, and nothing is opened. It never falls
|
|
41
|
+
* back to another credential: the app's runtime role cannot seal, and the owner is not used to seal.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createLedgerConnector(deps: LedgerConnectorDeps): LedgerConnector;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { auditSealerRef } from './sealer-role.js';
|
|
2
|
+
export const isUnavailable = (value) => 'unavailable' in value;
|
|
3
|
+
/**
|
|
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
|
|
6
|
+
* whose role or password is not there yet is unavailable with the reason, and nothing is opened. It never falls
|
|
7
|
+
* back to another credential: the app's runtime role cannot seal, and the owner is not used to seal.
|
|
8
|
+
*/
|
|
9
|
+
export function createLedgerConnector(deps) {
|
|
10
|
+
return {
|
|
11
|
+
async open(ledger) {
|
|
12
|
+
if (!ledger.sealerRole) {
|
|
13
|
+
return { unavailable: 'the sealing role has not been made for it yet' };
|
|
14
|
+
}
|
|
15
|
+
const password = await deps.heldKeys.resolve(auditSealerRef(ledger.orgId, ledger.entryKey));
|
|
16
|
+
if (!password)
|
|
17
|
+
return { unavailable: 'the sealing credential is not held' };
|
|
18
|
+
deps.secret?.(password);
|
|
19
|
+
return deps.connect({ ledger, user: ledger.sealerRole, password });
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { BUNDLE_FORMAT, type Checkpoint } from '@wtfalch/audit-sealing';
|
|
2
|
+
import { type LedgerConnector } from './connector.js';
|
|
3
|
+
import { type AppLedger } from './registry.js';
|
|
4
|
+
/**
|
|
5
|
+
* What Boule's audit screen reads for one app ledger (foundry#929, boule#363). Read only: no method here writes
|
|
6
|
+
* to a ledger. Entries are not served here: they stay behind the app's own `ledgerReadHandler` route, which
|
|
7
|
+
* checks the person's grant, and Boule reads them with `@wtfalch/audit/read`'s `fetchMergedLedgerPage`.
|
|
8
|
+
*/
|
|
9
|
+
export interface AnchorView {
|
|
10
|
+
checkpointHash: string;
|
|
11
|
+
provider: string;
|
|
12
|
+
/** The authority's time, `Date#toISOString()`. */
|
|
13
|
+
anchoredAt: string;
|
|
14
|
+
}
|
|
15
|
+
export interface LedgerStatus {
|
|
16
|
+
ledger: string;
|
|
17
|
+
/** The platform signer's public key, for the trusted-keys file a verifier is handed. */
|
|
18
|
+
signerPublicKey: string;
|
|
19
|
+
retention: string;
|
|
20
|
+
checkpointCount: number;
|
|
21
|
+
latest: Checkpoint | null;
|
|
22
|
+
anchoredCheckpoints: number;
|
|
23
|
+
}
|
|
24
|
+
export interface EvidenceBundle {
|
|
25
|
+
format: typeof BUNDLE_FORMAT;
|
|
26
|
+
/** File name -> text. Written to a folder as is, it is what `audit-verify-bundle` reads. */
|
|
27
|
+
files: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
export type EvidenceResult<T> = {
|
|
30
|
+
ok: true;
|
|
31
|
+
value: T;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
why: string;
|
|
35
|
+
};
|
|
36
|
+
export interface AuditEvidence {
|
|
37
|
+
status(ledger: AppLedger): Promise<EvidenceResult<LedgerStatus>>;
|
|
38
|
+
checkpoints(ledger: AppLedger): Promise<EvidenceResult<Checkpoint[]>>;
|
|
39
|
+
anchors(ledger: AppLedger): Promise<EvidenceResult<AnchorView[]>>;
|
|
40
|
+
/** The offline-verifiable export, whole or `from`..`to` (sequence numbers). */
|
|
41
|
+
bundle(ledger: AppLedger, range?: {
|
|
42
|
+
from?: number;
|
|
43
|
+
to?: number;
|
|
44
|
+
}): Promise<EvidenceResult<EvidenceBundle>>;
|
|
45
|
+
}
|
|
46
|
+
export declare function createAuditEvidence(deps: {
|
|
47
|
+
connector: LedgerConnector;
|
|
48
|
+
signerPublicKey: string;
|
|
49
|
+
}): AuditEvidence;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { BUNDLE_FORMAT, auditAnchors, buildBundle, listCheckpoints, } from '@wtfalch/audit-sealing';
|
|
2
|
+
import { isUnavailable } from './connector.js';
|
|
3
|
+
import { describeRetention } from './registry.js';
|
|
4
|
+
async function readAnchors(handle) {
|
|
5
|
+
const rows = await handle.select().from(auditAnchors);
|
|
6
|
+
return rows
|
|
7
|
+
.map((row) => ({
|
|
8
|
+
checkpointHash: row.checkpointHash,
|
|
9
|
+
provider: row.provider,
|
|
10
|
+
anchoredAt: row.anchoredAt.toISOString(),
|
|
11
|
+
}))
|
|
12
|
+
.sort((a, b) => a.anchoredAt.localeCompare(b.anchoredAt));
|
|
13
|
+
}
|
|
14
|
+
export function createAuditEvidence(deps) {
|
|
15
|
+
async function withLedger(ledger, read) {
|
|
16
|
+
let connection;
|
|
17
|
+
try {
|
|
18
|
+
connection = await deps.connector.open(ledger);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { ok: false, why: 'The log could not be reached. Try again in a minute.' };
|
|
22
|
+
}
|
|
23
|
+
if (isUnavailable(connection))
|
|
24
|
+
return { ok: false, why: connection.unavailable };
|
|
25
|
+
try {
|
|
26
|
+
return { ok: true, value: await read(connection.handle) };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { ok: false, why: 'The log could not be read. Try again in a minute.' };
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await connection.close().catch(() => undefined);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
status: (ledger) => withLedger(ledger, async (handle) => {
|
|
37
|
+
const checkpoints = await listCheckpoints(handle);
|
|
38
|
+
const anchors = await readAnchors(handle);
|
|
39
|
+
const anchored = new Set(anchors.map((a) => a.checkpointHash));
|
|
40
|
+
return {
|
|
41
|
+
ledger: ledger.ledger,
|
|
42
|
+
signerPublicKey: deps.signerPublicKey,
|
|
43
|
+
retention: describeRetention(ledger.retention),
|
|
44
|
+
checkpointCount: checkpoints.length,
|
|
45
|
+
latest: checkpoints.at(-1) ?? null,
|
|
46
|
+
anchoredCheckpoints: checkpoints.filter((c) => anchored.has(c.checkpoint_hash)).length,
|
|
47
|
+
};
|
|
48
|
+
}),
|
|
49
|
+
checkpoints: (ledger) => withLedger(ledger, (handle) => listCheckpoints(handle)),
|
|
50
|
+
anchors: (ledger) => withLedger(ledger, readAnchors),
|
|
51
|
+
bundle: (ledger, range) => withLedger(ledger, async (handle) => ({
|
|
52
|
+
format: BUNDLE_FORMAT,
|
|
53
|
+
files: await buildBundle(handle, { ledger: ledger.ledger, ...range }),
|
|
54
|
+
})),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './connector.js';
|
|
2
|
+
export * from './evidence.js';
|
|
3
|
+
export * from './registry.js';
|
|
4
|
+
export * from './seal.js';
|
|
5
|
+
export * from './sealer-role.js';
|
|
6
|
+
export * from './signer.js';
|
|
7
|
+
export * from './tick.js';
|
|
8
|
+
export { AUDIT_PACKAGE_VERSION, AUDIT_APPEND_ONLY_TABLES, AUDIT_APP_GRANTS, AUDIT_SEALER_GRANTS, withAuditDatabase, } from '../modules/audit-ledger.js';
|
|
9
|
+
export { auditMigrations } from './migrations.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './connector.js';
|
|
2
|
+
export * from './evidence.js';
|
|
3
|
+
export * from './registry.js';
|
|
4
|
+
export * from './seal.js';
|
|
5
|
+
export * from './sealer-role.js';
|
|
6
|
+
export * from './signer.js';
|
|
7
|
+
export * from './tick.js';
|
|
8
|
+
export { AUDIT_PACKAGE_VERSION, AUDIT_APPEND_ONLY_TABLES, AUDIT_APP_GRANTS, AUDIT_SEALER_GRANTS, withAuditDatabase, } from '../modules/audit-ledger.js';
|
|
9
|
+
export { auditMigrations } from './migrations.js';
|