Files
stack/packages/webui/src/request-http.mjs
T

248 lines
18 KiB
JavaScript

// #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);
}