Internal utilities

Lesson 16 · about 15 minutes · five small functions, five different answers to "who is allowed to call this?"

These functions are each under a few hundred lines, which makes them the fastest way to see the repo's auth vocabulary in full. Every one of them faces a different caller, and the guard it uses follows directly from that. Read them as a matrix, not as five unrelated files.

FunctionCallerGuardTalks to
notify-slackDB triggers via post_to_edge_functionvalidateServiceRoleSlack incoming webhook
fxpg_cron, daily 17:00 UTCvalidateServiceRoleFrankfurter (ECB rates), fx.currency_rates
proxyMobile app, signed-in userAuthMiddleware (any valid JWT)Google Places, Address Validation
submit-feedbackMobile app, signed-in userverify_jwt = true and AuthMiddlewareSlack #user-feedback
clickup-sprint-guardClickUp webhookHMAC-SHA256 over raw bodyClickUp API

notify-slack: a relay that exists because of an env var

Postgres cannot read edge function environment variables. The Slack webhook URL lives in SLACK_ALERT_WEBHOOK_URL, so a trigger that wants to alert has two options: copy the URL into Vault, or go through a function that already has it. The relay was chosen so there is one place to rotate the URL notify-slack/index.ts:8-11. The handler is a checklist of status codes: 404 for any other path, 405 non-POST, 401 without service role, 400 for missing or oversized text, 502 when Slack rejects, and 503 when the env var is unset notify-slack/index.ts:29-73.

Why 503 instead of a quiet 200 when the URL is missing? The previous design read a Vault secret that was never created and returned nothing, so the draft alert was silent for two months and nobody knew. A 503 shows up in net._http_response, which is the ledger a cron or trigger caller can be checked against. Failing loudly at the boundary is cheaper than discovering silence later notify-slack/index.ts:56-58 · migrations/20260925104500:1-4.

The trigger side wraps the call in an exception block: the article insert is the point, the alert is not migrations/20260925104500_route_slack_alerts_via_edge_function.sql:19-33.

fx: a cron job that was silently broken for six weeks

The handler fetches USD-based rates, inverts them to "1 unit of currency = N USD", and upserts into fx.currency_rates keyed by currency fx/index.ts:56-83. The leaderboard's USD conversion calls fx.to_usd(amount, currency), which returns NULL for an unknown currency so the row drops out of the sum rather than being counted wrong migrations/20260506161203:31-44 · migrations/20260506161204:24-27.

The first schedule migration used current_setting('app.supabase_url'), which raises "unrecognized configuration parameter" at fire time. The job existed, ran, and failed every day. The fix migration re-issues cron.schedule with the same name, which upserts, and reads from Vault like every other job migrations/20260506163139 · migrations/20260616120000:1-8.

The fx cron still hand-rolls net.http_post without an Origin header. It works only because fx/index.ts does not call validateCorsOrigin. Add that gate and the cron breaks; route it through post_to_edge_function first.

proxy: hide a vendor key behind a user JWT

Google API keys cannot ship in a mobile binary. The proxy checks only that the caller has any valid Supabase JWT via AuthMiddleware, then forwards to places/autocomplete, places/details or addressvalidation/validate. Routing takes the last two path segments, the only router in the repo that does proxy/index.ts:19-32. Vendor errors are logged in full server-side but returned as a generic message with the vendor's status code proxy/handlers/autocomplete.ts:42-48.

submit-feedback: the odd one out in config.toml

This is the only function with verify_jwt = true and its own import_map config.toml [functions.submit-feedback]. Its deno.json maps @2toad/profanity to an npm: specifier so the handler can use a bare import. The processing order matters: length check, strip HTML and escape Slack mrkdwn characters, then censor profanity, because the censor must run on already-safe text submit-feedback/handlers/iOSAppFeedback.ts:17-22,52-58 · submit-feedback/utils/sanitize.ts:31-40. Double gating (platform JWT check plus AuthMiddleware) is redundant but harmless; the second one lets getUserId attribute the feedback.

clickup-sprint-guard: a webhook with a side effect

ClickUp signs each webhook with HMAC-SHA256 over the raw body, hex-encoded in X-Signature. The function reads req.text() first, verifies with crypto.subtle, and only then parses JSON clickup-sprint-guard/index.ts:63-77 · security/signatureVerification.ts:21-50. A task moving into "ready for testing", "in review", "awaiting deployment" or "complete" without sprint points is reverted to "in progress" and a comment tags the assignees handlers/enforceSprintPoints.ts:20-30. It always returns 200 after verification so ClickUp does not retry and re-trigger the revert. Note it still uses the old serve from deno.land/std@0.168.0 rather than Deno.serve; both work, and this is the last function on the old form.

Check yourself

Do this in the repo

  1. With the stack served, call the relay without a key and then with the local service key. Predict both codes first:
    curl -i -X POST http://localhost:54321/functions/v1/notify-slack -H 'Content-Type: application/json' -d '{"text":"hi"}'
    curl -i -X POST http://localhost:54321/functions/v1/notify-slack -H "apikey: $SERVICE_ROLE_KEY" -H 'Content-Type: application/json' -d '{"text":"hi"}'
  2. Run cd src && deno test --allow-all supabase/tests/functions/fx-refresh-rates-test.ts and find the assertion that checks the rate inversion.
  3. Grep for every hand-rolled net.http_post that skips post_to_edge_function: grep -ln 'net.http_post' src/supabase/migrations/*.sql | xargs grep -L post_to_edge_function. Each hit is a future ticket.

Primary source

Supabase: pg_net explains the async request queue and the _http_response table that makes the 503 visible. For the HMAC pattern, ClickUp: Webhook signature.