diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 396c422..517c296 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,35 +19,47 @@ Projects are the root entity in the system, stored in the `projects` table with: ```sql CREATE TABLE projects ( id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, name TEXT NOT NULL, + description TEXT, repo_url TEXT NOT NULL, repo_default_branch TEXT DEFAULT 'main', - - -- Integration credentials (per-project) - linear_token TEXT, - linear_team_id TEXT, - linear_webhook_id TEXT, - linear_webhook_url TEXT, - - github_token TEXT, - github_repo_owner TEXT, - github_repo_name TEXT, - - slack_enabled BOOLEAN DEFAULT 0, - slack_webhook_url TEXT, - slack_channel_id TEXT, - + -- Wiki/RAG status wiki_status TEXT DEFAULT 'idle', + status TEXT NOT NULL DEFAULT 'pending', + documents_count INTEGER NOT NULL DEFAULT 0, + chunks_count INTEGER NOT NULL DEFAULT 0, wiki_error TEXT, - documents_count INTEGER DEFAULT 0, - chunks_count INTEGER DEFAULT 0, - + last_wiki_generated_at INTEGER, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); ``` +### Integration Credentials + +Integration secrets (Linear PATs, GitHub tokens, Slack bot tokens, Resend keys, +OpenRouter keys) **never** live on the `projects` row. They are stored in the +`project_integrations` table, encrypted with AES-256-GCM under a per-row DEK +that is itself wrapped by `APP_MASTER_KEY` (envelope encryption — see +`runtime/src/lib/crypto-envelope.ts`). + +```sql +CREATE TABLE project_integrations ( + project_id TEXT NOT NULL, + provider TEXT NOT NULL, -- linear | resend | slack | github | openrouter + encrypted_key BLOB NOT NULL, + meta TEXT NOT NULL DEFAULT '{}', -- non-sensitive: teamId, channelId, repoFullName, fromEmail, … + status TEXT NOT NULL DEFAULT 'active', + last_tested_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (project_id, provider) +); +``` + ### Related Tables - **wiki_documents**: Repository documents for RAG, scoped to `project_id` @@ -71,12 +83,12 @@ Implements CRUD operations on projects: - `DELETE /projects/:id` — cascade delete project and all related data #### B. Integration Configuration Routes (`integration-routes.ts`) -Per-project integration setup and testing: -- `POST /projects/:projectId/settings/linear/test` — validate Linear token, save -- `POST /projects/:projectId/settings/linear/webhook` — register webhook with Linear API -- `POST /projects/:projectId/settings/github/test` — validate GitHub PAT -- `POST /projects/:projectId/settings/slack/test` — send test message to webhook -- `GET /projects/:projectId/settings/integrations` — fetch all integration status +Per-project integration setup, validation, and testing. Same shape across all 5 +providers (linear | resend | slack | github | openrouter): +- `GET /projects/:projectId/integrations` — list all integrations for the project (status + non-sensitive meta) +- `PUT /projects/:projectId/integrations/:provider` — upsert key + meta (envelope-encrypted) +- `DELETE /projects/:projectId/integrations/:provider` — remove an integration +- `POST /projects/:projectId/integrations/:provider/test` — validate a key against the upstream API without persisting (returns picker data: teams, channels, repos, …) #### C. Project-Scoped Routes (`scoped-routes.ts`) Per-project data queries using project's own credentials: @@ -88,8 +100,9 @@ Per-project data queries using project's own credentials: **Implementation pattern**: ``` -Extract projectId → Fetch project row → Validate required integration (e.g., linear_token) -→ Use project's credentials → Filter data to project_id → Return +Extract projectId → Assert ownership → Resolve integration key via +getIntegrationKey(projectId, provider) (decrypts envelope) → Use the +per-tenant credentials → Filter data to project_id → Return ``` #### D. Middleware (`project-middleware.ts`) @@ -122,37 +135,31 @@ Validates `projectId` parameter before route handlers: - Emits custom event `triage:project-change` when selection changes - Other components listen to this event and auto-refetch project-scoped data -#### Integration Settings (`project-settings.lazy.tsx`) -Per-project integration configuration page accessible at `/project-settings`: - -**Three sections**: - -**Linear Integration** -- Input field for API token -- Test & Save button → calls `POST /api/projects/:projectId/settings/linear/test` -- On success: shows "Connected as ()" -- Webhook registration section (only if token configured): - - Pre-filled webhook URL: `{origin}/api/webhooks/linear` - - Optional Team ID input - - Register Webhook button → calls `POST /api/projects/:projectId/settings/linear/webhook` - -**GitHub Integration** -- Input fields: Owner, Repo, Personal Access Token -- Test & Save → `POST /api/projects/:projectId/settings/github/test` -- Shows authenticated user on success - -**Slack Integration** -- Webhook URL input (masked) -- Optional Channel ID input -- Test & Save → `POST /api/projects/:projectId/settings/slack/test` -- Backend sends real test message to webhook +#### Integrations Page (`integrations.lazy.tsx`) +Single per-project integrations page at `/integrations`. Each of the 5 providers +(Linear, Resend, Slack, GitHub, OpenRouter) renders a card that walks the user +through Test → pick from upstream picker (teams / channels / repos / domains) → +Save. Persistence goes through the encrypted `project_integrations` row keyed +by `(project_id, provider)`. + +**Card flow (same shape per provider)**: +1. User pastes the API key/PAT. +2. `POST /projects/:projectId/integrations/:provider/test` — backend validates + against the upstream API (e.g. `LinearClient.viewer`, GitHub `/user`, Slack + `auth.test`, Resend `/domains`) and returns picker data without persisting. +3. User picks the resource (team, channel, repo, fromEmail). +4. `PUT /projects/:projectId/integrations/:provider` with `{ key, meta }` — + backend envelope-encrypts the key, stores `meta` JSON alongside, and marks + `last_tested_at`. +5. Card flips to configured state with a "Change" button (relaunches the flow) + and a "Disconnect" trash button (DELETE). **UI Features**: -- Status indicator per integration (green checkmark "Configured" or red X "Not configured") -- Masked input fields for sensitive values -- Loading states and error messages -- Query invalidation to refresh status after save -- Integrations are project-scoped via `useCurrentProjectId()` hook +- Status badge per card: active | invalid | disabled, sourced from the + `project_integrations.status` column. +- Sensitive fields use masked inputs; the stored ciphertext is never returned. +- Query invalidation refreshes status after save. +- All requests are project-scoped via `useCurrentProjectId()`. ## Data Flow: Creating a Project & Generating Wiki @@ -177,40 +184,46 @@ Per-project integration configuration page accessible at `/project-settings`: ## Data Flow: Using a Project's Integrations ``` -1. User navigates to /project-settings +1. User navigates to /integrations ↓ -2. Frontend fetches GET /projects/:projectId/settings/integrations +2. Frontend fetches GET /projects/:projectId/integrations (per-project status) ↓ -3. Shows configuration status (configured boolean, non-sensitive metadata) +3. Shows configuration status (active | invalid | disabled + non-sensitive + metadata from `project_integrations.meta`: teamId, channelId, repoFullName, + fromEmail, …). The encrypted key itself is never returned. ↓ -4. User enters Linear token and clicks Test & Save +4. User enters a Linear PAT and clicks Test & Save ↓ -5. POST /api/projects/:projectId/settings/linear/test { token } +5. POST /projects/:projectId/integrations/linear/test { key } — backend + validates via LinearClient.viewer + team listing for the picker UI ↓ -6. Backend validates token via LinearClient.viewer +6. User picks a team, clicks Save → PUT /projects/:projectId/integrations/linear + { key, meta: { teamId, teamName, teamKey } } ↓ -7. Saves token to projects.linear_token (encrypted in production) +7. Backend envelope-encrypts the key and UPSERTs the project_integrations row; + `meta` (non-sensitive) is stored as JSON alongside. ↓ -8. Frontend invalidates query and refreshes status +8. Frontend invalidates query and refreshes status → "Team: X (KEY)" card ↓ -9. User registers webhook via POST /projects/:projectId/settings/linear/webhook +9. Optional: register Linear webhook → secret persisted to webhook_secrets + keyed by (provider, project_id) ↓ -10. Backend creates webhook via LinearClient.createWebhook() - ↓ -11. Webhook ID and URL stored in DB - ↓ -12. When issue moves to Done, webhook sends update to /api/webhooks/linear - ↓ -13. Endpoint resumes suspended workflow run for that project +10. When issue moves to Done, the webhook hits /api/webhooks/linear, the + handler verifies HMAC against webhook_secrets, then resumes the suspended + workflow run for the right project. ``` ## Data Isolation Guarantees **Project A and Project B cannot see each other's data**: -1. **Credential isolation**: Each project stores its own Linear token, GitHub token, Slack webhook - - A Linear query by Project A uses Project A's token and team ID - - Project B's token/team is completely separate +1. **Credential isolation**: Each project's integration keys live in their own + encrypted `project_integrations` row (PK = `(project_id, provider)`), + decrypted on demand via `getIntegrationKey`. Plaintext never crosses a + project boundary. + - A Linear query for Project A decrypts Project A's key and uses + `meta.teamId` from the same row. + - Project B's row is a separate ciphertext under a separate DEK. 2. **Document isolation**: Wiki documents and chunks are filtered by `WHERE project_id = ?` - Queries never leak documents from other projects @@ -248,7 +261,7 @@ runtime/ src/ lib/ project-routes.ts # GET/POST/PATCH/DELETE /projects - integration-routes.ts # POST /projects/:projectId/settings/* + integration-routes.ts # GET/PUT/DELETE/POST /projects/:projectId/integrations/* scoped-routes.ts # GET/POST /projects/:projectId/linear/* /wiki/* project-middleware.ts # projectId validation middleware wiki-rag.ts # Wiki generation pipeline diff --git a/QUICK_START.md b/QUICK_START.md index 8abe463..9904470 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -110,9 +110,9 @@ docker logs ### Check Runtime Health ```bash -curl http://localhost:4111/api/config/status +curl http://localhost:4111/health # Expected response: -# {"success":true,"data":{"linearConfigured":true,"openrouterConfigured":true}} +# {"status":"ok","service":"triage-runtime"} ``` ### Access Web UI @@ -194,8 +194,9 @@ docker compose logs -f # Is runtime connecting to database? curl http://localhost:4111/health -# Can runtime reach Linear? -curl http://localhost:4111/api/linear/members +# Can runtime reach Linear? (requires session — call from the UI or +# inspect the per-project endpoint with auth cookies): +# curl --cookie "$COOKIE" http://localhost:4111/api/projects//linear/members # Can runtime reach Langfuse? # Check logs for initialization messages @@ -279,7 +280,7 @@ Once the complete flow works end-to-end: 1. **Test with your Linear workspace** — Update `LINEAR_TEAM_ID` to your workspace 2. **Configure Slack notifications** — Add `SLACK_BOT_TOKEN` and `SLACK_CHANNEL_ID` -3. **Setup wiki generation** — Trigger `/api/wiki/generate` for RAG +3. **Setup wiki generation** — Trigger `POST /api/projects/:id/wiki/generate` for RAG 4. **Monitor in production** — Use Langfuse dashboard for observability 5. **Configure GitHub webhooks** — Link PRs to tickets for full automation diff --git a/TESTING.md b/TESTING.md index 30653f7..cfc850a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -262,7 +262,7 @@ DONE - [ ] `OPENROUTER_API_KEY` is valid - [ ] `LIBSQL_URL` points to running database - [ ] Docker containers are running: `docker ps` -- [ ] Runtime is responding: `curl http://localhost:4111/api/config/status` +- [ ] Runtime is responding: `curl http://localhost:4111/health` ## Reset State diff --git a/VERIFICATION-GUIDE.md b/VERIFICATION-GUIDE.md index cac8ac6..e985f61 100644 --- a/VERIFICATION-GUIDE.md +++ b/VERIFICATION-GUIDE.md @@ -139,7 +139,7 @@ Activity Report: - **Issue:** Slack message never arrives - **Check:** Bot token valid? Channel ID saved to project? - - **Verify:** `GET /projects/:id/settings/integrations` returns `slack.configured: true` + - **Verify:** `GET /projects/:id/integrations` returns the slack row with `status: 'active'` - **Issue:** Comments not showing in activity summary - **Check:** Issue was updated with status before webhook fired? @@ -161,8 +161,8 @@ POST /projects # → Wiki generation starts in background # 2. Configure Linear integration -POST /projects/:projectId/settings/linear/test -{ "token": "lin_api_..." } +PUT /projects/:projectId/integrations/linear +{ "apiKey": "lin_api_...", "meta": { "teamId": "...", "teamName": "...", "teamKey": "..." } } # 3. Configure assignee & reporter # (Assume Linear team already set up) diff --git a/frontend/src/components/integrations/helpers.ts b/frontend/src/components/integrations/helpers.ts new file mode 100644 index 0000000..da76bb4 --- /dev/null +++ b/frontend/src/components/integrations/helpers.ts @@ -0,0 +1,26 @@ +import type { TestResponse } from "./types" + +export function reasonToMessage( + res: Extract, + providerName: string, +): string { + if (res.reason === "invalid_key") + return `Key rejected by ${providerName} (401).` + if (res.reason === "network") + return `Couldn't reach ${providerName}${res.message ? ` — ${res.message}` : ""}.` + if (res.reason === "not_implemented") + return "Test connection isn't implemented for this provider yet." + return "Test failed." +} + +export function formatRelative(iso: string): string { + const then = new Date(iso).getTime() + const diff = Date.now() - then + const min = Math.round(diff / 60_000) + if (min < 1) return "just now" + if (min < 60) return `${min} min ago` + const hr = Math.round(min / 60) + if (hr < 24) return `${hr} hr ago` + const d = Math.round(hr / 24) + return `${d} d ago` +} diff --git a/frontend/src/components/integrations/types.ts b/frontend/src/components/integrations/types.ts new file mode 100644 index 0000000..0fba8eb --- /dev/null +++ b/frontend/src/components/integrations/types.ts @@ -0,0 +1,46 @@ +/** + * Shared types for the integrations API surface. + * + * Both `/integrations` (the canonical management page) and `/onboarding` (the + * first-time setup wizard) consume the same endpoints, so the response shapes + * live here to keep the two surfaces in lock-step with the backend contract + * defined in `runtime/src/lib/integration-routes.ts`. + */ + +export type Provider = "openrouter" | "linear" | "resend" | "slack" | "github" +export type Status = "active" | "disabled" | "invalid" + +export interface IntegrationSummary { + provider: Provider + status: Status + meta: Record + lastTestedAt: string | null + createdAt: string + updatedAt: string +} + +export interface LinearTeam { + id: string + name: string + key: string +} + +export interface SlackChannel { + id: string + name: string + isPrivate: boolean +} + +export interface TestPreview { + teams?: LinearTeam[] + channels?: SlackChannel[] +} + +export type TestResponse = + | { valid: true; integration: IntegrationSummary } + | { valid: true; preview: TestPreview } + | { + valid: false + reason: "invalid_key" | "network" | "not_implemented" + message?: string + } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index e5acd28..25c674d 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,22 +16,22 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as ChatRouteImport } from './routes/chat' import { Route as IndexRouteImport } from './routes/index' -const SettingsLazyRouteImport = createFileRoute('/settings')() const ProjectsLazyRouteImport = createFileRoute('/projects')() +const OnboardingLazyRouteImport = createFileRoute('/onboarding')() const ObservabilityLazyRouteImport = createFileRoute('/observability')() const IntegrationsLazyRouteImport = createFileRoute('/integrations')() const BoardLazyRouteImport = createFileRoute('/board')() -const SettingsLazyRoute = SettingsLazyRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => rootRouteImport, -} as any).lazy(() => import('./routes/settings.lazy').then((d) => d.Route)) const ProjectsLazyRoute = ProjectsLazyRouteImport.update({ id: '/projects', path: '/projects', getParentRoute: () => rootRouteImport, } as any).lazy(() => import('./routes/projects.lazy').then((d) => d.Route)) +const OnboardingLazyRoute = OnboardingLazyRouteImport.update({ + id: '/onboarding', + path: '/onboarding', + getParentRoute: () => rootRouteImport, +} as any).lazy(() => import('./routes/onboarding.lazy').then((d) => d.Route)) const ObservabilityLazyRoute = ObservabilityLazyRouteImport.update({ id: '/observability', path: '/observability', @@ -76,8 +76,8 @@ export interface FileRoutesByFullPath { '/board': typeof BoardLazyRoute '/integrations': typeof IntegrationsLazyRoute '/observability': typeof ObservabilityLazyRoute + '/onboarding': typeof OnboardingLazyRoute '/projects': typeof ProjectsLazyRoute - '/settings': typeof SettingsLazyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -87,8 +87,8 @@ export interface FileRoutesByTo { '/board': typeof BoardLazyRoute '/integrations': typeof IntegrationsLazyRoute '/observability': typeof ObservabilityLazyRoute + '/onboarding': typeof OnboardingLazyRoute '/projects': typeof ProjectsLazyRoute - '/settings': typeof SettingsLazyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -99,8 +99,8 @@ export interface FileRoutesById { '/board': typeof BoardLazyRoute '/integrations': typeof IntegrationsLazyRoute '/observability': typeof ObservabilityLazyRoute + '/onboarding': typeof OnboardingLazyRoute '/projects': typeof ProjectsLazyRoute - '/settings': typeof SettingsLazyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -112,8 +112,8 @@ export interface FileRouteTypes { | '/board' | '/integrations' | '/observability' + | '/onboarding' | '/projects' - | '/settings' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -123,8 +123,8 @@ export interface FileRouteTypes { | '/board' | '/integrations' | '/observability' + | '/onboarding' | '/projects' - | '/settings' id: | '__root__' | '/' @@ -134,8 +134,8 @@ export interface FileRouteTypes { | '/board' | '/integrations' | '/observability' + | '/onboarding' | '/projects' - | '/settings' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -146,19 +146,12 @@ export interface RootRouteChildren { BoardLazyRoute: typeof BoardLazyRoute IntegrationsLazyRoute: typeof IntegrationsLazyRoute ObservabilityLazyRoute: typeof ObservabilityLazyRoute + OnboardingLazyRoute: typeof OnboardingLazyRoute ProjectsLazyRoute: typeof ProjectsLazyRoute - SettingsLazyRoute: typeof SettingsLazyRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/settings': { - id: '/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof SettingsLazyRouteImport - parentRoute: typeof rootRouteImport - } '/projects': { id: '/projects' path: '/projects' @@ -166,6 +159,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsLazyRouteImport parentRoute: typeof rootRouteImport } + '/onboarding': { + id: '/onboarding' + path: '/onboarding' + fullPath: '/onboarding' + preLoaderRoute: typeof OnboardingLazyRouteImport + parentRoute: typeof rootRouteImport + } '/observability': { id: '/observability' path: '/observability' @@ -173,13 +173,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ObservabilityLazyRouteImport parentRoute: typeof rootRouteImport } - '/board': { - id: '/board' - path: '/board' - fullPath: '/board' - preLoaderRoute: typeof BoardLazyRouteImport - parentRoute: typeof rootRouteImport - } '/integrations': { id: '/integrations' path: '/integrations' @@ -187,6 +180,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IntegrationsLazyRouteImport parentRoute: typeof rootRouteImport } + '/board': { + id: '/board' + path: '/board' + fullPath: '/board' + preLoaderRoute: typeof BoardLazyRouteImport + parentRoute: typeof rootRouteImport + } '/register': { id: '/register' path: '/register' @@ -226,8 +226,8 @@ const rootRouteChildren: RootRouteChildren = { BoardLazyRoute: BoardLazyRoute, IntegrationsLazyRoute: IntegrationsLazyRoute, ObservabilityLazyRoute: ObservabilityLazyRoute, + OnboardingLazyRoute: OnboardingLazyRoute, ProjectsLazyRoute: ProjectsLazyRoute, - SettingsLazyRoute: SettingsLazyRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/frontend/src/routes/integrations.lazy.tsx b/frontend/src/routes/integrations.lazy.tsx index 2e77553..5676579 100644 --- a/frontend/src/routes/integrations.lazy.tsx +++ b/frontend/src/routes/integrations.lazy.tsx @@ -1,4 +1,4 @@ -import { createLazyFileRoute } from "@tanstack/react-router" +import { createLazyFileRoute, Link } from "@tanstack/react-router" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useState } from "react" import { @@ -8,59 +8,31 @@ import { Loader2, Trash2, FolderGit2, + Sparkles, } from "lucide-react" import { apiFetch } from "@/lib/api" import { useCurrentProjectId } from "@/components/project-selector" import { ConfirmDialog } from "@/components/confirm-dialog" import { Picker } from "@/components/picker" import { BrandIcon } from "@/components/brand-icon" +import { + type IntegrationSummary, + type LinearTeam, + type Provider, + type SlackChannel, + type Status, + type TestPreview, + type TestResponse, +} from "@/components/integrations/types" +import { + formatRelative, + reasonToMessage, +} from "@/components/integrations/helpers" export const Route = createLazyFileRoute("/integrations")({ component: IntegrationsPage, }) -type Provider = "openrouter" | "linear" | "resend" | "slack" | "github" -type Status = "active" | "disabled" | "invalid" - -interface IntegrationSummary { - provider: Provider - status: Status - meta: Record - lastTestedAt: string | null - createdAt: string - updatedAt: string -} - -interface LinearTeam { - id: string - name: string - key: string -} -interface SlackChannel { - id: string - name: string - isPrivate: boolean -} - -interface TestPreview { - teams?: LinearTeam[] - channels?: SlackChannel[] -} - -type TestResponse = - | { - valid: true - integration: IntegrationSummary - } - | { - valid: true - preview: TestPreview - } - | { - valid: false - reason: "invalid_key" | "network" | "not_implemented" - message?: string - } // Brand icons (GitHub, Linear, Slack, Resend, OpenRouter) now route through // `BrandIcon` which serves themed SVGs sourced from svgl.app and shipped from @@ -118,6 +90,7 @@ function IntegrationsContent({ projectId }: { projectId: string }) {

