diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d5ac9a46..496513fb 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Gitea verification diagnostics — 29 October. Partial.** The approved +runner network configuration was already applied, and later runs reached the +application and published images. Run #85 failed in administrator acceptance +when a fetch threw before its response. Its logged 403 was the previous +completed request, a possible expected denial, not proof of an authorization +failure; the exception's cause was not recorded. Acceptance diagnostics now +distinguish request exceptions from the last completed response and report only +sanitized details; the security checks are unchanged. Two focused diagnostic +cases pass. Run #86 was building its container at the last check; the updated +diagnostics still need CI verification. + **Truthtrance ally funding — 29 October. Prototyped / Partial.** Supported quiet Basic Bidding and Shipment/Movement questions can bind a future funding update to the respondent's current mutual ally. Existing escrow alone diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 3047cf80..47f1121e 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -8,8 +8,8 @@ runtime prototype does not change public release or rules-mode gates. ## Checkpoint images -The source repository is moving to [Gitea](https://git.juiced.computer/admin/Dune). -Its `.gitea/workflows/container.yml` is intended to build and verify checkpoint +The source repository is hosted on [Gitea](https://git.juiced.computer/admin/Dune). +Its `.gitea/workflows/container.yml` builds and verifies checkpoint images on pushes to `main`. Configure the Gitea Actions variables `DUNE_CONTAINER_IMAGE` for the full registry image name and `DUNE_REGISTRY_USERNAME` for the registry account; supply the registry token as @@ -17,14 +17,15 @@ the `CONTAINER_REGISTRY_TOKEN` Actions secret. The default image name is `git.juiced.computer/admin/dune`. Registry access and image visibility depend on the actual Gitea package settings; do not assume anonymous pulls work. -No Gitea runner has been registered and no Gitea image build has succeeded yet. -A source push is therefore not a verified container image or a deployment. -Register and configure an `ubuntu-latest` runner whose job environment has a -working Docker CLI, daemon and Buildx, plus network access to actions, npm and -container images. Configure registry access, then verify the -workflow and its published image before considering a manual NAS update. The -existing Compose configuration continues to reference the GHCR image until a -separately verified migration is applied at a safe point. +An `ubuntu-latest` Gitea runner is configured. On 29 October 2026, run #83 +verified and published commit `b18a7674702b92b6988b00cbde6212fa3b66b74a`. +The registry shows its immutable `sha-b18a7674702b92b6988b00cbde6212fa3b66b74a` +tag and `checkpoint` with the same digest. Run #85 failed before publication, +so a source push still does not by itself establish a verified image or a +deployment. For each checkpoint, verify the relevant workflow run and its +published revision before considering a manual NAS update. The existing TrueNAS +deployment and Compose configuration continue to reference the GHCR image; a +Gitea publication does not migrate them. The previous GitHub workflow, `.github/workflows/container.yml`, builds a production image using the lockfile on GitHub pushes. Types, lint and offline diff --git a/tests/admin-verification-diagnostics.test.ts b/tests/admin-verification-diagnostics.test.ts new file mode 100644 index 00000000..2231cb33 --- /dev/null +++ b/tests/admin-verification-diagnostics.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + describeFailure, + failureLocation, + requestFailure, + requestIdentity, +} from '../tools/admin-verification-diagnostics.mjs'; + +void test('a request failure remains distinct from an earlier expected denial', () => { + const completed = { ...requestIdentity('POST', '/api/admin/rooms/ABCDEFGH/seat-ai'), status: 403 }; + const error = Object.assign(new TypeError('private message'), { + cause: { code: 'ECONNRESET', message: 'private cause' }, + }); + const failed = requestFailure(requestIdentity('POST', '/api/admin/rooms/ABCDEFGH/seat-ai?secret=private'), error); + assert.equal(describeFailure(completed, undefined), 'POST /api/admin/rooms/ABCDEFGH/seat-ai last completed with HTTP 403'); + assert.equal(describeFailure(failed, 'tools/admin-seat-ai-verification.mjs:35:7'), + 'POST /api/admin/rooms/ABCDEFGH/seat-ai failed before completing a response (TypeError, ECONNRESET); tools/admin-seat-ai-verification.mjs:35:7'); + assert.doesNotMatch(JSON.stringify(failed), /private|secret/); +}); + +void test('diagnostics allow only known error names, codes and verification locations', () => { + const error = { name: 'credential', code: 'private', stack: [ + 'Error: private assertion payload', + ' at request (file:///workspace/tools/verify-admin.mjs:42:5)', + ' at verifyParticipantAi (file:///workspace/tools/admin-seat-ai-verification.mjs:35:7)', + ].join('\n') }; + assert.deepEqual(requestFailure(requestIdentity('GET', '/api/admin/rooms'), error), { + method: 'GET', path: '/api/admin/rooms', kind: 'request-error', name: 'Error', + }); + assert.equal(failureLocation(error), 'tools/admin-seat-ai-verification.mjs:35:7'); + assert.equal(failureLocation({ stack: 'Error: private\n at other (file:///elsewhere/private.js:2:3)' }), undefined); + assert.equal(requestIdentity('GET', '/api/admin/rooms/!secret').path, '[invalid path]'); +}); diff --git a/tools/admin-verification-diagnostics.mjs b/tools/admin-verification-diagnostics.mjs new file mode 100644 index 00000000..d446c64e --- /dev/null +++ b/tools/admin-verification-diagnostics.mjs @@ -0,0 +1,40 @@ +// Keep acceptance diagnostics useful without copying request or assertion data. +const errorNames = new Set(['Error', 'TypeError', 'AbortError', 'TimeoutError']); +const networkCodes = new Set([ + 'ABORT_ERR', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', + 'ENOTFOUND', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET', +]); + +export function requestIdentity(method, path) { + const pathname = typeof path === 'string' ? path.split('?')[0] : ''; + return { + method: method === 'POST' ? 'POST' : 'GET', + path: /^\/api\/[a-zA-Z0-9_/-]+$/.test(pathname) ? pathname : '[invalid path]', + }; +} + +export function requestFailure(request, error) { + const name = errorNames.has(error?.name) ? error.name : 'Error'; + const candidate = error?.cause?.code ?? error?.code; + const causeCode = networkCodes.has(candidate) ? candidate : undefined; + return { ...request, kind: 'request-error', name, ...(causeCode ? { causeCode } : {}) }; +} + +export function failureLocation(error) { + const lines = typeof error?.stack === 'string' ? error.stack.split('\n') : []; + const matches = lines.flatMap(line => { + const match = line.match(/(?:^|\/)tools\/(admin-[a-z0-9-]+-verification\.mjs|verify-admin\.mjs):(\d+):(\d+)(?:\)|$)/); + return match ? [`tools/${match[1]}:${match[2]}:${match[3]}`] : []; + }); + return matches.find(location => !location.startsWith('tools/verify-admin.mjs:')) ?? matches[0]; +} + +export function describeFailure(request, location) { + if (!request) return undefined; + const prefix = `${request.method} ${request.path}`; + const result = request.kind === 'request-error' + ? `failed before completing a response (${request.name}${request.causeCode ? `, ${request.causeCode}` : ''})` + : `last completed with HTTP ${request.status}`; + return `${prefix} ${result}${location ? `; ${location}` : ''}`; +} diff --git a/tools/verify-admin.mjs b/tools/verify-admin.mjs index ac67eaea..cb820aec 100644 --- a/tools/verify-admin.mjs +++ b/tools/verify-admin.mjs @@ -13,6 +13,7 @@ import { verifyParticipantAi } from './admin-seat-ai-verification.mjs'; import { verifyRoomArchive } from './admin-archive-verification.mjs'; import { verifyRoomClosure } from './admin-closure-verification.mjs'; import { verifyRoomRemoval } from './admin-removal-verification.mjs'; +import { requestIdentity, requestFailure, failureLocation } from './admin-verification-diagnostics.mjs'; const options = {}; for (let n = 2; n < process.argv.length; n += 2) { @@ -33,18 +34,28 @@ const key = (await readFile(options['--key-file'], 'utf8')).trim(); const base = url.origin; const report = { origin: base, startedAt: new Date().toISOString(), passed: false, checks: [], room: null }; let session1, session2, adminId; +const requestFailures = new WeakMap(); async function request(path, body, cookie, origin = base, expectedAdminId = adminId) { - const response = await fetch(base + path, { - method: body === undefined ? 'GET' : 'POST', - headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}), - ...(expectedAdminId && (path === '/api/admin/audit' || path.startsWith('/api/admin/audit?') || path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure|archive|seat-ai|discussion)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000), - }); - report.lastRequest = { path, status: response.status }; - const raw = await response.text(); - let data; - try { data = JSON.parse(raw); } catch { data = null; } - return { status: response.status, data, cookie: response.headers.get('set-cookie')?.split(';')[0], rawCookie: response.headers.get('set-cookie'), cache: response.headers.get('cache-control') }; + const identity = requestIdentity(body === undefined ? 'GET' : 'POST', path); + report.inFlightRequest = identity; + try { + const response = await fetch(base + path, { + method: body === undefined ? 'GET' : 'POST', + headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}), + ...(expectedAdminId && (path === '/api/admin/audit' || path.startsWith('/api/admin/audit?') || path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure|archive|seat-ai|discussion)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000), + }); + report.lastRequest = { ...identity, status: response.status }; + const raw = await response.text(); + let data; + try { data = JSON.parse(raw); } catch { data = null; } + return { status: response.status, data, cookie: response.headers.get('set-cookie')?.split(';')[0], rawCookie: response.headers.get('set-cookie'), cache: response.headers.get('cache-control') }; + } catch (error) { + if (error && typeof error === 'object') requestFailures.set(error, requestFailure(identity, error)); + throw error; + } finally { + delete report.inFlightRequest; + } } try { const denied = await request('/api/admin/rooms'); @@ -115,8 +126,8 @@ try { } catch (error) { // Do not serialize assertion payloads: failed authentication responses might contain credentials. report.failure = 'Administrator HTTP verification failed. Investigate the last completed check.'; - report.failureRequest ??= report.lastRequest; - report.failureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('verify-admin.mjs'))?.trim() : undefined; + report.failureRequest = (error && typeof error === 'object' ? requestFailures.get(error) : undefined) ?? report.failureRequest ?? report.lastRequest; + report.failureLocation = failureLocation(error); process.exitCode = 1; } finally { for (const cookie of [session1, session2].filter(Boolean)) { diff --git a/tools/verify-container.mjs b/tools/verify-container.mjs index 68dc44c2..cc1b49c0 100644 --- a/tools/verify-container.mjs +++ b/tools/verify-container.mjs @@ -6,6 +6,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join as joinPath } from 'node:path'; +import { describeFailure } from './admin-verification-diagnostics.mjs'; const image = process.argv[2]; if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE'); @@ -171,12 +172,15 @@ try { } console.log('Container origin, restart, replacement, saved seat and HTTP integration checks passed.'); } catch (error) { - annotateFailure(String(error?.message ?? error).split('\n')[0]); + annotateFailure(stage === 'administrator HTTP acceptance' + ? 'Administrator HTTP acceptance failed; see sanitized details below.' + : String(error?.message ?? error).split('\n')[0]); if (stage === 'administrator HTTP acceptance') { try { const report = JSON.parse(readFileSync(joinPath(adminFiles, 'http/report.json'), 'utf8')); const request = report.failureRequest ?? report.lastRequest; - if (request) annotateFailure(`${request.path} returned ${request.status}; ${report.failureLocation ?? 'see the failed acceptance check'}`); + const detail = describeFailure(request, report.failureLocation); + if (detail) annotateFailure(detail); } catch { /* Early failures may not create the private report; the original error remains fatal. */ } } try { console.error(docker('logs', name).replaceAll(/dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}/g, '[REDACTED ADMIN KEY]')); } catch { /* May not exist yet. */ }