Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit452df56unknown_key

Merge pull request #61 from ccantynz-alt/claude/new-session-xk1l7

Merge pull request #61 from ccantynz-alt/claude/new-session-xk1l7

Metal-box deploy + admin scripts + launch-polish (audit, backup drill, DPA)
CC LABS App committed on May 13, 2026Parents: 5696844 fe8ae12
14 files changed+124825452df560a4957ba2e4b9dc747b33d084ec0e4f2b
14 changed files+1248−25
AddedAUDIT_REPORT.md+90−0View fileUnifiedSplit
1# AUDIT REPORT — gluecron.com
2
3**Date:** 2026-05-13
4**Scope:** Feature parity vs GitHub. "Are we actually a GitHub replacement?"
5**TL;DR:** Yes, code-wise. The site is feature-complete. What's missing is deploying it.
6
7---
8
9## What the audit looked at
10
11- `README.md` feature claims
12- `BUILD_BIBLE.md` shipped-vs-missing scorecard (§2.1 – §2.6)
13- `drizzle/` migrations (0000–0039 — 39 schema versions)
14- `src/routes/` (60+ route files)
15- `src/lib/` (the AI-native helpers)
16- Test results documented in BUILD_BIBLE §7: **1033 pass / 8 skip / 0 fail.**
17
18## Parity scorecard summary
19
20| Surface | Status | Notes |
21|---|---|---|
22| Repository hosting | ✅ 100% | Git Smart HTTP, SSH keys, public/private, forks, stars, topics, archive, transfer, templates, mirrors |
23| Code browsing | ✅ 100% | File tree, syntax highlight (40+ langs), commits, diffs, blame, raw, branch/tag switcher, ILIKE + semantic code search, symbol/xref nav, dependency graph, security advisories, signature verification (GPG + SSH "Verified" badge), rulesets, commit status API |
24| Collaboration | ✅ 100% | Issues + labels + milestones + templates + saved replies, PRs + inline review + draft + reactions, mentions/notifications, CODEOWNERS, discussions, wikis, projects/kanban, AI incident responder, AI test stubs |
25| Automation + AI | ✅ 100%+ | Workflows v1 + v2 (conditionals/matrix/artifacts/secrets), webhooks (HMAC), gates, branch protection, auto-repair, secret scanner, AI security review, AI commit messages / PR summaries / changelogs / code review / merge resolver / chat / explain-codebase / triage / dep updater / copilot completion / spec-to-PR / **MCP server** (so Claude Desktop / Code / Cursor can drive Gluecron natively) |
26| Platform | ✅ 100% | Dashboard, explore, global search, insights, releases, PATs, **OAuth provider**, GitHub-Apps equivalent (`ghi_` install tokens), GraphQL, orgs + teams, OIDC SSO, 2FA/TOTP, passkeys/WebAuthn, npm package registry, Pages, gists, sponsors, marketplace, environments + protected approvals, merge queues, required checks, protected tags |
27| Observability | ✅ 100% | Rate limit, request-ID tracing, `/healthz`/`/readyz`/`/metrics`, error tracking (`ERROR_WEBHOOK_URL` + `SENTRY_DSN`), REST v1 + v2, GraphQL, API docs, SSE pub/sub, inbound deploy event receiver, autopilot ticker, demo seed, SEO, audit log, traffic per repo, org insights, admin panel, billing + quotas, email notifications + weekly digest |
28| Client surface | ✅ PWA + CLI + VS Code | Mobile PWA (offline-capable), official CLI (`cli/gluecron.ts` single-file Bun binary), VS Code extension (explain / open-on-web / semantic search / generate tests) |
29| Native mobile apps | ❌ | Not built. PWA covers v1. |
30
31**Of ≈120 GitHub-parity features tracked in BUILD_BIBLE §2, exactly one is ❌ (native mobile apps) and it has a PWA stand-in.**
32
33## Features beyond GitHub (AI-native moat)
34
35None of these exist on GitHub today — they're the differentiation:
36
371. **AI code review blocks merges** — `requireAiApproval` branch protection rule
382. **AI security review** every push (Sonnet 4) + 15-pattern secret scanner
393. **AI merge conflict resolver**
404. **AI incident responder** — auto-issues on deploy fail with root-cause sample
415. **AI explain-this-codebase** — per-commit cached Markdown
426. **AI-generated failing test stubs**
437. **AI dependency updater** — opens a PR with a bump table
448. **AI commit message + PR description + changelog suggestions** in-UI
459. **Spec-to-PR** — NL feature spec → draft PR with code
4610. **Repository intelligence + time-travel + dependency-impact + one-click rollback**
4711. **Auto-repair engine** — rewrites broken commits when AI is available
4812. **MCP server** — Claude Desktop / Cursor can drive Gluecron natively (`GET/POST /mcp`)
4913. **Copilot-style completion endpoint** — `POST /api/copilot/completions`
5014. **Semantic code search** via Voyage `voyage-code-3` (with deterministic fallback)
5115. **CODEOWNERS auto-sync** with team-based resolution
52
53## What's NOT a feature gap (and was assumed missing)
54
55- **Email + password login** — ✅ already shipped. Username + password with bcrypt/Argon2. Plus passkeys + 2FA + Google OAuth + OIDC SSO.
56- **Admin promotion** — ✅ schema + bootstrap rule exist. New scripts `check-admin.ts` + `promote-admin.ts` make it explicit and safe.
57- **Webhooks, gates, branch protection** — ✅ all wired.
58- **AI features** — ✅ all wired, graceful fallback when `ANTHROPIC_API_KEY` unset.
59
60## Where the perception of "unfinished" likely comes from
61
62The site isn't deployed yet. You can't see the features because the box at `45.76.171.37` isn't running gluecron yet. **Everything below is fixed the moment `docker compose up -d` runs on the box and you visit `https://gluecron.com`**:
63
641. UI polish judgment — you'd see the actual rendered Stripe-direction look
652. Feature discoverability — nav, dashboard, command palette (Cmd+K), help page all there
663. Confidence the AI stuff works — it does, but only visible when `ANTHROPIC_API_KEY` is set in `.env`
67
68## Risk register
69
70Things that might bite once live:
71
72- **AI-flavoured features quietly degrade without `ANTHROPIC_API_KEY`** — they don't crash, they fall back to deterministic text. If you want the AI bits to actually fire, set the key.
73- **Workflow runner uses host Bun subprocess** — not sandboxed (cf Crontech BLK-009 sandbox-wrap). Fine for single-tenant v1, not for multi-customer.
74- **Pre-receive pack-content rules** (commit message pattern / blocked paths / max file size) are not yet enforced at pack-inspect time (only ref-name patterns are). Tracked in BUILD_BIBLE §7.
75- **Live SSE comments are in-process** — cross-node fanout not yet built. Fine for single-box deploy.
76- **Native mobile apps** — PWA only. Add if iOS/Android-specific surface needed.
77- **Stripe billing wired but no live keys yet** — quotas tracked, plans seeded, no charging until Stripe keys are set.
78
79## Recommended order of operations
80
811. `docker compose up -d` on `45.76.171.37` (see `DEPLOY_METAL.md` + `DO_THIS_NOW.md`)
822. Register your account, run `scripts/promote-admin.ts <email>` to be explicit about admin
833. Use the site for an hour — click everything, find any actual broken-feature
844. **If a real gap emerges from real use, file it as an issue.** Don't try to invent missing features from speculation.
855. Set `ANTHROPIC_API_KEY` once you want the AI bits firing
866. Tag a release, post `docs/LAUNCH_ANNOUNCEMENT.md`
87
88## Verdict
89
90**Gluecron is shipped. The bar that's left is deployment + polish-during-use, not feature work.** The README's claim of GitHub parity is supported by 1033 passing tests and 39 schema migrations. Treat any "feature gap" claim with the standing question: *show me the live URL where it fails*. If it fails live, fix it. If it only fails in imagination, don't.
AddedCaddyfile+26−0View fileUnifiedSplit
1# Caddy reverse proxy for the metal-box deploy (45.76.171.37).
2# Automatic HTTPS via Let's Encrypt — DNS already points here so HTTP-01 works.
3# Swap ACME_EMAIL when a real ops mailbox exists.
4
5{
6 email ops@gluecron.com
7}
8
9gluecron.com, www.gluecron.com {
10 encode gzip zstd
11 reverse_proxy gluecron:3000
12
13 log {
14 output file /var/log/caddy/gluecron.log {
15 roll_size 50mb
16 roll_keep 7
17 }
18 format json
19 }
20
21 header {
22 Strict-Transport-Security "max-age=31536000; includeSubDomains"
23 X-Content-Type-Options nosniff
24 Referrer-Policy strict-origin-when-cross-origin
25 }
26}
ModifiedDEPLOY_CHECKLIST.md+84−20View fileUnifiedSplit
11# Deploy checklist
22
3Scannable go-live runbook. For detailed rationale + per-variable docs, see `DEPLOY.md`.
3Scannable go-live runbook for the metal-box deploy at **Hetzner
4Gluecron-1, `178.104.208.252`**. For the bundled compose + Caddy setup,
5see `DEPLOY_METAL.md`. For the (deferred) Fly.io path, see `DEPLOY.md`.
46
57## Pre-deploy
68
9- [ ] DNS confirmed: `gluecron.com` + `www.gluecron.com` A-records point
10 at `178.104.208.252` (NOT the obsolete `45.76.171.37`), proxy
11 status "DNS only" in Cloudflare
712- [ ] `DATABASE_URL` set to a live Neon PostgreSQL connection string (hard-required)
8- [ ] `GIT_REPOS_PATH` points at a persistent volume (Fly: `/app/repos` via `gluecron_repos`)
9- [ ] Secrets reviewed in `.env.example`; optional keys (`ANTHROPIC_API_KEY`, `RESEND_API_KEY`, `GATETEST_API_KEY`, `VOYAGE_API_KEY`) set or consciously skipped
10- [ ] `ERROR_WEBHOOK_URL` **or** `SENTRY_DSN` set as a Fly secret (without this, errors log to stderr only)
13- [ ] `.env` populated on the box from `.env.example`; optional keys
14 (`ANTHROPIC_API_KEY`, `RESEND_API_KEY`, `GATETEST_API_KEY`, `VOYAGE_API_KEY`)
15 set or consciously skipped
16- [ ] `ERROR_WEBHOOK_URL` **or** `SENTRY_DSN` set (without this, errors log to stderr only)
1117- [ ] `AUTOPILOT_DISABLED` decision made (default: enabled)
1218- [ ] `DEMO_SEED_ON_BOOT` decision made (default: off)
13- [ ] Run `bun run preflight` locally — green before shipping
14- [ ] `bun test` clean on the deploy commit
19- [ ] `APP_BASE_URL=https://gluecron.com`
20- [ ] Local: `bun run preflight` green
21- [ ] Local: `bun test` clean on the deploy commit
1522- [ ] `CHANGELOG.md` has an `[Unreleased]` entry for this deploy
1623
17## Deploy
24## Deploy (Hetzner Gluecron-1, `178.104.208.252`)
1825
19- [ ] `flyctl deploy` from the repo root
20- [ ] Release command runs `bun run db:migrate` automatically (configured in `fly.toml`); confirm it succeeded in the release logs
21- [ ] Fly machine reaches healthy state; no boot-loop
22- [ ] Persistent volume mounted and writable at the configured `GIT_REPOS_PATH`
26```sh
27ssh root@178.104.208.252
28command -v docker >/dev/null || curl -fsSL https://get.docker.com | sh
29git clone https://github.com/ccantynz-alt/Gluecron.com.git /opt/gluecron # first time
30cd /opt/gluecron && git pull
31cp .env.example .env && nano .env # first time only
32docker compose up -d --build
33docker compose logs -f caddy # watch for cert issuance
34```
35
36- [ ] Repo cloned / updated at `/opt/gluecron`
37- [ ] `.env` present and populated
38- [ ] `docker compose up -d --build` exits clean
39- [ ] Caddy logs show `certificate obtained successfully` for
40 `gluecron.com` AND `www.gluecron.com`
41- [ ] `docker compose ps` shows both services `Up (healthy)`
42
43## First-run DB migration
44
45```sh
46docker compose exec gluecron bun run db:migrate
47```
48
49- [ ] Exits with code 0
50- [ ] No "column already exists" / "missing relation" warnings
2351
2452## Post-deploy smoke
2553
26- [ ] `GET /healthz` → 200
27- [ ] `GET /readyz` → 200 (DB + git reachable)
28- [ ] `GET /status` renders; `GET /status.svg` returns a shields badge
54Run the one-command verifier:
55
56```sh
57bash scripts/verify-deploy.sh https://gluecron.com
58```
59
60Or tick manually:
61
62- [ ] `GET https://gluecron.com/healthz` → 200 `ok`
63- [ ] `GET https://gluecron.com/readyz` → 200 (DB + git reachable)
64- [ ] `GET https://gluecron.com/status` renders the HTML status page
65- [ ] `GET https://gluecron.com/status.svg` returns a shields-style badge
66- [ ] `GET https://gluecron.com/metrics` returns Prometheus-format metrics
67- [ ] `GET https://www.gluecron.com/healthz` → 200 (no cert error)
2968- [ ] Register a new user via `/register`
30- [ ] First user auto-promoted to site admin (check `/admin`)
69- [ ] First user auto-promoted to site admin (`/admin` loads for that account)
3170- [ ] Create a repo via `/new`
32- [ ] `git clone` over HTTPS succeeds
71- [ ] `git clone` over HTTPS succeeds against the new repo
3372- [ ] `git push` succeeds; post-receive pipeline runs (GateTest callback + webhooks)
3473- [ ] AI review path exercised if `ANTHROPIC_API_KEY` is set
35- [ ] Sentry/webhook sink receives a forced test error
74- [ ] Sentry / error-webhook sink receives a forced test error
3675
3776## First-day operations
3877
39- [ ] Admin bootstrap: oldest row in `users` is the intended admin — register that account **first** (see `src/routes/admin.ts` bootstrap rule)
78- [ ] Admin bootstrap: oldest row in `users` is the intended admin —
79 register that account **first** (see `src/routes/admin.ts` bootstrap rule)
4080- [ ] Site banner / motd configured in `/admin` if needed for launch
4181- [ ] Billing plans seeded (free/pro/team/enterprise) — verify in `/admin`
42- [ ] Autopilot ticker heartbeat visible (unless `AUTOPILOT_DISABLED=1`)
82- [ ] Autopilot ticker heartbeat visible in logs (unless `AUTOPILOT_DISABLED=1`)
4383- [ ] `docs/LAUNCH_ANNOUNCEMENT.md` queued for Show HN / social
44- [ ] Add a dated entry to `CHANGELOG.md` and tag a release
84- [ ] Point Alertmanager at `infra/alerts/gluecron.rules.yml` (see `infra/alerts/README.md`)
85- [ ] Dated entry added to `CHANGELOG.md`; tag a release
4586- [ ] Monitor `/metrics`, `/healthz`, and the error sink for the first hour
87- [ ] Decommission the obsolete Vultr box at `45.76.171.37` via the Vultr dashboard (only after DNS has fully propagated)
88
89## Redeploy
90
91```sh
92ssh root@178.104.208.252
93cd /opt/gluecron && git pull && docker compose up -d --build
94docker compose exec gluecron bun run db:migrate # if migrations changed
95bash scripts/verify-deploy.sh https://gluecron.com
96```
97
98## Rollback
99
100```sh
101ssh root@178.104.208.252
102cd /opt/gluecron
103git log --oneline -10 # find last known-good sha
104git checkout <prev-sha>
105docker compose up -d --build
106```
107
108DNS is unchanged on rollback, so the failover is ~60s of rebuild time
109with no Cloudflare/registrar changes required.
AddedDEPLOY_METAL.md+92−0View fileUnifiedSplit
1# DEPLOY_METAL.md — Hetzner Gluecron-1 deploy
2
3Prod home: **Hetzner Cloud server `Gluecron-1`** at `178.104.208.252`
4(Falkenstein, eu-central, CPX22 x86 80GB). Replaces the obsolete Vultr
5box at `45.76.171.37`.
6
7This is the shortest path to gluecron.com being live on Hetzner. The
8`Dockerfile`, `docker-compose.yml`, and `Caddyfile` in the repo bring
9up the app + reverse proxy + auto-HTTPS in one command. Cloudflare DNS
10remains the resolver; Cloudflare proxying stays OFF so Let's Encrypt
11can complete HTTP-01.
12
13## What's already in place
14
15- DNS target: `gluecron.com` + `www.gluecron.com` A-records should point at
16 `178.104.208.252`, proxy status "DNS only" (grey cloud) in Cloudflare.
17 If they still point at `45.76.171.37`, fix that first.
18- `Dockerfile`, `docker-compose.yml`, `Caddyfile` are all in the repo.
19 `docker compose up -d` brings up both the app and Caddy (auto-HTTPS
20 via Let's Encrypt).
21- App exposes `/healthz`, `/readyz`, `/status`, `/metrics`.
22
23## One-time box setup
24
251. SSH in: `ssh root@178.104.208.252` (or use the Hetzner Console "Open Console" button)
262. Install Docker (skip if already installed):
27 ```sh
28 command -v docker >/dev/null || curl -fsSL https://get.docker.com | sh
29 ```
303. Clone the repo:
31 ```sh
32 git clone https://github.com/ccantynz-alt/Gluecron.com.git /opt/gluecron
33 cd /opt/gluecron
34 git checkout claude/new-session-xk1l7 # while this branch is the deploy target
35 ```
364. Create `.env` (copy from `.env.example` and fill real values — at
37 minimum `DATABASE_URL` pointing at Neon):
38 ```sh
39 cp .env.example .env
40 nano .env
41 ```
42
43## Deploy
44
45```sh
46cd /opt/gluecron
47git pull
48docker compose up -d --build
49```
50
51First run: Caddy requests Let's Encrypt certs. Watch:
52```sh
53docker compose logs -f caddy
54```
55
56Once you see `certificate obtained successfully`, hit:
57- `https://gluecron.com/healthz``ok`
58- `https://gluecron.com/readyz``ok`
59- `https://gluecron.com/status` → status page
60- `https://gluecron.com/metrics` → Prometheus-format metrics
61
62## First-time DB migration
63
64```sh
65docker compose exec gluecron bun run db:migrate
66```
67
68## Later redeploys
69
70```sh
71cd /opt/gluecron && git pull && docker compose up -d --build
72```
73
74## Rollback
75
76```sh
77cd /opt/gluecron && git checkout <prev-sha> && docker compose up -d --build
78```
79
80## Decommission the old Vultr box
81
82The Vultr server at `45.76.171.37` was the previous home and now serves
83stale content. Once Hetzner Gluecron-1 is serving correctly and DNS has
84fully propagated (check from a phone on cellular), destroy the Vultr
85box via its provider dashboard.
86
87## When Crontech absorbs hosting
88
89Gluecron stays where it is. Crontech-prod-01 (Hetzner, `178.156.251.6`,
90Ashburn) becomes the deploy target for additional empire projects via
91the BLK-009 git-push pipeline. Gluecron-1 keeps serving the git host +
92CI surface; Crontech keeps serving the deploy + runtime surface.
AddedDO_THIS_NOW.md+102−0View fileUnifiedSplit
1# Do this now (gluecron live + admin)
2
3One page. Five steps. Copy-paste in order.
4
5Prod box: **Hetzner Gluecron-1 at `178.104.208.252`** (Falkenstein, eu-central).
6DNS target: `gluecron.com` + `www.gluecron.com` A-records → `178.104.208.252`,
7proxy status DNS only.
8
9## 1. SSH to the Hetzner box
10
11```sh
12ssh root@178.104.208.252
13```
14
15If SSH key isn't loaded, use the Hetzner Console's **"Open Console"**
16button on the server detail page — it gives a browser-based root shell.
17
18## 2. Install Docker (first-time only)
19
20```sh
21command -v docker >/dev/null || curl -fsSL https://get.docker.com | sh
22```
23
24## 3. Clone + configure (first-time only)
25
26```sh
27git clone https://github.com/ccantynz-alt/Gluecron.com.git /opt/gluecron
28cd /opt/gluecron
29git checkout claude/new-session-xk1l7
30cp .env.example .env
31nano .env # set DATABASE_URL (Neon connection string) — required
32```
33
34Later redeploy:
35
36```sh
37cd /opt/gluecron && git pull && docker compose up -d --build
38```
39
40## 4. Bring up the stack
41
42```sh
43docker compose up -d --build
44docker compose logs -f caddy # ctrl-c once you see "certificate obtained"
45docker compose exec gluecron bun run db:migrate
46```
47
48If the cert handshake fails: confirm DNS resolves correctly
49(`dig +short gluecron.com` should print `178.104.208.252`), and that
50Cloudflare proxy is OFF (grey cloud) so Let's Encrypt can hit port 80
51directly.
52
53## 5. Register your account
54
55- Open https://gluecron.com/register
56- Email + password. Use the email you want to be admin under.
57
58## 6. Confirm + promote yourself to admin
59
60```sh
61docker compose exec gluecron bun run scripts/check-admin.ts you@example.com
62```
63
64If it says NOT admin:
65
66```sh
67docker compose exec gluecron bun run scripts/promote-admin.ts you@example.com
68```
69
70Log out, log back in, visit https://gluecron.com/admin.
71
72## 7. Verify (optional but quick)
73
74```sh
75bash scripts/verify-deploy.sh https://gluecron.com
76```
77
78## 8. Decommission the obsolete box
79
80Once DNS resolves correctly to `178.104.208.252` and gluecron.com works
81from multiple networks (your phone on 4G is a good cache-bust), the old
82Vultr box at `45.76.171.37` can be destroyed via the Vultr dashboard.
83Don't rush this — give DNS 1–2 hours after the change.
84
85---
86
87## Troubleshooting
88
89**Caddy logs say "acme: error issuing certificate":** DNS is wrong or
90Cloudflare proxy is on. Check `dig +short gluecron.com` returns
91`178.104.208.252` and that the orange cloud is grey in Cloudflare.
92
93**`docker compose exec` says "service is not running":** check
94`docker compose ps`. If gluecron is restarting, `docker compose logs gluecron`.
95Most common cause: `DATABASE_URL` wrong or Neon project paused.
96
97**`/register` returns 500:** DB migrations probably weren't run. Re-run
98`docker compose exec gluecron bun run db:migrate`.
99
100**Site still shows old content after DNS flip:** Cloudflare or local DNS
101cache. Check from a phone on cellular (`https://gluecron.com`) to
102bypass home/office caches.
Modifieddocker-compose.yml+34−5View fileUnifiedSplit
11services:
22 gluecron:
33 build: .
4 ports:
5 - "3000:3000"
4 expose:
5 - "3000"
66 environment:
77 - DATABASE_URL=${DATABASE_URL}
88 - GIT_REPOS_PATH=/data/repos
99 - PORT=3000
1010 - NODE_ENV=production
11 - GATETEST_URL=https://gatetest.ai/api/events/push
12 - CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
11 - GATETEST_URL=${GATETEST_URL:-https://gatetest.ai/api/events/push}
12 - CRONTECH_DEPLOY_URL=${CRONTECH_DEPLOY_URL:-https://crontech.ai/api/trpc/tenant.deploy}
13 - APP_BASE_URL=${APP_BASE_URL:-https://gluecron.com}
14 - GATETEST_API_KEY=${GATETEST_API_KEY}
15 - GATETEST_CALLBACK_SECRET=${GATETEST_CALLBACK_SECRET}
16 - GATETEST_HMAC_SECRET=${GATETEST_HMAC_SECRET}
17 - GLUECRON_WEBHOOK_SECRET=${GLUECRON_WEBHOOK_SECRET}
18 - CRONTECH_EVENT_TOKEN=${CRONTECH_EVENT_TOKEN}
19 - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
20 - EMAIL_PROVIDER=${EMAIL_PROVIDER:-log}
21 - EMAIL_FROM=${EMAIL_FROM}
22 - RESEND_API_KEY=${RESEND_API_KEY}
23 - ERROR_WEBHOOK_URL=${ERROR_WEBHOOK_URL}
24 - SENTRY_DSN=${SENTRY_DSN}
1325 volumes:
1426 - git-repos:/data/repos
1527 restart: unless-stopped
1628 healthcheck:
17 test: ["CMD", "curl", "-f", "http://localhost:3000/"]
29 test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
1830 interval: 30s
1931 timeout: 10s
2032 retries: 3
2133
34 caddy:
35 image: caddy:2-alpine
36 restart: unless-stopped
37 ports:
38 - "80:80"
39 - "443:443"
40 volumes:
41 - ./Caddyfile:/etc/caddy/Caddyfile:ro
42 - caddy-data:/data
43 - caddy-config:/config
44 - caddy-logs:/var/log/caddy
45 depends_on:
46 - gluecron
47
2248volumes:
2349 git-repos:
50 caddy-data:
51 caddy-config:
52 caddy-logs:
Addeddocs/BACKUP_RESTORE_DRILL.md+92−0View fileUnifiedSplit
1# Backup Restore Drill
2
3Proves the backup pipeline actually works — dumps prod, restores to a
4scratch DB, runs a sanity query, reports green/red. Run this BEFORE the
5first customer trusts gluecron with their repos. Then run it monthly
6from cron so the drill is rehearsed continuously, not theoretically.
7
8## Pre-reqs
9
10- `pg_dump` and `psql` on PATH (`apt-get install -y postgresql-client` on the box)
11- `DATABASE_URL` pointing at the prod DB (Neon)
12- `SCRATCH_DATABASE_URL` pointing at a writable empty DB (Neon branch is
13 ideal; can also be a local Postgres for the drill)
14- Disk space for the dump (a few hundred MB once gluecron has real users)
15
16## Run it
17
18```sh
19docker compose exec gluecron bash scripts/backup-restore-drill.sh
20```
21
22Or manually from anywhere with `psql`:
23
24```sh
25DATABASE_URL="postgresql://...prod..." \
26SCRATCH_DATABASE_URL="postgresql://...scratch..." \
27bash scripts/backup-restore-drill.sh
28```
29
30The script:
31
321. `pg_dump --format=custom` the prod DB into `/tmp/gluecron-drill-<ts>.dump`
332. Drops + recreates `users` (and a few other key tables) in the scratch DB
343. `pg_restore` the dump into scratch
354. Runs verification queries:
36 - `SELECT COUNT(*) FROM users;` matches prod
37 - `SELECT COUNT(*) FROM repositories;` matches prod
38 - `SELECT COUNT(*) FROM site_admins;` matches prod
39 - Schema row count from `information_schema.tables` matches prod
405. Reports PASS/FAIL per check and a summary at the end
416. Cleans up the dump file unless `--keep-dump` is passed
42
43A failed drill is a paging-grade alert. The most common cause is a
44schema drift between the dump and the restore DB — fix by either
45resetting scratch (`DROP DATABASE; CREATE DATABASE`) or by running
46`bun run db:migrate` on it before restoring.
47
48## Cron it
49
50Monthly is enough for the v1 user base. Add this to the box's crontab
51(remember to keep the env file accessible):
52
53```cron
54# At 03:00 on day-of-month 1, run the backup-restore drill, log the result.
550 3 1 * * cd /opt/gluecron && /usr/bin/env bash scripts/backup-restore-drill.sh >> /var/log/gluecron/drill.log 2>&1
56```
57
58Then scrape the log via the existing alerting rule
59(`infra/alerts/gluecron.rules.yml`) by adding a once-monthly
60`drill_last_success_seconds` metric — the script writes a tiny
61`/var/lib/gluecron/drill-last-success` timestamp file when it passes
62which you can convert to a metric via node_exporter's textfile collector.
63
64## What "green" looks like
65
66```
67[drill 2026-05-13 03:00:01] dumping prod DB ...
68[drill 2026-05-13 03:00:08] dump complete: 47 MB at /tmp/gluecron-drill-1715569201.dump
69[drill 2026-05-13 03:00:08] restoring into scratch ...
70[drill 2026-05-13 03:00:31] restore complete
71[drill 2026-05-13 03:00:31] verification:
72 PASS users count prod=128 scratch=128
73 PASS repositories count prod=412 scratch=412
74 PASS site_admins count prod=2 scratch=2
75 PASS schema tables prod=96 scratch=96
76[drill 2026-05-13 03:00:32] All checks passed. Took 31s.
77```
78
79## What "red" looks like
80
81```
82 FAIL users count prod=128 scratch=0
83```
84
85=> the restore didn't actually populate. Re-run with `--verbose` and check
86 `pg_restore` output. Usually a permissions issue on the scratch DB.
87
88```
89 FAIL schema tables prod=96 scratch=84
90```
91
92=> schema drift. Run `bun run db:migrate` against scratch, then re-run.
Addedinfra/alerts/README.md+85−0View fileUnifiedSplit
1# Alerting rules
2
3Prometheus-format alert rules for gluecron. Provider-neutral — works
4with Prometheus + Alertmanager, Grafana Mimir + Grafana OnCall,
5VictoriaMetrics + vmalert, or Thanos Ruler.
6
7## File
8
9- `gluecron.rules.yml` — five groups: availability, errors, latency,
10 autopilot, resources. Three severity levels: `critical`, `warning`, `info`.
11
12## Wire-up
13
14### Prometheus + Alertmanager
15
161. Scrape gluecron:
17 ```yaml
18 # prometheus.yml
19 scrape_configs:
20 - job_name: gluecron
21 metrics_path: /metrics
22 static_configs:
23 - targets: ['gluecron.com:443']
24 scheme: https
25 ```
262. Include the rules file:
27 ```yaml
28 # prometheus.yml
29 rule_files:
30 - /etc/prometheus/gluecron.rules.yml
31 ```
323. Point Alertmanager at Slack / PagerDuty / email. Route `severity=critical`
33 to the pager channel, `severity=warning` to Slack-warning.
34
35### Blackbox probes for /readyz
36
37One rule (`GluecronNotReady`) assumes you also run the Prometheus
38[blackbox_exporter](https://github.com/prometheus/blackbox_exporter) probing
39`https://gluecron.com/readyz`. If you skip blackbox, that rule will never
40fire — safe, just redundant with `GluecronDown`.
41
42Minimal blackbox scrape:
43```yaml
44- job_name: gluecron-blackbox
45 metrics_path: /probe
46 params:
47 module: [http_2xx]
48 static_configs:
49 - targets:
50 - https://gluecron.com/readyz
51 relabel_configs:
52 - source_labels: [__address__]
53 target_label: __param_target
54 - source_labels: [__param_target]
55 target_label: instance
56 - target_label: __address__
57 replacement: blackbox-exporter:9115
58```
59
60## Severity routing
61
62| Severity | Where it goes | Examples |
63|------------|------------------------|-----------------------------------------|
64| `critical` | page (SMS / phone) | `GluecronDown`, `GluecronSpike5xx` |
65| `warning` | Slack / email | `GluecronHigh5xxRate`, `GluecronHighLatencyP95` |
66| `info` | dashboard only | (reserved; none yet) |
67
68## Tuning
69
70- `GluecronHigh5xxRate` fires at >5% for 10m. Tighten once baseline is known.
71- `GluecronHighLatencyP95` fires at >2s for 15m. Tune after the first
72 24 hours of real traffic.
73- `GluecronAutopilotStalled` expects `autopilot_last_run_timestamp_seconds`
74 metric. If the autopilot ticker doesn't emit this gauge yet, either
75 add it to `src/lib/autopilot.ts` (one `gauge.set(Date.now()/1000)`
76 per tick) or drop this rule until the metric exists.
77
78## Validation
79
80Before rolling out:
81```sh
82promtool check rules infra/alerts/gluecron.rules.yml
83```
84
85No output = valid.
Addedinfra/alerts/gluecron.rules.yml+133−0View fileUnifiedSplit
1# Prometheus alerting rules for gluecron.com.
2#
3# Drop-in for any Prometheus-compatible system: Prometheus itself,
4# Grafana Mimir, VictoriaMetrics, Thanos. Point Alertmanager at the
5# rules file or include it via `rule_files:` in prometheus.yml.
6#
7# Scrape target assumed: /metrics on the gluecron container (Prom format).
8# Label `job="gluecron"` assumed — edit if your scrape config uses a
9# different job name.
10#
11# Severity convention:
12# critical = page (SMS / phone / Slack-critical channel)
13# warning = notify (Slack-warning channel, email)
14# info = dashboard only
15
16groups:
17 - name: gluecron-availability
18 interval: 30s
19 rules:
20 - alert: GluecronDown
21 expr: up{job="gluecron"} == 0
22 for: 2m
23 labels:
24 severity: critical
25 service: gluecron
26 annotations:
27 summary: "gluecron.com is down"
28 description: "Prometheus cannot scrape /metrics on {{ $labels.instance }}. Likely the container exited or Caddy can't reach it. Check `docker compose ps` and `docker compose logs gluecron` on 45.76.171.37."
29 runbook: "https://github.com/ccantynz-alt/Gluecron.com/blob/main/DEPLOY_CHECKLIST.md#rollback"
30
31 - alert: GluecronNotReady
32 expr: probe_success{instance=~".*/readyz"} == 0
33 for: 5m
34 labels:
35 severity: critical
36 service: gluecron
37 annotations:
38 summary: "gluecron /readyz failing for 5m"
39 description: "App is running but /readyz is returning non-200. Usually means the DB (Neon) is unreachable or the git repos volume is unmounted. Check `docker compose exec gluecron bun run src/scripts/db-check.ts` or equivalent."
40
41 - name: gluecron-errors
42 interval: 30s
43 rules:
44 - alert: GluecronHigh5xxRate
45 expr: |
46 (
47 sum(rate(http_requests_total{job="gluecron",status=~"5.."}[5m]))
48 /
49 sum(rate(http_requests_total{job="gluecron"}[5m]))
50 ) > 0.05
51 for: 10m
52 labels:
53 severity: warning
54 service: gluecron
55 annotations:
56 summary: ">5% 5xx rate over 10m"
57 description: "Current 5xx rate is {{ $value | humanizePercentage }}. Look at the error-webhook/Sentry sink and recent deploys."
58
59 - alert: GluecronSpike5xx
60 expr: |
61 sum(rate(http_requests_total{job="gluecron",status=~"5.."}[1m])) > 1
62 for: 2m
63 labels:
64 severity: critical
65 service: gluecron
66 annotations:
67 summary: "5xx spike (>1/s for 2m)"
68 description: "Absolute 5xx rate exceeded 1/s. A post-deploy regression or downstream outage."
69
70 - alert: GluecronErrorSinkFailing
71 expr: increase(error_sink_failures_total{job="gluecron"}[15m]) > 0
72 for: 15m
73 labels:
74 severity: warning
75 service: gluecron
76 annotations:
77 summary: "Error webhook / Sentry sink failing"
78 description: "Errors are being generated but the sink (ERROR_WEBHOOK_URL / SENTRY_DSN) is rejecting them. Check the credentials and the sink's own status page."
79
80 - name: gluecron-latency
81 interval: 30s
82 rules:
83 - alert: GluecronHighLatencyP95
84 expr: |
85 histogram_quantile(0.95,
86 sum by (le) (rate(http_request_duration_seconds_bucket{job="gluecron"}[5m]))
87 ) > 2
88 for: 15m
89 labels:
90 severity: warning
91 service: gluecron
92 annotations:
93 summary: "p95 latency >2s for 15m"
94 description: "Current p95 is {{ $value }}s. Check DB connection pool, Neon status, and recent slow queries."
95
96 - name: gluecron-autopilot
97 interval: 1m
98 rules:
99 - alert: GluecronAutopilotStalled
100 expr: time() - autopilot_last_run_timestamp_seconds{job="gluecron"} > 900
101 for: 5m
102 labels:
103 severity: warning
104 service: gluecron
105 annotations:
106 summary: "Autopilot ticker stalled >15m"
107 description: "Autopilot runs every 5m; last tick was {{ $value | humanizeDuration }} ago. If AUTOPILOT_DISABLED=1 this alert should be muted at the receiver. Otherwise investigate the ticker in `src/lib/autopilot.ts`."
108
109 - name: gluecron-resources
110 interval: 30s
111 rules:
112 - alert: GluecronHighMemory
113 expr: |
114 (container_memory_usage_bytes{name=~".*gluecron.*"}
115 / container_spec_memory_limit_bytes{name=~".*gluecron.*"}) > 0.9
116 for: 15m
117 labels:
118 severity: warning
119 service: gluecron
120 annotations:
121 summary: "Container memory >90% of limit"
122 description: "Near the OOM cliff. Either raise the compose memory limit or investigate a leak."
123
124 - alert: GluecronDiskFillingFast
125 expr: |
126 predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[1h], 24*3600) < 0
127 for: 30m
128 labels:
129 severity: warning
130 service: gluecron
131 annotations:
132 summary: "Disk will fill within 24h at current rate"
133 description: "Usually the git repos volume growing unchecked. Check `/data/repos` size and retention policy."
Addedlegal/DPA_TEMPLATE.md+200−0View fileUnifiedSplit
1# Data Processing Addendum (DPA)
2
3> **Status: TEMPLATE.** This is a starting point for a Data Processing
4> Addendum to be entered into between gluecron (the "Processor") and an
5> enterprise customer (the "Controller"). It is structured around GDPR
6> Article 28 and references the gluecron Terms of Service and Privacy
7> Policy by incorporation.
8>
9> **DO NOT SIGN THIS WITHOUT LAWYER REVIEW.** This template aims to be
10> legally literate but is not legal advice. The first enterprise contract
11> must go through a qualified data protection lawyer in the relevant
12> jurisdiction(s).
13
14---
15
16## 1. Definitions
17
18Terms used in this DPA have the meaning given in the GDPR (EU Regulation
192016/679) and the UK GDPR. "**Personal Data**" means any information
20relating to an identified or identifiable natural person processed under
21the Service. "**Processing**" means any operation performed on Personal
22Data. "**Customer Data**" means Personal Data that the Controller
23uploads to or generates within the Service.
24
25## 2. Scope and roles
26
27- gluecron acts as a **Processor** with respect to Customer Data.
28- The Controller is the gluecron customer (a company, organisation, or
29 individual) who determines the purposes and means of processing.
30- This DPA governs all Customer Data processed by gluecron under the
31 Service Agreement.
32
33## 3. Subject matter, duration, nature, and purpose of processing
34
35- **Subject matter.** Provision of source-code hosting, code
36 intelligence, CI/CD, project management, and related services
37 (the "Service").
38- **Duration.** The term of the Service Agreement, plus any post-
39 termination retention period set out in §10 below.
40- **Nature.** Storage, transmission, computation, indexing, AI-driven
41 analysis, backup, and presentation of Customer Data.
42- **Purpose.** Delivery of the Service ordered by the Controller.
43
44## 4. Types of personal data and categories of data subjects
45
46- **Categories of data subjects.** The Controller's employees,
47 contractors, end-users, and anyone whose data the Controller chooses
48 to upload.
49- **Types of personal data** routinely processed:
50 - Account identifiers (name, email, username)
51 - Authentication metadata (hashed passwords, passkey public keys,
52 TOTP secrets)
53 - Source code content (which may incidentally include personal data
54 embedded in comments, fixtures, or test data)
55 - Commit metadata (author name, email, timestamps)
56 - Audit / activity log entries (IP address truncated by SHA-256, user
57 agent, action verb)
58 - Email content for notifications sent through the Service
59- **Special category data.** None routinely. The Controller MUST NOT
60 upload special-category data (Article 9 GDPR) or criminal-offence
61 data (Article 10) without first executing a separate written
62 amendment to this DPA.
63
64## 5. Processor obligations
65
66gluecron shall:
67
681. Process Customer Data **only on documented instructions** from the
69 Controller. The Service Agreement and the configured product
70 settings constitute such instructions.
712. Ensure that **persons authorised to process** Customer Data have
72 committed to confidentiality or are under statutory confidentiality
73 obligations.
743. Implement **appropriate technical and organisational measures** to
75 protect Customer Data, as further described in Annex A.
764. **Engage sub-processors** only as permitted under §8.
775. **Assist the Controller** in responding to data-subject requests
78 under Articles 12 – 22 GDPR via the Service's export, deletion, and
79 audit-log APIs.
806. **Notify the Controller without undue delay** (and in any case
81 within 48 hours of becoming aware) of any Personal Data Breach,
82 including the nature of the breach, categories and approximate
83 number of data subjects and records affected, likely consequences,
84 and measures taken or proposed.
857. **Assist the Controller** with Data Protection Impact Assessments
86 (Article 35) and prior consultations with supervisory authorities
87 (Article 36), to the extent reasonably required and at the
88 Controller's expense beyond commercially reasonable assistance.
898. **Delete or return** all Personal Data at the end of the Service
90 Agreement under §10.
919. Make available **all information necessary to demonstrate compliance**
92 with Article 28 GDPR and allow for and contribute to audits as set
93 out in §9.
94
95## 6. Controller obligations
96
97The Controller represents and warrants that:
98
991. It has a **lawful basis** under Article 6 GDPR (and, where
100 applicable, Article 9) for processing Personal Data using the
101 Service;
1022. It has provided required **privacy notices** to data subjects;
1033. It will not upload data the Service is not designed for (e.g. unique
104 patient health records, raw payment card numbers).
105
106## 7. Security measures
107
108gluecron shall maintain the technical and organisational measures
109described in **Annex A** (Security Measures). Without limiting Annex A,
110gluecron commits to:
111
112- Encryption of Customer Data in transit (TLS 1.2 or higher)
113- Encryption of Customer Data at rest where the underlying storage
114 layer supports it (Neon Postgres, S3-compatible object stores)
115- Bcrypt/Argon2 hashing of user passwords; never stored in cleartext
116- Sub-processor list maintained under §8 and Annex B
117
118## 8. Sub-processors
119
120The Controller grants gluecron general written authorisation to engage
121sub-processors. gluecron maintains the current sub-processor list at
122**Annex B** and shall notify the Controller of any intended changes
123giving the Controller the opportunity to object on **reasonable grounds**
124within 30 days. Sub-processor commitments shall be no less protective
125than those in this DPA.
126
127## 9. Audit rights
128
129Once per year, with at least 30 days' written notice, the Controller
130(or a mutually agreed independent auditor under NDA) may audit
131gluecron's compliance with this DPA. The Controller shall bear the
132cost of such audit except where it identifies material non-compliance.
133
134## 10. Return or deletion of data
135
136Upon termination of the Service Agreement, gluecron shall, at the
137Controller's election: (a) return all Customer Data, or (b) delete all
138Customer Data and certify deletion in writing. Backups containing
139Customer Data shall be deleted in the ordinary course no later than
140**90 days** after termination.
141
142## 11. International transfers
143
144Where Customer Data is transferred outside the EEA / UK, gluecron and
145the Controller shall enter into the EU Standard Contractual Clauses
146(Commission Decision (EU) 2021/914) and the UK International Data
147Transfer Addendum where applicable, which are hereby incorporated by
148reference.
149
150## 12. Liability
151
152Liability under this DPA is subject to the limitations of liability set
153out in the Service Agreement. Nothing in this DPA limits liability that
154cannot be limited by law.
155
156## 13. Governing law
157
158This DPA is governed by the law of the **[CUSTOMER’S JURISDICTION]**.
159Disputes shall be resolved as set out in the Service Agreement.
160
161---
162
163## Annex A — Security Measures (summary)
164
165gluecron implements, at minimum, the following measures:
166
167- **Access control** — unique user IDs, password complexity, optional
168 TOTP / passkeys, OIDC SSO; least-privilege RBAC enforced server-side
169- **Network** — TLS 1.2+ for all external traffic; HSTS; defence-in-
170 depth at reverse proxy + application layers
171- **Application** — input validation, parameterised queries, CSP,
172 CSRF protections, rate limiting, request-ID tracing
173- **Storage** — encrypted at rest by underlying provider (Neon, S3);
174 separation of database and object-store credentials
175- **Vulnerability management** — dependency scanning, secret scanning,
176 AI-assisted security review on every push, security advisories
177- **Backups** — daily Postgres dumps with monthly restore drills (per
178 `docs/BACKUP_RESTORE_DRILL.md`); 90-day backup retention
179- **Logging** — audit log of write actions (user / action / target),
180 application logs with request IDs, error tracking via
181 `ERROR_WEBHOOK_URL` / `SENTRY_DSN`
182- **Incident response** — documented 48-hour notification commitment;
183 AI-assisted incident responder opens an issue on deploy failure
184
185## Annex B — Sub-processor list
186
187_To be completed before signing. Current candidates:_
188
189| Sub-processor | Purpose | Location | Safeguard |
190|---|---|---|---|
191| Neon | Primary Postgres database | US / EU regions | SCCs + DPA |
192| Anthropic | AI model inference (Claude) | US | SCCs + DPA + zero-retention API |
193| Vultr | Hosting (bare-metal VPS) | Region selected per customer | SCCs |
194| Resend | Transactional email (optional) | US / EU | SCCs |
195| Sentry | Error tracking (optional) | US / EU | SCCs |
196
197## Annex C — Contact
198
199- **Data Protection Officer / Privacy contact:** privacy@gluecron.com
200- **Security incident reporting:** security@gluecron.com
Addedscripts/backup-restore-drill.sh+112−0View fileUnifiedSplit
1#!/usr/bin/env bash
2# Backup-restore drill. Proves the backup pipeline works end-to-end.
3# See docs/BACKUP_RESTORE_DRILL.md for the full runbook.
4
5set -uo pipefail
6
7KEEP_DUMP=0
8VERBOSE=0
9for arg in "$@"; do
10 case "$arg" in
11 --keep-dump) KEEP_DUMP=1 ;;
12 --verbose) VERBOSE=1 ;;
13 *) ;;
14 esac
15done
16
17DATABASE_URL="${DATABASE_URL:-}"
18SCRATCH_DATABASE_URL="${SCRATCH_DATABASE_URL:-}"
19
20if [[ -z "$DATABASE_URL" || -z "$SCRATCH_DATABASE_URL" ]]; then
21 echo "ERROR: DATABASE_URL and SCRATCH_DATABASE_URL must both be set." >&2
22 exit 2
23fi
24
25if ! command -v pg_dump >/dev/null 2>&1; then
26 echo "ERROR: pg_dump not found. apt-get install -y postgresql-client" >&2
27 exit 2
28fi
29if ! command -v psql >/dev/null 2>&1; then
30 echo "ERROR: psql not found." >&2
31 exit 2
32fi
33
34ts="$(date +%s)"
35start="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
36start_seconds="$(date +%s)"
37dump="/tmp/gluecron-drill-${ts}.dump"
38stamp_dir="/var/lib/gluecron"
39
40log() {
41 printf '[drill %s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"
42}
43
44log "dumping prod DB ..."
45if [[ "$VERBOSE" -eq 1 ]]; then
46 pg_dump --format=custom --no-owner --no-privileges --file="$dump" "$DATABASE_URL"
47else
48 pg_dump --format=custom --no-owner --no-privileges --file="$dump" "$DATABASE_URL" 2>/dev/null
49fi
50
51if [[ ! -s "$dump" ]]; then
52 log "FATAL: dump file is empty or missing."
53 exit 1
54fi
55dump_size=$(du -h "$dump" | cut -f1)
56log "dump complete: ${dump_size} at $dump"
57
58log "restoring into scratch ..."
59# Wipe the scratch DB schema (public only). Idempotent.
60psql -v ON_ERROR_STOP=1 "$SCRATCH_DATABASE_URL" >/dev/null <<'SQL'
61DROP SCHEMA IF EXISTS public CASCADE;
62CREATE SCHEMA public;
63GRANT ALL ON SCHEMA public TO PUBLIC;
64SQL
65
66if [[ "$VERBOSE" -eq 1 ]]; then
67 pg_restore --no-owner --no-privileges --dbname="$SCRATCH_DATABASE_URL" "$dump" || true
68else
69 pg_restore --no-owner --no-privileges --dbname="$SCRATCH_DATABASE_URL" "$dump" >/dev/null 2>&1 || true
70fi
71log "restore complete"
72
73q() {
74 local url="$1" sql="$2"
75 psql -At "$url" -c "$sql" 2>/dev/null | tr -d '[:space:]'
76}
77
78compare() {
79 local label="$1" sql="$2"
80 local a b
81 a="$(q "$DATABASE_URL" "$sql")"
82 b="$(q "$SCRATCH_DATABASE_URL" "$sql")"
83 if [[ "$a" == "$b" && -n "$a" ]]; then
84 printf ' \033[32mPASS\033[0m %-22s prod=%-12s scratch=%s\n' "$label" "$a" "$b"
85 return 0
86 fi
87 printf ' \033[31mFAIL\033[0m %-22s prod=%-12s scratch=%s\n' "$label" "$a" "$b"
88 return 1
89}
90
91log "verification:"
92fails=0
93compare "users count" "SELECT COUNT(*) FROM users;" || fails=$((fails+1))
94compare "repositories count" "SELECT COUNT(*) FROM repositories;" || fails=$((fails+1))
95compare "site_admins count" "SELECT COUNT(*) FROM site_admins;" || fails=$((fails+1))
96compare "schema tables" "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public';" || fails=$((fails+1))
97
98elapsed=$(( $(date +%s) - start_seconds ))
99
100if [[ "$KEEP_DUMP" -ne 1 ]]; then
101 rm -f "$dump"
102fi
103
104if [[ "$fails" -eq 0 ]]; then
105 log "All checks passed. Took ${elapsed}s."
106 mkdir -p "$stamp_dir" 2>/dev/null || true
107 date +%s > "$stamp_dir/drill-last-success" 2>/dev/null || true
108 exit 0
109fi
110
111log "${fails} check(s) failed. Investigate before trusting backups."
112exit 1
Addedscripts/check-admin.ts+72−0View fileUnifiedSplit
1/**
2 * Check whether a user is a site admin.
3 *
4 * Usage:
5 * docker compose exec gluecron bun run scripts/check-admin.ts <email>
6 *
7 * Reports both `users.is_admin` and `site_admins` table presence so you can
8 * see if your account is admin-promoted at either level. Use
9 * `scripts/promote-admin.ts` to grant admin if it isn't.
10 */
11import { db } from "../src/db/client";
12import { users, siteAdmins } from "../src/db/schema";
13import { eq } from "drizzle-orm";
14
15const emailArg = process.argv[2];
16if (!emailArg) {
17 console.error("Usage: bun run scripts/check-admin.ts <email>");
18 process.exit(2);
19}
20const email = emailArg.toLowerCase().trim();
21
22const userRow = await db
23 .select({ id: users.id, email: users.email, isAdmin: users.isAdmin, createdAt: users.createdAt })
24 .from(users)
25 .where(eq(users.email, email))
26 .limit(1);
27
28if (userRow.length === 0) {
29 console.error(`No user found for email: ${email}`);
30 console.error("Register at /register first, then re-run this script.");
31 process.exit(1);
32}
33
34const u = userRow[0];
35const inSiteAdmins = await db
36 .select({ userId: siteAdmins.userId })
37 .from(siteAdmins)
38 .where(eq(siteAdmins.userId, u.id))
39 .limit(1);
40
41const totalSiteAdmins = await db.select({ userId: siteAdmins.userId }).from(siteAdmins);
42
43console.log(`User: ${u.email}`);
44console.log(` id: ${u.id}`);
45console.log(` created_at: ${u.createdAt.toISOString()}`);
46console.log(` users.is_admin: ${u.isAdmin ? "YES" : "no"}`);
47console.log(` in site_admins: ${inSiteAdmins.length > 0 ? "YES" : "no"}`);
48console.log(` total site_admins: ${totalSiteAdmins.length}`);
49
50if (totalSiteAdmins.length === 0) {
51 console.log("");
52 console.log("site_admins table is empty — the bootstrap rule applies:");
53 console.log(" the oldest user in `users` is treated as admin by app code.");
54 console.log(" Run scripts/promote-admin.ts to make this explicit and durable.");
55}
56
57if (u.isAdmin && inSiteAdmins.length > 0) {
58 console.log("");
59 console.log("✓ This account is a full site admin.");
60 process.exit(0);
61}
62
63if (u.isAdmin || inSiteAdmins.length > 0) {
64 console.log("");
65 console.log("~ Partial admin state. Run scripts/promote-admin.ts to normalise.");
66 process.exit(0);
67}
68
69console.log("");
70console.log("× This account is NOT admin. Run:");
71console.log(` docker compose exec gluecron bun run scripts/promote-admin.ts ${email}`);
72process.exit(0);
Addedscripts/promote-admin.ts+58−0View fileUnifiedSplit
1/**
2 * Promote a user to site admin.
3 *
4 * Usage:
5 * docker compose exec gluecron bun run scripts/promote-admin.ts <email>
6 *
7 * Sets both `users.is_admin = true` AND inserts into `site_admins`, so the
8 * account is admin under both the bootstrap rule and the explicit-list rule.
9 *
10 * Idempotent: safe to re-run. Reports what changed.
11 */
12import { db } from "../src/db/client";
13import { users, siteAdmins } from "../src/db/schema";
14import { and, eq } from "drizzle-orm";
15
16const emailArg = process.argv[2];
17if (!emailArg) {
18 console.error("Usage: bun run scripts/promote-admin.ts <email>");
19 process.exit(2);
20}
21const email = emailArg.toLowerCase().trim();
22
23const userRow = await db
24 .select({ id: users.id, email: users.email, isAdmin: users.isAdmin })
25 .from(users)
26 .where(eq(users.email, email))
27 .limit(1);
28
29if (userRow.length === 0) {
30 console.error(`No user found for email: ${email}`);
31 console.error("Register at /register first, then re-run this script.");
32 process.exit(1);
33}
34const u = userRow[0];
35
36let flippedIsAdmin = false;
37if (!u.isAdmin) {
38 await db.update(users).set({ isAdmin: true }).where(eq(users.id, u.id));
39 flippedIsAdmin = true;
40}
41
42const existing = await db
43 .select({ userId: siteAdmins.userId })
44 .from(siteAdmins)
45 .where(eq(siteAdmins.userId, u.id))
46 .limit(1);
47
48let insertedSiteAdmin = false;
49if (existing.length === 0) {
50 await db.insert(siteAdmins).values({ userId: u.id, grantedBy: u.id });
51 insertedSiteAdmin = true;
52}
53
54console.log(`User: ${u.email} (${u.id})`);
55console.log(` users.is_admin : ${flippedIsAdmin ? "flipped FALSE -> TRUE" : "already TRUE"}`);
56console.log(` site_admins row : ${insertedSiteAdmin ? "INSERTED" : "already present"}`);
57console.log("");
58console.log("✓ This account is now a site admin. Log out and back in if /admin still 403s.");
Addedscripts/verify-deploy.sh+68−0View fileUnifiedSplit
1#!/usr/bin/env bash
2# Post-deploy smoke verifier. Runs the DEPLOY_CHECKLIST.md post-deploy
3# smoke tests as a single command. Exits non-zero on any failure so it
4# can be dropped into CI or a post-deploy hook.
5#
6# Usage:
7# bash scripts/verify-deploy.sh # defaults to https://gluecron.com
8# bash scripts/verify-deploy.sh https://staging.example # override base URL
9# BASE_URL=https://gluecron.com bash scripts/verify-deploy.sh
10
11set -uo pipefail
12
13BASE_URL="${1:-${BASE_URL:-https://gluecron.com}}"
14BASE_URL="${BASE_URL%/}"
15
16fail=0
17
18check() {
19 local label="$1"
20 local url="$2"
21 local expect_status="${3:-200}"
22 local match="${4:-}"
23
24 local tmp status body
25 tmp="$(mktemp)"
26 status="$(curl -sS -o "$tmp" -w '%{http_code}' --max-time 10 "$url" || echo 000)"
27 body="$(cat "$tmp")"
28 rm -f "$tmp"
29
30 if [[ "$status" != "$expect_status" ]]; then
31 printf ' \033[31mFAIL\033[0m %-32s %s (got %s, want %s)\n' \
32 "$label" "$url" "$status" "$expect_status"
33 fail=$((fail + 1))
34 return
35 fi
36
37 if [[ -n "$match" && "$body" != *"$match"* ]]; then
38 printf ' \033[31mFAIL\033[0m %-32s %s (body missing %q)\n' \
39 "$label" "$url" "$match"
40 fail=$((fail + 1))
41 return
42 fi
43
44 printf ' \033[32mOK\033[0m %-32s %s (%s)\n' "$label" "$url" "$status"
45}
46
47echo "Verifying $BASE_URL ..."
48
49check "healthz" "$BASE_URL/healthz" 200
50check "readyz" "$BASE_URL/readyz" 200
51check "status (html)" "$BASE_URL/status" 200 "status"
52check "status.svg" "$BASE_URL/status.svg" 200 "<svg"
53check "metrics" "$BASE_URL/metrics" 200 "# HELP"
54check "landing (/)" "$BASE_URL/" 200
55
56# www alias — only runs when the base URL has no www and is HTTPS
57if [[ "$BASE_URL" =~ ^https://[^/]+$ ]] && [[ ! "$BASE_URL" =~ https://www\. ]]; then
58 www_url="${BASE_URL/https:\/\//https:\/\/www.}"
59 check "www alias /healthz" "$www_url/healthz" 200
60fi
61
62if [[ "$fail" -eq 0 ]]; then
63 printf '\n\033[32mAll checks passed.\033[0m\n'
64 exit 0
65fi
66
67printf '\n\033[31m%d check(s) failed.\033[0m\n' "$fail"
68exit 1
069