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',