* feat(assistant): optimize MCP and custom widget authoring * fix(assistant): isolate server-safe JSX analyzer * refactor(assistant): generate MCP tool staging * fix(assistant): preserve widget placement follow-up * fix(ci): allow fast gate to finish * fix(custom-widgets): address review findings * fix(custom-widgets): split preview evidence module * style: format assistant custom widget files * fix(custom-widgets): sync formatted skill schema
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import { z } from "zod/v4";
|
|
|
|
import {
|
|
customWidgetOptionsSchema,
|
|
customWidgetRequestsSchema,
|
|
customWidgetSecretKinds,
|
|
customWidgetSourcesSchema,
|
|
hasSameCustomWidgetSourceAuthentication,
|
|
} from "../core";
|
|
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
|
import { CustomWidgetDomainError } from "./errors";
|
|
import {
|
|
getPreviewEvidenceKey,
|
|
isVerifiedPreviewEvidence,
|
|
parsePreviewJournalEntries,
|
|
parsePreviewStoredValue,
|
|
previewJournalEntrySchema,
|
|
} from "./preview-session-evidence";
|
|
import type { CustomWidgetPreviewJournalEntry } from "./preview-session-evidence";
|
|
import { validatePreviewTemplateRevision } from "./preview-session-template";
|
|
|
|
const SESSION_TTL_MS = 10 * 60_000;
|
|
const MAX_JOURNAL_ENTRIES = 50;
|
|
const MAX_UPDATE_ATTEMPTS = 8;
|
|
|
|
const encryptedSecretSchema = z.object({
|
|
sourceId: z.string(),
|
|
kind: z.enum(customWidgetSecretKinds),
|
|
value: z.string(),
|
|
});
|
|
|
|
const sessionSchema = z.object({
|
|
id: z.string(),
|
|
userId: z.string(),
|
|
revision: z.number().int().nonnegative(),
|
|
expiresAt: z.number(),
|
|
sources: customWidgetSourcesSchema,
|
|
secrets: z.array(encryptedSecretSchema),
|
|
requests: customWidgetRequestsSchema,
|
|
name: z.string(),
|
|
description: z.string().optional(),
|
|
iconUrl: z.string().optional(),
|
|
template: z.string(),
|
|
optionDefinitions: customWidgetOptionsSchema,
|
|
options: z.record(z.string(), z.unknown()),
|
|
definitionId: z.string().optional(),
|
|
liveActions: z.boolean(),
|
|
});
|
|
export type CustomWidgetPreviewSession = z.infer<typeof sessionSchema>;
|
|
|
|
export type { CustomWidgetPreviewJournalEntry } from "./preview-session-evidence";
|
|
|
|
export interface CreatePreviewSessionInput {
|
|
userId: string;
|
|
sources: Record<string, CustomWidgetSource>;
|
|
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
|
requests: Record<string, CustomJsxRequest>;
|
|
name: string;
|
|
description?: string;
|
|
iconUrl?: string;
|
|
template: string;
|
|
optionDefinitions: CustomWidgetOptions;
|
|
options: Record<string, unknown>;
|
|
definitionId?: string;
|
|
}
|
|
|
|
export interface PreviewSessionStore {
|
|
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
|
|
compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number): Promise<boolean>;
|
|
getSession(id: string): Promise<unknown>;
|
|
deleteSession(id: string): Promise<void>;
|
|
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
|
|
getJournal(id: string, maxEntries: number): Promise<unknown[]>;
|
|
saveEvidence(id: string, key: string, value: unknown, ttlMs: number): Promise<void>;
|
|
getEvidence(id: string): Promise<unknown[]>;
|
|
}
|
|
|
|
export interface PreviewSessionServiceOptions {
|
|
createId(): string;
|
|
encrypt(value: string): string;
|
|
decrypt(value: string): string;
|
|
now?: () => number;
|
|
store?: PreviewSessionStore;
|
|
}
|
|
|
|
export class CustomWidgetPreviewSessionService {
|
|
private readonly sessions = new Map<string, CustomWidgetPreviewSession>();
|
|
private readonly journals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
|
|
private readonly evidence = new Map<string, Map<string, CustomWidgetPreviewJournalEntry>>();
|
|
private readonly now: () => number;
|
|
|
|
public constructor(private readonly options: PreviewSessionServiceOptions) {
|
|
this.now = options.now ?? Date.now;
|
|
}
|
|
|
|
public async create(input: CreatePreviewSessionInput) {
|
|
const sourceIds = new Set(Object.keys(input.sources));
|
|
if (input.secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
|
|
throw new CustomWidgetDomainError({
|
|
code: "BAD_REQUEST",
|
|
message: "A preview secret references an unknown source",
|
|
});
|
|
}
|
|
const session: CustomWidgetPreviewSession = {
|
|
id: this.options.createId(),
|
|
userId: input.userId,
|
|
revision: 0,
|
|
expiresAt: this.now() + SESSION_TTL_MS,
|
|
sources: input.sources,
|
|
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
|
requests: input.requests,
|
|
name: input.name,
|
|
description: input.description,
|
|
iconUrl: input.iconUrl,
|
|
template: input.template,
|
|
optionDefinitions: input.optionDefinitions,
|
|
options: input.options,
|
|
definitionId: input.definitionId,
|
|
liveActions: false,
|
|
};
|
|
await this.save(session);
|
|
return { id: session.id, revision: session.revision, expiresAt: session.expiresAt, liveActions: false as const };
|
|
}
|
|
|
|
public async get(id: string, userId: string): Promise<CustomWidgetPreviewSession> {
|
|
const candidate = this.options.store ? await this.options.store.getSession(id) : this.sessions.get(id);
|
|
const parsed = sessionSchema.safeParse(parsePreviewStoredValue(candidate));
|
|
if (!parsed.success || parsed.data.expiresAt <= this.now()) {
|
|
if (this.options.store) await this.options.store.deleteSession(id);
|
|
else {
|
|
this.sessions.delete(id);
|
|
this.journals.delete(id);
|
|
this.evidence.delete(id);
|
|
}
|
|
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
|
}
|
|
if (parsed.data.userId !== userId) {
|
|
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
public async setLiveActions(id: string, userId: string, enabled: boolean) {
|
|
const session = await this.update(id, userId, (current) => ({ ...current, liveActions: enabled }));
|
|
return { id, expiresAt: session.expiresAt, liveActions: enabled };
|
|
}
|
|
|
|
public async reviseTemplate(id: string, userId: string, template: string, expectedRevision?: number) {
|
|
const session = await this.update(
|
|
id,
|
|
userId,
|
|
(current) => ({ ...current, template: validatePreviewTemplateRevision(current, template) }),
|
|
expectedRevision,
|
|
);
|
|
return {
|
|
id: session.id,
|
|
revision: session.revision,
|
|
expiresAt: session.expiresAt,
|
|
liveActions: session.liveActions,
|
|
evidenceReset: true as const,
|
|
};
|
|
}
|
|
|
|
public async appendJournal(
|
|
session: CustomWidgetPreviewSession,
|
|
input: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
|
|
): Promise<void> {
|
|
const value = previewJournalEntrySchema.parse({ ...input, id: this.options.createId(), timestamp: this.now() });
|
|
const ttlMs = Math.max(1, session.expiresAt - this.now());
|
|
if (this.options.store) {
|
|
const writes = [this.options.store.appendJournal(session.id, value, MAX_JOURNAL_ENTRIES, ttlMs)];
|
|
if (isVerifiedPreviewEvidence(value)) {
|
|
writes.push(this.options.store.saveEvidence(session.id, getPreviewEvidenceKey(value), value, ttlMs));
|
|
}
|
|
await Promise.all(writes);
|
|
return;
|
|
}
|
|
|
|
this.journals.set(session.id, [value, ...(this.journals.get(session.id) ?? [])].slice(0, MAX_JOURNAL_ENTRIES));
|
|
if (!isVerifiedPreviewEvidence(value)) return;
|
|
const evidence = this.evidence.get(session.id) ?? new Map<string, CustomWidgetPreviewJournalEntry>();
|
|
evidence.set(getPreviewEvidenceKey(value), value);
|
|
this.evidence.set(session.id, evidence);
|
|
}
|
|
|
|
public async getJournal(id: string, userId: string): Promise<CustomWidgetPreviewJournalEntry[]> {
|
|
await this.get(id, userId);
|
|
const entries = this.options.store
|
|
? await this.options.store.getJournal(id, MAX_JOURNAL_ENTRIES)
|
|
: (this.journals.get(id) ?? []);
|
|
return parsePreviewJournalEntries(entries);
|
|
}
|
|
|
|
public async getEvidence(id: string, userId: string): Promise<CustomWidgetPreviewJournalEntry[]> {
|
|
await this.get(id, userId);
|
|
const entries = this.options.store
|
|
? await this.options.store.getEvidence(id)
|
|
: [...(this.evidence.get(id)?.values() ?? [])];
|
|
return parsePreviewJournalEntries(entries);
|
|
}
|
|
|
|
public getSecrets(session: CustomWidgetPreviewSession, sourceId: string) {
|
|
return session.secrets
|
|
.filter((secret) => secret.sourceId === sourceId)
|
|
.map((secret) => ({ kind: secret.kind, value: this.options.decrypt(secret.value) }));
|
|
}
|
|
|
|
public async setSecrets(
|
|
id: string,
|
|
userId: string,
|
|
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
|
|
) {
|
|
const next = await this.update(id, userId, (session) => {
|
|
const sourceIds = new Set(Object.keys(session.sources));
|
|
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
|
|
throw new CustomWidgetDomainError({
|
|
code: "BAD_REQUEST",
|
|
message: "A preview secret references an unknown source",
|
|
});
|
|
}
|
|
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
|
return {
|
|
...session,
|
|
secrets: [
|
|
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
|
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
|
],
|
|
};
|
|
});
|
|
return { id, expiresAt: next.expiresAt };
|
|
}
|
|
|
|
public async configureSource(
|
|
id: string,
|
|
userId: string,
|
|
sourceId: string,
|
|
source: CustomWidgetSource,
|
|
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
|
|
) {
|
|
const next = await this.update(id, userId, (session) => {
|
|
const currentSource = session.sources[sourceId];
|
|
if (!currentSource) {
|
|
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
|
}
|
|
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
|
|
throw new CustomWidgetDomainError({
|
|
code: "BAD_REQUEST",
|
|
message: "Preview source authentication changed; create a new configuration request",
|
|
});
|
|
}
|
|
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
|
return {
|
|
...session,
|
|
sources: {
|
|
...session.sources,
|
|
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
|
|
},
|
|
secrets: [
|
|
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
|
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
|
],
|
|
};
|
|
});
|
|
return { id, expiresAt: next.expiresAt };
|
|
}
|
|
|
|
private async update(
|
|
id: string,
|
|
userId: string,
|
|
mutate: (session: CustomWidgetPreviewSession) => CustomWidgetPreviewSession,
|
|
expectedRevision?: number,
|
|
) {
|
|
for (let attempt = 0; attempt < MAX_UPDATE_ATTEMPTS; attempt += 1) {
|
|
const current = await this.get(id, userId);
|
|
if (expectedRevision !== undefined && current.revision !== expectedRevision) {
|
|
throw new CustomWidgetDomainError({
|
|
code: "CONFLICT",
|
|
message: `Preview session revision changed from ${expectedRevision} to ${current.revision}`,
|
|
});
|
|
}
|
|
const next = { ...mutate(current), revision: current.revision + 1 };
|
|
const ttlMs = Math.max(0, next.expiresAt - this.now());
|
|
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
|
|
if (this.options.store) {
|
|
if (await this.options.store.compareAndSwapSession(id, current.revision, next, ttlMs)) return next;
|
|
} else if (this.sessions.get(id)?.revision === current.revision) {
|
|
this.sessions.set(id, next);
|
|
return next;
|
|
}
|
|
}
|
|
throw new CustomWidgetDomainError({ code: "CONFLICT", message: "Preview session changed too many times; retry" });
|
|
}
|
|
|
|
private async save(session: CustomWidgetPreviewSession): Promise<void> {
|
|
const ttlMs = Math.max(0, session.expiresAt - this.now());
|
|
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
|
|
if (this.options.store) await this.options.store.saveSession(session.id, session, ttlMs);
|
|
else this.sessions.set(session.id, session);
|
|
}
|
|
}
|