feat(m2): ship isolated task dashboard surface

This commit is contained in:
2026-08-03 23:11:29 +08:00
parent 9fd321c41c
commit ccff57b2a1
9 changed files with 829 additions and 9 deletions
+242
View File
@@ -0,0 +1,242 @@
import type { JsonObject } from "../protocol";
/**
* M2-01 development-time Surface declaration. These records are authored by
* the host, bundled with it, and never accepted from a conversation payload.
* Remote bundle URLs, hashes and downloads deliberately belong to M4.
*/
export type SurfaceManifest = Readonly<{
app_id: string;
version: string;
bundle_id: string;
state_schema_version: number;
max_state_bytes: number;
max_bundle_bytes: number;
local_bundle_bytes: number;
development_only: true;
}>;
export type EnabledSurface = Readonly<{
app_id: string;
version: string;
enabled_at: string;
}>;
export type SurfaceRegistryDisposition =
| "accepted"
| "unknown_app"
| "version_mismatch"
| "disabled"
| "invalid_manifest"
| "invalid_state"
| "state_too_large"
| "bundle_override_forbidden";
export type SurfaceRegistryResult = Readonly<{
disposition: SurfaceRegistryDisposition;
manifest?: SurfaceManifest;
}>;
export type SurfaceRegistrySnapshot = Readonly<{
version: 1;
enabled: readonly EnabledSurface[];
}>;
export type SurfaceOpenRequest = Readonly<{
instance_id: string;
app: Readonly<{ app_id: string; version: string }>;
state?: JsonObject;
}>;
const APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const BUNDLE_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
const INSTANCE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
const MAX_APP_ID_LENGTH = 96;
const MAX_VERSION_LENGTH = 64;
const MAX_BUNDLE_ID_LENGTH = 128;
const MAX_STATE_DEPTH = 16;
const MAX_STATE_KEYS = 512;
const MAX_DECLARED_STATE_BYTES = 64 * 1024;
const MAX_DECLARED_BUNDLE_BYTES = 512 * 1024;
/**
* The sole surface known in the development build. Declaring it here does
* not make it visible to an Agent or openable: App Center must explicitly
* enable this exact app/version first.
*/
export const DEVELOPMENT_SURFACE_MANIFESTS: readonly SurfaceManifest[] = Object.freeze([
Object.freeze({
app_id: "lineup.task-dashboard",
version: "0.1.0",
bundle_id: "lineup.task-dashboard.dev.v1",
state_schema_version: 1,
max_state_bytes: 32 * 1024,
max_bundle_bytes: 256 * 1024,
local_bundle_bytes: 0,
development_only: true,
}),
]);
/**
* Local App Center enablement boundary. It is intentionally free of DOM,
* storage, transport, iframe and capability concerns so it can be persisted
* by a future host adapter without changing its security rules.
*/
export class SurfaceRegistry {
private readonly manifests = new Map<string, SurfaceManifest>();
private readonly enabled = new Map<string, EnabledSurface>();
public constructor(manifests: readonly SurfaceManifest[] = DEVELOPMENT_SURFACE_MANIFESTS) {
for (const candidate of manifests) {
const manifest = normalizeManifest(candidate);
if (!manifest || this.manifests.has(key(manifest.app_id, manifest.version))) continue;
this.manifests.set(key(manifest.app_id, manifest.version), manifest);
}
}
public enable(appID: string, version: string, enabledAt: string): SurfaceRegistryResult {
const result = this.find(appID, version);
if (result.disposition !== "accepted" || !result.manifest || !validTimestamp(enabledAt)) return result.disposition === "accepted"
? { disposition: "invalid_manifest" }
: result;
this.enabled.set(key(appID, version), { app_id: appID, version, enabled_at: enabledAt });
return result;
}
public disable(appID: string, version: string): SurfaceRegistryResult {
const result = this.find(appID, version);
if (result.disposition !== "accepted") return result;
this.enabled.delete(key(appID, version));
return result;
}
public isEnabled(appID: string, version: string): boolean {
return this.enabled.has(key(appID, version));
}
/** Resolves only an exact, locally enabled declaration; no inventory leaks. */
public resolveEnabled(appID: string, version: string): SurfaceRegistryResult {
const result = this.find(appID, version);
if (result.disposition !== "accepted") return result;
return this.enabled.has(key(appID, version)) ? result : { disposition: "disabled" };
}
/** Validates a trusted host state before Instance Manager can retain it. */
public validateState(appID: string, version: string, state: unknown): SurfaceRegistryResult {
const result = this.resolveEnabled(appID, version);
if (result.disposition !== "accepted" || !result.manifest) return result;
const measured = measureState(state);
if (!measured.valid) return { disposition: "invalid_state" };
return measured.bytes <= result.manifest.max_state_bytes
? result
: { disposition: "state_too_large" };
}
/**
* Rejects payload attempts to replace the locally packaged bundle before
* they can reach Instance Manager. This validates shape only; M2-02 owns
* lifecycle semantics and M2-04 owns the dashboard state schema.
*/
public validateOpenRequest(payload: unknown): SurfaceRegistryResult {
if (!isPlainObject(payload)) return { disposition: "invalid_state" };
if (hasBundleOverride(payload)) return { disposition: "bundle_override_forbidden" };
const instanceID = payload.instance_id;
const app = payload.app;
if (typeof instanceID !== "string" || !INSTANCE_ID.test(instanceID) || !isPlainObject(app)
|| !validAppID(app.app_id) || !validVersion(app.version)) return { disposition: "invalid_state" };
if (payload.state !== undefined && !isPlainObject(payload.state)) return { disposition: "invalid_state" };
return this.validateState(app.app_id, app.version, payload.state ?? {});
}
public snapshot(): SurfaceRegistrySnapshot {
return {
version: 1,
enabled: [...this.enabled.values()]
.sort((left, right) => key(left.app_id, left.version).localeCompare(key(right.app_id, right.version)))
.map(entry => ({ ...entry })),
};
}
/** Restoring never adds an unknown, mismatched, malformed or duplicate app. */
public restore(snapshot: unknown): void {
this.enabled.clear();
if (!isPlainObject(snapshot) || snapshot.version !== 1 || !Array.isArray(snapshot.enabled)) return;
for (const raw of snapshot.enabled) {
if (!isPlainObject(raw) || !validAppID(raw.app_id) || !validVersion(raw.version) || !validTimestamp(raw.enabled_at)) continue;
const result = this.find(raw.app_id, raw.version);
if (result.disposition !== "accepted") continue;
this.enabled.set(key(raw.app_id, raw.version), { app_id: raw.app_id, version: raw.version, enabled_at: raw.enabled_at });
}
}
private find(appID: string, version: string): SurfaceRegistryResult {
if (!validAppID(appID)) return { disposition: "unknown_app" };
const matchingApp = [...this.manifests.values()].some(manifest => manifest.app_id === appID);
if (!matchingApp) return { disposition: "unknown_app" };
if (!validVersion(version) || !this.manifests.has(key(appID, version))) return { disposition: "version_mismatch" };
return { disposition: "accepted", manifest: this.manifests.get(key(appID, version))! };
}
}
function normalizeManifest(value: SurfaceManifest): SurfaceManifest | undefined {
if (!value || typeof value !== "object" || !validAppID(value.app_id) || !validVersion(value.version)
|| !validBundleID(value.bundle_id) || !Number.isSafeInteger(value.state_schema_version) || value.state_schema_version < 1
|| !validBound(value.max_state_bytes, MAX_DECLARED_STATE_BYTES) || !validBound(value.max_bundle_bytes, MAX_DECLARED_BUNDLE_BYTES)
|| !Number.isSafeInteger(value.local_bundle_bytes) || value.local_bundle_bytes < 0 || value.local_bundle_bytes > value.max_bundle_bytes
|| value.development_only !== true) return undefined;
return Object.freeze({ ...value });
}
function validAppID(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_APP_ID_LENGTH && APP_ID.test(value); }
function validVersion(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_VERSION_LENGTH && VERSION.test(value); }
function validBundleID(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_BUNDLE_ID_LENGTH && BUNDLE_ID.test(value); }
function validBound(value: unknown, maximum: number): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= maximum; }
function validTimestamp(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
function key(appID: string, version: string): string { return `${appID}@${version}`; }
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function hasBundleOverride(payload: Record<string, unknown>): boolean {
return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in payload);
}
function measureState(value: unknown): { valid: boolean; bytes: number } {
let keys = 0;
const visit = (candidate: unknown, depth: number): boolean => {
if (depth > MAX_STATE_DEPTH || candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return depth <= MAX_STATE_DEPTH;
if (typeof candidate === "number") return Number.isFinite(candidate);
if (Array.isArray(candidate)) return candidate.every(entry => visit(entry, depth + 1));
if (!isPlainObject(candidate)) return false;
const entries = Object.entries(candidate);
keys += entries.length;
return keys <= MAX_STATE_KEYS && entries.every(([entryKey, entryValue]) => entryKey.length <= 128 && visit(entryValue, depth + 1));
};
if (!isPlainObject(value) || !visit(value, 0)) return { valid: false, bytes: 0 };
try {
return { valid: true, bytes: new TextEncoder().encode(JSON.stringify(value as JsonObject)).byteLength };
} catch {
return { valid: false, bytes: 0 };
}
}
/** A narrow helper for the M2-02 manager; it does not mutate registry state. */
export function parseSurfaceOpenRequest(payload: unknown): SurfaceOpenRequest | undefined {
if (!isPlainObject(payload) || hasBundleOverride(payload) || typeof payload.instance_id !== "string" || !INSTANCE_ID.test(payload.instance_id)
|| !isPlainObject(payload.app) || !validAppID(payload.app.app_id) || !validVersion(payload.app.version)
|| (payload.state !== undefined && !isPlainObject(payload.state))) return undefined;
return {
instance_id: payload.instance_id,
app: { app_id: payload.app.app_id, version: payload.app.version },
...(payload.state === undefined ? {} : { state: payload.state as JsonObject }),
};
}
/** Exposed only for focused state-limit tests; no renderer should need it. */
export function isSurfaceStateJson(value: unknown): value is JsonObject {
return measureState(value).valid && isPlainObject(value);
}