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

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

admin.tsxBlame792 lines · 2 contributors
8f50ed0Claude1/**
2 * Block F3 — Site admin panel.
3 *
4 * GET /admin — dashboard (counts + recent users)
5 * GET /admin/users — user list + search
6 * POST /admin/users/:id/admin — toggle site-admin flag
7 * GET /admin/repos — repo list (including private)
8 * POST /admin/repos/:id/delete — nuclear delete (audit-logged)
9 * GET /admin/flags — site flags CRUD
10 * POST /admin/flags — set flag
11 *
12 * All routes gated by `isSiteAdmin`. First registered user is the bootstrap
13 * admin. Site banner + registration lock are surfaced to the rest of the app
14 * via `getFlag`.
15 */
16
17import { Hono } from "hono";
18import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
5618f9aClaude22import { softAuth } from "../middleware/auth";
8f50ed0Claude23import type { AuthEnv } from "../middleware/auth";
24import {
25 grantSiteAdmin,
26 isSiteAdmin,
27 KNOWN_FLAGS,
28 listFlags,
29 listSiteAdmins,
30 revokeSiteAdmin,
31 setFlag,
32} from "../lib/admin";
33import { audit } from "../lib/notify";
08420cdClaude34import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
8e9f1d9Claude35import {
36 getLastTick,
37 getTickCount,
38 runAutopilotTick,
39} from "../lib/autopilot";
988380aClaude40import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
8f50ed0Claude41
42const admin = new Hono<AuthEnv>();
43admin.use("*", softAuth);
44
45async function gate(c: any): Promise<{ user: any } | Response> {
46 const user = c.get("user");
47 if (!user) return c.redirect("/login?next=/admin");
48 if (!(await isSiteAdmin(user.id))) {
49 return c.html(
50 <Layout title="Forbidden" user={user}>
51 <div class="empty-state">
52 <h2>403 — Not a site admin</h2>
53 <p>You don't have permission to view this page.</p>
54 </div>
55 </Layout>,
56 403
57 );
58 }
59 return { user };
60}
61
62admin.get("/admin", async (c) => {
63 const g = await gate(c);
64 if (g instanceof Response) return g;
65 const { user } = g;
66
67 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
68 const [rc] = await db
69 .select({ n: sql<number>`count(*)::int` })
70 .from(repositories);
71
72 const recent = await db
73 .select({
74 id: users.id,
75 username: users.username,
76 createdAt: users.createdAt,
77 })
78 .from(users)
79 .orderBy(desc(users.createdAt))
80 .limit(10);
81
82 const admins = await listSiteAdmins();
83
988380aClaude84 const msg = c.req.query("result") || c.req.query("error");
85 const isErr = !!c.req.query("error");
86
8f50ed0Claude87 return c.html(
88 <Layout title="Admin — Gluecron" user={user}>
89 <h2>Site admin</h2>
90
988380aClaude91 {msg && (
92 <div
93 class={isErr ? "auth-error" : "banner"}
94 style="margin-bottom:16px"
95 >
96 {decodeURIComponent(msg)}
97 </div>
98 )}
99
8f50ed0Claude100 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
101 <div class="panel" style="padding:12px;text-align:center">
102 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
103 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
104 Users
105 </div>
106 </div>
107 <div class="panel" style="padding:12px;text-align:center">
108 <div style="font-size:22px;font-weight:700">{Number(rc?.n || 0)}</div>
109 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
110 Repos
111 </div>
112 </div>
113 <div class="panel" style="padding:12px;text-align:center">
114 <div style="font-size:22px;font-weight:700">{admins.length}</div>
115 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
116 Site admins
117 </div>
118 </div>
119 </div>
120
988380aClaude121 <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:20px">
8f50ed0Claude122 <a href="/admin/users" class="btn">
123 Manage users
124 </a>
125 <a href="/admin/repos" class="btn">
126 Manage repos
127 </a>
128 <a href="/admin/flags" class="btn">
129 Site flags
130 </a>
08420cdClaude131 <a href="/admin/digests" class="btn">
132 Email digests
133 </a>
edf7c36Claude134 <a href="/admin/sso" class="btn">
135 Enterprise SSO
136 </a>
988380aClaude137 <a href="/admin/autopilot" class="btn">
138 Autopilot
139 </a>
140 <form
141 method="post"
142 action="/admin/demo/reseed"
143 style="display:contents"
144 >
145 <button class="btn" type="submit" title="Idempotently (re)create demo user + 3 sample repos">
146 Reseed demo
147 </button>
148 </form>
8f50ed0Claude149 </div>
150
151 <h3>Recent signups</h3>
152 <div class="panel" style="margin-bottom:20px">
153 {recent.map((u) => (
154 <div class="panel-item" style="justify-content:space-between">
155 <a href={`/${u.username}`}>{u.username}</a>
156 <span style="font-size:12px;color:var(--text-muted)">
157 {u.createdAt
158 ? new Date(u.createdAt as unknown as string).toLocaleString()
159 : ""}
160 </span>
161 </div>
162 ))}
163 </div>
164
165 <h3>Site admins</h3>
166 <div class="panel">
167 {admins.length === 0 ? (
168 <div class="panel-empty">
169 No admins (bootstrap mode — oldest user is admin).
170 </div>
171 ) : (
172 admins.map((a) => (
173 <div class="panel-item" style="justify-content:space-between">
174 <a href={`/${a.username}`}>{a.username}</a>
175 <span style="font-size:12px;color:var(--text-muted)">
176 Granted{" "}
177 {a.grantedAt
178 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
179 : ""}
180 </span>
181 </div>
182 ))
183 )}
184 </div>
185 </Layout>
186 );
187});
188
189// ----- Users -----
190
191admin.get("/admin/users", async (c) => {
192 const g = await gate(c);
193 if (g instanceof Response) return g;
194 const { user } = g;
195 const q = c.req.query("q") || "";
196 const rows = await db
197 .select({
198 id: users.id,
199 username: users.username,
200 email: users.email,
201 createdAt: users.createdAt,
202 })
203 .from(users)
204 .where(
205 q
206 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
207 : sql`1=1`
208 )
209 .orderBy(desc(users.createdAt))
210 .limit(200);
211
212 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
213
214 return c.html(
215 <Layout title="Admin — Users" user={user}>
216 <h2>Users</h2>
b9968e3Claude217 <form method="get" action="/admin/users" style="margin-bottom:16px">
8f50ed0Claude218 <input
219 type="text"
220 name="q"
221 value={q}
222 placeholder="Search username or email"
2c3ba6ecopilot-swe-agent[bot]223 aria-label="Search username or email"
8f50ed0Claude224 style="width:320px"
225 />{" "}
226 <button type="submit" class="btn">
227 Search
228 </button>
229 <a href="/admin" class="btn" style="margin-left:6px">
230 Back
231 </a>
232 </form>
233 <div class="panel">
234 {rows.length === 0 ? (
235 <div class="panel-empty">No users found.</div>
236 ) : (
237 rows.map((u) => {
238 const isAdmin = adminIds.has(u.id);
239 return (
240 <div class="panel-item" style="justify-content:space-between">
241 <div>
242 <a href={`/${u.username}`} style="font-weight:600">
243 {u.username}
244 </a>{" "}
245 <span style="color:var(--text-muted)">{u.email}</span>
246 {isAdmin && (
247 <span
248 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
249 >
250 ADMIN
251 </span>
252 )}
253 </div>
254 <form
b9968e3Claude255 method="post"
8f50ed0Claude256 action={`/admin/users/${u.id}/admin`}
257 onsubmit={
258 isAdmin
259 ? "return confirm('Revoke site admin?')"
260 : "return confirm('Grant site admin?')"
261 }
262 >
263 <button type="submit" class="btn btn-sm">
264 {isAdmin ? "Revoke admin" : "Grant admin"}
265 </button>
266 </form>
267 </div>
268 );
269 })
270 )}
271 </div>
272 </Layout>
273 );
274});
275
276admin.post("/admin/users/:id/admin", async (c) => {
277 const g = await gate(c);
278 if (g instanceof Response) return g;
279 const { user } = g;
280 const id = c.req.param("id");
281 const admins = await listSiteAdmins();
282 const isAlready = admins.some((a) => a.userId === id);
283 if (isAlready) {
284 await revokeSiteAdmin(id);
285 await audit({
286 userId: user.id,
287 action: "site_admin.revoke",
288 targetType: "user",
289 targetId: id,
290 });
291 } else {
292 await grantSiteAdmin(id, user.id);
293 await audit({
294 userId: user.id,
295 action: "site_admin.grant",
296 targetType: "user",
297 targetId: id,
298 });
299 }
300 return c.redirect("/admin/users");
301});
302
303// ----- Repos -----
304
305admin.get("/admin/repos", async (c) => {
306 const g = await gate(c);
307 if (g instanceof Response) return g;
308 const { user } = g;
309 const rows = await db
310 .select({
311 id: repositories.id,
312 name: repositories.name,
313 ownerUsername: users.username,
0316dbbClaude314 isPrivate: repositories.isPrivate,
8f50ed0Claude315 createdAt: repositories.createdAt,
316 starCount: repositories.starCount,
317 })
318 .from(repositories)
319 .innerJoin(users, eq(repositories.ownerId, users.id))
320 .orderBy(desc(repositories.createdAt))
321 .limit(200);
322
323 return c.html(
324 <Layout title="Admin — Repos" user={user}>
325 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
326 <h2>Repositories</h2>
327 <a href="/admin" class="btn btn-sm">
328 Back
329 </a>
330 </div>
331 <div class="panel">
332 {rows.length === 0 ? (
333 <div class="panel-empty">No repositories.</div>
334 ) : (
335 rows.map((r) => (
336 <div class="panel-item" style="justify-content:space-between">
337 <div>
338 <a
339 href={`/${r.ownerUsername}/${r.name}`}
340 style="font-weight:600"
341 >
342 {r.ownerUsername}/{r.name}
343 </a>
344 <span
345 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
346 >
0316dbbClaude347 {r.isPrivate ? "private" : "public"}
8f50ed0Claude348 </span>
349 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
350 {r.starCount} stars ·{" "}
351 {r.createdAt
352 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
353 : ""}
354 </div>
355 </div>
356 <form
b9968e3Claude357 method="post"
8f50ed0Claude358 action={`/admin/repos/${r.id}/delete`}
359 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
360 >
361 <button type="submit" class="btn btn-sm btn-danger">
362 Delete
363 </button>
364 </form>
365 </div>
366 ))
367 )}
368 </div>
369 </Layout>
370 );
371});
372
373admin.post("/admin/repos/:id/delete", async (c) => {
374 const g = await gate(c);
375 if (g instanceof Response) return g;
376 const { user } = g;
377 const id = c.req.param("id");
378 try {
379 await db.delete(repositories).where(eq(repositories.id, id));
380 } catch (err) {
381 console.error("[admin] repo delete:", err);
382 }
383 await audit({
384 userId: user.id,
385 action: "admin.repo.delete",
386 targetType: "repository",
387 targetId: id,
388 });
389 return c.redirect("/admin/repos");
390});
391
392// ----- Flags -----
393
394admin.get("/admin/flags", async (c) => {
395 const g = await gate(c);
396 if (g instanceof Response) return g;
397 const { user } = g;
398
399 const existing = await listFlags();
400 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
401 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
402
403 return c.html(
404 <Layout title="Admin — Flags" user={user}>
405 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
406 <h2>Site flags</h2>
407 <a href="/admin" class="btn btn-sm">
408 Back
409 </a>
410 </div>
411 <form
b9968e3Claude412 method="post"
8f50ed0Claude413 action="/admin/flags"
414 class="panel"
415 style="padding:16px"
416 >
417 {keys.map((k) => {
418 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
419 return (
420 <div class="form-group">
421 <label>{k}</label>
422 <input
423 type="text"
424 name={k}
425 value={current}
2c3ba6ecopilot-swe-agent[bot]426 aria-label={k}
8f50ed0Claude427 style="font-family:var(--font-mono)"
428 />
429 <div
430 style="font-size:11px;color:var(--text-muted);margin-top:2px"
431 >
432 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
433 </div>
434 </div>
435 );
436 })}
437 <button type="submit" class="btn btn-primary">
438 Save
439 </button>
440 </form>
441 </Layout>
442 );
443});
444
445admin.post("/admin/flags", async (c) => {
446 const g = await gate(c);
447 if (g instanceof Response) return g;
448 const { user } = g;
449 const body = await c.req.parseBody();
450 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
451 for (const k of keys) {
452 const v = String(body[k] ?? "");
453 await setFlag(k, v, user.id);
454 }
455 await audit({ userId: user.id, action: "admin.flags.save" });
456 return c.redirect("/admin/flags");
457});
458
08420cdClaude459// ----- Email digests (Block I7) -----
460
461admin.get("/admin/digests", async (c) => {
462 const g = await gate(c);
463 if (g instanceof Response) return g;
464 const { user } = g;
465
466 const [optedRow] = await db
467 .select({ n: sql<number>`count(*)::int` })
468 .from(users)
469 .where(eq(users.notifyEmailDigestWeekly, true));
470 const opted = Number(optedRow?.n || 0);
471
472 const recentlySent = await db
473 .select({
474 id: users.id,
475 username: users.username,
476 lastDigestSentAt: users.lastDigestSentAt,
477 })
478 .from(users)
479 .where(sql`${users.lastDigestSentAt} is not null`)
480 .orderBy(desc(users.lastDigestSentAt))
481 .limit(20);
482
483 const result = c.req.query("result");
484 const error = c.req.query("error");
485
486 return c.html(
487 <Layout title="Admin — Digests" user={user}>
488 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
489 <h2>Email digests</h2>
490 <a href="/admin" class="btn btn-sm">
491 Back
492 </a>
493 </div>
494
495 {result && (
496 <div class="auth-success">{decodeURIComponent(result)}</div>
497 )}
498 {error && (
499 <div class="auth-error">{decodeURIComponent(error)}</div>
500 )}
501
502 <div class="panel" style="padding:16px;margin-bottom:20px">
503 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
504 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
505 </div>
b9968e3Claude506 <form method="post" action="/admin/digests/run" style="margin-bottom:8px">
08420cdClaude507 <button
508 type="submit"
509 class="btn btn-primary"
510 onclick="return confirm('Send weekly digest to all opted-in users now?')"
511 >
512 Send digests now
513 </button>
514 </form>
b9968e3Claude515 <form method="post" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
08420cdClaude516 <input
517 type="text"
518 name="username"
519 placeholder="username"
520 required
2c3ba6ecopilot-swe-agent[bot]521 aria-label="Username"
08420cdClaude522 style="width:240px"
523 />
524 <button type="submit" class="btn btn-sm">
525 Send to one user
526 </button>
527 </form>
528 </div>
529
530 <h3>Recently sent</h3>
531 <div class="panel">
532 {recentlySent.length === 0 ? (
533 <div class="panel-empty">No digests have been sent yet.</div>
534 ) : (
535 recentlySent.map((u) => (
536 <div class="panel-item" style="justify-content:space-between">
537 <a href={`/${u.username}`}>{u.username}</a>
538 <span style="font-size:12px;color:var(--text-muted)">
539 {u.lastDigestSentAt
540 ? new Date(
541 u.lastDigestSentAt as unknown as string
542 ).toLocaleString()
543 : ""}
544 </span>
545 </div>
546 ))
547 )}
548 </div>
549 </Layout>
550 );
551});
552
553admin.post("/admin/digests/run", async (c) => {
554 const g = await gate(c);
555 if (g instanceof Response) return g;
556 const { user } = g;
557 const results = await sendDigestsToAll();
558 const sent = results.filter((r) => r.ok).length;
559 const skipped = results.length - sent;
560 await audit({
561 userId: user.id,
562 action: "admin.digests.run",
563 metadata: { sent, skipped, total: results.length },
564 });
565 return c.redirect(
566 `/admin/digests?result=${encodeURIComponent(
567 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
568 )}`
569 );
570});
571
572admin.post("/admin/digests/preview", async (c) => {
573 const g = await gate(c);
574 if (g instanceof Response) return g;
575 const { user } = g;
576 const body = await c.req.parseBody();
577 const username = String(body.username || "").trim();
578 if (!username) {
579 return c.redirect("/admin/digests?error=Username+required");
580 }
581 const [target] = await db
582 .select({ id: users.id, username: users.username })
583 .from(users)
584 .where(eq(users.username, username))
585 .limit(1);
586 if (!target) {
587 return c.redirect("/admin/digests?error=User+not+found");
588 }
589 const result = await sendDigestForUser(target.id);
590 await audit({
591 userId: user.id,
592 action: "admin.digests.preview",
593 targetType: "user",
594 targetId: target.id,
595 metadata: {
596 ok: result.ok,
597 skipped: "skipped" in result ? result.skipped : null,
598 },
599 });
600 if (result.ok) {
601 return c.redirect(
602 `/admin/digests?result=${encodeURIComponent(
603 `Digest sent to ${target.username}.`
604 )}`
605 );
606 }
607 return c.redirect(
608 `/admin/digests?error=${encodeURIComponent(
609 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
610 )}`
611 );
612});
613
8e9f1d9Claude614admin.get("/admin/autopilot", async (c) => {
615 const g = await gate(c);
616 if (g instanceof Response) return g;
617 const { user } = g;
618 const tick = getLastTick();
619 const total = getTickCount();
620 const disabled = process.env.AUTOPILOT_DISABLED === "1";
621 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
622 const intervalMs =
623 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
624 ? Number(intervalRaw)
625 : 5 * 60 * 1000;
626 const msg = c.req.query("result") || c.req.query("error");
627 const isErr = !!c.req.query("error");
628 return c.html(
629 <Layout title="Autopilot — admin" user={user}>
630 <div style="max-width: 960px; margin: 0 auto; padding: 24px 16px">
631 <h1 style="margin-bottom: 8px">Autopilot</h1>
632 <p style="color: var(--text-muted); margin-bottom: 24px">
633 Periodic platform-maintenance loop — mirror sync, merge-queue
87edc73Claude634 progress, weekly digests, advisory rescans, environment
635 wait-timer release, scheduled workflow triggers (cron).
8e9f1d9Claude636 </p>
637 {msg && (
638 <div
639 class={isErr ? "auth-error" : "banner"}
640 style="margin-bottom: 16px"
641 >
642 {decodeURIComponent(msg)}
643 </div>
644 )}
645 <div
646 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px"
647 >
648 <div class="stat-card">
649 <div class="stat-label">Status</div>
650 <div class="stat-value">
651 {disabled ? "disabled" : "running"}
652 </div>
653 </div>
654 <div class="stat-card">
655 <div class="stat-label">Interval</div>
656 <div class="stat-value">{Math.round(intervalMs / 1000)}s</div>
657 </div>
658 <div class="stat-card">
659 <div class="stat-label">Ticks this process</div>
660 <div class="stat-value">{total}</div>
661 </div>
662 <div class="stat-card">
663 <div class="stat-label">Last tick</div>
664 <div class="stat-value" style="font-size: 14px">
665 {tick ? tick.finishedAt : "never"}
666 </div>
667 </div>
668 </div>
669 <form
670 method="post"
671 action="/admin/autopilot/run"
672 style="margin-bottom: 24px"
673 >
674 <button class="btn btn-primary" type="submit">
675 Run tick now
676 </button>
677 <span style="color: var(--text-muted); margin-left: 12px; font-size: 13px">
678 Executes all sub-tasks synchronously and records the result.
679 </span>
680 </form>
681 <h2 style="margin-bottom: 12px">Last tick tasks</h2>
682 {tick ? (
683 <table class="table" style="width: 100%">
684 <thead>
685 <tr>
686 <th style="text-align: left">Task</th>
687 <th style="text-align: left">Status</th>
688 <th style="text-align: right">Duration</th>
689 <th style="text-align: left">Error</th>
690 </tr>
691 </thead>
692 <tbody>
693 {tick.tasks.map((t) => (
694 <tr>
695 <td>
696 <code>{t.name}</code>
697 </td>
698 <td
699 style={
700 t.ok
701 ? "color: var(--green)"
702 : "color: var(--red)"
703 }
704 >
705 {t.ok ? "ok" : "failed"}
706 </td>
707 <td style="text-align: right">{t.durationMs}ms</td>
708 <td style="color: var(--text-muted); font-size: 13px">
709 {t.error || ""}
710 </td>
711 </tr>
712 ))}
713 </tbody>
714 </table>
715 ) : (
716 <p style="color: var(--text-muted)">
717 No ticks have run yet. The first tick fires after the interval
718 elapses. Click "Run tick now" to fire one immediately.
719 </p>
720 )}
721 <p style="margin-top: 32px; color: var(--text-muted); font-size: 13px">
722 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
723 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
724 </p>
725 </div>
726 </Layout>
727 );
728});
729
988380aClaude730admin.post("/admin/demo/reseed", async (c) => {
731 const g = await gate(c);
732 if (g instanceof Response) return g;
733 const { user } = g;
734 try {
735 const result = await ensureDemoContent({ force: true });
736 const summary = `Demo reseed: user=${result.created.user ? "created" : "existed"}, repos=${result.created.repos.length}, issues=${result.created.issues}, prs=${result.created.prs}${result.errors.length ? `, errors=${result.errors.length}` : ""}`;
737 await audit({
738 userId: user.id,
739 action: "admin.demo.reseed",
740 targetType: "user",
741 targetId: result.demoUser?.id ?? "demo",
742 metadata: {
743 createdUser: result.created.user,
744 createdRepos: result.created.repos,
745 createdIssues: result.created.issues,
746 createdPrs: result.created.prs,
747 errors: result.errors.slice(0, 5),
748 },
749 });
750 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
751 } catch (err) {
752 const message = err instanceof Error ? err.message : String(err);
753 return c.redirect(
754 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
755 );
756 }
757});
758
759// Public jump-to-demo — redirects to the first demo repo if present,
760// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
761admin.get("/demo", (c) => {
762 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
763});
764
8e9f1d9Claude765admin.post("/admin/autopilot/run", async (c) => {
766 const g = await gate(c);
767 if (g instanceof Response) return g;
768 const { user } = g;
769 let summary = "";
770 try {
771 const result = await runAutopilotTick();
772 const ok = result.tasks.filter((t) => t.ok).length;
773 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
774 await audit({
775 userId: user.id,
776 action: "admin.autopilot.run",
777 targetType: "system",
778 targetId: "autopilot",
779 metadata: { ok, total: result.tasks.length },
780 });
781 return c.redirect(
782 `/admin/autopilot?result=${encodeURIComponent(summary)}`
783 );
784 } catch (err) {
785 const message = err instanceof Error ? err.message : String(err);
786 return c.redirect(
787 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
788 );
789 }
790});
791
8f50ed0Claude792export default admin;