From 46321d658a4ac9a024fbfe969aaca41b488defe9 Mon Sep 17 00:00:00 2001 From: HandyWote Date: Tue, 8 Sep 2026 23:47:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(module-sdk,hello):=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E9=AA=8C=E7=AD=BE=E6=94=B9=E9=83=A8=E7=BD=B2=E6=9C=9F=E5=85=AC?= =?UTF-8?q?=E9=92=A5=E6=B3=A8=E5=85=A5=EF=BC=8C=E6=9C=AC=E5=9C=B0=20JWKS?= =?UTF-8?q?=20=E9=9B=B6=E8=BF=90=E8=A1=8C=E6=97=B6=E7=BD=91=E7=BB=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因①:模块 Worker 运行时跨 Worker 拉 core JWKS 被 CF 同 zone 禁令拦截 → /api/count 恒 401。 B 方案:verifyModuleToken 改为入参 CORE_JWKS_JSON 字符串 + createLocalJWKSet 本地验签, 删除远程 JWKS 路径(唯一使用方 hello);hello Bindings 由 CORE_JWKS_URL 改 CORE_JWKS_JSON, 缺注入回 503 jwks not provisioned(不再谎报 401)。 测试:真 keypair 签 token→200;坏 token/aud 不符/篡改 payload→401;缺 CORE_JWKS_JSON→503; 破坏性红测:全局 fetch 抛错下合法 token 仍 200 且 fetch 零调用。 Signed-off-by: HandyWote --- modules/hello/src/index.ts | 42 +++++++-------- modules/hello/test/hello.test.ts | 60 ++++++++++++++++------ packages/module-sdk/README.md | 15 +++++- packages/module-sdk/src/verify.ts | 10 ++-- packages/module-sdk/test/sdk.test.ts | 77 +++++++++++++++++++++++++--- 5 files changed, 155 insertions(+), 49 deletions(-) diff --git a/modules/hello/src/index.ts b/modules/hello/src/index.ts index a970d59..dd2cbed 100644 --- a/modules/hello/src/index.ts +++ b/modules/hello/src/index.ts @@ -1,12 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import { Hono, type Context, type MiddlewareHandler } from 'hono'; -import { jwtVerify, createRemoteJWKSet } from 'jose'; import type { ModuleTokenClaims } from '@unself/contracts'; -import { createD1Storage, type D1MinimalDatabase } from '@unself/module-sdk'; +import { createD1Storage, verifyModuleToken, type D1MinimalDatabase } from '@unself/module-sdk'; /** * hello 模块(#13,M0 垂直切片验收载体): - * - verifyModuleToken 中间件:jose + Core JWKS 验签 + aud=hello(§5.2) + * - verifyModuleToken 中间件:SDK 本地 JWKS 验签(部署期注入 CORE_JWKS_JSON,§5.2 B 方案零运行时网络)+ aud=hello * - GET /api/count:经 SDK 存储接口读写 hello_counter(#9 前缀守卫) * - GET /life/export、POST /life/purge:模块生命周期骨架(§5.4 契约) * - 页面:身份行(claims 姓名/邮箱)+ 计数 + [+1] 并排(≤50 行样式,tokens 化) @@ -14,8 +13,8 @@ import { createD1Storage, type D1MinimalDatabase } from '@unself/module-sdk'; export interface Bindings { MODULES_DB: D1Database; - /** Core 实例 JWKS 端点(验签公钥真值来源,§5.2)。 */ - CORE_JWKS_URL?: string; + /** Core 公钥 JWKS 的 JSON 序列化(部署期注入,§5.2 B 方案:模块本地验签,零运行时网络)。 */ + CORE_JWKS_JSON?: string; /** Core issuer(可选校验;M0 以 aud 锁定为主)。 */ CORE_ISSUER?: string; } @@ -23,19 +22,23 @@ export interface Bindings { /** 模块 id:aud 锁定 + SDK 存储子域 + 表前缀三处一致。 */ const MODULE_ID = 'hello'; -/** Bearer 提取 + JWKS 验签 + claims 校验(aud=hello);失败回 401 人话。 */ -export function createAuthMiddleware(jwksUrl: string): MiddlewareHandler<{ Bindings: Bindings; Variables: { claims: ModuleTokenClaims } }> { - const JWKS = createRemoteJWKSet(new URL(jwksUrl)); +/** Bearer 提取 + SDK 本地 JWKS 验签 + claims 校验(aud=hello);失败回 401 人话。 */ +export function createAuthMiddleware(): MiddlewareHandler<{ Bindings: Bindings; Variables: { claims: ModuleTokenClaims } }> { return async (c, next) => { const auth = c.req.header('authorization'); if (!auth?.startsWith('Bearer ')) { return c.json({ error: 'missing bearer token', requestId: c.req.header('x-request-id') }, 401); } + const coreJwksJson = c.env.CORE_JWKS_JSON; + if (!coreJwksJson) { + return c.json({ error: 'jwks not provisioned', requestId: c.req.header('x-request-id') }, 503); + } try { - const { payload } = await jwtVerify(auth.slice(7), JWKS, { audience: MODULE_ID }); - // claims 形状契约校验(ModuleTokenClaimsSchema),SDK 侧 decodeContext 同规 - const { ModuleTokenClaimsSchema } = await import('@unself/contracts'); - c.set('claims', ModuleTokenClaimsSchema.parse(payload)); + const claims = await verifyModuleToken(auth.slice(7), { + coreJwksJson, + audience: MODULE_ID, + }); + c.set('claims', claims); await next(); } catch { // §6.5 人话 + request id:不回 jose 原始错误 @@ -71,7 +74,6 @@ const app = new Hono<{ Bindings: Bindings; Variables: { claims: ModuleTokenClaim * 页面内 fetch/import 全部用相对路径(不帶前导 /):同一路径制下模块同时挂载在 * /m//(部署)与开发期根路径,根相对路径只在后者成立——相对路径两处皆可(#14)。 */ app.get('/', (c) => { - const jwksUrl = c.env?.CORE_JWKS_URL ?? '/.well-known/jwks.json'; return c.html(` @@ -187,7 +189,7 @@ app.post('/api/count', async (c) => { return c.json({ count: next }); }); -/** 用请求内 Bearer 做一次性验签(测试可注入 fake JWKS;运行时与中间件同规)。 */ +/** 用请求内 Bearer 做一次性验签(运行时与中间件同规:本地 JWKS,零运行时网络)。 */ async function requireAuth( c: Context<{ Bindings: Bindings; Variables: { claims: ModuleTokenClaims } }>, ): Promise { @@ -195,16 +197,16 @@ async function requireAuth( if (!auth?.startsWith('Bearer ')) { return c.json({ error: 'missing bearer token', requestId: c.req.header('x-request-id') }, 401); } - const jwksUrl = c.env.CORE_JWKS_URL; - if (!jwksUrl) { - return c.json({ error: 'jwks not configured', requestId: c.req.header('x-request-id') }, 503); + const coreJwksJson = c.env.CORE_JWKS_JSON; + if (!coreJwksJson) { + return c.json({ error: 'jwks not provisioned', requestId: c.req.header('x-request-id') }, 503); } try { - const { payload } = await jwtVerify(auth.slice(7), createRemoteJWKSet(new URL(jwksUrl)), { + const claims = await verifyModuleToken(auth.slice(7), { + coreJwksJson, audience: MODULE_ID, }); - const { ModuleTokenClaimsSchema } = await import('@unself/contracts'); - c.set('claims', ModuleTokenClaimsSchema.parse(payload)); + c.set('claims', claims); return null; } catch { return c.json({ error: 'token invalid or expired', requestId: c.req.header('x-request-id') }, 401); diff --git a/modules/hello/test/hello.test.ts b/modules/hello/test/hello.test.ts index 03b6eae..743e5cc 100644 --- a/modules/hello/test/hello.test.ts +++ b/modules/hello/test/hello.test.ts @@ -1,55 +1,52 @@ // SPDX-License-Identifier: AGPL-3.0-only import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair } from 'jose'; import app from '../src/index'; import { createModuleDb, type ModuleTestDb } from '../../../packages/module-sdk/test/test-factory'; +// 隔离网络破坏用例(零网络红测):每个测试后还原全局 stub。 +afterEach(() => { + vi.unstubAllGlobals(); +}); + /** - * #13 测试:假 JWKS(本测内生成 ES256 对,服务 JWKS 端点)+ + * #71 测试:真实 ES256 keypair + 部署期注入的 CORE_JWKS_JSON(B 方案本地验签,零运行时网络)+ * 真 SQLite module_kv(#60:假 D1 换真库——modules 统一迁移真建表,SDK 收口由真库裁决)。 * 验收链路:SDK 存储读写 hello 计数、跨前缀拒绝由 SDK 层保证(#9/#60 用例), * 这里验证 HTTP 面验签/计数/生命周期骨架。 */ let privateKey: CryptoKey; +let kid: string; /** 造 aud=hello 的合法模块 token。 */ async function makeToken(overrides: Record = {}): Promise { return new SignJWT({ iss: 'https://core.example', sub: 'u_1', aud: 'hello', name: '黄一', email: 'huang@example.com', ...overrides }) - .setProtectedHeader({ alg: 'ES256' }) + .setProtectedHeader({ alg: 'ES256', kid }) .setIssuedAt() .setExpirationTime('10m') .sign(privateKey); } -/** 假 Core:/.well-known/jwks.json 返回测试公钥;token 校验走真实 jose。 */ +/** 每次测试生成真实 ES256 密钥对;CORE_JWKS_JSON 注入 env(§5.2 B 方案:本地验签,不 stub、不发起任何网络)。 */ async function envFor(): Promise<{ MODULES_DB: D1Database; - CORE_JWKS_URL: string; + CORE_JWKS_JSON: string; db: ModuleTestDb; }> { const pair = await generateKeyPair('ES256', { extractable: true }); privateKey = pair.privateKey; const publicJwk = await exportJWK(pair.publicKey); - const jwksBody = JSON.stringify({ keys: [{ ...publicJwk, use: 'sig', alg: 'ES256' }] }); - const originalFetch = globalThis.fetch; - globalThis.fetch = ( - async (input: RequestInfo | URL) => { - if (String(input).includes('/.well-known/jwks.json')) { - return new Response(jwksBody, { status: 200, headers: { 'content-type': 'application/json' } }); - } - throw new Error(`unexpected fetch ${String(input)}`); - } - ) as typeof fetch; + kid = await calculateJwkThumbprint(publicJwk); + const coreJwksJson = JSON.stringify({ keys: [{ ...publicJwk, kid, use: 'sig', alg: 'ES256' }] }); const db = createModuleDb(); afterEach(() => { - globalThis.fetch = originalFetch; db.close(); }); return { MODULES_DB: db.d1 as unknown as D1Database, - CORE_JWKS_URL: 'https://core.example/.well-known/jwks.json', + CORE_JWKS_JSON: coreJwksJson, db, }; } @@ -60,6 +57,35 @@ describe('module-hello(#13 垂直切片载体)', () => { expect(res.status).toBe(200); }); + it('缺 CORE_JWKS_JSON(其余 env 正常)→ 503 且 error=jwks not provisioned', async () => { + const env = await envFor(); + const token = await makeToken(); + const res = await app.request('https://m.example/api/count', { + headers: { authorization: `Bearer ${token}`, 'x-request-id': 'req-no-jwks' }, + }, { MODULES_DB: env.MODULES_DB }); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string; requestId?: string }; + expect(body.error).toBe('jwks not provisioned'); + expect(body.requestId).toBe('req-no-jwks'); + }); + + it('本地验签零网络:fetch 全局破坏(抛错)下合法 token 仍 200 且 fetch 零调用', async () => { + const env = await envFor(); + const token = await makeToken(); + const fetchSpy = vi.fn(() => { + throw new Error('network must not be used'); + }); + vi.stubGlobal('fetch', fetchSpy); + + const res = await app.request('https://m.example/api/count', { + headers: { authorization: `Bearer ${token}` }, + }, env); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ count: 0 }); + // B 方案零运行时网络:验签路径不得触碰 fetch。 + expect(fetchSpy).toHaveBeenCalledTimes(0); + }); + it('无 Bearer 的 /api/count 回 401(人话 + requestId)', async () => { const env = await envFor(); const res = await app.request('https://m.example/api/count', {}, env); diff --git a/packages/module-sdk/README.md b/packages/module-sdk/README.md index b7c332f..3d790e2 100644 --- a/packages/module-sdk/README.md +++ b/packages/module-sdk/README.md @@ -1,3 +1,16 @@ # @unself/module-sdk -M0 模块 SDK 骨架:客户端消息协议(ready/token/navigate/notify/theme)、上下文解码(decodeContext)与模块后端 token 校验(verifyModuleToken,jose/JWKS)。 +M0 模块 SDK 骨架:客户端消息协议(ready/token/navigate/notify/theme)、上下文解码(decodeContext)与模块后端 token 校验(verifyModuleToken)。 + +## verifyModuleToken(模块后端验签,§5.2 B 方案) + +```ts +verifyModuleToken(token, { + coreJwksJson: string, // Core GET /.well-known/jwks.json 响应体 JSON 序列化(部署期注入 vars,零运行时网络) + audience: string, // 期望 audience(= 模块 id;aud 锁定防跨模块重放) +}) +``` + +内部 `createLocalJWKSet(JSON.parse(coreJwksJson))` + `jwtVerify(token, jwks, { audience })`, +再经 `ModuleTokenClaimsSchema.parse` 解析;返回 `ModuleTokenClaims`。 +audience 不匹配、签名失败或 claims 形状不符均抛错(调用方回 401)。 diff --git a/packages/module-sdk/src/verify.ts b/packages/module-sdk/src/verify.ts index 40ee992..8ee0f93 100644 --- a/packages/module-sdk/src/verify.ts +++ b/packages/module-sdk/src/verify.ts @@ -1,24 +1,24 @@ // SPDX-License-Identifier: AGPL-3.0-only import { ModuleTokenClaimsSchema, type ModuleTokenClaims } from '@unself/contracts'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { createLocalJWKSet, jwtVerify } from 'jose'; export interface VerifyModuleTokenOptions { - /** Core 的 JWKS 端点(OIDC jwks_uri)。 */ - jwksUrl: string; + /** Core 公钥 JWKS 的 JSON 序列化(部署期注入,内容 = Core `GET /.well-known/jwks.json` 响应体,§5.2 B 方案)。 */ + coreJwksJson: string; /** 期望 audience(与 ModuleTokenClaims.aud 一致)。 */ audience: string; } /** * 模块后端侧校验 Core 签发的模块 token: - * 远端 JWKS 验签 + audience 校验 + claims schema 解析。 + * 本地 JWKS 验签(零运行时网络)+ audience 校验 + claims schema 解析。 * audience 不匹配或解析失败均抛错。 */ export async function verifyModuleToken( token: string, options: VerifyModuleTokenOptions, ): Promise { - const jwks = createRemoteJWKSet(new URL(options.jwksUrl)); + const jwks = createLocalJWKSet(JSON.parse(options.coreJwksJson)); const { payload } = await jwtVerify(token, jwks, { audience: options.audience, }); diff --git a/packages/module-sdk/test/sdk.test.ts b/packages/module-sdk/test/sdk.test.ts index fece3d7..0275ba6 100644 --- a/packages/module-sdk/test/sdk.test.ts +++ b/packages/module-sdk/test/sdk.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair, type CryptoKey } from 'jose'; import { createModuleSDK, decodeJwtPayload, verifyModuleToken } from '../src/index'; @@ -346,12 +347,76 @@ describe('startTokenLoop / stopTokenLoop(静默续期)', () => { }); describe('verifyModuleToken', () => { - it('rejects a garbage token (fails inside jose before any JWKS fetch)', async () => { + let privateKey: CryptoKey; + let coreJwksJson: string; + + beforeEach(async () => { + // 真实 ES256 密钥对;kid = RFC 7638 JWK 指纹(与 Core 签发侧同规)。 + const pair = await generateKeyPair('ES256', { extractable: true }); + privateKey = pair.privateKey; + const publicJwk = await exportJWK(pair.publicKey); + const kid = await calculateJwkThumbprint(publicJwk); + coreJwksJson = JSON.stringify({ keys: [{ ...publicJwk, kid, use: 'sig', alg: 'ES256' }] }); + }); + + async function sign(overrides: Record = {}): Promise { + const now = Math.floor(Date.now() / 1000); + const kid = (JSON.parse(coreJwksJson) as { keys: { kid: string }[] }).keys[0]!.kid; + return new SignJWT({ iss: 'https://core.unself.example', sub: 'mod-a', aud: 'core-api', ...overrides }) + .setProtectedHeader({ alg: 'ES256', kid }) + .setIssuedAt(now) + .setExpirationTime(now + 600) + .sign(privateKey); + } + + it('verifies a token signed by the core key pair and returns schema-parsed claims', async () => { + // 签名 keypair 与注入 JWKS 同源:jose 本地验签通过,claims 载荷原样解析。 + const now = Math.floor(Date.now() / 1000); + const token = await sign(); + await expect(verifyModuleToken(token, { coreJwksJson, audience: 'core-api' })).resolves.toEqual({ + iss: 'https://core.unself.example', + sub: 'mod-a', + aud: 'core-api', + iat: now, + exp: now + 600, + }); + }); + + it('rejects a garbage token (fails inside jose before any key use)', async () => { await expect( - verifyModuleToken('not-a-jwt', { - jwksUrl: 'https://core.unself.example/.well-known/jwks.json', - audience: 'core-api', - }), + verifyModuleToken('not-a-jwt', { coreJwksJson, audience: 'core-api' }), ).rejects.toThrow(); }); + + it('rejects a token signed by a different key pair (signature mismatch)', async () => { + const other = await generateKeyPair('ES256', { extractable: true }); + const token = await new SignJWT({ + iss: 'https://core.unself.example', + sub: 'mod-a', + aud: 'core-api', + }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuedAt() + .setExpirationTime('10m') + .sign(other.privateKey); + await expect(verifyModuleToken(token, { coreJwksJson, audience: 'core-api' })).rejects.toThrow(); + }); + + it('rejects a token with the wrong audience (aud 锁定不变)', async () => { + const token = await sign({ aud: 'other-module' }); + await expect(verifyModuleToken(token, { coreJwksJson, audience: 'core-api' })).rejects.toThrow( + /aud/, + ); + }); + + it('verifies with zero network calls: global fetch throwing does not break verification', async () => { + // 本地 JWKS 验签:即使网络被破坏(fetch 抛错),合法 token 仍通过——B 方案零运行时网络的直接证明。 + vi.stubGlobal('fetch', () => { + throw new Error('network must not be used'); + }); + const token = await sign(); + await expect(verifyModuleToken(token, { coreJwksJson, audience: 'core-api' })).resolves.toEqual( + expect.objectContaining({ sub: 'mod-a', aud: 'core-api' }), + ); + }); }); From c66169abee9e63e88a0713f0def430673dccc973 Mon Sep 17 00:00:00 2001 From: HandyWote Date: Tue, 8 Sep 2026 23:47:51 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(deploy):=20=E8=A3=85=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=20CORE=5FJWKS=5FJSON=EF=BC=8C=E4=B8=89?= =?UTF-8?q?=E7=BA=A7=E5=8F=96=E9=92=A5=E4=B8=8E=E6=8D=A2=E9=92=A5=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moduleWranglerConfig 增必填 jwksJson → vars.CORE_JWKS_JSON,删除 jwksUrl/workers.dev 占位分支; keypair 导出 publicJwksJson(与 core toPublicJwks 同形状);步骤④前解析公钥: 本运行新生成 keypair → 内存公钥(不抓公网);否则 GET /.well-known/jwks.json; 失败硬报错并提示 DNS/路由未就绪可重跑。README 补模块 vars、换钥流程、Docker 等价注记。 测试:装配产物 CORE_JWKS_JSON shape 合法且无 CORE_JWKS_URL;steps 内存/公网/失败三分支。 Signed-off-by: HandyWote --- deploy/cloudflare/README.md | 30 ++++++ deploy/cloudflare/src/assemble.ts | 14 +-- deploy/cloudflare/src/keypair.ts | 11 ++ deploy/cloudflare/src/steps.ts | 43 +++++++- deploy/cloudflare/test/assemble.test.ts | 37 ++++++- deploy/cloudflare/test/steps.test.ts | 102 ++++++++++++++++++ .../test/three-way-consistency.test.ts | 17 ++- 7 files changed, 238 insertions(+), 16 deletions(-) diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md index 1c9b682..75505e4 100644 --- a/deploy/cloudflare/README.md +++ b/deploy/cloudflare/README.md @@ -54,6 +54,36 @@ core 与模块全部 zone 路径路由:core = `/*`,模块 = `/.well-known/jwks.json`(无 CF 同 zone 禁令);失败即硬报错,提示 DNS/路由可能尚未就绪,可重跑部署(幂等) | + +## 换钥流程(轮换 JWT 签名密钥) + +1. `wrangler secret put JWT_PRIVATE_KEY --name unself-core-api`(新 PKCS8 PEM) + ——core 的签名密钥由 secret 派生,替换后立即生效(secret put 会触发重新部署); +2. 重跑部署脚本:核心迁移/模块部署幂等收敛,步骤④会重新抓取 `/.well-known/jwks.json` + (现在已是新公钥)并把新 JWKS 注入各模块 `vars.CORE_JWKS_JSON`; +3. 旧 token 由新 kid 拒绝,系统自然失效——存量会话需重新登录。 + +## Docker 等价注记 + +`docker/` 目录目前为空壳,本次改动只在 CF 装配器(`deploy/cloudflare`)落地。 +容器化部署的等价做法:同一环境变量 `CORE_JWKS_JSON` 写入 compose 的模块服务 +environment(值由生成脚本在启动时从 core 侧导出),模块行为与 CF 一致——本地验签、 +零运行时网络取钥。待 docker/ 落地时按此注记实现。 + ## 产物 所有生成的部署配置与 shell 构建副本落在 `.deploy/cloudflare/`(已 gitignore): diff --git a/deploy/cloudflare/src/assemble.ts b/deploy/cloudflare/src/assemble.ts index 0e1484d..d91a647 100644 --- a/deploy/cloudflare/src/assemble.ts +++ b/deploy/cloudflare/src/assemble.ts @@ -232,19 +232,17 @@ export function coreWranglerConfig(input: { ); } -/** 生成模块部署配置(前缀剥除 wrapper + D1 绑定 + CORE_JWKS_URL + route)。 */ +/** 生成模块部署配置(前缀剥除 wrapper + D1 绑定 + CORE_JWKS_JSON + route)。 */ export function moduleWranglerConfig(input: { config: UnselfConfig; dbIds: { modules: string }; mod: { id: string }; - jwksPath: string; - /** baseUrl 已知时直接给完整 JWKS URL(workers.dev 场景在步骤③后才可知)。 */ - jwksUrl?: string; + /** 部署期注入的 core 公钥 JWKS 字符串(模块本地验签,零运行时网络,§5.2/#71 根因①)。 */ + jwksJson: string; /** zone 路由必填(wrangler schema:routes[] 元素需 zone_id|zone_name);部署脚本经 API 探测注入。 */ zoneName?: string; }): string { - const { config, dbIds, mod, jwksPath, jwksUrl, zoneName } = input; - const host = config.domain || 'workers.dev-placeholder'; + const { config, dbIds, mod, jwksJson, zoneName } = input; return JSON.stringify( { $schema: 'node_modules/wrangler/config-schema.json', @@ -275,7 +273,9 @@ export function moduleWranglerConfig(input: { ], vars: { MODULE_ID: mod.id, - CORE_JWKS_URL: jwksUrl ?? `https://${host}${jwksPath}`, + // 部署期注入公钥 JWKS:模块本地验签零运行时网络(#71 根因①:跨 Worker 拉 core JWKS) + // 被 CF 同 zone 禁令拦截 → 恒 401。换钥 = core secret 更新后重跑部署同步。 + CORE_JWKS_JSON: jwksJson, }, observability: { enabled: true }, }, diff --git a/deploy/cloudflare/src/keypair.ts b/deploy/cloudflare/src/keypair.ts index 6f76cb0..e48597b 100644 --- a/deploy/cloudflare/src/keypair.ts +++ b/deploy/cloudflare/src/keypair.ts @@ -39,6 +39,17 @@ export function createKeypair(): Promise { return generateInstanceKeyPair(); } +/** + * 公钥 JWKS JSON 字符串(与 core keys.ts 的 toPublicJwks 同形状): + * `{ keys: [ { kty:'EC', crv:'P-256', x, y, kid, use:'sig', alg:'ES256' } ] }`。 + * 部署期注入各模块 vars.CORE_JWKS_JSON;私钥材料(d 等)永不出现。 + */ +export function publicJwksJson(pair: InstanceKeyPair): string { + return JSON.stringify({ + keys: [{ ...pair.publicJwk, kid: pair.kid, use: 'sig', alg: 'ES256' }], + }); +} + /** * wrangler secret put(值经 stdin 管道喂入:不出现在 argv/日志/落盘)。 * 返回 secret 的 sha256 指纹前 16 位(部署输出备查,不含密钥本体)。 diff --git a/deploy/cloudflare/src/steps.ts b/deploy/cloudflare/src/steps.ts index 615861d..1f70b0e 100644 --- a/deploy/cloudflare/src/steps.ts +++ b/deploy/cloudflare/src/steps.ts @@ -20,7 +20,7 @@ import { type Provisioned, } from './assemble'; import { loadUnselfConfig, type ModuleRef, type UnselfConfig } from './config'; -import { createKeypair, detectExistingSecret, putSecret, JWT_SECRET_NAME } from './keypair'; +import { createKeypair, detectExistingSecret, publicJwksJson, putSecret, JWT_SECRET_NAME } from './keypair'; import { ensureTotalTls, ensureZoneRecord, findAccountId, findZone, removeLegacyCustomDomains } from './dns'; import { ensureDatabases, ensureR2Bucket, validateS3Storage, CORE_DB_NAME, MODULES_DB_NAME } from './provision'; import { registryCommands, sqlString } from './registry'; @@ -101,6 +101,11 @@ export async function runNineSteps(input: { configOverride?: UnselfConfig; /** 测试注入口:拦截 secret put(默认走真实 spawn)。 */ putSecret?: (workerName: string, value: string) => Promise; + /** + * @internal 仅供测试注入(steps.test.ts):跳过公网 JWKS 抓取(fake wrangler 无真实部署)。 + * 生产路径一律走 defaultFetchJwks(真实 fetch GET /.well-known/jwks.json)。 + */ + fetchJwks?: (baseUrl: string) => Promise; }): Promise { const { rootDir, wrangler } = input; const rep = input.reporter ?? consoleReporter(); @@ -250,6 +255,25 @@ export async function runNineSteps(input: { // ④ 模块构建/上传/路由绑定 rep.step(4, '构建上传模块 Worker,绑 /m//* 路由(zone 路径)与存储绑定'); + // 签名公钥:部署期一次性解析,注入各模块 vars.CORE_JWKS_JSON(模块本地验签,零运行时网络)。 + // 两级取钥(#71 根因①):本运行刚生成 keypair(首部署)→ 用内存公钥,绝不抓取公网; + // 已有 secret → 公网抓取 core JWKS(部署器在公网,无 CF 同 zone 禁令问题),抓取失败即硬报错。 + let jwksJson: string; + if (freshPair) { + jwksJson = publicJwksJson(freshPair); + rep.log('首部署:使用本运行生成的公钥'); + } else { + const fetchJwks = input.fetchJwks ?? defaultFetchJwks; + try { + jwksJson = await fetchJwks(baseUrl); + } catch (err) { + throw new Error( + `无法获取 Core 公钥(${baseUrl}${JWKS_PATH}):${err instanceof Error ? err.message : String(err)};` + + 'DNS/路由可能尚未就绪,可重跑部署(幂等)', + ); + } + rep.log('已获取 Core 公钥 JWKS(部署期注入模块 vars)'); + } for (const mod of provisioned.modules) { await writeConfig( join(provisioned.outDir, `modules/${mod.id}.wrangler.jsonc`), @@ -257,8 +281,7 @@ export async function runNineSteps(input: { config, dbIds: { modules: dbIds.modules }, mod, - jwksPath: JWKS_PATH, - jwksUrl: `${baseUrl}${JWKS_PATH}`, + jwksJson, zoneName: resolvedZone?.name, }), ); @@ -356,6 +379,20 @@ async function resolveBaseUrl( return url; } +/** 公网抓取 core JWKS(部署器在公网,无 CF 同 zone 禁令);非 2xx 或形状非法(无 keys 数组/空)→ 抛错。 */ +async function defaultFetchJwks(baseUrl: string): Promise { + const url = `${baseUrl}${JWKS_PATH}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`GET ${url} → HTTP ${res.status}`); + } + const body = (await res.json()) as { keys?: unknown }; + if (!Array.isArray(body.keys) || body.keys.length === 0) { + throw new Error(`GET ${url} → JWKS 形状非法(无 keys 数组或为空)`); + } + return JSON.stringify(body); +} + /** secret put(stdin 喂值):正式部署路径;测试以注入 fake Wrangler 时不会走到此处之外的真实 spawn—— * 幂等收敛测试首跑带 freshPair,也需要 put。为了让 fake 账户可测,提供 spy 挂点: * 单测通过 env UNSELF_SKIP_SECRET_PUT=1 跳过真实 spawn(fake 状态由测试自记)。 */ diff --git a/deploy/cloudflare/test/assemble.test.ts b/deploy/cloudflare/test/assemble.test.ts index ab6c8ca..c723725 100644 --- a/deploy/cloudflare/test/assemble.test.ts +++ b/deploy/cloudflare/test/assemble.test.ts @@ -48,6 +48,19 @@ describe('coreWranglerConfig(③生成的部署配置)', () => { }); }); +/** 合法公钥 JWKS(真实 P-256 公钥 JWK 形状的静态夹具,与 core GET /.well-known/jwks.json 同形)。 */ +const JWKS_FIXTURE = JSON.stringify({ + keys: [{ + kty: 'EC', + crv: 'P-256', + x: '2zYTVcy0bDXQ7qqeNDB38zsPVvwUkKZ6-m3xA1zwA2U', + y: 'j8zUPxAyGRUAaHRNYwdU3IW7TSBI1kSrg7RmUhb8lZk', + kid: 'RDB_5KqpPvLCvU7V6n8r6-xxpSJutKJCWNmyZWesNSg', + use: 'sig', + alg: 'ES256', + }], +}); + describe('moduleWranglerConfig(④生成的部署配置)', () => { const input = { config: { @@ -57,21 +70,33 @@ describe('moduleWranglerConfig(④生成的部署配置)', () => { } as UnselfConfig, dbIds: { modules: 'modules-uuid' }, mod: { id: 'hello' }, - jwksPath: '/.well-known/jwks.json', + jwksJson: JWKS_FIXTURE, zoneName: 'example.com', }; - it('route 绑定 zone 路径 /m//*(无 custom_domain)+ MODULES_DB 真实 id + CORE_JWKS_URL 绝对地址', () => { + it('route 绑定 zone 路径 /m//*(无 custom_domain)+ MODULES_DB 真实 id + CORE_JWKS_JSON 完整 JWKS', () => { const cfg = JSON.parse(moduleWranglerConfig(input)) as { routes: Array<{ pattern: string; custom_domain?: boolean }>; d1_databases: Array<{ binding: string; database_id: string }>; - vars: { CORE_JWKS_URL: string; MODULE_ID: string }; + vars: { MODULE_ID: string; CORE_JWKS_JSON: string; CORE_JWKS_URL?: string }; }; // 整对象断言:zone 路径 pattern 且无 custom_domain 键(Custom Domain 子域形态已废弃) expect(cfg.routes).toEqual([{ pattern: 'team.example.com/m/hello/*', zone_name: 'example.com' }]); expect(cfg.d1_databases[0]?.database_id).toBe('modules-uuid'); - expect(cfg.vars.CORE_JWKS_URL).toBe('https://team.example.com/.well-known/jwks.json'); expect(cfg.vars.MODULE_ID).toBe('hello'); + // vars:仅注入的 JWKS JSON 字符串,无运行时 JWKS URL(#71 根因①:模块零运行时网络取钥) + expect(cfg.vars.CORE_JWKS_URL).toBeUndefined(); + expect(cfg.vars.CORE_JWKS_JSON).toBe(JWKS_FIXTURE); + const jwks = JSON.parse(cfg.vars.CORE_JWKS_JSON) as { keys: Array> }; + expect(Array.isArray(jwks.keys)).toBe(true); + const key = jwks.keys[0]!; + expect(key.kty).toBe('EC'); + expect(key.crv).toBe('P-256'); + expect(key.x!.length).toBeGreaterThan(0); + expect(key.y!.length).toBeGreaterThan(0); + expect(key.kid!.length).toBeGreaterThan(0); + expect(key.use).toBe('sig'); + expect(key.alg).toBe('ES256'); }); it('domain 空 → 无 routes(workers.dev 回退)', () => { @@ -81,6 +106,10 @@ describe('moduleWranglerConfig(④生成的部署配置)', () => { })) as { routes?: unknown }; expect(cfg.routes).toBeUndefined(); }); + + it('同一输入生成字节级相同配置(确定性/幂等前提)', () => { + expect(moduleWranglerConfig(input)).toBe(moduleWranglerConfig(input)); + }); }); describe('prefixStripWrapperSource(④挂载前缀剥除)', () => { diff --git a/deploy/cloudflare/test/steps.test.ts b/deploy/cloudflare/test/steps.test.ts index b4a04ea..575ab2c 100644 --- a/deploy/cloudflare/test/steps.test.ts +++ b/deploy/cloudflare/test/steps.test.ts @@ -1,4 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { needsTotalTls, runNineSteps } from '../src/steps'; import type { Wrangler } from '../src/wrangler'; @@ -87,6 +89,19 @@ function makeFakeWrangler(options?: { existingD1?: string[]; existingBuckets?: s /** 步骤依赖的最小仓库现场(真实文件布局:modules/hello、services/core-api、apps/shell)。 */ const ROOT = new URL('../../..', import.meta.url).pathname; +/** 合法公钥 JWKS 字符串(真实 P-256 公钥 JWK 形状的静态夹具,与 core GET /.well-known/jwks.json 同形)。 */ +const FIXED_JWKS = JSON.stringify({ + keys: [{ + kty: 'EC', + crv: 'P-256', + x: '2zYTVcy0bDXQ7qqeNDB38zsPVvwUkKZ6-m3xA1zwA2U', + y: 'j8zUPxAyGRUAaHRNYwdU3IW7TSBI1kSrg7RmUhb8lZk', + kid: 'RDB_5KqpPvLCvU7V6n8r6-xxpSJutKJCWNmyZWesNSg', + use: 'sig', + alg: 'ES256', + }], +}); + const SMOKE_OK = { setupToken: async () => ({ token: 't', setupUrl: '/setup?token=t' }), smoke: async (b: string, ids: string[]) => @@ -146,6 +161,7 @@ describe('runNineSteps(九步编排 · 幂等收敛)', () => { wrangler: second.wrangler, http: SMOKE_OK, resolveBaseUrl: async () => summary1.baseUrl, + fetchJwks: async () => FIXED_JWKS, }); // 收敛:零创建、零 secret 重写、registry 仍 upsert(终态一致) expect(second.state.commands.some((c) => c.startsWith('d1 create'))).toBe(false); @@ -168,6 +184,7 @@ describe('runNineSteps(九步编排 · 幂等收敛)', () => { wrangler: fake.wrangler, http: SMOKE_OK, resolveBaseUrl: async () => 'https://x.example', + fetchJwks: async () => FIXED_JWKS, }); const cmds = fake.state.commands; const idxOf = (re: RegExp) => cmds.findIndex((c) => re.test(c)); @@ -195,6 +212,7 @@ describe('runNineSteps(九步编排 · 幂等收敛)', () => { fake.state.commands.push('__ensureTotalTls'); }, resolveBaseUrl: async () => 'https://demo.handywote.top', + fetchJwks: async () => FIXED_JWKS, ensureDns: async (domain) => { fake.state.commands.push(`__ensureDns:${domain}`); }, @@ -223,6 +241,7 @@ describe('runNineSteps(九步编排 · 幂等收敛)', () => { smoke: async () => [{ name: 'core-api', url: 'x', ok: false, status: 503, detail: 'HTTP 503' }], }, resolveBaseUrl: async () => 'https://x.example', + fetchJwks: async () => FIXED_JWKS, }), ).rejects.toThrow(/冒烟失败/); }); @@ -235,7 +254,90 @@ describe('runNineSteps(九步编排 · 幂等收敛)', () => { wrangler: fake.wrangler, http: { setupToken: async () => ({ sealed: true }), smoke: SMOKE_OK.smoke }, resolveBaseUrl: async () => 'https://x.example', + fetchJwks: async () => FIXED_JWKS, }); expect(summary.setup).toEqual({ sealed: true }); }); + + it('分支 A:首部署(无 secret)→ vars.CORE_JWKS_JSON 用本运行公钥,不调用 fetchJwks', { timeout: 120_000 }, async () => { + const fake = makeFakeWrangler(); + let fetchCalls = 0; + await runNineSteps({ + rootDir: ROOT, + configOverride: { domain: '', modules: ['hello'], storage: { provider: 'r2', bucket: 'unself-storage' } }, + wrangler: fake.wrangler, + http: SMOKE_OK, + resolveBaseUrl: async () => 'https://unself-core-api.test-subdomain.workers.dev', + // spy:被调用即失败——分支 A 必须完全跳过公网抓取 + fetchJwks: async () => { + fetchCalls++; + throw new Error('分支 A 不应调用 fetchJwks'); + }, + putSecret: async (workerName) => { + fake.state.secrets.add('JWT_PRIVATE_KEY'); + fake.state.secretsPut++; + fake.state.commands.push(`secret put JWT_PRIVATE_KEY --name ${workerName}`); + }, + }); + expect(fetchCalls).toBe(0); + const cfg = JSON.parse( + await readFile(join(ROOT, '.deploy/cloudflare/modules/hello.wrangler.jsonc'), 'utf8'), + ) as { vars: { CORE_JWKS_JSON: string } }; + const jwks = JSON.parse(cfg.vars.CORE_JWKS_JSON) as { keys: Array> }; + expect(jwks.keys).toHaveLength(1); + expect(jwks.keys[0]?.kty).toBe('EC'); + expect(jwks.keys[0]?.crv).toBe('P-256'); + expect(jwks.keys[0]?.kid).toBeTruthy(); + expect(jwks.keys[0]?.use).toBe('sig'); + expect(jwks.keys[0]?.alg).toBe('ES256'); + }); + + it('分支 B:已有 secret → 公网抓取 JWKS 注入 vars.CORE_JWKS_JSON(fetchJwks 收到 baseUrl)', { timeout: 120_000 }, async () => { + const fake = makeFakeWrangler({ existingD1: ['unself-core', 'unself-modules'], hasSecret: true }); + const received: string[] = []; + await runNineSteps({ + rootDir: ROOT, + configOverride: { domain: '', modules: ['hello'], storage: { provider: 'r2', bucket: 'unself-storage' } }, + wrangler: fake.wrangler, + http: SMOKE_OK, + resolveBaseUrl: async () => 'https://unself-core-api.test-subdomain.workers.dev', + fetchJwks: async (baseUrl) => { + received.push(baseUrl); + return FIXED_JWKS; + }, + }); + expect(received).toEqual(['https://unself-core-api.test-subdomain.workers.dev']); + const cfg = JSON.parse( + await readFile(join(ROOT, '.deploy/cloudflare/modules/hello.wrangler.jsonc'), 'utf8'), + ) as { vars: { CORE_JWKS_JSON: string } }; + expect(cfg.vars.CORE_JWKS_JSON).toBe(FIXED_JWKS); + }); + + it('分支 C:公网抓取失败 → 硬报错(含「无法获取 Core 公钥」与重跑提示)', async () => { + const fake = makeFakeWrangler({ existingD1: ['unself-core', 'unself-modules'], hasSecret: true }); + await expect( + runNineSteps({ + rootDir: ROOT, + configOverride: { domain: '', modules: ['hello'], storage: { provider: 'r2', bucket: 'unself-storage' } }, + wrangler: fake.wrangler, + http: SMOKE_OK, + resolveBaseUrl: async () => 'https://unself-core-api.test-subdomain.workers.dev', + fetchJwks: async () => { + throw new Error('boom'); + }, + }), + ).rejects.toThrow(/无法获取 Core 公钥/); + await expect( + runNineSteps({ + rootDir: ROOT, + configOverride: { domain: '', modules: ['hello'], storage: { provider: 'r2', bucket: 'unself-storage' } }, + wrangler: fake.wrangler, + http: SMOKE_OK, + resolveBaseUrl: async () => 'https://unself-core-api.test-subdomain.workers.dev', + fetchJwks: async () => { + throw new Error('boom'); + }, + }), + ).rejects.toThrow(/可重跑部署(幂等)/); + }); }); diff --git a/deploy/cloudflare/test/three-way-consistency.test.ts b/deploy/cloudflare/test/three-way-consistency.test.ts index 56c2670..fa2ce22 100644 --- a/deploy/cloudflare/test/three-way-consistency.test.ts +++ b/deploy/cloudflare/test/three-way-consistency.test.ts @@ -19,6 +19,19 @@ const CONFIG = { const MANIFEST = 'id: hello\nversion: 0.1.0\n'; +/** 合法公钥 JWKS 字符串(模块 vars.CORE_JWKS_JSON 注入值;测试只关心路由三方一致,不做验签)。 */ +const JWKS_JSON = JSON.stringify({ + keys: [{ + kty: 'EC', + crv: 'P-256', + x: '2zYTVcy0bDXQ7qqeNDB38zsPVvwUkKZ6-m3xA1zwA2U', + y: 'j8zUPxAyGRUAaHRNYwdU3IW7TSBI1kSrg7RmUhb8lZk', + kid: 'RDB_5KqpPvLCvU7V6n8r6-xxpSJutKJCWNmyZWesNSg', + use: 'sig', + alg: 'ES256', + }], +}); + /** 路由 pattern → 归一化 {host, prefix}:`demo.handywote.top/m/hello/*` → {demo.handywote.top, /m/hello/}。 */ function normalizeRoutePattern(pattern: string): { host: string; prefix: string } { const url = new URL(`https://${pattern.replace(/\*$/, '')}`); @@ -33,7 +46,7 @@ describe('三方一致性(#59 §5.3:entry 装载点 == route 路由点 == sm config: CONFIG, dbIds: { modules: 'modules-uuid' }, mod: { id: 'hello' }, - jwksPath: '/.well-known/jwks.json', + jwksJson: JWKS_JSON, zoneName: 'handywote.top', })) as { routes: Array<{ pattern: string; custom_domain?: boolean; zone_name?: string }> }; @@ -74,7 +87,7 @@ describe('三方一致性(#59 §5.3:entry 装载点 == route 路由点 == sm config: CONFIG, dbIds: { modules: 'modules-uuid' }, mod: { id: 'hello' }, - jwksPath: '/.well-known/jwks.json', + jwksJson: JWKS_JSON, zoneName: 'handywote.top', })) as { routes: unknown }; From c684b4900349b5440d3669163febe7f6cc8df87a Mon Sep 17 00:00:00 2001 From: HandyWote Date: Tue, 8 Sep 2026 23:47:51 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(shell):=20=E5=BC=95=E5=85=A5=20@vue/te?= =?UTF-8?q?st-utils=20+=20jsdom=20=E7=BB=84=E4=BB=B6=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: HandyWote --- apps/shell/package.json | 8 +- pnpm-lock.yaml | 516 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 513 insertions(+), 11 deletions(-) diff --git a/apps/shell/package.json b/apps/shell/package.json index 7f0845f..0ce80aa 100644 --- a/apps/shell/package.json +++ b/apps/shell/package.json @@ -14,14 +14,16 @@ }, "dependencies": { "@unself/contracts": "workspace:*", - "vue": "^3.5.0", - "vue-router": "^4.5.0", "@unself/ui": "workspace:*", - "lucide-vue-next": "^0.544.0" + "lucide-vue-next": "^0.544.0", + "vue": "^3.5.0", + "vue-router": "^4.5.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@vitejs/plugin-vue": "^6.0.0", + "@vue/test-utils": "^2.5.0", + "jsdom": "^27.4.0", "tailwindcss": "^4.0.0", "typescript": "^5.9.0", "vite": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db3a7aa..7b4b2f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: '@vitejs/plugin-vue': specifier: ^6.0.0 version: 6.0.8(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13))(vue@3.5.42(typescript@5.9.3)) + '@vue/test-utils': + specifier: ^2.5.0 + version: 2.5.0(@vue/compiler-dom@3.5.42)(@vue/server-renderer@3.5.42)(vue@3.5.42(typescript@5.9.3)) + jsdom: + specifier: ^27.4.0 + version: 27.4.0(supports-color@10.2.2) tailwindcss: specifier: ^4.0.0 version: 4.3.3 @@ -43,7 +49,7 @@ importers: version: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13) vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@27.4.0(supports-color@10.2.2))(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) vue-tsc: specifier: ^3.0.0 version: 3.3.11(typescript@5.9.3) @@ -74,7 +80,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@27.4.0(supports-color@10.2.2))(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) modules/hello: dependencies: @@ -102,7 +108,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@27.4.0(supports-color@10.2.2))(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) wrangler: specifier: ^4.0.0 version: 4.129.0(@cloudflare/workers-types@4.20260702.1) @@ -118,7 +124,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@27.4.0(supports-color@10.2.2))(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) packages/module-sdk: dependencies: @@ -134,7 +140,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@27.4.0(supports-color@10.2.2))(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) packages/ui: dependencies: @@ -156,7 +162,7 @@ importers: version: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13) vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@27.4.0(supports-color@10.2.2))(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) vue-tsc: specifier: ^3.0.0 version: 3.3.11(typescript@5.9.3) @@ -184,13 +190,25 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@27.4.0(supports-color@10.2.2))(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13) wrangler: specifier: ^4.0.0 version: 4.129.0(@cloudflare/workers-types@4.20260702.1) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -258,6 +276,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} @@ -573,6 +627,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -761,6 +824,9 @@ packages: os: [linux] libc: [glibc] + '@one-ini/wasm@0.2.1': + resolution: {integrity: sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -1107,6 +1173,24 @@ packages: '@vue/shared@3.5.42': resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + '@vue/test-utils@2.5.0': + resolution: {integrity: sha512-6Clu5EKR/r6cDPYrKsu+8wenciWJJ3rhS9OEGsfDlZeZIhlJeEPGIZQHxE4lHRJCzPSq3EWMsFxQUqCvrbHQuQ==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + + abbrev@5.0.0: + resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} @@ -1114,9 +1198,20 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1129,13 +1224,32 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1145,6 +1259,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -1153,6 +1270,11 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + editorconfig@3.0.2: + resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==} + engines: {node: '>=20'} + hasBin: true + enhanced-resolve@5.24.5: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} @@ -1161,6 +1283,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -1201,6 +1327,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1208,6 +1338,24 @@ packages: resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1215,9 +1363,26 @@ packages: jose@6.2.12: resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-beautify@2.0.3: + resolution: {integrity: sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -1299,6 +1464,10 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lucide-vue-next@0.544.0: resolution: {integrity: sha512-mDp/AdGOPIDkpFHnFiTWgQgCST9aBXHVaiobZfOMIvv7nrOukzF/TP+7KoOwrngdWRaH9TMiepMBIX1vsgKJ3g==} deprecated: Package deprecated. Please use @lucide/vue instead. @@ -1308,10 +1477,21 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + miniflare@5.20260903.0-alpha: resolution: {integrity: sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==} engines: {node: '>=22.0.0'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1323,9 +1503,21 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nopt@10.0.1: + resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -1347,11 +1539,26 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rollup@4.63.1: resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1381,6 +1588,9 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1410,6 +1620,21 @@ packages: resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} engines: {node: '>=14.0.0'} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1509,6 +1734,9 @@ packages: vscode-uri@3.2.0: resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + vue-component-type-helpers@3.3.11: + resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==} + vue-router@4.6.4: resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} peerDependencies: @@ -1528,6 +1756,26 @@ packages: typescript: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1560,6 +1808,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -1571,6 +1826,26 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -1613,6 +1888,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -1774,6 +2073,8 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@exodus/bytes@1.15.1': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.2': @@ -1907,6 +2208,8 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@one-ini/wasm@0.2.1': {} + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -2209,12 +2512,35 @@ snapshots: '@vue/shared@3.5.42': {} + '@vue/test-utils@2.5.0(@vue/compiler-dom@3.5.42)(@vue/server-renderer@3.5.42)(vue@3.5.42(typescript@5.9.3))': + dependencies: + '@vue/compiler-dom': 3.5.42 + js-beautify: 2.0.3 + vue: 3.5.42(typescript@5.9.3) + vue-component-type-helpers: 3.3.11 + optionalDependencies: + '@vue/server-renderer': 3.5.42 + + abbrev@5.0.0: {} + + agent-base@7.1.4: {} + alien-signals@3.2.1: {} assertion-error@2.0.1: {} + balanced-match@4.0.4: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + blake3-wasm@2.1.5: {} + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + cac@6.7.14: {} chai@5.3.3: @@ -2227,20 +2553,53 @@ snapshots: check-error@2.1.3: {} + commander@14.0.3: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + cookie@1.1.1: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.5.2 + csstype@3.2.3: {} + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 10.2.2 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} detect-libc@2.1.2: {} + editorconfig@3.0.2: + dependencies: + '@one-ini/wasm': 0.2.1 + commander: 14.0.3 + minimatch: 10.2.6 + semver: 7.8.5 + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 @@ -2248,6 +2607,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + error-stack-parser-es@1.0.5: {} es-module-lexer@1.7.0: {} @@ -2325,16 +2686,84 @@ snapshots: fsevents@2.3.3: optional: true + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + graceful-fs@4.2.11: {} hono@4.13.7: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + ini@1.3.8: {} + + is-potential-custom-element-name@1.0.1: {} + jiti@2.7.0: {} jose@6.2.12: {} + js-beautify@2.0.3: + dependencies: + config-chain: 1.1.13 + editorconfig: 3.0.2 + glob: 13.0.6 + js-cookie: 3.0.8 + nopt: 10.0.1 + + js-cookie@3.0.8: {} + js-tokens@9.0.1: {} + jsdom@27.4.0(supports-color@10.2.2): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@exodus/bytes': 1.15.1 + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + kleur@4.1.5: {} lightningcss-android-arm64@1.32.0: @@ -2388,6 +2817,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@11.5.2: {} + lucide-vue-next@0.544.0(vue@3.5.42(typescript@5.9.3)): dependencies: vue: 3.5.42(typescript@5.9.3) @@ -2396,6 +2827,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + mdn-data@2.27.1: {} + miniflare@5.20260903.0-alpha: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -2408,14 +2841,33 @@ snapshots: - bufferutil - utf-8-validate + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.3: {} + ms@2.1.3: {} muggle-string@0.4.1: {} nanoid@3.3.18: {} + nopt@10.0.1: + dependencies: + abbrev: 5.0.0 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-browserify@1.0.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -2432,6 +2884,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + proto-list@1.2.4: {} + + punycode@2.3.1: {} + + require-from-string@2.0.2: {} + rollup@4.63.1: dependencies: '@types/estree': 1.0.9 @@ -2464,6 +2922,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.63.1 fsevents: 2.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.5: {} sharp@0.35.2: @@ -2512,6 +2974,8 @@ snapshots: supports-color@10.2.2: {} + symbol-tree@3.2.4: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -2531,6 +2995,20 @@ snapshots: tinyspy@4.0.6: {} + tldts-core@7.4.12: {} + + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tslib@2.8.1: optional: true @@ -2586,7 +3064,7 @@ snapshots: lightningcss: 1.32.0 tsx: 4.23.13 - vitest@3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13): + vitest@3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@27.4.0(supports-color@10.2.2))(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.23.13): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -2613,6 +3091,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + jsdom: 27.4.0(supports-color@10.2.2) transitivePeerDependencies: - jiti - less @@ -2629,6 +3108,8 @@ snapshots: vscode-uri@3.2.0: {} + vue-component-type-helpers@3.3.11: {} + vue-router@4.6.4(vue@3.5.42(typescript@5.9.3)): dependencies: '@vue/devtools-api': 6.6.4 @@ -2650,6 +3131,21 @@ snapshots: optionalDependencies: typescript: 5.9.3 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -2682,6 +3178,10 @@ snapshots: ws@8.21.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 From cb4b5832040b576043dde0b90d81480a1c060a1d Mon Sep 17 00:00:00 2001 From: HandyWote Date: Tue, 8 Sep 2026 23:47:51 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(shell):=20=E6=A8=A1=E5=9D=97=E6=A1=A5?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=E6=97=B6=E6=9C=BA=EF=BC=8Cflush=20post=20+?= =?UTF-8?q?=20iframe=20=E5=B0=B1=E4=BD=8D=E5=85=9C=E5=BA=95=20+=20retry=20?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E9=87=8D=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因②:watch(activeModule) 默认 pre-flush,回调时 iframe 未进 DOM → frameEl 为 null → 误判「模块入口配置无效」;retry 不重载 iframe,模块 ready 已发出无人接收(概率性失败)。 改 flush:'post' + nextTick 兜底,iframe 未挂载则等模板 ref 就位(不再静默 failed); retry 自增 frameReload 并入 frameKey,强制 iframe 重挂使模块重新发 ready。 测试:jsdom 组件测试断言桥在 iframe 挂载后 attach、ready→token→帧就位、retry 替换 iframe DOM。 Signed-off-by: HandyWote --- apps/shell/src/App.test.ts | 166 +++++++++++++++++++++++++++++++++++++ apps/shell/src/App.vue | 102 +++++++++++++++-------- 2 files changed, 235 insertions(+), 33 deletions(-) create mode 100644 apps/shell/src/App.test.ts diff --git a/apps/shell/src/App.test.ts b/apps/shell/src/App.test.ts new file mode 100644 index 0000000..cc43903 --- /dev/null +++ b/apps/shell/src/App.test.ts @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// @vitest-environment jsdom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import App from './App.vue' +import { fetchMe } from './lib/session-api' +import { fetchEnabledModules } from './lib/registry-api' +import { fetchModuleToken } from './lib/token-api' + +const { TOKEN, MODULE, REJECTED } = vi.hoisted(() => { + const TOKEN = { + token: 'tok-1', + expiresIn: 600, + claims: { + iss: 'unself', + sub: 'u1', + aud: 'hello', + iat: 1_700_000_000, + exp: 1_700_000_600, + }, + } + /** enabled 模块:manifest.entry 同域路径(§5.3 单域名路径制)。 */ + const MODULE = { + id: 'hello', + enabled: true, + version: '0.1.0', + manifest: { + id: 'hello', + route: '/m/hello/', + entry: '/m/hello/', + runtime: 'vue', + version: '0.1.0', + }, + } + const REJECTED = Object.assign(new Error('登录已过期,请重新登录'), { status: 401 }) + return { TOKEN, MODULE, REJECTED } +}) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('./lib/session-api', () => ({ + fetchMe: vi.fn().mockResolvedValue({ + authenticated: true, + user: { id: 'u1', name: '黄一', issuer: 'unself', sub: 'u1' }, + }), + loginUrl: (next?: string) => `/api/auth/login${next ? `?next=${encodeURIComponent(next)}` : ''}`, + logout: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('./lib/registry-api', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchEnabledModules: vi.fn().mockResolvedValue([MODULE]), + } +}) + +vi.mock('./lib/token-api', () => ({ + fetchModuleToken: vi.fn(), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +/** 冲刷挂载 → 拉注册表 → 选模块 → post-flush 挂桥整条异步链。 */ +async function settle() { + await flushPromises() + await nextTick() + await flushPromises() + await nextTick() + await flushPromises() +} + +/** 模拟 iframe 内 SDK 发 ready(真实 MessageEvent,走真实桥的消息监听)。 */ +function dispatchReady(iframe: HTMLIFrameElement) { + const ev = new MessageEvent('message', { + data: { type: 'ready' }, + origin: window.location.origin, + source: iframe.contentWindow, + }) + window.dispatchEvent(ev) +} + +// 注意:jsdom 中未挂入 document 的 iframe 共享同一 contentWindow, +// 因此每个用例必须 wrapper.unmount()(触发 onBeforeUnmount 拆桥), +// 否则上一个用例的桥监听器会截获下一个用例的 ready 事件。 +describe('App.vue 模块桥挂载时机(#71 根因 2)', () => { + it('桥在 iframe 挂载后 attach:ready → token → frameState ready(不误判配置无效)', async () => { + vi.mocked(fetchModuleToken).mockResolvedValue(TOKEN) + vi.mocked(fetchMe).mockResolvedValue({ + authenticated: true, + user: { id: 'u1', name: '黄一', issuer: 'unself', sub: 'u1' }, + }) + vi.mocked(fetchEnabledModules).mockResolvedValue([MODULE]) + + const wrapper = mount(App) + await settle() + + const iframeEl = wrapper.find('iframe') + expect(iframeEl.exists()).toBe(true) + // 落地规则:直访 / 落第一个启用模块 → hello 进入握手期(骨架可见、帧隐藏) + expect(iframeEl.attributes('src')).toBe('/m/hello/') + expect(iframeEl.classes()).toContain('shell-frame-hidden') + expect(wrapper.text()).not.toContain('模块入口配置无效') + + dispatchReady(iframeEl.element as HTMLIFrameElement) + await settle() + + // 行为断言:真实桥收到了 ready → 以 'hello' 请求 token → 下发 → 帧就位 + expect(fetchModuleToken).toHaveBeenCalledTimes(1) + expect(fetchModuleToken).toHaveBeenCalledWith('hello') + expect(wrapper.find('iframe').classes()).not.toContain('shell-frame-hidden') + expect(wrapper.text()).not.toContain('模块加载失败') + expect(wrapper.text()).not.toContain('模块入口配置无效') + + wrapper.unmount() + }) + + it('retry 强制 iframe 重挂(key 变化 → DOM 替换)后对新帧重新挂桥', async () => { + vi.mocked(fetchModuleToken) + .mockRejectedValueOnce(REJECTED) + .mockResolvedValue(TOKEN) + vi.mocked(fetchMe).mockResolvedValue({ + authenticated: true, + user: { id: 'u1', name: '黄一', issuer: 'unself', sub: 'u1' }, + }) + vi.mocked(fetchEnabledModules).mockResolvedValue([MODULE]) + + const wrapper = mount(App) + await settle() + + const before = wrapper.find('iframe').element as HTMLIFrameElement + expect(before).toBeTruthy() + + // 首次 ready → 领 token 失败(401)→ 失败卡 + dispatchReady(before) + await settle() + expect(fetchModuleToken).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('模块加载失败') + const retry = wrapper.find('button.u-error-retry') + expect(retry.text()).toBe('重新加载') + + // retry:frameReload 自增 → iframe 按 key 重挂(DOM 元素被替换) + await retry.trigger('click') + await settle() + + const after = wrapper.find('iframe').element as HTMLIFrameElement + expect(after).not.toBe(before) + expect(after.getAttribute('src')).toBe('/m/hello/') + + // 新 iframe 重新发 ready → 真实桥对新帧再次取 token → 就位 + dispatchReady(after) + await settle() + expect(fetchModuleToken).toHaveBeenCalledTimes(2) + expect(fetchModuleToken).toHaveBeenNthCalledWith(2, 'hello') + expect(wrapper.find('iframe').classes()).not.toContain('shell-frame-hidden') + expect(wrapper.text()).not.toContain('模块加载失败') + + wrapper.unmount() + }) +}) diff --git a/apps/shell/src/App.vue b/apps/shell/src/App.vue index 1236118..f018576 100644 --- a/apps/shell/src/App.vue +++ b/apps/shell/src/App.vue @@ -1,6 +1,6 @@