@unieai/uad-unieai-web-gate
v0.1.21
Published
Browser sign-in gate: the /auth routes, the server-rendered sign-in page, and the WebServer request guard behind them
Readme
@unieai/uad-unieai-web-gate
English | 中文
浏览器登录闸门:/auth/* 路由、其后由服务端渲染的登录页,以及决定一个请求能否进入分发的 webServer 请求守卫。
身份来自 UnieAI Copilot 网页产品,走 RFC 8628 设备码授权。核准时该产品只返回一份凭证:用于其自身 /api/desktop/* 的 API key。
两个产品刻意分开。Copilot 是 SaaS;本桌面端是在本机运行自己 agent 的个人应用,只向该产品索取身份、方案与模型凭证,不接受它派来的任何工作。因此这趟登录不会产生任何能让该产品反向进入这台机器的东西。
为什么是守卫,而不是应用内的登录画面
client/runtime 在插件阶段就打开 WebSocket 下行并发出第一个 RPC,早于 React 挂载。此后渲染的任何东西都只是一块挂在已经开演的舞台前的幕布,而非浏览器调用方根本看不到那块幕布。守卫改在两个分发入口执行,因此一个决定即覆盖应用外壳、插件包注册表、/api 与两条下行。
认证不等于授权
本主机运行着一个持有 bash 与文件系统工具的 agent,因此放行所有有效账号,等于把任意代码执行权交给任何能在该产品注册的人。所以仅有身份并不构成放行:第一个完成登录的账号取得本实例的归属,之后的账号一律拒绝,除非 allowedUserIds 明确列出。刻意不提供「任何账号皆可」的模式。
允许名单属于部署,绝不属于随包出货的 bundle:在人人都会安装的包里写死账号,会让它只认那几个账号,于是其他每一份安装都没有人登得进去——而且重启和升级都没用,因为允许名单是配置,不是它所覆盖的那个内存中的认领。verify-cordis-config 因此拒绝 packages/bundle/*/cordis.patch.yml 里出现允许名单;共享实例在自己的 patch 层里声明它的账号。
认领会在最后一个会话登出时释放,而且仅在那时——只要还有另一个浏览器登录着,这台机器就仍然属于那个账号。认领的存在是为了让一台机器只服务一个账号,不是为了让它永远只服务那一个:登出是一个人在说他用完了这台机器,而活得比登出更久的认领,会把他自己的下一次登录当成「另一个账号」拒绝掉,并且除了重启 Host 之外没有任何办法清除。配置好的 allowedUserIds 不是认领,永远不会被释放——明确列出账号的部署是认真的,任何浏览器操作都不该扩大它。
启动路由
GET /auth/bootstrap 是桌面端的启动答复:刚加载完的应用关于自己账号所需要的一切,装在一个 body 里,在本 host 上取好。
它之所以存在,是因为浏览器过去要分别问四个问题——账号、providers、可用模型、可挂载的 MCP 服务器——各走各的路由、各按各的时机,而每一个到了这里照样会变成一次或多次产品调用。放在这一侧预取严格更优,理由是两件关于「东西在哪」的事实,而不是偏好:本 host 本就握着会话与 API key,而且它可以在浏览器存在之前就开始。设备码授权落地的那一刻预取就开始了,而那时浏览器还在从登录页跳往应用的路上。
答复是 { status, parts, pending }。其中每一部分都逐字等同于该部分自己的路由会给出的答复——parts.account 就是一份 /auth/account 的 body,parts.providers 就是一份 /auth/providers 的 body,依此类推;两者由同一批函数构建,因此不可能产生分歧。每一部分都到齐时 status 为 ready,只要有一部分没到就是 partial,并由 pending 点名。抵达了产品但失败的部分是一个存在的部分,携带该路由自己的失败 body:取到的失败与从未落地的部分是两件不同的事,只有后者才算 pending。
| 界限 | 数值 | 作用 | |---|---|---| | 作答截止 | 2 秒 | 冷启动预取最多等这么久。超过即以已到齐的部分作答,并继续取余下的。 | | 缓存寿命 | 30 秒 | 一次完成的预取被从内存作答多久。它覆盖的是一次跳转,而不是一个数据存储。 | | 上游上限 | 15 秒 | 何时放弃一次永不作答的产品读取,以免为该账号一直占着一个死掉的 socket。 |
有三件事它刻意不做。它绝不为没有有效会话的请求查阅缓存——会话先被解析,因此已失效的会话无论取好了什么都答以 signed-out。它绝不把为某个账号取好的东西交给另一个账号——缓存以账号 id 为键,并在该账号最后一个会话消失时丢弃。它也绝不服务于那些单独的路由:/auth/account 与 /auth/providers 是刷新路径,在保存或新建之后被读取,用缓存作答只会报出读者正想越过的那个状态。
没有会话的浏览器完全不花代价:没有产品调用,没有等待,{ status: 'signed-out' }。这件事比看上去重要——未登录是运行这台桌面端的正常方式,而本地 agent 并不需要产品。
账号路由
GET /auth/account 是唯一一条并非为登录而存在的路由。账户设置区块需要用户的方案与剩余用量,而这些调用所需的凭据正是本闸门会话表中持有的 API key。该 key 不得进入页面,因此由浏览器询问本 host、本 host 询问产品:该路由解析会话,以该 key 作为 bearer 调用 /api/desktop/me、/usage 与 /invite,并回答 { status: 'signed-out' }、{ status: 'signed-in', snapshot } 或 { status: 'failed', message }。三者都不含该 key。
失败时的 message 为英文。本 host 不知道读者的语言,因此它是给直接调用方看的诊断信息;消费本路由的浏览器半边 @unieai/uad-client-unieai-account-gateway 会改用自己的本地化文案。
快照中的 user.avatarUrl 来自下面的资料路由,而非 /api/desktop/me——后者不回报照片。没有头像的账号收到的答复中根本没有该字段:空 src 会渲染成一张坏图,而缺失才会画出首字母标记。
快照还携带 stats——概览区的五个数字,以及热力图背后的按日序列——在同一次调用中读自 /api/desktop/stats。它随快照同行、而不是只留给 /auth/stats,是因为浏览器的账号网关只读一个端点:没有出现在这个答复里的数字,就是概览区画不出来的数字。与其他附加分节一样,读取失败会让它「缺席」而不是归零,因为零是关于一个账号的断言,而产品没有作答不是。
invites 在一直传递的 inviteCount 之外,还携带推荐记录本身——inviteeEmail、status、createdAt、inviteUrl——来自同一次 /api/desktop/invite 调用,不增加任何成本。每一行都按字段名逐个构建,因此产品日后新增的列没有落脚处;inviteUrl 是刻意转发的,因为兑换链接是该操作的产物、是本人要转交给别人的东西,而不是本桌面端代其持有的凭证。
资料路由
GET/POST /auth/profile 是显示名称与头像的同一道缝,也正是它让桌面端的「账户」分区能够更改这两者,而不只是展示。GET 代理 /api/desktop/profile,回答 { status: 'signed-out' }、{ status: 'signed-in', profile } 或 { status: 'failed', message };POST 代理该路由的 PATCH,回答 { status: 'saved', profile } 或 { status: 'failed', message }。与 /auth/account 一样,会话的 API key 不出现在其中任何一个答复里,只朝产品方向花费。
此处有四条性质:
- 保存回报的是存下的内容,而不是请求的内容。 产品接受的
POST之后会有一次回读,因此去掉首尾空白的名称、重新编码的照片会以产品保留的样子到达页面。 - 拒绝会说明自己是哪一种。 产品用一句写给直接调用方看的英文拒绝
PATCH,因此闸门辨认出是它三项检查中的哪一项失败,并回答{ status: 'failed', reason },取值为name-required、avatar-format或avatar-payload——这就是/auth/providers的做法,只不过这条路由的拒绝是以散文而非代码抵达的。闸门自身对「未指定显示名称」的形状拒绝也用同一个name-required,因此读者无需知道是哪一侧发现的。本版本无法归位的句子仍然是一次无解释的失败,而不是被猜出来的。 - 头像的三种意图保持可分。
data:URL 设置照片,null清除照片,缺失的image保持不动。把缺失并入 null 会在每次只改名称的保存中删掉头像。 - 此处不做任何校验。 合法的显示名称与合法的头像由
app/api/desktop/profile判定,在本 host 上再写一份规则只会与它相左。
本路由自己加的唯一约束是请求体的缓冲上限(12 MiB),因为头像以 base64 内联传输。产品不限制图片大小,因此这是传输限制而非校验规则——它比桌面端自身 512px 裁切的产物高出一个数量级。
Provider 路由
GET/POST /auth/providers 是账号 API Provider 的同一道缝,桌面版正是经由它显示与网页版「API Provider 设置」页相同的那份列表。GET 代理 /api/desktop/providers,回答 { status: 'signed-out' }、{ status: 'signed-in', providers } 或 { status: 'failed', message };POST 代理创建,回答 { status: 'created', provider } 或 { status: 'refused', reason }。
PATCH/DELETE /auth/providers/<id> 是单一行的同一道缝:注册在集合路径之下的前缀路由,代理 /api/desktop/providers/<id>,回答 { status: 'updated', provider }、{ status: 'deleted' }、{ status: 'refused', reason, fields } 或 { status: 'failed', message }。其他动词一律 405,更深的路径一律 404,因此 id 永远只有一段。
这两条路由上有两条规则,并针对整份序列化答复做了测试。与 /auth/account 一样,会话的 API key 绝不出现在其中。此外,任何 Provider 自身的凭据也绝不回传:产品的桌面投影对任何行都不携带凭据,ProviderSummary 也没有存放它的字段。凭据只朝一个方向移动——POST,或使用者刚重新输入 apiKey 的 PATCH,把 key 送往产品,那才是要花用它的存储。未指名 apiKey 的 PATCH 不会送出任何 key:缺席正是告诉产品保留既有凭据的方式,送出空字符串则会在改名时把它抹掉。
reason 原样转发产品自己的标识符(prefix_taken、byo_provider_limit_reached、managed_provider_readonly、not_found 等)而非文句,因为只有浏览器知道读者的语言,fields 则在旁携带被拒绝的字段名。平台托管的行能改动什么——只有各模型的启用选择与整体启用旗标,且永远不得删除——由产品在行的旁边决定;本主机不留第二份该规则,只转发执行它的 409。
Model 路由
GET /auth/models 是同一道缝,取的是账号在网页产品上有权运行的模型:也就是产品自身选单所依据的那份并集——账号自有 Provider 中已选取的模型、其群组所授予的模型,以及全局模型。它代理 /api/desktop/models,回答 { status: 'signed-out' }、{ status: 'signed-in', models } 或 { status: 'failed', message }。与 /auth/providers 一样,会话的 API key 绝不出现在其中,任何条目也不携带 Provider 凭据——EntitledModel 既没有存放凭据的字段,也没有存放端点的字段。
这些模型可以运行,但不是从这里运行。 任何条目都不携带 Provider 的 base URL 或凭据,将来也不会:那些留在产品侧。让这份列表不只是一个账号可见性界面的,是产品自己的中继 POST /api/desktop/v1/chat/completions——由桌面 API key 认证,并在产品侧解析上游、执行方案配额、计量该回合。@unieai/uad-llm-unieai-cloud 通过 ctx.unieaiGate 读取同一份列表,并把它注册成一条指向该中继的 llm 路由,因此一个权限模型恰好在存在闸门会话可为其认证时变得可选。
刻意只读:完全没有写入方向。一个账号可运行哪些模型,是由它的 Provider、它的群组与平台决定的,而其中没有任何一项是桌面版靠指名一个模型就能改变的。
邀请路由
POST /auth/invite 发出一封推荐邀请:请求体是 { email },答复为 { status: 'sent', url? }、{ status: 'refused', reason }、{ status: 'failed' } 或 { status: 'signed-out' }。reason 是产品自己的标识符(invalid_email、self_invite、already_invited),原样转发,因此一个本版本不认识的原因仍能抵达一个也许认识它的页面。
刻意没有 GET 半边。账号已经发出的邀请随 /auth/account 同行,而浏览器为了余额与计数本来就在读它;再来一条列表路由,就是同一批记录的第二个来源。
这里不决定什么是合法地址,也不决定哪个地址是账号自己的。那些是产品的规则;闸门只拒绝根本没有指定地址的请求体,并以产品自己的 invalid_email 回报,好让页面无论是哪一侧发现的都只有一行文案。
统计路由
GET /auth/stats 是同一道缝,取的是账号的个人活动,并提供 /auth/account 只带一份副本的那整条记录:totalTokens、peakDayTokens、longestTaskMinutes、currentStreakDays、longestStreakDays 与 daily——有用量的日期按升序排列,没有用量的日期是缺席而非零。它代理 /api/desktop/stats,回答 { status: 'signed-out' }、{ status: 'signed-in', stats } 或 { status: 'failed', message }。会话的 API key 不出现在其中任何一个里。
与产品路由一致,只有个人范围:网页产品自己的 /api/user/stats 还带一块组织面板,而本桌面端没有组织界面。
MCP 路由
GET /auth/mcp 列出账号可挂载的 MCP 服务器。它是所有 /auth/* 路由中唯一一条其答复是对产品所发内容的刻意「收窄」的路由,因为产品在这里发下了凭证:每台服务器都带一枚按用户签发的 bearer,每次读取都重新铸造,大约只管一小时。
浏览器拿到的是 McpServerView——id、label、origin、expiresAt、tools——一个根本没有 token 成员的类型,遵循 lib/desktop/providers.ts 剔除 apiKey 的同一条原则:将来想要发送该 bearer 的修改,必须先加上这个字段,而那是评审看得见的改动。端点被收窄为其 origin,理由与产品自己把 MCP 条目公布为 origin 的理由相同——远端 MCP URL 的路径或查询串里经常带着令牌。
expiresAt 会随答复同行,因为它是关于服务器的事实而非凭证:一个在授权失效后仍把服务器显示为已连接的插件页,展示的是一件已不再为真的事。
技能路由
GET /auth/skills 列出账号在网页产品里保存的技能,GET /auth/skills/<slug> 则把其中一个作为一整份 SKILL.md 回答。列表是给页面的,文件是给 host 的,这个切分就是设计本身。技能是模型会照着做的指令,所以落进技能目录里的字节必须来自账号,而不是来自浏览器:页面把 slug 交给 host,host 透过 ctx.unieaiGate.accountSkill 读出文件再写下去。
slug 是一个目录名,不只是一个标识符。 它会被接到这台机器上的技能目录后面,所以一个带分隔符、带前导点、或者是那两个会指向别处的名字(.、..)的值,在这里和在产品那边都会被拒绝,而列表里带着这种值的行会被丢掉而不是显示出来。两个地方检查同一条规则,是因为这一侧才是路径最终被拼出来的地方。
复制就是复制:文件成了那台机器的,在那边改它对账号没有任何影响,再复制一次就覆盖回去。两边都不记录哪台机器有哪个技能。
文件路由把 404 与「读取失败」分开,因为这两者引出的句子不同:账号已经没有这个技能,意思是刚才读到的那份列表已经过期了;而读取失败是值得重试的。空文件被当成失败拒绝——一个没有正文的技能文件教不了模型任何事,却会把一个原本有内容的覆盖掉。
Host 侧的闸门服务
ctx.unieaiGate 是闸门的另一半:不是路由,而是让 host 插件代已登录账号行事的那道缝。它公开 productUrl、session()——账号及其 API key——以及三个代理读取 mcpServers()、entitledModels() 与 accountSkill(),返回的是浏览器投影收窄之前、产品原样发来的内容。@unieai/uad-unieai-mcp-supervisor 借第一个挂载账号的 MCP 服务器;@unieai/uad-llm-unieai-cloud 借第二个构建账号可运行的模型路由;@unieai/uad-host-apiproxy 里的 skill.install 借第三个把技能文件写下来——这也正是那条路由只收一个 slug、不收任何内容的原因。
该服务之所以存在,是因为会话表是本 host 上唯一持有产品凭证的地方,而需要凭证的 host 插件不得伸手进那张表。unieai-gate/session 在一次登录产生会话时、以及最后一个会话因登出而消失时发出——闲置过期不会发出,因为那是在读取时判定的,因而没有任何东西在它发生的那一刻观察到它。代账号持有资源的消费方按自己的节奏重读。
配置
| 字段 | 默认值 | 含义 |
|---|---|---|
| productUrl | https://agent.unieai.com | 本桌面端登录的网页产品。 |
| enforce | false | 守卫是否真的拒绝流量。默认关闭,以便组合先挂载闸门并在 /auth/login 走通流程再决定启用:默认打开一道尚未验证的栅栏,会把操作者锁在一台正在使用的机器之外。 |
| allowedUserIds | [] | 允许进入的账号。为空则交由 claimFirstLogin 决定。 |
| claimFirstLogin | true | 空名单是否由第一次成功登录者取得归属。 |
| idleTimeoutMs | 12 小时 | 浏览器会话的闲置寿命。 |
Model Experience
无,本包只对浏览器流量把关并提供一份登录文档;此处没有任何内容进入模型请求。
KV Cache effect
无;本包既不组装也不发送供应商请求。
Known Limitations and Deferred Work
- 会话保存在内存中 —— 主机重启会让所有浏览器登出。服务单一操作者的桌面端重新认证只需一个动作,因此持久化存储推迟到确有部署需要会话跨重启存活时再做。
enforce默认为关 —— 闸门随组合挂载但不拦截,需要关闭机器的部署自行打开该开关。这是刻意的分阶段选择,而非建议:一个可达且enforce: false的实例,只受其前方任何东西的保护。- 登录页内联重述了自己的设计取值 —— 它服务于可能无法访问插件注册表的访客,因此不能引用
ui-theme。它照 shadcn/ui 的LoginForm区块布局,其 Tailwind 类在此解成字面值,因为本仓库既不带 Tailwind 也不带组件库;那份配色与尺寸在这里重复,必须随主题一同更新。区块中的邮箱输入、各家登录按钮与条款行是刻意不做的 —— 桌面端没有自己的用户库,选哪一家登录发生在网页产品自己的页面上,而本部署没有可供链接的条款页。 /auth/account每次调用都读取产品 —— 没有缓存,也没有重验证窗口,因此反复重新加载的浏览器每次都会发出同样的四次产品调用。要加缓存就需要一条失效规则,而本部署没有可依据的来源。/auth/bootstrap是唯一的例外,而它被限定在一次启动前后的那几秒里,并不是那条缺失的规则。- 无论有没有人来读,启动预取都会跑 —— 一次登录会立刻把缓存暖上,因此一次之后并没有应用加载的设备码授权,仍要花掉一轮产品调用的扇出。它是每次登录一次而非每次请求一次,也正是它让随后那次读取通常免费。
- 启动答复中的某一部分最多可能有一个缓存寿命那么旧 —— 预取后 30 秒内的重新加载由内存作答,因此这几秒里在另一窗口消耗掉的额度不会反映出来。各界面自己的刷新读取不受影响;只有启动答复如此。
- 启动答复没有推送的另一半 —— 没有任何东西会告诉浏览器它还在等的那一部分已经到了。客户端按自己的节奏再问一次,此后那一部分就归该界面自己的重试手势管。
- 一次资料保存要花两次产品调用 ——
PATCH加上回报存下内容的那次回读。产品的PATCH只回答它保留的名称与图片,不含邮箱地址,因此仅凭它作答会给页面一份残缺的资料。 - 产品的拒绝原因被丢弃 ——
app/api/desktop/profile以英文说明拒绝(Name is required、Unsupported avatar format),那是给直接调用方看的诊断信息,因此本路由回答自己的英文诊断,由浏览器半边本地化。若要像/auth/providers那样转发结构化原因码,需要产品先发布一套。 - 12 MiB 的请求体上限只属于本 host —— 产品不限制头像大小,因此介于该上限与产品实际可接受范围之间的图片,只会在此处被拒。
/auth/models本身不喂养任何东西 —— 该浏览器路由是可见性界面;让这些权限变得可运行的模型路由改为通过ctx.unieaiGate读取同一份列表,因为 host 插件无法出示浏览器 cookie。两者因此各自读取产品,并可能在其中一方的刷新窗口内彼此不一致。/auth/models每次调用都读取产品 —— 与/auth/account一样,没有缓存,也没有重验证窗口。- 无法归位的资料拒绝会丢失原因 —— 闸门靠匹配产品的三句拒绝措辞来说明是哪一种拒绝,因此产品新增第四项检查、或改写了措辞,都会让答复以一次无解释的失败抵达页面,直到这份清单被更新。产品没有为这些拒绝发布代码,这正是这里在匹配散文的原因。
/auth/invite回报的是这次发送,而不是列表 —— 它新增到的那批记录要从/auth/account重读,因此想展示新邀请的页面必须重新读取快照。/auth/stats与/auth/mcp每次调用都读取产品 —— 与/auth/account一样,没有缓存,也没有重验证窗口。两者中更昂贵的是/auth/mcp:每次读取都会在产品侧铸出新的 bearer,因此一个轮询它的页面正在铸造自己从不使用的凭证。/auth/mcp报告的是授权而非挂载 —— 它回答的是产品会允许该账号挂载什么,而不一定是本 host 实际打开了什么。被 MCP 监管者跳过或连接失败的服务器,在这里与一台正在提供工具的服务器看起来完全一样。- 闲置过期是惰性的 —— 会话的闲置寿命在读取会话时才判定,因此
ctx.unieaiGate既不会察觉也不会宣告某个会话失效的那一刻,unieai-gate/session也不会为此发出。 - 未刷新或撤销 API key —— 它在会话存续期间持有、登出时丢弃;在到期前续期的工作推迟给消费它的账号界面。
