Commit8102dd4
fix(audit): private-repo API leak, dead pushedAt, "000" counts, layout overflow
fix(audit): private-repo API leak, dead pushedAt, "000" counts, layout overflow Findings from a 270-page live render sweep (96 anon + 147 authed desktop, 27 mobile @390px). Six independent defects, each verified against the live site before and after. - api: GET /api/repos/:owner/:name returned the full repo row — including isPrivate, description, ownerId and the internal diskPath — to anonymous callers. Gate on the existing resolveRepoAccess() helper (mirroring the HTML surface, which already 404s) and never serialize diskPath at all. Types the api router with AuthEnv so it can read the softAuth viewer. - post-receive: repositories.pushedAt was never written anywhere in the codebase, so it read null forever and every "recently active" sort and dashboard ranked all repos as dormant. Gluecron.com's own updated_at was still its creation date despite constant pushes. Stamp both on push, fire-and-forget so the push path cannot break. - web: the "Get started with <repo>" card is an EMPTY-repo nudge, but was gated only on owner + !onboarding_shown, and onboarding_shown flips only on explicit dismiss. A populated repo (27 files, 7 branches) therefore rendered "add a README" above the fold forever. Require an actually empty tree + no branches. - pulls: sql<number> is a compile-time cast only — Postgres count() returns bigint, which the driver returns as a string. "0"+"0"+"0" rendered the All pill as "000", and openCount === 0 was never true so the empty state never showed. Coerce with Number() at the boundary. - app: mount marketplaceAgentsRoutes before marketplaceRoutes; the generic /marketplace/:slug was swallowing /marketplace/agents and 404ing it. - layout: fix horizontal overflow on five pages. docs used a 1fr grid track (min-width:auto) that grew to its widest <pre>, so overflow-x:auto never engaged — 3406px wide at a 1440 viewport. signin-v2 ships a standalone stylesheet that never reset the browser default body margin, and .si-magic-row floored the column at 422px. pricing/enterprise hero orbs are decorative and now clip. Verified: 3406->1440, 3092->390, 821->390, 430->390, 405->390, 435->390. - deploy: the container never received a build SHA. self-deploy.sh pinned BUILD_SHA into a systemd drop-in, but the app runs under docker compose, which passed neither BUILD_SHA nor GIT_SHA — and build-info.ts reads GIT_SHA while version.ts/pwa.ts read BUILD_SHA. So the footer showed "unknown . unknown", /api/version reported a SHA that never changed (the auto-update banner could never fire) and the PWA service-worker cache key never rotated between deploys. Export all three in auto-update.sh (the real deploy path) and interpolate them in the compose file. Typecheck clean. Test suite: 3122 pass / 4 fail — the same 4 that already fail on main (login shell, playground x2, wiki 404), unchanged by this work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 files changed+105−88102dd48ce9b9f7f31208b9e3c7f5866014194d9
11 changed files+105−8
Modifieddocker-compose.standalone.yml+10−0View fileUnifiedSplit
@@ -55,6 +55,16 @@ services:
5555 - APP_BASE_URL=https://gluecron.com
5656 - SSH_PORT=0
5757 - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
58 # Build provenance. The image has no .git, so without these
59 # src/lib/build-info.ts falls through to "unknown" — which froze the
60 # footer at "unknown · unknown", made /api/version report a SHA that
61 # never changes (so the auto-update banner could never fire), and left
62 # the PWA service-worker cache key static across deploys.
63 # GIT_SHA is what build-info.ts reads; BUILD_SHA is what version.ts and
64 # pwa.ts read. Both are exported by scripts/self-deploy.sh.
65 - GIT_SHA=${GIT_SHA:-}
66 - GIT_BRANCH=${GIT_BRANCH:-main}
67 - BUILD_SHA=${BUILD_SHA:-}
5868 # "Sign in with Google" — values come from .env (gitignored). Wiring them
5969 # here is what makes the env bootstrap reach the container and survive
6070 # redeploys; a config saved at /admin/google-oauth (DB) still takes
Modifiedscripts/auto-update.sh+11−0View fileUnifiedSplit
@@ -44,6 +44,17 @@ prev_sha="$local_sha"
4444
4545git reset --hard "origin/$BRANCH" # untracked .env / backups are preserved
4646
47# Build provenance for the container. The image ships without a .git dir, so
48# src/lib/build-info.ts can only report a real SHA if we hand it one via env.
49# Without this the footer renders "unknown · unknown", /api/version reports a
50# SHA that never changes (so the client auto-update banner can never fire),
51# and the PWA service-worker cache key never rotates between deploys.
52# docker-compose.standalone.yml interpolates these at `up` time.
53GIT_SHA="$(git rev-parse HEAD)"
54GIT_BRANCH="$BRANCH"
55BUILD_SHA="$GIT_SHA"
56export GIT_SHA GIT_BRANCH BUILD_SHA
57
4758# NOTE: `|| true` is deliberate. On this Coolify co-tenant box the compose
4859# also defines a `caddy` service that tries to bind host :80/:443, which
4960# Coolify's proxy already owns — so `up` reports a non-zero exit for caddy
Modifiedsrc/app.tsx+5−1View fileUnifiedSplit
@@ -839,8 +839,12 @@ app.route("/", gatesRoutes);
839839app.route("/", gistsRoutes);
840840app.route("/", graphqlRoutes);
841841app.route("/", mcpRoutes);
842app.route("/", marketplaceRoutes);
842// marketplaceAgentsRoutes MUST precede marketplaceRoutes: the latter defines
843// the generic `/marketplace/:slug`, which otherwise swallows the literal
844// `/marketplace/agents` (and `/marketplace/agents/publish`) and 404s them as
845// unknown app slugs.
843846app.route("/", marketplaceAgentsRoutes);
847app.route("/", marketplaceRoutes);
844848app.route("/", mergeQueueRoutes);
845849app.route("/", mirrorsRoutes);
846850// orgInsightsRoutes + orgHealthRoutes are mounted earlier (before the generic
Modifiedsrc/hooks/post-receive.ts+19−0View fileUnifiedSplit
@@ -133,6 +133,25 @@ export async function onPostReceive(
133133 repoId = repoRow?.id ?? "";
134134 } catch { /* non-blocking */ }
135135
136 // Stamp `pushed_at` (and bump `updated_at`) so every "recently active"
137 // sort, dashboard ordering and repo-list surface reflects this push.
138 // Nothing else in the codebase ever wrote this column, so it read null
139 // forever and those surfaces silently ranked every repo as dormant.
140 // Fire-and-forget: a DB hiccup must never break the push path.
141 if (repoId && refs.some((r) => !r.newSha.startsWith("0000"))) {
142 const pushedAt = new Date();
143 void db
144 .update(repositories)
145 .set({ pushedAt, updatedAt: pushedAt })
146 .where(eq(repositories.id, repoId))
147 .catch((err) => {
148 console.warn(
149 "[post-receive] pushedAt update failed:",
150 err instanceof Error ? err.message : err
151 );
152 });
153 }
154
136155 const automationSettings = repoId
137156 ? await getAutomationSettings(repoId).catch(() => null)
138157 : null;
Modifiedsrc/routes/api.ts+23−2View fileUnifiedSplit
@@ -3,6 +3,7 @@
33 */
44
55import { Hono } from "hono";
6import type { AuthEnv } from "../middleware/auth";
67import { eq, and, isNull, ilike, sql } from "drizzle-orm";
78import { db } from "../db";
89import { users, repositories, organizations, orgMembers } from "../db/schema";
@@ -11,7 +12,9 @@ import { hashPassword } from "../lib/auth";
1112import { orgRoleAtLeast } from "../lib/orgs";
1213import { renderMarkdown } from "../lib/markdown";
1314
14const api = new Hono().basePath("/api");
15// Typed with AuthEnv so handlers can read the viewer that the global
16// `softAuth` middleware already resolved (needed for private-repo gating).
17const api = new Hono<AuthEnv>().basePath("/api");
1518
1619// Create repository
1720api.post("/repos", async (c) => {
@@ -175,7 +178,25 @@ api.get("/repos/:owner/:name", async (c) => {
175178 const { loadRepoByPath } = await import("../lib/namespace");
176179 const repo = await loadRepoByPath(ownerName, name);
177180 if (!repo) return c.json({ error: "Not found" }, 404);
178 return c.json(repo);
181
182 // A private repo must not disclose its existence — let alone its
183 // description, ownerId and on-disk path — to a viewer without read
184 // access. Mirror the HTML surface, which already 404s here.
185 const viewer = c.get("user");
186 const { resolveRepoAccess } = await import("../middleware/repo-access");
187 const access = await resolveRepoAccess({
188 repoId: repo.id,
189 userId: viewer?.id ?? null,
190 isPublic: !repo.isPrivate,
191 });
192 if (access === "none") return c.json({ error: "Not found" }, 404);
193
194 // `diskPath` is server-internal (it leaks the storage layout); never
195 // serialize it to an API client regardless of access level.
196 const { diskPath: _diskPath, ...safeRepo } = repo as typeof repo & {
197 diskPath?: string;
198 };
199 return c.json(safeRepo);
179200 } catch (err) {
180201 console.error("[api] /repos/:owner/:name:", err);
181202 return c.json({ error: "Service unavailable" }, 503);
Modifiedsrc/routes/docs.tsx+6−0View fileUnifiedSplit
@@ -33,6 +33,12 @@ const docsStyles = `
3333 gap: var(--space-6);
3434 align-items: start;
3535 }
36 /* A 1fr grid track defaults to min-width:auto, so it grows to fit its
37 widest child — a long unwrapped line inside .docs-content pre stretched
38 the whole page to ~3400px and overflow-x:auto on the pre never got a
39 chance to kick in. min-width:0 lets the track shrink so the pre
40 scrolls internally instead. */
41 .docs-content { min-width: 0; }
3642 (max-width: 800px) {
3743 .docs-wrap { grid-template-columns: 1fr; }
3844 .docs-sidebar { display: none; }
Modifiedsrc/routes/enterprise.tsx+3−0View fileUnifiedSplit
@@ -353,6 +353,9 @@ const enterpriseCss = `
353353 position: relative;
354354 max-width: 820px;
355355 margin: 0 auto;
356 /* Clip the decorative absolutely-positioned orb so it can't push the
357 document wider than the viewport on narrow screens. */
358 overflow: hidden;
356359 }
357360 .ent-hero-hairline {
358361 position: absolute;
Modifiedsrc/routes/pricing.tsx+4−0View fileUnifiedSplit
@@ -389,6 +389,10 @@ const pricingCss = `
389389 max-width: 920px;
390390 margin: 0 auto;
391391 position: relative;
392 /* The decorative 420px orb is absolutely positioned and centred, so on
393 narrow viewports it hangs ~15px past each edge and gives the whole
394 document a horizontal scrollbar. Clip it — it is purely decorative. */
395 overflow: hidden;
392396 }
393397 .pl-hero::before {
394398 content: '';
Modifiedsrc/routes/pulls.tsx+8−4View fileUnifiedSplit
@@ -2695,10 +2695,14 @@ pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c)
26952695 .from(pullRequests)
26962696 .where(eq(pullRequests.repositoryId, resolved.repo.id));
26972697
2698 const openCount = counts?.open ?? 0;
2699 const mergedCount = counts?.merged ?? 0;
2700 const closedCount = counts?.closed ?? 0;
2701 const draftCount = counts?.draft ?? 0;
2698 // `sql<number>` is a compile-time cast only: Postgres count() returns
2699 // bigint, which the driver hands back as a STRING. Without Number() the
2700 // "All" pill renders "0"+"0"+"0" = "000", and `openCount === 0` is never
2701 // true so the empty state never shows. Coerce at the boundary.
2702 const openCount = Number(counts?.open ?? 0);
2703 const mergedCount = Number(counts?.merged ?? 0);
2704 const closedCount = Number(counts?.closed ?? 0);
2705 const draftCount = Number(counts?.draft ?? 0);
27022706 const allCount = openCount + mergedCount + closedCount;
27032707
27042708 // "All" is presentational only — the DB query for state='all' matches
Modifiedsrc/routes/web.tsx+6−1View fileUnifiedSplit
@@ -3141,7 +3141,12 @@ web.get("/:owner/:repo", async (c) => {
31413141 // if the DB is down the card simply won't appear. Only the owner sees it.
31423142 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
31433143 let showOnboarding = false;
3144 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
3144 // The card is an EMPTY-REPO nudge ("add a README, add a .gitignore"). It must
3145 // only appear while the repo actually is empty — otherwise a populated repo
3146 // (files + branches pushed) keeps rendering "Get started" above the fold
3147 // forever, because `onboarding_shown` only flips on an explicit dismiss.
3148 const repoLooksEmpty = tree.length === 0 && branches.length === 0;
3149 if (repoLooksEmpty && repoId && user && repoOwnerId && user.id === repoOwnerId) {
31453150 try {
31463151 // Check if onboarding_shown is still false (not yet dismissed)
31473152 const [repoRow2] = await db
Modifiedsrc/views/signin-v2.tsx+10−0View fileUnifiedSplit
@@ -270,6 +270,11 @@ const css = `
270270
271271*, *::before, *::after { box-sizing: border-box; }
272272
273/* This view ships its own standalone stylesheet (it does not go through
274 layout.tsx), so the browser default body margin of 8px still applied and
275 pushed the document 8px wider than the viewport on mobile. */
276html, body { margin: 0; padding: 0; }
277
273278.si-root {
274279 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
275280 font-size: 14px;
@@ -291,6 +296,10 @@ const css = `
291296 flex-direction: column;
292297 padding: 28px 32px;
293298 min-height: 100vh;
299 /* Grid/flex items default to min-width:auto, so .si-magic-row
300 ("Email me a link", min-content 358px) floored this column at 422px and
301 forced a horizontal scrollbar at 390px. Let it shrink instead. */
302 min-width: 0;
294303}
295304
296305.si-logo {
@@ -322,6 +331,7 @@ const css = `
322331 justify-content: center;
323332 max-width: 380px;
324333 width: 100%;
334 min-width: 0;
325335 margin: 0 auto;
326336 padding: 48px 0;
327337}
328338