From 831107d19ddcce437ec520ad5ce3088a31c31a64 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 2 Sep 2026 19:09:18 -0700 Subject: [PATCH 1/5] feat(wiki-writer): add atomic GitHub write path --- wiki-writer/src/contract.ts | 14 + wiki-writer/src/github-data-client.ts | 230 ++++++++++++++ wiki-writer/src/operation-ledger.ts | 145 +++++++++ wiki-writer/src/retention.ts | 34 +++ wiki-writer/src/server.ts | 82 ++++- wiki-writer/src/write-operation.ts | 321 ++++++++++++++++++++ wiki-writer/test/github-data-client.test.ts | 54 ++++ wiki-writer/test/operation-ledger.test.ts | 55 ++++ wiki-writer/test/retention.test.ts | 21 ++ wiki-writer/test/security-boundary.test.ts | 15 + wiki-writer/test/server.test.ts | 27 +- wiki-writer/test/write-operation.test.ts | 265 ++++++++++++++++ 12 files changed, 1259 insertions(+), 4 deletions(-) create mode 100644 wiki-writer/src/github-data-client.ts create mode 100644 wiki-writer/src/operation-ledger.ts create mode 100644 wiki-writer/src/retention.ts create mode 100644 wiki-writer/src/write-operation.ts create mode 100644 wiki-writer/test/github-data-client.test.ts create mode 100644 wiki-writer/test/operation-ledger.test.ts create mode 100644 wiki-writer/test/retention.test.ts create mode 100644 wiki-writer/test/security-boundary.test.ts create mode 100644 wiki-writer/test/write-operation.test.ts diff --git a/wiki-writer/src/contract.ts b/wiki-writer/src/contract.ts index 391cf73..0c0c14f 100644 --- a/wiki-writer/src/contract.ts +++ b/wiki-writer/src/contract.ts @@ -18,6 +18,10 @@ export interface WikiWriteRequest { readonly ref: string readonly path: string readonly content: string + readonly operationId?: string + readonly expectedParentSha?: string + readonly expectedBlobSha?: string + readonly corrections?: readonly Record[] } export type OperationAuthorization = @@ -49,13 +53,22 @@ export interface WikiWriterApp { readonly rejectBodyTooLarge: (requestId: string) => Response } +export interface WikiWriteOperationHandler { + readonly execute: (request: WikiWriteRequest) => Promise +} + export interface WikiWriterAppOptions { readonly secretFilePath: string + readonly githubAppId?: string | number + readonly githubInstallationId?: number + readonly githubPrivateKeyFilePath?: string + readonly ledgerPath?: string readonly nowSeconds?: () => number readonly skewSeconds?: number readonly replayStore?: ReplayStore readonly audit?: AuditSink readonly authorizeOperation?: OperationAuthorizer + readonly writeOperation?: WikiWriteOperationHandler } export interface InternalWikiWriterAppOptions { @@ -64,6 +77,7 @@ export interface InternalWikiWriterAppOptions { readonly replayStore?: ReplayStore readonly audit?: AuditSink readonly authorizeOperation?: OperationAuthorizer + readonly writeOperation?: WikiWriteOperationHandler } export interface ReplayStore { diff --git a/wiki-writer/src/github-data-client.ts b/wiki-writer/src/github-data-client.ts new file mode 100644 index 0000000..63ff412 --- /dev/null +++ b/wiki-writer/src/github-data-client.ts @@ -0,0 +1,230 @@ +import {Buffer} from 'node:buffer' +import {readFile} from 'node:fs/promises' +import {createAppAuth} from '@octokit/auth-app' +import {Octokit} from '@octokit/core' + +export const WIKI_REPOSITORY = 'fro-bot/.github' +export const WIKI_REF = 'data' +export const WIKI_OWNER = 'fro-bot' +export const WIKI_REPO = '.github' + +const ALLOWED_PATH = /^knowledge\/wiki\/(?:repos|topics|entities|comparisons)\/[^/]+\.md$/u +const CORRECTIONS_PATH = 'knowledge/corrections.yaml' + +export interface GitHubTreeEntry { + readonly path?: string + readonly type?: string + readonly sha?: string +} + +export interface GitHubDataTransport { + readonly getRef?: (params: Record) => Promise<{data: unknown}> + readonly getCommit?: (params: Record) => Promise<{data: unknown}> + readonly getTree?: (params: Record) => Promise<{data: unknown}> + readonly getBlob?: (params: Record) => Promise<{data: unknown}> + readonly createBlob?: (params: Record) => Promise<{data: unknown}> + readonly createTree?: (params: Record) => Promise<{data: unknown}> + readonly createCommit?: (params: Record) => Promise<{data: unknown}> + readonly updateRef?: (params: Record) => Promise<{data: unknown}> +} + +export interface GitHubFile { + readonly content: string + readonly sha: string +} + +export interface GitHubSnapshot { + readonly headSha: string + readonly treeSha: string + readonly files: Record + readonly fileShas: Record +} + +export interface GitHubCommit { + readonly sha: string + readonly message: string + readonly parents: readonly string[] + readonly treeSha?: string +} + +export interface TreeEntry { + readonly path: string + readonly sha: string | null +} + +export interface CreateCommitInput { + readonly treeSha: string + readonly parentSha: string + readonly message: string + readonly author?: {readonly name: string; readonly email: string} + readonly committer?: {readonly name: string; readonly email: string} +} + +export interface GitHubDataClient { + readonly getSnapshot: () => Promise + readonly createBlob: (content: string) => Promise + readonly createTree: (baseTreeSha: string, entries: readonly TreeEntry[]) => Promise + readonly createCommit: (input: CreateCommitInput) => Promise + readonly updateRef: (commitSha: string) => Promise + readonly getCommit: (commitSha: string) => Promise +} + +export interface GitHubAppClientOptions { + readonly appId: string | number + readonly installationId: number + readonly privateKeyFilePath: string +} + +export function assertAllowedTarget(repository: string, ref: string, path: string): void { + if (repository !== WIKI_REPOSITORY) throw new Error('repository is not allowed') + if (ref !== WIKI_REF) throw new Error('ref is not allowed') + if (!isAllowedWikiPath(path)) throw new Error('path is not allowed') +} + +export function isAllowedWikiPath(path: string): boolean { + return path === CORRECTIONS_PATH || ALLOWED_PATH.test(path) +} + +export async function createGitHubDataClient(options: GitHubAppClientOptions): Promise { + const privateKey = await readFile(options.privateKeyFilePath, 'utf8') + const octokit = new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: options.appId, + privateKey, + installationId: options.installationId, + }, + }) + + return createGitHubDataClientWithTransport({ + getRef: async params => request(octokit, 'GET /repos/{owner}/{repo}/git/ref/{ref}', params), + getCommit: async params => request(octokit, 'GET /repos/{owner}/{repo}/git/commits/{commit_sha}', params), + getTree: async params => request(octokit, 'GET /repos/{owner}/{repo}/git/trees/{tree_sha}', params), + getBlob: async params => request(octokit, 'GET /repos/{owner}/{repo}/git/blobs/{file_sha}', params), + createBlob: async params => request(octokit, 'POST /repos/{owner}/{repo}/git/blobs', params), + createTree: async params => request(octokit, 'POST /repos/{owner}/{repo}/git/trees', params), + createCommit: async params => request(octokit, 'POST /repos/{owner}/{repo}/git/commits', params), + updateRef: async params => request(octokit, 'PATCH /repos/{owner}/{repo}/git/refs/{ref}', params), + }) +} + +export function createGitHubDataClientWithTransport(transport: GitHubDataTransport): GitHubDataClient { + const call = async (name: keyof GitHubDataTransport, params: Record): Promise => { + const method = transport[name] + if (method === undefined) throw new Error(`GitHub transport method ${String(name)} is unavailable`) + return method(params).then(response => response.data as T) + } + + async function getSnapshot(): Promise { + const ref = await call<{object?: {sha?: unknown}}>('getRef', { + owner: WIKI_OWNER, + repo: WIKI_REPO, + ref: `heads/${WIKI_REF}`, + }) + const headSha = requireString(ref.object?.sha, 'GitHub ref did not return a head SHA') + const commit = await getCommit(headSha) + const tree = await call<{tree?: unknown}>('getTree', { + owner: WIKI_OWNER, + repo: WIKI_REPO, + tree_sha: requireString(commit.treeSha, 'GitHub commit did not return a tree'), + recursive: '1', + }) + const entries = Array.isArray(tree.tree) ? tree.tree as GitHubTreeEntry[] : [] + const files: Record = {} + const fileShas: Record = {} + for (const entry of entries) { + if (entry.type !== 'blob' || typeof entry.path !== 'string' || typeof entry.sha !== 'string') continue + const blob = await call<{content?: unknown; encoding?: unknown}>('getBlob', { + owner: WIKI_OWNER, + repo: WIKI_REPO, + file_sha: entry.sha, + }) + files[entry.path] = decodeBlob(blob) + fileShas[entry.path] = entry.sha + } + return {headSha, treeSha: requireString(commit.treeSha, 'GitHub commit did not return a tree'), files, fileShas} + } + + async function createBlob(content: string): Promise { + const response = await call<{sha?: unknown}>('createBlob', { + owner: WIKI_OWNER, + repo: WIKI_REPO, + content, + encoding: 'utf-8', + }) + return requireString(response.sha, 'GitHub blob creation did not return a SHA') + } + + async function createTree(baseTreeSha: string, entries: readonly TreeEntry[]): Promise { + const response = await call<{sha?: unknown}>('createTree', { + owner: WIKI_OWNER, + repo: WIKI_REPO, + base_tree: baseTreeSha, + tree: entries.map(entry => ({path: entry.path, mode: '100644', type: 'blob', sha: entry.sha})), + }) + return requireString(response.sha, 'GitHub tree creation did not return a SHA') + } + + async function createCommit(input: CreateCommitInput): Promise { + const response = await call<{sha?: unknown}>('createCommit', { + owner: WIKI_OWNER, + repo: WIKI_REPO, + message: input.message, + tree: input.treeSha, + parents: [input.parentSha], + author: input.author ?? {name: 'Fro Bot', email: '41898282+github-actions[bot]@users.noreply.github.com'}, + committer: input.committer ?? {name: 'Fro Bot', email: '41898282+github-actions[bot]@users.noreply.github.com'}, + }) + return requireString(response.sha, 'GitHub commit creation did not return a SHA') + } + + async function updateRef(commitSha: string): Promise { + await call('updateRef', { + owner: WIKI_OWNER, + repo: WIKI_REPO, + ref: `heads/${WIKI_REF}`, + sha: commitSha, + force: false, + }) + } + + async function getCommit(commitSha: string): Promise { + const response = await call<{sha?: unknown; message?: unknown; parents?: unknown; tree?: unknown}>('getCommit', { + owner: WIKI_OWNER, + repo: WIKI_REPO, + commit_sha: commitSha, + }) + const parents = Array.isArray(response.parents) + ? response.parents + .map(parent => parent !== null && typeof parent === 'object' ? (parent as {sha?: unknown}).sha : undefined) + .filter((parent): parent is string => typeof parent === 'string') + : [] + return { + sha: requireString(response.sha, 'GitHub commit did not return a SHA'), + message: typeof response.message === 'string' ? response.message : '', + parents, + treeSha: commitTreeSha(response), + } + } + + return {getSnapshot, createBlob, createTree, createCommit, updateRef, getCommit} +} + +function commitTreeSha(commit: {tree?: unknown}): string { + if (commit.tree === null || typeof commit.tree !== 'object') throw new Error('GitHub commit did not return a tree') + return requireString((commit.tree as {sha?: unknown}).sha, 'GitHub commit tree did not return a SHA') +} + +function decodeBlob(blob: {content?: unknown; encoding?: unknown}): string { + if (blob.encoding !== 'base64' || typeof blob.content !== 'string') throw new Error('GitHub blob response was invalid') + return Buffer.from(blob.content.replaceAll(/\s+/gu, ''), 'base64').toString('utf8') +} + +function requireString(value: unknown, message: string): string { + if (typeof value !== 'string' || value.length === 0) throw new Error(message) + return value +} + +async function request(octokit: Octokit, route: string, params: Record): Promise<{data: unknown}> { + return octokit.request(route, params) +} diff --git a/wiki-writer/src/operation-ledger.ts b/wiki-writer/src/operation-ledger.ts new file mode 100644 index 0000000..572d472 --- /dev/null +++ b/wiki-writer/src/operation-ledger.ts @@ -0,0 +1,145 @@ +import {mkdirSync} from 'node:fs' +import {dirname} from 'node:path' +import {DatabaseSync} from 'node:sqlite' +import {selectOperationIdsToPrune, type RetentionOperationRecord} from './retention.ts' + +export type OperationState = 'pending' | 'succeeded' | 'failed' | 'indeterminate' + +export interface OperationIntent { + readonly operationId: string + readonly repository: string + readonly ref: string + readonly path: string + readonly expectedParentSha: string + readonly contentDigest: string + readonly createdAt: number +} + +export interface OperationRecord extends OperationIntent { + readonly state: OperationState + readonly commitSha: string | null + readonly updatedAt: number +} + +export type OperationCompletion = + | {readonly state: 'pending' | 'failed' | 'indeterminate'; readonly updatedAt: number} + | {readonly state: 'succeeded'; readonly commitSha: string; readonly updatedAt: number} + +export interface OperationLedger { + readonly begin: (intent: OperationIntent) => void + readonly complete: (operationId: string, completion: OperationCompletion) => void + readonly get: (operationId: string) => OperationRecord | undefined + readonly list: () => OperationRecord[] + readonly listIndeterminate: () => OperationRecord[] + readonly prune: (now: number) => void + readonly close: () => void +} + +interface OperationRow { + operation_id: string + repository: string + ref: string + path: string + expected_parent_sha: string + content_digest: string + state: OperationState + created_at: number + updated_at: number + commit_sha: string | null +} + +export function createOperationLedger(dbPath: string): OperationLedger { + if (dbPath !== ':memory:') mkdirSync(dirname(dbPath), {recursive: true}) + + const db = new DatabaseSync(dbPath) + db.exec(` + CREATE TABLE IF NOT EXISTS wiki_write_operations ( + operation_id TEXT PRIMARY KEY, + repository TEXT NOT NULL, + ref TEXT NOT NULL, + path TEXT NOT NULL, + expected_parent_sha TEXT NOT NULL, + content_digest TEXT NOT NULL, + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + commit_sha TEXT NULL + ) + `) + + const insert = db.prepare(` + INSERT INTO wiki_write_operations + (operation_id, repository, ref, path, expected_parent_sha, content_digest, state, created_at, updated_at, commit_sha) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, NULL) + `) + const update = db.prepare('UPDATE wiki_write_operations SET state = ?, updated_at = ?, commit_sha = ? WHERE operation_id = ?') + const select = db.prepare('SELECT * FROM wiki_write_operations WHERE operation_id = ?') + const selectAll = db.prepare('SELECT * FROM wiki_write_operations ORDER BY created_at ASC') + const selectIndeterminate = db.prepare("SELECT * FROM wiki_write_operations WHERE state = 'indeterminate' ORDER BY created_at ASC") + + function begin(intent: OperationIntent): void { + try { + insert.run( + intent.operationId, + intent.repository, + intent.ref, + intent.path, + intent.expectedParentSha, + intent.contentDigest, + intent.createdAt, + intent.createdAt, + ) + } catch (error) { + if (error instanceof Error && error.message.includes('UNIQUE')) throw new Error('operation intent already exists') + throw error + } + } + + function complete(operationId: string, completion: OperationCompletion): void { + const commitSha = completion.state === 'succeeded' ? completion.commitSha : null + const result = update.run(completion.state, completion.updatedAt, commitSha, operationId) + if (Number(result.changes) !== 1) throw new Error('operation intent was not found') + } + + function get(operationId: string): OperationRecord | undefined { + const row = select.get(operationId) as unknown as OperationRow | undefined + return row === undefined ? undefined : rowToRecord(row) + } + + function list(): OperationRecord[] { + return (selectAll.all() as unknown as OperationRow[]).map(rowToRecord) + } + + function listIndeterminate(): OperationRecord[] { + return (selectIndeterminate.all() as unknown as OperationRow[]).map(rowToRecord) + } + + function prune(now: number): void { + const records = list() + const ids = selectOperationIdsToPrune(records satisfies readonly RetentionOperationRecord[], now) + if (ids.length === 0) return + const placeholders = ids.map(() => '?').join(', ') + db.prepare(`DELETE FROM wiki_write_operations WHERE operation_id IN (${placeholders})`).run(...ids) + } + + function close(): void { + db.close() + } + + return {begin, complete, get, list, listIndeterminate, prune, close} +} + +function rowToRecord(row: OperationRow): OperationRecord { + return { + operationId: row.operation_id, + repository: row.repository, + ref: row.ref, + path: row.path, + expectedParentSha: row.expected_parent_sha, + contentDigest: row.content_digest, + state: row.state, + createdAt: row.created_at, + updatedAt: row.updated_at, + commitSha: row.commit_sha, + } +} diff --git a/wiki-writer/src/retention.ts b/wiki-writer/src/retention.ts new file mode 100644 index 0000000..ae55978 --- /dev/null +++ b/wiki-writer/src/retention.ts @@ -0,0 +1,34 @@ +export const OPERATION_MAX_ROWS = 500 +export const OPERATION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 + +export type RetentionOperationState = 'pending' | 'succeeded' | 'failed' | 'indeterminate' + +export interface RetentionOperationRecord { + readonly operationId: string + readonly state: RetentionOperationState + readonly createdAt: number +} + +/** + * Select only resolved records for deletion. Pending and indeterminate records + * are deliberately retained until an explicit resolution path handles them. + */ +export function selectOperationIdsToPrune(records: readonly RetentionOperationRecord[], now: number): string[] { + const resolved = records + .filter(record => record.state === 'succeeded' || record.state === 'failed') + .sort((left, right) => left.createdAt - right.createdAt) + const ids = new Set() + + for (const record of resolved) { + if (now - record.createdAt > OPERATION_MAX_AGE_MS) ids.add(record.operationId) + } + + const remainingResolved = resolved.filter(record => !ids.has(record.operationId)) + const overflow = Math.max(0, records.length - OPERATION_MAX_ROWS) + for (const record of [...remainingResolved, ...resolved.filter(record => ids.has(record.operationId))]) { + if (ids.size >= overflow) break + ids.add(record.operationId) + } + + return [...ids] +} diff --git a/wiki-writer/src/server.ts b/wiki-writer/src/server.ts index bfac2ae..e02468e 100644 --- a/wiki-writer/src/server.ts +++ b/wiki-writer/src/server.ts @@ -1,16 +1,21 @@ -import type {AuditSink, InternalWikiWriterAppOptions, WikiWriterApp, WikiWriterAppOptions} from './contract.ts' +import type {AuditSink, InternalWikiWriterAppOptions, WikiWriteOperationHandler, WikiWriterApp, WikiWriterAppOptions, WikiWriteRequest} from './contract.ts' import {Buffer} from 'node:buffer' import {createServer, type IncomingMessage, type Server, type ServerResponse} from 'node:http' import process from 'node:process' import {fileURLToPath} from 'node:url' import {isWikiWriteRequest, WIKI_WRITER_HEALTH_PATH, WIKI_WRITER_WRITE_PATH} from './contract.ts' +import {createGateContractChecker} from './gate-contract.ts' +import {createGitHubDataClient} from './github-data-client.ts' import {authenticateInternalRequest, createRequestSignature, emitAuditRejection, InMemoryReplayStore, loadInternalAuthSecret} from './internal-auth.ts' +import {createOperationLedger} from './operation-ledger.ts' +import {createWikiWriteOperation, type WikiWriteResult} from './write-operation.ts' export const WIKI_WRITER_MAX_RAW_BYTES = 1024 * 1024 export async function createWikiWriterApp(options: WikiWriterAppOptions): Promise { const secret = await loadInternalAuthSecret(options.secretFilePath) - return createWikiWriterAppWithInjectedSecret(secret, options) + const writeOperation = options.writeOperation ?? await createProductionWriteOperation(options) + return createWikiWriterAppWithInjectedSecret(secret, {...options, writeOperation}) } /** @@ -61,6 +66,12 @@ export function createWikiWriterAppWithInjectedSecret(secret: Uint8Array, option if (!isWikiWriteRequest(payload)) return jsonResponse({error: 'invalid-request'}, 400) + if (options.writeOperation !== undefined) { + if (!isCompleteWriteRequest(payload)) return jsonResponse({error: 'invalid-request'}, 400) + const result = await options.writeOperation.execute(payload) + return writeResultResponse(result) + } + const authorization = await authorizeOperation(payload) if (!authorization.allowed) return jsonResponse({error: 'forbidden'}, 403) return jsonResponse({accepted: true}, 202) @@ -85,7 +96,13 @@ export function createWikiWriterHttpServer(app: WikiWriterApp): Server { export async function startWikiWriterServer(options: WikiWriterHttpOptions = {}): Promise { const secretFilePath = process.env.WIKI_WRITER_HMAC_SECRET_FILE ?? '/run/secrets/wiki_writer_hmac' - const app = await createWikiWriterApp({secretFilePath}) + const app = await createWikiWriterApp({ + secretFilePath, + githubAppId: requiredEnvironment('WIKI_WRITER_GITHUB_APP_ID'), + githubInstallationId: parseEnvironmentInteger('WIKI_WRITER_GITHUB_INSTALLATION_ID'), + githubPrivateKeyFilePath: requiredEnvironment('WIKI_WRITER_GITHUB_PRIVATE_KEY_FILE'), + ledgerPath: process.env.WIKI_WRITER_LEDGER_PATH ?? '/var/lib/wiki-writer/operations.sqlite', + }) const server = createWikiWriterHttpServer(app) await new Promise((resolve, reject) => { server.once('error', reject) @@ -204,6 +221,65 @@ function jsonResponse(body: object, status = 200): Response { }) } +async function createProductionWriteOperation(options: WikiWriterAppOptions): Promise { + const configuredFields = [options.githubAppId, options.githubInstallationId, options.githubPrivateKeyFilePath] + const hasAnyGitHubConfig = configuredFields.some(value => value !== undefined) + const hasGitHubConfig = options.githubAppId !== undefined && + options.githubInstallationId !== undefined && + options.githubPrivateKeyFilePath !== undefined + if (hasAnyGitHubConfig && !hasGitHubConfig) throw new Error('GitHub writer configuration is incomplete') + if (!hasGitHubConfig) return undefined + + const client = await createGitHubDataClient({ + appId: options.githubAppId, + installationId: options.githubInstallationId, + privateKeyFilePath: options.githubPrivateKeyFilePath, + }) + const ledger = createOperationLedger(options.ledgerPath ?? '/var/lib/wiki-writer/operations.sqlite') + const operation = createWikiWriteOperation({ + client, + ledger, + gateContractChecker: createGateContractChecker({fetch}), + }) + return { + execute: async request => operation.execute(request as unknown as import('./write-operation.ts').WikiWriteRequest), + } +} + +function isCompleteWriteRequest(value: WikiWriteRequest): value is WikiWriteRequest & Required> { + return typeof value.operationId === 'string' && typeof value.expectedParentSha === 'string' +} + +function writeResultResponse(value: unknown): Response { + if (value === null || typeof value !== 'object') return jsonResponse({error: 'write-failed'}, 500) + const result = value as WikiWriteResult + switch (result.state) { + case 'succeeded': + return jsonResponse({accepted: true, operationId: result.operationId, commitSha: result.commitSha}, 202) + case 'conflict': + return jsonResponse({error: 'precondition-failed'}, 412) + case 'indeterminate': + return jsonResponse({accepted: true, status: 'indeterminate', operationId: result.operationId}, 202) + case 'rejected': + if (result.reason === 'content-too-large') return jsonResponse({error: 'content-too-large'}, 413) + return jsonResponse({error: result.reason, ...(result.findings === undefined ? {} : {findings: result.findings})}, 422) + case 'failed': + return jsonResponse({error: 'write-failed', correlationId: result.correlationId}, 500) + } +} + +function requiredEnvironment(name: string): string { + const value = process.env[name] + if (value === undefined || value.length === 0) throw new Error(`${name} is required`) + return value +} + +function parseEnvironmentInteger(name: string): number { + const value = Number(requiredEnvironment(name)) + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`) + return value +} + if (process.argv[1] === fileURLToPath(import.meta.url)) { startWikiWriterServer().catch(() => { process.exitCode = 1 diff --git a/wiki-writer/src/write-operation.ts b/wiki-writer/src/write-operation.ts new file mode 100644 index 0000000..e19d9dd --- /dev/null +++ b/wiki-writer/src/write-operation.ts @@ -0,0 +1,321 @@ +import type {GateContractDecision} from './gate-contract.ts' +import type {OperationLedger, OperationRecord} from './operation-ledger.ts' +import {Buffer} from 'node:buffer' +import {createHash, randomUUID} from 'node:crypto' +import { + buildWikiIngestChanges, + lintWikiSnapshot, + parseCorrections, + reconstructFrontmatter, + recordCorrection, + serializeCorrections, + validateRenderingPolicy, + type CorrectionsFile, + type RecordCorrectionInput, +} from '@fro-bot/wiki-write-core' +import { + assertAllowedTarget, + type GitHubDataClient, + type GitHubSnapshot, + type TreeEntry, +} from './github-data-client.ts' + +export const MAX_RAW_ENVELOPE_BYTES = 1024 * 1024 +export const MAX_DECODED_CONTENT_BYTES = 512 * 1024 +export const OPERATION_TRAILER = 'Fro-Operation-Id' +export const FRO_BOT_IDENTITY = { + name: 'Fro Bot', + email: '41898282+github-actions[bot]@users.noreply.github.com', +} as const + +export interface WikiWriteRequest { + readonly operation: 'write' + readonly operationId: string + readonly repository: string + readonly ref: string + readonly path: string + readonly content: string + readonly expectedParentSha: string + readonly expectedBlobSha?: string + readonly corrections?: readonly RecordCorrectionInput[] +} + +export interface WikiWriteGateInput { + readonly path: string + readonly content: string + readonly existingFiles: Record + readonly corrections?: readonly RecordCorrectionInput[] + readonly now: Date +} + +export interface WikiWriteGateFinding { + readonly kind: string + readonly path: string + readonly message: string + readonly target?: string +} + +export type WikiWriteGateResult = + | {readonly ok: true; readonly files: Record; readonly findings: readonly WikiWriteGateFinding[]} + | {readonly ok: false; readonly findings: readonly WikiWriteGateFinding[]} + +export interface WikiWriteGates { + readonly run: (input: WikiWriteGateInput) => WikiWriteGateResult | Promise +} + +export interface GateContractChecker { + readonly check: () => Promise +} + +export interface WikiWriteOperationOptions { + readonly client: GitHubDataClient + readonly ledger: OperationLedger + readonly gates?: WikiWriteGates + readonly gateContractChecker: GateContractChecker + readonly now?: () => number +} + +export type WikiWriteResult = + | {readonly state: 'succeeded'; readonly operationId: string; readonly commitSha: string} + | {readonly state: 'conflict'; readonly status: 412} + | {readonly state: 'rejected'; readonly reason: 'target' | 'content-too-large' | 'invalid-request' | 'gate' | 'gate-contract'; readonly findings?: readonly WikiWriteGateFinding[]} + | {readonly state: 'failed'; readonly operationId: string; readonly correlationId: string} + | {readonly state: 'indeterminate'; readonly operationId: string} + +export interface WikiWriteOperation { + readonly execute: (request: WikiWriteRequest) => Promise +} + +export function createWikiWriteOperation(options: WikiWriteOperationOptions): WikiWriteOperation { + const gates = options.gates ?? createSharedWikiWriteGates() + const now = options.now ?? (() => Date.now()) + + async function execute(request: WikiWriteRequest): Promise { + try { + assertAllowedTarget(request.repository, request.ref, request.path) + } catch { + return {state: 'rejected', reason: 'target'} + } + + if (!isValidRequest(request)) return {state: 'rejected', reason: 'invalid-request'} + if (Buffer.byteLength(request.content, 'utf8') > MAX_DECODED_CONTENT_BYTES) { + return {state: 'rejected', reason: 'content-too-large'} + } + + const contract = await options.gateContractChecker.check() + if (!contract.proceed) return {state: 'rejected', reason: 'gate-contract'} + + const existing = options.ledger.get(request.operationId) + if (existing !== undefined) return resultFromExisting(existing) + + let snapshot: GitHubSnapshot + try { + snapshot = await options.client.getSnapshot() + } catch { + return {state: 'failed', operationId: request.operationId, correlationId: randomUUID()} + } + + if (snapshot.headSha !== request.expectedParentSha) return {state: 'conflict', status: 412} + + const existingContent = snapshot.files[request.path] + if (existingContent === undefined) return {state: 'rejected', reason: 'invalid-request'} + if (request.expectedBlobSha !== undefined && snapshot.fileShas[request.path] !== request.expectedBlobSha) { + return {state: 'conflict', status: 412} + } + + let submittedContent: string + try { + submittedContent = request.path === 'knowledge/corrections.yaml' + ? request.content + : reconstructFrontmatter(existingContent, request.content) + } catch { + return {state: 'rejected', reason: 'gate'} + } + + let gateResult: WikiWriteGateResult + try { + gateResult = await gates.run({ + path: request.path, + content: submittedContent, + existingFiles: snapshot.files, + corrections: request.corrections, + now: new Date(now()), + }) + } catch { + return {state: 'rejected', reason: 'gate'} + } + if (!gateResult.ok) return {state: 'rejected', reason: 'gate', findings: boundFindings(gateResult.findings, request.content)} + + const files = gateResult.files + const contentDigest = digest(submittedContent) + options.ledger.begin({ + operationId: request.operationId, + repository: request.repository, + ref: request.ref, + path: request.path, + expectedParentSha: request.expectedParentSha, + contentDigest, + createdAt: now(), + }) + + const changedEntries: TreeEntry[] = [] + try { + for (const [path, content] of Object.entries(files)) { + if (snapshot.files[path] === content) continue + changedEntries.push({path, sha: await options.client.createBlob(content)}) + } + } catch { + completeLedger(options.ledger, request.operationId, {state: 'failed', updatedAt: now()}, now) + return {state: 'failed', operationId: request.operationId, correlationId: randomUUID()} + } + if (changedEntries.length === 0) { + completeLedger(options.ledger, request.operationId, {state: 'failed', updatedAt: now()}, now) + return {state: 'rejected', reason: 'invalid-request'} + } + + const message = `docs(knowledge): manual edit ${request.path}\n\n${OPERATION_TRAILER}: ${request.operationId}` + try { + const treeSha = await options.client.createTree(snapshot.treeSha, changedEntries) + const commitSha = await options.client.createCommit({ + treeSha, + parentSha: snapshot.headSha, + message, + author: FRO_BOT_IDENTITY, + committer: FRO_BOT_IDENTITY, + }) + try { + await options.client.updateRef(commitSha) + } catch (error) { + if (statusOf(error) === 422) { + completeLedger(options.ledger, request.operationId, {state: 'failed', updatedAt: now()}, now) + return {state: 'conflict', status: 412} + } + + const reconciliation = await reconcile(options.client, request, contentDigest) + if (reconciliation.state === 'succeeded') { + completeLedger(options.ledger, request.operationId, {state: 'succeeded', commitSha: reconciliation.commitSha, updatedAt: now()}, now) + return reconciliation + } + completeLedger(options.ledger, request.operationId, {state: 'indeterminate', updatedAt: now()}, now) + return {state: 'indeterminate', operationId: request.operationId} + } + + completeLedger(options.ledger, request.operationId, {state: 'succeeded', commitSha, updatedAt: now()}, now) + return {state: 'succeeded', operationId: request.operationId, commitSha} + } catch { + completeLedger(options.ledger, request.operationId, {state: 'failed', updatedAt: now()}, now) + return {state: 'failed', operationId: request.operationId, correlationId: randomUUID()} + } + } + + return {execute} +} + +export function createSharedWikiWriteGates(): WikiWriteGates { + return { + run: ({path, content, existingFiles, corrections, now}) => { + if (path === 'knowledge/corrections.yaml') { + const parsed = parseCorrections(content) + return {ok: true, files: {[path]: serializeCorrections(parsed)}, findings: []} + } + + const currentCorrections = readCorrectionsFile(existingFiles['knowledge/corrections.yaml']) + const nextCorrections = applyCorrections(currentCorrections, corrections) + const built = buildWikiIngestChanges({ + existingFiles, + operation: 'manual-edit', + target: path, + summary: `Manual correction to ${path}`, + timestamp: now, + sources: [], + pages: [{path, content}], + corrections: nextCorrections, + }) + const files = {...built.files} + if (nextCorrections !== currentCorrections) files['knowledge/corrections.yaml'] = serializeCorrections(nextCorrections) + + const nextFiles = {...existingFiles, ...files} + const lint = lintWikiSnapshot({files: nextFiles, now}) + const renderingFindings = path === 'knowledge/corrections.yaml' ? [] : validateRenderingPolicy({path, content}) + const findings: WikiWriteGateFinding[] = [ + ...built.findings, + ...lint.deterministicFindings, + ...renderingFindings, + ] + return findings.length === 0 ? {ok: true, files, findings} : {ok: false, findings} + }, + } +} + +async function reconcile(client: GitHubDataClient, request: WikiWriteRequest, contentDigest: string): Promise<{state: 'succeeded'; operationId: string; commitSha: string} | {state: 'indeterminate'}> { + try { + const snapshot = await client.getSnapshot() + if (digest(snapshot.files[request.path] ?? '') !== contentDigest) return {state: 'indeterminate'} + + let sha = snapshot.headSha + for (let depth = 0; depth < 32; depth += 1) { + const commit = await client.getCommit(sha) + const hasTrailer = new RegExp(`^${OPERATION_TRAILER}: ${escapeRegExp(request.operationId)}$`, 'mu').test(commit.message) + const hasExpectedParent = commit.parents.includes(request.expectedParentSha) + if (hasTrailer && hasExpectedParent) return {state: 'succeeded', operationId: request.operationId, commitSha: commit.sha} + const next = commit.parents[0] + if (next === undefined || next === request.expectedParentSha) break + sha = next + } + } catch { + return {state: 'indeterminate'} + } + return {state: 'indeterminate'} +} + +function isValidRequest(request: WikiWriteRequest): boolean { + return request.operation === 'write' && + typeof request.operationId === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(request.operationId) && + typeof request.expectedParentSha === 'string' && + request.expectedParentSha.length > 0 +} + +function completeLedger(ledger: OperationLedger, operationId: string, completion: Parameters[1], now: () => number): void { + ledger.complete(operationId, completion) + ledger.prune(now()) +} + +function resultFromExisting(record: OperationRecord): WikiWriteResult { + if (record.state === 'succeeded' && record.commitSha !== null) return {state: 'succeeded', operationId: record.operationId, commitSha: record.commitSha} + if (record.state === 'indeterminate') return {state: 'indeterminate', operationId: record.operationId} + return {state: 'failed', operationId: record.operationId, correlationId: randomUUID()} +} + +function boundFindings(findings: readonly WikiWriteGateFinding[], submittedContent: string): WikiWriteGateFinding[] { + return findings.map(finding => ({ + kind: finding.kind, + path: finding.path, + ...(finding.target !== undefined && submittedContent.includes(finding.target) ? {target: finding.target} : {}), + message: 'Gate rejected the submitted content.', + })) +} + +function digest(content: string): string { + return createHash('sha256').update(content, 'utf8').digest('hex') +} + +function statusOf(error: unknown): number | undefined { + if (error === null || typeof error !== 'object') return undefined + const status = (error as {status?: unknown}).status + return typeof status === 'number' ? status : undefined +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`) +} + +function readCorrectionsFile(raw: string | undefined): CorrectionsFile { + if (raw === undefined) return {version: 1, corrections: []} + return parseCorrections(raw) +} + +function applyCorrections(current: CorrectionsFile, inputs: readonly RecordCorrectionInput[] | undefined): CorrectionsFile { + if (inputs === undefined || inputs.length === 0) return current + return inputs.reduce((file, input) => recordCorrection(file, input), current) +} diff --git a/wiki-writer/test/github-data-client.test.ts b/wiki-writer/test/github-data-client.test.ts new file mode 100644 index 0000000..75be162 --- /dev/null +++ b/wiki-writer/test/github-data-client.test.ts @@ -0,0 +1,54 @@ +import {Buffer} from 'node:buffer' +import {describe, expect, it, vi} from 'vitest' +import { + assertAllowedTarget, + createGitHubDataClientWithTransport, + WIKI_REF, + WIKI_REPOSITORY, +} from '../src/github-data-client.ts' + +describe('GitHub data client', () => { + it('rejects an out-of-scope target before invoking transport', () => { + expect(() => assertAllowedTarget('other/repo', WIKI_REF, 'knowledge/wiki/topics/example.md')).toThrow('repository') + expect(() => assertAllowedTarget(WIKI_REPOSITORY, 'main', 'knowledge/wiki/topics/example.md')).toThrow('ref') + expect(() => assertAllowedTarget(WIKI_REPOSITORY, WIKI_REF, 'src/private.md')).toThrow('path') + }) + + it('reads a tree snapshot and decodes only blob files', async () => { + const transport = { + getRef: vi.fn().mockResolvedValue({data: {object: {sha: 'head-1'}}}), + getCommit: vi.fn().mockResolvedValue({data: {sha: 'head-1', tree: {sha: 'tree-1'}, message: '', parents: []}}), + getTree: vi.fn().mockResolvedValue({data: {tree: [ + {path: 'knowledge/wiki/topics/example.md', type: 'blob', sha: 'blob-1'}, + {path: 'ignored', type: 'tree', sha: 'tree-2'}, + ]}}), + getBlob: vi.fn().mockResolvedValue({data: {content: Buffer.from('# Example\n').toString('base64'), encoding: 'base64'}}), + } + const client = createGitHubDataClientWithTransport(transport) + + await expect(client.getSnapshot()).resolves.toMatchObject({ + headSha: 'head-1', + treeSha: 'tree-1', + files: {'knowledge/wiki/topics/example.md': '# Example\n'}, + }) + expect(transport.getBlob).toHaveBeenCalledWith({owner: 'fro-bot', repo: '.github', file_sha: 'blob-1'}) + }) + + it('uses a non-forced ref update and supplies the observed parent to the commit', async () => { + const transport = { + createBlob: vi.fn().mockResolvedValue({data: {sha: 'blob-2'}}), + createTree: vi.fn().mockResolvedValue({data: {sha: 'tree-2'}}), + createCommit: vi.fn().mockResolvedValue({data: {sha: 'commit-2'}}), + updateRef: vi.fn().mockResolvedValue({data: {}}), + } + const client = createGitHubDataClientWithTransport(transport) + + const blob = await client.createBlob('# Example\n') + const tree = await client.createTree('tree-1', [{path: 'knowledge/wiki/topics/example.md', sha: blob}]) + await client.createCommit({treeSha: tree, parentSha: 'head-1', message: 'docs: edit\n\nFro-Operation-Id: op-1'}) + await client.updateRef('commit-2') + + expect(transport.createCommit).toHaveBeenCalledWith(expect.objectContaining({parents: ['head-1']})) + expect(transport.updateRef).toHaveBeenCalledWith({owner: 'fro-bot', repo: '.github', ref: 'heads/data', sha: 'commit-2', force: false}) + }) +}) diff --git a/wiki-writer/test/operation-ledger.test.ts b/wiki-writer/test/operation-ledger.test.ts new file mode 100644 index 0000000..976df65 --- /dev/null +++ b/wiki-writer/test/operation-ledger.test.ts @@ -0,0 +1,55 @@ +import {afterEach, describe, expect, it} from 'vitest' +import {createOperationLedger, type OperationIntent} from '../src/operation-ledger.ts' + +const intent: OperationIntent = { + operationId: '11111111-1111-4111-8111-111111111111', + repository: 'fro-bot/.github', + ref: 'data', + path: 'knowledge/wiki/topics/example.md', + expectedParentSha: 'parent-1', + contentDigest: 'digest-1', + createdAt: 1_700_000_000_000, +} + +describe('operation ledger', () => { + const ledgers: ReturnType[] = [] + + afterEach(() => { + for (const ledger of ledgers.splice(0)) ledger.close() + }) + + it('persists an intent and its terminal outcome', () => { + const ledger = createOperationLedger(':memory:') + ledgers.push(ledger) + + ledger.begin(intent) + ledger.complete(intent.operationId, {state: 'succeeded', commitSha: 'commit-1', updatedAt: 1_700_000_000_001}) + + expect(ledger.get(intent.operationId)).toEqual({ + ...intent, + state: 'succeeded', + commitSha: 'commit-1', + updatedAt: 1_700_000_000_001, + }) + }) + + it('rejects duplicate operation intents without creating a second record', () => { + const ledger = createOperationLedger(':memory:') + ledgers.push(ledger) + + ledger.begin(intent) + + expect(() => ledger.begin(intent)).toThrow('already exists') + expect(ledger.list()).toHaveLength(1) + }) + + it('stores indeterminate outcomes with their resolution state available', () => { + const ledger = createOperationLedger(':memory:') + ledgers.push(ledger) + + ledger.begin(intent) + ledger.complete(intent.operationId, {state: 'indeterminate', updatedAt: 1_700_000_000_002}) + + expect(ledger.listIndeterminate()).toEqual([{...intent, state: 'indeterminate', commitSha: null, updatedAt: 1_700_000_000_002}]) + }) +}) diff --git a/wiki-writer/test/retention.test.ts b/wiki-writer/test/retention.test.ts new file mode 100644 index 0000000..6db3b55 --- /dev/null +++ b/wiki-writer/test/retention.test.ts @@ -0,0 +1,21 @@ +import {describe, expect, it} from 'vitest' +import {OPERATION_MAX_AGE_MS, OPERATION_MAX_ROWS, selectOperationIdsToPrune} from '../src/retention.ts' + +describe('operation retention', () => { + it('does not delete unresolved indeterminate records when age and count bounds are exceeded', () => { + const now = 2_000_000_000_000 + const records = [ + {operationId: 'indeterminate', state: 'indeterminate' as const, createdAt: now - OPERATION_MAX_AGE_MS - 1}, + ...Array.from({length: OPERATION_MAX_ROWS + 1}, (_, index) => ({ + operationId: `succeeded-${index}`, + state: 'succeeded' as const, + createdAt: now - index, + })), + ] + + const pruned = selectOperationIdsToPrune(records, now) + + expect(pruned).not.toContain('indeterminate') + expect(pruned).toContain('succeeded-500') + }) +}) diff --git a/wiki-writer/test/security-boundary.test.ts b/wiki-writer/test/security-boundary.test.ts new file mode 100644 index 0000000..aa9d9b0 --- /dev/null +++ b/wiki-writer/test/security-boundary.test.ts @@ -0,0 +1,15 @@ +import {readFile} from 'node:fs/promises' +import {describe, expect, it} from 'vitest' + +describe('writer security boundary', () => { + it('keeps key loading and the write client outside dashboard source paths', async () => { + const source = [ + await readFile(new URL('../../src/server.ts', import.meta.url), 'utf8'), + await readFile(new URL('../../src/secrets.ts', import.meta.url), 'utf8'), + ].join('\n') + + expect(source).not.toContain('createGitHubDataClient') + expect(source).not.toContain('privateKeyFilePath') + expect(source).not.toContain('WIKI_WRITER_GITHUB_PRIVATE_KEY_FILE') + }) +}) diff --git a/wiki-writer/test/server.test.ts b/wiki-writer/test/server.test.ts index f7139b4..d7470d9 100644 --- a/wiki-writer/test/server.test.ts +++ b/wiki-writer/test/server.test.ts @@ -6,7 +6,7 @@ import {tmpdir} from 'node:os' import {join} from 'node:path' import {afterEach, describe, expect, it, vi} from 'vitest' import {createRequestSignature} from '../src/internal-auth.ts' -import {createWikiWriterApp, createWikiWriterHttpServer, WIKI_WRITER_MAX_RAW_BYTES} from '../src/server.ts' +import {createWikiWriterApp, createWikiWriterAppWithInjectedSecret, createWikiWriterHttpServer, WIKI_WRITER_MAX_RAW_BYTES} from '../src/server.ts' const SECRET = Buffer.from('wiki-writer-server-secret-which-is-long-enough') const NOW = 1_756_000_000 @@ -139,6 +139,31 @@ describe('wiki-writer HTTP boundary', () => { }) }) + it('maps the injected write operation result onto the writer wire contract', async () => { + const writeOperation = { + execute: vi.fn().mockResolvedValue({state: 'indeterminate', operationId: '11111111-1111-4111-8111-111111111111'}), + } + const body = JSON.stringify({ + operation: 'write', + repository: 'fro-bot/.github', + ref: 'data', + path: 'knowledge/wiki/page.md', + content: '# Test', + operationId: '11111111-1111-4111-8111-111111111111', + expectedParentSha: 'head-1', + }) + const injectedApp = createWikiWriterAppWithInjectedSecret(SECRET, { + nowSeconds: () => NOW, + writeOperation, + }) + + const response = await injectedApp.fetch(signedRequest('POST', '/write', body, 'write-operation-001')) + + expect(response.status).toBe(202) + expect(await response.json()).toEqual({accepted: true, status: 'indeterminate', operationId: '11111111-1111-4111-8111-111111111111'}) + expect(writeOperation.execute).toHaveBeenCalledOnce() + }) + it('does not invoke the operation seam for rejected authentication or replay', async () => { const authorizeOperation = vi.fn().mockResolvedValue({allowed: true}) const {app} = await createApp(authorizeOperation) diff --git a/wiki-writer/test/write-operation.test.ts b/wiki-writer/test/write-operation.test.ts new file mode 100644 index 0000000..44168ba --- /dev/null +++ b/wiki-writer/test/write-operation.test.ts @@ -0,0 +1,265 @@ +import {createHash} from 'node:crypto' +import {GATE_CONTRACT_VERSION} from '@fro-bot/wiki-write-core' +import {describe, expect, it, vi} from 'vitest' +import {WIKI_REF, WIKI_REPOSITORY, type GitHubDataClient} from '../src/github-data-client.ts' +import {createOperationLedger} from '../src/operation-ledger.ts' +import {createSharedWikiWriteGates, createWikiWriteOperation, type WikiWriteGateInput, type WikiWriteGates, type WikiWriteRequest} from '../src/write-operation.ts' + +const OPERATION_ID = '22222222-2222-4222-8222-222222222222' +const PATH = 'knowledge/wiki/topics/example.md' +const CONTENT = '# Corrected example\n' + +function request(overrides: Partial = {}): WikiWriteRequest { + return { + operation: 'write', + operationId: OPERATION_ID, + repository: WIKI_REPOSITORY, + ref: WIKI_REF, + path: PATH, + content: CONTENT, + expectedParentSha: 'head-1', + ...overrides, + } +} + +function snapshot(content = '---\ntype: topic\ntitle: Example\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n# Old example\n') { + return { + headSha: 'head-1', + treeSha: 'tree-1', + files: {[PATH]: content}, + fileShas: {[PATH]: 'blob-1'}, + } +} + +function createClient(overrides: Partial = {}): GitHubDataClient { + const getSnapshot = vi.fn().mockResolvedValue(snapshot()) + const createBlob = vi.fn().mockResolvedValue('blob-2') + const createTree = vi.fn().mockResolvedValue('tree-2') + const createCommit = vi.fn().mockResolvedValue('commit-2') + const updateRef = vi.fn().mockResolvedValue(undefined) + const getCommit = vi.fn().mockResolvedValue({sha: 'commit-2', message: `docs: edit\n\nFro-Operation-Id: ${OPERATION_ID}`, parents: ['head-1']}) + return { + getSnapshot, + createBlob, + createTree, + createCommit, + updateRef, + getCommit, + ...overrides, + } +} + +const gates: WikiWriteGates = { + run: async ({existingFiles, path, content}: WikiWriteGateInput) => ({ok: true as const, files: {...existingFiles, [path]: content}, findings: []}), +} + +function checker() { + return {check: vi.fn().mockResolvedValue({ + proceed: true as const, + marker: {version: GATE_CONTRACT_VERSION, sourceTreeHash: 'source-1'}, + readiness: {ready: true, stale: false, mainHeadSha: 'main-1'}, + })} +} + +describe('wiki write operation', () => { + it('reconstructs system frontmatter and creates one Fro Bot-attributed commit intent', async () => { + const client = createClient() + const ledger = createOperationLedger(':memory:') + const createBlob = vi.fn(async () => { + expect(ledger.get(OPERATION_ID)).toMatchObject({state: 'pending'}) + return 'blob-2' + }) + const createCommit = vi.fn(async input => { + expect(input.parentSha).toBe('head-1') + expect(input.message).toContain(`Fro-Operation-Id: ${OPERATION_ID}`) + expect(input.author?.name).toBe('Fro Bot') + return 'commit-2' + }) + const clientWithHook: GitHubDataClient = {...client, createBlob, createCommit} + const operation = createWikiWriteOperation({client: clientWithHook, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request())).resolves.toMatchObject({state: 'succeeded', commitSha: 'commit-2'}) + expect(createCommit).toHaveBeenCalledOnce() + expect(client.updateRef).toHaveBeenCalledOnce() + expect(ledger.get(OPERATION_ID)).toMatchObject({state: 'succeeded', expectedParentSha: 'head-1'}) + ledger.close() + }) + + it('returns a conflict before ref update when the observed parent is stale', async () => { + const client = createClient({getSnapshot: vi.fn().mockResolvedValue({...snapshot(), headSha: 'head-2'})}) + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request())).resolves.toEqual({state: 'conflict', status: 412}) + expect(client.updateRef).not.toHaveBeenCalled() + ledger.close() + }) + + it('rejects an out-of-scope target before contract or GitHub access', async () => { + const getSnapshot = vi.fn().mockResolvedValue(snapshot()) + const contract = checker() + const client = createClient({getSnapshot}) + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: contract}) + + await expect(operation.execute(request({repository: 'other/repo'}))).resolves.toEqual({state: 'rejected', reason: 'target'}) + expect(getSnapshot).not.toHaveBeenCalled() + expect(contract.check).not.toHaveBeenCalled() + ledger.close() + }) + + it('returns a conflict before ref update when the blob validator is stale', async () => { + const client = createClient() + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request({expectedBlobSha: 'blob-stale'}))).resolves.toEqual({state: 'conflict', status: 412}) + expect(client.updateRef).not.toHaveBeenCalled() + ledger.close() + }) + + it('persists marked corrections in the sidecar file rather than page frontmatter', async () => { + const gateResult = await createSharedWikiWriteGates().run({ + path: PATH, + content: '---\ntype: topic\ntitle: Example\ncreated: 2026-01-01\nupdated: 2026-01-01\nnode_id: node-1\n---\n\n# Corrected example\nMarked fact\n', + existingFiles: { + [PATH]: '---\ntype: topic\ntitle: Example\ncreated: 2026-01-01\nupdated: 2026-01-01\nnode_id: node-1\n---\n\n# Old example\n', + 'knowledge/index.md': '# Wiki Index\n', + 'knowledge/log.md': '# Wiki Log\n', + }, + corrections: [{ + id: 'correction-1', + pageNodeId: 'node-1', + span: {text: 'Marked fact'}, + serverDerivedAttribution: {actor: 'operator-1', recorded_at: '2026-09-03T00:00:00.000Z'}, + }], + now: new Date('2026-09-03T00:00:00.000Z'), + }) + + expect(gateResult.ok).toBe(true) + if (gateResult.ok) { + expect(gateResult.files['knowledge/corrections.yaml']).toContain('correction-1') + expect(gateResult.files[PATH]).not.toContain('correction-1') + } + }) + + it('surfaces gate rejection without leaking text absent from submitted content', async () => { + const secret = 'private upstream secret' + const rejectingGates: WikiWriteGates = { + run: vi.fn().mockResolvedValue({ok: false, findings: [{kind: 'unsafe-html', path: PATH, message: secret}]}), + } + const client = createClient() + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates: rejectingGates, gateContractChecker: checker()}) + + const result = await operation.execute(request()) + expect(result).toMatchObject({state: 'rejected'}) + expect(JSON.stringify(result)).not.toContain(secret) + expect(client.updateRef).not.toHaveBeenCalled() + ledger.close() + }) + + it('refuses a mismatched gate contract before creating a ledger intent', async () => { + const client = createClient() + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({ + client, + ledger, + gates, + gateContractChecker: {check: vi.fn().mockResolvedValue({ + proceed: false as const, + reason: 'version_mismatch' as const, + readiness: {ready: false, stale: false, mainHeadSha: 'main-1'}, + })}, + }) + + await expect(operation.execute(request())).resolves.toEqual({state: 'rejected', reason: 'gate-contract'}) + expect(ledger.list()).toHaveLength(0) + ledger.close() + }) + + it('maps a non-fast-forward update failure to a 412 without retrying', async () => { + const updateRef = vi.fn().mockRejectedValue(Object.assign(new Error('non-fast-forward'), {status: 422})) + const client = createClient({updateRef}) + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request())).resolves.toEqual({state: 'conflict', status: 412}) + expect(updateRef).toHaveBeenCalledOnce() + expect(ledger.get(OPERATION_ID)).toMatchObject({state: 'failed'}) + ledger.close() + }) + + it('keeps matching content indeterminate when the operation trailer is absent', async () => { + const desired = `---\ntype: topic\ntitle: Example\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n${CONTENT}` + const client = createClient({ + updateRef: vi.fn().mockRejectedValue(new Error('response lost')), + getSnapshot: vi.fn().mockResolvedValueOnce(snapshot()).mockResolvedValue(snapshot(desired)), + getCommit: vi.fn().mockResolvedValue({sha: 'head-2', message: 'docs: another identical edit', parents: ['head-1']}), + }) + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request())).resolves.toEqual({state: 'indeterminate', operationId: OPERATION_ID}) + expect(ledger.listIndeterminate()).toHaveLength(1) + ledger.close() + }) + + it('does not issue a blind write when an indeterminate operation is submitted again', async () => { + const updateRef = vi.fn().mockRejectedValue(new Error('response lost')) + const client = createClient({ + updateRef, + getSnapshot: vi.fn() + .mockResolvedValueOnce(snapshot()) + .mockResolvedValue({...snapshot(), headSha: 'unrelated-head'}), + getCommit: vi.fn().mockResolvedValue({sha: 'unrelated-head', message: 'unrelated', parents: []}), + }) + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await operation.execute(request()) + await expect(operation.execute(request())).resolves.toEqual({state: 'indeterminate', operationId: OPERATION_ID}) + expect(updateRef).toHaveBeenCalledOnce() + ledger.close() + }) + + it('reconciles an ambiguous write only when trailer, parent, and digest all match', async () => { + const desired = `---\ntype: topic\ntitle: Example\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n${CONTENT}` + const client = createClient({ + updateRef: vi.fn().mockRejectedValue(new Error('response lost')), + getSnapshot: vi.fn().mockResolvedValueOnce(snapshot()).mockResolvedValue({...snapshot(desired), headSha: 'commit-2'}), + }) + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request())).resolves.toMatchObject({state: 'succeeded', commitSha: 'commit-2'}) + expect(ledger.get(OPERATION_ID)).toMatchObject({state: 'succeeded', commitSha: 'commit-2'}) + ledger.close() + }) + + it('rejects content over the decoded ceiling before fetching GitHub state', async () => { + const getSnapshot = vi.fn().mockResolvedValue(snapshot()) + const client = createClient({getSnapshot}) + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + const result = await operation.execute(request({content: 'x'.repeat(512 * 1024 + 1)})) + expect(result).toMatchObject({state: 'rejected', reason: 'content-too-large'}) + expect(getSnapshot).not.toHaveBeenCalled() + ledger.close() + }) +}) + +describe('write operation discrimination fixture', () => { + it('uses the final reconstructed content digest as the ledger identity', async () => { + const client = createClient() + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await operation.execute(request()) + + const stored = ledger.get(OPERATION_ID) + expect(stored?.contentDigest).toBe(createHash('sha256').update(`---\ntype: topic\ntitle: Example\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n${CONTENT}`).digest('hex')) + ledger.close() + }) +}) From c8a2685f55e62de25c49fea8e157dfd4fa4440e8 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 2 Sep 2026 19:16:43 -0700 Subject: [PATCH 2/5] test(wiki-writer): cover credential boundary recursively --- wiki-writer/test/security-boundary.test.ts | 45 ++++++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/wiki-writer/test/security-boundary.test.ts b/wiki-writer/test/security-boundary.test.ts index aa9d9b0..4582218 100644 --- a/wiki-writer/test/security-boundary.test.ts +++ b/wiki-writer/test/security-boundary.test.ts @@ -1,15 +1,44 @@ -import {readFile} from 'node:fs/promises' +import {readdir, readFile} from 'node:fs/promises' +import {join, relative} from 'node:path' +import {fileURLToPath} from 'node:url' import {describe, expect, it} from 'vitest' describe('writer security boundary', () => { it('keeps key loading and the write client outside dashboard source paths', async () => { - const source = [ - await readFile(new URL('../../src/server.ts', import.meta.url), 'utf8'), - await readFile(new URL('../../src/secrets.ts', import.meta.url), 'utf8'), - ].join('\n') + const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url)) + const sourceRoots = [join(repositoryRoot, 'src'), join(repositoryRoot, 'web', 'src')] + const sourceFiles = (await Promise.all(sourceRoots.map(async root => listTypeScriptFiles(root)))).flat().sort() + const forbiddenTokens = [ + {label: 'createGitHubDataClient', pattern: /\bcreateGitHubDataClient\b/gu}, + {label: 'createGitHubDataClientWithTransport', pattern: /\bcreateGitHubDataClientWithTransport\b/gu}, + {label: 'privateKeyFilePath', pattern: /\bprivateKeyFilePath\b/gu}, + {label: 'WIKI_WRITER_GITHUB_PRIVATE_KEY_FILE', pattern: /\bWIKI_WRITER_GITHUB_PRIVATE_KEY_FILE\b/gu}, + {label: 'wiki-writer import', pattern: /(?:\bfrom[ \t]+|\bimport[ \t]*\([ \t]*)["'][^"']*wiki-writer[/"]/gu}, + ] - expect(source).not.toContain('createGitHubDataClient') - expect(source).not.toContain('privateKeyFilePath') - expect(source).not.toContain('WIKI_WRITER_GITHUB_PRIVATE_KEY_FILE') + expect(sourceFiles.length).toBeGreaterThan(90) + for (const filePath of sourceFiles) { + const source = await readFile(filePath, 'utf8') + for (const token of forbiddenTokens) { + if (token.pattern.test(source)) { + throw new Error(`Credential boundary violation in ${relative(repositoryRoot, filePath)}: matched ${token.label}`) + } + token.pattern.lastIndex = 0 + } + } }) }) + +async function listTypeScriptFiles(directory: string): Promise { + const entries = await readdir(directory, {withFileTypes: true}) + const files: string[] = [] + for (const entry of entries) { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + files.push(...await listTypeScriptFiles(entryPath)) + } else if (entry.isFile() && /\.(?:ts|tsx)$/u.test(entry.name)) { + files.push(entryPath) + } + } + return files +} From 2760980852f75c91f3ab9cf03b6e84b01a472f7d Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 2 Sep 2026 19:44:21 -0700 Subject: [PATCH 3/5] fix(wiki-writer): close write-path safety gaps --- pnpm-lock.yaml | 6 + wiki-writer/package.json | 4 +- wiki-writer/src/contract.ts | 44 ++++++- wiki-writer/src/github-data-client.ts | 30 +++-- wiki-writer/src/server.ts | 12 +- wiki-writer/src/write-operation.ts | 95 +++++++++++---- wiki-writer/test/github-data-client.test.ts | 23 ++++ wiki-writer/test/runtime-dependencies.test.ts | 59 ++++++++++ wiki-writer/test/server.test.ts | 20 ++++ wiki-writer/test/write-operation.test.ts | 111 ++++++++++++++++++ 10 files changed, 364 insertions(+), 40 deletions(-) create mode 100644 wiki-writer/test/runtime-dependencies.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cac9353..fa3d043 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,12 @@ importers: '@fro-bot/wiki-write-core': specifier: github:fro-bot/.github#37abb495df047e6b8beb690017ac6d217978fb9c&path:packages/wiki-write-core version: https://codeload.github.com/fro-bot/.github/tar.gz/37abb495df047e6b8beb690017ac6d217978fb9c#path:packages/wiki-write-core + '@octokit/auth-app': + specifier: 8.3.0 + version: 8.3.0 + '@octokit/core': + specifier: 7.0.8 + version: 7.0.8 packages: diff --git a/wiki-writer/package.json b/wiki-writer/package.json index 40f857f..1541b11 100644 --- a/wiki-writer/package.json +++ b/wiki-writer/package.json @@ -8,7 +8,9 @@ "start": "node src/server.ts" }, "dependencies": { - "@fro-bot/wiki-write-core": "github:fro-bot/.github#37abb495df047e6b8beb690017ac6d217978fb9c&path:packages/wiki-write-core" + "@fro-bot/wiki-write-core": "github:fro-bot/.github#37abb495df047e6b8beb690017ac6d217978fb9c&path:packages/wiki-write-core", + "@octokit/auth-app": "8.3.0", + "@octokit/core": "7.0.8" }, "engines": { "node": ">=24" diff --git a/wiki-writer/src/contract.ts b/wiki-writer/src/contract.ts index 0c0c14f..bec6a3f 100644 --- a/wiki-writer/src/contract.ts +++ b/wiki-writer/src/contract.ts @@ -1,3 +1,5 @@ +import type {RecordCorrectionInput} from '@fro-bot/wiki-write-core' + /** * Narrow wire contract for the private wiki writer boundary. * @@ -24,6 +26,18 @@ export interface WikiWriteRequest { readonly corrections?: readonly Record[] } +export type CompleteWikiWriteRequest = WikiWriteRequest & { + readonly operationId: string + readonly expectedParentSha: string + readonly corrections?: readonly RecordCorrectionInput[] +} + +export function isCompleteWikiWriteRequest(value: WikiWriteRequest): value is CompleteWikiWriteRequest { + return typeof value.operationId === 'string' && + typeof value.expectedParentSha === 'string' && + (value.corrections === undefined || value.corrections.every(isRecordCorrectionInput)) +} + export type OperationAuthorization = | {readonly allowed: true} | {readonly allowed: false; readonly reasonClass: 'operation_not_authorized'} @@ -54,7 +68,7 @@ export interface WikiWriterApp { } export interface WikiWriteOperationHandler { - readonly execute: (request: WikiWriteRequest) => Promise + readonly execute: (request: CompleteWikiWriteRequest) => Promise } export interface WikiWriterAppOptions { @@ -95,3 +109,31 @@ export function isWikiWriteRequest(value: unknown): value is WikiWriteRequest { typeof candidate.content === 'string' ) } + +function isRecordCorrectionInput(value: unknown): value is RecordCorrectionInput { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + const candidate = value as Record + return typeof candidate.id === 'string' && candidate.id.length > 0 && + typeof candidate.pageNodeId === 'string' && candidate.pageNodeId.length > 0 && + isCorrectionSpan(candidate.span) && isCorrectionAttribution(candidate.serverDerivedAttribution) && + (candidate.supersedesId === undefined || typeof candidate.supersedesId === 'string') +} + +function isCorrectionSpan(value: unknown): value is RecordCorrectionInput['span'] { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + const span = value as Record + return typeof span.text === 'string' && + (span.start === undefined || isNonNegativeInteger(span.start)) && + (span.end === undefined || isNonNegativeInteger(span.end)) +} + +function isCorrectionAttribution(value: unknown): value is RecordCorrectionInput['serverDerivedAttribution'] { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + const attribution = value as Record + return typeof attribution.actor === 'string' && attribution.actor.length > 0 && + typeof attribution.recorded_at === 'string' && attribution.recorded_at.length > 0 +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 +} diff --git a/wiki-writer/src/github-data-client.ts b/wiki-writer/src/github-data-client.ts index 63ff412..b159f8c 100644 --- a/wiki-writer/src/github-data-client.ts +++ b/wiki-writer/src/github-data-client.ts @@ -10,6 +10,7 @@ export const WIKI_REPO = '.github' const ALLOWED_PATH = /^knowledge\/wiki\/(?:repos|topics|entities|comparisons)\/[^/]+\.md$/u const CORRECTIONS_PATH = 'knowledge/corrections.yaml' +const BLOB_READ_CONCURRENCY = 8 export interface GitHubTreeEntry { readonly path?: string @@ -132,15 +133,26 @@ export function createGitHubDataClientWithTransport(transport: GitHubDataTranspo const entries = Array.isArray(tree.tree) ? tree.tree as GitHubTreeEntry[] : [] const files: Record = {} const fileShas: Record = {} - for (const entry of entries) { - if (entry.type !== 'blob' || typeof entry.path !== 'string' || typeof entry.sha !== 'string') continue - const blob = await call<{content?: unknown; encoding?: unknown}>('getBlob', { - owner: WIKI_OWNER, - repo: WIKI_REPO, - file_sha: entry.sha, - }) - files[entry.path] = decodeBlob(blob) - fileShas[entry.path] = entry.sha + let nextEntry = 0 + const readBlob = async (): Promise<{path: string; content: string; sha: string}[]> => { + const result: {path: string; content: string; sha: string}[] = [] + while (nextEntry < entries.length) { + const entry = entries[nextEntry] + nextEntry += 1 + if (entry?.type !== 'blob' || typeof entry.path !== 'string' || typeof entry.sha !== 'string') continue + const blob = await call<{content?: unknown; encoding?: unknown}>('getBlob', { + owner: WIKI_OWNER, + repo: WIKI_REPO, + file_sha: entry.sha, + }) + result.push({path: entry.path, content: decodeBlob(blob), sha: entry.sha}) + } + return result + } + const workers = Array.from({length: Math.min(BLOB_READ_CONCURRENCY, entries.length)}, async () => readBlob()) + for (const blob of (await Promise.all(workers)).flat()) { + files[blob.path] = blob.content + fileShas[blob.path] = blob.sha } return {headSha, treeSha: requireString(commit.treeSha, 'GitHub commit did not return a tree'), files, fileShas} } diff --git a/wiki-writer/src/server.ts b/wiki-writer/src/server.ts index e02468e..aff8b5c 100644 --- a/wiki-writer/src/server.ts +++ b/wiki-writer/src/server.ts @@ -1,9 +1,9 @@ -import type {AuditSink, InternalWikiWriterAppOptions, WikiWriteOperationHandler, WikiWriterApp, WikiWriterAppOptions, WikiWriteRequest} from './contract.ts' +import type {AuditSink, InternalWikiWriterAppOptions, WikiWriteOperationHandler, WikiWriterApp, WikiWriterAppOptions} from './contract.ts' import {Buffer} from 'node:buffer' import {createServer, type IncomingMessage, type Server, type ServerResponse} from 'node:http' import process from 'node:process' import {fileURLToPath} from 'node:url' -import {isWikiWriteRequest, WIKI_WRITER_HEALTH_PATH, WIKI_WRITER_WRITE_PATH} from './contract.ts' +import {isCompleteWikiWriteRequest, isWikiWriteRequest, WIKI_WRITER_HEALTH_PATH, WIKI_WRITER_WRITE_PATH} from './contract.ts' import {createGateContractChecker} from './gate-contract.ts' import {createGitHubDataClient} from './github-data-client.ts' import {authenticateInternalRequest, createRequestSignature, emitAuditRejection, InMemoryReplayStore, loadInternalAuthSecret} from './internal-auth.ts' @@ -67,7 +67,7 @@ export function createWikiWriterAppWithInjectedSecret(secret: Uint8Array, option if (!isWikiWriteRequest(payload)) return jsonResponse({error: 'invalid-request'}, 400) if (options.writeOperation !== undefined) { - if (!isCompleteWriteRequest(payload)) return jsonResponse({error: 'invalid-request'}, 400) + if (!isCompleteWikiWriteRequest(payload)) return jsonResponse({error: 'invalid-request'}, 400) const result = await options.writeOperation.execute(payload) return writeResultResponse(result) } @@ -242,14 +242,10 @@ async function createProductionWriteOperation(options: WikiWriterAppOptions): Pr gateContractChecker: createGateContractChecker({fetch}), }) return { - execute: async request => operation.execute(request as unknown as import('./write-operation.ts').WikiWriteRequest), + execute: async request => operation.execute(request), } } -function isCompleteWriteRequest(value: WikiWriteRequest): value is WikiWriteRequest & Required> { - return typeof value.operationId === 'string' && typeof value.expectedParentSha === 'string' -} - function writeResultResponse(value: unknown): Response { if (value === null || typeof value !== 'object') return jsonResponse({error: 'write-failed'}, 500) const result = value as WikiWriteResult diff --git a/wiki-writer/src/write-operation.ts b/wiki-writer/src/write-operation.ts index e19d9dd..a7efd33 100644 --- a/wiki-writer/src/write-operation.ts +++ b/wiki-writer/src/write-operation.ts @@ -89,6 +89,7 @@ export interface WikiWriteOperation { export function createWikiWriteOperation(options: WikiWriteOperationOptions): WikiWriteOperation { const gates = options.gates ?? createSharedWikiWriteGates() const now = options.now ?? (() => Date.now()) + const inFlight = new Map>() async function execute(request: WikiWriteRequest): Promise { try { @@ -106,7 +107,7 @@ export function createWikiWriteOperation(options: WikiWriteOperationOptions): Wi if (!contract.proceed) return {state: 'rejected', reason: 'gate-contract'} const existing = options.ledger.get(request.operationId) - if (existing !== undefined) return resultFromExisting(existing) + if (existing !== undefined) return resultFromExisting(existing, options, now) let snapshot: GitHubSnapshot try { @@ -118,7 +119,9 @@ export function createWikiWriteOperation(options: WikiWriteOperationOptions): Wi if (snapshot.headSha !== request.expectedParentSha) return {state: 'conflict', status: 412} const existingContent = snapshot.files[request.path] - if (existingContent === undefined) return {state: 'rejected', reason: 'invalid-request'} + // Page writes preserve system-owned frontmatter, so a new page is rejected; + // the corrections sidecar is a complete YAML document and may be bootstrapped. + if (existingContent === undefined && request.path !== 'knowledge/corrections.yaml') return {state: 'rejected', reason: 'invalid-request'} if (request.expectedBlobSha !== undefined && snapshot.fileShas[request.path] !== request.expectedBlobSha) { return {state: 'conflict', status: 412} } @@ -127,7 +130,7 @@ export function createWikiWriteOperation(options: WikiWriteOperationOptions): Wi try { submittedContent = request.path === 'knowledge/corrections.yaml' ? request.content - : reconstructFrontmatter(existingContent, request.content) + : reconstructFrontmatter(existingContent ?? '', request.content) } catch { return {state: 'rejected', reason: 'gate'} } @@ -148,15 +151,28 @@ export function createWikiWriteOperation(options: WikiWriteOperationOptions): Wi const files = gateResult.files const contentDigest = digest(submittedContent) - options.ledger.begin({ - operationId: request.operationId, - repository: request.repository, - ref: request.ref, - path: request.path, - expectedParentSha: request.expectedParentSha, - contentDigest, - createdAt: now(), - }) + const priorFlight = inFlight.get(request.operationId) + const flight = priorFlight ?? createDeferred() + if (priorFlight === undefined) inFlight.set(request.operationId, flight) + try { + options.ledger.begin({ + operationId: request.operationId, + repository: request.repository, + ref: request.ref, + path: request.path, + expectedParentSha: request.expectedParentSha, + contentDigest, + createdAt: now(), + }) + } catch (error) { + if (isDuplicateIntentError(error)) { + if (priorFlight !== undefined) return priorFlight.promise + const duplicate = options.ledger.get(request.operationId) + if (duplicate !== undefined) return resultFromExisting(duplicate, options, now) + } + if (priorFlight === undefined) inFlight.delete(request.operationId) + return {state: 'failed', operationId: request.operationId, correlationId: randomUUID()} + } const changedEntries: TreeEntry[] = [] try { @@ -166,11 +182,11 @@ export function createWikiWriteOperation(options: WikiWriteOperationOptions): Wi } } catch { completeLedger(options.ledger, request.operationId, {state: 'failed', updatedAt: now()}, now) - return {state: 'failed', operationId: request.operationId, correlationId: randomUUID()} + return finish(flight, inFlight, request.operationId, {state: 'failed', operationId: request.operationId, correlationId: randomUUID()}) } if (changedEntries.length === 0) { completeLedger(options.ledger, request.operationId, {state: 'failed', updatedAt: now()}, now) - return {state: 'rejected', reason: 'invalid-request'} + return finish(flight, inFlight, request.operationId, {state: 'rejected', reason: 'invalid-request'}) } const message = `docs(knowledge): manual edit ${request.path}\n\n${OPERATION_TRAILER}: ${request.operationId}` @@ -188,23 +204,23 @@ export function createWikiWriteOperation(options: WikiWriteOperationOptions): Wi } catch (error) { if (statusOf(error) === 422) { completeLedger(options.ledger, request.operationId, {state: 'failed', updatedAt: now()}, now) - return {state: 'conflict', status: 412} + return finish(flight, inFlight, request.operationId, {state: 'conflict', status: 412}) } const reconciliation = await reconcile(options.client, request, contentDigest) if (reconciliation.state === 'succeeded') { completeLedger(options.ledger, request.operationId, {state: 'succeeded', commitSha: reconciliation.commitSha, updatedAt: now()}, now) - return reconciliation + return finish(flight, inFlight, request.operationId, reconciliation) } completeLedger(options.ledger, request.operationId, {state: 'indeterminate', updatedAt: now()}, now) - return {state: 'indeterminate', operationId: request.operationId} + return finish(flight, inFlight, request.operationId, {state: 'indeterminate', operationId: request.operationId}) } completeLedger(options.ledger, request.operationId, {state: 'succeeded', commitSha, updatedAt: now()}, now) - return {state: 'succeeded', operationId: request.operationId, commitSha} + return finish(flight, inFlight, request.operationId, {state: 'succeeded', operationId: request.operationId, commitSha}) } catch { completeLedger(options.ledger, request.operationId, {state: 'failed', updatedAt: now()}, now) - return {state: 'failed', operationId: request.operationId, correlationId: randomUUID()} + return finish(flight, inFlight, request.operationId, {state: 'failed', operationId: request.operationId, correlationId: randomUUID()}) } } @@ -247,7 +263,7 @@ export function createSharedWikiWriteGates(): WikiWriteGates { } } -async function reconcile(client: GitHubDataClient, request: WikiWriteRequest, contentDigest: string): Promise<{state: 'succeeded'; operationId: string; commitSha: string} | {state: 'indeterminate'}> { +async function reconcile(client: GitHubDataClient, request: Pick, contentDigest: string): Promise<{state: 'succeeded'; operationId: string; commitSha: string} | {state: 'indeterminate'}> { try { const snapshot = await client.getSnapshot() if (digest(snapshot.files[request.path] ?? '') !== contentDigest) return {state: 'indeterminate'} @@ -281,12 +297,49 @@ function completeLedger(ledger: OperationLedger, operationId: string, completion ledger.prune(now()) } -function resultFromExisting(record: OperationRecord): WikiWriteResult { +async function resultFromExisting(record: OperationRecord, options: WikiWriteOperationOptions, now: () => number): Promise { if (record.state === 'succeeded' && record.commitSha !== null) return {state: 'succeeded', operationId: record.operationId, commitSha: record.commitSha} if (record.state === 'indeterminate') return {state: 'indeterminate', operationId: record.operationId} + if (record.state === 'pending') { + const reconciliation = await reconcile(options.client, record, record.contentDigest) + if (reconciliation.state === 'succeeded') { + completeLedger(options.ledger, record.operationId, {state: 'succeeded', commitSha: reconciliation.commitSha, updatedAt: now()}, now) + return reconciliation + } + completeLedger(options.ledger, record.operationId, {state: 'indeterminate', updatedAt: now()}, now) + return {state: 'indeterminate', operationId: record.operationId} + } return {state: 'failed', operationId: record.operationId, correlationId: randomUUID()} } +interface Deferred { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function createDeferred(): Deferred { + let resolvePromise: ((value: T) => void) | undefined + const promise = new Promise(resolve => { + resolvePromise = resolve + }) + return { + promise, + resolve: value => { + if (resolvePromise !== undefined) resolvePromise(value) + }, + } +} + +function finish(flight: Deferred, inFlight: Map>, operationId: string, result: WikiWriteResult): WikiWriteResult { + flight.resolve(result) + inFlight.delete(operationId) + return result +} + +function isDuplicateIntentError(error: unknown): boolean { + return error instanceof Error && error.message === 'operation intent already exists' +} + function boundFindings(findings: readonly WikiWriteGateFinding[], submittedContent: string): WikiWriteGateFinding[] { return findings.map(finding => ({ kind: finding.kind, diff --git a/wiki-writer/test/github-data-client.test.ts b/wiki-writer/test/github-data-client.test.ts index 75be162..a25ff1c 100644 --- a/wiki-writer/test/github-data-client.test.ts +++ b/wiki-writer/test/github-data-client.test.ts @@ -51,4 +51,27 @@ describe('GitHub data client', () => { expect(transport.createCommit).toHaveBeenCalledWith(expect.objectContaining({parents: ['head-1']})) expect(transport.updateRef).toHaveBeenCalledWith({owner: 'fro-bot', repo: '.github', ref: 'heads/data', sha: 'commit-2', force: false}) }) + + it('bounds concurrent blob reads while batching a tree snapshot', async () => { + let active = 0 + let maximum = 0 + const entries = Array.from({length: 16}, (_, index) => ({path: `knowledge/wiki/topics/${index}.md`, type: 'blob', sha: `blob-${index}`})) + const transport = { + getRef: vi.fn().mockResolvedValue({data: {object: {sha: 'head-1'}}}), + getCommit: vi.fn().mockResolvedValue({data: {sha: 'head-1', tree: {sha: 'tree-1'}, message: '', parents: []}}), + getTree: vi.fn().mockResolvedValue({data: {tree: entries}}), + getBlob: vi.fn(async () => { + active += 1 + maximum = Math.max(maximum, active) + await new Promise(resolve => setTimeout(resolve, 1)) + active -= 1 + return {data: {content: Buffer.from('# Example\n').toString('base64'), encoding: 'base64'}} + }), + } + + await createGitHubDataClientWithTransport(transport).getSnapshot() + + expect(maximum).toBeGreaterThan(1) + expect(maximum).toBeLessThanOrEqual(8) + }) }) diff --git a/wiki-writer/test/runtime-dependencies.test.ts b/wiki-writer/test/runtime-dependencies.test.ts new file mode 100644 index 0000000..4ab8281 --- /dev/null +++ b/wiki-writer/test/runtime-dependencies.test.ts @@ -0,0 +1,59 @@ +import {readdir, readFile} from 'node:fs/promises' +import {join} from 'node:path' +import ts from 'typescript' +import {describe, expect, it} from 'vitest' + +describe('wiki-writer runtime dependencies', () => { + it('declares every external source import in runtime dependencies', async () => { + const packagePath = new URL('../package.json', import.meta.url) + const packageJson = JSON.parse(await readFile(packagePath, 'utf8')) as {dependencies?: Record} + const sourceFiles = await listTypeScriptFiles(filePath('../src')) + const imports = new Map() + + for (const filePath of sourceFiles) { + const source = await readFile(filePath, 'utf8') + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true) + visit(sourceFile, specifier => { + const packageName = runtimePackageName(specifier) + if (packageName !== undefined && !imports.has(packageName)) imports.set(packageName, filePath) + }) + } + + for (const [packageName, filePath] of imports) { + expect(packageJson.dependencies?.[packageName], `Missing runtime dependency ${packageName} imported by ${filePath}`).toBeDefined() + } + }) +}) + +function filePath(relativePath: string): string { + return new URL(relativePath, import.meta.url).pathname +} + +async function listTypeScriptFiles(directory: string): Promise { + const entries = await readdir(directory, {withFileTypes: true}) + const files: string[] = [] + for (const entry of entries) { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) files.push(...await listTypeScriptFiles(entryPath)) + else if (entry.isFile() && /\.(?:ts|tsx)$/u.test(entry.name)) files.push(entryPath) + } + return files +} + +function visit(node: ts.Node, onImport: (specifier: string) => void): void { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) onImport(node.moduleSpecifier.text) + if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && ts.isStringLiteral(node.moduleReference.expression)) { + onImport(node.moduleReference.expression.text) + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments.length === 1) { + const argument = node.arguments[0] + if (argument !== undefined && ts.isStringLiteral(argument)) onImport(argument.text) + } + node.forEachChild(child => visit(child, onImport)) +} + +function runtimePackageName(specifier: string): string | undefined { + if (specifier.startsWith('node:') || specifier.startsWith('.')) return undefined + const parts = specifier.split('/') + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] +} diff --git a/wiki-writer/test/server.test.ts b/wiki-writer/test/server.test.ts index d7470d9..87bc8bb 100644 --- a/wiki-writer/test/server.test.ts +++ b/wiki-writer/test/server.test.ts @@ -164,6 +164,26 @@ describe('wiki-writer HTTP boundary', () => { expect(writeOperation.execute).toHaveBeenCalledOnce() }) + it('rejects malformed corrections before invoking the write operation', async () => { + const writeOperation = {execute: vi.fn().mockResolvedValue({state: 'succeeded', operationId: 'op', commitSha: 'sha'})} + const body = JSON.stringify({ + operation: 'write', + repository: 'fro-bot/.github', + ref: 'data', + path: 'knowledge/wiki/page.md', + content: '# Test', + operationId: '11111111-1111-4111-8111-111111111111', + expectedParentSha: 'head-1', + corrections: [{}], + }) + const injectedApp = createWikiWriterAppWithInjectedSecret(SECRET, {nowSeconds: () => NOW, writeOperation}) + + const response = await injectedApp.fetch(signedRequest('POST', '/write', body, 'malformed-corrections-001')) + + expect(response.status).toBe(400) + expect(writeOperation.execute).not.toHaveBeenCalled() + }) + it('does not invoke the operation seam for rejected authentication or replay', async () => { const authorizeOperation = vi.fn().mockResolvedValue({allowed: true}) const {app} = await createApp(authorizeOperation) diff --git a/wiki-writer/test/write-operation.test.ts b/wiki-writer/test/write-operation.test.ts index 44168ba..a518c6d 100644 --- a/wiki-writer/test/write-operation.test.ts +++ b/wiki-writer/test/write-operation.test.ts @@ -118,6 +118,109 @@ describe('wiki write operation', () => { ledger.close() }) + it('allows the corrections sidecar to be created when it is absent', async () => { + const client = createClient() + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request({ + path: 'knowledge/corrections.yaml', + content: 'version: 1\ncorrections: []\n', + }))).resolves.toMatchObject({state: 'succeeded'}) + expect(client.createBlob).toHaveBeenCalled() + ledger.close() + }) + + it('returns the existing result when duplicate requests race at ledger insertion', async () => { + const snapshotsReady = createDeferred() + let snapshotCalls = 0 + const client = createClient({ + getSnapshot: vi.fn(async () => { + snapshotCalls += 1 + if (snapshotCalls === 2) snapshotsReady.resolve() + await snapshotsReady.promise + return snapshot() + }), + }) + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + const first = operation.execute(request()) + const second = operation.execute(request()) + const results = await Promise.all([first, second]) + + expect(results[0]).toMatchObject({state: 'succeeded', commitSha: 'commit-2'}) + expect(results[1]).toEqual(results[0]) + expect(client.createCommit).toHaveBeenCalledOnce() + ledger.close() + }) + + it('reconciles a pending record before allowing a new write', async () => { + const desired = `---\ntype: topic\ntitle: Example\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n${CONTENT}` + const client = createClient({ + getSnapshot: vi.fn().mockResolvedValue({...snapshot(desired), headSha: 'commit-2'}), + }) + const ledger = createOperationLedger(':memory:') + ledger.begin({ + operationId: OPERATION_ID, + repository: WIKI_REPOSITORY, + ref: WIKI_REF, + path: PATH, + expectedParentSha: 'head-1', + contentDigest: createHash('sha256').update(desired).digest('hex'), + createdAt: Date.now(), + }) + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request())).resolves.toMatchObject({state: 'succeeded', commitSha: 'commit-2'}) + expect(client.createCommit).not.toHaveBeenCalled() + ledger.close() + }) + + it('keeps a pending record indeterminate when reconciliation finds no landed commit', async () => { + const client = createClient({ + getSnapshot: vi.fn().mockResolvedValue(snapshot()), + getCommit: vi.fn().mockResolvedValue({sha: 'head-1', message: 'unrelated', parents: []}), + }) + const ledger = createOperationLedger(':memory:') + ledger.begin({ + operationId: OPERATION_ID, + repository: WIKI_REPOSITORY, + ref: WIKI_REF, + path: PATH, + expectedParentSha: 'head-1', + contentDigest: 'digest-not-present', + createdAt: Date.now(), + }) + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request())).resolves.toEqual({state: 'indeterminate', operationId: OPERATION_ID}) + expect(ledger.get(OPERATION_ID)).toMatchObject({state: 'indeterminate'}) + ledger.close() + }) + + it('keeps pending content-only matches indeterminate', async () => { + const desired = `---\ntype: topic\ntitle: Example\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n${CONTENT}` + const client = createClient({ + getSnapshot: vi.fn().mockResolvedValue({...snapshot(desired), headSha: 'head-2'}), + getCommit: vi.fn().mockResolvedValue({sha: 'head-2', message: 'docs: identical content', parents: ['head-1']}), + }) + const ledger = createOperationLedger(':memory:') + ledger.begin({ + operationId: OPERATION_ID, + repository: WIKI_REPOSITORY, + ref: WIKI_REF, + path: PATH, + expectedParentSha: 'head-1', + contentDigest: createHash('sha256').update(desired).digest('hex'), + createdAt: Date.now(), + }) + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request())).resolves.toEqual({state: 'indeterminate', operationId: OPERATION_ID}) + ledger.close() + }) + it('persists marked corrections in the sidecar file rather than page frontmatter', async () => { const gateResult = await createSharedWikiWriteGates().run({ path: PATH, @@ -250,6 +353,14 @@ describe('wiki write operation', () => { }) }) +function createDeferred(): {promise: Promise; resolve: (value: T) => void} { + let resolve!: (value: T) => void + const promise = new Promise(value => { + resolve = value + }) + return {promise, resolve} +} + describe('write operation discrimination fixture', () => { it('uses the final reconstructed content digest as the ledger identity', async () => { const client = createClient() From 6648c540667a6bec8253f4f7fe7fd4339a3cb040 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Fri, 4 Sep 2026 14:14:02 -0700 Subject: [PATCH 4/5] fix(wiki-writer): validate ledger rows at runtime instead of casting Replace the three 'as unknown as OperationRow' casts on SQLite results with a runtime predicate that checks every field's type and confirms state is one of the permitted lifecycle values. A row that fails validation throws OperationRowValidationError naming the operation ID instead of flowing unvalidated into reconciliation decisions. --- wiki-writer/src/operation-ledger.ts | 64 ++++++++- wiki-writer/test/operation-ledger.test.ts | 158 +++++++++++++++++++++- 2 files changed, 216 insertions(+), 6 deletions(-) diff --git a/wiki-writer/src/operation-ledger.ts b/wiki-writer/src/operation-ledger.ts index 572d472..6d8e303 100644 --- a/wiki-writer/src/operation-ledger.ts +++ b/wiki-writer/src/operation-ledger.ts @@ -48,6 +48,62 @@ interface OperationRow { commit_sha: string | null } +const OPERATION_STATES = new Set(['pending', 'succeeded', 'failed', 'indeterminate']) + +export class OperationRowValidationError extends Error { + constructor(operationId: unknown, reason: string) { + const id = typeof operationId === 'string' && operationId.length > 0 ? operationId : '' + super(`Ledger row for operation ${id} failed validation: ${reason}`) + this.name = 'OperationRowValidationError' + } +} + +function isOperationRow(value: unknown): value is OperationRow { + if (value === null || typeof value !== 'object') { + throw new OperationRowValidationError(undefined, 'row is not an object') + } + const row = value as Record + const operationId = row.operation_id + + if (typeof row.operation_id !== 'string' || row.operation_id.length === 0) { + throw new OperationRowValidationError(operationId, 'operation_id is not a non-empty string') + } + if (typeof row.repository !== 'string' || row.repository.length === 0) { + throw new OperationRowValidationError(operationId, 'repository is not a non-empty string') + } + if (typeof row.ref !== 'string' || row.ref.length === 0) { + throw new OperationRowValidationError(operationId, 'ref is not a non-empty string') + } + if (typeof row.path !== 'string' || row.path.length === 0) { + throw new OperationRowValidationError(operationId, 'path is not a non-empty string') + } + if (typeof row.expected_parent_sha !== 'string' || row.expected_parent_sha.length === 0) { + throw new OperationRowValidationError(operationId, 'expected_parent_sha is not a non-empty string') + } + if (typeof row.content_digest !== 'string' || row.content_digest.length === 0) { + throw new OperationRowValidationError(operationId, 'content_digest is not a non-empty string') + } + if (typeof row.state !== 'string' || !OPERATION_STATES.has(row.state as OperationState)) { + throw new OperationRowValidationError(operationId, `state is not one of the permitted lifecycle values (got ${JSON.stringify(row.state)})`) + } + if (typeof row.created_at !== 'number' || !Number.isFinite(row.created_at)) { + throw new OperationRowValidationError(operationId, 'created_at is not a finite number') + } + if (typeof row.updated_at !== 'number' || !Number.isFinite(row.updated_at)) { + throw new OperationRowValidationError(operationId, 'updated_at is not a finite number') + } + if (row.commit_sha !== null && typeof row.commit_sha !== 'string') { + throw new OperationRowValidationError(operationId, 'commit_sha is neither null nor a string') + } + + return true +} + +function toOperationRow(value: unknown): OperationRow { + if (!isOperationRow(value)) throw new OperationRowValidationError(undefined, 'row failed validation') + return value +} + export function createOperationLedger(dbPath: string): OperationLedger { if (dbPath !== ':memory:') mkdirSync(dirname(dbPath), {recursive: true}) @@ -102,16 +158,16 @@ export function createOperationLedger(dbPath: string): OperationLedger { } function get(operationId: string): OperationRecord | undefined { - const row = select.get(operationId) as unknown as OperationRow | undefined - return row === undefined ? undefined : rowToRecord(row) + const row = select.get(operationId) + return row === undefined ? undefined : rowToRecord(toOperationRow(row)) } function list(): OperationRecord[] { - return (selectAll.all() as unknown as OperationRow[]).map(rowToRecord) + return selectAll.all().map(row => rowToRecord(toOperationRow(row))) } function listIndeterminate(): OperationRecord[] { - return (selectIndeterminate.all() as unknown as OperationRow[]).map(rowToRecord) + return selectIndeterminate.all().map(row => rowToRecord(toOperationRow(row))) } function prune(now: number): void { diff --git a/wiki-writer/test/operation-ledger.test.ts b/wiki-writer/test/operation-ledger.test.ts index 976df65..9687415 100644 --- a/wiki-writer/test/operation-ledger.test.ts +++ b/wiki-writer/test/operation-ledger.test.ts @@ -1,5 +1,9 @@ +import {mkdtemp, rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {DatabaseSync} from 'node:sqlite' import {afterEach, describe, expect, it} from 'vitest' -import {createOperationLedger, type OperationIntent} from '../src/operation-ledger.ts' +import {createOperationLedger, OperationRowValidationError, type OperationIntent} from '../src/operation-ledger.ts' const intent: OperationIntent = { operationId: '11111111-1111-4111-8111-111111111111', @@ -11,11 +15,92 @@ const intent: OperationIntent = { createdAt: 1_700_000_000_000, } +async function createFileBackedLedger(temporaryDirectories: string[], ledgers: ReturnType[]) { + const directory = await mkdtemp(join(tmpdir(), 'wiki-writer-ledger-')) + temporaryDirectories.push(directory) + const dbPath = join(directory, 'ledger.sqlite') + const ledger = createOperationLedger(dbPath) + ledgers.push(ledger) + return {ledger, dbPath} +} + +type RawColumnValue = string | number | null + +interface RawRowOverrides { + operation_id?: RawColumnValue + repository?: RawColumnValue + ref?: RawColumnValue + path?: RawColumnValue + expected_parent_sha?: RawColumnValue + content_digest?: RawColumnValue + state?: RawColumnValue + created_at?: RawColumnValue + updated_at?: RawColumnValue + commit_sha?: RawColumnValue +} + +function writeRawRow(dbPath: string, overrides: RawRowOverrides): void { + const raw = new DatabaseSync(dbPath) + try { + const base = { + operation_id: intent.operationId, + repository: intent.repository, + ref: intent.ref, + path: intent.path, + expected_parent_sha: intent.expectedParentSha, + content_digest: intent.contentDigest, + state: 'pending', + created_at: intent.createdAt, + updated_at: intent.createdAt, + commit_sha: null as RawColumnValue, + ...overrides, + } + raw.exec(`DELETE FROM wiki_write_operations WHERE operation_id = '${String(base.operation_id)}'`) + raw + .prepare( + `INSERT INTO wiki_write_operations + (operation_id, repository, ref, path, expected_parent_sha, content_digest, state, created_at, updated_at, commit_sha) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + base.operation_id, + base.repository, + base.ref, + base.path, + base.expected_parent_sha, + base.content_digest, + base.state, + base.created_at, + base.updated_at, + base.commit_sha, + ) + } finally { + raw.close() + } +} + +function insertRawRow(dbPath: string, values: readonly RawColumnValue[]): void { + const raw = new DatabaseSync(dbPath) + try { + raw + .prepare( + `INSERT INTO wiki_write_operations + (operation_id, repository, ref, path, expected_parent_sha, content_digest, state, created_at, updated_at, commit_sha) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(...values) + } finally { + raw.close() + } +} + describe('operation ledger', () => { const ledgers: ReturnType[] = [] + const temporaryDirectories: string[] = [] - afterEach(() => { + afterEach(async () => { for (const ledger of ledgers.splice(0)) ledger.close() + await Promise.all(temporaryDirectories.splice(0).map(async directory => rm(directory, {recursive: true, force: true}))) }) it('persists an intent and its terminal outcome', () => { @@ -52,4 +137,73 @@ describe('operation ledger', () => { expect(ledger.listIndeterminate()).toEqual([{...intent, state: 'indeterminate', commitSha: null, updatedAt: 1_700_000_000_002}]) }) + + describe('row validation', () => { + it('narrows a well-formed row and returns the expected record', async () => { + const {ledger, dbPath} = await createFileBackedLedger(temporaryDirectories, ledgers) + writeRawRow(dbPath, {state: 'succeeded', commit_sha: 'commit-1'}) + + expect(ledger.get(intent.operationId)).toEqual({ + ...intent, + state: 'succeeded', + commitSha: 'commit-1', + updatedAt: intent.createdAt, + }) + }) + + it('throws naming the operation ID when state is outside the permitted set', async () => { + const {ledger, dbPath} = await createFileBackedLedger(temporaryDirectories, ledgers) + writeRawRow(dbPath, {state: 'archived'}) + + expect(() => ledger.get(intent.operationId)).toThrow(OperationRowValidationError) + expect(() => ledger.get(intent.operationId)).toThrow(intent.operationId) + }) + + it('throws when a field has the wrong type', async () => { + const {ledger, dbPath} = await createFileBackedLedger(temporaryDirectories, ledgers) + writeRawRow(dbPath, {created_at: 'not-a-number'}) + + expect(() => ledger.get(intent.operationId)).toThrow(OperationRowValidationError) + }) + + it('list() validates every row, not just the first', async () => { + const {ledger, dbPath} = await createFileBackedLedger(temporaryDirectories, ledgers) + writeRawRow(dbPath, {operation_id: '22222222-2222-4222-8222-222222222222', created_at: intent.createdAt}) + insertRawRow(dbPath, [ + '33333333-3333-4333-8333-333333333333', + intent.repository, + intent.ref, + intent.path, + intent.expectedParentSha, + intent.contentDigest, + 'not-a-real-state', + intent.createdAt + 1, + intent.createdAt + 1, + null, + ]) + + expect(() => ledger.list()).toThrow(OperationRowValidationError) + expect(() => ledger.list()).toThrow('33333333-3333-4333-8333-333333333333') + }) + + it('listIndeterminate() validates every row, not just the first', async () => { + const {ledger, dbPath} = await createFileBackedLedger(temporaryDirectories, ledgers) + writeRawRow(dbPath, {operation_id: '44444444-4444-4444-8444-444444444444', state: 'indeterminate'}) + insertRawRow(dbPath, [ + '55555555-5555-4555-8555-555555555555', + intent.repository, + intent.ref, + intent.path, + intent.expectedParentSha, + intent.contentDigest, + 'indeterminate', + 'not-a-number', + intent.createdAt + 1, + null, + ]) + + expect(() => ledger.listIndeterminate()).toThrow(OperationRowValidationError) + expect(() => ledger.listIndeterminate()).toThrow('55555555-5555-4555-8555-555555555555') + }) + }) }) From c543394ceacc59a95fb9c94a790b04b30b110dcc Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Fri, 4 Sep 2026 14:22:14 -0700 Subject: [PATCH 5/5] test(wiki-writer): lock in rejection of new wiki pages Page writes preserve system-owned frontmatter, so a path absent from the snapshot is refused before any blob is created or ledger intent recorded. Only the corrections sidecar may be bootstrapped. --- wiki-writer/test/write-operation.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/wiki-writer/test/write-operation.test.ts b/wiki-writer/test/write-operation.test.ts index a518c6d..376e186 100644 --- a/wiki-writer/test/write-operation.test.ts +++ b/wiki-writer/test/write-operation.test.ts @@ -118,6 +118,20 @@ describe('wiki write operation', () => { ledger.close() }) + it('rejects creating a wiki page that is absent from the snapshot', async () => { + const client = createClient() + const ledger = createOperationLedger(':memory:') + const operation = createWikiWriteOperation({client, ledger, gates, gateContractChecker: checker()}) + + await expect(operation.execute(request({ + path: 'knowledge/wiki/topics/new-page.md', + content: '---\ntitle: New\n---\n\nBody\n', + }))).resolves.toEqual({state: 'rejected', reason: 'invalid-request'}) + expect(client.createBlob).not.toHaveBeenCalled() + expect(ledger.get(request().operationId)).toBeUndefined() + ledger.close() + }) + it('allows the corrections sidecar to be created when it is absent', async () => { const client = createClient() const ledger = createOperationLedger(':memory:')