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.tsx

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