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.tsxBlame798 lines · 4 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
dc26881CC LABS App100 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);margin-bottom:var(--space-5)">
101 <div class="panel" style="padding:var(--space-3);text-align:center">
8f50ed0Claude102 <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>
dc26881CC LABS App107 <div class="panel" style="padding:var(--space-3);text-align:center">
8f50ed0Claude108 <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>
dc26881CC LABS App113 <div class="panel" style="padding:var(--space-3);text-align:center">
8f50ed0Claude114 <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
dc26881CC LABS App121 <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-2);margin-bottom:var(--space-5)">
9dd96b9Test User122 <a href="/admin/ops" class="btn btn-primary">
123 Operations
124 </a>
826eccfTest User125 <a href="/admin/diagnose" class="btn">
126 Diagnose
127 </a>
8f50ed0Claude128 <a href="/admin/users" class="btn">
129 Manage users
130 </a>
131 <a href="/admin/repos" class="btn">
132 Manage repos
133 </a>
134 <a href="/admin/flags" class="btn">
135 Site flags
136 </a>
08420cdClaude137 <a href="/admin/digests" class="btn">
138 Email digests
139 </a>
edf7c36Claude140 <a href="/admin/sso" class="btn">
141 Enterprise SSO
142 </a>
988380aClaude143 <a href="/admin/autopilot" class="btn">
144 Autopilot
145 </a>
146 <form
147 method="post"
148 action="/admin/demo/reseed"
149 style="display:contents"
150 >
151 <button class="btn" type="submit" title="Idempotently (re)create demo user + 3 sample repos">
152 Reseed demo
153 </button>
154 </form>
8f50ed0Claude155 </div>
156
157 <h3>Recent signups</h3>
158 <div class="panel" style="margin-bottom:20px">
159 {recent.map((u) => (
160 <div class="panel-item" style="justify-content:space-between">
161 <a href={`/${u.username}`}>{u.username}</a>
162 <span style="font-size:12px;color:var(--text-muted)">
163 {u.createdAt
164 ? new Date(u.createdAt as unknown as string).toLocaleString()
165 : ""}
166 </span>
167 </div>
168 ))}
169 </div>
170
171 <h3>Site admins</h3>
172 <div class="panel">
173 {admins.length === 0 ? (
174 <div class="panel-empty">
175 No admins (bootstrap mode — oldest user is admin).
176 </div>
177 ) : (
178 admins.map((a) => (
179 <div class="panel-item" style="justify-content:space-between">
180 <a href={`/${a.username}`}>{a.username}</a>
181 <span style="font-size:12px;color:var(--text-muted)">
182 Granted{" "}
183 {a.grantedAt
184 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
185 : ""}
186 </span>
187 </div>
188 ))
189 )}
190 </div>
191 </Layout>
192 );
193});
194
195// ----- Users -----
196
197admin.get("/admin/users", async (c) => {
198 const g = await gate(c);
199 if (g instanceof Response) return g;
200 const { user } = g;
201 const q = c.req.query("q") || "";
202 const rows = await db
203 .select({
204 id: users.id,
205 username: users.username,
206 email: users.email,
207 createdAt: users.createdAt,
208 })
209 .from(users)
210 .where(
211 q
212 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
213 : sql`1=1`
214 )
215 .orderBy(desc(users.createdAt))
216 .limit(200);
217
218 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
219
220 return c.html(
221 <Layout title="Admin — Users" user={user}>
222 <h2>Users</h2>
b9968e3Claude223 <form method="get" action="/admin/users" style="margin-bottom:16px">
8f50ed0Claude224 <input
225 type="text"
226 name="q"
227 value={q}
228 placeholder="Search username or email"
2c3ba6ecopilot-swe-agent[bot]229 aria-label="Search username or email"
8f50ed0Claude230 style="width:320px"
231 />{" "}
232 <button type="submit" class="btn">
233 Search
234 </button>
235 <a href="/admin" class="btn" style="margin-left:6px">
236 Back
237 </a>
238 </form>
239 <div class="panel">
240 {rows.length === 0 ? (
241 <div class="panel-empty">No users found.</div>
242 ) : (
243 rows.map((u) => {
244 const isAdmin = adminIds.has(u.id);
245 return (
246 <div class="panel-item" style="justify-content:space-between">
247 <div>
248 <a href={`/${u.username}`} style="font-weight:600">
249 {u.username}
250 </a>{" "}
251 <span style="color:var(--text-muted)">{u.email}</span>
252 {isAdmin && (
253 <span
254 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
255 >
256 ADMIN
257 </span>
258 )}
259 </div>
260 <form
b9968e3Claude261 method="post"
8f50ed0Claude262 action={`/admin/users/${u.id}/admin`}
263 onsubmit={
264 isAdmin
265 ? "return confirm('Revoke site admin?')"
266 : "return confirm('Grant site admin?')"
267 }
268 >
269 <button type="submit" class="btn btn-sm">
270 {isAdmin ? "Revoke admin" : "Grant admin"}
271 </button>
272 </form>
273 </div>
274 );
275 })
276 )}
277 </div>
278 </Layout>
279 );
280});
281
282admin.post("/admin/users/:id/admin", async (c) => {
283 const g = await gate(c);
284 if (g instanceof Response) return g;
285 const { user } = g;
286 const id = c.req.param("id");
287 const admins = await listSiteAdmins();
288 const isAlready = admins.some((a) => a.userId === id);
289 if (isAlready) {
290 await revokeSiteAdmin(id);
291 await audit({
292 userId: user.id,
293 action: "site_admin.revoke",
294 targetType: "user",
295 targetId: id,
296 });
297 } else {
298 await grantSiteAdmin(id, user.id);
299 await audit({
300 userId: user.id,
301 action: "site_admin.grant",
302 targetType: "user",
303 targetId: id,
304 });
305 }
306 return c.redirect("/admin/users");
307});
308
309// ----- Repos -----
310
311admin.get("/admin/repos", async (c) => {
312 const g = await gate(c);
313 if (g instanceof Response) return g;
314 const { user } = g;
315 const rows = await db
316 .select({
317 id: repositories.id,
318 name: repositories.name,
319 ownerUsername: users.username,
0316dbbClaude320 isPrivate: repositories.isPrivate,
8f50ed0Claude321 createdAt: repositories.createdAt,
322 starCount: repositories.starCount,
323 })
324 .from(repositories)
325 .innerJoin(users, eq(repositories.ownerId, users.id))
326 .orderBy(desc(repositories.createdAt))
327 .limit(200);
328
329 return c.html(
330 <Layout title="Admin — Repos" user={user}>
331 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
332 <h2>Repositories</h2>
333 <a href="/admin" class="btn btn-sm">
334 Back
335 </a>
336 </div>
337 <div class="panel">
338 {rows.length === 0 ? (
339 <div class="panel-empty">No repositories.</div>
340 ) : (
341 rows.map((r) => (
342 <div class="panel-item" style="justify-content:space-between">
343 <div>
344 <a
345 href={`/${r.ownerUsername}/${r.name}`}
346 style="font-weight:600"
347 >
348 {r.ownerUsername}/{r.name}
349 </a>
350 <span
351 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
352 >
0316dbbClaude353 {r.isPrivate ? "private" : "public"}
8f50ed0Claude354 </span>
355 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
356 {r.starCount} stars ·{" "}
357 {r.createdAt
358 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
359 : ""}
360 </div>
361 </div>
362 <form
b9968e3Claude363 method="post"
8f50ed0Claude364 action={`/admin/repos/${r.id}/delete`}
365 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
366 >
367 <button type="submit" class="btn btn-sm btn-danger">
368 Delete
369 </button>
370 </form>
371 </div>
372 ))
373 )}
374 </div>
375 </Layout>
376 );
377});
378
379admin.post("/admin/repos/:id/delete", async (c) => {
380 const g = await gate(c);
381 if (g instanceof Response) return g;
382 const { user } = g;
383 const id = c.req.param("id");
384 try {
385 await db.delete(repositories).where(eq(repositories.id, id));
386 } catch (err) {
387 console.error("[admin] repo delete:", err);
388 }
389 await audit({
390 userId: user.id,
391 action: "admin.repo.delete",
392 targetType: "repository",
393 targetId: id,
394 });
395 return c.redirect("/admin/repos");
396});
397
398// ----- Flags -----
399
400admin.get("/admin/flags", async (c) => {
401 const g = await gate(c);
402 if (g instanceof Response) return g;
403 const { user } = g;
404
405 const existing = await listFlags();
406 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
407 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
408
409 return c.html(
410 <Layout title="Admin — Flags" user={user}>
411 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
412 <h2>Site flags</h2>
413 <a href="/admin" class="btn btn-sm">
414 Back
415 </a>
416 </div>
417 <form
b9968e3Claude418 method="post"
8f50ed0Claude419 action="/admin/flags"
420 class="panel"
dc26881CC LABS App421 style="padding:var(--space-4)"
8f50ed0Claude422 >
423 {keys.map((k) => {
424 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
425 return (
426 <div class="form-group">
427 <label>{k}</label>
428 <input
429 type="text"
430 name={k}
431 value={current}
2c3ba6ecopilot-swe-agent[bot]432 aria-label={k}
8f50ed0Claude433 style="font-family:var(--font-mono)"
434 />
435 <div
436 style="font-size:11px;color:var(--text-muted);margin-top:2px"
437 >
438 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
439 </div>
440 </div>
441 );
442 })}
443 <button type="submit" class="btn btn-primary">
444 Save
445 </button>
446 </form>
447 </Layout>
448 );
449});
450
451admin.post("/admin/flags", async (c) => {
452 const g = await gate(c);
453 if (g instanceof Response) return g;
454 const { user } = g;
455 const body = await c.req.parseBody();
456 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
457 for (const k of keys) {
458 const v = String(body[k] ?? "");
459 await setFlag(k, v, user.id);
460 }
461 await audit({ userId: user.id, action: "admin.flags.save" });
462 return c.redirect("/admin/flags");
463});
464
08420cdClaude465// ----- Email digests (Block I7) -----
466
467admin.get("/admin/digests", async (c) => {
468 const g = await gate(c);
469 if (g instanceof Response) return g;
470 const { user } = g;
471
472 const [optedRow] = await db
473 .select({ n: sql<number>`count(*)::int` })
474 .from(users)
475 .where(eq(users.notifyEmailDigestWeekly, true));
476 const opted = Number(optedRow?.n || 0);
477
478 const recentlySent = await db
479 .select({
480 id: users.id,
481 username: users.username,
482 lastDigestSentAt: users.lastDigestSentAt,
483 })
484 .from(users)
485 .where(sql`${users.lastDigestSentAt} is not null`)
486 .orderBy(desc(users.lastDigestSentAt))
487 .limit(20);
488
489 const result = c.req.query("result");
490 const error = c.req.query("error");
491
492 return c.html(
493 <Layout title="Admin — Digests" user={user}>
494 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
495 <h2>Email digests</h2>
496 <a href="/admin" class="btn btn-sm">
497 Back
498 </a>
499 </div>
500
501 {result && (
502 <div class="auth-success">{decodeURIComponent(result)}</div>
503 )}
504 {error && (
505 <div class="auth-error">{decodeURIComponent(error)}</div>
506 )}
507
dc26881CC LABS App508 <div class="panel" style="padding:var(--space-4);margin-bottom:var(--space-5)">
08420cdClaude509 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
510 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
511 </div>
b9968e3Claude512 <form method="post" action="/admin/digests/run" style="margin-bottom:8px">
08420cdClaude513 <button
514 type="submit"
515 class="btn btn-primary"
516 onclick="return confirm('Send weekly digest to all opted-in users now?')"
517 >
518 Send digests now
519 </button>
520 </form>
b9968e3Claude521 <form method="post" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
08420cdClaude522 <input
523 type="text"
524 name="username"
525 placeholder="username"
526 required
2c3ba6ecopilot-swe-agent[bot]527 aria-label="Username"
08420cdClaude528 style="width:240px"
529 />
530 <button type="submit" class="btn btn-sm">
531 Send to one user
532 </button>
533 </form>
534 </div>
535
536 <h3>Recently sent</h3>
537 <div class="panel">
538 {recentlySent.length === 0 ? (
539 <div class="panel-empty">No digests have been sent yet.</div>
540 ) : (
541 recentlySent.map((u) => (
542 <div class="panel-item" style="justify-content:space-between">
543 <a href={`/${u.username}`}>{u.username}</a>
544 <span style="font-size:12px;color:var(--text-muted)">
545 {u.lastDigestSentAt
546 ? new Date(
547 u.lastDigestSentAt as unknown as string
548 ).toLocaleString()
549 : ""}
550 </span>
551 </div>
552 ))
553 )}
554 </div>
555 </Layout>
556 );
557});
558
559admin.post("/admin/digests/run", async (c) => {
560 const g = await gate(c);
561 if (g instanceof Response) return g;
562 const { user } = g;
563 const results = await sendDigestsToAll();
564 const sent = results.filter((r) => r.ok).length;
565 const skipped = results.length - sent;
566 await audit({
567 userId: user.id,
568 action: "admin.digests.run",
569 metadata: { sent, skipped, total: results.length },
570 });
571 return c.redirect(
572 `/admin/digests?result=${encodeURIComponent(
573 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
574 )}`
575 );
576});
577
578admin.post("/admin/digests/preview", async (c) => {
579 const g = await gate(c);
580 if (g instanceof Response) return g;
581 const { user } = g;
582 const body = await c.req.parseBody();
583 const username = String(body.username || "").trim();
584 if (!username) {
585 return c.redirect("/admin/digests?error=Username+required");
586 }
587 const [target] = await db
588 .select({ id: users.id, username: users.username })
589 .from(users)
590 .where(eq(users.username, username))
591 .limit(1);
592 if (!target) {
593 return c.redirect("/admin/digests?error=User+not+found");
594 }
595 const result = await sendDigestForUser(target.id);
596 await audit({
597 userId: user.id,
598 action: "admin.digests.preview",
599 targetType: "user",
600 targetId: target.id,
601 metadata: {
602 ok: result.ok,
603 skipped: "skipped" in result ? result.skipped : null,
604 },
605 });
606 if (result.ok) {
607 return c.redirect(
608 `/admin/digests?result=${encodeURIComponent(
609 `Digest sent to ${target.username}.`
610 )}`
611 );
612 }
613 return c.redirect(
614 `/admin/digests?error=${encodeURIComponent(
615 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
616 )}`
617 );
618});
619
8e9f1d9Claude620admin.get("/admin/autopilot", async (c) => {
621 const g = await gate(c);
622 if (g instanceof Response) return g;
623 const { user } = g;
624 const tick = getLastTick();
625 const total = getTickCount();
626 const disabled = process.env.AUTOPILOT_DISABLED === "1";
627 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
628 const intervalMs =
629 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
630 ? Number(intervalRaw)
631 : 5 * 60 * 1000;
632 const msg = c.req.query("result") || c.req.query("error");
633 const isErr = !!c.req.query("error");
634 return c.html(
635 <Layout title="Autopilot — admin" user={user}>
dc26881CC LABS App636 <div style="max-width: 960px; margin: 0 auto; padding: var(--space-6) var(--space-4)">
8e9f1d9Claude637 <h1 style="margin-bottom: 8px">Autopilot</h1>
638 <p style="color: var(--text-muted); margin-bottom: 24px">
639 Periodic platform-maintenance loop — mirror sync, merge-queue
87edc73Claude640 progress, weekly digests, advisory rescans, environment
641 wait-timer release, scheduled workflow triggers (cron).
8e9f1d9Claude642 </p>
643 {msg && (
644 <div
645 class={isErr ? "auth-error" : "banner"}
646 style="margin-bottom: 16px"
647 >
648 {decodeURIComponent(msg)}
649 </div>
650 )}
651 <div
dc26881CC LABS App652 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-6)"
8e9f1d9Claude653 >
654 <div class="stat-card">
655 <div class="stat-label">Status</div>
656 <div class="stat-value">
657 {disabled ? "disabled" : "running"}
658 </div>
659 </div>
660 <div class="stat-card">
661 <div class="stat-label">Interval</div>
662 <div class="stat-value">{Math.round(intervalMs / 1000)}s</div>
663 </div>
664 <div class="stat-card">
665 <div class="stat-label">Ticks this process</div>
666 <div class="stat-value">{total}</div>
667 </div>
668 <div class="stat-card">
669 <div class="stat-label">Last tick</div>
670 <div class="stat-value" style="font-size: 14px">
671 {tick ? tick.finishedAt : "never"}
672 </div>
673 </div>
674 </div>
675 <form
676 method="post"
677 action="/admin/autopilot/run"
678 style="margin-bottom: 24px"
679 >
680 <button class="btn btn-primary" type="submit">
681 Run tick now
682 </button>
683 <span style="color: var(--text-muted); margin-left: 12px; font-size: 13px">
684 Executes all sub-tasks synchronously and records the result.
685 </span>
686 </form>
687 <h2 style="margin-bottom: 12px">Last tick tasks</h2>
688 {tick ? (
689 <table class="table" style="width: 100%">
690 <thead>
691 <tr>
692 <th style="text-align: left">Task</th>
693 <th style="text-align: left">Status</th>
694 <th style="text-align: right">Duration</th>
695 <th style="text-align: left">Error</th>
696 </tr>
697 </thead>
698 <tbody>
699 {tick.tasks.map((t) => (
700 <tr>
701 <td>
702 <code>{t.name}</code>
703 </td>
704 <td
705 style={
706 t.ok
707 ? "color: var(--green)"
708 : "color: var(--red)"
709 }
710 >
711 {t.ok ? "ok" : "failed"}
712 </td>
713 <td style="text-align: right">{t.durationMs}ms</td>
714 <td style="color: var(--text-muted); font-size: 13px">
715 {t.error || ""}
716 </td>
717 </tr>
718 ))}
719 </tbody>
720 </table>
721 ) : (
722 <p style="color: var(--text-muted)">
723 No ticks have run yet. The first tick fires after the interval
724 elapses. Click "Run tick now" to fire one immediately.
725 </p>
726 )}
727 <p style="margin-top: 32px; color: var(--text-muted); font-size: 13px">
728 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
729 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
730 </p>
731 </div>
732 </Layout>
733 );
734});
735
988380aClaude736admin.post("/admin/demo/reseed", async (c) => {
737 const g = await gate(c);
738 if (g instanceof Response) return g;
739 const { user } = g;
740 try {
741 const result = await ensureDemoContent({ force: true });
742 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}` : ""}`;
743 await audit({
744 userId: user.id,
745 action: "admin.demo.reseed",
746 targetType: "user",
747 targetId: result.demoUser?.id ?? "demo",
748 metadata: {
749 createdUser: result.created.user,
750 createdRepos: result.created.repos,
751 createdIssues: result.created.issues,
752 createdPrs: result.created.prs,
753 errors: result.errors.slice(0, 5),
754 },
755 });
756 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
757 } catch (err) {
758 const message = err instanceof Error ? err.message : String(err);
759 return c.redirect(
760 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
761 );
762 }
763});
764
765// Public jump-to-demo — redirects to the first demo repo if present,
766// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
767admin.get("/demo", (c) => {
768 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
769});
770
8e9f1d9Claude771admin.post("/admin/autopilot/run", async (c) => {
772 const g = await gate(c);
773 if (g instanceof Response) return g;
774 const { user } = g;
775 let summary = "";
776 try {
777 const result = await runAutopilotTick();
778 const ok = result.tasks.filter((t) => t.ok).length;
779 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
780 await audit({
781 userId: user.id,
782 action: "admin.autopilot.run",
783 targetType: "system",
784 targetId: "autopilot",
785 metadata: { ok, total: result.tasks.length },
786 });
787 return c.redirect(
788 `/admin/autopilot?result=${encodeURIComponent(summary)}`
789 );
790 } catch (err) {
791 const message = err instanceof Error ? err.message : String(err);
792 return c.redirect(
793 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
794 );
795 }
796});
797
8f50ed0Claude798export default admin;