diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44bce86..23ef448 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,14 +66,10 @@ jobs: # Do not contend for the host package manager during a repository job. run: npx playwright install chromium - # A plain static server rather than the dev toolchain, so the audit tests - # exactly the bytes that will be deployed and does not depend on a dev - # server starting inside a fixed window. - - name: Serve the built site - run: npm run serve:dist & - + # One process owns a loopback ephemeral-port preview and its audit child. + # Readiness checks the per-run identity and exact build, with bounded waits. - name: Accessibility and horizontal overflow, every page, both themes - run: npx wait-on http://localhost:4328/docs-wallet/ && npm run check:a11y + run: npm run check:a11y - name: Secret scan (full history) uses: trufflesecurity/trufflehog@v3.90.5 diff --git a/llms.txt b/llms.txt index ef6f37d..048d156 100644 --- a/llms.txt +++ b/llms.txt @@ -67,7 +67,7 @@ - [What this release authorizes](https://bitcoinuniverseio.github.io/docs-wallet/assets/support-state): The generated answer to which protocol operations Universe Wallet 1.7.5.8 authorizes, and why the number is what it is. - [What a digital artifact is](https://bitcoinuniverseio.github.io/docs-wallet/assets/what-a-digital-artifact-is): How an inscription, a token balance, and a rare satoshi actually exist on Bitcoin, and why that makes them fragile in an ordinary wallet. - [Why an action is unavailable](https://bitcoinuniverseio.github.io/docs-wallet/assets/why-unavailable): What the unavailable screen means, what it is not, and the list of things that will not change it. -- [Zcash market listings](https://bitcoinuniverseio.github.io/docs-wallet/assets/zcash-market-listings): What a Zerdinal listing signature commits you to, why the approval can be blocked outright, and why cancelling a listing is only advisory. +- [Zcash market listings](https://bitcoinuniverseio.github.io/docs-wallet/assets/zcash-market-listings): Review Zerdinal listings and full token lots with exact total prices, clear quantities, and a separate purchase approval. ## Safety diff --git a/package.json b/package.json index b67622a..3975351 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,11 @@ "check:manifest": "node scripts/check-manifest.mjs", "check:diagrams": "node scripts/check-diagrams.mjs", "check:markdown": "markdownlint-cli2 \"src/content/**/*.{md,mdx}\" \"*.md\"", - "check:a11y": "node scripts/check-accessibility.mjs", + "check:a11y": "node scripts/run-accessibility.mjs", + "check:preview": "node --test scripts/preview-lifecycle.test.mjs", "capability:pull": "node scripts/sync-capability.mjs --pull", "capability:write": "node scripts/sync-capability.mjs", - "test": "npm run check:copy && npm run check:public-safety && npm run check:capability && npm run check:manifest && npm run check:schema && npm run check:diagrams && npm run check:llms && npm run check:data && npm run check:markdown", + "test": "npm run check:preview && npm run check:copy && npm run check:public-safety && npm run check:capability && npm run check:manifest && npm run check:schema && npm run check:diagrams && npm run check:llms && npm run check:data && npm run check:markdown", "check:llms": "node scripts/generate-llms.mjs --check", "check:schema": "node scripts/validate-manifest-schema.mjs", "llms": "node scripts/generate-llms.mjs", diff --git a/scripts/check-accessibility.mjs b/scripts/check-accessibility.mjs index a4d84b1..4743825 100644 --- a/scripts/check-accessibility.mjs +++ b/scripts/check-accessibility.mjs @@ -5,7 +5,7 @@ // the source intends. Fails on any axe violation at serious or critical impact, // and on any page that scrolls horizontally at 320px. // -// Run: npx astro preview --port 4323 & node scripts/check-accessibility.mjs +// Run: npm run check:a11y (owns and verifies its built preview). import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; diff --git a/scripts/preview-lifecycle.test.mjs b/scripts/preview-lifecycle.test.mjs new file mode 100644 index 0000000..8f8b537 --- /dev/null +++ b/scripts/preview-lifecycle.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { BASE, buildDigest, IDENTITY_PATH, startDistServer } from './serve-dist.mjs'; +import { runAudit, verifyPreview, withBuiltPreview } from './run-accessibility.mjs'; + +async function fixture(t, text = 'built docs') { + const directory = await mkdtemp(join(tmpdir(), 'docs-wallet-preview-')); + const root = join(directory, 'dist'); + await mkdir(root); + await writeFile(join(root, 'index.html'), text); + await writeFile(join(root, 'sitemap-0.xml'), ''); + t.after(() => rm(directory, { recursive: true, force: true })); + return { directory, root }; +} + +test('simultaneous previews own separate loopback ports and exact built identities', async (t) => { + const a = await fixture(t, 'build A'); + const b = await fixture(t, 'build B'); + const [first, second] = await Promise.all([ + startDistServer({ root: a.root }), startDistServer({ root: b.root }), + ]); + t.after(() => Promise.all([first.close(), second.close()])); + assert.notEqual(first.origin, second.origin); + assert.match(first.origin, /^http:\/\/127\.0\.0\.1:/); + assert.notEqual(first.identity.owner, second.identity.owner); + assert.notEqual(first.identity.buildDigest, second.identity.buildDigest); + assert.equal(first.identity.buildDigest, await buildDigest(a.root)); + await Promise.all([verifyPreview(first.origin, first.identity), verifyPreview(second.origin, second.identity)]); + assert.equal(await (await fetch(first.origin + BASE + '/')).text(), 'build A'); + const response = await fetch(second.origin + BASE + '/'); + assert.equal(response.headers.get('access-control-allow-origin'), '*'); + assert.equal(await response.text(), 'build B'); + await assert.rejects(verifyPreview(first.origin, { ...first.identity, owner: second.identity.owner }), /identity mismatch/); + await assert.rejects(verifyPreview(first.origin, { ...first.identity, buildDigest: second.identity.buildDigest }), /identity mismatch/); + await first.close(); + await assert.rejects(fetch(first.origin + BASE + '/')); + assert.equal(await (await fetch(second.origin + BASE + '/')).text(), 'build B'); +}); + +test('occupied listeners are never borrowed or killed, and readiness is bounded', async (t) => { + const { root } = await fixture(t); + const existing = await startDistServer({ root }); + t.after(() => existing.close()); + await assert.rejects(startDistServer({ root, port: Number(new URL(existing.origin).port) }), { code: 'EADDRINUSE' }); + await verifyPreview(existing.origin, existing.identity); + const stalled = createServer(() => {}); + await new Promise((ready) => stalled.listen(0, '127.0.0.1', ready)); + t.after(async () => { + stalled.closeAllConnections(); + await new Promise((closed) => stalled.close(closed)); + }); + await assert.rejects( + verifyPreview('http://127.0.0.1:' + stalled.address().port, existing.identity, 25), + { name: 'TimeoutError' }, + ); +}); + +test('failed callbacks and changed build bytes close only the owned preview', async (t) => { + const { root } = await fixture(t); + const survivor = await startDistServer({ root }); + t.after(() => survivor.close()); + let failedOrigin; + await assert.rejects(withBuiltPreview(async ({ origin }) => { + failedOrigin = origin; + throw new Error('gate failed'); + }, { root }), /gate failed/); + await assert.rejects(fetch(failedOrigin + IDENTITY_PATH)); + await verifyPreview(survivor.origin, survivor.identity); + await assert.rejects(withBuiltPreview(async () => { + await writeFile(join(root, 'index.html'), 'different build'); + }, { root }), /changed during the audit/); + let ran = false; + await assert.rejects(withBuiltPreview(() => { ran = true; }, { root: join(root, 'missing') })); + assert.equal(ran, false); +}); + +test('audit children receive their owned origin and nonzero gates propagate', async (t) => { + const { root, directory } = await fixture(t, 'expected build'); + const script = join(directory, 'failing-audit.mjs'); + const observed = join(directory, 'observed.txt'); + await writeFile(script, + "import { writeFile } from 'node:fs/promises';\n" + + "const response = await fetch(process.env.PREVIEW_ORIGIN + '/docs-wallet/');\n" + + "await writeFile(" + JSON.stringify(observed) + ", await response.text());\n" + + "process.exitCode = 42;\n"); + let origin; + await assert.rejects(withBuiltPreview(async (preview) => { + origin = preview.origin; + await runAudit({ + origin, script, env: { ...process.env, PREVIEW_ORIGIN: 'http://127.0.0.1:1' }, + stdio: 'ignore', + }); + }, { root }), /exited with code 42/); + assert.equal(await readFile(observed, 'utf8'), 'expected build'); + await assert.rejects(fetch(origin + IDENTITY_PATH)); +}); + +test('timed out and cancelled children terminate without closing other previews', async (t) => { + const { root, directory } = await fixture(t); + const script = join(directory, 'stalled-audit.mjs'); + await writeFile(script, "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);\n"); + const survivor = await startDistServer({ root }); + t.after(() => survivor.close()); + await assert.rejects(withBuiltPreview(({ origin }) => + runAudit({ origin, script, timeoutMs: 250, stdio: 'ignore' }), { root }), + /audit timed out/); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 250); + try { + await assert.rejects(withBuiltPreview(({ origin }) => + runAudit({ origin, script, signal: controller.signal, stdio: 'ignore' }), { root }), + /audit cancelled/); + } finally { clearTimeout(timer); } + await verifyPreview(survivor.origin, survivor.identity); +}); diff --git a/scripts/run-accessibility.mjs b/scripts/run-accessibility.mjs new file mode 100644 index 0000000..ae7806e --- /dev/null +++ b/scripts/run-accessibility.mjs @@ -0,0 +1,84 @@ +// Run the unchanged browser gates against one owned, identified static build. +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildDigest, DIST_ROOT, IDENTITY_PATH, startDistServer } from './serve-dist.mjs'; + +const AUDIT_SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), 'check-accessibility.mjs'); + +export async function verifyPreview(origin, identity, timeoutMs = 10_000) { + if (!/^http:\/\/127\.0\.0\.1:[1-9][0-9]*$/.test(origin)) throw new Error('Preview must use owned loopback origin'); + const response = await fetch(origin + IDENTITY_PATH, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error('Preview identity request failed: ' + response.status); + const actual = await response.json(); + if (actual.owner !== identity.owner || actual.buildDigest !== identity.buildDigest) { + throw new Error('Preview ownership or built-document identity mismatch'); + } +} + +export async function withBuiltPreview(run, { root = DIST_ROOT, readinessTimeoutMs = 10_000 } = {}) { + const expectedDigest = await buildDigest(root); + const preview = await startDistServer({ root, port: 0, owner: randomUUID() }); + try { + await verifyPreview(preview.origin, { ...preview.identity, buildDigest: expectedDigest }, readinessTimeoutMs); + process.stdout.write('Auditing built docs ' + expectedDigest + ' at ' + preview.origin + '\n'); + const result = await run(preview); + if (await buildDigest(root) !== expectedDigest) throw new Error('Built documents changed during the audit'); + return result; + } finally { + await preview.close(); + } +} + +export async function runAudit({ + origin, script = AUDIT_SCRIPT, signal, timeoutMs = 20 * 60_000, + env = process.env, stdio = 'inherit', +}) { + if (signal?.aborted) throw new Error('Accessibility audit cancelled'); + const child = spawn(process.execPath, [script], { + env: { ...env, PREVIEW_ORIGIN: origin }, + stdio, + shell: false, + }); + let failure; + let forcedStop; + const stop = (message) => { + if (failure) return; + failure = new Error(message); + child.kill('SIGTERM'); + // The handle belongs to this invocation; never inspect or kill by port. + forcedStop = setTimeout(() => child.kill('SIGKILL'), 2_000); + }; + const onAbort = () => stop('Accessibility audit cancelled'); + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout(() => stop('Accessibility audit timed out'), timeoutMs); + try { + const code = await new Promise((resolveExit, reject) => { + child.once('error', reject); + child.once('close', resolveExit); + }); + if (failure) throw failure; + if (code !== 0) throw new Error('Accessibility audit exited with code ' + code); + } finally { + clearTimeout(timer); + clearTimeout(forcedStop); + signal?.removeEventListener('abort', onAbort); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const controller = new AbortController(); + const onSignal = () => controller.abort(); + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + try { + await withBuiltPreview(({ origin }) => runAudit({ origin, signal: controller.signal })); + } catch (error) { + process.stderr.write(error.message + '\n'); + process.exitCode = 1; + } finally { + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + } +} diff --git a/scripts/serve-dist.mjs b/scripts/serve-dist.mjs index 4b7a301..2e5ffc3 100644 --- a/scripts/serve-dist.mjs +++ b/scripts/serve-dist.mjs @@ -1,76 +1,119 @@ -// Minimal static server for dist/, mounted at the site's base path. -// Used by the accessibility audit so it does not depend on the dev toolchain -// starting within a fixed window on a loaded machine. +// Static preview of the built site. Each audit owns a loopback listener. +import { createHash, randomUUID } from 'node:crypto'; import { createServer } from 'node:http'; -import { readFile, stat } from 'node:fs/promises'; -import { join, extname, resolve, dirname } from 'node:path'; +import { readFile, readdir, realpath, stat } from 'node:fs/promises'; +import { join, extname, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist'); -const BASE = '/docs-wallet'; -const PORT = Number(process.env.PORT ?? 4328); - +export const DIST_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist'); +export const BASE = '/docs-wallet'; +export const IDENTITY_PATH = BASE + '/__preview_identity'; const TYPES = { - '.html': 'text/html; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.svg': 'image/svg+xml', - '.woff2': 'font/woff2', - '.xml': 'application/xml; charset=utf-8', - '.txt': 'text/plain; charset=utf-8', - '.wasm': 'application/wasm', - '.png': 'image/png', - '.webp': 'image/webp', - '.avif': 'image/avif', + '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', '.xml': 'application/xml; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', '.wasm': 'application/wasm', + '.png': 'image/png', '.webp': 'image/webp', '.avif': 'image/avif', '.webmanifest': 'application/manifest+json', - '.pf_meta': 'application/octet-stream', - '.pf_fragment': 'application/octet-stream', + '.pf_meta': 'application/octet-stream', '.pf_fragment': 'application/octet-stream', '.pf_index': 'application/octet-stream', }; -async function resolveFile(pathname) { - let p = pathname.startsWith(BASE) ? pathname.slice(BASE.length) : pathname; - if (p === '' || p === '/') p = '/index.html'; - let file = join(root, decodeURIComponent(p)); - try { - const s = await stat(file); - if (s.isDirectory()) file = join(file, 'index.html'); - return file; - } catch { - for (const candidate of [`${file}.html`, join(file, 'index.html')]) { - try { - await stat(candidate); - return candidate; - } catch { - /* keep looking */ - } +export async function buildDigest(root = DIST_ROOT) { + const hash = createHash('sha256'); + for (const required of ['index.html', 'sitemap-0.xml']) { + if (!(await stat(join(root, required))).isFile()) throw new Error('Missing built document: ' + required); + } + async function visit(directory, prefix = '') { + const entries = (await readdir(directory, { withFileTypes: true })) + .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry of entries) { + const name = prefix + entry.name; + if (entry.isDirectory()) await visit(join(directory, entry.name), name + '/'); + else if (entry.isFile()) { + const bytes = await readFile(join(directory, entry.name)); + hash.update(name + '\0' + bytes.length + '\0').update(bytes).update('\0'); + } else throw new Error('Unsupported built document entry: ' + name); } - return null; } + await visit(root); + return hash.digest('hex'); } -createServer(async (req, res) => { - const { pathname } = new URL(req.url, 'http://localhost'); - const file = await resolveFile(pathname); - if (!file) { - const notFound = join(root, '404.html'); - try { - res.writeHead(404, { 'content-type': TYPES['.html'] }); - res.end(await readFile(notFound)); - } catch { - res.writeHead(404).end('not found'); +export async function startDistServer({ root = DIST_ROOT, port = 0, owner = randomUUID() } = {}) { + if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid preview port'); + root = await realpath(root); + const identity = { owner, buildDigest: await buildDigest(root) }; + + async function resolveFile(pathname) { + const decoded = decodeURIComponent(pathname); + let path = decoded === BASE || decoded.startsWith(BASE + '/') ? decoded.slice(BASE.length) : decoded; + if (path === '' || path === '/') path = '/index.html'; + const file = resolve(root, '.' + path); + if (file !== root && !file.startsWith(root + sep)) return null; + for (const candidate of [file, file + '.html', join(file, 'index.html')]) { + try { + const actual = await realpath(candidate); + if (!actual.startsWith(root + sep)) continue; + if ((await stat(actual)).isFile()) return actual; + } catch { /* try the next static route */ } } - return; + return null; } - // GitHub Pages serves every asset with ACAO:*, which the sandboxed - // simulator frame (opaque origin) depends on for its module scripts. + + const server = createServer((req, res) => { + void (async () => { + const { pathname } = new URL(req.url, 'http://127.0.0.1'); + if (pathname === IDENTITY_PATH) { + res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(JSON.stringify(identity)); + return; + } + const file = await resolveFile(pathname); + if (!file) { + const notFound = await readFile(join(root, '404.html')).catch(() => Buffer.from('not found')); + res.writeHead(404, { 'content-type': TYPES['.html'] }).end(notFound); + return; + } + const bytes = await readFile(file); + // The sandboxed simulator has an opaque origin and needs Pages' CORS policy. res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', 'access-control-allow-origin': '*', }); - res.end(await readFile(file)); -}).listen(PORT, () => { - process.stdout.write(`serving dist at http://localhost:${PORT}${BASE}/\n`); -}); + res.end(bytes); + })().catch(() => { + if (!res.headersSent) res.writeHead(500); + res.end('preview request failed'); + }); + }); + + await new Promise((resolveReady, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.off('error', reject); + resolveReady(); + }); + }); + const origin = 'http://127.0.0.1:' + server.address().port; + return { + origin, identity, + async close() { + if (!server.listening) return; + server.closeAllConnections(); + await new Promise((resolveClosed, reject) => server.close((error) => error ? reject(error) : resolveClosed())); + }, + }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const preview = await startDistServer({ port: Number(process.env.PORT ?? 0) }); + process.stdout.write('serving dist at ' + preview.origin + BASE + '/\n'); + const stop = () => void preview.close().catch((error) => { + process.stderr.write(error.message + '\n'); + process.exitCode = 1; + }); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); +} diff --git a/src/content/docs/assets/zcash-market-listings.mdx b/src/content/docs/assets/zcash-market-listings.mdx index bc954f9..cd02a9b 100644 --- a/src/content/docs/assets/zcash-market-listings.mdx +++ b/src/content/docs/assets/zcash-market-listings.mdx @@ -1,88 +1,96 @@ --- title: Zcash market listings -description: What a Zerdinal listing signature commits you to, why the approval can be blocked outright, and why cancelling a listing is only advisory. +description: Review Zerdinal listings and full token lots with exact total prices, clear quantities, and a separate purchase approval. sourceRepo: bitcoinuniverseio/wallet -sourcePath: backend/shared/utils/zcash-tx.ts, frontend/ui/pages/Approval/components/ZcashSignMarketListing.tsx +sourcePath: backend/shared/utils/zcash-market-settlement.ts, frontend/ui/pages/Approval/components/ZcashSignMarketSettlement/index.tsx chain: [zcash] lifecycle: not-released -lastVerified: 2026-09-01 +lastVerified: 2026-09-17 --- import { Aside } from '@astrojs/starlight/components'; -import snapshot from '../../../../capability-snapshot.json'; - -Universe Wallet implements listing signatures for the Universe market's signed-order protocol, ZMarket -Orders v1: a fixed-price offer to sell a Zerdinal inscription. - -