Files
app/tauri/src/runtime/app-management/miniapp-manifest.ts
T

99 lines
4.6 KiB
TypeScript

/**
* 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<MiniAppToolHandling>(["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<string>();
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 };
}