Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions deploy/cloudflare/src/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,44 @@ export async function ensureDatabases(
return { core: ids.core!, modules: ids.modules! };
}

/**
* 解析 `wrangler r2 bucket list` 输出,返回桶名列表(去重)。
*
* wrangler v4 的 bucket list 无 --json,用 formatLabelledValues 输出文本,每桶形如
* ```
* name: unself-storage
* creation_date: Wed, 01 Jan 2025 00:00:00 GMT
* ```
* (label 对齐空格不定;桶间空行分隔)。兼容旧 JSON 数组输出;空行与 creation_date 行忽略。
*/
export function parseR2BucketList(stdout: string): string[] {
const trimmed = stdout.trim();
if (!trimmed) return [];
// 剥离 ANSI:wrangler 在 TTY / FORCE_COLOR 下用 chalk 为 label 与值着色(\x1b[37mname:\x1b[39m …)
const plain = trimmed.replace(/\x1b\[[0-9;]*m/g, '');
const names: string[] = [];
// 旧形态:JSON 数组([{"name":"…"}])
if (plain.startsWith('[')) {
try {
const parsed: unknown = JSON.parse(plain);
if (Array.isArray(parsed)) {
for (const row of parsed) {
const name = (row as Record<string, unknown>).name;
if (typeof name === 'string' && name) names.push(name);
}
}
} catch {
// 落到文本解析
}
}
// 文本格式:"name: <桶名>" 行(对齐空格数量不定;容 CRLF 的 \r)。
// 首尾 \s* 容忍前导空格与行尾 \r;creation_date 行与空行天然不匹配。
for (const m of plain.matchAll(/^\s*name:\s+(\S+)\s*$/gm)) {
names.push(m[1]!);
}
return [...new Set(names)];
}

/** R2 桶查漏(步骤⑥):provider=r2 时确保桶存在。 */
export async function ensureR2Bucket(
wrangler: Wrangler,
Expand All @@ -92,21 +130,7 @@ export async function ensureR2Bucket(
const res = await wrangler.tryRun(['r2', 'bucket', 'list']);
if (res.ok) {
try {
// wrangler v4 的 bucket list 无 --json:成功输出形如 "name: <桶名>";JSON 旧形态兼容
const textNames = [...res.stdout.matchAll(/^name:\s+(\S+)$/gm)].map((m) => m[1]!);
const jsonNames = (() => {
const trimmed = res.stdout.trim();
if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) return [] as string[];
try {
const parsed: unknown = JSON.parse(trimmed);
return Array.isArray(parsed)
? parsed.map((b) => (b as Record<string, unknown>).name as string).filter(Boolean)
: [];
} catch {
return [];
}
})();
const names = [...new Set([...textNames, ...jsonNames])];
const names = parseR2BucketList(res.stdout);
if (names.includes(bucket)) {
log(`R2 桶 ${bucket} 已存在`);
return 'exists';
Expand Down
42 changes: 32 additions & 10 deletions deploy/cloudflare/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,36 @@ ON CONFLICT(id) DO UPDATE SET
manifest_json = excluded.manifest_json`;
}

/** 最小 manifest.yaml 读取:顶层 key: value 行(id/route/version/icon 等标量)。 */
function manifestScalarFields(text: string): Record<string, string> {
const out: Record<string, string> = {};
/**
* 最小 manifest.yaml 读取(§5.5 快照所需子集,不引入完整 YAML 解析):
* - 顶层标量 `key: value` 行(id/route/version/icon/description 等);
* - 缩进 list 项 ` - item`(requires/capabilities),归属最近一个「key: 空值」的顶层 key(行尾注释剥除)。
*/
function manifestTopLevelFields(
text: string,
): { scalars: Record<string, string>; lists: Record<string, string[]> } {
const scalars: Record<string, string> = {};
const lists: Record<string, string[]> = {};
let currentListKey: string | null = null;
for (const line of text.split('\n')) {
const m = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*(?:#.*)?$/.exec(line);
if (m && m[2] !== undefined && m[2] !== '') {
out[m[1]!] = m[2]!;
const scalar = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*(?:#.*)?$/.exec(line);
if (scalar) {
const value = scalar[2]!;
if (value !== '') {
scalars[scalar[1]!] = value;
currentListKey = null;
} else {
// 顶层 key 空值 → 后续缩进 list 项归属该 key
currentListKey = scalar[1]!;
}
continue;
}
const item = /^\s*-\s+(.+?)\s*(?:#.*)?$/.exec(line);
if (item && currentListKey) {
(lists[currentListKey] ??= []).push(item[1]!);
}
}
return out;
return { scalars, lists };
}

/** manifest.yaml 文本 → ModuleManifest(§5.5 快照 + §5.3 entry 重写为实例 URL)。 */
Expand All @@ -36,17 +56,19 @@ export function buildManifestSnapshot(input: {
/** 实例 base URL(https://domain 或 workers.dev);空字符串 = workers.dev 占位。 */
baseUrl: string;
}): ModuleManifest {
const fields = manifestScalarFields(input.manifestText);
const { scalars: fields, lists } = manifestTopLevelFields(input.manifestText);
const host = input.baseUrl || 'https://unself-module-placeholder.workers.dev';
const candidate = {
id: fields.id ?? input.moduleId,
route: fields.route ?? `/m/${input.moduleId}`,
// 部署后模块实际从实例根相对路径装载(同域路径制 §5.3)
entry: `${host}/m/${input.moduleId}/`,
runtime: 'worker' as const,
requires: ['identity' as const],
capabilities: ['demo'],
// 契约 requires min(1):清单缺失时回退 identity;capabilities 缺失为空(不再硬编码 'demo')
requires: (lists.requires?.length ? lists.requires : ['identity']) as Array<'identity'>,
capabilities: lists.capabilities ?? [],
version: fields.version ?? '0.0.0',
...(fields.description ? { description: fields.description } : {}),
...(fields.icon ? { icon: fields.icon } : {}),
};
return ModuleManifestSchema.parse(candidate);
Expand Down
39 changes: 38 additions & 1 deletion deploy/cloudflare/test/provision-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { describe, expect, it } from 'vitest';
import { ensureDatabases, parseD1List } from '../src/provision';
import { ensureDatabases, parseD1List, parseR2BucketList } from '../src/provision';
import { registryCommands, registryDisableCommand, registryUpsertCommand, sqlString } from '../src/registry';
import { buildManifestSnapshot } from '../src/registry';
import { ModuleManifestSchema } from '@unself/contracts';
Expand All @@ -27,6 +27,43 @@ describe('parseD1List', () => {
});
});

describe('parseR2BucketList(真实文本 · issue #60)', () => {
// wrangler v4 formatLabelledValues:valuesAlignment=14、spacer=2 → name: 后 11 空格、creation_date: 后 2 空格
const block = (name: string) =>
`name:${' '.repeat(11)}${name}\n` +
`creation_date:${' '.repeat(2)}Wed, 01 Jan 2025 00:00:00 GMT`;
const listOf = (...names: string[]) => `${names.map(block).join('\n\n')}\n`;

it('多桶真实文本 → [a,b](对齐空格、creation_date 行、桶间空行不误判)', () => {
expect(parseR2BucketList(listOf('a', 'b'))).toEqual(['a', 'b']);
});

it('空输出 / 纯空白 → []', () => {
expect(parseR2BucketList('')).toEqual([]);
expect(parseR2BucketList('\n\n')).toEqual([]);
});

it('旧 JSON 数组 → 名称', () => {
expect(parseR2BucketList('[{"name":"a"},{"name":"b"}]')).toEqual(['a', 'b']);
});

it('重复名去重(文本与 JSON 混合重复)', () => {
expect(parseR2BucketList(listOf('a', 'b', 'a'))).toEqual(['a', 'b']);
expect(parseR2BucketList('[{"name":"a"},{"name":"a"}]')).toEqual(['a']);
});

it('含 ANSI 着色(TTY/FORCE_COLOR)仍可解析', () => {
const colored =
`\x1b[37mname:\x1b[39m${' '.repeat(11)}\x1b[90ma\x1b[39m\n` +
`\x1b[37mcreation_date:\x1b[39m \x1b[90mWed, 01 Jan 2025 00:00:00 GMT\x1b[39m\n`;
expect(parseR2BucketList(colored)).toEqual(['a']);
});

it('CRLF(\r\n)与尾行空白不破坏解析', () => {
expect(parseR2BucketList(listOf('a').replace(/\n/g, '\r\n'))).toEqual(['a']);
});
});

describe('ensureDatabases(①幂等)', () => {
it('两库都不存在 → 创建两次;都存在 → 零创建', async () => {
const uuid = 'a1b2c3d4-0000-0000-0000-000000000001';
Expand Down
60 changes: 60 additions & 0 deletions deploy/cloudflare/test/registry-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { describe, expect, it } from 'vitest';
import { buildManifestSnapshot } from '../src/registry';

/** 非默认值 list 形式 manifest:requires/capabilities 必须是 YAML list、capabilities 非默认 'demo'。 */
const DEMO_MANIFEST = `# SPDX-License-Identifier: AGPL-3.0-only
id: demo-mod
route: /m/demo-mod
version: 1.2.3
icon: chart-bar
description: 计数器演示
requires:
- identity
capabilities:
- counter
- notify
`;

describe('buildManifestSnapshot(§5.5 快照 · list 形式 YAML)', () => {
it('capabilities/requires 来自清单,全字段(含 description/icon/version)与清单一致', () => {
const manifest = buildManifestSnapshot({
manifestText: DEMO_MANIFEST,
moduleId: 'demo-mod',
baseUrl: 'https://team.example.com',
});
expect(manifest.capabilities).toEqual(['counter', 'notify']);
expect(manifest.requires).toEqual(['identity']);
expect(manifest.id).toBe('demo-mod');
expect(manifest.route).toBe('/m/demo-mod');
expect(manifest.version).toBe('1.2.3');
expect(manifest.icon).toBe('chart-bar');
expect(manifest.description).toBe('计数器演示');
// entry 仍重写为实例 URL(§5.3 同域路径制)
expect(manifest.entry).toBe('https://team.example.com/m/demo-mod/');
});

it('缺失时回退:requires 默认 identity(契约 min(1))、capabilities 默认空', () => {
const manifest = buildManifestSnapshot({
manifestText: 'id: bare\nroute: /m/bare\nversion: 0.1.0\n',
moduleId: 'bare',
baseUrl: 'https://team.example.com',
});
expect(manifest.requires).toEqual(['identity']);
expect(manifest.capabilities).toEqual([]);
});

it('list 项剥掉行尾注释、缩进无关', () => {
const manifest = buildManifestSnapshot({
manifestText: `id: demo-mod # 演示模块
capabilities:
- counter # 加计数
- notify
`,
moduleId: 'demo-mod',
baseUrl: 'https://x.example',
});
expect(manifest.id).toBe('demo-mod');
expect(manifest.capabilities).toEqual(['counter', 'notify']);
});
});
13 changes: 12 additions & 1 deletion deploy/cloudflare/test/steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,18 @@ function makeFakeWrangler(options?: { existingD1?: string[]; existingBuckets?: s
const sub = rest[0];
if (sub === 'bucket') {
const op = rest[1];
if (op === 'list') return json([...state.buckets].map((name) => ({ name })));
if (op === 'list') {
// wrangler v4 真机格式:formatLabelledValues 输出(label 对齐、桶间空行、无 --json)
return okOut(
[...state.buckets]
.map(
(name) =>
`name:${' '.repeat(11)}${name}\n` +
`creation_date:${' '.repeat(2)}Wed, 01 Jan 2025 00:00:00 GMT`,
)
.join('\n\n'),
);
}
if (op === 'create') {
const name = rest[2]!;
if (state.buckets.has(name)) return fail(`bucket exists: ${name}`);
Expand Down
32 changes: 26 additions & 6 deletions modules/hello/test/hello.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,29 @@ describe('module-hello(#13 垂直切片载体)', () => {
).toBe('1');
});

it('身份行数据源:claims 姓名/邮箱进入 token(验收 2 的数据面)', async () => {
it('身份行数据源:claims 姓名/邮箱真值只能来自服务端验签通过的 token(签名载荷不可篡改)', async () => {
const env = await envFor();
const token = await makeToken();
const { decodeJwtPayload } = await import('@unself/module-sdk');
const claims = decodeJwtPayload(token) as { name?: string; email?: string };
expect(claims.name).toBe('黄一');
expect(claims.email).toBe('huang@example.com');
const token = await makeToken(); // 真实 ES256 签名,payload 含 name=黄一 / email=huang@example.com

// 正签 token:服务端真实验签(jose + JWKS)通过——验签接受的 claims 即签名载荷。
const ok = await app.request('https://m.example/api/count', {
headers: { authorization: `Bearer ${token}` },
}, env);
expect(ok.status).toBe(200);

// 同一签名换 payload(姓名/邮箱被改写,其余字段不变):签名不再匹配,服务端必须拒绝。
// 证明身份行可展示的 claims(姓名/邮箱)不能由客户端任意注入,只能来自签发方签名过的 token。
const [header, payloadB64, signature] = token.split('.');
const payload = JSON.parse(
Buffer.from(payloadB64!, 'base64url').toString('utf8'),
) as Record<string, unknown>;
const forged = `${header}.${Buffer.from(
JSON.stringify({ ...payload, name: '黑客', email: 'evil@example.com' }),
).toString('base64url')}.${signature}`;
const forgedRes = await app.request('https://m.example/api/count', {
headers: { authorization: `Bearer ${forged}` },
}, env);
expect(forgedRes.status).toBe(401);
});

it('GET /life/export 返回契约形状 ExportBundle', async () => {
Expand Down Expand Up @@ -151,6 +167,10 @@ describe('module-hello(#13 垂直切片载体)', () => {
expect(html).toContain('+1');
expect(html).toContain('createModuleSDK');
expect(html).toContain('viewport');
// 身份行数据源接线:token claims 经 decodeContext 填入 who/email(与上一条验签用例呼应)。
expect(html).toContain('sdk.decodeContext');
expect(html).toContain('claims.name ?? claims.sub');
expect(html).toContain('claims.email');
});

it('页面内 fetch/import 不用根相对路径(部署挂载在 /m/<id>/ 子路径,#14 装配前提)', async () => {
Expand Down
31 changes: 25 additions & 6 deletions packages/module-sdk/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,33 @@ describe('outbound messages(ready / navigate / notify / theme)', () => {
expect(() => sdk.theme('sepia')).toThrow(/invalid theme mode/);
});

it('outbound messages are no-ops without a window', () => {
const sdk = createModuleSDK({ moduleId: 'mod-a' });
expect(() => {
it('outbound guards are runtime-conditional: no window → parent untouched; window → real postMessage', () => {
// 无 window 环境:globalThis.parent 换成带 spy 的 getter。
// 若守卫失效,出站调用要么直接命中 spy,要么对 undefined 解包抛错——两者都判失败。
const parentGetter = vi.fn(() => undefined);
Object.defineProperty(globalThis, 'parent', { configurable: true, get: parentGetter });
try {
const sdk = createModuleSDK({ moduleId: 'mod-a', coreOrigin: CORE_ORIGIN });
expect(() => {
sdk.ready();
sdk.navigate('/m/a');
sdk.notify('x');
sdk.theme('dark');
}).not.toThrow();
expect(parentGetter).not.toHaveBeenCalled();

// 同一 SDK:装上浏览器式 window 后必须真的走 postMessage 通道(守卫是运行时条件,不是 no-op 桩)。
const fake = installFakeWindow();
sdk.ready();
expect(fake.postMessage).toHaveBeenCalledWith({ type: 'ready' }, CORE_ORIGIN);
sdk.navigate('/m/a');
sdk.notify('x');
sdk.theme('dark');
}).not.toThrow();
expect(fake.postMessage).toHaveBeenCalledWith(
{ type: 'navigate', path: '/m/a' },
CORE_ORIGIN,
);
} finally {
Reflect.deleteProperty(globalThis, 'parent');
}
});
});

Expand Down
Loading
Loading