Appearance
Shared Kit (_shared)
Every Edge Function is built from the same reusable utilities in supabase/functions/_shared/. Eighteen modules, with unit tests co-located in _shared/tests/ — ten of them: auth, cardCache, cors, errors, internalAuth, observability, r2, razorpay, response, webhook.
@supabase/supabase-js is pinned to 2.39.7 in the Edge Functions (unanimous across all 18 import sites, verified 2026-08-12). ⚠ Do not confuse this with the frontend pin, which is a different number on purpose: the npm dependency the apps use is pinned separately to 2.30.0. This page previously stated 2.30.0 as the pin, which is the frontend's — an EF written against it would disagree with every other function in the tree.
Modules
| File | Key exports | Use |
|---|---|---|
cors.ts | corsHeaders, handleCors(req) | First line of every EF: returns a preflight Response or null. |
auth.ts | requireAuth(req), requireAdmin(req), optionalAuth(req), AuthResult, OptionalAuthResult | Resolve { user, serviceClient }; enforce JWT / admin role. |
errors.ts | AppError + ValidationError, AuthError, ForbiddenError, NotFoundError, ConflictError, RateLimitError, UnprocessableError, PartialSuccessError, DatabaseError, TimeoutError | Typed, status-mapped errors. Throw these; err() maps them. |
response.ts | ok(data, init?), err(functionName, error) | Uniform JSON responses; err() produces safe messages + structured logs. |
logging.ts | Logger, LoggerConfig | Structured JSON logs with an event field on INFO/WARN. |
error-handler.ts | isRetryableError, formatErrorResponse, logError | Correlation-id error formatting + retry classification (Type B). |
database.ts | getSupabaseClient, insertLogEntry, insertCronExecution, insertCacheOperation, getActiveCronJobs, batchInsert, batchUpdate, getJobByName | Ops-table helpers used by public_page_ops_* Type B functions. |
cloudflare.ts | cloudflare (purge API), PurgeResult | Cloudflare cache purge/invalidation from EFs. |
config-cache.ts | configCache, FeatureConfig | In-memory feature-config cache for hot paths. |
retry.ts | retryWithBackoff<T>(...) | Exponential-backoff retry for external calls (Type B / webhooks). |
webhook.ts | verifyHmacSignature(rawBody, sigHex, secret), isWithinTimestampWindow(ts, maxAge?, now?) | Webhook receivers only (QRS-311, built 2026-08-03 — ADR-0002 claimed this existed earlier; it did not). Signature verification is provider-agnostic HMAC-SHA256-hex over the raw, unparsed body — call with await req.text() before any JSON.parse. Timestamp freshness is a separate, unsigned defense-in-depth check (Razorpay does not bind a timestamp into its signature). Idempotency is deliberately not here — each receiver owns its own UNIQUE event_id ledger table. |
idempotency.ts | validateIdempotencyKey, hashRequest, claimIdempotencyKey, completeIdempotencyKey, failIdempotencyKey, assertUnderRateLimit, ClaimParams, ClaimResult | The one implementation of CLAUDE.md's required-idempotency-key rule, over public.idempotency_keys, so two functions cannot dedupe differently. Not boilerplate: 6 of 11 rows in the legacy production reminders table were double-tap duplicates (QRS-210). ⚠ key is the sole PRIMARY KEY — globally unique, so reusing one across operations is detectable rather than silently allowed; request_hash is NOT NULL and there is no action column, which is why the pre-v2 per-function helpers would fail at runtime (23502 / 42703) rather than merely behave differently. status is a state machine (in_progress → succeeded | failed) claimed before the mutation, so a crash mid-write leaves a row and the retry is caught — the old helper wrote only after success, so a crash left nothing and the retry re-executed. ⚠ The ledger expires after 24h; a composite PK on the target table does not — see reminder_occurrences (reminder_id, due_at). A genuine second tap arrives with a fresh key and is caught only by the table's own constraint. |
standardWebhooks.ts | verifyStandardWebhook, decodeHookSecret, SW_HEADERS, SendSmsHookPayload | Supabase auth hooks only (QRS-804). ⚠⚠ NOT THE SAME SCHEME AS webhook.ts, AND THIS IS THE TRAP THE MODULE EXISTS TO PREVENT. Standard Webhooks signs {id}.{timestamp}.{body} and encodes the digest as base64, with the secret base64-DECODED from v1,whsec_<base64>; webhook.ts signs the raw body alone as hex with the secret's UTF-8 bytes. Reaching for the wrong one fails closed — which sounds safe and is not, because the symptom is "the OTP hook rejects everything" and the obvious move under pressure is to weaken the check rather than notice it is the wrong algorithm. Tests run each verifier against the other's fixtures so nobody can conclude they are interchangeable. ⚠ Freshness here IS cryptographic (the timestamp is inside the signed content), unlike Razorpay's — but it is still not idempotency: a valid request replayed inside the window verifies every time, and idempotency_key is what makes it a no-op. ⚠ Hand-written rather than esm.sh/standardwebhooks: this sits on the authentication path, the one place a third-party import gates every other thing. |
whatsapp.ts | sendTemplateMessage, WhatsAppApiError, WhatsAppSender, TemplateSendParams | The Meta Cloud API client (QRS-804). Knows Meta, knows nothing about QRSETU domains — that separation is what makes every future sender a thin adapter rather than a second send path (ADR-0029 D2). ⚠ THE SENDER IS AN ARGUMENT, NEVER READ FROM ENV. Configuring it in env would cap the platform at one number forever and make whatsapp_phone_numbers decorative. Env holds the credential; the registry holds the routing. ⚠ phoneNumberId is Meta's id, not the phone number — passing the display number yields a 404 that reads like "not registered". Failure classification is by HTTP status first, with a numeric-code override, deliberately that way round: status-based classification cannot rot, while Meta's codes drift. An unrecognised 4xx defaults to permanent, which is the safe direction — retrying a certain failure burns the user's wait and can bill per attempt, whereas the inverse costs one avoidable failure that is visible in the ledger. ⚠ Retries are in-request and deadline-bound; retry.ts is deliberately unused (its 1s+2s waits alone eat a third of the hook's ~10s budget, and it retries every error including permanent ones). |
communication.ts | resolveSender, resolveApprovedTemplate, assertCategoryMatchesTemplate, countRecent, enqueueMessage, markSent/markFailed/markSuppressed, applyStatusEvent, recordProviderEvent, closeProviderEvent, statusRank | The channel-agnostic messaging core (QRS-804). Knows messages, knows nothing about channels. ⚠ IT HOLDS NO BUDGETS. countRecent is a generic "count this dimension over this window" primitive and the adapter chooses the numbers — an "OTP per-phone limit" defined here would leak authentication into the messaging layer, and the next consumer would inherit a rule that makes no sense for it. ⚠ The GLOBAL limit (omit column) is the one that matters and the easiest to omit: a per-recipient limit is untouched by an attacker cycling ten thousand distinct numbers, and Meta's ceiling is per business portfolio, so an OTP flood does not degrade sign-in — it stops every merchant's order confirmations. ⚠ A per-IP limit is IMPOSSIBLE at an auth hook (Supabase calls it, not the client); saying so beats implying a limit we cannot enforce. Limits count the ledger, never a separate counter, so they cannot disagree with what was accepted (QRS-249). ⚠ enqueueMessage writes before the provider call, so a timed-out send is still evidenced, and detects a replay by constraint rather than a prior SELECT — check-then-insert loses to a concurrent retry of the same hook. |
features.ts | resolveFeatures, assertFeature, ResolvedFeature | The actual server-side feature gate (ADR-0021) — the client hiding an entry point is an affordance, since a merchant can still reach an EF by deep link, a stale bundle or plain HTTP. RLS is defence in depth and does not even run here, because these functions hold the service role. ⚠ Replaces the retired capabilities.ts, which was silently FAILING OPEN: that helper called get_capabilities_for_profile and read a feature_flags kill switch, and against the ADR-0020 baseline neither exists. Its isGatingEnabled() returned false ("do not enforce") when the flag row could not be read — and a missing table reads as exactly that failure, so it would have enforced nothing, silently, on every call. A gate that no-ops when its own dependency disappears is worse than no gate, because it reports success. The replacement has no such switch to lose: ADR-0021's availability axis is the ship-dark mechanism. |
cardCache.ts | invalidateCardCache, setuCardCacheTag, InvalidateCardCacheParams | Setu Card edge-cache purge (QRS-350, plan G1; ADR-0027 "purge-on-write, never TTL"). Every write that changes what a published card shows must purge Cache-Tag: card-{slug} — without it a vendor edits their profile or catalog and sees no change, which reads as a broken product rather than a caching nuance. Fires whenever a slug is present regardless of is_published, because an unpublish must purge too (a cached card must not keep serving content once its route starts 404ing), and purging a tag Cloudflare never cached is a harmless no-op. Best-effort by construction: a purge failure must never fail a write that already succeeded. Records every attempt through the structured Logger — card_cache.purged / card_cache.purge_failed / card_cache.purge_threw, each carrying the cache tag (QRS-573). |
internalAuth.ts | requireInternalSecret | Shared-secret auth for the internal/Worker→EF hop, so a Type B function is never publicly callable even when verify_jwt = false. |
observability.ts | captureServerException | Sentry (@sentry/deno) reporting, hooked into err() for 5xx only. No-op until SENTRY_DSN is set, and PII-scrubbed — report through this seam, never a Sentry SDK directly. |
media.ts | ALLOWED_IMAGE_TYPES, MAX_UPLOAD_BYTES, validateImageContentType(), validateUploadByteSize(), extensionFor() | The upload rules every upload path must share (QRS-1145). Promoted out of manage-media/helpers.ts the moment a SECOND upload path appeared (manage-biodata's biodata photographs), because these are not conveniences: ⚠⚠ THE ALLOW-LIST IS A STORED-XSS CONTROL. presignR2Url pins Content-Type into the SigV4 signature, R2 stores what was signed and serves it back, so whatever this list permits is a content type an attacker can get the media host to serve — text/html or image/svg+xml would be stored XSS, since SVG carries script and a browser executes it when the document is navigated to directly. ⚠ A PRIVATE BUCKET DOES NOT WEAKEN THAT, which is the trap worth knowing because the second caller is the private one: a presigned GET of a stored image/svg+xml is still a navigable document whose script runs. Privacy decides who gets the URL, never what the bytes do. So the list is identical on both paths, deliberately. ⚠ PROMOTED BEFORE THE SECOND CALLER, NOT AFTER, and the reason is the failure mode: two copies of an allow-list drift, the copy that drifts is the one nobody is looking at, and a permitted image/svg+xml uploads, stores and serves perfectly — the only symptom is somebody else's script running. 🛠 Proven by mutation: re-admitting image/svg+xml here fails 4 tests across BOTH functions, which is the whole argument for one implementation. extensionFor is what keeps a storage key's extension derived from the validated content type and never from a client filename. |
r2.ts | r2Config(), presignR2Url(cfg, params), R2Config, PresignParams, PresignedRequest | Cloudflare R2 presigned URLs for chat media and item images (QRS-657). No SDK, deliberately: R2 speaks the S3 API, so a presigned URL is plain AWS SigV4 — ~80 lines of HMAC chaining over crypto.subtle — and an EF pays for every import on cold start. Presign rather than proxy the bytes: a voice note base64-in-JSON inflates ~33% against an EF body ceiling, on the connection of a vendor at a stall; presigning leaves this function handling only the authorisation decision. ⚠ SIGNING Content-Type AND Content-Length IS WHAT PRESERVES SERVER-SIDE VALIDATION — unsigned, a client authorised for a 40 KB audio/mp4 could PUT a 2 GB executable at the same key and the URL would still verify. Whatever is signed becomes required, so presignR2Url returns requiredHeaders alongside the URL instead of documenting the obligation in prose. ⚠ A presigned URL is a bearer token in a URL: expiries are short, and it is never logged and never persisted — media.storage_key is the stored identity, the URL is derived on demand and discarded. encodeURIComponent alone is not sufficient (it leaves !'()*, which S3's canonical request expects percent-encoded), and the payload hash is UNSIGNED-PAYLOAD so a 5 MB photo streams without being hashed on a cheap phone first. 🟢 NOW PROVISIONED AND HAS ITS FIRST LIVE IMPORTER (2026-08-22). This entry read "Unverified against a real account: R2_* is unset on Dev" until then, and both halves have changed: the five R2_* secrets are set on the Dev project, and manage-media (QRS-809) is this module's first live importer — it had ZERO before, which is why no image in the product could ever load. CLOUDFLARE_ZONE_ID (QRS-306) is a SEPARATE gap and is still open; do not read one as the other. ⚠ PresignParams.method now accepts DELETE, signed and then called by the SERVER for its own immediate use — never handed to a client, where it would be a licence to destroy an object revocable only by expiry. Reusing the tested signer beats writing a second signed-request path. ⚠ Read media.ts above before using this: the content-type allow-list is a stored-XSS control rather than validation, because R2 serves back the type that was SIGNED. It moved out of manage-media's README and into a shared module when the second upload path arrived (QRS-1145). ⚠ r2Config NOW TAKES A BUCKET ENV ('R2_BUCKET' default, or 'R2_PRIVATE_BUCKET'), because there are two buckets and they are not interchangeable: a key in R2_BUCKET is served from a CDN origin with no authorisation in front of it, while R2_PRIVATE_BUCKET is reachable only through a short-lived presigned URL. A biodata photograph MUST be private — a family releases it to named people with an expiry, and a public key would make that disclosure model decorative. Added as a PARAMETER rather than a second r2PrivateConfig() so the credential reading and its error message have ONE implementation. ⚠ AND THE CALLER MUST SIGN AGAINST THE BUCKET THE ROW NAMES — biodata-read defaulted to the public bucket while holding media.bucket and discarding it, so no biodata photograph could ever have resolved (QRS-1147). It failed CLOSED, which is why it would have been misdiagnosed: a wrong-bucket signature 404s and renders as "the photograph did not load". |
razorpay.ts | createPaymentLink, RazorpayApiError, CreatePaymentLinkParams, CreatePaymentLinkResult | Razorpay Payment Links with Route transfers attached at creation (ADR-0002). Amount is derived server-side from catalogue price, never taken from the client. |
commission.ts | resolveCommissionBp(serviceClient, workspaceId), parseCommissionBp, DEFAULT_COMMISSION_BP | One place that answers "what do we charge this workspace". ⚠ A SEAM, NOT A FEATURE — the body reads PLATFORM_COMMISSION_BP exactly as the inline commissionBp() in place-public-order/helpers.ts did before it (that copy was removed, not re-exported). Nothing about the rate changed; what changed is that its source is replaceable in one body, so the wave-2 platform_commission_rates table becomes an edit to this file instead of a sweep across the money path. ⚠ It is async and takes two parameters it does not use — deliberately, and that is the whole point. The wave-2 implementation is an RPC, so a synchronous signature or a narrower one would force every call site to change when it lands. Do not "clean them up". ⚠ A MISSING RATE MUST NOT BECOME A ZERO RATE: defaulting to 0 would give the platform's entire commission away silently, on every order, until someone read a bank statement — so parseCommissionBp falls back to DEFAULT_COMMISSION_BP (500 = 5%, the Ganapati MVP rate) and rejects anything unparseable. ⚠ NEVER replace a read of payments.commission_rate_bp with a call to this. The column is a per-row snapshot taken at link creation, so a later rate change cannot rewrite the history of money that already moved: this function answers "what is the rate NOW", the column answers "what was the rate THEN". ⚠ Call it OUTSIDE the try that saves the order (see place-public-order/index.ts): once it is an RPC, a transient failure must not lose a real buyer's order while a genuinely missing term must be a 500 — collapsing those two categories either charges no commission or discards a customer's order. |
workspace.ts | assertWorkspaceWriteAccess(serviceClient, userId, workspaceId), resolveWorkspaceId | The write-authorization boundary for every merchant write function. Moved here from manage-item/helpers.ts on 2026-08-15 — moved, not copied: manage-order needs the identical predicate, and a second copy of an authorization check is the duplicate-source-of-truth class (QRS-249/284/287) in the one place where the drift is a cross-tenant write rather than a display bug. manage-item/helpers.ts re-exports both so every existing import path and test is unchanged with exactly one implementation behind it. ⚠ These run on the SERVICE client, which BYPASSES RLS — that is precisely why they exist: the policies on orders/catalog_items are never consulted for these callers, so membership is asserted in code or not at all. userId MUST come from the verified JWT (requireAuth), never from a request body. ⚠ The write predicate is DELIBERATELY NARROWER THAN THE READ ONE. get_my_catalogue admits three relationships — own membership ∪ oversight ∪ org-shared — and only the first may write; mirroring the read predicate is the natural-looking mistake and would hand every overseer and every sharing child write access to somebody else's catalogue. ⚠ It throws NotFoundError, never a "forbidden", because "you may not write to this workspace" confirms the uuid names a real workspace and turns a blind guess into an enumeration oracle — the same reasoning behind the read RPCs' no_data_found. |
✅ The ops-table writes are gone — the audit trail is the structured log (QRS-573, fixed 2026-08-12)
The ADR-0020 baseline dropped the whole public_page_ops_* family on 2026-08-08 and two _shared helpers were never updated with it, so both wrote to tables that no longer existed and swallowed the error:
logging.tsdid it on EVERY log line, in every Edge Function — a failing round-trip plus a spuriousconsole.error('Failed to insert log entry: …'), so the log stream carried roughly one bogus error per real line. This was the larger half by far and was found while fixing the smaller one.cardCache.tsdid it on every card-affecting write, so a successful purge recorded nothing anywhere while a failed one was correctly logged — the inverse of what an audit trail is for, on the evidence path for launch-blocking QRS-350/351.
console.log was always the real persistence path: Supabase collects Edge Function stdout/stderr into its log stream. The tables were a redundant second copy, so removing the inserts loses no observability. invalidateCardCache now emits card_cache.purged on success alongside the existing card_cache.purge_failed, both carrying the cache tag.
⚠ Deliberately NOT moved to the outbox. public.outbox exists with a cache.purge topic (20260808170000_v2_locations_media_outbox.sql) and is the right home per ADR-0027 — but nothing drains it: no worker, no cron, no consumer (verified 2026-08-12). Enqueuing today would replace a purge that works with rows nobody processes. It moves when the drain worker lands with the ADR-0025 campaigns work.
⚠ Five database.ts helpers still target the four dropped tables and are still exported — insertLogEntry, insertCacheOperation, insertCronExecution, getActiveCronJobs, getJobByName. None has a live caller now. They are kept because _archive_pre_v2/ imports them, and that tree is excluded from deno check/deno test — so a banner in the file is the only control. Do not call them. getSupabaseClient, batchInsert and batchUpdate are table-agnostic and fine.
Typed errors → HTTP status
Canonical usage
ts
import { handleCors } from '../_shared/cors.ts'
import { requireAuth } from '../_shared/auth.ts'
import { ValidationError } from '../_shared/errors.ts'
import { ok, err } from '../_shared/response.ts'
Deno.serve(async (req) => {
const preflight = handleCors(req); if (preflight) return preflight
try {
const { user, serviceClient } = await requireAuth(req)
const body = await req.json()
if (!body.action) throw new ValidationError('action is required.')
// ...business logic (complex parts in helpers.ts)
return ok({ result })
} catch (e) {
return err('manage-item', e)
}
})⚠ Read
manage-item/index.tsormanage-reminder/index.tsfor a live reference. This page used to point atmanage-profile, which was archived 2026-08-09 tosupabase/functions/_archive_pre_v2/along with theprofilesandbio_pagestables it wrote — so it is not prior art for any live table. (EDGE_FUNCTION_GUIDELINES.mdstill uses it as its running example throughout; the prose there is correct, only the file it names is gone.) See Backend architecture for the Type A / B / Webhook model and Adding an Edge Function for the full recipe.