Files
homarr/packages/custom-widgets/src/server/preview-sessions.ts
T
Thomas 5e23659d99 feat(assistant): optimize MCP and custom widget authoring (#6743)
* 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
2026-08-30 23:43:29 +02:00

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);
}
}