/** * Runtime 本地 Core App 注册表。 * * 当前 MVP 只注册默认且可恢复的 `chat`;后续 App Registry/市场只能通过 Runtime * 管理记录,App 自身不得直接篡改注册表。 * * Runtime-local application registry for the first App MVP. * * `app_scope` deliberately is a local routing key, not a publisher identity * or a marketplace namespace. The initial registry is static, but the same * record shape is the boundary that the later App Registry/Market will own. */ import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest"; import { BUNDLED_REFERENCE_MANIFESTS } from "@/runtime/app-management/reference-miniapps"; import type { JsonObject } from "@/runtime/protocol/lineup-v1"; export type AppScope = "chat" | "app-registry" | "settings" | "runtime" | (string & {}); export type MiniAppKind = "system" | "bundled"; const SYSTEM_APP_SCOPE: AppScope = "chat"; export type AppToolDefinition = { name: string; handling: "direct" | "interactive" | "launch" | "foreground" | "operation"; /** JSON-object property names accepted by this tool. */ parameters: readonly string[]; requires_permissions?: readonly string[]; input_schema?: JsonObject; output_schema?: JsonObject; target?: { app_scope: AppScope; requires_foreground: boolean; restore_previous_focus: boolean; }; }; /** * A Runtime-validated app manifest. This deliberately contains declarative * metadata only: no bundle URL, executable code, or Host capability handle. */ export type AppManifest = { app_scope: AppScope; version: string; tools: readonly AppToolDefinition[]; permissions: readonly string[]; }; export type CoreAppRecord = { app_scope: AppScope; kind: MiniAppKind; enabled: boolean; default_eligible: boolean; recovery: boolean; manifest: AppManifest; }; /** * The Runtime installation boundary uses the frozen SDK v1 vocabulary. * Legacy host labels (`core` / `extension`) are intentionally not accepted * here; callers must migrate before they can register an App. */ export type CoreAppRecordInput = Omit & { kind: MiniAppKind }; const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [ { app_scope: "chat", kind: "system", enabled: true, default_eligible: true, recovery: true, manifest: { app_scope: "chat", version: "1.0.0", permissions: [], tools: [ { name: "choice", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] }, { name: "confirm", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] }, { name: "input", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] }, ], }, }, ]; /** * The Runtime owns this registry. Apps may later request operations through * a Runtime SDK, but they never mutate this collection directly. */ export class CoreAppRegistry { private readonly apps = new Map(); public constructor(records: readonly CoreAppRecordInput[] = INITIAL_CORE_APPS) { for (const record of records) this.register(record); if (records === INITIAL_CORE_APPS) { for (const manifest of BUNDLED_REFERENCE_MANIFESTS) this.installMiniAppManifest(manifest); } } public list(): readonly CoreAppRecord[] { return [...this.apps.values()].map(copyRecord); } public get(appScope: AppScope): CoreAppRecord | undefined { const record = this.apps.get(appScope); return record ? copyRecord(record) : undefined; } public defaultApp(): CoreAppRecord { const selected = this.list().find(record => record.enabled && record.default_eligible); if (selected) return selected; const recovery = this.list().find(record => record.enabled && record.recovery); if (recovery) return recovery; throw new Error("Runtime has no enabled recovery application."); } /** Runtime-only installation path; extension Apps never receive this object. */ public install(record: CoreAppRecordInput): void { this.register(record); } /** Admits a validated SDK v1 Manifest without exposing the Manifest object to Apps. */ public installMiniAppManifest(manifest: MiniAppManifestV1, options: Pick = { enabled: true, default_eligible: false, recovery: false }): void { const validation = validateMiniAppManifest(manifest); if (!validation.accepted) throw new Error(`MiniApp Manifest denied: ${validation.detail}`); this.install({ app_scope: manifest.app_scope, kind: manifest.kind, ...options, manifest: { app_scope: manifest.app_scope, version: manifest.version, permissions: [...manifest.requested_capabilities], tools: manifest.tools.map(tool => ({ name: tool.id, handling: tool.handling, parameters: [], input_schema: tool.input_schema, output_schema: tool.output_schema, target: tool.target, ...(tool.permissions?.length ? { requires_permissions: [...tool.permissions] } : {}), })), }, }); } private register(record: CoreAppRecordInput): void { if (!isAppScope(record.app_scope) || record.manifest.app_scope !== record.app_scope) throw new Error("Core App Registry received an invalid app_scope."); if (record.kind === "system" && record.app_scope !== SYSTEM_APP_SCOPE) throw new Error("Only the Interact system MiniApp may use kind=system in SDK v1."); if (this.apps.has(record.app_scope)) throw new Error(`Core App Registry has duplicate app_scope: ${record.app_scope}`); if (!isManifest(record.manifest)) throw new Error(`Core App Registry has an invalid manifest: ${record.app_scope}`); this.apps.set(record.app_scope, copyRecord(record)); } } function copyRecord(record: CoreAppRecord): CoreAppRecord { return { ...record, manifest: { ...record.manifest, permissions: [...record.manifest.permissions], tools: record.manifest.tools.map(tool => ({ ...tool, parameters: [...tool.parameters], ...(tool.input_schema ? { input_schema: clone(tool.input_schema) } : {}), ...(tool.output_schema ? { output_schema: clone(tool.output_schema) } : {}) })), }, }; } function isManifest(manifest: AppManifest): boolean { const toolNames = new Set(); return typeof manifest.version === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version) && manifest.permissions.every(permission => /^[a-z][a-z0-9._-]{0,63}$/.test(permission)) && manifest.tools.every(tool => { const valid = /^[a-z][a-z0-9._-]{0,63}$/.test(tool.name) && !toolNames.has(tool.name) && ["direct", "interactive", "launch", "foreground", "operation"].includes(tool.handling) && tool.parameters.every(parameter => /^[a-z][a-z0-9_]{0,63}$/.test(parameter)) && (!tool.input_schema || isJsonObject(tool.input_schema)) && (!tool.output_schema || isJsonObject(tool.output_schema)) && (!tool.requires_permissions || (tool.requires_permissions.length > 0 && new Set(tool.requires_permissions).size === tool.requires_permissions.length && tool.requires_permissions.every(permission => /^[a-z][a-z0-9._-]{0,63}$/.test(permission) && manifest.permissions.includes(permission)))); toolNames.add(tool.name); return valid; }); } function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function isJsonObject(value: unknown): value is JsonObject { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } export function isAppScope(value: unknown): value is AppScope { return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value); }