# @mushi-mushi/server
> **Your AI wrote it. Mushi tells you why it broke.**
Supabase edge functions and admin API — self-host the comprehension layer.
Backend for Mushi Mushi — Supabase Edge Functions powering the LLM pipeline, knowledge graph, and admin API.
Scale: run pnpm docs-stats in the monorepo for live edge-function, migration, and agent counts. See docs/stats.md.
## Architecture
```
supabase/functions/
api/ Hono-based REST API (ingest, admin CRUD, graph, NL queries, billing, plugins, SSO, integrations, organizations + invitations under /v1/org and /v1/invitations, A2A v1.0.0 tasks under /v1/a2a/tasks, OpenAPI 3.1 spec at /openapi.json, JSON Schemas at /v1/schemas/*)
mcp/ Mushi v2 — **MCP Streamable HTTP transport**, dual-era (2024-11-05 … 2025-11-25 legacy with `initialize`; 2026-07-28 modern with `server/discover`, MRTR and the tasks extension) at /functions/v1/mcp. Single endpoint, POST returns application/json; legacy GET opens an SSE heartbeat stream; modern GET/DELETE answer 405; no Mcp-Session-Id is ever issued. Proxies tool calls into the existing /v1/admin/* REST surface — same auth (API key with `mcp:read|write` scope OR JWT), same RLS. `verify_jwt = false` in `config.toml` because this function handles its own dual-mode auth
fast-filter/ Stage 1 — Haiku extracts key facts and a structured evidence object, blocks spam (prompt-cached). **Internal-only** — rejects callers without `MUSHI_INTERNAL_CALLER_SECRET` / `SUPABASE_SERVICE_ROLE_KEY` since 2026-04-21 (SEC-1)
classify-report/ Stage 2 — Sonnet deep analysis with vision + RAG. AIR-GAPPED: only consumes Stage 1's structured evidence, never raw user strings (prompt-cached). **Internal-only + `airGap=true` required** — any caller omitting the flag gets `400 AIR_GAP_REQUIRED` (SEC-7, belt-and-braces around OWASP LLM01 prompt injection)
judge-batch/ Nightly LLM quality scoring + prompt A/B auto-promotion
intelligence-report/ Automated weekly summary generation
generate-synthetic/ Synthetic test data generator
stripe-webhooks/ D5 — handles Stripe subscription + invoice events
usage-aggregator/ D5 — hourly cron pushing usage_events to Stripe Meter Events
webhooks-github-indexer/ GitHub App webhook → codebase RAG indexer; `?mode=sweep` reindexes all installed repos for cron use
sentry-seer-poll/ Polls Sentry Seer issues for proactive bug intake. verify_jwt=false — invoked only by pg_cron via Vault-stored token
fix-worker/ Self-hosted fix-agent runner. **Internal-only** since 2026-04-21 (SEC-1). Now recovers the originating inventory `Action` for the report (via `reports_against` graph edge or the `inventoryActionNodeId` override on the dispatch row), threads `expected_outcome` into the LLM prompt, runs `validateAgainstSpec` as a deterministic pre-PR gate, and queues a targeted post-PR synthetic probe (`synthetic_runs` row with `status='skipped'`, `error_message='queued_post_pr'`) the moment the PR opens (whitepaper §2.10 spec-traceability)
inventory-crawler/ Mushi v2 — Playwright (and `crawler_auth_config`-aware) crawler that walks the customer's `app.base_url`, snapshots discovered pages / elements / actions, and writes the rolling diff into `inventory_crawl_summaries`. Powers Gate 4 (`crawl`)
inventory-gates/ Mushi v2 — server-side runner for Gates 3 (`api_contract`), 4 (`crawl`), 5 (`status_claim`). Consumes `discovered_apis` from `mushi-mushi-gates discover-api`, runs the crawl + status reconciler, and persists `gate_runs` / `gate_findings` for `/inventory ▸ Gates`
inventory-propose/ Mushi v2.1 — LLM proposer. Reads `discovery_observed_inventory` (rolling 30-day SDK observations) + the current `inventory.yaml`, asks Claude Sonnet 4.6 to draft a `user_stories` + `pages` proposal, validates with `@mushi-mushi/inventory-schema`, and persists into `inventory_proposals` for review on `/inventory ▸ Discovery`. **Internal-only**
status-reconciler/ Mushi v2 — derives every action's status (`stub` / `mocked` / `wired` / `verified` / `regressed`) from observable signals (lint, contract diff, CI test results, synthetic monitor, user reports) and writes the result back onto the inventory tree
synthetic-monitor/ Mushi v2 — periodic health-check runner. Hits each declared user-story's happy-path route via Playwright (using the crawler cookie when set) and writes results into `synthetic_runs` for the `/inventory` timeline. As of 2026-05-09 it also drains the post-PR probe queue (`synthetic_runs WHERE status='skipped' AND error_message='queued_post_pr'`) with priority and `evaluateExpectedOutcome` against each Action's `expected_outcome` (status_in + JSONPath assertions on the live HTTP response)
sentinel-audit/ Periodic audit sweep that compares the SDK-observed inventory against the accepted inventory and surfaces drift findings on `/inventory`
test-gen-from-report/ Generates a Playwright spec stub from a report so the next dispatch has a regression test to lean on
_shared/ Shared modules (db, auth, schemas, embeddings, notifications, prompt-ab,
telemetry, plugins, sanitize, stripe, invoice, quota, byok, region, age-graph,
audit, models, fix-schema, ...). `_shared/invoice.ts` exports the canonical
`subscriptionIdFromInvoice` helper used by `stripe-webhooks` to walk the
Stripe Basil 2025-03-31 `invoice.parent` shape; both production code and
`stripe-webhooks.test.ts` import from here so the test can never silently
diverge from the shipped resolution logic. `_shared/models.ts` is the single source of truth for
model IDs and stage → model defaults (Haiku 4.5 fast-filter, Sonnet 4.6
classify/judge/promoter). Opus 4.7 was briefly assigned to judge + promoter on
2026-04-22 then reverted on 2026-04-24 — Opus 4.7 dropped sampling knobs and
AI SDK v4's `generateObject` forces `tool_choice`, which Anthropic forbids when
thinking-mode is on; see SERVER-9 inline comments and
`_shared/models.ts#acceptsSamplingKnobs` for the full migration note. Admin UI
dropdowns and `project_settings.*_model` defaults read from here.
supabase/templates/ Branded HTML email templates (confirmation, recovery)
supabase/migrations/ PostgreSQL schema + RLS policies. Recent migrations:
- **Teams v1** — `organizations` + `organization_members`
above projects, `invitations` table with
`accept_invitation(token)` RPC, plan-gate trigger
that rejects invites on hobby/starter, last-owner
guard, and the `private.*` SECURITY DEFINER helpers
used by org-aware RLS to avoid recursion.
- **`20260429000000_sdk_versions`** — `sdk_versions`
catalogue + `reports.sdk_package` / `reports.sdk_version`
columns; powers the SDK identity + outdated-banner flow.
- **`20260429001000_report_repro_timeline`** — `reports.repro_timeline`
jsonb column with a partial GIN index for the SDK
timeline payload (`route` / `click` / `screen` events).
- **`20260430000000_two_way_reply`** — `report_comments.author_kind`
+ `report_comments.reporter_token_hash` columns
(admin / reporter author union with a CHECK constraint),
`reports.last_admin_reply_at` / `last_reporter_reply_at`,
partial indexes for reporter history, and the
`report_comments_fanout_to_reporter` trigger that
emits `reporter_notifications` on visible admin replies.
- **`20260504000000_v2_bidirectional_graph`** — Mushi v2.
Adds the positive-side inventory tables
(`inventory_apps`, `inventory_user_stories`,
`inventory_pages`, `inventory_elements`,
`inventory_actions`, `inventory_api_deps`,
`inventory_db_deps`, `inventory_tests`),
`inventory_crawl_summaries`, `gate_runs`,
`gate_findings`, `synthetic_runs`,
`inventory_drift_findings`, plus the
`project_settings.crawler_*` columns the
inventory-crawler + auth-runner write into.
- **`20260504120000_inventory_v2_plan_flags`** —
feature-flag the v2 surface per project
(`project_settings.inventory_v2_enabled`,
`synthetic_monitor_enabled`).
- **`20260504130000_inventory_discovery`** — Mushi v2.1.
Adds the SDK passive-discovery channel:
append-only `discovery_events` (one row per
throttled SDK observation),
`discovery_observed_inventory` view (30-day
rolling aggregate by route template),
`inventory_proposals` table (LLM-drafted
`inventory.yaml` candidates with
`status: 'draft' | 'accepted' | 'discarded'`),
and the partial covering indexes the proposer
+ `/inventory ▸ Discovery` page query against.
- **`20260430010000_migration_progress` (+ `20260430010001`
hardening)** — Migration Hub Phase 2. New
`public.migration_progress(user_id, project_id NULL,
guide_slug, completed_step_ids text[], …)` table with
two partial UNIQUE indexes (account-scoped via
`WHERE project_id IS NULL`, project-scoped via
`WHERE project_id IS NOT NULL`) so the same row shape
supports both "my progress across projects" and
"this project's shared migration". RLS reuses the
`private.is_project_member` helper from Teams v1
(project members read teammate rows; only the owner
writes), every policy uses the `(SELECT auth.uid())`
initplan pattern, and the hardening migration revokes
table-level GRANTs from `anon` + `authenticated` to
close `pg_graphql_*_table_exposed` advisor warnings —
the only intended caller is the Hono Edge Function
running as `service_role`.
- **`20260505000000_project_api_keys_last_seen`** — SDK
heartbeat columns on `project_api_keys` (`last_seen_at`,
`last_seen_origin`, `last_seen_user_agent`,
`last_seen_endpoint_host`) plus a partial covering index
on `(project_id, last_seen_at DESC) WHERE last_seen_at
IS NOT NULL`. `apiKeyAuth` updates these fire-and-forget
on every successful SDK auth (throttled to one write /
30s / key) so the dashboard's `sdk_installed` checklist
step ticks green the moment the SDK reaches the backend
— no need to wait for a real user-triggered report — and
`/v1/admin/setup` surfaces the heartbeat metadata so
operators can spot cross-environment mismatches (e.g.
SDK pointed at local Supabase while the admin reads
cloud) instead of staring at a stuck red checkmark.
- **`20260509100000_inventory_action_traceability`** —
spec-traceability on the WRITE side of the loop
(whitepaper §2.10). Adds nullable
`inventory_action_node_id UUID REFERENCES graph_nodes(id)
ON DELETE SET NULL` to both `fix_dispatch_jobs` and
`fix_attempts` (so the worker can persist the
originating Action without forcing every legacy report
to have an inventory linkage), plus
`spec_validation_warnings JSONB` on `fix_attempts` for
the soft warnings `validateAgainstSpec` emits when the
diff doesn't reference the contract's required DB
table or page route. Two partial indexes
(`WHERE inventory_action_node_id IS NOT NULL`) back the
"show me every fix that touched this Action" admin
drawer.
- **`20260511120000_get_report_inventory_action`** —
`public.get_report_inventory_action(p_report_id uuid)
RETURNS jsonb`, called by `/v1/admin/reports/:id` to
hydrate the FixCard "Origin — Inventory action" drawer
without an extra graph round-trip. Two resolution paths:
(1) `graph_nodes(node_type='report_group',
label=reportId)` → `graph_edges(edge_type='reports_against')`
→ `graph_nodes(node_type='action')` (populated by
`classify-report → linkReportToAction`), or
(2) `fix_dispatch_jobs.inventory_action_node_id`
fallback for reports dispatched without classification.
Returns NULL when neither path resolves.
- **`20260511120100_promote_candidate_atomic`** —
`public.promote_prompt_candidate(uuid, text, text)`
wraps the two-step `prompt_versions` deactivate-then-
promote in a single transaction. Closes the partial-
failure window where the Edge Function dying between
UPDATEs left the (project, stage) pair with no active
row and silently fell through to the hardcoded default.
- **`20260511120200_seed_managed_prompts`** — seeds
global defaults (`project_id IS NULL`) for the
`inventory-propose` + `sentinel` stages so the new
`getPromptForStage` fallback resolves a managed row on
first call. Idempotent via `WHERE NOT EXISTS`.
- **`20260511120300_updated_at_trigger_coverage`** —
ensures every mutable table with an `updated_at` column
has a `BEFORE UPDATE` trigger calling
`public.set_updated_at()`. Covers
`billing_customers`, `billing_subscriptions`,
`fix_coordinations`, `mushi_runtime_config`,
`organizations`, `pricing_plans`, `project_repos`,
`region_routing` that previously relied on the column
default and silently drifted on UPDATE.
- **`20260511120400_fix_rls_initplan`** — rewrites the
`discovery_events_service_all` +
`inventory_proposals_{admin,service}_all` RLS policies
to wrap `auth.role()` / `auth.uid()` in `(SELECT ...)`
subqueries so the planner caches the result once per
statement instead of recomputing per row
(`auth_rls_initplan` advisor warning → 0). Also adds
`idx_fix_corpus_report_id` to back the missing FK
index Performance Advisor flagged.
- **`20260511120500_revoke_anon_security_definer`** —
revokes `EXECUTE` from `anon` on 24 sensitive
`SECURITY DEFINER` functions (`vault_*`,
`fix_dispatch_claim_next`, `mushi_age_*`, rate-limit
helpers, `promote_prompt_candidate`). The functions
run as the function owner, so any anon grant was an
escalation path — they should only be reachable via
`authenticated` (when the row-level check would
require user context) or `service_role` (when only
cron / internal callers need them).
- **`20260527000000_fix_dispatch_jobs_allow_skipped`** —
extends the `fix_dispatch_jobs.status` CHECK constraint
to include `'skipped'`. The fix-worker wrote
`status='skipped'` for short-circuit paths
(`skipped_no_context`, `skipped_unsupported_agent`,
`skipped_no_sandbox`) but the original schema only
allowed `queued|running|completed|failed|cancelled`,
so every skip raised `23514 check_violation` and left
the dispatch row stuck in `running`. Also updates the
partial index `idx_fix_dispatch_status` (used by the
claim-next query) to match the new constraint, and
backfills any row stuck in `running` for > 1 hour as
`failed` so operators see a clean state on next visit.
**P0 production bug.**
- **`20260527010000_backfill_project_settings`** —
inserts a `project_settings` row for every project that
was created before the `project_settings` auto-insert
trigger was added. Legacy projects lacked rows, which
caused `POST /autofix/toggle` (`.update()` path) to
silently affect 0 rows and return `200 OK`, making the
autofix toggle snap ON then immediately back to OFF on
UI refresh. Also adds `codebase_index_enabled` and
`autofix_enabled` columns with `DEFAULT false` if they
don't already exist. **P0 production bug.**
```
## Development
### Prerequisites
- [Supabase CLI](https://supabase.com/docs/guides/cli)
- Docker (for local Supabase)
### Local Development
```bash
cd packages/server
# Start local Supabase (Postgres, Auth, Storage, Edge Functions)
pnpm dev:db
# Apply migrations
pnpm db:push
# Deploy functions locally
pnpm dev
```
### Run Tests
```bash
pnpm test # Vitest smoke tests for Edge Functions
```
### Deploy to Supabase
```bash
pnpm db:push # Run migrations
pnpm deploy # Deploy all Edge Functions
```
### Environment Variables
Set these as Supabase secrets:
| Variable | Required | Description |
| ------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ANTHROPIC_API_KEY` | Yes | Claude API key for LLM pipeline |
| `OPENAI_API_KEY` | No | OpenAI fallback when Anthropic is down |
| `LANGFUSE_SECRET_KEY` | No | Langfuse LLM trace logging |
| `LANGFUSE_PUBLIC_KEY` | No | Langfuse LLM trace logging |
| `STRIPE_SECRET_KEY` | Cloud | Stripe server key (apps/cloud billing flow) |
| `STRIPE_WEBHOOK_SECRET` | Cloud | Verifies signatures on `stripe-webhooks` |
| `STRIPE_PRICE_ID_REPORTS` | Cloud | Metered price ID used by checkout |
| `E2B_API_KEY` | No | Managed sandbox provider for fix agents |
| `MUSHI_REGION` | No | `us` / `eu` / `jp` — data residency tag |
| `MUSHI_INTERNAL_CALLER_SECRET` | Yes (prod) | Shared secret for cross-function + `pg_cron` → edge-function calls. Must also be mirrored into `public.mushi_runtime_config` (`key='service_role_key'`) so `pg_net` can read it from SQL. See [Internal-caller authentication](#internal-caller-authentication-sec-1) below |
| `SUPABASE_URL` | Auto | Set by Supabase runtime |
| `SUPABASE_SERVICE_ROLE_KEY` | Auto | Set by Supabase runtime — auto-injected inside edge functions, not reachable from `pg_net`/`pg_cron`, which is why `MUSHI_INTERNAL_CALLER_SECRET` exists |
## API Routes
All routes are served from the `api` function under `/v1/`:
- `POST /v1/reports` — SDK report submission. Returns **HTTP 402** + `{ code: 'QUOTA_EXCEEDED', limit, used }` when the project's free-tier monthly quota is hit (`_shared/quota.ts`); paid plans bypass via Stripe metered billing. Now persists `sdk_package`, `sdk_version`, and `repro_timeline` from the SDK payload so triage can see what shipped and what the user did before the report
- `POST /v1/reports/batch` — Batch report submission (up to 10), same quota gate
- `GET /v1/sdk/latest-version?package=@mushi-mushi/web` — public, unauthenticated. Reads from `public.sdk_versions` and returns `{ package, latest, deprecated, deprecationMessage, releasedAt }` for the SDK's outdated-banner check. CORS allowlist explicitly admits `X-Mushi-Internal` so the SDK's own freshness call doesn't trip self-cascade detection
- `GET /v1/reporter/reports` — HMAC-authed list of the reporter's own reports for the widget's "Your reports" view. Auth is `X-Reporter-Token-Hash` (sha256 of the reporter token) + `X-Reporter-Ts` + `X-Reporter-Hmac` (sha256-hmac of `projectId.ts.tokenHash` keyed by the public API key); no Supabase auth user required. The digest is the credential, so the server never stores it: every reporter table holds `rk1_` + sha256(digest) (`public.mushi_reporter_key`, `_shared/reporter-token.ts`), and a stored value presented back matches nothing. Each row carries `unread_count` derived from `last_admin_reply_at` vs the reporter's last poll
- `GET /v1/reporter/reports/:id/comments` — HMAC-authed comment thread, filtered to `visible_to_reporter = true` admin comments and the reporter's own replies (matched on `reporter_token_hash`)
- `POST /v1/reporter/reports/:id/reply` — HMAC-authed reply endpoint. Inserts into `report_comments` with `author_kind = 'reporter'`; the `report_comments_fanout_to_reporter` trigger flips `reports.last_reporter_reply_at` so the admin list-view can render a "reporter replied" badge
- `GET/PATCH /v1/admin/reports` — Report management. `GET` accepts `status`, `category`, `severity`, `component`, and `reporter` (reporter token hash) query params for filtered/cross-linked views in the admin console. **1.0+:** also accepts `?tag=key:value` (matched via the `reports.tags` JSONB GIN index using `@>`), `?trace=`, `?release=`, and `?sentryEnv=` — all backed by partial b-tree indexes so the filters are O(index-lookup) regardless of total volume. Each returned row carries a `dedup_count` (number of reports sharing the same `report_group_id`) so the admin UI can collapse duplicates into a `+N similar` badge without an N+1 fetch, and now ships `breadcrumbs[]`, `tags{}`, `sentry_trace_id`, `sentry_release`, `sentry_environment` so the list-row hover popover (`BreadcrumbPeek`) can render the last 5 SDK breadcrumbs without a follow-up fetch
- `GET /v1/admin/stats` — Dashboard statistics
- `GET /v1/admin/dashboard` — Single-call payload for the admin dashboard. Includes a `pdcaStages` block (one entry per Plan / Do / Check / Act stage with `count`, `tone`, `bottleneck` caption, a `cta` deep-link, **and a 7-day `series: number[]` for sparkline rendering**) plus a `focusStage` field indicating the current bottleneck. Powers the `PdcaCockpit` strip
- `GET /v1/admin/setup` — Single source of truth for the onboarding checklist. Aggregates eight signals per accessible project (`project_created`, `api_key_generated`, `sdk_installed`, `first_report_received`, `github_connected`, `sentry_connected`, `byok_anthropic`, `first_fix_dispatched`) into a `{ has_any_project, projects: SetupProject[], admin_endpoint_host }` envelope. The `sdk_installed` step's primary signal is the per-key SDK heartbeat (`project_api_keys.last_seen_at IS NOT NULL`); the legacy "any non-`mushi-admin` report seen" check is kept as a fallback for projects whose SDKs predate the heartbeat migration. Each step optionally carries a `diagnostic` object — for `sdk_installed` it surfaces `last_sdk_seen_at`, `last_sdk_origin`, `last_sdk_user_agent`, and `last_sdk_endpoint_host` so the admin's `` can render an inline strip explaining whether the SDK has been seen, when, and from which backend (cross-env mismatches surface as a `BACKEND MISMATCH` warning when the heartbeat host differs from the top-level `admin_endpoint_host`). Drives the dashboard banner-mode checklist, the full `/onboarding` wizard, and per-page `EmptyState` nudges
- `GET /v1/admin/reports/severity-stats` — 14-day severity rollup. Also returns a `byDay: Array<{ day, critical, high, medium, low, total }>` matrix so the FE can render per-tile sparklines without a second round-trip
- `GET /v1/admin/query/history` — Returns `{ ok: true, data: { history: [], degraded: 'schema_pending' } }` instead of 500 when the `is_saved` column is missing (`pg_code='42703'`) so the Query page keeps rendering during partial schema deploys
- `GET /v1/admin/judge/evaluations` — Hydrates each row with the underlying report's `summary`, `severity`, and `status` so judge UIs can show human-readable summaries instead of opaque `report_id` hashes
- `GET /v1/admin/graph/*` — Knowledge graph queries
- `POST /v1/admin/query` — Natural language data queries
- `GET/PATCH /v1/admin/settings` — Project configuration
- `GET /v1/admin/billing` — Per-project plan, monthly usage, free-tier quota, `over_quota` flag
- `GET /v1/admin/billing/invoices` — Recent Stripe invoices for the project's customer (`stripe.listInvoices`)
- `POST /v1/admin/billing/checkout` — Start a Stripe Checkout session
- `POST /v1/admin/billing/portal` — Open the Stripe Billing Portal
- `POST /v1/admin/queue/flush-queued` — Force-process reports stuck in `status='queued'` (kicks `fast-filter` for each)
- `GET /v1/admin/repo/overview?project_id=...` — Repo-wide rollup for the admin `/repo` page. Returns `{ repo: { repo_url, default_branch, github_app_installation_id, last_indexed_at }, counts: { open, ci_passing, ci_failed, merged, failed_to_open }, branches: FixAttempt[50] }`. Each branch row carries `id`, `branch`, `pr_url`, `pr_number`, `status`, `check_run_status`, `check_run_conclusion`, `files_changed`, `started_at`, `completed_at`, `report_id`, `report_summary`. RLS mirrors the `fix_attempts` table — requester must be a member of the project
- `GET /v1/admin/repo/activity?project_id=...&limit=100` — Chronological timeline of branch / PR events synthesised from `fix_attempts` (and `fix_events` where available): dispatched → branch created → commit → PR opened → CI resolved → completed / failed. Default limit 100, capped at 500. Same RLS as `/repo/overview`
- `GET | POST | DELETE /v1/admin/integrations[/:type]` — Integration credentials CRUD. `GET` masks secrets; `POST` merges with existing masked values so partial updates don't drop tokens
- `GET | POST /v1/admin/sso`, `DELETE /v1/admin/sso/:id` — SAML provider self-service via Supabase Auth Admin API. Returns ACS URL + Entity ID for IdP setup. OIDC currently writes config and returns a hint pending GoTrue admin OIDC support
- `GET/POST /v1/admin/plugins` — Marketplace registry CRUD
- `POST /v1/admin/ask-mushi/messages` — Ask Mushi single-shot (non-streaming) turn. Accepts `{ threadId?, route, intent?, context?, messages[] }`, returns the assistant reply with LLM telemetry (`model`, `latencyMs`, `inputTokens`, `outputTokens`, `costUsd`). Rate-limited to 300 rq/hr per user via `scoped_rate_limit_claim`
- `POST /v1/admin/ask-mushi/messages/stream` — Ask Mushi SSE streaming variant. Same payload as above, returns `event: start/delta/meta/done/error` over `text/event-stream`. Same 300 rq/hr rate limit
- `GET /v1/admin/ask-mushi/threads` — List conversation threads for the authenticated user. Supports `?route=` filter and `?limit=`/`?offset=` pagination
- `GET /v1/admin/ask-mushi/threads/:id` — Retrieve all messages in a thread
- `DELETE /v1/admin/ask-mushi/threads/:id` — Delete a thread and all its messages (PII purge). Owner-scoped via RLS
- `GET /v1/admin/ask-mushi/mentions?q=...` — Search reports, fixes, and branches for the `@` mention typeahead in the Ask Mushi composer
- `POST /v1/admin/assist` — Back-compat shim that internally transforms the legacy payload and forwards to the `/ask-mushi/messages` handler. Will be removed after one release cycle
- `GET /.well-known/agent-card` — A2A agent card
- `GET /v1/admin/auth/manifest` — RFC 8414-style discovery doc for A2A clients. Lists every advertised endpoint + supported `grant_types`. contract test (`src/__tests__/manifest-contract.test.ts`) asserts every URL listed here is registered as a Hono route, so the manifest can never advertise a 404 again
- `POST /v1/admin/auth/token`— OAuth-style endpoint with two modes: (1) `grant_type=refresh_token` + `refresh_token` body → calls `auth.refreshSession` and returns a fresh access token + expiry, (2) `Authorization: Bearer ` only → returns RFC 7662-shape `{ active, sub, email }` introspection for an A2A client to validate a token. Without these the manifest was lying to clients
- `POST /v1/admin/projects/:id/keys/rotate`— atomic API key rotation. Revokes every active key for the project (audit-logged with the revoked prefixes), generates a new one, and returns it in the same response (`mushi_<32hex>`, 201). The plaintext is shown exactly once — clients store it immediately or rotate again. Project ownership is enforced via `jwtAuth` + `owner_id` check, so cross-project rotation is impossible
- `GET | PUT | DELETE /v1/admin/migrations/progress[/:guide_slug]` — Migration Hub Phase 2 sync endpoints (`supabase/functions/api/routes/migration-progress.ts`). `GET` returns the caller's account-scoped rows plus any project-scoped rows they can read via `userCanAccessProject`, in one envelope, alongside the catalog's `knownGuideSlugs` so the docs sync hook can locally validate. `PUT /:guide_slug` accepts `{ completed_step_ids[], required_step_count?, completed_required_count?, source?, project_id?, client_updated_at? }`, runs through `migration-progress-helpers.ts > normalizeProgressUpsert` (sort + dedupe step ids, slug + UUID + source validation), and upserts via the partial-unique indexes on `(user_id, guide_slug) WHERE project_id IS NULL` / `(user_id, project_id, guide_slug) WHERE project_id IS NOT NULL`. `DELETE` clears one slug's remote row without touching the docs `localStorage` cache. **CORS exception:** these routes are the only `/v1/admin/*` surface that also accept the docs origin (`apps/docs` → `kensaur.us` / `docs.mushimushi.dev` / `localhost:3000-3001`); the per-route `app.use('/v1/admin/migrations/*', cors({ origin: MIGRATIONS_PROGRESS_ORIGINS, allowHeaders: ['Content-Type', 'Authorization', 'X-Mushi-Project-Id', 'X-Mushi-Org-Id'], ... }))` block in `index.ts` is registered BEFORE the general `/v1/admin/*` block so Hono's first-match-wins ordering keeps the rest of the admin surface pinned to the admin allowlist. Both `X-Mushi-Project-Id` and `X-Mushi-Org-Id` are in the allowlist because the admin's `apiFetch` (`apps/admin/src/lib/supabase.ts`) appends both whenever a project / org is active — omitting either header from the allowlist made every preflight from the admin's `/projects` page fail until 2026-05-05. Pure helper logic is unit-tested in `src/__tests__/migration-progress-helpers.test.ts`; the live RLS contract is pinned by `supabase/tests/rls_migration_progress.test.sql`
- `POST /v1/sdk/discovery` — Mushi v2.1 SDK passive-discovery ingest. Public-API-key authed, accepts a `MushiDiscoveryEventPayload` (route template, page title, `[data-testid]` values, recent fetch paths, query-param **keys only**, sha256 user/session hash). Tagged `X-Mushi-Internal: discovery` so the SDK's own emissions don't trip the self-cascade detector. Validated by `_shared/schemas.ts::discoveryEventSchema` and inserted into `discovery_events`; the `discovery_observed_inventory` view aggregates 30 days into the per-route summary the proposer reads
- `POST | GET | PATCH /v1/admin/inventory/:projectId` — ingest, read, partial-update the project's `inventory.yaml`. `POST` accepts the raw YAML string, validates it through `@mushi-mushi/inventory-schema`, and replaces the active inventory in one transaction. Read returns the parsed object plus stats (`pages`, `actions`, `coverage`)
- `GET /v1/admin/inventory/:projectId/user-stories` — flat user-story list with derived status (`stub` / `mocked` / `wired` / `verified` / `regressed`) per action, used by the `UserStoryMap` panel
- `GET /v1/admin/inventory/:projectId/diff` — drift between the accepted inventory and the most recent crawl/SDK observations. Powers the `DriftDiffPanel`
- `GET /v1/admin/inventory/:projectId/findings` — gate findings filtered by `?gate=` / `?status=`. Each row carries the `GateFindingCard` payload (locator, suggested fix, deep-link)
- `POST /v1/admin/inventory/:projectId/reconcile` — kicks the `status-reconciler` Edge Function for the project; returns the new derived statuses inline
- `POST /v1/admin/inventory/:projectId/gates/run` — runs Gates 3 + 4 + 5 server-side and persists `gate_runs` / `gate_findings`. Body accepts `{ commit_sha?, pr_number?, gates?: string[] }`; called by `mushi-mushi-gates gates`
- `GET /v1/admin/inventory/:projectId/discovery` — Mushi v2.1. Aggregates `discovery_observed_inventory` into `{ routes, total_events, ready_to_propose }` for the `/inventory ▸ Discovery` lifecycle stepper + observed-route cards. `ready_to_propose` flips true once the project has a defensible-sized sample
- `POST /v1/admin/inventory/:projectId/propose` — Mushi v2.1. Forwards to the `inventory-propose` Edge Function (Claude Sonnet 4.6). Returns `{ proposalId, storyCount, pageCount }` so a CI step can chain `mushi-mushi-gates propose` into a PR-comment job
- `GET /v1/admin/inventory/:projectId/proposals[/:id]` — list / read LLM-drafted `inventory.yaml` proposals (`status: 'draft' | 'accepted' | 'discarded'`) for the `ProposalReviewModal`. Includes `proposed_yaml`, `proposed_parsed`, and `rationale_by_story` so the modal's Stories / Why these stories / YAML tabs render from one round-trip
- `PATCH /v1/admin/inventory/:projectId/proposals/:id` — edit a draft proposal's YAML in-place before accepting (re-validates through `@mushi-mushi/inventory-schema`)
- `POST /v1/admin/inventory/:projectId/proposals/:id/accept` — replaces the project's active inventory with the proposal's parsed YAML and marks the proposal `accepted`. Idempotent; returns the new inventory snapshot
- `POST /v1/admin/inventory/:projectId/proposals/:id/discard` — marks the proposal `discarded` (used for malformed LLM outputs or stale drafts)
- `GET | PATCH /v1/admin/inventory/:projectId/settings` — Mushi v2.1. Project-scoped crawler / synthetic-monitor configuration: `crawler_base_url`, `crawler_auth_config` (cookie blob, `crawler_auth_runner` writes here), `synthetic_monitor_enabled`, `synthetic_monitor_target_url`, `synthetic_monitor_allow_mutations` (false by default — opt-in to allow non-safe HTTP verbs against the configured target; whitepaper §4.4). `PATCH` SSRF-validates `crawler_base_url` and `synthetic_monitor_target_url` at write time so misconfigured hosts (private IPs, cloud-metadata endpoints, embedded credentials) are rejected before the cron picks them up. Same endpoint `@mushi-mushi/inventory-auth-runner` POSTs the freshly-captured cookie back to
- `POST /v1/admin/fixes/dispatch` — agentic fix orchestrator dispatch. Body now optionally accepts `inventoryActionNodeId` (UUID-validated) so callers that already know the inventory `Action` they want repaired can pass it directly; the worker falls back to walking the `reports_against` graph edge when omitted (whitepaper §2.10 spec-traceability)
- `GET /v1/admin/fixes/dispatch/:id/stream` — AG-UI v0.4 SSE stream of fix dispatch events (`run.started` / `run.status` / `run.completed` / `run.failed`). **Auth swapped from JWT-only to `adminOrApiKey({ scope: 'mcp:read' })` on 2026-05-09** so API-key orchestrators can subscribe (was the single biggest unblock for non-browser clients). Sanitised against CVE-2026-29085
- `POST /v1/a2a/tasks` — Google A2A v1.0.0 task delegation. Wraps `fix_dispatch_jobs` rows as A2A `Task` resources; body shape `{ skill: 'dispatch_fix', input: { reportId, projectId, inventoryActionNodeId? } }`. Auth: `adminOrApiKey({ scope: 'mcp:write' })`
- `GET /v1/a2a/tasks/:id` — fetch A2A task state. Status names translated at the edge (`fix_dispatch_jobs.status='queued'` → A2A `state='submitted'`, `running` → `working`, `cancelled` → `canceled`, etc.). Includes a `metadata.inventoryActionNodeId` so external orchestrators can fetch the spec context via the MCP `get_fix_context` tool
- `POST /v1/a2a/tasks/:id:cancel` — A2A task cancellation (Hono path uses the regex constraint `:id{[^:]+}:cancel` to keep the literal colon-suffix verb intact)
- `GET /v1/a2a/tasks/:id:subscribe` — SSE stream of A2A `task.updated` / `task.terminal` events. Same back-pressure + heartbeat semantics as the AG-UI stream
- `GET /openapi.json` (alias `/v1/openapi.json`) — hand-curated OpenAPI 3.1 specification for the public REST surface (fixes dispatch, reports, inventory, A2A tasks, auth token). Referenced from the agent card so LangGraph code-gen, generic OpenAPI clients, and A2A skill negotiators can auto-generate Mushi clients
- `GET /v1/schemas` / `GET /v1/schemas/:name` — JSON Schemas (draft-07) for the public agent contracts: `fix-context.json`, `fix-result.json`, `sandbox-provider.json`, `expected-outcome.json`. Mirrored from `@mushi-mushi/agents` so non-TS orchestrators (Python LangGraph, Go agents, A2A skill cards) can implement the contract without typing-by-hand
- See `supabase/functions/api/index.ts` and `supabase/functions/api/routes/*.ts` for the full route table
### Inventory-route security guards (`_shared/inventory-guards.ts`)
The bidirectional-inventory routes share four hardening primitives consolidated in `supabase/functions/_shared/inventory-guards.ts` (added in the 2026-05-04 follow-up audit). Every guard has direct unit-test coverage in `src/__tests__/inventory-guards.test.ts`:
- `assertProjectScope(c, projectId, db)` — single source of truth for the API-key-vs-JWT scope branch. API-key callers must present a key minted for the exact `:projectId` (no fallback to "any project the human owner can see"); JWT callers fall through to the existing `accessibleProjectIds` membership check
- `adminOrApiKey({ scope: 'mcp:write' })` — every mutation route (`POST/PATCH/DELETE` on inventories, proposals, settings, reconcile, gates, propose, test-gen) now requires `mcp:write`. Read endpoints accept the default `mcp:read`. Mints from the admin console pick the correct scope automatically
- `assertSafeOutboundUrl(url, options)` + `safeFetch(...)` — OWASP-aligned SSRF gate for every outbound request the crawler / synthetic-monitor / settings PATCH issues. Rejects non-https schemes, embedded credentials, blocked ports (SSH, SMTP, Postgres, Redis, …), and every private/loopback/link-local IP family — including the cloud-metadata IPs (169.254.169.254 for AWS/Azure/DigitalOcean, `metadata.google.internal` for GCE). `safeFetch` opens with `redirect: 'manual'`, re-validates every hop against the allowlist, and strips `Authorization` / `Cookie` / `Proxy-Authorization` on cross-origin redirects (CVE-2025-21620 — Deno does NOT do this for us). Hosts are allowlisted from `inventory.app.{base,preview,staging}_url` so the crawler can only ever reach the customer's declared app
- `proposeRateLimiter` / `reconcileRateLimiter` / `gatesRunRateLimiter` — in-memory per-`(projectId, route)` token buckets. Defaults: 5/min for `/propose` (Sonnet 4.6 with 8K output × 3 retries — bounds spend at ~$1.50/min/project), 12/min for `/reconcile` and `/gates/run`. Tunable via `MUSHI_INVENTORY_PROPOSE_RPM` / `MUSHI_INVENTORY_RECONCILE_RPM` / `MUSHI_INVENTORY_GATES_RPM`. Returns 429 with `Retry-After` headers
## Manifest contract test
`src/__tests__/manifest-contract.test.ts` parses
`supabase/functions/api/index.ts` plus route modules, extracts every URL listed inside
`/v1/admin/auth/manifest`, and asserts each one is registered as a Hono
route via `app.(, ...)`. If a future PR adds an entry to the
manifest without wiring up the route — or deletes a route the manifest still
advertises — `pnpm test` fails with the offending URL named in the error.
This was added because static audit found two manifest entries
(`/v1/admin/auth/token`, `/v1/admin/projects/:id/keys/rotate`) that were
advertised but returned 404 in production. The test now blocks that class
of bug before it reaches a deploy.
## Error handling
Every Postgres error returned to the admin API flows through one helper —
`dbError(c, err)` in `supabase/functions/api/shared.ts`. It:
1. Logs to Sentry via `captureException` with `tags = { pg_code, route }` so
alert filters can split `42703` (undefined column, signals schema drift) from
`42501` (RLS denial), `23505` (unique violation), etc.
2. Returns a canonical `c.json({ error: 'database_error', code: pg_code, ... }, 500)`
so the FE always knows the error shape regardless of which endpoint failed.
It replaced ~25 inline `if (error) { console.error(); return c.json(...) }`
sites that previously sidestepped Hono's `app.onError` and never reached
Sentry. **If you add a new admin route, use `return dbError(c, error)` instead
of building the 500 by hand** — otherwise the new route's errors will be
invisible to the on-call dashboard.
### PostgrestBuilder is _not_ a Promise — no `.catch()`
A recurring foot-gun: Supabase's `db.from(...).insert/.upsert/.update/.delete()`
and `db.rpc(...)` return a `PostgrestBuilder`. It is a _thenable_ (`.then` only)
— it does **not** implement `.catch`. Writing
```ts
// BROKEN — throws TypeError at runtime
await db.from('audit_log').insert({...}).catch(() => {})
```
crashes with `TypeError: db.from(...).insert(...).catch is not a function`,
which bubbles to `app.onError` and masks the _preceding_ work as a generic 500.
This silently erased a successful BYOK vault write in Apr 2026
([MUSHI-MUSHI-SERVER-F](https://sakuramoto.sentry.io/issues/MUSHI-MUSHI-SERVER-F)).
**Use `try/await` for fire-and-forget writes:**
```ts
try {
await db.from('audit_log').insert({...})
} catch { /* best-effort */ }
```
Note: DB-level errors (unique violation, RLS denial) return `{data, error}`
synchronously — they never reject. `.catch()` wouldn't help there either; for
those, branch on `error` explicitly or let `dbError()` handle them.
## Stage 2 air-gap
Stage 2 (`classify-report`) **never receives raw user-supplied strings**. The
contract is enforced at the boundary: `fast-filter` produces a typed
`Stage1Evidence` object — title, normalised symptom buckets, suspected
component, severity hint, list of console-error frames (no payloads), list of
network failures (no bodies), reproducer steps. `classify-report` consumes only
that object plus the screenshot. Raw `description`, `userIntent`, console /
network bodies stay in the DB but never enter Stage 2 prompts. This closes the
prompt-injection / data-exfiltration vector raised in `MushiMushi_Critical_Analysis.md`.
## Internal-caller authentication (SEC-1)
Three internal-only edge functions — `fast-filter`, `classify-report`, and `fix-worker` — previously accepted any caller because Supabase deploys without `--no-verify-jwt` still pass anonymous `anon` requests through. The 2026-04-21 remediation (audit SEC-1) gates them behind a shared middleware in `_shared/auth.ts`:
```ts
import { requireServiceRoleAuth } from '../_shared/auth.ts';
const authErr = requireServiceRoleAuth(req);
if (authErr) return authErr;
```
The middleware accepts **either** token in the `Authorization: Bearer …` header:
1. `MUSHI_INTERNAL_CALLER_SECRET` — a non-reserved shared secret. Used by `pg_cron` → `pg_net.http_post` callers, because Postgres cannot read runtime-injected Supabase env vars. The same value is mirrored into `public.mushi_runtime_config` (row `key='service_role_key'`) so migrations like `recover_stranded_pipeline()` can look it up without a deploy.
2. `SUPABASE_SERVICE_ROLE_KEY` — auto-injected into the edge runtime. Used for function-to-function calls (`api` → `fast-filter`, `fast-filter` → `classify-report`, etc.) without any bespoke plumbing.
Both paths return `401 UNAUTHORIZED` otherwise. `classify-report` _additionally_ requires `body.airGap === true` (SEC-7) so a compromised Stage 1 cannot bypass the air-gap by handing Stage 2 raw user strings.
**To rotate the secret**:
```bash
export NEW_SECRET="$(openssl rand -hex 32)"
supabase secrets set MUSHI_INTERNAL_CALLER_SECRET="$NEW_SECRET" --project-ref [
# Mirror into the DB so pg_cron reads the new value on its next tick.
# mushi_runtime_config is a (key, value) table — update the service_role_key row.
supabase db query --linked <
```
## Security: prompt-injection defense
`_shared/sanitize.ts` exposes `sanitizeForLLM` and `wrapUserContent`. Every
user-supplied string headed for an LLM prompt **must** flow through one of
those before being embedded — they neutralise OWASP LLM01 instruction-hijack
patterns, role-flip mimicry, system-prompt look-alikes, control characters,
and base64-wrapped variants.
The Node-side mirror (`@mushi-mushi/core/injection-defense`) and the full
vitest regression corpus are tracked under follow-up
`waveD-d8-node-mirror`. The Deno module is the source of truth until then.
## LLM Pipeline
### Prompt Caching
All LLM calls use Anthropic's ephemeral prompt caching (`experimental_providerMetadata`) to reduce token costs on repeated system prompts.
### Prompt A/B Testing
The `_shared/prompt-ab.ts` module enables per-project, per-stage prompt experimentation:
1. **Traffic routing** — candidate prompts receive a configurable % of traffic
2. **Score tracking** — `judge-batch` records running-average judge scores per prompt version
3. **Auto-promotion** — candidates that exceed the active prompt's score by >5% after 30+ evaluations are promoted automatically
Stages: `stage1` (fast-filter), `stage2` (classify-report), `judge`.
### Observability
LLM traces are sent to Langfuse via direct REST API calls from `_shared/observability.ts`. Each pipeline stage logs input tokens, output tokens, latency, and model used.
### Telemetry & Realtime
The `_shared/telemetry.ts` module writes best-effort structured events to:
- `llm_invocations` — every LLM call with model, fallback, latency, tokens
- `cron_runs` — scheduled job outcomes (success/error, last run, duration)
- `anti_gaming_events` — multi-account / velocity-anomaly / manual-flag events
- `reporter_notifications` — classified / fixed / reward events surfaced to reporters
Admin pages subscribe to these tables via Supabase Realtime (`apps/admin/src/lib/realtime.ts`) so the `/health`, `/anti-gaming`, and `/notifications` dashboards update live without polling. RLS for these tables is in `migrations/20260417000001_admin_realtime_policies.sql`.
## License
[AGPLv3](./LICENSE) — true OSI open source: self-host it, fork it, modify it for your own org. If you offer a modified server as a hosted service, publish your changes (§13) or obtain a [commercial license](../../COMMERCIAL-LICENSE.md). A small enterprise-edition boundary ([`./ee/`](./ee/README.md)) is source-available but commercial for production use.
]