diff --git a/.env.example b/.env.example index 94c742401..681061da5 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,18 @@ INDEXING_V3_AGENT_SECRET=your-long-random-cron-shared-secret # Secret required for /api/health?deep=1 Supabase probe (x-health-deep-token header). HEALTH_DEEP_PROBE_SECRET=your-long-random-health-deep-probe-secret +# --- Inbound webhook receivers (see docs/webhooks.md) --- +# Gate for POST /api/webhooks/railway. Railway can only set a target URL, so the +# secret travels as ?token=... on the configured webhook URL. Min 16 chars. +#RAILWAY_WEBHOOK_SECRET=your-long-random-railway-webhook-secret +# Gate for POST /api/webhooks/supabase/document-change. Sent by the Supabase +# Database Webhook as an Authorization: Bearer header. Min 16 chars. +#SUPABASE_INGESTION_WEBHOOK_SECRET=your-long-random-supabase-webhook-secret +# Outbound chat destinations shared by the webhook receivers and the +# Notify CI failure workflow. Set either, both, or neither. +#SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXX/YYY/ZZZ +#DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/XXX/YYY + # Dedicated staging-only cross-tenant regression harness. These values are read # only by `npm run test:cross-tenant:staging`; never point them at production. # The two accounts must be distinct, non-human staging test users. The staging diff --git a/.github/workflows/notify-ci-failure.yml b/.github/workflows/notify-ci-failure.yml new file mode 100644 index 000000000..c1a52e221 --- /dev/null +++ b/.github/workflows/notify-ci-failure.yml @@ -0,0 +1,95 @@ +name: Notify CI failure + +# Fires after another workflow completes and pings chat when a run on a +# protected branch fails. This is the outbound side of recommendation #2: a +# solo-maintainer safety net so a red `main` (which auto-deploys to production) +# is noticed immediately rather than on the next manual check. +# +# Delivery uses plain `curl` to Slack / Discord incoming webhooks stored as +# repository secrets (SLACK_WEBHOOK_URL / DISCORD_WEBHOOK_URL). Set either, both, +# or neither — a missing secret simply skips that channel. No external actions are +# used, so the pinned-action allowlist (scripts/github-action-pins.mjs) is not +# involved. See docs/webhooks.md. + +on: + workflow_run: + workflows: + - CI + - SAST + - Secret Scan + - PR Policy + - Live domain monitor + - Live drift check + - Eval Canary + - Ingestion Autopilot + - Docker image build + types: + - completed + +permissions: + contents: read + +concurrency: + group: notify-ci-failure-${{ github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + notify: + name: Notify on failure + runs-on: ubuntu-24.04 + timeout-minutes: 5 + # Only alert on genuine failures of runs against protected branches. + # The head_repository guard restricts notifications to runs that originated + # in THIS repo (push, schedule, workflow_dispatch). A fork PR's triggering + # run also fires workflow_run with repo secrets in scope, and a fork could + # name its branch `main`/`release/...` to forge or spam alerts — the guard + # blocks that while preserving the scheduled production monitors (which run + # on `schedule`, so an `event == 'push'` filter would wrongly drop them). + if: >- + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.head_repository.full_name == github.repository && + (github.event.workflow_run.head_branch == 'main' || + startsWith(github.event.workflow_run.head_branch, 'release/')) + steps: + - name: Post failure notification + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + RUN_EVENT: ${{ github.event.workflow_run.event }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + message="🔴 *CI failure:* ${WORKFLOW_NAME} failed on \`${HEAD_BRANCH}\` (${REPO}, ${RUN_EVENT}) + ${RUN_URL}" + + if [ -z "${SLACK_WEBHOOK_URL:-}" ] && [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then + echo "No SLACK_WEBHOOK_URL or DISCORD_WEBHOOK_URL secret set; nothing to notify." + exit 0 + fi + + json_escape() { + # Escape a string for embedding in JSON via a here-doc-free jq-less approach. + python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' + } + + payload_text=$(printf '%s' "$message" | json_escape) + + if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then + echo "Posting to Slack..." + curl --fail --silent --show-error --max-time 15 \ + -X POST -H 'Content-Type: application/json' \ + --data "{\"text\": ${payload_text}}" \ + "$SLACK_WEBHOOK_URL" || echo "Slack post failed (non-fatal)." + fi + + if [ -n "${DISCORD_WEBHOOK_URL:-}" ]; then + echo "Posting to Discord..." + curl --fail --silent --show-error --max-time 15 \ + -X POST -H 'Content-Type: application/json' \ + --data "{\"content\": ${payload_text}}" \ + "$DISCORD_WEBHOOK_URL" || echo "Discord post failed (non-fatal)." + fi diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 3df40e367..c8452c7a7 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -86,6 +86,7 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map | Images | `/api/images/[id]/signed-url` | `images/[id]/signed-url/route.ts` | | Ops | `/api/health`, `/api/health/ready`, `/api/setup-status`, `/api/local-project-id` | `health/`, `setup-status/`, `local-project-id/` | | Eval / jobs | `/api/eval-cases`, `/api/jobs` | `eval-cases/`, `jobs/` | +| Webhooks | `/api/webhooks/railway`, `/api/webhooks/supabase/document-change` (inbound; secret-gated — see docs/webhooks.md) | `webhooks/` | --- @@ -106,14 +107,15 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### Ingestion and indexing -| Module | Role | -| ----------------------------------------------------------------------- | -------------------------------- | -| `ingestion.ts`, `ingestion-recovery.ts`, `ingestion-mutation-safety.ts` | Job queue semantics and recovery | -| `chunking.ts`, `extractors/document.ts` | Text extraction and chunking | -| `document-index-units.ts`, `document-enrichment.ts`, `deep-memory.ts` | Index artifacts and enrichment | -| `visual-intelligence.ts`, `image-filtering.ts` | Image captioning and filtering | -| `index-quality.ts`, `indexing-coverage.ts`, `model-index-extraction.ts` | Index quality gates | -| `reindex-pipeline.ts`, `reindex-eval-gate.ts`, `bulk-import.ts` | Atomic reindex and bulk import | +| Module | Role | +| ------------------------------------------------------------------------ | --------------------------------------------------- | +| `ingestion.ts`, `ingestion-recovery.ts`, `ingestion-mutation-safety.ts` | Job queue semantics and recovery | +| `ingestion-enqueue.ts`, `webhooks/` (`secret-auth.ts`, `chat-notify.ts`) | Reindex enqueue + inbound webhook auth/chat forward | +| `chunking.ts`, `extractors/document.ts` | Text extraction and chunking | +| `document-index-units.ts`, `document-enrichment.ts`, `deep-memory.ts` | Index artifacts and enrichment | +| `visual-intelligence.ts`, `image-filtering.ts` | Image captioning and filtering | +| `index-quality.ts`, `indexing-coverage.ts`, `model-index-extraction.ts` | Index quality gates | +| `reindex-pipeline.ts`, `reindex-eval-gate.ts`, `bulk-import.ts` | Atomic reindex and bulk import | ### Source governance and metadata diff --git a/docs/site-map.md b/docs/site-map.md index 952607524..838108de7 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1063,6 +1063,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/api/search/universal` - Route discovered from app directory Source: `src/app/api/search/universal/route.ts`. - `/api/setup-status` - Setup status. Source: `src/app/api/setup-status/route.ts`. - `/api/upload` - Upload endpoint. Source: `src/app/api/upload/route.ts`. +- `/api/webhooks/railway` - Railway deploy webhook -> chat forwarder. Source: `src/app/api/webhooks/railway/route.ts`. +- `/api/webhooks/supabase/document-change` - Supabase document-change webhook -> ingestion enqueue. Source: `src/app/api/webhooks/supabase/document-change/route.ts`. ## Redirects diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 000000000..36a9ca2c2 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,98 @@ +# Webhooks + +This repo ships three webhook integrations. Two are inbound receivers under +`src/app/api/webhooks/`; one is an outbound GitHub Actions notifier. All three +share the same optional chat destinations. + +| # | Integration | Direction | Entry point | +| --- | ------------------------------------ | --------- | --------------------------------------------- | +| 1 | Railway deploy → chat | inbound | `POST /api/webhooks/railway` | +| 2 | GitHub CI failure → chat | outbound | `.github/workflows/notify-ci-failure.yml` | +| 3 | Supabase document change → ingestion | inbound | `POST /api/webhooks/supabase/document-change` | + +## Chat destinations (shared) + +Set either, both, or neither. A receiver with no destination configured still +accepts the event and reports it as undelivered. + +- `SLACK_WEBHOOK_URL` — a Slack incoming webhook (`{ "text": … }`). +- `DISCORD_WEBHOOK_URL` — a Discord webhook (`{ "content": … }`). + +The GitHub workflow reads these from repository **secrets** of the same name; the +inbound receivers read them from server env (`src/lib/env.ts`). + +Store every secret below in Railway/GitHub secret stores — never in the repo. + +## 1. Railway deploy → chat + +`POST /api/webhooks/railway` forwards notable Railway deploy status changes +(`SUCCESS`, `FAILED`, `CRASHED`, `REMOVED`) for the app + worker services to chat. +This reports the deploy outcome GitHub cannot see. + +**Auth.** Railway lets you configure only a target URL (no custom headers or +signing), so the shared secret `RAILWAY_WEBHOOK_SECRET` (min 16 chars) travels as +a `?token=` query parameter and is compared constant-time. The receiver fails +closed (`503`) when the secret is unset and returns `401` on a bad token. + +**Setup.** Railway → Project → Settings → Webhooks → add: + +``` +https://psychiatry.tools/api/webhooks/railway?token= +``` + +Transient phases (`BUILDING`, `DEPLOYING`, `QUEUED`, …) are dropped to keep the +channel quiet; the receiver answers `200 { "skipped": true }` for them. + +> Note: the receiver runs inside the app being deployed, so a notification about +> a deploy that takes the app fully down may not be delivered. Pair it with an +> external uptime monitor for hard-down detection. + +## 2. GitHub CI failure → chat + +`.github/workflows/notify-ci-failure.yml` triggers on `workflow_run: completed` +for the key workflows (CI, SAST, Secret Scan, PR Policy, and the scheduled +monitors) and pings chat when a run on `main` or `release/*` fails. + +**Setup.** Add repository secrets `SLACK_WEBHOOK_URL` and/or `DISCORD_WEBHOOK_URL`. +No secret → the workflow logs "nothing to notify" and exits cleanly. It posts with +`curl` only (no external actions), so the pinned-action allowlist is not involved. + +## 3. Supabase document change → ingestion + +`POST /api/webhooks/supabase/document-change` turns the polling ingestion path +into an event-driven one: when a `public.documents` row is inserted outside the +app upload flow, or an existing row is flagged for reindex, the receiver enqueues +one `ingestion_jobs` row that the worker then claims. + +**Auth.** Supabase Database Webhooks can send custom headers, so the shared secret +`SUPABASE_INGESTION_WEBHOOK_SECRET` (min 16 chars) is sent as +`Authorization: Bearer ` (or `x-webhook-secret`) and compared +constant-time. Fails closed (`503`) when unset, `401` on a bad secret. + +**When it enqueues (idempotent + loop-safe):** + +- **INSERT** of a not-yet-`indexed` document → enqueue. The app upload route also + enqueues, but the `ingestion_jobs` one-open-job-per-document unique index makes + the duplicate insert a benign no-op (`already_active`). +- **UPDATE** acts only when `record.metadata.reindex_requested === true`, then + clears that flag via `apply_document_metadata_patch`. The worker's own + completion writes (also UPDATEs) never carry the flag, so they cannot retrigger + an endless loop. If the clear itself fails, the receiver responds `500` (not + `2xx`) so Supabase retries delivery until the flag is actually cleared — the + idempotent enqueue means a retry cannot double-queue. +- `checkIngestionMutationSafety` refuses while a job is already active, and the + enqueue reports `already_active` instead of erroring on a lost race. + +Every write is owner-scoped (`owner_id`) — the app's single tenancy layer, since +the service-role client bypasses RLS. Events without an `owner_id`, on other +tables, or of type `DELETE` are skipped with `200`. + +**Setup (Supabase).** Create a Database Webhook (or SQL trigger via +`supabase_functions.http_request`) on `public.documents` for INSERT/UPDATE with: + +- URL: `https://psychiatry.tools/api/webhooks/supabase/document-change` +- HTTP header: `Authorization: Bearer ` + +To request a reindex of an existing document, set +`metadata.reindex_requested = true` on its row; the receiver enqueues the job and +clears the flag. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 17f1dabd0..b2110bdd5 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -124,6 +124,8 @@ const apiDescriptions: Record = { "/api/search/interaction": "Search interaction telemetry.", "/api/setup-status": "Setup status.", "/api/upload": "Upload endpoint.", + "/api/webhooks/railway": "Railway deploy webhook -> chat forwarder.", + "/api/webhooks/supabase/document-change": "Supabase document-change webhook -> ingestion enqueue.", }; const routeOwnershipRows = [ diff --git a/src/app/api/webhooks/railway/route.ts b/src/app/api/webhooks/railway/route.ts new file mode 100644 index 000000000..ef491345a --- /dev/null +++ b/src/app/api/webhooks/railway/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/lib/env"; +import { jsonError } from "@/lib/http"; +import { logger } from "@/lib/logger"; +import { postChatNotification, type ChatSeverity } from "@/lib/webhooks/chat-notify"; +import { verifyWebhookSecret } from "@/lib/webhooks/secret-auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Receiver for Railway project deploy webhooks. Railway can only be configured +// with a target URL (no custom headers or signing), so the shared secret travels +// as `?token=` on the configured webhook URL and is compared constant-time. The +// route forwards notable deploy status changes for the app + worker services to +// Slack/Discord — the piece GitHub cannot report, since it does not know Railway's +// deploy outcome. See docs/webhooks.md for setup. + +const namedEntitySchema = z.object({ name: z.string().optional() }).passthrough(); + +const railwayWebhookSchema = z + .object({ + type: z.string().optional(), + status: z.string().optional(), + timestamp: z.string().optional(), + project: namedEntitySchema.optional(), + environment: namedEntitySchema.optional(), + service: namedEntitySchema.optional(), + deployment: z + .object({ id: z.string().optional(), meta: z.record(z.string(), z.unknown()).optional() }) + .passthrough() + .optional(), + }) + .passthrough(); + +// Only forward status changes worth a ping; transient build/deploy phases are +// dropped to keep the channel quiet. +const NOTABLE_STATUSES = new Set(["SUCCESS", "FAILED", "CRASHED", "REMOVED"]); + +function severityForStatus(status: string): ChatSeverity { + if (status === "SUCCESS") return "success"; + if (status === "FAILED" || status === "CRASHED") return "error"; + if (status === "REMOVED") return "warning"; + return "info"; +} + +function serviceName(payload: z.infer): string { + const fromService = payload.service?.name; + if (typeof fromService === "string" && fromService) return fromService; + const fromMeta = payload.deployment?.meta?.["serviceName"]; + if (typeof fromMeta === "string" && fromMeta) return fromMeta; + return "unknown service"; +} + +export async function POST(request: Request) { + try { + const auth = verifyWebhookSecret(request, env.RAILWAY_WEBHOOK_SECRET, { allowQueryToken: true }); + if (!auth.ok) { + if (auth.reason === "misconfigured") { + return NextResponse.json( + { error: "Railway webhook receiver is not configured.", code: "webhook_not_configured" }, + { status: 503 }, + ); + } + return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const parsed = railwayWebhookSchema.safeParse(rawBody); + if (!parsed.success) { + // Authenticated but unrecognised shape: accept so Railway does not retry, + // and record it for debugging. + logger.warn("Railway webhook payload did not match expected shape"); + return NextResponse.json({ skipped: true, reason: "unrecognized_payload" }); + } + + const payload = parsed.data; + const status = (payload.status ?? "").toUpperCase(); + if (!NOTABLE_STATUSES.has(status)) { + return NextResponse.json({ skipped: true, reason: "status_not_notable", status }); + } + + const project = payload.project?.name ?? "Railway project"; + const environment = payload.environment?.name ?? "unknown environment"; + const service = serviceName(payload); + const severity = severityForStatus(status); + + const delivery = await postChatNotification({ + title: `Railway deploy ${status.toLowerCase()}: ${service}`, + text: `Deploy of *${service}* in *${project}* (${environment}) reported status *${status}*.`, + severity, + fields: [ + { label: "Project", value: project }, + { label: "Environment", value: environment }, + { label: "Service", value: service }, + { label: "Status", value: status }, + ], + }); + + return NextResponse.json({ forwarded: delivery.delivered, delivery }); + } catch (error) { + return jsonError(error); + } +} diff --git a/src/app/api/webhooks/supabase/document-change/route.ts b/src/app/api/webhooks/supabase/document-change/route.ts new file mode 100644 index 000000000..73df6e4e6 --- /dev/null +++ b/src/app/api/webhooks/supabase/document-change/route.ts @@ -0,0 +1,186 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { env, isDemoMode } from "@/lib/env"; +import { jsonError } from "@/lib/http"; +import { logger } from "@/lib/logger"; +import { enqueueDocumentReindexJob, type EnqueueableDocument } from "@/lib/ingestion-enqueue"; +import { checkIngestionMutationSafety } from "@/lib/ingestion-mutation-safety"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { verifyWebhookSecret } from "@/lib/webhooks/secret-auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Receiver for a Supabase Database Webhook on public.documents. It turns the +// polling ingestion-autopilot into an event-driven path: when a document is +// inserted outside the app upload flow, or an existing row is explicitly flagged +// for reindex, this enqueues a single (re)index job the worker then claims. +// +// Idempotency + loop-safety (see docs/webhooks.md): +// - INSERT of a not-yet-indexed document -> enqueue. The app upload route also +// enqueues, but the one-open-job-per-document unique index makes the second +// insert a benign no-op, so double delivery is harmless. +// - UPDATE only acts when record.metadata.reindex_requested === true, and the +// flag is CLEARED after acting. The worker's own completion writes (which are +// UPDATEs) never carry the flag, so they cannot retrigger an endless loop. +// - checkIngestionMutationSafety refuses while a job is already active, and the +// enqueue reports "already_active" instead of erroring on a lost race. + +const documentRecordSchema = z + .object({ + id: z.string().uuid(), + owner_id: z.string().uuid().nullable().optional(), + status: z.string().nullable().optional(), + error_message: z.string().nullable().optional(), + page_count: z.number().nullable().optional(), + chunk_count: z.number().nullable().optional(), + image_count: z.number().nullable().optional(), + import_batch_id: z.string().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .passthrough(); + +const supabaseWebhookSchema = z + .object({ + type: z.enum(["INSERT", "UPDATE", "DELETE"]), + table: z.string(), + schema: z.string().optional(), + record: documentRecordSchema.nullable().optional(), + old_record: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .passthrough(); + +function skip(reason: string, extra: Record = {}) { + return NextResponse.json({ skipped: true, reason, ...extra }); +} + +// A requested reindex-flag clear failed. Return 500 (not 2xx) so Supabase retries +// delivery — leaving the flag set would let every later UPDATE re-trigger enqueue, +// defeating the loop-safety guarantee documented above. +function reindexFlagClearFailed() { + return NextResponse.json( + { error: "Failed to clear reindex flag; retry.", code: "reindex_flag_clear_failed" }, + { status: 500 }, + ); +} + +export async function POST(request: Request) { + try { + // Supabase webhooks can send custom headers, so require the secret in an + // Authorization: Bearer / x-webhook-secret header (no query-token fallback). + const auth = verifyWebhookSecret(request, env.SUPABASE_INGESTION_WEBHOOK_SECRET); + if (!auth.ok) { + if (auth.reason === "misconfigured") { + return NextResponse.json( + { error: "Supabase ingestion webhook receiver is not configured.", code: "webhook_not_configured" }, + { status: 503 }, + ); + } + return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + } + + // No live database in demo mode — accept and no-op so retries stop. + if (isDemoMode()) return skip("demo_mode"); + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const parsed = supabaseWebhookSchema.safeParse(rawBody); + if (!parsed.success) return skip("unrecognized_payload"); + + const event = parsed.data; + if (event.table !== "documents") return skip("not_documents_table", { table: event.table }); + if (event.type === "DELETE") return skip("delete_event"); + + const record = event.record; + if (!record) return skip("missing_record"); + + const reindexRequested = record.metadata?.["reindex_requested"] === true; + const alreadyIndexed = record.status === "indexed"; + // INSERT of a fresh (not-yet-indexed) document, or an explicit reindex flag on + // any event, are the only actionable transitions. + const shouldEnqueue = (event.type === "INSERT" && !alreadyIndexed) || reindexRequested; + if (!shouldEnqueue) return skip("no_actionable_transition", { type: event.type, status: record.status ?? null }); + + const ownerId = record.owner_id ?? null; + // Owner scope is the app's tenancy layer; without an owner we cannot scope the + // enqueue write safely, so decline rather than touch an unscoped row. + if (!ownerId) return skip("missing_owner"); + + const supabase = createAdminClient(); + + const safety = await checkIngestionMutationSafety({ + supabase, + documentIds: [record.id], + action: "Reindex", + checkActiveJobs: true, + staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, + }); + if (!safety.ok) { + // Supabase unavailable -> 503 so the webhook is retried; an active/stale job + // already covers this document -> idempotent skip. + if (safety.reason === "supabase_unavailable") { + return NextResponse.json({ error: safety.message, code: "supabase_unavailable" }, { status: 503 }); + } + const cleared = await clearReindexFlagIfRequested(supabase, record.id, ownerId, reindexRequested); + if (reindexRequested && !cleared) return reindexFlagClearFailed(); + return skip("already_active", { safetyReason: safety.reason }); + } + + // Load the authoritative row (owner-scoped) rather than trusting the webhook + // payload for the mutation inputs. + const { data: document, error: documentError } = await supabase + .from("documents") + .select("id,owner_id,status,error_message,page_count,chunk_count,image_count,import_batch_id") + .eq("id", record.id) + .eq("owner_id", ownerId) + .maybeSingle(); + if (documentError) throw new Error(documentError.message); + if (!document) return skip("document_not_found"); + + const result = await enqueueDocumentReindexJob({ supabase, document: document as EnqueueableDocument }); + // Clear the reindex flag once handled so a later UPDATE (including the worker's + // own completion write) cannot retrigger this endlessly. If the clear fails we + // must NOT return 2xx: the flag is still set, so respond 500 to make Supabase + // retry the delivery until the flag is actually cleared. The enqueue is + // idempotent (one-open-job-per-document unique index), so a retry cannot + // double-enqueue — it will take the already_active path and retry the clear. + const cleared = await clearReindexFlagIfRequested(supabase, record.id, ownerId, reindexRequested); + if (reindexRequested && !cleared) return reindexFlagClearFailed(); + + if (result.outcome === "document_deleted") return skip("document_deleted"); + if (result.outcome === "already_active") return skip("already_active"); + + logger.info("Enqueued reindex job from Supabase webhook", { event: event.type }); + return NextResponse.json({ enqueued: true }, { status: 202 }); + } catch (error) { + return jsonError(error); + } +} + +// Returns true when there was nothing to clear or the clear succeeded, false when +// a requested clear failed (the caller then fails the request so Supabase retries). +async function clearReindexFlagIfRequested( + supabase: ReturnType, + documentId: string, + ownerId: string, + reindexRequested: boolean, +): Promise { + if (!reindexRequested) return true; + // Deep-merge {reindex_requested:false} onto documents.metadata. The RPC bumps + // updated_at (firing another UPDATE webhook), but with the flag now false that + // delivery hits "no_actionable_transition" — breaking the loop. + const { error } = await supabase.rpc("apply_document_metadata_patch", { + p_document_id: documentId, + p_metadata_patch: { reindex_requested: false }, + }); + if (error) { + logger.warn("Failed to clear reindex_requested flag", { ownerScoped: Boolean(ownerId) }); + return false; + } + return true; +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 6b3192964..92f5b060e 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -17,6 +17,22 @@ const envSchema = z.object({ SUPABASE_SERVICE_ROLE_KEY: z.string().optional(), SUPABASE_DB_URL: z.string().url().optional(), HEALTH_DEEP_PROBE_SECRET: z.string().min(16).optional(), + // Inbound webhook receivers. Each shared secret gates a machine-to-machine + // endpoint under /api/webhooks/* and fails closed when unset (the route 503s + // rather than trusting an unauthenticated caller). See docs/webhooks.md. + // Railway deploy webhook -> chat forwarder. Railway only lets you configure a + // target URL (no custom headers), so this secret travels as `?token=` in the + // configured URL and is compared constant-time. Min 16 chars. + RAILWAY_WEBHOOK_SECRET: z.string().min(16).optional(), + // Supabase Database Webhook -> ingestion enqueue. Supabase webhooks DO allow + // custom headers, so this secret is sent as `Authorization: Bearer` (or the + // `x-webhook-secret` header) and compared constant-time. Min 16 chars. + SUPABASE_INGESTION_WEBHOOK_SECRET: z.string().min(16).optional(), + // Optional outbound chat destinations shared by every /api/webhooks/* forwarder + // and the CI-failure GitHub workflow. Set either, both, or neither; a receiver + // with no destination configured accepts the event and reports it undelivered. + SLACK_WEBHOOK_URL: z.string().url().optional(), + DISCORD_WEBHOOK_URL: z.string().url().optional(), NEXT_PUBLIC_LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"), LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"), LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(), diff --git a/src/lib/ingestion-enqueue.ts b/src/lib/ingestion-enqueue.ts new file mode 100644 index 000000000..846bab58f --- /dev/null +++ b/src/lib/ingestion-enqueue.ts @@ -0,0 +1,121 @@ +import "server-only"; +import { env } from "@/lib/env"; +import { ingestionRollbackFenceStamp } from "@/lib/ingestion-mutation-safety"; +import { isAtomicReindexCandidate } from "@/lib/reindex-pipeline"; +import type { createAdminClient } from "@/lib/supabase/admin"; +import type { TablesUpdate } from "@/lib/supabase/database.types"; + +type AdminClient = ReturnType; + +// The subset of a `documents` row needed to enqueue a (re)index job and to build +// a rollback payload if the insert loses a race. +export type EnqueueableDocument = { + id: string; + owner_id: string | null; + status?: string | null; + error_message?: string | null; + page_count?: number | null; + chunk_count?: number | null; + image_count?: number | null; + import_batch_id?: string | null; +}; + +export type EnqueueReindexResult = + | { outcome: "enqueued"; job: Record } + | { outcome: "already_active" } + | { outcome: "document_deleted" }; + +// Enqueue a full (re)index job for a single document, mirroring the queue-state +// write in src/app/api/documents/[id]/reindex/route.ts (rollback fence + +// one-open-job-per-document unique index). Kept as a shared primitive so the +// Supabase document-change webhook can reuse the exact concurrency semantics +// without re-deriving them. The reindex ROUTE keeps its own inline copy because +// it also owns HTTP status shaping, rate limiting, and mutation-safety payloads; +// this helper is the machine-webhook path where "already queued" is a benign +// idempotent no-op rather than a 409. +export async function enqueueDocumentReindexJob(args: { + supabase: AdminClient; + document: EnqueueableDocument; +}): Promise { + const { supabase, document } = args; + const ownerId = document.owner_id; + const atomicReindex = isAtomicReindexCandidate(document); + + // Rollback fence: stamp updated_at with a per-request value so a stale rollback + // matches zero rows once an overlapping request re-stamps the row. + const rollbackFence = ingestionRollbackFenceStamp(); + const rollbackDocumentPayload: TablesUpdate<"documents"> = atomicReindex + ? { error_message: document.error_message ?? null } + : { + // documents.status is non-nullable; undefined leaves it unchanged if the + // prior value is somehow absent (real rows always carry a status string). + status: document.status ?? undefined, + error_message: document.error_message ?? null, + page_count: document.page_count ?? 0, + chunk_count: document.chunk_count ?? 0, + image_count: document.image_count ?? 0, + }; + + const queueUpdate = supabase + .from("documents") + .update( + atomicReindex + ? { error_message: null, updated_at: rollbackFence } + : { + status: "queued", + error_message: null, + page_count: 0, + chunk_count: 0, + image_count: 0, + updated_at: rollbackFence, + }, + ) + .eq("id", document.id); + // Owner scope is the app's single tenancy layer (RLS is bypassed by the + // service-role client); scope the write to the owning row when we have it. + const { error: updateError } = await (ownerId ? queueUpdate.eq("owner_id", ownerId) : queueUpdate); + if (updateError) throw new Error(updateError.message); + + const { data: job, error: jobError } = await supabase + .from("ingestion_jobs") + .insert({ + document_id: document.id, + batch_id: document.import_batch_id ?? null, + status: "pending", + stage: "queued", + progress: 0, + max_attempts: env.WORKER_MAX_ATTEMPTS, + }) + .select() + .single(); + + if (!jobError) return { outcome: "enqueued", job: job as Record }; + + // A concurrent transactional delete removed the document; the FK check fails + // with 23503. Normal lifecycle conflict — no surviving state to roll back. + if (jobError.code === "23503") return { outcome: "document_deleted" }; + + // 23505 from the one-open-job-per-document unique index: another path already + // has a pending/processing job for this document. If that job still exists it + // owns the queue state (leave it as-is); only roll back when no competing open + // job remains, matching the reindex route so we never orphan a "queued" row. + if (jobError.code === "23505") { + const { data: competingJobs, error: competingJobsError } = await supabase + .from("ingestion_jobs") + .select("id") + .eq("document_id", document.id) + .in("status", ["pending", "processing"]) + .limit(1); + if (!competingJobsError && (competingJobs?.length ?? 0) === 0) { + const rollback = supabase + .from("documents") + .update(rollbackDocumentPayload) + .eq("id", document.id) + .eq("updated_at", rollbackFence); + await (ownerId ? rollback.eq("owner_id", ownerId) : rollback); + } + return { outcome: "already_active" }; + } + + throw new Error(jobError.message); +} diff --git a/src/lib/webhooks/chat-notify.ts b/src/lib/webhooks/chat-notify.ts new file mode 100644 index 000000000..1b40afdcc --- /dev/null +++ b/src/lib/webhooks/chat-notify.ts @@ -0,0 +1,87 @@ +import "server-only"; +import { env } from "@/lib/env"; +import { logger } from "@/lib/logger"; +import { safeErrorLogDetails } from "@/lib/privacy"; + +// Outbound chat forwarder shared by the /api/webhooks/* receivers. Posts a single +// notification to whichever of Slack / Discord incoming webhooks are configured via +// env (SLACK_WEBHOOK_URL / DISCORD_WEBHOOK_URL). It NEVER throws: a webhook receiver +// must still return 2xx to its provider even when a downstream chat post fails, so +// delivery problems are logged and reported in the return value instead. + +export type ChatSeverity = "info" | "success" | "warning" | "error"; + +export type ChatNotification = { + title: string; + text: string; + severity?: ChatSeverity; + fields?: { label: string; value: string }[]; + // Optional context link (e.g. a Railway deploy or GitHub run URL). + url?: string; +}; + +export type ChatChannelResult = { configured: boolean; ok: boolean; status?: number }; + +export type ChatDeliveryResult = { + delivered: boolean; + slack: ChatChannelResult; + discord: ChatChannelResult; +}; + +const CHAT_POST_TIMEOUT_MS = 5_000; +const DISCORD_CONTENT_LIMIT = 2_000; + +const severityEmoji: Record = { + info: "🔵", + success: "✅", + warning: "⚠️", + error: "🔴", +}; + +function renderPlainMessage(notification: ChatNotification): string { + const severity = notification.severity ?? "info"; + const lines = [`${severityEmoji[severity]} *${notification.title}*`, notification.text]; + for (const field of notification.fields ?? []) { + lines.push(`• *${field.label}:* ${field.value}`); + } + if (notification.url) lines.push(notification.url); + return lines.filter(Boolean).join("\n"); +} + +async function postJson(url: string, body: unknown): Promise { + try { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(CHAT_POST_TIMEOUT_MS), + }); + if (!response.ok) { + logger.warn("Chat webhook delivery failed", { status: response.status }); + } + return { configured: true, ok: response.ok, status: response.status }; + } catch (error) { + logger.warn("Chat webhook delivery errored", { ...safeErrorLogDetails(error) }); + return { configured: true, ok: false }; + } +} + +export async function postChatNotification(notification: ChatNotification): Promise { + const message = renderPlainMessage(notification); + + const slackUrl = env.SLACK_WEBHOOK_URL; + const discordUrl = env.DISCORD_WEBHOOK_URL; + + const [slack, discord] = await Promise.all([ + slackUrl + ? // Slack incoming webhooks render `mrkdwn` in `text`. + postJson(slackUrl, { text: message }) + : Promise.resolve({ configured: false, ok: false }), + discordUrl + ? // Discord webhooks cap `content` at 2000 chars. + postJson(discordUrl, { content: message.slice(0, DISCORD_CONTENT_LIMIT) }) + : Promise.resolve({ configured: false, ok: false }), + ]); + + return { delivered: (slack.configured && slack.ok) || (discord.configured && discord.ok), slack, discord }; +} diff --git a/src/lib/webhooks/secret-auth.ts b/src/lib/webhooks/secret-auth.ts new file mode 100644 index 000000000..7632e90af --- /dev/null +++ b/src/lib/webhooks/secret-auth.ts @@ -0,0 +1,61 @@ +import "server-only"; +import { timingSafeEqual } from "node:crypto"; + +// Shared, constant-time secret gate for inbound machine-to-machine webhooks +// (/api/webhooks/*). Mirrors the byte-length-safe compare used by +// src/lib/deep-probe-auth.ts and supabase/functions/indexing-v3-agent: build the +// UTF-8 buffers first and length-gate before timingSafeEqual, so a crafted +// multi-byte token with the same code-unit count cannot make timingSafeEqual +// throw a RangeError (crafted-header 500). Fails closed on any mismatch. + +export function timingSafeSecretEqual(received: string, expected: string): boolean { + const expectedBytes = Buffer.from(expected, "utf8"); + const receivedBytes = Buffer.from(received, "utf8"); + if (expectedBytes.length !== receivedBytes.length) return false; + return timingSafeEqual(expectedBytes, receivedBytes); +} + +type PresentedSecretOptions = { + // Extra header carrying the raw secret (checked before Authorization: Bearer). + headerName?: string; + // Allow the secret to travel as a `?token=` query parameter. Only enable for + // providers (e.g. Railway) that can configure a target URL but not headers. + allowQueryToken?: boolean; +}; + +export function presentedWebhookSecret(request: Request, options: PresentedSecretOptions = {}): string { + const headerName = options.headerName ?? "x-webhook-secret"; + const headerSecret = request.headers.get(headerName); + if (headerSecret) return headerSecret.trim(); + + const authorization = request.headers.get("authorization") ?? ""; + const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim(); + if (bearer) return bearer; + + if (options.allowQueryToken) { + try { + const token = new URL(request.url).searchParams.get("token"); + if (token) return token.trim(); + } catch { + // Malformed request URL — treat as no presented secret. + } + } + return ""; +} + +export type WebhookAuthResult = + | { ok: true } + // `misconfigured` = the server has no secret set, so the endpoint must fail + // closed with 503 (not 401): there is nothing to authenticate against yet. + | { ok: false; reason: "misconfigured" | "unauthorized" }; + +export function verifyWebhookSecret( + request: Request, + expected: string | undefined, + options: PresentedSecretOptions = {}, +): WebhookAuthResult { + if (!expected) return { ok: false, reason: "misconfigured" }; + const presented = presentedWebhookSecret(request, options); + if (!presented) return { ok: false, reason: "unauthorized" }; + return timingSafeSecretEqual(presented, expected) ? { ok: true } : { ok: false, reason: "unauthorized" }; +} diff --git a/tests/ingestion-enqueue.test.ts b/tests/ingestion-enqueue.test.ts new file mode 100644 index 000000000..c4f0944d2 --- /dev/null +++ b/tests/ingestion-enqueue.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const documentId = "44444444-4444-4444-8444-444444444444"; +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); +}); + +// Minimal chainable Supabase mock covering exactly the calls enqueueDocumentReindexJob makes: +// documents.update(...).eq(...).eq(...) -> awaited { error } +// ingestion_jobs.insert(...).select().single() -> { data, error } +// ingestion_jobs.select("id").eq(...).in(...).limit(1) -> { data, error } (23505 path) +// documents.update(...).eq(...).eq(...).eq(...) -> awaited { error } (rollback) +type ClientConfig = { + jobInsert?: { data: unknown; error: { code?: string; message: string } | null }; + competing?: { data: unknown[]; error: null }; + updateError?: { message: string }; +}; + +function makeClient(cfg: ClientConfig) { + // eqCalls records the column names passed to .eq(), so tests can assert that + // both document writes (queue update + rollback) are owner-scoped. + const calls = { documentUpdates: 0, competingSelects: 0, eqCalls: [] as string[] }; + function builder(table: string) { + const b: Record = {}; + b.update = () => { + if (table === "documents") calls.documentUpdates += 1; + return b; + }; + b.insert = () => b; + b.select = () => b; + b.eq = (col: string) => { + calls.eqCalls.push(col); + return b; + }; + b.in = () => b; + b.limit = () => { + calls.competingSelects += 1; + return Promise.resolve(cfg.competing ?? { data: [], error: null }); + }; + b.single = () => Promise.resolve(cfg.jobInsert ?? { data: { id: "job1" }, error: null }); + // Awaiting a documents.update chain resolves here. + b.then = (resolve: (v: unknown) => void, reject?: (e: unknown) => void) => + Promise.resolve({ error: cfg.updateError ?? null }).then(resolve, reject); + return b; + } + return { client: { from: (t: string) => builder(t) }, calls }; +} + +async function load(cfg: ClientConfig = {}) { + vi.doMock("@/lib/env", () => ({ env: { WORKER_MAX_ATTEMPTS: 3 } })); + const { enqueueDocumentReindexJob } = await import("../src/lib/ingestion-enqueue"); + const { client, calls } = makeClient(cfg); + return { enqueueDocumentReindexJob, client, calls }; +} + +const doc = { id: documentId, owner_id: ownerId, status: "queued", import_batch_id: null }; + +describe("enqueueDocumentReindexJob", () => { + it("returns enqueued with the inserted job on success and owner-scopes the write", async () => { + const { enqueueDocumentReindexJob, client, calls } = await load(); + const result = await enqueueDocumentReindexJob({ supabase: client as never, document: doc }); + expect(result).toEqual({ outcome: "enqueued", job: { id: "job1" } }); + // The queue-state update must be scoped by owner_id (the app's tenancy layer). + expect(calls.eqCalls).toContain("owner_id"); + }); + + it("throws when the initial document update fails", async () => { + const { enqueueDocumentReindexJob, client } = await load({ updateError: { message: "update failed" } }); + await expect(enqueueDocumentReindexJob({ supabase: client as never, document: doc })).rejects.toThrow( + "update failed", + ); + }); + + it("returns document_deleted on a 23503 FK violation", async () => { + const { enqueueDocumentReindexJob, client } = await load({ + jobInsert: { data: null, error: { code: "23503", message: "fk" } }, + }); + const result = await enqueueDocumentReindexJob({ supabase: client as never, document: doc }); + expect(result).toEqual({ outcome: "document_deleted" }); + }); + + it("returns already_active without rolling back when a competing open job exists (23505)", async () => { + const { enqueueDocumentReindexJob, client, calls } = await load({ + jobInsert: { data: null, error: { code: "23505", message: "dup" } }, + competing: { data: [{ id: "other-job" }], error: null }, + }); + const result = await enqueueDocumentReindexJob({ supabase: client as never, document: doc }); + expect(result).toEqual({ outcome: "already_active" }); + // Only the initial queue-state update ran; no rollback update. + expect(calls.documentUpdates).toBe(1); + expect(calls.competingSelects).toBe(1); + }); + + it("rolls back the (owner-scoped) queue-state write when a 23505 leaves no competing open job", async () => { + const { enqueueDocumentReindexJob, client, calls } = await load({ + jobInsert: { data: null, error: { code: "23505", message: "dup" } }, + competing: { data: [], error: null }, + }); + const result = await enqueueDocumentReindexJob({ supabase: client as never, document: doc }); + expect(result).toEqual({ outcome: "already_active" }); + // Initial queue update + the compensating rollback update, both owner-scoped. + expect(calls.documentUpdates).toBe(2); + expect(calls.eqCalls.filter((col) => col === "owner_id").length).toBeGreaterThanOrEqual(2); + }); + + it("throws on an unexpected job-insert error", async () => { + const { enqueueDocumentReindexJob, client } = await load({ + jobInsert: { data: null, error: { code: "42P01", message: "boom" } }, + }); + await expect(enqueueDocumentReindexJob({ supabase: client as never, document: doc })).rejects.toThrow("boom"); + }); +}); diff --git a/tests/webhooks-chat-notify.test.ts b/tests/webhooks-chat-notify.test.ts new file mode 100644 index 000000000..763488dcc --- /dev/null +++ b/tests/webhooks-chat-notify.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); +}); + +async function loadWithEnv(envOverrides: Record) { + vi.doMock("@/lib/env", () => ({ env: envOverrides })); + return import("../src/lib/webhooks/chat-notify"); +} + +describe("postChatNotification", () => { + it("posts to both Slack and Discord when configured", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const { postChatNotification } = await loadWithEnv({ + SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/x", + DISCORD_WEBHOOK_URL: "https://discord.com/api/webhooks/x", + }); + + const result = await postChatNotification({ title: "Hi", text: "body", severity: "error" }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const callInit = (index: number) => (fetchMock.mock.calls[index] as unknown as [string, RequestInit])[1]; + const slackBody = JSON.parse(String(callInit(0).body)); + const discordBody = JSON.parse(String(callInit(1).body)); + expect(slackBody).toHaveProperty("text"); + expect(discordBody).toHaveProperty("content"); + expect(slackBody.text).toContain("Hi"); + expect(result.delivered).toBe(true); + expect(result.slack.configured).toBe(true); + expect(result.discord.configured).toBe(true); + }); + + it("reports undelivered and does not fetch when nothing is configured", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const { postChatNotification } = await loadWithEnv({}); + + const result = await postChatNotification({ title: "Hi", text: "body" }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.delivered).toBe(false); + expect(result.slack.configured).toBe(false); + }); + + it("never throws when a channel post fails", async () => { + const fetchMock = vi.fn(async () => { + throw new Error("network down"); + }); + vi.stubGlobal("fetch", fetchMock); + const { postChatNotification } = await loadWithEnv({ SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/x" }); + + const result = await postChatNotification({ title: "Hi", text: "body" }); + + expect(result.delivered).toBe(false); + expect(result.slack).toEqual({ configured: true, ok: false }); + }); +}); diff --git a/tests/webhooks-railway-route.test.ts b/tests/webhooks-railway-route.test.ts new file mode 100644 index 000000000..64bcac3ad --- /dev/null +++ b/tests/webhooks-railway-route.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const SECRET = "railway-webhook-secret-value-123"; + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); +}); + +async function loadRoute(envOverrides: Record) { + const postChatNotification = vi.fn(async () => ({ + delivered: true, + slack: { configured: true, ok: true, status: 200 }, + discord: { configured: false, ok: false }, + })); + vi.doMock("@/lib/env", () => ({ env: envOverrides })); + vi.doMock("@/lib/webhooks/chat-notify", () => ({ postChatNotification })); + const route = await import("../src/app/api/webhooks/railway/route"); + return { route, postChatNotification }; +} + +function post(url: string, body: unknown) { + return new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/webhooks/railway", () => { + it("returns 503 when the receiver secret is unset", async () => { + const { route } = await loadRoute({}); + const response = await route.POST(post("http://localhost/api/webhooks/railway", { status: "SUCCESS" })); + expect(response.status).toBe(503); + }); + + it("returns 401 on a bad token", async () => { + const { route } = await loadRoute({ RAILWAY_WEBHOOK_SECRET: SECRET }); + const response = await route.POST(post("http://localhost/api/webhooks/railway?token=wrong", { status: "SUCCESS" })); + expect(response.status).toBe(401); + }); + + it("skips transient statuses without notifying", async () => { + const { route, postChatNotification } = await loadRoute({ RAILWAY_WEBHOOK_SECRET: SECRET }); + const response = await route.POST( + post(`http://localhost/api/webhooks/railway?token=${SECRET}`, { status: "BUILDING" }), + ); + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.skipped).toBe(true); + expect(postChatNotification).not.toHaveBeenCalled(); + }); + + it("forwards a notable deploy status to chat", async () => { + const { route, postChatNotification } = await loadRoute({ RAILWAY_WEBHOOK_SECRET: SECRET }); + const response = await route.POST( + post(`http://localhost/api/webhooks/railway?token=${SECRET}`, { + type: "DEPLOY", + status: "FAILED", + project: { name: "Database" }, + environment: { name: "production" }, + service: { name: "worker" }, + }), + ); + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.forwarded).toBe(true); + expect(postChatNotification).toHaveBeenCalledTimes(1); + const notification = (postChatNotification.mock.calls[0] as unknown as [{ severity: string; title: string }])[0]; + expect(notification.severity).toBe("error"); + expect(notification.title).toContain("worker"); + }); +}); diff --git a/tests/webhooks-secret-auth.test.ts b/tests/webhooks-secret-auth.test.ts new file mode 100644 index 000000000..d446d4e72 --- /dev/null +++ b/tests/webhooks-secret-auth.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { presentedWebhookSecret, timingSafeSecretEqual, verifyWebhookSecret } from "../src/lib/webhooks/secret-auth"; + +const SECRET = "a-sufficiently-long-secret-value"; + +function req(init: { headers?: Record; url?: string } = {}) { + return new Request(init.url ?? "http://localhost/api/webhooks/test", { headers: init.headers ?? {} }); +} + +describe("timingSafeSecretEqual", () => { + it("returns true only for an exact match", () => { + expect(timingSafeSecretEqual(SECRET, SECRET)).toBe(true); + expect(timingSafeSecretEqual(SECRET, `${SECRET}x`)).toBe(false); + expect(timingSafeSecretEqual("", SECRET)).toBe(false); + }); + + it("does not throw on multi-byte length mismatches", () => { + // "é" is 2 UTF-8 bytes but 1 UTF-16 code unit; a naive .length gate would let + // this reach timingSafeEqual with mismatched buffers and throw. + expect(() => timingSafeSecretEqual("é", "ab")).not.toThrow(); + expect(timingSafeSecretEqual("é", "ab")).toBe(false); + }); +}); + +describe("presentedWebhookSecret", () => { + it("prefers the custom header, then Bearer, then optional query token", () => { + expect(presentedWebhookSecret(req({ headers: { "x-webhook-secret": "h" } }))).toBe("h"); + expect(presentedWebhookSecret(req({ headers: { authorization: "Bearer b" } }))).toBe("b"); + expect( + presentedWebhookSecret(req({ url: "http://localhost/api/webhooks/test?token=q" }), { allowQueryToken: true }), + ).toBe("q"); + }); + + it("ignores the query token unless explicitly allowed", () => { + expect(presentedWebhookSecret(req({ url: "http://localhost/api/webhooks/test?token=q" }))).toBe(""); + }); +}); + +describe("verifyWebhookSecret", () => { + it("fails closed as misconfigured when no secret is set", () => { + expect(verifyWebhookSecret(req(), undefined)).toEqual({ ok: false, reason: "misconfigured" }); + }); + + it("is unauthorized when nothing is presented or the value is wrong", () => { + expect(verifyWebhookSecret(req(), SECRET)).toEqual({ ok: false, reason: "unauthorized" }); + expect(verifyWebhookSecret(req({ headers: { "x-webhook-secret": "nope" } }), SECRET)).toEqual({ + ok: false, + reason: "unauthorized", + }); + }); + + it("authorizes a correct header, Bearer, or allowed query token", () => { + expect(verifyWebhookSecret(req({ headers: { "x-webhook-secret": SECRET } }), SECRET)).toEqual({ ok: true }); + expect(verifyWebhookSecret(req({ headers: { authorization: `Bearer ${SECRET}` } }), SECRET)).toEqual({ ok: true }); + expect( + verifyWebhookSecret(req({ url: `http://localhost/api/webhooks/test?token=${SECRET}` }), SECRET, { + allowQueryToken: true, + }), + ).toEqual({ ok: true }); + }); +}); diff --git a/tests/webhooks-supabase-document-change-route.test.ts b/tests/webhooks-supabase-document-change-route.test.ts new file mode 100644 index 000000000..fb3d80ab4 --- /dev/null +++ b/tests/webhooks-supabase-document-change-route.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const SECRET = "supabase-ingest-webhook-secret-1"; +const documentId = "44444444-4444-4444-8444-444444444444"; +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); +}); + +type LoadOptions = { + env?: Record; + demo?: boolean; + safety?: unknown; + document?: unknown; + enqueueResult?: unknown; + rpcError?: { message: string }; +}; + +async function loadRoute(options: LoadOptions = {}) { + const rpcMock = vi.fn(async () => ({ error: options.rpcError ?? null })); + const maybeSingle = vi.fn(async () => ({ + data: options.document ?? { id: documentId, owner_id: ownerId, status: "queued", import_batch_id: null }, + error: null, + })); + const chain: Record = {}; + chain.select = vi.fn(() => chain); + chain.eq = vi.fn(() => chain); + chain.maybeSingle = maybeSingle; + const client = { from: vi.fn(() => chain), rpc: rpcMock }; + + const checkIngestionMutationSafety = vi.fn(async () => options.safety ?? { ok: true }); + const enqueueDocumentReindexJob = vi.fn(async () => options.enqueueResult ?? { outcome: "enqueued", job: {} }); + + vi.doMock("@/lib/env", () => ({ + env: { SUPABASE_INGESTION_WEBHOOK_SECRET: SECRET, WORKER_STALE_AFTER_MINUTES: 45, ...(options.env ?? {}) }, + isDemoMode: () => options.demo ?? false, + })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client })); + vi.doMock("@/lib/ingestion-mutation-safety", () => ({ checkIngestionMutationSafety })); + vi.doMock("@/lib/ingestion-enqueue", () => ({ enqueueDocumentReindexJob })); + + const route = await import("../src/app/api/webhooks/supabase/document-change/route"); + return { route, rpcMock, checkIngestionMutationSafety, enqueueDocumentReindexJob }; +} + +function post(body: unknown, headers: Record = { authorization: `Bearer ${SECRET}` }) { + return new Request("http://localhost/api/webhooks/supabase/document-change", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +const insertEvent = (record: Record) => ({ type: "INSERT", table: "documents", record }); + +describe("POST /api/webhooks/supabase/document-change", () => { + it("returns 503 when the secret is unset", async () => { + const { route } = await loadRoute({ env: { SUPABASE_INGESTION_WEBHOOK_SECRET: undefined } }); + const response = await route.POST(post(insertEvent({ id: documentId, owner_id: ownerId }))); + expect(response.status).toBe(503); + }); + + it("returns 401 on a bad secret", async () => { + const { route } = await loadRoute(); + const response = await route.POST( + post(insertEvent({ id: documentId, owner_id: ownerId }), { authorization: "Bearer wrong" }), + ); + expect(response.status).toBe(401); + }); + + it("skips in demo mode", async () => { + const { route, enqueueDocumentReindexJob } = await loadRoute({ demo: true }); + const response = await route.POST(post(insertEvent({ id: documentId, owner_id: ownerId }))); + const body = await response.json(); + expect(body.skipped).toBe(true); + expect(enqueueDocumentReindexJob).not.toHaveBeenCalled(); + }); + + it("skips a plain UPDATE with no reindex flag (loop-safety)", async () => { + const { route, enqueueDocumentReindexJob } = await loadRoute(); + const response = await route.POST( + post({ type: "UPDATE", table: "documents", record: { id: documentId, owner_id: ownerId, status: "indexed" } }), + ); + const body = await response.json(); + expect(body.reason).toBe("no_actionable_transition"); + expect(enqueueDocumentReindexJob).not.toHaveBeenCalled(); + }); + + it("skips DELETE and non-documents tables", async () => { + const { route } = await loadRoute(); + const del = await route.POST(post({ type: "DELETE", table: "documents", old_record: { id: documentId } })); + expect((await del.json()).reason).toBe("delete_event"); + const other = await route.POST(post({ type: "INSERT", table: "rag_queries", record: { id: documentId } })); + expect((await other.json()).reason).toBe("not_documents_table"); + }); + + it("enqueues a reindex job on a fresh INSERT", async () => { + const { route, enqueueDocumentReindexJob } = await loadRoute(); + const response = await route.POST(post(insertEvent({ id: documentId, owner_id: ownerId, status: "queued" }))); + const body = await response.json(); + expect(response.status).toBe(202); + expect(body.enqueued).toBe(true); + expect(enqueueDocumentReindexJob).toHaveBeenCalledTimes(1); + }); + + it("acts on an explicit reindex flag and clears it", async () => { + const { route, rpcMock, enqueueDocumentReindexJob } = await loadRoute({ + document: { id: documentId, owner_id: ownerId, status: "indexed", import_batch_id: null }, + }); + const response = await route.POST( + post({ + type: "UPDATE", + table: "documents", + record: { id: documentId, owner_id: ownerId, status: "indexed", metadata: { reindex_requested: true } }, + }), + ); + expect(response.status).toBe(202); + expect(enqueueDocumentReindexJob).toHaveBeenCalledTimes(1); + expect(rpcMock).toHaveBeenCalledWith("apply_document_metadata_patch", { + p_document_id: documentId, + p_metadata_patch: { reindex_requested: false }, + }); + }); + + it("returns 500 when a requested reindex-flag clear fails so Supabase retries", async () => { + const { route } = await loadRoute({ + document: { id: documentId, owner_id: ownerId, status: "indexed", import_batch_id: null }, + rpcError: { message: "rpc down" }, + }); + const response = await route.POST( + post({ + type: "UPDATE", + table: "documents", + record: { id: documentId, owner_id: ownerId, status: "indexed", metadata: { reindex_requested: true } }, + }), + ); + const body = await response.json(); + expect(response.status).toBe(500); + expect(body.code).toBe("reindex_flag_clear_failed"); + }); + + it("treats an active job as an idempotent skip without enqueueing", async () => { + const { route, enqueueDocumentReindexJob } = await loadRoute({ + safety: { + ok: false, + status: 409, + reason: "active_jobs", + message: "busy", + activeJobs: [], + staleProcessingJobs: [], + }, + }); + const response = await route.POST(post(insertEvent({ id: documentId, owner_id: ownerId, status: "queued" }))); + const body = await response.json(); + expect(body.reason).toBe("already_active"); + expect(enqueueDocumentReindexJob).not.toHaveBeenCalled(); + }); + + it("returns 503 when Supabase is unavailable so the webhook retries", async () => { + const { route } = await loadRoute({ + safety: { + ok: false, + status: 503, + reason: "supabase_unavailable", + message: "down", + activeJobs: [], + staleProcessingJobs: [], + }, + }); + const response = await route.POST(post(insertEvent({ id: documentId, owner_id: ownerId, status: "queued" }))); + expect(response.status).toBe(503); + }); +});