Edge functions
Every function the kit ships, what calls it, what it trusts, and how they run locally.
The server side is a set of Supabase edge functions (Deno) under packages/kit/supabase/functions, assembled into the site's supabase/functions beside the site's own (db/functions). _shared/ holds what they share: lib.ts (CORS, the clients), offers.ts (Stripe made to match the files), ledger.ts (what a payment writes) over ledger-rules.js (what it decides — who paid, which offer a line is, what it grants — plain objects in and out, so the most consequential decisions in the kit can be checked without a stack; Commerce internals), email.ts and emails/ (the compiled designs, fill.js, tags.js, rules.js, markdown), marketing.ts (the campaign sends and the tracked links), cal.ts (cal.com and the one judge), print.ts and printers/ (the printer modules), shipping.ts, payouts.ts (connecting an account and sending what is payable — the one place money moves outward), and roles.ts — what a person may do, asked as a capability. matrix/ is the Matrix surface: ids.js, events.js and state.js translate rows to Matrix and back as pure functions, errors.js holds every refusal in Matrix's shape with a reason a person can act on, keys.js is the key store an encrypted channel needs — device and cross-signing keys, one-time keys claimed exactly once, the to-device queue and the key backup, all of it public keys and opaque blobs and never a secret — and session.ts is the one place a client's token becomes that person's own database session — the only module in the kit that holds a refresh token, and the file to read before changing anything about that surface. atproto/ is the social network as an atproto server the same way: ids.js makes handles, DIDs and at:// URIs from a member's identity as it is now and says which methods may be asked at all (clientAllowed: other apps only where the config's network.apps allows them) (pure, read by the tests too), errors.js every refusal in XRPC's shape, media.js what a post may carry and how it goes over the protocol (plain JavaScript the browser and the tests read too), views.ts rows into the app view's shapes (a record assembled and its CID hashed, never stored), writes.ts a record from another app into a row through the person's own client, session.ts app passwords and the sessions made with them — again the one module of the surface that holds the auth server's refresh token — federation.ts a joined member's portable identity, their signed repository made from their public rows and the numbered event stream relays read, and appview.ts the world outside asked of an app view as that member, with a service token signed by their own key. A function never compares a role's name: it reads the set with rolesOf(userId) and asks isAdmin, isStaff, isCoach, isSupport, mayEditContent or maySeeFigures, which are the same words the database's own helpers and the browser's lib/auth.js use, so the three can never drift apart about what a role means.
Provider adapters use a generic Reddit User-Agent by default; REDDIT_USER_AGENT can override it. LINKEDIN_API_VERSION optionally pins the LinkedIn API version. The Bluesky OAuth client metadata is labeled Site Social Publisher.
_shared/compiled.ts is how a function reads anything the build compiled rather than wrote — the catalog, the forms, the funnels. It puts the row of public.compiled over the file the deploy shipped, so a deployment that cannot rebuild (cloud mode with git out of the cycle and no deploy hook) still answers from what was last published; where there is no row, which is everywhere that builds, it is the file exactly as before. One read per isolate, and fresh() re-reads within a few seconds for the functions that answer the public. A module holding a value from an artefact declares it export let and re-derives it in onRefresh, so no call site had to change shape.
Two things the functions read are written by the build from platformkit.config.js rather than from the database: _shared/cloud.json (where cloud mode publishes — the token stays a secret in app_settings) and _shared/webhooks.json (the subscriptions this deployment ships, naming the environment variable a secret is in and never the secret). The site's own scheduler also falls back to a session type's declared default hours where a host has set none.
The functions
| Function | Called by | Trusts |
|---|---|---|
create-checkout-session |
the site (a cart, a Join button, a paid session's picker) | the person's JWT for memberships and ordinary paid sessions; no JWT for print/download-only guest carts, or for one paid public session when coaching.requireAuthToBook: false; paid-session fulfillment uses only Stripe's returned email |
confirm-checkout |
the site, on landing from Stripe | the JWT (or a guest's session id); Stripe's own record of the session |
create-portal-session |
the account menu's Manage membership | the JWT |
stripe-webhook |
Stripe | the signature over the raw body |
push-catalog |
the deploy, or an admin | the service-role key or an admin's JWT |
book-files |
the person's cover page and the store page | the JWT; signs the bucket's urls for an hour |
session-files |
the Coaching tab's past sessions and Sales → Bookings | the JWT; the booking read through the caller's own client (its policy the check), the recording's and transcript's urls signed for an hour, answered as paths |
session-recordings |
pg_cron (pull every ten minutes for the pending, retain nightly for the keep), the desk's Fetch again |
the service bearer, or an administrator's JWT for one booking; the pull itself is _shared/recordings.ts, which cal-webhook also calls the moment a recording is ready — cal.com's API asked for a fresh link (recordingsOf / transcriptsOf in _shared/cal.ts, the shapes cal.com's published OpenAPI gives: recordings with a downloadLink, transcripts an array of urls; the webhook's own body as cal.com's daily-video route sends it, the calendar event spread with downloadLink, or downloadLinks.transcription[]), the file streamed into the recordings bucket. The service bearer may name another API base — how recordings.test.mjs stands in for cal.com with its documented endpoints and a signed webhook on the local stack |
session-slots |
the Coaching tab's booker | no JWT for the availability of public anyone / guest types; otherwise the JWT and the one judge in _shared/sessions.ts; the response contains open times only, with no booking records or attendee data |
book-session, cancel-session |
the Coaching tab's booker | book-session accepts a signed-out request only when coaching.requireAuthToBook: false, the type admits guests, and a valid email is supplied; signed-in bookings and all moves/cancellations use the JWT. The one judge still decides eligibility, then schedulerOf(type) picks the site's own scheduler or cal.com's from _shared/schedulers/; _shared/booking.ts handles the booking, and cancel-session also takes the host's no-show |
session-reminders |
pg_cron every five minutes ({ action: 'send' }; dry: true lists what would go) |
the service bearer alone; each reminder's row written in session_reminders before the send, so a rerun never repeats one; an hour crossed longer ago than the run's cadence is skipped, not sent late |
session-room |
the call page | the JWT; the booking read through the caller's own client (its policy the check), the window (425 before, 410 after), the caller's side (the host by host_id / provider_id), then the video provider's credentials for that person and booking — _shared/video/{livekit,cal-video}.js behind one shape (room · admit · record · end · event), videoOf(type) picking LiveKit for a native type in a video room and Cal Video for a cal.com type; record and end for the host alone |
livekit-webhook |
LiveKit | the SDK's check of the Authorization header (a JWT over the body's sha256, keyed by the API secret); room_started / participant_joined → started_at, room_finished → ended with the follow-up, egress_ended → the recording's path (as a pulled one) or its failure; a replay changes nothing; livekit-server-sdk runs under the edge runtime as plain JS |
coach-hours |
the Coaching tab's Your hours | the JWT of a host (anybody who coaches — isCoach in _shared/roles.ts), their own hours alone — get, set, override, unoverride, the shape checked here in one place |
cal-webhook |
cal.com | the HMAC signature (CAL_WEBHOOK_SECRET) |
chat-notify |
the sender's browser after a coaching message | the JWT |
send-email |
the email editor's test send | an admin's JWT |
funnels-run |
pg_cron every hour, over pg_net | the service-role key |
email-blast |
the Blasts page | an admin's JWT |
email-track, email-unsubscribe |
a person's click | a random token / an HMAC on the id; nothing else |
support |
the support widget (every contact action, including an upload and the transcript its browser wrote down beside a voice note) and the staff's notify |
no JWT required; a guest's contact token or a person's JWT, resolved inside with the service role |
social |
Social Admin and the optional Spaces Desk adapter: provider account, post, comment and message operations, including replies | the caller's JWT; current Social grants and provider capabilities are checked for each operation |
form |
a form's runner (each slide posted, get for a return) and the embed on another site |
no JWT required; the person's JWT or the browser's anonymous id (X-Form-Anon); an embed's Origin against the form's allowed origins |
payouts |
a provider reading their own standing or connecting an account; the admin's Payouts page (who is owed, sending it, the sweep by hand). Nothing in it decides what anybody is owed — it reads the lines the sweep wrote. See Providers and payouts | the JWT: their own; an administrator's for anybody else, for due and for send — asking about somebody else is refused rather than quietly answered about yourself |
print-order |
Print Orders (send again, check now, order a proof) | an admin's JWT |
print-render |
sets a book in print where there is no machine: the typesetter (already pure) into Typst-as-WASM, the templates compiled in beside it, the type fetched from the print bucket once per worker, the interior stored under the same name the native path uses. The listing's pictures — the cover the store draws and three pages of the interior — need a browser and are not made here: the answer carries the document's own openers and highlights so the caller's browser can draw them, and takes them back under { edition, shots, art }, refused for any other edition. The edition id is the same with or without them |
an admin's JWT, or the service key |
print-webhook (lulu-webhook its alias) |
the printers | never the body — the job is read back |
agent-run |
the agent loop's worker: it takes ONE step of one run and returns, so a run outlives the function running it. { run } takes that one; { sweep: true } takes whatever is waiting; { steps: 1 } asks for a single step. See Bots, tools and commands |
the service-role key alone — a run is machinery, and nobody's browser drives it |
push |
the database's triggers over pg_net; the Notifications page's test | the service-role key / the JWT |
content |
any Contentful client at <domain>/spaces/… and <domain>/content/v1/…, and the OpenAPI document of the whole surface at /openapi.json (see the content API) |
no JWT required: a delivery key of the site's, or none where Delivery is public; a preview key for drafts and unpublished entries; a key allowed gated content for a gated entry's body; every write refused until the management change; Contentful's rate limit per key or address |
share-image |
draws a page's share picture where no browser can photograph the real one — the fallback behind the prerender sidecar, in the site's own faces and mark (hosting) | |
images |
every asset url the content API gives, with the Images API's transforms | no JWT required: public pictures, made once and cached |
forms-api |
Typeform's client at <domain>/api — the forms' definitions, the responses, the webhooks, the insights (see the Forms API) |
no JWT required: a key of kind forms, checked inside; never public |
scheduling-api |
cal.com's API v2 at <domain>/api/scheduling/v2/… — event types, slots, bookings, schedules, webhooks (see the Scheduling API) |
no JWT required: a key of kind scheduling, or a person's JWT read inside; the judge on every booking |
storefront-api |
Shopify's Storefront API at <domain>/api/<version>/graphql.json — the products, collections and carts a headless storefront reads and makes; a cart's checkout url the site's own bag (see the Store API); /api/desk/api/v1/* handed on to support-api |
no JWT required: a key of kind storefront (a public token) read inside; the handoff's cart read by its token alone |
support-api |
Chatwoot's Client API at <domain>/api/desk/public/api/desk/api/v1/… (the widget's door: a guest contact, its conversations and messages) and Application API at <domain>/api/desk/api/v1/… (the desk: conversations, replies, status, assignments, contacts, agents, webhooks); the webhooks' deliveries (see the Support API) |
no JWT required: the Client API under no key with the widget's guards, the Application API under a key of kind support, deliver under the service bearer from the database's triggers |
ingest |
every event: the site's own lib/analytics.js, the analytics script on another site, and any official PostHog SDK pointed at the site (its own paths under the function: /e/, /batch/, /flags/, /array/…/config.js, /s/) |
no JWT required; the user from the caller's JWT when there is one (never the body); a source's key against _shared/analytics.json and its Origin against the source's hosts — in the site's batch shape or PostHog's wire protocol; the country from the host's geo header; 50 rows a batch, 600 a minute; PostHog's flags, config and recording endpoints answered with everything off |
admin |
the admin in cloud mode (CONTENT_STORE=cloud): every call of the content pipe, publish and drafts, github for Content → Publishing; push-pending from pg_cron every minute |
the JWT of somebody who may edit the content (may_edit_content in _shared/roles.ts — an editor or an administrator, read with the service role) — or a coach, who is fenced to the pages they own (see below); the service-role key for push-pending |
The functions that take no JWT are listed in config.toml with verify_jwt = false; each trusts something else, named above.
create-checkout-sessiondecides two things the page only displays: whether the shape is still in stock, and what this buyer pays. Both use the same pure modules the page used (_shared/stock.js,_shared/prices.js) over the same data, so the two cannot disagree — but the function is the door, and a tab open since this morning arrives there. A sold-out line is refused409; a line a price list re-priced is charged inline rather than minting a second Stripe price. Guest session Checkout is a separate, opt-in exception: exactly one eligible paid-session line and its selected booking details are accepted, while mixed carts are refused. The ledger records a uniquebooking_checkout_fulfilmentsclaim by Checkout Session before calling a scheduler, preventing webhook and confirmation replays from creating a second booking. A guest booking uses Stripe's server-returned email; matching booking and credit rows are claimed only after app-level email verification.
Coaching chat access is deliberately narrow: a provider can reply in their own coaching threads without a membership. A reader's session-only access is derived by chat_session_with from the paid booking and its coach, through one week after end_at; rescheduling updates that window and cancellation closes it. It is not a subscribed entitlement.
The admin function
In cloud mode the admin's backend is admin: one POST shaped as the dev server's middleware request — { seg, method, q, body } for collections, list, file (get · put · delete · rename), preview, options, action, upload, upload-image, asset, the library's media (over the content-assets bucket), and cloud mode's own drafts (what waits), publish ({ col, name } for one record, nothing for every draft, with a message) and keep (a draft removal called off) — answered by the same pipe (admin-pipe.mjs and build/models, assembled under _shared/pipe/ with the site's own models under pipe/site/ and the theme's string scopes as pipe/theme-strings.json) over the row store (build/store/rows.mjs: public.content and the content-assets bucket) with the service role — so every write goes through the pipe's checks and nothing is written from a client. The actions that need a machine (enrich, introduce, the print's render · save · set · quote · pdf, tts) answer 409 this runs from a checkout or the deploy. A coach who is not an editor is let in with a fence — only: { col: 'pages', field: 'owner', value: <their username> } — and the pipe then answers for the pages collection alone and for the records whose owner is their name, refusing every other record on every verb rather than hiding it (build/admin-fence.test.mjs). A file write is a draft and pushes nothing; after a successful publish the function pushes every pending row to the repository as one commit with the message given; github (op: get · put · retry) reads and writes Site → Publishing's settings in app_settings (github:repo, github:branch, github:token — service-role only, the token never sent back) and asks for a push; push-pending, called by run_content_push() every minute over pg_net with the service bearer, bundles whatever is still pending the same way.
_shared/github.js is the push, plain JavaScript on fetch alone so a kit test runs it under Node with the API mocked: commitFiles through GitHub's git data API — the branch's head, its tree, a blob per file, one tree over the head with a removed file's entry gone, one commit, the ref moved — and pushPending over every pending row: only once the ref has moved are the rows cleared of pending together (a removed one is gone for good) and github:last_push set; a failure anywhere leaves every row pending with the reason in push_error, github:error says so, and the next minute tries the lot again. A branch with no commit yet is refused with a line saying to push an initial commit first.
What a payment writes
ledger.ts is the one writer: purchases, subscriptions, entitlements (the classes and tiers the client reads through get_my_entitlements), a paid session's credit and its booking, a print order sent to the printer, the order's email. The webhook calls it with Stripe's objects; confirm-checkout with the session retrieved from Stripe; the mock (STRIPE_MOCK=1) with the same objects made up. Nothing on the client writes to any of those tables.
Secrets
Server-only settings are never VITE_-prefixed (those reach the browser bundle). In production they are function secrets (supabase secrets set, which the deploy does from its environment); locally they live in supabase/functions/.env, which platformkit dev generates from .env with the run's APP_URL so every link comes back to the port the app is on. The service-role key is injected by the platform.
The functions on the site's own domain
Every user-facing link to a function — a tracked click, the open pixel, unsubscribe — is written as https://<site>/v1/<function>/… when PUBLIC_FUNCTIONS_URL (the functions' side) and VITE_PUBLIC_FUNCTIONS_URL (the client's) are set, and the host rewrites /v1/* to the project's functions/v1/* — same origin, one short address. Without them the site's shell forwards /v1/… to the function before anything loads, so clicks and unsubscribes work either way; only the pixel is written against the function's own address.
Running them locally
platformkit dev serves them (supabase functions serve) against the local stack; platformkit functions serves them alone; platformkit functions deploy deploys them (the deploy does). The push function uses npm:web-push, which runs in the edge runtime the stack ships, not under an old local Deno CLI.
Adding a function to a site
A folder under db/functions/<name>/ with an index.ts; the assembler places it beside the kit's (a site function of a kit's name is refused), and it imports the kit's _shared by relative path as the kit's do. Its verify_jwt line goes in the site's config.toml.