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

feat(BLOCK-Q): zero-friction onboarding paths — .dxt + magic link + anonymous playground

feat(BLOCK-Q): zero-friction onboarding paths — .dxt + magic link + anonymous playground

Three parallel sub-blocks closing every remaining "what does a stranger
actually do" gap. After this lands the platform has SIX distinct
onboarding paths, three of which are literally one click.

Q1 — Claude Desktop extension (.dxt)
  extension/gluecron.dxt/{manifest.json,icon.png,screenshot-1.png}
  + scripts/build-dxt.sh + scripts/build-dxt-assets.ts +
  public/gluecron.dxt (pre-built bundle) + src/routes/dxt.ts +
  src/__tests__/dxt-extension.test.ts + landing CTA + README section.
  • Drag-drop install via Claude Desktop's Extensions UI. User pastes
    their Gluecron PAT in the install dialog; the 15 K1 MCP tools
    light up.
  • Asset-server route GET /gluecron.dxt with 404-JSON fallback when
    the file is missing.
  • Pre-built bundle committed so the live site serves a fresh-enough
    artifact without rebuilding on every deploy.
  • dxt_version "0.1" with $schema_note flagging that Anthropic hasn't
    published a stable public schema. Field intent is the source of
    truth; regenerate if/when Claude Desktop ships a versioned spec.
  • 8 tests covering manifest validity, the 15 tools roster, server
    endpoint shape, asset endpoint behaviour, landing CTA presence.

Q2 — Magic-link sign-in (no password)
  drizzle/0051_magic_link_tokens.sql + src/lib/magic-link.ts +
  src/routes/magic-link.tsx + src/__tests__/magic-link.test.ts.
  • GET /login/magic → POST → email a one-time link → GET
    /login/magic/callback?token=… → session set, redirect to
    /dashboard (or /onboarding?welcome=1 for auto-created accounts).
  • 32-byte hex tokens, sha256-hashed at rest, 15-min expiry, single-
    use, all-unused-for-this-email invalidated on consume.
  • Auto-create on first magic-link sign-in: username `user-<8 hex>`
    with collision retry, unmatchable bcrypt as placeholder password
    (so the account is magic-link-only until they set a real password
    via /settings), emailVerifiedAt = now (the click is the proof).
  • Per-IP rate limit 5/min via app.tsx middleware. Per-email rate
    limit 3/hr via in-helper check (prevents enumeration + abuse).
  • Generic success messages — never reveals whether the email is on
    file. Same no-enumeration discipline as P1 + P2.
  • 25 tests covering happy path, expiry, double-use rejection,
    enumeration-resistance, auto-create branch, rate limits.

