Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions .github/workflows/notify-ci-failure.yml
Original file line number Diff line number Diff line change
@@ -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/'))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
18 changes: 10 additions & 8 deletions docs/codebase-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |

---

Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/site-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
98 changes: 98 additions & 0 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
@@ -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=<RAILWAY_WEBHOOK_SECRET>
```

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 <secret>` (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 <SUPABASE_INGESTION_WEBHOOK_SECRET>`

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.
2 changes: 2 additions & 0 deletions scripts/generate-site-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ const apiDescriptions: Record<string, string> = {
"/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 = [
Expand Down
110 changes: 110 additions & 0 deletions src/app/api/webhooks/railway/route.ts
Original file line number Diff line number Diff line change
@@ -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<typeof railwayWebhookSchema>): 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);
}
}
Loading