// #797 / #1839 Tasks 3/4: the Console's request intake routes. Reads and owner writes over one // request, each through the console wrapper (consoleCall) as the signed-in (provider, iss, sub): // GET /api/requests/:request request.get.console -> RequestState // GET /api/requests/:request/projects request.projects.console -> [{ project, name }] // GET /api/requests/:request/draft request.draft.console -> { candidate, prd, requirements, snapshot } // POST /api/requests/:request/answer request.answer.console { revision, question, op, text } // POST /api/requests/:request/attach request.attach.console { revision, project, op } // POST /api/requests/:request/mode request.mode.console { revision, mode_revision, mode, op } // POST /api/requests/:request/cancel request.cancel.console { revision, op } // POST /api/requests/:request/revoke prd.revoke.console { project, snapshot, revision, reason, op } // Revoke uses the project's snapshot-event revision, and returns a revocation receipt rather than // request state. Its project and snapshot are checked against the route's current request first. // Only the signed-in owner reaches this module (serve.mjs fences granted users and the tester before // it). A write needs the Console's own Origin and same-origin fetch metadata, a bounded JSON body of // exactly the fields above, and a live session. The browser never names the business, the subject or // a verb: the verb comes from the route, the subject from the session. Nothing here retries. A state // reply must name this request; a revoke receipt must name this snapshot and its next revision. A text answer is an answer to the // PM's question, never a PRD approval; the decision answer routes are decision-http.mjs's. import { BusReadError } from './bus.mjs'; import { createHash } from 'node:crypto'; import { CONSOLE_VERBS } from '../../bus/src/broker.mjs'; export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|workspace|answer|attach|mode|cancel|revoke)|\/(artifact)\/([^/]+)(\/(?:text))?)?$/; const WRITE_VERBS = Object.freeze({ answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console', revoke: 'prd.revoke.console', }); const READ_VERBS = Object.freeze({ get: 'request.get.console', projects: 'request.projects.console', draft: 'request.draft.console', workspace: 'request.workspace.console' }); // The exact key set of each write body. A key outside it, such as `request`, `business` or `subject`, // is refused: the route and the session say who and what. const FIELDS = Object.freeze({ answer: ['revision', 'question', 'op', 'text'], attach: ['revision', 'project', 'op'], mode: ['revision', 'mode_revision', 'mode', 'op'], cancel: ['revision', 'op'], revoke: ['project', 'snapshot', 'revision', 'reason', 'op'], }); // The endpoint contract. Each refusal is one of these, never a 200. export const REQUEST_REFUSALS = Object.freeze({ 'sign-in-required': [401, 'Sign in again before changing this request.'], 'read-only': [403, 'Console request controls are not enabled.'], 'cross-origin': [403, 'Act from the signed-in Console page.'], 'not-found': [404, 'Not found.'], 'human-required': [403, 'The bus refused this human identity.'], 'peercred-required': [403, 'The bus could not verify this connection.'], 'method-not-allowed': [405, 'Use GET to read and POST to change a request.'], 'invalid-request': [400, 'Invalid request change.'], 'request-too-large': [413, 'The request change is too large.'], 'request-not-found': [404, 'This request is unavailable.'], 'request-revision-conflict': [409, 'The request changed. Read it again.'], 'request-phase-refused': [409, 'The request is not at a step that takes this.'], 'request-mode-revision-conflict': [409, 'The mode changed. Read the request again.'], 'request-question-closed': [409, 'That question is already answered.'], 'request-question-mismatch': [409, 'That is not the question the project manager is waiting on.'], 'project-archived': [409, 'This project is archived.'], 'request-project-refused': [403, 'That project cannot be picked for this request.'], 'request-existing-project-changed': [409, 'The project mapping changed. Read the project picker again.'], 'request-operation-conflict': [409, 'That operation id was already used for a different change.'], 'prd-candidate-required': [409, 'No PRD draft has been submitted for this request yet.'], 'prd-candidate-stale': [409, 'The PRD or a requirement changed. A new submission is needed.'], 'stale-spec': [409, 'The confirmed intent changed. A new PRD submission is needed.'], 'requirement-snapshot-refused': [403, 'That approved snapshot does not belong to this request.'], 'requirement-snapshot-revoked': [409, 'That approved snapshot has been revoked. Read the request again.'], 'processing-halted': [409, 'Processing is stopped. This operation was refused.'], 'draft-unavailable': [503, 'The draft PRD view is not available yet.'], unavailable: [503, 'The request is unavailable. Try again later.'], 'outcome-unknown': [503, 'The change may have reached the bus. Read the request before trying again.'], }); // Bus codes by name. A bus that refuses this caller is human-required, whatever its code. Any other // code, or no answer, is `unavailable` at a read and `outcome-unknown` at a write: the write may have // landed, so the page reads the request first and never re-sends on its own. const BUS_CODES = { 'human-required': 'human-required', unauthenticated: 'human-required', 'unknown-business': 'human-required', 'access-refused': 'human-required', 'access-revoked': 'human-required', 'agent-required': 'human-required', 'peercred-required': 'peercred-required', 'request-not-found': 'request-not-found', 'request-revision-conflict': 'request-revision-conflict', 'request-phase-refused': 'request-phase-refused', 'request-mode-revision-conflict': 'request-mode-revision-conflict', 'project-archived': 'project-archived', 'request-question-closed': 'request-question-closed', 'request-question-mismatch': 'request-question-mismatch', 'request-project-refused': 'request-project-refused', 'request-existing-project-changed': 'request-existing-project-changed', 'request-operation-conflict': 'request-operation-conflict', 'invalid-request': 'invalid-request', 'prd-candidate-required': 'prd-candidate-required', 'prd-candidate-stale': 'prd-candidate-stale', 'stale-spec': 'stale-spec', 'requirement-snapshot-refused': 'requirement-snapshot-refused', 'requirement-snapshot-revoked': 'requirement-snapshot-revoked', 'processing-halted': 'processing-halted', 'read-only': 'read-only', }; const BODY_MAX = 4096; const ANSWER_BODY_MAX = 65536; const ANSWER_MAX = 8000; const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/; const MODES = ['autonomous', 'guided']; const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null); const positive = x => Number.isSafeInteger(x) && x > 0; export function requestRefuse(res, code) { const [status, message] = REQUEST_REFUSALS[code]; res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify({ error: code, message })); } function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); } async function readBody(req, max) { if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return { code: 'invalid-request' }; const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > max) return { code: 'request-too-large' }; chunks.push(chunk); } try { const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' }; } catch { return { code: 'invalid-request' }; } } // The fields of one write, or null. Everything the bus checks again is checked here first so a // malformed body never spawns a transport. function fields(action, value) { const keys = Object.keys(value); if (keys.length !== FIELDS[action].length || !FIELDS[action].every(k => keys.includes(k))) return null; if (!positive(value.revision) || typeof value.op !== 'string' || !ID.test(value.op)) return null; if (action === 'answer') { if (typeof value.question !== 'string' || !ID.test(value.question)) return null; if (typeof value.text !== 'string' || value.text.trim().length === 0 || value.text.length > ANSWER_MAX || value.text.includes('\0')) return null; } if (action === 'attach' && (typeof value.project !== 'string' || !ID.test(value.project))) return null; if (action === 'mode' && (!positive(value.mode_revision) || !MODES.includes(value.mode))) return null; if (action === 'revoke' && (typeof value.project !== 'string' || !ID.test(value.project) || typeof value.snapshot !== 'string' || !ID.test(value.snapshot) || typeof value.reason !== 'string' || !value.reason.trim() || value.reason.length > 1024 || value.reason.includes('\0'))) return null; return Object.fromEntries(FIELDS[action].map(k => [k, value[k]])); } // A reply is relayed only when it is the state of this request: version 1, this id, a positive // revision and a phase. const state = (v, id) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) && v.version === 1 && v.request === id && positive(v.revision) && typeof v.phase === 'string'; // Empty material still carries the counter after invalidation. Never infer it from request revision. const draft = (v, id) => { if (!v || typeof v !== 'object' || !Array.isArray(v.requirements) || !v.snapshot || !Number.isSafeInteger(v.snapshot.revision) || v.snapshot.revision < 0 || !(v.snapshot.id === null || typeof v.snapshot.id === 'string' && ID.test(v.snapshot.id) && positive(v.snapshot.revision))) return false; if (v.candidate === null) return v.prd === null && v.requirements.length === 0; return v.candidate?.request === id && typeof v.prd === 'string' && Array.isArray(v.candidate.requirements) && v.candidate.requirements.length === v.requirements.length && v.requirements.every((r, i) => r && v.candidate.requirements[i] && r.id === v.candidate.requirements[i].id && r.title === v.candidate.requirements[i].title && typeof r.text === 'string'); }; const revoked = (v, args) => v && v.kind === 'revoked' && v.snapshot === args.snapshot && v.revision === args.revision + 1 && typeof v.cause === 'string'; const picker = v => Array.isArray(v) && v.every(p => p && typeof p === 'object' && typeof p.project === 'string' && typeof p.name === 'string'); // `session` is the live sign-in session object this request passed, and `live()` reads it again right // before each spawn. `requestRead(verb, args, { subject })` and `requestWrite(...)` are the console // transports; either is null when the Console has no such transport. `verbs` is the console verb // list: the draft read exists only while it names `request.draft.console`. export async function requestHTTP(req, res, { path, requestRead, requestWrite, session, live, origin, log, verbs = CONSOLE_VERBS }) { const [, raw, suffix, artifactSuffix, rawDelivery, textSuffix] = REQUEST_ROUTE.exec(path); const action = suffix ?? artifactSuffix ?? 'get'; const write = Object.hasOwn(WRITE_VERBS, action); let id = null; try { id = decodeURIComponent(raw); } catch { /* refused below */ } if (req.method !== (write ? 'POST' : 'GET')) return requestRefuse(res, 'method-not-allowed'); if (write ? !requestWrite : !requestRead) return requestRefuse(res, write ? 'read-only' : 'unavailable'); if (!session) return requestRefuse(res, 'sign-in-required'); // A write needs the Console's own Origin and same-origin fetch metadata. A read refuses another // site's, and lets a plain navigation (no origin, no metadata) through. const site = one(req, 'sec-fetch-site'), from = one(req, 'origin'); if (write ? from !== origin || site !== 'same-origin' : (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site))) return requestRefuse(res, 'cross-origin'); const user = JSON.stringify(String(session.name ?? '').slice(0, 64)); const line = tail => log(`${new Date().toISOString()} request user=${user} request=${JSON.stringify(String(id ?? '').slice(0, 64))} action=${action}${tail}`); const refuse = (code, extra = '') => { line(`${extra} error=${code}`); return requestRefuse(res, code); }; if (id === null || !ID.test(id) || (action !== 'artifact' && rawDelivery !== undefined)) return refuse('request-not-found'); if (action === 'workspace' && (req.url?.includes('?') || Number(req.headersDistinct['content-length']?.[0] ?? 0) !== 0 || req.headersDistinct['transfer-encoding'])) return refuse('invalid-request'); let delivery = null; if (action === 'artifact') { try { delivery = decodeURIComponent(rawDelivery ?? ''); } catch {} if (!delivery || !ID.test(delivery)) return refuse('request-not-found'); } const subject = { provider: session.provider, iss: session.iss, sub: session.sub }; const busCode = (err, open) => (err instanceof BusReadError ? BUS_CODES[err.code] : undefined) ?? open; if (!write) { if (action === 'artifact') { if (!verbs.includes('request.artifact.console')) return refuse('unavailable'); if (live() !== session) return refuse('sign-in-required'); let current, artifact; try { current = await requestRead('request.get.console', { request: id }, { subject }); if (!state(current, id) || current.delivery !== delivery) return refuse('request-not-found'); if (live() !== session) return refuse('sign-in-required'); artifact = await requestRead('request.artifact.console', { request: id, delivery }, { subject }); } catch (err) { return refuse(busCode(err, 'unavailable')); } if (live() !== session) return refuse('sign-in-required'); if (!artifact || artifact.mime !== 'text/html' || typeof artifact.filename !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(artifact.filename) || typeof artifact.base64 !== 'string' || artifact.base64.length > 1398104 || !/^[A-Za-z0-9+/]*={0,2}$/.test(artifact.base64) || !/^[0-9a-f]{64}$/.test(artifact.sha256 ?? '')) return refuse('unavailable'); const bytes = Buffer.from(artifact.base64, 'base64'); if (bytes.length > 1024 * 1024 || bytes.toString('base64') !== artifact.base64 || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) return refuse('unavailable'); line(''); res.writeHead(200, textSuffix ? { 'content-type': 'text/plain; charset=utf-8', 'content-security-policy': "sandbox; default-src 'none'", 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' } : { 'content-type': artifact.mime, 'content-disposition': `attachment; filename="${artifact.filename}"`, 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' }); return res.end(bytes); } const verb = READ_VERBS[action]; if (action === 'draft' && !verbs.includes(verb)) return refuse('draft-unavailable'); if (action === 'workspace' && !verbs.includes(verb)) return refuse('unavailable'); if (live() !== session) return refuse('sign-in-required'); let reply; try { reply = await requestRead(verb, { request: id }, { subject }); } catch (err) { return refuse(busCode(err, 'unavailable')); } if (action === 'workspace') { if (live() !== session) return refuse('sign-in-required'); const validName = name => typeof name === 'string' && name.length <= 4096 && name.split('/').every(p => p && p !== '.' && p !== '..' && !p.startsWith('.') && !/[\x00-\x1f\x7f\\]/.test(p)); if (!reply || Object.keys(reply).sort().join(',') !== 'available,contentAvailable,entries,observedAt,truncated' || typeof reply.available !== 'boolean' || typeof reply.truncated !== 'boolean' || reply.contentAvailable !== false || typeof reply.observedAt !== 'string' || !Number.isFinite(Date.parse(reply.observedAt)) || !Array.isArray(reply.entries) || reply.entries.length > 100 || (!reply.available && (reply.entries.length || reply.truncated)) || reply.entries.some(e => !e || Object.keys(e).join(',') !== 'name' || !validName(e.name))) return refuse('unavailable'); line(''); return json(res, 200, reply); } if (action === 'projects') { if (!picker(reply)) return refuse('unavailable'); line(''); return json(res, 200, reply.map(({ project, name, mapping_revision }) => ({ project, name, ...(mapping_revision === undefined ? {} : { mapping_revision }) }))); } if (action === 'draft' ? !draft(reply, id) : !state(reply, id)) return refuse('unavailable'); line(''); return json(res, 200, reply); } const limit = action === 'answer' ? ANSWER_BODY_MAX : BODY_MAX; const { value, code } = await readBody(req, limit); if (code) return refuse(code); const args = fields(action, value); if (!args) return refuse('invalid-request'); const tail = ` revision=${args.revision}`; if (live() !== session) return refuse('sign-in-required', tail); if (action === 'revoke') { if (!requestRead) return refuse('unavailable', tail); let binding; try { binding = await requestRead(READ_VERBS.get, { request: id }, { subject }); } catch (err) { return refuse(busCode(err, 'unavailable'), tail); } if (!state(binding, id)) return refuse('unavailable', tail); if (binding.project !== args.project || binding.snapshot !== args.snapshot) return refuse('requirement-snapshot-refused', tail); if (live() !== session) return refuse('sign-in-required', tail); } let reply; try { reply = await requestWrite(WRITE_VERBS[action], action === 'revoke' ? args : { request: id, ...args }, { subject }); } catch (err) { return refuse(busCode(err, 'outcome-unknown'), tail); } if (action === 'revoke' ? !revoked(reply, args) : !state(reply, id)) return refuse('outcome-unknown', tail); line(`${tail} outcome=ok`); return json(res, 200, reply); }