Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

playground.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

playground.tsBlame836 lines · 1 contributor
cd4f63bTest User1/**
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};