# @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.