@agnt-sdk/simon-server 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +317 -2
- package/dist/account.d.ts +59 -0
- package/dist/account.js +244 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.js +96 -0
- package/dist/config.d.ts +219 -0
- package/dist/config.js +300 -0
- package/dist/context.d.ts +57 -0
- package/dist/context.js +2 -0
- package/dist/continuity.d.ts +35 -0
- package/dist/continuity.js +74 -0
- package/dist/deliver.d.ts +22 -0
- package/dist/deliver.js +208 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +11 -0
- package/dist/extensions.d.ts +216 -0
- package/dist/extensions.js +75 -0
- package/dist/fanout.d.ts +32 -0
- package/dist/fanout.js +79 -0
- package/dist/http.d.ts +69 -0
- package/dist/http.js +112 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +12 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +41 -0
- package/dist/net/dns.d.ts +31 -0
- package/dist/net/dns.js +100 -0
- package/dist/net/fetch.d.ts +48 -0
- package/dist/net/fetch.js +173 -0
- package/dist/net/ip.d.ts +38 -0
- package/dist/net/ip.js +159 -0
- package/dist/net/psl.d.ts +22 -0
- package/dist/net/psl.js +91 -0
- package/dist/net/public-suffixes.d.ts +10 -0
- package/dist/net/public-suffixes.js +6885 -0
- package/dist/notice.d.ts +5 -0
- package/dist/notice.js +61 -0
- package/dist/outbox.d.ts +14 -0
- package/dist/outbox.js +356 -0
- package/dist/parts.d.ts +29 -0
- package/dist/parts.js +443 -0
- package/dist/public.d.ts +43 -0
- package/dist/public.js +134 -0
- package/dist/published.d.ts +3 -0
- package/dist/published.js +140 -0
- package/dist/rate.d.ts +28 -0
- package/dist/rate.js +77 -0
- package/dist/readers.d.ts +31 -0
- package/dist/readers.js +48 -0
- package/dist/register.d.ts +4 -0
- package/dist/register.js +401 -0
- package/dist/resolver.d.ts +140 -0
- package/dist/resolver.js +386 -0
- package/dist/send.d.ts +30 -0
- package/dist/send.js +216 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.js +234 -0
- package/dist/signal.d.ts +5 -0
- package/dist/signal.js +137 -0
- package/dist/store/contract.d.ts +4 -0
- package/dist/store/contract.js +1177 -0
- package/dist/store/sqlite-migration.d.ts +82 -0
- package/dist/store/sqlite-migration.js +1019 -0
- package/dist/store/sqlite.d.ts +108 -0
- package/dist/store/sqlite.js +992 -0
- package/dist/store/store.d.ts +400 -0
- package/dist/store/store.js +23 -0
- package/dist/sync.d.ts +19 -0
- package/dist/sync.js +199 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +87 -0
- package/dist/webhooks.d.ts +10 -0
- package/dist/webhooks.js +162 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +33 -0
- package/package.json +57 -4
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline, copy-first schema-2 conversion. This module has no listener, worker, queue sender or
|
|
3
|
+
* operational promotion operation. Its output is a private, structurally verified candidate cohort;
|
|
4
|
+
* absent historical suppression/terminal evidence remains a blocking activation hold.
|
|
5
|
+
*/
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { backup, DatabaseSync } from 'node:sqlite';
|
|
10
|
+
import { canonicalize, checkMessage, isAddress, isImplemented, isPrincipal, fromB64url, normalizeDomain, readKeys, serverOrigin, isPublicKey, MAX_DOCUMENT_BYTES, MAX_DOCUMENT_KEYS, messageId, namedFiles, parseJson, parseKeyToken, parseTimestamp, } from '@agnt-sdk/simon-protocol';
|
|
11
|
+
import { listedKeys } from "../public.js";
|
|
12
|
+
import { SqliteStore } from "./sqlite.js";
|
|
13
|
+
import { resolveRetractionCapacity } from "./store.js";
|
|
14
|
+
/** The exact schema of the legacy schema-2 server; not a relabelled current schema. */
|
|
15
|
+
const LEGACY_SCHEMA = `
|
|
16
|
+
CREATE TABLE agents (address TEXT PRIMARY KEY, kind TEXT NOT NULL, principal TEXT, mailbox INTEGER NOT NULL,
|
|
17
|
+
created_at TEXT NOT NULL, retired_at TEXT);
|
|
18
|
+
CREATE TABLE agent_keys (key TEXT PRIMARY KEY, address TEXT NOT NULL, added TEXT NOT NULL, revoked TEXT,
|
|
19
|
+
seq INTEGER NOT NULL);
|
|
20
|
+
CREATE INDEX agent_keys_address ON agent_keys (address);
|
|
21
|
+
CREATE TABLE messages (id TEXT PRIMARY KEY, json TEXT NOT NULL, from_address TEXT NOT NULL, public INTEGER NOT NULL,
|
|
22
|
+
carrier TEXT NOT NULL, claims TEXT NOT NULL, received_at TEXT NOT NULL, expires INTEGER NOT NULL,
|
|
23
|
+
kept INTEGER NOT NULL);
|
|
24
|
+
CREATE INDEX messages_expires ON messages (expires);
|
|
25
|
+
CREATE TABLE message_parts (id TEXT NOT NULL, sha256 TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY (id, sha256));
|
|
26
|
+
CREATE INDEX message_parts_sha ON message_parts (sha256);
|
|
27
|
+
CREATE TABLE arrivals (id TEXT NOT NULL, recipient TEXT NOT NULL, hints TEXT NOT NULL, attempts INTEGER NOT NULL,
|
|
28
|
+
due INTEGER NOT NULL, PRIMARY KEY (id, recipient));
|
|
29
|
+
CREATE INDEX arrivals_due ON arrivals (due);
|
|
30
|
+
CREATE TABLE inbox (cursor INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, id TEXT NOT NULL,
|
|
31
|
+
received_at TEXT NOT NULL, event INTEGER NOT NULL, UNIQUE (address, id));
|
|
32
|
+
CREATE INDEX inbox_id ON inbox (id);
|
|
33
|
+
CREATE INDEX inbox_events ON inbox (cursor) WHERE event = 1;
|
|
34
|
+
CREATE TABLE deliveries (id TEXT NOT NULL, domain TEXT NOT NULL, recipients TEXT NOT NULL, hints TEXT NOT NULL,
|
|
35
|
+
server TEXT, status TEXT NOT NULL, reason TEXT, attempts INTEGER NOT NULL, next_attempt INTEGER NOT NULL,
|
|
36
|
+
created_at TEXT NOT NULL, deadline TEXT NOT NULL, lease TEXT, PRIMARY KEY (id, domain));
|
|
37
|
+
CREATE INDEX deliveries_due ON deliveries (status, next_attempt);
|
|
38
|
+
CREATE TABLE parts (sha256 TEXT NOT NULL, source TEXT NOT NULL, fetched INTEGER NOT NULL, size INTEGER NOT NULL,
|
|
39
|
+
bytes BLOB NOT NULL, created INTEGER NOT NULL, PRIMARY KEY (sha256, source));
|
|
40
|
+
CREATE TABLE fetches (sha256 TEXT NOT NULL, carrier TEXT NOT NULL, size INTEGER NOT NULL, attempts INTEGER NOT NULL,
|
|
41
|
+
due INTEGER NOT NULL, PRIMARY KEY (sha256, carrier));
|
|
42
|
+
CREATE INDEX fetches_due ON fetches (due);
|
|
43
|
+
CREATE TABLE marks (scope TEXT NOT NULL, id TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY (scope, id));
|
|
44
|
+
CREATE TABLE counters (scope TEXT NOT NULL, id TEXT NOT NULL, n INTEGER NOT NULL, expires INTEGER NOT NULL,
|
|
45
|
+
PRIMARY KEY (scope, id));
|
|
46
|
+
CREATE TABLE vals (scope TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL, expires INTEGER,
|
|
47
|
+
PRIMARY KEY (scope, id));
|
|
48
|
+
PRAGMA user_version = 2;
|
|
49
|
+
`;
|
|
50
|
+
const OUTBOX_SCHEMA = `
|
|
51
|
+
CREATE TABLE arrivals(key TEXT PRIMARY KEY,domain TEXT NOT NULL,body TEXT NOT NULL,since INTEGER NOT NULL);
|
|
52
|
+
CREATE INDEX arrivals_by_domain ON arrivals(domain);
|
|
53
|
+
PRAGMA user_version=1;
|
|
54
|
+
`;
|
|
55
|
+
const TABLES = [
|
|
56
|
+
'agents',
|
|
57
|
+
'agent_keys',
|
|
58
|
+
'messages',
|
|
59
|
+
'message_parts',
|
|
60
|
+
'arrivals',
|
|
61
|
+
'inbox',
|
|
62
|
+
'deliveries',
|
|
63
|
+
'parts',
|
|
64
|
+
'fetches',
|
|
65
|
+
'marks',
|
|
66
|
+
'counters',
|
|
67
|
+
'vals',
|
|
68
|
+
];
|
|
69
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
70
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
71
|
+
const MAX_FILE_BYTES = 256 * 1024 * 1024;
|
|
72
|
+
const MAX_ROWS = 250_000;
|
|
73
|
+
export class SqliteMigrationRefusal extends Error {
|
|
74
|
+
code;
|
|
75
|
+
constructor(code) {
|
|
76
|
+
// No raw values, addresses, keys, code material or native SQLite error text in diagnostics.
|
|
77
|
+
super(`Offline conversion refused: ${code}.`);
|
|
78
|
+
this.name = 'SqliteMigrationRefusal';
|
|
79
|
+
this.code = code;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function refuse(code) {
|
|
83
|
+
throw new SqliteMigrationRefusal(code);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The bundle's `004-VERIFIED.json` is durable, but releasing the source handles/writer exclusions failed
|
|
87
|
+
* afterwards. Not a refusal: the bundle keeps its single VERIFIED meaning and carries no REFUSED marker.
|
|
88
|
+
*/
|
|
89
|
+
export class SqliteV2CleanupFailure extends Error {
|
|
90
|
+
code = 'cleanup_failed';
|
|
91
|
+
candidate;
|
|
92
|
+
constructor(candidate) {
|
|
93
|
+
super('Offline conversion verified, but releasing the source handles failed: cleanup_failed.');
|
|
94
|
+
this.name = 'SqliteV2CleanupFailure';
|
|
95
|
+
this.candidate = candidate;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const hash = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
99
|
+
const fileHash = (file) => hash(fs.readFileSync(file));
|
|
100
|
+
const text = (value, code) => (typeof value === 'string' ? value : refuse(code));
|
|
101
|
+
const integer = (value, code, minimum = 0) => {
|
|
102
|
+
const n = typeof value === 'bigint' ? Number(value) : value;
|
|
103
|
+
if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < minimum)
|
|
104
|
+
refuse(code);
|
|
105
|
+
return n;
|
|
106
|
+
};
|
|
107
|
+
const time = (value, code) => {
|
|
108
|
+
const s = text(value, code);
|
|
109
|
+
const n = parseTimestamp(s);
|
|
110
|
+
if (n === null)
|
|
111
|
+
refuse(code);
|
|
112
|
+
return n;
|
|
113
|
+
};
|
|
114
|
+
const json = (value, code) => {
|
|
115
|
+
try {
|
|
116
|
+
return parseJson(Buffer.from(text(value, code)));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return refuse(code);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const object = (value, code) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
123
|
+
? value
|
|
124
|
+
: refuse(code);
|
|
125
|
+
const isoField = (value, code) => time(typeof value === 'string' ? value : null, code);
|
|
126
|
+
const q = (identifier) => `"${identifier.replaceAll('"', '""')}"`;
|
|
127
|
+
function all(db, sql, ...params) {
|
|
128
|
+
const statement = db.prepare(sql);
|
|
129
|
+
statement.setReadBigInts(true);
|
|
130
|
+
return statement.all(...params);
|
|
131
|
+
}
|
|
132
|
+
function rows(db, table) {
|
|
133
|
+
const result = all(db, `SELECT rowid AS __rowid,* FROM ${q(table)} ORDER BY rowid LIMIT ?`, MAX_ROWS + 1);
|
|
134
|
+
if (result.length > MAX_ROWS)
|
|
135
|
+
refuse('row_capacity');
|
|
136
|
+
for (const row of result)
|
|
137
|
+
for (const value of Object.values(row)) {
|
|
138
|
+
if (typeof value === 'bigint')
|
|
139
|
+
integer(value, 'unsafe_integer', -Number.MAX_SAFE_INTEGER);
|
|
140
|
+
if (typeof value === 'number' && !Number.isSafeInteger(value))
|
|
141
|
+
refuse('non_integer_numeric_state');
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
function rowHash(row) {
|
|
146
|
+
const typed = Object.entries(row).map(([key, value]) => [
|
|
147
|
+
key,
|
|
148
|
+
typeof value === 'bigint'
|
|
149
|
+
? ['integer', String(value)]
|
|
150
|
+
: value instanceof Uint8Array
|
|
151
|
+
? ['blob', hash(value), value.byteLength]
|
|
152
|
+
: [value === null ? 'null' : typeof value, value],
|
|
153
|
+
]);
|
|
154
|
+
return hash(JSON.stringify(typed));
|
|
155
|
+
}
|
|
156
|
+
function ledger(db, tables) {
|
|
157
|
+
return Object.fromEntries(tables.map(table => {
|
|
158
|
+
const hashes = rows(db, table).map(rowHash);
|
|
159
|
+
return [table, { count: hashes.length, orderedRowHashes: hashes, digest: hash(JSON.stringify(hashes)) }];
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
function schema(db) {
|
|
163
|
+
// Ignore whitespace/case only; unknown tables, indexes, views and triggers remain incompatible.
|
|
164
|
+
return all(db, 'SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').map(row => [
|
|
165
|
+
row.type,
|
|
166
|
+
row.name,
|
|
167
|
+
row.tbl_name,
|
|
168
|
+
row.sql === null ? null : text(row.sql, 'schema_sql').replace(/\s+/g, '').toLowerCase(),
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
function verifySchema(db, expected, version) {
|
|
172
|
+
if (integer(all(db, 'PRAGMA user_version')[0].user_version, 'schema_version') !== version)
|
|
173
|
+
refuse('schema_version');
|
|
174
|
+
const model = new DatabaseSync(':memory:');
|
|
175
|
+
try {
|
|
176
|
+
model.exec(expected);
|
|
177
|
+
if (JSON.stringify(schema(db)) !== JSON.stringify(schema(model)))
|
|
178
|
+
refuse('schema_fingerprint');
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
model.close();
|
|
182
|
+
}
|
|
183
|
+
const integrity = all(db, 'PRAGMA integrity_check');
|
|
184
|
+
if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok')
|
|
185
|
+
refuse('integrity_check');
|
|
186
|
+
}
|
|
187
|
+
function privateFile(file, secret = false) {
|
|
188
|
+
const absolute = path.resolve(file);
|
|
189
|
+
const stat = fs.lstatSync(absolute);
|
|
190
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)
|
|
191
|
+
refuse('unsafe_file_type');
|
|
192
|
+
if (process.getuid === undefined || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0)
|
|
193
|
+
refuse('unsafe_file_owner_or_mode');
|
|
194
|
+
if (secret && (stat.mode & 0o077) !== 0)
|
|
195
|
+
refuse('private_file_permissions');
|
|
196
|
+
if (fs.realpathSync(absolute) !== absolute)
|
|
197
|
+
refuse('symlink_path');
|
|
198
|
+
if (stat.size > MAX_FILE_BYTES)
|
|
199
|
+
refuse('file_capacity');
|
|
200
|
+
return absolute;
|
|
201
|
+
}
|
|
202
|
+
function maintenance(file) {
|
|
203
|
+
const bytes = fs.readFileSync(privateFile(file, true));
|
|
204
|
+
if (bytes.length > 32 * 1024)
|
|
205
|
+
refuse('maintenance_capacity');
|
|
206
|
+
const m = object(json(bytes.toString(), 'maintenance_json'), 'maintenance_json');
|
|
207
|
+
if (m.v !== 1 ||
|
|
208
|
+
typeof m.id !== 'string' ||
|
|
209
|
+
!/^[a-zA-Z0-9_-]{16,128}$/.test(m.id) ||
|
|
210
|
+
m.hostStopped !== true ||
|
|
211
|
+
m.callbacksDrained !== true ||
|
|
212
|
+
m.appOutboxStopped !== true)
|
|
213
|
+
refuse('maintenance_drain_attestation');
|
|
214
|
+
for (const field of ['serverKeyFingerprint', 'configDigest'])
|
|
215
|
+
if (typeof m[field] !== 'string' || !HEX64.test(m[field]))
|
|
216
|
+
refuse('maintenance_digest');
|
|
217
|
+
if (typeof m.toolCommit !== 'string' || !HEX40.test(m.toolCommit))
|
|
218
|
+
refuse('maintenance_tool_commit');
|
|
219
|
+
if (typeof m.origin !== 'string' || serverOrigin(m.origin, true) !== m.origin)
|
|
220
|
+
refuse('maintenance_origin');
|
|
221
|
+
if (typeof m.serverKey !== 'string' ||
|
|
222
|
+
!parseKeyToken(m.serverKey) ||
|
|
223
|
+
!isImplemented(m.serverKey) ||
|
|
224
|
+
!isPublicKey(m.serverKey) ||
|
|
225
|
+
hash(m.serverKey) !== m.serverKeyFingerprint)
|
|
226
|
+
refuse('maintenance_public_key');
|
|
227
|
+
const at = isoField(m.observedAt, 'maintenance_observation');
|
|
228
|
+
if (m.previousObservedAt !== undefined && isoField(m.previousObservedAt, 'maintenance_previous') > at)
|
|
229
|
+
refuse('maintenance_clock_rollback');
|
|
230
|
+
if (typeof m.clockProvenance !== 'string' || !m.clockProvenance.trim() || m.clockProvenance.length > 256)
|
|
231
|
+
refuse('maintenance_clock_provenance');
|
|
232
|
+
const limits = object(m.limits, 'maintenance_limits');
|
|
233
|
+
for (const field of ['messageBytes', 'requestBodyBytes', 'recipients', 'partBytes'])
|
|
234
|
+
integer(typeof limits[field] === 'number' ? limits[field] : null, 'maintenance_limits', 1);
|
|
235
|
+
if (typeof m.fenceFile !== 'string' || typeof m.fenceToken !== 'string')
|
|
236
|
+
refuse('maintenance_fence');
|
|
237
|
+
const fence = fs.readFileSync(privateFile(m.fenceFile, true), 'utf8');
|
|
238
|
+
if (!/^[a-zA-Z0-9_-]{22,128}$/.test(m.fenceToken) || fence !== m.fenceToken)
|
|
239
|
+
refuse('maintenance_fence');
|
|
240
|
+
return { value: m, digest: hash(bytes) };
|
|
241
|
+
}
|
|
242
|
+
function validateRows(data, m) {
|
|
243
|
+
const at = Date.parse(m.observedAt);
|
|
244
|
+
const agents = new Map(data.agents.map(row => [text(row.address, 'agent_address'), row]));
|
|
245
|
+
for (const row of data.agents) {
|
|
246
|
+
if (!isAddress(row.address) || !['solo', 'paired'].includes(text(row.kind, 'agent_kind')))
|
|
247
|
+
refuse('agent_identity');
|
|
248
|
+
if (row.kind === 'paired' ? !isPrincipal(row.principal) : row.principal !== null)
|
|
249
|
+
refuse('agent_principal');
|
|
250
|
+
if (![0, 1].includes(integer(row.mailbox, 'agent_mailbox')))
|
|
251
|
+
refuse('agent_mailbox');
|
|
252
|
+
if (time(row.created_at, 'agent_created') > at)
|
|
253
|
+
refuse('future_host_state');
|
|
254
|
+
if (row.retired_at !== null && time(row.retired_at, 'agent_retired') > at)
|
|
255
|
+
refuse('future_host_state');
|
|
256
|
+
const keys = data.agent_keys.filter(key => key.address === row.address);
|
|
257
|
+
const seqs = new Set();
|
|
258
|
+
for (const key of keys) {
|
|
259
|
+
if (!parseKeyToken(key.key) || (isImplemented(key.key) && !isPublicKey(key.key)))
|
|
260
|
+
refuse('agent_key');
|
|
261
|
+
const seq = integer(key.seq, 'agent_key_sequence');
|
|
262
|
+
if (seqs.has(seq))
|
|
263
|
+
refuse('agent_key_sequence');
|
|
264
|
+
seqs.add(seq);
|
|
265
|
+
if (time(key.added, 'agent_key_added') > at)
|
|
266
|
+
refuse('future_host_state');
|
|
267
|
+
if (key.revoked !== null)
|
|
268
|
+
time(key.revoked, 'agent_key_revoked');
|
|
269
|
+
if (row.retired_at !== null && key.revoked === null)
|
|
270
|
+
refuse('retired_active_key');
|
|
271
|
+
}
|
|
272
|
+
const agent = {
|
|
273
|
+
address: row.address,
|
|
274
|
+
kind: row.kind,
|
|
275
|
+
principal: row.principal,
|
|
276
|
+
keys: keys
|
|
277
|
+
.sort((a, b) => Number(a.seq) - Number(b.seq))
|
|
278
|
+
.map(key => ({
|
|
279
|
+
key: key.key,
|
|
280
|
+
added: key.added,
|
|
281
|
+
revoked: key.revoked,
|
|
282
|
+
})),
|
|
283
|
+
mailbox: row.mailbox === 1n,
|
|
284
|
+
createdAt: row.created_at,
|
|
285
|
+
retiredAt: row.retired_at,
|
|
286
|
+
};
|
|
287
|
+
if (!agent.keys.length)
|
|
288
|
+
refuse('agent_without_keys');
|
|
289
|
+
let projected;
|
|
290
|
+
try {
|
|
291
|
+
projected = listedKeys(agent, at);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
refuse('active_key_capacity');
|
|
295
|
+
}
|
|
296
|
+
if (projected.length > MAX_DOCUMENT_KEYS)
|
|
297
|
+
refuse('active_key_capacity');
|
|
298
|
+
if (!readKeys(projected.map(({ key, added, revoked }) => ({ key, added, revoked })), new Date(at)))
|
|
299
|
+
refuse('key_document_time');
|
|
300
|
+
// Upper bound for either supported server signature, without fabricating signed evidence.
|
|
301
|
+
const bytes = Buffer.byteLength(JSON.stringify({ address: agent.address, keys: projected })) + 4600;
|
|
302
|
+
if (bytes > MAX_DOCUMENT_BYTES)
|
|
303
|
+
refuse('key_document_byte_capacity');
|
|
304
|
+
}
|
|
305
|
+
for (const key of data.agent_keys)
|
|
306
|
+
if (!agents.has(text(key.address, 'agent_key_parent')))
|
|
307
|
+
refuse('agent_key_parent');
|
|
308
|
+
const messages = new Map();
|
|
309
|
+
for (const row of data.messages) {
|
|
310
|
+
if (integer(row.__rowid, 'message_rowid', 1) >= Number.MAX_SAFE_INTEGER)
|
|
311
|
+
refuse('message_rowid_sentinel');
|
|
312
|
+
let message;
|
|
313
|
+
try {
|
|
314
|
+
message = checkMessage(json(row.json, 'message_json'), m.limits);
|
|
315
|
+
if (messageId(message) !== row.id || message.from !== row.from_address)
|
|
316
|
+
refuse('message_id_binding');
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
refuse('message_structure_or_binding');
|
|
320
|
+
}
|
|
321
|
+
if (integer(row.public, 'message_public') !== (message.to.length === 0 ? 1 : 0))
|
|
322
|
+
refuse('message_public');
|
|
323
|
+
if (![0, 1].includes(integer(row.kept, 'message_kept')))
|
|
324
|
+
refuse('message_kept');
|
|
325
|
+
const carrier = text(row.carrier, 'message_carrier');
|
|
326
|
+
if (serverOrigin(carrier, true) !== carrier)
|
|
327
|
+
refuse('message_carrier');
|
|
328
|
+
if (!Array.isArray(json(row.claims, 'message_claims')))
|
|
329
|
+
refuse('message_claims');
|
|
330
|
+
if (time(row.received_at, 'message_received') > at)
|
|
331
|
+
refuse('future_host_state');
|
|
332
|
+
integer(row.expires, 'message_expiry');
|
|
333
|
+
messages.set(row.id, message);
|
|
334
|
+
const declared = new Map(namedFiles(message).map(part => [part.sha256, part.size]));
|
|
335
|
+
const indexed = new Map(data.message_parts.filter(part => part.id === row.id).map(part => [part.sha256, integer(part.size, 'part_size')]));
|
|
336
|
+
const expected = [...declared].sort(([a], [b]) => a.localeCompare(b));
|
|
337
|
+
const held = [...indexed].sort(([a], [b]) => String(a).localeCompare(String(b)));
|
|
338
|
+
if (JSON.stringify(expected) !== JSON.stringify(held))
|
|
339
|
+
refuse('message_part_projection');
|
|
340
|
+
}
|
|
341
|
+
for (const row of data.message_parts)
|
|
342
|
+
if (!messages.has(text(row.id, 'message_part_parent')))
|
|
343
|
+
refuse('message_part_parent');
|
|
344
|
+
for (const row of data.inbox) {
|
|
345
|
+
if (!agents.has(text(row.address, 'inbox_agent')) || !messages.has(text(row.id, 'inbox_message')))
|
|
346
|
+
refuse('inbox_parent');
|
|
347
|
+
integer(row.cursor, 'inbox_cursor', 1);
|
|
348
|
+
if (time(row.received_at, 'inbox_received') > at)
|
|
349
|
+
refuse('future_host_state');
|
|
350
|
+
if (![0, 1].includes(integer(row.event, 'inbox_event')))
|
|
351
|
+
refuse('inbox_event');
|
|
352
|
+
}
|
|
353
|
+
for (const table of ['arrivals', 'deliveries'])
|
|
354
|
+
for (const row of data[table]) {
|
|
355
|
+
if (!messages.has(text(row.id, 'job_parent')))
|
|
356
|
+
refuse('job_parent');
|
|
357
|
+
integer(row.attempts, 'job_attempts');
|
|
358
|
+
object(json(row.hints, 'job_hints'), 'job_hints');
|
|
359
|
+
if (table === 'arrivals') {
|
|
360
|
+
integer(row.__rowid, 'arrival_rowid', 1);
|
|
361
|
+
if (!isAddress(row.recipient))
|
|
362
|
+
refuse('arrival_recipient');
|
|
363
|
+
integer(row.due, 'arrival_due');
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
if (!['pending', 'delivered', 'failed'].includes(text(row.status, 'delivery_status')))
|
|
367
|
+
refuse('delivery_status');
|
|
368
|
+
const recipients = json(row.recipients, 'delivery_recipients');
|
|
369
|
+
if (!Array.isArray(recipients) || !recipients.length || !recipients.every(isAddress))
|
|
370
|
+
refuse('delivery_recipients');
|
|
371
|
+
integer(row.next_attempt, 'delivery_due');
|
|
372
|
+
time(row.created_at, 'delivery_created');
|
|
373
|
+
time(row.deadline, 'delivery_deadline');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const row of data.parts) {
|
|
377
|
+
if (typeof row.sha256 !== 'string' || !HEX64.test(row.sha256))
|
|
378
|
+
refuse('blob_hash');
|
|
379
|
+
if (!(row.bytes instanceof Uint8Array) || row.bytes.byteLength !== integer(row.size, 'blob_size'))
|
|
380
|
+
refuse('blob_size');
|
|
381
|
+
if (hash(row.bytes) !== row.sha256)
|
|
382
|
+
refuse('blob_hash');
|
|
383
|
+
const source = text(row.source, 'blob_source');
|
|
384
|
+
if (!isAddress(source) && serverOrigin(source, true) !== source)
|
|
385
|
+
refuse('blob_source');
|
|
386
|
+
if (![0, 1].includes(integer(row.fetched, 'blob_fetched')))
|
|
387
|
+
refuse('blob_fetched');
|
|
388
|
+
if (integer(row.created, 'blob_created') > at)
|
|
389
|
+
refuse('future_host_state');
|
|
390
|
+
}
|
|
391
|
+
for (const row of data.fetches) {
|
|
392
|
+
integer(row.__rowid, 'fetch_rowid', 1);
|
|
393
|
+
if (typeof row.sha256 !== 'string' || !HEX64.test(row.sha256))
|
|
394
|
+
refuse('fetch_hash');
|
|
395
|
+
const carrier = text(row.carrier, 'fetch_carrier');
|
|
396
|
+
if (serverOrigin(carrier, true) !== carrier)
|
|
397
|
+
refuse('fetch_carrier');
|
|
398
|
+
integer(row.size, 'fetch_size');
|
|
399
|
+
integer(row.attempts, 'fetch_attempts');
|
|
400
|
+
integer(row.due, 'fetch_due');
|
|
401
|
+
}
|
|
402
|
+
const sizes = new Map();
|
|
403
|
+
for (const table of ['message_parts', 'parts', 'fetches'])
|
|
404
|
+
for (const row of data[table]) {
|
|
405
|
+
const sha = text(row.sha256, 'part_hash');
|
|
406
|
+
const size = integer(row.size, 'part_size');
|
|
407
|
+
if (sizes.has(sha) && sizes.get(sha) !== size)
|
|
408
|
+
refuse('hash_size_conflict');
|
|
409
|
+
sizes.set(sha, size);
|
|
410
|
+
}
|
|
411
|
+
const archived = [];
|
|
412
|
+
for (const row of data.vals) {
|
|
413
|
+
const scope = text(row.scope, 'value_scope');
|
|
414
|
+
const value = json(row.value, 'value_json');
|
|
415
|
+
if (row.expires !== null)
|
|
416
|
+
integer(row.expires, 'value_expiry');
|
|
417
|
+
if (scope === 'code' || scope === 'recovery') {
|
|
418
|
+
if (value !== null || row.value !== 'null')
|
|
419
|
+
refuse('legacy_challenge_or_recovery');
|
|
420
|
+
archived.push(rowHash(row));
|
|
421
|
+
}
|
|
422
|
+
else if (scope.startsWith('sync-') ||
|
|
423
|
+
scope.includes('recovery') ||
|
|
424
|
+
scope.startsWith('fetch-') ||
|
|
425
|
+
scope === 'part-fetch-slot') {
|
|
426
|
+
refuse('reserved_value_scope');
|
|
427
|
+
}
|
|
428
|
+
else if (scope === 'continuity') {
|
|
429
|
+
if (normalizeDomain(row.id) !== row.id)
|
|
430
|
+
refuse('consumed_value_shape');
|
|
431
|
+
const state = object(value, 'consumed_value_shape');
|
|
432
|
+
const keys = (value) => {
|
|
433
|
+
if (!Array.isArray(value) ||
|
|
434
|
+
!value.length ||
|
|
435
|
+
value.length > MAX_DOCUMENT_KEYS ||
|
|
436
|
+
new Set(value).size !== value.length ||
|
|
437
|
+
!value.every(key => parseKeyToken(key) && (!isImplemented(key) || isPublicKey(key))))
|
|
438
|
+
refuse('consumed_value_shape');
|
|
439
|
+
};
|
|
440
|
+
keys(state.listed);
|
|
441
|
+
keys(state.signers);
|
|
442
|
+
if (state.held !== undefined) {
|
|
443
|
+
const held = object(state.held, 'consumed_value_shape');
|
|
444
|
+
if (typeof held.server !== 'string' || serverOrigin(held.server, true) !== held.server)
|
|
445
|
+
refuse('consumed_value_shape');
|
|
446
|
+
keys(held.keys);
|
|
447
|
+
isoField(held.until, 'consumed_value_shape');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
else if (scope === 'serving-since') {
|
|
451
|
+
if (normalizeDomain(row.id) !== row.id)
|
|
452
|
+
refuse('consumed_value_shape');
|
|
453
|
+
if (value === null) {
|
|
454
|
+
// send.ts explicitly writes an expired null tombstone when a domain leaves this server.
|
|
455
|
+
if (row.value !== 'null' || row.expires === null || integer(row.expires, 'value_expiry') > at)
|
|
456
|
+
refuse('consumed_value_shape');
|
|
457
|
+
}
|
|
458
|
+
else if (typeof value === 'string') {
|
|
459
|
+
if (isoField(value, 'consumed_value_shape') > at)
|
|
460
|
+
refuse('future_host_state');
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
const noted = object(value, 'consumed_value_shape');
|
|
464
|
+
const since = isoField(noted.since, 'consumed_value_shape');
|
|
465
|
+
const checked = isoField(noted.checked, 'consumed_value_shape');
|
|
466
|
+
if (since > checked || checked > at)
|
|
467
|
+
refuse('consumed_value_shape');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else if (scope === 'postmaster-key') {
|
|
471
|
+
const seed = typeof value === 'string' ? fromB64url(value) : null;
|
|
472
|
+
if (!isAddress(row.id) || !row.id.startsWith('postmaster@') || !seed || seed.length !== 32)
|
|
473
|
+
refuse('consumed_value_shape');
|
|
474
|
+
}
|
|
475
|
+
else if (['server-key-added', 'recipients-refused'].includes(scope)) {
|
|
476
|
+
if (isoField(value, 'consumed_value_shape') > at)
|
|
477
|
+
refuse('future_host_state');
|
|
478
|
+
}
|
|
479
|
+
else if (scope === 'delivery-move-cutoff') {
|
|
480
|
+
const cutoff = object(value, 'consumed_value_shape');
|
|
481
|
+
isoField(cutoff.until, 'consumed_value_shape');
|
|
482
|
+
if (!['invalid', 'bad_signature'].includes(String(cutoff.reason)) || typeof cutoff.fresh !== 'boolean')
|
|
483
|
+
refuse('consumed_value_shape');
|
|
484
|
+
}
|
|
485
|
+
else if (scope === 'reresolve') {
|
|
486
|
+
refuse('legacy_inflight_lease');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
for (const table of ['marks', 'counters'])
|
|
490
|
+
for (const row of data[table]) {
|
|
491
|
+
text(row.scope, 'state_scope');
|
|
492
|
+
text(row.id, 'state_id');
|
|
493
|
+
integer(row.expires, 'state_expiry');
|
|
494
|
+
if (table === 'counters')
|
|
495
|
+
integer(row.n, 'counter_value');
|
|
496
|
+
}
|
|
497
|
+
return { messages, archived };
|
|
498
|
+
}
|
|
499
|
+
function outboxRows(db) {
|
|
500
|
+
const result = rows(db, 'arrivals');
|
|
501
|
+
for (const row of result) {
|
|
502
|
+
integer(row.__rowid, 'outbox_rowid', 1);
|
|
503
|
+
integer(row.since, 'outbox_since');
|
|
504
|
+
const body = object(json(row.body, 'outbox_body'), 'outbox_body');
|
|
505
|
+
if (body.v !== 1 && body.v !== 2)
|
|
506
|
+
refuse('outbox_body_version');
|
|
507
|
+
if (!isAddress(body.address) || typeof body.id !== 'string' || !HEX64.test(body.id))
|
|
508
|
+
refuse('outbox_identity');
|
|
509
|
+
isoField(body.receivedAt, 'outbox_received');
|
|
510
|
+
if (row.key !== `${body.address}\n${body.id}` ||
|
|
511
|
+
row.domain !== body.address.slice(body.address.lastIndexOf('@') + 1))
|
|
512
|
+
refuse('outbox_identity');
|
|
513
|
+
if (body.v === 2 && (!isAddress(body.from) || (body.inReplyTo !== null && !HEX64.test(String(body.inReplyTo)))))
|
|
514
|
+
refuse('outbox_body_shape');
|
|
515
|
+
}
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
function precomputedReceipts(data, messages, limits) {
|
|
519
|
+
const receipts = Object.create(null);
|
|
520
|
+
for (const row of data.messages) {
|
|
521
|
+
const message = messages.get(row.id);
|
|
522
|
+
if (message.v !== 1)
|
|
523
|
+
continue;
|
|
524
|
+
const v1 = message;
|
|
525
|
+
if (v1.headers.rel !== 'retract' || v1.inReplyTo === null)
|
|
526
|
+
continue;
|
|
527
|
+
receipts[row.id] = {
|
|
528
|
+
record: {
|
|
529
|
+
id: row.id,
|
|
530
|
+
message,
|
|
531
|
+
carrier: row.carrier,
|
|
532
|
+
claims: json(row.claims, 'message_claims'),
|
|
533
|
+
signedClaims: null,
|
|
534
|
+
receivedAt: row.received_at,
|
|
535
|
+
expiresAt: new Date(Number(row.expires)).toISOString(),
|
|
536
|
+
},
|
|
537
|
+
author: message.from,
|
|
538
|
+
markers: (message.to.length ? message.to : ['*']).map(owner => ({
|
|
539
|
+
owner,
|
|
540
|
+
target: v1.inReplyTo,
|
|
541
|
+
author: message.from,
|
|
542
|
+
retraction: row.id,
|
|
543
|
+
})),
|
|
544
|
+
generation: '00000000-0000-0000-0000-000000000000',
|
|
545
|
+
active: true,
|
|
546
|
+
materialized: true,
|
|
547
|
+
knownDeliveries: data.deliveries
|
|
548
|
+
.filter(job => job.id === row.id)
|
|
549
|
+
.map(job => job.domain)
|
|
550
|
+
.sort(),
|
|
551
|
+
knownArrivals: data.arrivals
|
|
552
|
+
.filter(job => job.id === row.id)
|
|
553
|
+
.map(job => job.recipient)
|
|
554
|
+
.sort(),
|
|
555
|
+
pendingDeliveries: {},
|
|
556
|
+
pendingArrivals: {},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const values = Object.values(receipts);
|
|
560
|
+
const authors = new Map();
|
|
561
|
+
for (const receipt of values)
|
|
562
|
+
authors.set(receipt.author, (authors.get(receipt.author) ?? 0) + 1);
|
|
563
|
+
if (values.length > limits.maxRecords ||
|
|
564
|
+
[...authors.values()].some(n => n > limits.maxPerAuthor) ||
|
|
565
|
+
Buffer.byteLength(canonicalize(receipts)) + 16 * values.length > limits.maxBytes)
|
|
566
|
+
refuse('retraction_capacity');
|
|
567
|
+
return receipts;
|
|
568
|
+
}
|
|
569
|
+
function durableJson(directory, name, value) {
|
|
570
|
+
const file = path.join(directory, name);
|
|
571
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
572
|
+
try {
|
|
573
|
+
fs.writeFileSync(fd, JSON.stringify(value, null, 2) + '\n');
|
|
574
|
+
fs.fsyncSync(fd);
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
fs.closeSync(fd);
|
|
578
|
+
}
|
|
579
|
+
const parent = fs.openSync(directory, fs.constants.O_RDONLY);
|
|
580
|
+
try {
|
|
581
|
+
fs.fsyncSync(parent);
|
|
582
|
+
}
|
|
583
|
+
finally {
|
|
584
|
+
fs.closeSync(parent);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function syncFile(file) {
|
|
588
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY);
|
|
589
|
+
try {
|
|
590
|
+
fs.fsyncSync(fd);
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
fs.closeSync(fd);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function openRead(file) {
|
|
597
|
+
const db = new DatabaseSync(file, { readOnly: true, timeout: 0 });
|
|
598
|
+
db.exec('PRAGMA trusted_schema=OFF; PRAGMA query_only=ON;');
|
|
599
|
+
return db;
|
|
600
|
+
}
|
|
601
|
+
function insert(db, table, row) {
|
|
602
|
+
const columns = Object.keys(row);
|
|
603
|
+
const statement = db.prepare(`INSERT INTO ${q(table)}(${columns.map(q).join(',')}) VALUES(${columns.map(() => '?').join(',')})`);
|
|
604
|
+
statement.run(...columns.map(column => row[column]));
|
|
605
|
+
}
|
|
606
|
+
function preserveRow(db, table, row, extra = {}, omit = []) {
|
|
607
|
+
const { __rowid, ...fields } = row;
|
|
608
|
+
const preserved = Object.fromEntries(Object.entries(fields).filter(([column]) => !omit.includes(column)));
|
|
609
|
+
insert(db, table, { rowid: __rowid, ...preserved, ...extra });
|
|
610
|
+
}
|
|
611
|
+
function checkpoint(file) {
|
|
612
|
+
const db = new DatabaseSync(file);
|
|
613
|
+
try {
|
|
614
|
+
db.exec('PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;');
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
db.close();
|
|
618
|
+
}
|
|
619
|
+
fs.chmodSync(file, 0o600);
|
|
620
|
+
syncFile(file);
|
|
621
|
+
}
|
|
622
|
+
function sourceState(core, outbox) {
|
|
623
|
+
return { core: ledger(core, [...TABLES, 'sqlite_sequence']), outbox: ledger(outbox, ['arrivals']) };
|
|
624
|
+
}
|
|
625
|
+
function verifyPreservation(candidate, data, high, archived) {
|
|
626
|
+
for (const table of TABLES) {
|
|
627
|
+
const expected = data[table].filter(row => table !== 'vals' || !archived.includes(rowHash(row)));
|
|
628
|
+
const actual = rows(candidate, table);
|
|
629
|
+
// New reserved sync state is verified separately; legacy rows retain their own original rowids.
|
|
630
|
+
const old = table === 'vals' ? actual.filter(row => !String(row.scope).startsWith('sync-')) : actual;
|
|
631
|
+
if (old.length !== expected.length)
|
|
632
|
+
refuse('candidate_row_count');
|
|
633
|
+
for (let i = 0; i < expected.length; i++) {
|
|
634
|
+
const original = expected[i];
|
|
635
|
+
const fields = Object.keys(original).filter(column => !(table === 'inbox' && column === 'received_at'));
|
|
636
|
+
const projected = Object.fromEntries(fields.map(column => [column, old[i][column]]));
|
|
637
|
+
const source = Object.fromEntries(fields.map(column => [column, original[column]]));
|
|
638
|
+
if (rowHash(projected) !== rowHash(source))
|
|
639
|
+
refuse('candidate_raw_preservation');
|
|
640
|
+
if (table === 'inbox' &&
|
|
641
|
+
integer(old[i].taken, 'candidate_inbox_time') !== time(original.received_at, 'inbox_time'))
|
|
642
|
+
refuse('candidate_inbox_time');
|
|
643
|
+
if (table === 'messages' && (old[i].signed_claims !== null || old[i].cursor !== null || old[i].taken !== null))
|
|
644
|
+
refuse('invented_sent_or_signed_claims');
|
|
645
|
+
if (table === 'fetches' && (old[i].revision !== 0n || old[i].token !== null || old[i].exhausted !== null))
|
|
646
|
+
refuse('candidate_fetch_fence');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const positions = all(candidate, 'SELECT n FROM positions');
|
|
650
|
+
if (positions.length !== 1 || integer(positions[0].n, 'candidate_highwater') !== high)
|
|
651
|
+
refuse('candidate_highwater');
|
|
652
|
+
for (const agent of data.agents) {
|
|
653
|
+
const gap = all(candidate, "SELECT value,expires FROM vals WHERE scope='sync-history-gap' AND id=?", agent.address);
|
|
654
|
+
if (gap.length !== 1 || gap[0].value !== 'true' || gap[0].expires !== null)
|
|
655
|
+
refuse('candidate_history_gap');
|
|
656
|
+
}
|
|
657
|
+
if (all(candidate, 'SELECT 1 FROM signals UNION ALL SELECT 1 FROM webhooks').length)
|
|
658
|
+
refuse('invented_events');
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The maintenance attestation is an operator prerequisite, not proof that arbitrary external processes
|
|
662
|
+
* stopped. Both actual SQLite writer locks are acquired before either snapshot; the host fence must
|
|
663
|
+
* remain owned/respected outside this command. Output never selects an operational database path.
|
|
664
|
+
*/
|
|
665
|
+
export async function createSqliteV2Candidate(options) {
|
|
666
|
+
const locks = [];
|
|
667
|
+
const readers = [];
|
|
668
|
+
let directory = null;
|
|
669
|
+
let created = false;
|
|
670
|
+
// Set once 004-VERIFIED.json is durable (or a matching one is reused): from then on, nothing marks it REFUSED.
|
|
671
|
+
let verified = null;
|
|
672
|
+
try {
|
|
673
|
+
const source = privateFile(options.source);
|
|
674
|
+
const outbox = privateFile(path.join(path.dirname(source), 'app-arrivals.sqlite'));
|
|
675
|
+
const sourceParent = fs.statSync(path.dirname(source));
|
|
676
|
+
if (process.getuid === undefined || sourceParent.uid !== process.getuid() || (sourceParent.mode & 0o022) !== 0)
|
|
677
|
+
refuse('unsafe_source_parent');
|
|
678
|
+
for (const file of [source, outbox]) {
|
|
679
|
+
let size = fs.statSync(file).size;
|
|
680
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
681
|
+
const sidecar = file + suffix;
|
|
682
|
+
if (fs.existsSync(sidecar))
|
|
683
|
+
size += fs.statSync(privateFile(sidecar)).size;
|
|
684
|
+
}
|
|
685
|
+
if (size > MAX_FILE_BYTES)
|
|
686
|
+
refuse('cohort_file_capacity');
|
|
687
|
+
}
|
|
688
|
+
const attestation = maintenance(options.maintenanceFile);
|
|
689
|
+
const m = attestation.value;
|
|
690
|
+
const limits = resolveRetractionCapacity(options.retractions);
|
|
691
|
+
const identities = [source, outbox, privateFile(options.maintenanceFile), privateFile(m.fenceFile)].map(file => {
|
|
692
|
+
const stat = fs.statSync(file);
|
|
693
|
+
return `${stat.dev}:${stat.ino}`;
|
|
694
|
+
});
|
|
695
|
+
if (new Set(identities).size !== identities.length)
|
|
696
|
+
refuse('aliased_input');
|
|
697
|
+
directory = path.resolve(options.directory);
|
|
698
|
+
const parent = fs.lstatSync(path.dirname(directory));
|
|
699
|
+
if (!parent.isDirectory() ||
|
|
700
|
+
parent.isSymbolicLink() ||
|
|
701
|
+
process.getuid === undefined ||
|
|
702
|
+
parent.uid !== process.getuid() ||
|
|
703
|
+
(parent.mode & 0o022) !== 0 ||
|
|
704
|
+
fs.realpathSync(path.dirname(directory)) !== path.dirname(directory))
|
|
705
|
+
refuse('unsafe_candidate_parent');
|
|
706
|
+
// IMMEDIATE excludes writers in WAL and rollback modes without treating readers as drained.
|
|
707
|
+
for (const file of [source, outbox]) {
|
|
708
|
+
const db = new DatabaseSync(file, { timeout: 0 });
|
|
709
|
+
try {
|
|
710
|
+
db.exec('PRAGMA trusted_schema=OFF; BEGIN IMMEDIATE;');
|
|
711
|
+
locks.push(db);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
db.close();
|
|
715
|
+
refuse('writer_exclusion_failed');
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
for (const file of [source, outbox])
|
|
719
|
+
readers.push(openRead(file));
|
|
720
|
+
const [core, app] = readers;
|
|
721
|
+
verifySchema(core, LEGACY_SCHEMA, 2);
|
|
722
|
+
verifySchema(app, OUTBOX_SCHEMA, 1);
|
|
723
|
+
outboxRows(app);
|
|
724
|
+
const current = sourceState(core, app);
|
|
725
|
+
const existing = path.join(directory, '004-VERIFIED.json');
|
|
726
|
+
if (fs.existsSync(directory)) {
|
|
727
|
+
const stat = fs.lstatSync(directory);
|
|
728
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0)
|
|
729
|
+
refuse('unsafe_existing_candidate');
|
|
730
|
+
// A bundle that recorded a refusal is never reused, whatever else it holds.
|
|
731
|
+
if (fs.existsSync(path.join(directory, 'REFUSED.json')))
|
|
732
|
+
refuse('refused_candidate_choose_new_directory');
|
|
733
|
+
if (!fs.existsSync(existing))
|
|
734
|
+
refuse('incomplete_candidate_choose_new_directory');
|
|
735
|
+
const prior = object(json(fs.readFileSync(privateFile(existing), 'utf8'), 'candidate_manifest'), 'candidate_manifest');
|
|
736
|
+
const candidate = object(prior.candidate, 'candidate_manifest');
|
|
737
|
+
if (candidate.status !== 'VERIFIED_COPY_HELD' ||
|
|
738
|
+
candidate.activationBlocked !== true ||
|
|
739
|
+
candidate.noOperationalPromotion !== true ||
|
|
740
|
+
!Array.isArray(candidate.holds) ||
|
|
741
|
+
!candidate.holds.includes('historical_suppression_not_proven') ||
|
|
742
|
+
candidate.origin !== m.origin ||
|
|
743
|
+
candidate.configDigest !== m.configDigest ||
|
|
744
|
+
candidate.serverKeyFingerprint !== m.serverKeyFingerprint ||
|
|
745
|
+
candidate.toolCommit !== m.toolCommit ||
|
|
746
|
+
JSON.stringify(candidate.retractionLimits) !== JSON.stringify(limits) ||
|
|
747
|
+
candidate.maintenanceDigest !== attestation.digest ||
|
|
748
|
+
JSON.stringify(candidate.sourcePaths) !== JSON.stringify({ core: source, outbox }) ||
|
|
749
|
+
JSON.stringify(candidate.source) !== JSON.stringify(current))
|
|
750
|
+
refuse('candidate_input_mismatch');
|
|
751
|
+
const expectedFiles = [
|
|
752
|
+
'source-core.sqlite',
|
|
753
|
+
'source-app-arrivals.sqlite',
|
|
754
|
+
path.join('candidate', path.basename(source)),
|
|
755
|
+
path.join('candidate', 'app-arrivals.sqlite'),
|
|
756
|
+
].sort();
|
|
757
|
+
if (JSON.stringify(Object.keys(candidate.hashes).sort()) !== JSON.stringify(expectedFiles))
|
|
758
|
+
refuse('candidate_artifact_set');
|
|
759
|
+
for (const [file, digest] of Object.entries(candidate.hashes)) {
|
|
760
|
+
const absolute = path.resolve(directory, file);
|
|
761
|
+
if (!HEX64.test(digest) ||
|
|
762
|
+
!absolute.startsWith(directory + path.sep) ||
|
|
763
|
+
fileHash(privateFile(absolute)) !== digest)
|
|
764
|
+
refuse('candidate_artifact_mismatch');
|
|
765
|
+
}
|
|
766
|
+
verified = candidate;
|
|
767
|
+
return candidate;
|
|
768
|
+
}
|
|
769
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
770
|
+
created = true;
|
|
771
|
+
syncFile(path.dirname(directory));
|
|
772
|
+
const bundleId = crypto.randomUUID();
|
|
773
|
+
const snapshots = { core: 'source-core.sqlite', outbox: 'source-app-arrivals.sqlite' };
|
|
774
|
+
await backup(core, path.join(directory, snapshots.core));
|
|
775
|
+
await backup(app, path.join(directory, snapshots.outbox));
|
|
776
|
+
for (const file of Object.values(snapshots))
|
|
777
|
+
checkpoint(path.join(directory, file));
|
|
778
|
+
// A fence replaced/lost during snapshot invalidates the cut rather than granting a mixed cohort.
|
|
779
|
+
if (maintenance(options.maintenanceFile).digest !== attestation.digest)
|
|
780
|
+
refuse('maintenance_changed');
|
|
781
|
+
const journal = (stage, number, detail) => {
|
|
782
|
+
durableJson(directory, `${number}-${stage}.json`, { bundleId, stage, detail });
|
|
783
|
+
options.afterStage?.(stage);
|
|
784
|
+
};
|
|
785
|
+
journal('SNAPSHOTTED', '001', { source: current, snapshots, maintenanceDigest: attestation.digest });
|
|
786
|
+
const sourceCore = openRead(path.join(directory, snapshots.core));
|
|
787
|
+
const sourceApp = openRead(path.join(directory, snapshots.outbox));
|
|
788
|
+
readers.push(sourceCore, sourceApp);
|
|
789
|
+
verifySchema(sourceCore, LEGACY_SCHEMA, 2);
|
|
790
|
+
verifySchema(sourceApp, OUTBOX_SCHEMA, 1);
|
|
791
|
+
if (JSON.stringify(sourceState(sourceCore, sourceApp)) !== JSON.stringify(current))
|
|
792
|
+
refuse('snapshot_cohort_mismatch');
|
|
793
|
+
const data = Object.fromEntries(TABLES.map(table => [table, rows(sourceCore, table)]));
|
|
794
|
+
const { messages, archived } = validateRows(data, m);
|
|
795
|
+
const sequence = all(sourceCore, 'SELECT name,seq FROM sqlite_sequence');
|
|
796
|
+
if (sequence.some(row => row.name !== 'inbox'))
|
|
797
|
+
refuse('sequence_layout');
|
|
798
|
+
let high = 0;
|
|
799
|
+
for (const row of sequence)
|
|
800
|
+
high = Math.max(high, integer(row.seq, 'inbox_highwater'));
|
|
801
|
+
for (const row of data.inbox)
|
|
802
|
+
high = Math.max(high, integer(row.cursor, 'inbox_cursor', 1));
|
|
803
|
+
const headroom = Number.MAX_SAFE_INTEGER - 1 - high;
|
|
804
|
+
if (headroom <= 0)
|
|
805
|
+
refuse('stream_headroom');
|
|
806
|
+
const receipts = precomputedReceipts(data, messages, limits);
|
|
807
|
+
const appRows = outboxRows(sourceApp);
|
|
808
|
+
// No inference that absence of a schema2 row proves no deleted suppression or exhausted job.
|
|
809
|
+
const holds = ['historical_suppression_not_proven', 'host_runtime_bom_and_backend_adoption_unverified'];
|
|
810
|
+
if (data.fetches.length ||
|
|
811
|
+
data.parts.length ||
|
|
812
|
+
data.message_parts.length ||
|
|
813
|
+
data.counters.some(row => row.scope === 'files:give-up-rounds'))
|
|
814
|
+
holds.push('terminal_policy_history_and_host_adapter_unverified');
|
|
815
|
+
journal('PREFLIGHT_OK', '002', {
|
|
816
|
+
highWatermark: high,
|
|
817
|
+
usableHeadroom: headroom,
|
|
818
|
+
archivedTombstones: archived,
|
|
819
|
+
holds,
|
|
820
|
+
});
|
|
821
|
+
const converted = path.join(directory, 'candidate');
|
|
822
|
+
fs.mkdirSync(converted, { mode: 0o700 });
|
|
823
|
+
const candidate = {
|
|
824
|
+
core: path.join('candidate', path.basename(source)),
|
|
825
|
+
outbox: path.join('candidate', 'app-arrivals.sqlite'),
|
|
826
|
+
};
|
|
827
|
+
if (candidate.core === candidate.outbox)
|
|
828
|
+
refuse('core_outbox_name_collision');
|
|
829
|
+
const coreFile = path.join(directory, candidate.core);
|
|
830
|
+
const appFile = path.join(directory, candidate.outbox);
|
|
831
|
+
const fresh = new SqliteStore(coreFile, { retractions: limits });
|
|
832
|
+
await fresh.close();
|
|
833
|
+
const destination = new DatabaseSync(coreFile);
|
|
834
|
+
const generations = Object.create(null);
|
|
835
|
+
try {
|
|
836
|
+
destination.exec('BEGIN IMMEDIATE;');
|
|
837
|
+
destination.exec('DELETE FROM positions;');
|
|
838
|
+
insert(destination, 'positions', { n: high });
|
|
839
|
+
for (const table of TABLES)
|
|
840
|
+
for (const row of data[table]) {
|
|
841
|
+
if (table === 'vals' && archived.includes(rowHash(row)))
|
|
842
|
+
continue;
|
|
843
|
+
let extra = {};
|
|
844
|
+
if (table === 'agents')
|
|
845
|
+
extra = { unpairing: null, authority_version: 0, recovery_key_cancelled_at: null, authorized_keys: null };
|
|
846
|
+
if (table === 'agent_keys')
|
|
847
|
+
extra = { label: null };
|
|
848
|
+
if (table === 'messages' || table === 'fetches') {
|
|
849
|
+
const generation = crypto.randomUUID();
|
|
850
|
+
generations[`${table}:${rowHash(row)}`] = generation;
|
|
851
|
+
extra = { generation };
|
|
852
|
+
}
|
|
853
|
+
if (table === 'messages') {
|
|
854
|
+
const message = messages.get(row.id);
|
|
855
|
+
extra = {
|
|
856
|
+
...extra,
|
|
857
|
+
in_reply_to: message.v === 1 ? message.inReplyTo : null,
|
|
858
|
+
signed_claims: null,
|
|
859
|
+
cursor: null,
|
|
860
|
+
taken: null,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
if (table === 'fetches')
|
|
864
|
+
extra = { ...extra, revision: 0, token: null, exhausted: null };
|
|
865
|
+
if (table === 'inbox')
|
|
866
|
+
extra = { taken: time(row.received_at, 'inbox_received') };
|
|
867
|
+
preserveRow(destination, table, row, extra, table === 'inbox' ? ['received_at'] : []);
|
|
868
|
+
if (options.faultAt === 'copy-row')
|
|
869
|
+
refuse('synthetic_copy_failure');
|
|
870
|
+
}
|
|
871
|
+
for (const agent of data.agents)
|
|
872
|
+
insert(destination, 'vals', { scope: 'sync-history-gap', id: agent.address, value: 'true', expires: null });
|
|
873
|
+
insert(destination, 'vals', {
|
|
874
|
+
scope: 'sync-secret',
|
|
875
|
+
id: m.origin,
|
|
876
|
+
value: JSON.stringify(crypto.randomBytes(32).toString('base64url')),
|
|
877
|
+
expires: null,
|
|
878
|
+
});
|
|
879
|
+
verifyPreservation(destination, data, high, archived);
|
|
880
|
+
destination.exec('COMMIT;');
|
|
881
|
+
}
|
|
882
|
+
catch (error) {
|
|
883
|
+
destination.exec('ROLLBACK;');
|
|
884
|
+
throw error;
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
destination.close();
|
|
888
|
+
}
|
|
889
|
+
const out = new DatabaseSync(appFile);
|
|
890
|
+
try {
|
|
891
|
+
out.exec(OUTBOX_SCHEMA);
|
|
892
|
+
out.exec('BEGIN IMMEDIATE;');
|
|
893
|
+
for (const row of appRows)
|
|
894
|
+
preserveRow(out, 'arrivals', row);
|
|
895
|
+
out.exec('COMMIT;');
|
|
896
|
+
if (JSON.stringify(ledger(out, ['arrivals'])) !== JSON.stringify(current.outbox))
|
|
897
|
+
refuse('outbox_preservation');
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
out.close();
|
|
901
|
+
}
|
|
902
|
+
journal('COPIED', '003', { candidate, generations });
|
|
903
|
+
// Only the private candidate is opened for the real bounded current-schema retraction bootstrap.
|
|
904
|
+
if (options.faultAt === 'bootstrap')
|
|
905
|
+
refuse('synthetic_bootstrap_failure');
|
|
906
|
+
const validating = new SqliteStore(coreFile, { retractions: limits });
|
|
907
|
+
await validating.close();
|
|
908
|
+
const verification = openRead(coreFile);
|
|
909
|
+
try {
|
|
910
|
+
if (options.faultAt === 'verify')
|
|
911
|
+
refuse('synthetic_verification_failure');
|
|
912
|
+
verifyPreservation(verification, data, high, archived);
|
|
913
|
+
const actual = object(json(all(verification, 'SELECT body FROM retraction_admission')[0].body, 'receipts'), 'receipts');
|
|
914
|
+
if (Object.keys(actual).length !== Object.keys(receipts).length)
|
|
915
|
+
refuse('retraction_marker_completeness');
|
|
916
|
+
for (const [id, expected] of Object.entries(receipts)) {
|
|
917
|
+
const receipt = object(actual[id], 'retraction_marker_completeness');
|
|
918
|
+
if (typeof receipt.generation !== 'string' || !/^[0-9a-f-]{36}$/.test(receipt.generation))
|
|
919
|
+
refuse('retraction_fence');
|
|
920
|
+
generations[`retraction:${hash(id)}`] = receipt.generation;
|
|
921
|
+
const normalized = {
|
|
922
|
+
...receipt,
|
|
923
|
+
generation: expected.generation,
|
|
924
|
+
knownDeliveries: [...receipt.knownDeliveries].sort(),
|
|
925
|
+
knownArrivals: [...receipt.knownArrivals].sort(),
|
|
926
|
+
};
|
|
927
|
+
if (canonicalize(normalized) !== canonicalize(expected))
|
|
928
|
+
refuse('retraction_marker_completeness');
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
finally {
|
|
932
|
+
verification.close();
|
|
933
|
+
}
|
|
934
|
+
for (const file of [coreFile, appFile])
|
|
935
|
+
checkpoint(file);
|
|
936
|
+
// Persist the nested file entries before the outer VERIFIED record can become durable.
|
|
937
|
+
syncFile(converted);
|
|
938
|
+
const hashes = Object.fromEntries([...Object.values(snapshots), ...Object.values(candidate)].map(file => [
|
|
939
|
+
file,
|
|
940
|
+
fileHash(path.join(directory, file)),
|
|
941
|
+
]));
|
|
942
|
+
const result = {
|
|
943
|
+
status: 'VERIFIED_COPY_HELD',
|
|
944
|
+
activationBlocked: true,
|
|
945
|
+
holds,
|
|
946
|
+
bundleId,
|
|
947
|
+
maintenanceDigest: attestation.digest,
|
|
948
|
+
origin: m.origin,
|
|
949
|
+
serverKeyFingerprint: m.serverKeyFingerprint,
|
|
950
|
+
configDigest: m.configDigest,
|
|
951
|
+
toolCommit: m.toolCommit,
|
|
952
|
+
retractionLimits: limits,
|
|
953
|
+
observation: {
|
|
954
|
+
at: m.observedAt,
|
|
955
|
+
provenance: m.clockProvenance,
|
|
956
|
+
...(m.previousObservedAt && { previous: m.previousObservedAt }),
|
|
957
|
+
},
|
|
958
|
+
sourcePaths: { core: source, outbox },
|
|
959
|
+
source: current,
|
|
960
|
+
snapshots,
|
|
961
|
+
candidate,
|
|
962
|
+
hashes,
|
|
963
|
+
highWatermark: high,
|
|
964
|
+
usableHeadroom: headroom,
|
|
965
|
+
archivedTombstones: archived,
|
|
966
|
+
generations: { ...generations },
|
|
967
|
+
noOperationalPromotion: true,
|
|
968
|
+
};
|
|
969
|
+
durableJson(directory, '004-VERIFIED.json', { bundleId, stage: 'VERIFIED', candidate: result });
|
|
970
|
+
verified = result;
|
|
971
|
+
options.afterStage?.('VERIFIED');
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
const refusal = error instanceof SqliteMigrationRefusal ? error : new SqliteMigrationRefusal('conversion_failed');
|
|
976
|
+
// Once VERIFIED is durable, a later failure (a crash) must not give the bundle a second, refused meaning.
|
|
977
|
+
if (created && directory && !verified) {
|
|
978
|
+
try {
|
|
979
|
+
durableJson(directory, 'REFUSED.json', { status: 'REFUSED', code: refusal.code, noOperationalPromotion: true });
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
// A crash/full disk may prevent the final refusal journal. Existing stages/backups remain untouched.
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
throw refusal;
|
|
986
|
+
}
|
|
987
|
+
finally {
|
|
988
|
+
let cleanupFailed = false;
|
|
989
|
+
for (const reader of readers.reverse()) {
|
|
990
|
+
try {
|
|
991
|
+
reader.close();
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
cleanupFailed = true;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
for (const lock of locks.reverse()) {
|
|
998
|
+
try {
|
|
999
|
+
lock.exec('ROLLBACK;');
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
cleanupFailed = true;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
lock.close();
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
cleanupFailed = true;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (options.faultAt === 'cleanup')
|
|
1012
|
+
cleanupFailed = true;
|
|
1013
|
+
if (cleanupFailed) {
|
|
1014
|
+
if (verified)
|
|
1015
|
+
throw new SqliteV2CleanupFailure(verified);
|
|
1016
|
+
refuse('cleanup_failed');
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|