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.
| Function | Caller | Guard | Talks to |
|---|---|---|---|
notify-slack | DB triggers via post_to_edge_function | validateServiceRole | Slack incoming webhook |
fx | pg_cron, daily 17:00 UTC | validateServiceRole | Frankfurter (ECB rates), fx.currency_rates |
proxy | Mobile app, signed-in user | AuthMiddleware (any valid JWT) | Google Places, Address Validation |
submit-feedback | Mobile app, signed-in user | verify_jwt = true and AuthMiddleware | Slack #user-feedback |
clickup-sprint-guard | ClickUp webhook | HMAC-SHA256 over raw body | ClickUp 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.
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
- 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"}' - Run
cd src && deno test --allow-all supabase/tests/functions/fx-refresh-rates-test.tsand find the assertion that checks the rate inversion. - Grep for every hand-rolled
net.http_postthat skipspost_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.
Next lesson: 17, Testing: pgTAP for the database, Deno tests for functions, the CI filter that decides which run without a stack, and the legacy Node suite.
Ask your teacher: "which of these five should I copy when I add a new internal function?" is the right question to end on.