Commita5dea84unknown_key
Merge branch 'main' into claude/review-readme-docs-ulqPK
23 files changed+409−43a5dea847c6c532827133310912b09c4f624937f0
23 changed files+409−43
Addedsrc/__tests__/csrf.test.ts+227−0View fileUnifiedSplit
@@ -0,0 +1,227 @@
1/**
2 * CSRF middleware — same-origin (Origin/Referer) defence + double-submit
3 * cookie token fallback.
4 *
5 * The platform was previously broken in production for logged-in users:
6 * the global `csrfProtect` middleware required a token on every POST but
7 * ~40+ web-UI forms didn't include one, so every authenticated form
8 * submission returned 403. The middleware now accepts a request iff EITHER:
9 *
10 * 1) Origin/Referer header matches the request host (same-origin), OR
11 * 2) The legacy double-submit cookie token check passes.
12 *
13 * These tests lock in that behaviour so a future refactor cannot silently
14 * regress it.
15 */
16
17import { describe, it, expect } from "bun:test";
18import { Hono } from "hono";
19import { csrfToken, csrfProtect } from "../middleware/csrf";
20
21function buildApp() {
22 const app = new Hono();
23 app.use("*", csrfToken);
24 app.use("*", csrfProtect);
25 app.get("/echo", (c) => c.text("ok"));
26 app.post("/echo", (c) => c.text("ok"));
27 return app;
28}
29
30describe("csrfProtect — request-method gating", () => {
31 it("lets GET requests through unconditionally", async () => {
32 const app = buildApp();
33 const res = await app.request("/echo");
34 expect(res.status).toBe(200);
35 });
36
37 it("lets HEAD/OPTIONS pass the csrf gate (does not 403)", async () => {
38 const app = buildApp();
39 const head = await app.request("/echo", { method: "HEAD" });
40 const opts = await app.request("/echo", { method: "OPTIONS" });
41 // The middleware does not return 403 — any non-200 status here means
42 // the route handler didn't accept the method, not that CSRF rejected.
43 expect(head.status).not.toBe(403);
44 expect(opts.status).not.toBe(403);
45 });
46});
47
48describe("csrfProtect — skip paths", () => {
49 it("skips when no session cookie is present (anonymous user)", async () => {
50 const app = buildApp();
51 // No `session` cookie → anonymous → not a CSRF target
52 const res = await app.request("/echo", { method: "POST" });
53 expect(res.status).toBe(200);
54 });
55
56 it("skips API routes (/api/*) regardless of method", async () => {
57 const app = new Hono();
58 app.use("*", csrfToken);
59 app.use("*", csrfProtect);
60 app.post("/api/anything", (c) => c.text("ok"));
61
62 const res = await app.request("/api/anything", {
63 method: "POST",
64 headers: { cookie: "session=fake-session-cookie" },
65 });
66 expect(res.status).toBe(200);
67 });
68
69 it("skips Bearer-authenticated requests", async () => {
70 const app = buildApp();
71 const res = await app.request("/echo", {
72 method: "POST",
73 headers: {
74 cookie: "session=fake",
75 authorization: "Bearer some-token-here",
76 },
77 });
78 expect(res.status).toBe(200);
79 });
80});
81
82describe("csrfProtect — same-origin defence (Origin/Referer)", () => {
83 it("accepts POST when Origin matches request host", async () => {
84 const app = buildApp();
85 const res = await app.request("/echo", {
86 method: "POST",
87 headers: {
88 cookie: "session=fake",
89 host: "gluecron.example.com",
90 origin: "https://gluecron.example.com",
91 },
92 });
93 expect(res.status).toBe(200);
94 });
95
96 it("accepts POST when Origin matches host with a non-default port", async () => {
97 const app = buildApp();
98 const res = await app.request("/echo", {
99 method: "POST",
100 headers: {
101 cookie: "session=fake",
102 host: "localhost:3000",
103 origin: "http://localhost:3000",
104 },
105 });
106 expect(res.status).toBe(200);
107 });
108
109 it("accepts POST when Referer matches host (browser may strip Origin)", async () => {
110 const app = buildApp();
111 const res = await app.request("/echo", {
112 method: "POST",
113 headers: {
114 cookie: "session=fake",
115 host: "gluecron.example.com",
116 referer: "https://gluecron.example.com/some/page",
117 },
118 });
119 expect(res.status).toBe(200);
120 });
121
122 it("rejects POST when Origin is an attacker site", async () => {
123 const app = buildApp();
124 const res = await app.request("/echo", {
125 method: "POST",
126 headers: {
127 cookie: "session=fake",
128 host: "gluecron.example.com",
129 origin: "https://evil.example.com",
130 },
131 });
132 expect(res.status).toBe(403);
133 });
134
135 it("rejects POST when Referer is an attacker site", async () => {
136 const app = buildApp();
137 const res = await app.request("/echo", {
138 method: "POST",
139 headers: {
140 cookie: "session=fake",
141 host: "gluecron.example.com",
142 referer: "https://evil.example.com/page",
143 },
144 });
145 expect(res.status).toBe(403);
146 });
147
148 it("rejects POST when Origin and Referer are both absent and no token cookie", async () => {
149 const app = buildApp();
150 const res = await app.request("/echo", {
151 method: "POST",
152 headers: { cookie: "session=fake" },
153 });
154 expect(res.status).toBe(403);
155 });
156
157 it("rejects POST when Origin is a malformed URL", async () => {
158 const app = buildApp();
159 const res = await app.request("/echo", {
160 method: "POST",
161 headers: {
162 cookie: "session=fake",
163 host: "gluecron.example.com",
164 origin: "::::not a url::::",
165 },
166 });
167 expect(res.status).toBe(403);
168 });
169});
170
171describe("csrfProtect — double-submit token fallback", () => {
172 it("accepts POST with matching token in header even when Origin is missing", async () => {
173 const app = buildApp();
174 const res = await app.request("/echo", {
175 method: "POST",
176 headers: {
177 cookie: "session=fake; csrf_token=abc123",
178 "x-csrf-token": "abc123",
179 },
180 });
181 expect(res.status).toBe(200);
182 });
183
184 it("rejects POST with mismatched token", async () => {
185 const app = buildApp();
186 const res = await app.request("/echo", {
187 method: "POST",
188 headers: {
189 cookie: "session=fake; csrf_token=abc123",
190 "x-csrf-token": "wrong-token",
191 },
192 });
193 expect(res.status).toBe(403);
194 });
195
196 it("accepts POST with matching token in form body", async () => {
197 const app = buildApp();
198 const body = new URLSearchParams({ _csrf: "abc123", other: "field" });
199 const res = await app.request("/echo", {
200 method: "POST",
201 headers: {
202 cookie: "session=fake; csrf_token=abc123",
203 "content-type": "application/x-www-form-urlencoded",
204 },
205 body: body.toString(),
206 });
207 expect(res.status).toBe(200);
208 });
209});
210
211describe("csrfToken — cookie setter", () => {
212 it("sets a csrf_token cookie on first request when none exists", async () => {
213 const app = buildApp();
214 const res = await app.request("/echo");
215 const setCookie = res.headers.get("set-cookie") || "";
216 expect(setCookie).toContain("csrf_token=");
217 });
218
219 it("does not overwrite an existing csrf_token cookie", async () => {
220 const app = buildApp();
221 const res = await app.request("/echo", {
222 headers: { cookie: "csrf_token=existing-value" },
223 });
224 const setCookie = res.headers.get("set-cookie") || "";
225 expect(setCookie).not.toContain("csrf_token=");
226 });
227});
Modifiedsrc/app.tsx+3−1View fileUnifiedSplit
@@ -104,7 +104,9 @@ import workflowSecretsRoutes from "./routes/workflow-secrets";
104104import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
105105import { csrfToken, csrfProtect } from "./middleware/csrf";
106106
107const app = new Hono();
107import type { AuthEnv } from "./middleware/auth";
108
109const app = new Hono<AuthEnv>();
108110
109111// Request context (request ID, start time) runs before everything else
110112app.use("*", requestContext);
Modifiedsrc/db/index.ts+8−1View fileUnifiedSplit
@@ -60,8 +60,15 @@ export function getDb(): AnyDb {
6060
6161// Re-export as `db` for convenience — proxies to the chosen driver lazily.
6262// Will throw on first access if DATABASE_URL is unset.
63//
64// We type the proxy as `NeonHttpDatabase<typeof schema>` for ergonomics: the
65// two driver wrappers expose identical query-builder APIs at runtime (both are
66// `PgDatabase` underneath), but the union type narrows TypeScript's overload
67// resolution and makes `.returning({...})` etc. show as "0 args expected".
68// Casting to the Neon variant exposes the full drizzle PG query-builder
69// surface to call sites without changing runtime behaviour.
6370export const db = new Proxy({} as AnyDb, {
6471 get(_target, prop, receiver) {
6572 return Reflect.get(getDb(), prop, receiver) as unknown;
6673 },
67});
74}) as NeonHttpDatabase<typeof schema>;
Modifiedsrc/lib/auto-repair.ts+1−1View fileUnifiedSplit
@@ -533,7 +533,7 @@ ${parsed.patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
533533 tier: "ai-sonnet",
534534 patchSummary: parsed.summary,
535535 filesChanged: result.filesChanged,
536 commitSha: result.sha,
536 commitSha: result.sha ?? null,
537537 outcome: "success",
538538 }).catch(() => {});
539539 return {
Modifiedsrc/lib/follows.ts+2−0View fileUnifiedSplit
@@ -91,6 +91,7 @@ export async function listFollowers(userId: string): Promise<User[]> {
9191 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
9292 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
9393 lastDigestSentAt: users.lastDigestSentAt,
94 isAdmin: users.isAdmin,
9495 createdAt: users.createdAt,
9596 updatedAt: users.updatedAt,
9697 })
@@ -115,6 +116,7 @@ export async function listFollowing(userId: string): Promise<User[]> {
115116 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
116117 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
117118 lastDigestSentAt: users.lastDigestSentAt,
119 isAdmin: users.isAdmin,
118120 createdAt: users.createdAt,
119121 updatedAt: users.updatedAt,
120122 })
Modifiedsrc/lib/mcp-tools.ts+3−3View fileUnifiedSplit
@@ -287,18 +287,18 @@ const repoExplain: McpToolHandler = {
287287 .select({
288288 commitSha: codebaseExplanations.commitSha,
289289 markdown: codebaseExplanations.markdown,
290 createdAt: codebaseExplanations.createdAt,
290 generatedAt: codebaseExplanations.generatedAt,
291291 })
292292 .from(codebaseExplanations)
293293 .where(eq(codebaseExplanations.repositoryId, r.id))
294 .orderBy(desc(codebaseExplanations.createdAt))
294 .orderBy(desc(codebaseExplanations.generatedAt))
295295 .limit(1);
296296 if (!row) {
297297 return { explanation: null };
298298 }
299299 return {
300300 commitSha: row.commitSha,
301 generatedAt: row.createdAt,
301 generatedAt: row.generatedAt,
302302 markdown: row.markdown,
303303 };
304304 },
Modifiedsrc/lib/workflow-runner.ts+55−0View fileUnifiedSplit
@@ -132,6 +132,61 @@ function parseWorkflow(parsed: string): ParsedWorkflow | null {
132132 return null;
133133}
134134
135/**
136 * Coerce an `ExtendedParseResult` (from `workflow-parser-ext`) into the
137 * permissive `ParsedWorkflow` shape used by this v1 runner. The extended
138 * parser produces richer per-step/per-job metadata (needs, strategy, if,
139 * uses, env, outputs) — v1 only consumes `name`, `runsOn`, and `steps[].run`
140 * so the extras are tolerated via the index signature on `ParsedJob`.
141 *
142 * Returns null on parse failure or malformed input so callers fall back to
143 * the locked v1 parser.
144 */
145function _coerceExtParsed(result: unknown): ParsedWorkflow | null {
146 if (!result || typeof result !== "object") return null;
147 const r = result as { ok?: unknown; workflow?: unknown };
148 if (r.ok !== true) return null;
149 const wf = r.workflow;
150 if (!wf || typeof wf !== "object") return null;
151 const w = wf as {
152 name?: unknown;
153 on?: unknown;
154 jobs?: unknown;
155 };
156 if (!Array.isArray(w.jobs)) return null;
157 const jobs: ParsedJob[] = [];
158 for (const j of w.jobs) {
159 if (!j || typeof j !== "object") continue;
160 const job = j as Record<string, unknown>;
161 const steps: ParsedStep[] = [];
162 if (Array.isArray(job.steps)) {
163 for (const s of job.steps) {
164 if (!s || typeof s !== "object") continue;
165 const step = s as Record<string, unknown>;
166 steps.push({
167 name: typeof step.name === "string" ? step.name : undefined,
168 run: typeof step.run === "string" ? step.run : undefined,
169 });
170 }
171 }
172 jobs.push({
173 name: typeof job.name === "string" ? job.name : undefined,
174 runsOn:
175 typeof job.runsOn === "string"
176 ? job.runsOn
177 : typeof job["runs-on"] === "string"
178 ? (job["runs-on"] as string)
179 : undefined,
180 steps,
181 });
182 }
183 return {
184 name: typeof w.name === "string" ? w.name : undefined,
185 on: w.on,
186 jobs,
187 };
188}
189
135190// ---------------------------------------------------------------------------
136191// Terminal-state helpers — all wrap DB calls in try/catch.
137192// ---------------------------------------------------------------------------
Modifiedsrc/middleware/auth.ts+14−0View fileUnifiedSplit
@@ -22,6 +22,20 @@ export type AuthEnv = {
2222 /** When the caller authenticated via an OAuth bearer token, these are set. */
2323 oauthScopes?: string[];
2424 oauthAppId?: string;
25 /** Set by `csrf` middleware on GET requests; required field on POST forms. */
26 csrfToken?: string;
27 /** Set by `requestContext` middleware on every request — opaque request id. */
28 requestId?: string;
29 /** Wall-clock ms when the request entered the app — used by metrics. */
30 requestStart?: number;
31 /** Set by `requireRepoAccess` middleware — the resolved repository row. */
32 repository?: unknown;
33 /** Set by `requireRepoAccess` — the caller's access level ('read'|'write'|'admin'|'owner'). */
34 repoAccess?: string;
35 /** Set by API-auth middleware — how the caller authenticated. */
36 authMethod?: "token" | "session" | "none";
37 /** Set by API-auth middleware — scopes carried by the PAT or session. */
38 tokenScopes?: string[];
2539 };
2640};
2741
Modifiedsrc/middleware/csrf.ts+48−3View fileUnifiedSplit
@@ -39,8 +39,23 @@ export const csrfToken = createMiddleware(async (c, next) => {
3939});
4040
4141/**
42 * Validates CSRF token on mutating requests (POST, PUT, DELETE, PATCH).
43 * Checks form body field '_csrf' or header 'x-csrf-token' against cookie.
42 * Validates CSRF on mutating requests (POST, PUT, DELETE, PATCH).
43 *
44 * Defence-in-depth, in order:
45 * 1. Same-origin check via Origin/Referer header (OWASP-recommended primary
46 * defence against CSRF — a cross-site forged request from a victim's
47 * browser always carries the attacker's Origin, never the app's host).
48 * 2. Double-submit cookie token (the `_csrf` form field or `X-CSRF-Token`
49 * header must equal the `csrf_token` cookie). Optional — used as a
50 * belt-and-braces fallback when present.
51 *
52 * The Origin check alone is sufficient for modern browsers (all major
53 * browsers send Origin on cross-origin POSTs). The token check is retained
54 * because some legacy clients strip Origin/Referer, and because it gives
55 * forms an explicit "I came from a real page" signal.
56 *
57 * A request is accepted iff EITHER the Origin/Referer matches the request
58 * host OR the token check passes.
4459 */
4560export const csrfProtect = createMiddleware(async (c, next) => {
4661 const method = c.req.method.toUpperCase();
@@ -74,9 +89,39 @@ export const csrfProtect = createMiddleware(async (c, next) => {
7489 return next();
7590 }
7691
92 // ---- 1) Same-origin check (Origin / Referer header) -----------------
93 // A genuine same-origin request from our own pages will carry an Origin
94 // (always for cross-origin POSTs, usually for same-origin too) or a
95 // Referer that matches the request host. Cross-site forged requests
96 // either carry the attacker's origin or are stripped — in both cases
97 // they fail this check.
98 const host = c.req.header("host");
99 const origin = c.req.header("origin");
100 const referer = c.req.header("referer");
101 let originOk = false;
102 if (host) {
103 if (origin) {
104 try {
105 originOk = new URL(origin).host === host;
106 } catch {
107 originOk = false;
108 }
109 } else if (referer) {
110 try {
111 originOk = new URL(referer).host === host;
112 } catch {
113 originOk = false;
114 }
115 }
116 }
117 if (originOk) {
118 return next();
119 }
120
121 // ---- 2) Double-submit cookie token (fallback) ------------------------
77122 const cookieToken = getCookie(c, CSRF_COOKIE);
78123 if (!cookieToken) {
79 return c.text("CSRF token missing", 403);
124 return c.text("CSRF check failed: no Origin/Referer header and no token cookie", 403);
80125 }
81126
82127 // Check header first, then form body
Modifiedsrc/middleware/rate-limit.ts+9−1View fileUnifiedSplit
@@ -39,7 +39,15 @@ export function rateLimit(
3939 // In test env, expose informational rate-limit headers but do not actually
4040 // enforce limits — the shared in-memory store leaks across test files and
4141 // would push requests into 429 once accumulated.
42 if (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test") {
42 //
43 // Belt-and-braces: refuse to disable enforcement when NODE_ENV=production
44 // even if BUN_ENV/NODE_ENV are also somehow set to "test". A misconfigured
45 // production container with a leaked test env var must not silently drop
46 // rate limiting.
47 const isTestEnv =
48 process.env.NODE_ENV !== "production" &&
49 (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test");
50 if (isTestEnv) {
4351 c.header("X-RateLimit-Limit", String(maxRequests));
4452 c.header("X-RateLimit-Remaining", String(maxRequests));
4553 c.header("X-RateLimit-Reset", String(Math.ceil((Date.now() + windowMs) / 1000)));
Modifiedsrc/middleware/repo-access.ts+6−8View fileUnifiedSplit
@@ -195,23 +195,21 @@ export function requireRepoAccess(
195195 import("hono/jsx"),
196196 import("../views/layout"),
197197 ]);
198 const body = jsx(
198 const body = (jsx as any)(
199199 "div",
200200 {
201201 style:
202202 "max-width: 600px; margin: 80px auto; padding: 24px; text-align: center;",
203203 },
204 [
205 jsx("h1", { style: "margin-bottom: 12px" }, ["403 — Access denied"]),
206 jsx("p", { style: "color: var(--muted, #8b949e)" }, [reason]),
207 ]
204 (jsx as any)("h1", { style: "margin-bottom: 12px" }, "403 — Access denied"),
205 (jsx as any)("p", { style: "color: var(--muted, #8b949e)" }, reason)
208206 );
209 const page = jsx(
207 const page = (jsx as any)(
210208 Layout as any,
211209 { title: "Access denied", user },
212 [body]
210 body
213211 );
214 return c.html(page, 403);
212 return c.html(page as any, 403);
215213 }
216214
217215 c.set("repoAccess", access);
Modifiedsrc/routes/dep-updater.tsx+2−1View fileUnifiedSplit
@@ -13,6 +13,7 @@ import { db } from "../db";
1313import { depUpdateRuns, repositories, users } from "../db/schema";
1414import { Layout } from "../views/layout";
1515import { RepoHeader } from "../views/components";
16import { formatRelative } from "../views/ui";
1617import { IssueNav } from "./issues";
1718import { softAuth, requireAuth } from "../middleware/auth";
1819import type { AuthEnv } from "../middleware/auth";
@@ -170,7 +171,7 @@ depUpdater.get(
170171 const applied = safeParseBumps(r.appliedBumps);
171172 const attempted = safeParseBumps(r.attemptedBumps);
172173 const bumps = applied.length > 0 ? applied : attempted;
173 const when = new Date(r.createdAt).toLocaleString();
174 const when = formatRelative(r.createdAt as unknown as string);
174175 return (
175176 <div
176177 class="issue-row"
Modifiedsrc/routes/gates.tsx+2−2View fileUnifiedSplit
@@ -114,7 +114,7 @@ gates.get("/:owner/:repo/gates", async (c) => {
114114 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Passed</div>
115115 </div>
116116 <div class="panel" style="padding: 12px; text-align: center">
117 <div style="font-size: 22px; font-weight: 700; color: #bc8cff">{repaired}</div>
117 <div style="font-size: 22px; font-weight: 700; color: var(--accent)">{repaired}</div>
118118 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Repaired</div>
119119 </div>
120120 <div class="panel" style="padding: 12px; text-align: center">
@@ -154,7 +154,7 @@ gates.get("/:owner/:repo/gates", async (c) => {
154154 </div>
155155 )}
156156 {r.repairCommitSha && (
157 <div style="font-size: 12px; color: #bc8cff; margin-top: 2px">
157 <div style="font-size: 12px; color: var(--accent); margin-top: 2px">
158158 Auto-repaired in{" "}
159159 <a href={`/${owner}/${repo}/commit/${r.repairCommitSha}`}>
160160 {r.repairCommitSha.slice(0, 7)}
Modifiedsrc/routes/import-bulk.tsx+1−1View fileUnifiedSplit
@@ -125,7 +125,7 @@ importBulkRoutes.get("/import/bulk", requireAuth, async (c) => {
125125 </ul>
126126 </div>
127127
128 <form method="POST" action="/import/bulk">
128 <form method="post" action="/import/bulk">
129129 <div class="form-group">
130130 <label style="display:block; margin-bottom:4px; font-size:13px">
131131 GitHub org
Modifiedsrc/routes/import.tsx+5−5View fileUnifiedSplit
@@ -85,7 +85,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
8585 </p>
8686 <a
8787 href="/import/bulk"
88 style="display: block; background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid #3fb950; border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; font-size: 14px; text-decoration: none; color: inherit"
88 style="display: block; background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid var(--green); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; font-size: 14px; text-decoration: none; color: inherit"
8989 >
9090 <strong>Migrating a whole org? Try the bulk importer →</strong>
9191 <div style="color: var(--text-muted); margin-top: 4px">
@@ -116,7 +116,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
116116 id="import-progress"
117117 role="status"
118118 aria-live="polite"
119 style="display: none; background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid #f0b429; border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; font-size: 14px"
119 style="display: none; background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid var(--yellow); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; font-size: 14px"
120120 >
121121 <strong>Import in progress…</strong>
122122 <div style="color: var(--text-muted); margin-top: 4px">
@@ -129,7 +129,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
129129 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
130130 Import all public repositories from a GitHub user or organization.
131131 </p>
132 <form method="POST" action="/import/github/user" data-import-form>
132 <form method="post" action="/import/github/user" data-import-form>
133133 <div style="display: flex; gap: 8px">
134134 <input
135135 type="text"
@@ -151,7 +151,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
151151 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
152152 Import a specific repository by URL (https, ssh, or owner/repo).
153153 </p>
154 <form method="POST" action="/import/github/repo" data-import-form>
154 <form method="post" action="/import/github/repo" data-import-form>
155155 <div style="display: flex; gap: 8px">
156156 <input
157157 type="text"
@@ -174,7 +174,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
174174 Use a GitHub personal access token to import private repositories too.
175175 Generate one at github.com → Settings → Developer settings → Personal access tokens.
176176 </p>
177 <form method="POST" action="/import/github/user" data-import-form>
177 <form method="post" action="/import/github/user" data-import-form>
178178 <div class="form-group">
179179 <input
180180 type="text"
Modifiedsrc/routes/migrations.tsx+2−3View fileUnifiedSplit
@@ -20,6 +20,7 @@ import { desc, eq } from "drizzle-orm";
2020import { db } from "../db";
2121import { repositories } from "../db/schema";
2222import { Layout } from "../views/layout";
23import { formatRelative } from "../views/ui";
2324import { requireAuth } from "../middleware/auth";
2425import type { AuthEnv } from "../middleware/auth";
2526
@@ -154,9 +155,7 @@ migrations.get("/migrations", async (c) => {
154155 —
155156 </div>
156157 <div style="flex:1;min-width:0;font-size:12px;color:var(--text-muted)">
157 {r.createdAt
158 ? new Date(r.createdAt).toLocaleString()
159 : "—"}
158 {r.createdAt ? formatRelative(r.createdAt as unknown as string) : "—"}
160159 </div>
161160 <div style="width:120px;text-align:right">
162161 <a
Modifiedsrc/routes/mirrors.tsx+5−4View fileUnifiedSplit
@@ -14,6 +14,7 @@ import { db } from "../db";
1414import { repositories, users } from "../db/schema";
1515import { Layout } from "../views/layout";
1616import { RepoHeader } from "../views/components";
17import { formatRelative } from "../views/ui";
1718import { softAuth, requireAuth } from "../middleware/auth";
1819import type { AuthEnv } from "../middleware/auth";
1920import { isSiteAdmin } from "../lib/admin";
@@ -180,9 +181,9 @@ mirrors.get("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
180181 style="font-size:12px;color:var(--text-muted);text-transform:uppercase"
181182 >
182183 {mirror.lastStatus === "ok" ? "Success" : "Error"} —{" "}
183 {new Date(
184 {formatRelative(
184185 mirror.lastSyncedAt as unknown as string
185 ).toLocaleString()}
186 )}
186187 </div>
187188 {mirror.lastError && (
188189 <pre
@@ -222,9 +223,9 @@ mirrors.get("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
222223 <span
223224 style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)"
224225 >
225 {new Date(
226 {formatRelative(
226227 r.startedAt as unknown as string
227 ).toLocaleString()}
228 )}
228229 </span>
229230 </div>
230231 {r.message && (
Modifiedsrc/routes/packages.tsx+2−2View fileUnifiedSplit
@@ -163,7 +163,7 @@ ui.get("/:owner/:repo/packages", async (c) => {
163163 </li>
164164 <li>
165165 Add to your <code>.npmrc</code>:
166 <pre style="background: #0b0d0f; color: #c7ccd1; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin: 6px 0">
166 <pre style="background: var(--bg-tertiary); color: var(--text); padding: 8px 12px; border-radius: 4px; font-size: 12px; margin: 6px 0">
167167 registry={registryUrl}
168168 {"\n"}
169169 //{host}/npm/:_authToken=YOUR_PAT
@@ -315,7 +315,7 @@ ui.get("/:owner/:repo/packages/:pkgName{.+}", async (c) => {
315315 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 0 0 8px 0">
316316 Install
317317 </h3>
318 <pre style="background: #0b0d0f; color: #c7ccd1; padding: 10px 14px; border-radius: 6px; font-size: 13px; overflow-x: auto">
318 <pre style="background: var(--bg-tertiary); color: var(--text); padding: 10px 14px; border-radius: 6px; font-size: 13px; overflow-x: auto">
319319 npm install {fullName}
320320 {latestVersion ? `@${latestVersion.version}` : ""}
321321 </pre>
Modifiedsrc/routes/pulls.tsx+0−1View fileUnifiedSplit
@@ -27,7 +27,6 @@ import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
2727import { triggerPrTriage } from "../lib/pr-triage";
2828import { generatePrSummary } from "../lib/ai-generators";
2929import { isAiAvailable } from "../lib/ai-client";
30import { getRepoPath } from "../git/repository";
3130import { runAllGateChecks } from "../lib/gate";
3231import type { GateCheckResult } from "../lib/gate";
3332import {
Modifiedsrc/routes/workflows.tsx+1−1View fileUnifiedSplit
@@ -493,7 +493,7 @@ actions.get("/:owner/:repo/actions/runs/:runId", async (c) => {
493493 if (j.logs && j.logs.length > 0) {
494494 return (
495495 <pre
496 style="margin: 0; padding: 12px 14px; background: #0b0d0f; color: #c7ccd1; font-size: 12px; line-height: 1.45; overflow-x: auto; max-height: 480px; border-top: 1px solid var(--border)"
496 style="margin: 0; padding: 12px 14px; background: var(--bg-tertiary); color: var(--text); font-size: 12px; line-height: 1.45; overflow-x: auto; max-height: 480px; border-top: 1px solid var(--border)"
497497 >
498498 {j.logs}
499499 </pre>
Modifiedsrc/views/live-feed.tsx+1−1View fileUnifiedSplit
@@ -13,7 +13,7 @@ import { liveSubscribeScript } from "../lib/sse-client";
1313export function LiveFeed(props: {
1414 topic: string;
1515 title?: string;
16}): JSX.Element {
16}) {
1717 const title = props.title ?? "Live activity";
1818 const listId = "live-feed";
1919
Modifiedsrc/views/log-tail.tsx+2−2View fileUnifiedSplit
@@ -19,7 +19,7 @@ export function LogTail(props: {
1919 fallbackLogs?: string | null;
2020 height?: string;
2121 reloadOnRunDone?: boolean;
22}): JSX.Element {
22}) {
2323 const elementId = `log-tail-${props.runId}${props.jobId ? "-" + props.jobId : ""}`;
2424 const topic = `workflow-run-${props.runId}`;
2525 const script = liveLogTailScript({
@@ -40,7 +40,7 @@ export function LogTail(props: {
4040 </div>
4141 <pre
4242 id={elementId}
43 style={`margin: 0; padding: 12px 14px; background: #0b0d0f; color: #c7ccd1; font-size: 12px; line-height: 1.45; overflow: auto; max-height: ${props.height || "480px"}; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px`}
43 style={`margin: 0; padding: 12px 14px; background: var(--bg-tertiary); color: var(--text); font-size: 12px; line-height: 1.45; overflow: auto; max-height: ${props.height || "480px"}; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px`}
4444 >
4545 {props.fallbackLogs || ""}
4646 </pre>
Modifiedsrc/views/ui.tsx+10−2View fileUnifiedSplit
@@ -116,9 +116,17 @@ export const Form: FC<
116116 method?: string;
117117 csrfToken?: string;
118118 class?: string;
119 id?: string;
120 encType?: string;
119121 }>
120> = ({ children, action, method = "POST", csrfToken, class: cls }) => (
121 <form method={method.toLowerCase() as any} action={action} class={cls || ""}>
122> = ({ children, action, method = "POST", csrfToken, class: cls, id, encType }) => (
123 <form
124 method={method.toLowerCase() as any}
125 action={action}
126 class={cls || ""}
127 id={id}
128 enctype={encType as any}
129 >
122130 {csrfToken && <input type="hidden" name="_csrf" value={csrfToken} />}
123131 {children}
124132 </form>
125133