+ {/* Content */} @@ -168,6 +141,60 @@ function IntegrationsContent({ projectId }: { projectId: string }) { ) } +interface ProjectForGithub { + id: string + name: string + repositoryUrl: string + status: string +} + +function parseGithubRepo(url: string): { owner: string; repo: string; full: string } | null { + if (!url) return null + const m = + /^(?:https?|ssh):\/\/(?:[^@]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url.trim()) ?? + /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url.trim()) + if (!m) return null + return { owner: m[1], repo: m[2], full: `${m[1]}/${m[2]}` } +} + +function WizardCta({ + projectId, + byProvider, +}: { + projectId: string + byProvider: Partial> +}) { + const { data: project } = useQuery({ + queryKey: ["project", projectId], + queryFn: () => apiFetch(`/projects/${projectId}`), + }) + const needsGithub = project ? parseGithubRepo(project.repositoryUrl) !== null : true + + // Treat openrouter / linear / github as the required-for-triage trio. + // Slack + Resend are nice-to-have notifications; not counted here so the + // CTA stays focused on what blocks the core workflow. + // GitHub is only required when the project repo is actually on GitHub. + const required: ReadonlyArray = needsGithub + ? ["openrouter", "linear", "github"] + : ["openrouter", "linear"] + const remaining = required.filter( + (p) => byProvider[p]?.status !== "active", + ).length + if (remaining === 0) return null + return ( + + + Run setup wizard + + {remaining} + + + ) +} + function DomainSection({ title, description, @@ -1113,26 +1140,6 @@ function ResendCard({ ) } -interface ProjectForGithub { - id: string - name: string - repositoryUrl: string - status: string -} - -/** - * Simple github.com URL parser — mirrors the backend helper so the card can - * pre-empt a "project repo isn't on GitHub" error and render a friendlier - * disabled state up-front. Backend still enforces authoritatively. - */ -function parseGithubRepo(url: string): { owner: string; repo: string; full: string } | null { - if (!url) return null - const m = - /^(?:https?|ssh):\/\/(?:[^@]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url.trim()) ?? - /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url.trim()) - if (!m) return null - return { owner: m[1], repo: m[2], full: `${m[1]}/${m[2]}` } -} function GitHubCard({ projectId, @@ -1352,27 +1359,3 @@ function GitHubCard({ ) } - -function reasonToMessage( - res: Extract, - providerName: string, -): string { - if (res.reason === "invalid_key") return `Key rejected by ${providerName} (401).` - if (res.reason === "network") - return `Couldn't reach ${providerName}${res.message ? ` — ${res.message}` : ""}.` - if (res.reason === "not_implemented") - return "Test connection isn't implemented for this provider yet." - return "Test failed." -} - -function formatRelative(iso: string): string { - const then = new Date(iso).getTime() - const diff = Date.now() - then - const min = Math.round(diff / 60_000) - if (min < 1) return "just now" - if (min < 60) return `${min} min ago` - const hr = Math.round(min / 60) - if (hr < 24) return `${hr} hr ago` - const d = Math.round(hr / 24) - return `${d} d ago` -} diff --git a/frontend/src/routes/onboarding.lazy.tsx b/frontend/src/routes/onboarding.lazy.tsx new file mode 100644 index 0000000..21b3461 --- /dev/null +++ b/frontend/src/routes/onboarding.lazy.tsx @@ -0,0 +1,936 @@ +/** + * Onboarding wizard — sequential setup for the 5 per-project integrations. + * + * Lives at /onboarding. Reads from the same `/integrations` endpoint as the + * canonical management page; each step calls the same `/test` + PUT contract. + * The wizard is purely additive: skipping a step never changes the backing + * row, and users can always come back via /integrations to edit. + * + * Step order (priority): openrouter → linear → github → slack → resend. + * - openrouter, linear, github are flagged "required" so the global header + * CTA shows the count of remaining required steps. + * - slack, resend are optional and the "Skip" button reads accordingly. + * - github is auto-skipped when the project repo isn't on github.com (same + * check the GitHubCard pre-emptively renders on /integrations). + */ +import { createLazyFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useMemo, useState } from "react" +import { + CheckCircle2, + AlertCircle, + Loader2, + ArrowRight, + ArrowLeft, + SkipForward, + Sparkles, + FolderGit2, +} from "lucide-react" +import { apiFetch } from "@/lib/api" +import { useCurrentProjectId } from "@/components/project-selector" +import { Picker } from "@/components/picker" +import { BrandIcon } from "@/components/brand-icon" +import { + type IntegrationSummary, + type LinearTeam, + type Provider, + type SlackChannel, + type TestResponse, +} from "@/components/integrations/types" +import { reasonToMessage } from "@/components/integrations/helpers" + +export const Route = createLazyFileRoute("/onboarding")({ + component: OnboardingPage, +}) + +// ─── Step model ───────────────────────────────────────────────────────────── + +interface StepDef { + provider: Provider + title: string + blurb: string + required: boolean +} + +const STEPS: ReadonlyArray = [ + { + provider: "openrouter", + title: "OpenRouter", + blurb: "Key for all LLM agents and wiki embeddings.", + required: true, + }, + { + provider: "linear", + title: "Linear", + blurb: "Personal API token + team for ticket creation.", + required: true, + }, + { + provider: "github", + title: "GitHub", + blurb: "Personal access token (repo scope) for private-repo wiki + evidence lookups.", + required: true, + }, + { + provider: "slack", + title: "Slack", + blurb: "Bot token + channel for triage notifications.", + required: false, + }, + { + provider: "resend", + title: "Resend", + blurb: "API key + verified from address for outbound email.", + required: false, + }, +] + +interface ProjectMeta { + id: string + name: string + repositoryUrl: string + status: string +} + +function isGithubUrl(url: string | null | undefined): boolean { + if (!url) return false + return /(^|@|\/)github\.com[/:]/i.test(url.trim()) +} + +// ─── Wizard shell ─────────────────────────────────────────────────────────── + +function OnboardingPage() { + const [currentProjectId] = useCurrentProjectId() + if (!currentProjectId) { + return ( +
+
+
+ +
+

+ Select a project +

+

+ The setup wizard is per-project. Pick one from the sidebar to begin. +

+
+
+ ) + } + return +} + +function Wizard({ projectId }: { projectId: string }) { + const navigate = useNavigate() + const { data: integrations, isLoading: integrationsLoading } = useQuery< + IntegrationSummary[] + >({ + queryKey: ["integrations", projectId], + queryFn: () => + apiFetch(`/projects/${projectId}/integrations`), + }) + const { data: project, isLoading: projectLoading } = useQuery({ + queryKey: ["project", projectId], + queryFn: () => apiFetch(`/projects/${projectId}`), + }) + + const byProvider = useMemo>>( + () => { + const acc: Partial> = {} + for (const row of integrations ?? []) acc[row.provider] = row + return acc + }, + [integrations], + ) + + // Filter the canonical step order down to the steps that apply to this + // project. Today the only conditional skip is GitHub for non-github repos. + const applicableSteps = useMemo(() => { + if (!project) return STEPS + return STEPS.filter((s) => { + if (s.provider === "github") return isGithubUrl(project.repositoryUrl) + return true + }) + }, [project]) + + const [stepIndex, setStepIndex] = useState(0) + const safeIndex = Math.min(stepIndex, applicableSteps.length - 1) + const currentStep = applicableSteps[safeIndex] + + const remainingRequired = applicableSteps.filter( + (s) => s.required && byProvider[s.provider]?.status !== "active", + ).length + + const isLoading = integrationsLoading || projectLoading + + function advance() { + if (safeIndex < applicableSteps.length - 1) { + setStepIndex(safeIndex + 1) + } else { + // Last step — flow ends. Send the user to the chat for the project. + navigate({ to: "/chat" }) + } + } + function goBack() { + if (safeIndex > 0) setStepIndex(safeIndex - 1) + } + + if (isLoading || !currentStep) { + return ( +
+ +
+ ) + } + + const currentSummary = byProvider[currentStep.provider] + const isLastStep = safeIndex === applicableSteps.length - 1 + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ Set up {project?.name ?? "your project"} +

