import { db, encryptIntegrationSecret, schema } from "@superlog/db"; import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import type { Hono } from "hono"; import type { Context } from "hono"; import { INTEGRATION_MANIFESTS } from "./integrations-manifest.js"; import { resolveActiveOrgContext } from "./org-context.js"; type Vars = { userId: string; orgId: string | null }; const ORG_INSTRUCTIONS_MAX_LEN = 8000; const PROJECT_CONTEXT_MAX_LEN = 8000; const PROJECT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; const PROJECT_NAME_MAX_LEN = 80; function slugifyProjectName(name: string): string { const base = name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40); return base || "project"; } // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance. export function mountSettingsAuthed(app: Hono): void { app.get("/api/org/agent-settings", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ customInstructions: "" }); const row = await db.query.orgAgentSettings.findFirst({ where: eq(schema.orgAgentSettings.orgId, ctx.orgId), }); return c.json({ customInstructions: row?.customInstructions ?? "" }); }); app.put("/api/org/agent-settings", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); const body = (await c.req.json().catch(() => ({}))) as { customInstructions?: unknown; }; if (typeof body.customInstructions !== "string") { return c.json({ error: "customInstructions must be a string" }, 400); } const customInstructions = body.customInstructions.slice(0, ORG_INSTRUCTIONS_MAX_LEN); await db .insert(schema.orgAgentSettings) .values({ orgId: ctx.orgId, customInstructions }) .onConflictDoUpdate({ target: schema.orgAgentSettings.orgId, set: { customInstructions, updatedAt: new Date() }, }); return c.json({ customInstructions }); }); app.get("/api/org/digest", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) { return c.json({ enabled: false, channelId: null, channelName: null, installationId: null, lastRunAt: null, }); } const row = await db.query.orgAgentSettings.findFirst({ where: eq(schema.orgAgentSettings.orgId, ctx.orgId), }); return c.json({ enabled: row?.digestEnabled ?? false, channelId: row?.digestSlackChannelId ?? null, channelName: row?.digestSlackChannelName ?? null, installationId: row?.digestSlackInstallationId ?? null, lastRunAt: row?.digestLastRunAt?.toISOString() ?? null, }); }); app.put("/api/org/digest", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); const body = (await c.req.json().catch(() => ({}))) as { enabled?: unknown; channelId?: unknown; channelName?: unknown; }; const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined; const channelIdRaw = body.channelId === null ? null : typeof body.channelId === "string" ? body.channelId : undefined; const channelNameRaw = body.channelName === null ? null : typeof body.channelName === "string" ? body.channelName : undefined; // Digest is org-scoped today; pick any active slack install in the org // by joining through projects. (One install per project — for orgs with // multiple projects + multiple installs this returns an arbitrary one, // same ambiguity as the previous org-scoped lookup.) const installs = await db .select({ id: schema.slackInstallations.id, }) .from(schema.slackInstallations) .innerJoin(schema.projects, eq(schema.projects.id, schema.slackInstallations.projectId)) .where(and(eq(schema.projects.orgId, ctx.orgId), isNull(schema.slackInstallations.revokedAt))) .limit(1); const install = installs[0] ?? null; if (enabled === true && !channelIdRaw && !install) { return c.json({ error: "Slack must be installed to enable the digest" }, 400); } const update: Partial = { updatedAt: new Date() }; if (enabled !== undefined) update.digestEnabled = enabled; if (channelIdRaw !== undefined) { update.digestSlackChannelId = channelIdRaw; update.digestSlackInstallationId = channelIdRaw ? (install?.id ?? null) : null; } if (channelNameRaw !== undefined) update.digestSlackChannelName = channelNameRaw; await db .insert(schema.orgAgentSettings) .values({ orgId: ctx.orgId, digestEnabled: enabled ?? false, digestSlackChannelId: channelIdRaw ?? null, digestSlackChannelName: channelNameRaw ?? null, digestSlackInstallationId: channelIdRaw && install ? install.id : channelIdRaw === null ? null : null, }) .onConflictDoUpdate({ target: schema.orgAgentSettings.orgId, set: update, }); const row = await db.query.orgAgentSettings.findFirst({ where: eq(schema.orgAgentSettings.orgId, ctx.orgId), }); return c.json({ enabled: row?.digestEnabled ?? false, channelId: row?.digestSlackChannelId ?? null, channelName: row?.digestSlackChannelName ?? null, installationId: row?.digestSlackInstallationId ?? null, lastRunAt: row?.digestLastRunAt?.toISOString() ?? null, }); }); app.post("/api/org/digest/run-now", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); const row = await db.query.orgAgentSettings.findFirst({ where: eq(schema.orgAgentSettings.orgId, ctx.orgId), }); if (!row?.digestSlackChannelId || !row?.digestSlackInstallationId) { return c.json({ error: "configure a Slack channel for the digest first" }, 400); } // Worker fires next tick when last_run_at is null. We also bump enabled to // true so manual runs work even if the user hasn't flipped the toggle yet. await db .update(schema.orgAgentSettings) .set({ digestEnabled: true, digestLastRunAt: null, updatedAt: new Date() }) .where(eq(schema.orgAgentSettings.orgId, ctx.orgId)); return c.json({ ok: true }); }); app.get("/api/org/projects", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ projects: [] }); const rows = await db.query.projects.findMany({ where: eq(schema.projects.orgId, ctx.orgId), orderBy: [asc(schema.projects.createdAt)], }); return c.json({ projects: rows.map((p) => ({ id: p.id, name: p.name, slug: p.slug, projectContext: p.projectContext, })), }); }); app.post("/api/org/projects", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); const body = (await c.req.json().catch(() => ({}))) as { name?: unknown; slug?: unknown; projectContext?: unknown; }; const name = typeof body.name === "string" ? body.name.trim() : ""; if (!name) return c.json({ error: "name is required" }, 400); if (name.length > PROJECT_NAME_MAX_LEN) { return c.json({ error: `name must be ≤ ${PROJECT_NAME_MAX_LEN} chars` }, 400); } const slugInput = typeof body.slug === "string" && body.slug.trim() ? body.slug.trim().toLowerCase() : slugifyProjectName(name); if (!PROJECT_SLUG_RE.test(slugInput) || slugInput.length > 40) { return c.json({ error: "slug must be lowercase alphanumeric + dashes, max 40 chars" }, 400); } const existing = await db.query.projects.findFirst({ where: and(eq(schema.projects.orgId, ctx.orgId), eq(schema.projects.slug, slugInput)), }); if (existing) return c.json({ error: "slug already in use in this org" }, 409); const [project] = await db .insert(schema.projects) .values({ orgId: ctx.orgId, name, slug: slugInput, projectContext: typeof body.projectContext === "string" ? body.projectContext.slice(0, PROJECT_CONTEXT_MAX_LEN) : "", }) .returning(); if (!project) return c.json({ error: "failed to create project" }, 500); await db .insert(schema.projectAutomationSettings) .values({ projectId: project.id }) .onConflictDoNothing({ target: schema.projectAutomationSettings.projectId }); return c.json({ project: { id: project.id, name: project.name, slug: project.slug, projectContext: project.projectContext, }, }); }); app.patch("/api/org/projects/:projectId", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); const projectId = c.req.param("projectId"); const target = await db.query.projects.findFirst({ where: and(eq(schema.projects.id, projectId), eq(schema.projects.orgId, ctx.orgId)), }); if (!target) return c.json({ error: "project not found" }, 404); const body = (await c.req.json().catch(() => ({}))) as { name?: unknown; slug?: unknown; projectContext?: unknown; }; const patch: Partial = {}; if (typeof body.name === "string") { const name = body.name.trim(); if (!name) return c.json({ error: "name cannot be empty" }, 400); if (name.length > PROJECT_NAME_MAX_LEN) { return c.json({ error: `name must be ≤ ${PROJECT_NAME_MAX_LEN} chars` }, 400); } patch.name = name; } if (typeof body.slug === "string") { const slug = body.slug.trim().toLowerCase(); if (!PROJECT_SLUG_RE.test(slug) || slug.length > 40) { return c.json({ error: "slug must be lowercase alphanumeric + dashes, max 40 chars" }, 400); } if (slug !== target.slug) { const clash = await db.query.projects.findFirst({ where: and(eq(schema.projects.orgId, ctx.orgId), eq(schema.projects.slug, slug)), }); if (clash) return c.json({ error: "slug already in use in this org" }, 409); patch.slug = slug; } } if (typeof body.projectContext === "string") { patch.projectContext = body.projectContext.slice(0, PROJECT_CONTEXT_MAX_LEN); } if (Object.keys(patch).length === 0) { return c.json({ project: { id: target.id, name: target.name, slug: target.slug, projectContext: target.projectContext, }, }); } const [updated] = await db .update(schema.projects) .set(patch) .where(eq(schema.projects.id, target.id)) .returning(); // A concurrent DELETE between the findFirst above and this UPDATE leaves // `updated` undefined — return a clean 404 rather than a TypeError 500. if (!updated) return c.json({ error: "project not found" }, 404); return c.json({ project: { id: updated.id, name: updated.name, slug: updated.slug, projectContext: updated.projectContext, }, }); }); app.delete("/api/org/projects/:projectId", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); const projectId = c.req.param("projectId"); // The count-check and the delete must be atomic, or two concurrent // deletes in a 2-project org can both pass the `> 1` guard and leave // the org with zero projects. Wrap in a tx and take SELECT FOR UPDATE // locks on every project row in this org — any concurrent delete of a // sibling will block until our tx commits, at which point its count // sees the post-delete state. const result = await db.transaction(async (tx) => { const target = await tx.query.projects.findFirst({ where: and(eq(schema.projects.id, projectId), eq(schema.projects.orgId, ctx.orgId)), }); if (!target) return { status: 404 as const, body: { error: "project not found" } }; const locked = await tx .select({ id: schema.projects.id }) .from(schema.projects) .where(eq(schema.projects.orgId, ctx.orgId)) .for("update"); if (locked.length <= 1) { return { status: 409 as const, body: { error: "cannot delete the last project in an org" }, }; } await tx.delete(schema.projects).where(eq(schema.projects.id, target.id)); return { status: 200 as const, body: { ok: true } }; }); return c.json(result.body, result.status); }); app.get("/api/org/integrations", async (c) => { const ctx = await resolveUserOrg(c); const installed = ctx ? await db.query.orgIntegrations.findMany({ where: eq(schema.orgIntegrations.orgId, ctx.orgId), }) : []; const installedBySlug = new Map(installed.map((row) => [row.slug, row])); const presentSecrets = ctx ? await db.query.orgIntegrationSecrets.findMany({ where: installed.length > 0 ? inArray( schema.orgIntegrationSecrets.orgIntegrationId, installed.map((row) => row.id), ) : undefined, }) : []; const presentByIntegrationId = new Map>(); for (const s of presentSecrets) { const set = presentByIntegrationId.get(s.orgIntegrationId) ?? new Set(); set.add(s.secretName); presentByIntegrationId.set(s.orgIntegrationId, set); } const items = Object.values(INTEGRATION_MANIFESTS).map((manifest) => { const row = installedBySlug.get(manifest.slug); const present = row ? (presentByIntegrationId.get(row.id) ?? new Set()) : new Set(); return { slug: manifest.slug, name: manifest.name, description: manifest.description, installed: !!row, enabled: row?.enabled ?? false, required_secrets: manifest.required_secrets.map((spec) => ({ name: spec.name, description: spec.description, present: present.has(spec.name), })), }; }); return c.json({ integrations: items }); }); app.put("/api/org/integrations/:slug", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); const slug = c.req.param("slug"); const manifest = INTEGRATION_MANIFESTS[slug]; if (!manifest) return c.json({ error: "unknown integration" }, 404); const body = (await c.req.json().catch(() => ({}))) as { enabled?: unknown; secrets?: unknown; }; const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined; const secrets = body.secrets && typeof body.secrets === "object" && !Array.isArray(body.secrets) ? (body.secrets as Record) : null; if (secrets) { for (const key of Object.keys(secrets)) { if (!manifest.required_secrets.some((s) => s.name === key)) { return c.json({ error: `unknown secret: ${key}` }, 400); } const value = secrets[key]; if (value !== null && typeof value !== "string") { return c.json({ error: `secret ${key} must be a string or null` }, 400); } } } const existing = await db.query.orgIntegrations.findFirst({ where: and( eq(schema.orgIntegrations.orgId, ctx.orgId), eq(schema.orgIntegrations.slug, slug), ), }); const integrationRow = existing ? ( await db .update(schema.orgIntegrations) .set({ enabled: enabled ?? existing.enabled, updatedAt: new Date(), }) .where(eq(schema.orgIntegrations.id, existing.id)) .returning() )[0]! : ( await db .insert(schema.orgIntegrations) .values({ orgId: ctx.orgId, slug, enabled: enabled ?? true, }) .returning() )[0]!; if (secrets) { for (const [name, value] of Object.entries(secrets)) { if (value === null) { await db .delete(schema.orgIntegrationSecrets) .where( and( eq(schema.orgIntegrationSecrets.orgIntegrationId, integrationRow.id), eq(schema.orgIntegrationSecrets.secretName, name), ), ); continue; } if (typeof value !== "string" || value.length === 0) continue; const cipher = encryptIntegrationSecret(value); await db .insert(schema.orgIntegrationSecrets) .values({ orgIntegrationId: integrationRow.id, secretName: name, ciphertext: cipher.ciphertext, nonce: cipher.nonce, keyVersion: cipher.keyVersion, }) .onConflictDoUpdate({ target: [ schema.orgIntegrationSecrets.orgIntegrationId, schema.orgIntegrationSecrets.secretName, ], set: { ciphertext: cipher.ciphertext, nonce: cipher.nonce, keyVersion: cipher.keyVersion, updatedAt: new Date(), }, }); } } return c.json({ ok: true }); }); app.delete("/api/org/integrations/:slug", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); const slug = c.req.param("slug"); if (!INTEGRATION_MANIFESTS[slug]) return c.json({ error: "unknown integration" }, 404); await db .delete(schema.orgIntegrations) .where( and(eq(schema.orgIntegrations.orgId, ctx.orgId), eq(schema.orgIntegrations.slug, slug)), ); return c.json({ ok: true }); }); } async function resolveUserOrg( c: Context<{ Variables: Vars }>, ): Promise<{ userId: string; orgId: string } | null> { const userId = c.var.userId; if (!userId) return null; const ctx = await resolveActiveOrgContext({ userId, preferredOrgId: c.var.orgId, }).catch(() => null); if (!ctx) return null; return { userId: ctx.user.id, orgId: ctx.org.id }; }