Added opt-in canonical observer polling lifecycle

This commit is contained in:
2026-10-20 20:11:57 -04:00
parent 8cfcf6c025
commit 7025c37390
8 changed files with 200 additions and 15 deletions
+73 -6
View File
@@ -28,6 +28,7 @@ import { SALE_CANDIDATE, saleTermsMessage, saleCompletionMessage, saleCancellati
import { replayReservedSale, type SaleRehearsal } from '../../server/market/sale-rehearsal.ts';
import { keccak } from 'hash-wasm';
import type { CanonicalInboxPage } from '../../server/market/canonical-inbox.ts';
import { CanonicalObserver } from '../../server/market/canonical-observer.ts';
import { Ledger } from '../../server/testnet/ledger.ts';
import { GENESIS_TIME, makeTemplate, miningBlob, mintId, transition } from '../../server/testnet/consensus.ts';
import { deriveStats } from '../../shared/enrichment.ts';
@@ -106,6 +107,9 @@ class FixturePow {
async hash(key: string, blob: Uint8Array): Promise<string> { return sha256(Buffer.concat([Buffer.from(key, 'hex'), Buffer.from(blob)])); }
close(): void {}
}
class EasyFixturePow extends FixturePow {
async hash(_key: string, _blob: Uint8Array): Promise<string> { return '0'.repeat(64); }
}
function fixtureArticle(pageId = 50000) {
return { pageId, title: 'Canonical inbox fixture', extract: `Synthetic local-only canonical observer test fixture ${pageId}.`,
image: 'https://upload.wikimedia.org/wikipedia/commons/test-image.jpg', url: `https://en.wikipedia.org/?curid=${pageId}`,
@@ -117,13 +121,13 @@ function fixtureStats() {
languagesTruncated: false, pageviews: { status: 'unavailable' as const, months: [], latestMonth: '2026-09',
total: null, dailyAverage: null, trendPercent: null }, stats: deriveStats(pageBytes, languageCount, null) };
}
async function fixtureBlock(ancestors: TestnetBlock[], state: NftStateView, pageId = 50000) {
async function fixtureBlock(ancestors: TestnetBlock[], state: NftStateView, pageId = 50000, proof = new FixturePow()) {
const miner = seller;
const template = makeTemplate(ancestors, state, miner.actor, miner.publicKey, [], fixtureArticle(pageId), fixtureStats(),
GENESIS_TIME + (ancestors.length + 1) * 30);
const header = structuredClone(template.header), pow = new FixturePow();
const header = structuredClone(template.header);
for (header.nonce = 0; header.nonce < 100000; header.nonce++) {
const blob = miningBlob(header), powHash = await pow.hash(header.key, blob);
const blob = miningBlob(header), powHash = await proof.hash(header.key, blob);
if (BigInt(`0x${powHash}`) > BigInt(`0x${header.target}`)) continue;
const hash = sha256(blob);
return { ...header, hash, tokenId: mintId(header), powHash,
@@ -276,9 +280,10 @@ test('SQLite cutover preserves identity, history, prepared attempts, ordering an
test('PostgreSQL HTTP custody integration, safe storage binding and backup refusal use the real runtime role', { timeout: 45000 }, async t => {
const f = await fixture(t), dir = await mkdtemp(join(tmpdir(), 'custody-pg-http-'));
const urlFile = join(dir, 'pg-url'); await writeFile(urlFile, 'postgresql://custody_runtime@127.0.0.1/custody_test', { mode: 0o600 });
const env = { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'production', WIKIDECK_DATA_DIR: dir,
const env = { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'test', WIKIDECK_DATA_DIR: dir,
WIKIDECK_CUSTODY_MODE: 'sandbox', WIKIDECK_CUSTODY_STORAGE: 'postgres', WIKIDECK_CUSTODY_PG_URL_FILE: urlFile,
WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1', WIKIDECK_PAYMENT_MODE: 'mock', WIKIDECK_PEERS: '' };
WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1', WIKIDECK_PG_DISPOSABLE_TEST: '1', WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1',
WIKIDECK_PAYMENT_MODE: 'mock', WIKIDECK_PEERS: '' };
const child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
t.after(async () => { if (child.exitCode === null) child.kill('SIGTERM'); await closed; await rm(dir, { recursive: true, force: true }); });
@@ -289,12 +294,14 @@ test('PostgreSQL HTTP custody integration, safe storage binding and backup refus
child.once('exit', () => { clearTimeout(timer); reject(new Error('HTTP startup failed')); });
});
const page = await (await fetch(`${base}/api/custody`)).json(); assert.equal(page.info.custodianId, f.market.id);
const observerStream = await f.admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='canonical-inbox:v1:stream'");
assert.match(observerStream.rows[0].value, /^[a-f0-9-]{36}$/);
const command = envelope(f.market.id, buyer, { type: 'mock-credit' });
const response = await fetch(`${base}/api/custody/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command) });
assert.equal(response.status, 200); assert.equal((await response.json()).availableAtomicXmr, '1000000000000');
assert.match(response.headers.get('cache-control') ?? '', /no-store/);
assert.equal(JSON.parse(await readFile(join(dir, 'testnet', 'custody-postgres.json'), 'utf8')).custodianId, f.market.id);
child.kill('SIGTERM'); await closed;
child.kill('SIGTERM'); await closed; assert.equal(child.exitCode, 0, 'opt-in observer should drain before custody shutdown');
await assert.rejects(createBackup(dir, join(dir, '..', `refused-${randomUUID()}`)), /coordinated/);
await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'sqlite' }), /fallback prohibited/);
});
@@ -303,6 +310,14 @@ test('configuration requires secret files, verified TLS, literal backend, and lo
const dir = await mkdtemp(join(tmpdir(), 'custody-pg-config-')); t.after(() => rm(dir, { recursive: true, force: true }));
const file = join(dir, 'connection');
assert.equal(custodyStorage({}), 'sqlite'); assert.throws(() => custodyStorage({ WIKIDECK_CUSTODY_STORAGE: 'other' }));
await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'sqlite',
WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /requires PostgreSQL/);
await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'postgres',
WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1' }), /disposable database test guard/);
await assert.rejects(openCustodyStorage(dir, options, { NODE_ENV: 'production', WIKIDECK_CUSTODY_STORAGE: 'postgres',
WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /requires PostgreSQL/);
await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'postgres',
WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: 'yes', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /exactly 0 or 1/);
await assert.rejects(postgresConfig({}), /configuration/);
await writeFile(file, 'postgresql://user:secret@database.example/custody');
const secure = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file }); assert.equal((secure.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true);
@@ -562,6 +577,58 @@ test('gated adapter consumes actual validated local Ledger connect and reorg pag
await observer.audit();
});
test('scheduled observer bounds catch-up, stops cleanly, and resumes its durable cursor after restart', async t => {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-pg-observer-runner-'));
const pow = new EasyFixturePow(), ledger = await Ledger.open(dir, pow), f = await fixture(t);
const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
t.after(async () => { await observer.close(); ledger.close(); pow.close(); await rm(dir, { recursive: true, force: true }); });
const chain: TestnetBlock[] = []; let state: NftStateView = {};
for (let i = 0; i < 66; i++) {
const block = await fixtureBlock(chain, state, 51000 + i, pow);
await ledger.submit(block); state = transition(state, block, []); chain.push(block);
}
const firstRunner = new CanonicalObserver(observer, ledger, { intervalMs: 60000, pagesPerCycle: 1 });
try {
const priorMode = process.env.NODE_ENV, priorGate = process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL;
try {
process.env.NODE_ENV = 'production';
assert.throws(() => new CanonicalObserver(observer, ledger), /non-production/);
process.env.NODE_ENV = 'test'; delete process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL;
assert.throws(() => new CanonicalObserver(observer, ledger), /non-production/);
} finally {
if (priorMode === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = priorMode;
if (priorGate === undefined) delete process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL;
else process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL = priorGate;
}
const first = await firstRunner.start();
assert.equal(first.state, 'running'); assert.equal(first.cursor, 64); assert.equal(first.head, 66); assert.equal(first.caughtUp, false);
const caughtUp = await firstRunner.pollNow();
assert.equal(caughtUp.cursor, 66); assert.equal(caughtUp.caughtUp, true); assert.equal(caughtUp.tip, chain.at(-1)!.hash);
await firstRunner.close(); assert.equal(firstRunner.status().state, 'stopped');
const next = await fixtureBlock(chain, state, 52000, pow);
await ledger.submit(next);
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 66);
await assert.rejects(firstRunner.pollNow(), /not running/);
const restarted = new CanonicalObserver(observer, ledger, { intervalMs: 60000, pagesPerCycle: 1 });
try {
const resumed = await restarted.start();
assert.equal(resumed.cursor, 67); assert.equal(resumed.head, 67); assert.equal(resumed.caughtUp, true);
assert.equal(resumed.tip, next.hash);
} finally { await restarted.close(); }
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 67);
chain.push(next); state = transition(state, next, []);
const automatic = new CanonicalObserver(observer, ledger, { intervalMs: 25, pagesPerCycle: 1 });
try {
assert.equal((await automatic.start()).cursor, 67);
const automaticBlock = await fixtureBlock(chain, state, 52001, pow);
await ledger.submit(automaticBlock);
const deadline = Date.now() + 3000;
while (automatic.status().cursor < 68 && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(automatic.status().cursor, 68, 'scheduled poll did not consume the new ledger event');
} finally { await automatic.close(); }
} finally { await firstRunner.close(); }
});
test('canonical inbox rejects a disconnected block with a forged but rehashed ancestor', async t => {
const f = await fixture(t), observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
const streamId = randomUUID(), events = canonicalEvents(streamId);