Jason's owner report 2026-11-06: no way to archive or delete projects.
Archive is the reversible half; delete stays out of scope because project
and run records are write-once evidence.
- project.archive / project.unarchive: human-only (project.create's gate
minus the tester carve-out; tester, user and agent refused). State is the
latest row of a new append-only project_archives journal (3s->3t, additive;
the rows are the audit trail), so projects itself never changes and nothing
is deleted. Repeats refuse project-already-archived / project-not-archived.
- project.list hides archived projects until {includeArchived:true}, which
flags each with archived:true; a user still lists granted projects only.
- While archived, thread.create, thread.send, room.open and room.send refuse
project-archived; every existing record stays readable and unarchive
restores everything.
- Console: new public/project-archive.js (+css) and project-archive-http.mjs
routes (chatHTTP's write fence); chat.js gains two one-line hooks,
index.html one script and one link tag; styling only through the shell's
.project-archive-control / .project-archived-filter hooks.
- CLI: mosaic project archive|unarchive <name-or-id> and list --archived.
- stack-release COUNTED/LISTED gain project_archives (schema order); schema
step-count pins and migration fixtures updated for 3t.
bus 1601/1601 (Node 24; Node 26 has the pre-existing project-file socket
stall), webui 582/582, plugins 132/132, cli 1191/1194 (two pre-existing
load flakes of the row253 C4 and land_work families; both pass isolated and
the base commit's full suite fails four of the same family).
137 lines
6.6 KiB
JavaScript
137 lines
6.6 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { request } from 'node:http';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { createChat } from '../src/chat.mjs';
|
|
import { BusReadError } from '../src/bus.mjs';
|
|
import { createSsoSession } from './helpers/sso-session.mjs';
|
|
|
|
// #797 MVP3: the Console's project archive routes (src/project-archive-http.mjs), served ahead
|
|
// of chatHTTP. Three shapes: GET /api/chat/projects/archived, POST .../archive, POST .../unarchive.
|
|
|
|
const P = '10000000-0000-4000-8000-000000000001';
|
|
const ROW = { id: P, at: '2026-11-06T00:00:00.000Z', name: 'demo', repo: null, archived: true };
|
|
|
|
async function setup(t, { enabled = true } = {}) {
|
|
const calls = [], state = { value: null };
|
|
const auth = await createSsoSession(t, { host: 'console.example' });
|
|
const chat = createChat({ call: async (verb, args) => {
|
|
if (verb === 'plugins') return { plugins: { channel: ['webui'] } };
|
|
calls.push([verb, args]);
|
|
if (state.value instanceof Error) throw state.value;
|
|
return state.value;
|
|
} });
|
|
const server = await startServer({ port: 0, sso: auth.sso, send: enabled ? async () => ({}) : null, chat, log: () => {} });
|
|
t.after(() => new Promise(r => { server.close(r); server.closeIdleConnections(); }));
|
|
const session = await auth.signIn(server.address().port), headers = auth.writeHeaders(session);
|
|
const call = (path, { method = 'GET', value, hs = headers, query = '' } = {}) => new Promise((resolve, reject) => {
|
|
const req = request({ host: '127.0.0.1', port: server.address().port, path: `/api/chat/projects/${path}${query}`, method, headers: hs }, res => {
|
|
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
|
});
|
|
req.on('error', reject); req.end(value === undefined ? undefined : JSON.stringify(value));
|
|
});
|
|
return { calls, state, call, chat, headers };
|
|
}
|
|
|
|
test('archive seam exposes the three fixed calls', async t => {
|
|
const f = await setup(t);
|
|
f.state.value = [];
|
|
await f.chat.projects({ includeArchived: true });
|
|
assert.deepEqual(f.calls.at(-1), ['project.list', { includeArchived: true }]);
|
|
await f.chat.projects();
|
|
assert.deepEqual(f.calls.at(-1), ['project.list', {}]);
|
|
f.state.value = { project: P, archived: true };
|
|
await f.chat.archiveProject({ project: P });
|
|
assert.deepEqual(f.calls.at(-1), ['project.archive', { project: P }]);
|
|
f.state.value = { project: P, archived: false };
|
|
await f.chat.unarchiveProject({ project: P });
|
|
assert.deepEqual(f.calls.at(-1), ['project.unarchive', { project: P }]);
|
|
});
|
|
|
|
test('GET archived lists flagged rows; archive and unarchive answer their receipts', async t => {
|
|
const f = await setup(t);
|
|
f.state.value = [ROW];
|
|
const list = await f.call('archived');
|
|
assert.equal(list.status, 200);
|
|
assert.deepEqual(list.body.rows, [ROW]);
|
|
assert.ok(typeof list.body.at === 'string');
|
|
assert.deepEqual(f.calls.at(-1), ['project.list', { includeArchived: true }]);
|
|
f.state.value = { project: P, archived: true };
|
|
const archived = await f.call(`${P}/archive`, { method: 'POST', value: {} });
|
|
assert.equal(archived.status, 200);
|
|
assert.equal(archived.body.project, P);
|
|
assert.equal(archived.body.archived, true);
|
|
assert.ok(typeof archived.body.at === 'string');
|
|
assert.deepEqual(f.calls.at(-1), ['project.archive', { project: P }]);
|
|
f.state.value = { project: P, archived: false };
|
|
const back = await f.call(`${P}/unarchive`, { method: 'POST', value: {} });
|
|
assert.equal(back.status, 200);
|
|
assert.deepEqual([back.body.project, back.body.archived], [P, false]);
|
|
assert.deepEqual(f.calls.at(-1), ['project.unarchive', { project: P }]);
|
|
});
|
|
|
|
test('routes enforce methods, payloads and query strictness before the transport', async t => {
|
|
const f = await setup(t);
|
|
const cases = [
|
|
['archived', { method: 'POST', value: {} }, 405],
|
|
[`${P}/archive`, {}, 405],
|
|
[`${P}/unarchive`, {}, 405],
|
|
['archived', { query: '?limit=1' }, 400],
|
|
[`${P}/archive`, { method: 'POST', value: {}, query: '?x=1' }, 400],
|
|
[`${P}/archive`, { method: 'POST', value: { project: P } }, 400],
|
|
[`${P}/archive`, { method: 'POST', value: [] }, 400],
|
|
];
|
|
for (const [path, opts, status] of cases)
|
|
assert.equal((await f.call(path, opts)).status, status, path);
|
|
assert.deepEqual(f.calls, []);
|
|
});
|
|
|
|
test('write fences refuse before the transport, like chatHTTP', async t => {
|
|
const f = await setup(t), off = await setup(t, { enabled: false });
|
|
assert.equal((await off.call(`${P}/archive`, { method: 'POST', value: {} })).status, 403);
|
|
for (const hs of [
|
|
{ ...f.headers, origin: 'https://evil.example' },
|
|
{ ...f.headers, 'sec-fetch-site': 'cross-site' },
|
|
Object.fromEntries(Object.entries(f.headers).filter(([key]) => key !== 'cookie')),
|
|
]) assert.ok([401, 403].includes((await f.call(`${P}/archive`, { method: 'POST', value: {}, hs })).status), JSON.stringify(hs));
|
|
assert.deepEqual(f.calls, []);
|
|
assert.deepEqual(off.calls, []);
|
|
});
|
|
|
|
test('known refusals keep their codes, fixed messages and never transport prose', async t => {
|
|
const f = await setup(t);
|
|
const cases = [
|
|
['human-required', 403, `${P}/archive`],
|
|
['project-not-found', 404, `${P}/archive`],
|
|
['project-already-archived', 409, `${P}/archive`],
|
|
['project-not-archived', 409, `${P}/unarchive`],
|
|
];
|
|
for (const [code, status, path] of cases) {
|
|
f.state.value = new BusReadError(code, 'private transport reason');
|
|
const r = await f.call(path, { method: 'POST', value: {} });
|
|
assert.equal(r.status, status, code);
|
|
assert.equal(r.body.error, code);
|
|
assert.equal(r.body.sent, false);
|
|
assert.ok(r.body.message.length > 0);
|
|
assert.equal(JSON.stringify(r.body).includes('private transport reason'), false);
|
|
}
|
|
assert.equal(f.calls.length, cases.length);
|
|
});
|
|
|
|
test('malformed or lost receipts are outcome-unknown and never retried', async t => {
|
|
const f = await setup(t);
|
|
for (const value of [null, { project: P }, { project: P, archived: null }, { project: P, archived: 'yes' }, new Error('socket closed')]) {
|
|
f.state.value = value;
|
|
const before = f.calls.length, r = await f.call(`${P}/archive`, { method: 'POST', value: {} });
|
|
assert.equal(r.status, 503);
|
|
assert.equal(r.body.error, 'outcome-unknown');
|
|
assert.equal(r.body.sent, 'unknown');
|
|
assert.equal(f.calls.length, before + 1);
|
|
}
|
|
// A malformed list read answers invalid-response, also without prose.
|
|
f.state.value = [{ id: 'bad' }];
|
|
assert.equal((await f.call('archived')).status, 502);
|
|
f.state.value = new BusReadError('outcome-unknown');
|
|
assert.equal((await f.call('archived')).body.error, 'invalid-response');
|
|
});
|