Re-check finding 1 (P2): the wording for refusals that can come after the decision is consumed told the user to look for the proposal "as answered" in the inbox and otherwise read "nothing was applied". The bus inbox lists no closed decision, so a consumed one drops out of it and the user would read the false claim. Those codes now say only that part of it may already be applied and to check the Agents page. decision-http tests assert no "nothing was applied" in any case and no inbox pointer for those codes. Re-check P3-b: the autonomy controls (Save, Discard, notices, review summary and buttons) carry data-focus-key, so a focused button in a clean form gets its focus back after the poll repaints the section. Re-check P3-c: a failed autonomy read keeps the last read only while the page holds the form (dirty, or focus inside it). A clean form shows the read error. org-live-edit-browser test 4 covers both. Re-check P3-a (harness-off after the consume through a corrupt prior settings file) is deferred: wording it exactly needs the bus to mark the phase on the refusal.
327 lines
25 KiB
JavaScript
327 lines
25 KiB
JavaScript
// #797 / #1839 extends row 252's signed-in Console answers to ordinary decisions. Two POSTs.
|
|
// /confirmation asks the bus for authoritative answers and digest and returns a one-use
|
|
// confirmation bound to this session, business,
|
|
// decision, choice and digest. /resolve takes only that choice and confirmation, consumes the
|
|
// confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus
|
|
// rechecks everything in the resolve transaction. Nothing here retries.
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { BusReadError, subjectOk } from './bus.mjs';
|
|
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
|
|
|
|
export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve|start|templates)$/;
|
|
// The endpoint contract, design section 4. Each refusal is one of these, never a 200.
|
|
export const DECISION_REFUSALS = Object.freeze({
|
|
'sign-in-required': [401, 'Sign in again before answering.'],
|
|
'read-only': [403, 'Console decision answers are not enabled. Use mosaic decide.'],
|
|
'cross-origin': [403, 'Answer 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 POST.'],
|
|
'invalid-request': [400, 'Invalid decision answer.'],
|
|
'request-too-large': [413, 'Decision answer is too large.'],
|
|
'decision-not-found': [404, 'This decision is unavailable.'],
|
|
'decision-not-eligible': [403, 'Answer this decision with mosaic decide.'],
|
|
'decision-closed': [409, 'This decision is no longer open. Refresh the inbox.'],
|
|
'hire-owner-required': [403, 'Only the owner may approve these grants or a root agent.'],
|
|
'right-required': [403, 'This decision needs org.hire.'],
|
|
'decision-expired': [409, 'This decision expired. Ask for a fresh proposal.'],
|
|
'hire-resolution-required': [403, 'Use the hire approval path.'],
|
|
'decision-stale': [409, 'The choices changed. Review the decision again.'],
|
|
'confirmation-required': [409, 'Review and confirm this answer again.'],
|
|
'confirmation-unavailable': [503, 'Confirmation is unavailable. Try again later.'],
|
|
'outcome-unknown': [503, 'The answer may have reached the bus. Refresh 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 confirmation-unavailable at the preview and outcome-unknown at the
|
|
// resolve, which the page reads as "refresh first".
|
|
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',
|
|
'decision-not-found': 'decision-not-found', 'decision-not-eligible': 'decision-not-eligible',
|
|
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
|
|
'invalid-request': 'invalid-request', 'hire-owner-required':'hire-owner-required','right-required':'right-required','decision-expired':'decision-expired','hire-resolution-required':'hire-resolution-required',
|
|
};
|
|
// D4 (#1873, P1-2) and E2E6 P1-2: a hire, fire or change start the bus definitely refused keeps its
|
|
// own code and a next step. hire.start.console runs prepare() (hire-decisions.mjs) before it records
|
|
// the decision, then #hireConsole checks it, consumes it, and applies it. apply() runs check() again
|
|
// and the writers, authorityHost and #fireApproved run after the consume, so most codes can come from
|
|
// either side of it; for a fire the agent's runs may already be stopped. The code alone does not say
|
|
// which, and the inbox cannot tell either: it lists no closed decision (broker inbox verb), so a
|
|
// consumed one simply drops out of it. Those codes therefore say part of the change may already be
|
|
// applied and send the user to the Agents page (re-check finding 1). Only codes
|
|
// that prepare() alone raises say "Nothing was applied.": the route checks (checkRoutes runs in
|
|
// prepare(), and in adjusted() only for an adjust, which a start never carries), definition-shared,
|
|
// definition-not-hireable and no-chief-of-staff. organization-uncertain, a lost reply and the
|
|
// bus server's own request codes (request-timeout and invalid-envelope can fire while the request is
|
|
// processing) stay outcome-unknown.
|
|
const NONE = ' Nothing was applied.';
|
|
const EITHER = ' Part of it may already be applied: check the Agents page before trying again.';
|
|
const CHECK = ' Check the Agents page and the inbox before trying again.';
|
|
const HIRE_START_REFUSALS = {
|
|
'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy.' + NONE],
|
|
'model-catalogue-unavailable': [503, 'The model catalogue could not be read, so this model could not be checked. Check Settings > Model proxy, then try again.' + NONE],
|
|
'model-harness-incompatible': [409, 'This model does not run on the chosen harness. Pick another model or harness.' + NONE],
|
|
'thinking-unsupported': [409, 'The chosen harness does not apply this thinking level. Clear Thinking or pick a level it applies.' + NONE],
|
|
'unknown-model': [409, 'This model is not one of the organization\'s models. Pick a model listed under Settings > Model proxy.' + NONE],
|
|
'harness-off': [409, 'This harness is not switched on for the organization. Pick a harness listed under Settings > Routing, or ask the owner to switch it on.' + NONE],
|
|
'harness-refused': [409, 'The harness refused this route. Clear Thinking, or pick another model or harness under Settings > Routing.' + NONE],
|
|
'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template.' + NONE],
|
|
'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it.' + NONE],
|
|
'definition-rev-conflict': [409, 'The role definition changed since it was read. Reload the page and propose again.' + EITHER],
|
|
'definition-missing': [409, 'The agent\'s role definition is missing from the organization file. Check the Agents page.' + EITHER],
|
|
'definition-secret-check-required': [503, 'The bus cannot check role text for secrets right now. Ask the owner to check the organization bus.' + EITHER],
|
|
'credential-leak': [409, 'The proposal carries a value the credential store holds. Remove it from the role text or the reason.' + EITHER],
|
|
'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it.' + NONE],
|
|
'project-agents-invalid': [409, 'That project is not a code project of this organization. Pick a code project from Projects; a library project has no agents.' + EITHER],
|
|
'organization-unknown-instance': [409, 'No agent has that name at this level. Check the name on the Agents page.' + EITHER],
|
|
'organization-instance-exists': [409, 'An agent already has that name at this level. Pick another name, or propose a change to that agent.' + EITHER],
|
|
'organization-instance-retired': [409, 'That name belonged to a fired agent and cannot be reused. Pick another name.' + EITHER],
|
|
'organization-instance-referenced': [409, 'Another part of the organization still names this agent. Remove those references first.' + EITHER],
|
|
'instance-kept': [409, 'This agent is kept by the organization and cannot be fired or replaced this way.' + EITHER],
|
|
'root-fire-shared': [409, 'This root agent is shared with other organizations, so it cannot be fired from here.' + EITHER],
|
|
'decision-stale': [409, 'The role template or the organization changed since it was read. Reload the templates and propose again.' + EITHER],
|
|
'organization-refused': [409, 'The organization file refused this change. Check the role and route against the organization checks.' + EITHER],
|
|
'not-configured': [503, 'This Console\'s bus has no hire support configured. Ask the owner to check the organization bus.' + EITHER],
|
|
'organization-busy': [409, 'The organization is busy with another write.' + CHECK],
|
|
'fire-in-progress': [409, 'A fire of this agent is already in progress.' + CHECK],
|
|
'instance-fired': [409, 'This agent was fired.' + CHECK],
|
|
'copy-unavailable': [409, 'This copy is not available for credentials.' + CHECK],
|
|
'copy-refused': [409, 'The copy refused this credential change.' + CHECK],
|
|
'copy-stale': [409, 'The copy changed since it was read. Reload the Agents page.' + CHECK],
|
|
'root-policy-unreadable': [503, 'The root policy could not be read.' + CHECK],
|
|
'credential-definition-unpinned': [409, 'The copy\'s role definition is not pinned, so credentials cannot be bound to it.' + CHECK],
|
|
'credential-binding-changed': [409, 'The copy\'s credential binding changed since it was read.' + CHECK],
|
|
'credential-role-changed': [409, 'The copy\'s role changed since it was read.' + CHECK],
|
|
'credential-unbound': [409, 'The copy has no credential binding to remove.' + CHECK],
|
|
'credential-revoking': [409, 'The copy\'s credential is being revoked.' + CHECK],
|
|
'credential-scope-exceeded': [409, 'The requested credential scope is wider than the role allows.' + CHECK],
|
|
// The fields passed the shape check below, so a malformed answer here is a value the organization
|
|
// checks refused: most often a model that is not a launch.models key.
|
|
'invalid-request': [400, 'The bus refused a value in this proposal. Pick a model listed under Settings > Model proxy and a role the organization checks accept.' + EITHER],
|
|
};
|
|
// A reply the endpoint cannot read as a definite answer: no answer, or the bus server's request
|
|
// codes. Anything else that reads as a code is a definite refusal.
|
|
const NO_ANSWER = new Set(['outcome-unknown', 'invalid-response', 'response-too-large', 'organization-uncertain', 'request-timeout', 'invalid-envelope', 'incomplete-request', 'revision-conflict-revoked']);
|
|
const CODE = /^[a-z][a-z-]{0,63}$/;
|
|
const definiteStart = err => err instanceof BusReadError && typeof err.code === 'string' && CODE.test(err.code) &&
|
|
!NO_ANSWER.has(err.code) && !err.code.startsWith('outcome-unknown') ? err.code : null;
|
|
// E2E6 P2-4: the start's own shape check, the structural rules prepare() applies, so a malformed
|
|
// proposal names its field and rule before anything leaves for the bus. A binding request keeps the
|
|
// bus's own check (its shape lives in copy-bindings.mjs).
|
|
const HIRE_KEYS = ['op', 'level', 'project', 'name', 'role', 'credentials', 'personality', 'route', 'reason', 'binding'];
|
|
const plain = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
const idOk = x => typeof x === 'string' && ID_PATTERN.test(x) && !['human', 'tester', 'user'].includes(x);
|
|
const ID_RULE = 'lowercase letters, digits, dot, dash or underscore, starting with a letter or digit, at most 64 characters, and not human, tester or user';
|
|
export function hireProblem(hire) {
|
|
if (!plain(hire)) return { field: 'hire', rule: 'must be an object' };
|
|
const extra = Object.keys(hire).find(k => !HIRE_KEYS.includes(k));
|
|
if (extra) return { field: extra, rule: 'is not a hire field' };
|
|
if ('binding' in hire) return null;
|
|
for (const k of ['op', 'level', 'name', 'reason']) if (!Object.hasOwn(hire, k)) return { field: k, rule: 'is required' };
|
|
if (!['hire', 'fire', 'change'].includes(hire.op)) return { field: 'op', rule: 'must be hire, fire or change' };
|
|
if (!['org', 'project', 'root'].includes(hire.level)) return { field: 'level', rule: 'must be org, project or root' };
|
|
if (!idOk(hire.name)) return { field: 'name', rule: `must be ${ID_RULE}` };
|
|
if (typeof hire.reason !== 'string' || !hire.reason.trim()) return { field: 'reason', rule: 'must not be blank' };
|
|
if (hire.reason.length > 500) return { field: 'reason', rule: 'must be at most 500 characters' };
|
|
if (hire.level === 'project' && !idOk(hire.project)) return { field: 'project', rule: 'must name a code project at project level' };
|
|
if (hire.level !== 'project' && 'project' in hire) return { field: 'project', rule: 'is only for project level' };
|
|
if (hire.op === 'fire') {
|
|
const k = ['role', 'route', 'credentials', 'personality'].find(x => x in hire);
|
|
return k ? { field: k, rule: 'is not used by fire' } : null;
|
|
}
|
|
const route = hire.route;
|
|
if (!plain(route)) return { field: 'route', rule: 'must name a harness and a model' };
|
|
const rk = Object.keys(route).find(k => !['harness', 'model', 'thinking'].includes(k));
|
|
if (rk) return { field: `route.${rk}`, rule: 'is not a route field' };
|
|
for (const k of ['harness', 'model']) if (!Object.hasOwn(route, k)) return { field: `route.${k}`, rule: 'is required' };
|
|
for (const [k, v] of Object.entries(route)) if (typeof v !== 'string' || !v || v.length > 200) return { field: `route.${k}`, rule: 'must be text of 1 to 200 characters' };
|
|
const role = hire.role;
|
|
if (!plain(role)) return { field: 'role', rule: 'must name a role template' };
|
|
const kinds = ['template', 'definition', 'inline'].filter(k => k in role);
|
|
if (kinds.length !== 1) return { field: 'role', rule: 'must name exactly one of a template, a definition or an inline role' };
|
|
const ok = { template: ['template', 'sha256'], definition: ['definition', 'rev'], inline: ['inline'] }[kinds[0]];
|
|
const rx = Object.keys(role).find(k => !ok.includes(k));
|
|
if (rx) return { field: `role.${rx}`, rule: `is not used with role.${kinds[0]}` };
|
|
if (kinds[0] === 'template') {
|
|
if (!idOk(role.template)) return { field: 'role.template', rule: `must be ${ID_RULE}` };
|
|
if (!DIGEST.test(role.sha256 ?? '')) return { field: 'role.sha256', rule: 'must be the template\'s 64-character digest; read the templates again' };
|
|
}
|
|
if (kinds[0] === 'definition') {
|
|
if (!idOk(role.definition)) return { field: 'role.definition', rule: `must be ${ID_RULE}` };
|
|
if (!Number.isSafeInteger(role.rev)) return { field: 'role.rev', rule: 'must be a whole number' };
|
|
}
|
|
return null;
|
|
}
|
|
// #1873 CB5: the owner's next step once a copy credential binding decision is approved, fixed text
|
|
// (design section 6 and its three steps). Nothing in it comes from the decision, the bus or a token.
|
|
export const BINDING_NEXT = Object.freeze({
|
|
bind: "Binding approved. Issue the copy's token with mosaic credentials issue, then restart the organization bus. The copy's status on the Agents grid names each next step.",
|
|
unbind: "Unbind approved. The bus stops the copy's runs and revokes its credential. The copy's status on the Agents grid shows when the revoke finishes.",
|
|
});
|
|
// A binding decision's preview names its copy and whether it binds or unbinds. Only those two leave
|
|
// this server: the copy id, incarnation, definition pin and services stay with the bus. The owner's
|
|
// preview verb is the one bus answer that carries them; the inbox and the trail send the same two.
|
|
const bindingOf = (v) => (v?.binding && typeof v.binding === 'object' && subjectOk(v.binding.instance) &&
|
|
(v.binding.after === null || (typeof v.binding.after === 'object' && !Array.isArray(v.binding.after)))
|
|
? { instance: v.binding.instance, change: v.binding.after === null ? 'unbind' : 'bind' } : null);
|
|
const BODY_MAX = 4096;
|
|
const TOKEN = /^[a-f0-9]{64}$/;
|
|
const DIGEST = /^[a-f0-9]{64}$/;
|
|
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
|
const hash = (token) => createHash('sha256').update(token).digest('hex');
|
|
export function decisionRefuse(res, code) {
|
|
const [status, message] = DECISION_REFUSALS[code];
|
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
res.end(JSON.stringify({ error: code, message }));
|
|
}
|
|
|
|
// The confirmations, in this process's memory only and stored by hash. 120 seconds, at most 1024
|
|
// live. A full store refuses a new one rather than drop a live one. An expired one is swept.
|
|
export function createConfirmations({ ttlMs = 120000, max = 1024, now = Date.now } = {}) {
|
|
const live = new Map();
|
|
const sweep = () => { const t = now(); for (const [k, v] of live) if (v.expires <= t) live.delete(k); };
|
|
return {
|
|
issue(binding) {
|
|
sweep();
|
|
if (live.size >= max) return null;
|
|
const token = randomBytes(32).toString('hex');
|
|
live.set(hash(token), { ...binding, expires: now() + ttlMs });
|
|
return token;
|
|
},
|
|
// One use: a token found is gone, whether or not it matches.
|
|
consume(token, { session, decision, choice }) {
|
|
sweep();
|
|
if (typeof token !== 'string' || !TOKEN.test(token)) return null;
|
|
const k = hash(token), v = live.get(k);
|
|
if (!v) return null;
|
|
live.delete(k);
|
|
return v.session === session && v.decision === decision && v.choice === choice ? v : null;
|
|
},
|
|
get size() { sweep(); return live.size; },
|
|
};
|
|
}
|
|
|
|
async function readBody(req, max = BODY_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' }; }
|
|
}
|
|
const option = o => o && typeof o.key === 'string' && o.key.length > 0 && typeof o.text === 'string' && o.text.length > 0;
|
|
const options = list => Array.isArray(list) && list.length >= 2 && list.length <= 9 && list.every(option) && new Set(list.map(o => o.key)).size === list.length;
|
|
function preview(v, id) {
|
|
if (!v || v.id !== id || typeof v.business !== 'string' || !DIGEST.test(v.digest ?? '')) return false;
|
|
if(v.kind==='hire')return options(v.options) && Array.isArray(v.flagged) && Array.isArray(v.credentialLines) && typeof v.expiresAt==='string';
|
|
if (v.kind === 'decision') return options(v.options) && !['grant', 'approve', 'deny'].some(k => k in v);
|
|
// Retain the existing grant preview fields, including previews from an older pinned broker.
|
|
return (v.kind === undefined || v.kind === 'grant') && option(v.approve) && option(v.deny) &&
|
|
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string' &&
|
|
(v.options === undefined || (options(v.options) && v.options.length === 2 &&
|
|
[v.approve, v.deny].every(o => v.options.some(x => x.key === o.key && x.text === o.text))));
|
|
}
|
|
|
|
// `session` is the live sign-in session object this request passed, and `live()` reads it again
|
|
// right before each spawn. `call(verb, args, { subject, business })` is the console transport.
|
|
export async function decisionHTTP(req, res, { path, decide, session, live, confirmations, log }) {
|
|
const [, raw, step] = DECISION_ROUTE.exec(path);
|
|
let id = null;
|
|
try { id = decodeURIComponent(raw); } catch { /* refused below */ }
|
|
if (req.method !== 'POST') return decisionRefuse(res, 'method-not-allowed');
|
|
if (!decide) return decisionRefuse(res, 'read-only');
|
|
if (!session) return decisionRefuse(res, 'sign-in-required');
|
|
if (one(req, 'origin') !== decide.origin || one(req, 'sec-fetch-site') !== 'same-origin') return decisionRefuse(res, 'cross-origin');
|
|
const line = (outcome) => log(`${new Date().toISOString()} decide user=${JSON.stringify(String(session.name ?? '').slice(0, 64))} decision=${JSON.stringify(String(id ?? '').slice(0, 64))} step=${step} ${outcome}`);
|
|
const refuse = (code) => { line(`error=${code}`); return decisionRefuse(res, code); };
|
|
const { value, code } = await readBody(req, step==='start'?32768:BODY_MAX);
|
|
if (code) return refuse(code);
|
|
if (!subjectOk(id)) return refuse('decision-not-found');
|
|
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
|
|
const busCode = (err, open) => {
|
|
const c = err instanceof BusReadError ? BUS_CODES[err.code] : undefined;
|
|
return c ?? open;
|
|
};
|
|
if(step==='start' || step==='templates') {
|
|
const allowed=step==='start'?['business','hire']:['business'];
|
|
if(id!=='hire' || Object.keys(value).length!==allowed.length || Object.keys(value).some(k=>!allowed.includes(k)) || !subjectOk(value.business))return refuse('invalid-request');
|
|
if(step==='start') {
|
|
const problem=hireProblem(value.hire);
|
|
if(problem) {
|
|
const label=problem.field[0].toUpperCase()+problem.field.slice(1);
|
|
line(`error=invalid-request field=${problem.field}`);
|
|
return json(res,400,{error:'invalid-request',field:problem.field,rule:problem.rule,message:`${label} ${problem.rule}. Nothing was sent to the bus.`});
|
|
}
|
|
}
|
|
if(live()!==session)return refuse('sign-in-required');
|
|
try {
|
|
const result=await decide.call(step==='start'?'hire.start.console':'hire.templates.console',step==='start'?value.hire:{},{subject,business:value.business});
|
|
if(step==='start' && (typeof result?.id!=='string'||result.op!=='resolved'||result.choice!=='yes'))return refuse('outcome-unknown');
|
|
if(step==='templates' && !Array.isArray(result?.templates))return refuse('confirmation-unavailable');
|
|
line(step==='start'?'hire=recorded':'templates=read');
|
|
// CB5: an approved binding start says what the owner does next, fixed text by its shape.
|
|
const change=step==='start' && value.hire?.binding && typeof value.hire.binding==='object' && 'services' in value.hire.binding
|
|
? (value.hire.binding.services===null?'unbind':'bind') : null;
|
|
return json(res,200,step==='start'?{id:result.id,op:result.op,choice:result.choice,...(change?{next:BINDING_NEXT[change]}:{})}:result);
|
|
} catch(err) {
|
|
if(step!=='start')return refuse(busCode(err,'confirmation-unavailable'));
|
|
const code=definiteStart(err);
|
|
if(!code)return refuse('outcome-unknown');
|
|
if(Object.hasOwn(HIRE_START_REFUSALS,code)) {
|
|
const [status,message]=HIRE_START_REFUSALS[code];
|
|
line(`error=${code}`);
|
|
return json(res,status,{error:code,message});
|
|
}
|
|
if(Object.hasOwn(BUS_CODES,code))return refuse(BUS_CODES[code]);
|
|
// A definite refusal this page has no wording for keeps its code, with a safe next step.
|
|
line(`error=${code}`);
|
|
return json(res,409,{error:code,message:'The bus refused this proposal.'+CHECK});
|
|
}
|
|
}
|
|
if (step === 'confirmation') {
|
|
if (Object.keys(value).some(k=>!['choice','adjust'].includes(k)) || typeof value.choice !== 'string') return refuse('invalid-request');
|
|
if (live() !== session) return refuse('sign-in-required');
|
|
let v;
|
|
try { v = await decide.call(value.adjust===undefined?'decision.preview.console':'hire.preview.console', { id,...(value.adjust===undefined?{}:{adjust:value.adjust}) }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); }
|
|
if (!preview(v, id)) return refuse('confirmation-unavailable');
|
|
const ordinary = v.kind === 'decision' || v.kind === 'hire';
|
|
const chosen = (ordinary ? v.options : [v.approve, v.deny]).find(o => o.key === value.choice);
|
|
if (!chosen) return refuse('decision-stale');
|
|
const action = ordinary ? 'answer' : value.choice === v.approve.key ? 'approve' : 'deny';
|
|
if (live() !== session) return refuse('sign-in-required');
|
|
if(value.adjust!==undefined && v.kind!=='hire')return refuse('invalid-request');
|
|
const binding = v.kind === 'hire' ? bindingOf(v) : null;
|
|
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest, ...(v.kind==='hire'?{kind:'hire',...(value.adjust===undefined?{}:{adjust:structuredClone(value.adjust)}),...(binding?{change:binding.change}:{})}:{}) });
|
|
if (!confirmation) return refuse('confirmation-unavailable');
|
|
line(`action=${action}`);
|
|
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: chosen.text,
|
|
...(v.kind==='hire'?{kind:'hire',flagged:v.flagged,credentialLines:v.credentialLines,expiresAt:v.expiresAt,...(binding?{binding}:{})}:ordinary ? { kind: 'decision' } : { grant: v.grant }), confirmation });
|
|
}
|
|
const keys = Object.keys(value);
|
|
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
|
|
// Consumed before any await on the bus, so a second click finds nothing (J12).
|
|
const held = confirmations.consume(value.confirmation, { session, decision: id, choice: value.choice });
|
|
if (!held) return refuse('confirmation-required');
|
|
if (live() !== session) return refuse('sign-in-required');
|
|
let r;
|
|
try {
|
|
r = await decide.call(held.kind==='hire'?'hire.resolve.console':'decision.resolve.console', { id, choice: held.choice, digest: held.digest,...(held.adjust===undefined?{}:{adjust:held.adjust}) }, { subject, business: held.business });
|
|
} catch (err) { return refuse(busCode(err, 'outcome-unknown')); }
|
|
if (r?.id !== id || r.op !== 'resolved' || r.choice !== held.choice) return refuse('outcome-unknown');
|
|
line(`choice=${JSON.stringify(held.choice.slice(0, 64))}${r.grant ? ` grant=${r.grant}` : ''}`);
|
|
return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}),
|
|
...(held.change && r.choice === 'yes' ? { next: BINDING_NEXT[held.change] } : {}) });
|
|
}
|
|
function json(res, status, body) {
|
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
}
|