+

+ {remainingRequired === 0 + ? "All required integrations are configured." + : `${remainingRequired} required integration${remainingRequired === 1 ? "" : "s"} remaining.`} +

+
+
+ + Configure later → + +
+ + {/* Progress dots */} + + + {/* Step body */} +
+
+ + +
+
+ + {/* Footer nav */} +
+ +
+ {currentSummary?.status === "active" ? ( + + ) : ( + + )} +
+
+
+ ) +} + +function ProgressStrip({ + steps, + currentIndex, + byProvider, + onJump, +}: { + steps: ReadonlyArray + currentIndex: number + byProvider: Partial> + onJump: (i: number) => void +}) { + return ( +
+
+ {steps.map((step, i) => { + const summary = byProvider[step.provider] + const isActive = summary?.status === "active" + const isCurrent = i === currentIndex + return ( + + ) + })} +
+
+ ) +} + +function StepHeader({ step }: { step: StepDef }) { + return ( +
+
+ +
+
+

+ {step.title} + {!step.required && ( + + Optional + + )} +

+

{step.blurb}

+
+
+ ) +} + +function StepBody({ + projectId, + project, + step, + summary, +}: { + projectId: string + project: ProjectMeta | undefined + step: StepDef + summary: IntegrationSummary | undefined +}) { + // Already configured → render the canonical confirmation panel. The user + // can still re-run setup by clicking "Reconfigure" which clears the row. + if (summary?.status === "active") { + return + } + + switch (step.provider) { + case "openrouter": + return + case "linear": + return + case "github": + return + case "slack": + return + case "resend": + return + } +} + +// ─── Configured state (shared) ────────────────────────────────────────────── + +function ConfiguredPanel({ + projectId, + provider, + summary, +}: { + projectId: string + provider: Provider + summary: IntegrationSummary +}) { + const queryClient = useQueryClient() + const deleteMutation = useMutation({ + mutationFn: () => + apiFetch(`/projects/${projectId}/integrations/${provider}`, { + method: "DELETE", + }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["integrations", projectId] }), + }) + + const metaPairs = Object.entries(summary.meta).filter( + ([, v]) => v != null && v !== "", + ) + + return ( +
+
+ +

+ Already configured for this project. +

+
+ {metaPairs.length > 0 && ( +
+ {metaPairs.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ )} + +
+ ) +} + +// ─── OpenRouter step ──────────────────────────────────────────────────────── + +function OpenRouterStep({ projectId }: { projectId: string }) { + const queryClient = useQueryClient() + const [apiKey, setApiKey] = useState("") + const [error, setError] = useState(null) + + const m = useMutation({ + mutationFn: async () => + apiFetch( + `/projects/${projectId}/integrations/openrouter/test`, + { method: "POST", body: JSON.stringify({ apiKey }) }, + ), + onSuccess: (res) => { + if (res.valid) { + setError(null) + setApiKey("") + queryClient.invalidateQueries({ + queryKey: ["integrations", projectId], + }) + } else { + setError(reasonToMessage(res, "OpenRouter")) + } + }, + onError: (err: Error) => setError(err.message), + }) + + return ( + + { + setApiKey(v) + setError(null) + }} + placeholder="sk-or-..." + /> + m.mutate()} + disabled={!apiKey || m.isPending} + loading={m.isPending} + > + Test & Save + + + + ) +} + +// ─── Linear step ──────────────────────────────────────────────────────────── + +function LinearStep({ projectId }: { projectId: string }) { + const queryClient = useQueryClient() + const [apiKey, setApiKey] = useState("") + const [teams, setTeams] = useState([]) + const [selectedTeamId, setSelectedTeamId] = useState("") + const [error, setError] = useState(null) + + const test = useMutation({ + mutationFn: async () => + apiFetch( + `/projects/${projectId}/integrations/linear/test`, + { method: "POST", body: JSON.stringify({ apiKey }) }, + ), + onSuccess: (res) => { + if (res.valid && "preview" in res) { + setError(null) + setTeams(res.preview.teams ?? []) + setSelectedTeamId(res.preview.teams?.[0]?.id ?? "") + } else if (!res.valid) { + setTeams([]) + setSelectedTeamId("") + setError(reasonToMessage(res, "Linear")) + } + }, + onError: (err: Error) => setError(err.message), + }) + + const save = useMutation({ + mutationFn: async () => { + const team = teams.find((t) => t.id === selectedTeamId) + if (!team) throw new Error("Pick a team before saving") + return apiFetch( + `/projects/${projectId}/integrations/linear`, + { + method: "PUT", + body: JSON.stringify({ + apiKey, + meta: { teamId: team.id, teamName: team.name, teamKey: team.key }, + }), + }, + ) + }, + onSuccess: () => { + setError(null) + setApiKey("") + setTeams([]) + setSelectedTeamId("") + queryClient.invalidateQueries({ queryKey: ["integrations", projectId] }) + }, + onError: (err: Error) => setError(err.message), + }) + + return ( + + { + setApiKey(v) + setError(null) + setTeams([]) + setSelectedTeamId("") + }} + placeholder="lin_api_..." + /> + {teams.length > 0 && ( +
+ + t.id} + getLabel={(t) => `${t.name} (${t.key})`} + onChange={setSelectedTeamId} + placeholder="Pick a team" + /> +
+ )} + {teams.length === 0 ? ( + test.mutate()} + disabled={!apiKey || test.isPending} + loading={test.isPending} + > + Test + + ) : ( + save.mutate()} + disabled={!selectedTeamId || save.isPending} + loading={save.isPending} + > + Save team + + )} + +
+ ) +} + +// ─── GitHub step ──────────────────────────────────────────────────────────── + +function GithubStep({ + projectId, + project, +}: { + projectId: string + project: ProjectMeta | undefined +}) { + const queryClient = useQueryClient() + const [apiKey, setApiKey] = useState("") + const [error, setError] = useState(null) + + const verify = useMutation({ + mutationFn: async () => + apiFetch( + `/projects/${projectId}/integrations/github`, + { method: "PUT", body: JSON.stringify({ apiKey }) }, + ), + onSuccess: () => { + setError(null) + setApiKey("") + queryClient.invalidateQueries({ queryKey: ["integrations", projectId] }) + queryClient.invalidateQueries({ queryKey: ["project", projectId] }) + queryClient.invalidateQueries({ queryKey: ["projects"] }) + }, + onError: (err: Error) => setError(err.message), + }) + + const repoUrl = project?.repositoryUrl ?? "" + + return ( + +

