@wtfalch/foundry 0.68.2 → 0.69.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/audit/seal.d.ts +2 -0
- package/dist/audit/seal.js +50 -4
- package/dist/audit/sealer-role.d.ts +1 -1
- package/dist/audit/sealer-role.js +1 -1
- package/dist/audit/tick.js +1 -0
- package/dist/modules/audit-ledger.d.ts +7 -5
- package/dist/modules/audit-ledger.js +9 -5
- package/dist/plan/apps-db-route.js +8 -6
- package/dist/plan/worker-app.d.ts +2 -0
- package/dist/plan/worker-app.js +10 -0
- package/package.json +1 -1
package/dist/audit/seal.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface SealResult {
|
|
|
28
28
|
treeSize?: number;
|
|
29
29
|
/** How many checkpoints the authority stamped in this tick. */
|
|
30
30
|
anchored: number;
|
|
31
|
+
/** How many erased rows this tick sealed the erasure of; on a sweep that failed part way, the rows sealed before the failure. */
|
|
32
|
+
erasures: number;
|
|
31
33
|
/** Plain words on why a step did not happen. Present whenever `outcome` is not `sealed`, or anchoring did not run. */
|
|
32
34
|
reason?: string;
|
|
33
35
|
at: string;
|
package/dist/audit/seal.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { anchorCheckpoints, sealCheckpoint } from '@wtfalch/audit-sealing';
|
|
1
|
+
import { anchorCheckpoints, computeErasureHash, sealCheckpoint } from '@wtfalch/audit-sealing';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
2
3
|
import { isUnavailable } from './connector.js';
|
|
3
4
|
/**
|
|
4
5
|
* The sealing and anchoring tick (foundry#929, audit#56). The host calls {@link sealLedgers} on
|
|
@@ -51,6 +52,7 @@ async function sealOne(deps, ledger, now) {
|
|
|
51
52
|
...base,
|
|
52
53
|
outcome: 'failed',
|
|
53
54
|
anchored: 0,
|
|
55
|
+
erasures: 0,
|
|
54
56
|
reason: `could not open the ledger: ${plainFailure(error)}`,
|
|
55
57
|
at: now().toISOString(),
|
|
56
58
|
};
|
|
@@ -60,6 +62,7 @@ async function sealOne(deps, ledger, now) {
|
|
|
60
62
|
...base,
|
|
61
63
|
outcome: 'skipped',
|
|
62
64
|
anchored: 0,
|
|
65
|
+
erasures: 0,
|
|
63
66
|
reason: `not sealed, the ledger is not reachable: ${connection.unavailable}`,
|
|
64
67
|
at: now().toISOString(),
|
|
65
68
|
};
|
|
@@ -68,17 +71,33 @@ async function sealOne(deps, ledger, now) {
|
|
|
68
71
|
let outcome = 'unchanged';
|
|
69
72
|
let treeSize;
|
|
70
73
|
let anchored = 0;
|
|
74
|
+
let erasures = 0;
|
|
71
75
|
try {
|
|
76
|
+
const progress = { sealed: 0 };
|
|
77
|
+
try {
|
|
78
|
+
await sealErasures(connection.handle, progress);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (isMissingGrant(error)) {
|
|
82
|
+
notes.push("erasures not sealed yet: the sealing role gets the grant on the app's next apply");
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
outcome = 'failed';
|
|
86
|
+
notes.push(`sealing erasures failed: ${plainFailure(error)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
erasures = progress.sealed;
|
|
72
90
|
try {
|
|
73
91
|
const checkpoint = await sealCheckpoint(connection.handle, {
|
|
74
92
|
ledger: ledger.ledger,
|
|
75
93
|
signer: deps.signer,
|
|
76
94
|
});
|
|
77
95
|
if (checkpoint) {
|
|
78
|
-
outcome
|
|
96
|
+
if (outcome !== 'failed')
|
|
97
|
+
outcome = 'sealed';
|
|
79
98
|
treeSize = checkpoint.tree_size;
|
|
80
99
|
}
|
|
81
|
-
else {
|
|
100
|
+
else if (outcome !== 'failed') {
|
|
82
101
|
notes.push('nothing sealed, no entry since the last checkpoint');
|
|
83
102
|
}
|
|
84
103
|
}
|
|
@@ -87,7 +106,8 @@ async function sealOne(deps, ledger, now) {
|
|
|
87
106
|
...base,
|
|
88
107
|
outcome: 'failed',
|
|
89
108
|
anchored: 0,
|
|
90
|
-
|
|
109
|
+
erasures,
|
|
110
|
+
reason: [`sealing failed: ${plainFailure(error)}`, ...notes].join('; '),
|
|
91
111
|
at: now().toISOString(),
|
|
92
112
|
};
|
|
93
113
|
}
|
|
@@ -125,7 +145,33 @@ async function sealOne(deps, ledger, now) {
|
|
|
125
145
|
outcome,
|
|
126
146
|
...(treeSize === undefined ? {} : { treeSize }),
|
|
127
147
|
anchored,
|
|
148
|
+
erasures,
|
|
128
149
|
...(notes.length > 0 ? { reason: notes.join('; ') } : {}),
|
|
129
150
|
at: now().toISOString(),
|
|
130
151
|
};
|
|
131
152
|
}
|
|
153
|
+
const rowsOf = (result) => Array.isArray(result) ? result : (result.rows ?? []);
|
|
154
|
+
/**
|
|
155
|
+
* An existing sealing role gets the erasure grants only on its app's next apply, so 42501 (insufficient privilege)
|
|
156
|
+
* and 42883 (undefined function) mean "not yet", not broken. Read on the error or the driver error it wraps.
|
|
157
|
+
*/
|
|
158
|
+
function isMissingGrant(error) {
|
|
159
|
+
for (let e = error, depth = 0; e && depth < 3; depth++) {
|
|
160
|
+
const code = e.code;
|
|
161
|
+
if (code === '42501' || code === '42883')
|
|
162
|
+
return true;
|
|
163
|
+
e = e.cause;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
async function sealErasures(handle, progress) {
|
|
168
|
+
const pending = rowsOf(await handle.execute(sql `select id, row_hash, erased_at from audit_pending_erasures()`));
|
|
169
|
+
for (const row of pending) {
|
|
170
|
+
if (!row.row_hash || row.erased_at === null || row.erased_at === undefined)
|
|
171
|
+
continue;
|
|
172
|
+
const erasedAt = row.erased_at instanceof Date ? row.erased_at : new Date(String(row.erased_at));
|
|
173
|
+
const erasureHash = await computeErasureHash(row.row_hash, erasedAt);
|
|
174
|
+
await handle.execute(sql `select audit_seal_erasure(${row.id}, ${erasureHash})`);
|
|
175
|
+
progress.sealed++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -26,7 +26,7 @@ export interface SealerRoleSql {
|
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Makes `user` true: it can log in with `password`, is no superuser and bypasses no row security, may read and
|
|
29
|
-
* insert into the checkpoint, signing-key and anchor tables and run the
|
|
29
|
+
* insert into the checkpoint, signing-key and anchor tables and run the sealing functions (leaves, key retirement, pending erasures, erasure sealing), and holds nothing
|
|
30
30
|
* else. The grants are listed here and in `AUDIT_SEALER_GRANTS`; `audit/sealer-role.test.ts` runs a real
|
|
31
31
|
* checkpoint as this role to prove the list is enough.
|
|
32
32
|
*/
|
|
@@ -36,7 +36,7 @@ const ident = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
|
36
36
|
const literal = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
37
37
|
/**
|
|
38
38
|
* Makes `user` true: it can log in with `password`, is no superuser and bypasses no row security, may read and
|
|
39
|
-
* insert into the checkpoint, signing-key and anchor tables and run the
|
|
39
|
+
* insert into the checkpoint, signing-key and anchor tables and run the sealing functions (leaves, key retirement, pending erasures, erasure sealing), and holds nothing
|
|
40
40
|
* else. The grants are listed here and in `AUDIT_SEALER_GRANTS`; `audit/sealer-role.test.ts` runs a real
|
|
41
41
|
* checkpoint as this role to prove the list is enough.
|
|
42
42
|
*/
|
package/dist/audit/tick.js
CHANGED
|
@@ -12,13 +12,15 @@ export declare const AUDIT_PACKAGE_VERSION = "0.14.0";
|
|
|
12
12
|
/** Insert and read only: the runtime role has UPDATE, DELETE and TRUNCATE revoked on each. */
|
|
13
13
|
export declare const AUDIT_APPEND_ONLY_TABLES: readonly string[];
|
|
14
14
|
/**
|
|
15
|
-
* What the app's runtime role may run: the two functions the chain is written through. Not granted: the
|
|
16
|
-
* functions (`
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* What the app's runtime role may run: the two functions the chain is written through. Not granted: the sealing
|
|
16
|
+
* functions (`audit_chain_leaves`, `audit_retire_signing_key`) and the erasure sealing functions
|
|
17
|
+
* (`audit_pending_erasures`, `audit_seal_erasure`), which only the platform's sealing role runs. An app may list
|
|
18
|
+
* `audit_erase_person(text, text, text)` itself and nothing else from the erase set: it pseudonymises, and the
|
|
19
|
+
* sealing role seals each erasure in the next seal tick (William, 2026-10-26, settles foundry#929 item 10 for
|
|
20
|
+
* this case).
|
|
19
21
|
*/
|
|
20
22
|
export declare const AUDIT_APP_GRANTS: readonly string[];
|
|
21
|
-
/** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key. */
|
|
23
|
+
/** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key, seals erasures. */
|
|
22
24
|
export declare const AUDIT_SEALER_GRANTS: readonly string[];
|
|
23
25
|
type Database = NonNullable<WorkerAppConfig['database']>;
|
|
24
26
|
/**
|
|
@@ -16,16 +16,20 @@ export const AUDIT_APPEND_ONLY_TABLES = [
|
|
|
16
16
|
'audit_anchors',
|
|
17
17
|
];
|
|
18
18
|
/**
|
|
19
|
-
* What the app's runtime role may run: the two functions the chain is written through. Not granted: the
|
|
20
|
-
* functions (`
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* What the app's runtime role may run: the two functions the chain is written through. Not granted: the sealing
|
|
20
|
+
* functions (`audit_chain_leaves`, `audit_retire_signing_key`) and the erasure sealing functions
|
|
21
|
+
* (`audit_pending_erasures`, `audit_seal_erasure`), which only the platform's sealing role runs. An app may list
|
|
22
|
+
* `audit_erase_person(text, text, text)` itself and nothing else from the erase set: it pseudonymises, and the
|
|
23
|
+
* sealing role seals each erasure in the next seal tick (William, 2026-10-26, settles foundry#929 item 10 for
|
|
24
|
+
* this case).
|
|
23
25
|
*/
|
|
24
26
|
export const AUDIT_APP_GRANTS = ['audit_chain_tail()', 'audit_chain_tail_v2()'];
|
|
25
|
-
/** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key. */
|
|
27
|
+
/** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key, seals erasures. */
|
|
26
28
|
export const AUDIT_SEALER_GRANTS = [
|
|
27
29
|
'audit_chain_leaves(bigint, integer)',
|
|
28
30
|
'audit_retire_signing_key(text)',
|
|
31
|
+
'audit_pending_erasures()',
|
|
32
|
+
'audit_seal_erasure(bigint, text)',
|
|
29
33
|
];
|
|
30
34
|
const without = (list, drop) => (list ?? []).filter((item) => !drop.includes(item));
|
|
31
35
|
/**
|
|
@@ -49,7 +49,9 @@ export const APPS_DB_ROUTE_APPLIED_SCOPE_KEY = 'apps_db_route_applied';
|
|
|
49
49
|
export const appsDbHostname = (org) => hyperdriveHostname(org, APPS_DB_ENTRY_KEY, workerAppApex(org));
|
|
50
50
|
/** The applied marker: the tunnel hostname and the server it reaches. A route recorded with the hostname alone (the old Postgres server) differs, so it is planned again. */
|
|
51
51
|
export const appsDbRouteMarker = (org) => `${appsDbHostname(org)}|${appsDbName(org)}`;
|
|
52
|
-
|
|
52
|
+
// Read on use, not at load: `./audit` loaded first reaches this module before `worker-app.ts` has set
|
|
53
|
+
// APPS_DB_ENTRY_KEY (a cycle), and a top-level read then throws (0.68.2).
|
|
54
|
+
const routeKeys = () => remoteDatabaseKeys(APPS_DB_ENTRY_KEY);
|
|
53
55
|
/**
|
|
54
56
|
* Pure. `apply` once the company's apps are on and everything the route needs is recorded or planned;
|
|
55
57
|
* `retire` when they went off and the route is still recorded. Anything missing is blocked, not planned
|
|
@@ -67,10 +69,10 @@ export function planAppsDbRoute(org, recordedIds, plannedSteps, options) {
|
|
|
67
69
|
action: { system: 'cloudflare', kind: 'apps-db-route', key: org.id, desired },
|
|
68
70
|
step: { description, destroys: desired.mode === 'retire', migrates: false },
|
|
69
71
|
});
|
|
70
|
-
const recordedRoute = Boolean(recordedIds[routeKeys.route] ||
|
|
71
|
-
recordedIds[routeKeys.access] ||
|
|
72
|
-
recordedIds[routeKeys.token] ||
|
|
73
|
-
recordedIds[routeKeys.undelivered] ||
|
|
72
|
+
const recordedRoute = Boolean(recordedIds[routeKeys().route] ||
|
|
73
|
+
recordedIds[routeKeys().access] ||
|
|
74
|
+
recordedIds[routeKeys().token] ||
|
|
75
|
+
recordedIds[routeKeys().undelivered] ||
|
|
74
76
|
recordedIds[APPS_DB_ROUTE_APPLIED_SCOPE_KEY]);
|
|
75
77
|
if (!appsOn(org)) {
|
|
76
78
|
if (!recordedRoute)
|
|
@@ -93,7 +95,7 @@ export function planAppsDbRoute(org, recordedIds, plannedSteps, options) {
|
|
|
93
95
|
return block('the host supplies no Access, tunnel, DNS or zone port');
|
|
94
96
|
if (!options.canHoldSecrets)
|
|
95
97
|
return block('the host cannot hold secrets');
|
|
96
|
-
const lost = recordedIds[routeKeys.undelivered];
|
|
98
|
+
const lost = recordedIds[routeKeys().undelivered];
|
|
97
99
|
if (lost) {
|
|
98
100
|
return block(`service token ${lost} was created but its secret was never saved, so it cannot be used. To start over, turn apps off and apply (that removes the route and clears this note), delete the token ${lost} in Cloudflare Zero Trust if it is still listed, then turn apps on again`);
|
|
99
101
|
}
|
|
@@ -167,6 +167,8 @@ export interface WorkerAppWant {
|
|
|
167
167
|
noDelete?: string[];
|
|
168
168
|
grants?: string[];
|
|
169
169
|
migrationsSha256?: string;
|
|
170
|
+
/** `uses.audit`: a hash of `AUDIT_SEALER_GRANTS`, so an applied app is planned again when the list changes and its sealing role gets the new grants. */
|
|
171
|
+
auditSealerGrants?: string;
|
|
170
172
|
};
|
|
171
173
|
/** `workerApp.triggers.crons`, sorted. Absent when none, so a Worker without cron keeps the marker it already has. */
|
|
172
174
|
crons?: string[];
|
package/dist/plan/worker-app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { AUDIT_SEALER_GRANTS } from '../modules/audit-ledger.js';
|
|
2
3
|
import { APPS_DOMAIN, heldEnvRef } from '../org.js';
|
|
3
4
|
import { WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
|
|
4
5
|
import { WORKER_APP_DNS_RECORD, pendingWorkerAppMigrations, strayContainerImage, withCallTimeout, workerAppRegistryPrefix, workerAppShape, workerAppUpload, } from '../worker-app-upload.js';
|
|
@@ -203,6 +204,15 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
203
204
|
...(app.database.migrationsSha256
|
|
204
205
|
? { migrationsSha256: app.database.migrationsSha256 }
|
|
205
206
|
: {}),
|
|
207
|
+
// The order of AUDIT_SEALER_GRANTS is part of the hash: a reorder plans every audit app once.
|
|
208
|
+
...(entry.uses?.audit === true
|
|
209
|
+
? {
|
|
210
|
+
auditSealerGrants: createHash('sha256')
|
|
211
|
+
.update(JSON.stringify(AUDIT_SEALER_GRANTS))
|
|
212
|
+
.digest('hex')
|
|
213
|
+
.slice(0, 16),
|
|
214
|
+
}
|
|
215
|
+
: {}),
|
|
206
216
|
},
|
|
207
217
|
}
|
|
208
218
|
: {}),
|