Skip to main content

API surface

Two surfaces sit between a Railway client and the data: the Next.js API routes the app itself exposes under /api/*, and the PostgREST surface the app reads from and writes to over HTTP. This page documents both, and points at the live OpenAPI doc for the authoritative PostgREST shape.

Why two surfaces

The Next.js routes exist for things PostgREST can't do safely from a browser: minting and sealing session cookies, validating the registration Origin/Referer header, running the honeypot check, and the public submit (which goes through a single RPC that wraps the multi-table write in a transaction).

Everything else — reading content, listing registrations, the admin grids — is the Next server-side calling PostgREST directly with the staff JWT from the session cookie. Those calls don't go through the Next /api/* surface; they go through the PostgREST surface listed below.

Next.js API routes

All routes live under src/app/api/. Each is one route.ts file exporting the methods listed below.

MethodPathAuthWhat it does
GET/api/healthnoneLiveness probe. Calls PostgREST RPC app_log_alert_count; returns { ok, alerts } or 502/503 if PostgREST is unreachable.
POST/api/registrationsnone (origin-gated)Public registration submit. Validates Origin/Referer against PRIMARY_SITE_URL, runs the honeypot, proxies to PostgREST RPC submit_registration.
POST/api/admin/loginpassword or JWTStaff login. Accepts { password } (matched against ADMIN_PASSWORD, then a broad bootstrap JWT is minted with JWT_SECRET) or { staffJwt } (verified against JWT_SECRET). On success: sets the ADMIN_SESSION_COOKIE.
POST/api/admin/login/dummynone (dev tool)Dummy login picker. Accepts { profileId } (one of the entries in src/lib/dummy-login-roles.ts), mints a JWT with the profile's capabilities via JWT_SECRET, sets the session cookie. Used by /admin/login.
POST/api/admin/logoutsession cookieClears ADMIN_SESSION_COOKIE. Returns { ok: true }.
GET/api/admin/bootstrap-sessionenv-controlledDev / explicit opt-in only. Reads POSTGREST_ADMIN_JWT or POSTGREST_STAFF_JWT_UIS from the environment, verifies it, sets it as the session cookie, redirects to /admin. Enabled when NODE_ENV=development or ADMIN_BOOTSTRAP_SESSION_FROM_ENV=1. Returns 404 otherwise.

All admin auth paths converge on one cookie: ADMIN_SESSION_COOKIE (HttpOnly, SameSite=Lax, Secure in prod), whose value is the staff JWT itself. The same JWT is used to call PostgREST from server-side React. There's no separate session store.

Public submit (/api/registrations) — gates

In order:

  1. Content-Type: application/json check (415 otherwise).
  2. validateRegistrationPostOriginOrigin/Referer must match PRIMARY_SITE_URL, and Sec-Fetch-Site: cross-site is rejected unless REGISTRATION_RELAX_FETCH_METADATA=1.
  3. JSON parse (400 on failure).
  4. Honeypot field check — non-empty value logs log_event with category: 'honeypot' and 200s without persisting the row.
  5. Pass-through to PostgREST RPC submit_registration.

Mapping PostgREST error codes back to user-facing messages happens in src/lib/public-form/errors.ts.

PostgREST surface

The Next app talks to a single PostgREST instance over HTTP. Configuration:

  • URL: POSTGREST_URL (server-side only; never exposed to the browser).
  • Schema: railway (sent via Accept-Profile: railway header by src/lib/postgrest.ts).
  • Auth: HS256 JWTs signed with JWT_SECRET. Anon path uses POSTGREST_ANON_JWT; staff path uses the session cookie's JWT (or POSTGREST_ADMIN_JWT / POSTGREST_STAFF_JWT_UIS as fallback).
  • Capability gating: each table/RPC has RLS that calls railway.has_capability(<cap>) against the capabilities array in the JWT. See PostgreSQL roles for the model.

Live OpenAPI doc

PostgREST publishes its own OpenAPI spec at the root of its URL. Open it in a browser for the authoritative, always-current list of tables, views, columns, and RPCs:

Local dev (Traefik): http://api-railway.localhost/ Public (Tailscale Funnel): https://railway-postgrest.dog-pence.ts.net/

A 60 KB JSON blob; load it in Swagger UI or a JSON viewer for navigation. This is the source of truth — the table below is a hand-curated subset of what the Next app actually uses, not an exhaustive PostgREST inventory.

Tables and views the Next app reads

All under the railway schema. Capability requirement is what the RLS policy gates against; "anon" means the table is readable without a staff JWT (still subject to row filters).

Table / viewUsed forCapability
activitiesWizard step "Aktiviteter" + admin /admin/activitiescontent:read (admin); anon (public form)
activity_categoriesAdmin /admin/activity-categoriescontent:read
activity_settingsAdmin /admin/activity-settingscontent:read
app_logAdmin /admin/app-logapp_log:read
evaluation_optionsWizard + admin /admin/eval-optionscontent:read; anon (public form)
evaluation_questionsWizard + admin /admin/eval-questionscontent:read; anon (public form)
membership_optionsWizard + admin /admin/membership-optionscontent:read; anon (public form)
membership_statusesAdmin /admin/membership-statusescontent:read
no_selected_activity_optionsWizard fallback + admin /admin/no-selected-optionscontent:read; anon (public form)
public_form_payloadView — single payload the public wizard hydrates fromanon
registration_activitiesAdmin registration detailregistrations:read
registrationsAdmin /admin/registrations + detailregistrations:read
text_contentAdmin /admin/text-content + public form headerscontent:read; anon (public form)
user_languagesAdmin /admin/languagescontent:read

The Next app does not currently write through any table — all writes go through RPCs (next section). When admin "write" surfaces land (currently scaffolded but not all wired up), they'll touch the corresponding table with *:write capability checked by RLS.

RPCs the Next app calls

PostgreSQL functions in the railway schema, exposed by PostgREST as POST /rpc/<name>. Capability requirement is enforced inside the function body via auth.has_capability(...).

RPCCalled fromWhat it doesCapability
submit_registration/api/registrationsWraps the multi-table public submit in one transaction. Inserts into registrations, then registration_activities, then writes the evaluation answers. Returns the registration id or a PostgREST error code surfaced by public-form/errors.ts.anon
log_event/api/registrations (honeypot path) + ad-hoc audit callsInserts a row into app_log. Used by the honeypot path with category: 'honeypot'.anon for honeypot path; app_log:write otherwise
app_log_alert_count/api/healthReturns the count of unacknowledged alerts in app_log. Used as the liveness probe.anon (denies authenticated — see INVESTIGATE-app-log-alert-count-permission.md for the open question on whether to widen this).

Calling PostgREST directly

For local debugging or scripting against the cluster from outside the Next app:

# Anon read (e.g. introspection)
curl "$POSTGREST_URL/" -H "Accept-Profile: railway"

# Staff read of registrations (needs a JWT with registrations:read)
curl -H "Authorization: Bearer $POSTGREST_STAFF_JWT_UIS" \
-H "Accept-Profile: railway" \
"$POSTGREST_URL/registrations?select=id,created_at&limit=10"

# Public submit RPC (anon)
curl -X POST \
-H "Accept-Profile: railway" \
-H "Content-Type: application/json" \
-d '{ "p_payload": { ... } }' \
"$POSTGREST_URL/rpc/submit_registration"

JWTs are HS256 with aud: railway. To mint one locally that matches the cluster's JWT_SECRET, run node scripts/mint-staff-jwt.mjs.