* ops: let a function be retired, not just deployed The guarded deploy could create and update, never remove. rsync runs without --delete on purpose, so one project cannot wipe another's functions — but the consequence was that nothing ever left. A function deleted from its repository kept serving traffic, and nobody would know. Found the concrete case on 2026-08-01: email-verification had been removed from pwap on 2026-07-30 and was still live and reachable with only the anon key, running under the service-role key with Access-Control-Allow-Origin *. Its verify path sets profiles.email_verified for whatever user_id a token maps to. It was not exploitable — the send path calls getUserByEmail, which the SDK does not have, so no tokens could be minted and the table was empty. Broken is not the same as safe; it was protected by a bug, not by design. - --audit compares the volume against the registry both directions and exits 1 on drift. Runs after every deploy too, so a leftover is stated rather than invisible. - --retire moves functions into /opt/supabase-self-hosted/retired-functions/ with a timestamp. Quarantined, not deleted: retiring the wrong thing has to be one command to undo, and that copy is the only remaining record of what was serving traffic. - _cloud_hosted names now count as declared. They live on the cloud project and are owned by nobody here, so without this the audit would have offered to retire ask and telegram-bot, which look live. Applied: email-verification retired and dropped from the registry; the audit is clean; two-factor-auth and the miniapp's check-deposits both still answer. * ops: catch the function a project still serves but no longer ships The retire mechanism closed the second half of the problem. This closes the first: nothing could detect that email-verification needed retiring. It was never an orphan. The registry listed it as owned and current, the volume agreed, and both were right about each other — the repository was the one that had dropped it. Volume-vs-registry compares two of the three and cannot see a disagreement in the third. It only became visible as drift because I removed it from the registry by hand, which means the mechanism did not find it; I did. A deploy is the one moment we hold the project's actual source next to the registry and the volume, so it is the only place all three can be compared. Anything the project owns, that is on disk, and that is absent from what it just shipped, is now named with the command to retire it. Warns rather than refuses: deleting a function and shipping an unrelated change in the same commit is ordinary, and blocking that deploy until someone retires would punish the correct move. It is printed in the deploy output and recorded as an ABANDONED line in the deploy log. Verified against a source tree missing eight of pwap-web's functions: all eight named, deploy still allowed.
ops
Infrastructure that runs on the Supabase host, kept here rather than only on the server.
supabase-deploy-functions + functions-registry.json
The ownership gate for the shared edge-function volume on vps3.
Two projects deploy into one volume: this repo and pwap-web. Before the gate
existed, pwap-web rsynced its whole tree in, so whoever deployed last silently
overwrote any function name they happened to share. On 2026-06-28 that replaced
this project's telegram-auth with pwap-web's login-widget handler, and sign-in
— plus every wallet screen behind it — returned 401 for a month. Nothing failed
loudly, because the name still resolved; it just resolved to the wrong project's
code.
The gate is the only supported way to write into that volume. It refuses any directory the calling project does not own, and refuses names absent from the registry entirely, so a new collision cannot be introduced by accident.
supabase-deploy-functions --project <name> --src <dir> [--restart] [--dry-run]
- Validates every incoming directory before writing anything — a refused deploy leaves the volume untouched rather than half-updated
- Writes atomically per function, so a reader never sees a partial function
- Serialises with
flock: two projects deploy here and both restart the same runtime. Without it, one deploy can recreate the container while another is mid-restart, which is what turned a successful deploy into a failed job on 2026-07-30 - Retries the restart, and if it still fails, checks whether the runtime is actually up before reporting failure — a racing restart should not be reported as a broken deploy
- Logs every decision to
/var/log/supabase-function-deploys.log
rsync --delete and wholesale copying into that volume are not acceptable.
The registry
functions-registry.json records which project owns which function name. A new
function must be added here before it can be deployed — that is the point:
the gate refuses unknown names so a collision is caught at deploy time rather
than discovered a month later.
Current ownership: 21 names to this project, 12 to pwap-web, 2 to the platform
(hello, main).
_cloud_hosted lists functions that live on the cloud Supabase project
(vbhftvdayqfmcgmzdxfv), not on vps3: telegram-bot and ask. Both Telegram
bots reach the cloud project by webhook, and news.pex.mom's assistant calls ask
there. Stale copies of both sit in the vps3 volume from before that split and
serve no traffic — deploying them here updates a dead copy while the live one
keeps running whatever was last pushed by hand.
pwap-web depends on this file too. It is versioned here because this project owns the larger share and the gate was built for its incident, but a pwap-web change that adds a function needs a PR here first. That is deliberate friction: the registry is the record of who owns what, and it should not be edited on the server where nobody can see the change.
Deployment
.github/workflows/deploy.yml copies both to the host on every deploy, so the
server copy is replaced from version control rather than edited in place. Before
this, the script existed only at /usr/local/bin/supabase-deploy-functions — one
copy, no history, no review, and gone with the server. The mechanism built to
stop silent drift was itself drifting.
apply-repo-settings.sh
This repo's branch protection, as code. See the header in that file; run
--check to report drift without changing anything.
Note: every CI job is listed individually because this repo has no aggregate gate
job, so a renamed job silently drops a requirement. An aggregate job (as pwap-web
has with CI Gate ✅) would be sturdier.