+ The token is verified against{" "} + {repoUrl} directly. + Use a fine-grained PAT scoped to that repo (Contents: Read). +

+ { + setApiKey(v) + setError(null) + }} + placeholder="ghp_... or github_pat_..." + /> + verify.mutate()} + disabled={!apiKey || verify.isPending} + loading={verify.isPending} + > + Verify & Save + + +
+ ) +} + +// ─── Slack step ───────────────────────────────────────────────────────────── + +function SlackStep({ projectId }: { projectId: string }) { + const queryClient = useQueryClient() + const [apiKey, setApiKey] = useState("") + const [tokenValidated, setTokenValidated] = useState(false) + const [channels, setChannels] = useState([]) + const [selectedChannelId, setSelectedChannelId] = useState("") + const [manualChannelId, setManualChannelId] = useState("") + const [error, setError] = useState(null) + + const test = useMutation({ + mutationFn: async () => + apiFetch( + `/projects/${projectId}/integrations/slack/test`, + { method: "POST", body: JSON.stringify({ apiKey }) }, + ), + onSuccess: (res) => { + if (res.valid && "preview" in res) { + setError(null) + setTokenValidated(true) + setChannels(res.preview.channels ?? []) + setSelectedChannelId(res.preview.channels?.[0]?.id ?? "") + } else if (!res.valid) { + setTokenValidated(false) + setChannels([]) + setSelectedChannelId("") + setError(reasonToMessage(res, "Slack")) + } + }, + onError: (err: Error) => setError(err.message), + }) + + const save = useMutation({ + mutationFn: async () => { + const picked = channels.find((c) => c.id === selectedChannelId) + const channelId = picked?.id ?? manualChannelId.trim() + if (!channelId) throw new Error("Pick or enter a channel before saving") + const meta: Record = { channelId } + if (picked) meta.channelName = picked.name + return apiFetch( + `/projects/${projectId}/integrations/slack`, + { method: "PUT", body: JSON.stringify({ apiKey, meta }) }, + ) + }, + onSuccess: () => { + setError(null) + setApiKey("") + setChannels([]) + setSelectedChannelId("") + setManualChannelId("") + setTokenValidated(false) + queryClient.invalidateQueries({ queryKey: ["integrations", projectId] }) + }, + onError: (err: Error) => setError(err.message), + }) + + return ( + + { + setApiKey(v) + setError(null) + setTokenValidated(false) + setChannels([]) + setSelectedChannelId("") + setManualChannelId("") + }} + placeholder="xoxb-..." + /> + + {tokenValidated && channels.length > 0 && ( +
+ + c.id} + getLabel={(c) => ( + <> + {c.isPrivate ? "🔒 " : "#"} + {c.name} + + )} + onChange={setSelectedChannelId} + placeholder="Pick a channel" + /> +
+ )} + + {tokenValidated && channels.length === 0 && ( +
+ + setManualChannelId(e.target.value)} + placeholder="C01234ABCDE" + className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 font-mono" + /> +

+ Token authenticated but lacks channels:read/ + groups:read. Paste the channel ID manually + (right-click channel → Copy link). +

+
+ )} + + {!tokenValidated ? ( + test.mutate()} + disabled={!apiKey || test.isPending} + loading={test.isPending} + > + Test + + ) : ( + save.mutate()} + disabled={ + (channels.length > 0 ? !selectedChannelId : !manualChannelId.trim()) || + save.isPending + } + loading={save.isPending} + > + Save channel + + )} + +
+ ) +} + +// ─── Resend step ──────────────────────────────────────────────────────────── + +function ResendStep({ projectId }: { projectId: string }) { + const queryClient = useQueryClient() + const [apiKey, setApiKey] = useState("") + const [fromEmail, setFromEmail] = useState("") + const [error, setError] = useState(null) + + const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fromEmail) + + const m = useMutation({ + mutationFn: async () => + apiFetch( + `/projects/${projectId}/integrations/resend/test`, + { + method: "POST", + body: JSON.stringify({ apiKey, meta: { fromEmail } }), + }, + ), + onSuccess: (res) => { + if (res.valid) { + setError(null) + setApiKey("") + setFromEmail("") + queryClient.invalidateQueries({ + queryKey: ["integrations", projectId], + }) + } else { + setError(reasonToMessage(res, "Resend")) + } + }, + onError: (err: Error) => setError(err.message), + }) + + return ( + + { + setApiKey(v) + setError(null) + }} + placeholder="re_..." + /> +
+ + { + setFromEmail(e.target.value) + setError(null) + }} + placeholder="triage@yourdomain.com" + className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30" + /> +

