mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add secure console cron scheduling
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# QingLong 3.0 Architecture RFC
|
||||
|
||||
- D-423/ADR-0518(已实现源码候选,待远程阶段实物验收):Local Console 复用既有 immutable Trigger revision、固定 Task revision/content digest、semantic cron validation、durable schedule cursor 与原子 Run admission,新增有界 `GET /api/v3/projects/:projectId/triggers[/:triggerId]` 和强认证 `PUT /api/v3/projects/:projectId/triggers/:triggerId`。列表只返回低敏摘要,精确读取才返回完整 spec/Task digest;读取走 `task.read`,mutation 要求 User credential、`task.update`、两分钟一次性 owner-private exact-content proof、credential reconfirm,并由 request-scoped SQLite Trigger repository 在同一事务重验 Policy/RoleBinding/Task pin/credential fence、追加 immutable revision、初始化或更新 schedule 与 durable audit。Console 仅支持冻结的 `qinglong/cron@v1` expression/timezone/`skip|fire_once`,编辑、启停均追加 revision,不提供删除或通用 provider 编辑。Fresh/adopted Profile 共用现有 database close fence;不新增 package、migration、connection、daemon、watcher、每 Trigger timer 或第二 scheduler。默认 headless 不携带 API/Console 资产与 listener,Cluster 不复用 Local POSIX proof/SQLite authority。18-package clean build/test `3,044 total / 3,022 pass / 22 conditional skip / 0 fail`,完整 backend `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`,Local API `70/70`;package boundary、122-module Edge import 与精确 Cluster dependency audit compatible。默认 Edge 2,754,742 bytes/331 files/58 modules,opt-in Edge/Standalone Console 4,150,439/4,150,583 bytes、479 files/101 modules,三资产 84,401 bytes。该结论目前只证明可进入远程验收;最新可下载实物仍是 D-422,必须等 D-423 exact commit 的 CI、Kubernetes、双架构 Console milestone 与下载离线复核闭合后再升级。
|
||||
|
||||
- RFC ID: QL-RFC-0001
|
||||
- 标题:QingLong 3.0 运行时、工作流与 AI 自动化架构
|
||||
- 状态:Draft
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# ADR-0518:Local Console cron Trigger 管理
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-29
|
||||
- 对应 RFC 切片:D-423
|
||||
- 关联:ADR-0094、ADR-0103、ADR-0512、ADR-0516、ADR-0517
|
||||
|
||||
## 背景
|
||||
|
||||
Local Console 已能创建、无损编辑和手动运行内建 command Task,但部署者仍需转到受信 CLI 才能把 Task 变成周期自动化。QingLong 3.0 已有 immutable Trigger revision、固定 Task revision/content digest、语义化 cron 校验、durable schedule cursor 和原子 Run admission;缺口是一个不复制这些 authority、适合低配单机的安全产品入口。
|
||||
|
||||
本切片只开放既有本机 Trigger authority。它不能把 Local POSIX presence proof 搬到 Cluster,也不能为每个 Trigger 新建内存 timer,或让默认 headless 设备承担 Console 资产和 HTTP listener。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. Local API 暴露有界 Trigger 读取与强认证 mutation
|
||||
|
||||
新增固定路由:
|
||||
|
||||
```text
|
||||
GET /api/v3/projects/:projectId/triggers
|
||||
GET /api/v3/projects/:projectId/triggers/:triggerId
|
||||
PUT /api/v3/projects/:projectId/triggers/:triggerId
|
||||
```
|
||||
|
||||
列表按 `triggerId` 使用稳定 keyset,Edge 默认 16 条、Standalone 默认 32 条、硬上限 64 条。列表只返回 identity、revision、Task pin、schema、enabled、content digest 和时间戳;完整 cron spec 与 Task content digest 只在精确详情读取中返回。读取复用 `task.read` Policy、credential reconfirm 与 durable audit,存储损坏或不可用时失败关闭。
|
||||
|
||||
PUT 的 body 必须精确包含 `expectedRevision`、`mutationId`、Task ID/revision/content digest、spec、enabled 与发生时间。请求只接受 User credential,并要求 `task.update` Policy;服务为 canonical exact body 签发两分钟、一次性的 owner-private presence challenge,proof 消费后才形成 `local_console` principal。事务前再次确认 exact credential,随后由既有 Trigger administration service 和 request-scoped SQLite repository 重验 Project/RoleBinding/credential/Policy fence,原子写 immutable revision、head、schedule cursor 与 durable audit。
|
||||
|
||||
### 2. Trigger 始终固定当前 Task 内容
|
||||
|
||||
Console 在创建或更新 Trigger 前精确读取关联 Task,提交其当前 revision 与 content digest。既有 repository 在事务内重新验证 pin;Task 已变化时拒绝写入,不猜测重绑。
|
||||
|
||||
创建使用 `expectedRevision=null`,更新使用当前 revision。Trigger ID 与 Task ID 创建后不能改绑;启用、停用、cron/timezone/misfire 修改都追加新 revision。当前阶段不提供删除,停用是可审计、可恢复的替代操作。
|
||||
|
||||
### 3. 首个 UI 只支持冻结的内建 cron schema
|
||||
|
||||
Console 的“定时”工作区支持创建、查看、编辑、启用和停用 `qinglong/cron@v1`,字段为 cron expression、显式 timezone 与 `skip|fire_once` misfire policy。它不实现通用 provider/schema 编辑器,不接受浏览器自行扩展未知 Trigger kind。
|
||||
|
||||
Credential、presence proof 与编辑快照仍只保存在页面内存,不进入 Cookie、Web Storage、URL、日志或遥测。成功写入后由现有 durable Scheduler 在既有单一有界 lifecycle 中发现;Console 不轮询、不注册每 Trigger callback,也不新增 daemon、watcher、timer 或数据库连接。
|
||||
|
||||
### 4. Fresh 与 adopted Profile 使用同一窄 authority
|
||||
|
||||
Fresh SQLite runtime 和 adopted Profile 都在现有数据库 close fence 上暴露 `TriggerSource` 与 `triggerAdministrationForCredential`。每次 mutation 取得绑定当前 credential fence 的 request-scoped repository;常驻 Application 不持有一个可绕过认证的裸 append authority。
|
||||
|
||||
`@qinglong/local-api` 只有 `triggerPutRoute.ts` 被 package dependency audit 精确允许导入 `@qinglong/local-admin/trigger-administration`。同目录或其他文件不能借此扩大依赖;Cluster dependency audit 继续拒绝 Local SQLite/POSIX authority 越界。
|
||||
|
||||
### 5. 部署档位保持分层
|
||||
|
||||
- 默认 Edge/Standalone headless 不包含 Local API、Console 资产或 listener;SQLite runtime 只增加复用既有 Trigger mutation 的窄装配代码。
|
||||
- opt-in `edge-application-api` 与 `standalone-application-api` 承担同源 Console/API 增量,仍只有一个 Application 进程、一个 SQLite connection 和现有 Scheduler lifecycle。
|
||||
- Cluster 不复用 Local proof、SQLite repository 或 Local admin service;后续 Cluster Console mutation 必须走 TLS、共享 PostgreSQL authority、RBAC/Approval 与 HA fence。
|
||||
|
||||
## 不采用的方案
|
||||
|
||||
- 不在浏览器或 HTTP 层实现第二套 cron scheduler:调度事实必须留在 durable Trigger/schedule authority。
|
||||
- 不允许 Bearer 单因子直接写 Trigger:周期自动化会持续产生执行,必须保留本机 presence proof。
|
||||
- 不让列表返回完整 spec 或 Task digest:列表应是低敏、有界的浏览面。
|
||||
- 不通过删除实现“关闭定时”:不可变 revision 与停用记录更可审计。
|
||||
- 不新建 `ql3-local-trigger-api` 微包:现有 Local API capability 内的两个路由文件不足以形成独立部署/依赖边界。
|
||||
- 不为路由器注册每 Trigger timer:现有有界 durable scheduler 已覆盖该职责。
|
||||
|
||||
## 结果与验证边界
|
||||
|
||||
定向测试覆盖列表投影不泄漏 spec/digest、精确详情、坏存储失败关闭、presence challenge、exact body/credential fence、Policy/audit、Task pin、创建/更新冲突与 proof 漂移。真实 SQLite/loopback 旅程已完成 Task 创建/更新 → Trigger challenge/proof/create → bounded list → exact read → 新 revision disable,并验证 create/get/list/update audit。
|
||||
|
||||
本地 18-package clean build/test 为 `3,044 total / 3,022 pass / 22 conditional、platform 或 external-service skip / 0 fail`,其中 Local API `70/70`、Local SQLite `248/248`、Local Admin `96/96`,Local Application `55 total / 51 pass / 4 platform skip / 0 fail`;完整 backend 为 `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`。package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,122-module Edge import 与精确 Cluster dependency audit 均 compatible。
|
||||
|
||||
三项 Console 静态资产合计 84,401 bytes,仍低于 192 KiB 总闭包与 96 KiB 单文件门。默认 Edge 为 2,754,742 bytes/331 files/3 packages/58 modules,RSS delta 11,108,352 bytes;opt-in Edge/Standalone Console 为 4,150,439/4,150,583 bytes、479 files/12 packages/101 modules,RSS delta 17,088,512/17,055,744 bytes,均低于既有门。
|
||||
|
||||
这些结果证明 D-423 是可进入远程验收的源码候选,不自动等于新的下载产物。当前最新可下载实物仍是 D-422 绑定的 Console v5 milestone;只有 D-423 exact commit 的普通 CI、Kubernetes gate 与显式双架构 Local Console milestone 全部成功,且下载索引通过 checksum/离线 auditor 后,才能把本 ADR 的阶段状态升级为“已交付实物”。
|
||||
+2
-1
@@ -520,7 +520,8 @@
|
||||
| [ADR-0514](./ADR-0514-stage-usable-first-automation-journey.md) | 阶段可用的首个自动化旅程 | Accepted(Console v5 双架构实物已由 D-420 闭合) |
|
||||
| [ADR-0515](./ADR-0515-bounded-local-console-run-log.md) | Local Console 的有界 Run 日志观察面 | Accepted(首份实际 Console v5 双架构 milestone 已交付) |
|
||||
| [ADR-0516](./ADR-0516-request-scoped-local-console-task-mutation.md) | request-scoped Local Console Task mutation | Accepted(新双架构 Trial Kit 待本阶段 milestone) |
|
||||
| [ADR-0517](./ADR-0517-strong-local-console-task-authoring-lease.md) | 强认证 Local Console Task authoring lease | Accepted(源码候选,待 CI 与新双架构 milestone) |
|
||||
| [ADR-0517](./ADR-0517-strong-local-console-task-authoring-lease.md) | 强认证 Local Console Task authoring lease | Accepted(D-422 双架构 milestone 已交付) |
|
||||
| [ADR-0518](./ADR-0518-local-console-cron-trigger-administration.md) | Local Console cron Trigger 管理 | Accepted(源码候选,待 CI 与新双架构 milestone) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -27,11 +27,14 @@
|
||||
| D-420 Console Run 日志观察面 | 选择 Run 后经既有认证/Policy/Audit 链读取 latest Attempt 首个 32 KiB,展示 range、truncation、pending/retired 等明确状态 | 不自动轮询、不提供整文件下载;Web Task 创建/修订仍待独立强认证事务切片 |
|
||||
| D-421 Console Task 创建切片 | request-scoped credential fence、两分钟一次性本机 proof、同事务 Policy/Audit/Task mutation 已完成;Console 可创建 command Task;同源双架构 Console v5 Trial Kit 与 milestone 已生成并验真 | Web update 等待 authoring read/lease;Cluster 不复用 Local proof;仍不是生产或公开发布 |
|
||||
| D-422 Console Task 安全编辑切片 | 强认证完整定义读取、10 分钟一次性 authoring lease、第二份 exact save proof 与 revision/content/credential fence 已完成;Console 可无损编辑内建 argv command Task;同源双架构 Console v5 Trial Kit 与 milestone 已生成并验真 | Cluster 不复用 Local proof;尚无通用 workflow 编辑器、2.x 升级或生产远程管理;仍不是正式发布 |
|
||||
| D-423 Console cron Trigger 管理切片 | 源码候选已复用既有 immutable Trigger、Task pin、durable schedule 与原子 audit authority;Console/API 可 list/read/create/update/enable/disable `qinglong/cron@v1`,真实 SQLite/loopback 已通过 | 尚未绑定新的远程 CI 与双架构 milestone;当前下载物仍是 D-422,Cluster 不复用 Local proof;不提供删除或通用 Trigger schema 编辑 |
|
||||
|
||||
D-421 已关闭 D-420 记录的“Web Task mutation 必须独立设计”缺口,而且没有改名复用 run `33173769047` 的旧 archive。修复提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 的普通主 CI [run 33229592307](https://github.com/whyour/qinglong/actions/runs/33229592307) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33229592293](https://github.com/whyour/qinglong/actions/runs/33229592293) 成功;随后显式 Local Console milestone [run 33230227006](https://github.com/whyour/qinglong/actions/runs/33230227006) 为 42 success/2 scope skip/0 fail。由此 Web 创建能力已进入新的阶段实物,而不再只是候选源码。
|
||||
|
||||
D-422 已从“源码候选”升级为阶段实物:本地真实 SQLite/loopback 已证明读取、租约、第二次 proof、更新与新围栏启动闭环;18-package clean build/test 为 `3,038 total / 3,016 pass / 22 conditional skip / 0 fail`,Local API `64/64`,默认 Edge 与 opt-in Edge/Standalone Console 资源门及真实 Chromium 双证明编辑均通过。修复提交 `f28bf74d1bd29e9b8a8727915de19509f4bda9cf` 的普通主 CI [run 33236204273](https://github.com/whyour/qinglong/actions/runs/33236204273) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33236204254](https://github.com/whyour/qinglong/actions/runs/33236204254) 为 1/1;随后显式 Local Console milestone [run 33237026187](https://github.com/whyour/qinglong/actions/runs/33237026187) 为 42 success/2 scope skip/0 fail。阶段产物绑定新的 exact commit/run/artifact digest,没有沿用或改名复用 D-421 archive。
|
||||
|
||||
D-423 当前是已通过本地门禁的源码候选,不冒充阶段实物:18-package clean build/test `3,044 total / 3,022 pass / 22 conditional skip / 0 fail`,完整 backend `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`,Local API `70/70`;18-package boundary、122-module Edge import 与 Cluster dependency audit 均 compatible。默认 headless Edge 为 2,754,742 bytes/331 files/58 modules,opt-in Edge/Standalone Console 为 4,150,439/4,150,583 bytes、479 files/101 modules,三资产合计 84,401 bytes。只有 D-423 exact commit 的完整 CI/Kubernetes 与显式 Console milestone 成功并完成下载复核后,下面的“当前最新可交付”才允许替换为 D-423;在此之前部署者应继续下载 D-422。
|
||||
|
||||
D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。
|
||||
|
||||
当前最新可交付 Local Console v5 绑定提交 `f28bf74d1bd29e9b8a8727915de19509f4bda9cf` 与 [GitHub Actions run 33237026187](https://github.com/whyour/qinglong/actions/runs/33237026187),保留至 2026-09-28:
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
# QingLong 3.0 Local Web Console
|
||||
|
||||
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建 command Task、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
|
||||
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
|
||||
|
||||
## 选择部署档位
|
||||
|
||||
| 场景 | 建议 |
|
||||
| --- | --- |
|
||||
| 内存很小、只需后台自动化的路由/NAS | 继续使用默认 `edge` headless Application;Console 零增量 |
|
||||
| 内存很小、只需后台自动化的路由/NAS | 继续使用默认 `edge` headless Application;不携带 Console/API 资产与 listener |
|
||||
| 路由/NAS 需要临时人工查看与操作 | 选择 `edge-application-api`,只通过 loopback 或 SSH tunnel 访问 |
|
||||
| 普通单节点服务器 | 选择 `standalone-application-api` |
|
||||
| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 |
|
||||
|
||||
D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421 增加 request-scoped strong-auth Task PUT 与 Console command Task 创建器;它不复用 CLI 的进程级 active credential,也不让单因子 Bearer 直接写 Task。绑定提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 的双架构实物已由 [milestone run 33230227006](https://github.com/whyour/qinglong/actions/runs/33230227006) 生成;普通 push 的源码和 CI 仍不是公开下载物。
|
||||
D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421/D-422 依次增加 request-scoped strong-auth Task 创建与双 proof 无损编辑;绑定提交 `f28bf74d1bd29e9b8a8727915de19509f4bda9cf` 的最新双架构实物已由 [milestone run 33237026187](https://github.com/whyour/qinglong/actions/runs/33237026187) 生成。D-423 源码候选继续开放既有 immutable Trigger/cron authority;在新的 exact-commit milestone 成功前,当前下载物仍不包含该能力。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已完成 Local fresh setup,并有受支持的 Application config;
|
||||
- Owner pepper keyring 与 SQLite active pepper 一致;
|
||||
- 已通过 [`ql3-identity`](./ql3-local-identity-credential.md) 为 active Identity 签发 API credential;
|
||||
- credential 对目标 Project 至少有读取 Task/Run 的权限;创建 Task、启动和取消分别还需要 `task.create`、`run.start` 与 `run.stop`;
|
||||
- credential 对目标 Project 至少有读取 Task/Run 的权限;创建 Task、修改 Task/Trigger、启动和取消分别还需要 `task.create`、`task.update`、`run.start` 与 `run.stop`;
|
||||
- config、keyring、database 和 credential delivery 保持既有 `0700/0600`、no-symlink 和同 UID authority。
|
||||
|
||||
## 启动
|
||||
@@ -57,22 +57,25 @@ ssh -L 5701:127.0.0.1:5701 router.example
|
||||
2. 选择“创建任务”,填写 Task ID、名称、argv 可执行文件和逐行参数,再选择“保存并生成本机证明”。
|
||||
3. 在部署设备上以 QingLong 数据目录 owner 读取 `<deploymentRoot>/console-presence/<页面显示的 basename>`;把 JSON 的完整 `ql3p_…` proof 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact 操作一次。不要通过聊天、日志或 URL 转发 proof。
|
||||
4. 编辑现有内建 command Task 时先选择“编辑任务”,完成第一次本机证明以读取完整定义并取得 10 分钟一次性编辑租约。保存新内容时页面会要求第二份 proof;第一份只授权读取,不能复用来保存。Task ID 只读,未展示的 command config 与 labels 会原样保留。
|
||||
5. 创建或更新成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`。
|
||||
6. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
|
||||
7. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
|
||||
8. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
|
||||
9. 完成后选择“断开并清除凭据”,再关闭页面。
|
||||
5. 需要周期运行时进入“定时”,选择“新建定时”,填写 Trigger ID、已存在的 Task ID、cron expression、显式 timezone、`skip|fire_once` misfire policy 与 enabled。页面会先读取当前 Task revision/content digest,再要求一份绑定这次 exact Trigger 内容的本机 proof。
|
||||
6. 编辑、启用或停用 Trigger 都会追加 immutable revision;当前阶段没有删除。Task 已被其他操作更新时,旧 pin 会失败关闭,应刷新后重新确认,不能猜测重绑。
|
||||
7. 创建或更新 Task 成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`;enabled Trigger 则由已有 durable Scheduler 自动产生 Run,不依赖浏览器保持打开。
|
||||
8. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
|
||||
9. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
|
||||
10. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
|
||||
11. 完成后选择“断开并清除凭据”,再关闭页面。
|
||||
|
||||
Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。
|
||||
|
||||
## 当前阶段可用边界
|
||||
|
||||
当前可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start 与 Run list/read/events/steps/log/cancel。编辑器只修改当前展示字段,并保留完整快照中的其他 config/labels;其他 kind/schema 继续使用受信管理入口。页面暂不负责:
|
||||
D-423 源码候选的可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。页面暂不负责:
|
||||
|
||||
- Identity、Policy、Secret、Plugin Package 或 AI 配置管理;
|
||||
- Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理;
|
||||
- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
|
||||
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
|
||||
|
||||
三项静态资产总计 69,723 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,077,890 / 4,078,034 bytes、473 files、12 packages、95 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 16,269,312 / 16,318,464 bytes,低于 28 MiB。基础 headless Edge 保持 2,737,205 bytes、329 files、3 packages、58 modules,不携带这些资产。
|
||||
三项静态资产总计 84,401 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,150,439 / 4,150,583 bytes、479 files、12 packages、101 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 17,088,512 / 17,055,744 bytes,低于 28 MiB。默认 headless Edge 为 2,754,742 bytes、331 files、3 packages、58 modules,RSS delta 11,108,352 bytes;它不携带 Console/API 资产、listener 或第二个 scheduler,但包含复用既有 SQLite Trigger mutation authority 的小幅装配代码增量。
|
||||
|
||||
停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3168,6 +3168,14 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packagePath === 'packages/ql3-local-api' &&
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/trigger/triggerPutRoute.ts' &&
|
||||
specifier === '@qinglong/local-admin/trigger-administration'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
path.isAbsolute(specifier) ||
|
||||
forbidden.some(
|
||||
|
||||
@@ -2541,6 +2541,10 @@ test('confines Local API identity authentication to its exact read-only adapter'
|
||||
"import { destroy } from '@qinglong/local-owner-console/pepper-custody/destructive';",
|
||||
'transport/widened.ts':
|
||||
"import { authenticate } from '@qinglong/local-owner-console/identity-authentication';",
|
||||
'trigger/triggerPutRoute.ts':
|
||||
"import { put } from '@qinglong/local-admin/trigger-administration';",
|
||||
'trigger/widened.ts':
|
||||
"import { put } from '@qinglong/local-admin/trigger-administration';",
|
||||
};
|
||||
for (const [relativePath, source] of Object.entries(sources)) {
|
||||
const filePath = path.join(packageDirectory, relativePath);
|
||||
@@ -2562,6 +2566,10 @@ test('confines Local API identity authentication to its exact read-only adapter'
|
||||
code: 'FORBIDDEN_LOCAL_IDENTITY_AUTHORITY_IMPORT',
|
||||
file: 'packages/ql3-local-api/src/transport/widened.ts',
|
||||
},
|
||||
{
|
||||
code: 'FORBIDDEN_PACKAGE_SOURCE_IMPORT',
|
||||
file: 'packages/ql3-local-api/src/trigger/widened.ts',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localApi.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 21,
|
||||
sourceFiles: 24,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 84,
|
||||
nestedSourceFiles: 20,
|
||||
nestedSourceFiles: 23,
|
||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user