@geminixiang/mikan 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +54 -181
- package/dist/adapters/discord/bot.d.ts.map +1 -1
- package/dist/adapters/discord/bot.js +5 -4
- package/dist/adapters/discord/bot.js.map +1 -1
- package/dist/adapters/shared.d.ts +3 -2
- package/dist/adapters/shared.d.ts.map +1 -1
- package/dist/adapters/shared.js +11 -11
- package/dist/adapters/shared.js.map +1 -1
- package/dist/adapters/slack/bot.d.ts +1 -0
- package/dist/adapters/slack/bot.d.ts.map +1 -1
- package/dist/adapters/slack/bot.js +43 -1
- package/dist/adapters/slack/bot.js.map +1 -1
- package/dist/adapters/telegram/bot.d.ts.map +1 -1
- package/dist/adapters/telegram/bot.js +2 -3
- package/dist/adapters/telegram/bot.js.map +1 -1
- package/dist/admin/portal.d.ts +27 -0
- package/dist/admin/portal.d.ts.map +1 -0
- package/dist/admin/portal.js +2029 -0
- package/dist/admin/portal.js.map +1 -0
- package/dist/admin/store.d.ts +22 -0
- package/dist/admin/store.d.ts.map +1 -0
- package/dist/admin/store.js +39 -0
- package/dist/admin/store.js.map +1 -0
- package/dist/agent.d.ts +5 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -9
- package/dist/agent.js.map +1 -1
- package/dist/commands/admin.d.ts +8 -0
- package/dist/commands/admin.d.ts.map +1 -0
- package/dist/commands/admin.js +59 -0
- package/dist/commands/admin.js.map +1 -0
- package/dist/commands/auto-reply.d.ts.map +1 -1
- package/dist/commands/auto-reply.js +8 -7
- package/dist/commands/auto-reply.js.map +1 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +2 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +38 -14
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/model.d.ts.map +1 -1
- package/dist/commands/model.js +2 -2
- package/dist/commands/model.js.map +1 -1
- package/dist/commands/new.d.ts.map +1 -1
- package/dist/commands/new.js +13 -3
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/sandbox.d.ts.map +1 -1
- package/dist/commands/sandbox.js +2 -2
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/session-view.d.ts.map +1 -1
- package/dist/commands/session-view.js +3 -6
- package/dist/commands/session-view.js.map +1 -1
- package/dist/commands/types.d.ts +11 -0
- package/dist/commands/types.d.ts.map +1 -1
- package/dist/commands/types.js.map +1 -1
- package/dist/commands/utils.d.ts +1 -0
- package/dist/commands/utils.d.ts.map +1 -1
- package/dist/commands/utils.js +5 -0
- package/dist/commands/utils.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -13
- package/dist/config.js.map +1 -1
- package/dist/events.d.ts +0 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +2 -5
- package/dist/events.js.map +1 -1
- package/dist/file-guards.d.ts.map +1 -1
- package/dist/file-guards.js +10 -7
- package/dist/file-guards.js.map +1 -1
- package/dist/login/portal.d.ts +11 -1
- package/dist/login/portal.d.ts.map +1 -1
- package/dist/login/portal.js +57 -175
- package/dist/login/portal.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +5 -1
- package/dist/main.js.map +1 -1
- package/dist/portal-shell.d.ts +30 -0
- package/dist/portal-shell.d.ts.map +1 -0
- package/dist/portal-shell.js +371 -0
- package/dist/portal-shell.js.map +1 -0
- package/dist/session-view/portal.d.ts.map +1 -1
- package/dist/session-view/portal.js +88 -242
- package/dist/session-view/portal.js.map +1 -1
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +30 -12
- package/dist/store.js.map +1 -1
- package/dist/vault.d.ts.map +1 -1
- package/dist/vault.js +2 -8
- package/dist/vault.js.map +1 -1
- package/package.json +1 -1
package/dist/events.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"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","sourcesContent":["import { Type, type Static } from \"@sinclair/typebox\";\nimport { Cron } from \"croner\";\nimport {\n existsSync,\n type FSWatcher,\n readdirSync,\n readFileSync,\n statSync,\n unlinkSync,\n watch,\n} from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport type { Bot, BotEvent, ConversationKind } from \"./adapter.js\";\nimport { ensureDirExists, parseJsonSchemaValue } from \"./file-guards.js\";\nimport * as log from \"./log.js\";\nimport { reportUserFacingError } from \"./sentry.js\";\nimport { inferConversationKind } from \"./sessions/policy.js\";\n\nexport interface ImmediateEvent {\n type: \"immediate\";\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n /** Creator userId — routes tool execution to that user's vault selection when fired. */\n userId?: string;\n text: string;\n}\n\nexport interface OneShotEvent {\n type: \"one-shot\";\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n userId?: string;\n text: string;\n at: string; // ISO 8601 with timezone offset\n // No sessionKey or threadTs: reminders fire as top-level messages regardless of where they were created.\n}\n\nexport interface PeriodicEvent {\n type: \"periodic\";\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n userId?: string;\n text: string;\n schedule: string; // cron syntax\n timezone: string; // IANA timezone\n}\n\nexport type MikanEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;\n\nconst EventFileSchema = Type.Object({\n type: Type.Optional(\n Type.Union([Type.Literal(\"immediate\"), Type.Literal(\"one-shot\"), Type.Literal(\"periodic\")]),\n ),\n platform: Type.Optional(Type.String()),\n conversationId: Type.Optional(Type.String()),\n channelId: Type.Optional(Type.String()),\n conversationKind: Type.Optional(Type.Union([Type.Literal(\"direct\"), Type.Literal(\"shared\")])),\n userId: Type.Optional(Type.String()),\n text: Type.Optional(Type.String()),\n at: Type.Optional(Type.String()),\n schedule: Type.Optional(Type.String()),\n timezone: Type.Optional(Type.String()),\n});\n\ntype EventFileData = Static<typeof EventFileSchema>;\n\nexport interface PeriodicEventInfo {\n filename: string;\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n text: string;\n schedule: string;\n timezone: string;\n nextRun: string | null; // ISO 8601\n}\n\nconst DEBOUNCE_MS = 100;\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 100;\n\nexport class EventsWatcher {\n private timers: Map<string, NodeJS.Timeout> = new Map();\n private timerEventTypes: Map<string, \"one-shot\"> = new Map();\n private crons: Map<string, Cron> = new Map();\n private debounceTimers: Map<string, NodeJS.Timeout> = new Map();\n private startTime: number;\n private watcher: FSWatcher | null = null;\n private knownFiles: Set<string> = new Set();\n\n constructor(\n private eventsDir: string,\n private botsByPlatform: Record<string, Bot>,\n ) {\n this.startTime = Date.now();\n }\n\n /**\n * Start watching for events. Call this after platform bots are initialized.\n */\n start(): void {\n // Ensure events directory exists\n ensureDirExists(this.eventsDir);\n\n log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);\n\n // Scan existing files\n this.scanExisting();\n\n // Watch for changes\n this.watcher = watch(this.eventsDir, (eventType, filename) => {\n if (!filename || !filename.endsWith(\".json\")) return;\n log.logInfo(\n `Events watcher fs event: ${String(eventType)} ${filename} (exists=${existsSync(join(this.eventsDir, filename))})`,\n );\n this.debounce(filename, () => this.handleFileChange(filename));\n });\n\n log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);\n }\n\n /**\n * Stop watching and cancel all scheduled events.\n */\n stop(): void {\n // Stop fs watcher\n if (this.watcher) {\n this.watcher.close();\n this.watcher = null;\n }\n\n // Cancel all debounce timers\n for (const timer of this.debounceTimers.values()) {\n clearTimeout(timer);\n }\n this.debounceTimers.clear();\n\n // Cancel all scheduled timers\n for (const timer of this.timers.values()) {\n clearTimeout(timer);\n }\n this.timers.clear();\n this.timerEventTypes.clear();\n\n // Cancel all cron jobs\n for (const cron of this.crons.values()) {\n cron.stop();\n }\n this.crons.clear();\n\n this.knownFiles.clear();\n log.logInfo(\"Events watcher stopped\");\n }\n\n /**\n * Return all active periodic (cron) events with their next run time.\n */\n getPeriodicEvents(): PeriodicEventInfo[] {\n const results: PeriodicEventInfo[] = [];\n for (const [filename, cron] of this.crons) {\n const filePath = join(this.eventsDir, filename);\n try {\n const content = readFileSync(filePath, \"utf-8\");\n const data = this.parseEvent(content, filename);\n if (!data || data.type !== \"periodic\") {\n continue;\n }\n const next = cron.nextRun();\n results.push({\n filename,\n platform: data.platform,\n conversationId: data.conversationId,\n conversationKind: data.conversationKind,\n text: data.text,\n schedule: data.schedule,\n timezone: data.timezone,\n nextRun: next?.toISOString() ?? null,\n });\n } catch {\n // File may have been deleted or corrupted, skip\n }\n }\n return results;\n }\n\n private debounce(filename: string, fn: () => void): void {\n const existing = this.debounceTimers.get(filename);\n if (existing) {\n clearTimeout(existing);\n }\n this.debounceTimers.set(\n filename,\n setTimeout(() => {\n this.debounceTimers.delete(filename);\n fn();\n }, DEBOUNCE_MS),\n );\n }\n\n private scanExisting(): void {\n let files: string[];\n try {\n files = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n } catch (err) {\n log.logWarning(\"Failed to read events directory\", String(err));\n return;\n }\n\n for (const filename of files) {\n this.handleFile(filename);\n }\n }\n\n private handleFileChange(filename: string): void {\n const filePath = join(this.eventsDir, filename);\n const exists = existsSync(filePath);\n const known = this.knownFiles.has(filename);\n log.logInfo(`Handling event file change: ${filename} (exists=${exists}, known=${known})`);\n\n if (!exists) {\n // fs.watch can briefly report a file as missing during create/rename churn.\n // Confirm deletion before canceling scheduled events.\n void this.handleDelete(filename);\n } else if (known) {\n // File was modified - cancel existing and re-schedule\n this.cancelScheduled(filename, \"file-modified\");\n void this.handleFile(filename);\n } else {\n // New file\n void this.handleFile(filename);\n }\n }\n\n private async handleDelete(filename: string): Promise<void> {\n if (!this.knownFiles.has(filename)) return;\n\n const filePath = join(this.eventsDir, filename);\n for (let i = 0; i < MAX_RETRIES; i++) {\n const delay = RETRY_BASE_MS * 2 ** i;\n await this.sleep(delay);\n const exists = existsSync(filePath);\n log.logInfo(`Confirming event deletion: ${filename} after ${delay}ms (exists=${exists})`);\n if (exists) {\n return;\n }\n }\n\n if (this.timerEventTypes.get(filename) === \"one-shot\" && this.timers.has(filename)) {\n log.logInfo(\n `Ignoring deleted one-shot file after scheduling: ${filename} (timer remains active)`,\n );\n return;\n }\n\n log.logInfo(`Event file deleted: ${filename}`);\n this.cancelScheduled(filename, \"confirmed-delete\");\n this.knownFiles.delete(filename);\n }\n\n private cancelScheduled(filename: string, reason = \"unspecified\"): void {\n const timer = this.timers.get(filename);\n const cron = this.crons.get(filename);\n log.logInfo(\n `Canceling scheduled event: ${filename} (reason=${reason}, timer=${Boolean(timer)}, cron=${Boolean(cron)})`,\n );\n if (timer) {\n clearTimeout(timer);\n this.timers.delete(filename);\n this.timerEventTypes.delete(filename);\n }\n\n if (cron) {\n cron.stop();\n this.crons.delete(filename);\n }\n }\n\n private async handleFile(filename: string): Promise<void> {\n const filePath = join(this.eventsDir, filename);\n log.logInfo(`Loading event file: ${filename} from ${filePath}`);\n\n // Parse with retries\n let event: MikanEvent | null = null;\n let lastError: Error | null = null;\n\n for (let i = 0; i < MAX_RETRIES; i++) {\n try {\n const content = await readFile(filePath, \"utf-8\");\n event = this.parseEvent(content, filename);\n break;\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (i < MAX_RETRIES - 1) {\n await this.sleep(RETRY_BASE_MS * 2 ** i);\n }\n }\n }\n\n if (!event) {\n log.logWarning(\n `Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`,\n lastError?.message,\n );\n this.deleteFile(filename, \"parse-failed\");\n return;\n }\n\n this.knownFiles.add(filename);\n log.logInfo(\n `Parsed event file: ${filename} (${event.type} for ${event.platform}/${event.conversationId})`,\n );\n\n // Schedule based on type\n switch (event.type) {\n case \"immediate\":\n this.handleImmediate(filename, event);\n break;\n case \"one-shot\":\n this.handleOneShot(filename, event);\n break;\n case \"periodic\":\n this.handlePeriodic(filename, event);\n break;\n }\n }\n\n private parseEvent(content: string, filename: string): MikanEvent | null {\n const data: EventFileData = parseJsonSchemaValue(content, EventFileSchema, (detail) =>\n detail === \"unexpected JSON shape\"\n ? `Expected top-level JSON object in ${filename}`\n : `Malformed event file ${filename}: ${detail}`,\n );\n const conversationId =\n typeof data.conversationId === \"string\"\n ? data.conversationId\n : typeof data.channelId === \"string\"\n ? data.channelId\n : undefined;\n const type = typeof data.type === \"string\" ? data.type : undefined;\n const text = typeof data.text === \"string\" ? data.text : undefined;\n\n if (!type || !conversationId || !text) {\n throw new Error(`Missing required fields (type, conversationId, text) in ${filename}`);\n }\n\n const platform = this.resolvePlatform(data.platform, filename);\n const conversationKind = this.resolveConversationKind(\n platform,\n conversationId,\n data.conversationKind,\n );\n const userId = typeof data.userId === \"string\" ? data.userId : undefined;\n switch (type) {\n case \"immediate\":\n return {\n type: \"immediate\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text,\n };\n\n case \"one-shot\":\n if (typeof data.at !== \"string\" || data.at.length === 0) {\n throw new Error(`Missing 'at' field for one-shot event in ${filename}`);\n }\n return {\n type: \"one-shot\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text,\n at: data.at,\n };\n\n case \"periodic\":\n if (typeof data.schedule !== \"string\" || data.schedule.length === 0) {\n throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n }\n if (typeof data.timezone !== \"string\" || data.timezone.length === 0) {\n throw new Error(`Missing 'timezone' field for periodic event in ${filename}`);\n }\n return {\n type: \"periodic\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text,\n schedule: data.schedule,\n timezone: data.timezone,\n };\n\n default:\n throw new Error(`Unknown event type '${type}' in ${filename}`);\n }\n }\n\n private resolvePlatform(platformValue: unknown, filename: string): string {\n const availablePlatforms = Object.keys(this.botsByPlatform);\n\n if (typeof platformValue === \"string\" && platformValue.trim().length > 0) {\n const platform = platformValue.trim().toLowerCase();\n if (!this.botsByPlatform[platform]) {\n throw new Error(\n `Unknown platform '${platformValue}' in ${filename}. Expected one of: ${availablePlatforms.join(\", \")}`,\n );\n }\n return platform;\n }\n\n if (availablePlatforms.length === 1) {\n return availablePlatforms[0];\n }\n\n throw new Error(\n `Missing required field 'platform' in ${filename}. Available platforms: ${availablePlatforms.join(\", \")}`,\n );\n }\n\n private resolveConversationKind(\n platform: string,\n conversationId: string,\n conversationKindValue: unknown,\n ): ConversationKind {\n if (conversationKindValue === \"direct\" || conversationKindValue === \"shared\") {\n return conversationKindValue;\n }\n\n return inferConversationKind(platform, conversationId);\n }\n\n private handleImmediate(filename: string, event: ImmediateEvent): void {\n const filePath = join(this.eventsDir, filename);\n\n // Check if stale (created before harness started)\n try {\n const stat = statSync(filePath);\n if (stat.mtimeMs < this.startTime) {\n log.logInfo(`Stale immediate event, deleting: ${filename}`);\n this.deleteFile(filename, \"stale-immediate\");\n return;\n }\n } catch {\n // File may have been deleted\n return;\n }\n\n log.logInfo(`Executing immediate event: ${filename}`);\n this.execute(filename, event);\n }\n\n private handleOneShot(filename: string, event: OneShotEvent): void {\n const atTime = new Date(event.at).getTime();\n const now = Date.now();\n\n if (atTime <= now) {\n // Past - delete without executing\n log.logInfo(`One-shot event in the past, deleting: ${filename}`);\n this.deleteFile(filename, \"one-shot-in-past\");\n return;\n }\n\n const delay = atTime - now;\n log.logInfo(\n `Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s (at=${event.at}, now=${new Date(now).toISOString()})`,\n );\n\n const timer = setTimeout(() => {\n this.timers.delete(filename);\n this.timerEventTypes.delete(filename);\n log.logInfo(`Executing one-shot event: ${filename}`);\n this.execute(filename, event);\n }, delay);\n\n this.timers.set(filename, timer);\n this.timerEventTypes.set(filename, \"one-shot\");\n log.logInfo(`Stored one-shot timer: ${filename} (active timers=${this.timers.size})`);\n }\n\n private handlePeriodic(filename: string, event: PeriodicEvent): void {\n try {\n const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {\n log.logInfo(`Executing periodic event: ${filename}`);\n this.execute(filename, event, false); // Don't delete periodic events\n });\n\n this.crons.set(filename, cron);\n\n const next = cron.nextRun();\n log.logInfo(\n `Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? \"unknown\"}`,\n );\n } catch (err) {\n log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));\n this.deleteFile(filename, \"invalid-cron\");\n }\n }\n\n private execute(filename: string, event: MikanEvent, deleteAfter: boolean = true): void {\n const message = this.buildEventPrompt(event);\n const bot = this.botsByPlatform[event.platform];\n\n if (!bot) {\n log.logWarning(`No bot configured for event platform '${event.platform}'`, filename);\n reportUserFacingError(new Error(\"Scheduled event delivery failed: missing bot\"), {\n domain: \"events\",\n surface: \"event_delivery\",\n operation: \"event_execute\",\n severity: \"error\",\n platform: event.platform,\n context: {\n failure: \"missing_bot\",\n filename,\n eventType: event.type,\n conversationId: event.conversationId,\n conversationKind: event.conversationKind,\n deleteAfter,\n triggeredByEventFile: true,\n textLength: event.text.length,\n },\n });\n if (deleteAfter) {\n this.deleteFile(filename, \"missing-bot\");\n }\n return;\n }\n\n const eventId = filename.replace(/\\.json$/i, \"\");\n const botEvent: BotEvent = {\n type: \"mention\",\n conversationId: event.conversationId,\n conversationKind: event.conversationKind,\n user: event.userId ?? \"EVENT\",\n text: message,\n ts: `event:${eventId}`,\n };\n\n // Enqueue for processing\n const enqueued = bot.enqueueEvent(botEvent);\n\n if (enqueued && deleteAfter) {\n // Delete file after successful enqueue (immediate and one-shot)\n this.deleteFile(filename, \"executed-and-enqueued\");\n } else if (!enqueued) {\n log.logWarning(`Event queue full, discarded: ${filename}`);\n reportUserFacingError(new Error(\"Scheduled event delivery failed: queue full\"), {\n domain: \"events\",\n surface: \"event_delivery\",\n operation: \"event_execute\",\n severity: \"error\",\n platform: event.platform,\n context: {\n failure: \"queue_full\",\n filename,\n eventType: event.type,\n conversationId: event.conversationId,\n conversationKind: event.conversationKind,\n deleteAfter,\n triggeredByEventFile: true,\n textLength: event.text.length,\n },\n });\n // Still delete immediate/one-shot even if discarded\n if (deleteAfter) {\n this.deleteFile(filename, \"queue-full-discarded\");\n }\n }\n }\n\n private buildEventPrompt(event: MikanEvent): string {\n switch (event.type) {\n case \"one-shot\":\n return [\n \"Please deliver the following reminder to the user in a short, natural way.\",\n \"Do not greet, do not introduce yourself, and do not ask generic follow-up questions.\",\n \"\",\n `Reminder: ${event.text}`,\n ].join(\"\\n\");\n case \"periodic\":\n return [\n \"Handle the following recurring task.\",\n \"Respond concisely. If there is nothing actionable to report, reply with [SILENT].\",\n \"\",\n `Task: ${event.text}`,\n ].join(\"\\n\");\n case \"immediate\":\n return [\n \"Handle the following event/update in a concise, context-appropriate way.\",\n \"If it reads like a reminder or follow-up, deliver it directly without greeting or generic offers to help.\",\n \"\",\n `Event: ${event.text}`,\n ].join(\"\\n\");\n }\n }\n\n private deleteFile(filename: string, reason = \"unspecified\"): void {\n const filePath = join(this.eventsDir, filename);\n log.logInfo(`Deleting event file: ${filename} (reason=${reason})`);\n try {\n unlinkSync(filePath);\n } catch (err) {\n // ENOENT is fine (file already deleted), other errors are warnings\n if (err instanceof Error && \"code\" in err && err.code !== \"ENOENT\") {\n log.logWarning(`Failed to delete event file: ${filename}`, String(err));\n }\n }\n this.knownFiles.delete(filename);\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n}\n\n/**\n * Create an events watcher for all configured platforms.\n */\nexport function createEventsWatcher(\n workspaceDir: string,\n botsByPlatform: Record<string, Bot>,\n): EventsWatcher {\n const eventsDir = join(workspaceDir, \"events\");\n return new EventsWatcher(eventsDir, botsByPlatform);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"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","sourcesContent":["import { Type, type Static } from \"@sinclair/typebox\";\nimport { Cron } from \"croner\";\nimport {\n existsSync,\n type FSWatcher,\n readdirSync,\n readFileSync,\n statSync,\n unlinkSync,\n watch,\n} from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport type { Bot, BotEvent, ConversationKind } from \"./adapter.js\";\nimport { ensureDirExists, parseJsonSchemaValue } from \"./file-guards.js\";\nimport * as log from \"./log.js\";\nimport { reportUserFacingError } from \"./sentry.js\";\nimport { inferConversationKind } from \"./sessions/policy.js\";\n\nexport interface ImmediateEvent {\n type: \"immediate\";\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n /** Creator userId — routes tool execution to that user's vault selection when fired. */\n userId?: string;\n text: string;\n}\n\nexport interface OneShotEvent {\n type: \"one-shot\";\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n userId?: string;\n text: string;\n at: string; // ISO 8601 with timezone offset\n // No sessionKey or threadTs: reminders fire as top-level messages regardless of where they were created.\n}\n\nexport interface PeriodicEvent {\n type: \"periodic\";\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n userId?: string;\n text: string;\n schedule: string; // cron syntax\n timezone: string; // IANA timezone\n}\n\nexport type MikanEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;\n\nconst EventFileSchema = Type.Object({\n type: Type.Optional(\n Type.Union([Type.Literal(\"immediate\"), Type.Literal(\"one-shot\"), Type.Literal(\"periodic\")]),\n ),\n platform: Type.Optional(Type.String()),\n conversationId: Type.Optional(Type.String()),\n channelId: Type.Optional(Type.String()),\n conversationKind: Type.Optional(Type.Union([Type.Literal(\"direct\"), Type.Literal(\"shared\")])),\n userId: Type.Optional(Type.String()),\n text: Type.Optional(Type.String()),\n at: Type.Optional(Type.String()),\n schedule: Type.Optional(Type.String()),\n timezone: Type.Optional(Type.String()),\n});\n\ntype EventFileData = Static<typeof EventFileSchema>;\n\nexport interface PeriodicEventInfo {\n filename: string;\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n text: string;\n schedule: string;\n timezone: string;\n nextRun: string | null; // ISO 8601\n}\n\nconst DEBOUNCE_MS = 100;\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 100;\n\nexport class EventsWatcher {\n private timers: Map<string, NodeJS.Timeout> = new Map();\n private timerEventTypes: Map<string, \"one-shot\"> = new Map();\n private crons: Map<string, Cron> = new Map();\n private debounceTimers: Map<string, NodeJS.Timeout> = new Map();\n private startTime: number;\n private watcher: FSWatcher | null = null;\n private knownFiles: Set<string> = new Set();\n\n constructor(\n private eventsDir: string,\n private botsByPlatform: Record<string, Bot>,\n ) {\n this.startTime = Date.now();\n }\n\n /**\n * Start watching for events. Call this after platform bots are initialized.\n */\n start(): void {\n // Ensure events directory exists\n ensureDirExists(this.eventsDir);\n\n log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);\n\n // Scan existing files\n this.scanExisting();\n\n // Watch for changes\n this.watcher = watch(this.eventsDir, (eventType, filename) => {\n if (!filename || !filename.endsWith(\".json\")) return;\n log.logInfo(\n `Events watcher fs event: ${String(eventType)} ${filename} (exists=${existsSync(join(this.eventsDir, filename))})`,\n );\n this.debounce(filename, () => this.handleFileChange(filename));\n });\n\n log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);\n }\n\n /**\n * Stop watching and cancel all scheduled events.\n */\n stop(): void {\n // Stop fs watcher\n if (this.watcher) {\n this.watcher.close();\n this.watcher = null;\n }\n\n // Cancel all debounce timers\n for (const timer of this.debounceTimers.values()) {\n clearTimeout(timer);\n }\n this.debounceTimers.clear();\n\n // Cancel all scheduled timers\n for (const timer of this.timers.values()) {\n clearTimeout(timer);\n }\n this.timers.clear();\n this.timerEventTypes.clear();\n\n // Cancel all cron jobs\n for (const cron of this.crons.values()) {\n cron.stop();\n }\n this.crons.clear();\n\n this.knownFiles.clear();\n log.logInfo(\"Events watcher stopped\");\n }\n\n /**\n * Return all active periodic (cron) events with their next run time.\n */\n getPeriodicEvents(): PeriodicEventInfo[] {\n const results: PeriodicEventInfo[] = [];\n for (const [filename, cron] of this.crons) {\n const filePath = join(this.eventsDir, filename);\n try {\n const content = readFileSync(filePath, \"utf-8\");\n const data = this.parseEvent(content, filename);\n if (!data || data.type !== \"periodic\") {\n continue;\n }\n const next = cron.nextRun();\n results.push({\n filename,\n platform: data.platform,\n conversationId: data.conversationId,\n conversationKind: data.conversationKind,\n text: data.text,\n schedule: data.schedule,\n timezone: data.timezone,\n nextRun: next?.toISOString() ?? null,\n });\n } catch {\n // File may have been deleted or corrupted, skip\n }\n }\n return results;\n }\n\n private debounce(filename: string, fn: () => void): void {\n const existing = this.debounceTimers.get(filename);\n if (existing) {\n clearTimeout(existing);\n }\n this.debounceTimers.set(\n filename,\n setTimeout(() => {\n this.debounceTimers.delete(filename);\n fn();\n }, DEBOUNCE_MS),\n );\n }\n\n private scanExisting(): void {\n let files: string[];\n try {\n files = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n } catch (err) {\n log.logWarning(\"Failed to read events directory\", String(err));\n return;\n }\n\n for (const filename of files) {\n this.handleFile(filename);\n }\n }\n\n private handleFileChange(filename: string): void {\n const filePath = join(this.eventsDir, filename);\n const exists = existsSync(filePath);\n const known = this.knownFiles.has(filename);\n log.logInfo(`Handling event file change: ${filename} (exists=${exists}, known=${known})`);\n\n if (!exists) {\n // fs.watch can briefly report a file as missing during create/rename churn.\n // Confirm deletion before canceling scheduled events.\n void this.handleDelete(filename);\n } else if (known) {\n // File was modified - cancel existing and re-schedule\n this.cancelScheduled(filename, \"file-modified\");\n void this.handleFile(filename);\n } else {\n // New file\n void this.handleFile(filename);\n }\n }\n\n private async handleDelete(filename: string): Promise<void> {\n if (!this.knownFiles.has(filename)) return;\n\n const filePath = join(this.eventsDir, filename);\n for (let i = 0; i < MAX_RETRIES; i++) {\n const delay = RETRY_BASE_MS * 2 ** i;\n await new Promise((resolve) => setTimeout(resolve, delay));\n const exists = existsSync(filePath);\n log.logInfo(`Confirming event deletion: ${filename} after ${delay}ms (exists=${exists})`);\n if (exists) {\n return;\n }\n }\n\n if (this.timerEventTypes.get(filename) === \"one-shot\" && this.timers.has(filename)) {\n log.logInfo(\n `Ignoring deleted one-shot file after scheduling: ${filename} (timer remains active)`,\n );\n return;\n }\n\n log.logInfo(`Event file deleted: ${filename}`);\n this.cancelScheduled(filename, \"confirmed-delete\");\n this.knownFiles.delete(filename);\n }\n\n private cancelScheduled(filename: string, reason = \"unspecified\"): void {\n const timer = this.timers.get(filename);\n const cron = this.crons.get(filename);\n log.logInfo(\n `Canceling scheduled event: ${filename} (reason=${reason}, timer=${Boolean(timer)}, cron=${Boolean(cron)})`,\n );\n if (timer) {\n clearTimeout(timer);\n this.timers.delete(filename);\n this.timerEventTypes.delete(filename);\n }\n\n if (cron) {\n cron.stop();\n this.crons.delete(filename);\n }\n }\n\n private async handleFile(filename: string): Promise<void> {\n const filePath = join(this.eventsDir, filename);\n log.logInfo(`Loading event file: ${filename} from ${filePath}`);\n\n // Parse with retries\n let event: MikanEvent | null = null;\n let lastError: Error | null = null;\n\n for (let i = 0; i < MAX_RETRIES; i++) {\n try {\n const content = await readFile(filePath, \"utf-8\");\n event = this.parseEvent(content, filename);\n break;\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (i < MAX_RETRIES - 1) {\n await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * 2 ** i));\n }\n }\n }\n\n if (!event) {\n log.logWarning(\n `Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`,\n lastError?.message,\n );\n this.deleteFile(filename, \"parse-failed\");\n return;\n }\n\n this.knownFiles.add(filename);\n log.logInfo(\n `Parsed event file: ${filename} (${event.type} for ${event.platform}/${event.conversationId})`,\n );\n\n // Schedule based on type\n switch (event.type) {\n case \"immediate\":\n this.handleImmediate(filename, event);\n break;\n case \"one-shot\":\n this.handleOneShot(filename, event);\n break;\n case \"periodic\":\n this.handlePeriodic(filename, event);\n break;\n }\n }\n\n private parseEvent(content: string, filename: string): MikanEvent | null {\n const data: EventFileData = parseJsonSchemaValue(content, EventFileSchema, (detail) =>\n detail === \"unexpected JSON shape\"\n ? `Expected top-level JSON object in ${filename}`\n : `Malformed event file ${filename}: ${detail}`,\n );\n const conversationId =\n typeof data.conversationId === \"string\"\n ? data.conversationId\n : typeof data.channelId === \"string\"\n ? data.channelId\n : undefined;\n const type = typeof data.type === \"string\" ? data.type : undefined;\n const text = typeof data.text === \"string\" ? data.text : undefined;\n\n if (!type || !conversationId || !text) {\n throw new Error(`Missing required fields (type, conversationId, text) in ${filename}`);\n }\n\n const platform = this.resolvePlatform(data.platform, filename);\n const conversationKind = this.resolveConversationKind(\n platform,\n conversationId,\n data.conversationKind,\n );\n const userId = typeof data.userId === \"string\" ? data.userId : undefined;\n switch (type) {\n case \"immediate\":\n return {\n type: \"immediate\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text,\n };\n\n case \"one-shot\":\n if (typeof data.at !== \"string\" || data.at.length === 0) {\n throw new Error(`Missing 'at' field for one-shot event in ${filename}`);\n }\n return {\n type: \"one-shot\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text,\n at: data.at,\n };\n\n case \"periodic\":\n if (typeof data.schedule !== \"string\" || data.schedule.length === 0) {\n throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n }\n if (typeof data.timezone !== \"string\" || data.timezone.length === 0) {\n throw new Error(`Missing 'timezone' field for periodic event in ${filename}`);\n }\n return {\n type: \"periodic\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text,\n schedule: data.schedule,\n timezone: data.timezone,\n };\n\n default:\n throw new Error(`Unknown event type '${type}' in ${filename}`);\n }\n }\n\n private resolvePlatform(platformValue: unknown, filename: string): string {\n const availablePlatforms = Object.keys(this.botsByPlatform);\n\n if (typeof platformValue === \"string\" && platformValue.trim().length > 0) {\n const platform = platformValue.trim().toLowerCase();\n if (!this.botsByPlatform[platform]) {\n throw new Error(\n `Unknown platform '${platformValue}' in ${filename}. Expected one of: ${availablePlatforms.join(\", \")}`,\n );\n }\n return platform;\n }\n\n if (availablePlatforms.length === 1) {\n return availablePlatforms[0];\n }\n\n throw new Error(\n `Missing required field 'platform' in ${filename}. Available platforms: ${availablePlatforms.join(\", \")}`,\n );\n }\n\n private resolveConversationKind(\n platform: string,\n conversationId: string,\n conversationKindValue: unknown,\n ): ConversationKind {\n if (conversationKindValue === \"direct\" || conversationKindValue === \"shared\") {\n return conversationKindValue;\n }\n\n return inferConversationKind(platform, conversationId);\n }\n\n private handleImmediate(filename: string, event: ImmediateEvent): void {\n const filePath = join(this.eventsDir, filename);\n\n // Check if stale (created before harness started)\n try {\n const stat = statSync(filePath);\n if (stat.mtimeMs < this.startTime) {\n log.logInfo(`Stale immediate event, deleting: ${filename}`);\n this.deleteFile(filename, \"stale-immediate\");\n return;\n }\n } catch {\n // File may have been deleted\n return;\n }\n\n log.logInfo(`Executing immediate event: ${filename}`);\n this.execute(filename, event);\n }\n\n private handleOneShot(filename: string, event: OneShotEvent): void {\n const atTime = new Date(event.at).getTime();\n const now = Date.now();\n\n if (atTime <= now) {\n // Past - delete without executing\n log.logInfo(`One-shot event in the past, deleting: ${filename}`);\n this.deleteFile(filename, \"one-shot-in-past\");\n return;\n }\n\n const delay = atTime - now;\n log.logInfo(\n `Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s (at=${event.at}, now=${new Date(now).toISOString()})`,\n );\n\n const timer = setTimeout(() => {\n this.timers.delete(filename);\n this.timerEventTypes.delete(filename);\n log.logInfo(`Executing one-shot event: ${filename}`);\n this.execute(filename, event);\n }, delay);\n\n this.timers.set(filename, timer);\n this.timerEventTypes.set(filename, \"one-shot\");\n log.logInfo(`Stored one-shot timer: ${filename} (active timers=${this.timers.size})`);\n }\n\n private handlePeriodic(filename: string, event: PeriodicEvent): void {\n try {\n const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {\n log.logInfo(`Executing periodic event: ${filename}`);\n this.execute(filename, event, false); // Don't delete periodic events\n });\n\n this.crons.set(filename, cron);\n\n const next = cron.nextRun();\n log.logInfo(\n `Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? \"unknown\"}`,\n );\n } catch (err) {\n log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));\n this.deleteFile(filename, \"invalid-cron\");\n }\n }\n\n private execute(filename: string, event: MikanEvent, deleteAfter: boolean = true): void {\n const message = this.buildEventPrompt(event);\n const bot = this.botsByPlatform[event.platform];\n\n if (!bot) {\n log.logWarning(`No bot configured for event platform '${event.platform}'`, filename);\n reportUserFacingError(new Error(\"Scheduled event delivery failed: missing bot\"), {\n domain: \"events\",\n surface: \"event_delivery\",\n operation: \"event_execute\",\n severity: \"error\",\n platform: event.platform,\n context: {\n failure: \"missing_bot\",\n filename,\n eventType: event.type,\n conversationId: event.conversationId,\n conversationKind: event.conversationKind,\n deleteAfter,\n triggeredByEventFile: true,\n textLength: event.text.length,\n },\n });\n if (deleteAfter) {\n this.deleteFile(filename, \"missing-bot\");\n }\n return;\n }\n\n const eventId = filename.replace(/\\.json$/i, \"\");\n const botEvent: BotEvent = {\n type: \"mention\",\n conversationId: event.conversationId,\n conversationKind: event.conversationKind,\n user: event.userId ?? \"EVENT\",\n text: message,\n ts: `event:${eventId}`,\n };\n\n // Enqueue for processing\n const enqueued = bot.enqueueEvent(botEvent);\n\n if (enqueued && deleteAfter) {\n // Delete file after successful enqueue (immediate and one-shot)\n this.deleteFile(filename, \"executed-and-enqueued\");\n } else if (!enqueued) {\n log.logWarning(`Event queue full, discarded: ${filename}`);\n reportUserFacingError(new Error(\"Scheduled event delivery failed: queue full\"), {\n domain: \"events\",\n surface: \"event_delivery\",\n operation: \"event_execute\",\n severity: \"error\",\n platform: event.platform,\n context: {\n failure: \"queue_full\",\n filename,\n eventType: event.type,\n conversationId: event.conversationId,\n conversationKind: event.conversationKind,\n deleteAfter,\n triggeredByEventFile: true,\n textLength: event.text.length,\n },\n });\n // Still delete immediate/one-shot even if discarded\n if (deleteAfter) {\n this.deleteFile(filename, \"queue-full-discarded\");\n }\n }\n }\n\n private buildEventPrompt(event: MikanEvent): string {\n switch (event.type) {\n case \"one-shot\":\n return [\n \"Please deliver the following reminder to the user in a short, natural way.\",\n \"Do not greet, do not introduce yourself, and do not ask generic follow-up questions.\",\n \"\",\n `Reminder: ${event.text}`,\n ].join(\"\\n\");\n case \"periodic\":\n return [\n \"Handle the following recurring task.\",\n \"Respond concisely. If there is nothing actionable to report, reply with [SILENT].\",\n \"\",\n `Task: ${event.text}`,\n ].join(\"\\n\");\n case \"immediate\":\n return [\n \"Handle the following event/update in a concise, context-appropriate way.\",\n \"If it reads like a reminder or follow-up, deliver it directly without greeting or generic offers to help.\",\n \"\",\n `Event: ${event.text}`,\n ].join(\"\\n\");\n }\n }\n\n private deleteFile(filename: string, reason = \"unspecified\"): void {\n const filePath = join(this.eventsDir, filename);\n log.logInfo(`Deleting event file: ${filename} (reason=${reason})`);\n try {\n unlinkSync(filePath);\n } catch (err) {\n // ENOENT is fine (file already deleted), other errors are warnings\n if (err instanceof Error && \"code\" in err && err.code !== \"ENOENT\") {\n log.logWarning(`Failed to delete event file: ${filename}`, String(err));\n }\n }\n this.knownFiles.delete(filename);\n }\n}\n\n/**\n * Create an events watcher for all configured platforms.\n */\nexport function createEventsWatcher(\n workspaceDir: string,\n botsByPlatform: Record<string, Bot>,\n): EventsWatcher {\n const eventsDir = join(workspaceDir, \"events\");\n return new EventsWatcher(eventsDir, botsByPlatform);\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-guards.d.ts","sourceRoot":"","sources":["../src/file-guards.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAIzD,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"file-guards.d.ts","sourceRoot":"","sources":["../src/file-guards.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAIzD,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAEjD;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CASrE;AAED,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,KAAK,IAAI,CAAC,EACxC,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAC3C,CAAC,GAAG,SAAS,CAGf;AAED,wBAAgB,0BAA0B,CAAC,CAAC,SAAS,OAAO,EAC1D,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,CAAC,EACT,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAC3C,MAAM,CAAC,CAAC,CAAC,GAAG,SAAS,CAGvB;AAWD,wBAAgB,cAAc,CAAC,CAAC,EAC9B,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,KAAK,IAAI,CAAC,EACxC,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAC3C,CAAC,CAMH;AAED,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,OAAO,EACpD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,CAAC,EACT,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAC3C,MAAM,CAAC,CAAC,CAAC,CAeX;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE","sourcesContent":["import type { Static, TSchema } from \"@sinclair/typebox\";\nimport { Value } from \"@sinclair/typebox/value\";\nimport { mkdirSync, readFileSync } from \"fs\";\n\nexport function ensureDirExists(dir: string): void {\n mkdirSync(dir, { recursive: true });\n}\n\nexport function readTextFileIfExists(path: string): string | undefined {\n try {\n return readFileSync(path, \"utf-8\");\n } catch (err) {\n if (err instanceof Error && \"code\" in err && err.code === \"ENOENT\") {\n return undefined;\n }\n throw err;\n }\n}\n\nexport function readJsonFileIfExists<T>(\n path: string,\n validate: (value: unknown) => value is T,\n malformedMessage: (detail: string) => string,\n): T | undefined {\n const raw = readTextFileIfExists(path);\n return raw === undefined ? undefined : parseJsonValue(raw, validate, malformedMessage);\n}\n\nexport function readJsonSchemaFileIfExists<T extends TSchema>(\n path: string,\n schema: T,\n malformedMessage: (detail: string) => string,\n): Static<T> | undefined {\n const raw = readTextFileIfExists(path);\n return raw === undefined ? undefined : parseJsonSchemaValue(raw, schema, malformedMessage);\n}\n\nfunction parseJson(raw: string, malformedMessage: (detail: string) => string): unknown {\n try {\n return JSON.parse(raw);\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n throw new Error(malformedMessage(detail), { cause: err });\n }\n}\n\nexport function parseJsonValue<T>(\n raw: string,\n validate: (value: unknown) => value is T,\n malformedMessage: (detail: string) => string,\n): T {\n const parsed = parseJson(raw, malformedMessage);\n if (!validate(parsed)) {\n throw new Error(malformedMessage(\"unexpected JSON shape\"));\n }\n return parsed;\n}\n\nexport function parseJsonSchemaValue<T extends TSchema>(\n raw: string,\n schema: T,\n malformedMessage: (detail: string) => string,\n): Static<T> {\n const parsed = parseJson(raw, malformedMessage);\n if (!Value.Check(schema, parsed)) {\n let firstError: { path: string; message: string } | undefined;\n for (const err of Value.Errors(schema, parsed)) {\n firstError = err;\n break;\n }\n const detail =\n !firstError || firstError.path === \"\" || firstError.path === \"/\"\n ? \"unexpected JSON shape\"\n : `${firstError.path}: ${firstError.message}`;\n throw new Error(malformedMessage(detail));\n }\n return parsed;\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n"]}
|
package/dist/file-guards.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { Value } from "@sinclair/typebox/value";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, readFileSync } from "fs";
|
|
3
3
|
export function ensureDirExists(dir) {
|
|
4
|
-
|
|
5
|
-
mkdirSync(dir, { recursive: true });
|
|
6
|
-
}
|
|
4
|
+
mkdirSync(dir, { recursive: true });
|
|
7
5
|
}
|
|
8
6
|
export function readTextFileIfExists(path) {
|
|
9
|
-
|
|
10
|
-
return
|
|
7
|
+
try {
|
|
8
|
+
return readFileSync(path, "utf-8");
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
throw err;
|
|
11
15
|
}
|
|
12
|
-
return readFileSync(path, "utf-8");
|
|
13
16
|
}
|
|
14
17
|
export function readJsonFileIfExists(path, validate, malformedMessage) {
|
|
15
18
|
const raw = readTextFileIfExists(path);
|
package/dist/file-guards.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-guards.js","sourceRoot":"","sources":["../src/file-guards.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAChD,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"file-guards.js","sourceRoot":"","sources":["../src/file-guards.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Static, TSchema } from \"@sinclair/typebox\";\nimport { Value } from \"@sinclair/typebox/value\";\nimport { mkdirSync, readFileSync } from \"fs\";\n\nexport function ensureDirExists(dir: string): void {\n mkdirSync(dir, { recursive: true });\n}\n\nexport function readTextFileIfExists(path: string): string | undefined {\n try {\n return readFileSync(path, \"utf-8\");\n } catch (err) {\n if (err instanceof Error && \"code\" in err && err.code === \"ENOENT\") {\n return undefined;\n }\n throw err;\n }\n}\n\nexport function readJsonFileIfExists<T>(\n path: string,\n validate: (value: unknown) => value is T,\n malformedMessage: (detail: string) => string,\n): T | undefined {\n const raw = readTextFileIfExists(path);\n return raw === undefined ? undefined : parseJsonValue(raw, validate, malformedMessage);\n}\n\nexport function readJsonSchemaFileIfExists<T extends TSchema>(\n path: string,\n schema: T,\n malformedMessage: (detail: string) => string,\n): Static<T> | undefined {\n const raw = readTextFileIfExists(path);\n return raw === undefined ? undefined : parseJsonSchemaValue(raw, schema, malformedMessage);\n}\n\nfunction parseJson(raw: string, malformedMessage: (detail: string) => string): unknown {\n try {\n return JSON.parse(raw);\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n throw new Error(malformedMessage(detail), { cause: err });\n }\n}\n\nexport function parseJsonValue<T>(\n raw: string,\n validate: (value: unknown) => value is T,\n malformedMessage: (detail: string) => string,\n): T {\n const parsed = parseJson(raw, malformedMessage);\n if (!validate(parsed)) {\n throw new Error(malformedMessage(\"unexpected JSON shape\"));\n }\n return parsed;\n}\n\nexport function parseJsonSchemaValue<T extends TSchema>(\n raw: string,\n schema: T,\n malformedMessage: (detail: string) => string,\n): Static<T> {\n const parsed = parseJson(raw, malformedMessage);\n if (!Value.Check(schema, parsed)) {\n let firstError: { path: string; message: string } | undefined;\n for (const err of Value.Errors(schema, parsed)) {\n firstError = err;\n break;\n }\n const detail =\n !firstError || firstError.path === \"\" || firstError.path === \"/\"\n ? \"unexpected JSON shape\"\n : `${firstError.path}: ${firstError.message}`;\n throw new Error(malformedMessage(detail));\n }\n return parsed;\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n"]}
|
package/dist/login/portal.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { type Server } from "http";
|
|
2
|
+
import type { Bot, PlatformName } from "../adapter.js";
|
|
3
|
+
import { type AdminRuntimeBridge } from "../admin/portal.js";
|
|
4
|
+
import type { InMemoryAdminTokenStore } from "../admin/store.js";
|
|
5
|
+
import type { SandboxConfig } from "../sandbox/index.js";
|
|
2
6
|
import { type SessionViewInteractiveOptions } from "../session-view/portal.js";
|
|
3
7
|
import type { InMemorySessionViewTokenStore } from "../session-view/store.js";
|
|
4
8
|
import type { InMemoryLinkTokenStore } from "./session.js";
|
|
@@ -15,5 +19,11 @@ export type NotifyFn = (platform: string, conversationId: string, message: strin
|
|
|
15
19
|
* POST /api/oauth/start — creates provider OAuth redirect URL
|
|
16
20
|
* GET /oauth/callback — OAuth callback endpoint
|
|
17
21
|
*/
|
|
18
|
-
export declare function startLinkServer(port: number, linkTokenStore: InMemoryLinkTokenStore, vaultManager: VaultManager, notify: NotifyFn, sessionViewTokenStore?: InMemorySessionViewTokenStore, sessionViewInteractive?: SessionViewInteractiveOptions
|
|
22
|
+
export declare function startLinkServer(port: number, linkTokenStore: InMemoryLinkTokenStore, vaultManager: VaultManager, notify: NotifyFn, sessionViewTokenStore?: InMemorySessionViewTokenStore, sessionViewInteractive?: SessionViewInteractiveOptions, adminOptions?: {
|
|
23
|
+
adminTokenStore: InMemoryAdminTokenStore;
|
|
24
|
+
workingDir?: string;
|
|
25
|
+
runtime?: AdminRuntimeBridge;
|
|
26
|
+
sandbox?: SandboxConfig;
|
|
27
|
+
botsByPlatform?: Partial<Record<PlatformName, Bot>>;
|
|
28
|
+
}): Server;
|
|
19
29
|
//# sourceMappingURL=portal.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../src/login/portal.ts"],"names":[],"mappings":"AACA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,MAAM,CAAC;AAE5F,OAAO,EAEL,KAAK,6BAA6B,EACnC,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,0BAA0B,CAAC;AAC9E,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAU3D,OAAO,EAA0B,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAIxE,oEAAoE;AACpE,MAAM,MAAM,QAAQ,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAyNpG;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,EACZ,cAAc,EAAE,sBAAsB,EACtC,YAAY,EAAE,YAAY,EAC1B,MAAM,EAAE,QAAQ,EAChB,qBAAqB,CAAC,EAAE,6BAA6B,EACrD,sBAAsB,CAAC,EAAE,6BAA6B,GACrD,MAAM,CA0IR","sourcesContent":["import { createHash, randomBytes } from \"crypto\";\nimport { createServer, type IncomingMessage, type Server, type ServerResponse } from \"http\";\nimport { resolveLinkBaseUrl } from \"../config.js\";\nimport {\n handleSessionViewRequest,\n type SessionViewInteractiveOptions,\n} from \"../session-view/portal.js\";\nimport type { InMemorySessionViewTokenStore } from \"../session-view/store.js\";\nimport type { InMemoryLinkTokenStore } from \"./session.js\";\nimport {\n getOAuthServices,\n resolveOAuthService,\n type LoginCredentialKind,\n type OAuthService,\n} from \"./index.js\";\nimport * as log from \"../log.js\";\nimport { reportUserFacingError } from \"../sentry.js\";\nimport { PRODUCT_NAME } from \"../ui-copy.js\";\nimport { defaultVaultTargetPath, type VaultManager } from \"../vault.js\";\n\n// ── Types ──────────────────────────────────────────────────────────────────────\n\n/** Called after a binding is written, to notify the user in chat */\nexport type NotifyFn = (platform: string, conversationId: string, message: string) => Promise<void>;\n\ninterface LinkCompleteBody {\n token: string;\n mode?: LoginCredentialKind;\n envKey?: string;\n credential?: string;\n env?: Record<string, string>;\n}\n\ninterface OAuthStartBody {\n token: string;\n serviceId: string;\n}\n\ninterface PendingOAuthState {\n linkToken: string;\n serviceId: string;\n codeVerifier: string;\n expiresAt: number;\n}\n\ninterface SecretPresetField {\n envKey: string;\n envKeys?: string[];\n label: string;\n type: \"text\" | \"password\";\n placeholder: string;\n helpText: string;\n optional?: boolean;\n pattern?: string;\n patternMessage?: string;\n}\n\ninterface SecretPreset {\n id: string;\n label: string;\n description: string;\n note?: string;\n fields: SecretPresetField[];\n}\n\nconst OAUTH_STATE_TTL_MS = 10 * 60 * 1000;\nconst DEFAULT_SECRET_CONFIG_ID = \"manual\";\nconst SECRET_PRESETS: SecretPreset[] = [\n {\n id: \"cloudflare_wrangler\",\n label: \"Cloudflare / Wrangler\",\n description:\n \"Store a Cloudflare API token and account ID for Wrangler, Workers, Pages, D1, and KV.\",\n note: \"Create a scoped API Token from Cloudflare Dashboard → My Profile → API Tokens. Do not use the Global API Key.\",\n fields: [\n {\n envKey: \"CLOUDFLARE_API_TOKEN\",\n label: \"Cloudflare API Token\",\n type: \"password\",\n placeholder: \"cfut_...\",\n helpText: \"Recommended for Wrangler, CI, and sandbox use.\",\n },\n {\n envKey: \"CLOUDFLARE_ACCOUNT_ID\",\n label: \"Cloudflare Account ID\",\n type: \"text\",\n placeholder: \"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\n helpText: \"Find this via wrangler whoami or in the Cloudflare dashboard account page.\",\n pattern: \"^[A-Fa-f0-9]{32}$\",\n patternMessage: \"Account ID must be a 32-character hexadecimal string.\",\n },\n ],\n },\n {\n id: \"openai\",\n label: \"OpenAI\",\n description: \"Store an OpenAI API key for tools and SDKs that use OPENAI_API_KEY.\",\n note: \"Create a standard API key from the OpenAI dashboard. Paste the key exactly as issued.\",\n fields: [\n {\n envKey: \"OPENAI_API_KEY\",\n label: \"OpenAI API Key\",\n type: \"password\",\n placeholder: \"sk-...\",\n helpText: \"Used by the OpenAI SDK, CLI wrappers, and many coding tools.\",\n },\n ],\n },\n {\n id: \"anthropic\",\n label: \"Anthropic\",\n description: \"Store an Anthropic API key for Claude and tools that use ANTHROPIC_API_KEY.\",\n note: \"Create this key from the Anthropic Console. Use a workspace-scoped key when possible.\",\n fields: [\n {\n envKey: \"ANTHROPIC_API_KEY\",\n label: \"Anthropic API Key\",\n type: \"password\",\n placeholder: \"sk-ant-...\",\n helpText: \"Used by Claude integrations and Anthropic-compatible tooling.\",\n },\n ],\n },\n {\n id: \"gemini\",\n label: \"Gemini\",\n description:\n \"Store one Google AI Studio key and expose it as both GEMINI_API_KEY and GOOGLE_API_KEY.\",\n note: \"Create a Gemini / Google AI Studio API key, then paste it once here for compatibility with both env names.\",\n fields: [\n {\n envKey: \"GEMINI_API_KEY\",\n envKeys: [\"GEMINI_API_KEY\", \"GOOGLE_API_KEY\"],\n label: \"Gemini API Key\",\n type: \"password\",\n placeholder: \"AIza...\",\n helpText: \"One value will be written to both GEMINI_API_KEY and GOOGLE_API_KEY.\",\n },\n ],\n },\n {\n id: \"openrouter\",\n label: \"OpenRouter\",\n description: \"Store an OpenRouter API key for tools that route models through OpenRouter.\",\n note: \"Create a key from the OpenRouter dashboard and paste it here.\",\n fields: [\n {\n envKey: \"OPENROUTER_API_KEY\",\n label: \"OpenRouter API Key\",\n type: \"password\",\n placeholder: \"sk-or-v1-...\",\n helpText: \"Used by OpenRouter SDKs and compatible model gateways.\",\n },\n ],\n },\n {\n id: \"github_pat\",\n label: \"GitHub PAT\",\n description:\n \"Store one GitHub personal access token and expose it as both GH_TOKEN and GITHUB_TOKEN.\",\n note: \"Create a fine-grained or classic personal access token from GitHub Settings → Developer settings.\",\n fields: [\n {\n envKey: \"GH_TOKEN\",\n envKeys: [\"GH_TOKEN\", \"GITHUB_TOKEN\"],\n label: \"GitHub Personal Access Token\",\n type: \"password\",\n placeholder: \"github_pat_...\",\n helpText: \"One value will be written to both GH_TOKEN and GITHUB_TOKEN.\",\n },\n ],\n },\n {\n id: \"vercel\",\n label: \"Vercel\",\n description: \"Store a Vercel token plus optional org and project IDs for deployment tooling.\",\n note: \"Create a token from the Vercel dashboard. Org ID and Project ID are optional but useful for scripted deploys.\",\n fields: [\n {\n envKey: \"VERCEL_TOKEN\",\n label: \"Vercel Token\",\n type: \"password\",\n placeholder: \"vercel_...\",\n helpText: \"Required for Vercel CLI and API access.\",\n },\n {\n envKey: \"VERCEL_ORG_ID\",\n label: \"Vercel Org ID\",\n type: \"text\",\n placeholder: \"team_...\",\n helpText: \"Optional. Set this when you want to target a specific team or account.\",\n optional: true,\n },\n {\n envKey: \"VERCEL_PROJECT_ID\",\n label: \"Vercel Project ID\",\n type: \"text\",\n placeholder: \"prj_...\",\n helpText: \"Optional. Set this when deploy scripts need a fixed project reference.\",\n optional: true,\n },\n ],\n },\n {\n id: \"sentry\",\n label: \"Sentry\",\n description:\n \"Store a Sentry auth token plus optional org/project, and mount ~/.sentryclirc for sentry-cli.\",\n note: \"Create an auth token from Sentry Settings → Account → API → Auth Tokens. Saving this preset also writes /root/.sentryclirc for sentry-cli.\",\n fields: [\n {\n envKey: \"SENTRY_AUTH_TOKEN\",\n label: \"Sentry Auth Token\",\n type: \"password\",\n placeholder: \"sntrys_...\",\n helpText:\n \"Required for Sentry CLI, releases, and sourcemap uploads. Also written to /root/.sentryclirc.\",\n },\n {\n envKey: \"SENTRY_ORG\",\n label: \"Sentry Org Slug\",\n type: \"text\",\n placeholder: \"my-org\",\n helpText: \"Optional. Helpful for Sentry CLI commands and CI automation.\",\n optional: true,\n },\n {\n envKey: \"SENTRY_PROJECT\",\n label: \"Sentry Project Slug\",\n type: \"text\",\n placeholder: \"my-project\",\n helpText: \"Optional. Helpful for release and sourcemap commands.\",\n optional: true,\n },\n ],\n },\n];\n\n// ── startLinkServer ────────────────────────────────────────────────────────────\n\n/**\n * Start a small HTTP server that receives credential onboarding callbacks from the web portal.\n *\n * Routes:\n * GET /health — health check\n * GET /link?token=xxx — credential onboarding page\n * POST /api/link/complete — API key completion endpoint\n * POST /api/oauth/start — creates provider OAuth redirect URL\n * GET /oauth/callback — OAuth callback endpoint\n */\nexport function startLinkServer(\n port: number,\n linkTokenStore: InMemoryLinkTokenStore,\n vaultManager: VaultManager,\n notify: NotifyFn,\n sessionViewTokenStore?: InMemorySessionViewTokenStore,\n sessionViewInteractive?: SessionViewInteractiveOptions,\n): Server {\n const oauthStates = new Map<string, PendingOAuthState>();\n\n const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {\n try {\n const url = new URL(req.url ?? \"/\", requestBaseUrl(req));\n\n if (req.method === \"GET\" && url.pathname === \"/health\") {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ ok: true }));\n return;\n }\n\n if (\n await handleSessionViewRequest(req, res, url, sessionViewTokenStore, sessionViewInteractive)\n ) {\n return;\n }\n\n if (req.method === \"GET\" && url.pathname === \"/link\") {\n const rawToken = url.searchParams.get(\"token\") ?? \"\";\n const linkToken = linkTokenStore.peek(rawToken);\n\n if (!linkToken) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderErrorPage(\n \"This link is invalid or has expired. Ask the bot for a new /login link.\",\n ),\n );\n return;\n }\n\n const oauthServiceHint = linkToken.providerId\n ? resolveOAuthService(linkToken.providerId)\n : undefined;\n const oauthServices = getOAuthServices();\n const defaultMode: LoginCredentialKind = oauthServiceHint ? \"oauth\" : \"api_key\";\n const existingSecrets = describeVaultSecrets(vaultManager, linkToken.vaultId);\n\n const title = oauthServiceHint ? `${oauthServiceHint.label} OAuth` : \"Store Secret\";\n const helpText = oauthServiceHint\n ? `Authorize ${oauthServiceHint.label} and store tokens in your vault.`\n : \"Set any environment variable key/value pair in your vault.\";\n const secretLabel = \"Secret value\";\n const placeholder = \"sk-...\";\n const initialEnvKey = \"\";\n\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderCredentialPage(\n rawToken,\n title,\n defaultMode,\n initialEnvKey,\n secretLabel,\n placeholder,\n helpText,\n oauthServices,\n oauthServiceHint?.id,\n existingSecrets,\n ),\n );\n return;\n }\n\n if (req.method === \"POST\" && url.pathname === \"/api/link/complete\") {\n if (!enforceCsrf(req, res)) return;\n void readJsonBody(req, res, async (body) => {\n await handleLinkComplete(body, linkTokenStore, vaultManager, notify, res);\n });\n return;\n }\n\n if (req.method === \"POST\" && url.pathname === \"/api/oauth/start\") {\n if (!enforceCsrf(req, res)) return;\n void readJsonBody(req, res, async (body) => {\n await handleOAuthStart(body, req, linkTokenStore, oauthStates, res);\n });\n return;\n }\n\n if (req.method === \"GET\" && url.pathname === \"/oauth/callback\") {\n void handleOAuthCallback(\n url,\n req,\n linkTokenStore,\n vaultManager,\n notify,\n oauthStates,\n res,\n ).catch((err: Error) => {\n log.logWarning(\"OAuth callback failed\", err.message);\n reportUserFacingError(err, {\n domain: \"login\",\n surface: \"oauth\",\n operation: \"oauth_callback\",\n severity: \"error\",\n context: { route: \"/oauth/callback\" },\n });\n res.writeHead(500, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"OAuth callback failed. Please retry /login.\"));\n });\n return;\n }\n\n res.writeHead(404);\n res.end();\n } catch (err) {\n log.logWarning(\"Link server request error\", err instanceof Error ? err.message : String(err));\n if (!res.headersSent) {\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n }\n res.end(JSON.stringify({ error: \"Internal server error\" }));\n }\n });\n\n // Bind to loopback when MIKAN_LINK_URL is unset so the credential UI and OAuth\n // callbacks are not exposed on public interfaces by default. Production\n // deployments set MIKAN_LINK_URL and are expected to front this server with a\n // reverse proxy, which can still reach it via 0.0.0.0.\n const bindHost = resolveLinkBaseUrl() ? undefined : \"127.0.0.1\";\n server.listen(port, bindHost, () => {\n log.logInfo(`Link callback server listening on ${bindHost ?? \"0.0.0.0\"}:${port}`);\n if (!resolveLinkBaseUrl()) {\n log.logWarning(\n \"MIKAN_LINK_URL is not set — bound to 127.0.0.1 and OAuth redirect_uri will be \" +\n \"derived from request headers (Host / X-Forwarded-*). Set \" +\n \"MIKAN_LINK_URL=https://your-host.example.com for production.\",\n );\n }\n });\n\n server.on(\"error\", (err) => {\n log.logWarning(\"Link server error\", err.message);\n });\n\n return server;\n}\n\n/**\n * Resolve the externally-visible base URL of this server.\n *\n * Prefers MIKAN_LINK_URL (see config.ts) so the OAuth `redirect_uri` is\n * deterministic and not influenced by attacker-controlled request headers.\n * Falls back to Host / X-Forwarded-* only when no base URL is configured\n * — intended for local development.\n */\nfunction requestBaseUrl(req: IncomingMessage): string {\n const configured = resolveLinkBaseUrl();\n if (configured) return configured;\n\n const protoRaw = (req.headers[\"x-forwarded-proto\"] as string | undefined)?.split(\",\")[0]?.trim();\n const proto = protoRaw || \"http\";\n const host =\n ((req.headers[\"x-forwarded-host\"] as string | undefined)?.split(\",\")[0]?.trim() ??\n req.headers.host ??\n `localhost`) ||\n `localhost`;\n return `${proto}://${host}`;\n}\n\n/**\n * Block cross-site POSTs to the credential endpoints. Two defenses:\n * 1. Require Content-Type: application/json, which forces a CORS preflight\n * for any cross-origin fetch and rules out `<form enctype=\"text/plain\">`\n * tricks that could otherwise smuggle a JSON body.\n * 2. When MIKAN_LINK_URL is configured, require that the Origin (or Referer,\n * as a fallback for browsers that strip Origin) matches that base URL.\n * This stops an attacker-controlled page — even one that somehow stole a\n * victim's link token — from completing the flow.\n */\nfunction enforceCsrf(req: IncomingMessage, res: ServerResponse): boolean {\n const contentType = (req.headers[\"content-type\"] as string | undefined)\n ?.split(\";\")[0]\n ?.trim()\n .toLowerCase();\n if (contentType !== \"application/json\") {\n res.writeHead(415, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Content-Type must be application/json\" }));\n return false;\n }\n\n const configured = resolveLinkBaseUrl();\n if (!configured) {\n // No trusted origin to compare against in local/dev mode; the loopback\n // bind already prevents cross-host access.\n return true;\n }\n\n let configuredOrigin: string;\n try {\n configuredOrigin = new URL(configured).origin;\n } catch {\n // Misconfigured MIKAN_LINK_URL — fail closed.\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Server misconfiguration\" }));\n return false;\n }\n\n if (requestOrigin(req) !== configuredOrigin) {\n res.writeHead(403, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Cross-origin request rejected\" }));\n return false;\n }\n\n return true;\n}\n\n/** Best-effort origin of the request, derived from Origin or Referer. */\nfunction requestOrigin(req: IncomingMessage): string | undefined {\n const origin = (req.headers.origin as string | undefined)?.trim();\n if (origin && origin !== \"null\") return origin;\n\n const referer = (req.headers.referer as string | undefined)?.trim();\n if (!referer) return undefined;\n try {\n return new URL(referer).origin;\n } catch {\n return undefined;\n }\n}\n\nasync function readJsonBody(\n req: IncomingMessage,\n res: ServerResponse,\n onBody: (body: string) => Promise<void>,\n): Promise<void> {\n let body = \"\";\n let bodyTooLarge = false;\n\n req.on(\"data\", (chunk: Buffer) => {\n if (bodyTooLarge) return;\n body += chunk.toString();\n if (body.length > 16 * 1024) {\n bodyTooLarge = true;\n res.writeHead(413);\n res.end();\n req.destroy();\n }\n });\n\n req.on(\"end\", async () => {\n if (bodyTooLarge) return;\n await onBody(body);\n });\n}\n\n// ── HTML helpers ───────────────────────────────────────────────────────────────\n\nfunction esc(s: string): string {\n return s.replace(\n /[&<>\"']/g,\n (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]!,\n );\n}\n\nconst sharedPageStyles = `\n :root {\n color-scheme: light;\n --bg: #f5f1e8;\n --panel: rgba(255, 255, 255, 0.9);\n --panel-border: rgba(28, 30, 33, 0.08);\n --text: #1c1e21;\n --muted: #5d5f64;\n --button: #1c1e21;\n --button-hover: #2c3035;\n --button-disabled: #8f949b;\n --field-border: #c9cfd6;\n --field-focus: #1c1e21;\n --ok-bg: #dff4e4;\n --ok-text: #1f5b34;\n --err-bg: #fde2e2;\n --err-text: #8a2f2f;\n }\n\n * { box-sizing: border-box; }\n\n body {\n margin: 0;\n min-height: 100vh;\n padding: 32px 20px;\n display: grid;\n grid-template-columns: minmax(0, 560px);\n justify-content: center;\n align-content: start;\n background:\n radial-gradient(circle at top, rgba(255, 255, 255, 0.7), transparent 45%),\n linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);\n color: var(--text);\n font-family:\n \"SF Pro Text\",\n \"Segoe UI\",\n system-ui,\n sans-serif;\n }\n\n .shell {\n width: 100%;\n min-width: 0;\n display: grid;\n gap: 16px;\n align-content: start;\n }\n\n .card {\n padding: 28px;\n border: 1px solid var(--panel-border);\n border-radius: 20px;\n background: var(--panel);\n box-shadow: 0 18px 48px rgba(28, 30, 33, 0.08);\n backdrop-filter: blur(8px);\n }\n\n .eyebrow {\n margin: 0 0 10px;\n color: var(--muted);\n font-size: 0.82rem;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n }\n\n h1 {\n margin: 0 0 10px;\n font-size: clamp(1.5rem, 2vw, 1.8rem);\n line-height: 1.15;\n text-wrap: balance;\n }\n\n p {\n margin: 0;\n color: var(--muted);\n font-size: 0.98rem;\n line-height: 1.5;\n }\n\n .stack > * + * {\n margin-top: 14px;\n }\n\n label {\n display: block;\n margin-bottom: 6px;\n font-size: 0.92rem;\n font-weight: 650;\n }\n\n input,\n select,\n button {\n font: inherit;\n }\n\n input,\n select {\n width: 100%;\n padding: 12px 14px;\n border: 1px solid var(--field-border);\n border-radius: 12px;\n background: #fff;\n color: var(--text);\n }\n\n input:focus-visible,\n select:focus-visible,\n button:focus-visible {\n outline: 2px solid var(--field-focus);\n outline-offset: 2px;\n }\n\n code {\n font-family: \"SFMono-Regular\", ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 0.92em;\n overflow-wrap: anywhere;\n }\n\n .primary-button {\n width: 100%;\n padding: 13px 18px;\n border: none;\n border-radius: 12px;\n background: var(--button);\n color: #fff;\n cursor: pointer;\n transition: background-color 160ms ease;\n }\n\n .primary-button:hover {\n background: var(--button-hover);\n }\n\n .primary-button:disabled {\n background: var(--button-disabled);\n cursor: default;\n }\n\n .service-logo {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 36px;\n height: 36px;\n border-radius: 10px;\n flex: 0 0 36px;\n background: #1c1e21;\n color: #fff;\n }\n\n .service-logo svg {\n display: block;\n width: 20px;\n height: 20px;\n }\n\n .service-logo-text {\n font-size: 11px;\n font-weight: 800;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n }\n\n .service-logo.cloudflare {\n background: linear-gradient(180deg, #ffb66d 0%, #f48120 100%);\n }\n\n .service-logo.openai {\n background: linear-gradient(180deg, #3e4045 0%, #111315 100%);\n }\n\n .service-logo.anthropic {\n background: linear-gradient(180deg, #d6b48c 0%, #9a6d3a 100%);\n }\n\n .service-logo.gemini {\n background: linear-gradient(180deg, #8ab4ff 0%, #5b6cff 100%);\n }\n\n .service-logo.openrouter {\n background: linear-gradient(180deg, #8c8cff 0%, #4f46e5 100%);\n }\n\n .service-logo.github {\n background: linear-gradient(180deg, #4a4f57 0%, #1b1f23 100%);\n }\n\n .service-logo.vercel {\n background: linear-gradient(180deg, #4a4f57 0%, #000 100%);\n }\n\n .service-logo.sentry {\n background: linear-gradient(180deg, #7c5cff 0%, #3f2e8c 100%);\n }\n\n .service-logo.manual {\n background: linear-gradient(180deg, #43474d 0%, #1c1e21 100%);\n }\n\n .provider-card > * + * {\n margin-top: 14px;\n }\n\n .provider-header {\n display: flex;\n align-items: center;\n gap: 12px;\n }\n\n .provider-title {\n flex: 1;\n margin: 0;\n font-size: 1rem;\n font-weight: 650;\n line-height: 1.3;\n }\n\n .provider-field label {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n\n .help {\n position: relative;\n display: inline-flex;\n align-items: center;\n }\n\n .help-trigger {\n width: 18px;\n height: 18px;\n padding: 0;\n border: 1px solid var(--field-border);\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.9);\n color: var(--muted);\n font-size: 11px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n }\n\n .help-trigger:hover {\n color: var(--text);\n border-color: var(--text);\n }\n\n .help-content {\n display: none;\n position: absolute;\n top: calc(100% + 6px);\n left: 0;\n z-index: 10;\n width: max-content;\n max-width: 280px;\n padding: 10px 12px;\n border: 1px solid var(--panel-border);\n border-radius: 10px;\n background: #fff;\n color: var(--text);\n font-size: 0.85rem;\n font-weight: 400;\n line-height: 1.45;\n box-shadow: 0 8px 24px rgba(28, 30, 33, 0.12);\n white-space: normal;\n }\n\n .help-trigger[aria-expanded=\"true\"] + .help-content {\n display: block;\n }\n\n .help-trigger[aria-expanded=\"true\"] {\n color: var(--text);\n border-color: var(--text);\n }\n\n .mode {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n margin-top: 22px;\n }\n\n .mode label {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n margin: 0;\n padding: 10px 12px;\n border: 1px solid var(--field-border);\n border-radius: 999px;\n background: rgba(255, 255, 255, 0.85);\n font-weight: 500;\n }\n\n .mode input {\n width: auto;\n margin: 0;\n }\n\n .panel {\n display: none;\n }\n\n .panel.active {\n display: block;\n }\n\n #api-panel.active {\n display: grid;\n gap: 16px;\n }\n\n .panel-note {\n margin-top: 10px;\n font-size: 0.92rem;\n }\n\n .result,\n .status {\n margin-top: 20px;\n padding: 14px 16px;\n border-radius: 14px;\n font-size: 0.95rem;\n }\n\n .result {\n display: none;\n }\n\n .result.ok,\n .status.ok {\n background: var(--ok-bg);\n color: var(--ok-text);\n }\n\n .result.err,\n .status.err {\n background: var(--err-bg);\n color: var(--err-text);\n }\n\n .secrets-summary {\n margin-top: 18px;\n padding: 14px 16px;\n border: 1px solid var(--panel-border);\n border-radius: 14px;\n background: rgba(255, 255, 255, 0.72);\n }\n\n .secrets-summary h2 {\n margin: 0 0 8px;\n font-size: 0.98rem;\n }\n\n .secrets-summary p {\n font-size: 0.92rem;\n }\n\n .secrets-summary ul {\n margin: 10px 0 0;\n padding-left: 18px;\n color: var(--text);\n }\n\n .secrets-summary li + li {\n margin-top: 6px;\n }\n\n .close-note {\n margin-top: 14px;\n font-size: 0.92rem;\n }\n\n @media (max-width: 640px) {\n body {\n padding: 16px 12px;\n }\n\n .shell {\n gap: 12px;\n }\n\n .card {\n padding: 20px;\n border-radius: 16px;\n }\n\n /* Mode toggle pills fill the row evenly */\n .mode label {\n flex: 1;\n justify-content: center;\n }\n\n /* Larger touch targets */\n input,\n select {\n padding: 14px;\n }\n\n .primary-button {\n padding: 15px 18px;\n }\n\n /* Prevent help popover from overflowing the viewport */\n .help-content {\n max-width: min(260px, calc(100vw - 40px));\n }\n\n /* Right-align popovers that sit near the right edge */\n .provider-header .help-content {\n left: auto;\n right: 0;\n }\n }\n`;\n\nfunction renderHtmlDocument(title: string, shellContent: string): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>${esc(title)} — ${PRODUCT_NAME}</title>\n <style>${sharedPageStyles}</style>\n</head>\n<body>\n <main class=\"shell\">\n ${shellContent}\n </main>\n</body>\n</html>`;\n}\n\nfunction renderPageDocument(title: string, body: string): string {\n return renderHtmlDocument(title, `<section class=\"card\">${body}</section>`);\n}\n\nfunction renderStatusPage(\n title: string,\n message: string,\n tone: \"ok\" | \"err\",\n options?: { closeNote?: boolean },\n): string {\n const closeNote = options?.closeNote ? '<p class=\"close-note\">You can close this tab.</p>' : \"\";\n return renderPageDocument(\n title,\n `<div class=\"stack\">\n <p class=\"eyebrow\">${PRODUCT_NAME}</p>\n <h1>${esc(title)}</h1>\n <div class=\"status ${tone}\">${esc(message)}</div>\n ${closeNote}\n </div>`,\n );\n}\n\ninterface ExistingSecretsSummary {\n envKeys: string[];\n mountTargets: string[];\n}\n\nfunction describeVaultSecrets(vaultManager: VaultManager, vaultId: string): ExistingSecretsSummary {\n const vault = vaultManager.resolve(vaultId);\n if (!vault) {\n return { envKeys: [], mountTargets: [] };\n }\n\n return {\n envKeys: Object.keys(vault.env).toSorted((left, right) => left.localeCompare(right)),\n mountTargets: [...new Set(vault.mounts.map((mount) => mount.target))].toSorted((left, right) =>\n left.localeCompare(right),\n ),\n };\n}\n\nfunction renderSecretsSummary(summary: ExistingSecretsSummary): string {\n if (summary.envKeys.length === 0 && summary.mountTargets.length === 0) {\n return `\n <section class=\"secrets-summary\">\n <h2>Currently stored</h2>\n <p>No secrets are stored in this vault yet.</p>\n </section>`;\n }\n\n const envItems = summary.envKeys.map((envKey) => `<li><code>${esc(envKey)}</code></li>`).join(\"\");\n const mountItems = summary.mountTargets\n .map((target) => `<li><code>${esc(target)}</code></li>`)\n .join(\"\");\n\n return `\n <section class=\"secrets-summary\">\n <h2>Currently stored</h2>\n <p>Only secret names and mounted paths are shown here. Secret values are never displayed.</p>\n ${summary.envKeys.length > 0 ? `<p><strong>Environment keys</strong></p><ul>${envItems}</ul>` : \"\"}\n ${summary.mountTargets.length > 0 ? `<p><strong>Mounted secret files</strong></p><ul>${mountItems}</ul>` : \"\"}\n </section>`;\n}\n\nfunction renderServiceLogo(kind: string): string {\n if (kind === \"cloudflare_wrangler\") {\n return `<span class=\"service-logo cloudflare\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\">\n <path d=\"M8.5 17.5h8.2a2.9 2.9 0 0 0 .4-5.78A4.45 4.45 0 0 0 8.9 10.4a3.7 3.7 0 0 0-.4 7.1Z\" fill=\"white\" fill-opacity=\"0.98\"/>\n <path d=\"M6.6 17.5h5.1a2.3 2.3 0 0 0 0-4.6 3.1 3.1 0 0 0-3-2.2 3.23 3.23 0 0 0-3.18 3.64A2.67 2.67 0 0 0 6.6 17.5Z\" fill=\"white\"/>\n </svg>\n </span>`;\n }\n\n const textLogos: Record<string, { className: string; text: string }> = {\n openai: { className: \"openai\", text: \"OA\" },\n anthropic: { className: \"anthropic\", text: \"AI\" },\n gemini: { className: \"gemini\", text: \"G\" },\n openrouter: { className: \"openrouter\", text: \"OR\" },\n github_pat: { className: \"github\", text: \"GH\" },\n vercel: { className: \"vercel\", text: \"V\" },\n sentry: { className: \"sentry\", text: \"S\" },\n manual: { className: \"manual\", text: \">_\" },\n };\n const logo = textLogos[kind] ?? textLogos.manual;\n return `<span class=\"service-logo ${logo.className}\" aria-hidden=\"true\"><span class=\"service-logo-text\">${logo.text}</span></span>`;\n}\n\nfunction resolveFieldEnvKeys(field: SecretPresetField): string[] {\n return field.envKeys && field.envKeys.length > 0 ? field.envKeys : [field.envKey];\n}\n\nfunction renderStoredEnvKeysInline(field: SecretPresetField): string {\n return resolveFieldEnvKeys(field)\n .map((envKey) => `<code>${esc(envKey)}</code>`)\n .join(\", \");\n}\n\nfunction renderHelpIcon(html: string): string {\n return `<span class=\"help\">\n <button type=\"button\" class=\"help-trigger\" aria-label=\"More info\" aria-expanded=\"false\">?</button>\n <span class=\"help-content\" role=\"tooltip\">${html}</span>\n </span>`;\n}\n\nfunction renderPresetProviderCard(preset: SecretPreset): string {\n const headerHelp = preset.note ? renderHelpIcon(esc(preset.note)) : \"\";\n const fields = preset.fields\n .map((field) => {\n const storedKeys = renderStoredEnvKeysInline(field);\n const helpText = `${esc(field.helpText)} Stored as ${storedKeys}.${field.optional ? \" Optional.\" : \"\"}`;\n return `<div class=\"provider-field\">\n <label for=\"preset-${esc(preset.id)}-${esc(field.envKey)}\">\n ${esc(field.label)}\n ${renderHelpIcon(helpText)}\n </label>\n <input\n id=\"preset-${esc(preset.id)}-${esc(field.envKey)}\"\n type=\"${field.type}\"\n autocomplete=\"off\"\n placeholder=\"${esc(field.placeholder)}\"\n data-env-key=\"${esc(field.envKey)}\"\n data-env-keys=\"${esc(resolveFieldEnvKeys(field).join(\",\"))}\"\n data-field-label=\"${esc(field.label)}\"\n ${field.optional ? 'data-optional=\"true\"' : \"\"}\n ${field.pattern ? `data-pattern=\"${esc(field.pattern)}\"` : \"\"}\n ${field.patternMessage ? `data-pattern-message=\"${esc(field.patternMessage)}\"` : \"\"}\n >\n </div>`;\n })\n .join(\"\\n\");\n\n return `<section class=\"card provider-card\" data-provider-kind=\"preset\" data-provider-id=\"${esc(preset.id)}\">\n <div class=\"provider-header\">\n ${renderServiceLogo(preset.id)}\n <h2 class=\"provider-title\">${esc(preset.label)}</h2>\n ${headerHelp}\n </div>\n ${fields}\n </section>`;\n}\n\nfunction renderManualProviderCard(\n initialEnvKey: string,\n secretLabel: string,\n placeholder: string,\n): string {\n const headerHelp = renderHelpIcon(\n esc(\n \"Set any environment variable key/value pair manually. Use this when no provider preset fits.\",\n ),\n );\n return `<section class=\"card provider-card\" data-provider-kind=\"manual\" data-provider-id=\"${esc(DEFAULT_SECRET_CONFIG_ID)}\">\n <div class=\"provider-header\">\n ${renderServiceLogo(\"manual\")}\n <h2 class=\"provider-title\">Manual entry</h2>\n ${headerHelp}\n </div>\n <div class=\"provider-field\">\n <label for=\"envKey\">Environment key</label>\n <input id=\"envKey\" type=\"text\" name=\"envKey\" placeholder=\"OPENAI_API_KEY\" value=\"${esc(initialEnvKey)}\" autocomplete=\"off\">\n </div>\n <div class=\"provider-field\">\n <label for=\"credential\">${esc(secretLabel)}</label>\n <input id=\"credential\" type=\"password\" name=\"credential\" placeholder=\"${esc(placeholder)}\" autocomplete=\"off\">\n </div>\n </section>`;\n}\n\nfunction renderCredentialPage(\n token: string,\n title: string,\n defaultMode: LoginCredentialKind,\n initialEnvKey: string,\n secretLabel: string,\n placeholder: string,\n helpText: string,\n oauthServices: OAuthService[],\n oauthServiceIdHint: string | undefined,\n existingSecrets: ExistingSecretsSummary,\n): string {\n const oauthOptions = oauthServices\n .map((service) => {\n const selected = service.id === oauthServiceIdHint ? ' selected=\"selected\"' : \"\";\n return `<option value=\"${esc(service.id)}\"${selected}>${esc(service.label)}</option>`;\n })\n .join(\"\\n\");\n const presetCards = SECRET_PRESETS.map(renderPresetProviderCard).join(\"\\n\");\n\n return renderHtmlDocument(\n \"Login\",\n `<section class=\"card stack\">\n <p class=\"eyebrow\">${PRODUCT_NAME}</p>\n <h1>${esc(title)}</h1>\n <p>Your personal sandbox is already provisioned automatically.</p>\n <p>${esc(helpText)}</p>\n ${renderSecretsSummary(existingSecrets)}\n <div class=\"mode\">\n <label><input type=\"radio\" name=\"mode\" value=\"api_key\" ${defaultMode === \"api_key\" ? \"checked\" : \"\"}> Secrets / API tokens</label>\n <label><input type=\"radio\" name=\"mode\" value=\"oauth\" ${defaultMode === \"oauth\" ? \"checked\" : \"\"}> OAuth login</label>\n </div>\n</section>\n\n<div id=\"api-panel\" class=\"panel\">\n ${presetCards}\n ${renderManualProviderCard(initialEnvKey, secretLabel, placeholder)}\n</div>\n\n<div id=\"oauth-panel\" class=\"panel card stack\">\n <label for=\"oauthService\">OAuth service</label>\n <select id=\"oauthService\" name=\"oauthService\">${oauthOptions}</select>\n <p class=\"panel-note\">You'll be redirected to the selected service's authorization page.</p>\n</div>\n\n<div>\n <button id=\"btn\" class=\"primary-button\" onclick=\"connect()\">Continue</button>\n <div id=\"result\" class=\"result\" aria-live=\"polite\"></div>\n</div>\n <script>\n const envKeyPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\n function selectedMode() {\n return document.querySelector('input[name=\"mode\"]:checked').value;\n }\n\n function showResult(message, ok) {\n const result = document.getElementById('result');\n result.style.display = 'block';\n result.className = ok ? 'result ok' : 'result err';\n result.textContent = message;\n }\n\n function resetContinueButton() {\n const btn = document.getElementById('btn');\n btn.disabled = false;\n btn.textContent = 'Continue';\n }\n\n function syncPanels() {\n const mode = selectedMode();\n document.getElementById('api-panel').classList.toggle('active', mode === 'api_key');\n document.getElementById('oauth-panel').classList.toggle('active', mode === 'oauth');\n }\n\n function collectManualCard(card) {\n const envKey = card.querySelector('#envKey').value.trim();\n const credential = card.querySelector('#credential').value.trim();\n if (!envKey && !credential) return { skip: true };\n if (!envKeyPattern.test(envKey)) return { error: 'Manual entry: please enter a valid environment key.' };\n if (!credential) return { error: 'Manual entry: please enter a secret value.' };\n return { env: { [envKey]: credential } };\n }\n\n function collectPresetCard(card) {\n const inputs = card.querySelectorAll('input[data-env-key]');\n const filled = Array.from(inputs).some((input) => input.value.trim() !== '');\n if (!filled) return { skip: true };\n\n const env = {};\n for (const input of inputs) {\n const value = input.value.trim();\n const label = input.dataset.fieldLabel || input.dataset.envKey || 'a value';\n const optional = input.dataset.optional === 'true';\n if (!value) {\n if (optional) continue;\n return { error: 'Please enter ' + label + '.' };\n }\n if (input.dataset.pattern && !(new RegExp(input.dataset.pattern).test(value))) {\n return { error: input.dataset.patternMessage || ('Invalid ' + label + '.') };\n }\n const envKeys = (input.dataset.envKeys || input.dataset.envKey || '')\n .split(',')\n .map((entry) => entry.trim())\n .filter(Boolean);\n for (const envKey of envKeys) {\n env[envKey] = value;\n }\n }\n return { env };\n }\n\n function collectApiEnv() {\n const env = {};\n let any = false;\n for (const card of document.querySelectorAll('.provider-card')) {\n const result = card.dataset.providerKind === 'manual'\n ? collectManualCard(card)\n : collectPresetCard(card);\n if (result.skip) continue;\n if (result.error) return { error: result.error };\n Object.assign(env, result.env);\n any = true;\n }\n if (!any) return { error: 'Fill in at least one provider before continuing.' };\n return { env };\n }\n\n async function startOAuthFlow() {\n const serviceId = document.getElementById('oauthService').value;\n const r = await fetch('/api/oauth/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: '${esc(token)}', serviceId }),\n });\n const data = await r.json();\n if (!r.ok) {\n showResult('Error: ' + (data.error ?? r.status), false);\n resetContinueButton();\n return;\n }\n window.location.href = data.redirectUrl;\n }\n\n async function saveApiSecrets() {\n const payload = collectApiEnv();\n if (payload.error) {\n showResult(payload.error, false);\n resetContinueButton();\n return;\n }\n\n const r = await fetch('/api/link/complete', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: '${esc(token)}', mode: 'api_key', env: payload.env }),\n });\n const data = await r.json();\n if (r.ok) {\n showResult(data.message ?? 'Credential stored. You can close this tab.', true);\n document.getElementById('btn').style.display = 'none';\n for (const input of document.querySelectorAll('input,select,button')) input.disabled = true;\n } else {\n showResult('Error: ' + (data.error ?? r.status), false);\n resetContinueButton();\n }\n }\n\n let openHelp = null;\n function closeOpenHelp() {\n if (openHelp) {\n openHelp.setAttribute('aria-expanded', 'false');\n openHelp = null;\n }\n }\n\n for (const trigger of document.querySelectorAll('.help-trigger')) {\n trigger.addEventListener('click', (event) => {\n event.stopPropagation();\n const wasOpen = trigger.getAttribute('aria-expanded') === 'true';\n closeOpenHelp();\n if (!wasOpen) {\n trigger.setAttribute('aria-expanded', 'true');\n openHelp = trigger;\n }\n });\n }\n\n document.addEventListener('click', closeOpenHelp);\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeOpenHelp();\n });\n\n for (const radio of document.querySelectorAll('input[name=\"mode\"]')) {\n radio.addEventListener('change', syncPanels);\n }\n\n syncPanels();\n\n async function connect() {\n const btn = document.getElementById('btn');\n const mode = selectedMode();\n btn.disabled = true;\n btn.textContent = mode === 'oauth' ? 'Redirecting…' : 'Saving…';\n\n try {\n if (mode === 'oauth') {\n await startOAuthFlow();\n return;\n }\n await saveApiSecrets();\n } catch (err) {\n showResult('Network error: ' + (err?.message ?? err), false);\n resetContinueButton();\n }\n }\n </script>`,\n );\n}\n\nfunction renderErrorPage(message: string): string {\n return renderStatusPage(\"Login Error\", message, \"err\");\n}\n\nfunction renderSuccessPage(message: string): string {\n return renderStatusPage(\"Connected\", message, \"ok\", { closeNote: true });\n}\n\nfunction isValidEnvKey(value: string): boolean {\n return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);\n}\n\nfunction extractEnvUpdates(data: Partial<LinkCompleteBody>): {\n updates?: Record<string, string>;\n error?: string;\n} {\n if (data.env && typeof data.env === \"object\" && !Array.isArray(data.env)) {\n const rawEntries = Object.entries(data.env);\n if (rawEntries.length === 0) return { error: \"Missing required field: env\" };\n\n const updates: Record<string, string> = {};\n for (const [rawKey, rawValue] of rawEntries) {\n const envKey = rawKey.trim();\n const credential = typeof rawValue === \"string\" ? rawValue.trim() : \"\";\n if (!isValidEnvKey(envKey)) return { error: `Invalid envKey format: ${rawKey}` };\n if (!credential) return { error: `Missing value for envKey: ${envKey}` };\n updates[envKey] = credential;\n }\n\n return { updates };\n }\n\n const envKey = data.envKey?.trim() ?? \"\";\n const credential = data.credential?.trim() ?? \"\";\n if (!isValidEnvKey(envKey)) return { error: \"Invalid envKey format\" };\n if (!credential) return { error: \"Missing required field: credential\" };\n return { updates: { [envKey]: credential } };\n}\n\nfunction renderStoredEnvMessage(envKeys: string[], fileTargets: string[] = []): string {\n const fileSuffix = fileTargets.length > 0 ? ` Mounted file(s): ${fileTargets.join(\", \")}.` : \"\";\n if (envKeys.length === 1) {\n return `${envKeys[0]} stored successfully in vault.${fileSuffix}`;\n }\n\n return `${envKeys.length} secrets stored successfully in vault: ${envKeys.join(\", \")}.${fileSuffix}`;\n}\n\nfunction renderSentryCliConfig(updates: Record<string, string>): string | undefined {\n const token = updates.SENTRY_AUTH_TOKEN?.trim();\n if (!token) return undefined;\n\n const lines = [\"[auth]\", `token=${token}`, \"\"];\n const defaults: string[] = [];\n const org = updates.SENTRY_ORG?.trim();\n const project = updates.SENTRY_PROJECT?.trim();\n if (org) defaults.push(`org = ${org}`);\n if (project) defaults.push(`project = ${project}`);\n if (defaults.length > 0) {\n lines.push(\"[defaults]\", ...defaults, \"\");\n }\n return lines.join(\"\\n\");\n}\n\n// ── API-key completion ────────────────────────────────────────────────────────\n\nasync function handleLinkComplete(\n body: string,\n linkTokenStore: InMemoryLinkTokenStore,\n vaultManager: VaultManager,\n notify: NotifyFn,\n res: ServerResponse,\n): Promise<void> {\n let data: Partial<LinkCompleteBody>;\n try {\n data = JSON.parse(body) as Partial<LinkCompleteBody>;\n } catch {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Invalid JSON\" }));\n return;\n }\n\n if (!data.token) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Missing required field: token\" }));\n return;\n }\n\n const { updates, error } = extractEnvUpdates(data);\n if (!updates || error) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: error ?? \"Invalid env payload\" }));\n return;\n }\n\n const envKeys = Object.keys(updates).toSorted((left, right) => left.localeCompare(right));\n\n // Atomic consume prevents two concurrent requests from both passing the\n // validity check before either deletes the token.\n const linkToken = linkTokenStore.consume(data.token);\n if (!linkToken) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Invalid or expired token\" }));\n return;\n }\n\n const fileTargets: string[] = [];\n const sentryCliConfig = renderSentryCliConfig(updates);\n\n try {\n vaultManager.upsertEnv(linkToken.vaultId, updates);\n if (sentryCliConfig) {\n vaultManager.upsertFile(\n linkToken.vaultId,\n \".sentryclirc\",\n sentryCliConfig,\n \"/root/.sentryclirc\",\n );\n fileTargets.push(\"/root/.sentryclirc\");\n }\n } catch (persistError) {\n log.logWarning(\n `Failed to persist [${envKeys.join(\", \")}] for ${linkToken.platform}/${linkToken.platformUserId}`,\n persistError instanceof Error ? persistError.message : String(persistError),\n );\n reportUserFacingError(persistError, {\n domain: \"login\",\n surface: \"credential_login\",\n operation: \"vault_persist\",\n severity: \"error\",\n platform: linkToken.platform,\n context: {\n conversationId: linkToken.conversationId,\n vaultId: linkToken.vaultId,\n credentialMode: data.mode ?? \"manual\",\n envKeyCount: envKeys.length,\n fileTargetCount: fileTargets.length,\n },\n });\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error:\n \"Failed to store credential on server. Please fix the server issue and run /login again.\",\n }),\n );\n return;\n }\n\n log.logInfo(\n `Stored [${envKeys.join(\", \")}] for ${linkToken.platform}/${linkToken.platformUserId} in vault:${linkToken.vaultId}`,\n );\n\n const message = renderStoredEnvMessage(envKeys, fileTargets);\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ ok: true, message }));\n\n notify(\n linkToken.platform,\n linkToken.conversationId,\n `${message} Vault: \\`${linkToken.vaultId}\\`.`,\n ).catch((err: Error) => {\n log.logWarning(\"Failed to notify user after credential login\", err.message);\n reportUserFacingError(err, {\n domain: \"login\",\n surface: \"credential_login\",\n operation: \"notify_user\",\n severity: \"warning\",\n platform: linkToken.platform,\n context: {\n conversationId: linkToken.conversationId,\n vaultId: linkToken.vaultId,\n credentialMode: data.mode ?? \"manual\",\n envKeyCount: envKeys.length,\n fileTargetCount: fileTargets.length,\n },\n });\n });\n}\n\n// ── OAuth flow ────────────────────────────────────────────────────────────────\n\nasync function handleOAuthStart(\n body: string,\n req: IncomingMessage,\n linkTokenStore: InMemoryLinkTokenStore,\n oauthStates: Map<string, PendingOAuthState>,\n res: ServerResponse,\n): Promise<void> {\n let data: Partial<OAuthStartBody>;\n try {\n data = JSON.parse(body) as Partial<OAuthStartBody>;\n } catch {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Invalid JSON\" }));\n return;\n }\n\n if (!data.token || !data.serviceId) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Missing required fields: token/serviceId\" }));\n return;\n }\n\n const linkToken = linkTokenStore.peek(data.token);\n if (!linkToken) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Invalid or expired token\" }));\n return;\n }\n\n const service = resolveOAuthService(data.serviceId);\n if (!service) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: `Unsupported OAuth service: ${data.serviceId}` }));\n return;\n }\n\n const clientId = process.env[service.clientIdEnvKey];\n const clientSecret = process.env[service.clientSecretEnvKey];\n if (!clientId || !clientSecret) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error:\n `OAuth service ${service.label} is not configured. ` +\n `Missing ${service.clientIdEnvKey}/${service.clientSecretEnvKey}.`,\n }),\n );\n return;\n }\n\n const state = randomBytes(16).toString(\"hex\");\n const codeVerifier = randomBytes(32).toString(\"base64url\");\n oauthStates.set(state, {\n linkToken: data.token,\n serviceId: service.id,\n codeVerifier,\n expiresAt: Date.now() + OAUTH_STATE_TTL_MS,\n });\n\n for (const [k, v] of oauthStates) {\n if (Date.now() > v.expiresAt) oauthStates.delete(k);\n }\n\n const redirectUri = `${requestBaseUrl(req)}/oauth/callback`;\n const authorizeUrl = new URL(service.authorizationUrl);\n authorizeUrl.searchParams.set(\"response_type\", \"code\");\n authorizeUrl.searchParams.set(\"client_id\", clientId);\n authorizeUrl.searchParams.set(\"redirect_uri\", redirectUri);\n authorizeUrl.searchParams.set(\"state\", state);\n if (service.scopes.length > 0) {\n authorizeUrl.searchParams.set(\"scope\", service.scopes.join(\" \"));\n }\n for (const [key, value] of Object.entries(service.authorizationParams ?? {})) {\n authorizeUrl.searchParams.set(key, value);\n }\n\n const codeChallenge = createHash(\"sha256\").update(codeVerifier).digest(\"base64url\");\n authorizeUrl.searchParams.set(\"code_challenge\", codeChallenge);\n authorizeUrl.searchParams.set(\"code_challenge_method\", \"S256\");\n\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ ok: true, redirectUrl: authorizeUrl.toString() }));\n}\n\nasync function handleOAuthCallback(\n url: URL,\n req: IncomingMessage,\n linkTokenStore: InMemoryLinkTokenStore,\n vaultManager: VaultManager,\n notify: NotifyFn,\n oauthStates: Map<string, PendingOAuthState>,\n res: ServerResponse,\n): Promise<void> {\n const state = url.searchParams.get(\"state\") ?? \"\";\n const code = url.searchParams.get(\"code\") ?? \"\";\n const oauthError = url.searchParams.get(\"error\");\n\n // Atomic pop: whatever path we take from here, this state is spent.\n // Done before any `await` to close the TOCTOU window between the state\n // lookup and the final delete.\n const pending = oauthStates.get(state);\n if (pending) oauthStates.delete(state);\n\n if (oauthError) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(`OAuth authorization failed: ${oauthError}`));\n return;\n }\n\n if (!pending || Date.now() > pending.expiresAt) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"OAuth state is invalid or expired. Please run /login again.\"));\n return;\n }\n\n if (!code) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"Missing OAuth authorization code.\"));\n return;\n }\n\n const service = resolveOAuthService(pending.serviceId);\n if (!service) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"Unsupported OAuth service.\"));\n return;\n }\n\n const clientId = process.env[service.clientIdEnvKey];\n const clientSecret = process.env[service.clientSecretEnvKey];\n if (!clientId || !clientSecret) {\n res.writeHead(500, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"OAuth service is not configured on server.\"));\n return;\n }\n\n // Atomic consume: pairs with the callback being one-shot. Two concurrent\n // callbacks for the same state would previously both pass `peek` and both\n // run `exchangeOAuthCode` across the await; only one reaches `consume`.\n const linkToken = linkTokenStore.consume(pending.linkToken);\n if (!linkToken) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"Login link is invalid or expired. Please run /login again.\"));\n return;\n }\n\n const redirectUri = `${requestBaseUrl(req)}/oauth/callback`;\n const tokenResp = await exchangeOAuthCode(\n service,\n code,\n clientId,\n clientSecret,\n redirectUri,\n pending.codeVerifier,\n );\n\n const accessToken = tokenResp.access_token?.trim();\n const refreshToken = tokenResp.refresh_token?.trim();\n\n if (!accessToken) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"OAuth token exchange did not return an access_token.\"));\n return;\n }\n\n const updates: Record<string, string> = {};\n if (service.accessTokenEnvKey) {\n updates[service.accessTokenEnvKey] = accessToken;\n }\n for (const key of service.additionalAccessTokenEnvKeys ?? []) {\n updates[key] = accessToken;\n }\n if (refreshToken && service.refreshTokenEnvKey) {\n updates[service.refreshTokenEnvKey] = refreshToken;\n }\n\n const fileOutput = service.fileOutput;\n let mountedPath: string | undefined;\n if (fileOutput?.type === \"authorized_user\") {\n if (!refreshToken) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderErrorPage(\n \"OAuth token exchange did not return a refresh_token. \" +\n \"Retry after revoking prior consent or ensure prompt=consent is applied.\",\n ),\n );\n return;\n }\n\n mountedPath = fileOutput.targetPath ?? defaultVaultTargetPath(fileOutput.relativePath);\n if (fileOutput.envKey) {\n updates[fileOutput.envKey] = mountedPath;\n }\n for (const key of fileOutput.additionalEnvKeys ?? []) {\n updates[key] = mountedPath;\n }\n }\n\n const storedTargets: string[] = [];\n try {\n if (Object.keys(updates).length > 0) {\n vaultManager.upsertEnv(linkToken.vaultId, updates);\n storedTargets.push(...Object.keys(updates).toSorted());\n }\n if (fileOutput?.type === \"authorized_user\" && refreshToken) {\n vaultManager.upsertFile(\n linkToken.vaultId,\n fileOutput.relativePath,\n renderAuthorizedUserCredential(clientId, clientSecret, refreshToken),\n fileOutput.targetPath,\n );\n if (mountedPath) storedTargets.push(mountedPath);\n }\n } catch (persistError) {\n log.logWarning(\n `Failed to persist OAuth credentials for ${linkToken.platform}/${linkToken.platformUserId}`,\n persistError instanceof Error ? persistError.message : String(persistError),\n );\n reportUserFacingError(persistError, {\n domain: \"login\",\n surface: \"oauth\",\n operation: \"vault_persist\",\n severity: \"error\",\n platform: linkToken.platform,\n context: {\n conversationId: linkToken.conversationId,\n vaultId: linkToken.vaultId,\n serviceId: service.id,\n credentialMode: \"oauth\",\n storedTargetCount: storedTargets.length,\n },\n });\n res.writeHead(500, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderErrorPage(\n \"OAuth tokens were received but could not be stored on the server. Fix the server issue and run /login again.\",\n ),\n );\n return;\n }\n\n log.logInfo(\n `Stored [${storedTargets.join(\", \")}] for ${linkToken.platform}/${linkToken.platformUserId} in vault:${linkToken.vaultId}`,\n );\n\n notify(\n linkToken.platform,\n linkToken.conversationId,\n `${service.label} OAuth stored (${storedTargets.join(\", \")}) in vault \\`${linkToken.vaultId}\\`.`,\n ).catch((err: Error) => {\n log.logWarning(\"Failed to notify user after OAuth login\", err.message);\n reportUserFacingError(err, {\n domain: \"login\",\n surface: \"oauth\",\n operation: \"notify_user\",\n severity: \"warning\",\n platform: linkToken.platform,\n context: {\n conversationId: linkToken.conversationId,\n vaultId: linkToken.vaultId,\n serviceId: service.id,\n credentialMode: \"oauth\",\n storedTargetCount: storedTargets.length,\n },\n });\n });\n\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderSuccessPage(`${service.label} OAuth connected successfully.`));\n}\n\nasync function exchangeOAuthCode(\n service: OAuthService,\n code: string,\n clientId: string,\n clientSecret: string,\n redirectUri: string,\n codeVerifier: string,\n): Promise<Record<string, string>> {\n const params = new URLSearchParams();\n params.set(\"grant_type\", \"authorization_code\");\n params.set(\"code\", code);\n params.set(\"client_id\", clientId);\n params.set(\"client_secret\", clientSecret);\n params.set(\"redirect_uri\", redirectUri);\n params.set(\"code_verifier\", codeVerifier);\n\n const response = await fetch(service.tokenUrl, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n Accept: \"application/json\",\n },\n body: params.toString(),\n });\n\n const text = await response.text();\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n let parsed: Record<string, string> = {};\n\n if (contentType.includes(\"application/json\")) {\n parsed = JSON.parse(text) as Record<string, string>;\n } else {\n const form = new URLSearchParams(text);\n parsed = Object.fromEntries(form.entries());\n }\n\n if (!response.ok) {\n const message = parsed.error_description ?? parsed.error ?? `${response.status}`;\n throw new Error(`OAuth token exchange failed for ${service.id}: ${message}`);\n }\n\n return parsed;\n}\n\nfunction renderAuthorizedUserCredential(\n clientId: string,\n clientSecret: string,\n refreshToken: string,\n): string {\n return (\n JSON.stringify(\n {\n client_id: clientId,\n client_secret: clientSecret,\n refresh_token: refreshToken,\n type: \"authorized_user\",\n },\n null,\n 2,\n ) + \"\\n\"\n );\n}\n"]}
|
|
1
|
+
{"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../src/login/portal.ts"],"names":[],"mappings":"AACA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,MAAM,CAAC;AAC5F,OAAO,KAAK,EAAE,GAAG,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AACvD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACjF,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,EAEL,KAAK,6BAA6B,EACnC,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,0BAA0B,CAAC;AAC9E,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAU3D,OAAO,EAA0B,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAIxE,oEAAoE;AACpE,MAAM,MAAM,QAAQ,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAyNpG;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,EACZ,cAAc,EAAE,sBAAsB,EACtC,YAAY,EAAE,YAAY,EAC1B,MAAM,EAAE,QAAQ,EAChB,qBAAqB,CAAC,EAAE,6BAA6B,EACrD,sBAAsB,CAAC,EAAE,6BAA6B,EACtD,YAAY,CAAC,EAAE;IACb,eAAe,EAAE,uBAAuB,CAAC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,kBAAkB,CAAC;IAC7B,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,GAAG,CAAC,CAAC,CAAC;CACrD,GACA,MAAM,CA2JR","sourcesContent":["import { createHash, randomBytes } from \"crypto\";\nimport { createServer, type IncomingMessage, type Server, type ServerResponse } from \"http\";\nimport type { Bot, PlatformName } from \"../adapter.js\";\nimport { handleAdminRequest, type AdminRuntimeBridge } from \"../admin/portal.js\";\nimport type { InMemoryAdminTokenStore } from \"../admin/store.js\";\nimport { renderPortalShell } from \"../portal-shell.js\";\nimport type { SandboxConfig } from \"../sandbox/index.js\";\nimport { resolveLinkBaseUrl } from \"../config.js\";\nimport {\n handleSessionViewRequest,\n type SessionViewInteractiveOptions,\n} from \"../session-view/portal.js\";\nimport type { InMemorySessionViewTokenStore } from \"../session-view/store.js\";\nimport type { InMemoryLinkTokenStore } from \"./session.js\";\nimport {\n getOAuthServices,\n resolveOAuthService,\n type LoginCredentialKind,\n type OAuthService,\n} from \"./index.js\";\nimport * as log from \"../log.js\";\nimport { reportUserFacingError } from \"../sentry.js\";\nimport { PRODUCT_NAME } from \"../ui-copy.js\";\nimport { defaultVaultTargetPath, type VaultManager } from \"../vault.js\";\n\n// ── Types ──────────────────────────────────────────────────────────────────────\n\n/** Called after a binding is written, to notify the user in chat */\nexport type NotifyFn = (platform: string, conversationId: string, message: string) => Promise<void>;\n\ninterface LinkCompleteBody {\n token: string;\n mode?: LoginCredentialKind;\n envKey?: string;\n credential?: string;\n env?: Record<string, string>;\n}\n\ninterface OAuthStartBody {\n token: string;\n serviceId: string;\n}\n\ninterface PendingOAuthState {\n linkToken: string;\n serviceId: string;\n codeVerifier: string;\n expiresAt: number;\n}\n\ninterface SecretPresetField {\n envKey: string;\n envKeys?: string[];\n label: string;\n type: \"text\" | \"password\";\n placeholder: string;\n helpText: string;\n optional?: boolean;\n pattern?: string;\n patternMessage?: string;\n}\n\ninterface SecretPreset {\n id: string;\n label: string;\n description: string;\n note?: string;\n fields: SecretPresetField[];\n}\n\nconst OAUTH_STATE_TTL_MS = 10 * 60 * 1000;\nconst DEFAULT_SECRET_CONFIG_ID = \"manual\";\nconst SECRET_PRESETS: SecretPreset[] = [\n {\n id: \"cloudflare_wrangler\",\n label: \"Cloudflare / Wrangler\",\n description:\n \"Store a Cloudflare API token and account ID for Wrangler, Workers, Pages, D1, and KV.\",\n note: \"Create a scoped API Token from Cloudflare Dashboard → My Profile → API Tokens. Do not use the Global API Key.\",\n fields: [\n {\n envKey: \"CLOUDFLARE_API_TOKEN\",\n label: \"Cloudflare API Token\",\n type: \"password\",\n placeholder: \"cfut_...\",\n helpText: \"Recommended for Wrangler, CI, and sandbox use.\",\n },\n {\n envKey: \"CLOUDFLARE_ACCOUNT_ID\",\n label: \"Cloudflare Account ID\",\n type: \"text\",\n placeholder: \"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\n helpText: \"Find this via wrangler whoami or in the Cloudflare dashboard account page.\",\n pattern: \"^[A-Fa-f0-9]{32}$\",\n patternMessage: \"Account ID must be a 32-character hexadecimal string.\",\n },\n ],\n },\n {\n id: \"openai\",\n label: \"OpenAI\",\n description: \"Store an OpenAI API key for tools and SDKs that use OPENAI_API_KEY.\",\n note: \"Create a standard API key from the OpenAI dashboard. Paste the key exactly as issued.\",\n fields: [\n {\n envKey: \"OPENAI_API_KEY\",\n label: \"OpenAI API Key\",\n type: \"password\",\n placeholder: \"sk-...\",\n helpText: \"Used by the OpenAI SDK, CLI wrappers, and many coding tools.\",\n },\n ],\n },\n {\n id: \"anthropic\",\n label: \"Anthropic\",\n description: \"Store an Anthropic API key for Claude and tools that use ANTHROPIC_API_KEY.\",\n note: \"Create this key from the Anthropic Console. Use a workspace-scoped key when possible.\",\n fields: [\n {\n envKey: \"ANTHROPIC_API_KEY\",\n label: \"Anthropic API Key\",\n type: \"password\",\n placeholder: \"sk-ant-...\",\n helpText: \"Used by Claude integrations and Anthropic-compatible tooling.\",\n },\n ],\n },\n {\n id: \"gemini\",\n label: \"Gemini\",\n description:\n \"Store one Google AI Studio key and expose it as both GEMINI_API_KEY and GOOGLE_API_KEY.\",\n note: \"Create a Gemini / Google AI Studio API key, then paste it once here for compatibility with both env names.\",\n fields: [\n {\n envKey: \"GEMINI_API_KEY\",\n envKeys: [\"GEMINI_API_KEY\", \"GOOGLE_API_KEY\"],\n label: \"Gemini API Key\",\n type: \"password\",\n placeholder: \"AIza...\",\n helpText: \"One value will be written to both GEMINI_API_KEY and GOOGLE_API_KEY.\",\n },\n ],\n },\n {\n id: \"openrouter\",\n label: \"OpenRouter\",\n description: \"Store an OpenRouter API key for tools that route models through OpenRouter.\",\n note: \"Create a key from the OpenRouter dashboard and paste it here.\",\n fields: [\n {\n envKey: \"OPENROUTER_API_KEY\",\n label: \"OpenRouter API Key\",\n type: \"password\",\n placeholder: \"sk-or-v1-...\",\n helpText: \"Used by OpenRouter SDKs and compatible model gateways.\",\n },\n ],\n },\n {\n id: \"github_pat\",\n label: \"GitHub PAT\",\n description:\n \"Store one GitHub personal access token and expose it as both GH_TOKEN and GITHUB_TOKEN.\",\n note: \"Create a fine-grained or classic personal access token from GitHub Settings → Developer settings.\",\n fields: [\n {\n envKey: \"GH_TOKEN\",\n envKeys: [\"GH_TOKEN\", \"GITHUB_TOKEN\"],\n label: \"GitHub Personal Access Token\",\n type: \"password\",\n placeholder: \"github_pat_...\",\n helpText: \"One value will be written to both GH_TOKEN and GITHUB_TOKEN.\",\n },\n ],\n },\n {\n id: \"vercel\",\n label: \"Vercel\",\n description: \"Store a Vercel token plus optional org and project IDs for deployment tooling.\",\n note: \"Create a token from the Vercel dashboard. Org ID and Project ID are optional but useful for scripted deploys.\",\n fields: [\n {\n envKey: \"VERCEL_TOKEN\",\n label: \"Vercel Token\",\n type: \"password\",\n placeholder: \"vercel_...\",\n helpText: \"Required for Vercel CLI and API access.\",\n },\n {\n envKey: \"VERCEL_ORG_ID\",\n label: \"Vercel Org ID\",\n type: \"text\",\n placeholder: \"team_...\",\n helpText: \"Optional. Set this when you want to target a specific team or account.\",\n optional: true,\n },\n {\n envKey: \"VERCEL_PROJECT_ID\",\n label: \"Vercel Project ID\",\n type: \"text\",\n placeholder: \"prj_...\",\n helpText: \"Optional. Set this when deploy scripts need a fixed project reference.\",\n optional: true,\n },\n ],\n },\n {\n id: \"sentry\",\n label: \"Sentry\",\n description:\n \"Store a Sentry auth token plus optional org/project, and mount ~/.sentryclirc for sentry-cli.\",\n note: \"Create an auth token from Sentry Settings → Account → API → Auth Tokens. Saving this preset also writes /root/.sentryclirc for sentry-cli.\",\n fields: [\n {\n envKey: \"SENTRY_AUTH_TOKEN\",\n label: \"Sentry Auth Token\",\n type: \"password\",\n placeholder: \"sntrys_...\",\n helpText:\n \"Required for Sentry CLI, releases, and sourcemap uploads. Also written to /root/.sentryclirc.\",\n },\n {\n envKey: \"SENTRY_ORG\",\n label: \"Sentry Org Slug\",\n type: \"text\",\n placeholder: \"my-org\",\n helpText: \"Optional. Helpful for Sentry CLI commands and CI automation.\",\n optional: true,\n },\n {\n envKey: \"SENTRY_PROJECT\",\n label: \"Sentry Project Slug\",\n type: \"text\",\n placeholder: \"my-project\",\n helpText: \"Optional. Helpful for release and sourcemap commands.\",\n optional: true,\n },\n ],\n },\n];\n\n// ── startLinkServer ────────────────────────────────────────────────────────────\n\n/**\n * Start a small HTTP server that receives credential onboarding callbacks from the web portal.\n *\n * Routes:\n * GET /health — health check\n * GET /link?token=xxx — credential onboarding page\n * POST /api/link/complete — API key completion endpoint\n * POST /api/oauth/start — creates provider OAuth redirect URL\n * GET /oauth/callback — OAuth callback endpoint\n */\nexport function startLinkServer(\n port: number,\n linkTokenStore: InMemoryLinkTokenStore,\n vaultManager: VaultManager,\n notify: NotifyFn,\n sessionViewTokenStore?: InMemorySessionViewTokenStore,\n sessionViewInteractive?: SessionViewInteractiveOptions,\n adminOptions?: {\n adminTokenStore: InMemoryAdminTokenStore;\n workingDir?: string;\n runtime?: AdminRuntimeBridge;\n sandbox?: SandboxConfig;\n botsByPlatform?: Partial<Record<PlatformName, Bot>>;\n },\n): Server {\n const oauthStates = new Map<string, PendingOAuthState>();\n\n const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {\n try {\n const url = new URL(req.url ?? \"/\", requestBaseUrl(req));\n\n if (req.method === \"GET\" && url.pathname === \"/health\") {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ ok: true }));\n return;\n }\n\n if (\n adminOptions?.adminTokenStore &&\n handleAdminRequest(req, res, url, {\n vaultManager,\n linkTokenStore,\n sessionViewTokenStore,\n adminTokenStore: adminOptions.adminTokenStore,\n portalBaseUrl: resolveLinkBaseUrl() ?? undefined,\n workingDir: adminOptions.workingDir,\n runtime: adminOptions.runtime,\n sandbox: adminOptions.sandbox,\n botsByPlatform: adminOptions.botsByPlatform,\n })\n ) {\n return;\n }\n\n if (\n await handleSessionViewRequest(req, res, url, sessionViewTokenStore, sessionViewInteractive)\n ) {\n return;\n }\n\n if (req.method === \"GET\" && url.pathname === \"/link\") {\n const rawToken = url.searchParams.get(\"token\") ?? \"\";\n const linkToken = linkTokenStore.peek(rawToken);\n\n if (!linkToken) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderErrorPage(\n \"This link is invalid or has expired. Ask the bot for a new /login link.\",\n ),\n );\n return;\n }\n\n const oauthServiceHint = linkToken.providerId\n ? resolveOAuthService(linkToken.providerId)\n : undefined;\n const oauthServices = getOAuthServices();\n const defaultMode: LoginCredentialKind = oauthServiceHint ? \"oauth\" : \"api_key\";\n const existingSecrets = describeVaultSecrets(vaultManager, linkToken.vaultId);\n\n const title = oauthServiceHint ? `${oauthServiceHint.label} OAuth` : \"Store Secret\";\n const helpText = oauthServiceHint\n ? `Authorize ${oauthServiceHint.label} and store tokens in your vault.`\n : \"Set any environment variable key/value pair in your vault.\";\n const secretLabel = \"Secret value\";\n const placeholder = \"sk-...\";\n const initialEnvKey = \"\";\n\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderCredentialPage(\n rawToken,\n title,\n defaultMode,\n initialEnvKey,\n secretLabel,\n placeholder,\n helpText,\n oauthServices,\n oauthServiceHint?.id,\n existingSecrets,\n ),\n );\n return;\n }\n\n if (req.method === \"POST\" && url.pathname === \"/api/link/complete\") {\n if (!enforceCsrf(req, res)) return;\n void readJsonBody(req, res, async (body) => {\n await handleLinkComplete(body, linkTokenStore, vaultManager, notify, res);\n });\n return;\n }\n\n if (req.method === \"POST\" && url.pathname === \"/api/oauth/start\") {\n if (!enforceCsrf(req, res)) return;\n void readJsonBody(req, res, async (body) => {\n await handleOAuthStart(body, req, linkTokenStore, oauthStates, res);\n });\n return;\n }\n\n if (req.method === \"GET\" && url.pathname === \"/oauth/callback\") {\n void handleOAuthCallback(\n url,\n req,\n linkTokenStore,\n vaultManager,\n notify,\n oauthStates,\n res,\n ).catch((err: Error) => {\n log.logWarning(\"OAuth callback failed\", err.message);\n reportUserFacingError(err, {\n domain: \"login\",\n surface: \"oauth\",\n operation: \"oauth_callback\",\n severity: \"error\",\n context: { route: \"/oauth/callback\" },\n });\n res.writeHead(500, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"OAuth callback failed. Please retry /login.\"));\n });\n return;\n }\n\n res.writeHead(404);\n res.end();\n } catch (err) {\n log.logWarning(\"Link server request error\", err instanceof Error ? err.message : String(err));\n if (!res.headersSent) {\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n }\n res.end(JSON.stringify({ error: \"Internal server error\" }));\n }\n });\n\n // Bind to loopback when MIKAN_LINK_URL is unset so the credential UI and OAuth\n // callbacks are not exposed on public interfaces by default. Production\n // deployments set MIKAN_LINK_URL and are expected to front this server with a\n // reverse proxy, which can still reach it via 0.0.0.0.\n const bindHost = resolveLinkBaseUrl() ? undefined : \"127.0.0.1\";\n server.listen(port, bindHost, () => {\n log.logInfo(`Link callback server listening on ${bindHost ?? \"0.0.0.0\"}:${port}`);\n if (!resolveLinkBaseUrl()) {\n log.logWarning(\n \"MIKAN_LINK_URL is not set — bound to 127.0.0.1 and OAuth redirect_uri will be \" +\n \"derived from request headers (Host / X-Forwarded-*). Set \" +\n \"MIKAN_LINK_URL=https://your-host.example.com for production.\",\n );\n }\n });\n\n server.on(\"error\", (err) => {\n log.logWarning(\"Link server error\", err.message);\n });\n\n return server;\n}\n\n/**\n * Resolve the externally-visible base URL of this server.\n *\n * Prefers MIKAN_LINK_URL (see config.ts) so the OAuth `redirect_uri` is\n * deterministic and not influenced by attacker-controlled request headers.\n * Falls back to Host / X-Forwarded-* only when no base URL is configured\n * — intended for local development.\n */\nfunction requestBaseUrl(req: IncomingMessage): string {\n const configured = resolveLinkBaseUrl();\n if (configured) return configured;\n\n const protoRaw = (req.headers[\"x-forwarded-proto\"] as string | undefined)?.split(\",\")[0]?.trim();\n const proto = protoRaw || \"http\";\n const host =\n ((req.headers[\"x-forwarded-host\"] as string | undefined)?.split(\",\")[0]?.trim() ??\n req.headers.host ??\n `localhost`) ||\n `localhost`;\n return `${proto}://${host}`;\n}\n\n/**\n * Block cross-site POSTs to the credential endpoints. Two defenses:\n * 1. Require Content-Type: application/json, which forces a CORS preflight\n * for any cross-origin fetch and rules out `<form enctype=\"text/plain\">`\n * tricks that could otherwise smuggle a JSON body.\n * 2. When MIKAN_LINK_URL is configured, require that the Origin (or Referer,\n * as a fallback for browsers that strip Origin) matches that base URL.\n * This stops an attacker-controlled page — even one that somehow stole a\n * victim's link token — from completing the flow.\n */\nfunction enforceCsrf(req: IncomingMessage, res: ServerResponse): boolean {\n const contentType = (req.headers[\"content-type\"] as string | undefined)\n ?.split(\";\")[0]\n ?.trim()\n .toLowerCase();\n if (contentType !== \"application/json\") {\n res.writeHead(415, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Content-Type must be application/json\" }));\n return false;\n }\n\n const configured = resolveLinkBaseUrl();\n if (!configured) {\n // No trusted origin to compare against in local/dev mode; the loopback\n // bind already prevents cross-host access.\n return true;\n }\n\n let configuredOrigin: string;\n try {\n configuredOrigin = new URL(configured).origin;\n } catch {\n // Misconfigured MIKAN_LINK_URL — fail closed.\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Server misconfiguration\" }));\n return false;\n }\n\n if (requestOrigin(req) !== configuredOrigin) {\n res.writeHead(403, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Cross-origin request rejected\" }));\n return false;\n }\n\n return true;\n}\n\n/** Best-effort origin of the request, derived from Origin or Referer. */\nfunction requestOrigin(req: IncomingMessage): string | undefined {\n const origin = (req.headers.origin as string | undefined)?.trim();\n if (origin && origin !== \"null\") return origin;\n\n const referer = (req.headers.referer as string | undefined)?.trim();\n if (!referer) return undefined;\n try {\n return new URL(referer).origin;\n } catch {\n return undefined;\n }\n}\n\nasync function readJsonBody(\n req: IncomingMessage,\n res: ServerResponse,\n onBody: (body: string) => Promise<void>,\n): Promise<void> {\n let body = \"\";\n let bodyTooLarge = false;\n\n req.on(\"data\", (chunk: Buffer) => {\n if (bodyTooLarge) return;\n body += chunk.toString();\n if (body.length > 16 * 1024) {\n bodyTooLarge = true;\n res.writeHead(413);\n res.end();\n req.destroy();\n }\n });\n\n req.on(\"end\", async () => {\n if (bodyTooLarge) return;\n await onBody(body);\n });\n}\n\n// ── HTML helpers ───────────────────────────────────────────────────────────────\n\nfunction esc(s: string): string {\n return s.replace(\n /[&<>\"']/g,\n (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]!,\n );\n}\n\nconst loginViewStyles = `\n /* Login portal inherits all base chrome from the shared shell.\n This module only adds preset cards, service logos, help popovers,\n and the mode toggle. */\n\n p { margin: 0; color: var(--muted); font-size: 0.92rem; line-height: 1.55; }\n\n .stack > * + * { margin-top: 14px; }\n\n label {\n display: block;\n margin-bottom: 6px;\n font-size: 0.86rem;\n font-weight: 600;\n color: var(--text);\n }\n\n input, select {\n width: 100%;\n padding: 10px 12px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: #fff;\n color: var(--text);\n font: inherit;\n }\n\n input:focus-visible,\n select:focus-visible {\n outline: 2px solid var(--text);\n outline-offset: 1px;\n }\n\n .primary-button {\n width: 100%;\n padding: 12px 18px;\n border: none;\n border-radius: 12px;\n background: var(--text);\n color: #fff;\n cursor: pointer;\n font: 500 0.95rem/1.2 'DM Sans', sans-serif;\n transition: opacity 120ms;\n }\n\n .primary-button:hover:not(:disabled) { opacity: 0.85; }\n .primary-button:disabled { opacity: 0.5; cursor: default; }\n\n .service-logo {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 36px;\n height: 36px;\n border-radius: 10px;\n flex: 0 0 36px;\n background: #1c1e21;\n color: #fff;\n }\n\n .service-logo svg {\n display: block;\n width: 20px;\n height: 20px;\n }\n\n .service-logo-text {\n font-size: 11px;\n font-weight: 800;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n }\n\n .service-logo.cloudflare {\n background: linear-gradient(180deg, #ffb66d 0%, #f48120 100%);\n }\n\n .service-logo.openai {\n background: linear-gradient(180deg, #3e4045 0%, #111315 100%);\n }\n\n .service-logo.anthropic {\n background: linear-gradient(180deg, #d6b48c 0%, #9a6d3a 100%);\n }\n\n .service-logo.gemini {\n background: linear-gradient(180deg, #8ab4ff 0%, #5b6cff 100%);\n }\n\n .service-logo.openrouter {\n background: linear-gradient(180deg, #8c8cff 0%, #4f46e5 100%);\n }\n\n .service-logo.github {\n background: linear-gradient(180deg, #4a4f57 0%, #1b1f23 100%);\n }\n\n .service-logo.vercel {\n background: linear-gradient(180deg, #4a4f57 0%, #000 100%);\n }\n\n .service-logo.sentry {\n background: linear-gradient(180deg, #7c5cff 0%, #3f2e8c 100%);\n }\n\n .service-logo.manual {\n background: linear-gradient(180deg, #43474d 0%, #1c1e21 100%);\n }\n\n .provider-card > * + * {\n margin-top: 14px;\n }\n\n .provider-header {\n display: flex;\n align-items: center;\n gap: 12px;\n }\n\n .provider-title {\n flex: 1;\n margin: 0;\n font-size: 1rem;\n font-weight: 650;\n line-height: 1.3;\n }\n\n .provider-field label {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n\n .help {\n position: relative;\n display: inline-flex;\n align-items: center;\n }\n\n .help-trigger {\n width: 18px;\n height: 18px;\n padding: 0;\n border: 1px solid var(--border);\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.9);\n color: var(--muted);\n font-size: 11px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n }\n\n .help-trigger:hover {\n color: var(--text);\n border-color: var(--text);\n }\n\n .help-content {\n display: none;\n position: absolute;\n top: calc(100% + 6px);\n left: 0;\n z-index: 10;\n width: max-content;\n max-width: 280px;\n padding: 10px 12px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: #fff;\n color: var(--text);\n font-size: 0.85rem;\n font-weight: 400;\n line-height: 1.45;\n box-shadow: 0 8px 24px rgba(28, 30, 33, 0.12);\n white-space: normal;\n }\n\n .help-trigger[aria-expanded=\"true\"] + .help-content {\n display: block;\n }\n\n .help-trigger[aria-expanded=\"true\"] {\n color: var(--text);\n border-color: var(--text);\n }\n\n .mode {\n display: flex;\n flex-wrap: wrap;\n gap: 10px;\n margin-top: 22px;\n }\n\n .mode label {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n margin: 0;\n padding: 10px 12px;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: rgba(255, 255, 255, 0.85);\n font-weight: 500;\n }\n\n .mode input {\n width: auto;\n margin: 0;\n }\n\n .panel {\n display: none;\n }\n\n .panel.active {\n display: block;\n }\n\n #api-panel.active {\n display: grid;\n gap: 16px;\n }\n\n .panel-note {\n margin-top: 10px;\n font-size: 0.92rem;\n }\n\n .result,\n .status {\n margin-top: 20px;\n padding: 14px 16px;\n border-radius: 14px;\n font-size: 0.95rem;\n }\n\n .result {\n display: none;\n }\n\n .result.ok,\n .status.ok {\n background: var(--ok-bg);\n color: var(--ok-text);\n }\n\n .result.err,\n .status.err {\n background: var(--err-bg);\n color: var(--err-text);\n }\n\n .secrets-summary {\n margin-top: 18px;\n padding: 14px 16px;\n border: 1px solid var(--border);\n border-radius: 14px;\n background: rgba(255, 255, 255, 0.72);\n }\n\n .secrets-summary h2 {\n margin: 0 0 8px;\n font-size: 0.98rem;\n }\n\n .secrets-summary p {\n font-size: 0.92rem;\n }\n\n .secrets-summary ul {\n margin: 10px 0 0;\n padding-left: 18px;\n color: var(--text);\n }\n\n .secrets-summary li + li {\n margin-top: 6px;\n }\n\n .close-note {\n margin-top: 14px;\n font-size: 0.92rem;\n }\n\n @media (max-width: 640px) {\n .mode label { flex: 1; justify-content: center; }\n input, select { padding: 12px; }\n .primary-button { padding: 14px 18px; }\n .help-content { max-width: min(260px, calc(100vw - 40px)); }\n .provider-header .help-content { left: auto; right: 0; }\n }\n`;\n\nfunction renderHtmlDocument(title: string, shellContent: string): string {\n return renderPortalShell({\n activeView: \"vault\",\n pageTitle: \"Vault\",\n body: shellContent,\n extraStyles: loginViewStyles,\n });\n}\n\nfunction renderPageDocument(title: string, body: string): string {\n return renderHtmlDocument(title, `<section class=\"card\">${body}</section>`);\n}\n\nfunction renderStatusPage(\n title: string,\n message: string,\n tone: \"ok\" | \"err\",\n options?: { closeNote?: boolean },\n): string {\n const closeNote = options?.closeNote ? '<p class=\"close-note\">You can close this tab.</p>' : \"\";\n return renderPageDocument(\n title,\n `<div class=\"stack\">\n <p class=\"eyebrow\">${PRODUCT_NAME}</p>\n <h1 class=\"page-title\">${esc(title)}</h1>\n <div class=\"status ${tone}\">${esc(message)}</div>\n ${closeNote}\n </div>`,\n );\n}\n\ninterface ExistingSecretsSummary {\n envKeys: string[];\n mountTargets: string[];\n}\n\nfunction describeVaultSecrets(vaultManager: VaultManager, vaultId: string): ExistingSecretsSummary {\n const vault = vaultManager.resolve(vaultId);\n if (!vault) {\n return { envKeys: [], mountTargets: [] };\n }\n\n return {\n envKeys: Object.keys(vault.env).toSorted((left, right) => left.localeCompare(right)),\n mountTargets: [...new Set(vault.mounts.map((mount) => mount.target))].toSorted((left, right) =>\n left.localeCompare(right),\n ),\n };\n}\n\nfunction renderSecretsSummary(summary: ExistingSecretsSummary): string {\n if (summary.envKeys.length === 0 && summary.mountTargets.length === 0) {\n return `\n <section class=\"secrets-summary\">\n <h2>Currently stored</h2>\n <p>No secrets are stored in this vault yet.</p>\n </section>`;\n }\n\n const envItems = summary.envKeys.map((envKey) => `<li><code>${esc(envKey)}</code></li>`).join(\"\");\n const mountItems = summary.mountTargets\n .map((target) => `<li><code>${esc(target)}</code></li>`)\n .join(\"\");\n\n return `\n <section class=\"secrets-summary\">\n <h2>Currently stored</h2>\n <p>Only secret names and mounted paths are shown here. Secret values are never displayed.</p>\n ${summary.envKeys.length > 0 ? `<p><strong>Environment keys</strong></p><ul>${envItems}</ul>` : \"\"}\n ${summary.mountTargets.length > 0 ? `<p><strong>Mounted secret files</strong></p><ul>${mountItems}</ul>` : \"\"}\n </section>`;\n}\n\nfunction renderServiceLogo(kind: string): string {\n if (kind === \"cloudflare_wrangler\") {\n return `<span class=\"service-logo cloudflare\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\">\n <path d=\"M8.5 17.5h8.2a2.9 2.9 0 0 0 .4-5.78A4.45 4.45 0 0 0 8.9 10.4a3.7 3.7 0 0 0-.4 7.1Z\" fill=\"white\" fill-opacity=\"0.98\"/>\n <path d=\"M6.6 17.5h5.1a2.3 2.3 0 0 0 0-4.6 3.1 3.1 0 0 0-3-2.2 3.23 3.23 0 0 0-3.18 3.64A2.67 2.67 0 0 0 6.6 17.5Z\" fill=\"white\"/>\n </svg>\n </span>`;\n }\n\n const textLogos: Record<string, { className: string; text: string }> = {\n openai: { className: \"openai\", text: \"OA\" },\n anthropic: { className: \"anthropic\", text: \"AI\" },\n gemini: { className: \"gemini\", text: \"G\" },\n openrouter: { className: \"openrouter\", text: \"OR\" },\n github_pat: { className: \"github\", text: \"GH\" },\n vercel: { className: \"vercel\", text: \"V\" },\n sentry: { className: \"sentry\", text: \"S\" },\n manual: { className: \"manual\", text: \">_\" },\n };\n const logo = textLogos[kind] ?? textLogos.manual;\n return `<span class=\"service-logo ${logo.className}\" aria-hidden=\"true\"><span class=\"service-logo-text\">${logo.text}</span></span>`;\n}\n\nfunction resolveFieldEnvKeys(field: SecretPresetField): string[] {\n return field.envKeys && field.envKeys.length > 0 ? field.envKeys : [field.envKey];\n}\n\nfunction renderStoredEnvKeysInline(field: SecretPresetField): string {\n return resolveFieldEnvKeys(field)\n .map((envKey) => `<code>${esc(envKey)}</code>`)\n .join(\", \");\n}\n\nfunction renderHelpIcon(html: string): string {\n return `<span class=\"help\">\n <button type=\"button\" class=\"help-trigger\" aria-label=\"More info\" aria-expanded=\"false\">?</button>\n <span class=\"help-content\" role=\"tooltip\">${html}</span>\n </span>`;\n}\n\nfunction renderPresetProviderCard(preset: SecretPreset): string {\n const headerHelp = preset.note ? renderHelpIcon(esc(preset.note)) : \"\";\n const fields = preset.fields\n .map((field) => {\n const storedKeys = renderStoredEnvKeysInline(field);\n const helpText = `${esc(field.helpText)} Stored as ${storedKeys}.${field.optional ? \" Optional.\" : \"\"}`;\n return `<div class=\"provider-field\">\n <label for=\"preset-${esc(preset.id)}-${esc(field.envKey)}\">\n ${esc(field.label)}\n ${renderHelpIcon(helpText)}\n </label>\n <input\n id=\"preset-${esc(preset.id)}-${esc(field.envKey)}\"\n type=\"${field.type}\"\n autocomplete=\"off\"\n placeholder=\"${esc(field.placeholder)}\"\n data-env-key=\"${esc(field.envKey)}\"\n data-env-keys=\"${esc(resolveFieldEnvKeys(field).join(\",\"))}\"\n data-field-label=\"${esc(field.label)}\"\n ${field.optional ? 'data-optional=\"true\"' : \"\"}\n ${field.pattern ? `data-pattern=\"${esc(field.pattern)}\"` : \"\"}\n ${field.patternMessage ? `data-pattern-message=\"${esc(field.patternMessage)}\"` : \"\"}\n >\n </div>`;\n })\n .join(\"\\n\");\n\n return `<section class=\"card provider-card\" data-provider-kind=\"preset\" data-provider-id=\"${esc(preset.id)}\">\n <div class=\"provider-header\">\n ${renderServiceLogo(preset.id)}\n <h2 class=\"provider-title\">${esc(preset.label)}</h2>\n ${headerHelp}\n </div>\n ${fields}\n </section>`;\n}\n\nfunction renderManualProviderCard(\n initialEnvKey: string,\n secretLabel: string,\n placeholder: string,\n): string {\n const headerHelp = renderHelpIcon(\n esc(\n \"Set any environment variable key/value pair manually. Use this when no provider preset fits.\",\n ),\n );\n return `<section class=\"card provider-card\" data-provider-kind=\"manual\" data-provider-id=\"${esc(DEFAULT_SECRET_CONFIG_ID)}\">\n <div class=\"provider-header\">\n ${renderServiceLogo(\"manual\")}\n <h2 class=\"provider-title\">Manual entry</h2>\n ${headerHelp}\n </div>\n <div class=\"provider-field\">\n <label for=\"envKey\">Environment key</label>\n <input id=\"envKey\" type=\"text\" name=\"envKey\" placeholder=\"OPENAI_API_KEY\" value=\"${esc(initialEnvKey)}\" autocomplete=\"off\">\n </div>\n <div class=\"provider-field\">\n <label for=\"credential\">${esc(secretLabel)}</label>\n <input id=\"credential\" type=\"password\" name=\"credential\" placeholder=\"${esc(placeholder)}\" autocomplete=\"off\">\n </div>\n </section>`;\n}\n\nfunction renderCredentialPage(\n token: string,\n title: string,\n defaultMode: LoginCredentialKind,\n initialEnvKey: string,\n secretLabel: string,\n placeholder: string,\n helpText: string,\n oauthServices: OAuthService[],\n oauthServiceIdHint: string | undefined,\n existingSecrets: ExistingSecretsSummary,\n): string {\n const oauthOptions = oauthServices\n .map((service) => {\n const selected = service.id === oauthServiceIdHint ? ' selected=\"selected\"' : \"\";\n return `<option value=\"${esc(service.id)}\"${selected}>${esc(service.label)}</option>`;\n })\n .join(\"\\n\");\n const presetCards = SECRET_PRESETS.map(renderPresetProviderCard).join(\"\\n\");\n\n return renderHtmlDocument(\n \"Login\",\n `<section class=\"card stack\">\n <p class=\"eyebrow\">${PRODUCT_NAME}</p>\n <h1 class=\"page-title\">${esc(title)}</h1>\n <p>Your personal sandbox is already provisioned automatically.</p>\n <p>${esc(helpText)}</p>\n ${renderSecretsSummary(existingSecrets)}\n <div class=\"mode\">\n <label><input type=\"radio\" name=\"mode\" value=\"api_key\" ${defaultMode === \"api_key\" ? \"checked\" : \"\"}> Secrets / API tokens</label>\n <label><input type=\"radio\" name=\"mode\" value=\"oauth\" ${defaultMode === \"oauth\" ? \"checked\" : \"\"}> OAuth login</label>\n </div>\n</section>\n\n<div id=\"api-panel\" class=\"panel\">\n ${presetCards}\n ${renderManualProviderCard(initialEnvKey, secretLabel, placeholder)}\n</div>\n\n<div id=\"oauth-panel\" class=\"panel card stack\">\n <label for=\"oauthService\">OAuth service</label>\n <select id=\"oauthService\" name=\"oauthService\">${oauthOptions}</select>\n <p class=\"panel-note\">You'll be redirected to the selected service's authorization page.</p>\n</div>\n\n<div>\n <button id=\"btn\" class=\"primary-button\" onclick=\"connect()\">Continue</button>\n <div id=\"result\" class=\"result\" aria-live=\"polite\"></div>\n</div>\n <script>\n const envKeyPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\n function selectedMode() {\n return document.querySelector('input[name=\"mode\"]:checked').value;\n }\n\n function showResult(message, ok) {\n const result = document.getElementById('result');\n result.style.display = 'block';\n result.className = ok ? 'result ok' : 'result err';\n result.textContent = message;\n }\n\n function resetContinueButton() {\n const btn = document.getElementById('btn');\n btn.disabled = false;\n btn.textContent = 'Continue';\n }\n\n function syncPanels() {\n const mode = selectedMode();\n document.getElementById('api-panel').classList.toggle('active', mode === 'api_key');\n document.getElementById('oauth-panel').classList.toggle('active', mode === 'oauth');\n }\n\n function collectManualCard(card) {\n const envKey = card.querySelector('#envKey').value.trim();\n const credential = card.querySelector('#credential').value.trim();\n if (!envKey && !credential) return { skip: true };\n if (!envKeyPattern.test(envKey)) return { error: 'Manual entry: please enter a valid environment key.' };\n if (!credential) return { error: 'Manual entry: please enter a secret value.' };\n return { env: { [envKey]: credential } };\n }\n\n function collectPresetCard(card) {\n const inputs = card.querySelectorAll('input[data-env-key]');\n const filled = Array.from(inputs).some((input) => input.value.trim() !== '');\n if (!filled) return { skip: true };\n\n const env = {};\n for (const input of inputs) {\n const value = input.value.trim();\n const label = input.dataset.fieldLabel || input.dataset.envKey || 'a value';\n const optional = input.dataset.optional === 'true';\n if (!value) {\n if (optional) continue;\n return { error: 'Please enter ' + label + '.' };\n }\n if (input.dataset.pattern && !(new RegExp(input.dataset.pattern).test(value))) {\n return { error: input.dataset.patternMessage || ('Invalid ' + label + '.') };\n }\n const envKeys = (input.dataset.envKeys || input.dataset.envKey || '')\n .split(',')\n .map((entry) => entry.trim())\n .filter(Boolean);\n for (const envKey of envKeys) {\n env[envKey] = value;\n }\n }\n return { env };\n }\n\n function collectApiEnv() {\n const env = {};\n let any = false;\n for (const card of document.querySelectorAll('.provider-card')) {\n const result = card.dataset.providerKind === 'manual'\n ? collectManualCard(card)\n : collectPresetCard(card);\n if (result.skip) continue;\n if (result.error) return { error: result.error };\n Object.assign(env, result.env);\n any = true;\n }\n if (!any) return { error: 'Fill in at least one provider before continuing.' };\n return { env };\n }\n\n async function startOAuthFlow() {\n const serviceId = document.getElementById('oauthService').value;\n const r = await fetch('/api/oauth/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: '${esc(token)}', serviceId }),\n });\n const data = await r.json();\n if (!r.ok) {\n showResult('Error: ' + (data.error ?? r.status), false);\n resetContinueButton();\n return;\n }\n window.location.href = data.redirectUrl;\n }\n\n async function saveApiSecrets() {\n const payload = collectApiEnv();\n if (payload.error) {\n showResult(payload.error, false);\n resetContinueButton();\n return;\n }\n\n const r = await fetch('/api/link/complete', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: '${esc(token)}', mode: 'api_key', env: payload.env }),\n });\n const data = await r.json();\n if (r.ok) {\n showResult(data.message ?? 'Credential stored. You can close this tab.', true);\n document.getElementById('btn').style.display = 'none';\n for (const input of document.querySelectorAll('input,select,button')) input.disabled = true;\n } else {\n showResult('Error: ' + (data.error ?? r.status), false);\n resetContinueButton();\n }\n }\n\n let openHelp = null;\n function closeOpenHelp() {\n if (openHelp) {\n openHelp.setAttribute('aria-expanded', 'false');\n openHelp = null;\n }\n }\n\n for (const trigger of document.querySelectorAll('.help-trigger')) {\n trigger.addEventListener('click', (event) => {\n event.stopPropagation();\n const wasOpen = trigger.getAttribute('aria-expanded') === 'true';\n closeOpenHelp();\n if (!wasOpen) {\n trigger.setAttribute('aria-expanded', 'true');\n openHelp = trigger;\n }\n });\n }\n\n document.addEventListener('click', closeOpenHelp);\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') closeOpenHelp();\n });\n\n for (const radio of document.querySelectorAll('input[name=\"mode\"]')) {\n radio.addEventListener('change', syncPanels);\n }\n\n syncPanels();\n\n async function connect() {\n const btn = document.getElementById('btn');\n const mode = selectedMode();\n btn.disabled = true;\n btn.textContent = mode === 'oauth' ? 'Redirecting…' : 'Saving…';\n\n try {\n if (mode === 'oauth') {\n await startOAuthFlow();\n return;\n }\n await saveApiSecrets();\n } catch (err) {\n showResult('Network error: ' + (err?.message ?? err), false);\n resetContinueButton();\n }\n }\n </script>`,\n );\n}\n\nfunction renderErrorPage(message: string): string {\n return renderStatusPage(\"Login Error\", message, \"err\");\n}\n\nfunction renderSuccessPage(message: string): string {\n return renderStatusPage(\"Connected\", message, \"ok\", { closeNote: true });\n}\n\nfunction isValidEnvKey(value: string): boolean {\n return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);\n}\n\nfunction extractEnvUpdates(data: Partial<LinkCompleteBody>): {\n updates?: Record<string, string>;\n error?: string;\n} {\n if (data.env && typeof data.env === \"object\" && !Array.isArray(data.env)) {\n const rawEntries = Object.entries(data.env);\n if (rawEntries.length === 0) return { error: \"Missing required field: env\" };\n\n const updates: Record<string, string> = {};\n for (const [rawKey, rawValue] of rawEntries) {\n const envKey = rawKey.trim();\n const credential = typeof rawValue === \"string\" ? rawValue.trim() : \"\";\n if (!isValidEnvKey(envKey)) return { error: `Invalid envKey format: ${rawKey}` };\n if (!credential) return { error: `Missing value for envKey: ${envKey}` };\n updates[envKey] = credential;\n }\n\n return { updates };\n }\n\n const envKey = data.envKey?.trim() ?? \"\";\n const credential = data.credential?.trim() ?? \"\";\n if (!isValidEnvKey(envKey)) return { error: \"Invalid envKey format\" };\n if (!credential) return { error: \"Missing required field: credential\" };\n return { updates: { [envKey]: credential } };\n}\n\nfunction renderStoredEnvMessage(envKeys: string[], fileTargets: string[] = []): string {\n const fileSuffix = fileTargets.length > 0 ? ` Mounted file(s): ${fileTargets.join(\", \")}.` : \"\";\n if (envKeys.length === 1) {\n return `${envKeys[0]} stored successfully in vault.${fileSuffix}`;\n }\n\n return `${envKeys.length} secrets stored successfully in vault: ${envKeys.join(\", \")}.${fileSuffix}`;\n}\n\nfunction renderSentryCliConfig(updates: Record<string, string>): string | undefined {\n const token = updates.SENTRY_AUTH_TOKEN?.trim();\n if (!token) return undefined;\n\n const lines = [\"[auth]\", `token=${token}`, \"\"];\n const defaults: string[] = [];\n const org = updates.SENTRY_ORG?.trim();\n const project = updates.SENTRY_PROJECT?.trim();\n if (org) defaults.push(`org = ${org}`);\n if (project) defaults.push(`project = ${project}`);\n if (defaults.length > 0) {\n lines.push(\"[defaults]\", ...defaults, \"\");\n }\n return lines.join(\"\\n\");\n}\n\n// ── API-key completion ────────────────────────────────────────────────────────\n\nasync function handleLinkComplete(\n body: string,\n linkTokenStore: InMemoryLinkTokenStore,\n vaultManager: VaultManager,\n notify: NotifyFn,\n res: ServerResponse,\n): Promise<void> {\n let data: Partial<LinkCompleteBody>;\n try {\n data = JSON.parse(body) as Partial<LinkCompleteBody>;\n } catch {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Invalid JSON\" }));\n return;\n }\n\n if (!data.token) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Missing required field: token\" }));\n return;\n }\n\n const { updates, error } = extractEnvUpdates(data);\n if (!updates || error) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: error ?? \"Invalid env payload\" }));\n return;\n }\n\n const envKeys = Object.keys(updates).toSorted((left, right) => left.localeCompare(right));\n\n // Atomic consume prevents two concurrent requests from both passing the\n // validity check before either deletes the token.\n const linkToken = linkTokenStore.consume(data.token);\n if (!linkToken) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Invalid or expired token\" }));\n return;\n }\n\n const fileTargets: string[] = [];\n const sentryCliConfig = renderSentryCliConfig(updates);\n\n try {\n vaultManager.upsertEnv(linkToken.vaultId, updates);\n if (sentryCliConfig) {\n vaultManager.upsertFile(\n linkToken.vaultId,\n \".sentryclirc\",\n sentryCliConfig,\n \"/root/.sentryclirc\",\n );\n fileTargets.push(\"/root/.sentryclirc\");\n }\n } catch (persistError) {\n log.logWarning(\n `Failed to persist [${envKeys.join(\", \")}] for ${linkToken.platform}/${linkToken.platformUserId}`,\n persistError instanceof Error ? persistError.message : String(persistError),\n );\n reportUserFacingError(persistError, {\n domain: \"login\",\n surface: \"credential_login\",\n operation: \"vault_persist\",\n severity: \"error\",\n platform: linkToken.platform,\n context: {\n conversationId: linkToken.conversationId,\n vaultId: linkToken.vaultId,\n credentialMode: data.mode ?? \"manual\",\n envKeyCount: envKeys.length,\n fileTargetCount: fileTargets.length,\n },\n });\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error:\n \"Failed to store credential on server. Please fix the server issue and run /login again.\",\n }),\n );\n return;\n }\n\n log.logInfo(\n `Stored [${envKeys.join(\", \")}] for ${linkToken.platform}/${linkToken.platformUserId} in vault:${linkToken.vaultId}`,\n );\n\n const message = renderStoredEnvMessage(envKeys, fileTargets);\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ ok: true, message }));\n\n notify(\n linkToken.platform,\n linkToken.conversationId,\n `${message} Vault: \\`${linkToken.vaultId}\\`.`,\n ).catch((err: Error) => {\n log.logWarning(\"Failed to notify user after credential login\", err.message);\n reportUserFacingError(err, {\n domain: \"login\",\n surface: \"credential_login\",\n operation: \"notify_user\",\n severity: \"warning\",\n platform: linkToken.platform,\n context: {\n conversationId: linkToken.conversationId,\n vaultId: linkToken.vaultId,\n credentialMode: data.mode ?? \"manual\",\n envKeyCount: envKeys.length,\n fileTargetCount: fileTargets.length,\n },\n });\n });\n}\n\n// ── OAuth flow ────────────────────────────────────────────────────────────────\n\nasync function handleOAuthStart(\n body: string,\n req: IncomingMessage,\n linkTokenStore: InMemoryLinkTokenStore,\n oauthStates: Map<string, PendingOAuthState>,\n res: ServerResponse,\n): Promise<void> {\n let data: Partial<OAuthStartBody>;\n try {\n data = JSON.parse(body) as Partial<OAuthStartBody>;\n } catch {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Invalid JSON\" }));\n return;\n }\n\n if (!data.token || !data.serviceId) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Missing required fields: token/serviceId\" }));\n return;\n }\n\n const linkToken = linkTokenStore.peek(data.token);\n if (!linkToken) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Invalid or expired token\" }));\n return;\n }\n\n const service = resolveOAuthService(data.serviceId);\n if (!service) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: `Unsupported OAuth service: ${data.serviceId}` }));\n return;\n }\n\n const clientId = process.env[service.clientIdEnvKey];\n const clientSecret = process.env[service.clientSecretEnvKey];\n if (!clientId || !clientSecret) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error:\n `OAuth service ${service.label} is not configured. ` +\n `Missing ${service.clientIdEnvKey}/${service.clientSecretEnvKey}.`,\n }),\n );\n return;\n }\n\n const state = randomBytes(16).toString(\"hex\");\n const codeVerifier = randomBytes(32).toString(\"base64url\");\n oauthStates.set(state, {\n linkToken: data.token,\n serviceId: service.id,\n codeVerifier,\n expiresAt: Date.now() + OAUTH_STATE_TTL_MS,\n });\n\n for (const [k, v] of oauthStates) {\n if (Date.now() > v.expiresAt) oauthStates.delete(k);\n }\n\n const redirectUri = `${requestBaseUrl(req)}/oauth/callback`;\n const authorizeUrl = new URL(service.authorizationUrl);\n authorizeUrl.searchParams.set(\"response_type\", \"code\");\n authorizeUrl.searchParams.set(\"client_id\", clientId);\n authorizeUrl.searchParams.set(\"redirect_uri\", redirectUri);\n authorizeUrl.searchParams.set(\"state\", state);\n if (service.scopes.length > 0) {\n authorizeUrl.searchParams.set(\"scope\", service.scopes.join(\" \"));\n }\n for (const [key, value] of Object.entries(service.authorizationParams ?? {})) {\n authorizeUrl.searchParams.set(key, value);\n }\n\n const codeChallenge = createHash(\"sha256\").update(codeVerifier).digest(\"base64url\");\n authorizeUrl.searchParams.set(\"code_challenge\", codeChallenge);\n authorizeUrl.searchParams.set(\"code_challenge_method\", \"S256\");\n\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ ok: true, redirectUrl: authorizeUrl.toString() }));\n}\n\nasync function handleOAuthCallback(\n url: URL,\n req: IncomingMessage,\n linkTokenStore: InMemoryLinkTokenStore,\n vaultManager: VaultManager,\n notify: NotifyFn,\n oauthStates: Map<string, PendingOAuthState>,\n res: ServerResponse,\n): Promise<void> {\n const state = url.searchParams.get(\"state\") ?? \"\";\n const code = url.searchParams.get(\"code\") ?? \"\";\n const oauthError = url.searchParams.get(\"error\");\n\n // Atomic pop: whatever path we take from here, this state is spent.\n // Done before any `await` to close the TOCTOU window between the state\n // lookup and the final delete.\n const pending = oauthStates.get(state);\n if (pending) oauthStates.delete(state);\n\n if (oauthError) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(`OAuth authorization failed: ${oauthError}`));\n return;\n }\n\n if (!pending || Date.now() > pending.expiresAt) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"OAuth state is invalid or expired. Please run /login again.\"));\n return;\n }\n\n if (!code) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"Missing OAuth authorization code.\"));\n return;\n }\n\n const service = resolveOAuthService(pending.serviceId);\n if (!service) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"Unsupported OAuth service.\"));\n return;\n }\n\n const clientId = process.env[service.clientIdEnvKey];\n const clientSecret = process.env[service.clientSecretEnvKey];\n if (!clientId || !clientSecret) {\n res.writeHead(500, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"OAuth service is not configured on server.\"));\n return;\n }\n\n // Atomic consume: pairs with the callback being one-shot. Two concurrent\n // callbacks for the same state would previously both pass `peek` and both\n // run `exchangeOAuthCode` across the await; only one reaches `consume`.\n const linkToken = linkTokenStore.consume(pending.linkToken);\n if (!linkToken) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"Login link is invalid or expired. Please run /login again.\"));\n return;\n }\n\n const redirectUri = `${requestBaseUrl(req)}/oauth/callback`;\n const tokenResp = await exchangeOAuthCode(\n service,\n code,\n clientId,\n clientSecret,\n redirectUri,\n pending.codeVerifier,\n );\n\n const accessToken = tokenResp.access_token?.trim();\n const refreshToken = tokenResp.refresh_token?.trim();\n\n if (!accessToken) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderErrorPage(\"OAuth token exchange did not return an access_token.\"));\n return;\n }\n\n const updates: Record<string, string> = {};\n if (service.accessTokenEnvKey) {\n updates[service.accessTokenEnvKey] = accessToken;\n }\n for (const key of service.additionalAccessTokenEnvKeys ?? []) {\n updates[key] = accessToken;\n }\n if (refreshToken && service.refreshTokenEnvKey) {\n updates[service.refreshTokenEnvKey] = refreshToken;\n }\n\n const fileOutput = service.fileOutput;\n let mountedPath: string | undefined;\n if (fileOutput?.type === \"authorized_user\") {\n if (!refreshToken) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderErrorPage(\n \"OAuth token exchange did not return a refresh_token. \" +\n \"Retry after revoking prior consent or ensure prompt=consent is applied.\",\n ),\n );\n return;\n }\n\n mountedPath = fileOutput.targetPath ?? defaultVaultTargetPath(fileOutput.relativePath);\n if (fileOutput.envKey) {\n updates[fileOutput.envKey] = mountedPath;\n }\n for (const key of fileOutput.additionalEnvKeys ?? []) {\n updates[key] = mountedPath;\n }\n }\n\n const storedTargets: string[] = [];\n try {\n if (Object.keys(updates).length > 0) {\n vaultManager.upsertEnv(linkToken.vaultId, updates);\n storedTargets.push(...Object.keys(updates).toSorted());\n }\n if (fileOutput?.type === \"authorized_user\" && refreshToken) {\n vaultManager.upsertFile(\n linkToken.vaultId,\n fileOutput.relativePath,\n renderAuthorizedUserCredential(clientId, clientSecret, refreshToken),\n fileOutput.targetPath,\n );\n if (mountedPath) storedTargets.push(mountedPath);\n }\n } catch (persistError) {\n log.logWarning(\n `Failed to persist OAuth credentials for ${linkToken.platform}/${linkToken.platformUserId}`,\n persistError instanceof Error ? persistError.message : String(persistError),\n );\n reportUserFacingError(persistError, {\n domain: \"login\",\n surface: \"oauth\",\n operation: \"vault_persist\",\n severity: \"error\",\n platform: linkToken.platform,\n context: {\n conversationId: linkToken.conversationId,\n vaultId: linkToken.vaultId,\n serviceId: service.id,\n credentialMode: \"oauth\",\n storedTargetCount: storedTargets.length,\n },\n });\n res.writeHead(500, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderErrorPage(\n \"OAuth tokens were received but could not be stored on the server. Fix the server issue and run /login again.\",\n ),\n );\n return;\n }\n\n log.logInfo(\n `Stored [${storedTargets.join(\", \")}] for ${linkToken.platform}/${linkToken.platformUserId} in vault:${linkToken.vaultId}`,\n );\n\n notify(\n linkToken.platform,\n linkToken.conversationId,\n `${service.label} OAuth stored (${storedTargets.join(\", \")}) in vault \\`${linkToken.vaultId}\\`.`,\n ).catch((err: Error) => {\n log.logWarning(\"Failed to notify user after OAuth login\", err.message);\n reportUserFacingError(err, {\n domain: \"login\",\n surface: \"oauth\",\n operation: \"notify_user\",\n severity: \"warning\",\n platform: linkToken.platform,\n context: {\n conversationId: linkToken.conversationId,\n vaultId: linkToken.vaultId,\n serviceId: service.id,\n credentialMode: \"oauth\",\n storedTargetCount: storedTargets.length,\n },\n });\n });\n\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderSuccessPage(`${service.label} OAuth connected successfully.`));\n}\n\nasync function exchangeOAuthCode(\n service: OAuthService,\n code: string,\n clientId: string,\n clientSecret: string,\n redirectUri: string,\n codeVerifier: string,\n): Promise<Record<string, string>> {\n const params = new URLSearchParams();\n params.set(\"grant_type\", \"authorization_code\");\n params.set(\"code\", code);\n params.set(\"client_id\", clientId);\n params.set(\"client_secret\", clientSecret);\n params.set(\"redirect_uri\", redirectUri);\n params.set(\"code_verifier\", codeVerifier);\n\n const response = await fetch(service.tokenUrl, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n Accept: \"application/json\",\n },\n body: params.toString(),\n });\n\n const text = await response.text();\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n let parsed: Record<string, string> = {};\n\n if (contentType.includes(\"application/json\")) {\n parsed = JSON.parse(text) as Record<string, string>;\n } else {\n const form = new URLSearchParams(text);\n parsed = Object.fromEntries(form.entries());\n }\n\n if (!response.ok) {\n const message = parsed.error_description ?? parsed.error ?? `${response.status}`;\n throw new Error(`OAuth token exchange failed for ${service.id}: ${message}`);\n }\n\n return parsed;\n}\n\nfunction renderAuthorizedUserCredential(\n clientId: string,\n clientSecret: string,\n refreshToken: string,\n): string {\n return (\n JSON.stringify(\n {\n client_id: clientId,\n client_secret: clientSecret,\n refresh_token: refreshToken,\n type: \"authorized_user\",\n },\n null,\n 2,\n ) + \"\\n\"\n );\n}\n"]}
|