+ Must be on a domain verified in your Resend account. +

+
+ m.mutate()} + disabled={!apiKey || !isValidEmail || m.isPending} + loading={m.isPending} + > + Test & Save + + +
+ ) +} + +// ─── Shared form primitives ───────────────────────────────────────────────── + +function StepCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function PasswordField({ + value, + onChange, + placeholder, +}: { + value: string + onChange: (v: string) => void + placeholder: string +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 font-mono" + /> + ) +} + +function PrimaryButton({ + onClick, + disabled, + loading, + children, +}: { + onClick: () => void + disabled: boolean + loading: boolean + children: React.ReactNode +}) { + return ( + + ) +} + +function ErrorLine({ error }: { error: string | null }) { + if (!error) return null + return ( +

+ + {error} +

+ ) +} diff --git a/frontend/src/routes/settings.lazy.tsx b/frontend/src/routes/settings.lazy.tsx deleted file mode 100644 index 69fb594..0000000 --- a/frontend/src/routes/settings.lazy.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { createLazyFileRoute, Link } from '@tanstack/react-router'; -import { KeyRound } from 'lucide-react'; - -export const Route = createLazyFileRoute('/settings')({ component: SettingsRedirectPage }); - -function SettingsRedirectPage() { - return ( -
-
-
- -
-

- Settings moved -

-

- Integration keys are now per-project. Configure them in the new - Integrations page. -

- - Go to Integrations - -
-
- ); -} diff --git a/runtime/init-db.mjs b/runtime/init-db.mjs index 73e1528..f6e0a45 100644 --- a/runtime/init-db.mjs +++ b/runtime/init-db.mjs @@ -57,18 +57,6 @@ const tables = [ description TEXT, repo_url TEXT NOT NULL, repo_default_branch TEXT DEFAULT 'main', - linear_token TEXT, - linear_team_id TEXT, - linear_webhook_id TEXT, - linear_webhook_url TEXT, - slack_enabled INTEGER DEFAULT 0, - slack_channel_id TEXT, - slack_webhook_url TEXT, - github_token TEXT, - github_repo_owner TEXT, - github_repo_name TEXT, - resend_api_key TEXT, - reporter_email TEXT, wiki_status TEXT DEFAULT 'idle', status TEXT NOT NULL DEFAULT 'pending', documents_count INTEGER NOT NULL DEFAULT 0, @@ -339,4 +327,91 @@ try { console.warn('[init-db] projects status backfill skipped:', msg); } +// One-shot migration: drop legacy plaintext integration columns from projects. +// They were superseded by the encrypted `project_integrations` table (MT-03..05) +// plus webhook_secrets (MT-01). Zero remaining readers across runtime/src. +// +// SQLite ≥ 3.35 supports `ALTER TABLE … DROP COLUMN`, but the libsql image we +// ship is pinned to a version that has the feature; even so, we go the +// table-rebuild route to stay idempotent and portable against older DBs that +// might be mounted on upgrade. +const droppedProjectCols = [ + 'linear_token', + 'linear_team_id', + 'linear_webhook_id', + 'linear_webhook_url', + 'slack_enabled', + 'slack_channel_id', + 'slack_webhook_url', + 'github_token', + 'github_repo_owner', + 'github_repo_name', + 'resend_api_key', + 'reporter_email', +]; +try { + const info = await client.execute('PRAGMA table_info(projects)'); + const presentCols = new Set(info.rows.map((r) => r.name)); + const stillThere = droppedProjectCols.filter((c) => presentCols.has(c)); + if (stillThere.length === 0) { + console.log('[init-db] projects plaintext columns already dropped (skip)'); + } else { + console.log( + `[init-db] Dropping ${stillThere.length} legacy plaintext column(s) from projects: ${stillThere.join(', ')}`, + ); + // Keep the canonical column list in sync with the CREATE TABLE above. + const keepCols = [ + 'id', + 'user_id', + 'name', + 'description', + 'repo_url', + 'repo_default_branch', + 'wiki_status', + 'status', + 'documents_count', + 'chunks_count', + 'wiki_error', + 'error', + 'last_wiki_generated_at', + 'created_at', + 'updated_at', + ].filter((c) => presentCols.has(c)); + const colList = keepCols.join(', '); + await client.execute('BEGIN'); + await client.execute(`CREATE TABLE projects_new ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + repo_url TEXT NOT NULL, + repo_default_branch TEXT DEFAULT 'main', + wiki_status TEXT DEFAULT 'idle', + status TEXT NOT NULL DEFAULT 'pending', + documents_count INTEGER NOT NULL DEFAULT 0, + chunks_count INTEGER NOT NULL DEFAULT 0, + wiki_error TEXT, + error TEXT, + last_wiki_generated_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`); + await client.execute( + `INSERT INTO projects_new (${colList}) SELECT ${colList} FROM projects`, + ); + await client.execute('DROP TABLE projects'); + await client.execute('ALTER TABLE projects_new RENAME TO projects'); + // Recreate the user_id index that the original CREATE INDEX statement set up. + await client.execute( + `CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id)`, + ); + await client.execute('COMMIT'); + console.log('[init-db] projects plaintext column drop complete'); + } +} catch (err) { + try { await client.execute('ROLLBACK'); } catch {} + const msg = err instanceof Error ? err.message : String(err); + console.warn('[init-db] projects plaintext column drop skipped:', msg); +} + console.log('[init-db] All tables and indexes ready'); diff --git a/runtime/src/db/schema.ts b/runtime/src/db/schema.ts index fa2f834..3dfc7b4 100644 --- a/runtime/src/db/schema.ts +++ b/runtime/src/db/schema.ts @@ -67,26 +67,6 @@ export const projects = sqliteTable('projects', { repoUrl: text('repo_url').notNull(), repoDefaultBranch: text('repo_default_branch').default('main'), - // Linear integration - linearToken: text('linear_token'), - linearTeamId: text('linear_team_id'), - linearWebhookId: text('linear_webhook_id'), - linearWebhookUrl: text('linear_webhook_url'), - - // Slack integration - slackEnabled: integer('slack_enabled', { mode: 'boolean' }).default(false), - slackChannelId: text('slack_channel_id'), - slackWebhookUrl: text('slack_webhook_url'), - - // GitHub integration - githubToken: text('github_token'), - githubRepoOwner: text('github_repo_owner'), - githubRepoName: text('github_repo_name'), - - // Email - resendApiKey: text('resend_api_key'), - reporterEmail: text('reporter_email'), - // Wiki/RAG wikiStatus: text('wiki_status').default('idle'), documentsCount: integer('documents_count').default(0), diff --git a/runtime/src/lib/project-routes.probe.test.ts b/runtime/src/lib/project-routes.probe.test.ts index 7f74e7a..85d932d 100644 --- a/runtime/src/lib/project-routes.probe.test.ts +++ b/runtime/src/lib/project-routes.probe.test.ts @@ -63,8 +63,6 @@ async function seedDb(): Promise { error TEXT, documents_count INTEGER DEFAULT 0, chunks_count INTEGER DEFAULT 0, - linear_token TEXT, - linear_team_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )`); diff --git a/runtime/src/lib/project-routes.test.ts b/runtime/src/lib/project-routes.test.ts index 8250b0c..ede3c3c 100644 --- a/runtime/src/lib/project-routes.test.ts +++ b/runtime/src/lib/project-routes.test.ts @@ -131,16 +131,6 @@ function fakeExecute(input: unknown, maybeArgs?: unknown[]): { rows: Row[]; rows wiki_error: null, documents_count: 0, chunks_count: 0, - linear_token: null, - linear_team_id: null, - linear_webhook_id: null, - linear_webhook_url: null, - github_token: null, - github_repo_owner: null, - github_repo_name: null, - slack_enabled: 0, - slack_channel_id: null, - slack_webhook_url: null, created_at, updated_at, }); @@ -379,16 +369,6 @@ function seedProject(overrides: Partial = {}): Row { wiki_error: null, documents_count: 0, chunks_count: 0, - linear_token: null, - linear_team_id: null, - linear_webhook_id: null, - linear_webhook_url: null, - github_token: null, - github_repo_owner: null, - github_repo_name: null, - slack_enabled: 0, - slack_channel_id: null, - slack_webhook_url: null, created_at: now, updated_at: now, ...overrides, @@ -612,154 +592,3 @@ describe('data isolation between projects', () => { // scoped-routes.test.ts with :memory: libsql + proper session cookies. }); -// Placeholder kept so the file numbering below doesn't shift for readers. -describe.skip('scoped-routes (moved to scoped-routes.test.ts)', () => { - describe('GET /projects/:projectId/linear/issues', () => { - it('404 when project not found', async () => { - const { listProjectIssuesRoute } = await loadScopedRoutes(); - const res = (await listProjectIssuesRoute.handler( - makeCtx({ params: { projectId: 'ghost' } }), - )) as unknown as JsonResponse; - expect(res.status).toBe(404); - }); - - it('400 when Linear integration is not configured', async () => { - seedProject({ id: 'p1', linear_token: null }); - const { listProjectIssuesRoute } = await loadScopedRoutes(); - const res = (await listProjectIssuesRoute.handler( - makeCtx({ params: { projectId: 'p1' } }), - )) as unknown as JsonResponse; - expect(res.status).toBe(400); - expect((res.body as { error: { code: string } }).error.code).toBe('NO_LINEAR_CONFIG'); - }); - - it('returns an object keyed by state name when issues are present', async () => { - seedProject({ id: 'p1', linear_token: 't', linear_team_id: 'tm' }); - linearMockState.issues = [ - { - id: 'i1', - identifier: 'ABC-1', - title: 'First', - priority: 2, - estimate: null, - url: 'https://linear/i1', - createdAt: new Date('2026-04-01'), - updatedAt: new Date('2026-04-02'), - state: Promise.resolve({ name: 'Todo' }), - assignee: Promise.resolve(null), - labels: () => Promise.resolve({ nodes: [] }), - project: Promise.resolve(null), - }, - ]; - const { listProjectIssuesRoute } = await loadScopedRoutes(); - const res = (await listProjectIssuesRoute.handler( - makeCtx({ params: { projectId: 'p1' } }), - )) as unknown as JsonResponse; - expect(res.status).toBe(200); - const body = res.body as { data: Record> }; - expect(body.data.Todo).toBeDefined(); - expect(body.data.Todo[0].identifier).toBe('ABC-1'); - }); - }); - - describe('GET /projects/:projectId/linear/cycle', () => { - it('returns the active cycle when present', async () => { - seedProject({ id: 'p1', linear_token: 't', linear_team_id: 'tm' }); - const { getProjectCycleRoute } = await loadScopedRoutes(); - const res = (await getProjectCycleRoute.handler( - makeCtx({ params: { projectId: 'p1' } }), - )) as unknown as JsonResponse; - expect(res.status).toBe(200); - const body = res.body as { data: { id: string; name: string } | null }; - expect(body.data?.id).toBe('c-1'); - expect(body.data?.name).toBe('Cycle One'); - }); - - it('returns null when no active cycle', async () => { - seedProject({ id: 'p1', linear_token: 't', linear_team_id: 'tm' }); - linearMockState.activeCycle = null; - const { getProjectCycleRoute } = await loadScopedRoutes(); - const res = (await getProjectCycleRoute.handler( - makeCtx({ params: { projectId: 'p1' } }), - )) as unknown as JsonResponse; - expect((res.body as { data: unknown }).data).toBeNull(); - }); - - it('400 without linear token', async () => { - seedProject({ id: 'p1' }); - const { getProjectCycleRoute } = await loadScopedRoutes(); - const res = (await getProjectCycleRoute.handler( - makeCtx({ params: { projectId: 'p1' } }), - )) as unknown as JsonResponse; - expect(res.status).toBe(400); - }); - }); - - describe('GET /projects/:projectId/linear/members', () => { - it('filters out guest and inactive members', async () => { - seedProject({ id: 'p1', linear_token: 't', linear_team_id: 'tm' }); - const { listProjectMembersRoute } = await loadScopedRoutes(); - const res = (await listProjectMembersRoute.handler( - makeCtx({ params: { projectId: 'p1' } }), - )) as unknown as JsonResponse; - const body = res.body as { data: { members: Array<{ id: string }> } }; - expect(body.data.members.map((m) => m.id)).toEqual(['m1']); - }); - - it('404 when project missing', async () => { - const { listProjectMembersRoute } = await loadScopedRoutes(); - const res = (await listProjectMembersRoute.handler( - makeCtx({ params: { projectId: 'ghost' } }), - )) as unknown as JsonResponse; - expect(res.status).toBe(404); - }); - }); - - describe('POST /projects/:projectId/wiki/generate', () => { - it('marks project as processing and returns processing status', async () => { - seedProject({ id: 'p1', repo_url: 'https://g/h/r', repo_default_branch: 'main' }); - const { generateProjectWikiRoute } = await loadScopedRoutes(); - const res = (await generateProjectWikiRoute.handler( - makeCtx({ params: { projectId: 'p1' } }), - )) as unknown as JsonResponse; - expect(res.status).toBe(200); - const body = res.body as { data: { status: string; projectId: string } }; - expect(body.data.status).toBe('processing'); - expect(body.data.projectId).toBe('p1'); - expect(store.projects.get('p1')!.wiki_status).toBe('processing'); - }); - - it('404 when project missing', async () => { - const { generateProjectWikiRoute } = await loadScopedRoutes(); - const res = (await generateProjectWikiRoute.handler( - makeCtx({ params: { projectId: 'ghost' } }), - )) as unknown as JsonResponse; - expect(res.status).toBe(404); - }); - }); - - describe('GET /projects/:projectId/wiki/status', () => { - it('reports counts for a project with no wiki data', async () => { - seedProject({ id: 'p1' }); - const { getProjectWikiStatusRoute } = await loadScopedRoutes(); - const res = (await getProjectWikiStatusRoute.handler( - makeCtx({ params: { projectId: 'p1' } }), - )) as unknown as JsonResponse; - const body = res.body as { - data: { documents: number; chunks: number; done: boolean }; - }; - expect(body.data.documents).toBe(0); - expect(body.data.chunks).toBe(0); - expect(body.data.done).toBe(false); - }); - - it('marks done when wiki_status is ready', async () => { - seedProject({ id: 'p1', wiki_status: 'ready' }); - const { getProjectWikiStatusRoute } = await loadScopedRoutes(); - const res = (await getProjectWikiStatusRoute.handler( - makeCtx({ params: { projectId: 'p1' } }), - )) as unknown as JsonResponse; - expect((res.body as { data: { done: boolean } }).data.done).toBe(true); - }); - }); -}); diff --git a/runtime/src/lib/scoped-routes.test.ts b/runtime/src/lib/scoped-routes.test.ts index 47cc129..84d750b 100644 --- a/runtime/src/lib/scoped-routes.test.ts +++ b/runtime/src/lib/scoped-routes.test.ts @@ -4,9 +4,9 @@ * * Two independent axes under test: * 1. Ownership gate: unauthenticated → 401, cross-tenant → 404. - * 2. Credential resolution: tenant row wins; falls back to env + legacy - * `projects.linear_team_id` when no tenant row; surfaces NO_LINEAR_CONFIG - * when neither source has usable data. + * 2. Credential resolution: tenant row required; surfaces NO_LINEAR_CONFIG + * when no tenant row exists or it lacks a teamId. The legacy env + + * `projects.linear_team_id` fallback was removed in MT-06. * * Uses `:memory:` libsql and a stubbed @linear/sdk so we can observe which * apiKey + teamId reached the SDK on each call. @@ -89,18 +89,16 @@ async function seedDb(): Promise { created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )`); - // Columns mirror the runtime shape closely enough for the handlers. - // `linear_team_id` is the legacy plaintext column the env-fallback path - // still reads from. `linear_token` is intentionally left null — the new - // code must not read it even when a row exists. + // Columns mirror the runtime shape. The legacy plaintext integration + // columns (`linear_token`, `linear_team_id`, etc.) were dropped in MT-06, + // so the seed schema no longer carries them and there is no env-fallback + // path left that could read them. await client.execute(`CREATE TABLE projects ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, repo_url TEXT, repo_default_branch TEXT, - linear_token TEXT, - linear_team_id TEXT, wiki_status TEXT, wiki_error TEXT, status TEXT, @@ -136,20 +134,17 @@ async function seedDb(): Promise { sql: 'INSERT INTO auth_session (id, user_id, token, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', args: ['s2', 'user-other', 'other-token', now + 1_000_000, now, now], }); - // proj-owned: legacy `linear_team_id` present so env-fallback has something - // to resolve; `linear_token` deliberately NULL to prove the flip doesn't - // read the plaintext column anymore. await client.execute({ sql: `INSERT INTO projects - (id, user_id, name, repo_url, repo_default_branch, linear_token, linear_team_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`, - args: ['proj-owned', 'user-owner', 'mine', 'https://github.com/x/y', 'main', 'team-legacy', now, now], + (id, user_id, name, repo_url, repo_default_branch, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + args: ['proj-owned', 'user-owner', 'mine', 'https://github.com/x/y', 'main', now, now], }); await client.execute({ sql: `INSERT INTO projects - (id, user_id, name, repo_url, repo_default_branch, linear_token, linear_team_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`, - args: ['proj-foreign', 'user-other', 'not-mine', 'https://github.com/z/w', 'main', 'team-legacy', now, now], + (id, user_id, name, repo_url, repo_default_branch, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + args: ['proj-foreign', 'user-other', 'not-mine', 'https://github.com/z/w', 'main', now, now], }); return client; } @@ -292,16 +287,6 @@ describe('scoped-routes', () => { expect(lastCall.teamId).toBe('team-tenant'); }); - it('falls back to env + legacy projects.linear_team_id when no tenant row', async () => { - process.env.LINEAR_API_KEY = 'env-fallback-key'; - const res = (await scoped.getProjectCycleRoute.handler( - makeCtx({ params: { projectId: 'proj-owned' } }), - )) as unknown as JsonRes; - expect(res.status).toBe(200); - expect(lastCall.apiKey).toBe('env-fallback-key'); - expect(lastCall.teamId).toBe('team-legacy'); - }); - it('returns 400 NO_LINEAR_CONFIG when tenant missing teamId', async () => { await seedTenantLinear('proj-owned', 'tenant-pat', { /* meta without teamId */ @@ -314,28 +299,19 @@ describe('scoped-routes', () => { expect((res.body as { error: { code: string } }).error.code).toBe('NO_LINEAR_CONFIG'); }); - it('returns 400 NO_LINEAR_CONFIG when neither tenant nor env have a key', async () => { + it('returns 400 NO_LINEAR_CONFIG when no tenant row, even with LINEAR_API_KEY env set', async () => { + // MT-06 dropped the legacy env-fallback path (which read + // projects.linear_team_id alongside env LINEAR_API_KEY). Projects must + // be onboarded through /integrations to use scoped routes; env alone + // does not unblock them anymore. + process.env.LINEAR_API_KEY = 'env-key-that-must-be-ignored'; const res = (await scoped.listProjectIssuesRoute.handler( makeCtx({ params: { projectId: 'proj-owned' } }), )) as unknown as JsonRes; expect(res.status).toBe(400); expect((res.body as { error: { code: string } }).error.code).toBe('NO_LINEAR_CONFIG'); - }); - - it('never reads projects.linear_token plaintext (regression guard)', async () => { - // Set a nonsense plaintext token on the project row directly — if the - // flip left any read path for it, the SDK spy would capture it. - await client.execute({ - sql: 'UPDATE projects SET linear_token = ? WHERE id = ?', - args: ['plaintext-MUST-NOT-LEAK', 'proj-owned'], - }); - process.env.LINEAR_API_KEY = 'env-ok'; - - await scoped.listProjectIssuesRoute.handler( - makeCtx({ params: { projectId: 'proj-owned' } }), - ); - expect(lastCall.apiKey).not.toBe('plaintext-MUST-NOT-LEAK'); - expect(lastCall.apiKey).toBe('env-ok'); + // The handler never reached the Linear SDK — apiKey stays at reset value. + expect(lastCall.apiKey).toBeNull(); }); }); diff --git a/runtime/src/lib/scoped-routes.ts b/runtime/src/lib/scoped-routes.ts index 68b86af..1cf0af8 100644 --- a/runtime/src/lib/scoped-routes.ts +++ b/runtime/src/lib/scoped-routes.ts @@ -42,37 +42,24 @@ async function getProject(projectId: string) { } type LinearContext = - | { ok: true; apiKey: string; teamId: string; source: 'tenant' | 'env' } + | { ok: true; apiKey: string; teamId: string } | { ok: false; reason: 'no_key' | 'no_team' }; /** * Resolve the API key + team id for a project's Linear integration. * - * Tenant row wins: key + meta.teamId come from the encrypted - * `project_integrations` row the user set up in /integrations. - * - * Env fallback: if there's no tenant row, use `process.env.LINEAR_API_KEY` - * and read the legacy `projects.linear_team_id` column (populated by the - * pre-multi-tenant seed script). This keeps `/projects/:id/linear/*` alive - * for projects that haven't onboarded through the new UI yet. + * Tenant row required: key + meta.teamId come from the encrypted + * `project_integrations` row the user set up in /integrations. The + * pre-multi-tenant env-fallback path (which read `projects.linear_team_id`) + * was removed alongside the plaintext-columns drop — projects must be + * onboarded through the new UI to use `/projects/:id/linear/*`. */ -async function resolveLinearContext( - projectId: string, - project: Record, -): Promise { +async function resolveLinearContext(projectId: string): Promise { const tenant = await getIntegrationKey(projectId, 'linear'); - if (tenant.ok) { - const teamId = tenant.meta.teamId; - if (!teamId) return { ok: false, reason: 'no_team' }; - return { ok: true, apiKey: tenant.plaintext, teamId, source: 'tenant' }; - } - const envKey = process.env.LINEAR_API_KEY; - if (!envKey) return { ok: false, reason: 'no_key' }; - const teamId = project.linear_team_id; - if (typeof teamId !== 'string' || teamId.length === 0) { - return { ok: false, reason: 'no_team' }; - } - return { ok: true, apiKey: envKey, teamId, source: 'env' }; + if (!tenant.ok) return { ok: false, reason: 'no_key' }; + const teamId = tenant.meta.teamId; + if (!teamId) return { ok: false, reason: 'no_team' }; + return { ok: true, apiKey: tenant.plaintext, teamId }; } function linearConfigError(c: Context, reason: 'no_key' | 'no_team') { @@ -96,10 +83,7 @@ export const listProjectIssuesRoute = registerApiRoute('/projects/:projectId/lin const auth = await assertProjectOwnership(c, projectId); if (!auth.ok) return authErrorResponse(c, auth.status); - const project = await getProject(projectId); - if (!project) return authErrorResponse(c, 404); - - const lc = await resolveLinearContext(projectId, project as Record); + const lc = await resolveLinearContext(projectId); if (!lc.ok) return linearConfigError(c, lc.reason); const linearClient = new LinearClient({ apiKey: lc.apiKey }); @@ -161,10 +145,7 @@ export const getProjectCycleRoute = registerApiRoute('/projects/:projectId/linea const auth = await assertProjectOwnership(c, projectId); if (!auth.ok) return authErrorResponse(c, auth.status); - const project = await getProject(projectId); - if (!project) return authErrorResponse(c, 404); - - const lc = await resolveLinearContext(projectId, project as Record); + const lc = await resolveLinearContext(projectId); if (!lc.ok) return linearConfigError(c, lc.reason); const linearClient = new LinearClient({ apiKey: lc.apiKey }); @@ -207,10 +188,7 @@ export const listProjectMembersRoute = registerApiRoute('/projects/:projectId/li const auth = await assertProjectOwnership(c, projectId); if (!auth.ok) return authErrorResponse(c, auth.status); - const project = await getProject(projectId); - if (!project) return authErrorResponse(c, 404); - - const lc = await resolveLinearContext(projectId, project as Record); + const lc = await resolveLinearContext(projectId); if (!lc.ok) return linearConfigError(c, lc.reason); const linearClient = new LinearClient({ apiKey: lc.apiKey }); diff --git a/runtime/src/mastra/index.ts b/runtime/src/mastra/index.ts index 8c45f3c..6166bf1 100644 --- a/runtime/src/mastra/index.ts +++ b/runtime/src/mastra/index.ts @@ -231,131 +231,6 @@ export const mastra = new Mastra({ }, }, - // GET /api/config/status — check configuration status - { - path: '/api/config/status', - method: 'GET' as const, - handler: async (c: Context) => { - return c.json({ - success: true, - data: { - linearConfigured: !!config.LINEAR_API_KEY, - openrouterConfigured: !!process.env.OPENROUTER_API_KEY, - }, - }); - }, - }, - - // GET /api/linear/members — list team members - { - path: '/api/linear/members', - method: 'GET' as const, - handler: async (c: Context) => { - try { - if (!linearClient) { - return c.json({ success: false, error: { code: 'NO_LINEAR_KEY', message: 'LINEAR_API_KEY not configured' } }, 500); - } - - const team = await linearClient.team(LINEAR_CONSTANTS.TEAM_ID); - const members = await team.members(); - const data = members.nodes - .filter((m: { guest: boolean; active: boolean }) => !m.guest && m.active) - .map((m: { id: string; name: string; email: string; displayName: string }) => ({ - id: m.id, - name: m.name, - email: m.email, - displayName: m.displayName, - })); - - return c.json({ success: true, data: { members: data } }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return c.json({ success: false, error: { code: 'LINEAR_ERROR', message } }, 500); - } - }, - }, - - // POST /api/wiki/generate — start wiki generation via wiki-rag pipeline - { - path: '/api/wiki/generate', - method: 'POST' as const, - handler: async (c: Context) => { - try { - const body = await c.req.json() as { repoUrl?: string }; - if (!body.repoUrl) { - return c.json({ success: false, error: { code: 'MISSING_REPO_URL', message: 'repoUrl is required' } }, 400); - } - - const { generateWiki } = await import('../lib/wiki-rag'); - const crypto = await import('crypto'); - - const db = createClient({ url: process.env.LIBSQL_URL || 'http://libsql:8080' }); - const projectId = crypto.randomUUID(); - const now = Date.now(); - - // Create project record - await db.execute({ - sql: `INSERT INTO projects (id, name, repository_url, branch, status, created_at, updated_at) VALUES (?, ?, ?, 'main', 'processing', ?, ?)`, - args: [projectId, body.repoUrl.split('/').pop() || 'repo', body.repoUrl, now, now], - }); - - console.log(`[wiki/generate] Starting wiki generation for: ${body.repoUrl} (project: ${projectId})`); - - // Run in background (non-blocking) - generateWiki(projectId, body.repoUrl).catch((err: Error) => { - console.error(`[wiki/generate] Pipeline error: ${err.message}`); - }); - - return c.json({ success: true, data: { status: 'processing', repoUrl: body.repoUrl, projectId } }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return c.json({ success: false, error: { code: 'WIKI_ERROR', message } }, 500); - } - }, - }, - - // GET /api/wiki/status — wiki generation status (reads from projects table + counts from wiki_* tables) - { - path: '/api/wiki/status', - method: 'GET' as const, - handler: async (c: Context) => { - try { - const db = createClient({ url: process.env.LIBSQL_URL || 'http://libsql:8080' }); - - // Get the latest project - const projectResult = await db.execute('SELECT id, status, error FROM projects ORDER BY created_at DESC LIMIT 1'); - const project = projectResult.rows[0]; - if (!project) { - return c.json({ success: true, data: { total: 0, processed: 0, done: true, status: 'idle' } }); - } - - // Count actual documents and chunks from the tables - const docsResult = await db.execute('SELECT COUNT(*) as count FROM wiki_documents WHERE project_id = ?', [project.id as string]); - const chunksResult = await db.execute('SELECT COUNT(*) as count FROM wiki_chunks WHERE document_id IN (SELECT id FROM wiki_documents WHERE project_id = ?)', [project.id as string]); - - const docCount = Number(docsResult.rows[0]?.count ?? 0); - const chunkCount = Number(chunksResult.rows[0]?.count ?? 0); - const done = project.status === 'ready' || project.status === 'error'; - - return c.json({ - success: true, - data: { - total: docCount + chunkCount, - processed: chunkCount, // chunks are the actual embeddings created - done, - status: project.status, - error: project.error || undefined, - documents: docCount, - chunks: chunkCount, - }, - }); - } catch (err) { - console.error('[wiki/status] Error:', err instanceof Error ? err.message : 'unknown'); - return c.json({ success: true, data: { total: 0, processed: 0, done: false, status: 'error' } }); - } - }, - }, - // POST /api/linear/webhook/setup — register the Linear webhook for this deployment { path: '/api/linear/webhook/setup',