feat(m2): ship isolated task dashboard surface
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user