Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame788 lines · 1 contributor
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"
223 style="width:320px"
224 />{" "}
225 <button type="submit" class="btn">
226 Search
227 </button>
228 <a href="/admin" class="btn" style="margin-left:6px">
229 Back
230 </a>
231 </form>
232 <div class="panel">
233 {rows.length === 0 ? (
234 <div class="panel-empty">No users found.</div>
235 ) : (
236 rows.map((u) => {
237 const isAdmin = adminIds.has(u.id);
238 return (
239 <div class="panel-item" style="justify-content:space-between">
240 <div>
241 <a href={`/${u.username}`} style="font-weight:600">
242 {u.username}
243 </a>{" "}
244 <span style="color:var(--text-muted)">{u.email}</span>
245 {isAdmin && (
246 <span
247 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
248 >
249 ADMIN
250 </span>
251 )}
252 </div>
253 <form
b9968e3Claude254 method="post"
8f50ed0Claude255 action={`/admin/users/${u.id}/admin`}
256 onsubmit={
257 isAdmin
258 ? "return confirm('Revoke site admin?')"
259 : "return confirm('Grant site admin?')"
260 }
261 >
262 <button type="submit" class="btn btn-sm">
263 {isAdmin ? "Revoke admin" : "Grant admin"}
264 </button>
265 </form>
266 </div>
267 );
268 })
269 )}
270 </div>
271 </Layout>
272 );
273});
274
275admin.post("/admin/users/:id/admin", async (c) => {
276 const g = await gate(c);
277 if (g instanceof Response) return g;
278 const { user } = g;
279 const id = c.req.param("id");
280 const admins = await listSiteAdmins();
281 const isAlready = admins.some((a) => a.userId === id);
282 if (isAlready) {
283 await revokeSiteAdmin(id);
284 await audit({
285 userId: user.id,
286 action: "site_admin.revoke",
287 targetType: "user",
288 targetId: id,
289 });
290 } else {
291 await grantSiteAdmin(id, user.id);
292 await audit({
293 userId: user.id,
294 action: "site_admin.grant",
295 targetType: "user",
296 targetId: id,
297 });
298 }
299 return c.redirect("/admin/users");
300});
301
302// ----- Repos -----
303
304admin.get("/admin/repos", async (c) => {
305 const g = await gate(c);
306 if (g instanceof Response) return g;
307 const { user } = g;
308 const rows = await db
309 .select({
310 id: repositories.id,
311 name: repositories.name,
312 ownerUsername: users.username,
0316dbbClaude313 isPrivate: repositories.isPrivate,
8f50ed0Claude314 createdAt: repositories.createdAt,
315 starCount: repositories.starCount,
316 })
317 .from(repositories)
318 .innerJoin(users, eq(repositories.ownerId, users.id))
319 .orderBy(desc(repositories.createdAt))
320 .limit(200);
321
322 return c.html(
323 <Layout title="Admin — Repos" user={user}>
324 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
325 <h2>Repositories</h2>
326 <a href="/admin" class="btn btn-sm">
327 Back
328 </a>
329 </div>
330 <div class="panel">
331 {rows.length === 0 ? (
332 <div class="panel-empty">No repositories.</div>
333 ) : (
334 rows.map((r) => (
335 <div class="panel-item" style="justify-content:space-between">
336 <div>
337 <a
338 href={`/${r.ownerUsername}/${r.name}`}
339 style="font-weight:600"
340 >
341 {r.ownerUsername}/{r.name}
342 </a>
343 <span
344 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
345 >
0316dbbClaude346 {r.isPrivate ? "private" : "public"}
8f50ed0Claude347 </span>
348 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
349 {r.starCount} stars ·{" "}
350 {r.createdAt
351 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
352 : ""}
353 </div>
354 </div>
355 <form
b9968e3Claude356 method="post"
8f50ed0Claude357 action={`/admin/repos/${r.id}/delete`}
358 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
359 >
360 <button type="submit" class="btn btn-sm btn-danger">
361 Delete
362 </button>
363 </form>
364 </div>
365 ))
366 )}
367 </div>
368 </Layout>
369 );
370});
371
372admin.post("/admin/repos/:id/delete", async (c) => {
373 const g = await gate(c);
374 if (g instanceof Response) return g;
375 const { user } = g;
376 const id = c.req.param("id");
377 try {
378 await db.delete(repositories).where(eq(repositories.id, id));
379 } catch (err) {
380 console.error("[admin] repo delete:", err);
381 }
382 await audit({
383 userId: user.id,
384 action: "admin.repo.delete",
385 targetType: "repository",
386 targetId: id,
387 });
388 return c.redirect("/admin/repos");
389});
390
391// ----- Flags -----
392
393admin.get("/admin/flags", async (c) => {
394 const g = await gate(c);
395 if (g instanceof Response) return g;
396 const { user } = g;
397
398 const existing = await listFlags();
399 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
400 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
401
402 return c.html(
403 <Layout title="Admin — Flags" user={user}>
404 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
405 <h2>Site flags</h2>
406 <a href="/admin" class="btn btn-sm">
407 Back
408 </a>
409 </div>
410 <form
b9968e3Claude411 method="post"
8f50ed0Claude412 action="/admin/flags"
413 class="panel"
414 style="padding:16px"
415 >
416 {keys.map((k) => {
417 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
418 return (
419 <div class="form-group">
420 <label>{k}</label>
421 <input
422 type="text"
423 name={k}
424 value={current}
425 style="font-family:var(--font-mono)"
426 />
427 <div
428 style="font-size:11px;color:var(--text-muted);margin-top:2px"
429 >
430 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
431 </div>
432 </div>
433 );
434 })}
435 <button type="submit" class="btn btn-primary">
436 Save
437 </button>
438 </form>
439 </Layout>
440 );
441});
442
443admin.post("/admin/flags", async (c) => {
444 const g = await gate(c);
445 if (g instanceof Response) return g;
446 const { user } = g;
447 const body = await c.req.parseBody();
448 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
449 for (const k of keys) {
450 const v = String(body[k] ?? "");
451 await setFlag(k, v, user.id);
452 }
453 await audit({ userId: user.id, action: "admin.flags.save" });
454 return c.redirect("/admin/flags");
455});
456
08420cdClaude457// ----- Email digests (Block I7) -----
458
459admin.get("/admin/digests", async (c) => {
460 const g = await gate(c);
461 if (g instanceof Response) return g;
462 const { user } = g;
463
464 const [optedRow] = await db
465 .select({ n: sql<number>`count(*)::int` })
466 .from(users)
467 .where(eq(users.notifyEmailDigestWeekly, true));
468 const opted = Number(optedRow?.n || 0);
469
470 const recentlySent = await db
471 .select({
472 id: users.id,
473 username: users.username,
474 lastDigestSentAt: users.lastDigestSentAt,
475 })
476 .from(users)
477 .where(sql`${users.lastDigestSentAt} is not null`)
478 .orderBy(desc(users.lastDigestSentAt))
479 .limit(20);
480
481 const result = c.req.query("result");
482 const error = c.req.query("error");
483
484 return c.html(
485 <Layout title="Admin — Digests" user={user}>
486 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
487 <h2>Email digests</h2>
488 <a href="/admin" class="btn btn-sm">
489 Back
490 </a>
491 </div>
492
493 {result && (
494 <div class="auth-success">{decodeURIComponent(result)}</div>
495 )}
496 {error && (
497 <div class="auth-error">{decodeURIComponent(error)}</div>
498 )}
499
500 <div class="panel" style="padding:16px;margin-bottom:20px">
501 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
502 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
503 </div>
b9968e3Claude504 <form method="post" action="/admin/digests/run" style="margin-bottom:8px">
08420cdClaude505 <button
506 type="submit"
507 class="btn btn-primary"
508 onclick="return confirm('Send weekly digest to all opted-in users now?')"
509 >
510 Send digests now
511 </button>
512 </form>
b9968e3Claude513 <form method="post" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
08420cdClaude514 <input
515 type="text"
516 name="username"
517 placeholder="username"
518 required
519 style="width:240px"
520 />
521 <button type="submit" class="btn btn-sm">
522 Send to one user
523 </button>
524 </form>
525 </div>
526
527 <h3>Recently sent</h3>
528 <div class="panel">
529 {recentlySent.length === 0 ? (
530 <div class="panel-empty">No digests have been sent yet.</div>
531 ) : (
532 recentlySent.map((u) => (
533 <div class="panel-item" style="justify-content:space-between">
534 <a href={`/${u.username}`}>{u.username}</a>
535 <span style="font-size:12px;color:var(--text-muted)">
536 {u.lastDigestSentAt
537 ? new Date(
538 u.lastDigestSentAt as unknown as string
539 ).toLocaleString()
540 : ""}
541 </span>
542 </div>
543 ))
544 )}
545 </div>
546 </Layout>
547 );
548});
549
550admin.post("/admin/digests/run", async (c) => {
551 const g = await gate(c);
552 if (g instanceof Response) return g;
553 const { user } = g;
554 const results = await sendDigestsToAll();
555 const sent = results.filter((r) => r.ok).length;
556 const skipped = results.length - sent;
557 await audit({
558 userId: user.id,
559 action: "admin.digests.run",
560 metadata: { sent, skipped, total: results.length },
561 });
562 return c.redirect(
563 `/admin/digests?result=${encodeURIComponent(
564 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
565 )}`
566 );
567});
568
569admin.post("/admin/digests/preview", async (c) => {
570 const g = await gate(c);
571 if (g instanceof Response) return g;
572 const { user } = g;
573 const body = await c.req.parseBody();
574 const username = String(body.username || "").trim();
575 if (!username) {
576 return c.redirect("/admin/digests?error=Username+required");
577 }
578 const [target] = await db
579 .select({ id: users.id, username: users.username })
580 .from(users)
581 .where(eq(users.username, username))
582 .limit(1);
583 if (!target) {
584 return c.redirect("/admin/digests?error=User+not+found");
585 }
586 const result = await sendDigestForUser(target.id);
587 await audit({
588 userId: user.id,
589 action: "admin.digests.preview",
590 targetType: "user",
591 targetId: target.id,
592 metadata: {
593 ok: result.ok,
594 skipped: "skipped" in result ? result.skipped : null,
595 },
596 });
597 if (result.ok) {
598 return c.redirect(
599 `/admin/digests?result=${encodeURIComponent(
600 `Digest sent to ${target.username}.`
601 )}`
602 );
603 }
604 return c.redirect(
605 `/admin/digests?error=${encodeURIComponent(
606 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
607 )}`
608 );
609});
610
8e9f1d9Claude611admin.get("/admin/autopilot", async (c) => {
612 const g = await gate(c);
613 if (g instanceof Response) return g;
614 const { user } = g;
615 const tick = getLastTick();
616 const total = getTickCount();
617 const disabled = process.env.AUTOPILOT_DISABLED === "1";
618 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
619 const intervalMs =
620 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
621 ? Number(intervalRaw)
622 : 5 * 60 * 1000;
623 const msg = c.req.query("result") || c.req.query("error");
624 const isErr = !!c.req.query("error");
625 return c.html(
626 <Layout title="Autopilot — admin" user={user}>
627 <div style="max-width: 960px; margin: 0 auto; padding: 24px 16px">
628 <h1 style="margin-bottom: 8px">Autopilot</h1>
629 <p style="color: var(--text-muted); margin-bottom: 24px">
630 Periodic platform-maintenance loop — mirror sync, merge-queue
631 progress, weekly digests, advisory rescans.
632 </p>
633 {msg && (
634 <div
635 class={isErr ? "auth-error" : "banner"}
636 style="margin-bottom: 16px"
637 >
638 {decodeURIComponent(msg)}
639 </div>
640 )}
641 <div
642 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px"
643 >
644 <div class="stat-card">
645 <div class="stat-label">Status</div>
646 <div class="stat-value">
647 {disabled ? "disabled" : "running"}
648 </div>
649 </div>
650 <div class="stat-card">
651 <div class="stat-label">Interval</div>
652 <div class="stat-value">{Math.round(intervalMs / 1000)}s</div>
653 </div>
654 <div class="stat-card">
655 <div class="stat-label">Ticks this process</div>
656 <div class="stat-value">{total}</div>
657 </div>
658 <div class="stat-card">
659 <div class="stat-label">Last tick</div>
660 <div class="stat-value" style="font-size: 14px">
661 {tick ? tick.finishedAt : "never"}
662 </div>
663 </div>
664 </div>
665 <form
666 method="post"
667 action="/admin/autopilot/run"
668 style="margin-bottom: 24px"
669 >
670 <button class="btn btn-primary" type="submit">
671 Run tick now
672 </button>
673 <span style="color: var(--text-muted); margin-left: 12px; font-size: 13px">
674 Executes all sub-tasks synchronously and records the result.
675 </span>
676 </form>
677 <h2 style="margin-bottom: 12px">Last tick tasks</h2>
678 {tick ? (
679 <table class="table" style="width: 100%">
680 <thead>
681 <tr>
682 <th style="text-align: left">Task</th>
683 <th style="text-align: left">Status</th>
684 <th style="text-align: right">Duration</th>
685 <th style="text-align: left">Error</th>
686 </tr>
687 </thead>
688 <tbody>
689 {tick.tasks.map((t) => (
690 <tr>
691 <td>
692 <code>{t.name}</code>
693 </td>
694 <td
695 style={
696 t.ok
697 ? "color: var(--green)"
698 : "color: var(--red)"
699 }
700 >
701 {t.ok ? "ok" : "failed"}
702 </td>
703 <td style="text-align: right">{t.durationMs}ms</td>
704 <td style="color: var(--text-muted); font-size: 13px">
705 {t.error || ""}
706 </td>
707 </tr>
708 ))}
709 </tbody>
710 </table>
711 ) : (
712 <p style="color: var(--text-muted)">
713 No ticks have run yet. The first tick fires after the interval
714 elapses. Click "Run tick now" to fire one immediately.
715 </p>
716 )}
717 <p style="margin-top: 32px; color: var(--text-muted); font-size: 13px">
718 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
719 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
720 </p>
721 </div>
722 </Layout>
723 );
724});
725
988380aClaude726admin.post("/admin/demo/reseed", async (c) => {
727 const g = await gate(c);
728 if (g instanceof Response) return g;
729 const { user } = g;
730 try {
731 const result = await ensureDemoContent({ force: true });
732 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}` : ""}`;
733 await audit({
734 userId: user.id,
735 action: "admin.demo.reseed",
736 targetType: "user",
737 targetId: result.demoUser?.id ?? "demo",
738 metadata: {
739 createdUser: result.created.user,
740 createdRepos: result.created.repos,
741 createdIssues: result.created.issues,
742 createdPrs: result.created.prs,
743 errors: result.errors.slice(0, 5),
744 },
745 });
746 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
747 } catch (err) {
748 const message = err instanceof Error ? err.message : String(err);
749 return c.redirect(
750 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
751 );
752 }
753});
754
755// Public jump-to-demo — redirects to the first demo repo if present,
756// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
757admin.get("/demo", (c) => {
758 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
759});
760
8e9f1d9Claude761admin.post("/admin/autopilot/run", async (c) => {
762 const g = await gate(c);
763 if (g instanceof Response) return g;
764 const { user } = g;
765 let summary = "";
766 try {
767 const result = await runAutopilotTick();
768 const ok = result.tasks.filter((t) => t.ok).length;
769 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
770 await audit({
771 userId: user.id,
772 action: "admin.autopilot.run",
773 targetType: "system",
774 targetId: "autopilot",
775 metadata: { ok, total: result.tasks.length },
776 });
777 return c.redirect(
778 `/admin/autopilot?result=${encodeURIComponent(summary)}`
779 );
780 } catch (err) {
781 const message = err instanceof Error ? err.message : String(err);
782 return c.redirect(
783 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
784 );
785 }
786});
787
8f50ed0Claude788export default admin;