Unify halt and fire cancellation by verified run identity with durable first-owner receipts and shared deadlines. Integrate the instance control with the current host, library and tester-socket paths. Focused nine-file evidence is 62 passing tests; broad suites, mutants and review remain pending.
51 lines
3.6 KiB
JavaScript
51 lines
3.6 KiB
JavaScript
// Instance control uses the existing management transport, independently of channels.
|
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
|
|
const json = (res, status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); };
|
|
const errors = { 'human-required': 403, 'read-only': 403, 'processing-state-invalid': 409,
|
|
'processing-control-unsafe': 409, 'processing-uninitialized': 409, 'processing-stop-pending': 409,
|
|
'processing-generation-changed': 409, 'not-configured': 503, 'outcome-unknown': 503 };
|
|
function answer(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
![true, false, null].includes(value.halted) || (value.invalid !== true &&
|
|
(value.version !== 1 || typeof value.generation !== 'string' || !UUID.test(value.generation))) ||
|
|
(value.quiescent !== undefined && typeof value.quiescent !== 'boolean')) throw Error('invalid receipt');
|
|
return Object.fromEntries(['version', 'generation', 'halted', 'invalid', 'code', 'at', 'quiescent', 'remaining',
|
|
'stopped', 'stopping', 'recovered'].filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]]));
|
|
}
|
|
export async function processingHTTP(req, res, { path, search, processing, named, send, preview }) {
|
|
const write = req.method === 'POST';
|
|
const refuse = (status, code, sent = false) => json(res, status, { error: code,
|
|
message: code === 'outcome-unknown' ? 'Outcome unknown. Read processing status. Nothing was retried.' : 'Processing control refused.',
|
|
...(write ? { sent } : {}) });
|
|
if (req.method !== 'GET' && !write) return refuse(405, 'method-not-allowed');
|
|
if (search) return refuse(400, 'invalid-request');
|
|
if (!write && path !== '/api/processing') return refuse(405, 'method-not-allowed');
|
|
let action = 'read', args = {};
|
|
if (write) {
|
|
if (!send || !named || !preview) return refuse(403, 'read-only');
|
|
if (preview.sso !== true && one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
|
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin');
|
|
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
|
|
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return refuse(415, 'invalid-request');
|
|
if (!['/api/processing/halt', '/api/processing/resume'].includes(path)) return refuse(404, 'not-found');
|
|
action = path.endsWith('/halt') ? 'halt' : 'resume';
|
|
try {
|
|
let size = 0; const chunks = [];
|
|
for await (const chunk of req) { size += chunk.length; if (size > 1024) throw Error(); chunks.push(chunk); }
|
|
args = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
if (!args || typeof args !== 'object' || Array.isArray(args)) throw Error();
|
|
if (action === 'halt' ? Object.keys(args).length !== 0 :
|
|
Object.keys(args).length !== 1 || typeof args.generation !== 'string' || !UUID.test(args.generation)) throw Error();
|
|
} catch { return refuse(400, 'invalid-request'); }
|
|
}
|
|
if (typeof processing?.[action] !== 'function') return refuse(503, 'not-configured');
|
|
try {
|
|
const value = answer(await processing[action](args));
|
|
return json(res, write && value.quiescent === false ? 202 : 200, value);
|
|
} catch (e) {
|
|
const code = Object.hasOwn(errors, e.code) ? e.code : 'outcome-unknown';
|
|
return refuse(errors[code], code, code === 'outcome-unknown' ? 'unknown' : false);
|
|
}
|
|
}
|