feat(ql3): add secret-backed console automation

This commit is contained in:
whyour
2026-08-29 19:44:04 +08:00
parent 53d65abd8d
commit f46fb44ac9
26 changed files with 2272 additions and 39 deletions
+2
View File
@@ -1,5 +1,7 @@
# QingLong 3.0 Architecture RFC
- D-424/ADR-0519(已实现并通过本地门,当前为源码候选):Local Console 新增 current-only Secret metadata list,以及要求 User credential、`secret.manage`、exact plaintext digest owner-private proof 和事务内 credential/Policy/RoleBinding fence 的 Secret create/rotate。既有 Local administration service 继续负责 AES-256-GCM 与 immutable versionHTTP/Console/audit/Task 均不返回或持久化 plaintext、ciphertext、key ID。Task 编辑器把 `ENV=secret[@version]` 解析成 pinned `SecretRef`,省略版本时立即固定当前版本,执行时仍由既有 materializer 解密。Fresh/adopted Profile 共用现有 SQLite connection/close fence;无新 package、migration、connection、cache、daemon、timer 或 watcher。默认 headless 无 API/Console surfaceCluster 不复用 Local proof/SQLite custody。18-package clean build/test `3,052 total / 3,030 pass / 22 conditional skip / 0 fail`Local API `76/76`、Local SQLite `250/250`package boundary、122-module Edge source import 和精确 Cluster dependency audit compatible。默认 Edge 2,760,847 bytes/332 files/59 modulesopt-in Edge/Standalone Console 4,210,024/4,210,168 bytes、482 files/111 modules,三资产 102,182 bytes,资源门均通过。D-424 必须等 exact commit 的远端普通主 CI、Kubernetes 与显式双架构 milestone 完成后才升级为阶段实物;在此之前,D-423 run `33245745837` 仍是最新可下载、已验真的 `3.0.0-alpha.2` 产物。
- D-423/ADR-0518(已实现并交付同源双架构 Console Alpha Candidate):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 资产与 listenerCluster 不复用 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 modulesopt-in Edge/Standalone Console 4,150,439/4,150,583 bytes、479 files/101 modules,三资产 84,401 bytes。提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的普通主 CI run `33244982727` 为 41 success/3 expected skip/0 failKubernetes run `33244982694` 成功;显式 Console milestone run `33245745837` 为 42 success/2 scope skip/0 fail,生成 187,914,706-byte amd64、185,146,322-byte arm64 Trial Kit 与 5,623-byte milestone,保留至 2026-09-28。下载索引通过 checksum 与离线 auditor,返回 `compatible=true`。因此 D-423 已是可下载、可验真、可 fresh 试运行并配置 cron 自动化的 `3.0.0-alpha.2` 阶段实物,但仍不是公开 release、2.x 生产升级、Cluster HA 交付或长期支持版本。
- RFC ID: QL-RFC-0001
@@ -0,0 +1,67 @@
# ADR-0519Local Console Secret-backed 自动化
- 状态:Accepted(源码候选;阶段产物待远端门闭合)
- 日期:2026-08-29
- 对应 RFC 切片:D-424
- 关联:ADR-0069、ADR-0071、ADR-0512、ADR-0516、ADR-0517、ADR-0518
## 背景
D-423 已让部署者在 Local Console 创建 command Task 并配置 cron Trigger,但需要凭据的自动化仍只能借助受信 CLI。QingLong 3.0 已有 AES-256-GCM Secret custody、immutable version、pinned `SecretRef`、Task execution-time materialization 和 durable audit;缺口是一个不会暴露明文或复制密钥 authority 的有界产品入口。
该入口必须同时适配小路由设备和普通单节点:默认 headless 不承担 Console/API 成本,opt-in Console 不增加连接、迁移或后台生命周期。Cluster 必须继续使用自己的共享 Secret custody 和 HA authority,不能复用 Local SQLite/POSIX proof。
## 决策
### 1. 列表只暴露当前版本元数据
新增固定路由:
```text
GET /api/v3/projects/:projectId/secrets
PUT /api/v3/projects/:projectId/secrets
```
GET 使用按名称排序的稳定 keysetEdge 默认 16 条、Standalone 默认 32 条、硬上限 64 条。每个名称只返回当前 version、创建时间和 canonical pinned `SecretRef`。响应不包含 plaintext、ciphertext、nonce、tag、key ID、mutation ID 或历史版本内容。
Fresh 与 adopted Profile 在既有数据库 close fence 上暴露只读 metadata source;不开第二个 SQLite connection,不新增 table、migration、cache、watcher 或 timer。损坏、关闭或越界查询均失败关闭。
### 2. Secret mutation 继续使用既有加密 authority
PUT body 精确包含 `name``plaintext``mutationId``expectedCurrentVersion`。请求只接受 User credential、`secret.manage` Policy 与绑定 exact plaintext digest、Project、credential ID/version、User subject 的两分钟一次性 owner-private proof。
proof 验证后再次确认 credential,并创建 request-scoped credential-fenced Secret repository。既有 Local administration service 在数据库事务外完成 AES-256-GCM 加密,在事务内重验 credential、Identity、RoleBinding、Policy 与 expected current version,再原子追加 immutable Secret version 和 durable audit。HTTP 只返回名称、当前版本、pinned reference 与幂等状态。
plaintext 仅在请求解析、proof 等待的页面内存与加密调用所需的短生命周期中存在;Console 在成功、取消、断开时清空输入和 pending body。它不进入 Cookie、Web Storage、URL、响应、audit 或日志。
### 3. Task 编辑器只保存 pinned SecretRef
Console 使用逐行 `ENV_NAME=secret-name[@version]` 绑定。省略 version 时只能从当前有界目录解析并立即固定为当前版本;显式历史版本不得大于目录中的当前版本。未知名称、未来版本、重复环境变量、`QL3_` 保留前缀和不安全格式均拒绝。
保存时保留完整 authoring snapshot 中未展示的 public environment 与其他 config/labels,只替换 Console 可识别的 Secret bindings。Task spec 只持久化 canonical `SecretRef`;执行时继续由既有 runtime materializer 解密,不把明文写回 Task、Run、Event 或 Console。
### 4. Profile 与部署边界不扩张
- 默认 Edge/Standalone headless 不开放 Secret HTTP/Console surface,不携带静态资产或 listener;常驻增量仅是复用现有 SQLite connection 的小型 metadata repository。
- `edge-application-api``standalone-application-api` 才装配 Secret metadata、presence 与 administration capability;仍是同一 Application 进程和数据库连接。
- `@qinglong/local-api` 仅允许 `src/secret/secretRoutes.ts` 导入 Local administrationLocal Application contract 仅允许 runtime-core 的 metadata contractCluster dependency audit 对其他路径继续拒绝。
- Cluster 不使用 Local proof、keyring 或 SQLite authority。后续 Cluster Console Secret 管理必须走共享 custody、TLS、RBAC/Approval、PostgreSQL transaction 与 HA fence。
## 不采用的方案
- 不让浏览器读取、回显或下载 Secret 明文;轮换也只接受新值,不提供“显示现值”。
- 不把 ciphertext、key ID 或 mutation metadata 当作“低敏列表字段”;这些都留在 custody 边界内。
- 不把 Secret value 写入 Task environment、authoring lease、presence challenge 或 audit detail。
- 不用单因子 Bearer 直接创建或轮换 Secret;长期自动化凭据需要本机 presence 与 credential transaction fence。
- 不新建只有一两个文件的微包;该能力留在现有 runtime-core contract、Local SQLite repository、Local administration 和 Local API capability 内。
- 不为低配设备增加 Secret cache、后台清理器或独立进程。
## 结果与验证边界
定向测试覆盖 current-only metadata、稳定分页、关闭/越界失败、无 ciphertext/key 泄漏、exact presence、plaintext digest 漂移、宽化 body 拒绝、admission delegation、HTTP 路由与真实 SQLite/loopback 创建。集成旅程验证数据库只保存不含 plaintext 的密文,Task spec 保存精确 pinned `SecretRef`,并出现 `secret.create``secret.list` durable audit。
本地 18-package clean build/test 为 `3,052 total / 3,030 pass / 22 conditional、platform 或 external-service skip / 0 fail`,其中 Local SQLite `250/250`、Local API `76/76`、Local Admin `96/96`、Local Application `55 total / 51 pass / 4 platform skip / 0 fail`。package boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`122-module Edge source import 与精确 Cluster dependency audit compatible。
三项 Console 静态资产合计 102,182 bytes,低于 192 KiB 总闭包与 96 KiB 单文件门。默认 Edge 为 2,760,847 bytes/332 files/3 packages/59 loaded modulesRSS delta 11,026,432 bytesopt-in Edge/Standalone Console 为 4,210,024/4,210,168 bytes、482 files/12 packages/111 loaded modulesRSS delta 20,447,232/18,399,232 bytes,均低于既有门。Edge executor benchmark 继续通过。
这些证据只把 D-424 提升为可提交的源码候选。必须等待 exact source commit 的普通主 CI、Kubernetes deployment 和显式 Local Console 双架构 milestone 全绿,并下载复核 milestone checksum/auditor 后,才能把 D-424 称为新的阶段可用实物。在此之前,D-423 run `33245745837` 仍是最新可下载、可验真的 Console Trial Kit。
+1
View File
@@ -522,6 +522,7 @@
| [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 | AcceptedD-422 双架构 milestone 已交付) |
| [ADR-0518](./ADR-0518-local-console-cron-trigger-administration.md) | Local Console cron Trigger 管理 | AcceptedD-423 双架构 milestone 已交付) |
| [ADR-0519](./ADR-0519-local-console-secret-backed-automation.md) | Local Console Secret-backed 自动化 | Accepted(D-424 源码候选;阶段产物待远端门闭合) |
## 规则
+3
View File
@@ -28,6 +28,7 @@
| 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/leaseCluster 不复用 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 authorityConsole/API 可 list/read/create/update/enable/disable `qinglong/cron@v1`,真实 SQLite/loopback 与同源双架构 Console milestone 已通过 | Cluster 不复用 Local proof;不提供删除或通用 Trigger schema 编辑;仍不是正式发布或生产升级 |
| D-424 Console Secret-backed 自动化切片 | 源码候选已完成 current-only metadata、强认证 AES-256-GCM create/rotate 与 Task pinned `SecretRef` 绑定;真实 SQLite/loopback、本地全量包、资源与架构门已通过 | exact commit 的远端主 CI、Kubernetes 与双架构 milestone 尚未闭合;当前可下载实物仍是 D-423 |
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 创建能力已进入新的阶段实物,而不再只是候选源码。
@@ -35,6 +36,8 @@ D-422 已从“源码候选”升级为阶段实物:本地真实 SQLite/loopba
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 modulesopt-in Edge/Standalone Console 为 4,150,439/4,150,583 bytes、479 files/101 modules,三资产合计 84,401 bytes。提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的普通主 CI [run 33244982727](https://github.com/whyour/qinglong/actions/runs/33244982727) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33244982694](https://github.com/whyour/qinglong/actions/runs/33244982694) 成功;随后显式 Local Console milestone [run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837) 为 42 success/2 scope skip/0 fail。阶段产物绑定新的 exact commit/run/artifact digest,没有沿用或改名复用 D-422 archive。
D-424 当前严格标记为源码候选,而不是“开发完成即交付”:本地 18-package clean build/test 为 `3,052 total / 3,030 pass / 22 conditional skip / 0 fail`Local SQLite `250/250`、Local API `76/76`,默认 Edge 与 opt-in Console 资源门、Edge benchmark、package/source boundary 和 Cluster dependency audit 均通过。它已经证明 Secret metadata 不泄漏 custody 字段、create/rotate 必须 exact local presence、SQLite 只保存密文、Task 只保存 pinned `SecretRef`。只有 exact source commit 的普通主 CI、Kubernetes deployment 与显式双架构 Console milestone 全绿,并下载复核 checksum/auditor 后,本段才会改为阶段实物;当前下载者应继续使用下面的 D-423 run `33245745837`
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 绑定提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 与 [GitHub Actions run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837),保留至 2026-09-28
+13 -12
View File
@@ -1,6 +1,6 @@
# QingLong 3.0 Local Web Console
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、管理加密 Secret 绑定、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
## 选择部署档位
@@ -11,7 +11,7 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创
| 普通单节点服务器 | 选择 `standalone-application-api` |
| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 |
D-418 已闭合独立 Console image/Trial KitD-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 无损编辑。D-423 继续开放既有 immutable Trigger/cron authority;绑定提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的最新双架构实物已由 [milestone run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837) 生成并完成离线审计。
D-418 已闭合独立 Console image/Trial KitD-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 无损编辑。D-423 继续开放既有 immutable Trigger/cron authority;绑定提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的最新双架构实物已由 [milestone run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837) 生成并完成离线审计。D-424 的 Secret-backed 自动化已通过本地门,但在新的远端 CI 与双架构 milestone 闭合前仍是源码候选,不能借用 D-423 archive 宣称交付。
## 前置条件
@@ -57,25 +57,26 @@ 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. 需要周期运行时进入“定时”,选择“新建定时”,填写 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. 完成后选择“断开并清除凭据”,再关闭页面
5. 需要敏感环境变量时进入“凭据”,创建或轮换 Secret。每次操作都要求一份绑定 exact plaintext digest 的新 proofConsole 永不显示旧值,提交后立即清空输入。Task 编辑器使用 `ENV_NAME=secret-name``ENV_NAME=secret-name@version`,省略版本时也会在保存前固定为当前版本
6. 需要周期运行时进入“定时”,选择“新建定时”,填写 Trigger ID、已存在的 Task ID、cron expression、显式 timezone、`skip|fire_once` misfire policy 与 enabled。页面会先读取当前 Task revision/content digest,再要求一份绑定这次 exact Trigger 内容的本机 proof
7. 编辑、启用或停用 Trigger 都会追加 immutable revision;当前阶段没有删除。Task 已被其他操作更新时,旧 pin 会失败关闭,应刷新后重新确认,不能猜测重绑
8. 创建或更新 Task 成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`enabled Trigger 则由已有 durable Scheduler 自动产生 Run,不依赖浏览器保持打开
9. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取
10. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失
11. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止
12. 完成后选择“断开并清除凭据”,再关闭页面。
Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。
## 当前阶段可用边界
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 继续使用受信管理入口。页面暂不负责:
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。D-424 源码候选在此基础上增加 Secret current metadata、create/rotate 与 Task pinned binding;只有新 milestone 生成后该能力才进入下载产物。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。页面暂不负责:
- Identity、Policy、Secret、Plugin Package 或 AI 配置管理;
- Identity、Policy、Secret 明文读取/删除/历史浏览、Plugin Package 或 AI 配置管理;
- Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理;
- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
三项静态资产总计 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 modulesRSS delta 11,108,352 bytes;它不携带 Console/API 资产、listener 或第二个 scheduler,但包含复用有 SQLite Trigger mutation authority 的小幅装配代码增量
D-424 源码候选的三项静态资产总计 102,182 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,210,024 / 4,210,168 bytes、482 files、12 packages、111 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 20,447,232 / 18,399,232 bytes,低于 28 MiB。默认 headless Edge 为 2,760,847 bytes、332 files、3 packages、59 modulesRSS delta 11,026,432 bytes;它不携带 Console/API 资产、listener 或 Secret mutation surface,只增加复用有 SQLite connection 的有界 metadata 装配
停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。
@@ -25,8 +25,11 @@ type TriggerAdministrationForCredential =
type LocalScheduleStore = ReadyLocalStorage['schedules'];
type LocalDispatchStore = ReadyLocalStorage['dispatch'];
type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets'];
type LocalSecretMetadataSource = ReadyLocalStorage['localSecretMetadata'];
type LocalSecretAdministrationRepository =
ReadyLocalStorage['localSecretAdministration'];
type LocalSecretAdministrationForCredential =
ReadyLocalStorage['localSecretAdministrationForCredential'];
type ProjectPolicyRepository = ReadyLocalStorage['projectPolicy'];
type SecurityAuditSink = ReadyLocalStorage['securityAudit'];
type ApiCredentialRepository = ReadyLocalStorage['apiCredentials'];
@@ -102,7 +105,9 @@ export type LocalAdoptedProfileBootstrapResult =
readonly runAttemptLogRetention: ReadyLocalStorage['runAttemptLogRetention'];
readonly runLostRetry: ReadyLocalStorage['runLostRetry'];
readonly localSecrets: LocalSecretEnvelopeRepository;
readonly localSecretMetadata: LocalSecretMetadataSource;
readonly localSecretAdministration: LocalSecretAdministrationRepository;
readonly localSecretAdministrationForCredential: LocalSecretAdministrationForCredential;
readonly projectPolicy: ProjectPolicyRepository;
readonly securityAudit: SecurityAuditSink;
readonly apiCredentials: ApiCredentialRepository;
@@ -255,7 +260,10 @@ export async function bootstrapLocalAdoptedProfileStorage(
runAttemptLogRetention: readyStorage.runAttemptLogRetention,
runLostRetry: readyStorage.runLostRetry,
localSecrets: readyStorage.localSecrets,
localSecretMetadata: readyStorage.localSecretMetadata,
localSecretAdministration: readyStorage.localSecretAdministration,
localSecretAdministrationForCredential:
readyStorage.localSecretAdministrationForCredential,
projectPolicy: readyStorage.projectPolicy,
securityAudit: readyStorage.securityAudit,
apiCredentials: readyStorage.apiCredentials,
@@ -3,6 +3,8 @@
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 ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
const SECRET_REF_PREFIX = 'qlsecret:v1:';
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}$/;
@@ -57,6 +59,11 @@
'Trigger、Task 或授权在确认期间发生变化。请刷新后重新编辑。',
invalid_trigger: '定时配置无效。请检查表达式、时区与 Task 状态。',
trigger_unavailable: '定时配置暂时无法保存。请检查数据库状态。',
secret_query_unavailable: 'Secret 元数据暂时不可读取。请检查数据库状态。',
secret_fence_rejected:
'Secret、凭据或授权在确认期间发生变化。请刷新后重新保存。',
invalid_secret: 'Secret 名称、版本或明文无效。',
secret_unavailable: 'Secret 暂时无法加密保存。请检查密钥与数据库状态。',
run_cancellation_fence_rejected:
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
@@ -78,6 +85,7 @@
refresh: document.getElementById('refresh-button'),
createTask: document.getElementById('create-task-button'),
createTrigger: document.getElementById('create-trigger-button'),
createSecret: document.getElementById('create-secret-button'),
dialog: document.getElementById('confirmation-dialog'),
dialogTitle: document.getElementById('confirmation-title'),
dialogCopy: document.getElementById('confirmation-copy'),
@@ -94,6 +102,7 @@
taskDescription: document.getElementById('task-description-input'),
taskCommand: document.getElementById('task-command-input'),
taskArgs: document.getElementById('task-args-input'),
taskSecretBindings: document.getElementById('task-secret-bindings-input'),
taskEnabled: document.getElementById('task-enabled-input'),
taskEnabledLabel: document.getElementById('task-enabled-label'),
triggerEditor: document.getElementById('trigger-editor-dialog'),
@@ -108,6 +117,15 @@
triggerTimezone: document.getElementById('trigger-timezone-input'),
triggerMisfire: document.getElementById('trigger-misfire-input'),
triggerEnabled: document.getElementById('trigger-enabled-input'),
secretEditor: document.getElementById('secret-editor-dialog'),
secretEditorTitle: document.getElementById('secret-editor-title'),
secretEditorIntro: document.getElementById('secret-editor-intro'),
secretEditorNote: document.getElementById('secret-editor-note'),
secretEditorForm: document.getElementById('secret-editor-form'),
secretEditorClose: document.getElementById('secret-editor-close'),
secretEditorSave: document.getElementById('secret-editor-save'),
secretName: document.getElementById('secret-name-input'),
secretValue: document.getElementById('secret-value-input'),
presenceDialog: document.getElementById('presence-dialog'),
presenceForm: document.getElementById('presence-form'),
presenceCopy: document.getElementById('presence-copy'),
@@ -129,6 +147,8 @@
pendingPresence: null,
authoringSnapshot: null,
triggerSnapshot: null,
secretSnapshot: null,
secretCatalog: [],
toastTimer: null,
};
@@ -223,6 +243,138 @@
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
}
function encodeBase64UrlUtf8(value) {
const bytes = new TextEncoder().encode(value);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return window
.btoa(binary)
.replace(/\+/gu, '-')
.replace(/\//gu, '_')
.replace(/=+$/gu, '');
}
function decodeBase64UrlUtf8(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/u.test(value)) {
throw new TypeError('SecretRef 编码无效。');
}
const padding = '='.repeat((4 - (value.length % 4)) % 4);
const binary = window.atob(
value.replace(/-/gu, '+').replace(/_/gu, '/') + padding,
);
const bytes = Uint8Array.from(binary, (character) =>
character.charCodeAt(0),
);
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
}
function createSecretRef(projectId, name, version) {
const payload = JSON.stringify({ projectId, name, version });
const result = `${SECRET_REF_PREFIX}${encodeBase64UrlUtf8(payload)}`;
if (result.length > 512) throw new TypeError('SecretRef 过长。');
return result;
}
function parseSecretRef(value) {
if (
typeof value !== 'string' ||
value.length > 512 ||
!value.startsWith(SECRET_REF_PREFIX)
) {
throw new TypeError('SecretRef 无效。');
}
const encoded = value.slice(SECRET_REF_PREFIX.length);
const parsed = JSON.parse(decodeBase64UrlUtf8(encoded));
if (
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed) ||
Object.keys(parsed).sort().join(',') !== 'name,projectId,version' ||
typeof parsed.projectId !== 'string' ||
typeof parsed.name !== 'string' ||
!Number.isSafeInteger(parsed.version) ||
parsed.version < 1 ||
createSecretRef(parsed.projectId, parsed.name, parsed.version) !== value
) {
throw new TypeError('SecretRef 无效。');
}
return Object.freeze(parsed);
}
function isValidSecretName(value) {
return (
typeof value === 'string' &&
value.length > 0 &&
new TextEncoder().encode(value).length <= 128 &&
!/[\u0000-\u001f\u007f]/u.test(value)
);
}
function secretBindingsFromTask(task) {
const environment = task?.spec?.config?.environment;
if (environment === undefined) return '';
if (!Array.isArray(environment) || environment.length > 256) {
throw new TypeError('Task 环境变量定义无效。');
}
return environment
.filter((entry) => entry?.kind === 'secret')
.map((entry) => {
const reference = parseSecretRef(entry.secretRef);
if (
!ENVIRONMENT_NAME_PATTERN.test(entry.name) ||
entry.name.startsWith('QL3_') ||
reference.projectId !== state.project ||
!TASK_PATTERN.test(reference.name)
) {
throw new TypeError(
'当前 Task 包含 Console 无法安全编辑的 Secret 绑定。',
);
}
return `${entry.name}=${reference.name}@${reference.version}`;
})
.join('\n');
}
function parseSecretBindings(value) {
const entries = [];
const names = new Set();
const lines = value
.split(/\r?\n/u)
.map((entry) => entry.trim())
.filter(Boolean);
if (lines.length > 256) throw new TypeError('Secret 绑定不能超过 256 条。');
for (const line of lines) {
const match =
/^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9][A-Za-z0-9._:-]{0,127})(?:@([1-9][0-9]{0,9}))?$/u.exec(
line,
);
if (!match || match[1].startsWith('QL3_') || names.has(match[1])) {
throw new TypeError(`Secret 绑定无效:${line}`);
}
const available = state.secretCatalog.find(
(secret) => secret.name === match[2],
);
const version = match[3] ? Number(match[3]) : available?.currentVersion;
if (
!available ||
!Number.isSafeInteger(version) ||
version < 1 ||
version > available.currentVersion
) {
throw new TypeError(`找不到 Secret 当前版本:${match[2]}`);
}
names.add(match[1]);
entries.push(
Object.freeze({
name: match[1],
kind: 'secret',
secretRef: createSecretRef(state.project, match[2], version),
}),
);
}
return Object.freeze(entries);
}
async function api(path, options = {}) {
if (!state.token) {
throw new ConsoleRequestError('authentication_required', 401, null);
@@ -393,11 +545,17 @@
nodes.taskEditorIntro.textContent = editing
? `完整定义已由本机证明读取,并绑定 revision ${snapshot.task.revision}。保存时还会生成一份只绑定新内容的证明。`
: '定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。';
const secretHint =
state.secretCatalog.length === 0
? '当前没有可绑定 Secret;可先到“凭据”创建。'
: `当前可绑定:${state.secretCatalog
.map((secret) => `${secret.name}@${secret.currentVersion}`)
.join('、')}`;
nodes.taskEditorNote.textContent = editing
? `编辑租约将在 ${formatTime(
snapshot.authoring.expiresAtMs,
)} 失效;关闭后重新选择“编辑任务”可取得新快照`
: 'Alpha 当前从 Console 创建 qinglong/command@v1;高级 Task schema 仍使用受信任管理入口。';
)} 失效。${secretHint}`
: `Console 创建 qinglong/command@v1${secretHint}`;
nodes.taskEnabledLabel.textContent = editing
? '保存后允许运行'
: '创建后允许运行';
@@ -411,9 +569,11 @@
nodes.taskDescription.value = snapshot.task.description || '';
nodes.taskCommand.value = command.file;
nodes.taskArgs.value = command.args.join('\n');
nodes.taskSecretBindings.value = secretBindingsFromTask(snapshot.task);
nodes.taskEnabled.checked = snapshot.task.enabled;
} else {
nodes.taskCommand.value = '/bin/echo';
nodes.taskSecretBindings.value = '';
nodes.taskEnabled.checked = true;
}
nodes.taskEditor.returnValue = '';
@@ -462,6 +622,9 @@
.split(/\r?\n/u)
.map((value) => value.trim())
.filter((value) => value.length > 0);
const secretEnvironment = parseSecretBindings(
nodes.taskSecretBindings.value,
);
if (!TASK_PATTERN.test(taskId)) {
throw new TypeError('Task ID 格式无效。');
}
@@ -469,11 +632,24 @@
throw new TypeError('名称、命令或参数数量无效。');
}
const snapshot = state.authoringSnapshot;
const publicEnvironment = snapshot
? (snapshot.task.spec.config.environment || []).filter(
(entry) => entry?.kind === 'public',
)
: [];
const environment = Object.freeze([
...publicEnvironment,
...secretEnvironment,
]);
if (environment.length > 256) {
throw new TypeError('环境变量总数不能超过 256 条。');
}
const spec = snapshot
? Object.freeze({
...snapshot.task.spec,
config: Object.freeze({
...snapshot.task.spec.config,
environment,
command: Object.freeze({
...snapshot.task.spec.config.command,
kind: 'argv',
@@ -486,6 +662,7 @@
schema: 'qinglong/command@v1',
config: Object.freeze({
command: Object.freeze({ kind: 'argv', file, args }),
environment,
}),
});
return Object.freeze({
@@ -622,6 +799,205 @@
}
}
function normalizeSecretMetadata(value) {
const secrets = Array.isArray(value?.secrets) ? value.secrets : null;
if (
!secrets ||
secrets.length > 64 ||
secrets.some((secret) => {
if (
!secret ||
!isValidSecretName(secret.name) ||
!Number.isSafeInteger(secret.currentVersion) ||
secret.currentVersion < 1 ||
!Number.isSafeInteger(secret.createdAtMs) ||
secret.createdAtMs < 0
) {
return true;
}
const reference = parseSecretRef(secret.secretRef);
return (
reference.projectId !== state.project ||
reference.name !== secret.name ||
reference.version !== secret.currentVersion
);
})
) {
throw new ConsoleRequestError('response_unavailable', 503, null);
}
return Object.freeze(secrets.map((secret) => Object.freeze(secret)));
}
async function loadSecretCatalog() {
const value = await api(
`/api/v3/projects/${state.project}/secrets?limit=64`,
);
state.secretCatalog = normalizeSecretMetadata(value);
return Object.freeze({
secrets: state.secretCatalog,
truncated: value.truncated === true,
});
}
function openSecretEditor(snapshot = null) {
state.secretSnapshot = snapshot;
nodes.secretEditorForm.reset();
const rotating = snapshot !== null;
nodes.secretEditorTitle.textContent = rotating
? '轮换加密凭据'
: '创建加密凭据';
nodes.secretEditorIntro.textContent = rotating
? `新值将写入 ${snapshot.name} 的 version ${
snapshot.currentVersion + 1
};已有 Task 仍固定使用旧版本。`
: '明文只在当前页面内存和本次 loopback 请求中短暂存在;服务端只持久化 AES-256-GCM 密文。';
nodes.secretEditorNote.textContent = rotating
? '轮换不会悄悄改变现有自动化;请编辑 Task 明确切换到新版本。'
: '保存需要一次性本机证明;API、审计、Console 与日志都不会返回明文。';
nodes.secretName.readOnly = rotating;
if (rotating) {
nodes.secretName.setAttribute('aria-readonly', 'true');
nodes.secretName.value = snapshot.name;
} else {
nodes.secretName.removeAttribute('aria-readonly');
}
nodes.secretValue.value = '';
nodes.secretEditor.returnValue = '';
nodes.secretEditor.showModal();
(rotating ? nodes.secretValue : nodes.secretName).focus();
}
function secretDraft() {
const name = nodes.secretName.value.trim();
const plaintext = nodes.secretValue.value;
nodes.secretValue.value = '';
if (!TASK_PATTERN.test(name)) {
throw new TypeError('Secret 名称格式无效。');
}
if (!plaintext || new TextEncoder().encode(plaintext).length > 16 * 1024) {
throw new TypeError('Secret 新值必须为 116384 bytes。');
}
return Object.freeze({
name,
plaintext,
mutationId: newMutationId(),
expectedCurrentVersion: state.secretSnapshot?.currentVersion || 0,
});
}
async function saveSecretDraft() {
let body;
try {
body = secretDraft();
} catch (error) {
showToast(
error instanceof Error ? error.message : 'Secret 定义无效。',
'error',
);
return;
}
nodes.secretEditorSave.disabled = true;
try {
const value = await api(`/api/v3/projects/${state.project}/secrets`, {
method: 'PUT',
body,
acceptStatus: 428,
});
if (value.code === 'local_presence_required') {
showPresenceChallenge({ kind: 'secret-mutation', body }, value);
return;
}
throw new ConsoleRequestError('response_unavailable', 503, null);
} catch (error) {
body = null;
showToast(describeError(error), 'error');
} finally {
nodes.secretEditorSave.disabled = false;
}
}
async function renderSecrets() {
const value = await loadSecretCatalog();
if (value.secrets.length === 0) {
empty('还没有加密 Secret。创建后可在命令 Task 中绑定固定版本。');
return;
}
const fragment = document.createDocumentFragment();
fragment.append(
listHeader('Encrypted Secret catalog', value.secrets.length),
);
const list = element('div', 'record-list');
for (const secret of value.secrets) {
const button = element('button', 'record');
button.type = 'button';
button.dataset.identity = secret.name;
if (state.selectedId === secret.name) {
button.setAttribute('aria-current', 'true');
}
const main = element('span');
main.append(element('span', 'record-title', secret.name));
main.append(
recordMeta([`version ${secret.currentVersion}`, 'AES-256-GCM']),
);
const side = element('span', 'record-side');
const status = element('span', 'status', '已加密');
status.dataset.tone = 'active';
side.append(status);
side.append(
element('span', 'record-time', formatTime(secret.createdAtMs)),
);
button.append(main, side);
button.addEventListener('click', () => selectSecret(secret.name));
list.append(button);
}
fragment.append(list);
if (value.truncated) {
fragment.append(
element(
'p',
'privacy-note',
'当前只展示前 64 条;使用 API 可继续读取下一页。',
),
);
}
replace(nodes.ledger, fragment);
}
function selectSecret(name) {
state.selectedId = name;
for (const row of nodes.ledger.querySelectorAll('.record')) {
if (row.dataset.identity === name)
row.setAttribute('aria-current', 'true');
else row.removeAttribute('aria-current');
}
const secret = state.secretCatalog.find((entry) => entry.name === name);
if (!secret) {
detailEmpty('Secret 元数据已变化,请刷新后重试。');
return;
}
const fragment = document.createDocumentFragment();
fragment.append(detailHeader('Encrypted Secret', secret.name, secret.name));
const facts = element('div', 'facts');
facts.append(
fact('当前版本', secret.currentVersion),
fact('存储', 'AES-256-GCM 密文'),
fact('Pinned ref', shortDigest(secret.secretRef)),
fact('版本时间', formatTime(secret.createdAtMs)),
);
fragment.append(facts);
const actions = element('div', 'detail-actions');
actions.append(actionButton('轮换新版本', () => openSecretEditor(secret)));
fragment.append(actions);
fragment.append(
element(
'p',
'privacy-note',
'Task 只绑定固定版本;轮换后必须显式编辑 Task 才会采用新值。明文永不从此接口返回。',
),
);
replace(nodes.detail, fragment);
}
function showPresenceChallenge(action, challenge) {
if (
challenge?.code !== 'local_presence_required' ||
@@ -634,13 +1010,20 @@
state.pendingPresence = Object.freeze({ ...action, challenge });
const authoringRead = action.kind === 'authoring';
const triggerMutation = action.kind === 'trigger-mutation';
const secretMutation = action.kind === 'secret-mutation';
nodes.presenceCopy.textContent = authoringRead
? '读取完整 Task 定义需要部署设备上的一次性证明。返回的编辑租约不替代保存时的新内容证明。'
: triggerMutation
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Trigger 与 Task revision,且只能使用一次。'
: secretMutation
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明绑定这次 Secret 内容摘要、当前版本和 User Credential,且只能使用一次。'
: '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。';
nodes.presenceSubmit.textContent = authoringRead
? '验证并加载定义'
: secretMutation
? action.body.expectedCurrentVersion === 0
? '验证并加密创建'
: '验证并轮换版本'
: action.mutation.body.expectedRevision === null
? '验证并创建'
: '验证并更新';
@@ -653,6 +1036,7 @@
nodes.presenceError.hidden = true;
nodes.taskEditor.close();
nodes.triggerEditor.close();
nodes.secretEditor.close();
nodes.presenceDialog.returnValue = '';
nodes.presenceDialog.showModal();
nodes.presenceProof.focus();
@@ -735,6 +1119,15 @@
state.pendingPresence = null;
nodes.presenceProof.value = '';
nodes.presenceDialog.close();
try {
await loadSecretCatalog();
} catch {
state.secretCatalog = [];
showToast(
'Task 已加载,但 Secret 目录暂不可用;已有绑定仍会保留。',
'error',
);
}
openTaskEditor(snapshot);
showToast('完整 Task 定义已加载;保存仍需要新的本机证明。');
return;
@@ -767,6 +1160,32 @@
await selectTrigger(pending.mutation.triggerId);
return;
}
if (pending.kind === 'secret-mutation') {
const value = await api(`/api/v3/projects/${state.project}/secrets`, {
method: 'PUT',
body: pending.body,
presence: proof,
});
const rotated = pending.body.expectedCurrentVersion > 0;
state.pendingPresence = null;
state.secretSnapshot = null;
nodes.secretValue.value = '';
nodes.presenceProof.value = '';
nodes.presenceDialog.close();
showToast(
value.status === 'existing'
? '已找到同一 Secret 请求。'
: rotated
? `Secret 已轮换到 version ${value.secret.currentVersion}`
: 'Secret 已加密创建。',
);
state.view = 'secrets';
state.selectedId = pending.body.name;
updateNavigation();
await refresh();
selectSecret(pending.body.name);
return;
}
const value = await api(
`/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`,
{
@@ -1326,6 +1745,7 @@
if (state.view === 'tasks') {
nodes.createTask.hidden = false;
nodes.createTrigger.hidden = true;
nodes.createSecret.hidden = true;
nodes.kicker.textContent = 'Project task authority';
nodes.title.textContent = '任务调度台';
nodes.description.textContent =
@@ -1333,13 +1753,23 @@
} else if (state.view === 'triggers') {
nodes.createTask.hidden = true;
nodes.createTrigger.hidden = false;
nodes.createSecret.hidden = true;
nodes.kicker.textContent = 'Durable cron authority';
nodes.title.textContent = '定时触发器';
nodes.description.textContent =
'配置内置 cron Trigger,绑定 Task 当前 revision;停用只追加历史,不删除证据。';
} else if (state.view === 'secrets') {
nodes.createTask.hidden = true;
nodes.createTrigger.hidden = true;
nodes.createSecret.hidden = false;
nodes.kicker.textContent = 'Encrypted local custody';
nodes.title.textContent = 'Secret 凭据库';
nodes.description.textContent =
'只展示名称和当前版本;明文经本机证明后加密保存,Task 显式绑定固定版本。';
} else {
nodes.createTask.hidden = true;
nodes.createTrigger.hidden = true;
nodes.createSecret.hidden = true;
nodes.kicker.textContent = 'Durable run evidence';
nodes.title.textContent = '运行事实账本';
nodes.description.textContent =
@@ -1353,6 +1783,7 @@
try {
if (state.view === 'tasks') await renderTasks();
else if (state.view === 'triggers') await renderTriggers();
else if (state.view === 'secrets') await renderSecrets();
else await renderRuns();
setConnection('connected', `${state.project} · 已连接`);
} catch (error) {
@@ -1387,8 +1818,11 @@
state.pendingPresence = null;
state.authoringSnapshot = null;
state.triggerSnapshot = null;
state.secretSnapshot = null;
state.secretCatalog = [];
if (nodes.taskEditor.open) nodes.taskEditor.close();
if (nodes.triggerEditor.open) nodes.triggerEditor.close();
if (nodes.secretEditor.open) nodes.secretEditor.close();
if (nodes.presenceDialog.open) nodes.presenceDialog.close();
nodes.token.value = '';
nodes.token.disabled = false;
@@ -1399,6 +1833,7 @@
nodes.refresh.hidden = true;
nodes.createTask.hidden = true;
nodes.createTrigger.hidden = true;
nodes.createSecret.hidden = true;
setConnection('idle', '等待凭据');
nodes.kicker.textContent = 'Connection gate';
nodes.title.textContent = '先建立一条本机连接';
@@ -1435,8 +1870,16 @@
nodes.disconnect.addEventListener('click', disconnect);
nodes.refresh.addEventListener('click', refresh);
nodes.createTask.addEventListener('click', () => openTaskEditor());
nodes.createTask.addEventListener('click', async () => {
try {
await loadSecretCatalog();
openTaskEditor();
} catch (error) {
showToast(describeError(error), 'error');
}
});
nodes.createTrigger.addEventListener('click', () => openTriggerEditor());
nodes.createSecret.addEventListener('click', () => openSecretEditor());
nodes.taskEditorClose.addEventListener('click', () => {
state.authoringSnapshot = null;
nodes.taskEditor.close();
@@ -1453,10 +1896,21 @@
event.preventDefault();
await saveTriggerDraft();
});
nodes.secretEditorClose.addEventListener('click', () => {
state.secretSnapshot = null;
nodes.secretValue.value = '';
nodes.secretEditor.close();
});
nodes.secretEditorForm.addEventListener('submit', async (event) => {
event.preventDefault();
await saveSecretDraft();
});
nodes.presenceCancel.addEventListener('click', () => {
state.pendingPresence = null;
state.authoringSnapshot = null;
state.triggerSnapshot = null;
state.secretSnapshot = null;
nodes.secretValue.value = '';
nodes.presenceProof.value = '';
nodes.presenceDialog.close();
});
@@ -1495,6 +1949,7 @@
nodes.dialog.open ||
nodes.taskEditor.open ||
nodes.triggerEditor.open ||
nodes.secretEditor.open ||
nodes.presenceDialog.open
) {
return;
@@ -1504,6 +1959,8 @@
? 'tasks'
: event.key.toLowerCase() === 's'
? 'triggers'
: event.key.toLowerCase() === 'k'
? 'secrets'
: event.key.toLowerCase() === 'r'
? 'runs'
: null;
@@ -70,6 +70,9 @@
<button type="button" data-view="triggers">
<span>定时</span><kbd>S</kbd>
</button>
<button type="button" data-view="secrets">
<span>凭据</span><kbd>K</kbd>
</button>
<button type="button" data-view="runs">
<span>运行</span><kbd>R</kbd>
</button>
@@ -97,6 +100,9 @@
<button class="action-button" id="create-trigger-button" type="button" hidden>
<span aria-hidden="true"></span> 创建定时
</button>
<button class="action-button" id="create-secret-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>
@@ -175,6 +181,10 @@
<span>参数 · 每行一个</span>
<textarea id="task-args-input" rows="5" maxlength="16384" spellcheck="false"></textarea>
</label>
<label class="editor-wide">
<span>Secret 环境变量 · 每行 ENV=secret-name@version</span>
<textarea id="task-secret-bindings-input" rows="4" maxlength="24576" spellcheck="false" placeholder="API_TOKEN=github-token"></textarea>
</label>
<label class="editor-check">
<input id="task-enabled-input" type="checkbox" checked />
<span id="task-enabled-label">创建后允许运行</span>
@@ -243,6 +253,39 @@
</form>
</dialog>
<dialog id="secret-editor-dialog" class="task-editor-dialog">
<form id="secret-editor-form" autocomplete="off">
<div class="dialog-heading">
<div>
<p class="eyebrow">Encrypted Secret custody</p>
<h2 id="secret-editor-title">创建加密凭据</h2>
</div>
<button class="quiet-button" id="secret-editor-close" type="button">关闭</button>
</div>
<p class="editor-intro" id="secret-editor-intro">
明文只在当前页面内存和本次 loopback 请求中短暂存在;服务端只持久化 AES-256-GCM 密文。
</p>
<div class="editor-grid">
<label class="editor-wide">
<span>Secret 名称</span>
<input id="secret-name-input" maxlength="128" spellcheck="false" autocomplete="off" required />
</label>
<label class="editor-wide">
<span>新值</span>
<input id="secret-value-input" type="password" maxlength="16384" spellcheck="false" autocomplete="new-password" required />
</label>
</div>
<p class="editor-note" id="secret-editor-note">
保存需要一次性本机证明;API、审计、Console 与日志都不会返回明文。
</p>
<div class="dialog-actions">
<button class="primary-button" id="secret-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>
@@ -33,6 +33,10 @@ import type {
LocalApiTriggerReadRoute,
} from '../trigger/triggerReadRoutes';
import type { LocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
import type {
LocalApiSecretListRoute,
LocalApiSecretPutRoute,
} from '../secret/secretRoutes';
import type { LocalApiResponse } from '../transport/contract';
export type LocalApiAdmissionOperation =
@@ -111,6 +115,16 @@ export type LocalApiAdmissionOperation =
operationId: 'trigger.put';
projectId: string;
triggerId: string;
}>
| Readonly<{
operationId: 'secret.list';
projectId: string;
limit: number;
after?: Readonly<{ readonly name: string }>;
}>
| Readonly<{
operationId: 'secret.put';
projectId: string;
}>;
export interface LocalApiAdmissionRequest {
@@ -152,6 +166,8 @@ export interface LocalApiAdmissionOptions {
readonly triggerListRoute: LocalApiTriggerListRoute;
readonly triggerReadRoute: LocalApiTriggerReadRoute;
readonly triggerPutRoute: LocalApiTriggerPutRoute;
readonly secretListRoute: LocalApiSecretListRoute;
readonly secretPutRoute: LocalApiSecretPutRoute;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
@@ -235,6 +251,8 @@ export function createLocalApiAdmission(
typeof options.triggerListRoute?.handle !== 'function' ||
typeof options.triggerReadRoute?.handle !== 'function' ||
typeof options.triggerPutRoute?.handle !== 'function' ||
typeof options.secretListRoute?.handle !== 'function' ||
typeof options.secretPutRoute?.handle !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function')
@@ -342,6 +360,24 @@ export function createLocalApiAdmission(
});
}
if (request.operation.operationId === 'secret.put') {
const secretPutOperation = request.operation;
return Object.freeze({
bodyMode: 'json' as const,
maximumBodyBytes: 20 * 1_024,
async handle(body: unknown | null) {
return options.secretPutRoute.handle({
requestId: request.requestId,
projectId: secretPutOperation.projectId,
body,
presence: request.localPresence,
authenticated,
signal: request.signal,
});
},
});
}
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = normalizeSecurityPolicyDecision(
@@ -359,6 +395,8 @@ export function createLocalApiAdmission(
request.operation.operationId === 'trigger.list' ||
request.operation.operationId === 'trigger.get'
? 'task.read'
: request.operation.operationId === 'secret.list'
? 'secret.manage'
: 'run.read',
),
);
@@ -506,9 +544,19 @@ export function createLocalApiAdmission(
projectId: request.operation.projectId,
triggerId: request.operation.triggerId,
});
case 'secret.list':
if (body !== null) return response(400, 'invalid_request_body');
return options.secretListRoute.handle({
projectId: request.operation.projectId,
limit: request.operation.limit,
...(request.operation.after
? { after: request.operation.after }
: {}),
});
case 'task.put':
case 'task.authoring':
case 'trigger.put':
case 'secret.put':
return response(503, 'request_unavailable');
}
},
@@ -27,6 +27,10 @@ import {
createLocalApiTriggerReadRoute,
} from '../trigger/triggerReadRoutes';
import { createLocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
import {
createLocalApiSecretListRoute,
createLocalApiSecretPutRoute,
} from '../secret/secretRoutes';
import { startLocalApiHttpSurface } from '../transport/httpSurface';
export interface LocalApiProductSurfaceEvent {
@@ -195,6 +199,28 @@ export function createLocalApiProductSurface(
? {}
: { randomUuid: options.randomUuid }),
});
const secretListRoute = createLocalApiSecretListRoute(
authority.localSecretMetadata,
);
const secretPutRoute = createLocalApiSecretPutRoute({
projectPolicy: authority.projectPolicy,
secretAdministrationForCredential: (fence) => {
if (fence.subjectType !== 'user') {
throw new TypeError('Secret mutation requires a User credential');
}
return authority.localSecretAdministrationForCredential({
...fence,
subjectType: 'user',
});
},
securityAudit: authority.securityAudit,
secretKeys: authority.localSecretKeys,
presenceProof,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
const admission = createLocalApiAdmission({
authenticator,
policy,
@@ -213,6 +239,8 @@ export function createLocalApiProductSurface(
triggerListRoute,
triggerReadRoute,
triggerPutRoute,
secretListRoute,
secretPutRoute,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
@@ -0,0 +1,596 @@
import { createHash, randomUUID } from 'node:crypto';
import {
LocalSecretAdministrationAuthenticationError,
LocalSecretAdministrationAuthorizationError,
LocalSecretAdministrationConfigurationError,
LocalSecretAdministrationUnavailableError,
createLocalSecretAdministrationService,
} from '@qinglong/local-admin/secret-administration';
import {
LocalSecretMetadataUnavailableError,
LocalSecretMutationConflictError,
LocalSecretVersionConflictError,
assertLocalSecretExpectedVersion,
assertLocalSecretMutationId,
assertLocalSecretName,
assertLocalSecretPlaintext,
assertLocalSecretProjectId,
assertLocalSecretVersion,
createLocalSecretRef,
type LocalSecretMetadataPage,
type LocalSecretKeyProvider,
type LocalSecretMetadataSource,
} from '@qinglong/runtime-core/local-secret';
import {
LocalSecretAuthorizationFenceConflictError,
type LocalSecretAdministrationRepository,
} from '@qinglong/runtime-core/local-secret-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 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([
'expectedCurrentVersion',
'mutationId',
'name',
'plaintext',
]);
export interface LocalApiSecretListRoute {
handle(request: LocalApiSecretListRequest): Promise<LocalApiResponse>;
}
export interface LocalApiSecretListRequest {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<{ readonly name: string }>;
}
export interface LocalApiSecretPutRequest {
readonly requestId: string;
readonly projectId: string;
readonly body: unknown | null;
readonly presence: string | null;
readonly authenticated: Readonly<AuthenticatedLocalApiRequest>;
readonly signal: AbortSignal;
}
export interface LocalApiSecretPutRoute {
handle(
request: Readonly<LocalApiSecretPutRequest>,
): Promise<LocalApiResponse>;
}
export interface LocalApiSecretPutRouteOptions {
readonly projectPolicy: ProjectPolicyRepository;
readonly secretAdministrationForCredential: (
fence: Readonly<AuthenticatedLocalApiRequest['credentialFence']>,
) => Promise<LocalSecretAdministrationRepository>;
readonly securityAudit: SecurityAuditSink;
readonly secretKeys: LocalSecretKeyProvider;
readonly presenceProof: LocalPresenceProofManager;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
type SecretPutCommand = Readonly<{
name: string;
plaintext: string;
mutationId: string;
expectedCurrentVersion: number;
}>;
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): SecretPutCommand {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new TypeError('Secret body is invalid');
}
const keys = Object.keys(body).sort();
if (
BODY_KEYS.some((key) => !keys.includes(key)) ||
keys.some((key) => !BODY_KEYS.includes(key))
) {
throw new TypeError('Secret body shape is invalid');
}
const candidate = body as Record<string, unknown>;
assertLocalSecretName(candidate.name);
assertLocalSecretPlaintext(candidate.plaintext);
assertLocalSecretMutationId(candidate.mutationId);
assertLocalSecretExpectedVersion(candidate.expectedCurrentVersion);
if (
typeof candidate.mutationId !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(
candidate.mutationId,
)
) {
throw new TypeError('Secret mutation identity is invalid');
}
return Object.freeze(candidate as SecretPutCommand);
}
function requestDigest(projectId: string, command: SecretPutCommand): string {
return createHash('sha256')
.update('qinglong3.local-api-secret-put.v1\0', 'utf8')
.update(canonicalJson({ projectId, ...command }), 'utf8')
.digest('hex');
}
function presenceBinding(
projectId: string,
command: SecretPutCommand,
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(projectId, 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: 'secret.create' | 'secret.rotate';
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 isCredentialFenceConflict(error: unknown): boolean {
return (
!!error &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string' &&
error.code.startsWith('LOCAL_SQLITE_AUTHENTICATED_')
);
}
function normalizeMetadataPage(
request: Readonly<LocalApiSecretListRequest>,
value: unknown,
): Readonly<LocalSecretMetadataPage> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.keys(value).some(
(key) => !['next', 'secrets', 'truncated'].includes(key),
)
) {
throw new LocalSecretMetadataUnavailableError();
}
const page = value as Readonly<Record<string, unknown>>;
if (
!Array.isArray(page.secrets) ||
page.secrets.length > request.limit ||
typeof page.truncated !== 'boolean'
) {
throw new LocalSecretMetadataUnavailableError();
}
assertLocalSecretProjectId(request.projectId);
if (request.after) assertLocalSecretName(request.after.name);
let previous = request.after?.name;
const secrets = Object.freeze(
page.secrets.map((candidate) => {
if (
!candidate ||
typeof candidate !== 'object' ||
Array.isArray(candidate) ||
Object.keys(candidate).sort().join(',') !==
'createdAtMs,currentVersion,name,projectId'
) {
throw new LocalSecretMetadataUnavailableError();
}
const secret = candidate as Readonly<Record<string, unknown>>;
assertLocalSecretProjectId(secret.projectId);
assertLocalSecretName(secret.name);
assertLocalSecretVersion(secret.currentVersion);
if (
secret.projectId !== request.projectId ||
!Number.isSafeInteger(secret.createdAtMs) ||
(secret.createdAtMs as number) < 0 ||
(previous !== undefined &&
Buffer.compare(
Buffer.from(secret.name as string, 'utf8'),
Buffer.from(previous, 'utf8'),
) <= 0)
) {
throw new LocalSecretMetadataUnavailableError();
}
previous = secret.name as string;
return Object.freeze({
projectId: secret.projectId as string,
name: secret.name as string,
currentVersion: secret.currentVersion as number,
createdAtMs: secret.createdAtMs as number,
});
}),
);
const next = page.next;
if (
page.truncated === true
? !next ||
typeof next !== 'object' ||
Array.isArray(next) ||
Object.keys(next).join('') !== 'name' ||
(next as Readonly<Record<string, unknown>>).name !== previous
: next !== undefined
) {
throw new LocalSecretMetadataUnavailableError();
}
return Object.freeze({
secrets,
truncated: page.truncated,
...(page.truncated === true && previous !== undefined
? { next: Object.freeze({ name: previous }) }
: {}),
});
}
export function createLocalApiSecretListRoute(
source: LocalSecretMetadataSource,
): Readonly<LocalApiSecretListRoute> {
if (!source || typeof source.listLocalSecretMetadata !== 'function') {
throw new TypeError('Local API Secret metadata source is invalid');
}
return Object.freeze({
async handle(request: Readonly<LocalApiSecretListRequest>) {
try {
const page = normalizeMetadataPage(
request,
await source.listLocalSecretMetadata(request),
);
return response(200, {
secrets: Object.freeze(
page.secrets.map((secret) =>
Object.freeze({
name: secret.name,
currentVersion: secret.currentVersion,
secretRef: createLocalSecretRef({
projectId: secret.projectId,
name: secret.name,
version: secret.currentVersion,
}),
createdAtMs: secret.createdAtMs,
}),
),
),
truncated: page.truncated,
...(page.next
? {
next: Object.freeze({
after: Buffer.from(page.next.name, 'utf8').toString(
'base64url',
),
}),
}
: {}),
});
} catch {
return response(503, { code: 'secret_query_unavailable' });
}
},
});
}
export function createLocalApiSecretPutRoute(
options: Readonly<LocalApiSecretPutRouteOptions>,
): Readonly<LocalApiSecretPutRoute> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
typeof options.projectPolicy?.resolve !== 'function' ||
typeof options.secretAdministrationForCredential !== 'function' ||
typeof options.securityAudit?.record !== 'function' ||
typeof options.secretKeys?.active !== 'function' ||
typeof options.secretKeys?.resolve !== '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 Secret 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<LocalApiSecretPutRequest>) {
if (request.signal.aborted) {
return response(503, { code: 'request_unavailable' });
}
let command: SecretPutCommand;
try {
command = normalizeBody(request.body);
} catch {
return response(400, { code: 'invalid_secret' });
}
const operationId =
command.expectedCurrentVersion === 0
? ('secret.create' as const)
: ('secret.rotate' as const);
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,
'secret.manage',
),
);
} catch (error) {
const audited = await recordAudit(options.securityAudit, {
eventId: uuid(),
requestId: request.requestId,
operationId,
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,
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(
request.projectId,
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,
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,
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' });
}
try {
const strongPrincipal = strongLocalConsolePrincipal(
request.authenticated,
proof,
);
const mutations = await options.secretAdministrationForCredential(
request.authenticated.credentialFence,
);
const service = createLocalSecretAdministrationService(
options.projectPolicy,
mutations,
options.securityAudit,
options.secretKeys,
{ now },
);
const result = await service.put({
projectId: request.projectId,
name: command.name,
plaintext: command.plaintext,
mutationId: command.mutationId,
requestId: request.requestId,
expectedCurrentVersion: command.expectedCurrentVersion,
principal: strongPrincipal,
});
return response(
result.status === 'inserted' && command.expectedCurrentVersion === 0
? 201
: 200,
{
status: result.status,
secret: Object.freeze({
name: command.name,
currentVersion: result.version,
secretRef: result.secretRef,
}),
},
);
} catch (error) {
if (
error instanceof LocalSecretVersionConflictError ||
error instanceof LocalSecretMutationConflictError ||
error instanceof LocalSecretAuthorizationFenceConflictError ||
isCredentialFenceConflict(error)
) {
return response(409, { code: 'secret_fence_rejected' });
}
if (error instanceof LocalSecretAdministrationAuthenticationError) {
return response(401, { code: 'strong_authentication_required' });
}
if (error instanceof LocalSecretAdministrationAuthorizationError) {
return response(403, { code: 'forbidden' });
}
if (error instanceof LocalSecretAdministrationConfigurationError) {
return response(400, { code: 'invalid_secret' });
}
if (
error instanceof LocalSecretAdministrationUnavailableError ||
error instanceof LocalSecretMetadataUnavailableError
) {
return response(503, { code: 'secret_unavailable' });
}
return response(503, { code: 'secret_unavailable' });
}
},
});
}
@@ -13,6 +13,7 @@ import type { BoundedRunListInput } from '@qinglong/runtime-core/bounded-run-lis
import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection';
import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
import { assertLocalSecretName } from '@qinglong/runtime-core/local-secret';
import {
loadLocalConsoleAssets,
type LocalConsoleAsset,
@@ -46,6 +47,8 @@ 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 SECRET_ROUTE_PATTERN =
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/secrets$/;
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 =
@@ -60,7 +63,8 @@ type LocalApiRouteResolution =
| 'invalid_run_step_list_query'
| 'invalid_run_log_read_query'
| 'invalid_task_list_query'
| 'invalid_trigger_list_query';
| 'invalid_trigger_list_query'
| 'invalid_secret_list_query';
}>;
export interface LocalApiHttpSurfaceOptions {
@@ -445,6 +449,62 @@ function parseTriggerListQuery(
});
}
function parseSecretListQuery(
rawQuery: string | undefined,
profile: LocalApplicationProfile,
): Readonly<{
limit: number;
after?: Readonly<{ readonly name: 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')) {
throw new TypeError();
}
values.set(name, value);
}
const rawLimit = values.get('limit');
const limit =
rawLimit === undefined ? (profile === 'edge' ? 16 : 32) : Number(rawLimit);
if (
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > 64 ||
(rawLimit !== undefined && String(limit) !== rawLimit)
) {
throw new TypeError();
}
const encoded = values.get('after');
if (encoded === undefined) return Object.freeze({ limit });
if (encoded.length > 256 || !/^[A-Za-z0-9_-]+$/u.test(encoded)) {
throw new TypeError();
}
const bytes = Buffer.from(encoded, 'base64url');
let name: string;
try {
if (bytes.toString('base64url') !== encoded) throw new TypeError();
name = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
assertLocalSecretName(name);
} finally {
bytes.fill(0);
}
return Object.freeze({ limit, after: Object.freeze({ name }) });
}
function parseRunAttemptLogReadQuery(
rawQuery: string | undefined,
profile: LocalApplicationProfile,
@@ -534,6 +594,13 @@ function route(
: null;
}
if (request.method === 'PUT') {
const secretPutMatch = SECRET_ROUTE_PATTERN.exec(path);
if (secretPutMatch && rawQuery === undefined) {
return Object.freeze({
operationId: 'secret.put',
projectId: secretPutMatch[1]!,
});
}
const triggerPutMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
if (triggerPutMatch && rawQuery === undefined) {
return Object.freeze({
@@ -552,6 +619,18 @@ function route(
: null;
}
if (request.method !== 'GET') return null;
const secretListMatch = SECRET_ROUTE_PATTERN.exec(path);
if (secretListMatch) {
try {
return Object.freeze({
operationId: 'secret.list',
projectId: secretListMatch[1]!,
...parseSecretListQuery(rawQuery, profile),
});
} catch {
return Object.freeze({ errorCode: 'invalid_secret_list_query' });
}
}
const triggerReadMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
if (triggerReadMatch) {
return rawQuery === undefined
@@ -86,6 +86,51 @@ test('defers Trigger put Policy, presence and mutation to the route', async () =
]);
});
test('uses secret.manage for bounded Secret metadata and never widens the route input', async () => {
const { admission, events } = fixture();
assert.deepEqual(
await execute(
admission,
request({
operation: {
operationId: 'secret.list',
projectId: 'prj_default',
limit: 16,
after: { name: 'alpha' },
},
}),
),
{ statusCode: 200, body: { secrets: [], truncated: false } },
);
assert.deepEqual(events, [
'authenticate',
'authorize:secret.manage:prj_default',
'audit:allowed:secret.list',
'confirm',
'secrets:prj_default:16',
]);
});
test('defers Secret put Policy, presence and plaintext body to the fenced route', async () => {
const { admission, events } = fixture();
const prepared = await admission.prepare(
request({
localPresence: 'ql3p_bound',
operation: {
operationId: 'secret.put',
projectId: 'prj_default',
},
}),
);
assert.equal(prepared.bodyMode, 'json');
assert.equal(prepared.maximumBodyBytes, 20 * 1024);
assert.deepEqual(await prepared.handle({ plaintext: 'ephemeral' }), {
statusCode: 201,
body: { status: 'inserted' },
});
assert.deepEqual(events, ['authenticate', 'secret-put:prj_default']);
});
function request(overrides = {}) {
return Object.freeze({
requestId: 'local:019f70c0-0000-7000-8000-000000000001',
@@ -246,6 +291,18 @@ function fixture(overrides = {}) {
return { statusCode: 201, body: { status: 'created' } };
},
},
secretListRoute: {
async handle(value) {
events.push(`secrets:${value.projectId}:${value.limit}`);
return { statusCode: 200, body: { secrets: [], truncated: false } };
},
},
secretPutRoute: {
async handle(value) {
events.push(`secret-put:${value.projectId}`);
return { statusCode: 201, body: { status: 'inserted' } };
},
},
now: () => 10_000,
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
...overrides,
@@ -90,6 +90,15 @@ test('loads one bounded offline Console asset closure', () => {
assert.match(text, /triggers\/\$\{mutation\.triggerId\}/u);
assert.match(text, /state\.view === 'triggers'/u);
assert.match(text, /trigger_fence_rejected/u);
assert.match(text, /state\.view === 'secrets'/u);
assert.match(text, /secret-mutation/u);
assert.match(text, /createSecretRef/u);
assert.match(text, /kind: 'secret'/u);
assert.match(text, /secret_query_unavailable/u);
assert.equal(
/localStorage.*plaintext|sessionStorage.*plaintext/u.test(text),
false,
);
}
if (requestPath === '/') {
assert.match(text, /id="task-editor-dialog"/u);
@@ -99,6 +108,10 @@ test('loads one bounded offline Console asset closure', () => {
assert.match(text, /id="presence-copy"/u);
assert.match(text, /id="trigger-editor-dialog"/u);
assert.match(text, /data-view="triggers"/u);
assert.match(text, /data-view="secrets"/u);
assert.match(text, /id="secret-editor-dialog"/u);
assert.match(text, /id="task-secret-bindings-input"/u);
assert.match(text, /AES-256-GCM/u);
}
}
assert.ok(totalBytes <= 192 * 1024);
@@ -52,14 +52,19 @@ function request(port, path, options = {}) {
function preparedAdmission(handler) {
return {
async prepare(value) {
const json = ['run.cancel', 'task.start', 'task.put'].includes(
value.operation.operationId,
);
const json = [
'run.cancel',
'task.start',
'task.put',
'secret.put',
].includes(value.operation.operationId);
return {
bodyMode: json ? 'json' : 'none',
maximumBodyBytes:
value.operation.operationId === 'task.put'
? 72 * 1024
: value.operation.operationId === 'secret.put'
? 20 * 1024
: json
? 512
: 0,
@@ -83,7 +88,8 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
if (
value.operation.operationId === 'run.cancel' ||
value.operation.operationId === 'task.start' ||
value.operation.operationId === 'task.put'
value.operation.operationId === 'task.put' ||
value.operation.operationId === 'secret.put'
) {
return { statusCode: 202, body: { accepted: body } };
}
@@ -140,6 +146,12 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
body: { task: { taskId: value.operation.taskId } },
};
}
if (value.operation.operationId === 'secret.list') {
return {
statusCode: 200,
body: { secrets: [], truncated: false },
};
}
return {
statusCode: 200,
body: { runs: [], hasMore: false, input: value.operation.input },
@@ -356,6 +368,41 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
});
assert.equal(observed[11].localPresence, 'ql3p_authoring_read_proof');
const secrets = await request(
port,
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
);
assert.deepEqual(secrets.body, { secrets: [], truncated: false });
assert.deepEqual(observed[12].operation, {
operationId: 'secret.list',
projectId: 'prj_default',
limit: 8,
after: { name: 'alpha' },
});
const secretPutBody = JSON.stringify({ name: 'github-token' });
const secretPut = await request(
port,
'/api/v3/projects/prj_default/secrets',
{
method: 'PUT',
headers: {
authorization: 'Bearer opaque',
'x-qinglong-local-presence': 'ql3p_secret_bound_proof',
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(secretPutBody)),
},
body: secretPutBody,
},
);
assert.equal(secretPut.statusCode, 202);
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
assert.deepEqual(observed[13].operation, {
operationId: 'secret.put',
projectId: 'prj_default',
});
assert.equal(observed[13].localPresence, 'ql3p_secret_bound_proof');
for (const invalidPath of [
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
'/api/v3/projects/prj_default/runs/run%5f123',
@@ -402,6 +449,17 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
assert.equal(invalid.statusCode, 400);
assert.deepEqual(invalid.body, { code: 'invalid_task_list_query' });
}
for (const invalidQuery of [
'/api/v3/projects/prj_default/secrets?',
'/api/v3/projects/prj_default/secrets?limit=08',
'/api/v3/projects/prj_default/secrets?limit=65',
'/api/v3/projects/prj_default/secrets?after=Y',
'/api/v3/projects/prj_default/secrets?unknown=value',
]) {
const invalid = await request(port, invalidQuery);
assert.equal(invalid.statusCode, 400);
assert.deepEqual(invalid.body, { code: 'invalid_secret_list_query' });
}
for (const invalidQuery of [
'/api/v3/projects/prj_default/runs/run_123/events?',
'/api/v3/projects/prj_default/runs/run_123/events?after_sequence=07',
@@ -425,7 +483,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
assert.equal(invalid.statusCode, 400);
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
}
assert.equal(observed.length, 12);
assert.equal(observed.length, 14);
assert.deepEqual(
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
['stopped', 'stopped'],
@@ -0,0 +1,313 @@
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 {
createLocalPresenceProofManager,
} = require('../dist/authentication/localPresenceProof.js');
const {
createLocalApiSecretListRoute,
createLocalApiSecretPutRoute,
} = require('../dist/secret/secretRoutes.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 uuidFactory() {
let sequence = 400;
return () => {
sequence += 1;
return `019f9200-0000-4000-8000-${String(sequence).padStart(12, '0')}`;
};
}
function body(overrides = {}) {
return Object.freeze({
name: 'github-token',
plaintext: 'never-return-this-value',
mutationId: '019f9200-0000-4000-8000-000000000101',
expectedCurrentVersion: 0,
...overrides,
});
}
function fixture(t) {
const deploymentRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-secret-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, 17),
});
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 route = createLocalApiSecretPutRoute({
projectPolicy,
async secretAdministrationForCredential(fence) {
calls.push(['credential-fence', fence]);
return {
async resolveLocalSecretAdministrationMutation() {
return null;
},
async appendAuthorizedLocalSecretEnvelope(command) {
calls.push(['mutation', command]);
return {
status: 'inserted',
envelope: command.envelope,
audit: command.audit,
};
},
async record() {
throw new Error('not used');
},
};
},
securityAudit: {
async record(record) {
calls.push(['audit', record]);
},
},
secretKeys: {
async active() {
calls.push(['active-key']);
return { keyId: 'active-key', key: Buffer.alloc(32, 23) };
},
async resolve() {
return null;
},
},
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, requestBody, overrides = {}) {
return Object.freeze({
requestId: 'local:019f9200-0000-4000-8000-000000000301',
projectId: 'default',
body: requestBody,
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('lists bounded Secret metadata without storage or mutation material', async () => {
const route = createLocalApiSecretListRoute({
async listLocalSecretMetadata(options) {
assert.deepEqual(options, { projectId: 'default', limit: 1 });
return {
secrets: [
{
projectId: 'default',
name: 'github-token',
currentVersion: 2,
createdAtMs: 10_000,
},
],
truncated: true,
next: { name: 'github-token' },
};
},
});
const result = await route.handle({ projectId: 'default', limit: 1 });
assert.equal(result.statusCode, 200);
assert.deepEqual(Object.keys(result.body.secrets[0]).sort(), [
'createdAtMs',
'currentVersion',
'name',
'secretRef',
]);
assert.equal(
result.body.secrets[0].secretRef.startsWith('qlsecret:v1:'),
true,
);
assert.equal(result.body.next.after, 'Z2l0aHViLXRva2Vu');
assert.doesNotMatch(
JSON.stringify(result.body),
/cipher|plaintext|mutation|keyId/u,
);
});
test('fails closed on widened, cross-Project and over-budget metadata', async () => {
for (const page of [
{
secrets: [
{
projectId: 'other',
name: 'github-token',
currentVersion: 2,
createdAtMs: 10_000,
},
],
truncated: false,
},
{
secrets: [
{
projectId: 'default',
name: 'github-token',
currentVersion: 2,
createdAtMs: 10_000,
ciphertext: 'forbidden',
},
],
truncated: false,
},
{
secrets: [
{
projectId: 'default',
name: 'first',
currentVersion: 1,
createdAtMs: 10_000,
},
{
projectId: 'default',
name: 'second',
currentVersion: 1,
createdAtMs: 10_001,
},
],
truncated: true,
next: { name: 'second' },
},
]) {
const route = createLocalApiSecretListRoute({
async listLocalSecretMetadata() {
return page;
},
});
assert.deepEqual(await route.handle({ projectId: 'default', limit: 1 }), {
statusCode: 503,
body: { code: 'secret_query_unavailable' },
});
}
});
test('requires exact local presence and returns no Secret plaintext', async (t) => {
const state = fixture(t);
const command = body();
const challenge = await state.route.handle(request(state, command));
assert.equal(challenge.statusCode, 428);
const result = await state.route.handle(
request(state, command, { presence: readProof(state, challenge) }),
);
assert.equal(result.statusCode, 201);
assert.deepEqual(result.body, {
status: 'inserted',
secret: {
name: 'github-token',
currentVersion: 1,
secretRef: result.body.secret.secretRef,
},
});
assert.equal(JSON.stringify(result).includes(command.plaintext), false);
const mutation = state.calls.find(([kind]) => kind === 'mutation')[1];
assert.equal(
Buffer.from(mutation.envelope.ciphertext, 'base64url').includes(
Buffer.from(command.plaintext),
),
false,
);
assert.deepEqual(
state.calls
.filter(([kind]) => kind === 'audit')
.map(([, audit]) => [audit.operationId, audit.outcome, audit.reasons[0]]),
[['secret.create', 'approval_required', 'local_presence_required']],
);
});
test('binds proof to exact plaintext digest and rejects widened bodies', async (t) => {
const state = fixture(t);
assert.deepEqual(
await state.route.handle(request(state, { ...body(), extra: true })),
{ statusCode: 400, body: { code: 'invalid_secret' } },
);
const command = body();
const challenge = await state.route.handle(request(state, command));
const proof = readProof(state, challenge);
assert.deepEqual(
await state.route.handle(
request(state, body({ plaintext: 'changed-value' }), { presence: proof }),
),
{ statusCode: 401, body: { code: 'local_presence_rejected' } },
);
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 0);
});
@@ -187,8 +187,8 @@ function seed(databasePath, materialDigest) {
"role", "mutation_id", "changed_by_type", "changed_by_id",
"created_at_ms"
) VALUES (
'default', 'user', 'local-api-user', 1, 'active', 'operator',
'grant-local-api-operator', 'user', 'local-api-user', ?
'default', 'user', 'local-api-user', 1, 'active', 'owner',
'grant-local-api-owner', 'user', 'local-api-user', ?
)`,
)
.run(NOW - 500);
@@ -442,6 +442,19 @@ test('serves an authenticated Run through one real SQLite authority and durable
triggers: runtime.triggers,
triggerAdministrationForCredential:
runtime.triggerAdministrationForCredential,
localSecretMetadata: runtime.localSecretMetadata,
localSecretAdministrationForCredential:
runtime.localSecretAdministrationForCredential,
localSecretKeys: {
async active() {
return { keyId: 'integration-key', key: Buffer.alloc(32, 83) };
},
async resolve(keyId) {
return keyId === 'integration-key'
? { keyId, key: Buffer.alloc(32, 83) }
: null;
},
},
apiCredentials: runtime.apiCredentials,
ownerPepper: runtime.ownerPepper,
projectPolicy: runtime.projectPolicy,
@@ -531,6 +544,63 @@ test('serves an authenticated Run through one real SQLite authority and durable
{ statusCode: 404, body: { code: 'task_not_found' } },
);
const secretPlaintext = 'local-api-secret-value';
const secretBody = JSON.stringify({
name: 'github-token',
plaintext: secretPlaintext,
mutationId: '019f7300-0000-4000-8000-000000000700',
expectedCurrentVersion: 0,
});
const secretPath = '/api/v3/projects/default/secrets';
const secretOptions = {
method: 'PUT',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(secretBody)),
},
body: secretBody,
};
const secretChallenge = await request(
port,
`Bearer ${TOKEN}`,
secretPath,
secretOptions,
);
assert.equal(secretChallenge.statusCode, 428);
assert.equal(secretChallenge.body.code, 'local_presence_required');
const secretProof = JSON.parse(
fs.readFileSync(
path.join(root, 'console-presence', secretChallenge.body.proofFileName),
'utf8',
),
);
const secretCreated = await request(port, `Bearer ${TOKEN}`, secretPath, {
...secretOptions,
headers: {
...secretOptions.headers,
'x-qinglong-local-presence': secretProof.proof,
},
});
assert.equal(secretCreated.statusCode, 201);
assert.equal(secretCreated.body.secret.currentVersion, 1);
assert.match(secretCreated.body.secret.secretRef, /^qlsecret:v1:/u);
assert.equal(JSON.stringify(secretCreated).includes(secretPlaintext), false);
const secretList = await request(
port,
`Bearer ${TOKEN}`,
`${secretPath}?limit=64`,
);
assert.equal(secretList.statusCode, 200);
assert.deepEqual(secretList.body.secrets, [
{ ...secretCreated.body.secret, createdAtMs: NOW },
]);
assert.equal(secretList.body.truncated, false);
assert.doesNotMatch(
JSON.stringify(secretList),
new RegExp(`${secretPlaintext}|ciphertext|keyId|mutationId`, 'u'),
);
const taskCreateBody = JSON.stringify({
expectedRevision: null,
mutationId: '019f7300-0000-4000-8000-000000000701',
@@ -545,6 +615,13 @@ test('serves an authenticated Run through one real SQLite authority and durable
file: '/bin/echo',
args: ['console-created'],
},
environment: [
{
name: 'API_TOKEN',
kind: 'secret',
secretRef: secretCreated.body.secret.secretRef,
},
],
},
},
labels: { source: 'local-console' },
@@ -973,6 +1050,40 @@ test('serves an authenticated Run through one real SQLite authority and durable
);
const auditReader = new DatabaseSync(databasePath, { readOnly: true });
try {
const encryptedSecret = auditReader
.prepare(
`SELECT ciphertext FROM "QingLong3LocalSecretEnvelopes"
WHERE project_id = 'default' AND secret_name = 'github-token'
AND version = 1`,
)
.get();
assert.equal(
Buffer.from(encryptedSecret.ciphertext).includes(
Buffer.from(secretPlaintext),
),
false,
);
const createdSpec = JSON.parse(
auditReader
.prepare(
`SELECT revision.spec_json AS specJson
FROM "QingLong3TaskDefinitions" AS head
JOIN "QingLong3TaskDefinitionRevisions" AS revision
ON revision.project_id = head.project_id
AND revision.task_id = head.task_id
AND revision.revision = head.current_revision
WHERE head.project_id = 'default'
AND head.task_id = 'task-console-created'`,
)
.get().specJson,
);
assert.deepEqual(createdSpec.config.environment, [
{
name: 'API_TOKEN',
kind: 'secret',
secretRef: secretCreated.body.secret.secretRef,
},
]);
assert.deepEqual(
auditReader
.prepare(
@@ -981,7 +1092,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
'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',
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update'
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update',
'secret.create', 'secret.list'
)
ORDER BY operation_id, outcome`,
)
@@ -996,6 +1108,9 @@ test('serves an authenticated Run through one real SQLite authority and durable
'run.list:allowed',
'run.log.read:allowed',
'run.steps.list:allowed',
'secret.create:allowed',
'secret.create:approval_required',
'secret.list:allowed',
'task.authoring.read:allowed',
'task.authoring.read:approval_required',
'task.create:allowed',
@@ -525,6 +525,10 @@ export async function bootstrapLocalApplication(
triggers: storage.triggers,
triggerAdministrationForCredential:
storage.triggerAdministrationForCredential,
localSecretMetadata: storage.localSecretMetadata,
localSecretAdministrationForCredential:
storage.localSecretAdministrationForCredential,
localSecretKeys: secretKeys,
apiCredentials: storage.apiCredentials,
ownerPepper: storage.ownerPepper,
projectPolicy: storage.projectPolicy,
@@ -22,6 +22,7 @@ import type {
RunAttemptLogReadRequest,
RunAttemptLogReadResult,
} from '@qinglong/runtime-core/run-attempt-log-read';
import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret';
export type LocalApplicationProfile = 'edge' | 'standalone';
@@ -71,6 +72,9 @@ export interface LocalApplicationProductSurfaceAuthority {
readonly taskDefinitionAdministrationForCredential: ReadyFreshStorage['taskDefinitionAdministrationForCredential'];
readonly triggers: ReadyFreshStorage['triggers'];
readonly triggerAdministrationForCredential: ReadyFreshStorage['triggerAdministrationForCredential'];
readonly localSecretMetadata: ReadyFreshStorage['localSecretMetadata'];
readonly localSecretAdministrationForCredential: ReadyFreshStorage['localSecretAdministrationForCredential'];
readonly localSecretKeys: LocalSecretKeyProvider;
readonly runAttemptLogRead: Readonly<{
read(
request: Readonly<RunAttemptLogReadRequest>,
@@ -52,7 +52,9 @@ export type LocalProfileStorageBootstrapResult =
readonly runAttemptLogRetention: LocalSqliteRuntimeDatabase['runAttemptLogRetention'];
readonly runLostRetry: LocalSqliteRuntimeDatabase['runLostRetry'];
readonly localSecrets: LocalSqliteRuntimeDatabase['localSecrets'];
readonly localSecretMetadata: LocalSqliteRuntimeDatabase['localSecretMetadata'];
readonly localSecretAdministration: LocalSqliteRuntimeDatabase['localSecretAdministration'];
readonly localSecretAdministrationForCredential: LocalSqliteRuntimeDatabase['localSecretAdministrationForCredential'];
readonly projectPolicy: LocalSqliteRuntimeDatabase['projectPolicy'];
readonly securityAudit: LocalSqliteRuntimeDatabase['securityAudit'];
readonly apiCredentials: LocalSqliteRuntimeDatabase['apiCredentials'];
@@ -153,7 +155,10 @@ export async function bootstrapLocalProfileStorage(
runAttemptLogRetention: database.runAttemptLogRetention,
runLostRetry: database.runLostRetry,
localSecrets: database.localSecrets,
localSecretMetadata: database.localSecretMetadata,
localSecretAdministration: database.localSecretAdministration,
localSecretAdministrationForCredential:
database.localSecretAdministrationForCredential,
projectPolicy: database.projectPolicy,
securityAudit: database.securityAudit,
apiCredentials: database.apiCredentials,
@@ -17,7 +17,10 @@ import type { LocalRunStartupRecoverySource } from '@qinglong/runtime-core/local
import type { LocalDispatchStore } from '@qinglong/runtime-core/local-dispatch';
import type { LocalExecutionControlSource } from '@qinglong/runtime-core/local-execution-control';
import type { LocalCompletionReceiptJournal } from '@qinglong/runtime-core/local-completion-receipt-journal';
import type { LocalSecretEnvelopeRepository } from '@qinglong/runtime-core/local-secret';
import type {
LocalSecretEnvelopeRepository,
LocalSecretMetadataSource,
} from '@qinglong/runtime-core/local-secret';
import type { LocalSecretAdministrationRepository } from '@qinglong/runtime-core/local-secret-administration';
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
@@ -49,6 +52,7 @@ import type {
ProjectToolDefinitionSnapshotSourceRepository,
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
import { LocalSqliteSecretMetadataRepository } from '../security/secretMetadataRepository';
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import { LocalSqliteTaskDefinitionRepository } from '../task-definition/taskDefinitionRepository';
@@ -113,7 +117,11 @@ export interface LocalSqliteRuntimeDatabase {
readonly runAttemptLogRetention: LocalSqliteRunAttemptLogRetentionRepository;
readonly runLostRetry: LocalSqliteRunLostRetryRepository;
readonly localSecrets: LocalSecretEnvelopeRepository;
readonly localSecretMetadata: LocalSecretMetadataSource;
readonly localSecretAdministration: LocalSecretAdministrationRepository;
localSecretAdministrationForCredential(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
): Promise<LocalSecretAdministrationRepository>;
readonly projectPolicy: ProjectPolicyRepository;
readonly securityAudit: SecurityAuditSink;
readonly apiCredentials: ApiCredentialRepository;
@@ -184,6 +192,9 @@ export async function openLocalSqliteRuntimeDatabase(
const runRuntimeCapabilities =
createLocalSqliteRunRuntimeCapabilities(authority);
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
const localSecretMetadata = new LocalSqliteSecretMetadataRepository(
authority,
);
const taskDefinitions = new LocalSqliteTaskDefinitionRepository(
authority,
taskSpecSemanticRegistry,
@@ -326,7 +337,23 @@ export async function openLocalSqliteRuntimeDatabase(
runAttemptLogRetention,
runLostRetry,
localSecrets: securityAuthority,
localSecretMetadata,
localSecretAdministration: securityAuthority,
async localSecretAdministrationForCredential(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
) {
const { confirmLocalSqliteAuthenticatedUserCredentialFence } =
await import('../administration/packageManagement.js');
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
return new LocalSqliteSecurityAuthorityStore(authority, {
beforeAuthorizedLocalSecretMutation() {
confirmLocalSqliteAuthenticatedUserCredentialFence(
authority,
fence,
);
},
});
},
projectPolicy,
securityAudit: securityAuthority,
apiCredentials,
@@ -0,0 +1,133 @@
import {
LocalSecretMetadataUnavailableError,
MAX_LOCAL_SECRET_BATCH_SIZE,
assertLocalSecretName,
assertLocalSecretProjectId,
assertLocalSecretVersion,
type LocalSecretMetadata,
type LocalSecretMetadataPage,
type LocalSecretMetadataSource,
} from '@qinglong/runtime-core/local-secret';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
type Row = Record<string, unknown>;
function integer(row: Row, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value)) {
throw new LocalSecretMetadataUnavailableError();
}
return value as number;
}
function text(row: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string') {
throw new LocalSecretMetadataUnavailableError();
}
return value;
}
function metadata(row: Row): Readonly<LocalSecretMetadata> {
try {
const projectId = text(row, 'projectId');
const name = text(row, 'name');
const currentVersion = integer(row, 'currentVersion');
const createdAtMs = integer(row, 'createdAtMs');
assertLocalSecretProjectId(projectId);
assertLocalSecretName(name);
assertLocalSecretVersion(currentVersion);
if (createdAtMs < 0) throw new Error('invalid Secret timestamp');
return Object.freeze({ projectId, name, currentVersion, createdAtMs });
} catch (error) {
if (error instanceof LocalSecretMetadataUnavailableError) throw error;
throw new LocalSecretMetadataUnavailableError();
}
}
export class LocalSqliteSecretMetadataRepository
implements LocalSecretMetadataSource
{
constructor(private readonly authority: LocalSqliteOperationAuthority) {
if (!(authority instanceof LocalSqliteOperationAuthority)) {
throw new TypeError('Local Secret metadata authority is invalid');
}
}
listLocalSecretMetadata(options: {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<{ readonly name: string }>;
}): Promise<Readonly<LocalSecretMetadataPage>> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some(
(key) => !['after', 'limit', 'projectId'].includes(key),
) ||
!Object.hasOwn(options, 'limit') ||
!Object.hasOwn(options, 'projectId') ||
!Number.isSafeInteger(options.limit) ||
options.limit < 1 ||
options.limit > MAX_LOCAL_SECRET_BATCH_SIZE ||
(options.after !== undefined &&
(!options.after ||
typeof options.after !== 'object' ||
Array.isArray(options.after) ||
Object.keys(options.after).join('') !== 'name'))
) {
throw new TypeError('Local Secret metadata list options are invalid');
}
assertLocalSecretProjectId(options.projectId);
if (options.after) assertLocalSecretName(options.after.name);
return this.authority.enqueue(
async () => {
try {
const rows = this.authority.client
.prepare(
`SELECT secret."project_id" AS "projectId",
secret."secret_name" AS "name",
secret."version" AS "currentVersion",
secret."created_at_ms" AS "createdAtMs"
FROM "QingLong3LocalSecretEnvelopes" AS secret
WHERE secret."project_id" = ?
AND secret."secret_name" > ?
AND secret."version" = (
SELECT MAX(current."version")
FROM "QingLong3LocalSecretEnvelopes" AS current
WHERE current."project_id" = secret."project_id"
AND current."secret_name" = secret."secret_name"
)
ORDER BY secret."secret_name"
LIMIT ?`,
)
.all(
options.projectId,
options.after?.name ?? '',
options.limit + 1,
) as Row[] | undefined;
if (!Array.isArray(rows)) {
throw new LocalSecretMetadataUnavailableError();
}
const truncated = rows.length > options.limit;
const secrets = Object.freeze(
rows.slice(0, options.limit).map(metadata),
);
const last = secrets.at(-1);
return Object.freeze({
secrets,
truncated,
...(truncated && last
? { next: Object.freeze({ name: last.name }) }
: {}),
});
} catch {
throw new LocalSecretMetadataUnavailableError();
}
},
() => new LocalSecretMetadataUnavailableError(),
);
}
}
@@ -0,0 +1,115 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const { migrateLocalSqlitePath } = require('../dist/migration/migration.js');
const {
openLocalSqliteRuntimeDatabase,
} = require('../dist/runtime/runtimeDatabase.js');
function insertSecret(database, name, version, createdAtMs) {
database
.prepare(
`INSERT INTO "QingLong3LocalSecretEnvelopes"
("project_id", "secret_name", "version", "mutation_id", "key_id",
"algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms")
VALUES ('default', ?, ?, ?, 'active-key', 'aes-256-gcm', ?, ?, ?, ?)`,
)
.run(
name,
version,
`00000000-0000-4000-8000-${String(createdAtMs).padStart(12, '0')}`,
Buffer.alloc(12, version),
Buffer.from(`cipher-${name}-${version}`),
Buffer.alloc(16, version),
createdAtMs,
);
}
test('lists only current Secret metadata with a stable bounded cursor', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-meta-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const options = {
directory,
profile: 'edge',
databasePath: path.join(directory, 'qinglong3.sqlite'),
};
await migrateLocalSqlitePath(options);
const database = new DatabaseSync(options.databasePath);
insertSecret(database, 'alpha', 1, 101);
insertSecret(database, 'alpha', 2, 102);
insertSecret(database, 'beta', 1, 103);
insertSecret(database, 'gamma', 1, 104);
database.close();
const runtime = await openLocalSqliteRuntimeDatabase(options);
const first = await runtime.localSecretMetadata.listLocalSecretMetadata({
projectId: 'default',
limit: 2,
});
assert.deepEqual(first, {
secrets: [
{
projectId: 'default',
name: 'alpha',
currentVersion: 2,
createdAtMs: 102,
},
{
projectId: 'default',
name: 'beta',
currentVersion: 1,
createdAtMs: 103,
},
],
truncated: true,
next: { name: 'beta' },
});
assert.equal(JSON.stringify(first).includes('cipher'), false);
assert.equal(JSON.stringify(first).includes('key'), false);
assert.deepEqual(
await runtime.localSecretMetadata.listLocalSecretMetadata({
projectId: 'default',
limit: 2,
after: first.next,
}),
{
secrets: [
{
projectId: 'default',
name: 'gamma',
currentVersion: 1,
createdAtMs: 104,
},
],
truncated: false,
},
);
await runtime.close();
await assert.rejects(
runtime.localSecretMetadata.listLocalSecretMetadata({
projectId: 'default',
limit: 1,
}),
{ name: 'LocalSecretMetadataUnavailableError' },
);
});
test('rejects widened and over-budget metadata queries before storage', async () => {
const source = Object.create(
require('../dist/security/secretMetadataRepository.js')
.LocalSqliteSecretMetadataRepository.prototype,
);
assert.throws(
() =>
source.listLocalSecretMetadata({
projectId: 'default',
limit: 65,
}),
/options are invalid/u,
);
});
@@ -76,6 +76,36 @@ export interface LocalSecretEnvironmentProvider {
}): Promise<readonly string[] | null>;
}
export interface LocalSecretMetadata {
readonly projectId: string;
readonly name: string;
readonly currentVersion: number;
readonly createdAtMs: number;
}
export interface LocalSecretMetadataPage {
readonly secrets: readonly Readonly<LocalSecretMetadata>[];
readonly truncated: boolean;
readonly next?: Readonly<{ readonly name: string }>;
}
export interface LocalSecretMetadataSource {
listLocalSecretMetadata(options: {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<{ readonly name: string }>;
}): Promise<Readonly<LocalSecretMetadataPage>>;
}
export class LocalSecretMetadataUnavailableError extends Error {
readonly code = 'LOCAL_SECRET_METADATA_UNAVAILABLE';
constructor() {
super('Local Secret metadata is unavailable');
this.name = 'LocalSecretMetadataUnavailableError';
}
}
export interface PutEncryptedLocalSecretCommand {
readonly projectId: string;
readonly name: string;
@@ -151,7 +181,9 @@ function assertIdentifier(
}
}
export function assertLocalSecretProjectId(value: unknown): asserts value is string {
export function assertLocalSecretProjectId(
value: unknown,
): asserts value is string {
assertIdentifier('projectId', value, 128);
}
@@ -159,7 +191,9 @@ export function assertLocalSecretName(value: unknown): asserts value is string {
assertIdentifier('name', value, MAX_LOCAL_SECRET_NAME_BYTES);
}
export function assertLocalSecretVersion(value: unknown): asserts value is number {
export function assertLocalSecretVersion(
value: unknown,
): asserts value is number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 1 ||
@@ -169,11 +203,15 @@ export function assertLocalSecretVersion(value: unknown): asserts value is numbe
}
}
export function assertLocalSecretMutationId(value: unknown): asserts value is string {
export function assertLocalSecretMutationId(
value: unknown,
): asserts value is string {
assertIdentifier('mutationId', value, MAX_LOCAL_SECRET_MUTATION_ID_BYTES);
}
export function assertLocalSecretKeyId(value: unknown): asserts value is string {
export function assertLocalSecretKeyId(
value: unknown,
): asserts value is string {
if (
typeof value !== 'string' ||
value.length === 0 ||
@@ -184,7 +222,9 @@ export function assertLocalSecretKeyId(value: unknown): asserts value is string
}
}
export function assertLocalSecretPlaintext(value: unknown): asserts value is string {
export function assertLocalSecretPlaintext(
value: unknown,
): asserts value is string {
if (
typeof value !== 'string' ||
value.includes('\0') ||
@@ -194,7 +234,9 @@ export function assertLocalSecretPlaintext(value: unknown): asserts value is str
}
}
export function assertLocalSecretExpectedVersion(value: unknown): asserts value is number {
export function assertLocalSecretExpectedVersion(
value: unknown,
): asserts value is number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 0 ||
+18 -4
View File
@@ -1566,10 +1566,11 @@ function auditSourceImports(root, packagePath, findings) {
continue;
}
if (
!(
packagePath === 'packages/ql3-local-owner-cli' &&
path.relative(packageDirectory, filePath) ===
'src/security-management/secretCommand.ts'
![
'packages/ql3-local-owner-cli:src/security-management/secretCommand.ts',
'packages/ql3-local-api:src/secret/secretRoutes.ts',
].includes(
`${packagePath}:${path.relative(packageDirectory, filePath)}`,
) &&
specifier === '@qinglong/local-admin/secret-administration'
) {
@@ -2682,6 +2683,11 @@ function auditSourceImports(root, packagePath, findings) {
specifier ===
'@qinglong/runtime-core/plugin-package-automation-publication'
) &&
!(
path.relative(packageDirectory, filePath) ===
'src/application-runtime/contract.ts' &&
specifier === '@qinglong/runtime-core/local-secret'
) &&
!(
path.relative(packageDirectory, filePath) ===
'src/production-process/pluginPackageRecoveryCatalog.ts' &&
@@ -3168,6 +3174,14 @@ function auditSourceImports(root, packagePath, findings) {
) {
continue;
}
if (
packagePath === 'packages/ql3-local-api' &&
path.relative(packageDirectory, filePath) ===
'src/secret/secretRoutes.ts' &&
specifier === '@qinglong/local-admin/secret-administration'
) {
continue;
}
if (
packagePath === 'packages/ql3-local-api' &&
path.relative(packageDirectory, filePath) ===
+4 -4
View File
@@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: localApi.rootSourceFileRoles,
},
{
sourceFiles: 24,
sourceFiles: 25,
rootSourceFiles: 1,
rootSourceLines: 84,
nestedSourceFiles: 23,
nestedSourceFiles: 24,
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
},
);
@@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: localSqlite.rootSourceFileRoles,
},
{
sourceFiles: 209,
sourceFiles: 210,
rootSourceFiles: 1,
rootSourceLines: 31,
nestedSourceFiles: 208,
nestedSourceFiles: 209,
rootSourceFileRoles: { 'index.ts': 'public_export' },
},
);