182 lines
7.7 KiB
TypeScript
182 lines
7.7 KiB
TypeScript
/**
|
|
* 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<CoreAppRecord, "kind"> & { 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<AppScope, CoreAppRecord>();
|
|
|
|
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<CoreAppRecord, "enabled" | "default_eligible" | "recovery"> = { 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<string>();
|
|
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<T>(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);
|
|
}
|