- Edit and archive require the digest the board read (expect); the route refuses a write without it. - The edit dialog saves against the task it showed, so a poll that reads a newer task turns a stale save into task-conflict; the dialog reopens on the current task. - An answer the board cannot read, or one marked sent=unknown, is shown as an open outcome, not as a refusal. - The per-org cap applies after the filters and keeps the most recently changed tasks; projects, metrics and the inbox count every task. The board sends project, state, assignee and label filters to the server. - A poll keeps the focus, caret and scroll the person has at the moment it answers, not when it started. - An org whose tasks read fails shows "tasks not read" and is not writable. - browser.test.mjs pins tasks-board.js in the script order.
134 lines
9.2 KiB
JavaScript
134 lines
9.2 KiB
JavaScript
// U4b (#1873, PRD 1.20): the task board's writes, POST /api/workspace/tasks.
|
|
//
|
|
// One write goes to one organization's bus. The body names the org; the org is resolved for this
|
|
// session through the same admission the org pages use (organizations.mjs `resolve`), and the write
|
|
// goes out on that org's own transport (`taskWrite`, console-bus.mjs) and nowhere else. The verbs are
|
|
// the person's built-in store verbs (tasks.create, tasks.edit, tasks.archive): the bus runs its own
|
|
// human check on every one, so a Console started by an agent gets human-required and writes nothing.
|
|
//
|
|
// The route table's hook fence has already required a signed-in session on the page's own name, the
|
|
// exact Origin and Sec-Fetch-Site: same-origin. This handler adds the owner, --send and a strict JSON
|
|
// body. A refusal is answered with the bus's code and a fixed message; nothing is retried.
|
|
import { actor } from './action-trail.mjs';
|
|
import { TASK_STATES } from './workspace-read.mjs';
|
|
|
|
export const TASK_WRITE_ROUTE = '/api/workspace/tasks';
|
|
const MAX_BODY = 16384;
|
|
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
const STORE_REF = /^task:[1-9][0-9]{0,14}$/;
|
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
const REQUIREMENT = /^REQ-[A-Z]+-[1-9][0-9]*$/;
|
|
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
const DUE = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/;
|
|
// Each op: its bus verb, the keys it takes and the ones it needs. The board edits title, state,
|
|
// assignees, priority and due date; a create also names the requirement and the project (group).
|
|
const OPS = {
|
|
create: { verb: 'tasks.create', keys: ['title', 'requirement', 'group', 'state', 'assignees', 'priority', 'due_date'], need: ['title', 'requirement'] },
|
|
// An edit or archive names the digest the board read, so a change made since is a task-conflict
|
|
// and never a silent overwrite. The bus checks it; the route refuses a write without one.
|
|
edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref', 'expect'] },
|
|
archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref', 'expect'] },
|
|
};
|
|
const FIELD_CHECKS = {
|
|
task_ref: v => typeof v === 'string' && STORE_REF.test(v),
|
|
expect: v => typeof v === 'string' && DIGEST.test(v),
|
|
title: v => typeof v === 'string' && v.trim().length >= 1 && v.length <= 250 && !v.includes('\0'),
|
|
requirement: v => typeof v === 'string' && REQUIREMENT.test(v),
|
|
group: v => v === null || (typeof v === 'string' && v.length >= 1 && v.length <= 64 && !/[\u0000-\u001f\u007f]/.test(v)),
|
|
state: v => TASK_STATES.includes(v),
|
|
assignees: v => Array.isArray(v) && v.length <= 50 && v.every(r => typeof r === 'string' && ROLE_NAME.test(r)),
|
|
priority: v => Number.isSafeInteger(v) && v >= 0 && v <= 5,
|
|
due_date: v => v === null || (typeof v === 'string' && DUE.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v),
|
|
};
|
|
|
|
// The answers a person reads on the board, by bus code. Each says whether anything was written.
|
|
const REFUSALS = {
|
|
'human-required': [403, 'The bus takes task changes only from a person at the Console. Nothing was written.'],
|
|
'tracker-not-store': [409, 'This organization keeps its tasks in Vikunja. The board does not write to Vikunja; nothing was written.'],
|
|
'task-conflict': [409, 'The task changed after the board read it. Nothing was written; the board now shows the current task.'],
|
|
'task-archived': [409, 'The task is archived. Nothing was written.'],
|
|
'task-already-archived': [409, 'The task was already archived. Nothing was written.'],
|
|
'task-not-found': [404, 'The task is not on this organization\'s bus. Nothing was written.'],
|
|
'task-position-full': [409, 'This project has no room for another task position. Nothing was written.'],
|
|
'invalid-state': [400, 'The bus does not have that state. Nothing was written.'],
|
|
'unknown-role': [400, 'An assignee is not a role in this organization. Nothing was written.'],
|
|
'invalid-request': [400, 'The bus refused the change as invalid. Nothing was written.'],
|
|
'credential-leak': [400, 'The change holds part of a stored credential, so the bus refused it. Nothing was written.'],
|
|
'no-bus-host': [503, 'This organization\'s bus is not running. Nothing was written.'],
|
|
'outcome-unknown': [503, 'The bus did not answer. The change may or may not have been written; the board shows it on its next read.'],
|
|
};
|
|
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large', 'outcome-unknown-revoked'];
|
|
|
|
const json = (res, status, body) => {
|
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
};
|
|
const one = (req, name) => (req.headersDistinct?.[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
|
class BodyError extends Error { constructor(status, message) { super(message); this.status = status; } }
|
|
|
|
async function readBody(req) {
|
|
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) throw new BodyError(415, 'JSON body required');
|
|
let size = 0;
|
|
const chunks = [];
|
|
for await (const chunk of req) {
|
|
size += chunk.length;
|
|
if (size > MAX_BODY) throw new BodyError(413, 'body too large');
|
|
chunks.push(chunk);
|
|
}
|
|
let value;
|
|
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new BodyError(400, 'invalid JSON'); }
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BodyError(400, 'the body is an object');
|
|
return value;
|
|
}
|
|
|
|
// Strict: an unknown key, a missing required key or a malformed value is refused before any bus.
|
|
export function parseTaskWrite(body) {
|
|
const { op, org, ...rest } = body;
|
|
const spec = OPS[op];
|
|
if (!spec) throw new BodyError(400, `op takes ${Object.keys(OPS).join(', ')}`);
|
|
if (typeof org !== 'string' || !ORG_ID.test(org)) throw new BodyError(400, 'org takes an organization id');
|
|
for (const key of Object.keys(rest)) if (!spec.keys.includes(key)) throw new BodyError(400, `${op} does not take ${key}`);
|
|
for (const key of spec.need) if (!Object.hasOwn(rest, key)) throw new BodyError(400, `${op} needs ${key}`);
|
|
for (const [key, value] of Object.entries(rest)) if (!FIELD_CHECKS[key](value)) throw new BodyError(400, `${key} is not valid`);
|
|
if (op === 'edit' && !Object.keys(rest).some(k => !['task_ref', 'expect'].includes(k))) throw new BodyError(400, 'edit needs a field to change');
|
|
return { op, org, verb: spec.verb, args: rest };
|
|
}
|
|
|
|
// `organizations.resolve(id, session)` admits only an org this session belongs to and answers its
|
|
// options, `taskWrite` among them. Nothing here holds another org's transport.
|
|
export async function workspaceTasksHTTP(req, res, { session = null, owner = false, send = false, organizations = null, trail = null, log = () => {} }) {
|
|
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
|
if (!organizations || typeof organizations.resolve !== 'function') return json(res, 404, { error: 'org-unavailable' });
|
|
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'the task board\'s changes are the owner\'s' });
|
|
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
|
|
let write;
|
|
try { write = parseTaskWrite(await readBody(req)); }
|
|
catch (e) { if (e instanceof BodyError) return json(res, e.status, { error: 'invalid-request', message: e.message, sent: false }); throw e; }
|
|
let selected;
|
|
try { selected = await organizations.resolve(write.org, session); }
|
|
catch (e) {
|
|
if (e?.code === 'org-archived') return json(res, 409, { error: 'org-archived', message: 'organization is archived', sent: false });
|
|
return json(res, 404, { error: 'org-unavailable', sent: false });
|
|
}
|
|
if (!selected) return json(res, 404, { error: 'org-unavailable', sent: false });
|
|
if (selected.busHealthy === false) return json(res, 503, { error: 'no-bus-host', message: REFUSALS['no-bus-host'][1], sent: false });
|
|
if (typeof selected.taskWrite !== 'function') return json(res, 403, { error: 'read-only', message: 'this organization takes no task changes from this Console', sent: false });
|
|
const note = outcome => {
|
|
try { trail?.({ by: actor(session), org: write.org, action: write.verb, ...(write.args.task_ref ? { task: write.args.task_ref } : {}), outcome }); }
|
|
catch { log(`${new Date().toISOString()} trail write failed for ${write.verb}`); }
|
|
};
|
|
let result;
|
|
try { result = await selected.taskWrite(write.verb, write.args); }
|
|
catch (e) {
|
|
const code = typeof e?.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'outcome-unknown';
|
|
const open = OPEN.includes(code);
|
|
const [status, message] = REFUSALS[open ? 'outcome-unknown' : code] ?? [502, `The bus refused the change (${code}). Nothing was written.`];
|
|
log(`${new Date().toISOString()} task board: ${write.verb} on ${write.org} refused ${code}`);
|
|
note(open ? 'unknown' : `refused ${code}`);
|
|
return json(res, status, { error: open ? 'outcome-unknown' : code, message, sent: open ? 'unknown' : false });
|
|
}
|
|
note('written');
|
|
const task = result && typeof result === 'object' && typeof result.task_ref === 'string' ? result.task_ref : write.args.task_ref ?? null;
|
|
return json(res, 200, { ok: true, org: write.org, op: write.op, task_ref: task, digest: typeof result?.digest === 'string' ? result.digest : null });
|
|
}
|