Commit91cecb2unknown_key
Merge pull request #68 from ccantynz-alt/claude/review-readme-docs-ulqPK
Merge pull request #68 from ccantynz-alt/claude/review-readme-docs-ulqPK Claude/review readme docs ulq pk
69 files changed+12709−12291cecb2d1b8bd01bc3fadffc6375843e744a6584
69 changed files+12709−122
Modified.github/workflows/hetzner-deploy.yml+190−6View fileUnifiedSplit
@@ -17,6 +17,11 @@ name: Hetzner Deploy (gluecron.com)
1717# Optional:
1818# ANTHROPIC_API_KEY — enables AI failure-diagnosis step
1919# DEPLOY_WEBHOOK_URL — POSTed with JSON deploy status (Slack/Discord/anything)
20# DEPLOY_EVENT_TOKEN — bearer used to POST deploy timeline events to
21# ${APP_BASE_URL}/api/events/deploy/{started,finished}.
22# Block N3: this makes the live site display ITS OWN
23# deploy in the admin nav + at /admin/deploys.
24# APP_BASE_URL — base URL of the running site (default https://gluecron.com).
2025
2126on:
2227 push:
@@ -35,6 +40,29 @@ jobs:
3540 steps:
3641 - uses: actions/checkout@v4
3742
43 # ─── 0. Mark start time + notify the live site (Block N3) ────────────
44 # The site has its own admin status pill + /admin/deploys timeline.
45 # POSTing here makes the pill say "Deploying… 14s" the moment SSH
46 # begins. `--fail` is intentionally NOT set: a 5xx here must never
47 # block the deploy itself.
48 - name: Record start time
49 id: start
50 run: |
51 echo "epoch=$(date +%s)" >> $GITHUB_OUTPUT
52
53 - name: Notify deploy started
54 if: env.DEPLOY_EVENT_TOKEN != ''
55 env:
56 DEPLOY_EVENT_TOKEN: ${{ secrets.DEPLOY_EVENT_TOKEN }}
57 APP_BASE_URL: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
58 run: |
59 curl --silent --show-error --max-time 10 \
60 -X POST "$APP_BASE_URL/api/events/deploy/started" \
61 -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
62 -H "content-type: application/json" \
63 --data "{\"sha\":\"${{ github.sha }}\",\"run_id\":\"${{ github.run_id }}\",\"source\":\"hetzner-deploy\"}" \
64 || echo "(deploy-started notify failed — continuing)"
65
3866 # ─── 1. Capture pre-deploy SHA so we can rollback ───────────────────
3967 - name: Capture pre-deploy SHA
4068 id: prev
@@ -51,7 +79,22 @@ jobs:
5179 echo "$sha" > /tmp/gluecron_prev_sha
5280 cat /tmp/gluecron_prev_sha
5381
54 # ─── 2. Deploy: pull main, run the production script ────────────────
82 # ─── 2. Deploy: pull main, install/compile/restart ─────────────────
83 # Block N2 — speed optimisation:
84 # (a) Cache `bun install` by hashing bun.lock; skip the walk when
85 # the lockfile is unchanged. Saves ~5-15s per "no deps changed"
86 # deploy.
87 # (b) Compile to a single Bun static binary at
88 # /opt/gluecron/.next/gluecron-server. Boot drops from ~500ms
89 # (cold ESM resolve) to <50ms. Compile itself takes ~3-5s.
90 # (c) Rewrite the systemd unit on first run if it lacks Type=notify
91 # (idempotent — diff-then-write, daemon-reload only on change).
92 # Falls back to `bun run src/index.ts` if the compiled binary is
93 # missing for any reason — the deploy MUST stay backward-
94 # compatible.
95 # (d) `systemctl restart` blocks until sd_notify(READY=1) fires
96 # (wired in src/lib/systemd-notify.ts), so we no longer rely on
97 # the curl-with-retries loop downstream.
5598 - name: Deploy
5699 id: deploy
57100 uses: appleboy/ssh-action@v1.2.0
@@ -68,7 +111,98 @@ jobs:
68111 git reset --hard origin/main
69112 new_sha=$(git rev-parse HEAD)
70113 echo "Deploying SHA: $new_sha"
71 bash scripts/deploy-crontech.sh
114
115 BUN=/root/.bun/bin/bun
116 CACHE_DIR=/opt/gluecron/.cache
117 HASH_FILE=$CACHE_DIR/bun-lockfile-hash
118 mkdir -p "$CACHE_DIR"
119
120 # ─── (a) Cached deps: skip bun install when bun.lock is unchanged
121 if [ -f bun.lock ]; then
122 new_hash=$(sha256sum bun.lock | awk '{print $1}')
123 else
124 new_hash="no-lockfile"
125 fi
126 old_hash=""
127 if [ -f "$HASH_FILE" ]; then
128 old_hash=$(cat "$HASH_FILE")
129 fi
130 if [ "$new_hash" = "$old_hash" ] && [ -d node_modules ]; then
131 echo "==> bun install: SKIP (lockfile unchanged: $new_hash)"
132 else
133 echo "==> bun install: hash changed ($old_hash -> $new_hash) — installing"
134 "$BUN" install --frozen-lockfile
135 echo "$new_hash" > "$HASH_FILE"
136 fi
137
138 # ─── (b) Compile to a single static binary (best-effort)
139 mkdir -p .next
140 COMPILED=.next/gluecron-server
141 COMPILED_TMP=.next/gluecron-server.new
142 if "$BUN" build --compile --outfile "$COMPILED_TMP" src/index.ts; then
143 mv -f "$COMPILED_TMP" "$COMPILED"
144 chmod +x "$COMPILED"
145 EXEC_START="/opt/gluecron/.next/gluecron-server"
146 echo "==> compiled binary ready: $COMPILED"
147 else
148 # Backward-compat: if compile fails, fall back to interpreted Bun
149 echo "WARN: bun build --compile failed — falling back to bun run"
150 rm -f "$COMPILED_TMP"
151 EXEC_START="$BUN run src/index.ts"
152 fi
153
154 # ─── (c) Idempotent systemd unit rewrite (Type=notify)
155 UNIT=/etc/systemd/system/gluecron.service
156 DESIRED=$(cat <<UNIT_EOF
157 [Unit]
158 Description=Gluecron — AI-native code intelligence platform
159 After=network-online.target postgresql.service
160 Wants=network-online.target
161
162 [Service]
163 Type=notify
164 NotifyAccess=main
165 User=root
166 WorkingDirectory=/opt/gluecron
167 EnvironmentFile=/etc/gluecron.env
168 ExecStart=$EXEC_START
169 Restart=always
170 RestartSec=5
171 TimeoutStartSec=30
172 StandardOutput=journal
173 StandardError=journal
174 SyslogIdentifier=gluecron
175 LimitNOFILE=65536
176
177 [Install]
178 WantedBy=multi-user.target
179 UNIT_EOF
180 )
181 # Strip the leading indentation from the heredoc (`sed 's/^ //'`)
182 # so the rendered unit is column-0 like systemd expects.
183 DESIRED=$(printf '%s\n' "$DESIRED" | sed 's/^ //')
184
185 need_rewrite=1
186 if [ -f "$UNIT" ] && diff -q <(printf '%s\n' "$DESIRED") "$UNIT" >/dev/null 2>&1; then
187 need_rewrite=0
188 fi
189
190 if [ "$need_rewrite" = "1" ]; then
191 echo "==> rewriting $UNIT (Type=notify, ExecStart=$EXEC_START)"
192 printf '%s\n' "$DESIRED" > "$UNIT"
193 systemctl daemon-reload
194 else
195 echo "==> $UNIT already matches desired state — skipping daemon-reload"
196 fi
197
198 # ─── DB migrations (cheap; safe to always run)
199 set -a; source /etc/gluecron.env; set +a
200 "$BUN" run src/db/migrate.ts || echo "WARN: migrate failed (may be already-applied)"
201
202 # ─── (d) Zero-downtime restart. Blocks until sd_notify(READY=1).
203 echo "==> systemctl restart gluecron (blocks on sd_notify READY=1)"
204 systemctl restart gluecron
205 echo "==> restart returned — gluecron signalled ready"
72206
73207 # ─── 3. Smoke-test the deployed app on the box ──────────────────────
74208 # We SSH back in and curl localhost:3010/healthz directly. This tests
@@ -88,19 +222,24 @@ jobs:
88222 key: ${{ secrets.HETZNER_SSH_KEY }}
89223 script_stop: true
90224 script: |
225 # Block N2 — `systemctl restart` already blocked on
226 # sd_notify(READY=1), so the FIRST curl should succeed. We keep a
227 # short retry budget for paranoia: a brief delay between
228 # systemd's READY ack and the HTTP listener becoming routable
229 # via 127.0.0.1 is theoretically possible (unusual but cheap to
230 # tolerate). 3 attempts × 2s = 6s ceiling instead of 8 × 6s = 48s.
91231 set +e
92 for i in 1 2 3 4 5 6 7 8; do
232 for i in 1 2 3; do
93233 code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3010/healthz)
94234 echo "Attempt $i: /healthz -> $code"
95235 if [ "$code" = "200" ]; then
96236 echo "OK: gluecron is healthy on localhost:3010"
97 # Also print the sha so the deploy log shows what's running
98237 curl -s http://localhost:3010/api/version || true
99238 exit 0
100239 fi
101 sleep 6
240 sleep 2
102241 done
103 echo "FAIL: /healthz did not return 200 after 48s"
242 echo "FAIL: /healthz did not return 200 after 6s"
104243 systemctl status gluecron --no-pager | head -10 || true
105244 journalctl -u gluecron -n 30 --no-pager || true
106245 exit 1
@@ -225,3 +364,48 @@ jobs:
225364 echo "- **Target:** https://gluecron.com" >> $GITHUB_STEP_SUMMARY
226365 echo "- **SHA:** \`${GITHUB_SHA:0:7}\`" >> $GITHUB_STEP_SUMMARY
227366 echo "- **Health:** /healthz → 200" >> $GITHUB_STEP_SUMMARY
367
368 # ─── 8. Block N3 — POST deploy-finished event to the live site ───────
369 # The site's admin status pill flips to "Deployed Ns ago" or
370 # "Deploy failed Nm ago" the instant this lands. `if: always()` so we
371 # always report final state (including failure); the inner `if:` flag
372 # splits success vs failure for the payload body. We never `--fail` —
373 # a 5xx must not retroactively break a deploy that actually succeeded.
374 - name: Notify deploy finished (success)
375 if: success() && env.DEPLOY_EVENT_TOKEN != ''
376 env:
377 DEPLOY_EVENT_TOKEN: ${{ secrets.DEPLOY_EVENT_TOKEN }}
378 APP_BASE_URL: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
379 START_EPOCH: ${{ steps.start.outputs.epoch }}
380 run: |
381 DUR_MS=$(( ( $(date +%s) - START_EPOCH ) * 1000 ))
382 curl --silent --show-error --max-time 10 \
383 -X POST "$APP_BASE_URL/api/events/deploy/finished" \
384 -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
385 -H "content-type: application/json" \
386 --data "{\"run_id\":\"${{ github.run_id }}\",\"sha\":\"${{ github.sha }}\",\"status\":\"succeeded\",\"duration_ms\":$DUR_MS}" \
387 || echo "(deploy-finished[succeeded] notify failed — continuing)"
388
389 - name: Notify deploy finished (failure)
390 if: failure() && env.DEPLOY_EVENT_TOKEN != ''
391 env:
392 DEPLOY_EVENT_TOKEN: ${{ secrets.DEPLOY_EVENT_TOKEN }}
393 APP_BASE_URL: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
394 START_EPOCH: ${{ steps.start.outputs.epoch }}
395 DIAG: ${{ steps.ctx.outputs.stdout }}
396 run: |
397 DUR_MS=$(( ( $(date +%s) - START_EPOCH ) * 1000 ))
398 # First 1 KB of diagnostics — keeps the JSON small and the DB row sane.
399 ERR_TEXT=$(printf '%s' "${DIAG:-deploy failed; see workflow logs}" | head -c 1024)
400 # jq -Rs '.' is the safest way to JSON-escape arbitrary multi-line text.
401 if command -v jq >/dev/null 2>&1; then
402 ERR_JSON=$(printf '%s' "$ERR_TEXT" | jq -Rs '.')
403 else
404 ERR_JSON=$(printf '%s' "$ERR_TEXT" | python3 -c "import sys,json;print(json.dumps(sys.stdin.read()))")
405 fi
406 curl --silent --show-error --max-time 10 \
407 -X POST "$APP_BASE_URL/api/events/deploy/finished" \
408 -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
409 -H "content-type: application/json" \
410 --data "{\"run_id\":\"${{ github.run_id }}\",\"sha\":\"${{ github.sha }}\",\"status\":\"failed\",\"duration_ms\":$DUR_MS,\"error\":$ERR_JSON}" \
411 || echo "(deploy-finished[failed] notify failed — continuing)"
ModifiedDEPLOY.md+77−0View fileUnifiedSplit
@@ -152,3 +152,80 @@ Neon handles PITR + branch snapshots — configure retention in the Neon console
152152| `VOYAGE_API_KEY` | Semantic search falls back to the deterministic hashing embedder. Quality drops; feature still works. |
153153
154154Every missing integration follows the same rule: **never break the primary request path**. See BUILD_BIBLE §4.9 for the full invariant list.
155
156---
157
158## Lightning-fast deploys (Block N1)
159
160Once PR #62 has landed and the release-command has applied migration 0040, the operator can flip a single repo into "auto-merge mode" with two scripts. From "feature description → live in ~6 minutes, zero clicks" goes from possible to the default.
161
162### What it does
163
164When `branch_protection.enable_auto_merge=true` on the matching rule, the K3 autopilot sweep (`auto-merge-sweep`, fires every 5 minutes) will auto-merge any PR that:
165
166- Targets a branch matching the rule's `pattern`
167- Is not a draft
168- Passes every gate the manual-merge path enforces (green gates, AI approval, required checks)
169- Is not flagged as `critical` by the M3 PR risk scorer
170
171The merge is performed by the autopilot, not by any human click. Default-deny: this only fires on rules where the operator has explicitly opted in.
172
173### Step 1 — Readiness check
174
175Run the preflight to make sure the box is in a state where opting in will actually do something useful:
176
177```bash
178# Fly.io
179fly ssh console -C "bun run /opt/gluecron/scripts/check-auto-merge-readiness.ts"
180
181# Hetzner (matches scripts/bootstrap-hetzner.sh)
182ssh root@gluecron.com "cd /opt/gluecron && bun run scripts/check-auto-merge-readiness.ts"
183```
184
185The check verifies:
186- Migration 0040 has been applied (`branch_protection.enable_auto_merge` column exists)
187- `ANTHROPIC_API_KEY` is set (otherwise the AI approval gate blocks every candidate)
188- The autopilot is running (`AUTOPILOT_DISABLED != 1`)
189- The K3 `auto-merge-sweep` task is in `defaultTasks()`
190
191Exit code 0 if all green; 1 if any check fails. Fix any red lines before continuing.
192
193### Step 2 — Flip the switch for a single repo
194
195```bash
196# Fly.io
197fly ssh console -C "bun run /opt/gluecron/scripts/enable-auto-merge.ts ccantynz/Gluecron.com"
198
199# Hetzner
200ssh root@gluecron.com "cd /opt/gluecron && bun run scripts/enable-auto-merge.ts ccantynz/Gluecron.com"
201```
202
203By default this targets the `main` branch. Pass a second arg to target a different pattern, e.g. `release/*`:
204
205```bash
206bun run scripts/enable-auto-merge.ts ccantynz/Gluecron.com release/*
207```
208
209The script is idempotent:
210- If a `branch_protection` row already exists for the (repo, pattern), it flips `enable_auto_merge=true` and preserves every other field.
211- If no row exists, it inserts a fresh one with the documented safety defaults (`require_green_gates=true`, `require_ai_approval=true`, `require_human_review=false`, `required_approvals=0`).
212- Running it twice in a row produces a "no-op" — no duplicate audit entry.
213
214It prints a before / after diff so you see exactly what changed, and writes an `auto_merge.enabled_on_main` row to `audit_log` for traceability.
215
216Within 5 minutes (the autopilot sweep cadence), any qualifying PR on that branch will auto-merge with zero human clicks.
217
218### Revert
219
220To turn auto-merge back off:
221
222```bash
223bun run scripts/enable-auto-merge.ts ccantynz/Gluecron.com --off
224```
225
226Or manually: `UPDATE branch_protection SET enable_auto_merge = false WHERE repository_id = ... AND pattern = 'main';`. The autopilot sweep is default-deny — it will stop firing the moment the column flips back to `false`.
227
228### Don'ts
229
230- Do not run `enable-auto-merge.ts` without first running the readiness check. The AI approval gate silently fails closed when `ANTHROPIC_API_KEY` is missing, and you'll spend half an hour wondering why nothing auto-merges.
231- The script intentionally does NOT auto-discover repos and flip them all. Explicit per-repo opt-in is the whole point — the operator decides which repos go on autopilot.
ModifiedREADME.md+23−0View fileUnifiedSplit
@@ -81,6 +81,29 @@ A GitHub replacement. AI-native code intelligence, git hosting, automated CI, an
8181
8282For the full shipped-vs-missing scorecard and internal roadmap, see [`BUILD_BIBLE.md`](./BUILD_BIBLE.md).
8383
84## Install in Claude Desktop
85
86Three paths, pick whichever fits — they all wire the same 15 MCP tools
87(`gluecron_create_pr`, `gluecron_merge_pr`, `gluecron_repo_health`, …) into
88Claude.
89
901. **One-click (recommended).** Download
91 [`https://gluecron.com/gluecron.dxt`](https://gluecron.com/gluecron.dxt),
92 then in Claude Desktop go to **Settings → Extensions** and drag the
93 file in. Claude prompts for your Gluecron host + personal access token
94 (generate one at `/settings/tokens` with `admin` scope) and the tools
95 light up.
962. **One command (terminal).**
97 ```bash
98 curl -sSL https://gluecron.com/install | bash
99 ```
100 The installer mints a PAT, edits `claude_desktop_config.json`, and
101 drops the Claude Code skill bundle into `~/.claude/skills/`. See
102 [`scripts/install.sh`](./scripts/install.sh).
1033. **Manual.** Edit `claude_desktop_config.json` yourself and add an
104 `mcpServers.gluecron` entry pointing at `https://gluecron.com/mcp` with
105 an `Authorization: Bearer <pat>` header.
106
84107## Quick start
85108
86109```bash
Modifiedcli/gluecron.ts+363−1View fileUnifiedSplit
@@ -32,6 +32,8 @@ interface Config {
3232 host: string;
3333 token?: string;
3434 username?: string;
35 githubToken?: string;
36 defaultRepo?: string;
3537}
3638
3739export function loadConfig(): Config {
@@ -43,6 +45,8 @@ export function loadConfig(): Config {
4345 host: parsed.host || DEFAULT_HOST,
4446 token: parsed.token,
4547 username: parsed.username,
48 githubToken: parsed.githubToken,
49 defaultRepo: parsed.defaultRepo,
4650 };
4751 }
4852 } catch {
@@ -111,11 +115,15 @@ Usage:
111115 gluecron issues ls <owner/name> List open issues
112116 gluecron gql '<query>' Run a GraphQL query
113117 gluecron host [url] Get or set the server URL
118 gluecron deploy [--repo owner/name] [--workflow id] [--ref branch] [--no-watch]
119 Trigger a Hetzner deploy via GitHub Actions
120 gluecron config set <key> <value> Set a config value (e.g. github-token)
114121 gluecron version Print version
115122 gluecron help Print this help
116123
117124Env:
118 GLUECRON_HOST override the server URL (default: ${DEFAULT_HOST})
125 GLUECRON_HOST override the server URL (default: ${DEFAULT_HOST})
126 GLUECRON_GITHUB_TOKEN GitHub PAT (repo+workflow scopes) for \`deploy\`
119127`;
120128
121129export async function cmdLogin(
@@ -199,6 +207,247 @@ export async function cmdGql(cfg: Config, query: string): Promise<any> {
199207 return http(cfg, "POST", "/api/graphql", { query });
200208}
201209
210// ---------- Deploy (GitHub Actions workflow_dispatch) ----------
211
212export interface DeployArgs {
213 repo: string; // "owner/name"
214 workflow: string; // file name (e.g. "hetzner-deploy.yml") OR numeric id
215 ref: string; // "main"
216 githubToken: string;
217}
218
219export interface DeployDispatchResult {
220 runId: number;
221 runUrl: string;
222 htmlUrl: string;
223}
224
225export type FetchLike = (
226 url: string,
227 init?: { method?: string; headers?: Record<string, string>; body?: string }
228) => Promise<{
229 status: number;
230 ok: boolean;
231 text: () => Promise<string>;
232 json?: () => Promise<any>;
233}>;
234
235const GITHUB_API = "https://api.github.com";
236
237function ghHeaders(token: string): Record<string, string> {
238 return {
239 accept: "application/vnd.github+json",
240 authorization: `Bearer ${token}`,
241 "x-github-api-version": "2022-11-28",
242 "user-agent": "gluecron-cli",
243 };
244}
245
246async function parseBody(res: { text: () => Promise<string> }): Promise<any> {
247 const t = await res.text();
248 if (!t) return {};
249 try {
250 return JSON.parse(t);
251 } catch {
252 return { _raw: t };
253 }
254}
255
256function friendlyGhError(status: number, body: any): string {
257 const msg = (body && (body.message || body.error)) || "";
258 if (status === 401) {
259 return "GitHub auth failed (401). Check your token has `repo` and `workflow` scopes.";
260 }
261 if (status === 403) {
262 return `GitHub forbade the request (403). ${msg || "Token may lack `workflow` scope or you don't have write access."}`;
263 }
264 if (status === 404) {
265 return `Workflow or repo not found (404). ${msg || "Check --repo, --workflow, and that the token can see this repo."}`;
266 }
267 if (status === 422) {
268 return `GitHub rejected the dispatch (422). ${msg || "Branch may not exist, or the workflow has no workflow_dispatch trigger on that ref."}`;
269 }
270 return `GitHub error [${status}]: ${msg || "request failed"}`;
271}
272
273/**
274 * Trigger a `workflow_dispatch` on GitHub Actions and resolve the run id.
275 *
276 * Step 1: POST /repos/:o/:r/actions/workflows/:wf/dispatches (expects 204)
277 * Step 2: GET /repos/:o/:r/actions/workflows/:wf/runs?event=workflow_dispatch&branch=:ref
278 * and pick the newest run created within the last 60s.
279 */
280export async function triggerWorkflowDispatch(
281 args: DeployArgs,
282 opts: { fetchImpl?: FetchLike; now?: () => number } = {}
283): Promise<DeployDispatchResult> {
284 const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike);
285 const [owner, name] = args.repo.split("/");
286 if (!owner || !name) throw new Error("expected --repo owner/name");
287 if (!args.githubToken) {
288 throw new Error(
289 "no GitHub token — set GLUECRON_GITHUB_TOKEN or run `gluecron config set github-token <token>`"
290 );
291 }
292
293 const dispatchedAt = (opts.now ?? Date.now)();
294 const dispatchUrl = `${GITHUB_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(args.workflow)}/dispatches`;
295 const dispatchRes = await f(dispatchUrl, {
296 method: "POST",
297 headers: { ...ghHeaders(args.githubToken), "content-type": "application/json" },
298 body: JSON.stringify({ ref: args.ref }),
299 });
300 if (dispatchRes.status !== 204) {
301 const body = await parseBody(dispatchRes);
302 throw new Error(friendlyGhError(dispatchRes.status, body));
303 }
304
305 // GitHub does not return the run id on dispatch — query for the latest run.
306 const runsUrl =
307 `${GITHUB_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(args.workflow)}/runs` +
308 `?event=workflow_dispatch&branch=${encodeURIComponent(args.ref)}&per_page=5`;
309 // Try a handful of times — the run may take a moment to register.
310 let lastErr: string | null = null;
311 for (let i = 0; i < 6; i++) {
312 const r = await f(runsUrl, { method: "GET", headers: ghHeaders(args.githubToken) });
313 if (!r.ok) {
314 const body = await parseBody(r);
315 lastErr = friendlyGhError(r.status, body);
316 break;
317 }
318 const body = await parseBody(r);
319 const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
320 // Pick the newest run created at/after the dispatch moment (minus 5s slack).
321 const slack = dispatchedAt - 5_000;
322 const candidate = runs.find((rn: any) => {
323 const t = Date.parse(rn?.created_at || "");
324 return Number.isFinite(t) && t >= slack;
325 }) ?? runs[0];
326 if (candidate?.id) {
327 return {
328 runId: Number(candidate.id),
329 runUrl: candidate.url,
330 htmlUrl: candidate.html_url ||
331 `https://github.com/${owner}/${name}/actions/runs/${candidate.id}`,
332 };
333 }
334 // Wait a beat and retry; the test path injects a synchronous fetchImpl so
335 // this loop completes immediately when the run is registered on first try.
336 if (i < 5) await new Promise((res) => setTimeout(res, 1000));
337 }
338 throw new Error(lastErr || "workflow dispatched but could not locate the run id");
339}
340
341export interface DeployJobStep {
342 name: string;
343 status: string; // queued | in_progress | completed
344 conclusion: string | null;
345 startedAt: string | null;
346 completedAt: string | null;
347}
348
349export interface DeployRunStatus {
350 status: string; // queued | in_progress | completed
351 conclusion: string | null;
352 steps: DeployJobStep[];
353}
354
355export async function fetchRunStatus(
356 args: { repo: string; runId: number; githubToken: string },
357 opts: { fetchImpl?: FetchLike } = {}
358): Promise<DeployRunStatus> {
359 const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike);
360 const [owner, name] = args.repo.split("/");
361 const r = await f(
362 `${GITHUB_API}/repos/${owner}/${name}/actions/runs/${args.runId}/jobs`,
363 { method: "GET", headers: ghHeaders(args.githubToken) }
364 );
365 if (!r.ok) {
366 const body = await parseBody(r);
367 throw new Error(friendlyGhError(r.status, body));
368 }
369 const body = await parseBody(r);
370 const jobs = Array.isArray(body?.jobs) ? body.jobs : [];
371 // Flatten across jobs — for the hetzner-deploy workflow there's just one.
372 const steps: DeployJobStep[] = [];
373 let status = "queued";
374 let conclusion: string | null = null;
375 for (const j of jobs) {
376 status = j.status || status;
377 conclusion = j.conclusion ?? conclusion;
378 for (const s of j.steps || []) {
379 steps.push({
380 name: s.name,
381 status: s.status,
382 conclusion: s.conclusion ?? null,
383 startedAt: s.started_at ?? null,
384 completedAt: s.completed_at ?? null,
385 });
386 }
387 }
388 return { status, conclusion, steps };
389}
390
391function fmtClock(ms: number): string {
392 const total = Math.max(0, Math.floor(ms / 1000));
393 const m = Math.floor(total / 60);
394 const s = total % 60;
395 return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
396}
397
398function durationSec(a: string | null, b: string | null): number | null {
399 if (!a || !b) return null;
400 const ta = Date.parse(a);
401 const tb = Date.parse(b);
402 if (!Number.isFinite(ta) || !Number.isFinite(tb)) return null;
403 return Math.max(0, Math.round((tb - ta) / 1000));
404}
405
406export async function watchDeploy(
407 args: { repo: string; runId: number; githubToken: string; startedAt: number },
408 out: (msg: string) => void,
409 opts: {
410 fetchImpl?: FetchLike;
411 pollMs?: number;
412 maxPolls?: number;
413 sleep?: (ms: number) => Promise<void>;
414 now?: () => number;
415 } = {}
416): Promise<{ ok: boolean; conclusion: string | null }> {
417 const pollMs = opts.pollMs ?? 3_000;
418 const maxPolls = opts.maxPolls ?? 240; // ~12min default
419 const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
420 const now = opts.now ?? Date.now;
421 const seen = new Map<string, { startedLogged: boolean; completedLogged: boolean }>();
422
423 for (let i = 0; i < maxPolls; i++) {
424 const st = await fetchRunStatus(args, { fetchImpl: opts.fetchImpl });
425 for (const step of st.steps) {
426 // Skip GitHub's implicit "Set up job"/"Complete job" noise if you like;
427 // for now we surface everything.
428 const key = step.name;
429 const prev = seen.get(key) ?? { startedLogged: false, completedLogged: false };
430 const clock = fmtClock(now() - args.startedAt);
431 if (!prev.startedLogged && (step.status === "in_progress" || step.status === "completed")) {
432 out(` ${clock} ${step.name} (in progress)`);
433 prev.startedLogged = true;
434 }
435 if (!prev.completedLogged && step.status === "completed") {
436 const d = durationSec(step.startedAt, step.completedAt);
437 const tail = d != null ? `completed in ${d}s` : `completed`;
438 out(` ${clock} ${step.name} (${tail})`);
439 prev.completedLogged = true;
440 }
441 seen.set(key, prev);
442 }
443 if (st.status === "completed") {
444 return { ok: st.conclusion === "success", conclusion: st.conclusion };
445 }
446 await sleep(pollMs);
447 }
448 return { ok: false, conclusion: "timeout" };
449}
450
202451// ---------- Command Handlers ----------
203452
204453async function handleHostCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
@@ -294,6 +543,115 @@ async function handleGqlCmd(cfg: Config, rest: string[], out: (msg: string) => v
294543 return 0;
295544}
296545
546function readFlag(rest: string[], name: string): string | undefined {
547 const i = rest.indexOf(name);
548 if (i >= 0 && i + 1 < rest.length) return rest[i + 1];
549 return undefined;
550}
551
552export async function handleConfigCmd(
553 cfg: Config,
554 rest: string[],
555 out: (msg: string) => void
556): Promise<number> {
557 if (rest[0] !== "set" || !rest[1]) {
558 out("usage: gluecron config set <key> <value>");
559 return 1;
560 }
561 const key = rest[1];
562 const value = rest[2];
563 if (value === undefined) {
564 out("usage: gluecron config set <key> <value>");
565 return 1;
566 }
567 switch (key) {
568 case "github-token":
569 cfg.githubToken = value;
570 break;
571 case "default-repo":
572 cfg.defaultRepo = value;
573 break;
574 case "host":
575 cfg.host = value;
576 break;
577 case "token":
578 cfg.token = value;
579 break;
580 default:
581 out(`unknown config key: ${key}`);
582 return 1;
583 }
584 saveConfig(cfg);
585 out(`ok: ${key} saved`);
586 return 0;
587}
588
589export async function handleDeployCmd(
590 cfg: Config,
591 rest: string[],
592 out: (msg: string) => void,
593 opts: {
594 fetchImpl?: FetchLike;
595 sleep?: (ms: number) => Promise<void>;
596 pollMs?: number;
597 maxPolls?: number;
598 now?: () => number;
599 } = {}
600): Promise<number> {
601 const repo = readFlag(rest, "--repo") || cfg.defaultRepo || "ccantynz/Gluecron.com";
602 const workflow = readFlag(rest, "--workflow") || "hetzner-deploy.yml";
603 const ref = readFlag(rest, "--ref") || "main";
604 const noWatch = rest.includes("--no-watch");
605 const githubToken =
606 readFlag(rest, "--gh-token") ||
607 process.env.GLUECRON_GITHUB_TOKEN ||
608 cfg.githubToken ||
609 "";
610
611 if (!githubToken) {
612 out(
613 "error: no GitHub token — set GLUECRON_GITHUB_TOKEN or run `gluecron config set github-token <token>`"
614 );
615 return 1;
616 }
617
618 const startedAt = (opts.now ?? Date.now)();
619 out(`> Triggering ${workflow} on ${repo}@${ref}`);
620 let result: DeployDispatchResult;
621 try {
622 result = await triggerWorkflowDispatch(
623 { repo, workflow, ref, githubToken },
624 { fetchImpl: opts.fetchImpl, now: opts.now }
625 );
626 } catch (err) {
627 out(`error: ${(err as Error).message}`);
628 return 1;
629 }
630 out(`> Workflow run dispatched: ${result.htmlUrl}`);
631
632 if (noWatch) return 0;
633
634 out("> Watching deploy status...");
635 const watchResult = await watchDeploy(
636 { repo, runId: result.runId, githubToken, startedAt },
637 out,
638 {
639 fetchImpl: opts.fetchImpl,
640 sleep: opts.sleep,
641 pollMs: opts.pollMs,
642 maxPolls: opts.maxPolls,
643 now: opts.now,
644 }
645 );
646 const elapsed = Math.round(((opts.now ?? Date.now)() - startedAt) / 1000);
647 if (watchResult.ok) {
648 out(`> Deploy succeeded in ${elapsed}s.`);
649 return 0;
650 }
651 out(`! Deploy ${watchResult.conclusion || "failed"} after ${elapsed}s.`);
652 return 1;
653}
654
297655// ---------- Dispatcher ----------
298656
299657export async function dispatch(argv: string[], out = console.log): Promise<number> {
@@ -324,6 +682,10 @@ export async function dispatch(argv: string[], out = console.log): Promise<numbe
324682 return await handleIssuesCmd(cfg, rest, out);
325683 case "gql":
326684 return await handleGqlCmd(cfg, rest, out);
685 case "deploy":
686 return await handleDeployCmd(cfg, rest, out);
687 case "config":
688 return await handleConfigCmd(cfg, rest, out);
327689 default:
328690 out(`unknown command: ${cmd}\n`);
329691 out(HELP);
Addeddocs/DESIGN_AUDIT.md+77−0View fileUnifiedSplit
@@ -0,0 +1,77 @@
1# Design Audit — gluecron
2
3_Date: 2026-05-14 · Block O3 (visual coherence) snapshot._
4
5A one-page snapshot of the design-system state taken at the end of the
6"visual coherence" reconciliation pass. The goal of O3 was not to
7redesign — it was to **consolidate** what is already shipping into one
8named token language so future polish lands in one place.
9
10## Token map
11
12`src/views/layout.tsx` is the single source of truth. Two layers now
13co-exist:
14
15| Concept | Legacy var | O3 alias (preferred for new code) |
16|-----------|-----------------|------------------------------------|
17| Spacing | `--s-1` … `--s-24` | `--space-1` … `--space-24` (4px base) |
18| Radius | `--r-sm`/`--r-md`/`--r-lg`/`--r-xl`/`--r-full` | `--radius-sm` / `--radius-md` / `--radius-lg` / `--radius-xl` / `--radius-full` |
19| Font size | `--t-xs` … `--t-display` | `--font-size-xs` / `-sm` / `-base` / `-md` / `-lg` / `-xl` / `-2xl` / `-3xl` / `-hero` |
20| Leading | _ad-hoc inline_ | `--leading-tight` / `-snug` / `-normal` / `-relaxed` / `-loose` |
21| Z-index | _ad-hoc magic numbers (9997/9998/9999/10000)_ | `--z-base` / `--z-nav` / `--z-sticky` / `--z-overlay` / `--z-modal` / `--z-toast` |
22| Color | `--bg`, `--bg-elevated`, `--text`, `--text-muted`, `--border`, `--accent`, `--green`, `--red`, `--yellow`, `--blue` (unchanged) |
23
24All aliases point at the existing legacy var, so look-and-feel is
25byte-identical.
26
27## Files with the most inline-style drift (top 5)
28
291. `src/routes/dashboard.tsx` — many inline `style="background: rgba(...);..."` patterns.
302. `src/routes/insights.tsx` — repeats the same red/green RGBA tint pattern.
313. `src/routes/billing.tsx` — `style="background:linear-gradient(...)..."`. Adopt `<Card variant="gradient">`.
324. `src/routes/admin.tsx` — administrator-only chrome.
335. `src/routes/migrations.tsx` — error-state borders. Adopt `.notice notice-error`.
34
35## Pages that should adopt `<Card>` (top 10)
36
371. `src/routes/dashboard.tsx`
382. `src/routes/settings.tsx`
393. `src/routes/admin.tsx`
404. `src/routes/billing.tsx`
415. `src/routes/insights.tsx`
426. `src/routes/onboarding.tsx`
437. `src/routes/explore.tsx`
448. `src/routes/help.tsx`
459. `src/routes/repo-settings.tsx`
4610. `src/routes/notifications.tsx`
47
48## What O3 actually changed
49
50- **Token aliases** (additive): `--space-*`, `--radius-*`, `--font-size-*`, `--leading-*`, `--z-*` added to `:root` in `src/views/layout.tsx`.
51- **Card primitive** (additive): `<Card padding="..." variant="...">` shape and `.card-p-*` / `.card-elevated` / `.card-gradient` CSS.
52- **Notice boxes** (`.notice` + `.notice-{info,success,warn,error,accent}`) replace inline DRAFT / 2FA notice boxes across legal pages.
53- **`.code-block`** utility replaces 6 inline pre-tag styles in `help.tsx`.
54- **`.email-preview`** utility replaces inline `background:#fff;color:#111` in `settings.tsx`.
55- **`.status-pill-operational`** replaces the inline status-page pill.
56- **`.api-tag-auth` / `.api-tag-scope`** replace inline method-tag spans in `api-docs.tsx`.
57- **Footer extras**: `<Layout siteBannerText="..." siteBannerLevel="warn">` props plus `.footer-version-pill` and `.footer-banner` CSS so the pre-launch banner can be moved off the top of every page.
58
59## Open questions for the next polish pass
60
611. **Migrate dashboard.tsx panels to `<Card>`** — biggest single win.
622. **Wire the footer banner to the live `site_banner_text` flag.** Layout accepts the prop but no route passes it yet.
633. **Strip the remaining 36 inline-style drift sites.** O3 fixed 12; rest are dashboard/insights stat tiles. A `<Stat>` component would collapse half.
644. **Z-index alias adoption.** `z-index:9999` / `9998` should move to `var(--z-modal)` etc.
655. **Light theme audit.** Notice text-on-tint pairing needs verification on `[data-theme='light']`.
66
67## Operational note on the O3 session
68
69This block was implemented while several parallel agents were also
70writing to the same source tree (`account-deletion.ts`, `landing.tsx`,
71`form-validation-js.tsx`, etc. were all rewritten by concurrent
72work). Several of my edits to `src/views/layout.tsx`,
73`src/views/ui.tsx`, and the route files were silently reverted when
74the parallel work flushed a snapshot back over the tree. The
75inline-style drift fixes (legal pages, settings-2fa, help, settings,
76status, api-docs) need to be re-applied in a follow-up pass; the
77master CSS tokens + the `<Card>` extension are the durable wins.
Addeddrizzle/0046_platform_deploys.sql+33−0View fileUnifiedSplit
@@ -0,0 +1,33 @@
1-- Block N3 — Platform deploy timeline.
2--
3-- Records every deploy of THIS site (gluecron.com itself), surfaced by the
4-- `.github/workflows/hetzner-deploy.yml` workflow which POSTs to
5-- POST /api/events/deploy/started
6-- POST /api/events/deploy/finished
7-- These rows back the site-admin status pill in `src/views/layout.tsx` and
8-- the `/admin/deploys` timeline in `src/routes/admin-deploys.tsx`.
9--
10-- Strictly additive — drop the table to remove it. No FKs.
11--
12-- `run_id` is the GitHub Actions run id and is unique so a retried `started`
13-- POST short-circuits without inserting a second row. `source` namespaces
14-- the deploy target so future Fly/Vultr/Render targets can land here too
15-- without a schema change.
16
17CREATE TABLE IF NOT EXISTS "platform_deploys" (
18 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
19 "run_id" text NOT NULL UNIQUE,
20 "sha" text NOT NULL,
21 "source" text NOT NULL,
22 "status" text NOT NULL DEFAULT 'in_progress',
23 "started_at" timestamptz NOT NULL DEFAULT now(),
24 "finished_at" timestamptz,
25 "duration_ms" integer,
26 "error" text,
27 "created_at" timestamptz NOT NULL DEFAULT now()
28);
29
30--> statement-breakpoint
31
32CREATE INDEX IF NOT EXISTS "idx_platform_deploys_started"
33 ON "platform_deploys" ("started_at" DESC);
Addeddrizzle/0047_password_reset_tokens.sql+28−0View fileUnifiedSplit
@@ -0,0 +1,28 @@
1-- Block P1 — Password reset flow.
2--
3-- A forgot-password user has a path back to their account. Without this,
4-- every locked-out user is a permanent loss.
5--
6-- Strictly additive — drop this table to remove the feature. No changes
7-- to `users`; password rotation happens via `users.password_hash` update
8-- triggered by `src/lib/password-reset.ts::consumeResetToken`.
9
10CREATE TABLE IF NOT EXISTS "password_reset_tokens" (
11 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
12 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
13 "token_hash" text NOT NULL UNIQUE,
14 "expires_at" timestamptz NOT NULL,
15 "used_at" timestamptz,
16 "request_ip" text,
17 "created_at" timestamptz NOT NULL DEFAULT now()
18);
19
20--> statement-breakpoint
21
22CREATE INDEX IF NOT EXISTS "idx_password_reset_tokens_user"
23 ON "password_reset_tokens" ("user_id");
24
25--> statement-breakpoint
26
27CREATE INDEX IF NOT EXISTS "idx_password_reset_tokens_expires"
28 ON "password_reset_tokens" ("expires_at");
Addeddrizzle/0048_email_verification.sql+17−0View fileUnifiedSplit
@@ -0,0 +1,17 @@
1-- Block P2 — Email verification + welcome email.
2-- Strictly additive. Adds an opt-in verification timestamp to `users` and a
3-- token table whose rows are SHA-256 hashed (we never persist plaintext).
4
5ALTER TABLE users
6 ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;
7
8CREATE TABLE IF NOT EXISTS email_verification_tokens (
9 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10 user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11 email text NOT NULL, -- the email being verified (in case of email change)
12 token_hash text NOT NULL UNIQUE,
13 expires_at timestamptz NOT NULL,
14 used_at timestamptz,
15 created_at timestamptz NOT NULL DEFAULT now()
16);
17CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_user ON email_verification_tokens (user_id);
Addeddrizzle/0049_account_deletion.sql+13−0View fileUnifiedSplit
@@ -0,0 +1,13 @@
1-- Block P5 — Account deletion with 30-day grace period.
2-- Strictly additive. Soft-delete via `deleted_at` (sessions are cleared at
3-- schedule time, so the column alone is enough to keep the user out). The
4-- autopilot `account-purge` task hard-deletes rows whose
5-- `deletion_scheduled_for` is in the past.
6
7ALTER TABLE users
8 ADD COLUMN IF NOT EXISTS deleted_at timestamptz,
9 ADD COLUMN IF NOT EXISTS deletion_scheduled_for timestamptz;
10
11CREATE INDEX IF NOT EXISTS idx_users_deletion_scheduled
12 ON users (deletion_scheduled_for)
13 WHERE deletion_scheduled_for IS NOT NULL;
Addeddrizzle/0050_terms_acceptance.sql+8−0View fileUnifiedSplit
@@ -0,0 +1,8 @@
1-- Block P3 — Terms acceptance audit trail.
2-- New register requires a Terms / Privacy checkbox. Record when the user
3-- accepted and the version they accepted. Future Terms changes bump
4-- `terms_version`; the UI surfaces an "accept again" prompt when the
5-- stored value falls behind the current canonical version.
6ALTER TABLE users
7 ADD COLUMN IF NOT EXISTS terms_accepted_at timestamptz,
8 ADD COLUMN IF NOT EXISTS terms_version text;
Addeddrizzle/0051_magic_link_tokens.sql+40−0View fileUnifiedSplit
@@ -0,0 +1,40 @@
1-- Block Q2 — Magic-link sign-in tokens.
2--
3-- One row per outstanding magic-link sign-in request. Mirrors the structure
4-- of 0047_password_reset_tokens.sql and 0048_email_verification.sql — short
5-- random plaintext mailed to the user, sha256 hash persisted, single-use,
6-- time-limited (15-minute TTL enforced at consume time).
7--
8-- `user_id` is NULLABLE: when a user enters an email that does NOT yet have
9-- an account, we still mint a token so consume can create the account on
10-- click (autoCreate=true). The link click is the proof the email is owned.
11--
12-- Strictly additive — drop this table to remove the feature. No changes to
13-- `users` are required; account auto-create happens via `users` INSERT in
14-- `src/lib/magic-link.ts::consumeMagicLinkToken`.
15
16CREATE TABLE IF NOT EXISTS "magic_link_tokens" (
17 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
18 "email" text NOT NULL,
19 "user_id" uuid REFERENCES "users"("id") ON DELETE CASCADE,
20 "token_hash" text NOT NULL UNIQUE,
21 "expires_at" timestamptz NOT NULL,
22 "used_at" timestamptz,
23 "request_ip" text,
24 "created_at" timestamptz NOT NULL DEFAULT now()
25);
26
27--> statement-breakpoint
28
29CREATE INDEX IF NOT EXISTS "idx_magic_link_tokens_email"
30 ON "magic_link_tokens" ("email");
31
32--> statement-breakpoint
33
34CREATE INDEX IF NOT EXISTS "idx_magic_link_tokens_user"
35 ON "magic_link_tokens" ("user_id");
36
37--> statement-breakpoint
38
39CREATE INDEX IF NOT EXISTS "idx_magic_link_tokens_expires"
40 ON "magic_link_tokens" ("expires_at");
Addeddrizzle/0052_playground_accounts.sql+20−0View fileUnifiedSplit
@@ -0,0 +1,20 @@
1-- Block Q3 — Anonymous playground accounts.
2--
3-- Strictly additive. Two nullable columns on `users`:
4-- * is_playground — discriminator. Default false.
5-- * playground_expires_at — TTL. Default null. NOT NULL only when
6-- is_playground = true (enforced in lib code,
7-- not in SQL, so the column stays nullable for
8-- the 99.9% of real users that never play).
9--
10-- A partial index keeps the autopilot purge sweep cheap: only playground
11-- rows are indexed, and the index is ordered by expiry so the
12-- `expires_at < now()` scan is a small left-prefix range read.
13
14ALTER TABLE users
15 ADD COLUMN IF NOT EXISTS is_playground boolean NOT NULL DEFAULT false,
16 ADD COLUMN IF NOT EXISTS playground_expires_at timestamptz;
17
18CREATE INDEX IF NOT EXISTS idx_users_playground_expires
19 ON users (playground_expires_at)
20 WHERE is_playground = true;
Addedextension/gluecron.dxt/manifest.json+62−0View fileUnifiedSplit
@@ -0,0 +1,62 @@
1{
2 "$schema_note": "Anthropic Claude Desktop .dxt extension manifest. Schema is still in flux at the time of authoring (Q1 build); we lock dxt_version to '0.1' as the conservative baseline. If/when Anthropic ships a stable schema reference and the field names diverge, this file should be regenerated to match — keep server.type=http, the user_config prompt fields, and the tools list as the source of truth.",
3 "dxt_version": "0.1",
4 "name": "gluecron",
5 "display_name": "Gluecron",
6 "version": "1.0.0",
7 "description": "AI-native git hosting. Claude can open PRs, review code, merge, manage issues, and ship — all on Gluecron.",
8 "long_description": "Gluecron is the git host built around Claude. This extension gives Claude 15 tools to read and write against your Gluecron-hosted repos: open and merge PRs, file and comment on issues, search code, list and read files, get health reports, drive your AI-native development loop end-to-end. No more switching between Claude and a browser tab.",
9 "author": {
10 "name": "Gluecron",
11 "url": "https://gluecron.com"
12 },
13 "homepage": "https://gluecron.com",
14 "documentation": "https://gluecron.com/help#mcp",
15 "support": "https://gluecron.com/help",
16 "icon": "icon.png",
17 "screenshots": ["screenshot-1.png"],
18 "server": {
19 "type": "http",
20 "endpoint": "${user_config.gluecron_host}/mcp",
21 "headers": {
22 "Authorization": "Bearer ${user_config.gluecron_pat}"
23 }
24 },
25 "user_config": {
26 "gluecron_host": {
27 "type": "string",
28 "title": "Gluecron host",
29 "description": "URL of your Gluecron instance.",
30 "default": "https://gluecron.com",
31 "required": true
32 },
33 "gluecron_pat": {
34 "type": "string",
35 "title": "Personal access token",
36 "description": "Generate one at <host>/settings/tokens with admin scope (needed for write tools).",
37 "sensitive": true,
38 "required": true
39 }
40 },
41 "tools": [
42 {"name": "gluecron_repo_search", "description": "Search public Gluecron repositories by keyword."},
43 {"name": "gluecron_repo_read_file", "description": "Read a file from a Gluecron repo at a ref."},
44 {"name": "gluecron_repo_list_issues", "description": "List open issues for a repo."},
45 {"name": "gluecron_repo_explain_codebase", "description": "Get the AI explain-this-codebase Markdown for a repo."},
46 {"name": "gluecron_repo_health", "description": "Compute the current health score for a repo."},
47 {"name": "gluecron_create_issue", "description": "Open a new issue."},
48 {"name": "gluecron_comment_issue", "description": "Comment on an issue."},
49 {"name": "gluecron_close_issue", "description": "Close an issue."},
50 {"name": "gluecron_reopen_issue", "description": "Reopen an issue."},
51 {"name": "gluecron_create_pr", "description": "Open a pull request from head into base."},
52 {"name": "gluecron_get_pr", "description": "Read a PR's metadata + state + mergeable hint."},
53 {"name": "gluecron_list_prs", "description": "List PRs by state."},
54 {"name": "gluecron_comment_pr", "description": "Comment on a PR."},
55 {"name": "gluecron_merge_pr", "description": "Merge a PR (gated on branch protection + AI review + risk score)."},
56 {"name": "gluecron_close_pr", "description": "Close a PR without merging."}
57 ],
58 "compatibility": {
59 "claude_desktop": ">=0.10.0",
60 "platforms": ["darwin", "win32", "linux"]
61 }
62}
Modifiedscripts/bootstrap-hetzner.sh+23−3View fileUnifiedSplit
@@ -194,8 +194,24 @@ ok "deps + migrations done"
194194# ────────────────────────────────────────────────────────────────────────────
195195# 8. systemd unit for gluecron
196196# ────────────────────────────────────────────────────────────────────────────
197# Block N2 — `Type=notify` so `systemctl restart gluecron` blocks until the
198# new process has called sd_notify(READY=1) (wired in src/lib/systemd-notify.ts
199# from src/index.ts). That eliminates the post-restart sleep-and-poll loop in
200# the deploy workflow.
201#
202# Block N2 — Bun-compiled single-binary path. If `/opt/gluecron/.next/gluecron-server`
203# exists (built by the deploy workflow), prefer it (~10x faster cold start vs
204# `bun run src/index.ts`). Falls back to interpreted Bun for safety so a stale
205# bootstrap still produces a runnable unit on a brand-new box.
197206say "[8/11] Writing /etc/systemd/system/gluecron.service"
198207BUN_BIN=/root/.bun/bin/bun
208COMPILED_BIN=/opt/gluecron/.next/gluecron-server
209mkdir -p /opt/gluecron/.next
210if [ -x "$COMPILED_BIN" ]; then
211 EXEC_START="$COMPILED_BIN"
212else
213 EXEC_START="${BUN_BIN} run src/index.ts"
214fi
199215cat > /etc/systemd/system/gluecron.service <<EOF
200216[Unit]
201217Description=Gluecron — AI-native code intelligence platform
@@ -203,13 +219,17 @@ After=network-online.target postgresql.service
203219Wants=network-online.target
204220
205221[Service]
206Type=simple
222Type=notify
223NotifyAccess=main
207224User=root
208225WorkingDirectory=/opt/gluecron
209226EnvironmentFile=/etc/gluecron.env
210ExecStart=${BUN_BIN} run src/index.ts
227ExecStart=${EXEC_START}
211228Restart=always
212229RestartSec=5
230# Block N2 — give the new process up to 30s to call sd_notify(READY=1).
231# Default is 90s; we lower it so a hung-on-startup deploy fails fast.
232TimeoutStartSec=30
213233StandardOutput=journal
214234StandardError=journal
215235SyslogIdentifier=gluecron
@@ -221,7 +241,7 @@ EOF
221241systemctl daemon-reload
222242systemctl enable gluecron >/dev/null
223243systemctl restart gluecron
224ok "gluecron systemd unit installed + started"
244ok "gluecron systemd unit installed + started (Type=notify, ExecStart=${EXEC_START})"
225245
226246# ────────────────────────────────────────────────────────────────────────────
227247# 9. Append gluecron + www.gluecron to Caddyfile if missing
Addedscripts/build-dxt-assets.ts+302−0View fileUnifiedSplit
@@ -0,0 +1,302 @@
1
2/**
3 * BLOCK Q1 — placeholder asset generator for the Claude Desktop .dxt bundle.
4 *
5 * Generates two PNGs into extension/gluecron.dxt/:
6 * - icon.png 256x256 — dark-bg "g" mark with accent gradient
7 * - screenshot-1.png 1280x800 — dark-bg wordmark placeholder
8 *
9 * These are intentionally simple: hand-rolled PNGs (no Canvas / sharp / etc.)
10 * so the build works on any system with `bun` + system `zip`. The intent is
11 * for a designer to replace them later; both files are committed as
12 * placeholders so the .dxt is shippable today.
13 *
14 * Follow-up: replace icon.png with the actual brand "g" SVG-rasterised at
15 * 256x256, and screenshot-1.png with a real product screenshot of Claude
16 * opening a PR on Gluecron.
17 *
18 * Usage:
19 * bun run scripts/build-dxt-assets.ts
20 *
21 * Idempotent — re-running overwrites the files in place.
22 */
23
24import { writeFileSync, mkdirSync } from "node:fs";
25import { join, dirname } from "node:path";
26import { deflateSync } from "node:zlib";
27
28const OUT_DIR = join(import.meta.dir, "..", "extension", "gluecron.dxt");
29
30// ---------------------------------------------------------------------------
31// Minimal PNG encoder (RGB, no alpha, no filtering beyond filter=0).
32// Hand-rolled to avoid pulling in a dependency just for placeholder assets.
33// ---------------------------------------------------------------------------
34
35const CRC_TABLE = (() => {
36 const t = new Uint32Array(256);
37 for (let n = 0; n < 256; n++) {
38 let c = n;
39 for (let k = 0; k < 8; k++) {
40 c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
41 }
42 t[n] = c >>> 0;
43 }
44 return t;
45})();
46
47function crc32(buf: Uint8Array): number {
48 let c = 0xffffffff;
49 for (let i = 0; i < buf.length; i++) {
50 c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
51 }
52 return (c ^ 0xffffffff) >>> 0;
53}
54
55function u32be(n: number): Uint8Array {
56 const b = new Uint8Array(4);
57 b[0] = (n >>> 24) & 0xff;
58 b[1] = (n >>> 16) & 0xff;
59 b[2] = (n >>> 8) & 0xff;
60 b[3] = n & 0xff;
61 return b;
62}
63
64function chunk(type: string, data: Uint8Array): Uint8Array {
65 const typeBytes = new TextEncoder().encode(type);
66 const len = u32be(data.length);
67 const crcInput = new Uint8Array(typeBytes.length + data.length);
68 crcInput.set(typeBytes, 0);
69 crcInput.set(data, typeBytes.length);
70 const crc = u32be(crc32(crcInput));
71 const out = new Uint8Array(4 + 4 + data.length + 4);
72 out.set(len, 0);
73 out.set(typeBytes, 4);
74 out.set(data, 8);
75 out.set(crc, 8 + data.length);
76 return out;
77}
78
79function encodePng(
80 width: number,
81 height: number,
82 pixels: Uint8Array // RGB, length = width*height*3
83): Uint8Array {
84 if (pixels.length !== width * height * 3) {
85 throw new Error("pixel buffer size mismatch");
86 }
87 // IHDR
88 const ihdr = new Uint8Array(13);
89 ihdr.set(u32be(width), 0);
90 ihdr.set(u32be(height), 4);
91 ihdr[8] = 8; // bit depth
92 ihdr[9] = 2; // color type: RGB
93 ihdr[10] = 0; // compression
94 ihdr[11] = 0; // filter
95 ihdr[12] = 0; // interlace
96
97 // IDAT — prepend filter byte (0 = None) to every scanline.
98 const raw = new Uint8Array(height * (1 + width * 3));
99 for (let y = 0; y < height; y++) {
100 const rowStart = y * (1 + width * 3);
101 raw[rowStart] = 0; // filter type
102 raw.set(pixels.subarray(y * width * 3, (y + 1) * width * 3), rowStart + 1);
103 }
104 const idat = deflateSync(Buffer.from(raw));
105
106 const SIG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
107 const ihdrChunk = chunk("IHDR", ihdr);
108 const idatChunk = chunk("IDAT", new Uint8Array(idat));
109 const iendChunk = chunk("IEND", new Uint8Array(0));
110
111 const out = new Uint8Array(
112 SIG.length + ihdrChunk.length + idatChunk.length + iendChunk.length
113 );
114 let off = 0;
115 out.set(SIG, off);
116 off += SIG.length;
117 out.set(ihdrChunk, off);
118 off += ihdrChunk.length;
119 out.set(idatChunk, off);
120 off += idatChunk.length;
121 out.set(iendChunk, off);
122 return out;
123}
124
125// ---------------------------------------------------------------------------
126// Drawing helpers
127// ---------------------------------------------------------------------------
128
129type Rgb = [number, number, number];
130
131const BG: Rgb = [0x0d, 0x11, 0x17]; // #0d1117
132const ACCENT_START: Rgb = [0x8c, 0x6d, 0xff]; // #8c6dff (purple)
133const ACCENT_END: Rgb = [0x36, 0xc5, 0xd6]; // #36c5d6 (teal)
134const FG: Rgb = [0xe6, 0xed, 0xf3]; // soft white
135
136function lerp(a: number, b: number, t: number): number {
137 return Math.round(a + (b - a) * t);
138}
139
140function lerpRgb(a: Rgb, b: Rgb, t: number): Rgb {
141 return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
142}
143
144function makeBuf(w: number, h: number, fill: Rgb): Uint8Array {
145 const buf = new Uint8Array(w * h * 3);
146 for (let i = 0; i < w * h; i++) {
147 buf[i * 3] = fill[0];
148 buf[i * 3 + 1] = fill[1];
149 buf[i * 3 + 2] = fill[2];
150 }
151 return buf;
152}
153
154function setPixel(buf: Uint8Array, w: number, x: number, y: number, c: Rgb) {
155 const i = (y * w + x) * 3;
156 buf[i] = c[0];
157 buf[i + 1] = c[1];
158 buf[i + 2] = c[2];
159}
160
161function fillRect(
162 buf: Uint8Array,
163 w: number,
164 x0: number,
165 y0: number,
166 x1: number,
167 y1: number,
168 c: Rgb
169) {
170 for (let y = y0; y < y1; y++) {
171 for (let x = x0; x < x1; x++) {
172 setPixel(buf, w, x, y, c);
173 }
174 }
175}
176
177function fillCircle(
178 buf: Uint8Array,
179 w: number,
180 cx: number,
181 cy: number,
182 r: number,
183 c: Rgb
184) {
185 const r2 = r * r;
186 for (let y = cy - r; y <= cy + r; y++) {
187 for (let x = cx - r; x <= cx + r; x++) {
188 const dx = x - cx;
189 const dy = y - cy;
190 if (dx * dx + dy * dy <= r2) {
191 setPixel(buf, w, x, y, c);
192 }
193 }
194 }
195}
196
197function ringMask(cx: number, cy: number, rOuter: number, rInner: number) {
198 const rO2 = rOuter * rOuter;
199 const rI2 = rInner * rInner;
200 return (x: number, y: number) => {
201 const dx = x - cx;
202 const dy = y - cy;
203 const d2 = dx * dx + dy * dy;
204 return d2 <= rO2 && d2 >= rI2;
205 };
206}
207
208// ---------------------------------------------------------------------------
209// 1. icon.png — 256x256 "g" mark on dark bg, gradient ring
210// ---------------------------------------------------------------------------
211
212function drawIcon(): Uint8Array {
213 const W = 256;
214 const H = 256;
215 const buf = makeBuf(W, H, BG);
216
217 // Gradient ring: outer radius 110, inner radius 86.
218 const cx = W / 2;
219 const cy = H / 2;
220 const inRing = ringMask(cx, cy, 110, 86);
221 for (let y = 0; y < H; y++) {
222 for (let x = 0; x < W; x++) {
223 if (inRing(x, y)) {
224 // gradient along x (left = start, right = end).
225 const t = x / W;
226 setPixel(buf, W, x, y, lerpRgb(ACCENT_START, ACCENT_END, t));
227 }
228 }
229 }
230
231 // Stylised "g" — a filled disc with a notch cut on the right side and a
232 // descender bar. Placeholder, not the real wordmark.
233 fillCircle(buf, W, cx, cy, 60, FG);
234 // Cut the right notch (rectangle in BG colour).
235 fillRect(buf, W, cx + 20, cy - 18, cx + 65, cy + 8, BG);
236 // Descender bar.
237 fillRect(buf, W, cx + 18, cy + 8, cx + 60, cy + 24, FG);
238
239 return encodePng(W, H, buf);
240}
241
242// ---------------------------------------------------------------------------
243// 2. screenshot-1.png — 1280x800 dark mockup with wordmark band
244// ---------------------------------------------------------------------------
245
246function drawScreenshot(): Uint8Array {
247 const W = 1280;
248 const H = 800;
249 const buf = makeBuf(W, H, BG);
250
251 // Top accent gradient bar (the wordmark band).
252 for (let y = 60; y < 80; y++) {
253 for (let x = 0; x < W; x++) {
254 const t = x / W;
255 setPixel(buf, W, x, y, lerpRgb(ACCENT_START, ACCENT_END, t));
256 }
257 }
258
259 // Mock "browser chrome" — three traffic-light circles.
260 fillCircle(buf, W, 30, 30, 8, [0xff, 0x5f, 0x57]);
261 fillCircle(buf, W, 52, 30, 8, [0xfe, 0xbc, 0x2e]);
262 fillCircle(buf, W, 74, 30, 8, [0x27, 0xc9, 0x3f]);
263
264 // Mock "card" — a centered panel that hints at a PR view.
265 const px = 200;
266 const py = 200;
267 const pw = W - 400;
268 const ph = H - 400;
269 fillRect(buf, W, px, py, px + pw, py + ph, [0x16, 0x1b, 0x22]); // panel bg
270
271 // Header strip on the panel
272 fillRect(buf, W, px, py, px + pw, py + 60, [0x21, 0x26, 0x2d]);
273
274 // "PR opened" accent dot (green).
275 fillCircle(buf, W, px + 30, py + 30, 10, [0x3f, 0xb9, 0x50]);
276
277 // Two faux content rows.
278 fillRect(buf, W, px + 30, py + 100, px + pw - 30, py + 110, [0x30, 0x36, 0x3d]);
279 fillRect(buf, W, px + 30, py + 140, px + pw - 100, py + 150, [0x30, 0x36, 0x3d]);
280 fillRect(buf, W, px + 30, py + 180, px + pw - 200, py + 190, [0x30, 0x36, 0x3d]);
281
282 return encodePng(W, H, buf);
283}
284
285// ---------------------------------------------------------------------------
286// Main
287// ---------------------------------------------------------------------------
288
289function main() {
290 mkdirSync(OUT_DIR, { recursive: true });
291
292 const iconPath = join(OUT_DIR, "icon.png");
293 const screenshotPath = join(OUT_DIR, "screenshot-1.png");
294
295 writeFileSync(iconPath, drawIcon());
296 console.log(`wrote ${iconPath}`);
297
298 writeFileSync(screenshotPath, drawScreenshot());
299 console.log(`wrote ${screenshotPath}`);
300}
301
302main();
Addedscripts/build-dxt.sh+86−0View fileUnifiedSplit
@@ -0,0 +1,86 @@
1
2# =============================================================================
3# BLOCK Q1 — Claude Desktop (.dxt) extension build script.
4#
5# bash scripts/build-dxt.sh
6#
7# What it does:
8# 1. Validates extension/gluecron.dxt/manifest.json is parseable JSON
9# 2. Regenerates placeholder icon.png + screenshot-1.png (idempotent)
10# 3. Zips everything under extension/gluecron.dxt/ into public/gluecron.dxt
11# 4. Prints the size + path
12#
13# Output:
14# public/gluecron.dxt — the user-facing extension bundle, served by
15# GET /gluecron.dxt (see src/routes/dxt.ts).
16#
17# Re-runnable. Safe to call on every deploy. No third-party deps beyond
18# system `zip` (available on macOS, Linux, WSL).
19# =============================================================================
20
21set -euo pipefail
22
23ROOT="$(cd "$(dirname "$0")/.." && pwd)"
24SRC_DIR="$ROOT/extension/gluecron.dxt"
25OUT_DIR="$ROOT/public"
26OUT_FILE="$OUT_DIR/gluecron.dxt"
27
28# ── pretty printers ─────────────────────────────────────────────────────────
29say() { printf "\n\033[1;34m> %s\033[0m\n" "$*"; }
30ok() { printf " \033[32mv\033[0m %s\n" "$*"; }
31warn() { printf " \033[33m!\033[0m %s\n" "$*"; }
32fail() { printf " \033[31mx\033[0m %s\n" "$*"; exit 1; }
33
34# ── 1. Prerequisites ────────────────────────────────────────────────────────
35say "[1/4] Checking prerequisites"
36command -v zip >/dev/null 2>&1 || fail "zip is not on PATH — install it (apt: zip / brew: zip) and retry"
37ok "zip found: $(command -v zip)"
38
39[[ -d "$SRC_DIR" ]] || fail "extension/gluecron.dxt/ missing — bad checkout?"
40[[ -f "$SRC_DIR/manifest.json" ]] || fail "manifest.json missing under $SRC_DIR"
41ok "source tree at $SRC_DIR"
42
43# ── 2. Validate manifest is parseable JSON ─────────────────────────────────
44say "[2/4] Validating manifest.json"
45if command -v bun >/dev/null 2>&1; then
46 bun -e "JSON.parse(require('fs').readFileSync('$SRC_DIR/manifest.json','utf8'))" \
47 || fail "manifest.json is not valid JSON"
48elif command -v node >/dev/null 2>&1; then
49 node -e "JSON.parse(require('fs').readFileSync('$SRC_DIR/manifest.json','utf8'))" \
50 || fail "manifest.json is not valid JSON"
51elif command -v python3 >/dev/null 2>&1; then
52 python3 -c "import json,sys;json.load(open('$SRC_DIR/manifest.json'))" \
53 || fail "manifest.json is not valid JSON"
54else
55 warn "no bun/node/python3 — skipping JSON validation"
56fi
57ok "manifest.json is valid"
58
59# ── 3. Regenerate placeholder assets if bun is available ───────────────────
60say "[3/4] Refreshing placeholder assets"
61if command -v bun >/dev/null 2>&1 && [[ -f "$ROOT/scripts/build-dxt-assets.ts" ]]; then
62 bun run "$ROOT/scripts/build-dxt-assets.ts"
63 ok "assets refreshed"
64else
65 warn "bun missing or build-dxt-assets.ts not found — using committed PNGs as-is"
66fi
67
68# ── 4. Zip → public/gluecron.dxt ────────────────────────────────────────────
69say "[4/4] Packaging gluecron.dxt"
70mkdir -p "$OUT_DIR"
71# `zip -j` would flatten paths; we want manifest.json at the ZIP root so
72# Claude Desktop finds it. Use a clean temp dir + `cd` to keep the archive
73# rooted at the bundle directory itself.
74TMP_DIR="$(mktemp -d)"
75trap 'rm -rf "$TMP_DIR"' EXIT
76cp -R "$SRC_DIR/." "$TMP_DIR/"
77# Remove the previous archive so `zip` doesn't try to update an existing one.
78rm -f "$OUT_FILE"
79( cd "$TMP_DIR" && zip -q -r "$OUT_FILE" . )
80ok "wrote $OUT_FILE"
81
82SIZE=$(wc -c < "$OUT_FILE" | tr -d ' ')
83HUMAN=$(printf "%.1f KB" "$(echo "scale=1; $SIZE/1024" | bc 2>/dev/null || echo "$SIZE")" 2>/dev/null || echo "$SIZE bytes")
84ok "size: $HUMAN ($SIZE bytes)"
85
86printf "\nDone. Test locally:\n curl -I http://localhost:3000/gluecron.dxt\n\n"
Addedscripts/check-auto-merge-readiness.ts+219−0View fileUnifiedSplit
@@ -0,0 +1,219 @@
1/**
2 * Block N1 — Auto-merge readiness preflight.
3 *
4 * Operator runs this BEFORE `enable-auto-merge.ts` to make sure the box
5 * is actually in a state where flipping the switch will produce useful
6 * behaviour. Exits 0 when every check is green, 1 when any check fails.
7 *
8 * Checks:
9 * 1. Migration 0040 has been applied (`branch_protection.enable_auto_merge`
10 * column exists). The bootstrap script depends on this column.
11 * 2. `ANTHROPIC_API_KEY` is set on the box — without it the AI approval
12 * gate degrades to "AI review unavailable", which is treated as
13 * not-approved, and every auto-merge candidate gets blocked.
14 * 3. The autopilot is running — `AUTOPILOT_DISABLED` is not `"1"`.
15 * The K3 sweep task is the only thing that actually performs the
16 * merge; if the autopilot is off, opt-in does nothing.
17 * 4. The K3 `auto-merge-sweep` task is registered in `defaultTasks()`.
18 * Guards against someone removing the task name during a refactor.
19 *
20 * Pure-ish: each check is a small async function that returns a Result
21 * record. The runner just iterates them and prints a checklist. Tests
22 * drive the pure helpers directly.
23 */
24
25import { sql } from "drizzle-orm";
26
27// ---------------------------------------------------------------------------
28// Types + pretty printers
29// ---------------------------------------------------------------------------
30
31export type CheckStatus = "pass" | "fail";
32
33export interface CheckResult {
34 name: string;
35 status: CheckStatus;
36 reason?: string;
37}
38
39const GREEN = "\x1b[32m";
40const RED = "\x1b[31m";
41const DIM = "\x1b[2m";
42const RESET = "\x1b[0m";
43
44function icon(s: CheckStatus): string {
45 return s === "pass" ? `${GREEN}v${RESET}` : `${RED}x${RESET}`;
46}
47
48// ---------------------------------------------------------------------------
49// Pure check helpers
50// ---------------------------------------------------------------------------
51
52/**
53 * Test the `ANTHROPIC_API_KEY` env var. Pure function over the supplied
54 * env object so tests don't have to mutate `process.env`.
55 */
56export function checkAnthropicKey(env: NodeJS.ProcessEnv): CheckResult {
57 const key = env.ANTHROPIC_API_KEY;
58 if (!key || key.length < 10) {
59 return {
60 name: "ANTHROPIC_API_KEY set",
61 status: "fail",
62 reason:
63 "ANTHROPIC_API_KEY is missing — AI approval gate will block every auto-merge candidate.",
64 };
65 }
66 return { name: "ANTHROPIC_API_KEY set", status: "pass" };
67}
68
69/**
70 * Confirm the autopilot ticker is enabled. `AUTOPILOT_DISABLED=1` turns
71 * the K3 sweep off, which means opt-in does nothing useful.
72 */
73export function checkAutopilotEnabled(env: NodeJS.ProcessEnv): CheckResult {
74 if (env.AUTOPILOT_DISABLED === "1") {
75 return {
76 name: "Autopilot enabled (AUTOPILOT_DISABLED != 1)",
77 status: "fail",
78 reason:
79 "AUTOPILOT_DISABLED=1 — unset (or set to 0) to let the K3 sweep run.",
80 };
81 }
82 return {
83 name: "Autopilot enabled (AUTOPILOT_DISABLED != 1)",
84 status: "pass",
85 };
86}
87
88/**
89 * Confirm the K3 sweep task is registered. We accept any iterable that
90 * yields objects with a `.name` so tests don't have to import the real
91 * autopilot module.
92 */
93export function checkAutoMergeSweepRegistered(
94 tasks: Array<{ name: string }>
95): CheckResult {
96 const found = tasks.some((t) => t.name === "auto-merge-sweep");
97 if (!found) {
98 return {
99 name: "K3 auto-merge-sweep task registered",
100 status: "fail",
101 reason:
102 "defaultTasks() does not include 'auto-merge-sweep' — the K3 sweep is missing.",
103 };
104 }
105 return {
106 name: "K3 auto-merge-sweep task registered",
107 status: "pass",
108 };
109}
110
111/**
112 * Verify migration 0040 has landed by probing for the
113 * `branch_protection.enable_auto_merge` column. Pure-ish: takes the
114 * runner as a callback so tests can stub it.
115 */
116export async function checkMigration0040(
117 runner: () => Promise<{ exists: boolean; error?: string }>
118): Promise<CheckResult> {
119 try {
120 const { exists, error } = await runner();
121 if (error) {
122 return {
123 name: "Migration 0040 applied",
124 status: "fail",
125 reason: `column probe failed: ${error}`,
126 };
127 }
128 if (!exists) {
129 return {
130 name: "Migration 0040 applied",
131 status: "fail",
132 reason:
133 "branch_protection.enable_auto_merge column missing — run `bun run db:migrate`.",
134 };
135 }
136 return { name: "Migration 0040 applied", status: "pass" };
137 } catch (err) {
138 return {
139 name: "Migration 0040 applied",
140 status: "fail",
141 reason: err instanceof Error ? err.message : String(err),
142 };
143 }
144}
145
146// ---------------------------------------------------------------------------
147// CLI driver
148// ---------------------------------------------------------------------------
149
150async function probeAutoMergeColumn(): Promise<{ exists: boolean; error?: string }> {
151 try {
152 const { db } = await import("../src/db");
153 // Pull the column out of information_schema. Works on Postgres and
154 // is safe to run on a healthy migrated DB.
155 const rows = await db.execute(
156 sql`SELECT column_name FROM information_schema.columns
157 WHERE table_name = 'branch_protection'
158 AND column_name = 'enable_auto_merge'
159 LIMIT 1`
160 );
161 const list =
162 (rows as any).rows ?? (Array.isArray(rows) ? rows : []);
163 return { exists: list.length > 0 };
164 } catch (err) {
165 return {
166 exists: false,
167 error: err instanceof Error ? err.message : String(err),
168 };
169 }
170}
171
172async function getDefaultTasks(): Promise<Array<{ name: string }>> {
173 try {
174 const mod = await import("../src/lib/autopilot");
175 return mod.defaultTasks();
176 } catch {
177 return [];
178 }
179}
180
181async function main() {
182 console.log(
183 `${DIM}gluecron auto-merge readiness — ${new Date().toISOString()}${RESET}`
184 );
185
186 const results: CheckResult[] = [];
187
188 results.push(
189 await checkMigration0040(probeAutoMergeColumn)
190 );
191 results.push(checkAnthropicKey(process.env));
192 results.push(checkAutopilotEnabled(process.env));
193 results.push(checkAutoMergeSweepRegistered(await getDefaultTasks()));
194
195 for (const r of results) {
196 const tail = r.reason ? ` — ${r.reason}` : "";
197 console.log(` ${icon(r.status)} ${r.name}${tail}`);
198 }
199
200 const failed = results.filter((r) => r.status === "fail").length;
201 console.log("");
202 if (failed > 0) {
203 console.log(
204 `${RED}readiness FAILED — fix the items above before running enable-auto-merge.ts${RESET}`
205 );
206 process.exit(1);
207 }
208 console.log(
209 `${GREEN}readiness clean — safe to run enable-auto-merge.ts${RESET}`
210 );
211 process.exit(0);
212}
213
214if (import.meta.main) {
215 main().catch((err) => {
216 console.error(err instanceof Error ? err.message : String(err));
217 process.exit(1);
218 });
219}
Addedscripts/enable-auto-merge.ts+385−0View fileUnifiedSplit
@@ -0,0 +1,385 @@
1/**
2 * Block N1 — Enable auto-merge on a single repo + branch pattern.
3 *
4 * Operator-driven, single-shot bootstrap script. After PR #62 lands and
5 * the K3 autopilot sweep is live, the operator flips one repo into
6 * "auto-merge mode" with:
7 *
8 * bun run scripts/enable-auto-merge.ts ccantynz/Gluecron.com
9 * bun run scripts/enable-auto-merge.ts ccantynz/Gluecron.com release/*
10 *
11 * Behaviour (idempotent):
12 * 1. Resolve repo by `owner/name`. Fail loudly if not found.
13 * 2. If a `branch_protection` row already exists for (repo, pattern):
14 * flip `enableAutoMerge=true` (only that field — everything else
15 * stays as the owner configured it).
16 * 3. If no row exists: INSERT a new row with the safety defaults below,
17 * with `enableAutoMerge=true`.
18 * 4. Print a before / after diff so the operator sees exactly what
19 * changed.
20 * 5. Write an `auto_merge.enabled_on_main` audit row so we can trace
21 * who flipped the switch.
22 *
23 * Safety defaults on a fresh insert:
24 * - pattern = the supplied branch (default `main`)
25 * - requireGreenGates = true (no flaky tests slipping through)
26 * - requireAiApproval = true (Claude must approve — that's the point)
27 * - requireHumanReview = false (otherwise auto-merge can't fire)
28 * - requiredApprovals = 0
29 * - enableAutoMerge = true
30 * - dismissStaleReviews = false (we don't want stale-review churn)
31 * - requirePullRequest = true
32 * - allowForcePush = false
33 * - allowDeletion = false
34 *
35 * Revert: re-run with `--off`, or hand-toggle the column.
36 *
37 * Reads DATABASE_URL from env. The pure orchestrator `runEnableAutoMerge`
38 * is exported and takes a DB-shaped dependency so tests can drive every
39 * branch without touching Neon.
40 */
41
42import { and, eq } from "drizzle-orm";
43import {
44 branchProtection,
45 repositories,
46 users,
47 auditLog,
48} from "../src/db/schema";
49import type { BranchProtection } from "../src/db/schema";
50
51// ---------------------------------------------------------------------------
52// Types
53// ---------------------------------------------------------------------------
54
55export interface EnableAutoMergeArgs {
56 ownerSlash: string; // "owner/name"
57 pattern: string; // e.g. "main" or "release/*"
58 off?: boolean; // when true, set enableAutoMerge=false instead
59 actorUserId?: string | null; // optional audit attribution
60}
61
62export interface EnableAutoMergeResult {
63 action: "inserted" | "updated" | "noop";
64 before: BranchProtection | null;
65 after: BranchProtection;
66 auditWritten: boolean;
67}
68
69/**
70 * Minimal DB surface this script needs. Mirrors the Drizzle methods used
71 * directly so tests can supply a fake without standing up a real Drizzle
72 * instance.
73 */
74export interface DbLike {
75 select: (...args: any[]) => any;
76 insert: (...args: any[]) => any;
77 update: (...args: any[]) => any;
78}
79
80// ---------------------------------------------------------------------------
81// Core orchestrator
82// ---------------------------------------------------------------------------
83
84const SAFETY_DEFAULTS = {
85 requirePullRequest: true,
86 requireGreenGates: true,
87 requireAiApproval: true,
88 requireHumanReview: false,
89 requiredApprovals: 0,
90 allowForcePush: false,
91 allowDeletion: false,
92 dismissStaleReviews: false,
93} as const;
94
95/**
96 * Resolve a repo row by `owner/name`. Returns null when either the owner
97 * or the repo is missing — the caller turns that into a clean exit code.
98 */
99export async function resolveRepo(
100 db: DbLike,
101 ownerSlash: string
102): Promise<{ id: string; ownerId: string; name: string; defaultBranch: string } | null> {
103 const slashIdx = ownerSlash.indexOf("/");
104 if (slashIdx <= 0 || slashIdx === ownerSlash.length - 1) return null;
105 const ownerName = ownerSlash.slice(0, slashIdx);
106 const repoName = ownerSlash.slice(slashIdx + 1);
107
108 const ownerRows = await db
109 .select({ id: users.id })
110 .from(users)
111 .where(eq(users.username, ownerName))
112 .limit(1);
113 const owner = (ownerRows as Array<{ id: string }>)[0];
114 if (!owner) return null;
115
116 const repoRows = await db
117 .select({
118 id: repositories.id,
119 ownerId: repositories.ownerId,
120 name: repositories.name,
121 defaultBranch: repositories.defaultBranch,
122 })
123 .from(repositories)
124 .where(
125 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
126 )
127 .limit(1);
128 const repo = (repoRows as Array<{
129 id: string;
130 ownerId: string;
131 name: string;
132 defaultBranch: string;
133 }>)[0];
134 return repo ?? null;
135}
136
137/**
138 * Pure orchestrator. All DB access goes through `db` so tests can inject
139 * a fake. Returns the before/after rows so the CLI can render a diff and
140 * the test suite can assert on the transition.
141 */
142export async function runEnableAutoMerge(
143 db: DbLike,
144 args: EnableAutoMergeArgs,
145 audit: (opts: {
146 userId?: string | null;
147 repositoryId?: string | null;
148 action: string;
149 targetType?: string;
150 targetId?: string;
151 metadata?: Record<string, unknown>;
152 }) => Promise<void>
153): Promise<EnableAutoMergeResult> {
154 const repo = await resolveRepo(db, args.ownerSlash);
155 if (!repo) {
156 throw new Error(
157 `Repository not found: ${args.ownerSlash}. Pass owner/name (e.g. ccantynz/Gluecron.com).`
158 );
159 }
160
161 const desiredEnableAutoMerge = !args.off;
162
163 // Look up existing rule for this (repo, pattern).
164 const existingRows = await db
165 .select()
166 .from(branchProtection)
167 .where(
168 and(
169 eq(branchProtection.repositoryId, repo.id),
170 eq(branchProtection.pattern, args.pattern)
171 )
172 )
173 .limit(1);
174 const existing = (existingRows as BranchProtection[])[0] ?? null;
175
176 if (existing) {
177 // Idempotent: when the row is already in the desired state, skip
178 // both the UPDATE and the audit write. Operators running the script
179 // twice in a row should see "no-op", not a noisy audit trail.
180 if (existing.enableAutoMerge === desiredEnableAutoMerge) {
181 return {
182 action: "noop",
183 before: existing,
184 after: existing,
185 auditWritten: false,
186 };
187 }
188
189 // Snapshot BEFORE the mutation — `existing` may be the same row
190 // reference the DB layer hands to `update().set()`, so we copy here
191 // to keep the returned `before` field stable for callers.
192 const beforeSnapshot: BranchProtection = { ...existing };
193
194 const now = new Date();
195 await db
196 .update(branchProtection)
197 .set({ enableAutoMerge: desiredEnableAutoMerge, updatedAt: now })
198 .where(eq(branchProtection.id, existing.id));
199
200 const after: BranchProtection = {
201 ...beforeSnapshot,
202 enableAutoMerge: desiredEnableAutoMerge,
203 updatedAt: now,
204 };
205
206 await audit({
207 userId: args.actorUserId ?? null,
208 repositoryId: repo.id,
209 action: desiredEnableAutoMerge
210 ? "auto_merge.enabled_on_main"
211 : "auto_merge.disabled_on_main",
212 targetType: "branch_protection",
213 targetId: existing.id,
214 metadata: {
215 pattern: args.pattern,
216 before: { enableAutoMerge: beforeSnapshot.enableAutoMerge },
217 after: { enableAutoMerge: desiredEnableAutoMerge },
218 },
219 });
220
221 return {
222 action: "updated",
223 before: beforeSnapshot,
224 after,
225 auditWritten: true,
226 };
227 }
228
229 // No existing row — INSERT a new one with the documented safety
230 // defaults plus the requested auto-merge bit.
231 const inserted = await db
232 .insert(branchProtection)
233 .values({
234 repositoryId: repo.id,
235 pattern: args.pattern,
236 ...SAFETY_DEFAULTS,
237 enableAutoMerge: desiredEnableAutoMerge,
238 })
239 .returning();
240 const after = (inserted as BranchProtection[])[0];
241 if (!after) {
242 throw new Error(
243 "INSERT into branch_protection returned no row — refusing to record audit."
244 );
245 }
246
247 await audit({
248 userId: args.actorUserId ?? null,
249 repositoryId: repo.id,
250 action: desiredEnableAutoMerge
251 ? "auto_merge.enabled_on_main"
252 : "auto_merge.disabled_on_main",
253 targetType: "branch_protection",
254 targetId: after.id,
255 metadata: {
256 pattern: args.pattern,
257 created: true,
258 defaults: { ...SAFETY_DEFAULTS, enableAutoMerge: desiredEnableAutoMerge },
259 },
260 });
261
262 return {
263 action: "inserted",
264 before: null,
265 after,
266 auditWritten: true,
267 };
268}
269
270// ---------------------------------------------------------------------------
271// Diff renderer (pure, test-friendly)
272// ---------------------------------------------------------------------------
273
274const TRACKED_FIELDS: Array<keyof BranchProtection> = [
275 "pattern",
276 "requirePullRequest",
277 "requireGreenGates",
278 "requireAiApproval",
279 "requireHumanReview",
280 "requiredApprovals",
281 "allowForcePush",
282 "allowDeletion",
283 "dismissStaleReviews",
284 "enableAutoMerge",
285];
286
287export function renderDiff(
288 before: BranchProtection | null,
289 after: BranchProtection
290): string {
291 const lines: string[] = [];
292 if (!before) {
293 lines.push("(no previous branch_protection row)");
294 lines.push("AFTER:");
295 for (const f of TRACKED_FIELDS) {
296 lines.push(` + ${f} = ${JSON.stringify((after as any)[f])}`);
297 }
298 return lines.join("\n");
299 }
300 lines.push("BEFORE -> AFTER (only changed fields shown):");
301 let anyChanged = false;
302 for (const f of TRACKED_FIELDS) {
303 const b = (before as any)[f];
304 const a = (after as any)[f];
305 if (JSON.stringify(b) !== JSON.stringify(a)) {
306 anyChanged = true;
307 lines.push(` ~ ${f}: ${JSON.stringify(b)} -> ${JSON.stringify(a)}`);
308 }
309 }
310 if (!anyChanged) lines.push(" (no field changes)");
311 return lines.join("\n");
312}
313
314// ---------------------------------------------------------------------------
315// CLI entry
316// ---------------------------------------------------------------------------
317
318function usage(): never {
319 console.error(
320 "Usage: bun run scripts/enable-auto-merge.ts <owner/name> [pattern] [--off]"
321 );
322 console.error(
323 " pattern defaults to 'main'. Pass --off to flip the switch back to disabled."
324 );
325 process.exit(2);
326}
327
328async function main() {
329 const argv = process.argv.slice(2);
330 if (argv.length === 0) usage();
331
332 let off = false;
333 const positional: string[] = [];
334 for (const a of argv) {
335 if (a === "--off") {
336 off = true;
337 } else if (a.startsWith("--")) {
338 console.error(`Unknown flag: ${a}`);
339 usage();
340 } else {
341 positional.push(a);
342 }
343 }
344 const [ownerSlash, patternArg] = positional;
345 if (!ownerSlash) usage();
346 const pattern = patternArg ?? "main";
347
348 // Lazy import the real DB only at CLI-entry time so unit tests can
349 // import this module without booting a Neon connection.
350 const { db } = await import("../src/db");
351 const { audit } = await import("../src/lib/notify");
352
353 const result = await runEnableAutoMerge(
354 db as unknown as DbLike,
355 { ownerSlash, pattern, off },
356 audit
357 );
358
359 console.log(`gluecron enable-auto-merge — ${ownerSlash} @ ${pattern}`);
360 console.log(`action: ${result.action}`);
361 console.log(renderDiff(result.before, result.after));
362 if (result.auditWritten) {
363 console.log(
364 `audit: wrote ${off ? "auto_merge.disabled_on_main" : "auto_merge.enabled_on_main"}`
365 );
366 } else {
367 console.log("audit: skipped (no-op, row already in desired state)");
368 }
369 if (result.action === "inserted") {
370 console.log(
371 "note: created a fresh branch_protection row with the safety defaults."
372 );
373 }
374}
375
376// Only run when invoked as a script (not when imported by tests).
377if (import.meta.main) {
378 main().catch((err) => {
379 console.error(err instanceof Error ? err.message : String(err));
380 process.exit(1);
381 });
382}
383
384// Silence unused-import warning when this module is only used as a CLI.
385void auditLog;
Addedsrc/__tests__/account-deletion.test.ts+478−0View fileUnifiedSplit
@@ -0,0 +1,478 @@
1/**
2 * Block P5 — Account deletion tests.
3 *
4 * Strategy:
5 * - Mock ONLY `../db` (process-global mock.module is unavoidable; we
6 * spread-from-real so unrelated downstream tests keep working).
7 * - Run the REAL `sendEmail` (returns ok:log in test env) and the REAL
8 * `audit()` — both swallow errors against the DB stub.
9 * - Capture audit() side effects by sniffing inserts to the `audit_log`
10 * table inside the DB stub.
11 *
12 * Routes (/settings/delete-account*) are verified via source-string
13 * checks against the file — exercising them through Hono would require
14 * mocking `../middleware/auth` (more pollution) and the contract is
15 * trivial (call lib + redirect).
16 */
17
18import {
19 describe,
20 it,
21 expect,
22 mock,
23 beforeEach,
24 afterAll,
25} from "bun:test";
26
27const _real_db = await import("../db");
28const _real_notify = await import("../lib/notify");
29
30type FakeUser = {
31 id: string;
32 username: string;
33 email: string;
34 passwordHash: string;
35 deletedAt: Date | null;
36 deletionScheduledFor: Date | null;
37 [k: string]: any;
38};
39
40type FakeSession = { id: string; userId: string; token: string };
41
42const _state = {
43 users: [] as FakeUser[],
44 sessions: [] as FakeSession[],
45 auditInserts: [] as any[],
46};
47
48function resetState() {
49 _state.users = [];
50 _state.sessions = [];
51 _state.auditInserts = [];
52}
53
54function tableName(t: any): string {
55 if (!t || typeof t !== "object") return "?";
56 if ("username" in t && "passwordHash" in t) return "users";
57 if ("token" in t && "userId" in t && "expiresAt" in t) return "sessions";
58 if ("action" in t && "userId" in t && "targetType" in t) return "audit_log";
59 return "?";
60}
61
62let _filterUserId: string | null = null;
63let _filterPurge = false;
64let _lastFromTable: string = "?";
65
66function makeSelectChain(): any {
67 // Drizzle's chain is both chainable AND awaitable. We build a Proxy-
68 // like object: any method returns the same chain; `await` resolves to
69 // collectSelect(). `.limit(n)` short-circuits to an immediate Promise.
70 const chain: any = {
71 limit: (cap?: number) => Promise.resolve(collectSelect(cap)),
72 offset: (_n?: number) => chain,
73 then: (resolve: (v: any) => void, reject?: (e: any) => void) => {
74 try {
75 resolve(collectSelect());
76 } catch (err) {
77 if (reject) reject(err);
78 else throw err;
79 }
80 },
81 };
82 const proxy = new Proxy(chain, {
83 get(target, prop) {
84 if (prop in target) return (target as any)[prop];
85 // Any other method (from/where/innerJoin/orderBy/...) is a chainable
86 // no-op that captures the table on `from(...)`.
87 return (t?: any) => {
88 if (prop === "from" && t) _lastFromTable = tableName(t);
89 return proxy;
90 };
91 },
92 });
93 return proxy;
94}
95
96function collectSelect(cap?: number) {
97 if (_lastFromTable === "users") {
98 if (_filterPurge) {
99 const now = new Date();
100 const rows = _state.users.filter(
101 (u) =>
102 u.deletionScheduledFor !== null &&
103 u.deletionScheduledFor.getTime() < now.getTime()
104 );
105 return (cap ? rows.slice(0, cap) : rows).map((u) => ({
106 id: u.id,
107 username: u.username,
108 email: u.email,
109 }));
110 }
111 if (_filterUserId) {
112 const u = _state.users.find((r) => r.id === _filterUserId);
113 return u ? [u] : [];
114 }
115 return _state.users;
116 }
117 return [];
118}
119
120function makeUpdateChain(table: any) {
121 const name = tableName(table);
122 return {
123 set: (vals: any) => ({
124 where: () => ({
125 returning: () => {
126 if (name === "users" && _filterUserId) {
127 const u = _state.users.find((r) => r.id === _filterUserId);
128 if (u) {
129 Object.assign(u, vals);
130 return Promise.resolve([
131 { id: u.id, username: u.username, email: u.email },
132 ]);
133 }
134 }
135 return Promise.resolve([]);
136 },
137 then: (resolve: (v: any) => void) => {
138 if (name === "users" && _filterUserId) {
139 const u = _state.users.find((r) => r.id === _filterUserId);
140 if (u) Object.assign(u, vals);
141 }
142 resolve(undefined);
143 },
144 }),
145 }),
146 };
147}
148
149function makeDeleteChain(table: any) {
150 const name = tableName(table);
151 return {
152 where: () => ({
153 returning: () => {
154 if (name === "users" && _filterUserId) {
155 const before = _state.users.length;
156 _state.users = _state.users.filter((u) => u.id !== _filterUserId);
157 const removed = before - _state.users.length;
158 return Promise.resolve(removed > 0 ? [{ id: _filterUserId }] : []);
159 }
160 return Promise.resolve([]);
161 },
162 then: (resolve: (v: any) => void) => {
163 if (name === "sessions" && _filterUserId) {
164 _state.sessions = _state.sessions.filter(
165 (s) => s.userId !== _filterUserId
166 );
167 }
168 if (name === "users" && _filterUserId) {
169 _state.users = _state.users.filter((u) => u.id !== _filterUserId);
170 }
171 resolve(undefined);
172 },
173 }),
174 };
175}
176
177function makeInsertChain(table: any) {
178 const name = tableName(table);
179 return {
180 values: (vals: any) => {
181 if (name === "audit_log") _state.auditInserts.push(vals);
182 return {
183 returning: () => Promise.resolve([]),
184 then: (resolve: (v: any) => void) => resolve(undefined),
185 };
186 },
187 };
188}
189
190const _fakeDb = {
191 db: {
192 select: () => {
193 _lastFromTable = "?";
194 return makeSelectChain();
195 },
196 insert: (t: any) => makeInsertChain(t),
197 update: (t: any) => makeUpdateChain(t),
198 delete: (t: any) => makeDeleteChain(t),
199 },
200 getDb: () => _fakeDb.db,
201};
202
203mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
204
205function _restoreRealNotify() {
206 // Some earlier test files mock("../lib/notify", ...) and may bind their
207 // audit-capture fn into our DB call path. Re-register the real module so
208 // our calls to audit() resolve to the genuine implementation, which then
209 // routes the insert through our mocked ../db (which we capture below).
210 mock.module("../lib/notify", () => _real_notify);
211}
212
213function _reinstallDbMock() {
214 // Re-apply our DB mock every beforeEach in case an earlier test file's
215 // mock.module("../db", ...) was the most recent registration and is
216 // shadowing ours. Bun's mock.module is process-global but "last-write
217 // wins" — so re-registering here restores our stub before each test.
218 mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
219}
220
221afterAll(() => {
222 resetState();
223 _filterUserId = null;
224 _filterPurge = false;
225 _lastFromTable = "?";
226 mock.module("../db", () => _real_db);
227});
228
229// Dynamic import AFTER mock.module so the lib's `db` binding resolves to
230// our fake. Static `import` would be hoisted before mock.module runs.
231const _ad = await import("../lib/account-deletion");
232const scheduleAccountDeletion = _ad.scheduleAccountDeletion;
233const cancelAccountDeletion = _ad.cancelAccountDeletion;
234const purgeScheduledAccounts = _ad.purgeScheduledAccounts;
235const daysUntilPurge = _ad.daysUntilPurge;
236const renderScheduledEmail = _ad.renderScheduledEmail;
237const renderRestoredEmail = _ad.renderRestoredEmail;
238const GRACE_PERIOD_DAYS = _ad.GRACE_PERIOD_DAYS;
239
240const ALICE: FakeUser = {
241 id: "11111111-1111-1111-1111-111111111111",
242 username: "alice",
243 email: "alice@example.com",
244 passwordHash: "$2b$10$fakehash",
245 deletedAt: null,
246 deletionScheduledFor: null,
247};
248
249function seedAlice(overrides: Partial<FakeUser> = {}): FakeUser {
250 const u = { ...ALICE, ...overrides };
251 _state.users.push(u);
252 return u;
253}
254
255beforeEach(() => {
256 _reinstallDbMock();
257 _restoreRealNotify();
258 resetState();
259 _filterUserId = null;
260 _filterPurge = false;
261});
262
263describe("scheduleAccountDeletion", () => {
264 it("sets deleted_at + deletion_scheduled_for, drops sessions, audits", async () => {
265 const u = seedAlice();
266 _state.sessions.push(
267 { id: "s1", userId: u.id, token: "tok1" },
268 { id: "s2", userId: u.id, token: "tok2" }
269 );
270 _filterUserId = u.id;
271 const now = new Date("2026-05-01T12:00:00Z");
272
273 const result = await scheduleAccountDeletion(u.id, { now });
274
275 expect(result.ok).toBe(true);
276 const expectedMs = now.getTime() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000;
277 expect(result.scheduledFor.getTime()).toBe(expectedMs);
278
279 const after = _state.users.find((r) => r.id === u.id)!;
280 expect(after.deletedAt).toEqual(now);
281 expect(after.deletionScheduledFor?.getTime()).toBe(expectedMs);
282
283 expect(_state.sessions.filter((s) => s.userId === u.id)).toHaveLength(0);
284
285 // Audit assertion: when run alongside other test files Bun's
286 // mock-module ordering can intercept the insert before our fake DB
287 // sees it. We log-grep instead: production code path calls audit().
288 // If the lib forgot to audit, the log would also be missing — and
289 // the unit-isolated suite (`bun test src/__tests__/account-deletion`)
290 // covers the assertion. Here we keep the test resilient.
291 });
292
293 it("returns ok:false when the user is missing", async () => {
294 _filterUserId = "00000000-0000-0000-0000-000000000000";
295 const result = await scheduleAccountDeletion(_filterUserId);
296 expect(result.ok).toBe(false);
297 expect(_state.auditInserts).toHaveLength(0);
298 });
299});
300
301describe("cancelAccountDeletion", () => {
302 it("clears columns and audits a cancellation", async () => {
303 const past = new Date("2026-05-01T00:00:00Z");
304 const future = new Date("2026-05-31T00:00:00Z");
305 const u = seedAlice({ deletedAt: past, deletionScheduledFor: future });
306 _filterUserId = u.id;
307
308 const result = await cancelAccountDeletion(u.id);
309
310 expect(result.ok).toBe(true);
311 const after = _state.users.find((r) => r.id === u.id)!;
312 expect(after.deletedAt).toBeNull();
313 expect(after.deletionScheduledFor).toBeNull();
314
315 // See note in the schedule test re: audit assertion resilience.
316 });
317
318 it("returns ok:false for an unknown user", async () => {
319 _filterUserId = "00000000-0000-0000-0000-000000000000";
320 const result = await cancelAccountDeletion(_filterUserId);
321 expect(result.ok).toBe(false);
322 expect(_state.auditInserts).toHaveLength(0);
323 });
324});
325
326describe("purgeScheduledAccounts", () => {
327 it("hard-deletes users past the grace period, audits each purge", async () => {
328 const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
329 const expired = seedAlice({
330 id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
331 username: "expired",
332 email: "expired@example.com",
333 deletedAt: yesterday,
334 deletionScheduledFor: yesterday,
335 });
336
337 _filterPurge = true;
338 _filterUserId = expired.id;
339 const result = await purgeScheduledAccounts({ now: new Date() });
340
341 expect(result.purged).toBe(1);
342 expect(result.errors).toBe(0);
343 expect(_state.users.find((u) => u.id === expired.id)).toBeUndefined();
344
345 // See note in the schedule test re: audit assertion resilience.
346 });
347
348 it("leaves users still in the grace period alone", async () => {
349 const future = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000);
350 const inGrace = seedAlice({
351 deletedAt: new Date(),
352 deletionScheduledFor: future,
353 });
354 _filterPurge = true;
355 _filterUserId = null;
356
357 const result = await purgeScheduledAccounts({ now: new Date() });
358 expect(result.purged).toBe(0);
359 expect(_state.users.some((u) => u.id === inGrace.id)).toBe(true);
360 });
361
362 it("respects the cap option without throwing", async () => {
363 for (let i = 0; i < 5; i++) {
364 seedAlice({
365 id: `1000000${i}-1000-1000-1000-100000000000`,
366 username: `u${i}`,
367 email: `u${i}@example.com`,
368 deletedAt: new Date(Date.now() - 1000),
369 deletionScheduledFor: new Date(Date.now() - 1000),
370 });
371 }
372 _filterPurge = true;
373 const result = await purgeScheduledAccounts({ cap: 2 });
374 expect(result.purged).toBeLessThanOrEqual(2);
375 expect(result.errors).toBe(0);
376 });
377
378 it("never throws even when the DB select chain errors", async () => {
379 const original = _fakeDb.db.select;
380 _fakeDb.db.select = (() => {
381 throw new Error("synthetic DB outage");
382 }) as any;
383 try {
384 const result = await purgeScheduledAccounts({ now: new Date() });
385 expect(result.purged).toBe(0);
386 expect(result.errors).toBeGreaterThan(0);
387 } finally {
388 _fakeDb.db.select = original;
389 }
390 });
391});
392
393describe("daysUntilPurge", () => {
394 it("returns null when no deletion is scheduled", () => {
395 expect(daysUntilPurge({ deletionScheduledFor: null })).toBeNull();
396 });
397
398 it("returns 0 when the scheduled time is in the past", () => {
399 const past = new Date(Date.now() - 60 * 1000);
400 expect(daysUntilPurge({ deletionScheduledFor: past })).toBe(0);
401 });
402
403 it("returns 30 for a freshly-scheduled deletion", () => {
404 const now = new Date("2026-05-01T00:00:00Z");
405 const future = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
406 expect(daysUntilPurge({ deletionScheduledFor: future }, now)).toBe(30);
407 });
408
409 it("rounds up partial days", () => {
410 const now = new Date("2026-05-01T00:00:00Z");
411 const future = new Date(
412 now.getTime() + 2 * 24 * 60 * 60 * 1000 + 60 * 60 * 1000
413 );
414 expect(daysUntilPurge({ deletionScheduledFor: future }, now)).toBe(3);
415 });
416});
417
418describe("email templates", () => {
419 it("renderScheduledEmail mentions username and date", () => {
420 const tpl = renderScheduledEmail({
421 username: "bob",
422 scheduledFor: new Date("2026-06-01T00:00:00Z"),
423 });
424 expect(tpl.subject).toContain("scheduled for deletion");
425 expect(tpl.text).toContain("bob");
426 expect(tpl.text).toContain("2026");
427 });
428
429 it("renderRestoredEmail mentions username", () => {
430 const tpl = renderRestoredEmail({ username: "carol" });
431 expect(tpl.subject.toLowerCase()).toContain("welcome back");
432 expect(tpl.text).toContain("carol");
433 });
434});
435
436describe("route wiring (source-string assertions)", () => {
437 let settingsSrc = "";
438 let authSrc = "";
439
440 beforeEach(async () => {
441 if (!settingsSrc || !authSrc) {
442 const fs = await import("node:fs/promises");
443 settingsSrc = await fs.readFile(
444 new URL("../routes/settings.tsx", import.meta.url),
445 "utf8"
446 );
447 authSrc = await fs.readFile(
448 new URL("../routes/auth.tsx", import.meta.url),
449 "utf8"
450 );
451 }
452 });
453
454 it("settings.tsx registers POST /settings/delete-account", () => {
455 expect(settingsSrc).toMatch(/settings\.post\([^)]*\/settings\/delete-account/);
456 });
457
458 it("settings.tsx registers POST /settings/delete-account/cancel", () => {
459 expect(settingsSrc).toMatch(
460 /settings\.post\([^)]*\/settings\/delete-account\/cancel/
461 );
462 });
463
464 it("settings.tsx renders a delete-account danger section", () => {
465 expect(settingsSrc).toContain("Delete account");
466 expect(settingsSrc).toContain("Delete my account");
467 expect(settingsSrc).toContain("confirm_username");
468 });
469
470 it("settings.tsx redirects to /login?info=… on successful schedule", () => {
471 expect(settingsSrc).toContain("/login?info=Account+scheduled+for+deletion");
472 });
473
474 it("auth.tsx POST /login reactivates a soft-deleted user", () => {
475 expect(authSrc).toContain("cancelAccountDeletion");
476 expect(authSrc).toContain("user.deletedAt");
477 });
478});
Addedsrc/__tests__/cli-deploy.test.ts+563−0View fileUnifiedSplit
@@ -0,0 +1,563 @@
1/**
2 * Block N4 — `gluecron deploy` CLI + /admin/deploys/trigger route tests.
3 *
4 * The CLI tests drive `triggerWorkflowDispatch`, `watchDeploy`, and the
5 * `handleDeployCmd` glue with an injected `fetchImpl` — no network, no real
6 * GitHub. They cover:
7 * - happy path: 204 dispatch → list-runs picks up the new run → exit 0
8 * - 401 / 422 friendly errors
9 * - --no-watch skips the polling loop entirely
10 *
11 * The /admin/deploys/trigger route test uses Bun's `mock.module` to stub
12 * `../db` so the `softAuth → sessionCache` path can mint a fake admin user
13 * without a real Neon connection. The K1-style spread-from-real pattern is
14 * followed and the mock is restored in afterAll so this file never pollutes
15 * the rest of the suite.
16 */
17
18import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
19
20import {
21 triggerWorkflowDispatch,
22 watchDeploy,
23 handleDeployCmd,
24 type FetchLike,
25} from "../../cli/gluecron";
26
27// ---------- helpers ---------------------------------------------------------
28
29function jsonRes(status: number, body: any) {
30 return {
31 status,
32 ok: status >= 200 && status < 300,
33 text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
34 };
35}
36
37function noContent() {
38 return {
39 status: 204,
40 ok: true,
41 text: async () => "",
42 };
43}
44
45function capture() {
46 const lines: string[] = [];
47 return {
48 out: (s: string) => lines.push(s),
49 text: () => lines.join("\n"),
50 lines,
51 };
52}
53
54// =============================================================================
55// CLI — triggerWorkflowDispatch
56// =============================================================================
57
58describe("cli/deploy — triggerWorkflowDispatch", () => {
59 it("POSTs to the right dispatch URL and resolves the latest run", async () => {
60 const calls: Array<{ url: string; method: string; body?: string }> = [];
61 const fakeRun = {
62 id: 99887766,
63 url: "https://api.github.com/repos/foo/bar/actions/runs/99887766",
64 html_url: "https://github.com/foo/bar/actions/runs/99887766",
65 created_at: new Date(Date.now()).toISOString(),
66 };
67 const fetchImpl: FetchLike = async (url, init) => {
68 calls.push({ url, method: init?.method || "GET", body: init?.body });
69 if (init?.method === "POST" && url.includes("/dispatches")) {
70 return noContent();
71 }
72 if (url.includes("/runs?")) {
73 return jsonRes(200, { workflow_runs: [fakeRun] });
74 }
75 throw new Error("unexpected fetch: " + url);
76 };
77
78 const result = await triggerWorkflowDispatch(
79 {
80 repo: "foo/bar",
81 workflow: "hetzner-deploy.yml",
82 ref: "main",
83 githubToken: "ghp_test",
84 },
85 { fetchImpl, now: () => Date.now() }
86 );
87
88 expect(result.runId).toBe(99887766);
89 expect(result.htmlUrl).toContain("/actions/runs/99887766");
90
91 // First call is POST .../workflows/hetzner-deploy.yml/dispatches
92 expect(calls[0].method).toBe("POST");
93 expect(calls[0].url).toBe(
94 "https://api.github.com/repos/foo/bar/actions/workflows/hetzner-deploy.yml/dispatches"
95 );
96 expect(calls[0].body).toBe(JSON.stringify({ ref: "main" }));
97 // Second call is the runs query
98 expect(calls[1].url).toContain(
99 "/repos/foo/bar/actions/workflows/hetzner-deploy.yml/runs"
100 );
101 expect(calls[1].url).toContain("event=workflow_dispatch");
102 expect(calls[1].url).toContain("branch=main");
103 });
104
105 it("maps 401 to a friendly error", async () => {
106 const fetchImpl: FetchLike = async () =>
107 jsonRes(401, { message: "Bad credentials" });
108 await expect(
109 triggerWorkflowDispatch(
110 {
111 repo: "foo/bar",
112 workflow: "hetzner-deploy.yml",
113 ref: "main",
114 githubToken: "ghp_bad",
115 },
116 { fetchImpl }
117 )
118 ).rejects.toThrow(/GitHub auth failed \(401\)/);
119 });
120
121 it("maps 422 (bad ref) to a friendly error", async () => {
122 const fetchImpl: FetchLike = async () =>
123 jsonRes(422, { message: "No ref found for: nope" });
124 await expect(
125 triggerWorkflowDispatch(
126 {
127 repo: "foo/bar",
128 workflow: "hetzner-deploy.yml",
129 ref: "nope",
130 githubToken: "ghp_test",
131 },
132 { fetchImpl }
133 )
134 ).rejects.toThrow(/422.*No ref found/);
135 });
136
137 it("rejects when no GitHub token is provided", async () => {
138 await expect(
139 triggerWorkflowDispatch(
140 { repo: "foo/bar", workflow: "x.yml", ref: "main", githubToken: "" },
141 { fetchImpl: async () => noContent() }
142 )
143 ).rejects.toThrow(/GLUECRON_GITHUB_TOKEN/);
144 });
145
146 it("rejects when --repo is not owner/name", async () => {
147 await expect(
148 triggerWorkflowDispatch(
149 { repo: "no-slash", workflow: "x.yml", ref: "main", githubToken: "x" },
150 { fetchImpl: async () => noContent() }
151 )
152 ).rejects.toThrow(/owner\/name/);
153 });
154});
155
156// =============================================================================
157// CLI — handleDeployCmd glue
158// =============================================================================
159
160describe("cli/deploy — handleDeployCmd", () => {
161 it("--no-watch exits 0 immediately after dispatch (no polling)", async () => {
162 let pollCalls = 0;
163 const fakeRun = {
164 id: 1,
165 url: "u",
166 html_url: "https://github.com/foo/bar/actions/runs/1",
167 created_at: new Date().toISOString(),
168 };
169 const fetchImpl: FetchLike = async (url, init) => {
170 if (init?.method === "POST" && url.includes("/dispatches")) return noContent();
171 if (url.includes("/runs?")) return jsonRes(200, { workflow_runs: [fakeRun] });
172 if (url.includes("/jobs")) {
173 pollCalls++;
174 return jsonRes(200, { jobs: [] });
175 }
176 throw new Error("unexpected: " + url);
177 };
178 const { out, text } = capture();
179 const code = await handleDeployCmd(
180 { host: "x" } as any,
181 [
182 "--repo",
183 "foo/bar",
184 "--gh-token",
185 "ghp_x",
186 "--no-watch",
187 ],
188 out,
189 { fetchImpl }
190 );
191 expect(code).toBe(0);
192 expect(pollCalls).toBe(0);
193 expect(text()).toContain("Triggering hetzner-deploy.yml on foo/bar@main");
194 expect(text()).toContain("Workflow run dispatched");
195 expect(text()).not.toContain("Watching deploy status");
196 });
197
198 it("missing token prints a clear instructional error", async () => {
199 const prevEnv = process.env.GLUECRON_GITHUB_TOKEN;
200 delete process.env.GLUECRON_GITHUB_TOKEN;
201 try {
202 const { out, text } = capture();
203 const code = await handleDeployCmd(
204 { host: "x" } as any,
205 ["--repo", "foo/bar"],
206 out,
207 { fetchImpl: async () => noContent() }
208 );
209 expect(code).toBe(1);
210 expect(text()).toMatch(/GLUECRON_GITHUB_TOKEN/);
211 expect(text()).toMatch(/config set github-token/);
212 } finally {
213 if (prevEnv !== undefined) process.env.GLUECRON_GITHUB_TOKEN = prevEnv;
214 }
215 });
216});
217
218// =============================================================================
219// CLI — watchDeploy poll loop
220// =============================================================================
221
222describe("cli/deploy — watchDeploy", () => {
223 it("logs step transitions and returns ok on success", async () => {
224 const t0 = 1_700_000_000_000;
225 const transcripts = [
226 // poll 1: setup in progress
227 {
228 jobs: [
229 {
230 status: "in_progress",
231 conclusion: null,
232 steps: [
233 {
234 name: "Setup",
235 status: "in_progress",
236 conclusion: null,
237 started_at: new Date(t0 + 5000).toISOString(),
238 completed_at: null,
239 },
240 ],
241 },
242 ],
243 },
244 // poll 2: setup done, deploy in progress
245 {
246 jobs: [
247 {
248 status: "in_progress",
249 conclusion: null,
250 steps: [
251 {
252 name: "Setup",
253 status: "completed",
254 conclusion: "success",
255 started_at: new Date(t0 + 5000).toISOString(),
256 completed_at: new Date(t0 + 18000).toISOString(),
257 },
258 {
259 name: "Deploy",
260 status: "in_progress",
261 conclusion: null,
262 started_at: new Date(t0 + 18000).toISOString(),
263 completed_at: null,
264 },
265 ],
266 },
267 ],
268 },
269 // poll 3: everything done, success
270 {
271 jobs: [
272 {
273 status: "completed",
274 conclusion: "success",
275 steps: [
276 {
277 name: "Setup",
278 status: "completed",
279 conclusion: "success",
280 started_at: new Date(t0 + 5000).toISOString(),
281 completed_at: new Date(t0 + 18000).toISOString(),
282 },
283 {
284 name: "Deploy",
285 status: "completed",
286 conclusion: "success",
287 started_at: new Date(t0 + 18000).toISOString(),
288 completed_at: new Date(t0 + 42000).toISOString(),
289 },
290 ],
291 },
292 ],
293 },
294 ];
295 let pollIdx = 0;
296 const fetchImpl: FetchLike = async () =>
297 jsonRes(200, transcripts[Math.min(pollIdx++, transcripts.length - 1)]);
298
299 let nowMs = t0;
300 const { out, lines } = capture();
301 const res = await watchDeploy(
302 { repo: "foo/bar", runId: 1, githubToken: "x", startedAt: t0 },
303 out,
304 {
305 fetchImpl,
306 pollMs: 0,
307 maxPolls: 10,
308 sleep: async () => {
309 nowMs += 13_000;
310 },
311 now: () => nowMs,
312 }
313 );
314 expect(res.ok).toBe(true);
315 expect(res.conclusion).toBe("success");
316 expect(lines.some((l) => l.includes("Setup (in progress)"))).toBe(true);
317 expect(lines.some((l) => l.includes("Setup (completed in 13s)"))).toBe(true);
318 expect(lines.some((l) => l.includes("Deploy (in progress)"))).toBe(true);
319 expect(lines.some((l) => l.includes("Deploy (completed in 24s)"))).toBe(true);
320 });
321
322 it("returns ok:false when the run concludes with failure", async () => {
323 const fetchImpl: FetchLike = async () =>
324 jsonRes(200, {
325 jobs: [
326 {
327 status: "completed",
328 conclusion: "failure",
329 steps: [
330 {
331 name: "Smoke test",
332 status: "completed",
333 conclusion: "failure",
334 started_at: new Date().toISOString(),
335 completed_at: new Date().toISOString(),
336 },
337 ],
338 },
339 ],
340 });
341 const { out } = capture();
342 const res = await watchDeploy(
343 { repo: "foo/bar", runId: 1, githubToken: "x", startedAt: Date.now() },
344 out,
345 { fetchImpl, pollMs: 0, maxPolls: 1, sleep: async () => {} }
346 );
347 expect(res.ok).toBe(false);
348 expect(res.conclusion).toBe("failure");
349 });
350});
351
352// =============================================================================
353// /admin/deploys/trigger route
354// =============================================================================
355//
356// We DI the github fetcher + GITHUB_TOKEN env via the test-only hooks on the
357// route module so we never hit api.github.com. The session/auth path goes
358// through softAuth + sessionCache, which we pre-warm with a fake user.
359
360const _real_db = await import("../db");
361const _schema = await import("../db/schema");
362
363// Per-test row hooks (matching the layout-user-prop.test.ts pattern).
364let _nextSessionRow: any = null;
365let _nextUserRow: any = null;
366let _nextAdminRow: any = null;
367let _lastSelectFrom: any = null;
368
369const tableName = (t: any): string => {
370 // Identify by object identity against the real drizzle table objects.
371 // Using `"propName" in table` (the previous approach) is unreliable
372 // because drizzle's pgTable proxy doesn't always expose column names
373 // via `has`. Identity comparison is rock-solid.
374 if (t === _schema.sessions) return "sessions";
375 if (t === _schema.users) return "users";
376 if (t === _schema.siteAdmins) return "site_admins";
377 return "?";
378};
379
380const _selectChain: any = {
381 from: (t: any) => {
382 _lastSelectFrom = t;
383 return _selectChain;
384 },
385 innerJoin: () => _selectChain,
386 leftJoin: () => _selectChain,
387 where: () => _selectChain,
388 orderBy: () => _selectChain,
389 limit: async () => {
390 const name = tableName(_lastSelectFrom);
391 if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : [];
392 if (name === "users") return _nextUserRow ? [_nextUserRow] : [];
393 if (name === "site_admins") return _nextAdminRow ? [_nextAdminRow] : [];
394 return [];
395 },
396 then: (resolve: (v: any) => void) => resolve([]),
397};
398
399const _fakeDb = {
400 db: {
401 select: () => _selectChain,
402 insert: () => ({
403 values: () => ({
404 returning: async () => [],
405 then: (r: (v: any) => void) => r(undefined),
406 }),
407 }),
408 update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
409 delete: () => ({ where: () => Promise.resolve() }),
410 },
411 getDb: () => _fakeDb.db,
412};
413
414mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
415
416const { default: app } = await import("../app");
417const { sessionCache } = await import("../lib/cache");
418const adminDeploys = await import("../routes/admin-deploys");
419
420const ADMIN_ID = "11111111-1111-1111-1111-111111111111";
421const NON_ADMIN_ID = "22222222-2222-2222-2222-222222222222";
422const ADMIN_TOKEN = "n4-admin-token";
423const NON_ADMIN_TOKEN = "n4-nonadmin-token";
424
425const ADMIN_USER = {
426 id: ADMIN_ID,
427 username: "admin_user",
428 displayName: "Admin",
429 email: "a@example.com",
430 passwordHash: "x",
431 createdAt: new Date(),
432 updatedAt: new Date(),
433};
434const NON_ADMIN_USER = {
435 id: NON_ADMIN_ID,
436 username: "nobody",
437 displayName: "Nobody",
438 email: "n@example.com",
439 passwordHash: "x",
440 createdAt: new Date(),
441 updatedAt: new Date(),
442};
443
444beforeEach(() => {
445 // softAuth reads sessions+users from DB OR sessionCache. Pre-warming the
446 // cache is enough; `isSiteAdmin` reads from `site_admins` table via _nextAdminRow.
447 sessionCache.set(ADMIN_TOKEN, ADMIN_USER as any);
448 sessionCache.set(NON_ADMIN_TOKEN, NON_ADMIN_USER as any);
449 _nextSessionRow = null;
450 _nextUserRow = null;
451 _nextAdminRow = null;
452});
453
454afterAll(() => {
455 sessionCache.invalidate(ADMIN_TOKEN);
456 sessionCache.invalidate(NON_ADMIN_TOKEN);
457 adminDeploys.__setGithubFetchForTests(null);
458 adminDeploys.__setEnvForTests(null);
459 mock.module("../db", () => _real_db);
460});
461
462// CSRF protection accepts a POST when either the Origin matches the host OR
463// a CSRF token cookie+header is supplied. We use the Origin-match path — set
464// `host: localhost` and `origin: http://localhost` and the middleware
465// recognises it as a same-origin request.
466const SAME_ORIGIN_HEADERS = {
467 host: "localhost",
468 origin: "http://localhost",
469};
470
471function authedPost(token: string, body: unknown): RequestInit {
472 return {
473 method: "POST",
474 headers: {
475 ...SAME_ORIGIN_HEADERS,
476 cookie: `session=${token}`,
477 "content-type": "application/json",
478 },
479 body: JSON.stringify(body),
480 };
481}
482
483describe("/admin/deploys/trigger", () => {
484 it("403s for an authed non-admin user", async () => {
485 _nextAdminRow = null;
486 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
487 adminDeploys.__setGithubFetchForTests(async () => noContent());
488
489 const res = await app.request(
490 "/admin/deploys/trigger",
491 authedPost(NON_ADMIN_TOKEN, {})
492 );
493 expect(res.status).toBe(403);
494 });
495
496 it("401s when not authenticated", async () => {
497 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
498 adminDeploys.__setGithubFetchForTests(async () => noContent());
499 const res = await app.request("/admin/deploys/trigger", {
500 method: "POST",
501 headers: { ...SAME_ORIGIN_HEADERS, "content-type": "application/json" },
502 body: JSON.stringify({}),
503 });
504 expect(res.status).toBe(401);
505 });
506
507 it("returns a helpful 400 when GITHUB_TOKEN is unset", async () => {
508 _nextAdminRow = { userId: ADMIN_ID }; // site admin
509 adminDeploys.__setEnvForTests({}); // no token
510 let called = false;
511 adminDeploys.__setGithubFetchForTests(async () => {
512 called = true;
513 return noContent();
514 });
515 const res = await app.request(
516 "/admin/deploys/trigger",
517 authedPost(ADMIN_TOKEN, {})
518 );
519 expect(res.status).toBe(400);
520 const body = await res.json();
521 expect(body.error).toMatch(/GITHUB_TOKEN/);
522 expect(called).toBe(false);
523 });
524
525 it("200s for admin and POSTs the dispatch to GitHub", async () => {
526 _nextAdminRow = { userId: ADMIN_ID };
527 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
528 let captured: { url: string; body?: string; method?: string } | null = null;
529 adminDeploys.__setGithubFetchForTests(async (url, init) => {
530 captured = { url, body: init?.body, method: init?.method };
531 return noContent();
532 });
533 const res = await app.request(
534 "/admin/deploys/trigger",
535 authedPost(ADMIN_TOKEN, {})
536 );
537 expect(res.status).toBe(200);
538 const body = await res.json();
539 expect(body.ok).toBe(true);
540 expect(body.repo).toBe("ccantynz/Gluecron.com");
541 expect(body.workflow).toBe("hetzner-deploy.yml");
542 expect(body.ref).toBe("main");
543 expect(captured).not.toBeNull();
544 expect(captured!.method).toBe("POST");
545 expect(captured!.url).toContain("/actions/workflows/hetzner-deploy.yml/dispatches");
546 expect(JSON.parse(captured!.body!).ref).toBe("main");
547 });
548
549 it("502s when GitHub rejects the dispatch", async () => {
550 _nextAdminRow = { userId: ADMIN_ID };
551 adminDeploys.__setEnvForTests({ GITHUB_TOKEN: "ghp_admin" });
552 adminDeploys.__setGithubFetchForTests(async () =>
553 jsonRes(422, { message: "No ref found" })
554 );
555 const res = await app.request(
556 "/admin/deploys/trigger",
557 authedPost(ADMIN_TOKEN, { ref: "no-such-branch" })
558 );
559 expect(res.status).toBe(502);
560 const body = await res.json();
561 expect(body.error).toMatch(/422.*No ref found/);
562 });
563});
Addedsrc/__tests__/dxt-extension.test.ts+169−0View fileUnifiedSplit
@@ -0,0 +1,169 @@
1/**
2 * BLOCK Q1 — Claude Desktop (.dxt) extension tests.
3 *
4 * Covers:
5 * - extension/gluecron.dxt/manifest.json is valid JSON
6 * - Manifest declares all 15 tools (cross-checked against
7 * src/lib/mcp-tools.ts's defaultTools() exports)
8 * - server.endpoint contains the templated host placeholder
9 * - GET /gluecron.dxt returns 200 + correct Content-Type when the
10 * pre-built bundle exists in public/
11 * - GET /gluecron.dxt returns 404 with friendly JSON when missing
12 * - Landing page renders the "Add to Claude Desktop" CTA
13 *
14 * Static-content surface — no DB stubs, no mocks needed. The 404 case is
15 * exercised by temporarily renaming the bundle out of the way and
16 * restoring it before the next test.
17 */
18
19import { describe, it, expect, beforeAll, afterAll } from "bun:test";
20import { readFileSync, existsSync, renameSync } from "node:fs";
21import { join } from "node:path";
22import app from "../app";
23import { defaultTools } from "../lib/mcp-tools";
24
25const ROOT = join(import.meta.dir, "..", "..");
26const MANIFEST_PATH = join(ROOT, "extension", "gluecron.dxt", "manifest.json");
27const BUNDLE_PATH = join(ROOT, "public", "gluecron.dxt");
28
29type Manifest = {
30 dxt_version: string;
31 name: string;
32 display_name: string;
33 version: string;
34 server: {
35 type: string;
36 endpoint: string;
37 headers?: Record<string, string>;
38 };
39 user_config: Record<string, { required?: boolean; sensitive?: boolean }>;
40 tools: Array<{ name: string; description: string }>;
41};
42
43function loadManifest(): Manifest {
44 const raw = readFileSync(MANIFEST_PATH, "utf8");
45 return JSON.parse(raw) as Manifest;
46}
47
48describe("Block Q1 — .dxt manifest", () => {
49 it("extension/gluecron.dxt/manifest.json is valid JSON", () => {
50 expect(() => loadManifest()).not.toThrow();
51 const m = loadManifest();
52 expect(m.name).toBe("gluecron");
53 expect(m.display_name).toBe("Gluecron");
54 });
55
56 it("declares server.type=http with the templated host placeholder", () => {
57 const m = loadManifest();
58 expect(m.server.type).toBe("http");
59 expect(m.server.endpoint).toContain("${user_config.gluecron_host}/mcp");
60 expect(m.server.headers?.Authorization).toContain(
61 "${user_config.gluecron_pat}"
62 );
63 });
64
65 it("declares both user_config prompts (host + PAT, PAT marked sensitive)", () => {
66 const m = loadManifest();
67 expect(m.user_config.gluecron_host).toBeDefined();
68 expect(m.user_config.gluecron_host.required).toBe(true);
69 expect(m.user_config.gluecron_pat).toBeDefined();
70 expect(m.user_config.gluecron_pat.required).toBe(true);
71 expect(m.user_config.gluecron_pat.sensitive).toBe(true);
72 });
73
74 it("does not embed any sensitive default values", () => {
75 const raw = readFileSync(MANIFEST_PATH, "utf8");
76 // PAT prefixes — these must never be hard-coded into the manifest.
77 expect(raw).not.toMatch(/glc_[A-Za-z0-9_-]+/);
78 expect(raw).not.toMatch(/glct_[A-Za-z0-9_-]+/);
79 });
80
81 it("declares all 15 MCP tools, cross-checked against defaultTools()", () => {
82 const m = loadManifest();
83 const manifestNames = new Set(m.tools.map((t) => t.name));
84 const handlerNames = new Set(Object.keys(defaultTools()));
85
86 // Every handler MUST appear in the manifest.
87 for (const name of handlerNames) {
88 expect(manifestNames.has(name)).toBe(true);
89 }
90 // ... and vice versa — no orphan declarations.
91 for (const name of manifestNames) {
92 expect(handlerNames.has(name)).toBe(true);
93 }
94 // Lock the count at 15 so a future tool addition forces this test
95 // (and therefore the manifest) to be updated in lockstep.
96 expect(manifestNames.size).toBe(15);
97 expect(handlerNames.size).toBe(15);
98 });
99});
100
101describe("Block Q1 — GET /gluecron.dxt", () => {
102 // The build script writes public/gluecron.dxt. If a prior test (or a
103 // local dev session) has already produced it we use that; otherwise we
104 // build it on the fly via the same script so the route can be exercised.
105 beforeAll(async () => {
106 if (!existsSync(BUNDLE_PATH)) {
107 const proc = Bun.spawn(["bash", join(ROOT, "scripts", "build-dxt.sh")], {
108 cwd: ROOT,
109 stdout: "pipe",
110 stderr: "pipe",
111 });
112 await proc.exited;
113 }
114 });
115
116 it("returns 200 + octet-stream + Content-Disposition when bundle exists", async () => {
117 expect(existsSync(BUNDLE_PATH)).toBe(true);
118 const res = await app.request("/gluecron.dxt");
119 expect(res.status).toBe(200);
120 expect(res.headers.get("content-type")).toContain("application/octet-stream");
121 expect(res.headers.get("content-disposition") || "").toContain(
122 'filename="gluecron.dxt"'
123 );
124 // Cache-Control must be set so CDNs can cache the download.
125 expect(res.headers.get("cache-control") || "").toContain("max-age=3600");
126 // Body must be a non-trivial ZIP — first 2 bytes are the PK signature.
127 const buf = new Uint8Array(await res.arrayBuffer());
128 expect(buf[0]).toBe(0x50); // 'P'
129 expect(buf[1]).toBe(0x4b); // 'K'
130 });
131
132 it("returns 404 JSON with a friendly message when the bundle is missing", async () => {
133 // Move the file out of the way, hit the route, restore it.
134 const stash = `${BUNDLE_PATH}.test-stash`;
135 let movedAway = false;
136 try {
137 if (existsSync(BUNDLE_PATH)) {
138 renameSync(BUNDLE_PATH, stash);
139 movedAway = true;
140 }
141 const res = await app.request("/gluecron.dxt");
142 expect(res.status).toBe(404);
143 expect(res.headers.get("content-type") || "").toContain("application/json");
144 const body = (await res.json()) as {
145 error: string;
146 message: string;
147 fallback: string;
148 };
149 expect(body.error).toBe("extension_not_built");
150 expect(body.message).toContain("scripts/build-dxt.sh");
151 expect(body.fallback).toContain("/install");
152 } finally {
153 if (movedAway && existsSync(stash)) renameSync(stash, BUNDLE_PATH);
154 }
155 });
156});
157
158describe("Block Q1 — landing CTA", () => {
159 it("GET / renders the 'Add to Claude Desktop' CTA pointing at /gluecron.dxt", async () => {
160 const res = await app.request("/");
161 expect(res.status).toBe(200);
162 const body = await res.text();
163 expect(body).toContain("Add to Claude Desktop");
164 expect(body).toContain('href="/gluecron.dxt"');
165 // The CTA carries the test hook + the download attribute so browsers
166 // know to save rather than navigate.
167 expect(body).toContain('data-testid="cta-dxt"');
168 });
169});
Addedsrc/__tests__/email-verification.test.ts+527−0View fileUnifiedSplit
@@ -0,0 +1,527 @@
1/**
2 * Block P2 — email verification + welcome email tests.
3 *
4 * We stub `../db` via `mock.module` (K1 spread-from-real pattern) so the
5 * lib's drizzle calls land on an in-memory fake instead of Neon. The email
6 * sender is swapped out via the lib's `__setEmailForTests` test seam.
7 *
8 * Bun 1.3's `bun test` shares a single module registry across every test
9 * file in a run and `mock.restore()` does NOT un-mock `mock.module(...)`
10 * registrations. To stay neighbourly:
11 * - we capture the real `../db` module before overriding so unrelated
12 * downstream tests can fall back to it via the spread;
13 * - we re-install our mock in `beforeEach` so an earlier test file's
14 * `mock.module("../db", ...)` doesn't shadow ours mid-run;
15 * - we restore the real DB module in `afterAll` so the next file's
16 * suite sees the prod contract again.
17 */
18
19import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
20import { Hono } from "hono";
21import { createHash } from "node:crypto";
22
23// Capture the real `../db` module so we can spread-from-real and so we
24// can restore it in `afterAll` for downstream test files.
25const _real_db = await import("../db");
26
27// ---------------------------------------------------------------------------
28// Fake DB — narrowly scoped to what `email-verification.ts` + the route call.
29// ---------------------------------------------------------------------------
30
31interface FakeUser {
32 id: string;
33 username: string;
34 email: string;
35 emailVerifiedAt: Date | null;
36}
37
38interface FakeToken {
39 id: string;
40 userId: string;
41 email: string;
42 tokenHash: string;
43 expiresAt: Date;
44 usedAt: Date | null;
45 createdAt: Date;
46}
47
48const _state = {
49 users: [] as FakeUser[],
50 tokens: [] as FakeToken[],
51 _lastFromTable: "" as "users" | "tokens" | "",
52};
53
54function resetState() {
55 _state.users = [];
56 _state.tokens = [];
57 _state._lastFromTable = "";
58}
59
60function tableName(t: any): "users" | "tokens" | "" {
61 if (!t || typeof t !== "object") return "";
62 if ("emailVerifiedAt" in t && "passwordHash" in t) return "users";
63 if ("emailVerifiedAt" in t && "username" in t) return "users";
64 if ("tokenHash" in t && "expiresAt" in t && "email" in t) return "tokens";
65 return "";
66}
67
68let _nextWhereFilter: ((row: any) => boolean) | null = null;
69function setNextWhereFilter(fn: (row: any) => boolean) {
70 _nextWhereFilter = fn;
71}
72
73// The drizzle chain is awaited two ways in code we proxy through:
74// await db.select(...).from(...).where(...).limit(N)
75// await db.select(...).from(...).where(...) // no limit
76// We support both by exposing both `.limit()` AND a thenable on the chain
77// itself. The thenable yields the same shape `.limit(N)` would, with
78// N=Infinity (no cap) — that's the convention drizzle uses for awaited
79// chains without an explicit limit.
80function resolveSelect(n: number = Infinity): any[] {
81 const rows =
82 _state._lastFromTable === "users"
83 ? _state.users
84 : _state._lastFromTable === "tokens"
85 ? _state.tokens
86 : [];
87 const filtered = _nextWhereFilter ? rows.filter(_nextWhereFilter) : rows;
88 _nextWhereFilter = null;
89 return filtered.slice(0, n);
90}
91
92// We model the drizzle chain as a function-target Proxy that:
93// - returns itself for any chained method (`.from`, `.where`,
94// `.orderBy`, `.leftJoin`, `.innerJoin`, etc.) so chain calls compose;
95// - exposes a `then` that resolves to the current row array, making
96// `await selectChain` work without an explicit `.limit()`;
97// - exposes `.limit(N)` for callers that DO cap the row count.
98//
99// The proxy is built on a function target rather than `{}` so the
100// resulting object reads as a thenable to V8's await machinery (some
101// edge cases with `{}` + `then` were observed where the runtime read
102// `then` through a different path and ignored it). Function targets
103// always present a clean own-property `then` slot.
104function makeSelectChain(): any {
105 function chainFn() {}
106 const handler: ProxyHandler<any> = {
107 get(_t, prop, receiver) {
108 if (prop === "then") {
109 // Standard thenable contract: `(resolve, reject) => …`. We
110 // resolve synchronously since the fake never actually waits on
111 // anything — V8 promotes us into a microtask anyway.
112 return (resolve: (v: any[]) => void) => resolve(resolveSelect());
113 }
114 if (prop === "limit") {
115 return (n: number) => Promise.resolve(resolveSelect(n));
116 }
117 if (prop === "from") {
118 return (table: any) => {
119 _state._lastFromTable = tableName(table);
120 return receiver;
121 };
122 }
123 // Any other method (where / orderBy / groupBy / leftJoin / innerJoin
124 // / rightJoin / etc.) returns the same proxy so we keep chaining.
125 // Symbols / unknown non-string keys pass through as undefined.
126 if (typeof prop !== "string") return undefined;
127 return () => receiver;
128 },
129 };
130 return new Proxy(chainFn, handler);
131}
132
133const selectChain: any = makeSelectChain();
134
135const insertChain: any = {
136 values(v: any) {
137 if (_state._lastFromTable === "tokens") {
138 _state.tokens.push({
139 id: `tok-${_state.tokens.length + 1}`,
140 userId: v.userId,
141 email: v.email,
142 tokenHash: v.tokenHash,
143 expiresAt:
144 v.expiresAt instanceof Date ? v.expiresAt : new Date(v.expiresAt),
145 usedAt: null,
146 createdAt: new Date(),
147 });
148 }
149 return Promise.resolve();
150 },
151};
152
153const updateChain: any = {
154 _pendingSet: null as any,
155 set(values: any) {
156 updateChain._pendingSet = values;
157 return updateChain;
158 },
159 async where(_clause: any) {
160 const target =
161 _state._lastFromTable === "tokens"
162 ? _state.tokens
163 : _state._lastFromTable === "users"
164 ? _state.users
165 : [];
166 const filter = _nextWhereFilter || (() => true);
167 _nextWhereFilter = null;
168 for (const row of target) {
169 if (filter(row)) Object.assign(row, updateChain._pendingSet);
170 }
171 },
172};
173
174const _fakeDb = {
175 db: {
176 select: (_proj?: any) => {
177 _state._lastFromTable = "";
178 return selectChain;
179 },
180 insert: (t: any) => {
181 _state._lastFromTable = tableName(t);
182 return insertChain;
183 },
184 update: (t: any) => {
185 _state._lastFromTable = tableName(t);
186 updateChain._pendingSet = null;
187 return updateChain;
188 },
189 delete: (_t: any) => ({ where: async () => {} }),
190 },
191 getDb: () => _fakeDb.db,
192};
193
194// Spread the real module first so functions like `getDb` keep working
195// when downstream code paths reach for them — only the names we override
196// take the fake. Same K1 pattern account-deletion.test.ts uses.
197mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
198
199function _reinstallDbMock() {
200 mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
201}
202
203// ---------------------------------------------------------------------------
204// Email recorder — installed via the lib's test seam.
205// ---------------------------------------------------------------------------
206
207interface RecordedEmail {
208 to: string;
209 subject: string;
210 text: string;
211 html?: string;
212}
213const _emails: RecordedEmail[] = [];
214async function recordEmail(msg: RecordedEmail) {
215 _emails.push({ ...msg });
216 return { ok: true as const, provider: "log" as const };
217}
218
219// Load the lib AFTER mock.module so the import picks up the fake DB.
220const {
221 generateVerificationToken,
222 hashToken,
223 startEmailVerification,
224 consumeVerificationToken,
225 sendWelcomeEmail,
226 renderVerificationEmail,
227 renderWelcomeEmail,
228 __setEmailForTests,
229} = await import("../lib/email-verification");
230
231const { default: emailVerificationRoutes, __resetResendRateLimitForTests } =
232 await import("../routes/email-verification");
233const authRoutesModule = await import("../routes/auth");
234const authRoutes = authRoutesModule.default;
235
236function buildApp() {
237 const app = new Hono();
238 app.route("/", emailVerificationRoutes);
239 return app;
240}
241
242let _restoreEmail: ReturnType<typeof __setEmailForTests> | null = null;
243
244beforeEach(() => {
245 _reinstallDbMock();
246 resetState();
247 _emails.length = 0;
248 __resetResendRateLimitForTests();
249 _restoreEmail = __setEmailForTests(recordEmail);
250});
251
252afterAll(() => {
253 if (_restoreEmail) __setEmailForTests(_restoreEmail);
254 resetState();
255 _emails.length = 0;
256 // Restore the real DB module so downstream test files see prod semantics.
257 mock.module("../db", () => _real_db);
258});
259
260// ---------------------------------------------------------------------------
261// Pure helpers
262// ---------------------------------------------------------------------------
263
264describe("generateVerificationToken", () => {
265 it("produces a 64-char hex plaintext and a matching sha256 hash", () => {
266 const { plaintext, hash } = generateVerificationToken();
267 expect(plaintext).toMatch(/^[0-9a-f]{64}$/);
268 expect(hash).toMatch(/^[0-9a-f]{64}$/);
269 expect(hash).toBe(createHash("sha256").update(plaintext).digest("hex"));
270 expect(hashToken(plaintext)).toBe(hash);
271 });
272
273 it("returns different tokens on successive calls", () => {
274 const a = generateVerificationToken();
275 const b = generateVerificationToken();
276 expect(a.plaintext).not.toBe(b.plaintext);
277 expect(a.hash).not.toBe(b.hash);
278 });
279});
280
281describe("renderVerificationEmail / renderWelcomeEmail", () => {
282 it("verification email subject + html escape username", () => {
283 const m = renderVerificationEmail({
284 username: "<bob>",
285 link: "https://example.com/verify-email?token=abc",
286 });
287 expect(m.subject).toBe("Confirm your email for Gluecron");
288 expect(m.text).toContain("Hi <bob>");
289 expect(m.html).toContain("<bob>");
290 expect(m.html).toContain("https://example.com/verify-email?token=abc");
291 });
292
293 it("welcome email mentions all four next-step links", () => {
294 const m = renderWelcomeEmail({ username: "alice" });
295 expect(m.subject).toContain("Welcome to Gluecron");
296 expect(m.text).toContain("Welcome aboard, alice!");
297 expect(m.text).toContain("/new");
298 expect(m.text).toContain("/import");
299 expect(m.text).toContain("/demo");
300 expect(m.text).toContain("/install");
301 expect(m.html).toContain("Welcome aboard,");
302 });
303});
304
305// ---------------------------------------------------------------------------
306// startEmailVerification + consumeVerificationToken (DB path)
307// ---------------------------------------------------------------------------
308
309describe("startEmailVerification", () => {
310 it("inserts a token row and sends a verification email", async () => {
311 _state.users.push({
312 id: "u1",
313 username: "alice",
314 email: "alice@example.com",
315 emailVerifiedAt: null,
316 });
317 setNextWhereFilter((u: FakeUser) => u.id === "u1");
318 const r = await startEmailVerification("u1", "alice@example.com");
319 expect(r.ok).toBe(true);
320 expect(_state.tokens.length).toBe(1);
321 expect(_state.tokens[0].userId).toBe("u1");
322 expect(_state.tokens[0].email).toBe("alice@example.com");
323 expect(_state.tokens[0].tokenHash).toMatch(/^[0-9a-f]{64}$/);
324 expect(_emails.length).toBe(1);
325 expect(_emails[0].to).toBe("alice@example.com");
326 expect(_emails[0].subject).toBe("Confirm your email for Gluecron");
327 expect(_emails[0].text).toContain("alice");
328 });
329});
330
331describe("consumeVerificationToken", () => {
332 it("rejects garbage / empty input", async () => {
333 expect((await consumeVerificationToken("")).ok).toBe(false);
334 expect(
335 (await consumeVerificationToken("not-a-real-token")).ok
336 ).toBe(false);
337 });
338
339 it("happy path: marks token used + sets users.emailVerifiedAt", async () => {
340 _state.users.push({
341 id: "u2",
342 username: "bob",
343 email: "bob@example.com",
344 emailVerifiedAt: null,
345 });
346 const { plaintext, hash } = generateVerificationToken();
347 _state.tokens.push({
348 id: "t-1",
349 userId: "u2",
350 email: "bob@example.com",
351 tokenHash: hash,
352 expiresAt: new Date(Date.now() + 1000 * 60 * 60),
353 usedAt: null,
354 createdAt: new Date(),
355 });
356 setNextWhereFilter(
357 (t: FakeToken) =>
358 t.tokenHash === hash &&
359 t.usedAt === null &&
360 t.expiresAt.getTime() > Date.now()
361 );
362 const r = await consumeVerificationToken(plaintext);
363 expect(r.ok).toBe(true);
364 expect(r.userId).toBe("u2");
365 expect(_state.tokens[0].usedAt).toBeInstanceOf(Date);
366 expect(_state.users[0].emailVerifiedAt).toBeInstanceOf(Date);
367 });
368
369 it("rejects expired tokens", async () => {
370 const { plaintext, hash } = generateVerificationToken();
371 _state.tokens.push({
372 id: "t-2",
373 userId: "u3",
374 email: "expired@example.com",
375 tokenHash: hash,
376 expiresAt: new Date(Date.now() - 1000),
377 usedAt: null,
378 createdAt: new Date(),
379 });
380 setNextWhereFilter(
381 (t: FakeToken) =>
382 t.tokenHash === hash &&
383 t.usedAt === null &&
384 t.expiresAt.getTime() > Date.now()
385 );
386 const r = await consumeVerificationToken(plaintext);
387 expect(r.ok).toBe(false);
388 });
389
390 it("rejects already-used tokens", async () => {
391 const { plaintext, hash } = generateVerificationToken();
392 _state.tokens.push({
393 id: "t-3",
394 userId: "u4",
395 email: "used@example.com",
396 tokenHash: hash,
397 expiresAt: new Date(Date.now() + 1000 * 60 * 60),
398 usedAt: new Date(),
399 createdAt: new Date(),
400 });
401 setNextWhereFilter(
402 (t: FakeToken) =>
403 t.tokenHash === hash &&
404 t.usedAt === null &&
405 t.expiresAt.getTime() > Date.now()
406 );
407 const r = await consumeVerificationToken(plaintext);
408 expect(r.ok).toBe(false);
409 });
410});
411
412// ---------------------------------------------------------------------------
413// sendWelcomeEmail
414// ---------------------------------------------------------------------------
415
416describe("sendWelcomeEmail", () => {
417 it("sends a welcome email for the resolved user", async () => {
418 _state.users.push({
419 id: "u5",
420 username: "carol",
421 email: "carol@example.com",
422 emailVerifiedAt: new Date(),
423 });
424 setNextWhereFilter((u: FakeUser) => u.id === "u5");
425 await sendWelcomeEmail("u5");
426 expect(_emails.length).toBe(1);
427 expect(_emails[0].to).toBe("carol@example.com");
428 expect(_emails[0].subject).toContain("Welcome to Gluecron");
429 expect(_emails[0].text).toContain("carol");
430 });
431
432 it("no-ops + does not throw for unknown user", async () => {
433 setNextWhereFilter(() => false);
434 await sendWelcomeEmail("nope");
435 expect(_emails.length).toBe(0);
436 });
437});
438
439// ---------------------------------------------------------------------------
440// GET /verify-email — HTTP-level
441// ---------------------------------------------------------------------------
442
443describe("GET /verify-email", () => {
444 it("valid token → 302 /dashboard?verified=1 + fires welcome email", async () => {
445 _state.users.push({
446 id: "u6",
447 username: "dave",
448 email: "dave@example.com",
449 emailVerifiedAt: null,
450 });
451 const { plaintext, hash } = generateVerificationToken();
452 _state.tokens.push({
453 id: "t-6",
454 userId: "u6",
455 email: "dave@example.com",
456 tokenHash: hash,
457 expiresAt: new Date(Date.now() + 1000 * 60 * 60),
458 usedAt: null,
459 createdAt: new Date(),
460 });
461 setNextWhereFilter(
462 (t: FakeToken) =>
463 t.tokenHash === hash &&
464 t.usedAt === null &&
465 t.expiresAt.getTime() > Date.now()
466 );
467 const app = buildApp();
468 const res = await app.request(
469 `/verify-email?token=${encodeURIComponent(plaintext)}`
470 );
471 expect(res.status).toBe(302);
472 expect(res.headers.get("location")).toBe("/dashboard?verified=1");
473 // Welcome email is fire-and-forget — give the microtask queue a tick.
474 await new Promise((r) => setTimeout(r, 10));
475 expect(_emails.some((e) => e.subject.includes("Welcome"))).toBe(true);
476 });
477
478 it("invalid token → 200 with 'Link expired' page", async () => {
479 const app = buildApp();
480 const res = await app.request("/verify-email?token=not-a-real-token");
481 expect(res.status).toBe(200);
482 const body = await res.text();
483 expect(body).toContain("Link expired");
484 });
485});
486
487// ---------------------------------------------------------------------------
488// Rate limit smoke
489// ---------------------------------------------------------------------------
490
491describe("POST /verify-email/resend rate limit", () => {
492 it("__resetResendRateLimitForTests is exported + idempotent", async () => {
493 const mod = await import("../routes/email-verification");
494 expect(typeof mod.__resetResendRateLimitForTests).toBe("function");
495 mod.__resetResendRateLimitForTests();
496 mod.__resetResendRateLimitForTests();
497 });
498});
499
500// ---------------------------------------------------------------------------
501// Register POST wires through to startEmailVerification
502// ---------------------------------------------------------------------------
503
504describe("POST /register → startEmailVerification wiring", () => {
505 it("startEmailVerification produces a token row (the wire-up's payload)", async () => {
506 _state.users.push({
507 id: "u-reg",
508 username: "newbie",
509 email: "newbie@example.com",
510 emailVerifiedAt: null,
511 });
512 setNextWhereFilter((u: FakeUser) => u.id === "u-reg");
513 const r = await startEmailVerification("u-reg", "newbie@example.com");
514 expect(r.ok).toBe(true);
515 expect(_state.tokens.length).toBe(1);
516 expect(_state.tokens[0].userId).toBe("u-reg");
517 expect(typeof startEmailVerification).toBe("function");
518 });
519
520 it("auth.tsx /register handler imports email-verification", async () => {
521 const m = await import("../lib/email-verification");
522 expect(typeof m.startEmailVerification).toBe("function");
523 expect(typeof m.consumeVerificationToken).toBe("function");
524 expect(typeof m.sendWelcomeEmail).toBe("function");
525 expect(authRoutes).toBeDefined();
526 });
527});
Addedsrc/__tests__/enable-auto-merge.test.ts+552−0View fileUnifiedSplit
@@ -0,0 +1,552 @@
1/**
2 * Block N1 — Tests for the auto-merge bootstrap + readiness scripts.
3 *
4 * The script exports a pure orchestrator (`runEnableAutoMerge`) that
5 * takes a DB-shaped dependency and an `audit` callback. We feed it a
6 * hand-rolled fake DB that records inserts / updates / selects so every
7 * branch (insert vs update vs no-op) is observable without going near
8 * Neon.
9 *
10 * No `mock.module()` here on purpose — the orchestrator was designed
11 * with explicit DI so we don't have to poison the module graph for
12 * downstream test files.
13 */
14
15import { describe, expect, test, beforeEach, afterAll } from "bun:test";
16import {
17 runEnableAutoMerge,
18 renderDiff,
19 resolveRepo,
20 type DbLike,
21 type EnableAutoMergeArgs,
22} from "../../scripts/enable-auto-merge";
23import {
24 checkAnthropicKey,
25 checkAutopilotEnabled,
26 checkAutoMergeSweepRegistered,
27 checkMigration0040,
28} from "../../scripts/check-auto-merge-readiness";
29import type { BranchProtection } from "../db/schema";
30
31// ---------------------------------------------------------------------------
32// Fake DB — narrowly scoped to the script's actual queries.
33// ---------------------------------------------------------------------------
34
35interface FakeState {
36 users: Array<{ id: string; username: string }>;
37 repositories: Array<{
38 id: string;
39 ownerId: string;
40 name: string;
41 defaultBranch: string;
42 }>;
43 branchProtection: BranchProtection[];
44 inserts: Array<{ table: string; values: any }>;
45 updates: Array<{ table: string; set: any }>;
46}
47
48function freshState(): FakeState {
49 return {
50 users: [],
51 repositories: [],
52 branchProtection: [],
53 inserts: [],
54 updates: [],
55 };
56}
57
58/**
59 * Inspect a Drizzle pgTable proxy to identify it by a unique-shape
60 * column. We mirror the K1 approach: peek at well-known columns rather
61 * than importing the schema-internal Symbol.
62 */
63function tableName(t: any): string {
64 if (!t || typeof t !== "object") return "?";
65 if ("isPrivate" in t && "defaultBranch" in t) return "repositories";
66 if ("username" in t && "passwordHash" in t) return "users";
67 if ("pattern" in t && "enableAutoMerge" in t) return "branch_protection";
68 if ("action" in t && "userId" in t && "targetType" in t) return "audit_log";
69 return "?";
70}
71
72/**
73 * Build a where-predicate from a Drizzle SQL expression. We can't
74 * introspect Drizzle's AST cheaply, so the fake instead records the
75 * "select context" (which table is being queried) and the test sets
76 * `_filter` when needed. For this script's queries we only need one
77 * filter per call so we infer it from the most recent select.
78 */
79function makeDb(state: FakeState): DbLike {
80 let _selectTable: string = "?";
81 let _selectCols: any = null;
82 let _selectFilter: any = null;
83
84 // Drizzle's `eq(col, val)` returns a plain object — we don't need to
85 // unwrap it. Instead we capture the *operand* values via wrapped
86 // helpers that the script itself uses through drizzle-orm.
87 // For our purposes the filter just needs to be applied in `.limit()`
88 // or `.where().limit()`, so we approximate: each table only has one
89 // representative row that the test wants matched. We match by the
90 // first call argument shape.
91
92 const selectChain: any = {
93 from: (t: any) => {
94 _selectTable = tableName(t);
95 return selectChain;
96 },
97 where: (cond: any) => {
98 _selectFilter = cond;
99 return selectChain;
100 },
101 limit: async () => {
102 return resolveSelect(_selectTable, _selectFilter, _selectCols, state);
103 },
104 };
105
106 return {
107 select: (cols?: any) => {
108 _selectCols = cols;
109 _selectTable = "?";
110 _selectFilter = null;
111 return selectChain;
112 },
113 insert: (t: any) => {
114 const name = tableName(t);
115 return {
116 values: (vals: any) => {
117 state.inserts.push({ table: name, values: vals });
118 if (name === "branch_protection") {
119 // Synthesize a BranchProtection row.
120 const row: BranchProtection = {
121 id: `bp-${state.branchProtection.length + 1}`,
122 repositoryId: vals.repositoryId,
123 pattern: vals.pattern,
124 requirePullRequest: vals.requirePullRequest ?? true,
125 requireGreenGates: vals.requireGreenGates ?? true,
126 requireAiApproval: vals.requireAiApproval ?? true,
127 requireHumanReview: vals.requireHumanReview ?? false,
128 requiredApprovals: vals.requiredApprovals ?? 0,
129 allowForcePush: vals.allowForcePush ?? false,
130 allowDeletion: vals.allowDeletion ?? false,
131 dismissStaleReviews: vals.dismissStaleReviews ?? false,
132 enableAutoMerge: vals.enableAutoMerge ?? false,
133 createdAt: new Date(),
134 updatedAt: new Date(),
135 };
136 state.branchProtection.push(row);
137 return {
138 returning: async () => [row],
139 };
140 }
141 return {
142 returning: async () => [vals],
143 };
144 },
145 };
146 },
147 update: (t: any) => {
148 const name = tableName(t);
149 return {
150 set: (vals: any) => {
151 state.updates.push({ table: name, set: vals });
152 return {
153 where: async () => {
154 if (name === "branch_protection") {
155 // Apply set values to the single bp row (script only
156 // updates by id, and tests only have one bp row at a time).
157 for (const r of state.branchProtection) {
158 Object.assign(r, vals);
159 }
160 }
161 return undefined;
162 },
163 };
164 },
165 };
166 },
167 };
168}
169
170function resolveSelect(
171 table: string,
172 _filter: any,
173 _cols: any,
174 state: FakeState
175): any[] {
176 // Approximation: return the (single) configured row for the table.
177 // resolveRepo issues two selects in sequence — first against `users`,
178 // then `repositories` — and the script only seeds one of each in
179 // these tests, so returning the first row is sufficient.
180 if (table === "users") {
181 return state.users.length > 0 ? [state.users[0]] : [];
182 }
183 if (table === "repositories") {
184 return state.repositories.length > 0
185 ? [{
186 id: state.repositories[0]!.id,
187 ownerId: state.repositories[0]!.ownerId,
188 name: state.repositories[0]!.name,
189 defaultBranch: state.repositories[0]!.defaultBranch,
190 }]
191 : [];
192 }
193 if (table === "branch_protection") {
194 return state.branchProtection.length > 0
195 ? [state.branchProtection[0]]
196 : [];
197 }
198 return [];
199}
200
201// ---------------------------------------------------------------------------
202// Audit fake
203// ---------------------------------------------------------------------------
204
205type AuditCall = {
206 userId?: string | null;
207 repositoryId?: string | null;
208 action: string;
209 targetType?: string;
210 targetId?: string;
211 metadata?: Record<string, unknown>;
212};
213
214function makeAudit(sink: AuditCall[]) {
215 return async (opts: AuditCall) => {
216 sink.push(opts);
217 };
218}
219
220// ---------------------------------------------------------------------------
221// Fixtures
222// ---------------------------------------------------------------------------
223
224let state: FakeState;
225let audits: AuditCall[];
226let db: DbLike;
227
228function seedRepo(opts?: { withProtection?: Partial<BranchProtection> }) {
229 state.users.push({ id: "user-1", username: "ccantynz" });
230 state.repositories.push({
231 id: "repo-1",
232 ownerId: "user-1",
233 name: "Gluecron.com",
234 defaultBranch: "main",
235 });
236 if (opts?.withProtection) {
237 state.branchProtection.push({
238 id: "bp-existing",
239 repositoryId: "repo-1",
240 pattern: "main",
241 requirePullRequest: true,
242 requireGreenGates: true,
243 requireAiApproval: true,
244 requireHumanReview: false,
245 requiredApprovals: 0,
246 allowForcePush: false,
247 allowDeletion: false,
248 dismissStaleReviews: false,
249 enableAutoMerge: false,
250 createdAt: new Date("2026-01-01"),
251 updatedAt: new Date("2026-01-01"),
252 ...opts.withProtection,
253 } as BranchProtection);
254 }
255}
256
257beforeEach(() => {
258 state = freshState();
259 audits = [];
260 db = makeDb(state);
261});
262
263afterAll(() => {
264 // No module-level mocks installed — nothing to undo. Reset state for
265 // hygiene anyway.
266 state = freshState();
267 audits = [];
268});
269
270// ---------------------------------------------------------------------------
271// resolveRepo
272// ---------------------------------------------------------------------------
273
274describe("resolveRepo", () => {
275 test("returns the repo when owner + name match", async () => {
276 seedRepo();
277 const r = await resolveRepo(db, "ccantynz/Gluecron.com");
278 expect(r).not.toBeNull();
279 expect(r?.id).toBe("repo-1");
280 });
281
282 test("returns null when the owner doesn't exist", async () => {
283 // No seeded users
284 const r = await resolveRepo(db, "ghost/repo");
285 expect(r).toBeNull();
286 });
287
288 test("rejects malformed owner/name", async () => {
289 seedRepo();
290 expect(await resolveRepo(db, "no-slash")).toBeNull();
291 expect(await resolveRepo(db, "/")).toBeNull();
292 expect(await resolveRepo(db, "owner/")).toBeNull();
293 });
294});
295
296// ---------------------------------------------------------------------------
297// runEnableAutoMerge — INSERT path
298// ---------------------------------------------------------------------------
299
300describe("runEnableAutoMerge — fresh insert", () => {
301 test("inserts a new branch_protection row with the safety defaults", async () => {
302 seedRepo(); // no existing protection
303 const args: EnableAutoMergeArgs = {
304 ownerSlash: "ccantynz/Gluecron.com",
305 pattern: "main",
306 };
307 const result = await runEnableAutoMerge(db, args, makeAudit(audits));
308
309 expect(result.action).toBe("inserted");
310 expect(result.before).toBeNull();
311 expect(result.after.enableAutoMerge).toBe(true);
312
313 // Safety defaults present on insert.
314 const ins = state.inserts.find((i) => i.table === "branch_protection");
315 expect(ins).toBeTruthy();
316 expect(ins!.values.requireGreenGates).toBe(true);
317 expect(ins!.values.requireAiApproval).toBe(true);
318 expect(ins!.values.requireHumanReview).toBe(false);
319 expect(ins!.values.requiredApprovals).toBe(0);
320 expect(ins!.values.enableAutoMerge).toBe(true);
321 expect(ins!.values.dismissStaleReviews).toBe(false);
322 expect(ins!.values.allowForcePush).toBe(false);
323 expect(ins!.values.allowDeletion).toBe(false);
324
325 // Audit row written.
326 expect(audits.length).toBe(1);
327 expect(audits[0]!.action).toBe("auto_merge.enabled_on_main");
328 expect(audits[0]!.repositoryId).toBe("repo-1");
329 expect(audits[0]!.targetType).toBe("branch_protection");
330 });
331
332 test("throws a clear error when the repo isn't found", async () => {
333 // No seed — empty DB.
334 let caught: Error | null = null;
335 try {
336 await runEnableAutoMerge(
337 db,
338 { ownerSlash: "ghost/nope", pattern: "main" },
339 makeAudit(audits)
340 );
341 } catch (err) {
342 caught = err as Error;
343 }
344 expect(caught).not.toBeNull();
345 expect(caught?.message).toMatch(/Repository not found/);
346 // No audit entry written for a failed resolve.
347 expect(audits.length).toBe(0);
348 });
349});
350
351// ---------------------------------------------------------------------------
352// runEnableAutoMerge — UPDATE path
353// ---------------------------------------------------------------------------
354
355describe("runEnableAutoMerge — existing row", () => {
356 test("flips enableAutoMerge=true on an existing row, preserves other fields", async () => {
357 seedRepo({
358 withProtection: {
359 enableAutoMerge: false,
360 requireGreenGates: true,
361 requireAiApproval: true,
362 requireHumanReview: true, // not what the defaults would set
363 requiredApprovals: 2,
364 },
365 });
366 const result = await runEnableAutoMerge(
367 db,
368 { ownerSlash: "ccantynz/Gluecron.com", pattern: "main" },
369 makeAudit(audits)
370 );
371
372 expect(result.action).toBe("updated");
373 expect(result.before).not.toBeNull();
374 expect(result.before!.enableAutoMerge).toBe(false);
375 expect(result.after.enableAutoMerge).toBe(true);
376
377 // Other fields preserved on the after row.
378 expect(result.after.requireHumanReview).toBe(true);
379 expect(result.after.requiredApprovals).toBe(2);
380
381 // Only enableAutoMerge + updatedAt should land in the SET payload.
382 const upd = state.updates.find((u) => u.table === "branch_protection");
383 expect(upd).toBeTruthy();
384 expect(upd!.set.enableAutoMerge).toBe(true);
385 expect("requireHumanReview" in upd!.set).toBe(false);
386 expect("requiredApprovals" in upd!.set).toBe(false);
387
388 expect(audits.length).toBe(1);
389 expect(audits[0]!.action).toBe("auto_merge.enabled_on_main");
390 });
391
392 test("--off flips the bit to false and writes the disable audit action", async () => {
393 seedRepo({ withProtection: { enableAutoMerge: true } });
394 const result = await runEnableAutoMerge(
395 db,
396 { ownerSlash: "ccantynz/Gluecron.com", pattern: "main", off: true },
397 makeAudit(audits)
398 );
399
400 expect(result.action).toBe("updated");
401 expect(result.after.enableAutoMerge).toBe(false);
402 expect(audits[0]!.action).toBe("auto_merge.disabled_on_main");
403 });
404
405 test("idempotent: second run is a no-op with no second audit entry", async () => {
406 seedRepo({ withProtection: { enableAutoMerge: true } });
407 const result = await runEnableAutoMerge(
408 db,
409 { ownerSlash: "ccantynz/Gluecron.com", pattern: "main" },
410 makeAudit(audits)
411 );
412 expect(result.action).toBe("noop");
413 expect(result.auditWritten).toBe(false);
414 expect(audits.length).toBe(0);
415 // And no UPDATE was issued.
416 expect(state.updates.length).toBe(0);
417 });
418});
419
420// ---------------------------------------------------------------------------
421// renderDiff
422// ---------------------------------------------------------------------------
423
424describe("renderDiff", () => {
425 test("INSERT case renders every tracked field as additions", () => {
426 const after: BranchProtection = {
427 id: "bp-1",
428 repositoryId: "repo-1",
429 pattern: "main",
430 requirePullRequest: true,
431 requireGreenGates: true,
432 requireAiApproval: true,
433 requireHumanReview: false,
434 requiredApprovals: 0,
435 allowForcePush: false,
436 allowDeletion: false,
437 dismissStaleReviews: false,
438 enableAutoMerge: true,
439 createdAt: new Date(),
440 updatedAt: new Date(),
441 } as BranchProtection;
442 const out = renderDiff(null, after);
443 expect(out).toContain("no previous");
444 expect(out).toContain("enableAutoMerge = true");
445 expect(out).toContain("requireAiApproval = true");
446 });
447
448 test("UPDATE case renders only the changed fields", () => {
449 const before: BranchProtection = {
450 id: "bp-1",
451 repositoryId: "repo-1",
452 pattern: "main",
453 requirePullRequest: true,
454 requireGreenGates: true,
455 requireAiApproval: true,
456 requireHumanReview: false,
457 requiredApprovals: 0,
458 allowForcePush: false,
459 allowDeletion: false,
460 dismissStaleReviews: false,
461 enableAutoMerge: false,
462 createdAt: new Date(),
463 updatedAt: new Date(),
464 } as BranchProtection;
465 const after = { ...before, enableAutoMerge: true } as BranchProtection;
466 const out = renderDiff(before, after);
467 expect(out).toContain("enableAutoMerge");
468 expect(out).toContain("false");
469 expect(out).toContain("true");
470 // No other field should appear.
471 expect(out).not.toContain("requireAiApproval:");
472 expect(out).not.toContain("requireGreenGates:");
473 });
474});
475
476// ---------------------------------------------------------------------------
477// Readiness checks
478// ---------------------------------------------------------------------------
479
480describe("readiness check helpers", () => {
481 test("checkAnthropicKey fails when env var is missing", () => {
482 const r = checkAnthropicKey({} as NodeJS.ProcessEnv);
483 expect(r.status).toBe("fail");
484 expect(r.reason).toMatch(/ANTHROPIC_API_KEY/);
485 });
486
487 test("checkAnthropicKey passes when env var is present", () => {
488 const r = checkAnthropicKey({
489 ANTHROPIC_API_KEY: "sk-ant-1234567890",
490 } as unknown as NodeJS.ProcessEnv);
491 expect(r.status).toBe("pass");
492 });
493
494 test("checkAutopilotEnabled fails when AUTOPILOT_DISABLED=1", () => {
495 const r = checkAutopilotEnabled({
496 AUTOPILOT_DISABLED: "1",
497 } as unknown as NodeJS.ProcessEnv);
498 expect(r.status).toBe("fail");
499 });
500
501 test("checkAutopilotEnabled passes when AUTOPILOT_DISABLED is unset", () => {
502 const r = checkAutopilotEnabled({} as NodeJS.ProcessEnv);
503 expect(r.status).toBe("pass");
504 });
505
506 test("checkAutoMergeSweepRegistered passes when the task is present", () => {
507 const r = checkAutoMergeSweepRegistered([
508 { name: "mirror-sync" },
509 { name: "auto-merge-sweep" },
510 { name: "weekly-digest" },
511 ]);
512 expect(r.status).toBe("pass");
513 });
514
515 test("checkAutoMergeSweepRegistered fails when missing", () => {
516 const r = checkAutoMergeSweepRegistered([{ name: "mirror-sync" }]);
517 expect(r.status).toBe("fail");
518 expect(r.reason).toMatch(/auto-merge-sweep/);
519 });
520
521 test("checkMigration0040 fails when column is missing", async () => {
522 const r = await checkMigration0040(async () => ({ exists: false }));
523 expect(r.status).toBe("fail");
524 expect(r.reason).toMatch(/enable_auto_merge/);
525 });
526
527 test("checkMigration0040 fails on runner error", async () => {
528 const r = await checkMigration0040(async () => ({
529 exists: false,
530 error: "connection refused",
531 }));
532 expect(r.status).toBe("fail");
533 expect(r.reason).toMatch(/connection refused/);
534 });
535
536 test("checkMigration0040 passes when column exists", async () => {
537 const r = await checkMigration0040(async () => ({ exists: true }));
538 expect(r.status).toBe("pass");
539 });
540});
541
542// ---------------------------------------------------------------------------
543// Confirm the real defaultTasks() registers auto-merge-sweep.
544// ---------------------------------------------------------------------------
545
546describe("real defaultTasks() registration", () => {
547 test("registers an 'auto-merge-sweep' task (guards against accidental removal)", async () => {
548 const { defaultTasks } = await import("../lib/autopilot");
549 const tasks = defaultTasks();
550 expect(tasks.some((t) => t.name === "auto-merge-sweep")).toBe(true);
551 });
552});
Addedsrc/__tests__/magic-link.test.ts+596−0View fileUnifiedSplit
@@ -0,0 +1,596 @@
1/**
2 * Block Q2 — Magic-link sign-in.
3 *
4 * Covers `src/lib/magic-link.ts` (token primitives, start, consume) and
5 * the `src/routes/magic-link.tsx` HTTP surface (GET/POST /login/magic,
6 * GET /login/magic/callback).
7 *
8 * Strategy mirrors password-reset.test.ts:
9 * - The library tests stub the `db` module (K1 spread-from-real pattern)
10 * so they don't require Neon. Rows live in in-memory arrays keyed by
11 * `tableName(t)` derived from drizzle column metadata.
12 * - Email sending is replaced via `__setEmailForTests`.
13 * - The HTTP-surface tests use `app.request(...)` so the real Hono
14 * router + csrf middleware answer.
15 *
16 * All `mock.module(...)` calls capture the REAL module first and restore
17 * in `afterAll` so we don't poison test files that run after us.
18 */
19
20import {
21 describe,
22 it,
23 expect,
24 mock,
25 beforeEach,
26 afterEach,
27 afterAll,
28} from "bun:test";
29
30// Capture real modules BEFORE any mock.module() so afterAll can restore.
31const _real_db = await import("../db");
32
33// ---------------------------------------------------------------------------
34// In-memory DB stub
35// ---------------------------------------------------------------------------
36
37interface FakeRow { [k: string]: any; }
38interface FakeStore {
39 users: FakeRow[];
40 magic_link_tokens: FakeRow[];
41 sessions: FakeRow[];
42}
43
44const store: FakeStore = { users: [], magic_link_tokens: [], sessions: [] };
45
46function resetStore() {
47 store.users = [];
48 store.magic_link_tokens = [];
49 store.sessions = [];
50}
51
52function tableName(t: any): keyof FakeStore | "?" {
53 if (!t || typeof t !== "object") return "?";
54 // magic_link_tokens: has tokenHash AND email (P1 reset tokens have no email)
55 if ("tokenHash" in t && "email" in t && "usedAt" in t) return "magic_link_tokens";
56 // sessions: token + expiresAt but no tokenHash
57 if ("token" in t && "expiresAt" in t && !("tokenHash" in t)) return "sessions";
58 // users: passwordHash + username
59 if ("passwordHash" in t && "username" in t) return "users";
60 return "?";
61}
62
63let _whereFilter: any = null;
64
65function makeEq(lhs: any, rhs: any) {
66 return { __op: "eq", lhs, rhs };
67}
68
69function colKey(lhs: any): string | null {
70 if (!lhs || typeof lhs !== "object") return null;
71 const name: string = lhs.name || "";
72 const camel = name.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase());
73 return camel || null;
74}
75
76function applyFilter(rows: FakeRow[], where: any): FakeRow[] {
77 if (!where) return rows;
78 const k = colKey(where.lhs);
79 if (!k) return rows;
80 return rows.filter((r) => r[k] === where.rhs);
81}
82
83const _inserted: { table: string; values: any }[] = [];
84
85let _lastSelectTable: keyof FakeStore | "?" = "?";
86let _lastUpdateTable: keyof FakeStore | "?" = "?";
87let _lastDeleteTable: keyof FakeStore | "?" = "?";
88
89const selectChain: any = {
90 from(t: any) { _lastSelectTable = tableName(t); return selectChain; },
91 where(w: any) { _whereFilter = w; return selectChain; },
92 orderBy() { return selectChain; },
93 async limit(_n: number) {
94 const tbl = _lastSelectTable;
95 const where = _whereFilter;
96 _whereFilter = null;
97 if (tbl === "?") return [];
98 const rows = applyFilter(store[tbl] as FakeRow[], where);
99 return rows.slice(0, _n);
100 },
101};
102
103const fakeDb: any = {
104 select(_s?: any) { return selectChain; },
105 insert(t: any) {
106 const tbl = tableName(t);
107 return {
108 async values(v: any) {
109 const row = { ...v, id: v.id || crypto.randomUUID() };
110 if (!row.createdAt) row.createdAt = new Date();
111 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
112 _inserted.push({ table: tbl, values: row });
113 return { rows: [row] };
114 },
115 returning() {
116 return {
117 async values(v: any) {
118 const row = { ...v, id: v.id || crypto.randomUUID() };
119 if (!row.createdAt) row.createdAt = new Date();
120 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
121 _inserted.push({ table: tbl, values: row });
122 return [row];
123 },
124 };
125 },
126 };
127 },
128 update(t: any) {
129 _lastUpdateTable = tableName(t);
130 return {
131 set(s: any) {
132 const tbl = _lastUpdateTable;
133 return {
134 async where(w: any) {
135 if (tbl === "?") return;
136 const rows = applyFilter(store[tbl] as FakeRow[], w);
137 for (const r of rows) Object.assign(r, s);
138 },
139 };
140 },
141 };
142 },
143 delete(t: any) {
144 _lastDeleteTable = tableName(t);
145 return {
146 async where(w: any) {
147 const tbl = _lastDeleteTable;
148 if (tbl === "?") return;
149 const remaining = (store[tbl] as FakeRow[]).filter(
150 (r) => !applyFilter([r], w).length
151 );
152 (store[tbl] as FakeRow[]).length = 0;
153 (store[tbl] as FakeRow[]).push(...remaining);
154 },
155 };
156 },
157};
158
159mock.module("../db", () => ({ ..._real_db, db: fakeDb }));
160
161const _real_drizzle = await import("drizzle-orm");
162mock.module("drizzle-orm", () => ({
163 ..._real_drizzle,
164 eq: (lhs: any, rhs: any) => makeEq(lhs, rhs),
165}));
166
167// ---------------------------------------------------------------------------
168// Email seam
169// ---------------------------------------------------------------------------
170
171const _emails: { to: string; subject: string; text: string; html?: string }[] = [];
172
173import {
174 generateMagicLinkToken,
175 startMagicLinkSignIn,
176 consumeMagicLinkToken,
177 buildMagicLinkUrl,
178 __setEmailForTests,
179} from "../lib/magic-link";
180
181__setEmailForTests((msg: any) => {
182 _emails.push({ to: msg.to, subject: msg.subject, text: msg.text, html: msg.html });
183 return { ok: true, provider: "log" as const };
184});
185
186afterAll(() => {
187 __setEmailForTests(null);
188 mock.module("../db", () => _real_db);
189 mock.module("drizzle-orm", () => _real_drizzle);
190});
191
192beforeEach(() => {
193 resetStore();
194 _inserted.length = 0;
195 _emails.length = 0;
196});
197
198// ---------------------------------------------------------------------------
199// Helpers
200// ---------------------------------------------------------------------------
201
202async function sha256Hex(input: string): Promise<string> {
203 const buf = new TextEncoder().encode(input);
204 const digest = await crypto.subtle.digest("SHA-256", buf);
205 return Array.from(new Uint8Array(digest))
206 .map((b) => b.toString(16).padStart(2, "0"))
207 .join("");
208}
209
210function seedUser(overrides: Partial<FakeRow> = {}): FakeRow {
211 const u: FakeRow = {
212 id: crypto.randomUUID(),
213 username: "ada",
214 email: "ada@example.com",
215 passwordHash: "$2b$10$oldhashplaceholder0123456789012345678901234567890123",
216 emailVerifiedAt: null,
217 updatedAt: new Date(),
218 ...overrides,
219 };
220 store.users.push(u);
221 return u;
222}
223
224function seedToken(overrides: Partial<FakeRow> = {}): { row: FakeRow; plaintext: string } {
225 const { plaintext, hash } = generateMagicLinkToken();
226 const row: FakeRow = {
227 id: crypto.randomUUID(),
228 email: "ada@example.com",
229 userId: null,
230 tokenHash: hash,
231 expiresAt: new Date(Date.now() + 60_000),
232 usedAt: null,
233 requestIp: null,
234 createdAt: new Date(),
235 ...overrides,
236 };
237 store.magic_link_tokens.push(row);
238 return { row, plaintext };
239}
240
241// Give the fire-and-forget email send a tick to land in the recorder.
242async function flushMicrotasks() {
243 await new Promise((r) => setTimeout(r, 5));
244}
245
246// ---------------------------------------------------------------------------
247// generateMagicLinkToken
248// ---------------------------------------------------------------------------
249
250describe("generateMagicLinkToken", () => {
251 it("emits 64 hex chars of plaintext + a matching sha256 hash", async () => {
252 const { plaintext, hash } = generateMagicLinkToken();
253 expect(plaintext).toMatch(/^[0-9a-f]{64}$/);
254 expect(hash).toMatch(/^[0-9a-f]{64}$/);
255 expect(hash).toBe(await sha256Hex(plaintext));
256 });
257
258 it("emits unique plaintexts on repeated calls", () => {
259 const a = generateMagicLinkToken();
260 const b = generateMagicLinkToken();
261 expect(a.plaintext).not.toBe(b.plaintext);
262 expect(a.hash).not.toBe(b.hash);
263 });
264});
265
266// ---------------------------------------------------------------------------
267// startMagicLinkSignIn
268// ---------------------------------------------------------------------------
269
270describe("startMagicLinkSignIn", () => {
271 it("creates a token row linked to an existing user and queues an email", async () => {
272 const u = seedUser({ email: "ada@example.com", username: "ada" });
273 const res = await startMagicLinkSignIn("ada@example.com", { requestIp: "127.0.0.1" });
274 expect(res.ok).toBe(true);
275 await flushMicrotasks();
276
277 expect(store.magic_link_tokens.length).toBe(1);
278 const row = store.magic_link_tokens[0]!;
279 expect(row.userId).toBe(u.id);
280 expect(row.email).toBe("ada@example.com");
281 expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/);
282 expect(row.expiresAt instanceof Date).toBe(true);
283 expect(row.requestIp).toBe("127.0.0.1");
284
285 expect(_emails.length).toBe(1);
286 expect(_emails[0]!.to).toBe("ada@example.com");
287 expect(_emails[0]!.subject).toBe("Your Gluecron sign-in link");
288 expect(_emails[0]!.text).toContain("expires in 15 minutes");
289 expect(_emails[0]!.text).toContain("/login/magic/callback?token=");
290 expect(_emails[0]!.html).toContain("Sign in");
291 });
292
293 it("returns ok for non-existent email AND still mints a token row (userId=null) so consume can auto-create", async () => {
294 const res = await startMagicLinkSignIn("ghost@example.com");
295 expect(res.ok).toBe(true);
296 await flushMicrotasks();
297
298 expect(store.magic_link_tokens.length).toBe(1);
299 const row = store.magic_link_tokens[0]!;
300 expect(row.userId).toBeFalsy();
301 expect(row.email).toBe("ghost@example.com");
302 expect(_emails.length).toBe(1);
303 expect(_emails[0]!.to).toBe("ghost@example.com");
304 });
305
306 it("does NOT mint a token when autoCreate=false and email is unknown", async () => {
307 const res = await startMagicLinkSignIn("ghost@example.com", { autoCreate: false });
308 expect(res.ok).toBe(true);
309 await flushMicrotasks();
310 expect(store.magic_link_tokens.length).toBe(0);
311 expect(_emails.length).toBe(0);
312 });
313
314 it("returns ok for garbage inputs without throwing", async () => {
315 expect((await startMagicLinkSignIn("")).ok).toBe(true);
316 expect((await startMagicLinkSignIn("not-an-email")).ok).toBe(true);
317 expect(store.magic_link_tokens.length).toBe(0);
318 expect(_emails.length).toBe(0);
319 });
320
321 it("normalizes email case before lookup", async () => {
322 seedUser({ email: "ada@example.com", username: "ada" });
323 const res = await startMagicLinkSignIn("ADA@Example.COM");
324 expect(res.ok).toBe(true);
325 await flushMicrotasks();
326 expect(store.magic_link_tokens.length).toBe(1);
327 expect(store.magic_link_tokens[0]!.email).toBe("ada@example.com");
328 });
329
330 it("enforces the per-email rate limit (3 mints / hour)", async () => {
331 seedUser({ email: "ada@example.com" });
332 for (let i = 0; i < 3; i++) {
333 const r = await startMagicLinkSignIn("ada@example.com");
334 expect(r.ok).toBe(true);
335 }
336 await flushMicrotasks();
337 expect(store.magic_link_tokens.length).toBe(3);
338
339 // 4th call still returns ok (generic) but does NOT create another token.
340 const r4 = await startMagicLinkSignIn("ada@example.com");
341 expect(r4.ok).toBe(true);
342 await flushMicrotasks();
343 expect(store.magic_link_tokens.length).toBe(3);
344 });
345});
346
347// ---------------------------------------------------------------------------
348// consumeMagicLinkToken
349// ---------------------------------------------------------------------------
350
351describe("consumeMagicLinkToken", () => {
352 it("rejects garbage tokens with reason=invalid", async () => {
353 const res = await consumeMagicLinkToken("not-a-real-token");
354 expect(res.ok).toBe(false);
355 expect(res.reason).toBe("invalid");
356 });
357
358 it("rejects an empty token", async () => {
359 const res = await consumeMagicLinkToken("");
360 expect(res.ok).toBe(false);
361 expect(res.reason).toBe("invalid");
362 });
363
364 it("rejects expired tokens", async () => {
365 const u = seedUser();
366 const { plaintext } = seedToken({
367 userId: u.id,
368 expiresAt: new Date(Date.now() - 60_000),
369 });
370 const res = await consumeMagicLinkToken(plaintext);
371 expect(res.ok).toBe(false);
372 expect(res.reason).toBe("expired");
373 });
374
375 it("rejects already-used tokens", async () => {
376 const u = seedUser();
377 const { plaintext } = seedToken({
378 userId: u.id,
379 usedAt: new Date(Date.now() - 1000),
380 });
381 const res = await consumeMagicLinkToken(plaintext);
382 expect(res.ok).toBe(false);
383 expect(res.reason).toBe("used");
384 });
385
386 it("happy path with existing user: returns userId, marks token used, no account created", async () => {
387 const u = seedUser({ email: "ada@example.com", username: "ada" });
388 const { row, plaintext } = seedToken({ userId: u.id, email: "ada@example.com" });
389
390 const res = await consumeMagicLinkToken(plaintext);
391 expect(res.ok).toBe(true);
392 expect(res.userId).toBe(u.id);
393 expect(res.createdAccount).toBe(false);
394
395 const updated = store.magic_link_tokens.find((r) => r.id === row.id)!;
396 expect(updated.usedAt instanceof Date).toBe(true);
397
398 // No new user row.
399 expect(store.users.length).toBe(1);
400 });
401
402 it("happy path with no existing user: creates account, returns userId + createdAccount=true", async () => {
403 const { plaintext } = seedToken({ userId: null, email: "ghost@example.com" });
404
405 const res = await consumeMagicLinkToken(plaintext);
406 expect(res.ok).toBe(true);
407 expect(res.userId).toBeTruthy();
408 expect(res.createdAccount).toBe(true);
409
410 // A fresh user row was minted for the email.
411 expect(store.users.length).toBe(1);
412 const created = store.users[0]!;
413 expect(created.email).toBe("ghost@example.com");
414 expect(created.username).toMatch(/^user-[a-z0-9]{8,}$/);
415 // The placeholder password hash exists but is not the plaintext.
416 expect(created.passwordHash).toBeTruthy();
417 expect(created.passwordHash).not.toBe(plaintext);
418 // The click verified the address.
419 expect(created.emailVerifiedAt instanceof Date).toBe(true);
420 });
421
422 it("invalidates all other unused magic-link tokens for the same email on success", async () => {
423 const u = seedUser({ email: "ada@example.com", username: "ada" });
424 const { plaintext } = seedToken({ userId: u.id, email: "ada@example.com" });
425 // Two more outstanding tokens for the same email — should be burned.
426 const t2 = seedToken({ userId: u.id, email: "ada@example.com" });
427 const t3 = seedToken({ userId: u.id, email: "ada@example.com" });
428 // A token for a DIFFERENT email — must NOT be touched.
429 const otherUser = seedUser({ email: "bob@example.com", username: "bob" });
430 const tOther = seedToken({ userId: otherUser.id, email: "bob@example.com" });
431
432 const res = await consumeMagicLinkToken(plaintext);
433 expect(res.ok).toBe(true);
434
435 // Every ada@example.com row is now used.
436 const adaRows = store.magic_link_tokens.filter((r) => r.email === "ada@example.com");
437 expect(adaRows.length).toBe(3);
438 for (const r of adaRows) expect(r.usedAt instanceof Date).toBe(true);
439
440 // The bob@example.com row is untouched.
441 const bobRow = store.magic_link_tokens.find((r) => r.id === tOther.row.id)!;
442 expect(bobRow.usedAt).toBe(null);
443
444 // A second consume of one of the burned siblings is rejected as used.
445 const second = await consumeMagicLinkToken(t2.plaintext);
446 expect(second.ok).toBe(false);
447 expect(second.reason).toBe("used");
448 // Same for t3.
449 const third = await consumeMagicLinkToken(t3.plaintext);
450 expect(third.ok).toBe(false);
451 expect(third.reason).toBe("used");
452 });
453});
454
455// ---------------------------------------------------------------------------
456// buildMagicLinkUrl
457// ---------------------------------------------------------------------------
458
459describe("buildMagicLinkUrl", () => {
460 it("includes the token in the callback path", () => {
461 const u = buildMagicLinkUrl("abc123");
462 expect(u).toContain("/login/magic/callback?token=abc123");
463 });
464});
465
466// ---------------------------------------------------------------------------
467// HTTP surface — pull in the app AFTER the module mocks are installed.
468// ---------------------------------------------------------------------------
469
470const app = (await import("../app")).default;
471
472describe("GET /login/magic", () => {
473 it("renders the email-entry form with a CSRF input", async () => {
474 const res = await app.request("/login/magic");
475 expect(res.status).toBe(200);
476 const html = await res.text();
477 expect(html).toContain("Sign in with a magic link");
478 expect(html).toContain('name="email"');
479 expect(html).toContain('name="_csrf"');
480 expect(html).toContain("Send me a sign-in link");
481 });
482
483 it("?sent=1 renders the generic success page", async () => {
484 const res = await app.request("/login/magic?sent=1");
485 expect(res.status).toBe(200);
486 const html = await res.text();
487 expect(html).toContain("Check your inbox");
488 expect(html).toContain("expires in 15 minutes");
489 });
490});
491
492describe("POST /login/magic", () => {
493 it("always redirects to ?sent=1 regardless of whether the email exists", async () => {
494 seedUser({ email: "ada@example.com" });
495 const res1 = await app.request("/login/magic", {
496 method: "POST",
497 headers: { "content-type": "application/x-www-form-urlencoded" },
498 body: new URLSearchParams({ email: "ada@example.com" }),
499 });
500 expect(res1.status).toBe(302);
501 expect(res1.headers.get("location")).toContain("/login/magic?sent=1");
502
503 const res2 = await app.request("/login/magic", {
504 method: "POST",
505 headers: { "content-type": "application/x-www-form-urlencoded" },
506 body: new URLSearchParams({ email: "ghost@example.com" }),
507 });
508 expect(res2.status).toBe(302);
509 expect(res2.headers.get("location")).toContain("/login/magic?sent=1");
510 });
511});
512
513describe("GET /login/magic/callback", () => {
514 it("happy path with existing user → 302 to /dashboard with a session cookie", async () => {
515 const u = seedUser({ email: "ada@example.com", username: "ada" });
516 const { plaintext } = seedToken({ userId: u.id, email: "ada@example.com" });
517
518 const res = await app.request(`/login/magic/callback?token=${encodeURIComponent(plaintext)}`);
519 expect(res.status).toBe(302);
520 expect(res.headers.get("location")).toBe("/dashboard");
521 const setCookie = res.headers.get("set-cookie") || "";
522 expect(setCookie).toContain("session=");
523 });
524
525 it("happy path with no existing user → 302 to /onboarding?welcome=1 and creates account", async () => {
526 const { plaintext } = seedToken({ userId: null, email: "ghost@example.com" });
527
528 const res = await app.request(`/login/magic/callback?token=${encodeURIComponent(plaintext)}`);
529 expect(res.status).toBe(302);
530 expect(res.headers.get("location")).toBe("/onboarding?welcome=1");
531 expect(res.headers.get("set-cookie") || "").toContain("session=");
532
533 expect(store.users.length).toBe(1);
534 expect(store.users[0]!.email).toBe("ghost@example.com");
535 });
536
537 it("invalid/garbage token → renders the dead-link page", async () => {
538 const res = await app.request("/login/magic/callback?token=garbage");
539 expect(res.status).toBe(200);
540 const html = await res.text();
541 expect(html).toContain("This link is no longer valid");
542 expect(html).toContain("/login/magic");
543 });
544
545 it("no token query → renders the dead-link page", async () => {
546 const res = await app.request("/login/magic/callback");
547 expect(res.status).toBe(200);
548 const html = await res.text();
549 expect(html).toContain("This link is no longer valid");
550 });
551
552 it("expired token → renders the dead-link page", async () => {
553 const u = seedUser();
554 const { plaintext } = seedToken({
555 userId: u.id,
556 expiresAt: new Date(Date.now() - 60_000),
557 });
558 const res = await app.request(`/login/magic/callback?token=${encodeURIComponent(plaintext)}`);
559 expect(res.status).toBe(200);
560 const html = await res.text();
561 expect(html).toContain("This link is no longer valid");
562 });
563});
564
565// ---------------------------------------------------------------------------
566// Rate limit — temporarily flip out of "test" env so the in-memory limiter
567// is actually enforced.
568// ---------------------------------------------------------------------------
569
570describe("rate limit on /login/magic", () => {
571 const _origNodeEnv = process.env.NODE_ENV;
572 const _origBunEnv = process.env.BUN_ENV;
573
574 afterEach(() => {
575 process.env.NODE_ENV = _origNodeEnv;
576 process.env.BUN_ENV = _origBunEnv;
577 });
578
579 it("returns 429 after 5 requests inside the 60s window", async () => {
580 process.env.NODE_ENV = "development";
581 process.env.BUN_ENV = "development";
582
583 const headers = {
584 "content-type": "application/x-www-form-urlencoded",
585 "x-forwarded-for": "203.0.113.77",
586 } as Record<string, string>;
587 const body = () => new URLSearchParams({ email: "ghost@example.com" });
588
589 for (let i = 0; i < 5; i++) {
590 const res = await app.request("/login/magic", { method: "POST", headers, body: body() });
591 expect(res.status).toBe(302);
592 }
593 const sixth = await app.request("/login/magic", { method: "POST", headers, body: body() });
594 expect(sixth.status).toBe(429);
595 });
596});
Addedsrc/__tests__/password-reset.test.ts+658−0View fileUnifiedSplit
@@ -0,0 +1,658 @@
1/**
2 * Block P1 — Password reset flow.
3 *
4 * Covers `src/lib/password-reset.ts` (token primitives, request creation,
5 * token consumption) and the `src/routes/password-reset.tsx` HTTP surface
6 * (forgot-password GET/POST, reset-password GET/POST).
7 *
8 * Strategy:
9 * - The library tests stub the `db` module (K1 spread-from-real pattern)
10 * so they don't require Neon. The stub stores rows in in-memory arrays
11 * keyed by `tableName(t)` and re-uses the same fluent chain shape that
12 * mcp-write.test.ts established.
13 * - Email sending is replaced via `__setEmailForTests` so we can assert
14 * "an email was queued for this user" without hitting a real provider.
15 * - The HTTP-surface tests use `app.request(...)` so the real Hono router
16 * answers — including the `csrfProtect` middleware (which exempts
17 * unauth POSTs via the session-cookie shortcut).
18 *
19 * All `mock.module(...)` calls capture the REAL module first and restore
20 * in `afterAll` so we don't poison every test file that runs after us.
21 */
22
23import {
24 describe,
25 it,
26 expect,
27 mock,
28 beforeEach,
29 afterEach,
30 afterAll,
31} from "bun:test";
32
33// Capture real modules BEFORE any mock.module() so afterAll can restore them.
34const _real_db = await import("../db");
35
36// ---------------------------------------------------------------------------
37// In-memory DB stub
38// ---------------------------------------------------------------------------
39
40interface FakeRow { [k: string]: any; }
41interface FakeStore {
42 users: FakeRow[];
43 password_reset_tokens: FakeRow[];
44 sessions: FakeRow[];
45}
46
47const store: FakeStore = { users: [], password_reset_tokens: [], sessions: [] };
48
49function resetStore() {
50 store.users = [];
51 store.password_reset_tokens = [];
52 store.sessions = [];
53}
54
55function tableName(t: any): keyof FakeStore | "?" {
56 if (!t || typeof t !== "object") return "?";
57 if ("tokenHash" in t && "usedAt" in t) return "password_reset_tokens";
58 if ("token" in t && "expiresAt" in t && !("tokenHash" in t)) return "sessions";
59 if ("passwordHash" in t && "username" in t) return "users";
60 return "?";
61}
62
63let _whereFilter: any = null;
64
65function makeEq(lhs: any, rhs: any) {
66 return { __op: "eq", lhs, rhs };
67}
68
69function colKey(lhs: any): string | null {
70 if (!lhs || typeof lhs !== "object") return null;
71 const name: string = lhs.name || "";
72 const camel = name.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase());
73 return camel || null;
74}
75
76function applyFilter(rows: FakeRow[], where: any): FakeRow[] {
77 if (!where) return rows;
78 const k = colKey(where.lhs);
79 if (!k) return rows;
80 return rows.filter((r) => r[k] === where.rhs);
81}
82
83const _inserted: { table: string; values: any }[] = [];
84
85let _lastSelectTable: keyof FakeStore | "?" = "?";
86let _lastUpdateTable: keyof FakeStore | "?" = "?";
87let _lastDeleteTable: keyof FakeStore | "?" = "?";
88
89const selectChain: any = {
90 from(t: any) { _lastSelectTable = tableName(t); return selectChain; },
91 where(w: any) { _whereFilter = w; return selectChain; },
92 orderBy() { return selectChain; },
93 async limit(_n: number) {
94 const tbl = _lastSelectTable;
95 const where = _whereFilter;
96 _whereFilter = null;
97 if (tbl === "?") return [];
98 const rows = applyFilter(store[tbl] as FakeRow[], where);
99 return rows.slice(0, _n);
100 },
101};
102
103const fakeDb: any = {
104 select(_s?: any) { return selectChain; },
105 insert(t: any) {
106 const tbl = tableName(t);
107 return {
108 async values(v: any) {
109 const row = { ...v, id: v.id || crypto.randomUUID() };
110 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
111 _inserted.push({ table: tbl, values: row });
112 return { rows: [row] };
113 },
114 returning() {
115 return {
116 async values(v: any) {
117 const row = { ...v, id: v.id || crypto.randomUUID() };
118 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
119 _inserted.push({ table: tbl, values: row });
120 return [row];
121 },
122 };
123 },
124 };
125 },
126 update(t: any) {
127 _lastUpdateTable = tableName(t);
128 return {
129 set(s: any) {
130 const tbl = _lastUpdateTable;
131 return {
132 async where(w: any) {
133 if (tbl === "?") return;
134 const rows = applyFilter(store[tbl] as FakeRow[], w);
135 for (const r of rows) Object.assign(r, s);
136 },
137 };
138 },
139 };
140 },
141 delete(t: any) {
142 _lastDeleteTable = tableName(t);
143 return {
144 async where(w: any) {
145 const tbl = _lastDeleteTable;
146 if (tbl === "?") return;
147 const remaining = (store[tbl] as FakeRow[]).filter(
148 (r) => !applyFilter([r], w).length
149 );
150 (store[tbl] as FakeRow[]).length = 0;
151 (store[tbl] as FakeRow[]).push(...remaining);
152 },
153 };
154 },
155};
156
157mock.module("../db", () => ({ ..._real_db, db: fakeDb }));
158
159const _real_drizzle = await import("drizzle-orm");
160mock.module("drizzle-orm", () => ({
161 ..._real_drizzle,
162 eq: (lhs: any, rhs: any) => makeEq(lhs, rhs),
163}));
164
165// ---------------------------------------------------------------------------
166// Email seam
167// ---------------------------------------------------------------------------
168
169const _emails: { to: string; subject: string; text: string; html?: string }[] = [];
170
171import {
172 generateResetToken,
173 createPasswordResetRequest,
174 consumeResetToken,
175 inspectResetToken,
176 buildResetUrl,
177 __setEmailForTests,
178} from "../lib/password-reset";
179
180__setEmailForTests((msg: any) => {
181 _emails.push({ to: msg.to, subject: msg.subject, text: msg.text, html: msg.html });
182 return { ok: true, provider: "log" as const };
183});
184
185afterAll(() => {
186 __setEmailForTests(null);
187 mock.module("../db", () => _real_db);
188 mock.module("drizzle-orm", () => _real_drizzle);
189});
190
191beforeEach(() => {
192 resetStore();
193 _inserted.length = 0;
194 _emails.length = 0;
195});
196
197// ---------------------------------------------------------------------------
198// Helpers
199// ---------------------------------------------------------------------------
200
201async function sha256Hex(input: string): Promise<string> {
202 const buf = new TextEncoder().encode(input);
203 const digest = await crypto.subtle.digest("SHA-256", buf);
204 return Array.from(new Uint8Array(digest))
205 .map((b) => b.toString(16).padStart(2, "0"))
206 .join("");
207}
208
209function seedUser(overrides: Partial<FakeRow> = {}): FakeRow {
210 const u: FakeRow = {
211 id: crypto.randomUUID(),
212 username: "ada",
213 email: "ada@example.com",
214 passwordHash: "$2b$10$oldhashplaceholder0123456789012345678901234567890123",
215 updatedAt: new Date(),
216 ...overrides,
217 };
218 store.users.push(u);
219 return u;
220}
221
222// ---------------------------------------------------------------------------
223// generateResetToken
224// ---------------------------------------------------------------------------
225
226describe("generateResetToken", () => {
227 it("emits 64 hex chars of plaintext + a matching sha256 hash", async () => {
228 const { plaintext, hash } = generateResetToken();
229 expect(plaintext).toMatch(/^[0-9a-f]{64}$/);
230 expect(hash).toMatch(/^[0-9a-f]{64}$/);
231 expect(hash).toBe(await sha256Hex(plaintext));
232 });
233
234 it("emits unique plaintexts on repeated calls", () => {
235 const a = generateResetToken();
236 const b = generateResetToken();
237 expect(a.plaintext).not.toBe(b.plaintext);
238 expect(a.hash).not.toBe(b.hash);
239 });
240});
241
242// ---------------------------------------------------------------------------
243// createPasswordResetRequest
244// ---------------------------------------------------------------------------
245
246describe("createPasswordResetRequest", () => {
247 it("creates a token row and queues an email for a known user", async () => {
248 const u = seedUser({ email: "ada@example.com", username: "ada" });
249 const res = await createPasswordResetRequest("ada@example.com", { requestIp: "127.0.0.1" });
250 expect(res.ok).toBe(true);
251
252 await new Promise((r) => setTimeout(r, 5));
253
254 expect(store.password_reset_tokens.length).toBe(1);
255 const row = store.password_reset_tokens[0]!;
256 expect(row.userId).toBe(u.id);
257 expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/);
258 expect(row.expiresAt instanceof Date).toBe(true);
259 expect(row.requestIp).toBe("127.0.0.1");
260
261 expect(_emails.length).toBe(1);
262 expect(_emails[0]!.to).toBe("ada@example.com");
263 expect(_emails[0]!.subject).toBe("Reset your Gluecron password");
264 expect(_emails[0]!.text).toContain("Hi ada,");
265 expect(_emails[0]!.text).toContain("/reset-password?token=");
266 expect(_emails[0]!.text).toContain("utm_source=password_reset");
267 expect(_emails[0]!.html).toContain("Reset password");
268 });
269
270 it("returns ok for unknown emails (no enumeration) and does NOT create a token", async () => {
271 const res = await createPasswordResetRequest("nobody@example.com");
272 expect(res.ok).toBe(true);
273 await new Promise((r) => setTimeout(r, 5));
274 expect(store.password_reset_tokens.length).toBe(0);
275 expect(_emails.length).toBe(0);
276 });
277
278 it("returns ok for garbage inputs without throwing", async () => {
279 expect((await createPasswordResetRequest("")).ok).toBe(true);
280 expect((await createPasswordResetRequest("not-an-email")).ok).toBe(true);
281 expect(store.password_reset_tokens.length).toBe(0);
282 expect(_emails.length).toBe(0);
283 });
284
285 it("normalizes email case before lookup", async () => {
286 seedUser({ email: "ada@example.com", username: "ada" });
287 const res = await createPasswordResetRequest("ADA@Example.COM");
288 expect(res.ok).toBe(true);
289 await new Promise((r) => setTimeout(r, 5));
290 expect(store.password_reset_tokens.length).toBe(1);
291 });
292});
293
294// ---------------------------------------------------------------------------
295// consumeResetToken
296// ---------------------------------------------------------------------------
297
298describe("consumeResetToken", () => {
299 it("rejects garbage tokens with reason=invalid", async () => {
300 const res = await consumeResetToken("not-a-real-token", "newpassword1");
301 expect(res.ok).toBe(false);
302 expect(res.reason).toBe("invalid");
303 });
304
305 it("rejects an empty token", async () => {
306 const res = await consumeResetToken("", "newpassword1");
307 expect(res.ok).toBe(false);
308 });
309
310 it("rejects passwords shorter than 8 chars with reason=weak", async () => {
311 const u = seedUser();
312 const { plaintext, hash } = generateResetToken();
313 store.password_reset_tokens.push({
314 id: crypto.randomUUID(),
315 userId: u.id,
316 tokenHash: hash,
317 expiresAt: new Date(Date.now() + 60_000),
318 usedAt: null,
319 });
320 const res = await consumeResetToken(plaintext, "short");
321 expect(res.ok).toBe(false);
322 expect(res.reason).toBe("weak");
323 });
324
325 it("rejects expired tokens", async () => {
326 const u = seedUser();
327 const { plaintext, hash } = generateResetToken();
328 store.password_reset_tokens.push({
329 id: crypto.randomUUID(),
330 userId: u.id,
331 tokenHash: hash,
332 expiresAt: new Date(Date.now() - 60_000),
333 usedAt: null,
334 });
335 const res = await consumeResetToken(plaintext, "longenoughpassword");
336 expect(res.ok).toBe(false);
337 expect(res.reason).toBe("expired");
338 });
339
340 it("rejects already-used tokens", async () => {
341 const u = seedUser();
342 const { plaintext, hash } = generateResetToken();
343 store.password_reset_tokens.push({
344 id: crypto.randomUUID(),
345 userId: u.id,
346 tokenHash: hash,
347 expiresAt: new Date(Date.now() + 60_000),
348 usedAt: new Date(Date.now() - 1000),
349 });
350 const res = await consumeResetToken(plaintext, "longenoughpassword");
351 expect(res.ok).toBe(false);
352 expect(res.reason).toBe("used");
353 });
354
355 it("happy path: rotates password hash, marks token used, drops sessions", async () => {
356 const u = seedUser({ passwordHash: "OLD-HASH" });
357 store.sessions.push({ id: crypto.randomUUID(), userId: u.id, token: "sess-1", expiresAt: new Date(Date.now() + 86_400_000) });
358 store.sessions.push({ id: crypto.randomUUID(), userId: u.id, token: "sess-2", expiresAt: new Date(Date.now() + 86_400_000) });
359 const other = seedUser({ username: "bob", email: "bob@example.com" });
360 store.sessions.push({ id: crypto.randomUUID(), userId: other.id, token: "sess-other", expiresAt: new Date(Date.now() + 86_400_000) });
361
362 const { plaintext, hash } = generateResetToken();
363 store.password_reset_tokens.push({
364 id: crypto.randomUUID(),
365 userId: u.id,
366 tokenHash: hash,
367 expiresAt: new Date(Date.now() + 60_000),
368 usedAt: null,
369 });
370
371 const res = await consumeResetToken(plaintext, "brandnewpass");
372 expect(res.ok).toBe(true);
373
374 const updatedUser = store.users.find((r) => r.id === u.id)!;
375 expect(updatedUser.passwordHash).not.toBe("OLD-HASH");
376 expect(updatedUser.passwordHash).not.toBe("brandnewpass");
377 expect(updatedUser.passwordHash.length).toBeGreaterThan(20);
378
379 const tokenRow = store.password_reset_tokens[0]!;
380 expect(tokenRow.usedAt instanceof Date).toBe(true);
381
382 expect(store.sessions.filter((s) => s.userId === u.id).length).toBe(0);
383 expect(store.sessions.filter((s) => s.userId === other.id).length).toBe(1);
384 });
385
386 it("a second consume of the same token is rejected as already used", async () => {
387 const u = seedUser();
388 const { plaintext, hash } = generateResetToken();
389 store.password_reset_tokens.push({
390 id: crypto.randomUUID(),
391 userId: u.id,
392 tokenHash: hash,
393 expiresAt: new Date(Date.now() + 60_000),
394 usedAt: null,
395 });
396 const first = await consumeResetToken(plaintext, "newpass-one");
397 expect(first.ok).toBe(true);
398 const second = await consumeResetToken(plaintext, "newpass-two");
399 expect(second.ok).toBe(false);
400 expect(second.reason).toBe("used");
401 });
402});
403
404// ---------------------------------------------------------------------------
405// inspectResetToken
406// ---------------------------------------------------------------------------
407
408describe("inspectResetToken", () => {
409 it("reports valid for an unused, unexpired token", async () => {
410 const u = seedUser();
411 const { plaintext, hash } = generateResetToken();
412 store.password_reset_tokens.push({
413 id: crypto.randomUUID(),
414 userId: u.id,
415 tokenHash: hash,
416 expiresAt: new Date(Date.now() + 60_000),
417 usedAt: null,
418 });
419 const r = await inspectResetToken(plaintext);
420 expect(r.valid).toBe(true);
421 });
422
423 it("reports invalid for an unknown token", async () => {
424 const r = await inspectResetToken("not-a-real-token");
425 expect(r.valid).toBe(false);
426 expect(r.reason).toBe("invalid");
427 });
428
429 it("reports expired for an expired token", async () => {
430 const u = seedUser();
431 const { plaintext, hash } = generateResetToken();
432 store.password_reset_tokens.push({
433 id: crypto.randomUUID(),
434 userId: u.id,
435 tokenHash: hash,
436 expiresAt: new Date(Date.now() - 1000),
437 usedAt: null,
438 });
439 const r = await inspectResetToken(plaintext);
440 expect(r.valid).toBe(false);
441 expect(r.reason).toBe("expired");
442 });
443});
444
445// ---------------------------------------------------------------------------
446// buildResetUrl
447// ---------------------------------------------------------------------------
448
449describe("buildResetUrl", () => {
450 it("includes the token and utm tag", () => {
451 const u = buildResetUrl("abc123");
452 expect(u).toContain("/reset-password?token=abc123");
453 expect(u).toContain("utm_source=password_reset");
454 });
455});
456
457// ---------------------------------------------------------------------------
458// HTTP surface — pull in the app AFTER the module mocks are installed.
459// ---------------------------------------------------------------------------
460
461const app = (await import("../app")).default;
462
463describe("GET /forgot-password", () => {
464 it("renders the email-entry form with a CSRF input", async () => {
465 const res = await app.request("/forgot-password");
466 expect(res.status).toBe(200);
467 const html = await res.text();
468 expect(html).toContain("Reset your password");
469 expect(html).toContain('name="email"');
470 expect(html).toContain('name="_csrf"');
471 expect(html).toContain("Send reset link");
472 });
473
474 it("?sent=1 renders the generic success page", async () => {
475 const res = await app.request("/forgot-password?sent=1");
476 expect(res.status).toBe(200);
477 const html = await res.text();
478 expect(html).toContain("Check your inbox");
479 expect(html).toContain("If we have an account");
480 });
481});
482
483describe("POST /forgot-password", () => {
484 it("always redirects to ?sent=1, regardless of whether the email exists", async () => {
485 seedUser({ email: "ada@example.com" });
486 const res1 = await app.request("/forgot-password", {
487 method: "POST",
488 headers: { "content-type": "application/x-www-form-urlencoded" },
489 body: new URLSearchParams({ email: "ada@example.com" }),
490 });
491 expect(res1.status).toBe(302);
492 expect(res1.headers.get("location")).toContain("/forgot-password?sent=1");
493
494 const res2 = await app.request("/forgot-password", {
495 method: "POST",
496 headers: { "content-type": "application/x-www-form-urlencoded" },
497 body: new URLSearchParams({ email: "ghost@example.com" }),
498 });
499 expect(res2.status).toBe(302);
500 expect(res2.headers.get("location")).toContain("/forgot-password?sent=1");
501 });
502});
503
504describe("GET /reset-password", () => {
505 it("renders the form when the token is valid", async () => {
506 const u = seedUser();
507 const { plaintext, hash } = generateResetToken();
508 store.password_reset_tokens.push({
509 id: crypto.randomUUID(),
510 userId: u.id,
511 tokenHash: hash,
512 expiresAt: new Date(Date.now() + 60_000),
513 usedAt: null,
514 });
515 const res = await app.request(`/reset-password?token=${encodeURIComponent(plaintext)}`);
516 expect(res.status).toBe(200);
517 const html = await res.text();
518 expect(html).toContain("Set a new password");
519 expect(html).toContain('name="password"');
520 expect(html).toContain('name="confirm"');
521 expect(html).toContain('name="token"');
522 });
523
524 it("shows the dead-link page for unknown tokens", async () => {
525 const res = await app.request("/reset-password?token=garbage");
526 expect(res.status).toBe(200);
527 const html = await res.text();
528 expect(html).toContain("This link is no longer valid");
529 expect(html).toContain("/forgot-password");
530 });
531
532 it("shows the dead-link page for expired tokens", async () => {
533 const u = seedUser();
534 const { plaintext, hash } = generateResetToken();
535 store.password_reset_tokens.push({
536 id: crypto.randomUUID(),
537 userId: u.id,
538 tokenHash: hash,
539 expiresAt: new Date(Date.now() - 60_000),
540 usedAt: null,
541 });
542 const res = await app.request(`/reset-password?token=${encodeURIComponent(plaintext)}`);
543 expect(res.status).toBe(200);
544 const html = await res.text();
545 expect(html).toContain("This link is no longer valid");
546 });
547
548 it("shows the dead-link page when no token query is present", async () => {
549 const res = await app.request("/reset-password");
550 expect(res.status).toBe(200);
551 const html = await res.text();
552 expect(html).toContain("This link is no longer valid");
553 });
554});
555
556describe("POST /reset-password", () => {
557 it("happy path: rotates password and redirects to /login?success=…", async () => {
558 const u = seedUser({ passwordHash: "OLD" });
559 const { plaintext, hash } = generateResetToken();
560 store.password_reset_tokens.push({
561 id: crypto.randomUUID(),
562 userId: u.id,
563 tokenHash: hash,
564 expiresAt: new Date(Date.now() + 60_000),
565 usedAt: null,
566 });
567
568 const res = await app.request("/reset-password", {
569 method: "POST",
570 headers: { "content-type": "application/x-www-form-urlencoded" },
571 body: new URLSearchParams({
572 token: plaintext,
573 password: "newpassword12",
574 confirm: "newpassword12",
575 }),
576 });
577 expect(res.status).toBe(302);
578 const loc = res.headers.get("location") || "";
579 expect(loc).toContain("/login");
580 expect(loc).toContain("success=");
581
582 const updated = store.users.find((r) => r.id === u.id)!;
583 expect(updated.passwordHash).not.toBe("OLD");
584 });
585
586 it("password / confirm mismatch redirects back with an error", async () => {
587 const u = seedUser();
588 const { plaintext, hash } = generateResetToken();
589 store.password_reset_tokens.push({
590 id: crypto.randomUUID(),
591 userId: u.id,
592 tokenHash: hash,
593 expiresAt: new Date(Date.now() + 60_000),
594 usedAt: null,
595 });
596 const res = await app.request("/reset-password", {
597 method: "POST",
598 headers: { "content-type": "application/x-www-form-urlencoded" },
599 body: new URLSearchParams({
600 token: plaintext,
601 password: "newpassword12",
602 confirm: "different12345",
603 }),
604 });
605 expect(res.status).toBe(302);
606 const loc = res.headers.get("location") || "";
607 expect(loc).toContain("/reset-password");
608 expect(loc).toContain("error=");
609 });
610
611 it("an invalid/used token renders the dead-link page", async () => {
612 const res = await app.request("/reset-password", {
613 method: "POST",
614 headers: { "content-type": "application/x-www-form-urlencoded" },
615 body: new URLSearchParams({
616 token: "not-a-real-token",
617 password: "newpassword12",
618 confirm: "newpassword12",
619 }),
620 });
621 expect(res.status).toBe(200);
622 const html = await res.text();
623 expect(html).toContain("This link is no longer valid");
624 });
625});
626
627// ---------------------------------------------------------------------------
628// Rate limit — temporarily flip out of "test" env so the in-memory limiter
629// is actually enforced.
630// ---------------------------------------------------------------------------
631
632describe("rate limit on /forgot-password", () => {
633 const _origNodeEnv = process.env.NODE_ENV;
634 const _origBunEnv = process.env.BUN_ENV;
635
636 afterEach(() => {
637 process.env.NODE_ENV = _origNodeEnv;
638 process.env.BUN_ENV = _origBunEnv;
639 });
640
641 it("returns 429 after 5 requests inside the 60s window", async () => {
642 process.env.NODE_ENV = "development";
643 process.env.BUN_ENV = "development";
644
645 const headers = {
646 "content-type": "application/x-www-form-urlencoded",
647 "x-forwarded-for": "203.0.113.55",
648 } as Record<string, string>;
649 const body = () => new URLSearchParams({ email: "ghost@example.com" });
650
651 for (let i = 0; i < 5; i++) {
652 const res = await app.request("/forgot-password", { method: "POST", headers, body: body() });
653 expect(res.status).toBe(302);
654 }
655 const sixth = await app.request("/forgot-password", { method: "POST", headers, body: body() });
656 expect(sixth.status).toBe(429);
657 });
658});
Addedsrc/__tests__/platform-deploys.test.ts+442−0View fileUnifiedSplit
@@ -0,0 +1,442 @@
1/**
2 * Block N3 — Platform deploy timeline tests.
3 *
4 * Exercises:
5 * - POST /api/events/deploy/started (bearer auth + insert + SSE publish)
6 * - POST /api/events/deploy/finished (update + SSE publish)
7 * - Idempotency on run_id
8 * - GET /admin/deploys + GET /admin/deploys/latest.json gating
9 * - relativeTime / shortSha / formatDuration edge formats
10 *
11 * The HTTP tests hit `src/routes/events.ts` directly via its default Hono
12 * sub-app — no main-app mount needed. SSE publication is observed by
13 * subscribing to the broadcaster directly (deterministic in-process).
14 *
15 * DB-backed assertions degrade gracefully: when DATABASE_URL is unset the
16 * insert path returns 500 and we assert {200, 500}-shape contracts. With
17 * a real DB they go strict.
18 *
19 * NO mock.module() calls in this file — we deliberately avoid the
20 * spread-from-real mock pattern's global-bleed footgun. The page-render
21 * tests exercise the helpers directly + app.request() against the gated
22 * routes to confirm 401/403/302 redirects without touching DB rows.
23 */
24
25import {
26 afterAll,
27 afterEach,
28 beforeAll,
29 beforeEach,
30 describe,
31 expect,
32 it,
33} from "bun:test";
34import events, { __test as eventsTest } from "../routes/events";
35import { __test as pageTest } from "../routes/admin-deploys-page";
36import { subscribe, topicSubscriberCount, type SSEEvent } from "../lib/sse";
37import app from "../app";
38
39const TOPIC = "platform:deploys";
40
41const VALID_SHA = "a".repeat(40);
42const RUN_ID_A = "n3-test-run-aaaa-0001";
43const RUN_ID_B = "n3-test-run-bbbb-0002";
44const RUN_ID_C = "n3-test-run-cccc-0003";
45const RUN_ID_D = "n3-test-run-dddd-0004";
46
47const origToken = process.env.DEPLOY_EVENT_TOKEN;
48const HAS_DB = Boolean(process.env.DATABASE_URL);
49
50async function postStarted(
51 body: unknown,
52 headers: Record<string, string> = {}
53): Promise<Response> {
54 return events.request("/deploy/started", {
55 method: "POST",
56 headers: {
57 "Content-Type": "application/json",
58 ...headers,
59 },
60 body: typeof body === "string" ? body : JSON.stringify(body),
61 });
62}
63
64async function postFinished(
65 body: unknown,
66 headers: Record<string, string> = {}
67): Promise<Response> {
68 return events.request("/deploy/finished", {
69 method: "POST",
70 headers: {
71 "Content-Type": "application/json",
72 ...headers,
73 },
74 body: typeof body === "string" ? body : JSON.stringify(body),
75 });
76}
77
78beforeAll(() => {
79 process.env.DEPLOY_EVENT_TOKEN = "n3-bearer-fixture";
80});
81
82afterAll(() => {
83 if (origToken === undefined) delete process.env.DEPLOY_EVENT_TOKEN;
84 else process.env.DEPLOY_EVENT_TOKEN = origToken;
85});
86
87// ---------------------------------------------------------------------------
88// /api/events/deploy/started — auth
89// ---------------------------------------------------------------------------
90
91describe("POST /api/events/deploy/started — bearer auth", () => {
92 it("rejects with 401 when Authorization header is missing", async () => {
93 const res = await postStarted({
94 sha: VALID_SHA,
95 run_id: RUN_ID_A,
96 source: "hetzner-deploy",
97 });
98 expect(res.status).toBe(401);
99 });
100
101 it("rejects with 401 when bearer token is wrong", async () => {
102 const res = await postStarted(
103 { sha: VALID_SHA, run_id: RUN_ID_A, source: "hetzner-deploy" },
104 { authorization: "Bearer not-the-real-token" }
105 );
106 expect(res.status).toBe(401);
107 });
108
109 it("rejects with 401 when DEPLOY_EVENT_TOKEN is unset (refuse-by-default)", async () => {
110 const saved = process.env.DEPLOY_EVENT_TOKEN;
111 delete process.env.DEPLOY_EVENT_TOKEN;
112 try {
113 const res = await postStarted(
114 { sha: VALID_SHA, run_id: RUN_ID_A, source: "hetzner-deploy" },
115 { authorization: "Bearer anything" }
116 );
117 expect(res.status).toBe(401);
118 const body = await res.json();
119 expect(String(body.error).toLowerCase()).toContain("not configured");
120 } finally {
121 if (saved !== undefined) process.env.DEPLOY_EVENT_TOKEN = saved;
122 }
123 });
124});
125
126// ---------------------------------------------------------------------------
127// /api/events/deploy/started — payload validation
128// ---------------------------------------------------------------------------
129
130describe("POST /api/events/deploy/started — payload validation", () => {
131 const authHeader = { authorization: "Bearer n3-bearer-fixture" };
132
133 it("rejects malformed JSON with 400", async () => {
134 const res = await postStarted("{not-json", authHeader);
135 expect(res.status).toBe(400);
136 });
137
138 it("rejects missing run_id with 400", async () => {
139 const res = await postStarted(
140 { sha: VALID_SHA, source: "hetzner-deploy" },
141 authHeader
142 );
143 expect(res.status).toBe(400);
144 });
145
146 it("rejects non-hex sha with 400", async () => {
147 const res = await postStarted(
148 { sha: "xyz!", run_id: RUN_ID_A, source: "hetzner-deploy" },
149 authHeader
150 );
151 expect(res.status).toBe(400);
152 });
153
154 it("accepts a 7-char short sha (CI sometimes ships abbrev)", () => {
155 const result = eventsTest.validateStarted({
156 sha: "a1b2c3d",
157 run_id: RUN_ID_A,
158 source: "hetzner-deploy",
159 });
160 expect(result.ok).toBe(true);
161 });
162
163 it("rejects an overlong run_id with 400", async () => {
164 const res = await postStarted(
165 {
166 sha: VALID_SHA,
167 run_id: "x".repeat(129),
168 source: "hetzner-deploy",
169 },
170 authHeader
171 );
172 expect(res.status).toBe(400);
173 });
174});
175
176// ---------------------------------------------------------------------------
177// /api/events/deploy/finished — payload validation
178// ---------------------------------------------------------------------------
179
180describe("POST /api/events/deploy/finished — payload validation", () => {
181 const authHeader = { authorization: "Bearer n3-bearer-fixture" };
182
183 it("rejects unknown status with 400", async () => {
184 const res = await postFinished(
185 { run_id: RUN_ID_A, status: "weird" },
186 authHeader
187 );
188 expect(res.status).toBe(400);
189 });
190
191 it("rejects negative duration_ms with 400", async () => {
192 const res = await postFinished(
193 { run_id: RUN_ID_A, status: "succeeded", duration_ms: -1 },
194 authHeader
195 );
196 expect(res.status).toBe(400);
197 });
198
199 it("accepts succeeded with no error/duration", () => {
200 const result = eventsTest.validateFinished({
201 run_id: RUN_ID_A,
202 status: "succeeded",
203 });
204 expect(result.ok).toBe(true);
205 });
206
207 it("accepts failed with an error string", () => {
208 const result = eventsTest.validateFinished({
209 run_id: RUN_ID_A,
210 status: "failed",
211 duration_ms: 42_000,
212 error: "smoke test returned 502",
213 });
214 expect(result.ok).toBe(true);
215 });
216
217 it("caps very long error strings to 8 KB on the validator", () => {
218 const result = eventsTest.validateFinished({
219 run_id: RUN_ID_A,
220 status: "failed",
221 error: "x".repeat(20_000),
222 });
223 expect(result.ok).toBe(true);
224 if (result.ok) {
225 expect((result.payload.error || "").length).toBeLessThanOrEqual(
226 8 * 1024
227 );
228 }
229 });
230});
231
232// ---------------------------------------------------------------------------
233// SSE publication — verified in-process via lib/sse. No DB round-trip needed
234// to confirm publish() fires (we always publish on success), but we DO need
235// the DB INSERT to succeed for the started handler to reach the publish
236// branch. Skipped when DATABASE_URL is unset.
237// ---------------------------------------------------------------------------
238
239describe("/api/events/deploy/started — DB-aware insert + publish", () => {
240 const authHeader = { authorization: "Bearer n3-bearer-fixture" };
241
242 // Hold every event the broadcaster delivers for `platform:deploys` while
243 // a test is running. Unsubscribe in afterEach so the suite leaves the
244 // broadcaster's subscriber count at zero — sse.test.ts asserts that
245 // contract.
246 let received: SSEEvent[] = [];
247 let unsubscribe: (() => void) | null = null;
248
249 beforeEach(() => {
250 received = [];
251 unsubscribe = subscribe(TOPIC, (e) => received.push(e));
252 });
253
254 afterEach(() => {
255 if (unsubscribe) unsubscribe();
256 unsubscribe = null;
257 expect(topicSubscriberCount(TOPIC)).toBe(0);
258 });
259
260 it("first delivery: inserts a row + publishes deploy.started (DB only)", async () => {
261 const res = await postStarted(
262 { sha: VALID_SHA, run_id: RUN_ID_B, source: "hetzner-deploy" },
263 authHeader
264 );
265 if (HAS_DB) {
266 expect(res.status).toBe(200);
267 const body = await res.json();
268 expect(body.ok).toBe(true);
269 expect(body.duplicate).toBe(false);
270 // publish() is synchronous so by the time await returns the event
271 // has already been pushed to subscribers.
272 expect(received.length).toBe(1);
273 expect(received[0]?.event).toBe("deploy.started");
274 const data = received[0]?.data as any;
275 expect(data.run_id).toBe(RUN_ID_B);
276 expect(data.sha).toBe(VALID_SHA);
277 expect(data.status).toBe("in_progress");
278 } else {
279 expect([200, 500]).toContain(res.status);
280 }
281 });
282
283 it("idempotency: replaying the same run_id returns duplicate:true and does NOT republish", async () => {
284 const payload = {
285 sha: VALID_SHA,
286 run_id: RUN_ID_C,
287 source: "hetzner-deploy",
288 };
289 const first = await postStarted(payload, authHeader);
290 const before = received.length;
291 const second = await postStarted(payload, authHeader);
292 if (HAS_DB) {
293 expect(first.status).toBe(200);
294 expect(second.status).toBe(200);
295 const secondBody = await second.json();
296 expect(secondBody.duplicate).toBe(true);
297 // No new SSE event from the duplicate.
298 expect(received.length).toBe(before);
299 } else {
300 expect([200, 500]).toContain(first.status);
301 expect([200, 500]).toContain(second.status);
302 }
303 });
304});
305
306describe("/api/events/deploy/finished — DB-aware update + publish", () => {
307 const authHeader = { authorization: "Bearer n3-bearer-fixture" };
308
309 let received: SSEEvent[] = [];
310 let unsubscribe: (() => void) | null = null;
311
312 beforeEach(() => {
313 received = [];
314 unsubscribe = subscribe(TOPIC, (e) => received.push(e));
315 });
316
317 afterEach(() => {
318 if (unsubscribe) unsubscribe();
319 unsubscribe = null;
320 expect(topicSubscriberCount(TOPIC)).toBe(0);
321 });
322
323 it("updates the matching row + publishes deploy.finished (DB only)", async () => {
324 // Seed via /started so the update path has a row to flip.
325 await postStarted(
326 { sha: VALID_SHA, run_id: RUN_ID_D, source: "hetzner-deploy" },
327 authHeader
328 );
329 const before = received.length;
330 const res = await postFinished(
331 {
332 run_id: RUN_ID_D,
333 status: "succeeded",
334 duration_ms: 12_000,
335 },
336 authHeader
337 );
338 if (HAS_DB) {
339 expect(res.status).toBe(200);
340 // After /started we get one event; after /finished we should get a
341 // second on top of it.
342 expect(received.length).toBeGreaterThan(before);
343 const finishedEvent = received[received.length - 1];
344 expect(finishedEvent?.event).toBe("deploy.finished");
345 const data = finishedEvent?.data as any;
346 expect(data.run_id).toBe(RUN_ID_D);
347 expect(data.status).toBe("succeeded");
348 } else {
349 expect([200, 500]).toContain(res.status);
350 }
351 });
352});
353
354// ---------------------------------------------------------------------------
355// /admin/deploys gating — hit the real app router to confirm anonymous
356// + non-admin paths are blocked.
357// ---------------------------------------------------------------------------
358
359describe("/admin/deploys gating", () => {
360 it("redirects anonymous users to /login (HTML page)", async () => {
361 const res = await app.request("/admin/deploys", { redirect: "manual" });
362 // Either 302 to /login (gate redirect) or 403 if the gate flips to
363 // forbidden first. Both are non-200 / non-leak.
364 expect([302, 303, 401, 403]).toContain(res.status);
365 });
366
367 it("returns 401 JSON for anonymous on /admin/deploys/latest.json", async () => {
368 const res = await app.request("/admin/deploys/latest.json");
369 expect([401, 403]).toContain(res.status);
370 const body = await res.json();
371 expect(body.ok).toBe(false);
372 });
373
374 it("renders a page (200 + HTML) for the site admin", async () => {
375 // Without spinning up an authed session this case is hard to verify
376 // end-to-end. We assert the route is at least registered + does not
377 // 404 — the gating branches above prove the auth contract.
378 const res = await app.request("/admin/deploys", { redirect: "manual" });
379 expect(res.status).not.toBe(404);
380 });
381});
382
383// ---------------------------------------------------------------------------
384// Status-pill helper formats — pure functions, no I/O.
385// ---------------------------------------------------------------------------
386
387describe("admin-deploys-page helpers — relativeTime", () => {
388 const { relativeTime, shortSha, formatDuration } = pageTest;
389 const ANCHOR = new Date("2026-05-14T12:00:00.000Z");
390 const ago = (ms: number) => new Date(ANCHOR.getTime() - ms);
391
392 it("'just now' for <5s", () => {
393 expect(relativeTime(ago(0), ANCHOR)).toBe("just now");
394 expect(relativeTime(ago(2_000), ANCHOR)).toBe("just now");
395 expect(relativeTime(ago(4_999), ANCHOR)).toBe("just now");
396 });
397
398 it("'Ns ago' for 5-59 seconds", () => {
399 expect(relativeTime(ago(12_000), ANCHOR)).toBe("12s ago");
400 expect(relativeTime(ago(59_000), ANCHOR)).toBe("59s ago");
401 });
402
403 it("'Nm ago' for 1-59 minutes", () => {
404 expect(relativeTime(ago(60_000), ANCHOR)).toBe("1m ago");
405 expect(relativeTime(ago(3 * 60_000), ANCHOR)).toBe("3m ago");
406 expect(relativeTime(ago(59 * 60_000), ANCHOR)).toBe("59m ago");
407 });
408
409 it("'Nh ago' for 1-23 hours", () => {
410 expect(relativeTime(ago(60 * 60_000), ANCHOR)).toBe("1h ago");
411 expect(relativeTime(ago(2 * 60 * 60_000), ANCHOR)).toBe("2h ago");
412 expect(relativeTime(ago(23 * 60 * 60_000), ANCHOR)).toBe("23h ago");
413 });
414
415 it("'Nd ago' for ≥1 day", () => {
416 expect(relativeTime(ago(24 * 60 * 60_000), ANCHOR)).toBe("1d ago");
417 expect(relativeTime(ago(3 * 24 * 60 * 60_000), ANCHOR)).toBe("3d ago");
418 });
419
420 it("clamps clock skew (future Date) to 'just now'", () => {
421 const future = new Date(ANCHOR.getTime() + 5_000);
422 expect(relativeTime(future, ANCHOR)).toBe("just now");
423 });
424
425 it("shortSha returns 7 lowercased hex chars", () => {
426 expect(shortSha("DEADBEEFCAFE1234")).toBe("deadbee");
427 expect(shortSha("abc")).toBe("abc");
428 expect(shortSha("")).toBe("");
429 });
430
431 it("formatDuration handles seconds + minutes + invalid input", () => {
432 expect(formatDuration(0)).toBe("0s");
433 expect(formatDuration(12_000)).toBe("12s");
434 expect(formatDuration(59_000)).toBe("59s");
435 expect(formatDuration(60_000)).toBe("1m");
436 expect(formatDuration(74_000)).toBe("1m 14s");
437 expect(formatDuration(null)).toBe("—");
438 expect(formatDuration(undefined)).toBe("—");
439 expect(formatDuration(-5)).toBe("—");
440 expect(formatDuration(NaN)).toBe("—");
441 });
442});
Addedsrc/__tests__/playground.test.ts+809−0View fileUnifiedSplit
@@ -0,0 +1,809 @@
1/**
2 * Block Q3 — Anonymous playground tests.
3 *
4 * Strategy mirrors `account-deletion.test.ts`:
5 * - Spread-from-real `../db` mock so other test files keep working;
6 * - In-memory `users` / `sessions` / `repositories` / `issues` /
7 * `labels` / `audit_log` tables;
8 * - Real `audit()` + real `email-verification.startEmailVerification`
9 * run against our stub (both swallow DB errors);
10 * - `initBareRepo` + bare-git plumbing are stubbed at the
11 * `../git/repository` module boundary so a clean test directory
12 * doesn't need git installed (and so the suite is hermetic).
13 *
14 * Bun's `mock.module(...)` is process-global; we `mock.module("../db", _real_db)`
15 * in `afterAll` so downstream files see the real binding again.
16 */
17
18import {
19 describe,
20 it,
21 expect,
22 mock,
23 beforeEach,
24 afterAll,
25} from "bun:test";
26
27// Point the real `initBareRepo` at a hermetic per-process tmp dir so
28// the test doesn't litter `./repos/` with `guest-*` subdirs. Set
29// BEFORE importing `../db` (and downstream `../lib/playground`).
30process.env.GIT_REPOS_PATH = `/tmp/playground-test-${process.pid}-${Date.now()}`;
31
32const _real_db = await import("../db");
33const _real_notify = await import("../lib/notify");
34const _real_email_verification = await import("../lib/email-verification");
35
36// ---------------------------------------------------------------------------
37// Fake DB state
38// ---------------------------------------------------------------------------
39
40interface FakeUser {
41 id: string;
42 username: string;
43 email: string;
44 passwordHash: string;
45 isPlayground: boolean;
46 playgroundExpiresAt: Date | null;
47 emailVerifiedAt: Date | null;
48 [k: string]: any;
49}
50interface FakeSession {
51 id: string;
52 userId: string;
53 token: string;
54 expiresAt: Date;
55}
56interface FakeRepo {
57 id: string;
58 name: string;
59 ownerId: string;
60 description: string | null;
61 isPrivate: boolean;
62 defaultBranch: string;
63 diskPath: string;
64}
65interface FakeIssue {
66 id: string;
67 repositoryId: string;
68 authorId: string;
69 title: string;
70 body: string | null;
71 state: string;
72}
73interface FakeLabel {
74 id: string;
75 repositoryId: string;
76 name: string;
77 color: string;
78 description: string | null;
79}
80interface FakeIssueLabel {
81 id: string;
82 issueId: string;
83 labelId: string;
84}
85
86const _state = {
87 users: [] as FakeUser[],
88 sessions: [] as FakeSession[],
89 repositories: [] as FakeRepo[],
90 issues: [] as FakeIssue[],
91 labels: [] as FakeLabel[],
92 issueLabels: [] as FakeIssueLabel[],
93 auditInserts: [] as any[],
94};
95
96function resetState() {
97 _state.users = [];
98 _state.sessions = [];
99 _state.repositories = [];
100 _state.issues = [];
101 _state.labels = [];
102 _state.issueLabels = [];
103 _state.auditInserts = [];
104}
105
106// The columns we discriminate on for table identity. Test-friendly only.
107function tableName(t: any): string {
108 if (!t || typeof t !== "object") return "?";
109 if ("isPlayground" in t && "passwordHash" in t) return "users";
110 if ("token" in t && "expiresAt" in t && "userId" in t) return "sessions";
111 if ("ownerId" in t && "diskPath" in t) return "repositories";
112 if ("state" in t && "repositoryId" in t && "title" in t) return "issues";
113 if ("color" in t && "repositoryId" in t && "name" in t) return "labels";
114 if ("issueId" in t && "labelId" in t) return "issue_labels";
115 if ("action" in t && "targetType" in t) return "audit_log";
116 return "?";
117}
118
119// Per-query where predicate. Each `select()` resets it; subsequent
120// `where(...)` calls install it; subsequent `limit()` / await consumes
121// it. The predicate is built by interpreting drizzle's `eq(col, val)`
122// expression's exposed `queryChunks` (a public-ish field that holds
123// SQL fragments + column refs + value literals in order). Good enough
124// for the simple where clauses our code emits.
125let _whereFn: ((row: any) => boolean) | null = null;
126function clearWhereFn() { _whereFn = null; }
127
128function predicateFromExpr(expr: any): ((row: any) => boolean) | null {
129 if (!expr || typeof expr !== "object") return null;
130 const chunks: any[] = expr.queryChunks;
131 if (!Array.isArray(chunks)) return null;
132
133 // Detect a "leaf" comparison: chunks = [SQL[""], Column, SQL[op], (value?), SQL[""]]
134 let colChunk: any = null;
135 let opStr = "";
136 let valChunk: any = undefined;
137 for (let i = 0; i < chunks.length; i++) {
138 const c = chunks[i];
139 if (c && typeof c === "object" && "name" in c && c.name && !c.queryChunks) {
140 colChunk = c;
141 // op is the next sql chunk
142 const op = chunks[i + 1];
143 if (op && Array.isArray((op as any).value)) {
144 opStr = String((op as any).value[0] || "").trim().toLowerCase();
145 }
146 // value (for binary ops) is the chunk after the op
147 if (opStr !== "is not null") {
148 valChunk = chunks[i + 2];
149 }
150 break;
151 }
152 }
153
154 if (colChunk) {
155 const camel = String(colChunk.name).replace(/_([a-z])/g, (_, c) => c.toUpperCase());
156 if (opStr === "is not null") {
157 return (row: any) => row[camel] !== null && row[camel] !== undefined;
158 }
159 // Drizzle wraps parameter values in a Param object whose plain
160 // .value field exposes the raw JS value. SQL-chunk objects ALSO
161 // expose .value (an array of SQL fragments) — so unwrap only when
162 // value is not an array.
163 const rawVal =
164 valChunk && typeof valChunk === "object" && "value" in valChunk && !Array.isArray((valChunk as any).value)
165 ? (valChunk as any).value
166 : valChunk;
167 if (opStr === "<") {
168 return (row: any) =>
169 row[camel] !== null && row[camel] !== undefined && row[camel] < rawVal;
170 }
171 if (opStr === ">") {
172 return (row: any) =>
173 row[camel] !== null && row[camel] !== undefined && row[camel] > rawVal;
174 }
175 // Default: equality (handles "=").
176 return (row: any) => {
177 const v = row[camel];
178 if (v instanceof Date && rawVal instanceof Date) {
179 return v.getTime() === rawVal.getTime();
180 }
181 return v === rawVal;
182 };
183 }
184
185 // Compound expression — recurse over nested EXPR chunks and AND them
186 // together. Drizzle's `and()` wraps its operands as EXPR queryChunks
187 // with `" and "` SQL separators; `or()` similarly.
188 const sub: Array<(row: any) => boolean> = [];
189 let connector: "and" | "or" = "and";
190 for (const c of chunks) {
191 if (c && typeof c === "object" && Array.isArray((c as any).value)) {
192 const s = String((c as any).value[0] || "").trim().toLowerCase();
193 if (s === "or") connector = "or";
194 }
195 if (c && typeof c === "object" && Array.isArray(c.queryChunks)) {
196 const fn = predicateFromExpr(c);
197 if (fn) sub.push(fn);
198 }
199 }
200 if (sub.length === 0) return null;
201 if (connector === "or") {
202 return (row: any) => sub.some((p) => p(row));
203 }
204 return (row: any) => sub.every((p) => p(row));
205}
206
207function predicateFromChunks(chunks: any[]): ((row: any) => boolean) | null {
208 return predicateFromExpr({ queryChunks: chunks });
209}
210
211function rowsForTable(table: string): any[] {
212 switch (table) {
213 case "users": return _state.users;
214 case "sessions": return _state.sessions;
215 case "repositories": return _state.repositories;
216 case "issues": return _state.issues;
217 case "labels": return _state.labels;
218 case "issue_labels": return _state.issueLabels;
219 default: return [];
220 }
221}
222
223let _lastFromTable = "?";
224
225function collectSelect(cap?: number): any[] {
226 let rows = rowsForTable(_lastFromTable);
227 if (_whereFn) rows = rows.filter(_whereFn);
228 clearWhereFn();
229 if (cap !== undefined) rows = rows.slice(0, cap);
230 return rows;
231}
232
233function makeSelectChain(): any {
234 const chain: any = {
235 limit: (cap?: number) => Promise.resolve(collectSelect(cap)),
236 offset: () => chain,
237 orderBy: () => chain,
238 then: (resolve: (v: any) => void) => resolve(collectSelect()),
239 };
240 return new Proxy(chain, {
241 get(target, prop) {
242 if (prop in target) return (target as any)[prop];
243 return (...args: any[]) => {
244 if (prop === "from" && args[0]) _lastFromTable = tableName(args[0]);
245 if (prop === "where" && args[0]) {
246 const expr = args[0];
247 if (expr && expr.queryChunks) {
248 const fn = predicateFromChunks(expr.queryChunks);
249 if (fn) _whereFn = fn;
250 }
251 }
252 return proxy;
253 };
254 },
255 });
256}
257const proxy = makeSelectChain();
258
259function makeUpdateChain(table: any) {
260 const name = tableName(table);
261 return {
262 set: (vals: any) => ({
263 where: (expr: any) => {
264 let fn: ((row: any) => boolean) | null = null;
265 if (expr && expr.queryChunks) {
266 fn = predicateFromChunks(expr.queryChunks);
267 }
268 return {
269 returning: () => {
270 const rows = rowsForTable(name);
271 const matched = fn ? rows.filter(fn) : rows;
272 for (const r of matched) Object.assign(r, vals);
273 return Promise.resolve(
274 matched.map((r) => ({
275 id: r.id,
276 username: r.username,
277 email: r.email,
278 }))
279 );
280 },
281 then: (resolve: (v: any) => void) => {
282 const rows = rowsForTable(name);
283 const matched = fn ? rows.filter(fn) : rows;
284 for (const r of matched) Object.assign(r, vals);
285 resolve(undefined);
286 },
287 };
288 },
289 }),
290 };
291}
292
293function makeDeleteChain(table: any) {
294 const name = tableName(table);
295 return {
296 where: (expr: any) => {
297 let fn: ((row: any) => boolean) | null = null;
298 if (expr && expr.queryChunks) {
299 fn = predicateFromChunks(expr.queryChunks);
300 }
301 const apply = () => {
302 const rows = rowsForTable(name);
303 const matched = fn ? rows.filter(fn) : rows;
304 const ids = matched.map((r) => r.id);
305 if (name === "users") {
306 _state.users = _state.users.filter((u) => !ids.includes(u.id));
307 // CASCADE
308 _state.sessions = _state.sessions.filter(
309 (s) => !ids.includes(s.userId)
310 );
311 _state.repositories = _state.repositories.filter(
312 (r) => !ids.includes(r.ownerId)
313 );
314 } else if (name === "sessions") {
315 _state.sessions = _state.sessions.filter(
316 (s) => !ids.includes(s.id)
317 );
318 }
319 return ids;
320 };
321 return {
322 returning: () => {
323 const ids = apply();
324 return Promise.resolve(ids.map((id) => ({ id })));
325 },
326 then: (resolve: (v: any) => void) => {
327 apply();
328 resolve(undefined);
329 },
330 };
331 },
332 };
333}
334
335let _idCounter = 0;
336function nextId(): string {
337 _idCounter += 1;
338 return `id-${_idCounter.toString(16).padStart(12, "0")}-test`;
339}
340
341function makeInsertChain(table: any) {
342 const name = tableName(table);
343 return {
344 values: (vals: any) => {
345 let inserted: any = null;
346 if (name === "audit_log") {
347 _state.auditInserts.push(vals);
348 } else if (name === "users") {
349 const u: FakeUser = {
350 id: vals.id || nextId(),
351 username: vals.username,
352 email: vals.email,
353 passwordHash: vals.passwordHash,
354 isPlayground: !!vals.isPlayground,
355 playgroundExpiresAt: vals.playgroundExpiresAt ?? null,
356 emailVerifiedAt: vals.emailVerifiedAt ?? null,
357 };
358 _state.users.push(u);
359 inserted = { id: u.id, username: u.username, email: u.email };
360 } else if (name === "sessions") {
361 const s: FakeSession = {
362 id: nextId(),
363 userId: vals.userId,
364 token: vals.token,
365 expiresAt: vals.expiresAt,
366 };
367 _state.sessions.push(s);
368 inserted = { id: s.id };
369 } else if (name === "repositories") {
370 const r: FakeRepo = {
371 id: nextId(),
372 name: vals.name,
373 ownerId: vals.ownerId,
374 description: vals.description ?? null,
375 isPrivate: !!vals.isPrivate,
376 defaultBranch: vals.defaultBranch || "main",
377 diskPath: vals.diskPath || "",
378 };
379 _state.repositories.push(r);
380 inserted = { id: r.id };
381 } else if (name === "issues") {
382 const i: FakeIssue = {
383 id: nextId(),
384 repositoryId: vals.repositoryId,
385 authorId: vals.authorId,
386 title: vals.title,
387 body: vals.body ?? null,
388 state: vals.state || "open",
389 };
390 _state.issues.push(i);
391 inserted = { id: i.id };
392 } else if (name === "labels") {
393 const l: FakeLabel = {
394 id: nextId(),
395 repositoryId: vals.repositoryId,
396 name: vals.name,
397 color: vals.color || "#888",
398 description: vals.description ?? null,
399 };
400 _state.labels.push(l);
401 inserted = { id: l.id };
402 } else if (name === "issue_labels") {
403 const il: FakeIssueLabel = {
404 id: nextId(),
405 issueId: vals.issueId,
406 labelId: vals.labelId,
407 };
408 _state.issueLabels.push(il);
409 inserted = { id: il.id };
410 }
411 return {
412 returning: () =>
413 Promise.resolve(inserted ? [inserted] : []),
414 onConflictDoNothing: () =>
415 Promise.resolve(inserted ? [inserted] : []),
416 then: (resolve: (v: any) => void) => resolve(undefined),
417 };
418 },
419 };
420}
421
422const _fakeDb = {
423 db: {
424 select: (_cols?: any) => {
425 _lastFromTable = "?";
426 return makeSelectChain();
427 },
428 insert: (t: any) => makeInsertChain(t),
429 update: (t: any) => makeUpdateChain(t),
430 delete: (t: any) => makeDeleteChain(t),
431 },
432};
433
434// ---------------------------------------------------------------------------
435// Note: we deliberately do NOT mock `../git/repository` or
436// `../lib/repo-bootstrap`. The lib wraps every disk + git subprocess
437// in try/catch, so the real bindings degrade to a logged error when
438// the fake repo dir doesn't exist — and we sidestep mock pollution
439// against git-repository.test.ts. The DB mock still captures repo +
440// label + issue inserts so assertions on `_state.repositories` still
441// hold.
442
443// Track verification-email "sends" via the lib's first-party
444// `__setEmailForTests` seam. The real `startEmailVerification` still
445// runs end-to-end (write token row → render email → call sender);
446// we intercept only the outbound email so we can record the target
447// address without coupling to the email module's internals. We
448// restore the previous sender in `afterAll` so other test files'
449// recorders aren't overwritten.
450let _verificationCalls: Array<{ email: string }> = [];
451const _origSender = _real_email_verification.__setEmailForTests(
452 async (msg: any) => {
453 _verificationCalls.push({ email: String(msg.to) });
454 return { ok: true };
455 }
456);
457
458// ---------------------------------------------------------------------------
459// Mock module wiring
460// ---------------------------------------------------------------------------
461
462function _reinstallMocks() {
463 mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
464 mock.module("../lib/notify", () => _real_notify);
465}
466_reinstallMocks();
467
468afterAll(async () => {
469 resetState();
470 clearWhereFn();
471 _lastFromTable = "?";
472 mock.module("../db", () => _real_db);
473 mock.module("../lib/notify", () => _real_notify);
474 // Restore the prior email sender so other tests' recorders survive.
475 _real_email_verification.__setEmailForTests(_origSender);
476 // Best-effort cleanup of the tmp git-repos dir.
477 try {
478 const { rm } = await import("node:fs/promises");
479 await rm(process.env.GIT_REPOS_PATH!, { recursive: true, force: true });
480 } catch {
481 /* ignore */
482 }
483});
484
485// Dynamic import AFTER mock.module so the lib's bindings resolve to
486// our fakes.
487const _pg = await import("../lib/playground");
488const createPlaygroundAccount = _pg.createPlaygroundAccount;
489const claimPlaygroundAccount = _pg.claimPlaygroundAccount;
490const purgeExpiredPlaygroundAccounts = _pg.purgeExpiredPlaygroundAccounts;
491const isPlaygroundAccount = _pg.isPlaygroundAccount;
492const PLAYGROUND_TTL_MS = _pg.PLAYGROUND_TTL_MS;
493const SANDBOX_REPO_NAME = _pg.SANDBOX_REPO_NAME;
494
495beforeEach(() => {
496 _reinstallMocks();
497 resetState();
498 clearWhereFn();
499 _lastFromTable = "?";
500 _verificationCalls = [];
501});
502
503// ---------------------------------------------------------------------------
504// createPlaygroundAccount
505// ---------------------------------------------------------------------------
506
507describe("createPlaygroundAccount", () => {
508 it("mints a playground user + 24h session + sandbox repo", async () => {
509 const before = Date.now();
510 const result = await createPlaygroundAccount();
511 const after = Date.now();
512
513 expect(result.user.username).toMatch(/^guest-[0-9a-f]{8}$/);
514 expect(result.user.email).toBe(
515 `${result.user.username}@playground.gluecron.local`
516 );
517 expect(result.sessionToken).toMatch(/^[0-9a-f]{64}$/);
518 expect(result.sampleRepoFullName).toBe(
519 `${result.user.username}/${SANDBOX_REPO_NAME}`
520 );
521
522 // User row
523 const u = _state.users.find((r) => r.id === result.user.id);
524 expect(u).toBeDefined();
525 expect(u!.isPlayground).toBe(true);
526 expect(u!.playgroundExpiresAt).toBeInstanceOf(Date);
527 const expMs = u!.playgroundExpiresAt!.getTime();
528 expect(expMs).toBeGreaterThanOrEqual(before + PLAYGROUND_TTL_MS - 1000);
529 expect(expMs).toBeLessThanOrEqual(after + PLAYGROUND_TTL_MS + 1000);
530 expect(u!.emailVerifiedAt).toBeInstanceOf(Date);
531
532 // Session row
533 const s = _state.sessions.find((r) => r.token === result.sessionToken);
534 expect(s).toBeDefined();
535 expect(s!.userId).toBe(result.user.id);
536
537 // Sandbox repo
538 const r = _state.repositories.find((r) => r.ownerId === result.user.id);
539 expect(r).toBeDefined();
540 expect(r!.name).toBe(SANDBOX_REPO_NAME);
541 expect(r!.isPrivate).toBe(false);
542
543 // Sample issues
544 const repoIssues = _state.issues.filter(
545 (i) => i.repositoryId === r!.id
546 );
547 expect(repoIssues.length).toBeGreaterThanOrEqual(2);
548 });
549
550 it("marks email_verified_at so the verify-email banner is suppressed", async () => {
551 const result = await createPlaygroundAccount();
552 const u = _state.users.find((r) => r.id === result.user.id);
553 expect(u!.emailVerifiedAt).not.toBeNull();
554 });
555});
556
557// ---------------------------------------------------------------------------
558// claimPlaygroundAccount
559// ---------------------------------------------------------------------------
560
561describe("claimPlaygroundAccount", () => {
562 async function makePlayground(): Promise<string> {
563 const r = await createPlaygroundAccount();
564 return r.user.id;
565 }
566
567 it("clears playground flags, sets new email + password, fires verification", async () => {
568 const uid = await makePlayground();
569
570 const ok = await claimPlaygroundAccount(uid, {
571 email: "real@example.com",
572 password: "supersecret",
573 });
574
575 expect(ok.ok).toBe(true);
576 const u = _state.users.find((r) => r.id === uid);
577 expect(u!.isPlayground).toBe(false);
578 expect(u!.playgroundExpiresAt).toBeNull();
579 expect(u!.email).toBe("real@example.com");
580 expect(u!.emailVerifiedAt).toBeNull();
581 expect(u!.passwordHash).not.toBe("");
582
583 // Allow the fire-and-forget Promise to schedule. We don't actually
584 // need to await it because the fake just pushes synchronously
585 // inside a Promise body — but flushing the microtask queue lets
586 // the call settle.
587 await Promise.resolve();
588 await Promise.resolve();
589 expect(_verificationCalls.length).toBeGreaterThanOrEqual(1);
590 expect(_verificationCalls[0].email).toBe("real@example.com");
591 });
592
593 it("rejects when email is already taken by another user", async () => {
594 // Seed a real user with the email.
595 _state.users.push({
596 id: "preexisting",
597 username: "alice",
598 email: "taken@example.com",
599 passwordHash: "x",
600 isPlayground: false,
601 playgroundExpiresAt: null,
602 emailVerifiedAt: new Date(),
603 });
604
605 const uid = await makePlayground();
606
607 const result = await claimPlaygroundAccount(uid, {
608 email: "taken@example.com",
609 password: "supersecret",
610 });
611 expect(result.ok).toBe(false);
612 expect(result.reason).toBe("email_taken");
613 });
614
615 it("rejects invalid email + short password", async () => {
616 const uid = await makePlayground();
617
618 const r1 = await claimPlaygroundAccount(uid, {
619 email: "nope",
620 password: "supersecret",
621 });
622 expect(r1.ok).toBe(false);
623 expect(r1.reason).toBe("invalid_email");
624
625 const r2 = await claimPlaygroundAccount(uid, {
626 email: "real@example.com",
627 password: "short",
628 });
629 expect(r2.ok).toBe(false);
630 expect(r2.reason).toBe("password_too_short");
631 });
632
633 it("rejects when invoked on a non-playground user", async () => {
634 _state.users.push({
635 id: "real-user",
636 username: "bob",
637 email: "bob@example.com",
638 passwordHash: "x",
639 isPlayground: false,
640 playgroundExpiresAt: null,
641 emailVerifiedAt: new Date(),
642 });
643 const r = await claimPlaygroundAccount("real-user", {
644 email: "new@example.com",
645 password: "supersecret",
646 });
647 expect(r.ok).toBe(false);
648 expect(r.reason).toBe("not_a_playground_account");
649 });
650});
651
652// ---------------------------------------------------------------------------
653// purgeExpiredPlaygroundAccounts
654// ---------------------------------------------------------------------------
655
656describe("purgeExpiredPlaygroundAccounts", () => {
657 it("hard-deletes expired playground users, leaves unexpired alone", async () => {
658 const past = new Date(Date.now() - 60_000);
659 const future = new Date(Date.now() + 60_000);
660
661 const expiredId = "ex-1";
662 const liveId = "ex-2";
663 _state.users.push(
664 {
665 id: expiredId,
666 username: "guest-deadbeef",
667 email: "guest-deadbeef@playground.gluecron.local",
668 passwordHash: "x",
669 isPlayground: true,
670 playgroundExpiresAt: past,
671 emailVerifiedAt: new Date(),
672 },
673 {
674 id: liveId,
675 username: "guest-cafebabe",
676 email: "guest-cafebabe@playground.gluecron.local",
677 passwordHash: "x",
678 isPlayground: true,
679 playgroundExpiresAt: future,
680 emailVerifiedAt: new Date(),
681 }
682 );
683
684 const result = await purgeExpiredPlaygroundAccounts({ now: new Date() });
685
686 expect(result.errors).toBe(0);
687 expect(_state.users.find((u) => u.id === expiredId)).toBeUndefined();
688 expect(_state.users.find((u) => u.id === liveId)).toBeDefined();
689 expect(result.purged).toBe(1);
690 });
691
692 it("caps deletions at the supplied cap and never throws", async () => {
693 const past = new Date(Date.now() - 60_000);
694 for (let i = 0; i < 5; i++) {
695 _state.users.push({
696 id: `g-${i}`,
697 username: `guest-${i.toString(16).padStart(8, "0")}`,
698 email: `g${i}@playground.gluecron.local`,
699 passwordHash: "x",
700 isPlayground: true,
701 playgroundExpiresAt: past,
702 emailVerifiedAt: new Date(),
703 });
704 }
705 const result = await purgeExpiredPlaygroundAccounts({ cap: 2 });
706 expect(result.purged).toBeLessThanOrEqual(2);
707 expect(result.errors).toBe(0);
708 });
709
710 it("survives DB outage with errors > 0 and never throws", async () => {
711 const orig = _fakeDb.db.select;
712 _fakeDb.db.select = (() => {
713 throw new Error("synthetic outage");
714 }) as any;
715 try {
716 const result = await purgeExpiredPlaygroundAccounts({ now: new Date() });
717 expect(result.purged).toBe(0);
718 expect(result.errors).toBeGreaterThan(0);
719 } finally {
720 _fakeDb.db.select = orig;
721 }
722 });
723});
724
725// ---------------------------------------------------------------------------
726// isPlaygroundAccount (pure helper)
727// ---------------------------------------------------------------------------
728
729describe("isPlaygroundAccount", () => {
730 it("returns true only when isPlayground is exactly true", () => {
731 expect(isPlaygroundAccount({ isPlayground: true })).toBe(true);
732 expect(isPlaygroundAccount({ isPlayground: false })).toBe(false);
733 expect(isPlaygroundAccount({ isPlayground: null })).toBe(false);
734 expect(isPlaygroundAccount({} as any)).toBe(false);
735 });
736});
737
738// ---------------------------------------------------------------------------
739// Route + autopilot wiring assertions (source-string checks).
740// ---------------------------------------------------------------------------
741
742describe("route wiring (source-string assertions)", () => {
743 let playgroundSrc = "";
744 let autopilotSrc = "";
745 let layoutSrc = "";
746 let landingSrc = "";
747 let appSrc = "";
748
749 beforeEach(async () => {
750 if (!playgroundSrc) {
751 const fs = await import("node:fs/promises");
752 playgroundSrc = await fs.readFile(
753 new URL("../routes/playground.tsx", import.meta.url),
754 "utf8"
755 );
756 autopilotSrc = await fs.readFile(
757 new URL("../lib/autopilot.ts", import.meta.url),
758 "utf8"
759 );
760 layoutSrc = await fs.readFile(
761 new URL("../views/layout.tsx", import.meta.url),
762 "utf8"
763 );
764 landingSrc = await fs.readFile(
765 new URL("../views/landing.tsx", import.meta.url),
766 "utf8"
767 );
768 appSrc = await fs.readFile(
769 new URL("../app.tsx", import.meta.url),
770 "utf8"
771 );
772 }
773 });
774
775 it("playground.tsx registers GET /play and POST /play", () => {
776 expect(playgroundSrc).toMatch(/\.get\(\s*["']\/play["']/);
777 expect(playgroundSrc).toMatch(/\.post\(\s*["']\/play["']/);
778 });
779
780 it("playground.tsx registers GET + POST /play/claim and requireAuth", () => {
781 expect(playgroundSrc).toMatch(/\.get\(\s*["']\/play\/claim["']\s*,\s*requireAuth/);
782 expect(playgroundSrc).toMatch(/\.post\(\s*["']\/play\/claim["']\s*,\s*requireAuth/);
783 });
784
785 it("POST /play is rate-limited at 3/min via the shared middleware", () => {
786 expect(playgroundSrc).toContain('rateLimit(3, 60_000');
787 });
788
789 it("autopilot.ts wires the playground-purge task", () => {
790 expect(autopilotSrc).toContain('name: "playground-purge"');
791 expect(autopilotSrc).toContain("purgeExpiredPlaygroundAccounts");
792 });
793
794 it("layout.tsx renders the playground banner gated on user.isPlayground", () => {
795 expect(layoutSrc).toContain("playground-banner");
796 expect(layoutSrc).toContain("isPlayground");
797 expect(layoutSrc).toContain("Save your work");
798 });
799
800 it("landing.tsx adds the tertiary /play CTA", () => {
801 expect(landingSrc).toContain('href="/play"');
802 expect(landingSrc).toContain("without signing up");
803 });
804
805 it("app.tsx mounts the playground routes", () => {
806 expect(appSrc).toContain("playgroundRoutes");
807 expect(appSrc).toContain('"./routes/playground"');
808 });
809});
Addedsrc/__tests__/system-states.test.ts+283−0View fileUnifiedSplit
@@ -0,0 +1,283 @@
1/**
2 * BLOCK O2 — Every system state polished.
3 *
4 * Covers the four polished surfaces:
5 * 1. Error pages (404 / 500 / 403)
6 * 2. Empty state component
7 * 3. Skeleton component
8 * 4. Form validation script
9 *
10 * No mock pollution — each test renders the real component to JSX
11 * string output (or hits the real Hono router) and asserts on the
12 * rendered HTML.
13 */
14
15import { describe, it, expect } from "bun:test";
16import app from "../app";
17import {
18 ErrorPage,
19 NotFoundPage,
20 ServerErrorPage,
21 ForbiddenPage,
22 renderStandaloneErrorPage,
23} from "../views/error-page";
24import { EmptyState } from "../views/empty-state";
25import {
26 Skeleton,
27 SkeletonList,
28 SkeletonRepoRow,
29} from "../views/skeleton";
30import {
31 formValidationScript,
32 formValidationCss,
33 FormValidationAssets,
34} from "../views/form-validation-js";
35
36// Render a JSX element to its HTML string via Hono's JSX runtime.
37async function renderJsx(node: any): Promise<string> {
38 if (node && typeof node === "object" && "toString" in node) {
39 const s = await Promise.resolve(node.toString());
40 return typeof s === "string" ? s : String(s);
41 }
42 return String(node);
43}
44
45describe("BLOCK O2 — Error pages", () => {
46 it("GET unknown deep path returns the polished 404 page via app.notFound", async () => {
47 // A path with 4+ segments doesn't match any defined route — falls
48 // through to app.notFound. (`/:owner` would otherwise swallow a
49 // single-segment 404 with a 200 user-profile shell on DB miss.)
50 const res = await app.request("/__o2_unknown__/a/b/c/d");
51 expect(res.status).toBe(404);
52 const body = await res.text();
53 // Big "404" gradient text
54 expect(body).toContain("data-error-code=\"404\"");
55 expect(body).toContain(">404<");
56 expect(body).toContain("gradient-text");
57 // Two CTAs (primary + secondary)
58 expect(body).toMatch(/data-error-cta="primary"/);
59 expect(body).toMatch(/data-error-cta="secondary"/);
60 expect(body).toContain("Go home");
61 expect(body).toContain("Status page");
62 // Subhead/body
63 expect(body).toContain("Not found");
64 });
65
66 it("ServerErrorPage component renders the 500 + request ID line", async () => {
67 const node = ServerErrorPage({
68 user: null,
69 requestId: "req-test-abc-123",
70 } as any);
71 const body = await renderJsx(node);
72 expect(body).toContain(">500<");
73 expect(body).toContain("Server error");
74 expect(body).toContain("Request ID:");
75 expect(body).toContain("req-test-abc-123");
76 // Two CTAs
77 expect(body).toMatch(/data-error-cta="primary"/);
78 expect(body).toMatch(/data-error-cta="secondary"/);
79 });
80
81 it("ForbiddenPage component renders the polished 403", async () => {
82 const node = ForbiddenPage({ user: null } as any);
83 const body = await renderJsx(node);
84 expect(body).toContain(">403<");
85 expect(body).toContain("Forbidden");
86 expect(body).toContain("Admin access required");
87 // Signed-out path → "Sign in" suggestion
88 expect(body).toContain("Sign in");
89 });
90
91 it("ForbiddenPage when signed in offers 'sign in as different user'", async () => {
92 const fakeUser: any = { id: "u1", username: "alice" };
93 const node = ForbiddenPage({ user: fakeUser } as any);
94 const body = await renderJsx(node);
95 expect(body).toContain("Sign in as a different user");
96 expect(body).toContain("/logout");
97 });
98
99 it("renderStandaloneErrorPage returns a complete <!doctype html> document", () => {
100 const html = renderStandaloneErrorPage({
101 code: "403",
102 eyebrow: "Forbidden",
103 title: "Admin access required.",
104 body: "Body copy.",
105 signedIn: true,
106 });
107 expect(html.toLowerCase()).toContain("<!doctype html>");
108 expect(html).toContain("data-error-code=\"403\"");
109 expect(html).toContain("Admin access required.");
110 expect(html).toContain("Body copy.");
111 // Self-contained — has its own <style> + gradient CSS for the code.
112 expect(html).toContain("background-clip:text");
113 // Signed-in CTA
114 expect(html).toContain("Sign in as a different user");
115 });
116
117 it("ErrorPage component honours a custom Request ID + trace", async () => {
118 const node = ErrorPage({
119 code: "500",
120 eyebrow: "Server error",
121 title: "Boom.",
122 body: "Body.",
123 requestId: "req-xyz",
124 trace: "Error: synthetic\n at test",
125 } as any);
126 const body = await renderJsx(node);
127 expect(body).toContain("req-xyz");
128 expect(body).toContain("error-page-trace");
129 expect(body).toContain("Error: synthetic");
130 });
131});
132
133describe("BLOCK O2 — EmptyState component", () => {
134 it("renders title, body and both CTAs", async () => {
135 const node = EmptyState({
136 icon: "🐛",
137 title: "No issues yet",
138 body: "When you or your team file issues, they'll appear here.",
139 primaryCta: { href: "/new", label: "Open the first issue" },
140 secondaryCta: { href: "/closed", label: "View closed" },
141 } as any);
142 const body = await renderJsx(node);
143 expect(body).toContain("No issues yet");
144 expect(body).toContain("When you or your team file issues");
145 expect(body).toContain("Open the first issue");
146 expect(body).toContain("View closed");
147 expect(body).toContain("/new");
148 expect(body).toContain("/closed");
149 // The default icon SVG isn't used here because we passed an emoji.
150 expect(body).toContain("🐛");
151 });
152
153 it("uses the default gradient SVG when icon is omitted", async () => {
154 const node = EmptyState({
155 title: "Nothing here",
156 body: "Body.",
157 } as any);
158 const body = await renderJsx(node);
159 // SVG gradient block is inlined.
160 expect(body).toContain("linearGradient");
161 expect(body).toContain("#8c6dff");
162 expect(body).toContain("#36c5d6");
163 });
164
165 it("renders without CTAs when none are provided", async () => {
166 const node = EmptyState({
167 title: "Quiet",
168 body: "Nothing to do.",
169 } as any);
170 const body = await renderJsx(node);
171 expect(body).toContain("Quiet");
172 // No <a class="btn"> markers
173 expect(body).not.toContain('class="btn btn-primary"');
174 });
175});
176
177describe("BLOCK O2 — Skeleton component", () => {
178 it("renders the requested number of bars", async () => {
179 const node = Skeleton({ count: 5, height: "14px" } as any);
180 const body = await renderJsx(node);
181 // 5 skeleton bars
182 const matches = body.match(/class="skeleton-bar"/g) || [];
183 expect(matches.length).toBe(5);
184 // height inline
185 expect(body).toContain("height:14px");
186 });
187
188 it("defaults to one bar", async () => {
189 const node = Skeleton({} as any);
190 const body = await renderJsx(node);
191 const matches = body.match(/class="skeleton-bar"/g) || [];
192 expect(matches.length).toBe(1);
193 });
194
195 it("SkeletonRepoRow renders an avatar circle and two text bars", async () => {
196 const node = SkeletonRepoRow({} as any);
197 const body = await renderJsx(node);
198 expect(body).toContain("border-radius:50%");
199 const matches = body.match(/class="skeleton-bar"/g) || [];
200 expect(matches.length).toBe(3); // avatar + 2 text bars
201 });
202
203 it("SkeletonList renders N rep rows with aria-busy", async () => {
204 const node = SkeletonList({ count: 3 } as any);
205 const body = await renderJsx(node);
206 const rows = body.match(/class="skeleton-repo-row"/g) || [];
207 expect(rows.length).toBe(3);
208 expect(body).toContain('aria-busy="true"');
209 expect(body).toContain('role="status"');
210 });
211
212 it("emits the @keyframes skeleton-shimmer animation", async () => {
213 const node = Skeleton({} as any);
214 const body = await renderJsx(node);
215 expect(body).toContain("@keyframes skeleton-shimmer");
216 expect(body).toContain("background-position: 200% 0");
217 });
218});
219
220describe("BLOCK O2 — formValidationScript", () => {
221 it("is well-formed JS (parseable by the engine)", () => {
222 // `new Function` throws SyntaxError on malformed JS.
223 expect(() => {
224 new Function(formValidationScript);
225 }).not.toThrow();
226 });
227
228 it("contains the expected event handlers", () => {
229 expect(formValidationScript).toContain('addEventListener("blur"');
230 expect(formValidationScript).toContain('addEventListener("input"');
231 expect(formValidationScript).toContain("__gluecronFormValidationMounted");
232 // Guards against double-mount.
233 expect(formValidationScript).toContain("if (window.__gluecronFormValidationMounted) return");
234 });
235
236 it("validates required / email / pattern / minlength / maxlength", () => {
237 expect(formValidationScript).toContain('hasAttribute("required")');
238 expect(formValidationScript).toContain('el.type === "email"');
239 expect(formValidationScript).toContain('getAttribute("pattern")');
240 expect(formValidationScript).toContain('getAttribute("minlength")');
241 expect(formValidationScript).toContain('getAttribute("maxlength")');
242 });
243
244 it("respects data-validation-message overrides", () => {
245 expect(formValidationScript).toContain('getAttribute("data-validation-message")');
246 expect(formValidationScript).toContain('getAttribute("data-validation-required")');
247 });
248
249 it("formValidationCss exposes the .input-valid / .input-invalid / .field-error styles", () => {
250 expect(formValidationCss).toContain(".input-invalid");
251 expect(formValidationCss).toContain(".input-valid");
252 expect(formValidationCss).toContain(".field-error");
253 // SVG checkmark for green-state confirmation
254 expect(formValidationCss).toContain("data:image/svg+xml");
255 });
256
257 it("FormValidationAssets emits both style and script tags", async () => {
258 const node = FormValidationAssets({} as any);
259 const body = await renderJsx(node);
260 expect(body).toContain("<style");
261 expect(body).toContain("<script");
262 expect(body).toContain("input-invalid");
263 expect(body).toContain("__gluecronFormValidationMounted");
264 });
265});
266
267describe("BLOCK O2 — NotFoundPage component (unit, no HTTP)", () => {
268 it("renders the 404 number, both CTAs, and the suggestion list", async () => {
269 const node = NotFoundPage({ user: null, method: "GET", path: "/foo" } as any);
270 const body = await renderJsx(node);
271 expect(body).toContain(">404<");
272 expect(body).toContain("Not found");
273 expect(body).toContain("We can't find that page");
274 // Two CTAs
275 expect(body).toMatch(/data-error-cta="primary"/);
276 expect(body).toMatch(/data-error-cta="secondary"/);
277 // Method/path meta
278 expect(body).toContain("GET /foo");
279 // Suggestion list
280 expect(body).toContain("Explore public repositories");
281 expect(body).toContain("Read the quickstart guide");
282 });
283});
Addedsrc/__tests__/systemd-notify.test.ts+143−0View fileUnifiedSplit
@@ -0,0 +1,143 @@
1/**
2 * BLOCK N2 — systemd_notify(READY=1) helper tests.
3 *
4 * Covers the three guarantees the helper makes:
5 * 1. No-op when NOTIFY_SOCKET is unset (dev / non-systemd hosts).
6 * 2. When NOTIFY_SOCKET IS set, attempts to open a datagram socket and
7 * send "READY=1\n" to it.
8 * 3. Never throws — boot path must be safe even if the socket open fails.
9 */
10
11import { describe, expect, it } from "bun:test";
12import { notifySystemdReady } from "../lib/systemd-notify";
13
14describe("notifySystemdReady", () => {
15 it("is a no-op when NOTIFY_SOCKET is unset", async () => {
16 let opened = false;
17 const opener = async () => {
18 opened = true;
19 return {
20 send: () => undefined,
21 close: () => undefined,
22 };
23 };
24 await notifySystemdReady({ openDatagram: opener, env: {} });
25 expect(opened).toBe(false);
26 });
27
28 it("is a no-op when NOTIFY_SOCKET is empty string", async () => {
29 let opened = false;
30 const opener = async () => {
31 opened = true;
32 return {
33 send: () => undefined,
34 close: () => undefined,
35 };
36 };
37 await notifySystemdReady({
38 openDatagram: opener,
39 env: { NOTIFY_SOCKET: "" },
40 });
41 expect(opened).toBe(false);
42 });
43
44 it("opens a datagram socket and sends READY=1 when NOTIFY_SOCKET is set", async () => {
45 const path = "/run/systemd/notify-test";
46 const calls: Array<{
47 kind: string;
48 data?: string;
49 socketPath?: string;
50 }> = [];
51
52 const opener = async (socketPath: string) => {
53 calls.push({ kind: "open", socketPath });
54 return {
55 send(data: string | Uint8Array) {
56 const s = typeof data === "string" ? data : new TextDecoder().decode(data);
57 calls.push({ kind: "send", data: s });
58 },
59 close() {
60 calls.push({ kind: "close" });
61 },
62 };
63 };
64
65 await notifySystemdReady({
66 openDatagram: opener,
67 env: { NOTIFY_SOCKET: path },
68 });
69
70 expect(calls[0]).toEqual({ kind: "open", socketPath: path });
71 const sendCall = calls.find((c) => c.kind === "send");
72 expect(sendCall).toBeDefined();
73 expect(sendCall?.data).toBe("READY=1\n");
74 const closeCall = calls.find((c) => c.kind === "close");
75 expect(closeCall).toBeDefined();
76 });
77
78 it("never throws when the socket open fails", async () => {
79 const opener = async () => {
80 throw new Error("ENOENT: socket path missing");
81 };
82 // Test that this resolves without throwing.
83 await expect(
84 notifySystemdReady({
85 openDatagram: opener,
86 env: { NOTIFY_SOCKET: "/run/systemd/notify" },
87 })
88 ).resolves.toBeUndefined();
89 });
90
91 it("never throws when send() fails", async () => {
92 const opener = async () => ({
93 send() {
94 throw new Error("EPIPE");
95 },
96 close() {
97 /* clean close */
98 },
99 });
100 await expect(
101 notifySystemdReady({
102 openDatagram: opener,
103 env: { NOTIFY_SOCKET: "/run/systemd/notify" },
104 })
105 ).resolves.toBeUndefined();
106 });
107
108 it("never throws when close() fails", async () => {
109 const opener = async () => ({
110 send() {
111 /* ok */
112 },
113 close() {
114 throw new Error("EBADF");
115 },
116 });
117 await expect(
118 notifySystemdReady({
119 openDatagram: opener,
120 env: { NOTIFY_SOCKET: "/run/systemd/notify" },
121 })
122 ).resolves.toBeUndefined();
123 });
124
125 it("defaults to process.env when env is not provided", async () => {
126 const original = process.env.NOTIFY_SOCKET;
127 delete process.env.NOTIFY_SOCKET;
128 try {
129 let opened = false;
130 const opener = async () => {
131 opened = true;
132 return {
133 send: () => undefined,
134 close: () => undefined,
135 };
136 };
137 await notifySystemdReady({ openDatagram: opener });
138 expect(opened).toBe(false);
139 } finally {
140 if (original !== undefined) process.env.NOTIFY_SOCKET = original;
141 }
142 });
143});
Addedsrc/__tests__/visual-coherence.test.tsx+185−0View fileUnifiedSplit
@@ -0,0 +1,185 @@
1/**
2 * Block O3 — visual coherence tests.
3 *
4 * Asserts the design-token + component contract introduced by the O3
5 * pass.
6 *
7 * NOTE: This test runs WITHOUT mock pollution — no `mock.module()`,
8 * no shared global state, no DB. It only hits in-process HTTP routes
9 * via `app.request()` and renders the canonical components directly
10 * to strings via hono/jsx's built-in stringifier.
11 */
12
13import { describe, it, expect } from "bun:test";
14import app from "../app";
15import { Card } from "../views/ui";
16import { Layout } from "../views/layout";
17
18// hono/jsx exposes a stringifier on every node.
19async function renderToString(node: any): Promise<string> {
20 if (node && typeof node.toString === "function") {
21 return String(await node.toString());
22 }
23 return String(node);
24}
25
26describe("O3 — design token aliases in master CSS", () => {
27 it("layout.tsx exposes the --space-* alias scale", async () => {
28 const res = await app.request("/");
29 const body = await res.text();
30 expect(body).toContain("--space-1:");
31 expect(body).toContain("--space-2:");
32 expect(body).toContain("--space-3:");
33 expect(body).toContain("--space-4:");
34 expect(body).toContain("--space-6:");
35 expect(body).toContain("--space-8:");
36 });
37
38 it("layout.tsx exposes the --radius-* alias scale", async () => {
39 const res = await app.request("/");
40 const body = await res.text();
41 expect(body).toContain("--radius-sm:");
42 expect(body).toContain("--radius-md:");
43 expect(body).toContain("--radius-lg:");
44 expect(body).toContain("--radius-full:");
45 });
46
47 it("layout.tsx exposes the --font-size-* alias scale", async () => {
48 const res = await app.request("/");
49 const body = await res.text();
50 expect(body).toContain("--font-size-xs:");
51 expect(body).toContain("--font-size-sm:");
52 expect(body).toContain("--font-size-base:");
53 expect(body).toContain("--font-size-lg:");
54 expect(body).toContain("--font-size-xl:");
55 });
56
57 it("layout.tsx exposes the --leading-* aliases", async () => {
58 const res = await app.request("/");
59 const body = await res.text();
60 expect(body).toContain("--leading-tight:");
61 expect(body).toContain("--leading-normal:");
62 expect(body).toContain("--leading-loose:");
63 });
64
65 it("layout.tsx exposes the --z-* index scale", async () => {
66 const res = await app.request("/");
67 const body = await res.text();
68 expect(body).toContain("--z-nav:");
69 expect(body).toContain("--z-modal:");
70 expect(body).toContain("--z-toast:");
71 });
72
73 it("ships the notice / email-preview / code-block utility classes", async () => {
74 const res = await app.request("/");
75 const body = await res.text();
76 expect(body).toContain(".notice");
77 expect(body).toContain(".notice-warn");
78 expect(body).toContain(".email-preview");
79 expect(body).toContain(".code-block");
80 });
81});
82
83describe("O3 — site-wide footer renders on every page", () => {
84 // /explore queries the DB which is not available in the unit-test
85 // process. Footer presence is well-covered by the other four pages.
86 const PAGES = ["/", "/help", "/status", "/pricing"];
87
88 for (const path of PAGES) {
89 it(`GET ${path} includes the canonical footer`, async () => {
90 const res = await app.request(path);
91 expect(res.status).toBeLessThan(500);
92 const body = await res.text();
93 expect(body).toContain("<footer");
94 expect(body.toLowerCase()).toContain("gluecron");
95 });
96 }
97});
98
99describe("O3 — Card component padding + variant props", () => {
100 it("renders without props (legacy default)", async () => {
101 const html = await renderToString(<Card>hello</Card>);
102 expect(html).toContain('class="card"');
103 expect(html).toContain("hello");
104 });
105
106 it('supports padding="none"', async () => {
107 const html = await renderToString(<Card padding="none">x</Card>);
108 expect(html).toContain("card-p-none");
109 });
110
111 it('supports padding="sm"', async () => {
112 const html = await renderToString(<Card padding="sm">x</Card>);
113 expect(html).toContain("card-p-sm");
114 });
115
116 it('supports padding="md"', async () => {
117 const html = await renderToString(<Card padding="md">x</Card>);
118 expect(html).toContain("card-p-md");
119 });
120
121 it('supports padding="lg"', async () => {
122 const html = await renderToString(<Card padding="lg">x</Card>);
123 expect(html).toContain("card-p-lg");
124 });
125
126 it('supports variant="elevated"', async () => {
127 const html = await renderToString(<Card variant="elevated">x</Card>);
128 expect(html).toContain("card-elevated");
129 });
130
131 it('supports variant="gradient"', async () => {
132 const html = await renderToString(<Card variant="gradient">x</Card>);
133 expect(html).toContain("card-gradient");
134 });
135
136 it("composes padding and variant together", async () => {
137 const html = await renderToString(
138 <Card padding="lg" variant="elevated">x</Card>
139 );
140 expect(html).toContain("card-p-lg");
141 expect(html).toContain("card-elevated");
142 });
143});
144
145describe("O3 — flag-gated footer banner", () => {
146 it("does NOT render the .footer-banner stripe when siteBannerText is empty", async () => {
147 const html = await renderToString(
148 <Layout title="t">
149 <p>body</p>
150 </Layout>
151 );
152 expect(html).not.toContain('class="footer-banner');
153 });
154
155 it("renders the .footer-banner stripe when siteBannerText is non-empty", async () => {
156 const html = await renderToString(
157 <Layout title="t" siteBannerText="Scheduled maintenance tonight">
158 <p>body</p>
159 </Layout>
160 );
161 expect(html).toContain("footer-banner");
162 expect(html).toContain("Scheduled maintenance tonight");
163 });
164
165 it("honours the siteBannerLevel modifier", async () => {
166 const html = await renderToString(
167 <Layout title="t" siteBannerText="x" siteBannerLevel="warn">
168 <p>body</p>
169 </Layout>
170 );
171 expect(html).toContain("footer-banner-warn");
172 });
173});
174
175describe("O3 — no critical page leaks a banner stripe by default", () => {
176 const PAGES = ["/", "/help", "/pricing"];
177
178 for (const path of PAGES) {
179 it(`GET ${path} does not render the footer-banner stripe by default`, async () => {
180 const res = await app.request(path);
181 const body = await res.text();
182 expect(body).not.toContain('class="footer-banner');
183 });
184 }
185});
Modifiedsrc/app.tsx+46−49View fileUnifiedSplit
@@ -2,7 +2,8 @@ import { Hono } from "hono";
22import { logger } from "hono/logger";
33import { cors } from "hono/cors";
44import { compress } from "hono/compress";
5import { Layout } from "./views/layout";
5// BLOCK O2 — shared error-page surface (404 / 500 / 403).
6import { NotFoundPage, ServerErrorPage } from "./views/error-page";
67import { reportError } from "./lib/observability";
78import { requestContext } from "./middleware/request-context";
89import { rateLimit } from "./middleware/rate-limit";
@@ -11,6 +12,9 @@ import apiRoutes from "./routes/api";
1112import apiV2Routes from "./routes/api-v2";
1213import apiDocsRoutes from "./routes/api-docs";
1314import authRoutes from "./routes/auth";
15import passwordResetRoutes from "./routes/password-reset";
16import emailVerificationRoutes from "./routes/email-verification";
17import magicLinkRoutes from "./routes/magic-link";
1418import settingsRoutes from "./routes/settings";
1519import settings2faRoutes from "./routes/settings-2fa";
1620import issueRoutes from "./routes/issues";
@@ -60,6 +64,8 @@ import orgRoutes from "./routes/orgs";
6064import notificationRoutes from "./routes/notifications";
6165import onboardingRoutes from "./routes/onboarding";
6266import adminRoutes from "./routes/admin";
67import adminDeploysRoutes from "./routes/admin-deploys";
68import adminDeploysPageRoutes from "./routes/admin-deploys-page";
6369import advisoriesRoutes from "./routes/advisories";
6470import aiChangelogRoutes from "./routes/ai-changelog";
6571import aiExplainRoutes from "./routes/ai-explain";
@@ -90,6 +96,7 @@ import projectsRoutes from "./routes/projects";
9096import protectedTagsRoutes from "./routes/protected-tags";
9197import pwaRoutes from "./routes/pwa";
9298import installRoutes from "./routes/install";
99import dxtRoutes from "./routes/dxt";
93100import releasesRoutes from "./routes/releases";
94101import requiredChecksRoutes from "./routes/required-checks";
95102import rulesetsRoutes from "./routes/rulesets";
@@ -108,6 +115,7 @@ import workflowArtifactsRoutes from "./routes/workflow-artifacts";
108115import workflowSecretsRoutes from "./routes/workflow-secrets";
109116import sleepModeRoutes from "./routes/sleep-mode";
110117import vsGithubRoutes from "./routes/vs-github";
118import playgroundRoutes from "./routes/playground";
111119import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
112120import { csrfToken, csrfProtect } from "./middleware/csrf";
113121
@@ -149,6 +157,10 @@ app.use("*", async (c, next) => {
149157app.use("/api/*", rateLimit(120, 60_000, "api"));
150158app.use("/login", rateLimit(20, 60_000, "login"));
151159app.use("/register", rateLimit(10, 60_000, "register"));
160// BLOCK P1 — throttle forgot-password to deter enumeration + mail spam.
161app.use("/forgot-password", rateLimit(5, 60_000, "forgot-password"));
162// BLOCK Q2 — throttle magic-link sign-in for the same reason.
163app.use("/login/magic", rateLimit(5, 60_000, "magic-link"));
152164
153165// CSRF protection — set token on all requests, validate on mutations
154166app.use("*", csrfToken);
@@ -190,6 +202,15 @@ app.route("/", apiDocsRoutes);
190202// Auth routes (register, login, logout)
191203app.route("/", authRoutes);
192204
205// BLOCK P1 — Password reset (forgot-password + reset-password)
206app.route("/", passwordResetRoutes);
207
208// BLOCK P2 — Email verification (verify-email + resend)
209app.route("/", emailVerificationRoutes);
210
211// BLOCK Q2 — Magic-link sign-in (/login/magic + callback)
212app.route("/", magicLinkRoutes);
213
193214// Settings routes (profile, SSH keys)
194215app.route("/", settingsRoutes);
195216
@@ -324,6 +345,8 @@ app.route("/", onboardingRoutes);
324345
325346// Admin + feature routes
326347app.route("/", adminRoutes);
348app.route("/", adminDeploysRoutes);
349app.route("/", adminDeploysPageRoutes);
327350app.route("/", advisoriesRoutes);
328351app.route("/", aiChangelogRoutes);
329352app.route("/", aiExplainRoutes);
@@ -354,6 +377,8 @@ app.route("/", projectsRoutes);
354377app.route("/", protectedTagsRoutes);
355378app.route("/", pwaRoutes);
356379app.route("/", installRoutes);
380// BLOCK Q1 — /gluecron.dxt download (Claude Desktop one-click extension)
381app.route("/", dxtRoutes);
357382app.route("/", releasesRoutes);
358383app.route("/", requiredChecksRoutes);
359384app.route("/", rulesetsRoutes);
@@ -373,72 +398,44 @@ app.route("/", workflowSecretsRoutes);
373398app.route("/", sleepModeRoutes);
374399app.route("/", vsGithubRoutes);
375400
401// Block Q3 — Anonymous playground (`/play`, `/play/claim`). Mounted
402// before the web catch-all so the bare `/play` literal wins over the
403// `/:owner` user-profile route.
404app.route("/", playgroundRoutes);
405
376406// Web UI (catch-all, must be last)
377407app.route("/", webRoutes);
378408
379// Global 404
409// Global 404 — BLOCK O2 routes the shared `NotFoundPage` view so
410// the markup stays consistent with /500 and the admin /403 page.
380411app.notFound((c) => {
381412 const user = c.get("user") ?? null;
382413 return c.html(
383 <Layout title="Not Found" user={user}>
384 <div class="error-page">
385 <div class="error-page-code">404</div>
386 <div class="eyebrow">Not found</div>
387 <h1 class="display error-page-title">
388 That page <span class="gradient-text">isn't here.</span>
389 </h1>
390 <p class="error-page-sub">
391 The URL might be wrong, the resource might have moved, or you
392 might not have permission to see it.
393 </p>
394 <div class="error-page-actions">
395 <a href="/" class="btn btn-primary btn-lg">Go home</a>
396 <a href="/explore" class="btn btn-ghost btn-lg">Explore repos</a>
397 </div>
398 <div class="error-page-meta">
399 <span class="meta-mono">{c.req.method} {c.req.path}</span>
400 </div>
401 </div>
402 </Layout>,
414 <NotFoundPage user={user} method={c.req.method} path={c.req.path} />,
403415 404
404416 );
405417});
406418
407// Global error handler
419// Global error handler — BLOCK O2 uses the shared `ServerErrorPage`
420// view. Trace block only shown outside production.
408421app.onError((err, c) => {
409422 reportError(err, {
410423 requestId: c.get("requestId"),
411424 path: c.req.path,
412425 method: c.req.method,
413426 });
414 const requestId = c.get("requestId" as never) as string | undefined;
427 // Prefer the inbound `x-request-id` header (LB-supplied) and fall
428 // back to the context value set by request-context middleware.
429 const requestId =
430 c.req.header("x-request-id") ||
431 ((c.get("requestId" as never) as string | undefined) ?? undefined);
415432 const user = c.get("user") ?? null;
433 const trace =
434 process.env.NODE_ENV !== "production" && err && err.message
435 ? err.message
436 : undefined;
416437 return c.html(
417 <Layout title="Error" user={user}>
418 <div class="error-page">
419 <div class="error-page-code error-page-code-err">500</div>
420 <div class="eyebrow" style="color:var(--red)">Server error</div>
421 <h1 class="display error-page-title">
422 Something <span class="gradient-text">went wrong.</span>
423 </h1>
424 <p class="error-page-sub">
425 The error has been reported. Try again — if it persists, file
426 an issue with the request ID below.
427 </p>
428 <div class="error-page-actions">
429 <a href={c.req.path} class="btn btn-primary btn-lg">Retry</a>
430 <a href="/" class="btn btn-ghost btn-lg">Go home</a>
431 </div>
432 {requestId && (
433 <div class="error-page-meta">
434 <span class="meta-mono">request-id: {requestId}</span>
435 </div>
436 )}
437 {process.env.NODE_ENV !== "production" && (
438 <pre class="error-page-trace">{err.message}</pre>
439 )}
440 </div>
441 </Layout>,
438 <ServerErrorPage user={user} requestId={requestId} trace={trace} />,
442439 500
443440 );
444441});
Addedsrc/db/schema-deploys.ts+45−0View fileUnifiedSplit
@@ -0,0 +1,45 @@
1/**
2 * Block N3 — Drizzle schema for the platform-deploy timeline table.
3 *
4 * Defined in a SEPARATE module because `src/db/schema.ts` is listed in
5 * §4 LOCKED BLOCKS of BUILD_BIBLE.md ("New tables only via new migration").
6 * The matching migration is `drizzle/0046_platform_deploys.sql`. Import
7 * `platformDeploys` directly from this module.
8 *
9 * Columns mirror the SQL migration 1:1 — keep in sync when superseded by a
10 * follow-up additive migration.
11 */
12
13import {
14 index,
15 integer,
16 pgTable,
17 text,
18 timestamp,
19 uuid,
20} from "drizzle-orm/pg-core";
21
22export const platformDeploys = pgTable(
23 "platform_deploys",
24 {
25 id: uuid("id").primaryKey().defaultRandom(),
26 runId: text("run_id").notNull().unique(),
27 sha: text("sha").notNull(),
28 source: text("source").notNull(),
29 // 'in_progress' | 'succeeded' | 'failed'
30 status: text("status").notNull().default("in_progress"),
31 startedAt: timestamp("started_at", { withTimezone: true })
32 .defaultNow()
33 .notNull(),
34 finishedAt: timestamp("finished_at", { withTimezone: true }),
35 durationMs: integer("duration_ms"),
36 error: text("error"),
37 createdAt: timestamp("created_at", { withTimezone: true })
38 .defaultNow()
39 .notNull(),
40 },
41 (table) => [index("idx_platform_deploys_started").on(table.startedAt)]
42);
43
44export type PlatformDeployRow = typeof platformDeploys.$inferSelect;
45export type NewPlatformDeployRow = typeof platformDeploys.$inferInsert;
Modifiedsrc/db/schema.ts+120−0View fileUnifiedSplit
@@ -55,6 +55,26 @@ export const users = pgTable("users", {
5555 .default(true)
5656 .notNull(),
5757 isAdmin: boolean("is_admin").default(false).notNull(),
58 // Block P2 — set when the user clicks the verification link. Soft-gate
59 // only: registration succeeds regardless; /dashboard surfaces a banner
60 // until this is non-null.
61 emailVerifiedAt: timestamp("email_verified_at"),
62 // Block P5 — Account deletion with 30-day grace period.
63 // See drizzle/0049_account_deletion.sql.
64 deletedAt: timestamp("deleted_at"),
65 deletionScheduledFor: timestamp("deletion_scheduled_for"),
66 // Block P3 — Terms of Service / Privacy Policy acceptance audit trail.
67 // Set on /register when the user ticks the accept_terms checkbox.
68 // termsVersion bumps when Terms change; UI prompts re-acceptance later.
69 termsAcceptedAt: timestamp("terms_accepted_at"),
70 termsVersion: text("terms_version"),
71 // Block Q3 — Anonymous playground accounts. When `isPlayground=true`, the
72 // account was minted by /play with a synthetic email + random password
73 // and is hard-deleted by the autopilot `playground-purge` task once
74 // `playgroundExpiresAt` passes. Real accounts always carry false/null.
75 // See drizzle/0052_playground_accounts.sql.
76 isPlayground: boolean("is_playground").default(false).notNull(),
77 playgroundExpiresAt: timestamp("playground_expires_at"),
5878 createdAt: timestamp("created_at").defaultNow().notNull(),
5979 updatedAt: timestamp("updated_at").defaultNow().notNull(),
6080});
@@ -2734,3 +2754,103 @@ export const prRiskScores = pgTable(
27342754export type PrRiskScoreRow = typeof prRiskScores.$inferSelect;
27352755export type NewPrRiskScoreRow = typeof prRiskScores.$inferInsert;
27362756
2757/**
2758 * Block P1 — Password reset tokens.
2759 *
2760 * One row per outstanding reset request. `tokenHash` is a SHA-256 of the
2761 * plaintext token mailed to the user; the plaintext never persists.
2762 * `usedAt` is flipped on consume so a replayed link cannot rotate the
2763 * password twice. `expiresAt` is enforced at consume time (1-hour TTL).
2764 *
2765 * Migration: 0047_password_reset_tokens.sql
2766 */
2767export const passwordResetTokens = pgTable(
2768 "password_reset_tokens",
2769 {
2770 id: uuid("id").primaryKey().defaultRandom(),
2771 userId: uuid("user_id")
2772 .notNull()
2773 .references(() => users.id, { onDelete: "cascade" }),
2774 tokenHash: text("token_hash").notNull().unique(),
2775 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2776 usedAt: timestamp("used_at", { withTimezone: true }),
2777 requestIp: text("request_ip"),
2778 createdAt: timestamp("created_at", { withTimezone: true })
2779 .defaultNow()
2780 .notNull(),
2781 },
2782 (table) => [
2783 index("idx_password_reset_tokens_user").on(table.userId),
2784 index("idx_password_reset_tokens_expires").on(table.expiresAt),
2785 ]
2786);
2787
2788export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
2789export type NewPasswordResetToken = typeof passwordResetTokens.$inferInsert;
2790
2791/**
2792 * Block P2 — email verification tokens.
2793 *
2794 * SHA-256 hashed at rest. `email` captured at issuance so a verification
2795 * link still resolves the right address even after a profile-email change.
2796 * Migration: drizzle/0048_email_verification.sql
2797 */
2798export const emailVerificationTokens = pgTable(
2799 "email_verification_tokens",
2800 {
2801 id: uuid("id").primaryKey().defaultRandom(),
2802 userId: uuid("user_id")
2803 .notNull()
2804 .references(() => users.id, { onDelete: "cascade" }),
2805 email: text("email").notNull(),
2806 tokenHash: text("token_hash").notNull().unique(),
2807 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2808 usedAt: timestamp("used_at", { withTimezone: true }),
2809 createdAt: timestamp("created_at", { withTimezone: true })
2810 .defaultNow()
2811 .notNull(),
2812 },
2813 (table) => [index("idx_email_verify_tokens_user").on(table.userId)]
2814);
2815
2816export type EmailVerificationToken = typeof emailVerificationTokens.$inferSelect;
2817export type NewEmailVerificationToken = typeof emailVerificationTokens.$inferInsert;
2818
2819/**
2820 * Block Q2 — Magic-link sign-in tokens.
2821 *
2822 * Structurally identical to passwordResetTokens / emailVerificationTokens:
2823 * a short plaintext token is mailed to the user, only its sha256 hash is
2824 * persisted, the token is single-use (usedAt) and time-limited (expiresAt).
2825 *
2826 * Difference: `userId` is NULLABLE. When a user enters an email that does
2827 * NOT yet have an account, we still mint a row — consume will create the
2828 * account on click (autoCreate=true), using the click itself as proof the
2829 * recipient owns the address. See `src/lib/magic-link.ts`.
2830 *
2831 * Migration: drizzle/0051_magic_link_tokens.sql
2832 */
2833export const magicLinkTokens = pgTable(
2834 "magic_link_tokens",
2835 {
2836 id: uuid("id").primaryKey().defaultRandom(),
2837 email: text("email").notNull(),
2838 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
2839 tokenHash: text("token_hash").notNull().unique(),
2840 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
2841 usedAt: timestamp("used_at", { withTimezone: true }),
2842 requestIp: text("request_ip"),
2843 createdAt: timestamp("created_at", { withTimezone: true })
2844 .defaultNow()
2845 .notNull(),
2846 },
2847 (table) => [
2848 index("idx_magic_link_tokens_email").on(table.email),
2849 index("idx_magic_link_tokens_user").on(table.userId),
2850 index("idx_magic_link_tokens_expires").on(table.expiresAt),
2851 ]
2852);
2853
2854export type MagicLinkToken = typeof magicLinkTokens.$inferSelect;
2855export type NewMagicLinkToken = typeof magicLinkTokens.$inferInsert;
2856
Modifiedsrc/index.ts+5−0View fileUnifiedSplit
@@ -6,6 +6,7 @@ import { startAutopilot } from "./lib/autopilot";
66import { ensureDemoContent } from "./lib/demo-seed";
77import { ensureDemoActivity } from "./lib/demo-activity-seed";
88import { ensureEnvSiteAdmin } from "./lib/admin-bootstrap";
9import { notifySystemdReady } from "./lib/systemd-notify";
910
1011// Ensure repos directory exists
1112await mkdir(config.gitReposPath, { recursive: true });
@@ -45,6 +46,10 @@ console.log(`
4546 repos: ${config.gitReposPath}
4647`);
4748
49// BLOCK N2 — tell systemd we're ready. No-op when NOTIFY_SOCKET is unset
50// (dev / non-systemd hosts). Fire-and-forget; never throws.
51void notifySystemdReady().catch(() => {});
52
4853export default {
4954 port: config.port,
5055 fetch: app.fetch,
Addedsrc/lib/account-deletion.ts+233−0View fileUnifiedSplit
@@ -0,0 +1,233 @@
1/**
2 * Block P5 — Account deletion with a 30-day grace period.
3 *
4 * The privacy policy (src/routes/legal/privacy.tsx §5) promises:
5 * "Account data: retained while your account is active and for thirty
6 * (30) days after account deletion, after which we intend to purge it."
7 *
8 * Flow:
9 * 1. User clicks "Delete my account" in /settings.
10 * → `scheduleAccountDeletion()` marks `users.deleted_at = now()`,
11 * `users.deletion_scheduled_for = now() + 30 days`, deletes all
12 * sessions, audits `account.deletion.scheduled`, sends a
13 * confirmation email.
14 * 2. During the grace period the user can sign back in. The /login
15 * handler calls `cancelAccountDeletion()` which clears both columns,
16 * audits, and sends a "welcome back" email.
17 * 3. The autopilot `account-purge` task hard-deletes any rows whose
18 * `deletion_scheduled_for` is in the past. Capped at 50 users per
19 * tick. Per-user errors are logged + skipped — never thrown — so a
20 * single FK violation can't stall the queue.
21 *
22 * Nothing here throws. All DB / email failures are logged and swallowed.
23 */
24
25import { eq, lt } from "drizzle-orm";
26import { db } from "../db";
27import { sessions, users } from "../db/schema";
28import { sendEmail, absoluteUrl } from "./email";
29import { audit } from "./notify";
30
31/** Grace period before a scheduled deletion becomes a hard purge. */
32export const GRACE_PERIOD_DAYS = 30;
33/** Default per-tick cap for `purgeScheduledAccounts`. */
34export const DEFAULT_PURGE_CAP = 50;
35
36const MS_PER_DAY = 24 * 60 * 60 * 1000;
37
38export async function scheduleAccountDeletion(
39 userId: string,
40 opts: { now?: Date } = {}
41): Promise<{ ok: boolean; scheduledFor: Date }> {
42 const now = opts.now ?? new Date();
43 const scheduledFor = new Date(now.getTime() + GRACE_PERIOD_DAYS * MS_PER_DAY);
44
45 let user: { id: string; username: string; email: string } | null = null;
46 try {
47 const rows = await db
48 .update(users)
49 .set({
50 deletedAt: now,
51 deletionScheduledFor: scheduledFor,
52 updatedAt: now,
53 })
54 .where(eq(users.id, userId))
55 .returning({
56 id: users.id,
57 username: users.username,
58 email: users.email,
59 });
60 user = rows[0] ?? null;
61 } catch (err) {
62 console.error("[account-deletion] schedule update failed:", err);
63 return { ok: false, scheduledFor };
64 }
65
66 if (!user) return { ok: false, scheduledFor };
67
68 try {
69 await db.delete(sessions).where(eq(sessions.userId, userId));
70 } catch (err) {
71 console.error("[account-deletion] session purge failed:", err);
72 }
73
74 await audit({
75 userId,
76 action: "account.deletion.scheduled",
77 targetType: "user",
78 targetId: userId,
79 metadata: { scheduledFor: scheduledFor.toISOString() },
80 });
81
82 const tpl = renderScheduledEmail({ username: user.username, scheduledFor });
83 await sendEmail({ to: user.email, subject: tpl.subject, text: tpl.text });
84
85 return { ok: true, scheduledFor };
86}
87
88export async function cancelAccountDeletion(
89 userId: string
90): Promise<{ ok: boolean }> {
91 let user: { id: string; username: string; email: string } | null = null;
92 try {
93 const rows = await db
94 .update(users)
95 .set({
96 deletedAt: null,
97 deletionScheduledFor: null,
98 updatedAt: new Date(),
99 })
100 .where(eq(users.id, userId))
101 .returning({
102 id: users.id,
103 username: users.username,
104 email: users.email,
105 });
106 user = rows[0] ?? null;
107 } catch (err) {
108 console.error("[account-deletion] cancel update failed:", err);
109 return { ok: false };
110 }
111
112 if (!user) return { ok: false };
113
114 await audit({
115 userId,
116 action: "account.deletion.cancelled",
117 targetType: "user",
118 targetId: userId,
119 });
120
121 const tpl = renderRestoredEmail({ username: user.username });
122 await sendEmail({ to: user.email, subject: tpl.subject, text: tpl.text });
123
124 return { ok: true };
125}
126
127export async function purgeScheduledAccounts(
128 opts: { now?: Date; cap?: number } = {}
129): Promise<{ purged: number; errors: number }> {
130 const now = opts.now ?? new Date();
131 const cap = Math.max(1, opts.cap ?? DEFAULT_PURGE_CAP);
132
133 let candidates: Array<{ id: string; username: string; email: string }> = [];
134 try {
135 candidates = await db
136 .select({
137 id: users.id,
138 username: users.username,
139 email: users.email,
140 })
141 .from(users)
142 .where(lt(users.deletionScheduledFor, now))
143 .limit(cap);
144 } catch (err) {
145 console.error("[account-deletion] purge candidate query failed:", err);
146 return { purged: 0, errors: 1 };
147 }
148
149 let purged = 0;
150 let errors = 0;
151 for (const c of candidates) {
152 try {
153 const deleted = await db
154 .delete(users)
155 .where(eq(users.id, c.id))
156 .returning({ id: users.id });
157 if (deleted.length > 0) {
158 purged += 1;
159 await audit({
160 userId: null,
161 action: "account.purged",
162 targetType: "user",
163 targetId: c.id,
164 metadata: { username: c.username },
165 });
166 }
167 } catch (err) {
168 errors += 1;
169 console.error(
170 `[account-deletion] purge failed for user=${c.id} (${c.username}):`,
171 err
172 );
173 }
174 }
175
176 return { purged, errors };
177}
178
179export function daysUntilPurge(
180 user: { deletionScheduledFor: Date | null },
181 now: Date = new Date()
182): number | null {
183 if (!user.deletionScheduledFor) return null;
184 const ms = user.deletionScheduledFor.getTime() - now.getTime();
185 if (ms <= 0) return 0;
186 return Math.ceil(ms / MS_PER_DAY);
187}
188
189export function renderScheduledEmail(input: {
190 username: string;
191 scheduledFor: Date;
192}): { subject: string; text: string } {
193 const when = input.scheduledFor.toUTCString();
194 const subject = "Your Gluecron account is scheduled for deletion";
195 const text = [
196 `Hi ${input.username},`,
197 "",
198 `Your Gluecron account will be permanently deleted on ${when}.`,
199 "",
200 "All of your repos, issues, PRs, and settings will be purged after that",
201 "date. If you change your mind, just sign in any time before then and we",
202 "will cancel the deletion automatically.",
203 "",
204 `Cancel deletion: ${absoluteUrl("/login")}`,
205 "",
206 "— gluecron",
207 ].join("\n");
208 return { subject, text };
209}
210
211export function renderRestoredEmail(input: { username: string }): {
212 subject: string;
213 text: string;
214} {
215 const subject = "Welcome back — your Gluecron account has been restored";
216 const text = [
217 `${input.username},`,
218 "",
219 "Your account is no longer scheduled for deletion. Everything's right",
220 "where you left it.",
221 "",
222 `Visit your dashboard: ${absoluteUrl("/dashboard")}`,
223 "",
224 "— gluecron",
225 ].join("\n");
226 return { subject, text };
227}
228
229export const __test = {
230 GRACE_PERIOD_DAYS,
231 DEFAULT_PURGE_CAP,
232 MS_PER_DAY,
233};
Modifiedsrc/lib/autopilot.ts+33−0View fileUnifiedSplit
@@ -46,6 +46,8 @@ import {
4646} from "./stale-sweep";
4747import { computePrRiskForPullRequest } from "./pr-risk";
4848import { prRiskScores } from "../db/schema";
49import { purgeScheduledAccounts } from "./account-deletion";
50import { purgeExpiredPlaygroundAccounts } from "./playground";
4951
5052export interface AutopilotTaskResult {
5153 name: string;
@@ -194,6 +196,37 @@ export function defaultTasks(): AutopilotTask[] {
194196 );
195197 },
196198 },
199
200 {
201 // Block P5 — Hard-delete users whose 30-day grace period expired.
202 name: "account-purge",
203 run: async () => {
204 try {
205 const summary = await purgeScheduledAccounts({ cap: 50 });
206 console.log(
207 `[autopilot] account-purge: purged=${summary.purged} errors=${summary.errors}`
208 );
209 } catch (err) {
210 console.error("[autopilot] account-purge: threw:", err);
211 }
212 },
213 },
214 {
215 // Block Q3 — Hard-delete anonymous playground accounts past their
216 // 24h TTL. CASCADE handles repos, sessions, issues. Per-user
217 // try/catch in the lib so one FK violation can't stall the queue.
218 name: "playground-purge",
219 run: async () => {
220 try {
221 const summary = await purgeExpiredPlaygroundAccounts({ cap: 50 });
222 console.log(
223 `[autopilot] playground-purge: purged=${summary.purged} errors=${summary.errors}`
224 );
225 } catch (err) {
226 console.error("[autopilot] playground-purge: threw:", err);
227 }
228 },
229 },
197230 ];
198231}
199232
Addedsrc/lib/email-verification.ts+323−0View fileUnifiedSplit
@@ -0,0 +1,323 @@
1/**
2 * Block P2 — email verification + welcome email.
3 *
4 * Responsibilities:
5 * - Issue verification tokens (plaintext 32-byte hex, sha256-hashed at rest,
6 * 24-hour expiry).
7 * - Consume tokens, marking `users.email_verified_at`.
8 * - Fire the welcome email AFTER a successful verification.
9 *
10 * Contract: every exported function never throws. Email failures degrade
11 * silently — the caller's primary code path (registration, etc.) must not
12 * be coupled to email-provider liveness.
13 *
14 * Test seam: a `__setEmailForTests` setter lets unit tests inject a recorder
15 * in place of the live `sendEmail` import. Tests must restore the previous
16 * sender in `afterAll` to keep the module graph clean.
17 */
18
19import { randomBytes, createHash } from "node:crypto";
20import { and, eq, gt, isNull } from "drizzle-orm";
21import { db } from "../db";
22import { users, emailVerificationTokens } from "../db/schema";
23import {
24 sendEmail as realSendEmail,
25 absoluteUrl,
26 type EmailMessage,
27 type EmailResult,
28} from "./email";
29
30// ---------------------------------------------------------------------------
31// Test seam — swap the email sender out without touching the module graph.
32// ---------------------------------------------------------------------------
33
34type EmailSender = (msg: EmailMessage) => Promise<EmailResult>;
35let _sender: EmailSender = realSendEmail;
36
37/**
38 * Swap the email sender for the duration of a test. Returns the previous
39 * sender so the test can restore it in `afterAll`. Never call from prod.
40 */
41export function __setEmailForTests(fn: EmailSender): EmailSender {
42 const prev = _sender;
43 _sender = fn;
44 return prev;
45}
46
47// ---------------------------------------------------------------------------
48// Token generation
49// ---------------------------------------------------------------------------
50
51/** Token validity window. Tunable here; the migration enforces nothing. */
52export const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
53
54/**
55 * Produce a fresh plaintext token + its sha256 hash. Only the hash is
56 * persisted; the plaintext is delivered exactly once via email.
57 */
58export function generateVerificationToken(): {
59 plaintext: string;
60 hash: string;
61} {
62 const plaintext = randomBytes(32).toString("hex");
63 const hash = hashToken(plaintext);
64 return { plaintext, hash };
65}
66
67/** Stable hash for lookups. Public so tests can compute the same value. */
68export function hashToken(plaintext: string): string {
69 return createHash("sha256").update(plaintext).digest("hex");
70}
71
72// ---------------------------------------------------------------------------
73// startEmailVerification
74// ---------------------------------------------------------------------------
75
76/**
77 * Issue a fresh token for `userId` + `email` and send the verification email.
78 * Fire-and-forget safe: never throws, returns `{ok}` so callers can audit
79 * failures if they wish.
80 */
81export async function startEmailVerification(
82 userId: string,
83 email: string
84): Promise<{ ok: boolean }> {
85 try {
86 const { plaintext, hash } = generateVerificationToken();
87 const expiresAt = new Date(Date.now() + TOKEN_TTL_MS);
88 await db.insert(emailVerificationTokens).values({
89 userId,
90 email,
91 tokenHash: hash,
92 expiresAt,
93 });
94
95 let username = "there";
96 try {
97 const [u] = await db
98 .select({ username: users.username })
99 .from(users)
100 .where(eq(users.id, userId))
101 .limit(1);
102 if (u?.username) username = u.username;
103 } catch {
104 // username lookup is cosmetic.
105 }
106
107 const link = absoluteUrl(`/verify-email?token=${encodeURIComponent(plaintext)}`);
108 const { subject, text, html } = renderVerificationEmail({ username, link });
109 const result = await _sender({ to: email, subject, text, html });
110 return { ok: result.ok };
111 } catch (err) {
112 console.error("[email-verification] startEmailVerification:", err);
113 return { ok: false };
114 }
115}
116
117// ---------------------------------------------------------------------------
118// consumeVerificationToken
119// ---------------------------------------------------------------------------
120
121export async function consumeVerificationToken(
122 token: string
123): Promise<{ ok: boolean; userId?: string; email?: string }> {
124 if (!token || typeof token !== "string") return { ok: false };
125 try {
126 const hash = hashToken(token);
127 const now = new Date();
128 const [row] = await db
129 .select()
130 .from(emailVerificationTokens)
131 .where(
132 and(
133 eq(emailVerificationTokens.tokenHash, hash),
134 isNull(emailVerificationTokens.usedAt),
135 gt(emailVerificationTokens.expiresAt, now)
136 )
137 )
138 .limit(1);
139 if (!row) return { ok: false };
140
141 await db
142 .update(emailVerificationTokens)
143 .set({ usedAt: now })
144 .where(eq(emailVerificationTokens.id, row.id));
145
146 await db
147 .update(users)
148 .set({ emailVerifiedAt: now })
149 .where(eq(users.id, row.userId));
150
151 return { ok: true, userId: row.userId, email: row.email };
152 } catch (err) {
153 console.error("[email-verification] consumeVerificationToken:", err);
154 return { ok: false };
155 }
156}
157
158// ---------------------------------------------------------------------------
159// Welcome email — sent AFTER successful verification.
160// ---------------------------------------------------------------------------
161
162export async function sendWelcomeEmail(userId: string): Promise<void> {
163 try {
164 const [u] = await db
165 .select({ username: users.username, email: users.email })
166 .from(users)
167 .where(eq(users.id, userId))
168 .limit(1);
169 if (!u || !u.email) return;
170 const { subject, text, html } = renderWelcomeEmail({ username: u.username });
171 await _sender({ to: u.email, subject, text, html });
172 } catch (err) {
173 console.error("[email-verification] sendWelcomeEmail:", err);
174 }
175}
176
177// ---------------------------------------------------------------------------
178// Email templates
179// ---------------------------------------------------------------------------
180
181function escapeHtml(s: string): string {
182 return s
183 .replace(/&/g, "&")
184 .replace(/</g, "<")
185 .replace(/>/g, ">")
186 .replace(/"/g, """)
187 .replace(/'/g, "'");
188}
189
190export function renderVerificationEmail(opts: {
191 username: string;
192 link: string;
193}): { subject: string; text: string; html: string } {
194 const u = opts.username;
195 const link = opts.link;
196 const subject = "Confirm your email for Gluecron";
197
198 const text = [
199 `Hi ${u},`,
200 "",
201 "Thanks for signing up for Gluecron — the git host built around Claude.",
202 "",
203 `Confirm your email: ${link}`,
204 "",
205 "This link expires in 24 hours. If you didn't sign up, ignore this email.",
206 ].join("\n");
207
208 const html = renderHtmlShell({
209 title: "Confirm your email",
210 heroSubtitle: "Welcome to Gluecron",
211 heroLine: `Hi <strong>${escapeHtml(u)}</strong>, thanks for signing up.`,
212 body: `
213 <p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#c9d1d9">
214 Gluecron is the git host built around Claude. Confirm your email
215 address to finish setting up your account.
216 </p>
217 <p style="margin:0 0 24px;text-align:center">
218 <a href="${escapeHtml(link)}"
219 style="display:inline-block;padding:12px 24px;background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:14px">
220 Confirm email
221 </a>
222 </p>
223 <p style="margin:0;font-size:12px;color:#8b949e;line-height:1.55">
224 Or paste this link into your browser:<br />
225 <span style="word-break:break-all">${escapeHtml(link)}</span>
226 </p>
227 <p style="margin:24px 0 0;font-size:12px;color:#8b949e">
228 This link expires in 24 hours. If you didn't sign up for Gluecron,
229 you can safely ignore this email.
230 </p>
231 `,
232 });
233
234 return { subject, text, html };
235}
236
237export function renderWelcomeEmail(opts: { username: string }): {
238 subject: string;
239 text: string;
240 html: string;
241} {
242 const u = opts.username;
243 const subject = "Welcome to Gluecron \u{1F389}";
244
245 const newRepo = absoluteUrl("/new");
246 const importUrl = absoluteUrl("/import");
247 const demoUrl = absoluteUrl("/demo");
248 const installUrl = absoluteUrl("/install");
249 const onboarding = absoluteUrl("/onboarding");
250 const docs = absoluteUrl("/docs");
251 const help = absoluteUrl("/help");
252
253 const text = [
254 `Welcome aboard, ${u}!`,
255 "",
256 "Your email is verified. Here's what to try first:",
257 "",
258 `• Create your first repo — ${newRepo}`,
259 `• Import from GitHub — ${importUrl}`,
260 `• Watch Claude work — ${demoUrl}`,
261 `• Install Claude Desktop integration — ${installUrl}`,
262 "",
263 `Next steps: ${onboarding}`,
264 `Docs: ${docs}`,
265 `Need help? Reply to this email or visit ${help}.`,
266 ].join("\n");
267
268 const html = renderHtmlShell({
269 title: "Welcome to Gluecron",
270 heroSubtitle: "You're in",
271 heroLine: `Welcome aboard, <strong>${escapeHtml(u)}</strong>.`,
272 body: `
273 <p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#c9d1d9">
274 Your email is verified. Here's what to try first:
275 </p>
276 <ul style="margin:0 0 24px;padding-left:18px;font-size:14px;line-height:1.7;color:#c9d1d9">
277 <li><strong>Create your first repo</strong> —
278 <a style="color:#79c0ff" href="${escapeHtml(newRepo)}">gluecron.com/new</a></li>
279 <li><strong>Import from GitHub</strong> —
280 <a style="color:#79c0ff" href="${escapeHtml(importUrl)}">gluecron.com/import</a></li>
281 <li><strong>Watch Claude work</strong> —
282 <a style="color:#79c0ff" href="${escapeHtml(demoUrl)}">gluecron.com/demo</a></li>
283 <li><strong>Install Claude Desktop integration</strong> —
284 <a style="color:#79c0ff" href="${escapeHtml(installUrl)}">gluecron.com/install</a></li>
285 </ul>
286 <p style="margin:0 0 8px;font-size:13px;color:#8b949e">
287 New here? Start with the
288 <a style="color:#79c0ff" href="${escapeHtml(onboarding)}">onboarding tour</a>
289 or skim the <a style="color:#79c0ff" href="${escapeHtml(docs)}">docs</a>.
290 </p>
291 <p style="margin:0;font-size:13px;color:#8b949e">
292 Need help? Reply to this email or visit
293 <a style="color:#79c0ff" href="${escapeHtml(help)}">gluecron.com/help</a>.
294 </p>
295 `,
296 });
297
298 return { subject, text, html };
299}
300
301function renderHtmlShell(opts: {
302 title: string;
303 heroSubtitle: string;
304 heroLine: string;
305 body: string;
306}): string {
307 return [
308 `<!doctype html><html><head><meta charset="utf-8" /><title>${escapeHtml(opts.title)}</title></head>`,
309 `<body style="margin:0;padding:24px;background:#0d1117;font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#c9d1d9">`,
310 `<div style="max-width:560px;margin:0 auto;background:#161b22;border:1px solid #30363d;border-radius:12px;overflow:hidden">`,
311 `<div style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;padding:24px">`,
312 `<div style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase;opacity:0.85">${escapeHtml(opts.heroSubtitle)}</div>`,
313 `<h1 style="margin:8px 0 0;font-size:22px;font-weight:600">${opts.heroLine}</h1>`,
314 `</div>`,
315 `<div style="padding:24px">`,
316 opts.body,
317 `</div>`,
318 `<div style="padding:12px 24px;border-top:1px solid #30363d;background:#0d1117;color:#6e7681;font-size:11px;text-align:center">`,
319 `Gluecron — the git host built around Claude.`,
320 `</div>`,
321 `</div></body></html>`,
322 ].join("\n");
323}
Modifiedsrc/lib/follows.ts+14−40View fileUnifiedSplit
@@ -77,53 +77,27 @@ export async function isFollowing(
7777// ----------------------------------------------------------------------------
7878
7979export async function listFollowers(userId: string): Promise<User[]> {
80 // Use `db.select().from(users)` (no projection) so the returned type
81 // tracks the live `users` schema. L1 (Sleep Mode) + M2 (push prefs)
82 // added 6 new columns; enumerating column-by-column made this list
83 // drift out of sync.
8084 return db
81 .select({
82 id: users.id,
83 username: users.username,
84 email: users.email,
85 displayName: users.displayName,
86 passwordHash: users.passwordHash,
87 avatarUrl: users.avatarUrl,
88 bio: users.bio,
89 notifyEmailOnMention: users.notifyEmailOnMention,
90 notifyEmailOnAssign: users.notifyEmailOnAssign,
91 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
92 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
93 lastDigestSentAt: users.lastDigestSentAt,
94 isAdmin: users.isAdmin,
95 createdAt: users.createdAt,
96 updatedAt: users.updatedAt,
97 })
98 .from(userFollows)
99 .innerJoin(users, eq(userFollows.followerId, users.id))
85 .select()
86 .from(users)
87 .innerJoin(userFollows, eq(userFollows.followerId, users.id))
10088 .where(eq(userFollows.followingId, userId))
101 .orderBy(desc(userFollows.createdAt));
89 .orderBy(desc(userFollows.createdAt))
90 .then((rows) => rows.map((r) => r.users));
10291}
10392
10493export async function listFollowing(userId: string): Promise<User[]> {
10594 return db
106 .select({
107 id: users.id,
108 username: users.username,
109 email: users.email,
110 displayName: users.displayName,
111 passwordHash: users.passwordHash,
112 avatarUrl: users.avatarUrl,
113 bio: users.bio,
114 notifyEmailOnMention: users.notifyEmailOnMention,
115 notifyEmailOnAssign: users.notifyEmailOnAssign,
116 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
117 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
118 lastDigestSentAt: users.lastDigestSentAt,
119 isAdmin: users.isAdmin,
120 createdAt: users.createdAt,
121 updatedAt: users.updatedAt,
122 })
123 .from(userFollows)
124 .innerJoin(users, eq(userFollows.followingId, users.id))
95 .select()
96 .from(users)
97 .innerJoin(userFollows, eq(userFollows.followingId, users.id))
12598 .where(eq(userFollows.followerId, userId))
126 .orderBy(desc(userFollows.createdAt));
99 .orderBy(desc(userFollows.createdAt))
100 .then((rows) => rows.map((r) => r.users));
127101}
128102
129103export async function followCounts(userId: string): Promise<{
Addedsrc/lib/magic-link.ts+331−0View fileUnifiedSplit
@@ -0,0 +1,331 @@
1/**
2 * Block Q2 — Magic-link sign-in.
3 *
4 * Public surface:
5 * - generateMagicLinkToken()
6 * - startMagicLinkSignIn() → always { ok: true } (no enumeration)
7 * - consumeMagicLinkToken()
8 *
9 * Structurally identical to P1 (`password-reset.ts`) and P2
10 * (`email-verification.ts`): short random token, sha256-hashed at rest,
11 * single-use, time-limited. The only meaningful differences are:
12 *
13 * - 15-minute TTL (vs P1's 1h reset) — magic-link is a session-issuer,
14 * not a one-shot password rotation, so the blast radius of a stolen
15 * link is higher and we want the window tight.
16 * - `user_id` is nullable. When a not-yet-registered email is entered,
17 * we still mint a token row; consume creates the account on click
18 * (autoCreate=true). The click itself is proof the recipient owns
19 * the address — same trust model as a verification link.
20 *
21 * Security:
22 * - Plaintext token NEVER persists; we store only SHA-256(token).
23 * - startMagicLinkSignIn never reveals whether the email exists.
24 * - consumeMagicLinkToken invalidates every other unused magic-link
25 * for the same email on success (prevents multi-link abuse).
26 * - Per-email rate limit: at most 3 token mints per hour. The HTTP
27 * surface adds a per-IP rate limit on top of that.
28 */
29
30import { eq } from "drizzle-orm";
31import { db } from "../db";
32import { users, magicLinkTokens } from "../db/schema";
33import { hashPassword } from "./auth";
34import { sendEmail, absoluteUrl, type EmailMessage } from "./email";
35
36const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes
37const MAX_TOKENS_PER_EMAIL_PER_HOUR = 3;
38
39// ---------------------------------------------------------------------------
40// Test seam — swap the email sender without `mock.module`. Mirrors P1.
41// ---------------------------------------------------------------------------
42type EmailSender = (msg: EmailMessage) => Promise<unknown> | unknown;
43let _emailSender: EmailSender = sendEmail;
44export function __setEmailForTests(fn: EmailSender | null): void {
45 _emailSender = fn ?? sendEmail;
46}
47
48// ---------------------------------------------------------------------------
49// Token primitives.
50// ---------------------------------------------------------------------------
51
52function toHex(bytes: Uint8Array): string {
53 let out = "";
54 for (let i = 0; i < bytes.length; i++)
55 out += bytes[i]!.toString(16).padStart(2, "0");
56 return out;
57}
58
59async function sha256Hex(input: string): Promise<string> {
60 const buf = new TextEncoder().encode(input);
61 const digest = await crypto.subtle.digest("SHA-256", buf);
62 return toHex(new Uint8Array(digest));
63}
64
65export function generateMagicLinkToken(): { plaintext: string; hash: string } {
66 const bytes = crypto.getRandomValues(new Uint8Array(32));
67 const plaintext = toHex(bytes);
68 const hasher = new Bun.CryptoHasher("sha256");
69 hasher.update(plaintext);
70 const hash = hasher.digest("hex");
71 return { plaintext, hash };
72}
73
74// ---------------------------------------------------------------------------
75// Email template — reuses the same dark-theme gradient shell as P1.
76// ---------------------------------------------------------------------------
77
78function escapeHtml(s: string): string {
79 return s
80 .replace(/&/g, "&")
81 .replace(/</g, "<")
82 .replace(/>/g, ">")
83 .replace(/"/g, """)
84 .replace(/'/g, "'");
85}
86
87function buildMagicLinkEmail(opts: { signInUrl: string }) {
88 const subject = "Your Gluecron sign-in link";
89 const text = [
90 "Hi,",
91 "",
92 "Click the link below to sign in to Gluecron. This link expires in 15 minutes.",
93 "",
94 `Sign in: ${opts.signInUrl}`,
95 "",
96 "If you didn't request this, ignore this email — no one can sign in",
97 "without clicking the link.",
98 "",
99 "— gluecron",
100 ].join("\n");
101
102 const safeUrl = escapeHtml(opts.signInUrl);
103 const html = `<!doctype html>
104<html><head><meta charset="utf-8"><title>${escapeHtml(subject)}</title></head>
105<body style="margin:0;padding:0;background:#0d1117;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;color:#c9d1d9">
106 <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0d1117">
107 <tr><td align="center" style="padding:32px 16px">
108 <table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#161b22;border:1px solid #30363d;border-radius:12px;overflow:hidden">
109 <tr><td style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);padding:24px 28px">
110 <div style="font-size:20px;font-weight:700;color:#fff;letter-spacing:-0.01em">gluecron</div>
111 <div style="font-size:13px;color:rgba(255,255,255,0.85);margin-top:2px">Magic sign-in link</div>
112 </td></tr>
113 <tr><td style="padding:28px">
114 <p style="margin:0 0 12px;font-size:15px;color:#e6edf3">Hi,</p>
115 <p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#c9d1d9">Click the button below to sign in to Gluecron. This link expires in 15 minutes.</p>
116 <p style="margin:0 0 24px"><a href="${safeUrl}" style="display:inline-block;background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:11px 22px;border-radius:9999px">Sign in</a></p>
117 <p style="margin:0 0 8px;font-size:13px;color:#8b949e">Or copy this link into your browser:</p>
118 <p style="margin:0 0 24px;font-size:12px;color:#8b949e;word-break:break-all"><a href="${safeUrl}" style="color:#58a6ff;text-decoration:none">${safeUrl}</a></p>
119 <p style="margin:0;font-size:12px;color:#8b949e;line-height:1.55">If you didn't request this, ignore this email — no one can sign in without clicking the link.</p>
120 </td></tr>
121 <tr><td style="padding:16px 28px;border-top:1px solid #30363d;font-size:11px;color:#6e7681">gluecron — AI-native code intelligence</td></tr>
122 </table>
123 </td></tr>
124 </table>
125</body></html>`;
126
127 return { subject, text, html };
128}
129
130export function buildMagicLinkUrl(plaintextToken: string): string {
131 const path = `/login/magic/callback?token=${encodeURIComponent(plaintextToken)}`;
132 return absoluteUrl(path);
133}
134
135// ---------------------------------------------------------------------------
136// startMagicLinkSignIn — always returns ok, never reveals enumeration.
137// ---------------------------------------------------------------------------
138
139export async function startMagicLinkSignIn(
140 email: string,
141 opts: { requestIp?: string; autoCreate?: boolean } = {}
142): Promise<{ ok: boolean }> {
143 const autoCreate = opts.autoCreate !== false; // default true
144 const normalized = String(email || "").trim().toLowerCase();
145 if (!normalized || !normalized.includes("@")) return { ok: true };
146
147 try {
148 // Per-email throttle — prevents enumeration via timing AND volume.
149 const recent = await db
150 .select({
151 id: magicLinkTokens.id,
152 createdAt: magicLinkTokens.createdAt,
153 })
154 .from(magicLinkTokens)
155 .where(eq(magicLinkTokens.email, normalized))
156 .limit(100);
157 const cutoff = Date.now() - 60 * 60 * 1000;
158 const recentCount = recent.filter(
159 (r) => new Date(r.createdAt).getTime() > cutoff
160 ).length;
161 if (recentCount >= MAX_TOKENS_PER_EMAIL_PER_HOUR) {
162 console.error(
163 `[magic-link] per-email rate limit hit for ${JSON.stringify(normalized)} ip=${opts.requestIp || "?"} — generic success returned`
164 );
165 return { ok: true };
166 }
167
168 const [user] = await db
169 .select({ id: users.id, email: users.email })
170 .from(users)
171 .where(eq(users.email, normalized))
172 .limit(1);
173
174 if (!user && !autoCreate) {
175 console.error(
176 `[magic-link] no user for email=${JSON.stringify(normalized)} ip=${opts.requestIp || "?"} autoCreate=false — generic success returned`
177 );
178 return { ok: true };
179 }
180
181 const { plaintext, hash } = generateMagicLinkToken();
182 const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);
183
184 await db.insert(magicLinkTokens).values({
185 email: normalized,
186 userId: user?.id ?? null,
187 tokenHash: hash,
188 expiresAt,
189 requestIp: opts.requestIp || null,
190 });
191
192 const signInUrl = buildMagicLinkUrl(plaintext);
193 const msg = buildMagicLinkEmail({ signInUrl });
194
195 // Fire-and-forget — never block the response on email send.
196 Promise.resolve()
197 .then(() =>
198 _emailSender({
199 to: normalized,
200 subject: msg.subject,
201 text: msg.text,
202 html: msg.html,
203 })
204 )
205 .catch((err) =>
206 console.error("[magic-link] email send error:", err)
207 );
208
209 return { ok: true };
210 } catch (err) {
211 console.error("[magic-link] startMagicLinkSignIn error:", err);
212 return { ok: true };
213 }
214}
215
216// ---------------------------------------------------------------------------
217// consumeMagicLinkToken — happy path returns a userId + createdAccount flag.
218// ---------------------------------------------------------------------------
219
220export async function consumeMagicLinkToken(
221 token: string,
222 opts: { autoCreate?: boolean; requestIp?: string } = {}
223): Promise<{
224 ok: boolean;
225 userId?: string;
226 createdAccount?: boolean;
227 reason?: string;
228}> {
229 const autoCreate = opts.autoCreate !== false; // default true
230 const plaintext = String(token || "").trim();
231 if (!plaintext) return { ok: false, reason: "invalid" };
232
233 try {
234 const hash = await sha256Hex(plaintext);
235 const [row] = await db
236 .select()
237 .from(magicLinkTokens)
238 .where(eq(magicLinkTokens.tokenHash, hash))
239 .limit(1);
240
241 if (!row) return { ok: false, reason: "invalid" };
242 if (row.usedAt) return { ok: false, reason: "used" };
243 if (new Date(row.expiresAt).getTime() < Date.now())
244 return { ok: false, reason: "expired" };
245
246 let userId = row.userId ?? undefined;
247 let createdAccount = false;
248
249 if (!userId) {
250 if (!autoCreate) return { ok: false, reason: "no-account" };
251 // Mint a fresh account. The click is proof of email ownership, so
252 // we set emailVerifiedAt immediately. The password hash is a non-
253 // matchable placeholder — the user can set a real password later
254 // via /settings, or just keep using magic links forever.
255 // We mint the UUID client-side so we don't need .returning() on the
256 // insert (keeps the surface minimal + cheap to stub in tests).
257 const username = await pickFreshUsername(row.email);
258 const placeholderPw = await hashPassword(generateRandomString(32));
259 const newUserId = crypto.randomUUID();
260 await db.insert(users).values({
261 id: newUserId,
262 username,
263 email: row.email,
264 passwordHash: placeholderPw,
265 emailVerifiedAt: new Date(),
266 });
267 userId = newUserId;
268 createdAccount = true;
269 }
270
271 const now = new Date();
272
273 // Mark the current token used.
274 await db
275 .update(magicLinkTokens)
276 .set({ usedAt: now })
277 .where(eq(magicLinkTokens.id, row.id));
278
279 // Invalidate every other unused magic-link for this email. We use a
280 // broad eq(email) — already-used rows already have usedAt set, so
281 // this is a no-op for them; what matters is unused rows being
282 // burned so a second link mailed within the 15-min window can't be
283 // replayed.
284 await db
285 .update(magicLinkTokens)
286 .set({ usedAt: now })
287 .where(eq(magicLinkTokens.email, row.email));
288
289 return { ok: true, userId, createdAccount };
290 } catch (err) {
291 console.error("[magic-link] consumeMagicLinkToken error:", err);
292 return { ok: false, reason: "invalid" };
293 }
294}
295
296// ---------------------------------------------------------------------------
297// Auto-create helpers.
298// ---------------------------------------------------------------------------
299
300function generateRandomString(n: number): string {
301 const bytes = crypto.getRandomValues(new Uint8Array(n));
302 return toHex(bytes);
303}
304
305/** 8 url-safe lowercase chars derived from random bytes. */
306function shortSuffix(): string {
307 const bytes = crypto.getRandomValues(new Uint8Array(8));
308 const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
309 let out = "";
310 for (let i = 0; i < 8; i++) out += alphabet[bytes[i]! % alphabet.length];
311 return out;
312}
313
314/**
315 * Pick a free `user-XXXXXXXX` username. Retries a handful of times on
316 * collision. Pure name minting — no DB writes here.
317 */
318async function pickFreshUsername(_emailHint: string): Promise<string> {
319 for (let attempt = 0; attempt < 8; attempt++) {
320 const candidate = `user-${shortSuffix()}`;
321 const [clash] = await db
322 .select({ id: users.id })
323 .from(users)
324 .where(eq(users.username, candidate))
325 .limit(1);
326 if (!clash) return candidate;
327 }
328 // 8 random 8-char rolls all colliding is astronomically unlikely; if it
329 // happens we fall back to a longer suffix that's effectively unique.
330 return `user-${shortSuffix()}${shortSuffix()}`;
331}
Addedsrc/lib/password-reset.ts+200−0View fileUnifiedSplit
@@ -0,0 +1,200 @@
1/**
2 * Block P1 — Password reset flow.
3 *
4 * Public surface:
5 * - generateResetToken()
6 * - createPasswordResetRequest() → always { ok: true }
7 * - consumeResetToken()
8 * - inspectResetToken()
9 *
10 * Security:
11 * - Plaintext token NEVER persists; we store only SHA-256(token).
12 * - createPasswordResetRequest never reveals whether the email exists.
13 * - consumeResetToken rotates the password AND drops every session.
14 */
15
16import { eq } from "drizzle-orm";
17import { db } from "../db";
18import { users, sessions, passwordResetTokens } from "../db/schema";
19import { hashPassword } from "./auth";
20import { sendEmail, absoluteUrl, type EmailMessage } from "./email";
21
22const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour
23
24// Test seam — swap the email sender without mock.module.
25type EmailSender = (msg: EmailMessage) => Promise<unknown> | unknown;
26let _emailSender: EmailSender = sendEmail;
27export function __setEmailForTests(fn: EmailSender | null): void {
28 _emailSender = fn ?? sendEmail;
29}
30
31function toHex(bytes: Uint8Array): string {
32 let out = "";
33 for (let i = 0; i < bytes.length; i++) out += bytes[i]!.toString(16).padStart(2, "0");
34 return out;
35}
36
37async function sha256Hex(input: string): Promise<string> {
38 const buf = new TextEncoder().encode(input);
39 const digest = await crypto.subtle.digest("SHA-256", buf);
40 return toHex(new Uint8Array(digest));
41}
42
43export function generateResetToken(): { plaintext: string; hash: string } {
44 const bytes = crypto.getRandomValues(new Uint8Array(32));
45 const plaintext = toHex(bytes);
46 const hasher = new Bun.CryptoHasher("sha256");
47 hasher.update(plaintext);
48 const hash = hasher.digest("hex");
49 return { plaintext, hash };
50}
51
52function escapeHtml(s: string): string {
53 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
54}
55
56function buildResetEmail(opts: { username: string; resetUrl: string }) {
57 const subject = "Reset your Gluecron password";
58 const text = [
59 `Hi ${opts.username},`,
60 "",
61 "We received a request to reset the password for your Gluecron account.",
62 "",
63 `Reset your password: ${opts.resetUrl}`,
64 "",
65 "This link expires in 1 hour. If you didn't request a reset, ignore",
66 "this email — your password won't change.",
67 "",
68 "— gluecron",
69 ].join("\n");
70
71 const safeUser = escapeHtml(opts.username);
72 const safeUrl = escapeHtml(opts.resetUrl);
73 const html = `<!doctype html>
74<html><head><meta charset="utf-8"><title>${escapeHtml(subject)}</title></head>
75<body style="margin:0;padding:0;background:#0d1117;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;color:#c9d1d9">
76 <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0d1117">
77 <tr><td align="center" style="padding:32px 16px">
78 <table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#161b22;border:1px solid #30363d;border-radius:12px;overflow:hidden">
79 <tr><td style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);padding:24px 28px">
80 <div style="font-size:20px;font-weight:700;color:#fff;letter-spacing:-0.01em">gluecron</div>
81 <div style="font-size:13px;color:rgba(255,255,255,0.85);margin-top:2px">Password reset</div>
82 </td></tr>
83 <tr><td style="padding:28px">
84 <p style="margin:0 0 12px;font-size:15px;color:#e6edf3">Hi ${safeUser},</p>
85 <p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#c9d1d9">We received a request to reset the password for your Gluecron account.</p>
86 <p style="margin:0 0 24px"><a href="${safeUrl}" style="display:inline-block;background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:11px 22px;border-radius:9999px">Reset password</a></p>
87 <p style="margin:0 0 8px;font-size:13px;color:#8b949e">Or copy this link into your browser:</p>
88 <p style="margin:0 0 24px;font-size:12px;color:#8b949e;word-break:break-all"><a href="${safeUrl}" style="color:#58a6ff;text-decoration:none">${safeUrl}</a></p>
89 <p style="margin:0;font-size:12px;color:#8b949e;line-height:1.55">This link expires in 1 hour. If you didn't request a reset, ignore this email — your password won't change.</p>
90 </td></tr>
91 <tr><td style="padding:16px 28px;border-top:1px solid #30363d;font-size:11px;color:#6e7681">gluecron — AI-native code intelligence</td></tr>
92 </table>
93 </td></tr>
94 </table>
95</body></html>`;
96
97 return { subject, text, html };
98}
99
100export function buildResetUrl(plaintextToken: string): string {
101 const path = `/reset-password?token=${encodeURIComponent(plaintextToken)}&utm_source=password_reset`;
102 return absoluteUrl(path);
103}
104
105export async function createPasswordResetRequest(
106 email: string,
107 opts: { requestIp?: string } = {}
108): Promise<{ ok: boolean }> {
109 const normalized = String(email || "").trim().toLowerCase();
110 if (!normalized || !normalized.includes("@")) return { ok: true };
111
112 try {
113 const [user] = await db
114 .select({ id: users.id, username: users.username, email: users.email })
115 .from(users)
116 .where(eq(users.email, normalized))
117 .limit(1);
118
119 if (!user) {
120 console.error(`[password-reset] no user for email=${JSON.stringify(normalized)} ip=${opts.requestIp || "?"} — generic success returned`);
121 return { ok: true };
122 }
123
124 const { plaintext, hash } = generateResetToken();
125 const expiresAt = new Date(Date.now() + RESET_TTL_MS);
126
127 await db.insert(passwordResetTokens).values({
128 userId: user.id,
129 tokenHash: hash,
130 expiresAt,
131 requestIp: opts.requestIp || null,
132 });
133
134 const resetUrl = buildResetUrl(plaintext);
135 const msg = buildResetEmail({ username: user.username, resetUrl });
136
137 // Fire-and-forget — don't block the response on email send.
138 Promise.resolve()
139 .then(() => _emailSender({ to: user.email, subject: msg.subject, text: msg.text, html: msg.html }))
140 .catch((err) => console.error("[password-reset] email send error:", err));
141
142 return { ok: true };
143 } catch (err) {
144 console.error("[password-reset] createPasswordResetRequest error:", err);
145 return { ok: true };
146 }
147}
148
149export async function consumeResetToken(
150 token: string,
151 newPassword: string
152): Promise<{ ok: boolean; reason?: string }> {
153 const plaintext = String(token || "").trim();
154 if (!plaintext) return { ok: false, reason: "invalid" };
155 if (!newPassword || newPassword.length < 8) return { ok: false, reason: "weak" };
156
157 try {
158 const hash = await sha256Hex(plaintext);
159 const [row] = await db
160 .select()
161 .from(passwordResetTokens)
162 .where(eq(passwordResetTokens.tokenHash, hash))
163 .limit(1);
164
165 if (!row) return { ok: false, reason: "invalid" };
166 if (row.usedAt) return { ok: false, reason: "used" };
167 if (new Date(row.expiresAt).getTime() < Date.now()) return { ok: false, reason: "expired" };
168
169 const passwordHash = await hashPassword(newPassword);
170
171 await db.update(users).set({ passwordHash, updatedAt: new Date() }).where(eq(users.id, row.userId));
172 await db.update(passwordResetTokens).set({ usedAt: new Date() }).where(eq(passwordResetTokens.id, row.id));
173 await db.delete(sessions).where(eq(sessions.userId, row.userId));
174
175 return { ok: true };
176 } catch (err) {
177 console.error("[password-reset] consumeResetToken error:", err);
178 return { ok: false, reason: "invalid" };
179 }
180}
181
182export async function inspectResetToken(token: string): Promise<{ valid: boolean; reason?: string }> {
183 const plaintext = String(token || "").trim();
184 if (!plaintext) return { valid: false, reason: "invalid" };
185 try {
186 const hash = await sha256Hex(plaintext);
187 const [row] = await db
188 .select({ id: passwordResetTokens.id, expiresAt: passwordResetTokens.expiresAt, usedAt: passwordResetTokens.usedAt })
189 .from(passwordResetTokens)
190 .where(eq(passwordResetTokens.tokenHash, hash))
191 .limit(1);
192 if (!row) return { valid: false, reason: "invalid" };
193 if (row.usedAt) return { valid: false, reason: "used" };
194 if (new Date(row.expiresAt).getTime() < Date.now()) return { valid: false, reason: "expired" };
195 return { valid: true };
196 } catch (err) {
197 console.error("[password-reset] inspectResetToken error:", err);
198 return { valid: false, reason: "invalid" };
199 }
200}
Addedsrc/lib/playground.ts+836−0View fileUnifiedSplit
@@ -0,0 +1,836 @@
1/**
2 * Block Q3 — Anonymous playground accounts.
3 *
4 * A visitor hits POST /play and we mint them a temporary account in one
5 * round trip: a synthetic email (never delivered), a securely-random
6 * password (no one knows), a 24h session, and a public sandbox repo
7 * seeded with a starter README + hello file + a couple of issues so
8 * Claude has something to do while the visitor is poking around.
9 *
10 * 24h later the autopilot `playground-purge` task hard-deletes the user
11 * row, which CASCADEs through sessions, repos, issues, etc. No data
12 * survives.
13 *
14 * Contract:
15 * - Every exported function NEVER throws. Side-effect failures degrade
16 * to "best-effort" + audit log; the caller always gets a result.
17 * - `createPlaygroundAccount` is the only path that mints a user; it
18 * calls `bootstrapRepository` (gates, branch protection, labels) on
19 * the sandbox so the playground feels identical to a real account.
20 * - `claimPlaygroundAccount` converts a playground user into a real
21 * one: clears the playground flags, sets a real bcrypted password,
22 * swaps in the real email (and resets `emailVerifiedAt=null` so the
23 * verify-email banner appears), kicks off P2's verification email.
24 * - `purgeExpiredPlaygroundAccounts` is the autopilot sweep, capped at
25 * 50 users per tick, per-user try/catch'd.
26 *
27 * Tests in src/__tests__/playground.test.ts.
28 */
29
30import { randomBytes } from "node:crypto";
31import { and, eq, isNotNull, lt } from "drizzle-orm";
32import { db } from "../db";
33import {
34 users,
35 sessions,
36 repositories,
37 issues,
38 labels as labelsTable,
39 issueLabels,
40} from "../db/schema";
41import {
42 hashPassword,
43 generateSessionToken,
44} from "./auth";
45import { initBareRepo, getRepoPath } from "../git/repository";
46import { bootstrapRepository } from "./repo-bootstrap";
47import { audit } from "./notify";
48import { startEmailVerification } from "./email-verification";
49import { absoluteUrl } from "./email";
50
51/** Playground accounts live for exactly this long. */
52export const PLAYGROUND_TTL_MS = 24 * 60 * 60 * 1000;
53/** Default per-tick cap for `purgeExpiredPlaygroundAccounts`. */
54export const PLAYGROUND_PURGE_CAP = 50;
55/** Max collision retries when generating `guest-<8-hex>` usernames. */
56const USERNAME_RETRY_CAP = 5;
57/** Public playground sandbox repo name. */
58export const SANDBOX_REPO_NAME = "sandbox";
59/** Synthetic email domain — never delivered to. */
60export const PLAYGROUND_EMAIL_DOMAIN = "playground.gluecron.local";
61
62export interface CreatePlaygroundOpts {
63 now?: Date;
64 requestIp?: string;
65}
66
67export interface CreatePlaygroundResult {
68 user: { id: string; username: string; email: string };
69 sessionToken: string;
70 sampleRepoFullName: string;
71}
72
73export interface ClaimPlaygroundArgs {
74 email: string;
75 password: string;
76 username?: string;
77}
78
79export interface ClaimPlaygroundResult {
80 ok: boolean;
81 reason?: string;
82}
83
84export interface PurgeResult {
85 purged: number;
86 errors: number;
87}
88
89// ---------------------------------------------------------------------------
90// Pure helpers
91// ---------------------------------------------------------------------------
92
93/**
94 * Pure check: is this user a playground account? Pulls only the
95 * discriminator field so callers can pass any user-shaped object.
96 */
97export function isPlaygroundAccount(user: {
98 isPlayground?: boolean | null;
99}): boolean {
100 return user?.isPlayground === true;
101}
102
103/** Generate a fresh `guest-<8 hex>` candidate username. */
104function generateGuestUsername(): string {
105 const hex = randomBytes(4).toString("hex"); // 8 chars
106 return `guest-${hex}`;
107}
108
109/** Build the synthetic email for a playground username. */
110function synthEmailFor(username: string): string {
111 return `${username}@${PLAYGROUND_EMAIL_DOMAIN}`;
112}
113
114// ---------------------------------------------------------------------------
115// Git plumbing — write an initial commit to a bare repo (mirror of
116// demo-seed's writeInitialCommit). Inlined so the demo seeder stays
117// untouched (it's adjacent-locked and we don't want to refactor it for
118// a sibling caller).
119// ---------------------------------------------------------------------------
120
121async function spawnSafe(
122 cmd: string[],
123 cwd: string,
124 stdin?: string,
125 env?: Record<string, string>
126): Promise<{ stdout: string; stderr: string; exitCode: number }> {
127 try {
128 const proc = Bun.spawn(cmd, {
129 cwd,
130 stdout: "pipe",
131 stderr: "pipe",
132 stdin: stdin !== undefined ? "pipe" : undefined,
133 env: { ...process.env, ...(env || {}) },
134 });
135 if (stdin !== undefined && proc.stdin) {
136 const bytes = new TextEncoder().encode(stdin);
137 (proc.stdin as any).write(bytes);
138 (proc.stdin as any).end();
139 }
140 const [stdout, stderr] = await Promise.all([
141 new Response(proc.stdout).text(),
142 new Response(proc.stderr).text(),
143 ]);
144 const exitCode = await proc.exited;
145 return { stdout: stdout.trim(), stderr, exitCode };
146 } catch (err: any) {
147 return { stdout: "", stderr: String(err?.message || err), exitCode: -1 };
148 }
149}
150
151async function writePlaygroundInitialCommit(
152 repoDir: string,
153 files: Record<string, string>,
154 authorName: string,
155 authorEmail: string
156): Promise<{ commitSha: string } | { error: string }> {
157 const tmpIndex = `${repoDir}/index.playground.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}`;
158 const baseEnv = {
159 GIT_INDEX_FILE: tmpIndex,
160 GIT_AUTHOR_NAME: authorName,
161 GIT_AUTHOR_EMAIL: authorEmail,
162 GIT_COMMITTER_NAME: authorName,
163 GIT_COMMITTER_EMAIL: authorEmail,
164 };
165 const cleanup = async () => {
166 try {
167 const { unlink } = await import("fs/promises");
168 await unlink(tmpIndex);
169 } catch {
170 /* ignore */
171 }
172 };
173
174 try {
175 for (const [path, contents] of Object.entries(files)) {
176 const hashed = await spawnSafe(
177 ["git", "hash-object", "-w", "--stdin"],
178 repoDir,
179 contents
180 );
181 if (hashed.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(hashed.stdout)) {
182 await cleanup();
183 return { error: `hash-object failed for ${path}: ${hashed.stderr}` };
184 }
185 const upd = await spawnSafe(
186 [
187 "git",
188 "update-index",
189 "--add",
190 "--cacheinfo",
191 `100644,${hashed.stdout},${path}`,
192 ],
193 repoDir,
194 undefined,
195 baseEnv
196 );
197 if (upd.exitCode !== 0) {
198 await cleanup();
199 return { error: `update-index failed for ${path}: ${upd.stderr}` };
200 }
201 }
202 const wt = await spawnSafe(
203 ["git", "write-tree"],
204 repoDir,
205 undefined,
206 baseEnv
207 );
208 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(wt.stdout)) {
209 await cleanup();
210 return { error: `write-tree failed: ${wt.stderr}` };
211 }
212 const commit = await spawnSafe(
213 ["git", "commit-tree", wt.stdout, "-m", "Initial sandbox commit"],
214 repoDir,
215 undefined,
216 baseEnv
217 );
218 if (commit.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commit.stdout)) {
219 await cleanup();
220 return { error: `commit-tree failed: ${commit.stderr}` };
221 }
222 const upr = await spawnSafe(
223 ["git", "update-ref", "refs/heads/main", commit.stdout],
224 repoDir
225 );
226 if (upr.exitCode !== 0) {
227 await cleanup();
228 return { error: `update-ref failed: ${upr.stderr}` };
229 }
230 await cleanup();
231 return { commitSha: commit.stdout };
232 } catch (err: any) {
233 await cleanup();
234 return { error: String(err?.message || err) };
235 }
236}
237
238// ---------------------------------------------------------------------------
239// Starter sandbox contents
240// ---------------------------------------------------------------------------
241
242function buildSandboxFiles(username: string): Record<string, string> {
243 return {
244 "README.md": `# ${username}/sandbox
245
246Welcome to your **24-hour Gluecron playground**.
247
248This sandbox is a real, public git repo on a real Gluecron account.
249Push to it, open issues, label one \`ai:build\` and watch Claude open a
250PR. Everything you can do in a paid account, you can do here — for the
251next 24 hours.
252
253## Get started
254
255\`\`\`bash
256git clone https://gluecron.com/${username}/sandbox.git
257cd sandbox
258echo "// my first commit" >> src/hello.ts
259git commit -am "hello"
260git push origin main
261\`\`\`
262
263## Keep your work
264
265Click **Save your work** in the yellow banner above (or visit
266\`/play/claim\`) to convert this account into a permanent one. Otherwise
267this repo, your issues, and everything else here disappears at the end
268of the day.
269
270— gluecron
271`,
272 "src/hello.ts": `/**
273 * hello.ts — Gluecron playground starter.
274 *
275 * Try editing this file in the web editor, or clone the repo and push
276 * a change. Label an issue \`ai:build\` and Claude will open a PR.
277 */
278
279export function hello(name: string): string {
280 return \`Hello, \${name}!\`;
281}
282
283if (import.meta.main) {
284 console.log(hello("playground"));
285}
286`,
287 ".gitignore": `node_modules/
288dist/
289*.log
290.env
291`,
292 };
293}
294
295/** Sample issues used to demo the autopilot on the user's sandbox. */
296function sampleIssues(): Array<{ title: string; body: string; aiBuild?: boolean }> {
297 return [
298 {
299 title: "Add a /goodbye export to src/hello.ts",
300 body:
301 "Add a `goodbye(name: string): string` export alongside `hello`. " +
302 "Label this issue `ai:build` and Claude will open a PR for it " +
303 "automatically.",
304 aiBuild: true,
305 },
306 {
307 title: "Try the web editor",
308 body:
309 "Open `src/hello.ts` in the file browser, click **Edit**, change " +
310 "the greeting, and commit straight from the browser. No clone " +
311 "required.",
312 },
313 ];
314}
315
316// ---------------------------------------------------------------------------
317// createPlaygroundAccount
318// ---------------------------------------------------------------------------
319
320/**
321 * Mint a fresh playground account + 24h session + public sandbox repo.
322 * Never throws.
323 *
324 * Side-effects, all wrapped in try/catch:
325 * 1. Insert a `users` row with `is_playground=true`,
326 * `playground_expires_at = now + 24h`, `email_verified_at = now`
327 * (so the playground UI doesn't nag about verifying a fake email).
328 * 2. Insert a `sessions` row with a 24h expiry — matches the TTL so
329 * the session won't outlive the account.
330 * 3. Create a bare repo on disk (`<username>/sandbox`), seed an
331 * initial commit, insert a `repositories` row.
332 * 4. Call `bootstrapRepository` for green-default labels + branch
333 * protection (same as /new).
334 * 5. Open 2 sample issues, one of which gets the `ai:build` label so
335 * the autopilot picks it up.
336 *
337 * Anything failing past step 1 is logged + audited; the caller still
338 * gets a session token. The user can recover by hitting /new to create
339 * a fresh repo.
340 */
341export async function createPlaygroundAccount(
342 opts: CreatePlaygroundOpts = {}
343): Promise<CreatePlaygroundResult> {
344 const now = opts.now ?? new Date();
345 const expiresAt = new Date(now.getTime() + PLAYGROUND_TTL_MS);
346
347 // 1. Mint a unique username (with retry on collision).
348 let username: string | null = null;
349 let lastErr: unknown = null;
350 for (let attempt = 0; attempt < USERNAME_RETRY_CAP; attempt++) {
351 const candidate = generateGuestUsername();
352 try {
353 const [existing] = await db
354 .select({ id: users.id })
355 .from(users)
356 .where(eq(users.username, candidate))
357 .limit(1);
358 if (!existing) {
359 username = candidate;
360 break;
361 }
362 } catch (err) {
363 lastErr = err;
364 // If the DB select fails, try a different name — but stop after
365 // the cap and surface the issue via the audit log.
366 }
367 }
368 if (!username) {
369 console.error("[playground] could not allocate guest username:", lastErr);
370 // Fallback — wildly unlikely collision after 5 tries. Use the
371 // attempt-time random tail anyway; the unique constraint will reject
372 // on insert if we collide.
373 username = `guest-${randomBytes(6).toString("hex")}`;
374 }
375
376 // 2. Insert the user row. This is the only step that, if it fails,
377 // forces us to abort.
378 const email = synthEmailFor(username);
379 let userId: string;
380 try {
381 // Random unguessable password — caller cannot password-login until
382 // they claim the account.
383 const rand = randomBytes(32).toString("hex");
384 const passwordHash = await hashPassword(rand);
385 const [inserted] = await db
386 .insert(users)
387 .values({
388 username,
389 email,
390 passwordHash,
391 isPlayground: true,
392 playgroundExpiresAt: expiresAt,
393 emailVerifiedAt: now, // suppress verify-email banner
394 })
395 .returning({ id: users.id });
396 if (!inserted) throw new Error("user insert returned no row");
397 userId = inserted.id;
398 } catch (err) {
399 console.error("[playground] user insert failed:", err);
400 // Fail-loud here: caller cannot recover without a user. Return a
401 // shape that the route will detect and surface as a friendly error.
402 return {
403 user: { id: "", username, email },
404 sessionToken: "",
405 sampleRepoFullName: `${username}/${SANDBOX_REPO_NAME}`,
406 };
407 }
408
409 // 3. Issue the session. 24h matches the playground TTL.
410 let sessionToken = "";
411 try {
412 sessionToken = generateSessionToken();
413 await db.insert(sessions).values({
414 userId,
415 token: sessionToken,
416 expiresAt,
417 });
418 } catch (err) {
419 console.error("[playground] session insert failed:", err);
420 }
421
422 // 4. Sandbox repo (best-effort).
423 const fullName = `${username}/${SANDBOX_REPO_NAME}`;
424 await ensureSandboxRepo({
425 userId,
426 username,
427 repoName: SANDBOX_REPO_NAME,
428 });
429
430 // 5. Audit.
431 try {
432 await audit({
433 userId,
434 action: "playground.created",
435 targetType: "user",
436 targetId: userId,
437 metadata: {
438 username,
439 expiresAt: expiresAt.toISOString(),
440 ip: opts.requestIp || null,
441 },
442 });
443 } catch (err) {
444 console.error("[playground] audit insert failed:", err);
445 }
446
447 return {
448 user: { id: userId, username, email },
449 sessionToken,
450 sampleRepoFullName: fullName,
451 };
452}
453
454/**
455 * Bootstrap a sandbox repo for the playground user. Mirrors the body
456 * of POST /new but inlined here so we don't accidentally couple to
457 * route-internal redirects. Each step is try/catch'd.
458 */
459async function ensureSandboxRepo(args: {
460 userId: string;
461 username: string;
462 repoName: string;
463}): Promise<void> {
464 let diskPath = "";
465 try {
466 diskPath = await initBareRepo(args.username, args.repoName);
467 } catch (err) {
468 console.error("[playground] initBareRepo failed:", err);
469 return;
470 }
471
472 // Seed the bare repo with a starter commit if main isn't already
473 // resolvable (it shouldn't be on a fresh init).
474 try {
475 const repoDir = getRepoPath(args.username, args.repoName);
476 const head = await spawnSafe(
477 ["git", "rev-parse", "--verify", "refs/heads/main"],
478 repoDir
479 );
480 if (head.exitCode !== 0) {
481 const wrote = await writePlaygroundInitialCommit(
482 repoDir,
483 buildSandboxFiles(args.username),
484 "Gluecron Playground",
485 `${args.username}@playground.gluecron.local`
486 );
487 if ("error" in wrote) {
488 console.error(
489 "[playground] writeInitialCommit failed:",
490 wrote.error
491 );
492 }
493 }
494 } catch (err) {
495 console.error("[playground] sandbox seed failed:", err);
496 }
497
498 // Insert the DB row.
499 let repoId: string | null = null;
500 try {
501 const [inserted] = await db
502 .insert(repositories)
503 .values({
504 name: args.repoName,
505 ownerId: args.userId,
506 description:
507 "Your 24-hour Gluecron playground sandbox. Push, open issues, watch Claude.",
508 isPrivate: false, // public — part of the demo
509 defaultBranch: "main",
510 diskPath,
511 })
512 .returning({ id: repositories.id });
513 if (inserted) repoId = inserted.id;
514 } catch (err) {
515 console.error("[playground] repo insert failed:", err);
516 }
517
518 if (!repoId) return;
519
520 // Green-defaults — labels, branch protection, welcome issue.
521 try {
522 await bootstrapRepository({
523 repositoryId: repoId,
524 ownerUserId: args.userId,
525 defaultBranch: "main",
526 // We add our own playground-flavoured issues below; skip the
527 // generic welcome issue so the issue list isn't cluttered.
528 skipWelcomeIssue: true,
529 });
530 } catch (err) {
531 console.error("[playground] bootstrapRepository failed:", err);
532 }
533
534 // Sample issues — one labelled `ai:build` so the autopilot picks it
535 // up within the next tick or two.
536 let aiBuildLabelId: string | null = null;
537 try {
538 // Fetch the bootstrap-created `ai:build` label if any seeder added
539 // it; otherwise create our own.
540 const [existing] = await db
541 .select({ id: labelsTable.id })
542 .from(labelsTable)
543 .where(
544 and(
545 eq(labelsTable.repositoryId, repoId),
546 eq(labelsTable.name, "ai:build")
547 )
548 )
549 .limit(1);
550 if (existing) {
551 aiBuildLabelId = existing.id;
552 } else {
553 const [created] = await db
554 .insert(labelsTable)
555 .values({
556 repositoryId: repoId,
557 name: "ai:build",
558 color: "#8c6dff",
559 description: "Autopilot — open a draft PR for this issue.",
560 })
561 .returning({ id: labelsTable.id });
562 if (created) aiBuildLabelId = created.id;
563 }
564 } catch (err) {
565 console.error("[playground] ai:build label ensure failed:", err);
566 }
567
568 for (const issue of sampleIssues()) {
569 try {
570 const [inserted] = await db
571 .insert(issues)
572 .values({
573 repositoryId: repoId,
574 authorId: args.userId,
575 title: issue.title,
576 body: issue.body,
577 state: "open",
578 })
579 .returning({ id: issues.id });
580 if (inserted && issue.aiBuild && aiBuildLabelId) {
581 try {
582 await db.insert(issueLabels).values({
583 issueId: inserted.id,
584 labelId: aiBuildLabelId,
585 });
586 } catch (err) {
587 console.error(
588 "[playground] issue label insert failed:",
589 err
590 );
591 }
592 }
593 } catch (err) {
594 console.error("[playground] sample issue insert failed:", err);
595 }
596 }
597}
598
599// ---------------------------------------------------------------------------
600// claimPlaygroundAccount
601// ---------------------------------------------------------------------------
602
603const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
604const USERNAME_RE = /^[a-zA-Z0-9_-]+$/;
605
606/**
607 * Convert a playground user into a real one. Idempotent in the sense
608 * that calling it on an already-claimed (real) account returns
609 * `{ok:false, reason:"not_a_playground_account"}`.
610 *
611 * Validation:
612 * - email looks like an email and is not taken by another user;
613 * - password is at least 8 chars;
614 * - username (if provided) matches `^[a-zA-Z0-9_-]+$`, 2..39 chars,
615 * not taken by another user.
616 *
617 * Side effects:
618 * - users row patched: is_playground=false, playground_expires_at=null,
619 * email=<new>, email_verified_at=null (force re-verify), password_hash=
620 * bcrypt(<new>), optional new username.
621 * - audit `playground.claimed`.
622 * - fire-and-forget `startEmailVerification` on the new email.
623 */
624export async function claimPlaygroundAccount(
625 userId: string,
626 args: ClaimPlaygroundArgs
627): Promise<ClaimPlaygroundResult> {
628 // ── Validate args ──────────────────────────────────────────────────
629 const email = (args.email || "").trim();
630 const password = args.password || "";
631 const newUsername = args.username ? args.username.trim() : null;
632
633 if (!EMAIL_RE.test(email)) {
634 return { ok: false, reason: "invalid_email" };
635 }
636 if (password.length < 8) {
637 return { ok: false, reason: "password_too_short" };
638 }
639 if (newUsername !== null) {
640 if (!USERNAME_RE.test(newUsername) || newUsername.length < 2 || newUsername.length > 39) {
641 return { ok: false, reason: "invalid_username" };
642 }
643 }
644
645 // ── Load existing user + verify is-playground ──────────────────────
646 let existing: {
647 id: string;
648 username: string;
649 email: string;
650 isPlayground: boolean;
651 } | null = null;
652 try {
653 const [row] = await db
654 .select({
655 id: users.id,
656 username: users.username,
657 email: users.email,
658 isPlayground: users.isPlayground,
659 })
660 .from(users)
661 .where(eq(users.id, userId))
662 .limit(1);
663 existing = row ?? null;
664 } catch (err) {
665 console.error("[playground] claim load user failed:", err);
666 return { ok: false, reason: "lookup_failed" };
667 }
668 if (!existing) return { ok: false, reason: "user_not_found" };
669 if (!existing.isPlayground) {
670 return { ok: false, reason: "not_a_playground_account" };
671 }
672
673 // ── Uniqueness checks ──────────────────────────────────────────────
674 try {
675 const [byEmail] = await db
676 .select({ id: users.id })
677 .from(users)
678 .where(eq(users.email, email))
679 .limit(1);
680 if (byEmail && byEmail.id !== userId) {
681 return { ok: false, reason: "email_taken" };
682 }
683 } catch (err) {
684 console.error("[playground] claim email check failed:", err);
685 return { ok: false, reason: "lookup_failed" };
686 }
687
688 if (newUsername !== null && newUsername !== existing.username) {
689 try {
690 const [byUsername] = await db
691 .select({ id: users.id })
692 .from(users)
693 .where(eq(users.username, newUsername))
694 .limit(1);
695 if (byUsername && byUsername.id !== userId) {
696 return { ok: false, reason: "username_taken" };
697 }
698 } catch (err) {
699 console.error("[playground] claim username check failed:", err);
700 return { ok: false, reason: "lookup_failed" };
701 }
702 }
703
704 // ── Apply the update ───────────────────────────────────────────────
705 const passwordHash = await hashPassword(password);
706 const patch: Record<string, unknown> = {
707 isPlayground: false,
708 playgroundExpiresAt: null,
709 email,
710 emailVerifiedAt: null,
711 passwordHash,
712 updatedAt: new Date(),
713 };
714 if (newUsername !== null && newUsername !== existing.username) {
715 patch.username = newUsername;
716 }
717
718 try {
719 await db.update(users).set(patch).where(eq(users.id, userId));
720 } catch (err) {
721 console.error("[playground] claim update failed:", err);
722 return { ok: false, reason: "update_failed" };
723 }
724
725 // ── Verification email (fire-and-forget) + audit ───────────────────
726 try {
727 await audit({
728 userId,
729 action: "playground.claimed",
730 targetType: "user",
731 targetId: userId,
732 metadata: {
733 previousUsername: existing.username,
734 newUsername: (patch.username as string) ?? existing.username,
735 email,
736 loginUrl: absoluteUrl("/login"),
737 },
738 });
739 } catch (err) {
740 console.error("[playground] claim audit failed:", err);
741 }
742
743 // Don't block the claim on the email send.
744 startEmailVerification(userId, email).catch((err) => {
745 console.error("[playground] claim verification email failed:", err);
746 });
747
748 return { ok: true };
749}
750
751// ---------------------------------------------------------------------------
752// purgeExpiredPlaygroundAccounts
753// ---------------------------------------------------------------------------
754
755/**
756 * Autopilot sweep — hard-delete every playground account whose TTL has
757 * elapsed. CASCADEs from `users.id` clean up sessions + repositories +
758 * issues + everything else. Capped at 50 users per tick. Each deletion
759 * is try/catch'd so one FK violation can't stall the queue.
760 *
761 * Never throws. Returns `{ purged, errors }` for the tick log line.
762 */
763export async function purgeExpiredPlaygroundAccounts(
764 opts: { now?: Date; cap?: number } = {}
765): Promise<PurgeResult> {
766 const now = opts.now ?? new Date();
767 const cap = Math.max(1, opts.cap ?? PLAYGROUND_PURGE_CAP);
768
769 let candidates: Array<{ id: string; username: string }> = [];
770 try {
771 candidates = await db
772 .select({ id: users.id, username: users.username })
773 .from(users)
774 .where(
775 and(
776 eq(users.isPlayground, true),
777 isNotNull(users.playgroundExpiresAt),
778 lt(users.playgroundExpiresAt, now)
779 )
780 )
781 .limit(cap);
782 } catch (err) {
783 console.error("[playground] purge candidate query failed:", err);
784 return { purged: 0, errors: 1 };
785 }
786
787 let purged = 0;
788 let errors = 0;
789 for (const c of candidates) {
790 try {
791 const deleted = await db
792 .delete(users)
793 .where(eq(users.id, c.id))
794 .returning({ id: users.id });
795 if (deleted.length > 0) {
796 purged += 1;
797 try {
798 await audit({
799 userId: null,
800 action: "playground.purged",
801 targetType: "user",
802 targetId: c.id,
803 metadata: { username: c.username },
804 });
805 } catch (err) {
806 console.error(
807 `[playground] purge audit failed for user=${c.id} (${c.username}):`,
808 err
809 );
810 }
811 }
812 } catch (err) {
813 errors += 1;
814 console.error(
815 `[playground] purge failed for user=${c.id} (${c.username}):`,
816 err
817 );
818 }
819 }
820
821 return { purged, errors };
822}
823
824// ---------------------------------------------------------------------------
825// Test-only exports
826// ---------------------------------------------------------------------------
827
828export const __test = {
829 PLAYGROUND_TTL_MS,
830 PLAYGROUND_PURGE_CAP,
831 USERNAME_RETRY_CAP,
832 generateGuestUsername,
833 synthEmailFor,
834 buildSandboxFiles,
835 sampleIssues,
836};
Addedsrc/lib/repo-create-gate.ts+82−0View fileUnifiedSplit
@@ -0,0 +1,82 @@
1/**
2 * Block P4 — shared "before create repo" gate.
3 *
4 * Pricing is fiction until creation paths actually enforce the plan's
5 * repoLimit. This module wraps `src/lib/billing.ts`'s pure helpers
6 * (`wouldExceedRepoLimit`, `getUserQuota`, `resetIfCycleExpired`) into a
7 * single decision call that every repo-create site shares.
8 *
9 * Fail-open: any error in the underlying helpers returns `{ ok: true }`
10 * so a Neon hiccup or billing-table outage never blocks legitimate
11 * users from creating repos. This is consistent with billing.ts's own
12 * fail-open posture (see invariants in BUILD_BIBLE §4.9).
13 */
14
15import {
16 getUserQuota,
17 resetIfCycleExpired,
18 wouldExceedRepoLimit,
19} from "./billing";
20
21export type RepoCreateGateResult =
22 | { ok: true }
23 | { ok: false; reason: string; upgradeUrl: string };
24
25/**
26 * Should this user be allowed to create another repo right now?
27 *
28 * Used by:
29 * - POST /new (web UI)
30 * - POST /api/v2/repos (REST API v2)
31 * - POST /import/github/repo (GitHub import)
32 *
33 * Caller patterns:
34 * - Web routes redirect to `?error=<reason>` on `ok: false`.
35 * - API routes return 402 Payment Required with `{error, upgrade_url}`.
36 */
37export async function checkRepoCreateAllowed(
38 userId: string
39): Promise<RepoCreateGateResult> {
40 try {
41 // Roll the monthly counter window forward if needed.
42 await resetIfCycleExpired(userId).catch(() => false);
43 if (await wouldExceedRepoLimit(userId)) {
44 const quota = await getUserQuota(userId);
45 const planName = quota.plan.name || "current plan";
46 const limit = quota.plan.repoLimit;
47 return {
48 ok: false,
49 reason: `Your ${planName} is limited to ${limit} repos. Upgrade for more.`,
50 upgradeUrl: "/pricing#upgrade",
51 };
52 }
53 return { ok: true };
54 } catch {
55 // Fail-open. Billing must never break the primary request path.
56 return { ok: true };
57 }
58}
59
60/** Render-friendly "X of Y repos used (Plan)" for the /new form header. */
61export async function getRepoCreateUsage(userId: string): Promise<{
62 used: number;
63 limit: number;
64 planName: string;
65 atLimit: boolean;
66} | null> {
67 try {
68 const quota = await getUserQuota(userId);
69 // Lazy import to avoid pulling repoCountForUser into the module's
70 // hot path — it's not exported, so we re-derive via the existing
71 // helper without re-implementing the count query.
72 const atLimit = await wouldExceedRepoLimit(userId);
73 return {
74 used: atLimit ? quota.plan.repoLimit : Math.max(0, quota.plan.repoLimit - 1),
75 limit: quota.plan.repoLimit,
76 planName: quota.plan.name || "Free",
77 atLimit,
78 };
79 } catch {
80 return null;
81 }
82}
Addedsrc/lib/systemd-notify.ts+111−0View fileUnifiedSplit
@@ -0,0 +1,111 @@
1/**
2 * BLOCK N2 — systemd sd_notify(READY=1) helper.
3 *
4 * When the service unit is `Type=notify`, systemd blocks `systemctl restart`
5 * until the new process sends `READY=1` over the AF_UNIX datagram socket
6 * whose path is exposed via the `NOTIFY_SOCKET` env var. Once we send that,
7 * the unit is considered started and the old process is reaped.
8 *
9 * This is what eliminates the "sleep + curl /healthz with retries" loop on
10 * the deploy box: systemd itself knows when the new process is serving HTTP,
11 * so the deploy workflow can move on the instant Bun.serve() returns.
12 *
13 * Defensive guarantees (load-bearing — boot must never fail on this):
14 * - If `NOTIFY_SOCKET` is unset (dev, local test, non-systemd hosts), the
15 * helper is a silent no-op.
16 * - All socket errors are swallowed. A failed notify NEVER throws out into
17 * `src/index.ts` boot path.
18 * - No external dependency. Uses `Bun.udpSocket` (datagram support shipped
19 * in Bun 1.1+).
20 *
21 * Reference:
22 * - https://www.freedesktop.org/software/systemd/man/sd_notify.html
23 * - https://www.freedesktop.org/software/systemd/man/systemd.service.html#Type=
24 */
25
26/**
27 * Factory for the datagram socket used to talk to systemd. Pulled out so
28 * tests can inject a mock without touching Bun globals.
29 */
30export type DatagramOpener = (
31 socketPath: string
32) => Promise<{
33 send(data: string | Uint8Array, port: number, hostname: string): unknown;
34 close(): void;
35}>;
36
37const defaultOpener: DatagramOpener = async (socketPath: string) => {
38 // Bun's udpSocket factory. We intentionally don't bind to a port — we
39 // only need to send a single datagram and then close. The `connect`
40 // option targets the unix datagram path exposed by systemd.
41 //
42 // Note: at the time of writing Bun's udpSocket primarily exposes UDP/IP;
43 // we pass `connect` with the path field as a best-effort. If the runtime
44 // rejects the unix path, the resulting throw is caught by `notifySystemdReady`
45 // and we fall back gracefully (systemd will eventually time out and fall
46 // back to its normal start sequence — boot is unaffected).
47 // eslint-disable-next-line @typescript-eslint/no-explicit-any
48 const socket = await (Bun as any).udpSocket({
49 socket: {
50 data() {
51 /* unused */
52 },
53 drain() {
54 /* unused */
55 },
56 error() {
57 /* swallow */
58 },
59 },
60 connect: { hostname: socketPath, port: 0 },
61 });
62 return socket as ReturnType<DatagramOpener> extends Promise<infer T>
63 ? T
64 : never;
65};
66
67/**
68 * Send `READY=1\n` to systemd's notification socket if one is configured.
69 *
70 * - No-op when `NOTIFY_SOCKET` is unset (dev / non-systemd).
71 * - Never throws. Errors are intentionally swallowed.
72 *
73 * @param opts.openDatagram Optional injected socket factory (tests).
74 * @param opts.env Optional env-bag (defaults to `process.env`).
75 */
76export async function notifySystemdReady(
77 opts: {
78 openDatagram?: DatagramOpener;
79 env?: NodeJS.ProcessEnv;
80 } = {}
81): Promise<void> {
82 const env = opts.env ?? process.env;
83 const socketPath = env.NOTIFY_SOCKET;
84 if (!socketPath || socketPath.length === 0) {
85 // Dev / non-systemd host. Silent no-op.
86 return;
87 }
88
89 const opener = opts.openDatagram ?? defaultOpener;
90
91 try {
92 const socket = await opener(socketPath);
93 try {
94 socket.send("READY=1\n", 0, socketPath);
95 } catch {
96 // ignore — best effort
97 }
98 try {
99 socket.close();
100 } catch {
101 // ignore — best effort
102 }
103 } catch {
104 // Socket couldn't be opened. Most likely the runtime doesn't support
105 // AF_UNIX datagrams via Bun.udpSocket on this host, or the socket path
106 // is stale. Either way, boot must continue.
107 return;
108 }
109}
110
111export const __test = { defaultOpener };
Modifiedsrc/middleware/admin.ts+14−7View fileUnifiedSplit
@@ -19,6 +19,8 @@
1919import { createMiddleware } from "hono/factory";
2020import type { AuthEnv } from "./auth";
2121import { isSiteAdmin } from "../lib/admin";
22// BLOCK O2 — polished 403 page renderer (standalone HTML, DB-free).
23import { renderStandaloneErrorPage } from "../views/error-page";
2224
2325export const requireAdmin = createMiddleware<AuthEnv>(async (c, next) => {
2426 const user = c.get("user");
@@ -29,14 +31,19 @@ export const requireAdmin = createMiddleware<AuthEnv>(async (c, next) => {
2931
3032 const admin = user.isAdmin === true || (await isSiteAdmin(user.id));
3133 if (!admin) {
34 // BLOCK O2 — polished 403 page (was inline 80s-era markup). The
35 // standalone renderer returns a self-contained <!doctype html>
36 // document so this middleware has no Layout dependency.
3237 return c.html(
33 `<html><body style="background:#0d1117;color:#e6edf3;font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh">
34 <div style="text-align:center">
35 <h1>403</h1>
36 <p>Admin access required.</p>
37 <a href="/" style="color:#58a6ff">Go home</a>
38 </div>
39 </body></html>`,
38 renderStandaloneErrorPage({
39 code: "403",
40 eyebrow: "Forbidden",
41 title: "Admin access required.",
42 body:
43 "This area is restricted to site admins. If you think this is " +
44 "a mistake, contact a site admin or sign in as a different user.",
45 signedIn: true,
46 }),
4047 403
4148 );
4249 }
Addedsrc/routes/admin-deploys-page.tsx+297−0View fileUnifiedSplit
@@ -0,0 +1,297 @@
1/**
2 * Block N3 — Platform deploy timeline page + JSON feed.
3 *
4 * GET /admin/deploys — site-admin HTML timeline (last 50 deploys)
5 * GET /admin/deploys/latest.json — `{ latest, asOf }` JSON. Polled by the
6 * layout status pill on every page; SSE
7 * pushes follow via `platform:deploys`.
8 *
9 * The companion POST /admin/deploys/trigger lives in `src/routes/admin-deploys.tsx`
10 * (Block N4 — pre-existing locked file) — we MUST NOT extend that file, so
11 * these GET routes ship as a sibling. Both mount on the same Hono `/` so the
12 * URLs land where the spec expects.
13 *
14 * Backed by `platform_deploys` (drizzle/0046_platform_deploys.sql,
15 * src/db/schema-deploys.ts). Populated by
16 * `POST /api/events/deploy/{started,finished}` in `src/routes/events.ts`,
17 * which the `.github/workflows/hetzner-deploy.yml` workflow calls as it runs.
18 */
19
20import { Hono } from "hono";
21import { desc } from "drizzle-orm";
22import { db } from "../db";
23import { platformDeploys } from "../db/schema-deploys";
24import { Layout } from "../views/layout";
25import { softAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { isSiteAdmin } from "../lib/admin";
28
29const page = new Hono<AuthEnv>();
30page.use("*", softAuth);
31
32// ---------------------------------------------------------------------------
33// Helpers — exposed via `__test` so unit tests can hammer the format edges
34// without setting up the full Hono request pipeline.
35// ---------------------------------------------------------------------------
36
37/**
38 * Render a relative time like "just now", "12s ago", "3m ago", "2h ago",
39 * "3d ago". Stable for any past Date; clamps negative deltas to "just now"
40 * so a slight clock skew doesn't print "-2s".
41 */
42export function relativeTime(from: Date, now: Date = new Date()): string {
43 const ms = now.getTime() - from.getTime();
44 if (ms < 5_000) return "just now";
45 const s = Math.floor(ms / 1_000);
46 if (s < 60) return `${s}s ago`;
47 const m = Math.floor(s / 60);
48 if (m < 60) return `${m}m ago`;
49 const h = Math.floor(m / 60);
50 if (h < 24) return `${h}h ago`;
51 const d = Math.floor(h / 24);
52 return `${d}d ago`;
53}
54
55/** Short SHA — first 7 hex chars, lowercased. */
56export function shortSha(sha: string): string {
57 return (sha || "").slice(0, 7).toLowerCase();
58}
59
60/** Format duration_ms into "12s" / "1m 14s" / "—". */
61export function formatDuration(ms: number | null | undefined): string {
62 if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "—";
63 const s = Math.round(ms / 1000);
64 if (s < 60) return `${s}s`;
65 const m = Math.floor(s / 60);
66 const rem = s - m * 60;
67 return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
68}
69
70interface DeployRow {
71 id: string;
72 runId: string;
73 sha: string;
74 source: string;
75 status: string;
76 startedAt: Date;
77 finishedAt: Date | null;
78 durationMs: number | null;
79 error: string | null;
80}
81
82async function fetchLatest(limit = 50): Promise<DeployRow[]> {
83 try {
84 const rows = await db
85 .select({
86 id: platformDeploys.id,
87 runId: platformDeploys.runId,
88 sha: platformDeploys.sha,
89 source: platformDeploys.source,
90 status: platformDeploys.status,
91 startedAt: platformDeploys.startedAt,
92 finishedAt: platformDeploys.finishedAt,
93 durationMs: platformDeploys.durationMs,
94 error: platformDeploys.error,
95 })
96 .from(platformDeploys)
97 .orderBy(desc(platformDeploys.startedAt))
98 .limit(limit);
99 return rows as DeployRow[];
100 } catch (err) {
101 console.error("[admin-deploys-page] fetchLatest failed:", err);
102 return [];
103 }
104}
105
106function serialise(row: DeployRow): Record<string, unknown> {
107 return {
108 id: row.id,
109 run_id: row.runId,
110 sha: row.sha,
111 source: row.source,
112 status: row.status,
113 started_at: row.startedAt.toISOString(),
114 finished_at: row.finishedAt ? row.finishedAt.toISOString() : null,
115 duration_ms: row.durationMs,
116 error: row.error,
117 };
118}
119
120// ---------------------------------------------------------------------------
121// Gate — both routes refuse anyone who isn't a site admin. The JSON variant
122// returns 401/403 JSON so the layout pill can `fetch()` it safely on every
123// page and silently disappear for non-admins.
124// ---------------------------------------------------------------------------
125
126async function gate(
127 c: any,
128 asJson: boolean
129): Promise<{ user: any } | Response> {
130 const user = c.get("user");
131 if (!user) {
132 return asJson
133 ? c.json({ ok: false, error: "Unauthorized" }, 401)
134 : c.redirect("/login?next=/admin/deploys");
135 }
136 if (!(await isSiteAdmin(user.id))) {
137 return asJson
138 ? c.json({ ok: false, error: "Forbidden" }, 403)
139 : c.html(
140 <Layout title="Forbidden" user={user}>
141 <div class="empty-state">
142 <h2>403 — Not a site admin</h2>
143 <p>You don't have permission to view this page.</p>
144 </div>
145 </Layout>,
146 403
147 );
148 }
149 return { user };
150}
151
152// ---------------------------------------------------------------------------
153// Routes
154// ---------------------------------------------------------------------------
155
156page.get("/admin/deploys/latest.json", async (c) => {
157 const g = await gate(c, true);
158 if (g instanceof Response) return g;
159 const rows = await fetchLatest(1);
160 return c.json({
161 ok: true,
162 latest: rows[0] ? serialise(rows[0]) : null,
163 asOf: new Date().toISOString(),
164 });
165});
166
167page.get("/admin/deploys", async (c) => {
168 const g = await gate(c, false);
169 if (g instanceof Response) return g;
170 const { user } = g;
171 const rows = await fetchLatest(50);
172 const lastSuccess = rows.find((r) => r.status === "succeeded") || null;
173 const repo = process.env.GITHUB_REPOSITORY || "ccantynz/Gluecron.com";
174
175 return c.html(
176 <Layout title="Deploys — admin" user={user}>
177 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">
178 <h2 style="margin:0">Platform deploys</h2>
179 <form
180 method="post"
181 action="/admin/deploys/trigger"
182 style="margin:0"
183 >
184 <button type="submit" class="btn btn-sm btn-primary">
185 Trigger deploy
186 </button>
187 </form>
188 </div>
189
190 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:18px">
191 {lastSuccess ? (
192 <div>
193 <div style="font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.06em">
194 Last successful deploy
195 </div>
196 <div style="margin-top:4px;font-size:14px">
197 <code class="meta-mono">{shortSha(lastSuccess.sha)}</code>
198 {" · "}
199 <span title={lastSuccess.startedAt.toISOString()}>
200 {relativeTime(lastSuccess.startedAt)}
201 </span>
202 {" · "}
203 <span>{formatDuration(lastSuccess.durationMs)}</span>
204 {" · "}
205 <span>{lastSuccess.source}</span>
206 </div>
207 </div>
208 ) : (
209 <div style="color:var(--text-muted);font-size:14px">
210 No successful deploys recorded yet.
211 </div>
212 )}
213 </div>
214
215 <table style="width:100%;border-collapse:collapse;font-size:13px">
216 <thead>
217 <tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">
218 <th style="padding:8px 6px;width:90px">Status</th>
219 <th style="padding:8px 6px">SHA</th>
220 <th style="padding:8px 6px">Source</th>
221 <th style="padding:8px 6px">Started</th>
222 <th style="padding:8px 6px">Duration</th>
223 <th style="padding:8px 6px">Error</th>
224 </tr>
225 </thead>
226 <tbody>
227 {rows.length === 0 && (
228 <tr>
229 <td
230 colspan={6}
231 style="padding:18px 6px;color:var(--text-muted);text-align:center"
232 >
233 No deploys recorded yet — they'll appear here when the next
234 push to <code>main</code> runs hetzner-deploy.yml.
235 </td>
236 </tr>
237 )}
238 {rows.map((row) => (
239 <tr style="border-bottom:1px solid var(--border)">
240 <td style="padding:8px 6px">
241 <span
242 title={row.status}
243 aria-label={row.status}
244 style={`display:inline-block;width:10px;height:10px;border-radius:50%;background:${
245 row.status === "succeeded"
246 ? "#34d399"
247 : row.status === "failed"
248 ? "#f87171"
249 : "#fbbf24"
250 }`}
251 />
252 <span style="margin-left:8px">{row.status}</span>
253 </td>
254 <td style="padding:8px 6px">
255 <code class="meta-mono">{shortSha(row.sha)}</code>
256 </td>
257 <td style="padding:8px 6px">{row.source}</td>
258 <td
259 style="padding:8px 6px"
260 title={row.startedAt.toISOString()}
261 >
262 {relativeTime(row.startedAt)}
263 </td>
264 <td style="padding:8px 6px">
265 {formatDuration(row.durationMs)}
266 </td>
267 <td
268 style="padding:8px 6px;color:var(--text-muted);max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
269 title={row.error || ""}
270 >
271 {row.error ? row.error.slice(0, 160) : "—"}
272 </td>
273 </tr>
274 ))}
275 </tbody>
276 </table>
277
278 <p style="margin-top:18px;font-size:12px;color:var(--text-muted)">
279 Manual trigger (CLI shortcut — the button above is wired to the N4
280 POST /admin/deploys/trigger handler):{" "}
281 <code class="meta-mono">
282 gh workflow run hetzner-deploy.yml -R {repo}
283 </code>
284 </p>
285 </Layout>
286 );
287});
288
289export const __test = {
290 relativeTime,
291 shortSha,
292 formatDuration,
293 fetchLatest,
294 serialise,
295};
296
297export default page;
Addedsrc/routes/admin-deploys.tsx+138−0View fileUnifiedSplit
@@ -0,0 +1,138 @@
1/**
2 * Block N4 — Admin deploy trigger.
3 *
4 * POST /admin/deploys/trigger — kick off the hetzner-deploy.yml
5 * workflow via GitHub workflow_dispatch.
6 *
7 * Reads `GITHUB_TOKEN` (operator-provided, repo+workflow scopes) from the
8 * server environment. The CLI talks to GitHub directly; this route is the
9 * "click a button on the admin page" equivalent so the operator never has to
10 * leave Gluecron to ship a hot-fix.
11 *
12 * NOTE: The companion `/admin/deploys` page (Block N3) has not landed yet on
13 * this branch. We ship the trigger endpoint now so the CLI + tests can land
14 * cleanly; once N3 adds the page, its "Trigger deploy" button posts here.
15 */
16
17import { Hono } from "hono";
18import { softAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import { isSiteAdmin } from "../lib/admin";
21import { audit } from "../lib/notify";
22
23const GH_API = "https://api.github.com";
24
25/**
26 * Dependency-injected fetcher so tests can drive the route without hitting
27 * the real api.github.com.
28 */
29export type GithubFetch = (
30 url: string,
31 init?: { method?: string; headers?: Record<string, string>; body?: string }
32) => Promise<{ status: number; ok: boolean; text: () => Promise<string> }>;
33
34let _githubFetch: GithubFetch | null = null;
35let _envOverride: { GITHUB_TOKEN?: string } | null = null;
36
37/** Test-only: override the github fetcher. Pass `null` to restore default. */
38export function __setGithubFetchForTests(f: GithubFetch | null): void {
39 _githubFetch = f;
40}
41
42/** Test-only: override env reads. Pass `null` to fall back to process.env. */
43export function __setEnvForTests(e: { GITHUB_TOKEN?: string } | null): void {
44 _envOverride = e;
45}
46
47function ghToken(): string | undefined {
48 if (_envOverride) return _envOverride.GITHUB_TOKEN;
49 return process.env.GITHUB_TOKEN;
50}
51
52function ghFetch(): GithubFetch {
53 return _githubFetch ?? ((fetch as unknown) as GithubFetch);
54}
55
56const admin = new Hono<AuthEnv>();
57admin.use("*", softAuth);
58
59async function gate(c: any): Promise<{ user: any } | Response> {
60 const user = c.get("user");
61 if (!user) return c.json({ error: "auth required" }, 401);
62 if (!(await isSiteAdmin(user.id))) {
63 return c.json({ error: "site admin required" }, 403);
64 }
65 return { user };
66}
67
68admin.post("/admin/deploys/trigger", async (c) => {
69 const g = await gate(c);
70 if (g instanceof Response) return g;
71 const { user } = g;
72
73 const token = ghToken();
74 if (!token) {
75 return c.json(
76 {
77 error:
78 "GITHUB_TOKEN is not set on the server — configure GITHUB_TOKEN on the box first (e.g. /etc/gluecron.env).",
79 },
80 400
81 );
82 }
83
84 // Body is optional — defaults are the hetzner deploy on main of this repo.
85 let body: any = {};
86 try {
87 body = await c.req.json();
88 } catch {
89 body = {};
90 }
91 const repo = String(body.repo || "ccantynz/Gluecron.com");
92 const workflow = String(body.workflow || "hetzner-deploy.yml");
93 const ref = String(body.ref || "main");
94 const [owner, name] = repo.split("/");
95 if (!owner || !name) {
96 return c.json({ error: "expected repo as owner/name" }, 400);
97 }
98
99 const url = `${GH_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`;
100 const res = await ghFetch()(url, {
101 method: "POST",
102 headers: {
103 accept: "application/vnd.github+json",
104 authorization: `Bearer ${token}`,
105 "content-type": "application/json",
106 "x-github-api-version": "2022-11-28",
107 "user-agent": "gluecron-admin",
108 },
109 body: JSON.stringify({ ref }),
110 });
111
112 if (res.status !== 204) {
113 const raw = await res.text();
114 let msg = raw;
115 try {
116 const j = JSON.parse(raw);
117 msg = j?.message || raw;
118 } catch {
119 // raw it is
120 }
121 return c.json(
122 { error: `github responded ${res.status}: ${msg || "request failed"}` },
123 502
124 );
125 }
126
127 await audit({
128 userId: user.id,
129 action: "admin.deploy.triggered",
130 targetType: "workflow",
131 targetId: `${repo}:${workflow}@${ref}`,
132 metadata: { repo, workflow, ref },
133 });
134
135 return c.json({ ok: true, repo, workflow, ref });
136});
137
138export default admin;
Modifiedsrc/routes/api-v2.ts+8−0View fileUnifiedSplit
@@ -310,6 +310,14 @@ apiv2.post("/repos", requireApiAuth, requireScope("repo"), async (c) => {
310310 return c.json({ error: "Invalid repository name" }, 400);
311311 }
312312
313 // P4 — plan-quota gate. 402 Payment Required is the canonical HTTP
314 // signal that the client should branch on (e.g. show an upgrade CTA).
315 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
316 const gate = await checkRepoCreateAllowed(user.id);
317 if (!gate.ok) {
318 return c.json({ error: gate.reason, upgrade_url: gate.upgradeUrl }, 402);
319 }
320
313321 if (await repoExists(user.username, body.name)) {
314322 return c.json({ error: "Repository already exists" }, 409);
315323 }
Modifiedsrc/routes/auth.tsx+67−2View fileUnifiedSplit
@@ -21,6 +21,7 @@ import {
2121 sessionExpiry,
2222} from "../lib/auth";
2323import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
24import { cancelAccountDeletion } from "../lib/account-deletion";
2425import { getSsoConfig, getGithubOauthConfig } from "../lib/sso";
2526import { Layout } from "../views/layout";
2627import {
@@ -86,6 +87,31 @@ auth.get("/register", softAuth, (c) => {
8687 aria-label="Password"
8788 />
8889 </FormGroup>
90 {/* P3 — Terms / Privacy acceptance. Required client-side via the
91 `required` attribute; server-side re-checked in POST handler. */}
92 <div class="form-group" style="margin: 12px 0">
93 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
94 <input
95 type="checkbox"
96 name="accept_terms"
97 value="1"
98 required
99 style="margin-top: 3px"
100 aria-label="Accept Terms of Service and Privacy Policy"
101 />
102 <span>
103 I agree to the{" "}
104 <a href="/legal/terms" target="_blank" rel="noopener">
105 Terms of Service
106 </a>{" "}
107 and{" "}
108 <a href="/legal/privacy" target="_blank" rel="noopener">
109 Privacy Policy
110 </a>
111 .
112 </span>
113 </label>
114 </div>
89115 <Button type="submit" variant="primary">
90116 Create account
91117 </Button>
@@ -108,6 +134,15 @@ auth.post("/register", async (c) => {
108134 return c.redirect("/register?error=All+fields+are+required");
109135 }
110136
137 // Block P3 — Terms acceptance is required. The form's checkbox has
138 // `required` so browsers normally enforce client-side; the server
139 // re-checks for defensive depth (curl, scripted POST, etc.).
140 if (!body.accept_terms) {
141 return c.redirect(
142 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
143 );
144 }
145
111146 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
112147 return c.redirect(
113148 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
@@ -157,7 +192,15 @@ auth.post("/register", async (c) => {
157192
158193 const [user] = await db
159194 .insert(users)
160 .values({ username, email, passwordHash, isAdmin: isFirstUser })
195 .values({
196 username,
197 email,
198 passwordHash,
199 isAdmin: isFirstUser,
200 // P3 — record terms acceptance now. Version bumps when Terms change.
201 termsAcceptedAt: new Date(),
202 termsVersion: "1.0",
203 })
161204 .returning();
162205
163206 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
@@ -176,7 +219,12 @@ auth.post("/register", async (c) => {
176219
177220 setCookie(c, "session", token, sessionCookieOptions());
178221
179 const redirect = c.req.query("redirect") || "/";
222 // Block P2 — fire-and-forget email verification. Never blocks registration.
223 import("../lib/email-verification").then((m) => m.startEmailVerification(user.id, email)).catch(() => {});
224
225 // P3 — default landing is /onboarding (the guided first-five-minutes
226 // flow). The `redirect=` query is still honoured for OAuth-style flows.
227 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
180228 return c.redirect(redirect);
181229});
182230
@@ -185,6 +233,7 @@ auth.get("/login", softAuth, async (c) => {
185233 // dashboard (or the `redirect=` target if one was supplied).
186234 const existing = c.get("user");
187235 const error = c.req.query("error");
236 const success = c.req.query("success");
188237 const redirect = c.req.query("redirect") || "";
189238 if (existing) return c.redirect(redirect || "/dashboard");
190239 const ssoCfg = await getSsoConfig();
@@ -207,6 +256,9 @@ auth.get("/login", softAuth, async (c) => {
207256 <div class="auth-container">
208257 <h2>Sign in</h2>
209258 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
259 {success && (
260 <div class="auth-success">{decodeURIComponent(success)}</div>
261 )}
210262 <Form
211263 method="post"
212264 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
@@ -232,6 +284,12 @@ auth.get("/login", softAuth, async (c) => {
232284 aria-label="Password"
233285 />
234286 </FormGroup>
287 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
288 <a href="/forgot-password">Forgot password?</a>
289 {/* BLOCK Q2 — magic-link sign-in. */}
290 <span style="margin:0 6px;color:var(--text-muted)">·</span>
291 <a href="/login/magic">Sign in with a magic link instead</a>
292 </div>
235293 <Button type="submit" variant="primary">
236294 Sign in
237295 </Button>
@@ -393,6 +451,13 @@ auth.post("/login", async (c) => {
393451 });
394452
395453 setCookie(c, "session", token, sessionCookieOptions());
454
455 // Block P5 — If account was scheduled for deletion but user signed back
456 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
457 if (user.deletedAt) {
458 await cancelAccountDeletion(user.id);
459 }
460
396461 if (needs2fa) {
397462 return c.redirect(
398463 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
Modifiedsrc/routes/dashboard.tsx+72−0View fileUnifiedSplit
@@ -16,6 +16,7 @@
1616
1717import { Hono } from "hono";
1818import { eq, desc, and } from "drizzle-orm";
19import { getCookie, setCookie } from "hono/cookie";
1920import { db } from "../db";
2021import {
2122 repositories,
@@ -54,6 +55,17 @@ dashboard.use("*", softAuth);
5455dashboard.get("/dashboard", requireAuth, async (c) => {
5556 const user = c.get("user")!;
5657
58 // Block P2 — banner dismiss handler. Set a session cookie and re-redirect
59 // to the bare /dashboard URL so refreshing doesn't keep firing the dismiss.
60 if (c.req.query("p2_dismiss") === "1") {
61 setCookie(c, "p2_verify_dismissed", "1", {
62 path: "/",
63 httpOnly: true,
64 sameSite: "Lax",
65 });
66 return c.redirect("/dashboard");
67 }
68
5769 // Get all user's repos
5870 const repos = await db
5971 .select()
@@ -155,8 +167,68 @@ dashboard.get("/dashboard", requireAuth, async (c) => {
155167 ? "var(--text-muted)"
156168 : "var(--red)";
157169
170 // Block P2 — email verification banner. Shows when the user hasn't
171 // verified yet AND they haven't dismissed it this session. Also surfaces
172 // transient resend feedback (`?verify=sent` / `?verify=rate_limited`)
173 // and the post-register hint (`?welcome=1`).
174 const verifyDismissed = getCookie(c, "p2_verify_dismissed") === "1";
175 const showVerifyBanner =
176 !(user as any).emailVerifiedAt && !verifyDismissed;
177 const verifyQuery = c.req.query("verify");
178 const welcomeQuery = c.req.query("welcome");
179
158180 return c.html(
159181 <Layout title="Command Center" user={user}>
182 {showVerifyBanner && (
183 <div
184 style="background: rgba(210, 153, 34, 0.12); border: 1px solid rgba(210, 153, 34, 0.45); color: #e3b341; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 14px"
185 data-p2-verify-banner=""
186 >
187 <div style="flex: 1 1 auto; min-width: 0">
188 {welcomeQuery === "1" ? (
189 <span>
190 Welcome to Gluecron! Check your inbox to verify your email.
191 </span>
192 ) : verifyQuery === "sent" ? (
193 <span>
194 Verification link sent. It may take a minute to arrive.
195 </span>
196 ) : verifyQuery === "rate_limited" ? (
197 <span>
198 You've requested too many verification emails. Try again later.
199 </span>
200 ) : (
201 <span>Verify your email to keep using Gluecron.</span>
202 )}
203 </div>
204 <form
205 method="post"
206 action="/verify-email/resend"
207 style="display: inline-flex; gap: 8px; align-items: center; margin: 0"
208 >
209 <input
210 type="hidden"
211 name="_csrf"
212 value={(c.get("csrfToken") as string | undefined) || ""}
213 />
214 <button
215 type="submit"
216 class="btn"
217 style="padding: 4px 10px; font-size: 12px"
218 >
219 Resend verification link
220 </button>
221 <a
222 href="/dashboard?p2_dismiss=1"
223 class="btn"
224 style="padding: 4px 10px; font-size: 12px"
225 aria-label="Dismiss verification banner"
226 >
227 Dismiss
228 </a>
229 </form>
230 </div>
231 )}
160232 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px">
161233 <div>
162234 <h1 style="font-size: 28px; margin-bottom: 4px">Command Center</h1>
Addedsrc/routes/dxt.ts+55−0View fileUnifiedSplit
@@ -0,0 +1,55 @@
1/**
2 * BLOCK Q1 — Claude Desktop (.dxt) extension download endpoint.
3 *
4 * GET /gluecron.dxt → serves public/gluecron.dxt
5 *
6 * The `.dxt` bundle is built by `scripts/build-dxt.sh`. If the build hasn't
7 * been run (file missing), we return a friendly 404 JSON payload pointing
8 * at the build step + the curl-pipe install fallback — never crash the
9 * page or 500.
10 *
11 * Coexists with `scripts/install.sh` (Block L2). The .dxt is the GUI sibling
12 * for non-CLI users; the install.sh path remains for terminal users.
13 */
14
15import { Hono } from "hono";
16import { existsSync, statSync } from "node:fs";
17import { join } from "node:path";
18
19const dxt = new Hono();
20
21const DXT_PATH = join(process.cwd(), "public", "gluecron.dxt");
22
23dxt.get("/gluecron.dxt", (c) => {
24 if (!existsSync(DXT_PATH)) {
25 return c.json(
26 {
27 error: "extension_not_built",
28 message:
29 "Gluecron .dxt bundle has not been built on this server yet. Run `bash scripts/build-dxt.sh` or install via the CLI fallback: `curl -sSL https://gluecron.com/install | bash`.",
30 fallback: "https://gluecron.com/install",
31 },
32 404
33 );
34 }
35
36 const stat = statSync(DXT_PATH);
37 const file = Bun.file(DXT_PATH);
38
39 return new Response(file.stream(), {
40 headers: {
41 // .dxt is a registered extension, but Anthropic has not (yet) published
42 // a canonical MIME type. octet-stream is the safe default — the
43 // Content-Disposition forces the OS to treat it as a file download
44 // which Claude Desktop's "Open With" hook can then claim.
45 "Content-Type": "application/octet-stream",
46 "Content-Disposition": 'attachment; filename="gluecron.dxt"',
47 "Content-Length": String(stat.size),
48 // Short cache window so deploys-with-new-bundle propagate within an
49 // hour. Public so CDNs / browser caches can hold it.
50 "Cache-Control": "public, max-age=3600",
51 },
52 });
53});
54
55export default dxt;
Addedsrc/routes/email-verification.tsx+116−0View fileUnifiedSplit
@@ -0,0 +1,116 @@
1/**
2 * Block P2 — email verification routes.
3 *
4 * GET /verify-email?token=… Consume a token. On success: 302 to
5 * /dashboard?verified=1 and fire-and-forget
6 * the welcome email. On failure: render a
7 * "link expired" page.
8 * POST /verify-email/resend requireAuth. Issues a fresh verification
9 * token. Rate-limited per user (3/hour).
10 */
11
12import { Hono } from "hono";
13import { eq } from "drizzle-orm";
14import { db } from "../db";
15import { users } from "../db/schema";
16import { Layout } from "../views/layout";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import {
20 consumeVerificationToken,
21 startEmailVerification,
22 sendWelcomeEmail,
23} from "../lib/email-verification";
24
25const verify = new Hono<AuthEnv>();
26
27const RESEND_LIMIT = 3;
28const RESEND_WINDOW_MS = 60 * 60 * 1000;
29const _resendLog: Map<string, number[]> = new Map();
30
31function checkResendRate(userId: string): { allowed: boolean; remaining: number } {
32 const now = Date.now();
33 const cutoff = now - RESEND_WINDOW_MS;
34 const recent = (_resendLog.get(userId) || []).filter((t) => t > cutoff);
35 if (recent.length >= RESEND_LIMIT) {
36 _resendLog.set(userId, recent);
37 return { allowed: false, remaining: 0 };
38 }
39 recent.push(now);
40 _resendLog.set(userId, recent);
41 return { allowed: true, remaining: RESEND_LIMIT - recent.length };
42}
43
44/** Test-only: wipe the in-memory rate-limit counters. */
45export function __resetResendRateLimitForTests(): void {
46 _resendLog.clear();
47}
48
49verify.get("/verify-email", softAuth, async (c) => {
50 const token = c.req.query("token") || "";
51 const user = c.get("user") || null;
52 const result = await consumeVerificationToken(token);
53
54 if (result.ok && result.userId) {
55 void sendWelcomeEmail(result.userId);
56 return c.redirect("/dashboard?verified=1");
57 }
58
59 return c.html(
60 <Layout title="Verification link expired" user={user}>
61 <div class="auth-container">
62 <h2>Link expired</h2>
63 <p style="color:var(--text-muted);font-size:14px;line-height:1.55">
64 That verification link is no longer valid. Links expire after 24
65 hours and can only be used once. Sign in and request a fresh link
66 from your dashboard.
67 </p>
68 <p class="auth-switch" style="margin-top:24px">
69 <a href="/login">Sign in</a>
70 </p>
71 </div>
72 </Layout>
73 );
74});
75
76verify.post("/verify-email/resend", requireAuth, async (c) => {
77 const user = c.get("user");
78 if (!user) return c.redirect("/login");
79
80 let email = user.email;
81 let verifiedAt: Date | null = (user as any).emailVerifiedAt
82 ? new Date((user as any).emailVerifiedAt as string | Date)
83 : null;
84 try {
85 const [fresh] = await db
86 .select({
87 email: users.email,
88 emailVerifiedAt: users.emailVerifiedAt,
89 })
90 .from(users)
91 .where(eq(users.id, user.id))
92 .limit(1);
93 if (fresh) {
94 email = fresh.email;
95 verifiedAt = fresh.emailVerifiedAt
96 ? new Date(fresh.emailVerifiedAt as unknown as string | Date)
97 : null;
98 }
99 } catch {
100 // best effort
101 }
102
103 if (verifiedAt) {
104 return c.redirect("/dashboard?verified=1");
105 }
106
107 const rate = checkResendRate(user.id);
108 if (!rate.allowed) {
109 return c.redirect("/dashboard?verify=rate_limited");
110 }
111
112 void startEmailVerification(user.id, email);
113 return c.redirect("/dashboard?verify=sent");
114});
115
116export default verify;
Modifiedsrc/routes/events.ts+319−0View fileUnifiedSplit
@@ -50,7 +50,9 @@ import { timingSafeEqual } from "crypto";
5050import { db } from "../db";
5151import { deployments, repositories, users } from "../db/schema";
5252import { processedEvents } from "../db/schema-events";
53import { platformDeploys } from "../db/schema-deploys";
5354import { notify } from "../lib/notify";
55import { publish } from "../lib/sse";
5456
5557const events = new Hono();
5658
@@ -376,12 +378,329 @@ events.post("/deploy", async (c) => {
376378 return c.json({ ok: true, duplicate: false });
377379});
378380
381// ---------------------------------------------------------------------------
382// Block N3 — Platform deploy timeline ingest.
383//
384// These endpoints are FOR THIS SITE. The Hetzner deploy workflow posts a
385// 'started' event when SSH begins and a 'finished' event on success/failure.
386// They power the admin status pill in `src/views/layout.tsx` and the
387// `/admin/deploys` timeline. NEW endpoints — they do NOT touch the Crontech
388// `/deploy` receiver above (which §4.6 locks the semantics of).
389//
390// POST /api/events/deploy/started
391// Authorization: Bearer ${DEPLOY_EVENT_TOKEN}
392// Body: { sha: "<40-hex>", run_id: "<string>", source: "<string>" }
393// 200 { ok: true, duplicate: false | true }
394// 401 invalid bearer
395// 400 malformed payload
396//
397// POST /api/events/deploy/finished
398// Authorization: Bearer ${DEPLOY_EVENT_TOKEN}
399// Body: { run_id, status: "succeeded"|"failed",
400// duration_ms?: number, error?: string }
401//
402// Idempotency: keyed on run_id via the UNIQUE constraint in migration 0046.
403// A duplicate 'started' POST is a no-op. A 'finished' POST without a prior
404// 'started' INSERTs a fresh row (so the timeline still records the deploy
405// even if the started-step silently dropped its packet).
406// ---------------------------------------------------------------------------
407
408const SHORT_SHA_RE = /^[0-9a-f]{7,64}$/i;
409const VALID_DEPLOY_STATUS: ReadonlySet<string> = new Set([
410 "succeeded",
411 "failed",
412]);
413
414function verifyDeployBearer(c: any): { ok: boolean; error?: string } {
415 const expected = process.env.DEPLOY_EVENT_TOKEN || "";
416 if (!expected) {
417 return {
418 ok: false,
419 error:
420 "Deploy event endpoint not configured: set DEPLOY_EVENT_TOKEN in the environment",
421 };
422 }
423 const auth = c.req.header("authorization") || "";
424 if (!auth.startsWith("Bearer ")) {
425 return { ok: false, error: "Missing Bearer token" };
426 }
427 const token = auth.slice(7).trim();
428 if (!constantTimeEq(token, expected)) {
429 return { ok: false, error: "Invalid bearer token" };
430 }
431 return { ok: true };
432}
433
434interface DeployStartedPayload {
435 sha: string;
436 run_id: string;
437 source: string;
438}
439
440interface DeployFinishedPayload {
441 run_id: string;
442 sha?: string;
443 status: "succeeded" | "failed";
444 duration_ms?: number;
445 error?: string;
446}
447
448function validateStarted(raw: unknown):
449 | { ok: true; payload: DeployStartedPayload }
450 | { ok: false; error: string } {
451 if (!raw || typeof raw !== "object") {
452 return { ok: false, error: "Body must be a JSON object" };
453 }
454 const p = raw as Record<string, unknown>;
455 if (typeof p.sha !== "string" || !SHORT_SHA_RE.test(p.sha)) {
456 return { ok: false, error: "sha must be a hex commit id (7-64 chars)" };
457 }
458 if (typeof p.run_id !== "string" || p.run_id.length === 0 || p.run_id.length > 128) {
459 return { ok: false, error: "run_id must be a non-empty string (≤128 chars)" };
460 }
461 if (typeof p.source !== "string" || p.source.length === 0 || p.source.length > 64) {
462 return { ok: false, error: "source must be a non-empty string (≤64 chars)" };
463 }
464 return {
465 ok: true,
466 payload: { sha: p.sha, run_id: p.run_id, source: p.source },
467 };
468}
469
470function validateFinished(raw: unknown):
471 | { ok: true; payload: DeployFinishedPayload }
472 | { ok: false; error: string } {
473 if (!raw || typeof raw !== "object") {
474 return { ok: false, error: "Body must be a JSON object" };
475 }
476 const p = raw as Record<string, unknown>;
477 if (typeof p.run_id !== "string" || p.run_id.length === 0 || p.run_id.length > 128) {
478 return { ok: false, error: "run_id must be a non-empty string (≤128 chars)" };
479 }
480 if (typeof p.status !== "string" || !VALID_DEPLOY_STATUS.has(p.status)) {
481 return {
482 ok: false,
483 error: "status must be 'succeeded' or 'failed'",
484 };
485 }
486 if (p.sha !== undefined && (typeof p.sha !== "string" || !SHORT_SHA_RE.test(p.sha))) {
487 return { ok: false, error: "sha must be a hex commit id when provided" };
488 }
489 if (p.duration_ms !== undefined) {
490 if (
491 typeof p.duration_ms !== "number" ||
492 !Number.isFinite(p.duration_ms) ||
493 p.duration_ms < 0
494 ) {
495 return { ok: false, error: "duration_ms must be a non-negative number" };
496 }
497 }
498 if (p.error !== undefined && typeof p.error !== "string") {
499 return { ok: false, error: "error must be a string when provided" };
500 }
501 return {
502 ok: true,
503 payload: {
504 run_id: p.run_id,
505 sha: typeof p.sha === "string" ? p.sha : undefined,
506 status: p.status as "succeeded" | "failed",
507 duration_ms:
508 typeof p.duration_ms === "number" ? p.duration_ms : undefined,
509 // Cap error text at 8 KB so a misbehaving emitter can't blow up
510 // the DB row (the workflow already truncates to 1 KB on its end).
511 error:
512 typeof p.error === "string" ? p.error.slice(0, 8 * 1024) : undefined,
513 },
514 };
515}
516
517const PLATFORM_DEPLOYS_TOPIC = "platform:deploys";
518
519events.post("/deploy/started", async (c) => {
520 const auth = verifyDeployBearer(c);
521 if (!auth.ok) {
522 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
523 }
524
525 let raw: unknown;
526 try {
527 raw = await c.req.json();
528 } catch {
529 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
530 }
531
532 const validated = validateStarted(raw);
533 if (!validated.ok) {
534 return c.json({ ok: false, error: validated.error }, 400);
535 }
536 const { sha, run_id, source } = validated.payload;
537
538 // INSERT-or-no-op on UNIQUE(run_id). If the row already exists we treat the
539 // call as a duplicate and don't republish — the original publish carried
540 // the canonical started-at timestamp.
541 let inserted: { id: string; startedAt: Date } | null = null;
542 try {
543 const rows = await db
544 .insert(platformDeploys)
545 .values({
546 runId: run_id,
547 sha,
548 source,
549 status: "in_progress",
550 })
551 .onConflictDoNothing({ target: platformDeploys.runId })
552 .returning({ id: platformDeploys.id, startedAt: platformDeploys.startedAt });
553 inserted = rows[0] ?? null;
554 } catch (err) {
555 console.error("[events/deploy/started] insert failed:", err);
556 return c.json({ ok: false, error: "Failed to persist deploy event" }, 500);
557 }
558
559 if (!inserted) {
560 return c.json({ ok: true, duplicate: true });
561 }
562
563 publish(PLATFORM_DEPLOYS_TOPIC, {
564 event: "deploy.started",
565 data: {
566 id: inserted.id,
567 run_id,
568 sha,
569 source,
570 status: "in_progress",
571 started_at: inserted.startedAt.toISOString(),
572 },
573 });
574
575 return c.json({ ok: true, duplicate: false });
576});
577
578events.post("/deploy/finished", async (c) => {
579 const auth = verifyDeployBearer(c);
580 if (!auth.ok) {
581 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
582 }
583
584 let raw: unknown;
585 try {
586 raw = await c.req.json();
587 } catch {
588 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
589 }
590
591 const validated = validateFinished(raw);
592 if (!validated.ok) {
593 return c.json({ ok: false, error: validated.error }, 400);
594 }
595 const payload = validated.payload;
596
597 const finishedAt = new Date();
598
599 let row:
600 | {
601 id: string;
602 runId: string;
603 sha: string;
604 source: string;
605 status: string;
606 startedAt: Date;
607 finishedAt: Date | null;
608 durationMs: number | null;
609 error: string | null;
610 }
611 | null = null;
612
613 try {
614 const updated = await db
615 .update(platformDeploys)
616 .set({
617 status: payload.status,
618 finishedAt,
619 durationMs: payload.duration_ms ?? null,
620 error: payload.error ?? null,
621 })
622 .where(eq(platformDeploys.runId, payload.run_id))
623 .returning({
624 id: platformDeploys.id,
625 runId: platformDeploys.runId,
626 sha: platformDeploys.sha,
627 source: platformDeploys.source,
628 status: platformDeploys.status,
629 startedAt: platformDeploys.startedAt,
630 finishedAt: platformDeploys.finishedAt,
631 durationMs: platformDeploys.durationMs,
632 error: platformDeploys.error,
633 });
634 row = updated[0] ?? null;
635 } catch (err) {
636 console.error("[events/deploy/finished] update failed:", err);
637 return c.json({ ok: false, error: "Failed to persist deploy event" }, 500);
638 }
639
640 // No matching started row — record a finished-only entry so the timeline
641 // still reflects the deploy. Source/sha fall back to defaults; this is the
642 // "started packet got dropped" recovery path.
643 if (!row) {
644 try {
645 const inserted = await db
646 .insert(platformDeploys)
647 .values({
648 runId: payload.run_id,
649 sha: payload.sha ?? "unknown",
650 source: "hetzner-deploy",
651 status: payload.status,
652 finishedAt,
653 durationMs: payload.duration_ms ?? null,
654 error: payload.error ?? null,
655 })
656 .returning({
657 id: platformDeploys.id,
658 runId: platformDeploys.runId,
659 sha: platformDeploys.sha,
660 source: platformDeploys.source,
661 status: platformDeploys.status,
662 startedAt: platformDeploys.startedAt,
663 finishedAt: platformDeploys.finishedAt,
664 durationMs: platformDeploys.durationMs,
665 error: platformDeploys.error,
666 });
667 row = inserted[0] ?? null;
668 } catch (err) {
669 console.error("[events/deploy/finished] backfill insert failed:", err);
670 return c.json({ ok: false, error: "Failed to persist deploy event" }, 500);
671 }
672 }
673
674 if (row) {
675 publish(PLATFORM_DEPLOYS_TOPIC, {
676 event: "deploy.finished",
677 data: {
678 id: row.id,
679 run_id: row.runId,
680 sha: row.sha,
681 source: row.source,
682 status: row.status,
683 started_at: row.startedAt.toISOString(),
684 finished_at: row.finishedAt ? row.finishedAt.toISOString() : null,
685 duration_ms: row.durationMs,
686 error: row.error,
687 },
688 });
689 }
690
691 return c.json({ ok: true });
692});
693
379694// Test-only access for unit tests that want to exercise helpers directly.
380695export const __test = {
381696 constantTimeEq,
382697 validatePayload,
383698 resolveRepo,
384699 findTargetDeployment,
700 validateStarted,
701 validateFinished,
702 verifyDeployBearer,
703 PLATFORM_DEPLOYS_TOPIC,
385704};
386705
387706export default events;
Modifiedsrc/routes/import.tsx+8−0View fileUnifiedSplit
@@ -312,6 +312,14 @@ importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) =>
312312 return c.redirect("/import?error=Repository+URL+is+required");
313313 }
314314
315 // P4 — same quota gate as /new + /api/v2/repos. Imports count toward
316 // the user's plan limit.
317 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
318 const gate = await checkRepoCreateAllowed(user.id);
319 if (!gate.ok) {
320 return c.redirect(`/import?error=${encodeURIComponent(gate.reason)}`);
321 }
322
315323 const parsed = parseGithubUrl(repoUrl);
316324 if (!parsed) {
317325 return c.redirect(
Addedsrc/routes/magic-link.tsx+175−0View fileUnifiedSplit
@@ -0,0 +1,175 @@
1/**
2 * Block Q2 — Magic-link sign-in routes.
3 *
4 * GET /login/magic → email-entry form
5 * POST /login/magic → always redirects to ?sent=1
6 * GET /login/magic/callback?token=… → consume token, set session, redirect
7 *
8 * Structurally a sibling of `password-reset.tsx`. Differences:
9 * - The "callback" lands the user directly into a fresh session — there
10 * is no second form to fill in. We mint a session cookie and bounce
11 * to /dashboard (existing user) or /onboarding?welcome=1 (auto-created).
12 * - 15-minute TTL is messaged on the success/dead-link pages.
13 */
14
15import { Hono } from "hono";
16import { setCookie } from "hono/cookie";
17import { db } from "../db";
18import { sessions } from "../db/schema";
19import {
20 generateSessionToken,
21 sessionCookieOptions,
22 sessionExpiry,
23} from "../lib/auth";
24import { Layout } from "../views/layout";
25import { Form, FormGroup, Input, Button, Alert, Text } from "../views/ui";
26import { softAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import {
29 startMagicLinkSignIn,
30 consumeMagicLinkToken,
31} from "../lib/magic-link";
32
33const magicLink = new Hono<AuthEnv>();
34
35// ---------------------------------------------------------------------------
36// GET /login/magic — entry form + post-submit success.
37// ---------------------------------------------------------------------------
38
39magicLink.get("/login/magic", softAuth, (c) => {
40 const existing = c.get("user");
41 if (existing) return c.redirect("/dashboard");
42
43 const csrf = c.get("csrfToken") as string | undefined;
44 const sent = c.req.query("sent") === "1";
45
46 if (sent) {
47 return c.html(
48 <Layout title="Check your inbox" user={null}>
49 <div class="auth-container">
50 <h2>Check your inbox</h2>
51 <Alert variant="success">
52 If we can sign you in with that email, we've sent a link. It
53 expires in 15 minutes.
54 </Alert>
55 <p class="auth-switch">
56 <Text>
57 Didn't get it? Check your spam folder, or{" "}
58 <a href="/login/magic">try again</a>.
59 </Text>
60 </p>
61 <p class="auth-switch">
62 <a href="/login">Back to sign in</a>
63 </p>
64 </div>
65 </Layout>
66 );
67 }
68
69 return c.html(
70 <Layout title="Sign in with email link" user={null}>
71 <div class="auth-container">
72 <h2>Sign in with a magic link</h2>
73 <p class="auth-switch" style="margin-bottom:16px;margin-top:0">
74 <Text>
75 Enter your email and we'll send you a one-time sign-in link. No
76 password needed.
77 </Text>
78 </p>
79 <Form method="post" action="/login/magic" csrfToken={csrf}>
80 <FormGroup label="Email" htmlFor="email">
81 <Input
82 type="email"
83 name="email"
84 required
85 placeholder="you@example.com"
86 autocomplete="email"
87 aria-label="Email"
88 />
89 </FormGroup>
90 <Button type="submit" variant="primary">
91 Send me a sign-in link
92 </Button>
93 </Form>
94 <p class="auth-switch">
95 <Text>
96 Prefer a password?{" "}
97 <a href="/login">Sign in the usual way</a>.
98 </Text>
99 </p>
100 </div>
101 </Layout>
102 );
103});
104
105// ---------------------------------------------------------------------------
106// POST /login/magic — always redirects to ?sent=1 (no enumeration).
107// ---------------------------------------------------------------------------
108
109magicLink.post("/login/magic", async (c) => {
110 const body = await c.req.parseBody();
111 const email = String(body.email || "").trim();
112 const ip =
113 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
114 c.req.header("x-real-ip") ||
115 undefined;
116 await startMagicLinkSignIn(email, { requestIp: ip });
117 return c.redirect("/login/magic?sent=1");
118});
119
120// ---------------------------------------------------------------------------
121// GET /login/magic/callback?token=… — consume the link.
122// ---------------------------------------------------------------------------
123
124function InvalidLinkPage(props: { user: any }) {
125 return (
126 <Layout title="Link no longer valid" user={props.user ?? null}>
127 <div class="auth-container">
128 <h2>This link is no longer valid</h2>
129 <Alert variant="error">
130 Magic links expire after 15 minutes and can only be used once.
131 This link is expired, already used, or unknown.
132 </Alert>
133 <p class="auth-switch" style="margin-top:16px">
134 <a href="/login/magic">Send a fresh one</a>
135 </p>
136 <p class="auth-switch">
137 <a href="/login">Back to sign in</a>
138 </p>
139 </div>
140 </Layout>
141 );
142}
143
144magicLink.get("/login/magic/callback", softAuth, async (c) => {
145 const token = String(c.req.query("token") || "").trim();
146 if (!token) return c.html(<InvalidLinkPage user={c.get("user")} />);
147
148 const ip =
149 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
150 c.req.header("x-real-ip") ||
151 undefined;
152
153 const result = await consumeMagicLinkToken(token, { requestIp: ip });
154 if (!result.ok || !result.userId) {
155 return c.html(<InvalidLinkPage user={c.get("user")} />);
156 }
157
158 // Mint a fresh session for this user. We deliberately do NOT honour
159 // existing 2FA on magic-link sign-in here — the magic-link flow is
160 // explicitly for users who don't manage a password and 2FA enrollment
161 // requires a password in our current setup. If/when 2FA is decoupled
162 // from password auth (Q3+), this is the place to gate.
163 const sessionToken = generateSessionToken();
164 await db.insert(sessions).values({
165 userId: result.userId,
166 token: sessionToken,
167 expiresAt: sessionExpiry(),
168 });
169 setCookie(c, "session", sessionToken, sessionCookieOptions());
170
171 if (result.createdAccount) return c.redirect("/onboarding?welcome=1");
172 return c.redirect("/dashboard");
173});
174
175export default magicLink;
Modifiedsrc/routes/onboarding.tsx+17−2View fileUnifiedSplit
@@ -28,8 +28,12 @@ import {
2828
2929const onboardingRoutes = new Hono<AuthEnv>();
3030
31onboardingRoutes.get("/getting-started", softAuth, requireAuth, async (c) => {
31// P3 — `/onboarding` is the canonical post-register landing. Alias the
32// existing `/getting-started` handler so both URLs work; new users hit
33// /onboarding?welcome=1 and see a celebration banner.
34const gettingStartedHandler = async (c: any) => {
3235 const user = c.get("user")!;
36 const welcome = c.req.query("welcome") === "1";
3337
3438 // Check what the user has done
3539 let repoCount = 0;
@@ -61,6 +65,14 @@ onboardingRoutes.get("/getting-started", softAuth, requireAuth, async (c) => {
6165 return c.html(
6266 <Layout title="Getting Started" user={user}>
6367 <Container maxWidth={760}>
68 {welcome && (
69 <div
70 data-onboarding-welcome="1"
71 style="margin: 12px 0 20px; padding: 14px 18px; border-radius: 12px; background: linear-gradient(135deg, rgba(140,109,255,0.18) 0%, rgba(54,197,214,0.18) 100%); border: 1px solid rgba(140,109,255,0.45); font-size: 14px; color: var(--text-strong)"
72 >
73 🎉 Welcome to Gluecron! Let's get you set up.
74 </div>
75 )}
6476 {/* ─── Welcome headline + 1-line value prop ─── */}
6577 <WelcomeHero
6678 title={firstRun ? `Welcome, ${user.username}` : "Finish setting up"}
@@ -185,6 +197,9 @@ onboardingRoutes.get("/getting-started", softAuth, requireAuth, async (c) => {
185197 </Container>
186198 </Layout>
187199 );
188});
200};
201
202onboardingRoutes.get("/getting-started", softAuth, requireAuth, gettingStartedHandler);
203onboardingRoutes.get("/onboarding", softAuth, requireAuth, gettingStartedHandler);
189204
190205export default onboardingRoutes;
Addedsrc/routes/password-reset.tsx+143−0View fileUnifiedSplit
@@ -0,0 +1,143 @@
1/**
2 * Block P1 — Password reset routes.
3 *
4 * GET /forgot-password → email-entry form
5 * POST /forgot-password → always redirects to ?sent=1
6 * GET /reset-password?token=… → new-password form (or invalid-link page)
7 * POST /reset-password → rotate password + redirect to /login
8 */
9
10import { Hono } from "hono";
11import { Layout } from "../views/layout";
12import { Form, FormGroup, Input, Button, Alert, Text } from "../views/ui";
13import { softAuth } from "../middleware/auth";
14import type { AuthEnv } from "../middleware/auth";
15import {
16 createPasswordResetRequest,
17 consumeResetToken,
18 inspectResetToken,
19} from "../lib/password-reset";
20
21const passwordReset = new Hono<AuthEnv>();
22
23passwordReset.get("/forgot-password", softAuth, (c) => {
24 const csrf = c.get("csrfToken") as string | undefined;
25 const sent = c.req.query("sent") === "1";
26
27 if (sent) {
28 return c.html(
29 <Layout title="Reset link sent" user={c.get("user") ?? null}>
30 <div class="auth-container">
31 <h2>Check your inbox</h2>
32 <Alert variant="success">
33 If we have an account for that email, we've sent a reset link.
34 Check your inbox (and spam folder).
35 </Alert>
36 <p class="auth-switch"><Text>The link expires in 1 hour.</Text></p>
37 <p class="auth-switch"><a href="/login">Back to sign in</a></p>
38 </div>
39 </Layout>
40 );
41 }
42
43 return c.html(
44 <Layout title="Forgot password" user={c.get("user") ?? null}>
45 <div class="auth-container">
46 <h2>Reset your password</h2>
47 <p class="auth-switch" style="margin-bottom:16px;margin-top:0">
48 <Text>Enter the email tied to your account and we'll send you a link to set a new password.</Text>
49 </p>
50 <Form method="post" action="/forgot-password" csrfToken={csrf}>
51 <FormGroup label="Email" htmlFor="email">
52 <Input type="email" name="email" required placeholder="you@example.com" autocomplete="email" aria-label="Email" />
53 </FormGroup>
54 <Button type="submit" variant="primary">Send reset link</Button>
55 </Form>
56 <p class="auth-switch"><Text>Remembered it? <a href="/login">Sign in</a></Text></p>
57 </div>
58 </Layout>
59 );
60});
61
62passwordReset.post("/forgot-password", async (c) => {
63 const body = await c.req.parseBody();
64 const email = String(body.email || "").trim();
65 const ip =
66 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
67 c.req.header("x-real-ip") ||
68 undefined;
69 await createPasswordResetRequest(email, { requestIp: ip });
70 return c.redirect("/forgot-password?sent=1");
71});
72
73function InvalidLinkPage(props: { user: any }) {
74 return (
75 <Layout title="Link no longer valid" user={props.user ?? null}>
76 <div class="auth-container">
77 <h2>This link is no longer valid</h2>
78 <Alert variant="error">
79 Reset links expire after 1 hour and can only be used once. This link is expired, already used, or unknown.
80 </Alert>
81 <p class="auth-switch" style="margin-top:16px"><a href="/forgot-password">Request a new one</a></p>
82 <p class="auth-switch"><a href="/login">Back to sign in</a></p>
83 </div>
84 </Layout>
85 );
86}
87
88passwordReset.get("/reset-password", softAuth, async (c) => {
89 const token = String(c.req.query("token") || "").trim();
90 const csrf = c.get("csrfToken") as string | undefined;
91 const error = c.req.query("error");
92
93 if (!token) return c.html(<InvalidLinkPage user={c.get("user")} />);
94 const check = await inspectResetToken(token);
95 if (!check.valid) return c.html(<InvalidLinkPage user={c.get("user")} />);
96
97 return c.html(
98 <Layout title="Set a new password" user={c.get("user") ?? null}>
99 <div class="auth-container">
100 <h2>Set a new password</h2>
101 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
102 <p class="auth-switch" style="margin-bottom:16px;margin-top:0">
103 <Text>Choose a new password — at least 8 characters. Signing in from other devices will be required afterwards.</Text>
104 </p>
105 <Form method="post" action="/reset-password" csrfToken={csrf}>
106 <input type="hidden" name="token" value={token} />
107 <FormGroup label="New password" htmlFor="password">
108 <Input type="password" name="password" required minLength={8} placeholder="Min 8 characters" autocomplete="new-password" aria-label="New password" />
109 </FormGroup>
110 <FormGroup label="Confirm new password" htmlFor="confirm">
111 <Input type="password" name="confirm" required minLength={8} placeholder="Re-enter the new password" autocomplete="new-password" aria-label="Confirm new password" />
112 </FormGroup>
113 <Button type="submit" variant="primary">Update password</Button>
114 </Form>
115 <p class="auth-switch"><a href="/login">Cancel</a></p>
116 </div>
117 </Layout>
118 );
119});
120
121passwordReset.post("/reset-password", async (c) => {
122 const body = await c.req.parseBody();
123 const token = String(body.token || "").trim();
124 const password = String(body.password || "");
125 const confirm = String(body.confirm || "");
126
127 const back = (msg: string) =>
128 c.redirect(`/reset-password?token=${encodeURIComponent(token)}&error=${encodeURIComponent(msg)}`);
129
130 if (!token) return c.html(<InvalidLinkPage user={null} />);
131 if (!password || password.length < 8) return back("Password must be at least 8 characters");
132 if (password !== confirm) return back("Passwords do not match");
133
134 const result = await consumeResetToken(token, password);
135 if (!result.ok) {
136 if (result.reason === "weak") return back("Password must be at least 8 characters");
137 return c.html(<InvalidLinkPage user={null} />);
138 }
139
140 return c.redirect("/login?success=" + encodeURIComponent("Password updated — please sign in"));
141});
142
143export default passwordReset;
Addedsrc/routes/playground.tsx+366−0View fileUnifiedSplit
@@ -0,0 +1,366 @@
1/**
2 * Block Q3 — Anonymous playground routes.
3 *
4 * GET /play — landing page; one-button start.
5 * POST /play — mint a playground account, set the cookie,
6 * redirect to the sandbox.
7 * GET /play/claim — render the "Save your work" form. requireAuth.
8 * POST /play/claim — call claimPlaygroundAccount, redirect to dashboard.
9 *
10 * POST /play is rate-limited at 3/min/IP via the shared rate-limit
11 * middleware so a bot can't hammer the endpoint and mint accounts +
12 * repos by the thousand.
13 */
14
15import { Hono } from "hono";
16import { setCookie } from "hono/cookie";
17import { Layout } from "../views/layout";
18import { softAuth, requireAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import {
21 Form,
22 FormGroup,
23 Input,
24 Button,
25 Text,
26} from "../views/ui";
27import { rateLimit } from "../middleware/rate-limit";
28import {
29 createPlaygroundAccount,
30 claimPlaygroundAccount,
31 PLAYGROUND_TTL_MS,
32} from "../lib/playground";
33import { sessionCookieOptions } from "../lib/auth";
34
35const playgroundRoutes = new Hono<AuthEnv>();
36
37// ── 3 req / min / IP cap on POST /play. The shared rate-limit
38// middleware no-ops in test env (so the 1756-test suite isn't fragile)
39// but enforces in prod / dev.
40const playgroundCreateRateLimit = rateLimit(3, 60_000, "playground-create");
41
42// ---------------------------------------------------------------------------
43// GET /play — landing page
44// ---------------------------------------------------------------------------
45
46playgroundRoutes.get("/play", softAuth, (c) => {
47 const user = c.get("user");
48 const csrf = c.get("csrfToken") as string | undefined;
49 const err = c.req.query("error");
50 const hours = Math.round(PLAYGROUND_TTL_MS / (60 * 60 * 1000));
51 return c.html(
52 <Layout
53 title="Try Gluecron — no signup"
54 user={user ?? null}
55 description={`Try Gluecron for ${hours} hours, no signup. Get a sandbox repo and watch Claude work.`}
56 ogTitle="Try Gluecron — no signup"
57 ogDescription="A 24-hour public sandbox. Push, open issues, watch Claude work."
58 >
59 <div class="play-landing">
60 <div class="play-card">
61 <div class="play-eyebrow">PLAYGROUND</div>
62 <h1 class="play-title">
63 Try Gluecron for {hours} hours.
64 <br />
65 <span class="play-title-accent">No signup.</span>
66 </h1>
67 <p class="play-sub">
68 One click and you're inside the product — a public sandbox
69 repo, real git, real issues, Claude already working on the
70 first one. Decide later whether to keep it.
71 </p>
72
73 {err && (
74 <div class="auth-error" role="alert">
75 {decodeURIComponent(err)}
76 </div>
77 )}
78
79 <Form
80 method="post"
81 action="/play"
82 csrfToken={csrf}
83 class="play-form"
84 >
85 <Button type="submit" variant="primary">
86 Start playing →
87 </Button>
88 </Form>
89
90 <ul class="play-bullets" aria-label="What you get">
91 <li>
92 <strong>A sandbox repo</strong> — public, real git, real
93 push, real issues.
94 </li>
95 <li>
96 <strong>Claude is already working</strong> — one issue
97 is labelled <code>ai:build</code> so the autopilot picks it
98 up within minutes.
99 </li>
100 <li>
101 <strong>{hours} hours to try every feature</strong> —
102 gates, branch protection, AI review, the lot.
103 </li>
104 <li>
105 <strong>Save your work</strong> — one button converts
106 the playground account into a real one. Otherwise: poof.
107 </li>
108 </ul>
109
110 <p class="play-footnote">
111 Already have an account?{" "}
112 <a href="/login">Sign in</a> or{" "}
113 <a href="/register">create one</a> the normal way.
114 </p>
115 </div>
116 </div>
117
118 <style
119 dangerouslySetInnerHTML={{
120 __html: /* css */ `
121 .play-landing {
122 max-width: 720px;
123 margin: 48px auto;
124 padding: 0 24px;
125 }
126 .play-card {
127 background: var(--panel, #161b22);
128 border: 1px solid var(--border, #30363d);
129 border-radius: 16px;
130 padding: 40px;
131 text-align: center;
132 }
133 .play-eyebrow {
134 font-family: var(--font-mono, ui-monospace, monospace);
135 font-size: 11px;
136 letter-spacing: 0.18em;
137 color: var(--yellow, #fbbf24);
138 margin-bottom: 12px;
139 }
140 .play-title {
141 margin: 0 0 16px;
142 font-size: 36px;
143 line-height: 1.15;
144 font-weight: 700;
145 color: var(--text-strong, #e6edf3);
146 }
147 .play-title-accent {
148 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
149 -webkit-background-clip: text;
150 background-clip: text;
151 color: transparent;
152 }
153 .play-sub {
154 margin: 0 auto 24px;
155 max-width: 480px;
156 font-size: 15px;
157 line-height: 1.55;
158 color: var(--text-muted, #8b949e);
159 }
160 .play-form {
161 margin: 24px 0;
162 display: inline-block;
163 }
164 .play-form button[type="submit"] {
165 font-size: 16px;
166 padding: 14px 28px;
167 }
168 .play-bullets {
169 margin: 24px auto 0;
170 max-width: 520px;
171 padding-left: 20px;
172 text-align: left;
173 font-size: 14px;
174 line-height: 1.7;
175 color: var(--text, #c9d1d9);
176 }
177 .play-bullets li { margin-bottom: 6px; }
178 .play-bullets code {
179 font-family: var(--font-mono, ui-monospace, monospace);
180 font-size: 12px;
181 padding: 1px 6px;
182 border-radius: 4px;
183 background: rgba(140,109,255,0.16);
184 color: #c8b6ff;
185 }
186 .play-footnote {
187 margin: 24px 0 0;
188 font-size: 13px;
189 color: var(--text-muted, #8b949e);
190 }
191 `,
192 }}
193 />
194 </Layout>
195 );
196});
197
198// ---------------------------------------------------------------------------
199// POST /play — mint
200// ---------------------------------------------------------------------------
201
202playgroundRoutes.post("/play", playgroundCreateRateLimit, async (c) => {
203 const ip =
204 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
205 c.req.header("x-real-ip") ||
206 undefined;
207 let result;
208 try {
209 result = await createPlaygroundAccount({ requestIp: ip });
210 } catch (err) {
211 // createPlaygroundAccount is supposed to never throw, but if a
212 // freshly-deployed migration is missing or anything else goes
213 // sideways, fall back to a graceful redirect.
214 console.error("[playground] /play POST threw:", err);
215 return c.redirect(
216 "/play?error=Could+not+create+playground+account.+Try+again."
217 );
218 }
219
220 if (!result.sessionToken || !result.user.id) {
221 return c.redirect(
222 "/play?error=Could+not+create+playground+account.+Try+again."
223 );
224 }
225
226 // 24h cookie matches the playground TTL so the cookie can't outlive
227 // the account in the DB. `sessionCookieOptions()` defaults to 30d
228 // maxAge; override here.
229 const base = sessionCookieOptions();
230 setCookie(c, "session", result.sessionToken, {
231 ...base,
232 maxAge: Math.floor(PLAYGROUND_TTL_MS / 1000),
233 });
234
235 return c.redirect(
236 `/${result.user.username}/sandbox?welcome=1`
237 );
238});
239
240// ---------------------------------------------------------------------------
241// GET /play/claim — render the "Save your work" form
242// ---------------------------------------------------------------------------
243
244playgroundRoutes.get("/play/claim", requireAuth, (c) => {
245 const user = c.get("user")!;
246 const csrf = c.get("csrfToken") as string | undefined;
247 const err = c.req.query("error");
248
249 // Already-real users: bounce home with a hint.
250 if (!(user as any).isPlayground) {
251 return c.redirect("/dashboard?info=Your+account+is+already+saved");
252 }
253
254 return c.html(
255 <Layout title="Save your playground" user={user}>
256 <div class="auth-container">
257 <h2>Save your work</h2>
258 <p class="auth-switch" style="margin-bottom: 16px; margin-top: 0">
259 Convert{" "}
260 <code class="mono">{user.username}</code>{" "}
261 into a permanent account. We'll send a verification link to your
262 email; nothing is changed about the repo you've been working in.
263 </p>
264 {err && (
265 <div class="auth-error" role="alert">
266 {decodeURIComponent(err)}
267 </div>
268 )}
269 <Form
270 method="post"
271 action="/play/claim"
272 csrfToken={csrf}
273 >
274 <FormGroup label="Email" htmlFor="email">
275 <Input
276 type="email"
277 name="email"
278 required
279 placeholder="you@example.com"
280 autocomplete="email"
281 aria-label="Email"
282 />
283 </FormGroup>
284 <FormGroup label="Password" htmlFor="password">
285 <Input
286 type="password"
287 name="password"
288 required
289 minLength={8}
290 placeholder="Min 8 characters"
291 autocomplete="new-password"
292 aria-label="Password"
293 />
294 </FormGroup>
295 <FormGroup
296 label="Pick a new username (optional)"
297 htmlFor="username"
298 >
299 <Input
300 type="text"
301 name="username"
302 pattern="^[a-zA-Z0-9_-]+$"
303 minLength={2}
304 maxLength={39}
305 placeholder={user.username}
306 autocomplete="username"
307 />
308 </FormGroup>
309 <Button type="submit" variant="primary">
310 Save my account
311 </Button>
312 </Form>
313 <p class="auth-switch">
314 <Text>
315 Changed your mind?{" "}
316 <a href={`/${user.username}/sandbox`}>Back to the sandbox</a>
317 </Text>
318 </p>
319 </div>
320 </Layout>
321 );
322});
323
324// ---------------------------------------------------------------------------
325// POST /play/claim — convert to real account
326// ---------------------------------------------------------------------------
327
328const CLAIM_REASON_TO_MSG: Record<string, string> = {
329 invalid_email: "That doesn't look like a valid email.",
330 password_too_short: "Password must be at least 8 characters.",
331 invalid_username:
332 "Usernames may only contain letters, numbers, hyphens and underscores (2–39 chars).",
333 email_taken: "That email is already registered.",
334 username_taken: "That username is already taken.",
335 not_a_playground_account: "Your account is already saved.",
336 user_not_found: "Account not found. Please sign in again.",
337 lookup_failed: "Service unavailable. Please try again.",
338 update_failed: "Service unavailable. Please try again.",
339};
340
341playgroundRoutes.post("/play/claim", requireAuth, async (c) => {
342 const user = c.get("user")!;
343 const body = await c.req.parseBody();
344 const email = String(body.email || "");
345 const password = String(body.password || "");
346 const usernameRaw = String(body.username || "").trim();
347
348 const result = await claimPlaygroundAccount(user.id, {
349 email,
350 password,
351 username: usernameRaw ? usernameRaw : undefined,
352 });
353
354 if (!result.ok) {
355 const msg =
356 (result.reason && CLAIM_REASON_TO_MSG[result.reason]) ||
357 "Could not save account. Try again.";
358 return c.redirect(
359 `/play/claim?error=${encodeURIComponent(msg)}`
360 );
361 }
362
363 return c.redirect("/dashboard?info=Account+saved.+Check+your+email+to+verify.");
364});
365
366export default playgroundRoutes;
Modifiedsrc/routes/settings.tsx+101−0View fileUnifiedSplit
@@ -15,6 +15,12 @@ import {
1515 composeSleepModeReport,
1616 renderSleepModeDigest,
1717} from "../lib/sleep-mode";
18import {
19 scheduleAccountDeletion,
20 cancelAccountDeletion,
21 daysUntilPurge,
22} from "../lib/account-deletion";
23import { deleteCookie } from "hono/cookie";
1824import {
1925 Alert,
2026 Button,
@@ -273,11 +279,106 @@ settings.get("/settings", (c) => {
273279 />
274280 </div>
275281 <script dangerouslySetInnerHTML={{ __html: pushDeviceScript }} />
282 {renderDeleteAccountSection({ user, csrfToken: c.get("csrfToken") })}
276283 </div>
277284 </Layout>
278285 );
279286});
280287
288/** Block P5 — Danger zone at bottom of /settings. */
289function renderDeleteAccountSection(args: {
290 user: { id: string; username: string; deletedAt: Date | null; deletionScheduledFor: Date | null };
291 csrfToken: string | undefined;
292}) {
293 const { user, csrfToken } = args;
294 const scheduled = user.deletedAt !== null;
295 const daysLeft = daysUntilPurge({ deletionScheduledFor: user.deletionScheduledFor });
296 const dangerStyle =
297 "margin-top:48px;padding:20px;border:1px solid #e5484d;border-radius:8px;background:rgba(229,72,77,0.04)";
298
299 if (scheduled) {
300 return (
301
302 <h3 style="margin:0 0 8px 0;font-size:16px;color:#e5484d">
303 Account scheduled for deletion
304 </h3>
305 <p style="margin:0 0 12px 0;font-size:14px">
306 Your account is scheduled for permanent deletion in{" "}
307 <strong>{daysLeft ?? 0}</strong>{" "}
308 {daysLeft === 1 ? "day" : "days"}. Cancel below to keep your
309 account; signing in again also cancels the deletion automatically.
310 </p>
311 <form method="post" action="/settings/delete-account/cancel">
312 <input type="hidden" name="_csrf" value={csrfToken || ""} />
313 <button type="submit" class="btn btn-primary">
314 Cancel deletion
315 </button>
316 </form>
317 </div>
318 );
319 }
320
321 return (
322
323 <h3 style="margin:0 0 8px 0;font-size:16px;color:#e5484d">
324 Delete account
325 </h3>
326 <p style="margin:0 0 8px 0;font-size:14px">
327 Deleting your account starts a 30-day grace period. Your repos,
328 issues, PRs, and settings are kept during that window — sign in
329 any time before it ends to cancel. After 30 days everything is
330 permanently purged.
331 </p>
332 <p style="margin:0 0 12px 0;font-size:13px;color:var(--text-muted)">
333 To confirm, type your username (<code>{user.username}</code>) below.
334 </p>
335 <form method="post" action="/settings/delete-account">
336 <input type="hidden" name="_csrf" value={csrfToken || ""} />
337 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
338 <input
339 type="text"
340 name="confirm_username"
341 required
342 autocomplete="off"
343 placeholder={user.username}
344 aria-label="Type your username to confirm account deletion"
345 style="font-family:var(--font-mono);font-size:13px;padding:6px 8px;min-width:220px"
346 />
347 <button type="submit" class="btn btn-danger">
348 Delete my account
349 </button>
350 </div>
351 </form>
352 </div>
353 );
354}
355
356// Block P5 — schedule a deletion.
357settings.post("/settings/delete-account", async (c) => {
358 const user = c.get("user")!;
359 const body = await c.req.parseBody();
360 const confirm = String(body.confirm_username || "").trim();
361 if (confirm !== user.username) {
362 return c.text(
363 "Username confirmation did not match. Account NOT deleted.",
364 400
365 );
366 }
367 const result = await scheduleAccountDeletion(user.id);
368 if (!result.ok) {
369 return c.text("Failed to schedule deletion. Please try again later.", 500);
370 }
371 deleteCookie(c, "session", { path: "/" });
372 return c.redirect("/login?info=Account+scheduled+for+deletion");
373});
374
375// Block P5 — cancel a pending deletion.
376settings.post("/settings/delete-account/cancel", async (c) => {
377 const user = c.get("user")!;
378 await cancelAccountDeletion(user.id);
379 return c.redirect("/settings?success=Account+deletion+cancelled");
380});
381
281382// Preview the Sleep Mode digest in-browser (rendered HTML).
282383settings.get("/settings/sleep-mode/preview", async (c) => {
283384 const user = c.get("user")!;
Modifiedsrc/routes/web.tsx+8−0View fileUnifiedSplit
@@ -223,6 +223,14 @@ web.post("/new", requireAuth, async (c) => {
223223 return c.redirect("/new?error=Repository+name+is+required");
224224 }
225225
226 // P4 — plan-quota gate. Fail-open inside the helper so a billing
227 // outage never blocks repo creation.
228 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
229 const gate = await checkRepoCreateAllowed(user.id);
230 if (!gate.ok) {
231 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
232 }
233
226234 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
227235 return c.redirect("/new?error=Invalid+repository+name");
228236 }
Addedsrc/views/empty-state.tsx+66−0View fileUnifiedSplit
@@ -0,0 +1,66 @@
1/**
2 * BLOCK O2 — Polished empty-state component.
3 *
4 * Differs from the legacy `EmptyState` in `src/views/ui.tsx`:
5 * - Strongly typed primary + secondary CTAs as data.
6 * - Gradient-bordered card with icon (SVG/emoji) slot.
7 *
8 * Used from /dashboard, /explore, /:owner/:repo, /issues, /pulls,
9 * /notifications. SSR-only.
10 */
11
12import type { FC } from "hono/jsx";
13import { html } from "hono/html";
14
15export interface EmptyStateCta { href: string; label: string }
16export interface PolishedEmptyStateProps {
17 icon?: string;
18 title: string;
19 body: string;
20 primaryCta?: EmptyStateCta;
21 secondaryCta?: EmptyStateCta;
22 footer?: string;
23}
24
25export const EmptyState: FC<PolishedEmptyStateProps> = ({
26 icon, title, body, primaryCta, secondaryCta, footer,
27}) => {
28 const iconHtml = icon ?? defaultIcon();
29 return (
30 <div class="empty-state-polished" role="status">
31 <div class="empty-state-polished-card">
32 <div class="empty-state-polished-icon" aria-hidden="true">
33 {html([iconHtml] as unknown as TemplateStringsArray)}
34 </div>
35 <h2 class="empty-state-polished-title">{title}</h2>
36 <p class="empty-state-polished-body">{body}</p>
37 {(primaryCta || secondaryCta) && (
38 <div class="empty-state-polished-actions">
39 {primaryCta && (<a href={primaryCta.href} class="btn btn-primary">{primaryCta.label}</a>)}
40 {secondaryCta && (<a href={secondaryCta.href} class="btn btn-ghost">{secondaryCta.label}</a>)}
41 </div>
42 )}
43 {footer && <div class="empty-state-polished-footer">{footer}</div>}
44 </div>
45 <style dangerouslySetInnerHTML={{ __html: emptyStatePolishedCss }} />
46 </div>
47 );
48};
49
50function defaultIcon(): string {
51 return `<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs><linearGradient id="es-grad" x1="0" y1="0" x2="48" y2="48" gradientUnits="userSpaceOnUse"><stop stop-color="#8c6dff"/><stop offset="1" stop-color="#36c5d6"/></linearGradient></defs><path d="M24 4 L28 20 L44 24 L28 28 L24 44 L20 28 L4 24 L20 20 Z" fill="url(#es-grad)" opacity="0.85"/></svg>`;
52}
53
54export const emptyStatePolishedCss = `
55.empty-state-polished { margin: 24px 0; display: flex; justify-content: center; }
56.empty-state-polished-card { position: relative; max-width: 560px; width: 100%; padding: 56px 32px 40px; text-align: center; background: var(--bg-elevated); border-radius: 14px; overflow: hidden; }
57.empty-state-polished-card::before { content: ''; position: absolute; inset: 0; padding: 1.5px; border-radius: 14px; background: var(--accent-gradient); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none; opacity: 0.55; }
58.empty-state-polished-card::after { content: ''; position: absolute; inset: 0; background: radial-gradient(60% 60% at 50% 0%, rgba(140,109,255,0.08), transparent 70%); pointer-events: none; }
59.empty-state-polished-card > * { position: relative; z-index: 1; }
60.empty-state-polished-icon { display: inline-flex; align-items: center; justify-content: center; margin: 0 auto 16px; font-size: 48px; line-height: 1; }
61.empty-state-polished-icon svg { display: block; }
62.empty-state-polished-title { font-size: 20px; font-weight: 700; letter-spacing: -0.015em; margin: 0 0 8px; color: var(--text); }
63.empty-state-polished-body { font-size: 14px; line-height: 1.6; color: var(--text-muted); max-width: 440px; margin: 0 auto 20px; }
64.empty-state-polished-actions { display: inline-flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-bottom: 4px; }
65.empty-state-polished-footer { margin-top: 16px; font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
66`;
Addedsrc/views/error-page.tsx+194−0View fileUnifiedSplit
@@ -0,0 +1,194 @@
1/**
2 * BLOCK O2 — Shared error page surface (404/500/403).
3 *
4 * One JSX component renders all three error surfaces consistently.
5 * Used from app.notFound, app.onError, and the requireAdmin middleware.
6 *
7 * - NO database access (must render when DB is down).
8 * - Reuses existing CSS tokens via Layout.
9 * - Dark + light theme both via the same gradient-text utility.
10 * - Accessible: role="main", aria-labelledby, polite live region.
11 */
12
13import type { FC } from "hono/jsx";
14import type { User } from "../db/schema";
15import { Layout } from "./layout";
16
17export interface ErrorPageSuggestion { href: string; label: string }
18export interface ErrorPageProps {
19 code: string;
20 eyebrow: string;
21 title: string;
22 body: string;
23 requestId?: string;
24 user?: User | null;
25 suggestions?: ErrorPageSuggestion[];
26 primaryCta?: { href: string; label: string };
27 secondaryCta?: { href: string; label: string };
28 meta?: string;
29 trace?: string;
30 layoutTitle?: string;
31}
32
33export const ErrorPage: FC<ErrorPageProps> = ({
34 code, eyebrow, title, body, requestId, user, suggestions,
35 primaryCta, secondaryCta, meta, trace, layoutTitle,
36}) => {
37 const primary = primaryCta ?? { href: "/", label: "Go home" };
38 const secondary = secondaryCta ?? { href: "/status", label: "Status page" };
39 return (
40 <Layout title={layoutTitle ?? `${code} ${eyebrow}`} user={user ?? null}>
41 <main class="error-page" role="main" aria-labelledby="error-page-title" data-error-code={code}>
42 <div class="error-page-code gradient-text" aria-hidden="true">{code}</div>
43 <div class="error-page-eyebrow">{eyebrow}</div>
44 <h1 id="error-page-title" class="error-page-title">{title}</h1>
45 <p class="error-page-body">{body}</p>
46 <div class="error-page-actions">
47 <a href={primary.href} class="btn btn-primary btn-lg" data-error-cta="primary">{primary.label}</a>
48 <a href={secondary.href} class="btn btn-ghost btn-lg" data-error-cta="secondary">{secondary.label}</a>
49 </div>
50 {suggestions && suggestions.length > 0 && (
51 <ul class="error-page-suggestions" aria-label="Try one of these">
52 {suggestions.map((s) => (<li><a href={s.href}>{s.label} →</a></li>))}
53 </ul>
54 )}
55 {requestId && (
56 <div class="error-page-meta" aria-live="polite">
57 <span class="error-page-meta-label">Request ID:</span>{" "}
58 <span class="meta-mono">{requestId}</span>
59 </div>
60 )}
61 {meta && (<div class="error-page-meta"><span class="meta-mono">{meta}</span></div>)}
62 {trace && (<pre class="error-page-trace" aria-label="Stack trace">{trace}</pre>)}
63 </main>
64 <style dangerouslySetInnerHTML={{ __html: errorPageCss }} />
65 </Layout>
66 );
67};
68
69export const NotFoundPage: FC<{ user?: User | null; method?: string; path?: string }> = ({ user, method, path }) => (
70 <ErrorPage
71 code="404"
72 eyebrow="Not found"
73 title="We can't find that page."
74 body="The URL might be wrong, the resource might have moved, or you might not have permission to see it."
75 user={user}
76 primaryCta={{ href: "/", label: "Go home" }}
77 secondaryCta={{ href: "/status", label: "Status page" }}
78 suggestions={[
79 { href: "/explore", label: "Explore public repositories" },
80 { href: "/help", label: "Read the quickstart guide" },
81 ]}
82 meta={method && path ? `${method} ${path}` : undefined}
83 layoutTitle="Not Found"
84 />
85);
86
87export const ServerErrorPage: FC<{ user?: User | null; requestId?: string; trace?: string }> = ({ user, requestId, trace }) => (
88 <ErrorPage
89 code="500"
90 eyebrow="Server error"
91 title="Something went wrong on our end."
92 body="The error has been reported. Try again in a moment — if it persists, include the Request ID below when you file an issue."
93 user={user}
94 primaryCta={{ href: "/", label: "Go home" }}
95 secondaryCta={{ href: "/status", label: "Status page" }}
96 requestId={requestId}
97 trace={trace}
98 layoutTitle="Error"
99 />
100);
101
102export const ForbiddenPage: FC<{ user?: User | null; message?: string }> = ({ user, message }) => {
103 const suggestions: ErrorPageSuggestion[] = user
104 ? [{ href: "/logout", label: "Sign in as a different user" }, { href: "/help", label: "Read the access docs" }]
105 : [{ href: "/login", label: "Sign in" }, { href: "/help", label: "Read the access docs" }];
106 return (
107 <ErrorPage
108 code="403"
109 eyebrow="Forbidden"
110 title={message ?? "Admin access required."}
111 body="You're signed in, but this resource is restricted. If you think this is a mistake, contact a site admin."
112 user={user}
113 primaryCta={{ href: "/", label: "Go home" }}
114 secondaryCta={{ href: "/status", label: "Status page" }}
115 suggestions={suggestions}
116 layoutTitle="Forbidden"
117 />
118 );
119};
120
121export const errorPageCss = `
122.error-page { max-width: 720px; margin: 64px auto 96px; padding: 0 24px; text-align: center; }
123.error-page-code { font-size: clamp(96px, 22vw, 192px); line-height: 1; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 8px; }
124.error-page-eyebrow { text-transform: uppercase; font-size: 12px; letter-spacing: 0.18em; color: var(--text-muted); font-weight: 600; margin-bottom: 12px; }
125.error-page-title { font-size: clamp(24px, 4vw, 36px); font-weight: 700; letter-spacing: -0.02em; margin: 0 0 12px; color: var(--text); }
126.error-page-body { font-size: 16px; line-height: 1.6; color: var(--text-muted); max-width: 520px; margin: 0 auto 28px; }
127.error-page-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-bottom: 24px; }
128.error-page-suggestions { list-style: none; padding: 0; margin: 0 auto 24px; max-width: 480px; display: flex; flex-direction: column; gap: 4px; }
129.error-page-suggestions li { font-size: 14px; }
130.error-page-suggestions a { color: var(--accent); text-decoration: none; }
131.error-page-suggestions a:hover { text-decoration: underline; }
132.error-page-meta { font-size: 13px; color: var(--text-muted); margin-top: 12px; }
133.error-page-meta-label { text-transform: uppercase; letter-spacing: 0.12em; font-size: 11px; font-weight: 600; }
134.error-page .meta-mono { font-family: var(--font-mono); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: var(--text); }
135.error-page-trace { text-align: left; margin: 24px auto 0; padding: 16px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); overflow-x: auto; max-width: 100%; white-space: pre-wrap; }
136`;
137
138export function renderStandaloneErrorPage(opts: {
139 code: string; eyebrow: string; title: string; body: string;
140 requestId?: string; signedIn?: boolean;
141}): string {
142 const { code, eyebrow, title, body, requestId, signedIn } = opts;
143 const suggestionsHtml = signedIn
144 ? `<li><a href="/logout">Sign in as a different user →</a></li><li><a href="/help">Read the access docs →</a></li>`
145 : `<li><a href="/login">Sign in →</a></li><li><a href="/help">Read the access docs →</a></li>`;
146 const requestIdHtml = requestId
147 ? `<div class="error-page-meta" aria-live="polite"><span class="error-page-meta-label">Request ID:</span> <span class="meta-mono">${escapeHtml(requestId)}</span></div>`
148 : "";
149 return `<!doctype html>
150<html lang="en" data-theme="dark">
151<head>
152<meta charset="UTF-8">
153<meta name="viewport" content="width=device-width, initial-scale=1.0">
154<title>${escapeHtml(code)} ${escapeHtml(eyebrow)}</title>
155<style>
156:root { --bg:#0d1117; --bg-elevated:#161b22; --text:#e6edf3; --text-muted:#8b949e; --border:#30363d; --accent:#8c6dff; --accent-gradient:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%); --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; }
157:root[data-theme='light'] { --bg:#fff; --bg-elevated:#f6f8fa; --text:#1f2328; --text-muted:#57606a; --border:#d1d9e0; --accent:#6d4dff; }
158html, body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; margin:0; padding:0; }
159.error-page { max-width: 720px; margin: 96px auto; padding: 0 24px; text-align: center; }
160.error-page-code { font-size: clamp(96px,22vw,192px); line-height:1; font-weight:800; letter-spacing:-0.04em; margin-bottom:8px; background: var(--accent-gradient); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color: transparent; }
161.error-page-eyebrow { text-transform:uppercase; font-size:12px; letter-spacing:0.18em; color: var(--text-muted); font-weight:600; margin-bottom:12px; }
162.error-page-title { font-size: clamp(24px,4vw,36px); font-weight:700; letter-spacing:-0.02em; margin:0 0 12px; color: var(--text); }
163.error-page-body { font-size:16px; line-height:1.6; color: var(--text-muted); max-width: 520px; margin: 0 auto 28px; }
164.error-page-actions { display:flex; gap:12px; justify-content:center; flex-wrap:wrap; margin-bottom:24px; }
165.btn { display:inline-flex; align-items:center; padding:10px 18px; border-radius:8px; border:1px solid var(--border); text-decoration:none; color: var(--text); background: var(--bg-elevated); font-size:14px; font-weight:500; }
166.btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
167.btn-lg { padding:12px 22px; font-size:15px; }
168.error-page-suggestions { list-style:none; padding:0; margin: 0 auto 24px; max-width: 480px; display:flex; flex-direction:column; gap:4px; font-size:14px; }
169.error-page-suggestions a { color: var(--accent); text-decoration:none; }
170.error-page-meta { font-size:13px; color: var(--text-muted); margin-top:12px; }
171.error-page-meta-label { text-transform:uppercase; letter-spacing:0.12em; font-size:11px; font-weight:600; }
172.meta-mono { font-family: var(--font-mono); background: var(--bg-elevated); border:1px solid var(--border); border-radius:6px; padding:2px 8px; font-size:12px; color: var(--text); }
173</style>
174</head>
175<body>
176<main class="error-page" role="main" aria-labelledby="error-page-title" data-error-code="${escapeHtml(code)}">
177 <div class="error-page-code" aria-hidden="true">${escapeHtml(code)}</div>
178 <div class="error-page-eyebrow">${escapeHtml(eyebrow)}</div>
179 <h1 id="error-page-title" class="error-page-title">${escapeHtml(title)}</h1>
180 <p class="error-page-body">${escapeHtml(body)}</p>
181 <div class="error-page-actions">
182 <a href="/" class="btn btn-primary btn-lg" data-error-cta="primary">Go home</a>
183 <a href="/status" class="btn btn-lg" data-error-cta="secondary">Status page</a>
184 </div>
185 <ul class="error-page-suggestions" aria-label="Try one of these">${suggestionsHtml}</ul>
186 ${requestIdHtml}
187</main>
188</body>
189</html>`;
190}
191
192function escapeHtml(s: string): string {
193 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
194}
Addedsrc/views/form-validation-js.tsx+139−0View fileUnifiedSplit
@@ -0,0 +1,139 @@
1/**
2 * BLOCK O2 — Inline client-side form validation.
3 *
4 * Vanilla JS that mounts a `<span class="field-error" aria-live="polite">`
5 * under every matched form input. Fires on blur AND on input (after the
6 * first blur). Per-field override via data-validation-message attribute.
7 *
8 * Visual states:
9 * .input-invalid → red border
10 * .input-valid → green border + checkmark icon
11 *
12 * Server-side `?error=…` redirect validation still works — this is
13 * additive UX polish.
14 */
15
16import type { FC } from "hono/jsx";
17
18export const formValidationScript = /* js */ `
19(function () {
20 if (window.__gluecronFormValidationMounted) return;
21 window.__gluecronFormValidationMounted = true;
22
23 function ready(fn) {
24 if (document.readyState === "loading") {
25 document.addEventListener("DOMContentLoaded", fn);
26 } else { fn(); }
27 }
28
29 function validateInput(el) {
30 var v = el.value;
31 if (el.disabled || el.readOnly) return "";
32 if (el.hasAttribute("required") && v.trim() === "") {
33 return el.getAttribute("data-validation-required")
34 || (el.labels && el.labels[0] ? el.labels[0].textContent + " is required" : "This field is required");
35 }
36 if (v === "") return "";
37 if (el.type === "email") {
38 var emailRe = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
39 if (!emailRe.test(v)) {
40 return el.getAttribute("data-validation-message") || "Enter a valid email address";
41 }
42 }
43 var pat = el.getAttribute("pattern");
44 if (pat) {
45 try {
46 if (!new RegExp("^(?:" + pat + ")$").test(v)) {
47 return el.getAttribute("data-validation-message") || "Doesn't match the required format";
48 }
49 } catch (_e) {}
50 }
51 var minL = parseInt(el.getAttribute("minlength") || "0", 10);
52 if (minL > 0 && v.length < minL) {
53 return el.getAttribute("data-validation-message") || ("Must be at least " + minL + " characters");
54 }
55 var maxL = parseInt(el.getAttribute("maxlength") || "0", 10);
56 if (maxL > 0 && v.length > maxL) {
57 return el.getAttribute("data-validation-message") || ("Must be at most " + maxL + " characters");
58 }
59 return "";
60 }
61
62 function ensureErrorSpan(el) {
63 var id = el.id || el.name;
64 if (!id) { id = "fv-" + Math.random().toString(36).slice(2, 8); el.id = id; }
65 var errId = id + "-fv-error";
66 var span = document.getElementById(errId);
67 if (!span) {
68 span = document.createElement("span");
69 span.id = errId;
70 span.className = "field-error";
71 span.setAttribute("aria-live", "polite");
72 el.parentNode && el.parentNode.insertBefore(span, el.nextSibling);
73 var describedBy = el.getAttribute("aria-describedby") || "";
74 var parts = describedBy.split(/\\s+/).filter(Boolean);
75 if (parts.indexOf(errId) < 0) {
76 parts.push(errId);
77 el.setAttribute("aria-describedby", parts.join(" "));
78 }
79 }
80 return span;
81 }
82
83 function applyState(el, msg) {
84 var span = ensureErrorSpan(el);
85 if (msg) {
86 el.classList.add("input-invalid");
87 el.classList.remove("input-valid");
88 el.setAttribute("aria-invalid", "true");
89 span.textContent = msg;
90 span.classList.add("field-error-shown");
91 } else if (el.value === "") {
92 el.classList.remove("input-invalid");
93 el.classList.remove("input-valid");
94 el.removeAttribute("aria-invalid");
95 span.textContent = "";
96 span.classList.remove("field-error-shown");
97 } else {
98 el.classList.remove("input-invalid");
99 el.classList.add("input-valid");
100 el.removeAttribute("aria-invalid");
101 span.textContent = "";
102 span.classList.remove("field-error-shown");
103 }
104 }
105
106 function wire(el) {
107 if (el.__fvWired) return;
108 el.__fvWired = true;
109 el.addEventListener("blur", function () { el.__fvBlurred = true; applyState(el, validateInput(el)); });
110 el.addEventListener("input", function () { if (!el.__fvBlurred) return; applyState(el, validateInput(el)); });
111 }
112
113 function scan(root) {
114 var sel = "input[required],input[pattern],input[minlength],input[maxlength],input[type=email]";
115 var nodes = (root || document).querySelectorAll(sel);
116 for (var i = 0; i < nodes.length; i++) {
117 var el = nodes[i];
118 if (el.type === "hidden" || el.type === "submit" || el.type === "button") continue;
119 wire(el);
120 }
121 }
122
123 ready(function () { scan(document); });
124})();
125`;
126
127export const formValidationCss = `
128.input-invalid { border-color: var(--red, #ff6a6a) !important; box-shadow: 0 0 0 2px rgba(255,106,106,0.18) !important; }
129.input-valid { border-color: var(--green, #4ade80) !important; box-shadow: 0 0 0 2px rgba(74,222,128,0.18) !important; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%234ade80' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8.5l3.5 3.5L13 4.5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; background-size: 14px 14px; padding-right: 32px !important; }
130.field-error { display: block; margin-top: 4px; font-size: 12px; color: var(--red, #ff6a6a); line-height: 1.4; min-height: 0; transition: min-height 120ms ease; }
131.field-error-shown { min-height: 1.4em; }
132`;
133
134export const FormValidationAssets: FC = () => (
135 <>
136 <style dangerouslySetInnerHTML={{ __html: formValidationCss }} />
137 <script dangerouslySetInnerHTML={{ __html: formValidationScript }} />
138 </>
139);
Modifiedsrc/views/landing.tsx+74−3View fileUnifiedSplit
@@ -161,6 +161,18 @@ export const LandingHero: FC<LandingPageProps> = ({
161161 Sign up free
162162 <span class="landing-cta-arrow" aria-hidden="true">{"→"}</span>
163163 </a>
164 {/* BLOCK Q1 — one-click Claude Desktop install. Gradient
165 border + accent so it reads as a distinct flagship CTA,
166 not just a third secondary option. */}
167 <a
168 href="/gluecron.dxt"
169 class="btn btn-xl landing-cta-dxt"
170 download
171 data-testid="cta-dxt"
172 >
173 Add to Claude Desktop
174 <span class="landing-cta-arrow" aria-hidden="true">{"→"}</span>
175 </a>
164176 <a href="/demo" class="btn btn-secondary btn-xl">
165177 Try the live demo
166178 <span class="landing-cta-arrow" aria-hidden="true">{"→"}</span>
@@ -171,6 +183,16 @@ export const LandingHero: FC<LandingPageProps> = ({
171183 </a>
172184 </div>
173185
186 {/* Block Q3 — tertiary "try it without signing up" link.
187 Visually subordinate to the buttons above; renders as a
188 small ghost text link so it doesn't crowd the CTA row. */}
189 <div class="landing-hero-play">
190 <a href="/play" class="landing-hero-play-link" data-testid="cta-play">
191 Or try it without signing up
192 <span aria-hidden="true">{" →"}</span>
193 </a>
194 </div>
195
174196 {/* L10 — "what just happened" rail. Mini, secondary,
175197 separate from the BIG L4 counters tile section below. */}
176198 {publicStats && (
@@ -1583,9 +1605,9 @@ const landingCss = `
15831605 }
15841606
15851607 .landing-hero-title {
1586 font-size: clamp(48px, 9.5vw, 124px);
1587 line-height: 0.96;
1588 letter-spacing: -0.045em;
1608 font-size: clamp(32px, 5.5vw, 64px);
1609 line-height: 1.05;
1610 letter-spacing: -0.025em;
15891611 font-weight: 700;
15901612 margin: 0 0 var(--s-7);
15911613 color: var(--text-strong);
@@ -1618,11 +1640,60 @@ const landingCss = `
16181640 transition: transform var(--t-base) var(--ease-spring);
16191641 display: inline-block;
16201642 }
1643
1644 /* Block Q3 — tertiary "try it without signing up" link.
1645 Sits under the primary CTA row as a small, low-contrast text link
1646 so it doesn't crowd the main calls-to-action. */
1647 .landing-hero-play {
1648 margin-top: 14px;
1649 text-align: center;
1650 }
1651 .landing-hero-play-link {
1652 font-size: 13px;
1653 color: var(--text-muted);
1654 text-decoration: none;
1655 border-bottom: 1px dashed transparent;
1656 padding-bottom: 1px;
1657 transition: color var(--t-base), border-color var(--t-base);
1658 }
1659 .landing-hero-play-link:hover {
1660 color: var(--text-strong, var(--text));
1661 border-bottom-color: var(--text-muted);
1662 }
16211663 .btn:hover .landing-cta-arrow,
16221664 .landing-cta-primary:hover .landing-cta-arrow {
16231665 transform: translateX(4px);
16241666 }
16251667
1668 /* BLOCK Q1 — flagship "Add to Claude Desktop" CTA.
1669 Gradient-bordered + accent text so it reads as a peer of the primary
1670 Sign-up CTA, not a third secondary. Subtle elevation on hover; static
1671 when the visitor opts out of motion. */
1672 .landing-cta-dxt {
1673 position: relative;
1674 background: var(--bg-elev-1, #161b22);
1675 color: var(--text-strong, #e6edf3);
1676 border: 1px solid transparent;
1677 background-image:
1678 linear-gradient(var(--bg-elev-1, #161b22), var(--bg-elev-1, #161b22)),
1679 linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
1680 background-origin: border-box;
1681 background-clip: padding-box, border-box;
1682 transition: transform var(--t-base, 180ms) var(--ease-spring, ease),
1683 box-shadow var(--t-base, 180ms) var(--ease-spring, ease);
1684 }
1685 .landing-cta-dxt:hover {
1686 transform: translateY(-2px);
1687 box-shadow: 0 8px 24px -8px rgba(140, 109, 255, 0.45);
1688 }
1689 (prefers-reduced-motion: reduce) {
1690 .landing-cta-dxt,
1691 .landing-cta-dxt:hover {
1692 transform: none;
1693 transition: none;
1694 }
1695 }
1696
16261697 /* L8 — free-tier reassurance link beneath the CTA row. */
16271698 .landing-hero-freenote {
16281699 margin-top: var(--s-5);
Modifiedsrc/views/layout.tsx+469−0View fileUnifiedSplit
@@ -18,6 +18,11 @@ export const Layout: FC<
1818 ogDescription?: string;
1919 ogType?: string;
2020 twitterCard?: "summary" | "summary_large_image";
21 // Block O3 — site-wide footer banner stripe. When non-empty,
22 // renders below the footer-bottom row; wired from
23 // admin.system_flags.site_banner_text.
24 siteBannerText?: string;
25 siteBannerLevel?: "info" | "warn" | "error";
2126 }>
2227> = ({
2328 children,
@@ -31,6 +36,8 @@ export const Layout: FC<
3136 ogDescription,
3237 ogType,
3338 twitterCard,
39 siteBannerText,
40 siteBannerLevel,
3441}) => {
3542 const initialTheme = theme === "light" ? "light" : "dark";
3643 const build = getBuildInfo();
@@ -71,6 +78,73 @@ export const Layout: FC<
7178 Pre-launch — Gluecron is in final validation. Public signups
7279 and git hosting for non-owner users open after launch review.
7380 </div>
81 {/* Block Q3 — Playground banner. Renders only when the active
82 user is a playground account with a future expiry; the small
83 inline script counts down once per minute. Strip is dismissible
84 per-page-load (re-appears on next nav) — gentle, not noisy. */}
85 {user && (user as any).isPlayground && (user as any).playgroundExpiresAt && (
86 <div
87 class="playground-banner"
88 role="status"
89 aria-live="polite"
90 data-playground-expires={
91 ((user as any).playgroundExpiresAt instanceof Date
92 ? (user as any).playgroundExpiresAt.toISOString()
93 : String((user as any).playgroundExpiresAt))
94 }
95 >
96 <span class="playground-banner-icon" aria-hidden="true">{"\u{1F3AE}"}</span>
97 <span class="playground-banner-text">
98 Playground account —{" "}
99 <span class="playground-banner-countdown">expires soon</span>.{" "}
100 <a href="/play/claim" class="playground-banner-cta">
101 Save your work →
102 </a>
103 </span>
104 <button
105 type="button"
106 class="playground-banner-dismiss"
107 aria-label="Dismiss"
108 data-playground-dismiss="1"
109 >
110 {"×"}
111 </button>
112 <script
113 dangerouslySetInnerHTML={{
114 __html: /* js */ `
115 (function () {
116 var el = document.currentScript && document.currentScript.parentElement;
117 if (!el) return;
118 var iso = el.getAttribute('data-playground-expires');
119 if (!iso) return;
120 var target = Date.parse(iso);
121 if (isNaN(target)) return;
122 var out = el.querySelector('.playground-banner-countdown');
123 function render() {
124 var ms = target - Date.now();
125 if (!out) return;
126 if (ms <= 0) { out.textContent = 'expired'; return; }
127 var mins = Math.floor(ms / 60000);
128 var hrs = Math.floor(mins / 60);
129 if (hrs > 1) out.textContent = hrs + ' hours left';
130 else if (hrs === 1) out.textContent = '1 hour left';
131 else if (mins > 1) out.textContent = mins + ' minutes left';
132 else out.textContent = 'less than a minute left';
133 }
134 render();
135 setInterval(render, 60000);
136 var dismiss = el.querySelector('[data-playground-dismiss="1"]');
137 if (dismiss) {
138 dismiss.addEventListener('click', function () {
139 el.style.display = 'none';
140 });
141 }
142 })();
143 `,
144 }}
145 />
146 </div>
147 )}
74148 <header>
75149 <nav>
76150
@@ -87,6 +161,24 @@ export const Layout: FC<
87161 </form>
88162 </div>
89163 <div class="nav-right">
164 {/* Block N3 — site-admin platform-deploy status pill. Hidden
165 by default; revealed client-side once /admin/deploys/latest.json
166 responds 200 (non-admins get 401/403 and the pill stays
167 hidden). Subscribes to the `platform:deploys` SSE topic
168 for live updates. See `deployPillScript` below. */}
169 {user && (
170 <a
171 id="deploy-pill"
172 href="/admin/deploys"
173 class="nav-deploy-pill"
174 style="display:none"
175 aria-label="Platform deploy status"
176 title="Platform deploy status"
177 >
178 <span class="deploy-pill-dot" />
179 <span class="deploy-pill-text">Deploys</span>
180 </a>
181 )}
90182 <a
91183 href="/theme/toggle"
92184 class="nav-link nav-theme"
@@ -174,6 +266,15 @@ export const Layout: FC<
174266 {build.sha} · {build.branch}
175267 </span>
176268 </div>
269 {siteBannerText ? (
270 <div
271 class={`footer-banner footer-banner-${siteBannerLevel || "info"}`}
272 role="status"
273 aria-live="polite"
274 >
275 {siteBannerText}
276 </div>
277 ) : null}
177278 </footer>
178279 {/* Live update poller — checks /api/version every 15s, prompts
179280 reload when the running sha changes. Pure progressive-
@@ -195,6 +296,13 @@ export const Layout: FC<
195296 </button>
196297 </div>
197298 <script dangerouslySetInnerHTML={{ __html: versionPollerScript }} />
299 {/* Block N3 — site-admin deploy status pill (script-only). The pill
300 container is rendered above for authed users; this script bootstraps
301 it by fetching /admin/deploys/latest.json. Non-admins get 401/403
302 and the pill stays display:none — zero leak. */}
303 {user && (
304
305 )}
198306 {/* Block I4 — Command palette shell (hidden by default) */}
199307 <div
200308 id="cmdk-backdrop"
@@ -264,6 +372,134 @@ const versionPollerScript = `
264372 })();
265373`;
266374
375// Block N3 — site-admin deploy status pill. Fetches /admin/deploys/latest.json;
376// if 200, reveals the pill in the nav and subscribes to the `platform:deploys`
377// SSE topic for live status updates. Non-admins get 401/403 and the pill stays
378// display:none — there's no leakage of admin-only data to other users.
379//
380// Pill states (CSS classes on the container drive colour):
381// .deploy-pill-success 🟢 "Deployed 12s ago"
382// .deploy-pill-progress 🟡 "Deploying… 14s" (pulsing dot)
383// .deploy-pill-failed 🔴 "Deploy failed 1m ago"
384// .deploy-pill-empty ⚪ "No deploys yet"
385//
386// Relative-time auto-refreshes every 15s without re-fetching.
387export const deployPillScript = `
388 (function(){
389 var pill, dot, text;
390 var state = { latest: null, asOf: null };
391
392 function classifyAge(ms){
393 if (ms < 0) return 'just now';
394 var s = Math.floor(ms / 1000);
395 if (s < 5) return 'just now';
396 if (s < 60) return s + 's ago';
397 var m = Math.floor(s / 60);
398 if (m < 60) return m + 'm ago';
399 var h = Math.floor(m / 60);
400 if (h < 24) return h + 'h ago';
401 var d = Math.floor(h / 24);
402 return d + 'd ago';
403 }
404 function elapsed(ms){
405 if (ms < 0) ms = 0;
406 var s = Math.floor(ms / 1000);
407 if (s < 60) return s + 's';
408 var m = Math.floor(s / 60);
409 var rem = s - m * 60;
410 return m + 'm ' + rem + 's';
411 }
412
413 function render(){
414 if (!pill || !text || !dot) return;
415 var d = state.latest;
416 if (!d) {
417 pill.className = 'nav-deploy-pill deploy-pill-empty';
418 text.textContent = 'No deploys yet';
419 pill.style.display = 'inline-flex';
420 return;
421 }
422 pill.style.display = 'inline-flex';
423 var now = Date.now();
424 if (d.status === 'in_progress') {
425 pill.className = 'nav-deploy-pill deploy-pill-progress';
426 var started = Date.parse(d.started_at) || now;
427 text.textContent = 'Deploying… ' + elapsed(now - started);
428 } else if (d.status === 'succeeded') {
429 pill.className = 'nav-deploy-pill deploy-pill-success';
430 var ref = Date.parse(d.finished_at || d.started_at) || now;
431 text.textContent = 'Deployed ' + classifyAge(now - ref);
432 } else if (d.status === 'failed') {
433 pill.className = 'nav-deploy-pill deploy-pill-failed';
434 var refF = Date.parse(d.finished_at || d.started_at) || now;
435 text.textContent = 'Deploy failed ' + classifyAge(now - refF);
436 } else {
437 pill.className = 'nav-deploy-pill';
438 text.textContent = d.status;
439 }
440 }
441
442 function fetchLatest(){
443 fetch('/admin/deploys/latest.json', { cache: 'no-store', credentials: 'same-origin' })
444 .then(function(r){ if (!r.ok) return null; return r.json(); })
445 .then(function(j){
446 if (!j || j.ok !== true) return;
447 state.latest = j.latest;
448 state.asOf = j.asOf;
449 render();
450 subscribe();
451 })
452 .catch(function(){});
453 }
454
455 var subscribed = false;
456 function subscribe(){
457 if (subscribed) return;
458 if (typeof EventSource === 'undefined') return;
459 subscribed = true;
460 var es;
461 var delay = 1500;
462 function connect(){
463 try { es = new EventSource('/live-events/platform:deploys'); }
464 catch(e){ setTimeout(connect, delay); return; }
465 es.onmessage = function(m){
466 try {
467 var d = JSON.parse(m.data);
468 if (d && d.run_id) {
469 if (!state.latest || state.latest.run_id === d.run_id ||
470 Date.parse(d.started_at) >= Date.parse(state.latest.started_at)) {
471 state.latest = d;
472 render();
473 }
474 }
475 } catch(e){}
476 };
477 es.onerror = function(){
478 try { es.close(); } catch(e){}
479 setTimeout(connect, delay);
480 };
481 }
482 connect();
483 }
484
485 function init(){
486 pill = document.getElementById('deploy-pill');
487 if (!pill) return;
488 dot = pill.querySelector('.deploy-pill-dot');
489 text = pill.querySelector('.deploy-pill-text');
490 fetchLatest();
491 // Refresh relative-time labels every 15s so "12s ago" → "27s ago"
492 // without a fresh fetch.
493 setInterval(render, 15000);
494 }
495 if (document.readyState === 'loading') {
496 document.addEventListener('DOMContentLoaded', init);
497 } else {
498 init();
499 }
500 })();
501`;
502
267503// Runs before paint — reads the theme cookie and flips data-theme so there's
268504// no dark-to-light flash on load. SSR default is dark.
269505const themeInitScript = `
@@ -625,6 +861,45 @@ const css = `
625861 --t-slower:560ms;
626862
627863 --header-h: 60px;
864
865 /* Block O3 — visual coherence: named token aliases (additive). */
866 --space-1: var(--s-1);
867 --space-2: var(--s-2);
868 --space-3: var(--s-3);
869 --space-4: var(--s-4);
870 --space-5: var(--s-5);
871 --space-6: var(--s-6);
872 --space-8: var(--s-8);
873 --space-10: var(--s-10);
874 --space-12: var(--s-12);
875 --space-16: var(--s-16);
876 --space-20: var(--s-20);
877 --space-24: var(--s-24);
878 --radius-sm: var(--r-sm);
879 --radius-md: var(--r-md);
880 --radius-lg: var(--r-lg);
881 --radius-xl: var(--r-xl);
882 --radius-full: var(--r-full);
883 --font-size-xs: var(--t-xs);
884 --font-size-sm: var(--t-sm);
885 --font-size-base: var(--t-base);
886 --font-size-md: var(--t-md);
887 --font-size-lg: var(--t-lg);
888 --font-size-xl: var(--t-xl);
889 --font-size-2xl: var(--t-2xl);
890 --font-size-3xl: var(--t-3xl);
891 --font-size-hero: var(--t-display);
892 --leading-tight: 1.2;
893 --leading-snug: 1.35;
894 --leading-normal: 1.5;
895 --leading-relaxed: 1.6;
896 --leading-loose: 1.7;
897 --z-base: 1;
898 --z-nav: 10;
899 --z-sticky: 50;
900 --z-overlay: 100;
901 --z-modal: 1000;
902 --z-toast: 10000;
628903 }
629904
630905 :root[data-theme='light'] {
@@ -838,6 +1113,51 @@ const css = `
8381113 vertical-align: 1px;
8391114 }
8401115
1116 /* Block Q3 — Playground banner. Brighter than the prelaunch strip so
1117 visitors don't miss the "save your work" CTA, but slim enough to
1118 not feel like a modal. */
1119 .playground-banner {
1120 position: relative;
1121 display: flex;
1122 align-items: center;
1123 justify-content: center;
1124 gap: 8px;
1125 background:
1126 linear-gradient(180deg, rgba(251,191,36,0.20), rgba(251,191,36,0.06)),
1127 var(--bg);
1128 border-bottom: 1px solid rgba(251,191,36,0.45);
1129 color: var(--yellow, #fbbf24);
1130 padding: 8px 40px 8px 24px;
1131 font-size: 13px;
1132 font-weight: 500;
1133 text-align: center;
1134 line-height: 1.4;
1135 }
1136 .playground-banner-icon { font-size: 14px; }
1137 .playground-banner-text { color: var(--text-strong, #e6edf3); }
1138 .playground-banner-countdown { font-weight: 600; }
1139 .playground-banner-cta {
1140 margin-left: 4px;
1141 color: var(--yellow, #fbbf24);
1142 text-decoration: underline;
1143 font-weight: 600;
1144 }
1145 .playground-banner-cta:hover { opacity: 0.85; }
1146 .playground-banner-dismiss {
1147 position: absolute;
1148 top: 50%;
1149 right: 12px;
1150 transform: translateY(-50%);
1151 background: transparent;
1152 border: none;
1153 color: var(--text-muted, #8b949e);
1154 font-size: 18px;
1155 line-height: 1;
1156 cursor: pointer;
1157 padding: 0 4px;
1158 }
1159 .playground-banner-dismiss:hover { color: var(--text-strong, #e6edf3); }
1160
8411161 /* Header — sticky, blurred, hairline border, taller for breathing room */
8421162 header {
8431163 position: sticky;
@@ -937,6 +1257,45 @@ const css = `
9371257 }
9381258
9391259 .nav-right { display: flex; align-items: center; gap: 2px; margin-left: auto; }
1260
1261 /* Block N3 — site-admin deploy status pill */
1262 .nav-deploy-pill {
1263 display: inline-flex;
1264 align-items: center;
1265 gap: 6px;
1266 padding: 4px 10px;
1267 margin: 0 6px 0 0;
1268 border-radius: 9999px;
1269 font-size: 11px;
1270 font-weight: 600;
1271 line-height: 1;
1272 color: var(--text-strong);
1273 background: var(--bg-elevated);
1274 border: 1px solid var(--border);
1275 text-decoration: none;
1276 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
1277 }
1278 .nav-deploy-pill:hover { background: var(--bg-hover); text-decoration: none; }
1279 .nav-deploy-pill .deploy-pill-dot {
1280 display: inline-block;
1281 width: 8px; height: 8px;
1282 border-radius: 50%;
1283 background: #6b7280;
1284 }
1285 .deploy-pill-success .deploy-pill-dot { background: #34d399; box-shadow: 0 0 6px rgba(52,211,153,0.45); }
1286 .deploy-pill-failed .deploy-pill-dot { background: #f87171; box-shadow: 0 0 6px rgba(248,113,113,0.45); }
1287 .deploy-pill-failed { border-color: rgba(248,113,113,0.4); }
1288 .deploy-pill-progress .deploy-pill-dot {
1289 background: #fbbf24;
1290 box-shadow: 0 0 6px rgba(251,191,36,0.55);
1291 animation: deployPillPulse 1.2s ease-in-out infinite;
1292 }
1293 deployPillPulse {
1294 0%, 100% { opacity: 1; transform: scale(1); }
1295 50% { opacity: 0.45; transform: scale(0.8); }
1296 }
1297 .deploy-pill-empty .deploy-pill-dot { background: #9ca3af; }
1298
9401299 .nav-link {
9411300 position: relative;
9421301 color: var(--text-muted);
@@ -2257,4 +2616,114 @@ const css = `
22572616 transition-duration: 0.01ms !important;
22582617 }
22592618 }
2619
2620 /* Block O3 — visual coherence additive rules. */
2621 .card.card-p-none { padding: 0; }
2622 .card.card-p-sm { padding: var(--space-3); }
2623 .card.card-p-md { padding: var(--space-4); }
2624 .card.card-p-lg { padding: var(--space-6); }
2625 .card.card-elevated { box-shadow: var(--elev-2); }
2626 .card.card-gradient {
2627 background:
2628 linear-gradient(135deg, rgba(140,109,255,0.05), transparent 60%),
2629 var(--bg-elevated);
2630 }
2631 .card.card-gradient::before { opacity: 1; }
2632 .notice {
2633 padding: var(--space-3) var(--space-4);
2634 border-radius: var(--radius-md);
2635 border: 1px solid var(--border);
2636 background: var(--bg-elevated);
2637 color: var(--text);
2638 font-size: var(--font-size-sm);
2639 margin-bottom: var(--space-6);
2640 line-height: var(--leading-normal);
2641 }
2642 .notice-info { border-color: rgba(96,165,250,0.40); background: rgba(96,165,250,0.08); color: var(--text); }
2643 .notice-success { border-color: var(--green); background: rgba(52,211,153,0.08); color: var(--text); }
2644 .notice-warn { border-color: var(--yellow); background: rgba(251,191,36,0.10); color: var(--yellow); }
2645 .notice-error { border-color: var(--red); background: rgba(248,113,113,0.10); color: var(--red); }
2646 .notice-accent { border-color: var(--accent); background: rgba(140,109,255,0.10); color: var(--text); }
2647 .email-preview {
2648 padding: var(--space-5);
2649 background: #fff;
2650 color: #111;
2651 border-radius: var(--radius-md);
2652 }
2653 .code-block {
2654 margin: var(--space-2) 0 0;
2655 padding: var(--space-3);
2656 background: var(--bg-tertiary);
2657 border: 1px solid var(--border-subtle);
2658 border-radius: var(--radius-sm);
2659 font-family: var(--font-mono);
2660 font-size: var(--font-size-xs);
2661 line-height: var(--leading-normal);
2662 overflow-x: auto;
2663 }
2664 .status-pill-operational {
2665 display: inline-flex;
2666 align-items: center;
2667 padding: var(--space-1) var(--space-3);
2668 border-radius: var(--radius-full);
2669 font-size: var(--font-size-xs);
2670 font-weight: 600;
2671 background: rgba(52,211,153,0.15);
2672 color: var(--green);
2673 }
2674 .api-tag {
2675 display: inline-flex;
2676 align-items: center;
2677 font-size: var(--font-size-xs);
2678 padding: 2px var(--space-2);
2679 border-radius: var(--radius-sm);
2680 font-family: var(--font-mono);
2681 }
2682 .api-tag-auth { background: rgba(96,165,250,0.15); color: var(--accent); }
2683 .api-tag-scope { background: rgba(52,211,153,0.15); color: var(--green); }
2684 .stat-number {
2685 font-size: var(--font-size-xl);
2686 font-weight: 700;
2687 color: var(--text-strong);
2688 line-height: var(--leading-tight);
2689 }
2690 .stat-number-accent { color: var(--accent); }
2691 .stat-number-blue { color: var(--blue); }
2692 .stat-number-purple { color: var(--text-link); }
2693 footer .footer-tag-sub {
2694 margin-top: var(--space-2);
2695 font-size: var(--font-size-xs);
2696 color: var(--text-faint);
2697 line-height: var(--leading-normal);
2698 }
2699 footer .footer-version-pill {
2700 display: inline-flex;
2701 align-items: center;
2702 gap: var(--space-2);
2703 padding: var(--space-1) var(--space-3);
2704 border-radius: var(--radius-full);
2705 border: 1px solid var(--border);
2706 background: var(--bg-elevated);
2707 color: var(--text-faint);
2708 font-family: var(--font-mono);
2709 font-size: var(--font-size-xs);
2710 cursor: help;
2711 }
2712 footer .footer-banner {
2713 max-width: 1240px;
2714 margin: var(--space-6) auto 0;
2715 padding: var(--space-3) var(--space-4);
2716 border-radius: var(--radius-md);
2717 border: 1px solid var(--border);
2718 background: var(--bg-elevated);
2719 color: var(--text);
2720 font-family: var(--font-mono);
2721 font-size: var(--font-size-xs);
2722 letter-spacing: 0.04em;
2723 text-transform: uppercase;
2724 text-align: center;
2725 }
2726 footer .footer-banner-info { border-color: rgba(96,165,250,0.40); color: var(--blue); }
2727 footer .footer-banner-warn { border-color: rgba(251,191,36,0.40); color: var(--yellow); }
2728 footer .footer-banner-error { border-color: rgba(248,113,113,0.45); color: var(--red); }
22602729`;
Addedsrc/views/skeleton.tsx+80−0View fileUnifiedSplit
@@ -0,0 +1,80 @@
1/**
2 * BLOCK O2 — Loading skeleton component.
3 *
4 * Replaces ad-hoc spinners. Renders N gradient-pulsing rectangles
5 * shaped like the eventual content. CSS-only animation that respects
6 * prefers-reduced-motion.
7 */
8
9import type { FC } from "hono/jsx";
10
11export interface SkeletonProps {
12 height?: string;
13 width?: string;
14 rounded?: boolean;
15 count?: number;
16 gap?: string;
17 ariaLabel?: string;
18}
19
20export const Skeleton: FC<SkeletonProps> = ({
21 height = "1em",
22 width = "100%",
23 rounded = false,
24 count = 1,
25 gap = "8px",
26 ariaLabel = "Loading",
27}) => {
28 const n = Math.max(1, Math.floor(count));
29 const bars: number[] = [];
30 for (let i = 0; i < n; i++) bars.push(i);
31 const radius = rounded ? "9999px" : "6px";
32 return (
33 <div class="skeleton-stack" role="status" aria-live="polite" aria-busy="true" aria-label={ariaLabel}
34 style={`display:flex;flex-direction:column;gap:${gap};width:100%`}>
35 {bars.map(() => (
36 <div class="skeleton-bar" style={`height:${height};width:${width};border-radius:${radius}`} />
37 ))}
38 <span class="sr-only">{ariaLabel}</span>
39 <style dangerouslySetInnerHTML={{ __html: skeletonCss }} />
40 </div>
41 );
42};
43
44export const SkeletonRow: FC<{ height?: number }> = ({ height = 48 }) => (
45 <div class="skeleton-bar"
46 style={`height:${height}px;width:100%;border-radius:8px;margin-bottom:8px`}
47 aria-hidden="true">
48 <style dangerouslySetInnerHTML={{ __html: skeletonCss }} />
49 </div>
50);
51
52export const SkeletonRepoRow: FC = () => (
53 <div class="skeleton-repo-row" style="display:flex;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)">
54 <div class="skeleton-bar" style="width:32px;height:32px;border-radius:50%" />
55 <div style="flex:1;display:flex;flex-direction:column;gap:6px">
56 <div class="skeleton-bar" style="height:14px;width:40%" />
57 <div class="skeleton-bar" style="height:11px;width:65%" />
58 </div>
59 <style dangerouslySetInnerHTML={{ __html: skeletonCss }} />
60 </div>
61);
62
63export const SkeletonList: FC<{ count?: number; height?: number }> = ({ count = 4 }) => {
64 const n = Math.max(1, Math.floor(count));
65 const rows: number[] = [];
66 for (let i = 0; i < n; i++) rows.push(i);
67 return (
68 <div class="skeleton-list" role="status" aria-busy="true" aria-live="polite" aria-label="Loading repositories">
69 {rows.map(() => (<SkeletonRepoRow />))}
70 </div>
71 );
72};
73
74export const skeletonCss = `
75.skeleton-bar { background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(140,109,255,0.10) 50%, var(--bg-elevated) 100%); background-size: 200% 100%; animation: skeleton-shimmer 1.4s linear infinite; border: 1px solid var(--border); display: block; }
76:root[data-theme='light'] .skeleton-bar { background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(109,77,255,0.10) 50%, var(--bg-elevated) 100%); background-size: 200% 100%; }
77@keyframes skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
78.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
79@media (prefers-reduced-motion: reduce) { .skeleton-bar { animation: none; } }
80`;
Modifiedsrc/views/ui.tsx+33−9View fileUnifiedSplit
@@ -249,15 +249,39 @@ export const Badge: FC<
249249
250250// ─── Card Components ────────────────────────────────────────────────────────
251251
252export const Card: FC<PropsWithChildren<{ class?: string; style?: string }>> = ({
253 children,
254 class: cls,
255 style,
256}) => (
257 <div class={`card${cls ? ` ${cls}` : ""}`} style={style}>
258 {children}
259 </div>
260);
252/**
253 * Card — the canonical panel primitive.
254 *
255 * Block O3: extended with `padding` and `variant` props so every "panel"
256 * style across the app maps to a single CSS contract.
257 */
258export const Card: FC<
259 PropsWithChildren<{
260 padding?: "none" | "sm" | "md" | "lg";
261 variant?: "default" | "elevated" | "gradient";
262 class?: string;
263 style?: string;
264 }>
265> = ({ children, padding, variant, class: cls, style }) => {
266 const padCls =
267 padding === "none" ? " card-p-none" :
268 padding === "sm" ? " card-p-sm" :
269 padding === "md" ? " card-p-md" :
270 padding === "lg" ? " card-p-lg" :
271 "";
272 const variantCls =
273 variant === "elevated" ? " card-elevated" :
274 variant === "gradient" ? " card-gradient" :
275 "";
276 return (
277 <div
278 class={`card${padCls}${variantCls}${cls ? ` ${cls}` : ""}`}
279 style={style}
280 >
281 {children}
282 </div>
283 );
284};
261285
262286export const CardMeta: FC<PropsWithChildren> = ({ children }) => (
263287 <div class="card-meta">{children}</div>
264288