feat(ql3): add request-scoped console task creation

This commit is contained in:
whyour
2026-08-29 03:51:35 +08:00
parent 53b7072370
commit 884912d1c8
32 changed files with 2485 additions and 56 deletions
@@ -35,7 +35,8 @@ body {
}
button,
input {
input,
textarea {
font: inherit;
}
@@ -45,6 +46,7 @@ button {
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid var(--amber);
outline-offset: 3px;
@@ -321,6 +323,14 @@ input:focus-visible,
line-height: 1.6;
}
.masthead-actions {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.refresh-button {
flex: 0 0 auto;
color: var(--deep);
@@ -700,6 +710,155 @@ input:focus-visible,
box-shadow: var(--shadow);
}
.task-editor-dialog,
.presence-dialog {
padding: 0;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 0;
background: var(--paper);
box-shadow: var(--shadow);
}
.task-editor-dialog {
width: min(720px, calc(100vw - 32px));
}
.presence-dialog {
width: min(520px, calc(100vw - 32px));
}
.task-editor-dialog::backdrop,
.presence-dialog::backdrop {
background: rgba(11, 31, 36, 0.76);
}
.task-editor-dialog form,
.presence-dialog form {
padding: clamp(22px, 4vw, 34px);
}
.dialog-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.task-editor-dialog h2,
.presence-dialog h2 {
margin: 0;
font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif;
font-size: 34px;
line-height: 1;
}
.editor-intro,
.presence-dialog form > p:not(.eyebrow),
.editor-note,
.presence-expiry {
color: var(--muted);
line-height: 1.6;
}
.editor-grid {
margin-top: 24px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.editor-grid label,
.presence-input {
display: grid;
gap: 7px;
}
.editor-grid label > span,
.presence-input > span {
color: var(--muted);
font: 700 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-grid input:not([type='checkbox']),
.editor-grid textarea,
.presence-input input {
width: 100%;
padding: 11px 12px;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 0;
background: var(--white);
font: 500 13px/1.45 ui-monospace, 'SFMono-Regular', Consolas, monospace;
}
.editor-grid textarea {
resize: vertical;
}
.editor-wide {
grid-column: 1 / -1;
}
.editor-check {
grid-column: 1 / -1;
grid-template-columns: auto 1fr;
align-items: center;
justify-content: start;
}
.editor-check input {
width: 18px;
height: 18px;
accent-color: var(--signal);
}
.editor-note {
margin: 18px 0 0;
padding-left: 12px;
border-left: 3px solid var(--amber);
font-size: 11px;
}
.proof-ticket {
position: relative;
margin: 22px 0;
padding: 18px 16px 18px 22px;
display: block;
color: var(--white);
border-left: 6px solid var(--signal);
background: var(--deep);
font: 650 12px/1.5 ui-monospace, 'SFMono-Regular', Consolas, monospace;
overflow-wrap: anywhere;
}
.proof-ticket::after {
content: 'HOST FILE';
position: absolute;
top: 8px;
right: 10px;
color: rgba(255, 255, 255, 0.35);
font-size: 8px;
letter-spacing: 0.12em;
}
.presence-expiry {
margin-bottom: 0;
font-size: 11px;
}
.presence-error {
margin: 14px 0 0;
padding: 10px 12px;
color: var(--danger);
border-left: 3px solid var(--danger);
background: rgba(186, 74, 67, 0.08);
font-size: 12px;
line-height: 1.5;
}
.confirmation-dialog::backdrop {
background: rgba(11, 31, 36, 0.7);
}
@@ -813,6 +972,11 @@ input:focus-visible,
flex-direction: column;
}
.masthead-actions {
width: 100%;
justify-content: flex-start;
}
.masthead h2 {
font-size: 40px;
}
@@ -836,6 +1000,15 @@ input:focus-visible,
.record-side {
justify-items: start;
}
.editor-grid {
grid-template-columns: 1fr;
}
.editor-wide,
.editor-check {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -2,8 +2,10 @@
'use strict';
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TASK_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TOKEN_PATTERN =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
const PRESENCE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const LOG_READ_BYTES = 32 * 1024;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const STATUS_LABELS = Object.freeze({
@@ -30,6 +32,16 @@
run_step_list_unavailable: 'Workflow Step 暂时不可用。',
task_start_fence_rejected:
'任务在确认期间发生变化,本次启动已安全拒绝。请刷新后重试。',
local_presence_rejected:
'本机证明不匹配或已过期。请核对文件;过期后关闭窗口并重新保存。',
local_presence_unavailable:
'暂时无法生成本机证明。请检查部署数据目录权限。',
strong_authentication_required:
'当前凭据不能执行管理操作;请使用本机 User API Credential。',
task_definition_fence_rejected:
'Task 或授权在确认期间发生变化。请刷新后重新创建。',
invalid_task_definition: 'Task 定义无效。请检查 ID、命令与参数。',
task_definition_unavailable: 'Task 暂时无法保存。请检查数据库状态。',
run_cancellation_fence_rejected:
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
@@ -49,10 +61,29 @@
title: document.getElementById('section-title'),
description: document.getElementById('section-description'),
refresh: document.getElementById('refresh-button'),
createTask: document.getElementById('create-task-button'),
dialog: document.getElementById('confirmation-dialog'),
dialogTitle: document.getElementById('confirmation-title'),
dialogCopy: document.getElementById('confirmation-copy'),
dialogAccept: document.getElementById('confirmation-accept'),
taskEditor: document.getElementById('task-editor-dialog'),
taskEditorForm: document.getElementById('task-editor-form'),
taskEditorClose: document.getElementById('task-editor-close'),
taskEditorSave: document.getElementById('task-editor-save'),
taskId: document.getElementById('task-id-input'),
taskName: document.getElementById('task-name-input'),
taskDescription: document.getElementById('task-description-input'),
taskCommand: document.getElementById('task-command-input'),
taskArgs: document.getElementById('task-args-input'),
taskEnabled: document.getElementById('task-enabled-input'),
presenceDialog: document.getElementById('presence-dialog'),
presenceForm: document.getElementById('presence-form'),
presenceFile: document.getElementById('presence-file'),
presenceProof: document.getElementById('presence-proof-input'),
presenceExpiry: document.getElementById('presence-expiry'),
presenceError: document.getElementById('presence-error'),
presenceCancel: document.getElementById('presence-cancel'),
presenceSubmit: document.getElementById('presence-submit'),
toast: document.getElementById('toast'),
});
@@ -62,6 +93,7 @@
view: 'tasks',
selectedId: null,
pendingAction: null,
pendingTaskMutation: null,
toastTimer: null,
};
@@ -169,6 +201,9 @@
body = JSON.stringify(options.body);
headers['content-type'] = 'application/json';
}
if (options.presence !== undefined) {
headers['x-qinglong-local-presence'] = options.presence;
}
let response;
try {
response = await fetch(path, {
@@ -193,7 +228,7 @@
response.headers.get('x-request-id'),
);
}
if (!response.ok) {
if (!response.ok && response.status !== options.acceptStatus) {
throw new ConsoleRequestError(
typeof value.code === 'string' ? value.code : 'request_unavailable',
response.status,
@@ -310,6 +345,148 @@
nodes.dialog.showModal();
}
function openTaskEditor() {
nodes.taskEditorForm.reset();
nodes.taskCommand.value = '/bin/echo';
nodes.taskEnabled.checked = true;
nodes.taskEditor.returnValue = '';
nodes.taskEditor.showModal();
nodes.taskId.focus();
}
function taskDraft() {
const taskId = nodes.taskId.value.trim();
const name = nodes.taskName.value.trim();
const description = nodes.taskDescription.value.trim();
const file = nodes.taskCommand.value.trim();
const args = nodes.taskArgs.value
.split(/\r?\n/u)
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (!TASK_PATTERN.test(taskId)) {
throw new TypeError('Task ID 格式无效。');
}
if (!name || !file || args.length > 128) {
throw new TypeError('名称、命令或参数数量无效。');
}
return Object.freeze({
taskId,
body: Object.freeze({
expectedRevision: null,
mutationId: newMutationId(),
name,
...(description ? { description } : {}),
kind: 'command',
spec: Object.freeze({
schema: 'qinglong/command@v1',
config: Object.freeze({
command: Object.freeze({ kind: 'argv', file, args }),
}),
}),
labels: Object.freeze({ 'qinglong.source': 'local-console' }),
enabled: nodes.taskEnabled.checked,
occurredAtMs: Date.now(),
}),
});
}
function showPresenceChallenge(mutation, challenge) {
if (
challenge?.code !== 'local_presence_required' ||
typeof challenge.proofFileName !== 'string' ||
!/^[0-9a-f-]{36}\.json$/u.test(challenge.proofFileName) ||
!Number.isSafeInteger(challenge.expiresAtMs)
) {
throw new ConsoleRequestError('response_unavailable', 503, null);
}
state.pendingTaskMutation = Object.freeze({ mutation, challenge });
nodes.presenceFile.textContent = `console-presence/${challenge.proofFileName}`;
nodes.presenceExpiry.textContent = `证明将在 ${formatTime(
challenge.expiresAtMs,
)} 失效;内容改变后必须重新生成。`;
nodes.presenceProof.value = '';
nodes.presenceError.textContent = '';
nodes.presenceError.hidden = true;
nodes.taskEditor.close();
nodes.presenceDialog.returnValue = '';
nodes.presenceDialog.showModal();
nodes.presenceProof.focus();
}
async function saveTaskDraft() {
let mutation;
try {
mutation = taskDraft();
} catch (error) {
showToast(
error instanceof Error ? error.message : 'Task 定义无效。',
'error',
);
return;
}
nodes.taskEditorSave.disabled = true;
try {
const value = await api(
`/api/v3/projects/${state.project}/tasks/${mutation.taskId}`,
{
method: 'PUT',
body: mutation.body,
acceptStatus: 428,
},
);
if (value.code === 'local_presence_required') {
showPresenceChallenge(mutation, value);
return;
}
throw new ConsoleRequestError('response_unavailable', 503, null);
} catch (error) {
showToast(describeError(error), 'error');
} finally {
nodes.taskEditorSave.disabled = false;
}
}
async function completeTaskMutation() {
const pending = state.pendingTaskMutation;
const proof = nodes.presenceProof.value.trim();
if (!pending || !PRESENCE_PATTERN.test(proof)) {
nodes.presenceError.textContent =
'proof 格式无效。请完整复制私有文件中的 proof 字段。';
nodes.presenceError.hidden = false;
nodes.presenceProof.focus();
return;
}
nodes.presenceSubmit.disabled = true;
nodes.presenceError.hidden = true;
try {
const value = await api(
`/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`,
{
method: 'PUT',
body: pending.mutation.body,
presence: proof,
},
);
state.pendingTaskMutation = null;
nodes.presenceProof.value = '';
nodes.presenceDialog.close();
showToast(
value.status === 'existing'
? '已找到同一 Task 请求。'
: 'Task 已创建。',
);
state.selectedId = pending.mutation.taskId;
await refresh();
await selectTask(pending.mutation.taskId);
} catch (error) {
nodes.presenceError.textContent = describeError(error);
nodes.presenceError.hidden = false;
nodes.presenceProof.select();
} finally {
nodes.presenceSubmit.disabled = false;
}
}
async function renderTasks() {
const value = await api(`/api/v3/projects/${state.project}/tasks?limit=64`);
const tasks = Array.isArray(value.tasks) ? value.tasks : [];
@@ -595,7 +772,11 @@
unavailable: '日志暂时不可用;Run 状态与 Event 仍可独立核验。',
};
section.append(
element('p', 'run-log-placeholder', labels[logView.status] || labels.unavailable),
element(
'p',
'run-log-placeholder',
labels[logView.status] || labels.unavailable,
),
);
return section;
}
@@ -702,11 +883,13 @@
else button.removeAttribute('aria-current');
}
if (state.view === 'tasks') {
nodes.createTask.hidden = false;
nodes.kicker.textContent = 'Project task authority';
nodes.title.textContent = '任务调度台';
nodes.description.textContent =
'查看当前 Task revision 与内容围栏。运行前会再次读取详情并要求显式确认。';
'创建命令 Task,查看当前 revision 与内容围栏。管理写入需要部署设备上的一次性本机证明。';
} else {
nodes.createTask.hidden = true;
nodes.kicker.textContent = 'Durable run evidence';
nodes.title.textContent = '运行事实账本';
nodes.description.textContent =
@@ -750,6 +933,9 @@
state.token = null;
state.selectedId = null;
state.pendingAction = null;
state.pendingTaskMutation = null;
if (nodes.taskEditor.open) nodes.taskEditor.close();
if (nodes.presenceDialog.open) nodes.presenceDialog.close();
nodes.token.value = '';
nodes.token.disabled = false;
nodes.project.disabled = false;
@@ -757,6 +943,7 @@
nodes.disconnect.hidden = true;
nodes.nav.hidden = true;
nodes.refresh.hidden = true;
nodes.createTask.hidden = true;
setConnection('idle', '等待凭据');
nodes.kicker.textContent = 'Connection gate';
nodes.title.textContent = '先建立一条本机连接';
@@ -793,6 +980,23 @@
nodes.disconnect.addEventListener('click', disconnect);
nodes.refresh.addEventListener('click', refresh);
nodes.createTask.addEventListener('click', openTaskEditor);
nodes.taskEditorClose.addEventListener('click', () =>
nodes.taskEditor.close(),
);
nodes.taskEditorForm.addEventListener('submit', async (event) => {
event.preventDefault();
await saveTaskDraft();
});
nodes.presenceCancel.addEventListener('click', () => {
state.pendingTaskMutation = null;
nodes.presenceProof.value = '';
nodes.presenceDialog.close();
});
nodes.presenceForm.addEventListener('submit', async (event) => {
event.preventDefault();
await completeTaskMutation();
});
for (const button of nodes.nav.querySelectorAll('button[data-view]')) {
button.addEventListener('click', () => {
@@ -820,7 +1024,10 @@
event.ctrlKey ||
event.altKey ||
event.target instanceof HTMLInputElement ||
nodes.dialog.open
event.target instanceof HTMLTextAreaElement ||
nodes.dialog.open ||
nodes.taskEditor.open ||
nodes.presenceDialog.open
) {
return;
}
@@ -87,9 +87,14 @@
输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。
</p>
</div>
<button class="refresh-button" id="refresh-button" type="button" hidden>
<span aria-hidden="true"></span> 刷新
</button>
<div class="masthead-actions">
<button class="action-button" id="create-task-button" type="button" hidden>
<span aria-hidden="true"></span> 创建任务
</button>
<button class="refresh-button" id="refresh-button" type="button" hidden>
<span aria-hidden="true"></span> 刷新
</button>
</div>
</header>
<div class="workspace-grid">
@@ -125,6 +130,89 @@
</form>
</dialog>
<dialog id="task-editor-dialog" class="task-editor-dialog">
<form id="task-editor-form" autocomplete="off">
<div class="dialog-heading">
<div>
<p class="eyebrow">Task authoring</p>
<h2>创建命令任务</h2>
</div>
<button class="quiet-button" id="task-editor-close" type="button">关闭</button>
</div>
<p class="editor-intro">
定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。
</p>
<div class="editor-grid">
<label>
<span>Task ID</span>
<input id="task-id-input" maxlength="128" spellcheck="false" required />
</label>
<label>
<span>名称</span>
<input id="task-name-input" maxlength="160" required />
</label>
<label class="editor-wide">
<span>说明(可选)</span>
<input id="task-description-input" maxlength="1000" />
</label>
<label class="editor-wide">
<span>可执行文件</span>
<input
id="task-command-input"
value="/bin/echo"
maxlength="4096"
spellcheck="false"
required
/>
</label>
<label class="editor-wide">
<span>参数 · 每行一个</span>
<textarea id="task-args-input" rows="5" maxlength="16384" spellcheck="false"></textarea>
</label>
<label class="editor-check">
<input id="task-enabled-input" type="checkbox" checked />
<span>创建后允许运行</span>
</label>
</div>
<p class="editor-note">
Alpha 当前从 Console 创建 <code>qinglong/command@v1</code>;高级 Task schema 仍使用受信任管理入口。
</p>
<div class="dialog-actions">
<button class="primary-button" id="task-editor-save" type="submit">
保存并生成本机证明
</button>
</div>
</form>
</dialog>
<dialog id="presence-dialog" class="presence-dialog">
<form id="presence-form" autocomplete="off">
<p class="eyebrow">Local presence · 02:00</p>
<h2>从部署设备取得证明</h2>
<p>
使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。
</p>
<code class="proof-ticket" id="presence-file"></code>
<label class="presence-input">
<span>文件中的 proof</span>
<input
id="presence-proof-input"
type="password"
maxlength="128"
spellcheck="false"
autocomplete="off"
required
/>
</label>
<p class="presence-expiry" id="presence-expiry"></p>
<p class="presence-error" id="presence-error" role="alert" hidden></p>
<div class="dialog-actions">
<button class="quiet-button" id="presence-cancel" type="button">取消</button>
<button class="primary-button" id="presence-submit" type="submit">验证并创建</button>
</div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
+1
View File
@@ -40,6 +40,7 @@
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
},
"dependencies": {
"@qinglong/local-admin": "workspace:*",
"@qinglong/local-application": "workspace:*",
"@qinglong/local-command-file": "workspace:*",
"@qinglong/local-owner-console": "workspace:*",
@@ -26,6 +26,7 @@ import type { LocalApiRunAttemptLogReadRoute } from '../run/runAttemptLogReadRou
import type { LocalApiTaskListRoute } from '../task/taskListRoute';
import type { LocalApiTaskReadRoute } from '../task/taskReadRoute';
import type { LocalApiTaskStartRoute } from '../task/taskStartRoute';
import type { LocalApiTaskPutRoute } from '../task/taskPutRoute';
import type { LocalApiResponse } from '../transport/contract';
export type LocalApiAdmissionOperation =
@@ -78,12 +79,18 @@ export type LocalApiAdmissionOperation =
operationId: 'task.start';
projectId: string;
taskId: string;
}>
| Readonly<{
operationId: 'task.put';
projectId: string;
taskId: string;
}>;
export interface LocalApiAdmissionRequest {
readonly requestId: string;
readonly operation: LocalApiAdmissionOperation;
readonly authorization: string | null;
readonly localPresence: string | null;
readonly signal: AbortSignal;
}
@@ -112,6 +119,7 @@ export interface LocalApiAdmissionOptions {
readonly taskListRoute: LocalApiTaskListRoute;
readonly taskReadRoute: LocalApiTaskReadRoute;
readonly taskStartRoute: LocalApiTaskStartRoute;
readonly taskPutRoute: LocalApiTaskPutRoute;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
@@ -190,6 +198,7 @@ export function createLocalApiAdmission(
typeof options.taskListRoute?.handle !== 'function' ||
typeof options.taskReadRoute?.handle !== 'function' ||
typeof options.taskStartRoute?.handle !== 'function' ||
typeof options.taskPutRoute?.handle !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function')
@@ -239,6 +248,25 @@ export function createLocalApiAdmission(
}
if (request.signal.aborted) return response(503, 'request_unavailable');
if (request.operation.operationId === 'task.put') {
const taskPutOperation = request.operation;
return Object.freeze({
bodyMode: 'json' as const,
maximumBodyBytes: 72 * 1_024,
async handle(body: unknown | null) {
return options.taskPutRoute.handle({
requestId: request.requestId,
projectId: taskPutOperation.projectId,
taskId: taskPutOperation.taskId,
body,
presence: request.localPresence,
authenticated,
signal: request.signal,
});
},
});
}
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = normalizeSecurityPolicyDecision(
@@ -386,6 +414,8 @@ export function createLocalApiAdmission(
principal: authenticated.principal,
policyFence: decision.fence,
});
case 'task.put':
return response(503, 'request_unavailable');
}
},
});
@@ -9,6 +9,7 @@ import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
import { createLocalApiAdmission } from '../admission/localApiAdmission';
import { createLocalApiCredentialAuthenticator } from '../authentication/credentialAuthenticator';
import { createLocalPresenceProofManager } from '../authentication/localPresenceProof';
import type { LocalApiProcessConfig } from '../production-process/config';
import { createLocalApiRunListRoute } from '../run/runListRoute';
import { createLocalApiRunReadRoute } from '../run/runReadRoute';
@@ -19,6 +20,7 @@ import { createLocalApiRunAttemptLogReadRoute } from '../run/runAttemptLogReadRo
import { createLocalApiTaskListRoute } from '../task/taskListRoute';
import { createLocalApiTaskReadRoute } from '../task/taskReadRoute';
import { createLocalApiTaskStartRoute } from '../task/taskStartRoute';
import { createLocalApiTaskPutRoute } from '../task/taskPutRoute';
import { startLocalApiHttpSurface } from '../transport/httpSurface';
export interface LocalApiProductSurfaceEvent {
@@ -98,6 +100,14 @@ export function createLocalApiProductSurface(
provider,
options.now === undefined ? {} : { now: options.now },
);
const presenceProof = createLocalPresenceProofManager({
deploymentRoot: config.deploymentRoot,
profile: authority.profile,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
const policy = new ProjectPolicyEngine(authority.projectPolicy);
const runReadRoute = createLocalApiRunReadRoute(authority.runs);
const runListRoute = createLocalApiRunListRoute(authority.runs);
@@ -123,6 +133,25 @@ export function createLocalApiProductSurface(
authority.taskStart,
options.randomUuid ?? randomUUID,
);
const taskPutRoute = createLocalApiTaskPutRoute({
projectPolicy: authority.projectPolicy,
taskDefinitions: authority.taskDefinitions,
taskDefinitionAdministrationForCredential: (fence) => {
if (fence.subjectType !== 'user') {
throw new TypeError('Task mutation requires a User credential');
}
return authority.taskDefinitionAdministrationForCredential({
...fence,
subjectType: 'user',
});
},
securityAudit: authority.securityAudit,
presenceProof,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
const admission = createLocalApiAdmission({
authenticator,
policy,
@@ -136,20 +165,27 @@ export function createLocalApiProductSurface(
taskListRoute,
taskReadRoute,
taskStartRoute,
taskPutRoute,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
const active = await startLocalApiHttpSurface({
profile: authority.profile,
host: config.listener.host,
port: config.listener.port,
admission,
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
let active;
try {
active = await startLocalApiHttpSurface({
profile: authority.profile,
host: config.listener.host,
port: config.listener.port,
admission,
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
} catch (error) {
presenceProof.close();
throw error;
}
await bestEffortEmit(
options.emit,
surfaceEvent(config, 'listening', { level: 'info' }),
@@ -163,7 +199,12 @@ export function createLocalApiProductSurface(
options.emit,
surfaceEvent(config, 'draining', { level: 'info' }),
);
const stopResult = await active.stopAndDrain();
let stopResult = await active.stopAndDrain();
try {
presenceProof.close();
} catch {
stopResult = 'timed_out';
}
await bestEffortEmit(
options.emit,
surfaceEvent(config, 'stopped', {
@@ -19,6 +19,7 @@ const AUTHORIZATION_PATTERN =
export interface AuthenticatedLocalApiRequest {
readonly principal: Readonly<SecurityPrincipal>;
readonly credentialFence: Readonly<LocalApiCredentialFence>;
confirm(): Promise<void>;
}
@@ -32,7 +33,7 @@ export interface LocalApiCredentialAuthenticatorOptions {
readonly now?: () => number;
}
interface CredentialFence {
export interface LocalApiCredentialFence {
readonly credentialId: string;
readonly credentialVersion: number;
readonly pepperKeyId: string;
@@ -91,7 +92,7 @@ async function loadFence(
provider: LocalOwnerPepperKeyringFileProvider,
credentialId: string,
credentialVersion: number,
): Promise<Readonly<CredentialFence>> {
): Promise<Readonly<LocalApiCredentialFence>> {
try {
const candidate = await authority.apiCredentials.resolve(credentialId);
if (!candidate) throw new Error('credential is unavailable');
@@ -103,11 +104,7 @@ async function loadFence(
credential.state !== 'active' ||
credential.subjectStatus !== 'active' ||
!validKey(key) ||
!validMaterial(
material,
credential.pepperKeyId,
key.materialDigest,
)
!validMaterial(material, credential.pepperKeyId, key.materialDigest)
) {
throw new Error('credential fence is unavailable');
}
@@ -129,7 +126,10 @@ async function loadFence(
}
}
function sameFence(left: CredentialFence, right: CredentialFence): boolean {
function sameFence(
left: LocalApiCredentialFence,
right: LocalApiCredentialFence,
): boolean {
return (
left.credentialId === right.credentialId &&
left.credentialVersion === right.credentialVersion &&
@@ -201,6 +201,7 @@ export function createLocalApiCredentialAuthenticator(
}
return Object.freeze({
principal: authentication.principal,
credentialFence: fence,
async confirm() {
try {
const currentAuthentication =
@@ -227,7 +228,8 @@ export function createLocalApiCredentialAuthenticator(
}
} catch (error) {
if (
error instanceof LocalApiCredentialAuthenticationUnavailableError
error instanceof
LocalApiCredentialAuthenticationUnavailableError
) {
throw error;
}
@@ -238,9 +240,7 @@ export function createLocalApiCredentialAuthenticator(
},
});
} catch (error) {
if (
error instanceof LocalApiCredentialAuthenticationUnavailableError
) {
if (error instanceof LocalApiCredentialAuthenticationUnavailableError) {
throw error;
}
if (error instanceof LocalIdentityAuthenticationUnavailableError) {
@@ -0,0 +1,400 @@
import {
createHash,
randomBytes,
randomUUID,
timingSafeEqual,
} from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { LocalApplicationProfile } from '@qinglong/local-application';
const PRESENCE_DIRECTORY = 'console-presence';
const AUTHORIZATION_TTL_MS = 120_000;
const AUTHORIZATION_PATTERN =
/^ql3p_([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})_([A-Za-z0-9_-]{43})$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
interface PendingLocalPresenceAuthorization {
readonly authorizationId: string;
readonly fileName: string;
readonly requestDigest: string;
readonly credentialDigest: string;
readonly proofDigest: Buffer;
readonly expiresAtMs: number;
}
export interface LocalPresenceBinding {
readonly requestDigest: string;
readonly credentialId: string;
readonly credentialVersion: number;
readonly subjectType: 'user';
readonly subjectId: string;
}
export interface LocalPresenceChallenge {
readonly authorizationId: string;
readonly requestDigest: string;
readonly expiresAtMs: number;
readonly proofFileName: string;
}
export interface ConsumedLocalPresenceProof {
readonly authorizationId: string;
readonly authenticatedAtMs: number;
readonly expiresAtMs: number;
}
export interface LocalPresenceProofManager {
issue(binding: Readonly<LocalPresenceBinding>): LocalPresenceChallenge;
consume(
presentation: string | null,
binding: Readonly<LocalPresenceBinding>,
): ConsumedLocalPresenceProof | null;
close(): void;
}
export interface LocalPresenceProofManagerOptions {
readonly deploymentRoot: string;
readonly profile: LocalApplicationProfile;
readonly now?: () => number;
readonly randomUuid?: () => string;
readonly randomSecret?: () => Buffer;
}
export class LocalPresenceProofConfigurationError extends TypeError {
readonly code = 'QL3_LOCAL_PRESENCE_CONFIG_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Local presence proof configuration is invalid: ${message}`, options);
this.name = 'LocalPresenceProofConfigurationError';
}
}
export class LocalPresenceProofUnavailableError extends Error {
readonly code = 'QL3_LOCAL_PRESENCE_UNAVAILABLE';
constructor(message: string, options?: ErrorOptions) {
super(`Local presence proof is unavailable: ${message}`, options);
this.name = 'LocalPresenceProofUnavailableError';
}
}
function currentUid(): number {
if (typeof process.getuid !== 'function') {
throw new LocalPresenceProofConfigurationError(
'POSIX user identity is unavailable',
);
}
const uid = process.getuid();
if (!Number.isSafeInteger(uid) || uid < 0) {
throw new LocalPresenceProofConfigurationError('POSIX user is invalid');
}
return uid;
}
function privateDirectory(directoryPath: string, uid: number): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(directoryPath);
} catch (error) {
throw new LocalPresenceProofConfigurationError(
'private directory is unavailable',
{ cause: error },
);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o700
) {
throw new LocalPresenceProofConfigurationError(
'private directory ownership or mode is invalid',
);
}
}
function clock(now: () => number): number {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new LocalPresenceProofUnavailableError('clock is invalid');
}
return value;
}
function credentialDigest(binding: Readonly<LocalPresenceBinding>): string {
if (
!binding ||
typeof binding !== 'object' ||
Array.isArray(binding) ||
Object.keys(binding).sort().join('\0') !==
[
'credentialId',
'credentialVersion',
'requestDigest',
'subjectId',
'subjectType',
]
.sort()
.join('\0') ||
!SHA256_PATTERN.test(binding.requestDigest) ||
typeof binding.credentialId !== 'string' ||
binding.credentialId.length < 1 ||
binding.credentialId.length > 64 ||
!Number.isSafeInteger(binding.credentialVersion) ||
binding.credentialVersion < 1 ||
binding.subjectType !== 'user' ||
typeof binding.subjectId !== 'string' ||
binding.subjectId.length < 1 ||
binding.subjectId.length > 128
) {
throw new LocalPresenceProofUnavailableError('binding is invalid');
}
return createHash('sha256')
.update('qinglong3.local-presence-credential.v1\0', 'utf8')
.update(binding.credentialId, 'utf8')
.update('\0', 'utf8')
.update(String(binding.credentialVersion), 'utf8')
.update('\0', 'utf8')
.update(binding.subjectType, 'utf8')
.update('\0', 'utf8')
.update(binding.subjectId, 'utf8')
.digest('hex');
}
function removeFile(directory: string, fileName: string): void {
try {
fs.unlinkSync(path.join(directory, fileName));
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw new LocalPresenceProofUnavailableError(
'proof file cannot be removed',
{ cause: error },
);
}
}
}
function writeProofFile(
directory: string,
authorization: Omit<PendingLocalPresenceAuthorization, 'proofDigest'>,
presentation: string,
): void {
const filePath = path.join(directory, authorization.fileName);
const payload = Buffer.from(
`${JSON.stringify({
schemaVersion: 1,
kind: 'qinglong3-local-presence-proof',
authorizationId: authorization.authorizationId,
requestDigest: authorization.requestDigest,
expiresAtMs: authorization.expiresAtMs,
proof: presentation,
})}\n`,
'utf8',
);
let descriptor: number | undefined;
try {
descriptor = fs.openSync(
filePath,
fs.constants.O_CREAT |
fs.constants.O_EXCL |
fs.constants.O_WRONLY |
(fs.constants.O_NOFOLLOW ?? 0),
0o600,
);
fs.writeFileSync(descriptor, payload);
fs.fsyncSync(descriptor);
const stat = fs.fstatSync(descriptor);
if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1) {
throw new Error('proof file identity is invalid');
}
} catch (error) {
try {
fs.unlinkSync(filePath);
} catch {
// Preserve the original publication failure.
}
throw new LocalPresenceProofUnavailableError(
'proof file cannot be published',
{ cause: error },
);
} finally {
payload.fill(0);
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function createLocalPresenceProofManager(
options: Readonly<LocalPresenceProofManagerOptions>,
): Readonly<LocalPresenceProofManager> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some(
(key) =>
key !== 'deploymentRoot' &&
key !== 'profile' &&
key !== 'now' &&
key !== 'randomUuid' &&
key !== 'randomSecret',
) ||
typeof options.deploymentRoot !== 'string' ||
!path.isAbsolute(options.deploymentRoot) ||
path.normalize(options.deploymentRoot) !== options.deploymentRoot ||
path.parse(options.deploymentRoot).root === options.deploymentRoot ||
(options.profile !== 'edge' && options.profile !== 'standalone') ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function') ||
(options.randomSecret !== undefined &&
typeof options.randomSecret !== 'function')
) {
throw new LocalPresenceProofConfigurationError('options are invalid');
}
const uid = currentUid();
privateDirectory(options.deploymentRoot, uid);
const directory = path.join(options.deploymentRoot, PRESENCE_DIRECTORY);
try {
fs.mkdirSync(directory, { mode: 0o700 });
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'EEXIST') {
throw new LocalPresenceProofConfigurationError(
'private directory cannot be created',
{ cause: error },
);
}
}
privateDirectory(directory, uid);
const now = options.now ?? Date.now;
const uuid = options.randomUuid ?? randomUUID;
const secret = options.randomSecret ?? (() => randomBytes(32));
const maximumPending = options.profile === 'edge' ? 8 : 32;
const pending = new Map<string, PendingLocalPresenceAuthorization>();
let closed = false;
const sweep = (nowMs: number) => {
for (const [authorizationId, authorization] of pending) {
if (authorization.expiresAtMs > nowMs) continue;
removeFile(directory, authorization.fileName);
authorization.proofDigest.fill(0);
pending.delete(authorizationId);
}
};
return Object.freeze({
issue(binding: Readonly<LocalPresenceBinding>) {
if (closed) {
throw new LocalPresenceProofUnavailableError('manager is closed');
}
const nowMs = clock(now);
sweep(nowMs);
if (pending.size >= maximumPending) {
throw new LocalPresenceProofUnavailableError(
'pending authorization capacity is exhausted',
);
}
const boundCredentialDigest = credentialDigest(binding);
const authorizationId = uuid();
if (
!AUTHORIZATION_PATTERN.test(`ql3p_${authorizationId}_${'A'.repeat(43)}`)
) {
throw new LocalPresenceProofUnavailableError(
'authorization identity is invalid',
);
}
const material = secret();
if (!Buffer.isBuffer(material) || material.byteLength !== 32) {
throw new LocalPresenceProofUnavailableError(
'proof entropy is unavailable',
);
}
let presentation: string | undefined;
try {
presentation = `ql3p_${authorizationId}_${material.toString(
'base64url',
)}`;
const authorization = Object.freeze({
authorizationId,
fileName: `${authorizationId}.json`,
requestDigest: binding.requestDigest,
credentialDigest: boundCredentialDigest,
expiresAtMs: nowMs + AUTHORIZATION_TTL_MS,
});
writeProofFile(directory, authorization, presentation);
pending.set(
authorizationId,
Object.freeze({
...authorization,
proofDigest: createHash('sha256')
.update('qinglong3.local-presence-proof.v1\0', 'utf8')
.update(presentation, 'utf8')
.digest(),
}),
);
return Object.freeze({
authorizationId,
requestDigest: binding.requestDigest,
expiresAtMs: authorization.expiresAtMs,
proofFileName: authorization.fileName,
});
} finally {
material.fill(0);
presentation = undefined;
}
},
consume(
presentation: string | null,
binding: Readonly<LocalPresenceBinding>,
) {
if (closed || typeof presentation !== 'string') return null;
const nowMs = clock(now);
sweep(nowMs);
const match = AUTHORIZATION_PATTERN.exec(presentation);
if (!match) return null;
const authorization = pending.get(match[1]!);
if (!authorization) return null;
const actualProofDigest = createHash('sha256')
.update('qinglong3.local-presence-proof.v1\0', 'utf8')
.update(presentation, 'utf8')
.digest();
let valid = false;
try {
valid =
authorization.expiresAtMs > nowMs &&
authorization.requestDigest === binding.requestDigest &&
authorization.credentialDigest === credentialDigest(binding) &&
timingSafeEqual(actualProofDigest, authorization.proofDigest);
} finally {
actualProofDigest.fill(0);
}
if (!valid) return null;
pending.delete(authorization.authorizationId);
removeFile(directory, authorization.fileName);
authorization.proofDigest.fill(0);
return Object.freeze({
authorizationId: authorization.authorizationId,
authenticatedAtMs: nowMs,
expiresAtMs: authorization.expiresAtMs,
});
},
close() {
if (closed) return;
closed = true;
let firstError: unknown;
for (const authorization of pending.values()) {
try {
removeFile(directory, authorization.fileName);
} catch (error) {
firstError ??= error;
}
authorization.proofDigest.fill(0);
}
pending.clear();
if (firstError) throw firstError;
},
});
}
@@ -0,0 +1,462 @@
import { createHash, randomUUID } from 'node:crypto';
import {
LocalTaskDefinitionAdministrationAuthenticationError,
LocalTaskDefinitionAdministrationAuthorizationError,
LocalTaskDefinitionAdministrationConfigurationError,
LocalTaskDefinitionAdministrationUnavailableError,
createLocalTaskDefinitionAdministrationService,
} from '@qinglong/local-admin/task-definition-administration';
import {
ProjectPolicyEngine,
ProjectPolicyUnavailableError,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPolicyDecision,
normalizeSecurityPrincipal,
type SecurityPolicyDecision,
} from '@qinglong/runtime-core/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditOutcome,
type SecurityAuditSink,
} from '@qinglong/runtime-core/security-audit';
import {
InvalidTaskDefinitionError,
TaskDefinitionConflictError,
TaskDefinitionUnavailableError,
normalizeAppendTaskDefinitionRevisionCommand,
type AppendTaskDefinitionRevisionCommand,
type TaskDefinitionRecord,
type TaskDefinitionSource,
} from '@qinglong/runtime-core/task-definition';
import {
TaskDefinitionAdministrationAuthorizationFenceConflictError,
TaskDefinitionAdministrationMutationConflictError,
type TaskDefinitionAdministrationRepository,
} from '@qinglong/runtime-core/task-definition-administration';
import type { AuthenticatedLocalApiRequest } from '../authentication/credentialAuthenticator';
import {
LocalPresenceProofUnavailableError,
type LocalPresenceBinding,
type LocalPresenceProofManager,
} from '../authentication/localPresenceProof';
import type { LocalApiResponse } from '../transport/contract';
const BODY_KEYS = Object.freeze([
'enabled',
'expectedRevision',
'kind',
'labels',
'mutationId',
'name',
'occurredAtMs',
'spec',
]);
const OPTIONAL_BODY_KEYS = Object.freeze(['description']);
export interface LocalApiTaskPutRequest {
readonly requestId: string;
readonly projectId: string;
readonly taskId: string;
readonly body: unknown | null;
readonly presence: string | null;
readonly authenticated: Readonly<AuthenticatedLocalApiRequest>;
readonly signal: AbortSignal;
}
export interface LocalApiTaskPutRoute {
handle(request: Readonly<LocalApiTaskPutRequest>): Promise<LocalApiResponse>;
}
export interface LocalApiTaskPutRouteOptions {
readonly projectPolicy: ProjectPolicyRepository;
readonly taskDefinitions: TaskDefinitionSource;
readonly taskDefinitionAdministrationForCredential: (
fence: Readonly<AuthenticatedLocalApiRequest['credentialFence']>,
) => TaskDefinitionAdministrationRepository;
readonly securityAudit: SecurityAuditSink;
readonly presenceProof: LocalPresenceProofManager;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
function response(
statusCode: number,
body: Readonly<Record<string, unknown>>,
): LocalApiResponse {
return Object.freeze({ statusCode, body: Object.freeze(body) });
}
function canonicalJson(value: unknown): string {
if (
value === null ||
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'string'
) {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
}
const record = value as Readonly<Record<string, unknown>>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',')}}`;
}
function normalizeBody(
body: unknown | null,
projectId: string,
taskId: string,
): Readonly<AppendTaskDefinitionRevisionCommand> {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new InvalidTaskDefinitionError('HTTP body must be an object');
}
const keys = Object.keys(body).sort();
const allowed = new Set([...BODY_KEYS, ...OPTIONAL_BODY_KEYS]);
if (
BODY_KEYS.some((key) => !keys.includes(key)) ||
keys.some((key) => !allowed.has(key))
) {
throw new InvalidTaskDefinitionError('HTTP body has an invalid shape');
}
return normalizeAppendTaskDefinitionRevisionCommand({
projectId,
taskId,
...(body as Omit<
AppendTaskDefinitionRevisionCommand,
'projectId' | 'taskId'
>),
});
}
function requestDigest(
command: Readonly<AppendTaskDefinitionRevisionCommand>,
): string {
return createHash('sha256')
.update('qinglong3.local-api-task-put.v1\0', 'utf8')
.update(canonicalJson(command), 'utf8')
.digest('hex');
}
function presenceBinding(
command: Readonly<AppendTaskDefinitionRevisionCommand>,
authenticated: Readonly<AuthenticatedLocalApiRequest>,
): Readonly<LocalPresenceBinding> {
if (
authenticated.principal.subject.type !== 'user' ||
authenticated.credentialFence.subjectType !== 'user'
) {
throw new LocalPresenceProofUnavailableError(
'strong User credential is required',
);
}
return Object.freeze({
requestDigest: requestDigest(command),
credentialId: authenticated.credentialFence.credentialId,
credentialVersion: authenticated.credentialFence.credentialVersion,
subjectType: 'user',
subjectId: authenticated.credentialFence.subjectId,
});
}
function operationId(
command: Readonly<AppendTaskDefinitionRevisionCommand>,
): 'task.create' | 'task.update' {
return command.expectedRevision === null ? 'task.create' : 'task.update';
}
function summary(value: Readonly<TaskDefinitionRecord>) {
return Object.freeze({
taskId: value.taskId,
revision: value.revision,
name: value.name,
kind: value.kind,
specSchema: value.spec.schema,
enabled: value.enabled,
contentDigest: value.contentDigest,
createdAtMs: value.createdAtMs,
updatedAtMs: value.updatedAtMs,
});
}
function timestamp(now: () => number): number {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new LocalPresenceProofUnavailableError('clock is invalid');
}
return value;
}
async function recordAudit(
audit: SecurityAuditSink,
values: {
readonly eventId: string;
readonly requestId: string;
readonly operationId: 'task.create' | 'task.update';
readonly projectId: string;
readonly authenticated: Readonly<AuthenticatedLocalApiRequest> | null;
readonly outcome: SecurityAuditOutcome;
readonly reasons: readonly string[];
readonly fence: SecurityPolicyDecision['fence'];
readonly occurredAtMs: number;
},
): Promise<boolean> {
try {
await audit.record(
normalizeSecurityAuditRecord({
eventId: values.eventId,
requestId: values.requestId,
operationId: values.operationId,
projectId: values.projectId,
subject: values.authenticated?.principal.subject ?? null,
authenticationId:
values.authenticated?.principal.authenticationId ?? null,
outcome: values.outcome,
reasons: values.reasons,
fence: values.fence,
occurredAtMs: values.occurredAtMs,
}),
);
return true;
} catch {
return false;
}
}
export function createLocalApiTaskPutRoute(
options: Readonly<LocalApiTaskPutRouteOptions>,
): Readonly<LocalApiTaskPutRoute> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
typeof options.projectPolicy?.resolve !== 'function' ||
typeof options.taskDefinitions?.findCurrentTaskDefinition !== 'function' ||
typeof options.taskDefinitions?.findTaskDefinitionRevision !== 'function' ||
typeof options.taskDefinitions?.listTaskDefinitions !== 'function' ||
typeof options.taskDefinitionAdministrationForCredential !== 'function' ||
typeof options.securityAudit?.record !== 'function' ||
typeof options.presenceProof?.issue !== 'function' ||
typeof options.presenceProof?.consume !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function')
) {
throw new TypeError('Local API Task put route options are invalid');
}
const now = options.now ?? Date.now;
const uuid = options.randomUuid ?? randomUUID;
const policy = new ProjectPolicyEngine(options.projectPolicy);
return Object.freeze({
async handle(request: Readonly<LocalApiTaskPutRequest>) {
if (request.signal.aborted) {
return response(503, { code: 'request_unavailable' });
}
let command: Readonly<AppendTaskDefinitionRevisionCommand>;
try {
command = normalizeBody(
request.body,
request.projectId,
request.taskId,
);
} catch (error) {
return error instanceof InvalidTaskDefinitionError
? response(400, { code: 'invalid_task_definition' })
: response(503, { code: 'task_definition_unavailable' });
}
const operation = operationId(command);
const occurredAtMs = timestamp(now);
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = normalizeSecurityPolicyDecision(
await policy.authorize(
request.authenticated.principal,
request.projectId,
operation,
),
);
} catch (error) {
const audited = await recordAudit(options.securityAudit, {
eventId: uuid(),
requestId: request.requestId,
operationId: operation,
projectId: request.projectId,
authenticated: request.authenticated,
outcome: 'authorization_unavailable',
reasons: ['policy_unavailable'],
fence: null,
occurredAtMs,
});
return response(503, {
code:
audited && error instanceof ProjectPolicyUnavailableError
? 'authorization_unavailable'
: 'security_audit_unavailable',
});
}
if (decision.effect !== 'allow') {
const audited = await recordAudit(options.securityAudit, {
eventId: uuid(),
requestId: request.requestId,
operationId: operation,
projectId: request.projectId,
authenticated: request.authenticated,
outcome:
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
reasons: decision.reasons,
fence: decision.fence,
occurredAtMs,
});
if (!audited) {
return response(503, { code: 'security_audit_unavailable' });
}
return response(403, {
code:
decision.effect === 'require_approval'
? 'approval_required'
: 'forbidden',
});
}
let binding: Readonly<LocalPresenceBinding>;
try {
binding = presenceBinding(command, request.authenticated);
} catch {
return response(401, { code: 'strong_authentication_required' });
}
if (!request.presence) {
let challenge;
try {
challenge = options.presenceProof.issue(binding);
} catch {
return response(503, { code: 'local_presence_unavailable' });
}
const audited = await recordAudit(options.securityAudit, {
eventId: uuid(),
requestId: request.requestId,
operationId: operation,
projectId: request.projectId,
authenticated: request.authenticated,
outcome: 'approval_required',
reasons: ['local_presence_required'],
fence: decision.fence,
occurredAtMs,
});
if (!audited) {
return response(503, { code: 'security_audit_unavailable' });
}
return response(428, {
code: 'local_presence_required',
authorizationId: challenge.authorizationId,
requestDigest: challenge.requestDigest,
expiresAtMs: challenge.expiresAtMs,
proofFileName: challenge.proofFileName,
});
}
let proof;
try {
await request.authenticated.confirm();
proof = options.presenceProof.consume(request.presence, binding);
} catch {
return response(503, { code: 'authentication_unavailable' });
}
if (!proof) {
const audited = await recordAudit(options.securityAudit, {
eventId: uuid(),
requestId: request.requestId,
operationId: operation,
projectId: request.projectId,
authenticated: null,
outcome: 'authentication_rejected',
reasons: ['local_presence_rejected'],
fence: null,
occurredAtMs,
});
return audited
? response(401, { code: 'local_presence_rejected' })
: response(503, { code: 'security_audit_unavailable' });
}
if (request.signal.aborted) {
return response(503, { code: 'request_unavailable' });
}
let strongPrincipal;
try {
strongPrincipal = normalizeSecurityPrincipal(
{
subject: request.authenticated.principal.subject,
authenticationId: `local_presence:${proof.authorizationId}`,
authenticatedAtMs: proof.authenticatedAtMs,
expiresAtMs: Math.min(
proof.expiresAtMs,
request.authenticated.principal.expiresAtMs,
),
assurance: 'local_console',
},
proof.authenticatedAtMs,
);
} catch {
return response(503, { code: 'authentication_unavailable' });
}
try {
const service = createLocalTaskDefinitionAdministrationService(
options.projectPolicy,
options.taskDefinitionAdministrationForCredential(
request.authenticated.credentialFence,
),
options.taskDefinitions,
options.securityAudit,
{ now },
);
const result = await service.put({
...command,
requestId: request.requestId,
principal: strongPrincipal,
});
return response(result.status === 'created' ? 201 : 200, {
status: result.status,
task: summary(result.definition),
});
} catch (error) {
if (
error instanceof TaskDefinitionConflictError ||
error instanceof TaskDefinitionAdministrationMutationConflictError ||
error instanceof
TaskDefinitionAdministrationAuthorizationFenceConflictError
) {
return response(409, { code: 'task_definition_fence_rejected' });
}
if (
error instanceof LocalTaskDefinitionAdministrationAuthenticationError
) {
return response(401, { code: 'strong_authentication_required' });
}
if (
error instanceof LocalTaskDefinitionAdministrationAuthorizationError
) {
return response(403, { code: 'forbidden' });
}
if (
error instanceof InvalidTaskDefinitionError ||
error instanceof LocalTaskDefinitionAdministrationConfigurationError
) {
return response(400, { code: 'invalid_task_definition' });
}
if (
error instanceof TaskDefinitionUnavailableError ||
error instanceof LocalTaskDefinitionAdministrationUnavailableError
) {
return response(503, { code: 'task_definition_unavailable' });
}
return response(503, { code: 'task_definition_unavailable' });
}
},
});
}
@@ -88,6 +88,15 @@ function authorization(request: IncomingMessage): string | null {
return values.length === 1 ? values[0]! : null;
}
function localPresence(request: IncomingMessage): string | null {
const values = rawHeaderValues(request, 'x-qinglong-local-presence');
if (values.length === 0) return null;
if (values.length !== 1 || values[0]!.length > 160) {
throw new TypeError('invalid_local_presence');
}
return values[0]!;
}
function hasRequestBody(request: IncomingMessage): boolean {
const transferEncoding = rawHeaderValues(request, 'transfer-encoding');
const contentLength = rawHeaderValues(request, 'content-length');
@@ -453,6 +462,16 @@ function route(
})
: null;
}
if (request.method === 'PUT') {
const taskPutMatch = TASK_READ_ROUTE_PATTERN.exec(path);
return taskPutMatch && rawQuery === undefined
? Object.freeze({
operationId: 'task.put',
projectId: taskPutMatch[1]!,
taskId: taskPutMatch[2]!,
})
: null;
}
if (request.method !== 'GET') return null;
const runAttemptLogReadMatch = RUN_ATTEMPT_LOG_READ_ROUTE_PATTERN.exec(path);
if (runAttemptLogReadMatch) {
@@ -693,10 +712,19 @@ export async function startLocalApiHttpSurface(
response.once('close', () => {
if (!response.writableFinished) abort.abort();
});
let presentedLocalPresence: string | null;
try {
presentedLocalPresence = localPresence(request);
} catch {
send(response, requestId, errorResponse(400, 'invalid_local_presence'));
request.resume();
return;
}
const admissionRequest: LocalApiAdmissionRequest = Object.freeze({
requestId,
operation: resolvedRoute,
authorization: authorization(request),
localPresence: presentedLocalPresence,
signal: abort.signal,
});
let operation: Promise<void>;
@@ -22,6 +22,7 @@ function request(overrides = {}) {
runId: 'run_123',
}),
authorization: 'Bearer opaque',
localPresence: null,
signal: new AbortController().signal,
...overrides,
});
@@ -35,6 +36,17 @@ function fixture(overrides = {}) {
events.push('authenticate');
return Object.freeze({
principal: PRINCIPAL,
credentialFence: Object.freeze({
credentialId: 'credential-local',
credentialVersion: 1,
pepperKeyId: 'owner-v1',
materialDigest: 'a'.repeat(64),
subjectType: 'user',
subjectId: 'usr_local',
secretDigest: 'b'.repeat(64),
notBeforeAtMs: 1,
expiresAtMs: 20_000,
}),
async confirm() {
events.push('confirm');
},
@@ -127,6 +139,12 @@ function fixture(overrides = {}) {
return { statusCode: 202, body: { status: 'accepted' } };
},
},
taskPutRoute: {
async handle(value) {
events.push(`task-put:${value.projectId}:${value.taskId}`);
return { statusCode: 201, body: { status: 'created' } };
},
},
now: () => 10_000,
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
...overrides,
@@ -382,6 +400,28 @@ test('authorizes and audits run.start before exposing the Task body handler', as
assert.equal(events.at(-1), 'task-start:prj_default:task-a');
});
test('defers Task put Policy, audit and strong confirmation to the request-bound route', async () => {
const { admission, events } = fixture();
const prepared = await admission.prepare(
request({
operation: Object.freeze({
operationId: 'task.put',
projectId: 'prj_default',
taskId: 'task-a',
}),
localPresence: 'ql3p_proof',
}),
);
assert.equal(prepared.bodyMode, 'json');
assert.equal(prepared.maximumBodyBytes, 72 * 1024);
assert.deepEqual(events, ['authenticate']);
assert.deepEqual(await prepared.handle({ name: 'Task' }), {
statusCode: 201,
body: { status: 'created' },
});
assert.deepEqual(events, ['authenticate', 'task-put:prj_default:task-a']);
});
test('audits authentication rejection before returning a challenge', async () => {
const events = [];
const { admission } = fixture({
@@ -76,6 +76,15 @@ test('loads one bounded offline Console asset closure', () => {
assert.match(text, /const LOG_READ_BYTES = 32 \* 1024/u);
assert.match(text, /new TextDecoder\('utf-8'\)/u);
assert.match(text, /日志已按保留策略清理/u);
assert.match(text, /method: 'PUT'/u);
assert.match(text, /x-qinglong-local-presence/u);
assert.match(text, /local_presence_required/u);
assert.match(text, /state\.pendingTaskMutation/u);
}
if (requestPath === '/') {
assert.match(text, /id="task-editor-dialog"/u);
assert.match(text, /id="presence-dialog"/u);
assert.match(text, /保存并生成本机证明/u);
}
}
assert.ok(totalBytes <= 192 * 1024);
@@ -52,12 +52,17 @@ function request(port, path, options = {}) {
function preparedAdmission(handler) {
return {
async prepare(value) {
const json = ['run.cancel', 'task.start'].includes(
const json = ['run.cancel', 'task.start', 'task.put'].includes(
value.operation.operationId,
);
return {
bodyMode: json ? 'json' : 'none',
maximumBodyBytes: json ? 512 : 0,
maximumBodyBytes:
value.operation.operationId === 'task.put'
? 72 * 1024
: json
? 512
: 0,
handle(body) {
return handler(value, body);
},
@@ -77,7 +82,8 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
observed.push(value);
if (
value.operation.operationId === 'run.cancel' ||
value.operation.operationId === 'task.start'
value.operation.operationId === 'task.start' ||
value.operation.operationId === 'task.put'
) {
return { statusCode: 202, body: { accepted: body } };
}
@@ -283,12 +289,36 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
taskId: 'task_1',
});
const taskPutBody = JSON.stringify({ name: 'Task one' });
const taskPut = await request(
port,
'/api/v3/projects/prj_default/tasks/task_1',
{
method: 'PUT',
headers: {
authorization: 'Bearer opaque',
'x-qinglong-local-presence': 'ql3p_request_bound_proof',
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(taskPutBody)),
},
body: taskPutBody,
},
);
assert.equal(taskPut.statusCode, 202);
assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody));
assert.deepEqual(observed[8].operation, {
operationId: 'task.put',
projectId: 'prj_default',
taskId: 'task_1',
});
assert.equal(observed[8].localPresence, 'ql3p_request_bound_proof');
const log = await request(
port,
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32',
);
assert.deepEqual(log.body, { range: { offset: 4, length: 32 } });
assert.deepEqual(observed[8].operation, {
assert.deepEqual(observed[9].operation, {
operationId: 'run.log.read',
projectId: 'prj_default',
runId: 'run_123',
@@ -374,7 +404,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
assert.equal(invalid.statusCode, 400);
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
}
assert.equal(observed.length, 10);
assert.equal(observed.length, 11);
assert.deepEqual(
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
['stopped', 'stopped'],
@@ -0,0 +1,136 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const {
LocalPresenceProofUnavailableError,
createLocalPresenceProofManager,
} = require('../dist/authentication/localPresenceProof.js');
function root(t) {
const value = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-presence-'));
fs.chmodSync(value, 0o700);
t.after(() => fs.rmSync(value, { recursive: true, force: true }));
return value;
}
function binding(overrides = {}) {
return Object.freeze({
requestDigest: 'a'.repeat(64),
credentialId: 'owner-console',
credentialVersion: 1,
subjectType: 'user',
subjectId: 'owner',
...overrides,
});
}
function uuidFactory() {
let sequence = 0;
return () => {
sequence += 1;
return `019f9000-0000-4000-8000-${String(sequence).padStart(12, '0')}`;
};
}
test('publishes a private request-bound proof and consumes it exactly once', (t) => {
const deploymentRoot = root(t);
const manager = createLocalPresenceProofManager({
deploymentRoot,
profile: 'edge',
now: () => 1_000,
randomUuid: uuidFactory(),
randomSecret: () => Buffer.alloc(32, 7),
});
t.after(() => manager.close());
const challenge = manager.issue(binding());
const directory = path.join(deploymentRoot, 'console-presence');
const filePath = path.join(directory, challenge.proofFileName);
assert.equal(fs.statSync(directory).mode & 0o777, 0o700);
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600);
const payload = JSON.parse(fs.readFileSync(filePath, 'utf8'));
assert.equal(payload.authorizationId, challenge.authorizationId);
assert.equal(payload.requestDigest, challenge.requestDigest);
assert.match(payload.proof, /^ql3p_/);
assert.equal(JSON.stringify(payload).includes('owner-console'), false);
assert.equal(JSON.stringify(payload).includes('owner'), false);
const consumed = manager.consume(payload.proof, binding());
assert.deepEqual(consumed, {
authorizationId: challenge.authorizationId,
authenticatedAtMs: 1_000,
expiresAtMs: 121_000,
});
assert.equal(fs.existsSync(filePath), false);
assert.equal(manager.consume(payload.proof, binding()), null);
});
test('rejects wrong request, credential and proof without consuming the valid authorization', (t) => {
const deploymentRoot = root(t);
const manager = createLocalPresenceProofManager({
deploymentRoot,
profile: 'standalone',
now: () => 2_000,
randomUuid: uuidFactory(),
randomSecret: () => Buffer.alloc(32, 9),
});
t.after(() => manager.close());
const challenge = manager.issue(binding());
const filePath = path.join(
deploymentRoot,
'console-presence',
challenge.proofFileName,
);
const proof = JSON.parse(fs.readFileSync(filePath, 'utf8')).proof;
assert.equal(
manager.consume(proof, binding({ requestDigest: 'b'.repeat(64) })),
null,
);
assert.equal(manager.consume(proof, binding({ credentialVersion: 2 })), null);
assert.equal(manager.consume(`${proof.slice(0, -1)}A`, binding()), null);
assert.equal(fs.existsSync(filePath), true);
assert.equal(
manager.consume(proof, binding()).authorizationId,
challenge.authorizationId,
);
});
test('bounds pending Edge authorizations and lazily removes expired proof files', (t) => {
const deploymentRoot = root(t);
let now = 3_000;
const manager = createLocalPresenceProofManager({
deploymentRoot,
profile: 'edge',
now: () => now,
randomUuid: uuidFactory(),
randomSecret: () => Buffer.alloc(32, 11),
});
t.after(() => manager.close());
for (let index = 0; index < 8; index += 1) {
manager.issue(
binding({ requestDigest: index.toString(16).padStart(64, '0') }),
);
}
assert.throws(
() => manager.issue(binding({ requestDigest: 'f'.repeat(64) })),
LocalPresenceProofUnavailableError,
);
assert.equal(
fs.readdirSync(path.join(deploymentRoot, 'console-presence')).length,
8,
);
now += 120_000;
manager.issue(binding({ requestDigest: 'f'.repeat(64) }));
assert.equal(
fs.readdirSync(path.join(deploymentRoot, 'console-presence')).length,
1,
);
manager.close();
assert.equal(
fs.readdirSync(path.join(deploymentRoot, 'console-presence')).length,
0,
);
});
@@ -41,7 +41,7 @@ const {
LocalRunAttemptLogRangeReader,
} = require('../../ql3-local-execution/dist/artifact-read/localRunAttemptLogRangeReader.js');
const NOW = 1_800_000_000_000;
const NOW = Date.now();
const PEPPER_KEY_ID = 'local-api-pepper-v1';
const CREDENTIAL_ID = 'local-api-owner';
const RUN_ID = 'run_local_api_1';
@@ -437,6 +437,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
},
),
taskDefinitions: runtime.taskDefinitions,
taskDefinitionAdministrationForCredential:
runtime.taskDefinitionAdministrationForCredential,
apiCredentials: runtime.apiCredentials,
ownerPepper: runtime.ownerPepper,
projectPolicy: runtime.projectPolicy,
@@ -526,6 +528,64 @@ test('serves an authenticated Run through one real SQLite authority and durable
{ statusCode: 404, body: { code: 'task_not_found' } },
);
const taskCreateBody = JSON.stringify({
expectedRevision: null,
mutationId: '019f7300-0000-4000-8000-000000000701',
name: 'Console-created Task',
description: 'Created through request-scoped local presence',
kind: 'command',
spec: {
schema: 'qinglong/command@v1',
config: {
command: {
kind: 'argv',
file: '/bin/echo',
args: ['console-created'],
},
},
},
labels: { source: 'local-console' },
enabled: true,
occurredAtMs: NOW,
});
const taskCreatePath = '/api/v3/projects/default/tasks/task-console-created';
const taskCreateOptions = {
method: 'PUT',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(taskCreateBody)),
},
body: taskCreateBody,
};
const challenge = await request(
port,
`Bearer ${TOKEN}`,
taskCreatePath,
taskCreateOptions,
);
assert.equal(challenge.statusCode, 428);
assert.equal(challenge.body.code, 'local_presence_required');
assert.match(challenge.body.requestDigest, /^[0-9a-f]{64}$/);
const proofDocument = JSON.parse(
fs.readFileSync(
path.join(root, 'console-presence', challenge.body.proofFileName),
'utf8',
),
);
const taskCreated = await request(port, `Bearer ${TOKEN}`, taskCreatePath, {
...taskCreateOptions,
headers: {
...taskCreateOptions.headers,
'x-qinglong-local-presence': proofDocument.proof,
},
});
assert.equal(taskCreated.statusCode, 201);
assert.equal(taskCreated.body.status, 'created');
assert.equal(taskCreated.body.task.taskId, 'task-console-created');
assert.equal(taskCreated.body.task.revision, 1);
assert.equal(JSON.stringify(taskCreated).includes('console-created'), true);
assert.equal(JSON.stringify(taskCreated).includes('/bin/echo'), false);
const taskStartBody = JSON.stringify({
schema: 'qinglong/task-start@v1',
mutationId: '019f7300-0000-7000-8000-000000000800',
@@ -685,7 +745,7 @@ test('serves an authenticated Run through one real SQLite authority and durable
`SELECT operation_id, outcome FROM "QingLong3SecurityAuditEvents"
WHERE operation_id IN (
'run.get', 'run.list', 'run.events.list', 'run.steps.list',
'run.cancel', 'task.get', 'task.list'
'run.cancel', 'task.create', 'task.get', 'task.list'
, 'task.start', 'run.log.read'
)
ORDER BY operation_id, outcome`,
@@ -701,6 +761,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
'run.list:allowed',
'run.log.read:allowed',
'run.steps.list:allowed',
'task.create:allowed',
'task.create:approval_required',
'task.get:allowed',
'task.get:allowed',
'task.list:allowed',
@@ -0,0 +1,292 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const {
createTaskDefinitionRecord,
} = require('@qinglong/runtime-core/task-definition');
const {
createLocalPresenceProofManager,
} = require('../dist/authentication/localPresenceProof.js');
const { createLocalApiTaskPutRoute } = require('../dist/task/taskPutRoute.js');
const PRINCIPAL = Object.freeze({
subject: Object.freeze({ type: 'user', id: 'owner' }),
authenticationId: 'local_credential:owner-console:1',
authenticatedAtMs: 9_000,
expiresAtMs: 60_000,
assurance: 'single_factor',
});
const FENCE = Object.freeze({
credentialId: 'owner-console',
credentialVersion: 1,
pepperKeyId: 'owner-v1',
materialDigest: 'a'.repeat(64),
subjectType: 'user',
subjectId: 'owner',
secretDigest: 'b'.repeat(64),
notBeforeAtMs: 1,
expiresAtMs: 60_000,
});
function taskBody(overrides = {}) {
return Object.freeze({
expectedRevision: null,
mutationId: '019f9000-0000-4000-8000-000000000101',
name: 'Presence-bound Task',
description: 'Created from the Local Console mutation route',
kind: 'command',
spec: Object.freeze({
schema: 'qinglong/command@v1',
config: Object.freeze({
command: Object.freeze({
kind: 'argv',
file: '/bin/echo',
args: Object.freeze(['hello']),
}),
}),
}),
labels: Object.freeze({ 'qinglong.test': 'presence' }),
enabled: true,
occurredAtMs: 10_000,
...overrides,
});
}
function uuidFactory() {
let sequence = 200;
return () => {
sequence += 1;
return `019f9000-0000-4000-8000-${String(sequence).padStart(12, '0')}`;
};
}
function fixture(t, overrides = {}) {
const deploymentRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-task-put-'),
);
fs.chmodSync(deploymentRoot, 0o700);
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
let now = 10_000;
const calls = [];
const presenceProof = createLocalPresenceProofManager({
deploymentRoot,
profile: 'edge',
now: () => now,
randomUuid: uuidFactory(),
randomSecret: () => Buffer.alloc(32, 13),
});
t.after(() => presenceProof.close());
const projectPolicy = {
async resolve(projectId, subject) {
calls.push(['policy', projectId, subject]);
return {
project: {
id: projectId,
name: 'Default',
slug: 'default',
status: 'active',
version: 3,
createdAtMs: 1,
updatedAtMs: 2,
},
binding: {
projectId,
subject,
version: 5,
state: 'active',
role: 'owner',
mutationId: 'owner-binding',
changedBy: { type: 'user', id: 'bootstrap-owner' },
createdAtMs: 2,
},
};
},
async append() {
throw new Error('not used');
},
};
const taskDefinitions = {
async findCurrentTaskDefinition() {
return null;
},
async findTaskDefinitionRevision() {
return null;
},
async listTaskDefinitions() {
return { definitions: [], truncated: false };
},
};
const route = createLocalApiTaskPutRoute({
projectPolicy,
taskDefinitions,
taskDefinitionAdministrationForCredential(fence) {
calls.push(['credential-fence', fence]);
return {
async appendAuthorizedTaskDefinitionRevision(mutation) {
calls.push(['mutation', mutation]);
return {
status:
mutation.command.expectedRevision === null
? 'created'
: 'updated',
definition: createTaskDefinitionRecord(mutation.command, now),
};
},
};
},
securityAudit: {
async record(record) {
calls.push(['audit', record]);
},
},
presenceProof,
now: () => now,
randomUuid: uuidFactory(),
...overrides,
});
const authenticated = Object.freeze({
principal: PRINCIPAL,
credentialFence: FENCE,
async confirm() {
calls.push(['confirm']);
},
});
return {
route,
calls,
deploymentRoot,
authenticated,
setNow(value) {
now = value;
},
};
}
function request(state, body, overrides = {}) {
return Object.freeze({
requestId: 'local:019f9000-0000-4000-8000-000000000301',
projectId: 'default',
taskId: 'task-console',
body,
presence: null,
authenticated: state.authenticated,
signal: new AbortController().signal,
...overrides,
});
}
function readProof(state, response) {
const value = JSON.parse(
fs.readFileSync(
path.join(
state.deploymentRoot,
'console-presence',
response.body.proofFileName,
),
'utf8',
),
);
return value.proof;
}
test('requires local presence, re-confirms the credential and commits Policy/audit/mutation through a request fence', async (t) => {
const state = fixture(t);
const body = taskBody();
const challenge = await state.route.handle(request(state, body));
assert.equal(challenge.statusCode, 428);
assert.equal(challenge.body.code, 'local_presence_required');
assert.match(challenge.body.requestDigest, /^[a-f0-9]{64}$/);
assert.match(challenge.body.proofFileName, /^[0-9a-f-]+\.json$/);
const proof = readProof(state, challenge);
const created = await state.route.handle(
request(state, body, { presence: proof }),
);
assert.equal(created.statusCode, 201);
assert.equal(created.body.status, 'created');
assert.equal(created.body.task.taskId, 'task-console');
assert.equal(created.body.task.revision, 1);
assert.equal(state.calls.filter(([kind]) => kind === 'confirm').length, 1);
assert.equal(
state.calls.filter(([kind]) => kind === 'credential-fence').length,
1,
);
const mutation = state.calls.find(([kind]) => kind === 'mutation')[1];
assert.equal(mutation.actor.type, 'user');
assert.equal(mutation.actor.id, 'owner');
assert.deepEqual(mutation.fence, {
projectVersion: 3,
bindingVersion: 5,
});
assert.equal(mutation.audit.outcome, 'allowed');
assert.equal(
mutation.audit.authenticationId.startsWith('local_presence:'),
true,
);
assert.deepEqual(
state.calls
.filter(([kind]) => kind === 'audit')
.map(([, audit]) => [audit.operationId, audit.outcome, audit.reasons[0]]),
[['task.create', 'approval_required', 'local_presence_required']],
);
});
test('binds the proof to exact Task content and leaves it usable only for the original request', async (t) => {
const state = fixture(t);
const body = taskBody();
const challenge = await state.route.handle(request(state, body));
const proof = readProof(state, challenge);
const changed = await state.route.handle(
request(state, taskBody({ name: 'Changed after challenge' }), {
presence: proof,
}),
);
assert.deepEqual(changed, {
statusCode: 401,
body: { code: 'local_presence_rejected' },
});
const created = await state.route.handle(
request(state, body, { presence: proof }),
);
assert.equal(created.statusCode, 201);
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 1);
});
test('fails closed for malformed bodies, non-User credentials and expired presence proofs', async (t) => {
const state = fixture(t);
assert.deepEqual(
await state.route.handle(request(state, { name: 'partial' })),
{ statusCode: 400, body: { code: 'invalid_task_definition' } },
);
const serviceCredential = Object.freeze({
...state.authenticated,
principal: Object.freeze({
...PRINCIPAL,
subject: Object.freeze({ type: 'system', id: 'runtime' }),
assurance: 'service',
}),
credentialFence: Object.freeze({
...FENCE,
subjectType: 'system',
subjectId: 'runtime',
}),
});
assert.deepEqual(
await state.route.handle(
request(state, taskBody(), { authenticated: serviceCredential }),
),
{ statusCode: 401, body: { code: 'strong_authentication_required' } },
);
const challenge = await state.route.handle(request(state, taskBody()));
const proof = readProof(state, challenge);
state.setNow(challenge.body.expiresAtMs);
assert.deepEqual(
await state.route.handle(request(state, taskBody(), { presence: proof })),
{ statusCode: 401, body: { code: 'local_presence_rejected' } },
);
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 0);
});