@qomicex/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ // manifest.json 校验(手写极简 schema,等价于 zod 子集,零依赖)。
2
+ // 字段蓝本:src/plugins/types.ts + store 上传校验(checkQplugin)。
3
+ import { PERMISSION_CATALOG } from "./permissions.js";
4
+ export const ID_RE = /^(?!.*\.\.)[a-z0-9]+([.-][a-z0-9]+)*$/;
5
+ export const SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
6
+ export const LAYERS = ['l0', 'l1', 'l2', 'l3'];
7
+ export function checkId(id) {
8
+ const errs = [];
9
+ if (typeof id !== 'string')
10
+ return ['id 必须是字符串'];
11
+ if (id.length < 3 || id.length > 128)
12
+ errs.push('id 长度必须在 3-128 之间');
13
+ if (!ID_RE.test(id))
14
+ errs.push('id 必须是反向域名格式(小写字母/数字/点/连字符,禁止连续双点,如 com.example.demo)');
15
+ if (!id.includes('.'))
16
+ errs.push('id 建议使用反向域名格式(含至少一个点),当前形如 hello-plugin');
17
+ return errs;
18
+ }
19
+ export function validateManifest(raw) {
20
+ const issues = [];
21
+ const add = (severity, path, message) => issues.push({ severity, path, message });
22
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
23
+ return { ok: false, issues: [{ severity: 'error', path: 'manifest', message: 'manifest 必须是 JSON 对象' }] };
24
+ }
25
+ const m = raw;
26
+ for (const err of checkId(m.id))
27
+ add(err.startsWith('id 建议') ? 'warning' : 'error', 'id', err);
28
+ if (typeof m.name !== 'string' || !m.name.trim())
29
+ add('error', 'name', '缺少必填字段 name');
30
+ if (typeof m.version !== 'string') {
31
+ add('error', 'version', '缺少必填字段 version');
32
+ }
33
+ else if (!SEMVER_RE.test(m.version)) {
34
+ add('error', 'version', `version 不是合法 semver: ${m.version}`);
35
+ }
36
+ if (typeof m.minLauncherVersion !== 'string')
37
+ add('error', 'minLauncherVersion', '缺少必填字段 minLauncherVersion');
38
+ if (!Array.isArray(m.layers) || m.layers.length === 0) {
39
+ add('error', 'layers', 'layers 必须是数组且至少一项');
40
+ }
41
+ else {
42
+ for (const l of m.layers) {
43
+ if (!LAYERS.includes(l))
44
+ add('error', 'layers', `未知 layer: ${String(l)}(可用 ${LAYERS.join('/')})`);
45
+ }
46
+ const hasFrontend = !!m.entry?.frontend;
47
+ if (!m.layers.includes('l2') && !m.layers.includes('l3') && hasFrontend) {
48
+ add('warning', 'layers', '声明了 frontend 入口但 layers 无 l2/l3,将无法渲染 UI');
49
+ }
50
+ if (!m.layers.includes('l2') && hasFrontend) {
51
+ add('warning', 'layers', 'UI 插件建议声明 l2(iframe 沙箱)');
52
+ }
53
+ }
54
+ if (!Array.isArray(m.permissions)) {
55
+ add('error', 'permissions', 'permissions 必须是数组');
56
+ }
57
+ else {
58
+ for (const p of m.permissions) {
59
+ if (!PERMISSION_CATALOG[p]) {
60
+ add('warning', 'permissions', `未知权限: ${String(p)}(启动器可能不认识)`);
61
+ }
62
+ }
63
+ }
64
+ const entry = m.entry;
65
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
66
+ add('error', 'entry', '缺少必填字段 entry(至少 frontend/backend/theme 之一)');
67
+ }
68
+ else {
69
+ const e = entry;
70
+ if (!e.frontend && !e.backend && !e.theme) {
71
+ add('error', 'entry', 'entry 至少需要 frontend/backend/theme 之一');
72
+ }
73
+ if (typeof e.frontend === 'string' && !e.frontend.endsWith('.html')) {
74
+ add('warning', 'entry.frontend', 'frontend 应指向 .html(如 dist/index.html)');
75
+ }
76
+ }
77
+ if (m.dependencies !== undefined && !Array.isArray(m.dependencies))
78
+ add('error', 'dependencies', 'dependencies 必须是数组');
79
+ if (m.render !== undefined && m.render !== 'inline' && m.render !== 'iframe') {
80
+ add('warning', 'render', "render 仅支持 'inline' / 'iframe'");
81
+ }
82
+ if (m.contributes !== undefined) {
83
+ const c = m.contributes;
84
+ if (c.menuItems !== undefined && !Array.isArray(c.menuItems))
85
+ add('error', 'contributes.menuItems', 'menuItems 必须是数组');
86
+ if (c.overlay !== undefined) {
87
+ const o = c.overlay;
88
+ if (typeof o.file !== 'string')
89
+ add('error', 'contributes.overlay.file', 'overlay.file 必须指定 .html 文件');
90
+ }
91
+ }
92
+ return { ok: issues.every((i) => i.severity !== 'error'), issues };
93
+ }
@@ -0,0 +1,51 @@
1
+ export const PERMISSION_CATALOG = {
2
+ 'instance:read': { id: 'instance:read', risk: 'normal' },
3
+ 'instance:write': { id: 'instance:write', risk: 'warning' },
4
+ 'account:read': { id: 'account:read', risk: 'normal' },
5
+ 'license:read': { id: 'license:read', risk: 'normal' },
6
+ 'config:read': { id: 'config:read', risk: 'normal' },
7
+ 'config:write': { id: 'config:write', risk: 'warning' },
8
+ 'cache:access': { id: 'cache:access', risk: 'normal' },
9
+ 'endpoint:discover': { id: 'endpoint:discover', risk: 'normal' },
10
+ 'page:list': { id: 'page:list', risk: 'normal' },
11
+ 'network:fetch': { id: 'network:fetch', risk: 'warning' },
12
+ 'network:cors_proxy': { id: 'network:cors_proxy', risk: 'warning' },
13
+ 'network:websocket': { id: 'network:websocket', risk: 'warning' },
14
+ 'network:proxy': { id: 'network:proxy', risk: 'warning' },
15
+ 'ui:inject_sidebar': { id: 'ui:inject_sidebar', risk: 'normal' },
16
+ 'ui:inject_settings': { id: 'ui:inject_settings', risk: 'normal' },
17
+ 'ui:picture_in_picture': { id: 'ui:picture_in_picture', risk: 'warning' },
18
+ 'ui:sub_window': { id: 'ui:sub_window', risk: 'warning' },
19
+ 'ui:context_menu': { id: 'ui:context_menu', risk: 'normal' },
20
+ 'ui:toast': { id: 'ui:toast', risk: 'normal' },
21
+ 'ui:navigate': { id: 'ui:navigate', risk: 'normal' },
22
+ 'system:info': { id: 'system:info', risk: 'normal' },
23
+ 'system:notification': { id: 'system:notification', risk: 'normal' },
24
+ 'clipboard:read': { id: 'clipboard:read', risk: 'warning' },
25
+ 'clipboard:write': { id: 'clipboard:write', risk: 'warning' },
26
+ 'wasm:execute': { id: 'wasm:execute', risk: 'warning' },
27
+ 'plugin:install': { id: 'plugin:install', risk: 'danger' },
28
+ 'plugin:list': { id: 'plugin:list', risk: 'normal' },
29
+ 'resource:read': { id: 'resource:read', risk: 'normal' },
30
+ 'resource:write': { id: 'resource:write', risk: 'warning' },
31
+ 'java:manage': { id: 'java:manage', risk: 'warning' },
32
+ 'download:manage': { id: 'download:manage', risk: 'warning' },
33
+ 'game:process': { id: 'game:process', risk: 'warning' },
34
+ 'game:log': { id: 'game:log', risk: 'normal' },
35
+ 'connector:host': { id: 'connector:host', risk: 'warning' },
36
+ 'connector:scan': { id: 'connector:scan', risk: 'normal' },
37
+ 'shell:execute': { id: 'shell:execute', risk: 'danger' },
38
+ 'filesystem:read': { id: 'filesystem:read', risk: 'warning' },
39
+ 'filesystem:write': { id: 'filesystem:write', risk: 'danger' },
40
+ };
41
+ // 桥方法 → 所需权限(与 src/plugins/sandbox.ts METHOD_PERMISSIONS 保持一致,供权限最小化扫描)
42
+ export const METHOD_PERMISSIONS = {
43
+ getSettings: 'config:read', setSettings: 'config:write', setCache: 'cache:access', getCache: 'cache:access', callBackend: 'network:fetch', uploadPlugin: 'plugin:install', proxyFetch: 'network:cors_proxy', proxyFetchStream: 'network:cors_proxy',
44
+ registerMethod: 'config:write', callPlugin: 'network:fetch', callWasm: 'wasm:execute', listWasmPlugins: 'wasm:execute',
45
+ readText: 'filesystem:read', readBytes: 'filesystem:read', writeText: 'filesystem:write', writeBytes: 'filesystem:write', deleteFile: 'filesystem:write', execCommand: 'shell:execute',
46
+ navigate: 'config:read', showToast: 'ui:toast', getSystemInfo: 'system:info', openUrl: 'system:notification', listPlugins: 'plugin:list',
47
+ 'overlay.create': 'ui:sub_window', 'overlay.show': 'ui:sub_window', 'overlay.hide': 'ui:sub_window',
48
+ 'overlay.destroy': 'ui:sub_window', 'overlay.setHtml': 'ui:sub_window', 'overlay.setPosition': 'ui:sub_window',
49
+ 'download.addTask': 'download:manage', 'download.progress': 'download:manage', 'download.cancel': 'download:manage', 'download.list': 'download:manage', 'download.registerInstall': 'instance:write',
50
+ 'modpack.install': 'instance:write',
51
+ };
@@ -0,0 +1,35 @@
1
+ // 插件项目本地操作:manifest 读取 / dist 收集。
2
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ export function readManifestFile(file) {
5
+ const text = readFileSync(file, 'utf8');
6
+ return JSON.parse(text);
7
+ }
8
+ /** 收集目录下全部文件为 zip 条目(prefix 为包内前缀,路径用 /)。 */
9
+ export function collectDir(root, prefix) {
10
+ const out = [];
11
+ const walk = (dir) => {
12
+ let entries;
13
+ try {
14
+ entries = readdirSync(dir);
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ for (const name of entries) {
20
+ const p = join(dir, name);
21
+ const st = statSync(p);
22
+ if (st.isDirectory())
23
+ walk(p);
24
+ else
25
+ out.push({ name: `${prefix}${relative(root, p).split('\\').join('/')}`, data: readFileSync(p) });
26
+ }
27
+ };
28
+ walk(root);
29
+ return out;
30
+ }
31
+ export function assertProjectRoot(cwd) {
32
+ if (cwd === '' || cwd === undefined)
33
+ return '';
34
+ return cwd;
35
+ }
@@ -0,0 +1,104 @@
1
+ // verify 的静态启发式扫描:权限最小化 + 主线程长循环告警。
2
+ // 均为轻量文本/正则,不做真 AST(提案允许"简单启发式即可")。
3
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { join, relative } from 'node:path';
5
+ import { METHOD_PERMISSIONS } from "./permissions.js";
6
+ const SRC_EXTS = /\.(ts|tsx|js|jsx|mjs|cjs|html)$/;
7
+ /** 递归收集插件源码文件(跳过 node_modules/dist/release/.staging)。 */
8
+ export function collectSourceFiles(root, maxFiles = 400) {
9
+ const out = [];
10
+ const walk = (dir) => {
11
+ if (out.length >= maxFiles)
12
+ return;
13
+ let entries;
14
+ try {
15
+ entries = readdirSync(dir);
16
+ }
17
+ catch {
18
+ return;
19
+ }
20
+ for (const name of entries) {
21
+ if (name === 'node_modules' || name === 'dist' || name === 'release' || name.startsWith('.staging'))
22
+ continue;
23
+ const p = join(dir, name);
24
+ try {
25
+ const st = statSync(p);
26
+ if (st.isDirectory())
27
+ walk(p);
28
+ else if (st.size < 1_000_000 && SRC_EXTS.test(name))
29
+ out.push(p);
30
+ }
31
+ catch {
32
+ /* ignore unreadable */
33
+ }
34
+ }
35
+ };
36
+ walk(root);
37
+ return out;
38
+ }
39
+ /** 提取源码里用到的桥方法名(.call('x') 与 __PLUGIN_API__.x 两种形态)。 */
40
+ function usedMethods(content) {
41
+ const found = new Set();
42
+ const callRe = /\.call\(\s*['"]([a-zA-Z0-9_.]+)['"]/g;
43
+ let m;
44
+ while ((m = callRe.exec(content)) !== null)
45
+ found.add(m[1]);
46
+ const directRe = /__PLUGIN_API__\.([a-zA-Z0-9_]+)\(/g;
47
+ while ((m = directRe.exec(content)) !== null) {
48
+ const name = m[1];
49
+ if (name in METHOD_PERMISSIONS)
50
+ found.add(name);
51
+ }
52
+ const dottedRe = /__PLUGIN_API__\.(download|overlay|modpack)\.([a-zA-Z0-9_]+)\(/g;
53
+ while ((m = dottedRe.exec(content)) !== null)
54
+ found.add(`${m[1]}.${m[2]}`);
55
+ return found;
56
+ }
57
+ export function checkPermissions(root, declared) {
58
+ const findings = [];
59
+ const used = new Set();
60
+ for (const file of collectSourceFiles(root)) {
61
+ try {
62
+ for (const method of usedMethods(readFileSync(file, 'utf8')))
63
+ used.add(method);
64
+ }
65
+ catch {
66
+ /* skip */
67
+ }
68
+ }
69
+ const required = new Set();
70
+ for (const method of used) {
71
+ const perm = METHOD_PERMISSIONS[method];
72
+ if (perm)
73
+ required.add(perm);
74
+ }
75
+ const missing = [...required].filter((p) => !declared.includes(p));
76
+ const unused = declared.filter((p) => !required.has(p));
77
+ for (const p of missing) {
78
+ findings.push({ severity: 'error', message: `源码调用了桥方法,但未声明所需权限 ${p}(运行时会被启动器拒绝)` });
79
+ }
80
+ for (const p of unused) {
81
+ findings.push({ severity: 'error', message: `声明了权限 ${p} 但源码未使用(最小权限原则,建议移除)` });
82
+ }
83
+ return { findings, unused, missing };
84
+ }
85
+ /** 长循环 / 无界定时器启发式:命中即告警(不阻断),提示放到 Worker/WASM/后端。 */
86
+ export function checkLongLoops(root) {
87
+ const findings = [];
88
+ const patterns = [
89
+ [/while\s*\(\s*(?:true|1)\s*\)/, '检测到 while(true) 同步死循环,会冻结整个启动器主线程(即使在内联/iframe 内)'],
90
+ [/for\s*\(\s*;\s*;\s*\)/, '检测到 for(;;) 无退出条件同步死循环'],
91
+ [/setInterval\s*\(/, '检测到 setInterval 高频轮询;请确保有 clearInterval 且间隔合理(默认建议 ≥500ms)'],
92
+ ];
93
+ for (const file of collectSourceFiles(root)) {
94
+ const content = readFileSync(file, 'utf8');
95
+ for (const [re, msg] of patterns) {
96
+ const m = re.exec(content);
97
+ if (m) {
98
+ const lineNo = content.slice(0, m.index).split('\n').length;
99
+ findings.push({ severity: 'warning', message: msg, file: `${relative(root, file)}:${lineNo}` });
100
+ }
101
+ }
102
+ }
103
+ return findings;
104
+ }
@@ -0,0 +1,171 @@
1
+ // 插件包 Ed25519 签名(ADR-050 三级信任链)。
2
+ // 规范化与 store `src/lib/signature.ts` / launcher `plugin_signature.rs` 字节级一致:
3
+ // signedHash = SHA-256( canonicalJson({ manifest: sha256Hex(manifest.json 原始字节),
4
+ // files: [{path, sha256}...按 path 排序] }) )
5
+ // signature = Ed25519(私钥, 同载荷 UTF-8 字节)
6
+ // 用 Node WebCrypto(node >= 20),零第三方依赖。
7
+ export const SIGNATURE_FILE = 'signature.json';
8
+ export const CERT_FILE = 'signature.cert.json';
9
+ export const ALG = 'Ed25519';
10
+ /** 商店签名根公钥(raw base64),与 launcher plugin_signature.rs ROOT_PUBLIC_KEY_B64 一致 */
11
+ export const STORE_ROOT_PUBLIC_KEY_B64 = 'hNoXOazEkdTRoxBra8ABlCWXhy16S7rM5ZmEDa+GmnE=';
12
+ /** Ed25519 PKCS#8 DER 固定前缀(前 16 字节),后接 32 字节 seed */
13
+ const ED25519_PKCS8_PREFIX = Uint8Array.from([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]);
14
+ export function bytesToBase64(bytes) {
15
+ return Buffer.from(bytes).toString('base64');
16
+ }
17
+ export function base64ToBytes(b64) {
18
+ try {
19
+ return new Uint8Array(Buffer.from(b64, 'base64'));
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** 递归按键排序、无空白 JSON(键序对哈希无影响,保持确定性) */
26
+ export function canonicalJson(value) {
27
+ if (value === null || typeof value !== 'object') {
28
+ if (typeof value === 'string')
29
+ return JSON.stringify(value);
30
+ return String(value);
31
+ }
32
+ if (Array.isArray(value))
33
+ return `[${value.map(canonicalJson).join(',')}]`;
34
+ const obj = value;
35
+ const keys = Object.keys(obj).sort();
36
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(',')}}`;
37
+ }
38
+ function toHex(bytes) {
39
+ return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
40
+ }
41
+ export async function sha256HexOf(bytes) {
42
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
43
+ return toHex(new Uint8Array(digest));
44
+ }
45
+ /** 签名待签载荷:{ manifest: sha256Hex(manifest.json 原始字节), files: 按 path 排序 } */
46
+ export async function signedPayloadBytes(entries) {
47
+ const manifestBytes = entries['manifest.json'];
48
+ if (!manifestBytes)
49
+ throw new Error('包内缺少 manifest.json,无法签名');
50
+ const files = [];
51
+ for (const name of Object.keys(entries)) {
52
+ if (name === SIGNATURE_FILE || name === CERT_FILE)
53
+ continue;
54
+ files.push({ path: name, sha256: await sha256HexOf(entries[name]) });
55
+ }
56
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
57
+ return new TextEncoder().encode(canonicalJson({ manifest: await sha256HexOf(manifestBytes), files }));
58
+ }
59
+ /**
60
+ * 解析私钥 → PKCS#8 DER。支持:PKCS#8 PEM、PKCS#8 DER base64、raw 32 字节 seed base64。
61
+ */
62
+ export function parsePrivateKey(input) {
63
+ let pem = input.trim();
64
+ if (pem.includes('-----BEGIN')) {
65
+ const body = pem.replace(/-----BEGIN[^-]+-----/, '').replace(/-----END[^-]+-----/, '').replace(/\s+/g, '');
66
+ const der = base64ToBytes(body);
67
+ if (der)
68
+ return der;
69
+ }
70
+ // 可能是 DER base64(45-48 字节,含前缀)或 raw seed base64(32 字节)
71
+ const maybe = base64ToBytes(pem);
72
+ if (!maybe || maybe.length === 0)
73
+ throw new Error('无法解析私钥:只支持 PKCS#8 PEM / DER base64 / raw 32 字节 seed base64');
74
+ if (maybe.length === 32) {
75
+ const der = new Uint8Array(48);
76
+ der.set(ED25519_PKCS8_PREFIX, 0);
77
+ der.set(maybe, 16);
78
+ return der;
79
+ }
80
+ if (maybe.length === 45 || maybe.length === 48)
81
+ return maybe;
82
+ throw new Error(`私钥长度异常(${maybe.length} 字节),应为 32(raw seed)或 45/48(PKCS#8 DER)`);
83
+ }
84
+ async function signingKey(der) {
85
+ return crypto.subtle.importKey('pkcs8', der, { name: ALG }, true, ['sign']);
86
+ }
87
+ export async function derivePublicKey(privDer) {
88
+ // WebCrypto 不支持从私钥 exportKey('raw'),改用 node:crypto:PKCS#8 → 私钥对象 → 公钥 → SPKI 末 32 字节 raw。
89
+ const { createPrivateKey, createPublicKey } = await import('node:crypto');
90
+ const priv = createPrivateKey({ key: privDer, format: 'der', type: 'pkcs8' });
91
+ const pub = createPublicKey(priv);
92
+ const spki = pub.export({ format: 'der', type: 'spki' });
93
+ const raw = spki.subarray(spki.length - 32);
94
+ return bytesToBase64(new Uint8Array(raw));
95
+ }
96
+ async function signBytes(privDer, msg) {
97
+ const key = await signingKey(privDer);
98
+ const sig = await crypto.subtle.sign({ name: ALG }, key, msg);
99
+ return bytesToBase64(new Uint8Array(sig));
100
+ }
101
+ /**
102
+ * 对包内全部条目生成签名文件。
103
+ * @param certJson 商店根钥签发的开发者证书 JSON(POST /developer/keys 返回值),
104
+ * 无 cert 时仅生成 signature.json(pack 场景,publish 会走完整证书链)。
105
+ */
106
+ export async function signPackage(entries, privDer, keyId, certJson) {
107
+ const payload = await signedPayloadBytes(entries);
108
+ const signedHash = await sha256HexOf(payload);
109
+ const signature = await signBytes(privDer, payload);
110
+ const sigJson = { alg: ALG, signedHash, signerKeyId: keyId, signature };
111
+ const out = {};
112
+ out[SIGNATURE_FILE] = new TextEncoder().encode(JSON.stringify(sigJson));
113
+ if (certJson)
114
+ out[CERT_FILE] = new TextEncoder().encode(certJson);
115
+ return out;
116
+ }
117
+ async function verifyEd25519(pubKeyB64, msg, sigB64) {
118
+ const pubBytes = base64ToBytes(pubKeyB64);
119
+ const sigBytes = base64ToBytes(sigB64);
120
+ if (!pubBytes || !sigBytes || pubBytes.length !== 32 || sigBytes.length !== 64)
121
+ return false;
122
+ try {
123
+ const key = await crypto.subtle.importKey('raw', pubBytes, { name: ALG }, false, ['verify']);
124
+ return await crypto.subtle.verify({ name: ALG }, key, sigBytes, msg);
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ /**
131
+ * 验包(与 store verifyPackageSignature 同逻辑):缺少签名/证书、根钥验证失败、包体签名失败、哈希不符 → 拒绝。
132
+ */
133
+ export async function verifyPackageSignature(entries, rootPubKeyB64 = STORE_ROOT_PUBLIC_KEY_B64) {
134
+ const sigRaw = entries[SIGNATURE_FILE];
135
+ const certRaw = entries[CERT_FILE];
136
+ if (!sigRaw || !certRaw) {
137
+ return { ok: false, code: 'signature_required', reason: '缺少 signature.json / signature.cert.json(未签名)' };
138
+ }
139
+ let sig;
140
+ let cert;
141
+ try {
142
+ sig = JSON.parse(new TextDecoder().decode(sigRaw));
143
+ cert = JSON.parse(new TextDecoder().decode(certRaw));
144
+ }
145
+ catch {
146
+ return { ok: false, code: 'signature_invalid', reason: '签名文件解析失败' };
147
+ }
148
+ if (sig.alg !== ALG || cert.alg !== ALG)
149
+ return { ok: false, code: 'signature_invalid', reason: '不支持的签名算法' };
150
+ if (cert.keyId !== sig.signerKeyId)
151
+ return { ok: false, code: 'signature_invalid', reason: '证书与签名 signerKeyId 不一致' };
152
+ const certBody = canonicalJson({
153
+ alg: cert.alg,
154
+ keyId: cert.keyId,
155
+ developerId: cert.developerId,
156
+ developerName: cert.developerName,
157
+ publicKey: cert.publicKey,
158
+ issuedAt: cert.issuedAt,
159
+ });
160
+ const certSigOk = await verifyEd25519(rootPubKeyB64, new TextEncoder().encode(certBody), cert.signature);
161
+ if (!certSigOk)
162
+ return { ok: false, code: 'signature_invalid', reason: '签名证书无效(商店根钥验证失败)' };
163
+ const payload = await signedPayloadBytes(entries);
164
+ const sigOk = await verifyEd25519(cert.publicKey, payload, sig.signature);
165
+ if (!sigOk)
166
+ return { ok: false, code: 'signature_invalid', reason: '包体签名验证失败(开发者密钥不符或包被篡改)' };
167
+ const recomputed = await sha256HexOf(payload);
168
+ if (recomputed !== sig.signedHash)
169
+ return { ok: false, code: 'signature_invalid', reason: '签名哈希与包内容不匹配' };
170
+ return { ok: true, cert };
171
+ }
@@ -0,0 +1,100 @@
1
+ // 商店 API 客户端(只读参考契约,不改 store 接口)。
2
+ // API base: https://plugins.qomicex.top/api/v1(可用 QOMICEX_STORE_API 覆盖,本地测试指向 wrangler dev)
3
+ import { sleep } from "./io.js";
4
+ export const DEFAULT_API_BASE = 'https://plugins.qomicex.top/api/v1';
5
+ export class StoreApiError extends Error {
6
+ status;
7
+ code;
8
+ constructor(status, message, code) {
9
+ super(message);
10
+ this.status = status;
11
+ this.code = code;
12
+ }
13
+ }
14
+ async function request(base, path, init, token) {
15
+ const headers = new Headers(init?.headers);
16
+ if (token)
17
+ headers.set('Authorization', `Bearer ${token}`);
18
+ let res;
19
+ try {
20
+ res = await fetch(`${base}${path}`, { ...init, headers });
21
+ }
22
+ catch {
23
+ throw new StoreApiError(0, `无法连接商店 ${base}${path}(网络错误)`);
24
+ }
25
+ const text = await res.text();
26
+ let body = null;
27
+ try {
28
+ body = text ? JSON.parse(text) : null;
29
+ }
30
+ catch {
31
+ body = text;
32
+ }
33
+ if (!res.ok) {
34
+ const errBody = body;
35
+ throw new StoreApiError(res.status, errBody?.message ?? errBody?.error ?? `HTTP ${res.status}`, errBody?.code);
36
+ }
37
+ return body;
38
+ }
39
+ /** 发起设备流登录(RFC 8628)。 */
40
+ export async function requestDeviceCode(base) {
41
+ return request(base, '/auth/device/code', { method: 'POST' });
42
+ }
43
+ /** 轮询设备流换取访问令牌。 */
44
+ export async function pollDeviceLogin(base, code, onTick) {
45
+ const deadline = Date.now() + code.expiresIn * 1000;
46
+ let tried = 0;
47
+ for (;;) {
48
+ if (Date.now() > deadline)
49
+ throw new StoreApiError(0, '设备授权超时,请重新发起 qomicex publish');
50
+ await sleep(code.interval * 1000);
51
+ tried++;
52
+ onTick?.(tried);
53
+ const r = await request(base, '/auth/device/token', { method: 'POST', body: JSON.stringify({ deviceCode: code.deviceCode }), headers: { 'Content-Type': 'application/json' } });
54
+ if (r.status === 'ok' && r.accessToken) {
55
+ return { accessToken: r.accessToken, refreshToken: r.refreshToken ?? '', user: r.user ?? { id: '', username: '?' } };
56
+ }
57
+ if (r.status !== 'pending')
58
+ throw new StoreApiError(0, `设备授权失败: ${JSON.stringify(r)}`);
59
+ }
60
+ }
61
+ /** 我名下/我参与的插件(store 用 slug 查询,上传用行 id)。 */
62
+ export async function fetchMinePlugins(base, token) {
63
+ const r = await request(base, '/plugins/mine', {}, token);
64
+ return r.items ?? [];
65
+ }
66
+ /** 新建插件(store 返回 201 {plugin:{id}}),slug 占用抛 409。 */
67
+ export async function createPlugin(base, token, input) {
68
+ const r = await request(base, '/plugins/', {
69
+ method: 'POST',
70
+ body: JSON.stringify({
71
+ slug: input.slug,
72
+ name: input.name,
73
+ description: input.description ?? '',
74
+ category: input.category ?? 'tool',
75
+ tags: input.tags ?? [],
76
+ ...(input.orgId ? { orgId: input.orgId } : {}),
77
+ }),
78
+ headers: { 'Content-Type': 'application/json' },
79
+ }, token);
80
+ return { id: r.plugin?.id ?? r.id ?? '' };
81
+ }
82
+ /** 注册/替换开发者签名公钥,返回 keyId + 商店根钥签发的证书。 */
83
+ export async function registerDevKey(base, token, publicKey) {
84
+ const r = await request(base, '/developer/keys', {
85
+ method: 'POST',
86
+ body: JSON.stringify({ publicKey }),
87
+ headers: { 'Content-Type': 'application/json' },
88
+ }, token);
89
+ if (!r.keyId || !r.cert)
90
+ throw new StoreApiError(0, '注册签名公钥响应缺少 keyId/cert');
91
+ return r;
92
+ }
93
+ /** 上传插件版本(multipart file + 可选 changelog)。 */
94
+ export async function uploadVersion(base, token, pluginId, fileBytes, changelog) {
95
+ const form = new FormData();
96
+ form.append('file', new Blob([fileBytes], { type: 'application/zip' }), 'plugin.qplugin');
97
+ if (changelog)
98
+ form.append('changelog', changelog);
99
+ return request(base, `/plugins/${encodeURIComponent(pluginId)}/versions`, { method: 'POST', body: form }, token);
100
+ }