Q3 — Anonymous playground (zero-input account)
  drizzle/0052_playground_accounts.sql + src/lib/playground.ts +
  src/routes/playground.tsx + src/__tests__/playground.test.ts +
  autopilot purge task + global layout banner + landing tertiary CTA.
  • POST /play → server creates a `guest-<8 hex>` account with no
    email/password, 24h expiry, a sandbox repo seeded with a README +
    src/hello.ts + a sample issue + an ai:build-labelled issue (so
    K3 autopilot starts working on the user's sandbox within minutes).
  • Synthetic email @playground.gluecron.local (never sent to),
    emailVerifiedAt set so no verify-email banner, unmatchable
    password hash, 24h session.
  • /play/claim form converts the playground account into a real one:
    new email, real password, optional new username; clears
    is_playground, re-fires P2's email verification on the real
    address.
  • Autopilot `playground-purge` task (cap 50/tick) hard-deletes
    expired accounts via FK cascade. Per-user try/catch; never throws.
  • Global layout banner (yellow strip) visible only while
    user.isPlayground; shows hours-remaining countdown updated every
    60s; "Save your work →" CTA. Dismissible per page-load.
  • Tertiary "Or try it without signing up →" link in the hero CTA
    row — subordinate to the primary signup paths but always visible.
  • Rate limit on POST /play at 3/min per IP.
  • 17 tests covering create / claim / purge / banner / route auth /
    rate limit.

──────────────────────────────────────────────────────────────────────
The six onboarding paths after this commit
──────────────────────────────────────────────────────────────────────

  GitHub one-click           (L6, needs 3-min OAuth-app config)
  Google one-click           (I10, needs 2-min OAuth-app config)
  Anonymous playground       (Q3 — literally one click)
  Magic-link email           (Q2 — enter email, click link in inbox)
  Password (legacy)          (auth.tsx — username + email + password)
  Claude Desktop .dxt        (Q1 — drag-drop install)

──────────────────────────────────────────────────────────────────────
Test outcome
──────────────────────────────────────────────────────────────────────

bun test → 1809 tests, 1806 pass / 1 fail / 2 skip across 132 files.

The 1 fail remains `runScheduledWorkflowsTick — fail-open` — confirmed
pre-existing across BLOCK-P/Q agent reports; test passes 17/0 in
isolation. Cross-file mock pollution from a sibling test file;
tracked as a follow-up.

Net +50 tests over the pre-BLOCK-Q baseline (Q1: 8, Q2: 25, Q3: 17).

──────────────────────────────────────────────────────────────────────
Note on commit origin
──────────────────────────────────────────────────────────────────────

The Q work was auto-committed by the harness as an unauthored "seed"
commit on local main during parallel-agent execution. This commit
re-applies the same tree onto the PR-#62 feature branch with a proper
author, message, and block delimitation. Local main has been reset
back to origin/main to clean up the diverged state.
Test User committed on May 14, 2026Parent: 93fe97e
23 files changed+40920cd4f63b293cb5baa61b75e1a9f4821d8e5a28be4
23 changed files+4092−0
ModifiedREADME.md+23−0View fileUnifiedSplit
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
Addeddrizzle/0051_magic_link_tokens.sql+40−0View fileUnifiedSplit
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
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/icon.png+0−0View fileUnifiedSplit
Binary file not shown.
Addedextension/gluecron.dxt/manifest.json+62−0View fileUnifiedSplit
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}
Addedextension/gluecron.dxt/screenshot-1.png+0−0View fileUnifiedSplit
Binary file not shown.
Addedpublic/gluecron.dxt+0−0View fileUnifiedSplit
Binary file not shown.
Addedscripts/build-dxt-assets.ts+302−0View fileUnifiedSplit
1#!/usr/bin/env bun
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
1#!/usr/bin/env bash
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"
Addedsrc/__tests__/dxt-extension.test.ts+169−0View fileUnifiedSplit
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__/magic-link.test.ts+596−0View fileUnifiedSplit
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__/playground.test.ts+809−0View fileUnifiedSplit
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});
Modifiedsrc/app.tsx+15−0View fileUnifiedSplit
1414import authRoutes from "./routes/auth";
1515import passwordResetRoutes from "./routes/password-reset";
1616import emailVerificationRoutes from "./routes/email-verification";
17import magicLinkRoutes from "./routes/magic-link";
1718import settingsRoutes from "./routes/settings";
1819import settings2faRoutes from "./routes/settings-2fa";
1920import issueRoutes from "./routes/issues";
9596import protectedTagsRoutes from "./routes/protected-tags";
9697import pwaRoutes from "./routes/pwa";
9798import installRoutes from "./routes/install";
99import dxtRoutes from "./routes/dxt";
98100import releasesRoutes from "./routes/releases";
99101import requiredChecksRoutes from "./routes/required-checks";
100102import rulesetsRoutes from "./routes/rulesets";
113115import workflowSecretsRoutes from "./routes/workflow-secrets";
114116import sleepModeRoutes from "./routes/sleep-mode";
115117import vsGithubRoutes from "./routes/vs-github";
118import playgroundRoutes from "./routes/playground";
116119import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
117120import { csrfToken, csrfProtect } from "./middleware/csrf";
118121
156159app.use("/register", rateLimit(10, 60_000, "register"));
157160// BLOCK P1 — throttle forgot-password to deter enumeration + mail spam.
158161app.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"));
159164
160165// CSRF protection — set token on all requests, validate on mutations
161166app.use("*", csrfToken);
203208// BLOCK P2 — Email verification (verify-email + resend)
204209app.route("/", emailVerificationRoutes);
205210
211// BLOCK Q2 — Magic-link sign-in (/login/magic + callback)
212app.route("/", magicLinkRoutes);
213
206214// Settings routes (profile, SSH keys)
207215app.route("/", settingsRoutes);
208216
369377app.route("/", protectedTagsRoutes);
370378app.route("/", pwaRoutes);
371379app.route("/", installRoutes);
380// BLOCK Q1 — /gluecron.dxt download (Claude Desktop one-click extension)
381app.route("/", dxtRoutes);
372382app.route("/", releasesRoutes);
373383app.route("/", requiredChecksRoutes);
374384app.route("/", rulesetsRoutes);
388398app.route("/", sleepModeRoutes);
389399app.route("/", vsGithubRoutes);
390400
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
391406// Web UI (catch-all, must be last)
392407app.route("/", webRoutes);
393408
Modifiedsrc/db/schema.ts+45−0View fileUnifiedSplit
6868 // termsVersion bumps when Terms change; UI prompts re-acceptance later.
6969 termsAcceptedAt: timestamp("terms_accepted_at"),
7070 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"),
7178 createdAt: timestamp("created_at").defaultNow().notNull(),
7279 updatedAt: timestamp("updated_at").defaultNow().notNull(),
7380});
28092816export type EmailVerificationToken = typeof emailVerificationTokens.$inferSelect;
28102817export type NewEmailVerificationToken = typeof emailVerificationTokens.$inferInsert;
28112818
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/lib/autopilot.ts+17−0View fileUnifiedSplit
4747import { computePrRiskForPullRequest } from "./pr-risk";
4848import { prRiskScores } from "../db/schema";
4949import { purgeScheduledAccounts } from "./account-deletion";
50import { purgeExpiredPlaygroundAccounts } from "./playground";
5051
5152export interface AutopilotTaskResult {
5253 name: string;
210211 }
211212 },
212213 },
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 },
213230 ];
214231}
215232
Addedsrc/lib/magic-link.ts+331−0View fileUnifiedSplit
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, "&amp;")
81 .replace(/</g, "&lt;")
82 .replace(/>/g, "&gt;")
83 .replace(/"/g, "&quot;")
84 .replace(/'/g, "&#39;");
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/playground.ts+836−0View fileUnifiedSplit
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};
Modifiedsrc/routes/auth.tsx+3−0View fileUnifiedSplit
286286 </FormGroup>
287287 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
288288 <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>
289292 </div>
290293 <Button type="submit" variant="primary">
291294 Sign in
Addedsrc/routes/dxt.ts+55−0View fileUnifiedSplit
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/magic-link.tsx+175−0View fileUnifiedSplit
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;
Addedsrc/routes/playground.tsx+366−0View fileUnifiedSplit
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 &rarr;
87 </Button>
88 </Form>
89
90 <ul class="play-bullets" aria-label="What you get">
91 <li>
92 <strong>A sandbox repo</strong> &mdash; public, real git, real
93 push, real issues.
94 </li>
95 <li>
96 <strong>Claude is already working</strong> &mdash; 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> &mdash;
102 gates, branch protection, AI review, the lot.
103 </li>
104 <li>
105 <strong>Save your work</strong> &mdash; 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/views/landing.tsx+30−0View fileUnifiedSplit
183183 </a>
184184 </div>
185185
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
186196 {/* L10 — "what just happened" rail. Mini, secondary,
187197 separate from the BIG L4 counters tile section below. */}
188198 {publicStats && (
16301640 transition: transform var(--t-base) var(--ease-spring);
16311641 display: inline-block;
16321642 }
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 }
16331663 .btn:hover .landing-cta-arrow,
16341664 .landing-cta-primary:hover .landing-cta-arrow {
16351665 transform: translateX(4px);
Modifiedsrc/views/layout.tsx+112−0View fileUnifiedSplit
7878 Pre-launch &mdash; Gluecron is in final validation. Public signups
7979 and git hosting for non-owner users open after launch review.
8080 </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 &mdash;{" "}
99 <span class="playground-banner-countdown">expires soon</span>.{" "}
100 <a href="/play/claim" class="playground-banner-cta">
101 Save your work &rarr;
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 )}
81148 <header>
82149 <nav>
83150 <a href="/" class="logo">
10461113 vertical-align: 1px;
10471114 }
10481115
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
10491161 /* Header — sticky, blurred, hairline border, taller for breathing room */
10501162 header {
10511163 position: sticky;
10521164