merge: integrate per-user module access
This commit is contained in:
@@ -16,6 +16,8 @@
|
||||
- Learning 本地库、下载、播放器注册和 IPC 结果必须绑定 Electron Main 从当前登录身份派生的不透明账号分区;切换账号、注销或会话 epoch 变化后,旧账号文件、播放器 URL 和迟到结果不得继续可见。
|
||||
- Learning 归档必须限制为最多 512 MiB;下载最多跟随 5 次同 Works origin 的 HTTP(S) 重定向且不得向资源重定向转发 Bearer。播放器必须校验精确 loopback origin、账号绑定与短效 nonce;只有显式课堂读取能注册资源,Agent/runtime 必须先验证调用前已存在的 active registration,再做无副作用权威解析,不能接受任意来源消息、自注册课程或跨账号资源。
|
||||
- Robot Guided Hotspot Binding 必须保持固件零改动、Main-owned default-on capability、精确环境值 `0` 回滚、固定系统浏览器 Portal 和现有六位 Binding facade。Windows/macOS 页面内只能扫描开放 `Xiaozhi-*` 短效候选并连接用户明确选择的项;Makelore 不得接触家庭 Wi-Fi 凭据,也不得把热点发现或 Binding 成功等同于可信身份/在线。
|
||||
- 用户模块入口策略必须由 Electron Main 将 Works `/api/auth/me` 安全投影为四个布尔值;缺失对象/字段默认开启,`design` 映射 `painting`,Renderer 不获得 Token 或原始 profile。
|
||||
- 被关闭模块必须在卡片和根/深层/别名路由两层均无法进入,且在 `MainLayout`/模块初始化前阻断;Code provider 等待 policy hydration,全局 `/settings` 仍可访问,终止性 `401` 清理 Main/Renderer 会话。该客户端入口策略不能代替 API 授权。
|
||||
|
||||
## Quality Checks
|
||||
|
||||
@@ -24,6 +26,7 @@
|
||||
- 真实生产发布仍需成组验证服务端合同、不可变 Release、运营审核、CDN/App 播放;客户端本地验证不能替代该整链验收。
|
||||
- Learning 上线前必须使用真实 Works 账号、固定 URL/SHA-256 的 production Stage artifact 和正式安装包验证目录/生成/材料/取消恢复、下载、离线多模块播放、进度、Agent、ASR 与 PBL/评分;客户端自动化不得替代 Windows 与签名 macOS 整链验收。
|
||||
- Robot 默认引导路径必须通过聚焦 Renderer/Main/native 测试并保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚。真实 Host API/native seam Electron E2E、Windows Robot 真机、签名 macOS x64/arm64、指定固件镜像与六位码发行契约未完成前,不得宣称完整双平台硬件兼容或端到端配网已经验收。
|
||||
- 四模块入口策略发布前必须部署 Works migration/API、重新打包客户端,并以真实账号对 Code、Canvas、Learning、Robot 分别执行关闭 smoke;客户端自动化不得代替服务端 API 授权验收。
|
||||
|
||||
## Last Reviewed
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
| Flow | Source | Destination | Notes |
|
||||
|---|---|---|---|
|
||||
| 登录续期 | Renderer 活动信号 | Main Works Session | 连续 7 天未使用才清除会话 |
|
||||
| 用户模块入口策略 | 会话恢复 / 登录 / 刷新 | Electron Main → Works `/api/auth/me` → 四布尔安全投影 → Renderer auth store → 卡片/路由/provider gate | 缺失对象或字段默认 `true`;`design` 映射 `painting`;终止性 `401` 清理 Main/Renderer 会话;全局 `/settings` 不受 Code gate |
|
||||
| 项目创建 | 新建项目对话框 | Host API → Main 项目初始化 | 创建时固定 `ProjectType`;小游戏/小程序原子生成受控模板,自定义只生成项目空间 |
|
||||
| 一键提交 | `ProjectPublishAction` | Renderer capability → Host API → Main 本地 npm/Vite build → built snapshot preflight → source+built+contract 上传 | 只对小游戏/小程序开放;首次 create 原子写入文字资料但不上传封面,已有 draft/published 只提交版本并沿用云端资料/封面;状态竞态固定失败,不做无条件 metadata PATCH |
|
||||
| 构建产物预检 | Main-owned built snapshot | 一次性 loopback origin → fresh Electron WebContents/CDP(桌面、移动) | 检查错误、白屏和外域;不调用 Playwright,检查与上传归档相同字节,但不产生可信 receipt |
|
||||
@@ -32,6 +33,7 @@
|
||||
- Main 持有刷新凭据、发布 Token、固定 npm runtime、源码/构建归档、临时目录、幂等键和 submission binding v2;Renderer 不持有归档路径、构建 origin 或自动部署状态。
|
||||
- 项目内 `.niancode/project.json` 保存 `ProjectType`;Main 在配置写入和目录复用时保持其不可变,并在打包时重新读取校验。
|
||||
- Renderer 仅持有短效公开会话状态和提交展示状态。
|
||||
- Renderer 可持久化当前账号的四布尔模块入口策略,但不持有原始 Works profile 或 Token。新账号不继承上一账号缓存;网络/暂时上游失败可保留同会话已知策略,终止性 `401` 不得回退到默认开启。
|
||||
- 本地构建临时目录、HTTP origin 和预检 WebContents/partition 只属于一次调用;预检读取与 `built_archive` 相同的内存字节,但结果不写为可信上传 receipt,也不覆盖生产 opaque-origin。
|
||||
- 旧 schema v1 `submitted` 记录迁移并保留;旧 `armed`、`waiting_for_package`、`waiting_for_login`、`uploading`、`failed` 归一为 `legacy_retired`,不再启动 watcher 或上传任务。
|
||||
- AI 绘画 Conversation 持有消息、Brief、Quote、`turnRevision` 和服务端 Session 绑定;Workspace 持有 Conversation 列表、生成任务和资产。
|
||||
@@ -50,6 +52,7 @@
|
||||
## External Interfaces
|
||||
|
||||
- Works Square 项目创建、版本上传、构建状态与 Release 状态 API。
|
||||
- Works Square `/api/auth/me` 模块权限 API;Electron Main 持有 Bearer 并只向 Renderer 投影 `programming`/`design`/`learning`/`robot` 对应的四个布尔值。
|
||||
- 本机 Host API 的发布路由;发布路由要求 Renderer capability。
|
||||
- 本机 Host API 的 OpenCode session、history 与 prompt 路由,以及 Main-owned AI 模型代理。
|
||||
- Main-owned electron-updater IPC 与 Works Square 平台/架构稳定 feed;正式安装产物发布不由 Renderer 控制。
|
||||
|
||||
@@ -13,6 +13,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
|
||||
| Project Configuration & Template | 保存不可变 `ProjectType`,原子生成新项目骨架 | 小游戏/小程序生成受控 Vite 模板;自定义保持最小项目空间 |
|
||||
| Project Release Builder | Main-owned 安全快照、本地 npm/Vite 构建、双归档与 artifact contract | 固定 npm 11.6.2;Vite 由项目 lockfile 锁定;产物与预检使用同一内存字节 |
|
||||
| Works Session | 七天真实活动滑动续期 | 刷新凭据由 Main 安全持有 |
|
||||
| Module Access Policy | Main-owned `/api/auth/me` projection → Renderer auth state → module chooser/router | Renderer 只接收 Code/Canvas/Learning/Robot 四个布尔值;缺失对象或字段默认开启,服务端 `design` 映射客户端 `painting` |
|
||||
| Submission Binding | 保存云端已接受的精确 app/version/review/hash 绑定 | schema v2 只记录成功提交;旧中间态迁移为 `legacy_retired`,不恢复后台任务 |
|
||||
| Play URL Projection | 校验服务端公共播放地址 | 只接受同源 HTTPS 和精确 `/apps/{app_id}/`;优先 `play_url`,`runtime_url` 仅一版本回退 |
|
||||
| Built Artifact Preflight | 在上传前对最终 built snapshot 做桌面/移动 UX fail-fast | Main 使用临时 loopback origin 与 Electron WebContents/CDP;不使用 Playwright、不生成可信证明 |
|
||||
@@ -34,6 +35,8 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
|
||||
## Important Boundaries
|
||||
|
||||
- Code、Canvas、Learning 与 Robot 是四个已启用顶层产品模块;Robot 仍是唯一硬件产品模块,不存在单独 Hardware 卡片。
|
||||
- 每个登录用户可由 Works `module_access` 关闭任意顶层模块入口。Main 只投影四个布尔值;被关闭卡片置灰不可点,根/深层/别名路由在 `MainLayout` 和模块初始化前拦截。Code provider 必须等待 auth policy hydration,而全局 `/settings` 不属于 Code policy guard。
|
||||
- 模块入口策略是用户交互与客户端初始化边界,不是服务端 API 授权。终止性 `/api/auth/me` `401` 必须清理 Main 和 Renderer 会话;各模块 API 仍须独立强制认证与授权。
|
||||
- Product UI language is Chinese-only. Unsupported system or persisted language values normalize to `zh`; removed locale files are not runtime fallbacks.
|
||||
- Packaged Canvas remains cloud-only with no local fallback. `pnpm run dev` now uses the cloud adapter; the local Workspace adapter requires the explicit development command and remains unpackaged-only.
|
||||
- Prompt Museum is a read-only curated inspiration surface, not a user-content community. Main owns Works authentication and forwards only the bounded list/detail routes; the client never bundles museum content or automatically submits a selected Prompt.
|
||||
@@ -76,4 +79,4 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-08-16
|
||||
2026-08-17
|
||||
|
||||
@@ -4,6 +4,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
|
||||
|
||||
## Integrated Through
|
||||
|
||||
- `3b799af`: per-user Code/Canvas/Learning/Robot entry policy from Works Square, projected by Electron Main as four booleans and enforced before disabled module routes initialize.
|
||||
- `01bee31`: enabled AI Learning course catalog/generation/download/playback, Main-owned cloud/runtime bridges, verified external OpenMAIC player-artifact packaging, account profile reuse, removal of the transient `game-engine` Skill, and project-root `planning-with-files` output from the authoritative remote main. The merge hardens this with strict DTO/error projection, account-isolated local state, bounded same-origin downloads/packages, a nonce-protected account-bound player HTTP session, and an exact-source/origin single-document iframe bridge.
|
||||
- `26b52d7`: Canvas Prompt Museum, editable server-priced generation Quotes, project deletion/task-detail workflow, cloud-default Canvas development entry, and Chinese-only UI consolidation from the authoritative remote main. Its transient bundled `game-engine` Skill is superseded by `01bee31`.
|
||||
- `c1326a2`: Guided Hotspot Binding now scans bounded open `Xiaozhi-*` candidates and connects the user-selected hotspot inside the page through Main-owned Windows WLAN and macOS CoreWLAN/CoreLocation adapters; system Wi-Fi remains fallback, exact `=0` rollback and firmware/cloud contracts are unchanged.
|
||||
@@ -34,6 +35,8 @@ AI 绘画的一个 Workspace 可包含多条 Conversation。消息、Brief、Quo
|
||||
|
||||
Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、分页、详情、作者/来源/许可证和图片地址全部由服务端经 Main-owned Host API 提供,客户端不打包静态数据集;“使用此 Prompt”只把原文带回当前 Canvas 输入框,不自动发送。该模块不是投稿、点赞、评论或排行榜社区。客户端契约已就绪,但不据此宣称 Works Square 内容后台和生产数据已经部署。`pnpm run dev` 现在默认使用云端 Canvas 适配器,本地适配器只能通过显式开发命令启用;产品 UI 只保留中文。
|
||||
|
||||
Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/api/auth/me`,Renderer 只获得 Code、Canvas、Learning、Robot 四个布尔权限。缺失 `module_access` 或任一字段时默认开启;服务端 `design` 显式映射客户端 `painting`。被关闭的模块卡片置灰且不可点击,根路由、深层路由和别名路由均在 `MainLayout` 或模块初始化前阻断。Code provider 等待认证权限加载完成;权限查询返回终止性 `401` 时同时清理 Main 和 Renderer 会话。`/settings` 是全局设置,不受 Code 入口策略阻断。该机制只是客户端入口策略,不代替服务端 API 授权。
|
||||
|
||||
AI 学习现在是已启用顶层模块。Renderer 浏览 Works 课程、提交单课生成需求、显示任务进度并打开已安装课程;材料上传经 Main IPC 严格投影,需求最多 4,000 字,文件最多 5 个、单个 50 MiB、总计 150 MiB。Main 持有 Works Token、生成/课程下载/Agent/ASR/课堂 runtime 访问和按认证身份派生的不透明账号分区;账号切换、注销或 epoch 变化会隐藏旧本地课程、关闭旧 player server 并丢弃迟到结果。课程归档最大 512 MiB,只允许同 Works origin、最多 5 跳且不携带资源 Bearer 的下载重定向,按服务端声明大小与 SHA-256 校验后原子安装并在播放前再次校验。安装期和 player server 只允许 MIME/扩展匹配的被动图片、音视频和字体;同源 HTML/SVG/XML/脚本/PDF 被拒绝,运行时响应带 nosniff/sandbox CSP/CORP/no-store。只有显式 `readClassroom` 会注册课程;Agent/runtime 先验证调用前既存 active registration,再用无副作用 resolver 核对本地 aggregate/module,不能靠自身请求注册。HTTP URL/cookie 由短效 nonce 保护;iframe bridge 校验精确 source/origin,只允许初始 player 文档,二次导航后永久关闭。课程 aggregate `contentHash` 是云端权益/进度身份,模块 hash 只作为受控上下文。打包必须提供由固定清单与 SHA-256 验证的 OpenMAIC production Stage 产物;当前客户端自动化不等同于真实 Works 课程服务、生产播放器产物或签名安装包验收。
|
||||
|
||||
AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗余历史读取,prompt 可直接进入 Host API;普通历史会话仍刷新消息。Main AI proxy 只把明确的上游分组饱和投影为当前 OpenCode 的终止状态,配额耗尽保持独立终止态,通用限速继续保留 `429`。上下文压缩以每个 Session 的持久时间线事件呈现:自动与手动压缩使用不同文案,运行态原位弱化显示,完成后静态保留并可从历史恢复;`session.compacted` 只完成对应事件,只有真实 idle 才结束 run 和释放排队消息。
|
||||
@@ -44,6 +47,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
|
||||
|
||||
## Recently Completed
|
||||
|
||||
- 2026-08-17: Integrated the reviewed per-user module-entry policy source tip `3b799af`. Main exposes only four booleans from `/api/auth/me`; missing fields remain enabled, `design` maps to `painting`, disabled root/deep/alias routes stop before module initialization, Code provider startup waits for policy hydration, terminal `401` clears both session layers, and global settings remains reachable.
|
||||
- 2026-08-17: Integrated remote `01bee31`: Learning is enabled with course browsing, strict bounded generation materials, verified atomic course installation, multi-module playback, Main-owned Agent/ASR/runtime bridges, and a manifest-verified external OpenMAIC player artifact. Merge review added account-isolated generation/library/player state, fixed-binding token/fetch/401 guards, passive-only course media with hardened responses, pre-existing active-registration checks before side-effect-free identity resolution, nonce-protected single-document player sessions, and a recoverable deep-link profile error gate. Publishing now reflects the actual Works contract: first create is coverless, existing draft/published are version-only, and races fail closed without cover/PATCH side effects. The transient `game-engine` Skill was removed and `planning-with-files` writes its files to the project root. Production Works/player-artifact/signed-package acceptance remains pending.
|
||||
- 2026-08-16: Integrated remote `26b52d7`: Canvas now has server-backed Prompt Museum navigation, editable server-repriced generation Quotes, task result details/downloads, guarded project deletion, cloud-default development, and Chinese-only UI. That tip briefly bundled `game-engine`; authoritative successor `01bee31` removed it. Client integration is verified separately from production Prompt Museum data/backend deployment.
|
||||
- 2026-08-16: Integrated Windows/macOS in-page Robot hotspot discovery, explicit selection, connection, and exact-current-SSID verification behind the existing default-on guided capability. Candidate IDs are bounded and short-lived, native diagnostics stay in Main, system settings remain fallback, and firmware/Portal/Binding contracts are unchanged.
|
||||
@@ -71,9 +75,10 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
|
||||
|
||||
## Next Recommended Steps
|
||||
|
||||
1. 使用真实 Works 账号和固定 SHA-256 的 production Stage artifact 验收 Learning 课程目录/生成/材料上传/取消恢复、下载、离线播放、进度、Agent、ASR、PBL/评分 runtime,并完成 Windows 与签名 macOS 安装包 smoke。
|
||||
2. 对 default-on Guided Hotspot Binding 核对指定固件镜像与六位码发行/消费契约,补齐 Windows 真机热点连接、签名 macOS x64/arm64 CoreWLAN/CoreLocation/worker 打包验证、真实 Host API/native seam Electron E2E 和完整真机 smoke;发布支持保留精确 `=0` 回滚,不把缺失证据表述为已验收。
|
||||
3. 成组核对客户端 source+built+contract 上传 → 服务端逐字节校验 → OSS immutable Release → CDN/Edge 的发布契约与客户端 `play_url` 消费契约。
|
||||
1. 部署 Works `module_access` migration 与 `/api/auth/me` 权限 API,打包新 Makelore 客户端,再用真实账号分别关闭 Code、Canvas、Learning、Robot 执行卡片、根/深层/别名路由 smoke;同时独立验证模块 API 服务端授权。
|
||||
2. 使用真实 Works 账号和固定 SHA-256 的 production Stage artifact 验收 Learning 课程目录/生成/材料上传/取消恢复、下载、离线播放、进度、Agent、ASR、PBL/评分 runtime,并完成 Windows 与签名 macOS 安装包 smoke。
|
||||
3. 对 default-on Guided Hotspot Binding 核对指定固件镜像与六位码发行/消费契约,补齐 Windows 真机热点连接、签名 macOS x64/arm64 CoreWLAN/CoreLocation/worker 打包验证、真实 Host API/native seam Electron E2E 和完整真机 smoke;发布支持保留精确 `=0` 回滚,不把缺失证据表述为已验收。
|
||||
4. 成组核对客户端 source+built+contract 上传 → 服务端逐字节校验 → OSS immutable Release → CDN/Edge 的发布契约与客户端 `play_url` 消费契约。
|
||||
|
||||
## Open Questions / Blockers
|
||||
|
||||
@@ -81,6 +86,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
|
||||
|
||||
## Risky Areas
|
||||
|
||||
- 四模块权限只控制 Makelore 客户端入口和初始化,不是 API 授权边界。不得因卡片置灰或路由阻断而放宽 Works/模块服务端的身份与权限校验;旧服务端缺少对象/字段时默认开启是显式兼容策略。
|
||||
- Learning 的课程目录、生成、Agent、ASR 与 runtime 都依赖真实 Works 权益和服务端契约;本地课程归档与播放器 artifact 必须在信任前完成边界、大小与摘要校验。账号分区/epoch、fixed-binding token+fetch guards、同源重定向、512 MiB 上限、player nonce、exact source/origin 与单文档 bridge 边界不可放宽;不得把模块/场景自报身份当成 aggregate 课程权益,也不得把上游错误、Token、内部 URL 或本地归档路径投影到 Renderer。
|
||||
- Works Project 服务当前没有 metadata revision/ETag、draft-only 条件写或封面删除/原子绑定合同;首次发布暂不上传封面,已有 draft/published 只允许 version-only。恢复封面或已有资料编辑前必须先扩展并真实验证服务端原子合同,客户端不得以无条件 PATCH 或孤立上传替代。
|
||||
- Guided Hotspot Binding 默认开启并提供未经认证的热点扫描/显式连接,但当前 Hotspot/portal 仍是开放 SoftAP + 明文 HTTP,且精确出货镜像、激活码发行契约、签名 macOS、Windows 真机与完整整链尚未验证。界面必须保留环境警告,异常发布可用精确环境值 `0` 回滚;不得把 SSID 前缀宣称为可信设备发现、自动认领或在线证明。
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
## Scope
|
||||
|
||||
- On 2026-08-17, resume the existing Integration owner after the user's explicit takeover authorization to preserve the task's existing uncommitted record, merge reviewed Makelore module-access source tip `3b799af` into local `main`, promote its accepted per-user entry-policy facts, run merged-tree verification and independent review, and keep remote push outside this resumption unless separately authorized.
|
||||
- On 2026-08-16, resume the existing Integration owner after the user restored remote credentials: fetch authoritative `origin/main`, complete the already-started merge of remote tip `26b52d7`, preserve both remote Canvas/Prompt Museum work and local Robot hotspot work, run merged-tree verification and independent review, then perform a normal non-forced push and verify remote-tip equality.
|
||||
- On 2026-08-16, resume the existing Integration owner to merge reviewed cross-platform in-app Robot hotspot connection source `c1326a2` into local `main`, supersede only ADR-002's manual operating-system hotspot-selection step, preserve firmware/credential/Binding boundaries, and keep remote push outside this resumption.
|
||||
- On 2026-08-16, resume the existing Integration owner to merge reviewed default-on Guided Hotspot Binding source `b78fc07` into local `main`, accept the user's explicit reversal of the prior default-off policy, preserve exact environment value `0` as rollback, and keep firmware edits and remote push outside this resumption.
|
||||
@@ -52,6 +53,7 @@
|
||||
- The user's latest instruction explicitly authorizes default-on and supersedes only the earlier default-off/capability-not-enabled constraint. It does not authorize firmware changes, automatic discovery/claim claims, arbitrary portal URLs, Wi-Fi credential handling, or remote push. Missing installed-Electron/physical-device evidence remains an explicit residual release risk, not completed evidence.
|
||||
- The user has now explicitly authorized page-owned selection and connection of nearby open `Xiaozhi-*` provisioning hotspots on both Windows and macOS. This supersedes only manual operating-system hotspot selection; discovery remains unauthenticated convenience, Main remains the sole native-network owner, and signed macOS plus physical-Robot smoke remain release gates.
|
||||
- The latest instruction explicitly authorizes fetching, semantically resolving conflicts, and pushing `main`. Use the existing merge topology; do not rebase, reset, stash, discard remote/local commits, or force-push. Preserve remote Prompt Museum/Canvas/Chinese-only/Skill behavior alongside the already reviewed local Robot native hotspot path and `koffi` packaging.
|
||||
- The 2026-08-17 takeover confirmation authorizes continuing this existing Integration owner while preserving its uncommitted task history. For module access, source-task records remain read-only; the policy is a client entry/initialization gate rather than API authorization, and release acceptance requires the matching Works migration/API, a newly packaged client, and real-account four-module smoke.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
@@ -99,6 +101,14 @@ Relevant understanding:
|
||||
Gate result:
|
||||
- Passed.
|
||||
|
||||
### 2026-08-17 Per-User Module Access Integration Resume
|
||||
|
||||
- Reused the existing Integration owner after the user explicitly authorized takeover while preserving its uncommitted record. Registry status still identifies task `20260813-sync-push-main-9c2f71`, mode `integration`, branch `main`, and worktree `D:\Datas\OthersProjects\makelore`; no new task context was created.
|
||||
- Verified source task `20260817-makelore-module-access-6f2a91c4` is `ready_for_integration`, source tip `3b799af` is present as the current `MERGE_HEAD`, and the no-ff/no-commit merge stages the reviewed product changes. Its task record remains read-only on the source branch and is deliberately excluded from the integrated `main` tree.
|
||||
- Read the source outcome, verification, follow-up and promotion candidate against the Main-owned Works Session boundary and current four-module model. No accepted ADR or peer source contradicts default-open compatibility, `design` → `painting`, pre-layout route blocking, global `/settings`, terminal `401` session cleanup, or the explicit non-authorization boundary.
|
||||
- Integration outcome remains pending until canonical reconciliation, merged-tree checks, independent final review and the final merge commit complete. The final merge SHA does not yet exist and must not be invented.
|
||||
- Gate result: Passed for canonical promotion and merged-tree verification. Works migration/API deployment, a newly built client package, real-account four-module smoke and server-side API authorization validation remain release commitments.
|
||||
|
||||
### 2026-08-16 Remote `26b52d7` Synchronization Resume
|
||||
|
||||
- Reused the existing Integration owner and refreshed its reservation. Local `main` entered this resumption at `9af6c526a9500a0dbfb88e39ba0dee1eb7e1d097`; the worktree already contained an unfinished merge whose `MERGE_HEAD` was `26b52d76e3dedd754ca1b1c428abaa074b7f98da`.
|
||||
@@ -255,6 +265,7 @@ Gate result:
|
||||
|
||||
## Outcome
|
||||
|
||||
- 2026-08-17 module-access staged integration passed merged-tree verification and independent final review and is ready for its merge commit. Canonical reconciliation records the source tip `3b799af`, Main-owned four-boolean projection, default-open compatibility, `design` → `painting`, disabled card/root/deep/alias guards before initialization, Code policy hydration, terminal `401` dual-session cleanup, global `/settings`, and the client-entry-only security boundary; the exact merge SHA will be recorded only after Git creates it.
|
||||
- A mandatory pre-push fetch found authoritative `origin/main` had advanced to `01bee3188be4b03b6b358c2da60f1f9ed22d707f`, so no stale push was attempted. Started a second normal `--no-ff --no-commit` merge with reviewed local merge `bb16c1d12a24b2957149db960edba6e6660691aa` as first parent and `01bee31` as second parent; README was the only textual conflict and was reconciled to preserve both the enabled Learning module and the detailed Robot hotspot flow.
|
||||
- Preserved the remote Learning product scope, four-module navigation, profile reuse, removal of `game-engine`, and project-root `planning-with-files` output. The merged tree no longer depends on a sibling OpenMAIC checkout: CI/release packaging requires the fixed manifest artifact, while an explicit local source remains development-only.
|
||||
- Closed merge-audit security/correctness findings across Learning: strict Host/service DTO and safe-error projection; bounded Agent/ASR/runtime and ZIP consumption; Main-derived opaque account partitions for generation, local courses, IPC and player registration; fixed-binding checks across token acquisition, fetch and 401 refresh; stale account/epoch result rejection; 512 MiB archives; same-Works-origin redirects with a 5-hop cap and no resource Bearer; a nonce-protected account-bound player HTTP session; and an exact-source/origin single-document iframe bridge.
|
||||
@@ -378,6 +389,11 @@ Gate result:
|
||||
|
||||
## Verification
|
||||
|
||||
- 2026-08-17 module-access merged-tree focused Vitest — 4 files / 69 tests passed.
|
||||
- 2026-08-17 module-access merged-tree full Vitest — 175 files / 2047 tests passed.
|
||||
- 2026-08-17 merged-tree TypeScript `tsc --noEmit` and scoped ESLint on all changed TypeScript/TSX files — passed.
|
||||
- 2026-08-17 merged-tree Renderer/Electron Main/Preload `pnpm run build:vite` — passed; only the existing chunk-size and mixed static/dynamic import warnings remain.
|
||||
- 2026-08-17 independent staged-merge Sol review — `PASS`, no blocking Standards or Spec findings. It independently reran 69 focused tests, typecheck, scoped ESLint, `build:vite`, document drift, registry doctor and diff checks, and confirmed the intended two-parent topology and source-task-record exclusion.
|
||||
- Remote `01bee31` merged-tree `pnpm install --frozen-lockfile` passed with the package-pinned pnpm 10.33.4; Electron 40.10.6 was restored with `pnpm rebuild electron` before desktop smoke.
|
||||
- Learning/Robot/Canvas/publish/OpenCode focused selection — 26 files / 418 tests passed.
|
||||
- Bounded full unit suite — 175 files / 1944 tests passed.
|
||||
@@ -503,6 +519,7 @@ Gate result:
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Before releasing the per-user module-entry policy, deploy the Works `module_access` migration and `/api/auth/me` contract, build and install a new Makelore package, then use a real account to disable Code, Canvas, Learning and Robot one at a time and smoke chooser/root/deep/alias behavior, global settings, terminal `401`, and independent server-side API authorization.
|
||||
- Before releasing Learning, run a real Works account through catalog, generation/material/cancel-resume, bounded download, offline multi-module playback, progress, Agent, ASR and PBL/scoring using the exact production Stage artifact. Validate packaged loopback cookie/nonce behavior on Windows and a signed macOS build; current automation is not that acceptance.
|
||||
- Before restoring project cover upload or editing metadata on an existing draft/published project, add and verify a server-owned revision/ETag plus draft-only conditional write and atomic cover attachment or cleanup. Until then the client intentionally creates new projects without a cover and treats existing projects as version-only.
|
||||
- Complete the visible Git Credential Manager/PowerShell authentication prompt, then fetch and verify `origin/main` equals the local tip before recording push completion and releasing this Integration task.
|
||||
@@ -528,6 +545,7 @@ Gate result:
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- The `3b799af` module-access candidate is promoted into current state, architecture/data flow, business rules, success criteria, evidence and commitments: Electron Main projects only four booleans from `/api/auth/me`; missing values are enabled, server `design` maps to client `painting`, disabled card/root/deep/alias entry stops before layout/module initialization, Code provider waits for policy hydration, terminal `401` clears both session layers, and `/settings` remains global. This is a client entry policy, not API authorization; deployment/package/real-account validation remains pending.
|
||||
- Remote `01bee31` facts and the reviewed merge hardening were promoted into README, AGENTS, success criteria, current state, architecture/data flow, business rules, glossary, evidence and commitments: Learning is enabled but remains Main-owned, account-partitioned, bounded, artifact-verified and pending real Works/signed-package acceptance; `game-engine` is removed and planning files belong in the project root.
|
||||
- Remote `26b52d7` facts were promoted into current state, architecture, domain rules, glossary, evidence, README, and release commitments: Prompt Museum remains read-only/server-driven, Quote pricing is service-owned, Canvas deletion is an explicit Workspace mutation, development is cloud-default, UI language is Chinese-only, and `game-engine` is an optional bundled Skill. Production Museum content and real-account billing/deletion acceptance remain pending commitments rather than completed evidence.
|
||||
- The default-on candidate from `b78fc07` was promoted into ADR-002, current state, decision/success criteria, Robot architecture/domain/glossary, README, and a concrete release-validation commitment. No unresolved canonical candidate remains; the missing native/physical evidence is tracked as a pending commitment rather than overclaimed.
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
- 已发布作品优先读取 `play_url`,只有字段缺失时才使用一个客户端版本的 `runtime_url` 回退。公共播放 URL 必须是 Works Square 同源 HTTPS、无 userinfo/loopback、精确 `/apps/{encodeURIComponent(app_id)}/`、无 query/fragment,且上游明确 `playable === true` 并提供非空版本名;否则按不可播放处理。
|
||||
- `works-cloud-deploy.json` 仅是已安装客户端的数据兼容文件名,不表示客户端仍提供 cloud deployment coordinator。
|
||||
- Works Square 会话按真实键盘、鼠标或触摸活动滑动续期,连续 7 天未使用才要求重新授权。
|
||||
- 运营端可按用户关闭 Code、Canvas、Learning 或 Robot 客户端入口,默认全开。Makelore 通过 Main-owned `/api/auth/me` 只消费四布尔安全投影;缺失 `module_access` 或字段按开启处理,服务端 `design` 对应现有客户端 `painting`。
|
||||
- 关闭的模块卡片必须置灰且无法点击;其根路由、深层路由和别名路由必须在 `MainLayout` 或模块初始化前阻断。Code provider 只能在 auth policy hydration 完成且 Code 已开启时初始化;`/settings` 是全局设置,不得随 Code 关闭而失去访问。
|
||||
- 模块置灰/路由阻断不是 API 授权边界。每个 Works/模块服务端 API 仍必须独立执行身份与权限检查;`/api/auth/me` 返回终止性 `401` 时必须清理 Main 和 Renderer 会话,不得以默认全开继续。
|
||||
- 客户端更新检查由 Electron Main 持有。缺少当前平台正式稳定 manifest 必须保持错误并提示稍后重试或从官网下载,不得宣称“已是最新版”;设置页只显示一条中文用户提示,原始堆栈、URL、路径和错误码只保留在 Main 日志。
|
||||
- 一个 AI 绘画 Workspace 可以包含多条独立 Conversation;新建 Conversation 不得创建新 Workspace。
|
||||
- Conversation 持有消息、Brief、Quote 和 `turnRevision`;生成任务和资产属于 Workspace,切换 Conversation 后必须继续可见。
|
||||
@@ -65,6 +68,7 @@
|
||||
- Robot Guided Hotspot Binding default-on 发布仍需确认指定硬件/固件确实提供被审计的开放 Hotspot/Portal、部署端签发严格六位 ASCII 数字码且与 Works validator 的时效/消费语义一致,并完成 Windows 真机、签名 macOS x64/arm64 native worker/association 与真实设备端到端 smoke。
|
||||
- Prompt Museum 客户端契约和页面完成不等于 Works Square 内容后台、审核数据或 CDN 已部署;上线前需以真实登录账号验证列表、详情、署名链接、分页、图片和 Prompt 回填。
|
||||
- Learning 客户端集成不等于 Works 课程服务、权益、生成流水线、production Stage artifact 或签名安装包已验收;上线前需以真实账号和固定 artifact 完成 Windows/macOS 整链 smoke。
|
||||
- 四模块入口策略上线前需确认 Works `module_access` migration/API 已部署,安装包包含对应客户端,并用真实账号逐一关闭四个模块验证卡片、根/深层/别名路由和独立 API 授权。
|
||||
|
||||
## Last Reviewed
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Use this index for searchable, traceable evidence records.
|
||||
|
||||
| Date | Topic | Status | Source | Detail |
|
||||
|---|---|---|---|---|
|
||||
| 2026-08-17 | Makelore 用户级四模块入口策略 | 客户端实现与独立复审通过;服务端部署、新包与真实账号 smoke 待验收 | 源任务 `20260817-makelore-module-access-6f2a91c4`、源 tip `3b799af` | 4 files / 69 focused tests、175 files / 2047 full tests、TypeScript、scoped ESLint、Renderer/Main/Preload production build 与最终独立 Sol review PASS。覆盖 Main 四布尔安全投影、缺失默认开启、`design` → `painting`、卡片置灰不可点、根/深层/别名路由初始化前阻断、Code policy hydration、terminal `401` 双层清会话和全局 `/settings`;不据此宣称 API 授权或生产发布已验收 |
|
||||
| 2026-08-17 | 远程 `01bee31` Learning 主线集成与安全收口 | 合并树自动化与独立双轴复审通过;真实 Works、固定生产播放器 artifact 与签名安装包待验收 | 远程提交 `01bee31`、集成任务 `20260813-sync-push-main-9c2f71` | pnpm 10.33.4 frozen install、26 files / 418 pre-review focused、175 files / 1944 pre-review full;四轮审查修复后统一 9 files / 161、最终 175 files / 2028 full、typecheck、lint(0 errors / 7 existing warnings)、Renderer/Main/Preload build 与最终 Electron E2E 4/4 通过(更早跨模块选择 9/9 亦通过);第五轮 Standards/Spec 最终复审 PASS、无 P0-P3。最终树严格投影 generation IPC;分离显式 player registration 与无副作用 identity resolve;manifest 只接受相对 `audio|media|fonts`,权威 root 注入单一 module 前缀,consumer 生成 URL 已穿过 registered ZIP 的真实 HTTP/Woff2/MIME/security-header 测试;保持 fixed-binding 账号/Token/fetch/401 guard、512 MiB/ZIP/同源 5 跳下载、nonce player、exact-source/origin bridge 与可恢复 profile gate。Works 发布因缺少 revision/cover cleanup 合同采用 coverless first-create、existing version-only、竞态 fail-closed;不据此宣称生产服务或签名包已验收 |
|
||||
| 2026-08-16 | 远程 `26b52d7` Canvas/Prompt Museum 主线集成 | 合并树自动化验证通过;真实服务端内容、计费与删除结算待验收 | 远程提交 `26b52d7`、集成任务 `20260813-sync-push-main-9c2f71` | 主工作区及独立临时目录 clean frozen install(955 packages)、16 files / 284 focused、Prompt Museum 3 files / 13、161 files / 1850 full、typecheck、lint、Renderer/Main/Preload build、Electron E2E 6/6 与文档门禁通过。合并额外修复 lock override 实际签名依赖图、Prompt Museum 未知错误脱敏/严格 DTO+HTTPS 投影/401 refresh,以及过期 E2E 断言;不据此宣称 Museum 后台审核数据、Quote 真实计费或 Workspace 删除结算已部署 |
|
||||
| 2026-08-16 | Robot Windows/macOS 配网页内热点连接 | 实现与本地自动化验证通过;双平台实机发布证据待完成 | 源提交 `c1326a2`、ADR-003、集成任务 `20260813-sync-push-main-9c2f71` | 4 files / 132 focused tests、157 files / 1796 full tests、typecheck、lint、Renderer/Main/Preload build 与独立 Standards/Spec review 通过;Electron 40.10.6 加载 Koffi/wlanapi 成功,Windows 权限拒绝安全投影通过。签名 macOS x64/arm64、Windows Robot 真机和真实 Host/native Electron E2E 未完成,不得据此宣称双平台硬件验收 |
|
||||
|
||||
@@ -4,6 +4,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks,
|
||||
|
||||
| Date | Commitment | Trigger / Due | Owner | Status | Next Action |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-17 | 验收用户级四模块入口策略的真实发布链 | 发布包含源 tip `3b799af` 的 Makelore 安装包前 | Works 服务端/客户端/发布集成 | Pending | 部署 `module_access` migration 和 `/api/auth/me` API,重新打包并安装 Makelore;使用真实账号依次关闭 Code、Canvas、Learning、Robot,重启后验证卡片置灰/不可点、根/深层/别名路由阻断、`/settings` 仍可访问和 terminal `401` 退出;另行以直接 API 请求确认服务端授权,不以 UI 置灰代替 |
|
||||
| 2026-08-16 | 验收 AI Learning 的真实 Works、课程包与 production Stage 发布链 | 发布包含 `01bee31` Learning 行为的安装包前 | 客户端/服务端/发布集成 | Pending | 使用真实账号核对课程广场、单课生成(无材料/5 文件边界)、取消/恢复/finalize、同源最多 5 跳下载、512 MiB archive 上限、大小/SHA-256/原子安装、账号切换隔离、离线多模块播放、进度、Agent、ASR、PBL/评分权益;以固定 URL/SHA-256 的 player artifact 构建并完成 Windows 与签名 macOS 安装包 smoke,验证 packaged Chromium 的 loopback cookie/nonce,不以客户端单测替代生产验收 |
|
||||
| 2026-08-17 | 恢复 Works 项目封面与已有资料编辑的原子合同 | 再启用首次封面上传或 draft/published metadata 编辑前 | Works 服务端/客户端发布集成 | Pending | 服务端提供可验证的 metadata revision/ETag 与 draft-only 条件写,以及原子 cover 绑定或失败清理/回收;客户端用真实竞态验证 404→create 409、draft→published 与 cover 失败均不产生孤立对象、不覆盖已发布资料。合同落地前保持首次 coverless、已有项目 version-only |
|
||||
| 2026-08-16 | 验收 Prompt Museum 与 Canvas 删除/重报价的真实服务端链路 | 发布包含 `26b52d7` Canvas 行为的安装包前 | 客户端/服务端集成 | Pending | 使用真实 Works 账号核对 Museum 列表/详情/分页/筛选/署名/CDN/Prompt 回填;核对最终 Prompt/options 重报价和确认设计点;删除 Workspace 后确认软删除可见性、未提交任务取消/预留积分释放、已运行任务结算。保留 Main 错误脱敏和严格 DTO/HTTPS 投影,不以客户端回归替代服务端验收 |
|
||||
|
||||
@@ -9,7 +9,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人
|
||||
- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。
|
||||
- `Makelore Learning|AI 学习`:浏览和下载 Works 课程,也可以从需求、材料与多媒体选项发起后台单课生成;下载后的 frozen 课程包由内置 OpenMAIC production Stage 播放。
|
||||
|
||||
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入浏览器授权,授权成功后回到入口选择页,已登录用户可直接进入对应工作区。
|
||||
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入浏览器授权,授权成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
|
||||
|
||||
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。当前 Works 项目接口没有可验证的元数据 revision/条件写或封面回收合同,因此首次创建会原子保存文字资料但暂不上传封面,已有 draft/published 只提交新版本并沿用平台现有资料与封面;恢复封面或已有资料编辑前必须先补齐服务端原子合同。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../../services/works-square-runtime';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { WorksSquareTokenPayload } from '../../services/works-square-session';
|
||||
import { normalizeModuleAccess } from '../../../shared/module-access';
|
||||
|
||||
type AuthClientInput = {
|
||||
authBase?: unknown;
|
||||
@@ -465,6 +466,46 @@ async function handleSessionActivity(
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCurrentUser(res: ServerResponse, ctx: HostApiContext): Promise<void> {
|
||||
const accessToken = await getValidWorksSquareAccessToken({ forceRefresh: false });
|
||||
if (!accessToken) {
|
||||
sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await proxyAwareFetch(createWorksUrl('/api/auth/me').toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const payload = await readResponsePayload(response);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearWorksSquareSession();
|
||||
await Promise.allSettled([
|
||||
flushWorksSquareSessionPersistence(),
|
||||
clearManagedWorksSquareRuntimeBestEffort(ctx, 'terminal current-user lookup'),
|
||||
]);
|
||||
}
|
||||
sendJson(res, response.status === 401 ? 401 : 502, {
|
||||
success: false,
|
||||
error: response.status === 401
|
||||
? '登录已过期,请重新授权。'
|
||||
: '暂时无法读取模块权限,请稍后重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = payload && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? payload as Record<string, unknown>
|
||||
: {};
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
moduleAccess: normalizeModuleAccess(profile.module_access),
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureWorksSquareSessionRestored(): Promise<boolean> {
|
||||
if (getWorksSquareSessionRestoreStatus() === 'unavailable') {
|
||||
await retryWorksSquareSessionRestore();
|
||||
@@ -614,6 +655,11 @@ export async function handleAuthRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/me' && req.method === 'GET') {
|
||||
await handleCurrentUser(res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/session/clear' && req.method === 'POST') {
|
||||
await handleSessionClear(res, ctx);
|
||||
return true;
|
||||
|
||||
30
shared/module-access.ts
Normal file
30
shared/module-access.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export const MODULE_ACCESS_KEYS = [
|
||||
'programming',
|
||||
'design',
|
||||
'learning',
|
||||
'robot',
|
||||
] as const;
|
||||
|
||||
export type ModuleAccessKey = typeof MODULE_ACCESS_KEYS[number];
|
||||
|
||||
export type ModuleAccess = Record<ModuleAccessKey, boolean>;
|
||||
|
||||
export const DEFAULT_MODULE_ACCESS: ModuleAccess = {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
};
|
||||
|
||||
export function normalizeModuleAccess(value: unknown): ModuleAccess {
|
||||
const record = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
return {
|
||||
programming: typeof record.programming === 'boolean' ? record.programming : true,
|
||||
design: typeof record.design === 'boolean' ? record.design : true,
|
||||
learning: typeof record.learning === 'boolean' ? record.learning : true,
|
||||
robot: typeof record.robot === 'boolean' ? record.robot : true,
|
||||
};
|
||||
}
|
||||
56
src/App.tsx
56
src/App.tsx
@@ -31,7 +31,12 @@ import { useProviderStore } from './stores/providers';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useOpencodeStore } from './stores/opencode';
|
||||
import { useProjectConfigStore } from './stores/project-config';
|
||||
import { AI_MODULE_SELECTION_PATH } from './lib/ai-modules';
|
||||
import {
|
||||
AI_MODULE_SELECTION_PATH,
|
||||
getGuardedAiModuleForPath,
|
||||
isAiModuleAllowed,
|
||||
isProgrammingProviderRoute,
|
||||
} from './lib/ai-modules';
|
||||
import { useUserSyncStore } from './stores/user-sync';
|
||||
import { flushPendingAgentSessionSync } from '@/lib/agent-session-sync';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
@@ -118,27 +123,6 @@ function getReturnPath(location: ReturnType<typeof useLocation>): string {
|
||||
return `${location.pathname}${location.search}`;
|
||||
}
|
||||
|
||||
const PROGRAMMING_ROUTE_PREFIXES = [
|
||||
'/project-config',
|
||||
'/makelore-home',
|
||||
'/kangaroo',
|
||||
'/subagents',
|
||||
'/chat',
|
||||
'/deliverables',
|
||||
'/workbench',
|
||||
'/opencode-chat',
|
||||
'/projects',
|
||||
'/sessions',
|
||||
'/models',
|
||||
'/settings',
|
||||
] as const;
|
||||
|
||||
function isProgrammingRoute(pathname: string): boolean {
|
||||
return PROGRAMMING_ROUTE_PREFIXES.some(
|
||||
(route) => pathname === route || pathname.startsWith(`${route}/`),
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedLayout({
|
||||
authReady,
|
||||
authRequired,
|
||||
@@ -152,6 +136,8 @@ function ProtectedLayout({
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
||||
const moduleAccess = useAuthStore((state) => state.moduleAccess);
|
||||
const requestedModule = getGuardedAiModuleForPath(location.pathname);
|
||||
const allowsAnonymousImageWorkspace = imageWorkspaceLocalDevelopment
|
||||
&& (location.pathname === '/image-canvas'
|
||||
|| location.pathname.startsWith('/image-canvas/')
|
||||
@@ -176,6 +162,10 @@ function ProtectedLayout({
|
||||
);
|
||||
}
|
||||
|
||||
if (authenticated && requestedModule && !isAiModuleAllowed(requestedModule, moduleAccess)) {
|
||||
return <Navigate to={AI_MODULE_SELECTION_PATH} replace />;
|
||||
}
|
||||
|
||||
return <MainLayout />;
|
||||
}
|
||||
|
||||
@@ -264,6 +254,9 @@ function App() {
|
||||
const authInitialized = useAuthStore((state) => state.initialized);
|
||||
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
||||
const authAccessToken = useAuthStore((state) => state.accessToken);
|
||||
const programmingModuleAllowed = useAuthStore(
|
||||
(state) => state.moduleAccess.programming,
|
||||
);
|
||||
const bootstrapUserSync = useUserSyncStore((state) => state.bootstrap);
|
||||
const setupReady = setupComplete || skipSetupForE2E || rendererOnlyPreview;
|
||||
const authRequired = !skipSetupForE2E && !rendererOnlyPreview;
|
||||
@@ -325,9 +318,24 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (rendererOnlyPreview) return;
|
||||
if (!setupReady) return;
|
||||
if (!isProgrammingRoute(location.pathname)) return;
|
||||
if (authRequired && !authInitialized) return;
|
||||
if (authRequired && !authenticated) return;
|
||||
if (!isProgrammingProviderRoute(location.pathname)) return;
|
||||
if (
|
||||
getGuardedAiModuleForPath(location.pathname) === 'programming'
|
||||
&& !programmingModuleAllowed
|
||||
) return;
|
||||
initProviders();
|
||||
}, [initProviders, location.pathname, rendererOnlyPreview, setupReady]);
|
||||
}, [
|
||||
authInitialized,
|
||||
authRequired,
|
||||
authenticated,
|
||||
initProviders,
|
||||
location.pathname,
|
||||
programmingModuleAllowed,
|
||||
rendererOnlyPreview,
|
||||
setupReady,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rendererOnlyPreview) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Bot, Code2, Paintbrush, Sigma } from 'lucide-react';
|
||||
import type { ModuleAccess, ModuleAccessKey } from '../../shared/module-access';
|
||||
|
||||
export const AI_MODULE_SELECTION_PATH = '/module-select';
|
||||
|
||||
@@ -56,7 +57,45 @@ export const aiModules: readonly AiModuleDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function getAiModuleForPath(pathname: string): AiModuleId {
|
||||
const moduleAccessKeyById: Record<AiModuleId, ModuleAccessKey> = {
|
||||
programming: 'programming',
|
||||
painting: 'design',
|
||||
learning: 'learning',
|
||||
robot: 'robot',
|
||||
};
|
||||
|
||||
const PROGRAMMING_ROUTE_PREFIXES = [
|
||||
'/project-config',
|
||||
'/makelore-home',
|
||||
'/kangaroo',
|
||||
'/subagents',
|
||||
'/chat',
|
||||
'/deliverables',
|
||||
'/workbench',
|
||||
'/opencode-chat',
|
||||
'/projects',
|
||||
'/sessions',
|
||||
'/models',
|
||||
] as const;
|
||||
|
||||
const PROGRAMMING_PROVIDER_ROUTE_PREFIXES = [
|
||||
...PROGRAMMING_ROUTE_PREFIXES,
|
||||
'/settings',
|
||||
] as const;
|
||||
|
||||
function matchesRoute(pathname: string, route: string): boolean {
|
||||
return pathname === route || pathname.startsWith(`${route}/`);
|
||||
}
|
||||
|
||||
export function isAiModuleAllowed(moduleId: AiModuleId, access: ModuleAccess): boolean {
|
||||
return access[moduleAccessKeyById[moduleId]];
|
||||
}
|
||||
|
||||
export function isProgrammingProviderRoute(pathname: string): boolean {
|
||||
return PROGRAMMING_PROVIDER_ROUTE_PREFIXES.some((route) => matchesRoute(pathname, route));
|
||||
}
|
||||
|
||||
export function getGuardedAiModuleForPath(pathname: string): AiModuleId | null {
|
||||
if (pathname === '/image-canvas'
|
||||
|| pathname.startsWith('/image-canvas/')
|
||||
|| pathname === '/image-prompts'
|
||||
@@ -69,5 +108,12 @@ export function getAiModuleForPath(pathname: string): AiModuleId {
|
||||
if (pathname === '/learning' || pathname.startsWith('/learning/')) {
|
||||
return 'learning';
|
||||
}
|
||||
return 'programming';
|
||||
if (PROGRAMMING_ROUTE_PREFIXES.some((route) => matchesRoute(pathname, route))) {
|
||||
return 'programming';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAiModuleForPath(pathname: string): AiModuleId {
|
||||
return getGuardedAiModuleForPath(pathname) ?? 'programming';
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import moduleRobotImage from '@/assets/module-robot.jpg';
|
||||
import logoWordmarkSource from '@/assets/makelore-wordmark-source.png';
|
||||
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
|
||||
import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
|
||||
import { aiModules, type AiModuleId } from '@/lib/ai-modules';
|
||||
import { aiModules, isAiModuleAllowed, type AiModuleId } from '@/lib/ai-modules';
|
||||
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
@@ -23,6 +23,7 @@ const moduleSelectionContent: Record<AiModuleId, { label: string; image: string;
|
||||
export function ModuleSelection({ authRequired = true }: { authRequired?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const authUser = useAuthStore((state) => state.user);
|
||||
const moduleAccess = useAuthStore((state) => state.moduleAccess);
|
||||
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
||||
const {
|
||||
userProfile,
|
||||
@@ -37,8 +38,8 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
|| profileRequired
|
||||
|| Boolean(profileSyncError && dismissedSyncError !== profileSyncError);
|
||||
|
||||
const openModule = (route: string | null) => {
|
||||
if (!route) return;
|
||||
const openModule = (route: string | null, enabled: boolean) => {
|
||||
if (!route || !enabled) return;
|
||||
if (authRequired && !authenticated) {
|
||||
navigate('/login');
|
||||
return;
|
||||
@@ -71,17 +72,20 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
>
|
||||
{aiModules.map((module) => {
|
||||
const content = moduleSelectionContent[module.id];
|
||||
const enabledByPolicy = isAiModuleAllowed(module.id, moduleAccess);
|
||||
const enabled = module.enabled && enabledByPolicy;
|
||||
return (
|
||||
<button
|
||||
key={module.id}
|
||||
type="button"
|
||||
data-testid={`ai-module-option-${module.id}`}
|
||||
disabled={!module.enabled}
|
||||
onClick={() => openModule(module.route)}
|
||||
aria-label={`${content.label}:${module.description}`}
|
||||
disabled={!enabled}
|
||||
aria-disabled={!enabled}
|
||||
onClick={() => openModule(module.route, enabled)}
|
||||
aria-label={`${content.label}:${module.description}${enabled ? '' : '(已关闭)'}`}
|
||||
className={cn(
|
||||
'module-option-card module-option-card-horizontal group motion-press flex w-full min-w-0 items-stretch rounded-lg border border-transparent bg-transparent p-0 text-left shadow-none disabled:cursor-not-allowed disabled:bg-transparent disabled:text-muted-foreground/70 disabled:opacity-75',
|
||||
!module.enabled && 'module-option-card-disabled',
|
||||
!enabled && 'module-option-card-disabled',
|
||||
)}
|
||||
>
|
||||
<span className="module-option-card-frame" aria-hidden="true" />
|
||||
@@ -90,14 +94,18 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
<img
|
||||
src={content.image}
|
||||
alt={content.imageAlt}
|
||||
className={cn('module-option-card-image-media h-full w-full object-cover', !module.enabled && 'grayscale')}
|
||||
className={cn('module-option-card-image-media h-full w-full object-cover', !enabled && 'grayscale')}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
<div className="module-option-card-content relative min-h-0 flex-1">
|
||||
<p className="module-option-card-title font-medium tracking-[-0.02em]">{content.label}</p>
|
||||
<p className="module-option-card-description font-medium text-muted-foreground">{module.description}</p>
|
||||
{!module.enabled ? <p className="module-option-card-status font-semibold">暂未开放</p> : null}
|
||||
{!enabled ? (
|
||||
<p className="module-option-card-status font-semibold">
|
||||
{module.enabled ? '管理员已关闭' : '暂未开放'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="module-option-card-hit-area" aria-hidden="true" />
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
WORKS_SQUARE_ACTIVITY_SYNC_INTERVAL_MS,
|
||||
WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS,
|
||||
} from '../../shared/auth-session';
|
||||
import {
|
||||
DEFAULT_MODULE_ACCESS,
|
||||
normalizeModuleAccess,
|
||||
type ModuleAccess,
|
||||
} from '../../shared/module-access';
|
||||
|
||||
export type AuthUser = {
|
||||
username: string;
|
||||
@@ -51,6 +56,10 @@ type MainSessionResponse = AuthActionResponse & {
|
||||
session?: MainSession | null;
|
||||
};
|
||||
|
||||
type ModuleAccessResponse = AuthActionResponse & {
|
||||
moduleAccess?: unknown;
|
||||
};
|
||||
|
||||
type RefreshSessionOptions = {
|
||||
forceRefresh?: boolean;
|
||||
};
|
||||
@@ -69,6 +78,7 @@ type AuthState = {
|
||||
/** One-release bridge for moving old Renderer-persisted refresh tokens into Main. */
|
||||
legacyRefreshToken: string | null;
|
||||
user: AuthUser | null;
|
||||
moduleAccess: ModuleAccess;
|
||||
init: () => Promise<void>;
|
||||
loginWithBrowser: () => Promise<void>;
|
||||
refreshSession: (options?: RefreshSessionOptions) => Promise<string | null>;
|
||||
@@ -155,6 +165,7 @@ function getClearedSession() {
|
||||
canRefresh: false,
|
||||
legacyRefreshToken: null,
|
||||
user: null,
|
||||
moduleAccess: { ...DEFAULT_MODULE_ACCESS },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +231,17 @@ async function syncMainSession(session: {
|
||||
}
|
||||
}
|
||||
|
||||
async function readCurrentModuleAccess(fallback: ModuleAccess): Promise<ModuleAccess> {
|
||||
try {
|
||||
const response = await hostApiFetch<ModuleAccessResponse>('/api/auth/me');
|
||||
if (!response.success) return fallback;
|
||||
return normalizeModuleAccess(response.moduleAccess);
|
||||
} catch (error) {
|
||||
if (isTerminalAuthError(error)) throw error;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -326,7 +348,27 @@ export const useAuthStore = create<AuthState>()(
|
||||
return;
|
||||
}
|
||||
|
||||
set({ initialized: true, loading: false, error: null });
|
||||
let moduleAccess: ModuleAccess;
|
||||
try {
|
||||
moduleAccess = await readCurrentModuleAccess(
|
||||
normalizeModuleAccess(state.moduleAccess),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
if (isTerminalAuthError(error)) {
|
||||
advanceAuthSessionEpoch();
|
||||
set({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: '登录已过期,请重新授权。',
|
||||
...getClearedSession(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
moduleAccess = normalizeModuleAccess(state.moduleAccess);
|
||||
}
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
set({ initialized: true, loading: false, error: null, moduleAccess });
|
||||
},
|
||||
|
||||
loginWithBrowser: async () => {
|
||||
@@ -343,6 +385,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
|
||||
const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS });
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
|
||||
set({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
@@ -351,10 +396,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
clientId: DEFAULT_CLIENT_ID,
|
||||
...sessionFieldsFromMain(session),
|
||||
user: createUserFromToken(response.token),
|
||||
moduleAccess,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const terminal = isTerminalAuthError(error);
|
||||
const message = terminal
|
||||
? '登录已过期,请重新授权。'
|
||||
: (error instanceof Error ? error.message : String(error));
|
||||
advanceAuthSessionEpoch();
|
||||
set({ loading: false, error: message, ...getClearedSession() });
|
||||
throw new Error(message, { cause: error });
|
||||
@@ -389,17 +438,23 @@ export const useAuthStore = create<AuthState>()(
|
||||
throw new Error(response.error || 'Refresh failed');
|
||||
}
|
||||
|
||||
const moduleAccess = await readCurrentModuleAccess(state.moduleAccess);
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return null;
|
||||
|
||||
set({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
...sessionFieldsFromMain(session),
|
||||
moduleAccess,
|
||||
});
|
||||
return session.accessToken;
|
||||
} catch (error) {
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const terminal = isTerminalAuthError(error);
|
||||
const message = terminal
|
||||
? '登录已过期,请重新授权。'
|
||||
: (error instanceof Error ? error.message : String(error));
|
||||
if (terminal) advanceAuthSessionEpoch();
|
||||
set({
|
||||
initialized: true,
|
||||
@@ -589,7 +644,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
}),
|
||||
{
|
||||
name: 'niancode-auth',
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: (persistedState: unknown) => {
|
||||
const state = persistedState && typeof persistedState === 'object'
|
||||
? persistedState as Record<string, unknown>
|
||||
@@ -601,6 +656,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
...rest,
|
||||
canRefresh: state.canRefresh === true || Boolean(legacyRefreshToken),
|
||||
legacyRefreshToken,
|
||||
moduleAccess: normalizeModuleAccess(state.moduleAccess),
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
@@ -613,6 +669,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
canRefresh: state.canRefresh,
|
||||
legacyRefreshToken: state.legacyRefreshToken,
|
||||
user: state.user,
|
||||
moduleAccess: state.moduleAccess,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MemoryRouter, Outlet } from 'react-router-dom';
|
||||
import App from '@/App';
|
||||
@@ -8,7 +8,11 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
import { useUserSyncStore } from '@/stores/user-sync';
|
||||
|
||||
vi.mock('@/components/layout/MainLayout', () => ({
|
||||
MainLayout: () => <Outlet />,
|
||||
MainLayout: () => (
|
||||
<div data-testid="main-layout">
|
||||
<Outlet />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/AiHardware', () => ({
|
||||
@@ -23,6 +27,14 @@ vi.mock('@/pages/Chat', () => ({
|
||||
Chat: () => <div>Programming workspace</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/ModuleSelection', () => ({
|
||||
ModuleSelection: () => <div>Module chooser</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Settings', () => ({
|
||||
Settings: () => <div>Global settings</div>,
|
||||
}));
|
||||
|
||||
describe('App programming provider initialization gate', () => {
|
||||
const initProviders = vi.fn();
|
||||
|
||||
@@ -45,6 +57,12 @@ describe('App programming provider initialization gate', () => {
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
init: vi.fn(),
|
||||
});
|
||||
useUserSyncStore.setState({ bootstrap: vi.fn().mockResolvedValue(undefined) });
|
||||
@@ -73,4 +91,83 @@ describe('App programming provider initialization gate', () => {
|
||||
|
||||
await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('waits for the startup module policy before initializing programming providers', async () => {
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
|
||||
await renderAt('/opencode-chat');
|
||||
|
||||
expect(initProviders).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
moduleAccess: {
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Module chooser')).toBeInTheDocument();
|
||||
expect(initProviders).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/opencode-chat', 'programming'],
|
||||
['/workbench/project-1', 'programming'],
|
||||
['/chat', 'programming'],
|
||||
['/image-canvas', 'design'],
|
||||
['/image-prompts/example', 'design'],
|
||||
['/learning', 'learning'],
|
||||
['/learning/course/course-1', 'learning'],
|
||||
['/ai-hardware', 'robot'],
|
||||
['/ai-hardware/device-1', 'robot'],
|
||||
] as const)('redirects disabled %s routes before mounting their module', async (pathname, accessKey) => {
|
||||
useAuthStore.setState({
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
[accessKey]: false,
|
||||
},
|
||||
});
|
||||
|
||||
await renderAt(pathname);
|
||||
|
||||
expect(await screen.findByText('Module chooser')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('main-layout')).not.toBeInTheDocument();
|
||||
if (accessKey === 'programming') {
|
||||
expect(initProviders).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps global settings available when Programming is disabled', async () => {
|
||||
useAuthStore.setState({
|
||||
moduleAccess: {
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
|
||||
await renderAt('/settings');
|
||||
|
||||
expect(await screen.findByText('Global settings')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Module chooser')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('main-layout')).toBeInTheDocument();
|
||||
await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,116 @@ describe('auth host api routes', () => {
|
||||
providerServiceMock.deleteAccountApiKey.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('projects the current user module access without exposing the upstream profile', async () => {
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'main-access-token',
|
||||
refreshToken: 'main-refresh-token',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
id: 42,
|
||||
username: 'student',
|
||||
one_api_token_key: 'must-not-reach-renderer',
|
||||
module_access: {
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: false,
|
||||
robot: true,
|
||||
},
|
||||
}), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleAuthRoutes(
|
||||
createRequest('GET'),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/me'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
moduleAccess: {
|
||||
programming: false,
|
||||
design: true,
|
||||
learning: false,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain('must-not-reach-renderer');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://square.nianxx.cn/api/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer main-access-token' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps module access enabled when an older profile omits the policy', async () => {
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'main-access-token',
|
||||
refreshToken: 'main-refresh-token',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ username: 'student' }), { status: 200 }),
|
||||
));
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('GET'),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/me'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the Main session when the current-user lookup is unauthorized', async () => {
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'expired-access-token',
|
||||
refreshToken: 'expired-refresh-token',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ detail: 'upstream secret' }), { status: 401 }),
|
||||
));
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('GET'),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/me'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json()).toEqual({
|
||||
success: false,
|
||||
error: '登录已过期,请重新授权。',
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain('upstream secret');
|
||||
expect(getWorksSquareSessionSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it('exchanges username and AES-encrypted password through the app SSO token endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
|
||||
@@ -21,6 +21,12 @@ function resetAuthStore() {
|
||||
canRefresh: false,
|
||||
legacyRefreshToken: null,
|
||||
user: null,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,6 +66,15 @@ describe('auth store', () => {
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: false,
|
||||
},
|
||||
});
|
||||
|
||||
await useAuthStore.getState().loginWithBrowser();
|
||||
@@ -81,11 +96,154 @@ describe('auth store', () => {
|
||||
deptId: 9,
|
||||
authorities: ['ROLE_USER'],
|
||||
});
|
||||
expect(state.moduleAccess).toEqual({
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: false,
|
||||
});
|
||||
expect(window.localStorage.getItem('niancode-auth')).not.toContain(
|
||||
'must-not-return-to-renderer-storage',
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes module access while restoring the session and defaults missing keys to enabled', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
moduleAccess: { design: false },
|
||||
});
|
||||
useAuthStore.setState({
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
});
|
||||
|
||||
await useAuthStore.getState().init();
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
|
||||
expect(useAuthStore.getState().moduleAccess).toEqual({
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears restored auth when the current-user policy lookup is unauthorized', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
||||
details: { status: 401 },
|
||||
}));
|
||||
useAuthStore.setState({
|
||||
authBase: 'https://biz.nianxx.cn/auth/',
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: false,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
});
|
||||
|
||||
await useAuthStore.getState().init();
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: '登录已过期,请重新授权。',
|
||||
accessToken: null,
|
||||
user: null,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a new login when its current-user policy lookup is unauthorized', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
token: {
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer',
|
||||
username: 'zhangsan',
|
||||
user_id: '1',
|
||||
},
|
||||
session: {
|
||||
accessToken: 'access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
||||
details: { status: 401 },
|
||||
}));
|
||||
|
||||
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
|
||||
'登录已过期,请重新授权。',
|
||||
);
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: false,
|
||||
loading: false,
|
||||
accessToken: null,
|
||||
user: null,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces browser authorization failures and does not keep a partial session', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
@@ -477,6 +635,52 @@ describe('auth store', () => {
|
||||
expect(useAuthStore.getState().user?.username).toBe('zhangsan');
|
||||
});
|
||||
|
||||
it('clears auth when a refreshed session cannot read the current user', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'new-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 120_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
||||
details: { status: 401 },
|
||||
}));
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
accessToken: 'old-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 1_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
userId: '1',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(useAuthStore.getState().refreshSession()).resolves.toBeNull();
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
error: '登录已过期,请重新授权。',
|
||||
accessToken: null,
|
||||
user: null,
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('restores an expired access token when Main reports it can refresh', async () => {
|
||||
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
||||
hostApiFetchMock
|
||||
|
||||
@@ -113,6 +113,42 @@ describe('AI module navigation', () => {
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/ai-hardware');
|
||||
});
|
||||
|
||||
it('greys out a disabled module and does not navigate when it is clicked', async () => {
|
||||
useAuthStore.setState({
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: false,
|
||||
robot: true,
|
||||
},
|
||||
user: {
|
||||
username: 'zhangsan@example.com',
|
||||
userId: 'user-1',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/module-select']}>
|
||||
<Routes>
|
||||
<Route path="/module-select" element={<ModuleSelection />} />
|
||||
<Route path="*" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const learningOption = screen.getByTestId('ai-module-option-learning');
|
||||
expect(learningOption).toBeDisabled();
|
||||
expect(learningOption).toHaveClass('module-option-card-disabled');
|
||||
expect(learningOption).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
fireEvent.click(learningOption);
|
||||
expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('location-path')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes AI programming from the chooser to the project conversation entry', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/module-select']}>
|
||||
|
||||
Reference in New Issue
Block a user