feat(ql3): add secure console cron scheduling

This commit is contained in:
whyour
2026-08-29 17:13:09 +08:00
parent 951de26ffd
commit b970e2aede
30 changed files with 1916 additions and 31 deletions
@@ -19,6 +19,9 @@ type LocalSqliteTaskStartRepository = ReadyLocalStorage['taskStartRepository'];
type TaskDefinitionRepository = ReadyLocalStorage['taskDefinitions'];
type TaskDefinitionAdministrationForCredential =
ReadyLocalStorage['taskDefinitionAdministrationForCredential'];
type TriggerRepository = ReadyLocalStorage['triggers'];
type TriggerAdministrationForCredential =
ReadyLocalStorage['triggerAdministrationForCredential'];
type LocalScheduleStore = ReadyLocalStorage['schedules'];
type LocalDispatchStore = ReadyLocalStorage['dispatch'];
type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets'];
@@ -90,6 +93,8 @@ export type LocalAdoptedProfileBootstrapResult =
readonly taskStartRepository: LocalSqliteTaskStartRepository;
readonly taskDefinitions: TaskDefinitionRepository;
readonly taskDefinitionAdministrationForCredential: TaskDefinitionAdministrationForCredential;
readonly triggers: TriggerRepository;
readonly triggerAdministrationForCredential: TriggerAdministrationForCredential;
readonly schedules: LocalScheduleStore;
readonly dispatch: LocalDispatchStore;
readonly executionControl: ReadyLocalStorage['executionControl'];
@@ -240,6 +245,9 @@ export async function bootstrapLocalAdoptedProfileStorage(
taskDefinitions: readyStorage.taskDefinitions,
taskDefinitionAdministrationForCredential:
readyStorage.taskDefinitionAdministrationForCredential,
triggers: readyStorage.triggers,
triggerAdministrationForCredential:
readyStorage.triggerAdministrationForCredential,
schedules: readyStorage.schedules,
dispatch: readyStorage.dispatch,
executionControl: readyStorage.executionControl,
@@ -784,6 +784,7 @@ textarea:focus-visible,
.editor-grid input:not([type='checkbox']),
.editor-grid textarea,
.editor-grid select,
.presence-input input {
width: 100%;
padding: 11px 12px;
@@ -3,6 +3,7 @@
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 CRON_FIELD_PATTERN = /^[0-9A-Za-z*?,/#LW-]+$/;
const TOKEN_PATTERN =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
const PRESENCE_PATTERN =
@@ -51,6 +52,11 @@
'暂时无法建立安全编辑会话。请稍后重新读取 Task。',
invalid_task_definition: 'Task 定义无效。请检查 ID、命令与参数。',
task_definition_unavailable: 'Task 暂时无法保存。请检查数据库状态。',
trigger_query_unavailable: '定时触发器暂时不可读取。请检查数据库状态。',
trigger_fence_rejected:
'Trigger、Task 或授权在确认期间发生变化。请刷新后重新编辑。',
invalid_trigger: '定时配置无效。请检查表达式、时区与 Task 状态。',
trigger_unavailable: '定时配置暂时无法保存。请检查数据库状态。',
run_cancellation_fence_rejected:
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
@@ -71,6 +77,7 @@
description: document.getElementById('section-description'),
refresh: document.getElementById('refresh-button'),
createTask: document.getElementById('create-task-button'),
createTrigger: document.getElementById('create-trigger-button'),
dialog: document.getElementById('confirmation-dialog'),
dialogTitle: document.getElementById('confirmation-title'),
dialogCopy: document.getElementById('confirmation-copy'),
@@ -89,6 +96,18 @@
taskArgs: document.getElementById('task-args-input'),
taskEnabled: document.getElementById('task-enabled-input'),
taskEnabledLabel: document.getElementById('task-enabled-label'),
triggerEditor: document.getElementById('trigger-editor-dialog'),
triggerEditorTitle: document.getElementById('trigger-editor-title'),
triggerEditorIntro: document.getElementById('trigger-editor-intro'),
triggerEditorForm: document.getElementById('trigger-editor-form'),
triggerEditorClose: document.getElementById('trigger-editor-close'),
triggerEditorSave: document.getElementById('trigger-editor-save'),
triggerId: document.getElementById('trigger-id-input'),
triggerTaskId: document.getElementById('trigger-task-id-input'),
triggerExpression: document.getElementById('trigger-expression-input'),
triggerTimezone: document.getElementById('trigger-timezone-input'),
triggerMisfire: document.getElementById('trigger-misfire-input'),
triggerEnabled: document.getElementById('trigger-enabled-input'),
presenceDialog: document.getElementById('presence-dialog'),
presenceForm: document.getElementById('presence-form'),
presenceCopy: document.getElementById('presence-copy'),
@@ -109,6 +128,7 @@
pendingAction: null,
pendingPresence: null,
authoringSnapshot: null,
triggerSnapshot: null,
toastTimer: null,
};
@@ -487,6 +507,121 @@
});
}
function openTriggerEditor(snapshot = null, task = null) {
state.triggerSnapshot = snapshot;
nodes.triggerEditorForm.reset();
const editing = snapshot !== null;
nodes.triggerEditorTitle.textContent = editing
? '编辑定时触发器'
: '创建定时触发器';
nodes.triggerEditorIntro.textContent = editing
? `将基于 Trigger revision ${snapshot.revision} 写入新 revision,并重新绑定 Task 当前内容。`
: 'Trigger 会绑定 Task 当前 revision 与内容摘要;Task 改变后需重新保存定时配置。';
nodes.triggerId.readOnly = editing;
nodes.triggerTaskId.readOnly = editing;
if (editing) {
nodes.triggerId.setAttribute('aria-readonly', 'true');
nodes.triggerTaskId.setAttribute('aria-readonly', 'true');
nodes.triggerId.value = snapshot.triggerId;
nodes.triggerTaskId.value = snapshot.taskId;
nodes.triggerExpression.value = snapshot.spec.config.expression;
nodes.triggerTimezone.value = snapshot.spec.config.timezone;
nodes.triggerMisfire.value = snapshot.spec.config.misfirePolicy;
nodes.triggerEnabled.checked = snapshot.enabled;
} else {
nodes.triggerId.removeAttribute('aria-readonly');
nodes.triggerTaskId.removeAttribute('aria-readonly');
nodes.triggerExpression.value = '0 * * * *';
nodes.triggerTimezone.value = 'UTC';
nodes.triggerMisfire.value = 'skip';
nodes.triggerEnabled.checked = true;
if (task) {
nodes.triggerTaskId.value = task.taskId;
nodes.triggerId.value = `cron:${task.taskId}`;
}
}
nodes.triggerEditor.returnValue = '';
nodes.triggerEditor.showModal();
(editing || task ? nodes.triggerExpression : nodes.triggerId).focus();
}
async function triggerDraft() {
const triggerId = nodes.triggerId.value.trim();
const taskId = nodes.triggerTaskId.value.trim();
const expression = nodes.triggerExpression.value
.trim()
.replace(/\s+/gu, ' ');
const timezone = nodes.triggerTimezone.value.trim();
const fields = expression.split(' ');
if (!TASK_PATTERN.test(triggerId) || !TASK_PATTERN.test(taskId)) {
throw new TypeError('Trigger ID 或 Task ID 格式无效。');
}
if (
(fields.length !== 5 && fields.length !== 6) ||
fields.some((field) => !CRON_FIELD_PATTERN.test(field)) ||
!timezone
) {
throw new TypeError('Cron 表达式或时区无效。');
}
const taskValue = await api(
`/api/v3/projects/${state.project}/tasks/${taskId}`,
);
const task = taskValue.task;
if (
!task ||
task.taskId !== taskId ||
!Number.isSafeInteger(task.revision) ||
!/^[a-f0-9]{64}$/u.test(task.contentDigest)
) {
throw new ConsoleRequestError('response_unavailable', 503, null);
}
const snapshot = state.triggerSnapshot;
return Object.freeze({
triggerId,
body: Object.freeze({
expectedRevision: snapshot ? snapshot.revision : null,
mutationId: newMutationId(),
taskId,
taskRevision: task.revision,
taskContentDigest: task.contentDigest,
spec: Object.freeze({
schema: 'qinglong/cron@v1',
config: Object.freeze({
expression,
timezone,
misfirePolicy: nodes.triggerMisfire.value,
}),
}),
enabled: nodes.triggerEnabled.checked,
occurredAtMs: Date.now(),
}),
});
}
async function saveTriggerDraft() {
nodes.triggerEditorSave.disabled = true;
try {
const mutation = await triggerDraft();
const value = await api(
`/api/v3/projects/${state.project}/triggers/${mutation.triggerId}`,
{
method: 'PUT',
body: mutation.body,
acceptStatus: 428,
},
);
if (value.code === 'local_presence_required') {
showPresenceChallenge({ kind: 'trigger-mutation', mutation }, value);
return;
}
throw new ConsoleRequestError('response_unavailable', 503, null);
} catch (error) {
showToast(describeError(error), 'error');
} finally {
nodes.triggerEditorSave.disabled = false;
}
}
function showPresenceChallenge(action, challenge) {
if (
challenge?.code !== 'local_presence_required' ||
@@ -498,8 +633,11 @@
}
state.pendingPresence = Object.freeze({ ...action, challenge });
const authoringRead = action.kind === 'authoring';
const triggerMutation = action.kind === 'trigger-mutation';
nodes.presenceCopy.textContent = authoringRead
? '读取完整 Task 定义需要部署设备上的一次性证明。返回的编辑租约不替代保存时的新内容证明。'
: triggerMutation
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Trigger 与 Task revision,且只能使用一次。'
: '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。';
nodes.presenceSubmit.textContent = authoringRead
? '验证并加载定义'
@@ -514,6 +652,7 @@
nodes.presenceError.textContent = '';
nodes.presenceError.hidden = true;
nodes.taskEditor.close();
nodes.triggerEditor.close();
nodes.presenceDialog.returnValue = '';
nodes.presenceDialog.showModal();
nodes.presenceProof.focus();
@@ -600,6 +739,34 @@
showToast('完整 Task 定义已加载;保存仍需要新的本机证明。');
return;
}
if (pending.kind === 'trigger-mutation') {
const value = await api(
`/api/v3/projects/${state.project}/triggers/${pending.mutation.triggerId}`,
{
method: 'PUT',
body: pending.mutation.body,
presence: proof,
},
);
const updated = pending.mutation.body.expectedRevision !== null;
state.pendingPresence = null;
state.triggerSnapshot = null;
nodes.presenceProof.value = '';
nodes.presenceDialog.close();
showToast(
value.status === 'existing'
? '已找到同一 Trigger 请求。'
: updated
? '定时触发器已更新。'
: '定时触发器已创建。',
);
state.view = 'triggers';
state.selectedId = pending.mutation.triggerId;
updateNavigation();
await refresh();
await selectTrigger(pending.mutation.triggerId);
return;
}
const value = await api(
`/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`,
{
@@ -726,6 +893,9 @@
if (task.kind === 'command' && task.specSchema === 'qinglong/command@v1') {
actions.append(actionButton('编辑任务', () => beginTaskAuthoring(task)));
}
actions.append(
actionButton('添加定时', () => openTriggerEditor(null, task)),
);
if (task.enabled) {
actions.append(
actionButton('运行一次', () => {
@@ -771,6 +941,126 @@
}
}
async function renderTriggers() {
const value = await api(
`/api/v3/projects/${state.project}/triggers?limit=64`,
);
const triggers = Array.isArray(value.triggers) ? value.triggers : [];
if (triggers.length === 0) {
empty('还没有定时触发器。创建后,现有本地调度器会按 cron 自动生成 Run。');
return;
}
const fragment = document.createDocumentFragment();
fragment.append(listHeader('Cron trigger ledger', triggers.length));
const list = element('div', 'record-list');
for (const trigger of triggers) {
const button = element('button', 'record');
button.type = 'button';
button.dataset.identity = trigger.triggerId;
if (state.selectedId === trigger.triggerId) {
button.setAttribute('aria-current', 'true');
}
const main = element('span');
main.append(element('span', 'record-title', trigger.triggerId));
main.append(
recordMeta([
trigger.taskId,
`trigger rev ${trigger.revision}`,
`task rev ${trigger.taskRevision}`,
]),
);
const side = element('span', 'record-side');
const enabled = element(
'span',
'status',
trigger.enabled ? '自动执行' : '已停用',
);
enabled.dataset.tone = trigger.enabled ? 'active' : 'quiet';
side.append(enabled);
side.append(
element('span', 'record-time', formatTime(trigger.updatedAtMs)),
);
button.append(main, side);
button.addEventListener('click', () => selectTrigger(trigger.triggerId));
list.append(button);
}
fragment.append(list);
if (value.truncated) {
fragment.append(
element(
'p',
'privacy-note',
'当前只展示前 64 条;使用 API 可继续读取下一页。',
),
);
}
replace(nodes.ledger, fragment);
}
async function selectTrigger(triggerId) {
state.selectedId = triggerId;
for (const row of nodes.ledger.querySelectorAll('.record')) {
if (row.dataset.identity === triggerId) {
row.setAttribute('aria-current', 'true');
} else {
row.removeAttribute('aria-current');
}
}
const loadingBox = element('div', 'loading-state');
loadingBox.append(element('span'));
replace(nodes.detail, loadingBox);
try {
const value = await api(
`/api/v3/projects/${state.project}/triggers/${triggerId}`,
);
renderTriggerDetail(value.trigger);
} catch (error) {
detailEmpty(describeError(error));
}
}
function renderTriggerDetail(trigger) {
const config = trigger?.spec?.config;
const fragment = document.createDocumentFragment();
fragment.append(
detailHeader('Cron trigger', trigger.triggerId, trigger.taskId),
);
const facts = element('div', 'facts');
facts.append(
fact('状态', trigger.enabled ? '自动执行' : '已停用'),
fact('Revision', trigger.revision),
fact('Cron', config?.expression || '—'),
fact('时区', config?.timezone || '—'),
fact('Misfire', config?.misfirePolicy || '—'),
fact(
'Task fence',
`rev ${trigger.taskRevision} · ${shortDigest(
trigger.taskContentDigest,
)}`,
),
fact('Content fence', shortDigest(trigger.contentDigest)),
fact('更新时间', formatTime(trigger.updatedAtMs)),
);
fragment.append(facts);
const actions = element('div', 'detail-actions');
if (trigger.spec?.schema === 'qinglong/cron@v1') {
actions.append(
actionButton('编辑或停用', () => openTriggerEditor(trigger)),
);
}
actions.append(
actionButton('查看绑定任务', async () => {
state.view = 'tasks';
state.selectedId = trigger.taskId;
updateNavigation();
await refresh();
await selectTask(trigger.taskId);
}),
);
fragment.append(actions);
replace(nodes.detail, fragment);
}
async function renderRuns() {
const value = await api(`/api/v3/projects/${state.project}/runs?limit=64`);
const runs = Array.isArray(value.runs) ? value.runs : [];
@@ -1035,12 +1325,21 @@
}
if (state.view === 'tasks') {
nodes.createTask.hidden = false;
nodes.createTrigger.hidden = true;
nodes.kicker.textContent = 'Project task authority';
nodes.title.textContent = '任务调度台';
nodes.description.textContent =
'创建命令 Task,查看当前 revision 与内容围栏。管理写入需要部署设备上的一次性本机证明。';
} else if (state.view === 'triggers') {
nodes.createTask.hidden = true;
nodes.createTrigger.hidden = false;
nodes.kicker.textContent = 'Durable cron authority';
nodes.title.textContent = '定时触发器';
nodes.description.textContent =
'配置内置 cron Trigger,绑定 Task 当前 revision;停用只追加历史,不删除证据。';
} else {
nodes.createTask.hidden = true;
nodes.createTrigger.hidden = true;
nodes.kicker.textContent = 'Durable run evidence';
nodes.title.textContent = '运行事实账本';
nodes.description.textContent =
@@ -1053,6 +1352,7 @@
loading();
try {
if (state.view === 'tasks') await renderTasks();
else if (state.view === 'triggers') await renderTriggers();
else await renderRuns();
setConnection('connected', `${state.project} · 已连接`);
} catch (error) {
@@ -1086,7 +1386,9 @@
state.pendingAction = null;
state.pendingPresence = null;
state.authoringSnapshot = null;
state.triggerSnapshot = null;
if (nodes.taskEditor.open) nodes.taskEditor.close();
if (nodes.triggerEditor.open) nodes.triggerEditor.close();
if (nodes.presenceDialog.open) nodes.presenceDialog.close();
nodes.token.value = '';
nodes.token.disabled = false;
@@ -1096,6 +1398,7 @@
nodes.nav.hidden = true;
nodes.refresh.hidden = true;
nodes.createTask.hidden = true;
nodes.createTrigger.hidden = true;
setConnection('idle', '等待凭据');
nodes.kicker.textContent = 'Connection gate';
nodes.title.textContent = '先建立一条本机连接';
@@ -1133,6 +1436,7 @@
nodes.disconnect.addEventListener('click', disconnect);
nodes.refresh.addEventListener('click', refresh);
nodes.createTask.addEventListener('click', () => openTaskEditor());
nodes.createTrigger.addEventListener('click', () => openTriggerEditor());
nodes.taskEditorClose.addEventListener('click', () => {
state.authoringSnapshot = null;
nodes.taskEditor.close();
@@ -1141,9 +1445,18 @@
event.preventDefault();
await saveTaskDraft();
});
nodes.triggerEditorClose.addEventListener('click', () => {
state.triggerSnapshot = null;
nodes.triggerEditor.close();
});
nodes.triggerEditorForm.addEventListener('submit', async (event) => {
event.preventDefault();
await saveTriggerDraft();
});
nodes.presenceCancel.addEventListener('click', () => {
state.pendingPresence = null;
state.authoringSnapshot = null;
state.triggerSnapshot = null;
nodes.presenceProof.value = '';
nodes.presenceDialog.close();
});
@@ -1181,6 +1494,7 @@
event.target instanceof HTMLTextAreaElement ||
nodes.dialog.open ||
nodes.taskEditor.open ||
nodes.triggerEditor.open ||
nodes.presenceDialog.open
) {
return;
@@ -1188,6 +1502,8 @@
const view =
event.key.toLowerCase() === 't'
? 'tasks'
: event.key.toLowerCase() === 's'
? 'triggers'
: event.key.toLowerCase() === 'r'
? 'runs'
: null;
@@ -67,6 +67,9 @@
<button type="button" data-view="tasks" aria-current="page">
<span>任务</span><kbd>T</kbd>
</button>
<button type="button" data-view="triggers">
<span>定时</span><kbd>S</kbd>
</button>
<button type="button" data-view="runs">
<span>运行</span><kbd>R</kbd>
</button>
@@ -91,6 +94,9 @@
<button class="action-button" id="create-task-button" type="button" hidden>
<span aria-hidden="true"></span> 创建任务
</button>
<button class="action-button" id="create-trigger-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>
@@ -185,6 +191,58 @@
</form>
</dialog>
<dialog id="trigger-editor-dialog" class="task-editor-dialog">
<form id="trigger-editor-form" autocomplete="off">
<div class="dialog-heading">
<div>
<p class="eyebrow">Cron scheduling</p>
<h2 id="trigger-editor-title">创建定时触发器</h2>
</div>
<button class="quiet-button" id="trigger-editor-close" type="button">关闭</button>
</div>
<p class="editor-intro" id="trigger-editor-intro">
Trigger 会绑定 Task 当前 revision 与内容摘要;Task 改变后需重新保存定时配置。
</p>
<div class="editor-grid">
<label>
<span>Trigger ID</span>
<input id="trigger-id-input" maxlength="128" spellcheck="false" required />
</label>
<label>
<span>Task ID</span>
<input id="trigger-task-id-input" maxlength="128" spellcheck="false" required />
</label>
<label class="editor-wide">
<span>Cron 表达式 · 5 或 6 段</span>
<input id="trigger-expression-input" value="0 * * * *" maxlength="512" spellcheck="false" required />
</label>
<label>
<span>时区</span>
<input id="trigger-timezone-input" value="UTC" maxlength="128" spellcheck="false" required />
</label>
<label>
<span>错过执行窗口</span>
<select id="trigger-misfire-input">
<option value="skip">跳过</option>
<option value="fire_once">补跑一次</option>
</select>
</label>
<label class="editor-check">
<input id="trigger-enabled-input" type="checkbox" checked />
<span>保存后启用自动执行</span>
</label>
</div>
<p class="editor-note">
当前 Console 只开放内置 <code>qinglong/cron@v1</code>;停用会写入新 revision,不删除历史。
</p>
<div class="dialog-actions">
<button class="primary-button" id="trigger-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>
@@ -28,6 +28,11 @@ import type { LocalApiTaskReadRoute } from '../task/taskReadRoute';
import type { LocalApiTaskStartRoute } from '../task/taskStartRoute';
import type { LocalApiTaskPutRoute } from '../task/taskPutRoute';
import type { LocalApiTaskAuthoringRoute } from '../task/taskAuthoringRoute';
import type {
LocalApiTriggerListRoute,
LocalApiTriggerReadRoute,
} from '../trigger/triggerReadRoutes';
import type { LocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
import type { LocalApiResponse } from '../transport/contract';
export type LocalApiAdmissionOperation =
@@ -90,6 +95,22 @@ export type LocalApiAdmissionOperation =
operationId: 'task.authoring';
projectId: string;
taskId: string;
}>
| Readonly<{
operationId: 'trigger.list';
projectId: string;
limit: number;
after?: Readonly<{ readonly triggerId: string }>;
}>
| Readonly<{
operationId: 'trigger.get';
projectId: string;
triggerId: string;
}>
| Readonly<{
operationId: 'trigger.put';
projectId: string;
triggerId: string;
}>;
export interface LocalApiAdmissionRequest {
@@ -128,6 +149,9 @@ export interface LocalApiAdmissionOptions {
readonly taskStartRoute: LocalApiTaskStartRoute;
readonly taskPutRoute: LocalApiTaskPutRoute;
readonly taskAuthoringRoute: LocalApiTaskAuthoringRoute;
readonly triggerListRoute: LocalApiTriggerListRoute;
readonly triggerReadRoute: LocalApiTriggerReadRoute;
readonly triggerPutRoute: LocalApiTriggerPutRoute;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
@@ -208,6 +232,9 @@ export function createLocalApiAdmission(
typeof options.taskStartRoute?.handle !== 'function' ||
typeof options.taskPutRoute?.handle !== 'function' ||
typeof options.taskAuthoringRoute?.handle !== 'function' ||
typeof options.triggerListRoute?.handle !== 'function' ||
typeof options.triggerReadRoute?.handle !== 'function' ||
typeof options.triggerPutRoute?.handle !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function')
@@ -296,6 +323,25 @@ export function createLocalApiAdmission(
});
}
if (request.operation.operationId === 'trigger.put') {
const triggerPutOperation = request.operation;
return Object.freeze({
bodyMode: 'json' as const,
maximumBodyBytes: 20 * 1_024,
async handle(body: unknown | null) {
return options.triggerPutRoute.handle({
requestId: request.requestId,
projectId: triggerPutOperation.projectId,
triggerId: triggerPutOperation.triggerId,
body,
presence: request.localPresence,
authenticated,
signal: request.signal,
});
},
});
}
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = normalizeSecurityPolicyDecision(
@@ -309,7 +355,9 @@ export function createLocalApiAdmission(
: request.operation.operationId === 'task.start'
? 'run.start'
: request.operation.operationId === 'task.list' ||
request.operation.operationId === 'task.get'
request.operation.operationId === 'task.get' ||
request.operation.operationId === 'trigger.list' ||
request.operation.operationId === 'trigger.get'
? 'task.read'
: 'run.read',
),
@@ -443,8 +491,24 @@ export function createLocalApiAdmission(
principal: authenticated.principal,
policyFence: decision.fence,
});
case 'trigger.list':
if (body !== null) return response(400, 'invalid_request_body');
return options.triggerListRoute.handle({
projectId: request.operation.projectId,
limit: request.operation.limit,
...(request.operation.after
? { after: request.operation.after }
: {}),
});
case 'trigger.get':
if (body !== null) return response(400, 'invalid_request_body');
return options.triggerReadRoute.handle({
projectId: request.operation.projectId,
triggerId: request.operation.triggerId,
});
case 'task.put':
case 'task.authoring':
case 'trigger.put':
return response(503, 'request_unavailable');
}
},
@@ -22,6 +22,11 @@ import { createLocalApiTaskReadRoute } from '../task/taskReadRoute';
import { createLocalApiTaskStartRoute } from '../task/taskStartRoute';
import { createLocalApiTaskPutRoute } from '../task/taskPutRoute';
import { createLocalApiTaskAuthoringRoute } from '../task/taskAuthoringRoute';
import {
createLocalApiTriggerListRoute,
createLocalApiTriggerReadRoute,
} from '../trigger/triggerReadRoutes';
import { createLocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
import { startLocalApiHttpSurface } from '../transport/httpSurface';
export interface LocalApiProductSurfaceEvent {
@@ -165,6 +170,31 @@ export function createLocalApiProductSurface(
? {}
: { randomUuid: options.randomUuid }),
});
const triggerListRoute = createLocalApiTriggerListRoute(
authority.triggers,
);
const triggerReadRoute = createLocalApiTriggerReadRoute(
authority.triggers,
);
const triggerPutRoute = createLocalApiTriggerPutRoute({
projectPolicy: authority.projectPolicy,
triggers: authority.triggers,
triggerAdministrationForCredential: (fence) => {
if (fence.subjectType !== 'user') {
throw new TypeError('Trigger mutation requires a User credential');
}
return authority.triggerAdministrationForCredential({
...fence,
subjectType: 'user',
});
},
securityAudit: authority.securityAudit,
presenceProof,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
const admission = createLocalApiAdmission({
authenticator,
policy,
@@ -180,6 +210,9 @@ export function createLocalApiProductSurface(
taskStartRoute,
taskPutRoute,
taskAuthoringRoute,
triggerListRoute,
triggerReadRoute,
triggerPutRoute,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
@@ -0,0 +1,26 @@
import {
normalizeSecurityPrincipal,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
import type { AuthenticatedLocalApiRequest } from './credentialAuthenticator';
import type { ConsumedLocalPresenceProof } from './localPresenceProof';
export function strongLocalConsolePrincipal(
authenticated: Readonly<AuthenticatedLocalApiRequest>,
proof: Readonly<ConsumedLocalPresenceProof>,
): Readonly<SecurityPrincipal> {
return normalizeSecurityPrincipal(
{
subject: authenticated.principal.subject,
authenticationId: `local_presence:${proof.authorizationId}`,
authenticatedAtMs: proof.authenticatedAtMs,
expiresAtMs: Math.min(
proof.expiresAtMs,
authenticated.principal.expiresAtMs,
),
assurance: 'local_console',
},
proof.authenticatedAtMs,
);
}
@@ -14,7 +14,6 @@ import {
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPolicyDecision,
normalizeSecurityPrincipal,
type SecurityPolicyDecision,
} from '@qinglong/runtime-core/security';
import {
@@ -38,6 +37,7 @@ import {
} from '@qinglong/runtime-core/task-definition-administration';
import type { AuthenticatedLocalApiRequest } from '../authentication/credentialAuthenticator';
import { strongLocalConsolePrincipal } from '../authentication/strongLocalPrincipal';
import {
LocalPresenceProofUnavailableError,
type LocalPresenceBinding,
@@ -491,18 +491,9 @@ export function createLocalApiTaskPutRoute(
}
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,
strongPrincipal = strongLocalConsolePrincipal(
request.authenticated,
proof,
);
} catch {
return response(503, { code: 'authentication_unavailable' });
@@ -42,6 +42,10 @@ const TASK_START_ROUTE_PATTERN =
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs$/;
const TASK_AUTHORING_ROUTE_PATTERN =
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/authoring$/;
const TRIGGER_LIST_ROUTE_PATTERN =
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/triggers$/;
const TRIGGER_READ_ROUTE_PATTERN =
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/triggers\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})$/;
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY =
@@ -55,7 +59,8 @@ type LocalApiRouteResolution =
| 'invalid_run_event_list_query'
| 'invalid_run_step_list_query'
| 'invalid_run_log_read_query'
| 'invalid_task_list_query';
| 'invalid_task_list_query'
| 'invalid_trigger_list_query';
}>;
export interface LocalApiHttpSurfaceOptions {
@@ -393,6 +398,53 @@ function parseTaskListQuery(
});
}
function parseTriggerListQuery(
rawQuery: string | undefined,
profile: LocalApplicationProfile,
): Readonly<{
limit: number;
after?: Readonly<{ readonly triggerId: string }>;
}> {
if (rawQuery === undefined) {
return Object.freeze({ limit: profile === 'edge' ? 16 : 32 });
}
if (rawQuery.length === 0) throw new TypeError();
const values = new Map<string, string>();
for (const field of rawQuery.split('&')) {
const separator = field.indexOf('=');
if (
separator < 1 ||
separator !== field.lastIndexOf('=') ||
separator === field.length - 1
) {
throw new TypeError();
}
const name = field.slice(0, separator);
const value = field.slice(separator + 1);
if (values.has(name) || (name !== 'limit' && name !== 'after_trigger_id')) {
throw new TypeError();
}
values.set(name, value);
}
const rawLimit = values.get('limit');
const limit =
rawLimit === undefined ? (profile === 'edge' ? 16 : 32) : Number(rawLimit);
const triggerId = values.get('after_trigger_id');
if (
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > 64 ||
(rawLimit !== undefined && String(limit) !== rawLimit) ||
(triggerId !== undefined && !TASK_ID_PATTERN.test(triggerId))
) {
throw new TypeError();
}
return Object.freeze({
limit,
...(triggerId === undefined ? {} : { after: Object.freeze({ triggerId }) }),
});
}
function parseRunAttemptLogReadQuery(
rawQuery: string | undefined,
profile: LocalApplicationProfile,
@@ -482,6 +534,14 @@ function route(
: null;
}
if (request.method === 'PUT') {
const triggerPutMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
if (triggerPutMatch && rawQuery === undefined) {
return Object.freeze({
operationId: 'trigger.put',
projectId: triggerPutMatch[1]!,
triggerId: triggerPutMatch[2]!,
});
}
const taskPutMatch = TASK_READ_ROUTE_PATTERN.exec(path);
return taskPutMatch && rawQuery === undefined
? Object.freeze({
@@ -492,6 +552,29 @@ function route(
: null;
}
if (request.method !== 'GET') return null;
const triggerReadMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
if (triggerReadMatch) {
return rawQuery === undefined
? Object.freeze({
operationId: 'trigger.get',
projectId: triggerReadMatch[1]!,
triggerId: triggerReadMatch[2]!,
})
: null;
}
const triggerListMatch = TRIGGER_LIST_ROUTE_PATTERN.exec(path);
if (triggerListMatch) {
try {
const input = parseTriggerListQuery(rawQuery, profile);
return Object.freeze({
operationId: 'trigger.list',
projectId: triggerListMatch[1]!,
...input,
});
} catch {
return Object.freeze({ errorCode: 'invalid_trigger_list_query' });
}
}
const runAttemptLogReadMatch = RUN_ATTEMPT_LOG_READ_ROUTE_PATTERN.exec(path);
if (runAttemptLogReadMatch) {
try {
@@ -0,0 +1,469 @@
import { createHash, randomUUID } from 'node:crypto';
import {
LocalTriggerAdministrationAuthenticationError,
LocalTriggerAdministrationAuthorizationError,
LocalTriggerAdministrationConfigurationError,
LocalTriggerAdministrationUnavailableError,
createLocalTriggerAdministrationService,
} from '@qinglong/local-admin/trigger-administration';
import {
ProjectPolicyEngine,
ProjectPolicyUnavailableError,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPolicyDecision,
type SecurityPolicyDecision,
} from '@qinglong/runtime-core/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditOutcome,
type SecurityAuditSink,
} from '@qinglong/runtime-core/security-audit';
import {
InvalidTriggerError,
InvalidTriggerSpecSemanticError,
TriggerConflictError,
TriggerUnavailableError,
UnsupportedTriggerSpecError,
normalizeAppendTriggerRevisionCommand,
type AppendTriggerRevisionCommand,
type TriggerRecord,
type TriggerSource,
} from '@qinglong/runtime-core/trigger';
import {
TriggerAdministrationAuthorizationFenceConflictError,
TriggerAdministrationMutationConflictError,
type TriggerAdministrationRepository,
} from '@qinglong/runtime-core/trigger-administration';
import type { AuthenticatedLocalApiRequest } from '../authentication/credentialAuthenticator';
import {
LocalPresenceProofUnavailableError,
type LocalPresenceBinding,
type LocalPresenceProofManager,
} from '../authentication/localPresenceProof';
import { strongLocalConsolePrincipal } from '../authentication/strongLocalPrincipal';
import type { LocalApiResponse } from '../transport/contract';
const BODY_KEYS = Object.freeze([
'enabled',
'expectedRevision',
'mutationId',
'occurredAtMs',
'spec',
'taskContentDigest',
'taskId',
'taskRevision',
]);
export interface LocalApiTriggerPutRequest {
readonly requestId: string;
readonly projectId: string;
readonly triggerId: string;
readonly body: unknown | null;
readonly presence: string | null;
readonly authenticated: Readonly<AuthenticatedLocalApiRequest>;
readonly signal: AbortSignal;
}
export interface LocalApiTriggerPutRoute {
handle(
request: Readonly<LocalApiTriggerPutRequest>,
): Promise<LocalApiResponse>;
}
export interface LocalApiTriggerPutRouteOptions {
readonly projectPolicy: ProjectPolicyRepository;
readonly triggers: TriggerSource;
readonly triggerAdministrationForCredential: (
fence: Readonly<AuthenticatedLocalApiRequest['credentialFence']>,
) => Promise<TriggerAdministrationRepository>;
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,
triggerId: string,
): Readonly<AppendTriggerRevisionCommand> {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new InvalidTriggerError('HTTP body must be an object');
}
const keys = Object.keys(body).sort();
if (
BODY_KEYS.some((key) => !keys.includes(key)) ||
keys.some((key) => !BODY_KEYS.includes(key))
) {
throw new InvalidTriggerError('HTTP body has an invalid shape');
}
return normalizeAppendTriggerRevisionCommand({
projectId,
triggerId,
...(body as Omit<AppendTriggerRevisionCommand, 'projectId' | 'triggerId'>),
});
}
function operationId(
command: Readonly<AppendTriggerRevisionCommand>,
): 'trigger.create' | 'trigger.update' {
return command.expectedRevision === null
? 'trigger.create'
: 'trigger.update';
}
function requestDigest(
command: Readonly<AppendTriggerRevisionCommand>,
): string {
return createHash('sha256')
.update('qinglong3.local-api-trigger-put.v1\0', 'utf8')
.update(canonicalJson(command), 'utf8')
.digest('hex');
}
function presenceBinding(
command: Readonly<AppendTriggerRevisionCommand>,
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 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: 'trigger.create' | 'trigger.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;
}
}
function summary(trigger: Readonly<TriggerRecord>) {
return Object.freeze({
triggerId: trigger.triggerId,
revision: trigger.revision,
taskId: trigger.taskId,
taskRevision: trigger.taskRevision,
taskContentDigest: trigger.taskContentDigest,
spec: trigger.spec,
enabled: trigger.enabled,
contentDigest: trigger.contentDigest,
createdAtMs: trigger.createdAtMs,
updatedAtMs: trigger.updatedAtMs,
});
}
function isCredentialFenceConflict(error: unknown): boolean {
return (
!!error &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string' &&
error.code.startsWith('LOCAL_SQLITE_AUTHENTICATED_')
);
}
export function createLocalApiTriggerPutRoute(
options: Readonly<LocalApiTriggerPutRouteOptions>,
): Readonly<LocalApiTriggerPutRoute> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
typeof options.projectPolicy?.resolve !== 'function' ||
typeof options.triggers?.findCurrentTrigger !== 'function' ||
typeof options.triggers?.listTriggers !== 'function' ||
typeof options.triggerAdministrationForCredential !== '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 Trigger 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<LocalApiTriggerPutRequest>) {
if (request.signal.aborted) {
return response(503, { code: 'request_unavailable' });
}
let command: Readonly<AppendTriggerRevisionCommand>;
try {
command = normalizeBody(
request.body,
request.projectId,
request.triggerId,
);
} catch (error) {
return error instanceof InvalidTriggerError
? response(400, { code: 'invalid_trigger' })
: response(503, { code: 'trigger_unavailable' });
}
const operation = operationId(command);
let occurredAtMs: number;
try {
occurredAtMs = timestamp(now);
} catch {
return response(503, { code: 'local_presence_unavailable' });
}
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = normalizeSecurityPolicyDecision(
await policy.authorize(
request.authenticated.principal,
request.projectId,
'task.update',
),
);
} 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 = strongLocalConsolePrincipal(
request.authenticated,
proof,
);
} catch {
return response(503, { code: 'authentication_unavailable' });
}
try {
const mutations = await options.triggerAdministrationForCredential(
request.authenticated.credentialFence,
);
const service = createLocalTriggerAdministrationService(
options.projectPolicy,
mutations,
options.triggers,
options.securityAudit,
{ now },
);
const result = await service.put({
...command,
requestId: request.requestId,
principal: strongPrincipal,
});
return response(result.status === 'created' ? 201 : 200, {
status: result.status,
trigger: summary(result.trigger),
});
} catch (error) {
if (
error instanceof TriggerConflictError ||
error instanceof TriggerAdministrationMutationConflictError ||
error instanceof
TriggerAdministrationAuthorizationFenceConflictError ||
isCredentialFenceConflict(error)
) {
return response(409, { code: 'trigger_fence_rejected' });
}
if (error instanceof LocalTriggerAdministrationAuthenticationError) {
return response(401, { code: 'strong_authentication_required' });
}
if (error instanceof LocalTriggerAdministrationAuthorizationError) {
return response(403, { code: 'forbidden' });
}
if (
error instanceof InvalidTriggerError ||
error instanceof InvalidTriggerSpecSemanticError ||
error instanceof UnsupportedTriggerSpecError ||
error instanceof LocalTriggerAdministrationConfigurationError
) {
return response(400, { code: 'invalid_trigger' });
}
if (
error instanceof TriggerUnavailableError ||
error instanceof LocalTriggerAdministrationUnavailableError
) {
return response(503, { code: 'trigger_unavailable' });
}
return response(503, { code: 'trigger_unavailable' });
}
},
});
}
@@ -0,0 +1,121 @@
import {
InvalidTriggerError,
TriggerUnavailableError,
type TriggerRecord,
type TriggerSource,
} from '@qinglong/runtime-core/trigger';
import type { LocalApiResponse } from '../transport/contract';
export interface LocalApiTriggerListRequest {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<{ readonly triggerId: string }>;
}
export interface LocalApiTriggerReadRequest {
readonly projectId: string;
readonly triggerId: string;
}
export interface LocalApiTriggerListRoute {
handle(
request: Readonly<LocalApiTriggerListRequest>,
): Promise<LocalApiResponse>;
}
export interface LocalApiTriggerReadRoute {
handle(
request: Readonly<LocalApiTriggerReadRequest>,
): Promise<LocalApiResponse>;
}
function response(
statusCode: number,
body: Readonly<Record<string, unknown>>,
): LocalApiResponse {
return Object.freeze({ statusCode, body: Object.freeze(body) });
}
function summary(trigger: Readonly<TriggerRecord>) {
return Object.freeze({
triggerId: trigger.triggerId,
revision: trigger.revision,
taskId: trigger.taskId,
taskRevision: trigger.taskRevision,
specSchema: trigger.spec.schema,
enabled: trigger.enabled,
contentDigest: trigger.contentDigest,
createdAtMs: trigger.createdAtMs,
updatedAtMs: trigger.updatedAtMs,
});
}
function detail(trigger: Readonly<TriggerRecord>) {
return Object.freeze({
...summary(trigger),
projectId: trigger.projectId,
taskContentDigest: trigger.taskContentDigest,
spec: trigger.spec,
});
}
function unavailable(error: unknown): LocalApiResponse | null {
return error instanceof InvalidTriggerError ||
error instanceof TriggerUnavailableError
? response(503, { code: 'trigger_query_unavailable' })
: null;
}
export function createLocalApiTriggerListRoute(
triggers: Pick<TriggerSource, 'listTriggers'>,
): Readonly<LocalApiTriggerListRoute> {
if (!triggers || typeof triggers.listTriggers !== 'function') {
throw new TypeError('Local API Trigger list repository is invalid');
}
return Object.freeze({
async handle(request: Readonly<LocalApiTriggerListRequest>) {
try {
const page = await triggers.listTriggers({
projectId: request.projectId,
limit: request.limit,
...(request.after ? { after: request.after } : {}),
});
return response(200, {
triggers: Object.freeze(page.triggers.map(summary)),
truncated: page.truncated,
next: page.next ?? null,
});
} catch (error) {
const mapped = unavailable(error);
if (mapped) return mapped;
throw error;
}
},
});
}
export function createLocalApiTriggerReadRoute(
triggers: Pick<TriggerSource, 'findCurrentTrigger'>,
): Readonly<LocalApiTriggerReadRoute> {
if (!triggers || typeof triggers.findCurrentTrigger !== 'function') {
throw new TypeError('Local API Trigger read repository is invalid');
}
return Object.freeze({
async handle(request: Readonly<LocalApiTriggerReadRequest>) {
try {
const trigger = await triggers.findCurrentTrigger(
request.projectId,
request.triggerId,
);
return trigger
? response(200, { trigger: detail(trigger) })
: response(404, { code: 'trigger_not_found' });
} catch (error) {
const mapped = unavailable(error);
if (mapped) return mapped;
throw error;
}
},
});
}
@@ -13,6 +13,79 @@ const PRINCIPAL = Object.freeze({
assurance: 'single_factor',
});
test('uses task.read for bounded Trigger list and read projections', async () => {
const { admission, events } = fixture();
assert.deepEqual(
await execute(
admission,
request({
operation: {
operationId: 'trigger.list',
projectId: 'prj_default',
limit: 16,
},
}),
),
{ statusCode: 200, body: { triggers: [], truncated: false } },
);
assert.deepEqual(events, [
'authenticate',
'authorize:task.read:prj_default',
'audit:allowed:trigger.list',
'confirm',
'triggers:prj_default:16',
]);
events.length = 0;
assert.deepEqual(
await execute(
admission,
request({
operation: {
operationId: 'trigger.get',
projectId: 'prj_default',
triggerId: 'cron:task-a',
},
}),
),
{
statusCode: 200,
body: { trigger: { triggerId: 'cron:task-a' } },
},
);
assert.deepEqual(events, [
'authenticate',
'authorize:task.read:prj_default',
'audit:allowed:trigger.get',
'confirm',
'trigger:prj_default:cron:task-a',
]);
});
test('defers Trigger put Policy, presence and mutation to the route', async () => {
const { admission, events } = fixture();
const prepared = await admission.prepare(
request({
localPresence: 'ql3p_bound',
operation: {
operationId: 'trigger.put',
projectId: 'prj_default',
triggerId: 'cron:task-a',
},
}),
);
assert.equal(prepared.bodyMode, 'json');
assert.equal(prepared.maximumBodyBytes, 20 * 1024);
assert.deepEqual(await prepared.handle({ enabled: true }), {
statusCode: 201,
body: { status: 'created' },
});
assert.deepEqual(events, [
'authenticate',
'trigger-put:prj_default:cron:task-a',
]);
});
function request(overrides = {}) {
return Object.freeze({
requestId: 'local:019f70c0-0000-7000-8000-000000000001',
@@ -152,6 +225,27 @@ function fixture(overrides = {}) {
return { statusCode: 200, body: { task: { taskId: value.taskId } } };
},
},
triggerListRoute: {
async handle(value) {
events.push(`triggers:${value.projectId}:${value.limit}`);
return { statusCode: 200, body: { triggers: [], truncated: false } };
},
},
triggerReadRoute: {
async handle(value) {
events.push(`trigger:${value.projectId}:${value.triggerId}`);
return {
statusCode: 200,
body: { trigger: { triggerId: value.triggerId } },
};
},
},
triggerPutRoute: {
async handle(value) {
events.push(`trigger-put:${value.projectId}:${value.triggerId}`);
return { statusCode: 201, body: { status: 'created' } };
},
},
now: () => 10_000,
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
...overrides,
@@ -86,6 +86,10 @@ test('loads one bounded offline Console asset closure', () => {
assert.match(text, /\.\.\.snapshot\.task\.spec\.config/u);
assert.match(text, /snapshot\.task\.labels/u);
assert.match(text, /setAttribute\('aria-readonly', 'true'\)/u);
assert.match(text, /qinglong\/cron@v1/u);
assert.match(text, /triggers\/\$\{mutation\.triggerId\}/u);
assert.match(text, /state\.view === 'triggers'/u);
assert.match(text, /trigger_fence_rejected/u);
}
if (requestPath === '/') {
assert.match(text, /id="task-editor-dialog"/u);
@@ -93,6 +97,8 @@ test('loads one bounded offline Console asset closure', () => {
assert.match(text, /保存并生成本机证明/u);
assert.match(text, /id="task-editor-title"/u);
assert.match(text, /id="presence-copy"/u);
assert.match(text, /id="trigger-editor-dialog"/u);
assert.match(text, /data-view="triggers"/u);
}
}
assert.ok(totalBytes <= 192 * 1024);
@@ -439,6 +439,9 @@ test('serves an authenticated Run through one real SQLite authority and durable
taskDefinitions: runtime.taskDefinitions,
taskDefinitionAdministrationForCredential:
runtime.taskDefinitionAdministrationForCredential,
triggers: runtime.triggers,
triggerAdministrationForCredential:
runtime.triggerAdministrationForCredential,
apiCredentials: runtime.apiCredentials,
ownerPepper: runtime.ownerPepper,
projectPolicy: runtime.projectPolicy,
@@ -694,6 +697,126 @@ test('serves an authenticated Run through one real SQLite authority and durable
assert.equal(updatedTask.body.task.enabled, true);
assert.equal(JSON.stringify(updatedTask).includes('/bin/echo'), false);
const triggerPath = '/api/v3/projects/default/triggers/cron:task-1';
const triggerBody = JSON.stringify({
expectedRevision: null,
mutationId: '019f7300-0000-4000-8000-000000000703',
taskId: 'task-1',
taskRevision: updatedTask.body.task.revision,
taskContentDigest: updatedTask.body.task.contentDigest,
spec: {
schema: 'qinglong/cron@v1',
config: {
expression: '0 * * * *',
timezone: 'UTC',
misfirePolicy: 'skip',
},
},
enabled: true,
occurredAtMs: NOW,
});
const triggerOptions = {
method: 'PUT',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(triggerBody)),
},
body: triggerBody,
};
const triggerChallenge = await request(
port,
`Bearer ${TOKEN}`,
triggerPath,
triggerOptions,
);
assert.equal(triggerChallenge.statusCode, 428);
assert.equal(triggerChallenge.body.code, 'local_presence_required');
const triggerProof = JSON.parse(
fs.readFileSync(
path.join(root, 'console-presence', triggerChallenge.body.proofFileName),
'utf8',
),
);
const triggerCreated = await request(port, `Bearer ${TOKEN}`, triggerPath, {
...triggerOptions,
headers: {
...triggerOptions.headers,
'x-qinglong-local-presence': triggerProof.proof,
},
});
assert.equal(triggerCreated.statusCode, 201, JSON.stringify(triggerCreated));
assert.equal(triggerCreated.body.status, 'created');
assert.equal(triggerCreated.body.trigger.revision, 1);
assert.equal(triggerCreated.body.trigger.enabled, true);
const triggerList = await request(
port,
`Bearer ${TOKEN}`,
'/api/v3/projects/default/triggers?limit=16',
);
assert.equal(triggerList.statusCode, 200);
assert.equal(triggerList.body.triggers.length, 1);
assert.equal(triggerList.body.triggers[0].triggerId, 'cron:task-1');
assert.equal(triggerList.body.triggers[0].spec, undefined);
const triggerRead = await request(port, `Bearer ${TOKEN}`, triggerPath);
assert.equal(triggerRead.statusCode, 200);
assert.deepEqual(triggerRead.body.trigger.spec, {
schema: 'qinglong/cron@v1',
config: {
expression: '0 * * * *',
timezone: 'UTC',
misfirePolicy: 'skip',
},
});
assert.equal(
triggerRead.body.trigger.taskContentDigest,
updatedTask.body.task.contentDigest,
);
const triggerDisableBody = JSON.stringify({
...JSON.parse(triggerBody),
expectedRevision: 1,
mutationId: '019f7300-0000-4000-8000-000000000704',
enabled: false,
});
const triggerDisableOptions = {
method: 'PUT',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(triggerDisableBody)),
},
body: triggerDisableBody,
};
const triggerDisableChallenge = await request(
port,
`Bearer ${TOKEN}`,
triggerPath,
triggerDisableOptions,
);
assert.equal(triggerDisableChallenge.statusCode, 428);
const triggerDisableProof = JSON.parse(
fs.readFileSync(
path.join(
root,
'console-presence',
triggerDisableChallenge.body.proofFileName,
),
'utf8',
),
);
const triggerDisabled = await request(port, `Bearer ${TOKEN}`, triggerPath, {
...triggerDisableOptions,
headers: {
...triggerDisableOptions.headers,
'x-qinglong-local-presence': triggerDisableProof.proof,
},
});
assert.equal(triggerDisabled.statusCode, 200);
assert.equal(triggerDisabled.body.status, 'updated');
assert.equal(triggerDisabled.body.trigger.revision, 2);
assert.equal(triggerDisabled.body.trigger.enabled, false);
const taskStartBody = JSON.stringify({
schema: 'qinglong/task-start@v1',
mutationId: '019f7300-0000-7000-8000-000000000800',
@@ -857,7 +980,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
WHERE operation_id IN (
'run.get', 'run.list', 'run.events.list', 'run.steps.list',
'run.cancel', 'task.authoring.read', 'task.create', 'task.get',
'task.list', 'task.start', 'task.update', 'run.log.read'
'task.list', 'task.start', 'task.update', 'run.log.read',
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update'
)
ORDER BY operation_id, outcome`,
)
@@ -884,6 +1008,12 @@ test('serves an authenticated Run through one real SQLite authority and durable
'task.start:allowed',
'task.update:allowed',
'task.update:approval_required',
'trigger.create:allowed',
'trigger.create:approval_required',
'trigger.get:allowed',
'trigger.list:allowed',
'trigger.update:allowed',
'trigger.update:approval_required',
],
);
assert.deepEqual(
@@ -0,0 +1,227 @@
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 { createTriggerRecord } = require('@qinglong/runtime-core/trigger');
const {
createLocalPresenceProofManager,
} = require('../dist/authentication/localPresenceProof.js');
const {
createLocalApiTriggerPutRoute,
} = require('../dist/trigger/triggerPutRoute.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 triggerBody(overrides = {}) {
return Object.freeze({
expectedRevision: null,
mutationId: '019f9100-0000-4000-8000-000000000101',
taskId: 'task-a',
taskRevision: 2,
taskContentDigest: 'c'.repeat(64),
spec: Object.freeze({
schema: 'qinglong/cron@v1',
config: Object.freeze({
expression: '0 * * * *',
timezone: 'UTC',
misfirePolicy: 'skip',
}),
}),
enabled: true,
occurredAtMs: 10_000,
...overrides,
});
}
function uuidFactory() {
let sequence = 200;
return () => {
sequence += 1;
return `019f9100-0000-4000-8000-${String(sequence).padStart(12, '0')}`;
};
}
function fixture(t) {
const deploymentRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-trigger-put-'),
);
fs.chmodSync(deploymentRoot, 0o700);
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
const calls = [];
const presenceProof = createLocalPresenceProofManager({
deploymentRoot,
profile: 'edge',
now: () => 10_000,
randomUuid: uuidFactory(),
randomSecret: () => Buffer.alloc(32, 14),
});
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 triggers = {
async findCurrentTrigger() {
return null;
},
async findTriggerRevision() {
return null;
},
async listTriggers() {
return { triggers: [], truncated: false };
},
};
const route = createLocalApiTriggerPutRoute({
projectPolicy,
triggers,
async triggerAdministrationForCredential(fence) {
calls.push(['credential-fence', fence]);
return {
async appendAuthorizedTriggerRevision(mutation) {
calls.push(['mutation', mutation]);
return {
status: 'created',
trigger: createTriggerRecord(mutation.command, 10_000),
};
},
};
},
securityAudit: {
async record(record) {
calls.push(['audit', record]);
},
},
presenceProof,
now: () => 10_000,
randomUuid: uuidFactory(),
});
const authenticated = Object.freeze({
principal: PRINCIPAL,
credentialFence: FENCE,
async confirm() {
calls.push(['confirm']);
},
});
return { route, calls, deploymentRoot, authenticated };
}
function request(state, body, overrides = {}) {
return Object.freeze({
requestId: 'local:019f9100-0000-4000-8000-000000000301',
projectId: 'default',
triggerId: 'cron:task-a',
body,
presence: null,
authenticated: state.authenticated,
signal: new AbortController().signal,
...overrides,
});
}
function readProof(state, challenge) {
return JSON.parse(
fs.readFileSync(
path.join(
state.deploymentRoot,
'console-presence',
challenge.body.proofFileName,
),
'utf8',
),
).proof;
}
test('requires exact local presence and commits Trigger audit plus mutation through one credential fence', async (t) => {
const state = fixture(t);
const body = triggerBody();
const challenge = await state.route.handle(request(state, body));
assert.equal(challenge.statusCode, 428);
assert.equal(challenge.body.code, 'local_presence_required');
const created = await state.route.handle(
request(state, body, { presence: readProof(state, challenge) }),
);
assert.equal(created.statusCode, 201);
assert.equal(created.body.status, 'created');
assert.equal(created.body.trigger.triggerId, 'cron:task-a');
const mutation = state.calls.find(([kind]) => kind === 'mutation')[1];
assert.deepEqual(mutation.actor, { type: 'user', id: 'owner' });
assert.deepEqual(mutation.fence, {
projectVersion: 3,
bindingVersion: 5,
});
assert.equal(mutation.audit.operationId, 'trigger.create');
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]]),
[['trigger.create', 'approval_required', 'local_presence_required']],
);
});
test('binds the proof to exact Trigger content and rejects malformed requests', async (t) => {
const state = fixture(t);
assert.deepEqual(
await state.route.handle(request(state, { enabled: true })),
{ statusCode: 400, body: { code: 'invalid_trigger' } },
);
const body = triggerBody();
const challenge = await state.route.handle(request(state, body));
const proof = readProof(state, challenge);
assert.deepEqual(
await state.route.handle(
request(state, triggerBody({ enabled: false }), { presence: proof }),
),
{ statusCode: 401, body: { code: 'local_presence_rejected' } },
);
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 0);
});
@@ -0,0 +1,112 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
createLocalApiTriggerListRoute,
createLocalApiTriggerReadRoute,
} = require('../dist/trigger/triggerReadRoutes.js');
const {
TriggerUnavailableError,
triggerContentDigest,
} = require('@qinglong/runtime-core/trigger');
function trigger(triggerId = 'cron:task-a') {
const fields = {
projectId: 'default',
triggerId,
revision: 2,
taskId: 'task-a',
taskRevision: 3,
taskContentDigest: 'a'.repeat(64),
spec: {
schema: 'qinglong/cron@v1',
config: {
expression: '0 * * * *',
timezone: 'UTC',
misfirePolicy: 'skip',
},
},
enabled: true,
};
return Object.freeze({
...fields,
mutationId: '019f7300-0000-4000-8000-000000000001',
contentDigest: triggerContentDigest(fields),
createdAtMs: 100,
updatedAtMs: 200,
});
}
test('projects bounded Trigger summaries and one complete cron detail', async () => {
const record = trigger();
const source = {
async listTriggers(input) {
assert.deepEqual(input, { projectId: 'default', limit: 16 });
return { triggers: [record], truncated: false };
},
async findCurrentTrigger(projectId, triggerId) {
assert.equal(projectId, 'default');
return triggerId === record.triggerId ? record : null;
},
};
const list = createLocalApiTriggerListRoute(source);
const read = createLocalApiTriggerReadRoute(source);
const listed = await list.handle({ projectId: 'default', limit: 16 });
assert.equal(listed.statusCode, 200);
assert.equal(listed.body.triggers[0].triggerId, record.triggerId);
assert.equal(listed.body.triggers[0].spec, undefined);
assert.equal(listed.body.triggers[0].taskContentDigest, undefined);
const found = await read.handle({
projectId: 'default',
triggerId: record.triggerId,
});
assert.deepEqual(found, {
statusCode: 200,
body: {
trigger: {
triggerId: record.triggerId,
revision: 2,
taskId: 'task-a',
taskRevision: 3,
specSchema: 'qinglong/cron@v1',
enabled: true,
contentDigest: record.contentDigest,
createdAtMs: 100,
updatedAtMs: 200,
projectId: 'default',
taskContentDigest: 'a'.repeat(64),
spec: record.spec,
},
},
});
assert.deepEqual(
await read.handle({ projectId: 'default', triggerId: 'missing' }),
{ statusCode: 404, body: { code: 'trigger_not_found' } },
);
});
test('fails closed when Trigger storage is unavailable', async () => {
const source = {
async listTriggers() {
throw new TriggerUnavailableError();
},
async findCurrentTrigger() {
throw new TriggerUnavailableError();
},
};
assert.deepEqual(
await createLocalApiTriggerListRoute(source).handle({
projectId: 'default',
limit: 16,
}),
{ statusCode: 503, body: { code: 'trigger_query_unavailable' } },
);
assert.deepEqual(
await createLocalApiTriggerReadRoute(source).handle({
projectId: 'default',
triggerId: 'cron:task-a',
}),
{ statusCode: 503, body: { code: 'trigger_query_unavailable' } },
);
});
@@ -522,6 +522,9 @@ export async function bootstrapLocalApplication(
taskDefinitions: storage.taskDefinitions,
taskDefinitionAdministrationForCredential:
storage.taskDefinitionAdministrationForCredential,
triggers: storage.triggers,
triggerAdministrationForCredential:
storage.triggerAdministrationForCredential,
apiCredentials: storage.apiCredentials,
ownerPepper: storage.ownerPepper,
projectPolicy: storage.projectPolicy,
@@ -69,6 +69,8 @@ export interface LocalApplicationProductSurfaceAuthority {
| 'listTaskDefinitions'
>;
readonly taskDefinitionAdministrationForCredential: ReadyFreshStorage['taskDefinitionAdministrationForCredential'];
readonly triggers: ReadyFreshStorage['triggers'];
readonly triggerAdministrationForCredential: ReadyFreshStorage['triggerAdministrationForCredential'];
readonly runAttemptLogRead: Readonly<{
read(
request: Readonly<RunAttemptLogReadRequest>,
@@ -1759,6 +1759,11 @@ test('starts an optional product surface after recovery and drains it before own
typeof authority.taskDefinitions.listTaskDefinitions,
'function',
);
assert.equal(typeof authority.triggers.listTriggers, 'function');
assert.equal(
typeof authority.triggerAdministrationForCredential,
'function',
);
assert.equal(typeof authority.runAttemptLogRead.read, 'function');
assert.equal(typeof authority.apiCredentials.resolve, 'function');
assert.equal(typeof authority.ownerPepper.resolveKey, 'function');
@@ -43,6 +43,8 @@ export type LocalProfileStorageBootstrapResult =
readonly taskStartRepository: LocalSqliteRuntimeDatabase['taskStartRepository'];
readonly taskDefinitions: LocalSqliteRuntimeDatabase['taskDefinitions'];
readonly taskDefinitionAdministrationForCredential: LocalSqliteRuntimeDatabase['taskDefinitionAdministrationForCredential'];
readonly triggers: LocalSqliteRuntimeDatabase['triggers'];
readonly triggerAdministrationForCredential: LocalSqliteRuntimeDatabase['triggerAdministrationForCredential'];
readonly schedules: LocalSqliteRuntimeDatabase['schedules'];
readonly dispatch: LocalSqliteRuntimeDatabase['localDispatch'];
readonly executionControl: LocalSqliteRuntimeDatabase['executionControl'];
@@ -141,6 +143,9 @@ export async function bootstrapLocalProfileStorage(
taskDefinitions: database.taskDefinitions,
taskDefinitionAdministrationForCredential:
database.taskDefinitionAdministrationForCredential,
triggers: database.triggers,
triggerAdministrationForCredential:
database.triggerAdministrationForCredential,
schedules: database.schedules,
dispatch: database.localDispatch,
executionControl: database.executionControl,
@@ -37,6 +37,7 @@ import type { StepRunRepository } from '@qinglong/runtime-core/step-run';
import type { RunCancellationRepository } from '@qinglong/runtime-core/run-cancellation';
import type { TaskStartRepository } from '@qinglong/runtime-core/task-start';
import type { TaskDefinitionAdministrationRepository } from '@qinglong/runtime-core/task-definition-administration';
import type { TriggerAdministrationRepository } from '@qinglong/runtime-core/trigger-administration';
import type { ToolExecutionCompletionRepository } from '@qinglong/runtime-core/tool-execution-completion';
import type { ToolExecutionFailureCompletionRepository } from '@qinglong/runtime-core/tool-execution-failure-completion';
import type { ToolExecutionStartBarrierRepository } from '@qinglong/runtime-core/tool-execution-start-barrier';
@@ -102,6 +103,9 @@ export interface LocalSqliteRuntimeDatabase {
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
): Promise<TaskDefinitionAdministrationRepository>;
readonly triggers: LocalSqliteTriggerRepository;
triggerAdministrationForCredential(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
): Promise<TriggerAdministrationRepository>;
readonly schedules: LocalSqliteScheduleRepository;
readonly localDispatch: LocalDispatchStore;
readonly executionControl: LocalExecutionControlSource;
@@ -284,6 +288,37 @@ export async function openLocalSqliteRuntimeDatabase(
);
},
triggers,
async triggerAdministrationForCredential(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
) {
const [administration, triggerAdministration] = await Promise.all([
import('../administration/packageManagement.js'),
import('../scheduling/triggerAdministration.js'),
]);
const {
confirmLocalSqliteAuthenticatedUserCredentialFence,
LocalSqliteAuthenticatedManagementFenceError,
} = administration;
const { LocalSqliteTriggerAdministrationRepository } =
triggerAdministration;
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
return new LocalSqliteTriggerAdministrationRepository(
authority,
triggers,
(actor) => {
if (
actor.type !== fence.subjectType ||
actor.id !== fence.subjectId
) {
throw new LocalSqliteAuthenticatedManagementFenceError();
}
confirmLocalSqliteAuthenticatedUserCredentialFence(
authority,
fence,
);
},
);
},
schedules,
localDispatch: runRuntimeCapabilities.dispatch,
executionControl: runRuntimeCapabilities.executionControl,
@@ -97,7 +97,7 @@ function sameCredentialFence(
);
}
class LocalSqliteTriggerAdministrationRepository
export class LocalSqliteTriggerAdministrationRepository
implements TriggerAdministrationRepository
{
constructor(