/** * REST adapters for the session archive: status, manual import of * registered sources, and owner-only discovery/registration/init. * * Thin adapters over the transport-neutral sessions service. The instance is * archive-bound iff its loaded config carries a `sessions` block; there is no * cross-index routing. Remote callers may read status and import a registered * source by ID; they never name host paths and never learn host directories. * Discovery, source registration/removal and archive init are same-host only. * * @module src/serve/routes/sessions */ import type { Config } from "../../config/types"; import type { SqliteAdapter } from "../../store/sqlite/adapter"; import type { RequestPeerServer } from "../request-locality"; import type { ContextHolder } from "./api"; import { getConfigPaths, loadConfig } from "../../config"; import { withContentTypeRules } from "../../ingestion"; import { type AutomationContext, disableAutomation, enableAutomation, previewAutomationProfile, removeAutomationProfile, runAutomationProfile, setAutomationProfile, } from "../../sessions/automation"; import { SessionSourceSchema, watchedCollections } from "../../sessions/config"; import { adoptServedConfig, assertInstanceBinding as assertConfigBinding, readInstanceConfig, refreshServedConfig, type ServedSessionsConfig, } from "../../sessions/config-refresh"; import { importInChildProcess } from "../../sessions/import-child"; import { SessionsService } from "../../sessions/service"; import { addSessionSource, initSessionArchive, removeSessionSource, } from "../../sessions/setup"; import { SESSION_HARNESSES, type SessionHarness, type SessionImportReceipt, remoteSafeSessionsError, SessionsError, type SessionsErrorCode, SESSIONS_VALIDATION_CODES, } from "../../sessions/types"; import { isLocalClientRequest } from "../request-locality"; const HTTP_BAD_REQUEST = 400; const HTTP_FORBIDDEN = 403; const HTTP_CONFLICT = 409; const HTTP_INTERNAL = 500; /** Generic REST error code per status; `details.sessionsCode` is specific. */ const WIRE_CODES: Readonly> = { [HTTP_BAD_REQUEST]: "VALIDATION", [HTTP_FORBIDDEN]: "FORBIDDEN", [HTTP_CONFLICT]: "BUSY", [HTTP_INTERNAL]: "RUNTIME", }; /** Keys accepted by POST /api/sessions/import. */ const IMPORT_KEYS = new Set(["sourceId", "dryRun", "limit"]); /** Path-import keys that only local CLI/SDK callers may use. */ const IMPORT_PATH_KEYS = ["collection", "format"] as const; const SOURCE_KEYS = new Set([ "id", "harness", "path", "collection", "projects", ]); const INIT_KEYS = new Set(["archive", "collection"]); export interface SessionsRouteDeps { /** Peer lookup for same-host checks; absent means remote (fail closed). */ server?: RequestPeerServer; /** Discovery environment override (tests use a synthetic home). */ env?: NodeJS.ProcessEnv; } export interface SessionsInitResponse { schemaVersion: "1"; index: string; collection: string; archiveRoot: string; created: boolean; } export interface SessionsSourceAddResponse { id: string; registered: true; } export interface SessionsSourceRemoveResponse { id: string; removed: true; archiveRetained: true; } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function sessionsError( code: SessionsErrorCode, message: string, status: number ): Response { return Response.json( { error: { code: WIRE_CODES[status] ?? "RUNTIME", message, details: { sessionsCode: code }, }, }, { status } ); } /** * Map a thrown error onto the REST envelope; `details.sessionsCode` is * stable and messages never carry host paths. */ export function sessionsErrorResponse(error: unknown): Response { const typed = remoteSafeSessionsError(error); if (typed.code === "SESSIONS_BUSY") { return sessionsError(typed.code, typed.message, HTTP_CONFLICT); } return sessionsError( typed.code, typed.message, SESSIONS_VALIDATION_CODES.has(typed.code) ? HTTP_BAD_REQUEST : HTTP_INTERNAL ); } const invalid = (message: string): Response => sessionsError("SESSIONS_INVALID_INPUT", message, HTTP_BAD_REQUEST); function localOnly( req: Request, deps: SessionsRouteDeps, action: string ): Response | null { if (isLocalClientRequest(req, deps.server)) return null; return Response.json( { error: { code: "FORBIDDEN", message: `${action} is only available to a same-host browser`, }, }, { status: HTTP_FORBIDDEN } ); } async function readObjectBody( req: Request ): Promise< { ok: true; body: Record } | { ok: false; res: Response } > { let body: unknown; try { const text = await req.text(); body = text.trim() ? JSON.parse(text) : {}; } catch { return { ok: false, res: invalid("Invalid JSON body") }; } if (typeof body !== "object" || body === null || Array.isArray(body)) { return { ok: false, res: invalid("Request body must be a JSON object") }; } return { ok: true, body: body as Record }; } function instanceIdentity(ctxHolder: ContextHolder): { configPath: string; indexName: string; } { return { configPath: ctxHolder.actualConfigPath ?? "", indexName: ctxHolder.current.indexName, }; } /** Refuse an archive config opened against a different index (and vice versa). */ function assertInstanceBinding( ctxHolder: ContextHolder, config: Config = ctxHolder.config ): Promise { return assertConfigBinding(instanceIdentity(ctxHolder), config); } /** Service over the instance's own config/index pair, binding checked. */ async function archiveService( ctxHolder: ContextHolder, store?: SqliteAdapter ): Promise { await assertInstanceBinding(ctxHolder); const { configPath, indexName } = instanceIdentity(ctxHolder); return new SessionsService({ config: ctxHolder.config, configPath, indexName, store, }); } /** This instance's served config, as the shared config refresh sees it. */ function servedConfig( ctxHolder: ContextHolder, store: SqliteAdapter ): ServedSessionsConfig { return { ...instanceIdentity(ctxHolder), store, config: ctxHolder.config, setConfig: (config) => { ctxHolder.config = config; ctxHolder.current = { ...ctxHolder.current, config }; ctxHolder.watchService?.updateCollections( watchedCollections(config), withContentTypeRules({}, config) ); }, invalidateEgressPolicy: async () => { await ctxHolder.invalidateEgressPolicy?.(); }, markContentMutation: () => ctxHolder.markContentMutation?.(), markIndexMutation: () => ctxHolder.markIndexMutation?.(), }; } /** * Adopt a config the sessions service already persisted: project collections * and contexts into the open store, swap the in-memory context, and refresh * the watcher, egress policy and mutation generations. */ function adoptConfig( ctxHolder: ContextHolder, store: SqliteAdapter, config: Config ): Promise { return adoptServedConfig(servedConfig(ctxHolder, store), config); } /** Read this instance's config file, binding checked (see readInstanceConfig). */ function readConfigFile(ctxHolder: ContextHolder): Promise { return readInstanceConfig(instanceIdentity(ctxHolder)); } // ───────────────────────────────────────────────────────────────────────────── // Handlers // ───────────────────────────────────────────────────────────────────────────── /** * Adopt the config file when it changed underneath the running server, for * example after `gno sessions source add/remove` on the same pair. Runs * before the status read is admitted: adopting new collections moves the * authorization epoch, which would void a read already in flight. Returns an * error response, or null when the served config is current. */ export async function refreshSessionsConfig( ctxHolder: ContextHolder, store: SqliteAdapter ): Promise { try { await refreshServedConfig(servedConfig(ctxHolder, store)); return null; } catch (error) { return sessionsErrorResponse(error); } } /** GET /api/sessions/status */ export async function handleSessionsStatus( ctxHolder: ContextHolder ): Promise { try { const service = await archiveService(ctxHolder); return Response.json(await service.status()); } catch (error) { return sessionsErrorResponse(error); } } /** * POST /api/sessions/import * Body: { sourceId, dryRun?, limit? }. Host paths are refused before any read. */ export async function handleSessionsImport( ctxHolder: ContextHolder, req: Request ): Promise { const parsed = await readObjectBody(req); if (!parsed.ok) return parsed.res; const { body } = parsed; if ("paths" in body) { return sessionsError( "SESSIONS_UNSAFE_PATH", "Host paths cannot be named over REST; import a registered source by its ID.", HTTP_BAD_REQUEST ); } for (const key of IMPORT_PATH_KEYS) { if (key in body) { return invalid( `"${key}" applies to local path imports only; a registered source imports into its registered collections.` ); } } const unknown = Object.keys(body).filter((key) => !IMPORT_KEYS.has(key)); if (unknown.length > 0) { return invalid(`Unknown field(s): ${unknown.join(", ")}`); } if (body.sourceId === undefined) { return sessionsError( "SESSIONS_SELECTION_REQUIRED", "Select a registered source: { sourceId }.", HTTP_BAD_REQUEST ); } if (typeof body.sourceId !== "string" || !body.sourceId.trim()) { return invalid("sourceId must be a non-empty string"); } if (body.dryRun !== undefined && typeof body.dryRun !== "boolean") { return invalid("dryRun must be a boolean"); } if ( body.limit !== undefined && (typeof body.limit !== "number" || !Number.isSafeInteger(body.limit)) ) { return invalid("limit must be an integer"); } let receipt: SessionImportReceipt; try { await assertInstanceBinding(ctxHolder); const { configPath, indexName } = instanceIdentity(ctxHolder); // A child process keeps this server answering during a long import. receipt = await importInChildProcess({ config: ctxHolder.config, configPath: configPath || getConfigPaths().configFile, indexName, sourceId: body.sourceId.trim(), dryRun: body.dryRun === true, limit: body.limit as number | undefined, }); } catch (error) { return sessionsErrorResponse(error); } if (!receipt.dryRun && receipt.lexical.collections.length > 0) { ctxHolder.markContentMutation?.(); ctxHolder.markIndexMutation?.(); // Debounced embedding pass over the newly synced archive records. ctxHolder.scheduler?.notifySyncComplete(receipt.lexical.collections); } return Response.json(receipt); } /** GET /api/sessions/discover (same-host only: returns host paths). */ export async function handleSessionsDiscover( ctxHolder: ContextHolder, req: Request, deps: SessionsRouteDeps = {} ): Promise { const refused = localOnly(req, deps, "Session discovery"); if (refused) return refused; try { const { configPath, indexName } = instanceIdentity(ctxHolder); return Response.json( await new SessionsService({ config: ctxHolder.config, configPath, indexName, env: deps.env, }).discover() ); } catch (error) { return sessionsErrorResponse(error); } } /** POST /api/sessions/sources (same-host only). */ export async function handleSessionsAddSource( ctxHolder: ContextHolder, store: SqliteAdapter, req: Request, deps: SessionsRouteDeps = {} ): Promise { const refused = localOnly(req, deps, "Registering a session source"); if (refused) return refused; const parsed = await readObjectBody(req); if (!parsed.ok) return parsed.res; const { body } = parsed; const unknown = Object.keys(body).filter((key) => !SOURCE_KEYS.has(key)); if (unknown.length > 0) { return invalid(`Unknown field(s): ${unknown.join(", ")}`); } if ( typeof body.harness !== "string" || !(SESSION_HARNESSES as readonly string[]).includes(body.harness) ) { return sessionsError( "SESSIONS_UNSUPPORTED_FORMAT", `harness must be one of: ${SESSION_HARNESSES.join(", ")}.`, HTTP_BAD_REQUEST ); } const source = SessionSourceSchema.safeParse(body); if (!source.success) { const issue = source.error.issues[0]; return invalid( `${issue?.path.join(".") || "body"}: ${issue?.message ?? "invalid source"}` ); } try { const { configPath } = instanceIdentity(ctxHolder); await assertInstanceBinding(ctxHolder); const config = await addSessionSource({ configPath, id: source.data.id, harness: source.data.harness as SessionHarness, path: source.data.path, collection: source.data.collection, projects: source.data.projects, }); await adoptConfig(ctxHolder, store, config); } catch (error) { return sessionsErrorResponse(error); } const response: SessionsSourceAddResponse = { id: source.data.id, registered: true, }; return Response.json(response); } /** DELETE /api/sessions/sources/:id (same-host only; archive retained). */ export async function handleSessionsRemoveSource( ctxHolder: ContextHolder, store: SqliteAdapter, id: string, req: Request, deps: SessionsRouteDeps = {} ): Promise { const refused = localOnly(req, deps, "Removing a session source"); if (refused) return refused; if (!id) return invalid("Source ID is required"); try { const { configPath } = instanceIdentity(ctxHolder); await assertInstanceBinding(ctxHolder); const config = await removeSessionSource({ configPath, id }).catch( (error: unknown) => { // Already unregistered (for example from the CLI): the goal holds. if ( error instanceof SessionsError && error.code === "SESSIONS_UNKNOWN_SOURCE" ) { return readConfigFile(ctxHolder); } throw error; } ); // A no-op remove leaves the served config current; adopting it anyway // would reset the egress policy under concurrent reads. if (!Bun.deepEquals(config, ctxHolder.config)) await adoptConfig(ctxHolder, store, config); } catch (error) { return sessionsErrorResponse(error); } const response: SessionsSourceRemoveResponse = { id, removed: true, archiveRetained: true, }; return Response.json(response); } /** * POST /api/sessions/init (same-host only) * Body: { archive, collection }. Binds this instance's own config/index pair; * the service refuses the default config and the default index. */ export async function handleSessionsInit( ctxHolder: ContextHolder, store: SqliteAdapter, req: Request, deps: SessionsRouteDeps = {} ): Promise { const refused = localOnly(req, deps, "Creating a session archive"); if (refused) return refused; const parsed = await readObjectBody(req); if (!parsed.ok) return parsed.res; const { body } = parsed; const unknown = Object.keys(body).filter((key) => !INIT_KEYS.has(key)); if (unknown.length > 0) { return invalid(`Unknown field(s): ${unknown.join(", ")}`); } if ( typeof body.archive !== "string" || !body.archive.trim() || typeof body.collection !== "string" || !body.collection.trim() ) { return sessionsError( "SESSIONS_DESTINATION_REQUIRED", "Archive init needs { archive: , collection: }.", HTTP_BAD_REQUEST ); } const { configPath, indexName } = instanceIdentity(ctxHolder); if (!configPath) { return invalid("This server has no resolved config path."); } try { const result = await initSessionArchive({ configPath, indexName, archiveRoot: body.archive.trim(), collection: body.collection.trim(), }); await adoptConfig(ctxHolder, store, result.config); const response: SessionsInitResponse = { schemaVersion: "1", index: indexName, collection: body.collection.trim(), archiveRoot: result.archiveRoot, created: result.created, }; return Response.json(response); } catch (error) { return sessionsErrorResponse(error); } } // ───────────────────────────────────────────────────────────────────────────── // Automation // ───────────────────────────────────────────────────────────────────────────── const PROFILE_KEYS = new Set(["sources", "cadence", "limit", "retries"]); const ENABLE_KEYS = new Set(["hook", "schedule"]); const DISABLE_KEYS = new Set(["hook", "schedule"]); async function automationContext( ctxHolder: ContextHolder ): Promise { await assertInstanceBinding(ctxHolder); const { configPath, indexName } = instanceIdentity(ctxHolder); return { configPath, indexName }; } /** Keep the served config current after an automation change. */ async function adoptSavedConfig( ctxHolder: ContextHolder, store: SqliteAdapter ): Promise { const loaded = await loadConfig(instanceIdentity(ctxHolder).configPath); if (loaded.ok) await adoptConfig(ctxHolder, store, loaded.value); } function unknownKeys( body: Record, allowed: ReadonlySet ): Response | null { const unknown = Object.keys(body).filter((key) => !allowed.has(key)); return unknown.length > 0 ? invalid(`Unknown field(s): ${unknown.join(", ")}`) : null; } /** * POST /api/sessions/automation/run * Body: { profileId }. Any allowed client may request a run of a configured * profile; it cannot enable triggers or widen sources. */ export async function handleSessionsAutomationRun( ctxHolder: ContextHolder, store: SqliteAdapter, req: Request ): Promise { const parsed = await readObjectBody(req); if (!parsed.ok) return parsed.res; const { body } = parsed; const extra = unknownKeys(body, new Set(["profileId"])); if (extra) return extra; if (typeof body.profileId !== "string" || !body.profileId.trim()) { return invalid("profileId must be a non-empty string"); } try { const result = await runAutomationProfile( { ...(await automationContext(ctxHolder)), store, // A child process keeps this server answering during the run. inChildProcess: true, }, body.profileId.trim(), { trigger: "manual" } ); const collections = [ ...new Set( result.receipts.flatMap((receipt) => receipt.lexical.collections) ), ]; if (collections.length > 0) { ctxHolder.markContentMutation?.(); ctxHolder.markIndexMutation?.(); ctxHolder.scheduler?.notifySyncComplete(collections); } return Response.json(result); } catch (error) { return sessionsErrorResponse(error); } } /** GET /api/sessions/automation/:id/preview (same-host only: host paths). */ export async function handleSessionsAutomationPreview( ctxHolder: ContextHolder, id: string, req: Request, deps: SessionsRouteDeps = {} ): Promise { const refused = localOnly(req, deps, "Automation preview"); if (refused) return refused; try { return Response.json( await previewAutomationProfile(await automationContext(ctxHolder), id) ); } catch (error) { return sessionsErrorResponse(error); } } /** PUT /api/sessions/automation/:id (same-host only). Enables nothing. */ export async function handleSessionsAutomationSet( ctxHolder: ContextHolder, store: SqliteAdapter, id: string, req: Request, deps: SessionsRouteDeps = {} ): Promise { const refused = localOnly(req, deps, "Changing an automation profile"); if (refused) return refused; const parsed = await readObjectBody(req); if (!parsed.ok) return parsed.res; const { body } = parsed; const extra = unknownKeys(body, PROFILE_KEYS); if (extra) return extra; if ( !Array.isArray(body.sources) || !body.sources.every((source) => typeof source === "string") ) { return invalid("sources must be an array of registered source IDs"); } const optionalInt = (value: unknown): boolean => value === undefined || (typeof value === "number" && Number.isSafeInteger(value)); if ( (body.cadence !== undefined && typeof body.cadence !== "string") || !optionalInt(body.limit) || !optionalInt(body.retries) ) { return invalid("cadence must be a string; limit and retries integers"); } try { const preview = await setAutomationProfile( await automationContext(ctxHolder), { id, sources: body.sources as string[], cadence: body.cadence as string | undefined, limit: body.limit as number | undefined, retries: body.retries as number | undefined, } ); await adoptSavedConfig(ctxHolder, store); return Response.json(preview); } catch (error) { return sessionsErrorResponse(error); } } /** * POST /api/sessions/automation/:id/enable (same-host only) * Body: { hook?: { harness, settings? }, schedule?: { cadence? } }. */ export async function handleSessionsAutomationEnable( ctxHolder: ContextHolder, store: SqliteAdapter, id: string, req: Request, deps: SessionsRouteDeps = {} ): Promise { const refused = localOnly(req, deps, "Enabling automation"); if (refused) return refused; const parsed = await readObjectBody(req); if (!parsed.ok) return parsed.res; const { body } = parsed; const extra = unknownKeys(body, ENABLE_KEYS); if (extra) return extra; const hook = body.hook as { harness?: unknown } | undefined; const schedule = body.schedule as { cadence?: unknown } | undefined; if (hook && typeof hook === "object" && "settings" in hook) { // The browser installs only into the default or already recorded // settings file; another file is named locally with the CLI. return invalid( "hook.settings is not accepted here; choose another settings file with gno sessions automation enable --settings" ); } if ( (hook !== undefined && (typeof hook !== "object" || hook === null || typeof hook.harness !== "string")) || (schedule !== undefined && (typeof schedule !== "object" || schedule === null || (schedule.cadence !== undefined && typeof schedule.cadence !== "string"))) ) { return invalid( "Body must be { hook?: { harness, settings? }, schedule?: { cadence? } }" ); } try { const preview = await enableAutomation( await automationContext(ctxHolder), id, { ...(hook ? { hook: { harness: hook.harness as string } } : {}), ...(schedule ? { schedule: { cadence: schedule.cadence as string | undefined } } : {}), } ); await adoptSavedConfig(ctxHolder, store); return Response.json(preview); } catch (error) { return sessionsErrorResponse(error); } } /** * POST /api/sessions/automation/:id/disable (same-host only) * Body: { hook?: boolean, schedule?: boolean }; empty pauses both. */ export async function handleSessionsAutomationDisable( ctxHolder: ContextHolder, store: SqliteAdapter, id: string, req: Request, deps: SessionsRouteDeps = {} ): Promise { const refused = localOnly(req, deps, "Disabling automation"); if (refused) return refused; const parsed = await readObjectBody(req); if (!parsed.ok) return parsed.res; const { body } = parsed; const extra = unknownKeys(body, DISABLE_KEYS); if (extra) return extra; for (const key of DISABLE_KEYS) { if (body[key] !== undefined && typeof body[key] !== "boolean") { return invalid(`${key} must be a boolean`); } } try { const change = await disableAutomation( await automationContext(ctxHolder), id, { ...(body.hook === true ? { hook: true } : {}), ...(body.schedule === true ? { schedule: true } : {}), } ); await adoptSavedConfig(ctxHolder, store); return Response.json(change); } catch (error) { return sessionsErrorResponse(error); } } /** DELETE /api/sessions/automation/:id (same-host only; archive retained). */ export async function handleSessionsAutomationRemove( ctxHolder: ContextHolder, store: SqliteAdapter, id: string, req: Request, deps: SessionsRouteDeps = {} ): Promise { const refused = localOnly(req, deps, "Removing an automation profile"); if (refused) return refused; try { const change = await removeAutomationProfile( await automationContext(ctxHolder), id ); await adoptSavedConfig(ctxHolder, store); return Response.json(change); } catch (error) { return sessionsErrorResponse(error); } }