From b2b4751f7adafaf283c23d91bcc41be486d83715 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 31 Jul 2026 10:02:14 -0700 Subject: [PATCH] ops: version the ownership gate and this repo's branch protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of infrastructure existed only on servers, as single copies with no history and no review: /usr/local/bin/supabase-deploy-functions the gate both projects deploy through /opt/supabase-self-hosted/functions-registry.json who owns which function name The gate exists to stop silent drift between what a repo declares and what a server actually runs. Leaving it unversioned meant the mechanism was drifting the same way it was built to prevent — and losing that host would have lost it entirely, along with any record of why it works the way it does. Both are now in ops/ and synced to the host by CI on every deploy, installed before the functions they validate so a change to the gate ships together with them. Verified identical to the server copies by checksum before committing, so this captures the running state rather than replacing it. Branch protection gets the same treatment: apply-repo-settings.sh is idempotent and has a --check mode that reports drift without changing anything. It was applied by hand through the API today, so nothing here reflected it. Documented alongside: why the registry lives in this repo even though pwap-web depends on it (a pwap-web change needs a PR here — deliberate friction, since the registry is the record of who owns what and should not be edited on a server where nobody sees the change), and why listing each CI job individually in the protection rules is a weakness here that pwap-web avoids with an aggregate job. --- .github/workflows/deploy.yml | 20 ++++ ops/README.md | 78 +++++++++++++ ops/apply-repo-settings.sh | 92 +++++++++++++++ ops/functions-registry.json | 51 +++++++++ ops/supabase-deploy-functions | 204 ++++++++++++++++++++++++++++++++++ package.json | 2 +- src/version.json | 6 +- 7 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 ops/README.md create mode 100755 ops/apply-repo-settings.sh create mode 100644 ops/functions-registry.json create mode 100755 ops/supabase-deploy-functions diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index baabf0a..9cda61b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -96,6 +96,21 @@ jobs: source: 'functions.tgz' target: '/opt/miniapp-deploy-staging' + # The gate and its registry are versioned here, so the server copy is + # replaced from source control on every deploy rather than edited in + # place. They previously existed only at /usr/local/bin — one copy, no + # history, no review, gone with the server. The mechanism built to stop + # silent drift was itself drifting. + - name: Sync ownership gate to host + uses: appleboy/scp-action@v1.0.0 + with: + host: ${{ secrets.VPS2_HOST }} + username: ${{ secrets.VPS2_USER }} + key: ${{ secrets.VPS2_SSH_KEY }} + source: 'ops/supabase-deploy-functions,ops/functions-registry.json' + target: '/opt/miniapp-deploy-staging' + strip_components: 1 + - name: Deploy through ownership gate uses: appleboy/ssh-action@v1.0.0 with: @@ -105,6 +120,11 @@ jobs: script: | set -e BASE=/opt/miniapp-deploy-staging + + # Install the gate before using it, so a change to the gate ships in + # the same deploy as the functions it validates. + install -m 755 "$BASE/supabase-deploy-functions" /usr/local/bin/supabase-deploy-functions + install -m 644 "$BASE/functions-registry.json" /opt/supabase-self-hosted/functions-registry.json trap 'rm -rf "$BASE"' EXIT rm -rf "$BASE/functions" tar xzf "$BASE/functions.tgz" -C "$BASE" diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..ed4b8d9 --- /dev/null +++ b/ops/README.md @@ -0,0 +1,78 @@ +# 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. + +```bash +supabase-deploy-functions --project --src [--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. diff --git a/ops/apply-repo-settings.sh b/ops/apply-repo-settings.sh new file mode 100755 index 0000000..811fa13 --- /dev/null +++ b/ops/apply-repo-settings.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# apply-repo-settings.sh — this repo's GitHub protections, as code. +# +# Branch protection is configured through the GitHub API, so nothing in the +# repository reflects it: invisible here, unversioned, and gone without a trace +# if someone removes it. It exists so that nothing reaches main unreviewed, which +# is exactly why it should not live as undocumented clicks in a settings page. +# +# Idempotent — each call PUTs the full desired state, so repeated runs converge. +# +# ./ops/apply-repo-settings.sh # apply +# ./ops/apply-repo-settings.sh --check # report drift, change nothing +# +# Requires gh authenticated with admin rights on the repo. +set -euo pipefail + +REPO="${REPO:-pezkuwichain/pezkuwi-telegram-miniapp}" +BRANCH="${BRANCH:-main}" + +CHECK_ONLY=0 +[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1 + +ok() { printf ' \033[32m✔\033[0m %s\n' "$*"; } +bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; } + +# Every CI job is listed individually because this repo has no aggregate gate +# job. That means renaming a job here silently drops a requirement — if the CI +# workflow gains or renames a job, this list must be updated with it. An +# aggregate job (as pwap has) would be sturdier; until then, --check is what +# catches the mismatch. +read -r -d '' PROTECTION <<'JSON' || true +{ + "required_status_checks": { + "strict": true, + "contexts": ["Test", "ESLint", "TypeScript", "Build", "Secret Scan"] + }, + "enforce_admins": false, + "required_pull_request_reviews": { + "dismiss_stale_reviews": true, + "require_code_owner_reviews": false, + "required_approving_review_count": 1, + "require_last_push_approval": false + }, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false, + "required_conversation_resolution": true +} +JSON + +# enforce_admins stays false deliberately: an admin needs a way through during a +# real incident. Escape hatch, not the normal path. + +echo "▶ repo settings: $REPO" + +if [[ $CHECK_ONLY -eq 1 ]]; then + cur="$(gh api "repos/$REPO/branches/$BRANCH/protection" 2>/dev/null || echo '{}')" + if [[ "$cur" == "{}" ]]; then + bad "branch '$BRANCH' is NOT protected" + exit 1 + fi + python3 - "$cur" <<'PY' +import json, sys +d = json.loads(sys.argv[1]) +checks = d.get('required_status_checks') or {} +rev = d.get('required_pull_request_reviews') or {} +expected_checks = ["Test", "ESLint", "TypeScript", "Build", "Secret Scan"] +want = { + 'required checks': (sorted(checks.get('contexts') or []), sorted(expected_checks)), + 'strict': (checks.get('strict'), True), + 'approvals': (rev.get('required_approving_review_count'), 1), + 'dismiss stale': (rev.get('dismiss_stale_reviews'), True), + 'force pushes blocked': (not (d.get('allow_force_pushes') or {}).get('enabled'), True), + 'deletions blocked': (not (d.get('allow_deletions') or {}).get('enabled'), True), + 'conversation resolution': ((d.get('required_conversation_resolution') or {}).get('enabled'), True), +} +drift = 0 +for label, (got, exp) in want.items(): + if got == exp: + print(f' \033[32m✔\033[0m {label}') + else: + drift += 1 + print(f' \033[31m✗\033[0m {label} (expected {exp}, got {got})') +raise SystemExit(1 if drift else 0) +PY + exit $? +fi + +gh api -X PUT "repos/$REPO/branches/$BRANCH/protection" --input - <<<"$PROTECTION" >/dev/null +ok "protected: 1 approval, 5 required checks, no force push, no deletion" +echo "✔ done — verify with: $0 --check" diff --git a/ops/functions-registry.json b/ops/functions-registry.json new file mode 100644 index 0000000..45fc3c4 --- /dev/null +++ b/ops/functions-registry.json @@ -0,0 +1,51 @@ +{ + "_comment": "Ownership registry for the shared self-hosted Supabase edge-function volume.", + "_rule": "Every function directory under volumes/functions MUST be listed here with exactly one owning project. A project may only write the names it owns. Adding a new function requires adding it here FIRST.", + "_enforced_by": "/usr/local/bin/supabase-deploy-functions", + "_updated": "2026-07-29", + "projects": { + "pezkuwi-telegram-miniapp": [ + "accept-p2p-offer", + "announcement-reaction", + "check-deposits", + "create-offer-telegram", + "get-deposit-code", + "get-deposit-info", + "get-deposits", + "get-internal-balance", + "get-my-offers", + "get-p2p-offers", + "get-p2p-trades", + "get-payment-methods", + "p2p-dispute", + "p2p-messages", + "process-deposits", + "request-withdraw-telegram", + "save-wallet-address", + "tgm-process-withdraw", + "tgm-telegram-auth", + "trade-action", + "verify-deposit-telegram" + ], + "pwap-web": [ + "_shared", + "cleanup-proofs", + "confirm-payment", + "email-verification", + "lock-escrow", + "notifications-manager", + "process-withdraw", + "process-withdrawal", + "resolve-dispute", + "telegram-auth", + "two-factor-auth", + "verify-deposit" + ], + "_platform": ["hello", "main"] + }, + "_cloud_hosted": { + "_comment": "These live on the CLOUD Supabase project vbhftvdayqfmcgmzdxfv (DKSapp), not here. Both Telegram bots and the news.pex.mom assistant are served from there. Copies may still sit in this volume from before the move - they are stale and serve no traffic. Do not deploy these here.", + "project": "vbhftvdayqfmcgmzdxfv", + "names": ["telegram-bot", "ask"] + } +} diff --git a/ops/supabase-deploy-functions b/ops/supabase-deploy-functions new file mode 100755 index 0000000..fb8b778 --- /dev/null +++ b/ops/supabase-deploy-functions @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Guarded deploy for the shared Supabase edge-function volume. + +Several independent projects deploy into one edge-runtime volume. Before this +gate existed they rsynced whole trees into it, so whoever deployed last silently +overwrote any function name they happened to share. That is how the miniapp's +telegram-auth disappeared on 2026-06-28 and stayed broken for a month. + +This is the only supported way to write into the volume. It refuses to touch a +directory the calling project does not own, and it refuses names that are not in +the registry at all, so a new collision cannot be introduced by accident. + + supabase-deploy-functions --project --src [--restart] [--dry-run] + +Exit codes: 0 ok, 1 refused (nothing written), 2 usage/internal error. +""" + +import argparse +import fcntl +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone + +REGISTRY = "/opt/supabase-self-hosted/functions-registry.json" +VOLUME = "/opt/supabase-self-hosted/docker/volumes/functions" +LOG = "/var/log/supabase-function-deploys.log" +LOCK = "/var/lock/supabase-function-deploy.lock" +CONTAINER = "supabase-edge-functions" +COMPOSE_DIR = "/opt/supabase-self-hosted/docker" + + +def fail(msg, code=1): + print("ERROR: " + msg, file=sys.stderr) + sys.exit(code) + + +def load_registry(): + try: + with open(REGISTRY) as fh: + data = json.load(fh) + except FileNotFoundError: + fail("registry not found at " + REGISTRY, 2) + except json.JSONDecodeError as exc: + fail("registry is not valid JSON: %s" % exc, 2) + + projects = data.get("projects") + if not isinstance(projects, dict): + fail("registry has no 'projects' object", 2) + + owner_of = {} + for project, names in projects.items(): + for name in names: + if name in owner_of: + fail("registry is inconsistent: '%s' is claimed by both '%s' and '%s'" + % (name, owner_of[name], project), 2) + owner_of[name] = project + return projects, owner_of + + +def container_running(): + out = subprocess.run( + ["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], + capture_output=True, text=True, + ) + return out.returncode == 0 and out.stdout.strip() == "true" + + +def restart_runtime(): + """Recreate the edge runtime, tolerating a racing restart. + + `docker compose up --force-recreate` fails with "No such container" when + something else recreated the container between compose reading state and + acting on it — a hand-run restart, or another project's deploy. The deploy + itself already succeeded at that point, so failing the whole run over it + reports a false failure. Retry, then fall back to checking whether the + runtime is actually up. + """ + for attempt in (1, 2, 3): + r = subprocess.run( + ["docker", "compose", "up", "-d", "--force-recreate", "functions"], + cwd=COMPOSE_DIR, capture_output=True, text=True, + ) + if r.returncode == 0: + print("Edge runtime recreated.") + return + print("restart attempt %d failed: %s" % (attempt, r.stderr.strip()[:200]), file=sys.stderr) + time.sleep(3) + + # Compose kept failing. If the runtime is up it is serving the files we just + # wrote, so the deploy stands; say so rather than reporting a false failure. + if container_running(): + print("WARNING: could not recreate the runtime, but %s is running and " + "serving the deployed files." % CONTAINER, file=sys.stderr) + log_line("%s RESTART-DEGRADED container=%s" % ( + datetime.now(timezone.utc).isoformat(timespec="seconds"), CONTAINER)) + return + fail("restart failed and %s is not running" % CONTAINER, 2) + + +def log_line(text): + try: + with open(LOG, "a") as fh: + fh.write(text + "\n") + except OSError: + pass # logging must never block a deploy + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--project", required=True) + ap.add_argument("--src", required=True, help="staging dir holding one subdir per function") + ap.add_argument("--restart", action="store_true", help="recreate the edge runtime afterwards") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + projects, owner_of = load_registry() + if args.project not in projects: + fail("unknown project '%s'. Known: %s" % (args.project, ", ".join(sorted(projects))), 2) + owned = set(projects[args.project]) + + if not os.path.isdir(args.src): + fail("source dir not found: " + args.src, 2) + + incoming = sorted( + d for d in os.listdir(args.src) + if os.path.isdir(os.path.join(args.src, d)) and not d.startswith("__") + ) + if not incoming: + fail("no function directories found under " + args.src, 2) + + # Validate everything before writing anything: a partial deploy is worse + # than a refused one. + foreign, unknown = [], [] + for name in incoming: + if name in owned: + continue + if name in owner_of: + foreign.append((name, owner_of[name])) + else: + unknown.append(name) + + if foreign or unknown: + print("Refusing to deploy — nothing was written.\n", file=sys.stderr) + for name, owner in foreign: + print(" '%s' belongs to '%s', not '%s'" % (name, owner, args.project), file=sys.stderr) + for name in unknown: + print(" '%s' is not in the registry. Add it to %s under an owner first." + % (name, REGISTRY), file=sys.stderr) + print("\nRegistry: " + REGISTRY, file=sys.stderr) + log_line("%s REFUSED project=%s foreign=%s unknown=%s" % ( + datetime.now(timezone.utc).isoformat(timespec="seconds"), + args.project, [n for n, _ in foreign], unknown)) + sys.exit(1) + + if args.dry_run: + print("dry-run OK — %s may deploy: %s" % (args.project, ", ".join(incoming))) + return + + # Two projects deploy into this volume and both restart the same runtime, so + # serialise from here on. Without it a second deploy can recreate the + # container while the first is mid-restart, which is exactly what turned a + # successful deploy into a failed job on 2026-07-30. + lock_fd = os.open(LOCK, os.O_CREAT | os.O_RDWR, 0o644) + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print("another deploy is in progress, waiting for it to finish...") + fcntl.flock(lock_fd, fcntl.LOCK_EX) + + os.makedirs(VOLUME, exist_ok=True) + for name in incoming: + src = os.path.join(args.src, name) + dst = os.path.join(VOLUME, name) + # Stage beside the target, then swap, so a reader never sees a half-written function. + staged = tempfile.mkdtemp(prefix=".deploy-%s-" % name, dir=VOLUME) + try: + tree = os.path.join(staged, name) + shutil.copytree(src, tree) + old = dst + ".old" + if os.path.exists(old): + shutil.rmtree(old) + if os.path.exists(dst): + os.rename(dst, old) + os.rename(tree, dst) + if os.path.exists(old): + shutil.rmtree(old) + finally: + shutil.rmtree(staged, ignore_errors=True) + + stamp = datetime.now(timezone.utc).isoformat(timespec="seconds") + log_line("%s DEPLOYED project=%s functions=%s" % (stamp, args.project, ",".join(incoming))) + print("Deployed %d function(s) for %s: %s" % (len(incoming), args.project, ", ".join(incoming))) + + if args.restart: + restart_runtime() + + +if __name__ == "__main__": + main() diff --git a/package.json b/package.json index 6fd93af..65af608 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pezkuwi-telegram-miniapp", - "version": "1.0.243", + "version": "1.0.244", "type": "module", "description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards", "author": "Pezkuwichain Team", diff --git a/src/version.json b/src/version.json index be8ebc1..887f796 100644 --- a/src/version.json +++ b/src/version.json @@ -1,5 +1,5 @@ { - "version": "1.0.243", - "buildTime": "2026-07-21T14:51:23.002Z", - "buildNumber": 1784645483003 + "version": "1.0.244", + "buildTime": "2026-07-31T17:02:14.502Z", + "buildNumber": 1785517334503 }