From 971e17fcd8b236c8ab0b9baf383322f2d7d44eaa Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 20 Oct 2026 13:39:33 +0800 Subject: [PATCH] feat(desktop): authorize material accounts and sync private workspaces --- Dockerfile | 1 + app/api/desktop/[...path]/route.ts | 61 ++++++++++ app/client/authorize/authorize.css | 1 + app/client/authorize/page.tsx | 15 +++ app/client/authorize/ui.tsx | 12 ++ docs/detail-desktop-client.md | 41 +++++++ docs/生产数据库安全增量迁移SOP-2026-08-12.md | 11 +- lib/desktop-client/contract.mjs | 38 +++++++ lib/desktop-client/service.ts | 107 ++++++++++++++++++ .../migration.sql | 24 ++++ prisma/schema.prisma | 65 +++++++++++ scripts/desktop-client-contract.test.mjs | 28 +++++ scripts/desktop-client-migration-fixture.mjs | 13 +++ scripts/desktop-client-migration.test.mjs | 16 +++ scripts/safe-production-migrate.mjs | 32 +++++- scripts/safe-production-migrate.test.mjs | 11 +- 16 files changed, 468 insertions(+), 8 deletions(-) create mode 100644 app/api/desktop/[...path]/route.ts create mode 100644 app/client/authorize/authorize.css create mode 100644 app/client/authorize/page.tsx create mode 100644 app/client/authorize/ui.tsx create mode 100644 docs/detail-desktop-client.md create mode 100644 lib/desktop-client/contract.mjs create mode 100644 lib/desktop-client/service.ts create mode 100644 prisma/migrations/20261020090000_desktop_client_workspace/migration.sql create mode 100644 scripts/desktop-client-contract.test.mjs create mode 100644 scripts/desktop-client-migration-fixture.mjs create mode 100644 scripts/desktop-client-migration.test.mjs diff --git a/Dockerfile b/Dockerfile index 54b1bd4..1f24782 100644 --- a/Dockerfile +++ b/Dockerfile @@ -286,6 +286,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_detail_gen COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_ai_model_registry ./prisma/migrations/20261012_ai_model_registry COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_capabilities ./prisma/migrations/20261013_ai_model_capabilities COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_verifications ./prisma/migrations/20261013_ai_model_verifications +COPY --from=builder --chown=root:root /app/prisma/migrations/20261020090000_desktop_client_workspace ./prisma/migrations/20261020090000_desktop_client_workspace COPY --from=builder --chown=root:root /app/lib ./lib COPY --from=builder --chown=root:root /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=root:root /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/desktop/[...path]/route.ts b/app/api/desktop/[...path]/route.ts new file mode 100644 index 0000000..0f763d4 --- /dev/null +++ b/app/api/desktop/[...path]/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { desktopAssert, desktopPortal, DesktopClientError, DESKTOP_CLIENT_ID } from '@/lib/desktop-client/contract.mjs' +import {isAllowedCorsOrigin} from '@/lib/cors-origin' +import { approveDesktop, desktopUser, exchangeDesktop, getDesktopAsset, getDesktopProject, listDesktopDevices, listDesktopProjects, putDesktopAsset, putDesktopProject, requireDesktop, revokeDesktop } from '@/lib/desktop-client/service' +export const runtime='nodejs' +export const dynamic='force-dynamic' +const headers={'Cache-Control':'private, no-store','X-Content-Type-Options':'nosniff'} +async function body(request:Request,max=1600000){ + desktopAssert(Number(request.headers.get('content-length')||0)<=max,'请求内容过大',413) + const reader=request.body?.getReader();const chunks:Uint8Array[]=[];let size=0 + if(reader)while(true){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>max){await reader.cancel();throw new DesktopClientError('请求内容过大',413)}chunks.push(part.value)} + return Buffer.concat(chunks) +} +async function browserOwner(request:Request){ + const origin=request.headers.get('origin') + desktopAssert(origin&&isAllowedCorsOrigin(request,origin),'请从素材库授权页面操作',403,'invalid_origin') + desktopPortal(origin) + const session=await getServerSession(authOptions);return desktopUser((session?.user as {id?:string})?.id||'') +} +async function route(request:Request,{params}:{params:{path:string[]}}){ + try{ + const parts=params.path,url=new URL(request.url),method=request.method + let value:unknown + if(parts.length===1&&parts[0]==='config'&&method==='GET')return NextResponse.json({clientId:DESKTOP_CLIENT_ID,enabled:process.env.DETAIL_GENERATOR_ENABLED==='true',pkce:'S256'},{headers}) + if(parts.length===1&&parts[0]==='authorize'&&method==='POST'){const user=await browserOwner(request);value=await approveDesktop(user.id,JSON.parse((await body(request,5000)).toString()))} + else if(parts.length===1&&parts[0]==='token'&&method==='POST')value=await exchangeDesktop(JSON.parse((await body(request,5000)).toString())) + else if(parts[0]==='devices'){ + const session=await getServerSession(authOptions);const user=await desktopUser((session?.user as {id?:string})?.id||'') + if(method==='GET'&&parts.length===1)value={devices:await listDesktopDevices(user.id)} + else if(method==='DELETE'&&parts.length===2){await browserOwner(request);await revokeDesktop(parts[1],user.id);value={revoked:true}} + else throw new DesktopClientError('接口不存在',404) + }else{ + const auth=await requireDesktop(request),ownerId=auth.account.id + if(parts[0]==='session'&&parts.length===1){if(method==='GET')value={account:auth.account,expiresAt:auth.expiresAt};else if(method==='DELETE'){await revokeDesktop(auth.id,ownerId);value={revoked:true}}else throw new DesktopClientError('接口不存在',404)} + else if(parts[0]==='models'&&method==='GET'&&parts.length===1){const {getAiModelRuntimeState}=await import('@/lib/ai-model-service');value=await getAiModelRuntimeState()} + else if(parts[0]==='agent-models'&&parts.length===1&&method==='GET'){ + const {getAiModelRuntimeState,resolveAiModel}=await import('@/lib/ai-model-service');const config=await getAiModelRuntimeState();const models=[] + for(const option of config.modelOptions.copy){const resolved=await resolveAiModel('copy',option.value);const base=new URL(resolved.binding.baseUrl);if(base.hostname==='api.vip98k.com'&&base.protocol==='https:'&&['openai-chat','anthropic-messages'].includes(resolved.protocol.id))models.push({key:option.value,id:resolved.binding.modelId,label:option.label,protocol:resolved.protocol.id,path:resolved.protocol.path})} + value={models} + } + else if(parts[0]==='projects'){ + if(method==='GET'&&parts.length===1)value=await listDesktopProjects(ownerId,url.searchParams.get('cursor')) + else if(parts.length===3&&method==='GET')value=await getDesktopProject(ownerId,parts[1],parts[2]) + else if(parts.length===3&&parts[1]==='desktop'&&method==='PUT')value=await putDesktopProject(ownerId,parts[2],JSON.parse((await body(request)).toString())) + else if(parts.length===5&&parts[3]==='assets'){ + if(method==='PUT'&&parts[1]==='desktop')value=await putDesktopAsset(ownerId,parts[2],parts[4],await body(request,20*1024*1024),request.headers.get('content-type')||'') + else if(method==='GET'){const asset=await getDesktopAsset(ownerId,parts[1],parts[2],parts[4]);return new NextResponse(Uint8Array.from(asset.bytes).buffer,{headers:{...headers,'Content-Type':asset.mime,'Content-Disposition':'attachment'}})} + else throw new DesktopClientError('接口不存在',404) + }else throw new DesktopClientError('接口不存在',404) + }else throw new DesktopClientError('接口不存在',404) + } + return NextResponse.json(value,{headers}) + }catch(error){ + const known=error instanceof DesktopClientError||Boolean(error&&typeof error==='object'&&'status'in error) + const item=error as {status?:number;code?:string;message?:string} + return NextResponse.json({error:known?item.message:'客户端服务暂不可用,请稍后重试',code:known?item.code||'invalid_request':'service_unavailable'},{status:known?item.status||400:503,headers}) + } +} +export {route as GET,route as POST,route as PUT,route as DELETE} diff --git a/app/client/authorize/authorize.css b/app/client/authorize/authorize.css new file mode 100644 index 0000000..50e0eae --- /dev/null +++ b/app/client/authorize/authorize.css @@ -0,0 +1 @@ +.client-authorize{max-width:440px;margin:10vh auto;padding:24px;color:#101828;font:14px/1.6 system-ui,sans-serif}.authorize-brand{display:flex;align-items:center;gap:12px;margin-bottom:38px;font-weight:600}.authorize-logo{width:46px;height:46px;object-fit:contain}.client-authorize h1{font-size:26px;letter-spacing:-.025em;line-height:1.35;margin:0 0 10px}.authorize-account{color:#667085}.authorize-account strong{color:#344054}.client-authorize ul{padding:22px 0 22px 22px;display:grid;gap:12px}.authorize-note{font-size:13px;color:#667085;margin-bottom:24px}.authorize-actions{display:flex;justify-content:flex-end;gap:12px}.client-authorize button{min-height:44px;border:1px solid #d0d5dd;border-radius:7px;background:#fff;color:#344054;padding:0 16px;cursor:pointer}.client-authorize button:hover{background:#f2f4f7}.client-authorize button:focus-visible,.client-authorize summary:focus-visible{outline:2px solid #1677ff;outline-offset:3px}.client-authorize button.authorize-primary{background:#0050cb;border-color:#0050cb;color:white;font-weight:600}.client-authorize button:disabled{opacity:.6;cursor:wait}.client-authorize [role=alert]{padding:12px;background:#fff2f0;color:#b42318;margin-bottom:18px}.client-authorize details{border-top:1px solid #e4e7ec;margin-top:30px;padding-top:20px}.client-authorize summary{cursor:pointer;color:#667085}.authorize-device{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:16px 0}.authorize-device small{display:block;color:#667085}@media(max-width:480px){.client-authorize{margin:24px auto;padding:24px}.authorize-actions button{padding:0 12px}} diff --git a/app/client/authorize/page.tsx b/app/client/authorize/page.tsx new file mode 100644 index 0000000..c353323 --- /dev/null +++ b/app/client/authorize/page.tsx @@ -0,0 +1,15 @@ +import {getServerSession} from 'next-auth' +import {redirect} from 'next/navigation' +import {authOptions} from '@/lib/auth' +import {desktopAuthorization} from '@/lib/desktop-client/contract.mjs' +import {desktopUser} from '@/lib/desktop-client/service' +import AuthorizeClient from './ui' +export const dynamic='force-dynamic' +export const metadata={title:'授权详情页工作台',referrer:'no-referrer'} +export default async function AuthorizePage({searchParams}:{searchParams:Record}){ + const values=Object.fromEntries(Object.entries(searchParams).filter(([,value])=>typeof value==='string')) as Record + const session=await getServerSession(authOptions) + if(!session?.user)redirect('/login?redirect='+encodeURIComponent('/client/authorize?'+new URLSearchParams(values))) + try{desktopAuthorization(values);const account=await desktopUser((session.user as {id:string}).id);return } + catch(error){return

无法授权客户端

{error instanceof Error?error.message:'请从客户端重新发起登录'}

返回素材库
} +} diff --git a/app/client/authorize/ui.tsx b/app/client/authorize/ui.tsx new file mode 100644 index 0000000..53b4dfb --- /dev/null +++ b/app/client/authorize/ui.tsx @@ -0,0 +1,12 @@ +'use client' +import {useEffect,useState} from 'react' +import BrandLogo from '@/components/BrandLogo' +import {DEFAULT_LOGIN_LOGO_URL} from '@/lib/brand-assets' +import './authorize.css' +export default function AuthorizeClient({account,request}:{account:{id:string;name:string};request:Record}){ + const [busy,setBusy]=useState(false),[error,setError]=useState(''),[devices,setDevices]=useState<{id:string;deviceName:string;createdAt:string}[]>([]) + useEffect(()=>{void fetch('/api/desktop/devices',{cache:'no-store'}).then(r=>r.json()).then(r=>setDevices(r.devices||[])).catch(()=>{})},[]) + async function approve(){setBusy(true);setError('');try{const response=await fetch('/api/desktop/authorize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(request)});const value=await response.json();if(!response.ok)throw new Error(value.error);window.location.assign(value.redirect)}catch(error){setError(error instanceof Error?error.message:'授权未完成,请重试');setBusy(false)}} + function cancel(){const url=new URL(request.redirect_uri);url.searchParams.set('error','access_denied');url.searchParams.set('state',request.state);window.location.assign(url)} + return
由壹点 · 素材库

授权详情页工作台

使用 {account.name} 的账户继续

  • 查看、继续处理本人项目
  • 同步草稿、对话和项目文件
  • 读取管理员开放的模型列表

授权只用于当前客户端。你可以随时撤销,客户端不会获取网页登录密码。

{error&&

{error}

}
{devices.length>0&&
已授权的客户端{devices.map(device=>
{device.deviceName}{new Date(device.createdAt).toLocaleDateString('zh-CN')}
)}
}
+} diff --git a/docs/detail-desktop-client.md b/docs/detail-desktop-client.md new file mode 100644 index 0000000..a64ff84 --- /dev/null +++ b/docs/detail-desktop-client.md @@ -0,0 +1,41 @@ +# 详情页工作台:客户端授权与项目同步 + +客户端在素材库网页完成账户登录和明确授权。网页登录密码、平台模型密钥不进入客户端。正式素材库地址为 `https://sc.jiansun.vip`。 + +## 使用方式 + +1. 打开本机「详情页工作台」,选择「正式素材库」,点击「在素材库登录」。 +2. 在浏览器登录素材库账户,核对账户后点击「授权并返回客户端」。 +3. 客户端显示本账户的网页项目、客户端项目和草稿。打开网页项目后,保存为独立的客户端项目,不覆盖原网页版本。 +4. 「保存项目」同步画布、图片、对话记录和未发送的输入内容;本机自动保存供离线恢复。换电脑前需主动保存同步。 +5. 退出登录会撤销当前客户端会话;授权页可查看、撤销本账户的其他客户端。 + +正式验收前继续仅对当前有效超级管理员开放,普通管理员不等于超级管理员。每次接口调用都重新核对数据库权限、账户状态和授权有效期。 + +## 接口与安全边界 + +所有接口位于 `/api/desktop/`,响应均禁止缓存。 + +| 接口 | 鉴权与作用 | +| --- | --- | +| `GET config` | 公共发现;返回固定客户端 ID、启用状态、S256 | +| `POST authorize` | 素材库网页登录态与同源校验;批准固定用途的授权 | +| `POST token` | 60 秒一次性授权码和 PKCE S256 校验,回调固定为本机临时端口的 `/oauth/callback` | +| `GET/DELETE session` | Bearer 客户端会话;核对账户/撤销当前会话 | +| `GET devices`、`DELETE devices/:id` | 素材库网页登录态;只管理本账户的授权 | +| `GET projects` | 本账户项目,游标分页,每页最多 50 项 | +| `GET projects/:source/:id` | 读取本人网页或客户端项目及文件清单 | +| `PUT projects/desktop/:id` | 保存客户端项目;使用 revision 防止并发覆盖,冲突返回 409 | +| `GET projects/:source/:id/assets/:asset` | 鉴权后获取该项目私有文件 | +| `PUT projects/desktop/:id/assets/:source` | 上传 PNG/JPG/WEBP,单张 20MiB、每项目 128 张/256MiB | +| `GET models`、`GET agent-models` | 只返回管理员已开放且已通过验证的可用模型,不返回平台密钥 | + +客户端会话有效期 7 天,数据库仅保存令牌哈希。授权撤销或超级管理员资格撤销后,旧会话立即失去接口权限。本版本不开放任意生产终端或命令执行,不通过这些接口代理付费模型生成。 + +项目最多 12 屏,统一宽度为 750px 或 1440px,总高度最多 30000px。客户端项目单独存储,不迁移或改写现有网页项目。项目图片复用独立的私有持久目录,不进入公开上传目录。 + +## 部署与验收 + +新增 migration `20261020090000_desktop_client_workspace`,仅创建四张客户端表。生产迁移复用精确提交、真实备份、隔离恢复、数据库身份、迁移校验和与审计门禁,随后发布同一提交的应用镜像。 + +本地验收覆盖:真实网页登录/PKCE 回调、授权重放拒绝、跨账户访问拒绝、普通用户与撤权拒绝、私有文件、项目列表分页、并发版本冲突、项目与对话/输入草稿保存恢复、客户端重启恢复、退出后账户隔离,以及 1440px 图片导出。生产验收另记录在对应发布证据目录,不能以本地结果替代。 diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index c9b37e8..5b8ee23 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -2,7 +2,7 @@ ## 结论 -当前应用发布工具验证三十六份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。 +当前应用发布工具验证三十七份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。 目标 migration: @@ -42,11 +42,14 @@ - `20261012_ai_model_registry` - `20261013_ai_model_capabilities` - `20261013_ai_model_verifications` +- `20261020090000_desktop_client_workspace` `20261012_detail_generator` 仅新增个人详情页项目、私有文件、持久任务和品牌风格草案四张表及其约束/索引,不回填素材库或修改用户权限。正式验收前,启用配置仍只开放给当前有效超级管理员;私有文件目录独立持久挂载。 `20261012_ai_model_registry` 仅新增模型上游、目录、默认配置、审计四张表及约束/索引;`20261013_ai_model_capabilities` 仅新增目录的可空 `capability_metadata` JSONB 列。`20261013_ai_model_verifications` 新增 `ai_model_verifications`,持久保存按用途的真实测试结果、幂等请求及异步任务检查点;它将 `ai_model_enabled_compatible` 替换为 `ai_model_enabled_stages`,不删除历史 `compatibility` 列,也不将历史人工确认值转成已通过测试。 +`20261020090000_desktop_client_workspace` 仅新增客户端一次性授权码、可撤销会话、个人客户端项目与私有文件四张表,不改写现有网页项目或用户权限。核对凭据哈希唯一性、所属用户/项目外键,以及草稿、对话和输入框草稿的持久化字段。 + 受控迁移核对列类型、空值限制、安全默认值、模型唯一性、原连接唯一性、引用限制和凭据完整性约束。验证表还须核对 `stage`、`context_hash`、`status` 检查约束,`task_id` 可空文本及 `evidence` 非空 JSONB,模型外键、幂等唯一索引、未决测试唯一索引和模型用途时间索引;旧人工兼容启用约束必须已移除,新用途启用约束必须存在。切流前复核三份 AI SQL 的精确校验和及五表字段。迁移不写入真实密钥、不自动导入、开放或付费测试模型。 部署后,尚未迁入的连接由当前有效超级管理员显式执行“迁入当前连接”,然后“同步目录”;已迁入的连接直接核对配置并同步,不能用导入覆盖现有设置。迁入继续引用现有 `DETAIL_GENERATOR_API_KEY` 和精确原地址。新版本不再把旧人工确认或旧默认值视为用途测试证据;需分别实际测试计划开放的分析、文案和生图用途,通过后再由管理员勾选开放并保存三阶段默认。同步目录只读获取目录,不调用付费生成;实际测试需管理员显式发起,已有异步任务只查询结果,不重新生成。应核对迁入/配置/测试审计、动态目录范围、三阶段可用默认和普通用户拒绝状态。现有环境连接不依赖加密主密钥;新增上游或替换密钥才要求已配置至少 32 字节的专用 `API_KEY_ENCRYPTION_KEY`,不要替换已有主密钥或把凭据写进迁移、命令参数、日志。 @@ -82,7 +85,7 @@ 1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator; 2. legacy bootstrap `Audit`; 3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留; -4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十六条目标 migration 和迁移后审计; +4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十七条目标 migration 和迁移后审计; 5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。 顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。 @@ -408,7 +411,7 @@ docker run --rm \ "$MIGRATOR_IMAGE" --apply ``` -工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十六条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;AI 模型五表、能力证据及用途验证约束/索引也须通过前述结构检查,最后原子写审计 JSON。 +工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十七条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;AI 模型五表、能力证据及用途验证约束/索引也须通过前述结构检查,最后原子写审计 JSON。 apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写: @@ -424,7 +427,7 @@ test -n "$MIGRATION_AUDIT_SHA256" 只有以下全部成立,才进入应用首次安全上线步骤: - `applied.json` 的 `verified=true`; -- 三十六条 target migration 均 `completed=true`,source/database checksum 一致; +- 三十七条 target migration 均 `completed=true`,source/database checksum 一致; - `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空; - 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配; - AI 模型配置及验证五表、用途/上下文/状态约束、幂等与未决测试唯一索引和任务检查点字段精确匹配;旧人工兼容启用约束已由新用途约束替换; diff --git a/lib/desktop-client/contract.mjs b/lib/desktop-client/contract.mjs new file mode 100644 index 0000000..5e87105 --- /dev/null +++ b/lib/desktop-client/contract.mjs @@ -0,0 +1,38 @@ +export const DESKTOP_CLIENT_ID = 'jiansun-detail-desktop' +export const DESKTOP_SCOPES = ['projects:read', 'projects:write', 'assets:read', 'assets:write', 'models:read'] +export class DesktopClientError extends Error { + constructor(message, status = 400, code = 'invalid_request') { super(message); this.status = status; this.code = code } +} +export function desktopAssert(condition, message, status = 400, code = 'invalid_request') { if (!condition) throw new DesktopClientError(message, status, code) } +export function desktopId(value) { desktopAssert(typeof value === 'string' && /^[a-zA-Z0-9_-]{1,80}$/.test(value), '项目编号无效'); return value } +export function desktopRedirect(value) { + let url; try { url = new URL(value) } catch { throw new DesktopClientError('客户端回调地址无效') } + desktopAssert(url.protocol === 'http:' && url.hostname === '127.0.0.1' && Number(url.port) >= 1024 && Number(url.port) <= 65535 && url.pathname === '/oauth/callback' && !url.search && !url.hash && !url.username && !url.password, '仅允许当前电脑的客户端回调地址') + return url.href +} +export function desktopAuthorization(input) { + desktopAssert(input.client_id === DESKTOP_CLIENT_ID && input.response_type === 'code' && input.code_challenge_method === 'S256', '授权请求不符合客户端协议') + desktopAssert(typeof input.state === 'string' && /^[\w-]{32,128}$/.test(input.state), '授权状态无效') + desktopAssert(typeof input.code_challenge === 'string' && /^[\w-]{43}$/.test(input.code_challenge), '客户端校验参数无效') + return { clientId: DESKTOP_CLIENT_ID, redirectUri: desktopRedirect(input.redirect_uri), state: input.state, challenge: input.code_challenge } +} +export function desktopPortal(value) { + const url = new URL(value) + desktopAssert(!url.username && !url.password && url.pathname === '/' && !url.search && !url.hash, '素材库地址应只包含域名和端口') + desktopAssert((url.protocol === 'https:' && url.hostname === 'sc.jiansun.vip' && !url.port) || (url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname) && Number(url.port) >= 1024), '只允许正式素材库或本机开发服务') + return url.origin +} +export function desktopProjectBody(input) { + desktopAssert(input && typeof input === 'object' && JSON.stringify(input).length <= 1_500_000, '项目内容过大') + const doc = input.document + desktopAssert(doc?.version === 1 && typeof doc.name === 'string' && doc.name.length <= 120 && Array.isArray(doc.screens) && doc.screens.length > 0 && doc.screens.length <= 12, '客户端项目格式无效') + const width = doc.screens[0].width + desktopAssert([750, 1440].includes(width) && doc.screens.every(s => s.width === width && Number.isInteger(s.height) && s.height >= 400 && s.height <= 4608 && Array.isArray(s.layers) && s.layers.length <= 100) && doc.screens.reduce((sum,s) => sum + s.height,0) <= 30000, '项目画布超出限制') + desktopAssert(['draft', 'active', 'completed'].includes(input.status) && Number.isInteger(input.revision) && input.revision >= 0, '项目状态或版本无效') + const conversation = input.conversation || [] + desktopAssert(Array.isArray(conversation) && conversation.length <= 200 && conversation.every(m => typeof m.id === 'string' && ['user','assistant'].includes(m.role) && typeof m.text === 'string' && m.text.length <= 16000), '对话记录无效') + desktopAssert(conversation.reduce((sum,m) => sum + m.text.length,0) <= 180000, '对话记录过大,请新开对话') + const composer=input.composer||'' + desktopAssert(typeof composer==='string'&&composer.length<=6000,'对话草稿过长') + return { document: doc, conversation, composer, status: input.status, revision: input.revision, sourceWebId: input.sourceWebId ? desktopId(input.sourceWebId) : null } +} diff --git a/lib/desktop-client/service.ts b/lib/desktop-client/service.ts new file mode 100644 index 0000000..e1b8545 --- /dev/null +++ b/lib/desktop-client/service.ts @@ -0,0 +1,107 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import { prisma } from '@/lib/prisma' +import { assertDetailGeneratorUser } from '@/lib/detail-generator/access' +import { findDetailProject, listDetailAssets, findDetailAsset } from '@/lib/detail-generator/repository' +import { readDetailFile, saveDetailFile, deleteDetailFile } from '@/lib/detail-generator/storage' +import { desktopAssert, desktopAuthorization, desktopId, desktopProjectBody, DesktopClientError, DESKTOP_CLIENT_ID, DESKTOP_SCOPES } from './contract.mjs' + +const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex') +const db = prisma +type Row = Record +const columns = 'id,owner_id AS "ownerId",source_web_id AS "sourceWebId",document,conversation,composer,status,revision,updated_at AS "updatedAt"' +export async function desktopUser(id: string) { + await assertDetailGeneratorUser(id) + const user = await prisma.user.findUnique({ where: { id }, select: { id: true, username: true, realName: true } }) + desktopAssert(user, '账户不存在', 401) + return { id, name: user!.realName || user!.username } +} +export async function approveDesktop(ownerId: string, input: Record) { + await desktopUser(ownerId) + const auth = desktopAuthorization(input) + const code = 'dca_' + randomBytes(32).toString('base64url') + await db.$executeRawUnsafe('INSERT INTO desktop_client_grants(id,code_hash,owner_id,challenge,redirect_uri,expires_at) VALUES($1,$2,$3,$4,$5,$6)', randomUUID(), hash(code), ownerId, auth.challenge, auth.redirectUri, new Date(Date.now() + 60000)) + const callback = new URL(auth.redirectUri); callback.searchParams.set('code', code); callback.searchParams.set('state', auth.state) + return { redirect: callback.href } +} +export async function exchangeDesktop(input: Record) { + desktopAssert(input.client_id === DESKTOP_CLIENT_ID && input.grant_type === 'authorization_code' && /^dca_[\w-]{43}$/.test(input.code || '') && /^[a-zA-Z0-9._~-]{43,128}$/.test(input.code_verifier || ''), '授权码或校验参数无效', 400, 'invalid_grant') + const rows = await db.$queryRawUnsafe('SELECT id,owner_id AS "ownerId",challenge,redirect_uri AS "redirectUri" FROM desktop_client_grants WHERE code_hash=$1 AND used_at IS NULL AND expires_at>NOW()', hash(input.code)) + const grant = rows[0] + desktopAssert(grant && grant.redirectUri === input.redirect_uri, '授权已失效,请重新登录', 400, 'invalid_grant') + const challenge = createHash('sha256').update(input.code_verifier).digest('base64url') + desktopAssert(challenge.length === grant.challenge.length && timingSafeEqual(Buffer.from(challenge), Buffer.from(grant.challenge)), '授权校验未通过,请重新登录', 400, 'invalid_grant') + const account = await desktopUser(grant.ownerId) + const token = 'dcs_' + randomBytes(48).toString('base64url'), id = randomUUID(), expiresAt = new Date(Date.now() + 7 * 86400000) + await db.$transaction(async tx => { + const consumed = await tx.$executeRawUnsafe('UPDATE desktop_client_grants SET used_at=NOW() WHERE id=$1 AND used_at IS NULL AND expires_at>NOW()', grant.id) + desktopAssert(consumed === 1, '授权码已使用,请重新登录', 400, 'invalid_grant') + await tx.$executeRawUnsafe('INSERT INTO desktop_client_sessions(id,token_hash,owner_id,device_name,expires_at) VALUES($1,$2,$3,$4,$5)', id, hash(token), account.id, '详情页工作台', expiresAt) + }) + return { access_token: token, token_type: 'Bearer', expires_at: expiresAt.toISOString(), account, scope: DESKTOP_SCOPES.join(' ') } +} +export async function requireDesktop(request: Request) { + const token = request.headers.get('authorization')?.match(/^Bearer (dcs_[\w-]{64})$/)?.[1] + desktopAssert(token, '请在网页上登录并授权客户端', 401, 'unauthenticated') + const rows = await db.$queryRawUnsafe('SELECT id,owner_id AS "ownerId",expires_at AS "expiresAt" FROM desktop_client_sessions WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>NOW()', hash(token!)) + desktopAssert(rows[0], '客户端授权已过期或被撤销,请重新登录', 401, 'session_expired') + return { id:String(rows[0].id),expiresAt:rows[0].expiresAt,account: await desktopUser(rows[0].ownerId) } +} +export async function revokeDesktop(id: string, ownerId: string) { await db.$executeRawUnsafe('UPDATE desktop_client_sessions SET revoked_at=NOW() WHERE id=$1 AND owner_id=$2', id, ownerId) } +export async function listDesktopDevices(ownerId: string) { + return db.$queryRawUnsafe('SELECT id,device_name AS "deviceName",created_at AS "createdAt",expires_at AS "expiresAt" FROM desktop_client_sessions WHERE owner_id=$1 AND revoked_at IS NULL AND expires_at>NOW() ORDER BY created_at DESC', ownerId) +} +export async function listDesktopProjects(ownerId: string, cursor: string | null) { + let point = { date: '9999-01-01T00:00:00.000Z', id: 'zzzzzzzz', source: 'web' } + if (cursor) { + try { point = JSON.parse(Buffer.from(cursor, 'base64url').toString()) } catch { throw new DesktopClientError('分页参数无效') } + desktopAssert(typeof point.date === 'string' && Number.isFinite(Date.parse(point.date)) && typeof point.id === 'string' && /^[\w-]{1,80}$/.test(point.id) && ['web','desktop'].includes(point.source), '分页参数无效') + } + const rows = await db.$queryRawUnsafe(`SELECT id,source,name,status,revision,updated_at AS "updatedAt",width,screen_count AS "screenCount",has_draft AS "hasDraft" FROM ( + SELECT p.id,'desktop' AS source,p.document->>'name' AS name,p.status,p.revision,p.updated_at,(p.document->'screens'->0->>'width')::int AS width,jsonb_array_length(p.document->'screens') AS screen_count,(p.status='draft' OR p.composer<>'') AS has_draft FROM desktop_workspace_projects p WHERE p.owner_id=$1 + UNION ALL SELECT p.id,'web',p.document->>'name',CASE WHEN p.document->>'approvedAt' IS NULL THEN 'draft' ELSE 'active' END,p.revision,p.updated_at,COALESCE((p.document->'screens'->0->>'width')::int,750),jsonb_array_length(p.document->'screens'),p.document->>'approvedAt' IS NULL FROM detail_generator_projects p WHERE p.owner_id=$1 AND p.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM desktop_workspace_projects d WHERE d.owner_id=$1 AND d.source_web_id=p.id) + ) AS projects WHERE (updated_at,id,source)<($2::timestamptz,$3::text,$4::text) ORDER BY updated_at DESC,id DESC,source DESC LIMIT 51`, ownerId, point.date, point.id, point.source) + const items = rows.slice(0,50), last = items.at(-1) + return { items, nextCursor: rows.length > 50 && last ? Buffer.from(JSON.stringify({date:new Date(last.updatedAt).toISOString(),id:last.id,source:last.source})).toString('base64url') : null } +} +export async function getDesktopProject(ownerId: string, source: string, id: string) { + desktopId(id) + if (source === 'web') return { source, project: await findDetailProject(id,ownerId), assets: (await listDetailAssets(id,ownerId)).filter(asset => ['image/png','image/jpeg','image/webp'].includes(asset.mimeType)).map(asset => ({ id:asset.id,source:'web:'+asset.id,mime:asset.mimeType,bytes:asset.byteSize })) } + desktopAssert(source === 'desktop', '项目来源无效') + const rows = await db.$queryRawUnsafe(`SELECT ${columns} FROM desktop_workspace_projects WHERE id=$1 AND owner_id=$2`, id, ownerId) + desktopAssert(rows[0], '项目不存在或无权访问', 404, 'not_found') + const assets = await db.$queryRawUnsafe('SELECT id,source,sha256,mime_type AS mime,byte_size AS bytes FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2', id, ownerId) + return { source, project:rows[0], assets } +} +export async function putDesktopProject(ownerId: string, id: string, input: unknown) { + desktopId(id); const body = desktopProjectBody(input) + desktopAssert(body.document.id === id, '项目编号不一致') + if (body.sourceWebId) await findDetailProject(body.sourceWebId,ownerId) + const rows = body.revision === 0 ? await db.$queryRawUnsafe(`INSERT INTO desktop_workspace_projects(id,owner_id,source_web_id,document,conversation,status,composer) VALUES($1,$2,$3,$4::jsonb,$5::jsonb,$6,$7) ON CONFLICT DO NOTHING RETURNING ${columns}`,id,ownerId,body.sourceWebId,JSON.stringify(body.document),JSON.stringify(body.conversation),body.status,body.composer) + : await db.$queryRawUnsafe(`UPDATE desktop_workspace_projects SET document=$4::jsonb,conversation=$5::jsonb,status=$6,composer=$7,revision=revision+1,updated_at=NOW() WHERE id=$1 AND owner_id=$2 AND revision=$3 RETURNING ${columns}`,id,ownerId,body.revision,JSON.stringify(body.document),JSON.stringify(body.conversation),body.status,body.composer) + desktopAssert(rows[0], '项目已有新版本,请先保留本机草稿并刷新项目列表', 409, 'revision_conflict') + return { project:rows[0] } +} +export async function putDesktopAsset(ownerId:string,projectId:string,source:string,bytes:Buffer,mime:string) { + await getDesktopProject(ownerId,'desktop',projectId) + desktopAssert(/^local:[a-zA-Z0-9-]{1,100}$/.test(source) && bytes.length>0 && bytes.length<=20*1024*1024, '图片编号或体积无效') + const extension = mime==='image/png'?'png':mime==='image/jpeg'?'jpg':mime==='image/webp'?'webp':'' + desktopAssert(extension && (extension==='png'?bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])):extension==='jpg'?bytes[0]===255&&bytes[1]===216:bytes.toString('ascii',0,4)==='RIFF'&&bytes.toString('ascii',8,12)==='WEBP'), '仅支持 PNG、JPG、WEBP 图片') + const sha = hash(bytes) + return db.$transaction(async tx => { + await tx.$queryRawUnsafe('SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtext($1))) l', 'desktop:'+projectId) + const existing = await tx.$queryRawUnsafe('SELECT sha256 FROM desktop_workspace_assets WHERE project_id=$1 AND source=$2 AND owner_id=$3',projectId,source,ownerId) + if(existing[0]) { desktopAssert(existing[0].sha256===sha,'图片标识已关联其他内容',409); return {sha256:sha} } + const total = await tx.$queryRawUnsafe('SELECT COALESCE(SUM(byte_size),0)::int AS bytes,COUNT(*)::int AS count FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2',projectId,ownerId) + desktopAssert(total[0].bytes+bytes.length<=256*1024*1024 && total[0].count<128,'项目文件超过256MiB或128张限制') + const key=await saveDetailFile(projectId,bytes,extension) + try { await tx.$executeRawUnsafe('INSERT INTO desktop_workspace_assets(id,project_id,owner_id,source,sha256,storage_key,mime_type,byte_size) VALUES($1,$2,$3,$4,$5,$6,$7,$8)',randomUUID(),projectId,ownerId,source,sha,key,mime,bytes.length) } + catch(error) {await deleteDetailFile(key);throw error} + return {sha256:sha} + }) +} +export async function getDesktopAsset(ownerId:string,source:string,projectId:string,assetId:string) { + await getDesktopProject(ownerId,source,projectId) + if(source==='web') { const asset=await findDetailAsset(desktopId(assetId),ownerId);desktopAssert(asset.projectId===projectId, '文件不属于此项目',404);return {bytes:await readDetailFile(asset.storageKey),mime:asset.mimeType} } + const rows=await db.$queryRawUnsafe('SELECT storage_key AS key,mime_type AS mime FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2 AND source=$3',projectId,ownerId,assetId) + desktopAssert(rows[0],'文件不存在',404);return {bytes:await readDetailFile(rows[0].key),mime:rows[0].mime} +} diff --git a/prisma/migrations/20261020090000_desktop_client_workspace/migration.sql b/prisma/migrations/20261020090000_desktop_client_workspace/migration.sql new file mode 100644 index 0000000..ac8ad52 --- /dev/null +++ b/prisma/migrations/20261020090000_desktop_client_workspace/migration.sql @@ -0,0 +1,24 @@ +-- Additive desktop authorization and workspace storage. No existing tables/data are rewritten. +CREATE TABLE IF NOT EXISTS desktop_client_grants ( + id TEXT PRIMARY KEY, code_hash TEXT NOT NULL UNIQUE, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + challenge TEXT NOT NULL, redirect_uri TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS desktop_client_sessions ( + id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_name TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS desktop_client_sessions_owner_idx ON desktop_client_sessions(owner_id); +CREATE TABLE IF NOT EXISTS desktop_workspace_projects ( + id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, source_web_id TEXT, + document JSONB NOT NULL, conversation JSONB NOT NULL DEFAULT '[]', composer TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'draft', revision INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS desktop_workspace_projects_owner_idx ON desktop_workspace_projects(owner_id,updated_at); +CREATE UNIQUE INDEX IF NOT EXISTS desktop_workspace_projects_source_idx ON desktop_workspace_projects(owner_id,source_web_id); +CREATE TABLE IF NOT EXISTS desktop_workspace_assets ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES desktop_workspace_projects(id) ON DELETE CASCADE, + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, source TEXT NOT NULL, sha256 TEXT NOT NULL, + storage_key TEXT NOT NULL, mime_type TEXT NOT NULL, byte_size INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(project_id,source) +); +CREATE INDEX IF NOT EXISTS desktop_workspace_assets_owner_idx ON desktop_workspace_assets(owner_id,project_id); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 587814f..427f195 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,6 +64,10 @@ model User { detailGeneratorProjects DetailGeneratorProject[] detailGeneratorAssets DetailGeneratorAsset[] detailGeneratorJobs DetailGeneratorJob[] + desktopClientGrants DesktopClientGrant[] + desktopClientSessions DesktopClientSession[] + desktopWorkspaceProjects DesktopWorkspaceProject[] + desktopWorkspaceAssets DesktopWorkspaceAsset[] @@index([status]) @@index([archivedAt]) @@ -73,6 +77,67 @@ model User { @@map("users") } +model DesktopClientGrant { + id String @id + codeHash String @unique @map("code_hash") + ownerId String @map("owner_id") + challenge String + redirectUri String @map("redirect_uri") + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + usedAt DateTime? @map("used_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + @@map("desktop_client_grants") +} + +model DesktopClientSession { + id String @id + tokenHash String @unique @map("token_hash") + ownerId String @map("owner_id") + deviceName String @map("device_name") + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + @@index([ownerId], map: "desktop_client_sessions_owner_idx") + @@map("desktop_client_sessions") +} + +model DesktopWorkspaceProject { + id String @id + ownerId String @map("owner_id") + sourceWebId String? @map("source_web_id") + document Json + conversation Json @default("[]") + composer String @default("") + status String @default("draft") + revision Int @default(1) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + assets DesktopWorkspaceAsset[] + @@index([ownerId,updatedAt], map: "desktop_workspace_projects_owner_idx") + @@unique([ownerId,sourceWebId], map: "desktop_workspace_projects_source_idx") + @@map("desktop_workspace_projects") +} + +model DesktopWorkspaceAsset { + id String @id + projectId String @map("project_id") + ownerId String @map("owner_id") + source String + sha256 String + storageKey String @map("storage_key") + mimeType String @map("mime_type") + byteSize Int @map("byte_size") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + project DesktopWorkspaceProject @relation(fields: [projectId], references: [id], onDelete: Cascade) + @@unique([projectId,source]) + @@index([ownerId,projectId], map: "desktop_workspace_assets_owner_idx") + @@map("desktop_workspace_assets") +} + model AiModelProvider { id String @id name String diff --git a/scripts/desktop-client-contract.test.mjs b/scripts/desktop-client-contract.test.mjs new file mode 100644 index 0000000..ec531ed --- /dev/null +++ b/scripts/desktop-client-contract.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {desktopAuthorization,desktopPortal,desktopProjectBody} from '../lib/desktop-client/contract.mjs' + +test('desktop authorization requires S256 and an exact loopback callback',()=>{ + const request={client_id:'jiansun-detail-desktop',response_type:'code',code_challenge_method:'S256',state:'a'.repeat(32),code_challenge:'b'.repeat(43),redirect_uri:'http://127.0.0.1:40001/oauth/callback'} + assert.equal(desktopAuthorization(request).redirectUri,request.redirect_uri) + for(const redirect_uri of ['https://example.com/oauth/callback','http://localhost:40001/oauth/callback','http://127.0.0.1:80/oauth/callback','http://127.0.0.1:40001/oauth/callback?next=elsewhere','http://127.0.0.1:40001/other']){ + assert.throws(()=>desktopAuthorization({...request,redirect_uri})) + } + assert.throws(()=>desktopAuthorization({...request,code_challenge_method:'plain'})) + assert.throws(()=>desktopAuthorization({...request,state:'short'})) +}) + +test('desktop portal allowlist excludes unrelated services and credentials in URLs',()=>{ + assert.equal(desktopPortal('https://sc.jiansun.vip'),'https://sc.jiansun.vip') + assert.equal(desktopPortal('http://127.0.0.1:3138'),'http://127.0.0.1:3138') + for(const value of ['https://api.vip98k.com','http://sc.jiansun.vip','https://sc.jiansun.vip/other','https://user@sc.jiansun.vip'])assert.throws(()=>desktopPortal(value)) +}) + +test('native draft contract preserves chat and unsent input with bounded 1440px canvases',()=>{ + const value={document:{version:1,id:'acceptance',name:'验收草稿',screens:[{width:1440,height:1800,layers:[]}]},revision:0,status:'draft',conversation:[{id:'one',role:'user',text:'保留品牌字体'}],composer:'尚未发送的要求'} + assert.equal(desktopProjectBody(value).composer,value.composer) + assert.deepEqual(desktopProjectBody(value).conversation,value.conversation) + assert.throws(()=>desktopProjectBody({...value,document:{...value.document,screens:[{width:4096,height:1800,layers:[]}]}})) + assert.throws(()=>desktopProjectBody({...value,revision:-1})) + assert.throws(()=>desktopProjectBody({...value,composer:'x'.repeat(6001)})) +}) diff --git a/scripts/desktop-client-migration-fixture.mjs b/scripts/desktop-client-migration-fixture.mjs new file mode 100644 index 0000000..935fc45 --- /dev/null +++ b/scripts/desktop-client-migration-fixture.mjs @@ -0,0 +1,13 @@ +export function desktopEvidence(){ + const fields={ + desktop_client_grants:{id:'text',code_hash:'text',owner_id:'text',challenge:'text',redirect_uri:'text',expires_at:'timestamp with time zone',used_at:'timestamp with time zone'}, + desktop_client_sessions:{id:'text',token_hash:'text',owner_id:'text',device_name:'text',expires_at:'timestamp with time zone',revoked_at:'timestamp with time zone'}, + desktop_workspace_projects:{id:'text',owner_id:'text',source_web_id:'text',document:'jsonb',conversation:'jsonb',composer:'text',status:'text',revision:'integer'}, + desktop_workspace_assets:{id:'text',project_id:'text',owner_id:'text',source:'text',sha256:'text',storage_key:'text',mime_type:'text',byte_size:'integer'}, + } + const columns=Object.entries(fields).flatMap(([table,fields])=>Object.entries(fields).map(([column,type])=>({table_name:table,column_name:column,data_type:type,is_nullable:['used_at','revoked_at','source_web_id'].includes(column)?'YES':'NO'}))) + const indexes=[['desktop_client_grants','code_hash',['code_hash']],['desktop_client_sessions','token_hash',['token_hash']],['desktop_workspace_projects','source_idx',['owner_id','source_web_id']],['desktop_workspace_assets','project_id_source',['project_id','source']]].map(([table,suffix,columns])=>({tablename:table,indexname:table+'_'+suffix+(suffix.endsWith('idx')?'':'_key'),indisunique:true,indisprimary:false,indisvalid:true,indisready:true,access_method:'btree',predicate:'',columns})) + const constraints=Object.keys(fields).map(table=>({tablename:table,constraint_name:table+'_owner_id_fkey',contype:'f',convalidated:true,columns:['owner_id'],referenced_table:'users',referenced_columns:['id']})) + constraints.push({tablename:'desktop_workspace_assets',constraint_name:'desktop_workspace_assets_project_id_fkey',contype:'f',convalidated:true,columns:['project_id'],referenced_table:'desktop_workspace_projects',referenced_columns:['id']}) + return {columns,indexes,constraints} +} diff --git a/scripts/desktop-client-migration.test.mjs b/scripts/desktop-client-migration.test.mjs new file mode 100644 index 0000000..7eeb5bc --- /dev/null +++ b/scripts/desktop-client-migration.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {assertDesktopWorkspaceEvidence} from './safe-production-migrate.mjs' +import {desktopEvidence} from './desktop-client-migration-fixture.mjs' +test('desktop migration requires credential hashes, owner boundaries, and draft persistence columns',()=>{ + assert.doesNotThrow(()=>assertDesktopWorkspaceEvidence(desktopEvidence())) + for(const name of ['code_hash','token_hash','owner_id','expires_at','composer','document','storage_key']){ + const evidence=desktopEvidence();evidence.columns=evidence.columns.filter(row=>row.column_name!==name) + assert.throws(()=>assertDesktopWorkspaceEvidence(evidence),/column mismatch/) + } +}) +test('desktop migration rejects missing uniqueness and cross-account or invalid foreign keys',()=>{ + for(let i=0;i<4;i++){const evidence=desktopEvidence();evidence.indexes[i].indisunique=false;assert.throws(()=>assertDesktopWorkspaceEvidence(evidence),/unique index mismatch/)} + for(let i=0;i<5;i++){const evidence=desktopEvidence();evidence.constraints[i].referenced_table='other_accounts';assert.throws(()=>assertDesktopWorkspaceEvidence(evidence),/foreign key mismatch/)} + const evidence=desktopEvidence();evidence.columns.find(row=>row.column_name==='token_hash').is_nullable='YES';assert.throws(()=>assertDesktopWorkspaceEvidence(evidence),/column mismatch/) +}) diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs index 8329889..4227bd6 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -45,6 +45,7 @@ const TARGET_MIGRATIONS = Object.freeze([ '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications', + '20261020090000_desktop_client_workspace', ]) const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs' const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT' @@ -359,6 +360,7 @@ async function queryEvidence(prisma, localMigrations) { OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private')) OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits', 'material_purge_jobs', 'material_purge_audits') OR table_name IN ('detail_generator_projects', 'detail_generator_assets', 'detail_generator_jobs', 'detail_generator_brand_profiles') + OR table_name IN ('desktop_client_grants', 'desktop_client_sessions', 'desktop_workspace_projects', 'desktop_workspace_assets') OR table_name IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_audit', 'ai_model_verifications') OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag')) OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at')) @@ -431,6 +433,8 @@ async function queryEvidence(prisma, localMigrations) { 'materials_purge_references_idx', 'material_assets_purge_references_idx', 'material_skus_purge_references_idx', 'brand_knowledge_purge_references_idx', 'system_settings_purge_references_idx', 'performance_work_items_purge_references_idx', 'work_review_versions_purge_references_idx', 'package_upload_jobs_purge_references_idx', 'task_detail_changes_purge_references_idx', + 'desktop_client_grants_code_hash_key', 'desktop_client_sessions_token_hash_key', 'desktop_workspace_projects_source_idx', 'desktop_workspace_assets_project_id_source_key', + 'desktop_client_sessions_owner_idx', 'desktop_workspace_projects_owner_idx', 'desktop_workspace_assets_owner_idx', 'ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key', 'ai_model_audit_created_idx', 'ai_model_verifications_idempotency_key', 'ai_model_verifications_unresolved_key', 'ai_model_verifications_model_stage_idx' ) @@ -475,7 +479,8 @@ async function queryEvidence(prisma, localMigrations) { 'team_function_configs_organization_unit_id_fkey', 'team_function_configs_lead_employee_profile_id_fkey', 'team_function_config_audits_config_id_fkey', 'team_function_configs_revision_check', 'material_purge_audits_job_id_fkey' - ) OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_verifications') + ) OR cls.relname IN ('desktop_client_grants', 'desktop_client_sessions', 'desktop_workspace_projects', 'desktop_workspace_assets') + OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_verifications') OR (cls.relname IN ('material_logs', 'material_download_logs') AND ref.relname = 'materials')) ORDER BY con.conname`, ) @@ -878,6 +883,7 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) { } assertOrganizationConfigurationEvidence(evidence) assertDetailGeneratorEvidence(evidence) + assertDesktopWorkspaceEvidence(evidence) assertAiModelRegistryEvidence(evidence) assertMaterialPurgeEvidence(evidence) assertMaterialPurgeReferenceEvidence(evidence) @@ -900,6 +906,30 @@ export function materialPurgeReferenceExpectations() { return { referenceFunctions: functions, indexes } } +export function assertDesktopWorkspaceEvidence(evidence) { + const required={ + desktop_client_grants:{id:'text',code_hash:'text',owner_id:'text',challenge:'text',redirect_uri:'text',expires_at:'timestamp with time zone',used_at:'timestamp with time zone'}, + desktop_client_sessions:{id:'text',token_hash:'text',owner_id:'text',device_name:'text',expires_at:'timestamp with time zone',revoked_at:'timestamp with time zone'}, + desktop_workspace_projects:{id:'text',owner_id:'text',source_web_id:'text',document:'jsonb',conversation:'jsonb',composer:'text',status:'text',revision:'integer'}, + desktop_workspace_assets:{id:'text',project_id:'text',owner_id:'text',source:'text',sha256:'text',storage_key:'text',mime_type:'text',byte_size:'integer'}, + } + for(const [table,fields]of Object.entries(required))for(const [column,type]of Object.entries(fields)){ + const item=evidence.columns.find(row=>row.table_name===table&&row.column_name===column) + if(!item||item.data_type!==type||item.is_nullable!==(['used_at','revoked_at','source_web_id'].includes(column)?'YES':'NO'))fail(`Desktop database column mismatch: ${table}.${column}`) + } + for(const [table,name,columns]of [ + ['desktop_client_grants','desktop_client_grants_code_hash_key',['code_hash']], + ['desktop_client_sessions','desktop_client_sessions_token_hash_key',['token_hash']], + ['desktop_workspace_projects','desktop_workspace_projects_source_idx',['owner_id','source_web_id']], + ['desktop_workspace_assets','desktop_workspace_assets_project_id_source_key',['project_id','source']], + ])assertExactUniqueBtreeIndex(evidence.indexes.find(row=>row.indexname===name),table,name,columns) + for(const table of Object.keys(required)){ + const name=table+'_owner_id_fkey' + assertExactForeignKeyConstraint(evidence.constraints.find(row=>row.constraint_name===name),table,name,'owner_id','users','id') + } + assertExactForeignKeyConstraint(evidence.constraints.find(row=>row.constraint_name==='desktop_workspace_assets_project_id_fkey'),'desktop_workspace_assets','desktop_workspace_assets_project_id_fkey','project_id','desktop_workspace_projects','id') +} + export function assertDetailGeneratorEvidence(evidence) { const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row])) const required = { diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index ad41be4..f3b40ef 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -1,3 +1,4 @@ +import {desktopEvidence} from './desktop-client-migration-fixture.mjs' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -428,6 +429,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index ['20261012_ai_model_registry', { checksum: 'ah' }], ['20261013_ai_model_capabilities', { checksum: 'ai' }], ['20261013_ai_model_verifications', { checksum: 'aj' }], + ['20261020090000_desktop_client_workspace', { checksum: 'desktop' }], ]) const evidence = { migrationRows: [ @@ -703,6 +705,9 @@ test('target evidence rejects nullable logo columns and structurally wrong index evidence.migrationRows.push({ migration_name: '20261012_detail_generator', checksum: 'ag', finished_at: 'now', rolled_back_at: null }) evidence.migrationRows.push({ migration_name: '20261012_ai_model_registry', checksum: 'ah', finished_at: 'now', rolled_back_at: null }, { migration_name: '20261013_ai_model_capabilities', checksum: 'ai', finished_at: 'now', rolled_back_at: null }) evidence.migrationRows.push({ migration_name: '20261013_ai_model_verifications', checksum: 'aj', finished_at: 'now', rolled_back_at: null }) + evidence.migrationRows.push({ migration_name: '20261020090000_desktop_client_workspace', checksum: 'desktop', finished_at: 'now', rolled_back_at: null }) + const desktop = desktopEvidence() + for (const field of ['columns', 'indexes', 'constraints']) evidence[field].push(...desktop[field]) const aiModels = aiModelRegistryEvidence() for (const field of ['columns', 'indexes', 'constraints']) evidence[field].push(...aiModels[field]) for (const [table, fields] of Object.entries({ @@ -1074,12 +1079,12 @@ test('container supply chain and migrator image are immutable while SOP keeps co assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/) assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/) assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/) - assert.match(sop, /三十六份目标 migration/) - assert.match(sop, /三十六条目标 migration/) + assert.match(sop, /三十七份目标 migration/) + assert.match(sop, /三十七条目标 migration/) const targetBlock = migratorSource.match(/const TARGET_MIGRATIONS = Object\.freeze\(\[([\s\S]*?)\]\)/)?.[1] assert.ok(targetBlock, 'Migrator target list must be inspectable') const targets = [...targetBlock.matchAll(/'([^']+)'/g)].map(match => match[1]) - assert.equal(targets.length, 36) + assert.equal(targets.length, 37) for (const migration of targets) assert.ok(sop.includes(`\`${migration}\``), `SOP must name ${migration}`) const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")