/** * MiniApp SDK v1 的声明性 Manifest 契约。 * * 该模块只验证 Runtime 接受的声明,不加载 Bundle、不创建 DOM,也不授予任何能力。 * Runtime、Inventory 和 Tool Router 共享它,避免各自解释 Manifest。 */ import type { JsonObject } from "@/runtime/protocol/lineup-v1"; export type MiniAppKind = "system" | "bundled"; export type MiniAppToolHandling = "direct" | "interactive" | "launch" | "foreground" | "operation"; export type MiniAppToolTarget = Readonly<{ app_scope: string; requires_foreground: boolean; restore_previous_focus: boolean; }>; export type MiniAppToolDescriptor = Readonly<{ id: string; version: 1; handling: MiniAppToolHandling; target?: MiniAppToolTarget; input_schema: JsonObject; output_schema: JsonObject; permissions?: readonly string[]; timeout_ms?: number; }>; export type MiniAppManifestV1 = Readonly<{ app_scope: string; version: string; kind: MiniAppKind; tools: readonly MiniAppToolDescriptor[]; subscriptions: readonly string[]; requested_capabilities: readonly string[]; host: Readonly<{ min_version: string; surface_required: boolean; }>; }>; export type ManifestValidation = | Readonly<{ accepted: true }> | Readonly<{ accepted: false; code: "manifest_denied"; detail: string }>; const APP_SCOPE = /^[a-z][a-z0-9-]{0,63}$/; const TOOL_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const CAPABILITY = /^[a-z][a-z0-9._-]{0,63}$/; const HANDLINGS = new Set(["direct", "interactive", "launch", "foreground", "operation"]); /** * Checks only static, local rules. Policy and per-user capability approval are * intentionally deferred to Runtime, because a Manifest never grants access. */ export function validateMiniAppManifest(manifest: MiniAppManifestV1): ManifestValidation { if (!APP_SCOPE.test(manifest.app_scope)) return reject("app_scope must be a lowercase Runtime scope."); if (!SEMVER.test(manifest.version) || !SEMVER.test(manifest.host.min_version)) return reject("version and host.min_version must be semantic versions."); if (manifest.kind !== "system" && manifest.kind !== "bundled") return reject("kind must be system or bundled."); if (manifest.kind === "bundled" && manifest.host.surface_required !== true) return reject("bundled MiniApps require a restricted Surface."); if (!Array.isArray(manifest.tools) || !Array.isArray(manifest.subscriptions) || !Array.isArray(manifest.requested_capabilities)) return reject("manifest collections must be arrays."); if (!manifest.subscriptions.every(entry => typeof entry === "string" && entry.length > 0)) return reject("subscriptions must be non-empty strings."); if (!manifest.requested_capabilities.every(entry => CAPABILITY.test(entry))) return reject("requested capabilities contain an invalid identifier."); const toolIDs = new Set(); for (const tool of manifest.tools) { if (!TOOL_ID.test(tool.id) || toolIDs.has(tool.id)) return reject("tool IDs must be unique dotted identifiers."); toolIDs.add(tool.id); if (tool.version !== 1 || !HANDLINGS.has(tool.handling)) return reject("tool version or handling is invalid."); if (!isJsonObject(tool.input_schema) || !isJsonObject(tool.output_schema)) return reject("tool schemas must be JSON objects."); if (tool.permissions && !tool.permissions.every((permission: string) => manifest.requested_capabilities.includes(permission))) return reject("tool permissions must be declared by the manifest."); if (tool.timeout_ms !== undefined && (!Number.isInteger(tool.timeout_ms) || tool.timeout_ms <= 0)) return reject("tool timeout_ms must be a positive integer."); if (tool.handling === "interactive") { if (tool.target !== undefined) return reject("interactive Tools must not target a MiniApp."); if (tool.timeout_ms !== undefined) return reject("interactive Tools use interaction expiry, not tool timeout_ms."); } else if (!validTarget(tool.target)) { return reject("non-interactive Tools require a valid target."); } } return { accepted: true }; } function validTarget(target: MiniAppToolTarget | undefined): target is MiniAppToolTarget { return Boolean(target && APP_SCOPE.test(target.app_scope) && typeof target.requires_foreground === "boolean" && typeof target.restore_previous_focus === "boolean"); } function isJsonObject(value: unknown): value is JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } function reject(detail: string): ManifestValidation { return { accepted: false, code: "manifest_denied", detail }; }