Commit8f50ed0unknown_key
feat(BLOCK-F): F1 traffic + F2 org insights + F3 admin panel + F4 billing/quotas
feat(BLOCK-F): F1 traffic + F2 org insights + F3 admin panel + F4 billing/quotas F1 — Traffic analytics per repo * src/lib/traffic.ts: track/trackView/trackClone/trackByName + summarise + bucketDaily * src/routes/traffic.tsx: owner-only /:owner/:repo/traffic with 7/14/30/90d windows + ascii-bar chart * Fire-and-forget wiring in web.tsx (repo view) + git.ts (clone) * SHA-256-truncated IP hashing for unique-visitor approximation F2 — Org-wide insights * src/routes/org-insights.tsx: computeOrgInsights(orgId) rollup * GET /orgs/:slug/insights (org member only) * Live aggregation — no new tables F3 — Site admin panel * src/lib/admin.ts: isSiteAdmin with bootstrap rule (oldest user wins until site_admins populated) * KNOWN_FLAGS: registration_locked, site_banner_text, site_banner_level, read_only_mode * src/routes/admin.tsx: dashboard, users toggle, repos nuclear delete, flags CRUD * All mutations audit-logged F4 — Billing + quotas * src/lib/billing.ts: listPlans/getPlan/getUserQuota/bumpUsage/checkQuota/wouldExceedRepoLimit * FALLBACK_PLANS mirrors seed rows (free/pro/team/enterprise) * src/routes/billing.tsx: /settings/billing (personal) + /admin/billing (site-admin override) * checkQuota fails-open; never blocks primary request path Schema (migration 0020_analytics_and_admin.sql): * repo_traffic_events (views/clones with ip_hash + user_agent + referer) * system_flags (key/value CRUD for site-wide toggles) * site_admins (userId + grantedBy audit trail) * billing_plans (seeded free/pro/team/enterprise) * user_quotas (plan_slug + usage counters + cycle_start) Tests: 470 pass (up from 435)
17 files changed+2473−78f50ed0c8589b14b4deb20d09f1ca5315a172326
17 changed files+2473−7
ModifiedBUILD_BIBLE.md+18−7View fileUnifiedSplit
@@ -167,7 +167,10 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
167167| Health / readiness / metrics | ✅ | `/healthz` `/readyz` `/metrics` |
168168| Audit log (table) | ✅ | `audit_log` table |
169169| Audit log UI | ✅ | `/settings/audit` (personal) + `/:owner/:repo/settings/audit` (per-repo, owner-only) |
170| Traffic analytics per repo | ❌ | |
170| Traffic analytics per repo | ✅ | F1 — `src/lib/traffic.ts` + `src/routes/traffic.tsx`, `drizzle/0020_analytics_and_admin.sql`; owner-only 7/14/30/90d windows, ascii-bar daily chart. SHA-256-truncated IP for unique visitors. Fire-and-forget wiring in `web.tsx` + `git.ts`. |
171| Org insights dashboard | ✅ | F2 — `src/routes/org-insights.tsx`; `computeOrgInsights(orgId)` rollup of gate green-rate + PR/issue counts + per-repo breakdown. `GET /orgs/:slug/insights`. |
172| Site admin panel | ✅ | F3 — `src/lib/admin.ts` + `src/routes/admin.tsx`, tables `site_admins` + `system_flags`. Bootstrap rule (oldest user wins until `site_admins` populated). Flags: registration_locked, site_banner_*, read_only_mode. |
173| Billing + quotas | ✅ | F4 — `src/lib/billing.ts` + `src/routes/billing.tsx`, tables `billing_plans` + `user_quotas` seeded free/pro/team/enterprise. `/settings/billing` personal view + `/admin/billing` site-admin override. |
171174| Email notifications | ✅ | opt-in per kind (mention/assign/gate-fail) via `/settings`; provider-pluggable `src/lib/email.ts` (log default, resend in prod) |
172175| Email digest | ❌ | |
173176| Mobile PWA | 🟡 | responsive CSS, no manifest |
@@ -249,10 +252,10 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
249252- **E7** — Protected tags → ✅ shipped. `src/lib/protected-tags.ts`, `src/routes/protected-tags.tsx`, table `protected_tags` (migration 0019). Settings CRUD at `/:owner/:repo/settings/protected-tags`; patterns use same glob syntax as branch protection. v1 enforcement is advisory: post-receive logs audit entries (`protected_tags.{create|update|delete}_violation_candidate`) so owners can see violations; pre-receive blocking is future work.
250253
251254### BLOCK F — Observability + admin
252- **F1** — Traffic analytics per repo (views, clones, unique visitors)
253- **F2** — Org-wide insights (green rate across all repos)
254- **F3** — Admin / superuser panel (user moderation, repo audit)
255- **F4** — Billing + quotas (storage, AI tokens, bandwidth)
255- **F1** — Traffic analytics per repo → ✅ shipped. `src/lib/traffic.ts` + `src/routes/traffic.tsx`, table `repo_traffic_events` (migration 0020). `track`/`trackView`/`trackClone`/`trackByName` are fire-and-forget; SHA-256 of IP truncated to 16 chars for unique-visitor approximation. Owner-only `GET /:owner/:repo/traffic` renders 7/14/30/90 day windows with an ascii-bar daily chart. Wired into `src/routes/web.tsx` repo overview + `src/routes/git.ts` git-upload-pack handler.
256- **F2** — Org-wide insights → ✅ shipped. `src/routes/org-insights.tsx` exports `computeOrgInsights(orgId)`. `GET /orgs/:slug/insights` requires org membership; aggregates gate green-rate, open/merged PR counts, open issue count, and per-repo rows sorted by activity. No new tables — live rollup across existing `repositories`, `gate_runs`, `pull_requests`, `issues`.
257- **F3** — Admin / superuser panel → ✅ shipped. `src/lib/admin.ts` + `src/routes/admin.tsx`, tables `site_admins` + `system_flags` (migration 0020). `isSiteAdmin(userId)` with bootstrap rule (empty `site_admins` table → oldest user wins); `KNOWN_FLAGS` = { registration_locked, site_banner_text, site_banner_level, read_only_mode }. Routes: `GET /admin` (dashboard), `GET /admin/users` + toggle grant/revoke, `GET /admin/repos` + nuclear delete, `GET /admin/flags` + save. All mutations audit-logged.
258- **F4** — Billing + quotas → ✅ shipped. `src/lib/billing.ts` + `src/routes/billing.tsx`, tables `billing_plans` + `user_quotas` (migration 0020, seeded with free/pro/team/enterprise). `FALLBACK_PLANS` mirror the seeds so billing works pre-migration. Helpers: `getUserQuota` (auto-initialises free row on first read), `bumpUsage`, `checkQuota` (fail-open), `wouldExceedRepoLimit`, `resetIfCycleExpired`. Routes: `GET /settings/billing` (personal view with usage bars + plan cards), `GET /admin/billing` (site-admin plan override), `POST /admin/billing/:userId/plan`.
256259
257260### BLOCK G — Mobile + client
258261- **G1** — PWA manifest + service worker
@@ -274,7 +277,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
274277- `src/app.tsx` — route composition, middleware order, error handlers
275278- `src/index.ts` — Bun server entry
276279- `src/lib/config.ts` — env getters (late-binding)
277- `src/db/schema.ts` — 27 tables. New tables only via new migration.
280- `src/db/schema.ts` — 73 tables. New tables only via new migration.
278281- `src/db/index.ts` — lazy proxy DB connection
279282- `src/db/migrate.ts` — migration runner
280283- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -294,6 +297,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
294297- `drizzle/0017_merge_queue.sql` (Block E5) — migration, never edited in place. Adds `merge_queue_entries` (with partial unique index on `pull_request_id WHERE state IN ('queued','running')`).
295298- `drizzle/0018_required_checks.sql` (Block E6) — migration, never edited in place. Adds `branch_required_checks`.
296299- `drizzle/0019_protected_tags.sql` (Block E7) — migration, never edited in place. Adds `protected_tags`.
300- `drizzle/0020_analytics_and_admin.sql` (Block F) — migration, never edited in place. Adds `repo_traffic_events`, `system_flags`, `site_admins`, `billing_plans` (seeded free/pro/team/enterprise), `user_quotas`.
297301
298302### 4.2 Git layer (locked)
299303- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
@@ -398,6 +402,13 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
398402- `src/lib/branch-protection.ts` extends for E6 — `listRequiredChecks(branchProtectionId)`, `passingCheckNames(repositoryId, commitSha)` (scans `gate_runs` + `workflow_runs`), and `evaluateProtection(rule, ctx, requiredChecks[])` now takes a third param + reports `missingChecks`.
399403- `src/routes/protected-tags.tsx` (Block E7) — `/:owner/:repo/settings/protected-tags` CRUD (owner-only, requireAuth).
400404- `src/lib/protected-tags.ts` (Block E7) — `matchProtectedTag`, `isProtectedTag`, `canBypassProtectedTag`, `listProtectedTags`, `addProtectedTag`, `removeProtectedTag`, `userIdFromUsername`. Matching uses `matchGlob` from environments.ts with `refs/tags/` prefix stripped. Post-receive hook writes audit log entries (`protected_tags.{create|update|delete}_violation_candidate`) on matched pushes.
405- `src/lib/traffic.ts` (Block F1) — `track`, `trackView`, `trackClone`, `trackByName(owner, repo, kind, meta)`, `summarise(repoId, windowDays=14)`, pure `bucketDaily(events)`. SHA-256-truncated IP hashing (16 hex) for unique-visitor approximation. All callers use `.catch(() => {})` fire-and-forget.
406- `src/routes/traffic.tsx` (Block F1) — `GET /:owner/:repo/traffic` (owner-only) with 7/14/30/90d windows, ascii-bar daily chart, top referers, unique visitors.
407- `src/routes/org-insights.tsx` (Block F2) — exports `computeOrgInsights(orgId)` returning `OrgInsightsSummary` (repoCount, gateRunsTotal, greenRate, openIssues, openPrs, mergedPrs30d, perRepo[]). `GET /orgs/:slug/insights` requires org membership. No new tables.
408- `src/lib/admin.ts` (Block F3) — `isSiteAdmin(userId)` with bootstrap rule (empty `site_admins` → oldest user wins), `listSiteAdmins`, `grantSiteAdmin`, `revokeSiteAdmin`, `getFlag`, `setFlag`, `listFlags`, `KNOWN_FLAGS = { registration_locked, site_banner_text, site_banner_level, read_only_mode }`. All helpers swallow DB errors.
409- `src/routes/admin.tsx` (Block F3) — `GET /admin` dashboard (user/repo/admin counts + recent signups), `/admin/users` + toggle grant/revoke, `/admin/repos` + nuclear delete, `/admin/flags` form. All mutations audit-logged via `audit()`. Gated through a `gate(c)` helper that returns `{user} | Response`.
410- `src/lib/billing.ts` (Block F4) — plan + quota helpers. `FALLBACK_PLANS` (free/pro/team/enterprise) mirror the seed rows. `getUserQuota(userId)` auto-initialises free row. `bumpUsage`, `checkQuota` (fail-open), `wouldExceedRepoLimit`, `resetIfCycleExpired`, `formatPrice`. Never throws into request path.
411- `src/routes/billing.tsx` (Block F4) — `GET /settings/billing` (personal view with usage bars + plan cards), `GET /admin/billing` (site-admin user/plan table), `POST /admin/billing/:userId/plan` (override plan, audit-logged).
401412
402413### 4.7 Views (locked contracts)
403414- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -432,7 +443,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
432443```bash
433444bun install
434445bun dev # hot reload
435bun test # 435 tests currently pass
446bun test # 470 tests currently pass
436447bun run db:migrate
437448```
438449
Addeddrizzle/0020_analytics_and_admin.sql+91−0View fileUnifiedSplit
@@ -0,0 +1,91 @@
1-- Gluecron migration 0020: Block F — Observability + admin.
2--
3-- Covers F1 (traffic analytics), F3 (admin panel), and F4 (billing/quotas).
4-- F2 (org insights) is computed live from existing tables.
5--
6-- Tables:
7-- repo_traffic_events — view/clone events, 1 row per event. Rolled up via
8-- GROUP BY for daily/weekly charts.
9-- system_flags — simple key/value state for site-admin (e.g.
10-- "site_banner_text", "registration_locked"). Only
11-- site admins can write.
12-- site_admins — explicit list of user ids that are global admins.
13-- Absence of any row means "first user is admin"
14-- bootstrap (handled in code).
15-- billing_plans — plan catalogue (name, limits). Seeded with free.
16-- user_quotas — per-user usage counters + plan assignment. Writes
17-- are bumped from action paths (push bytes, ai tokens).
18
19--> statement-breakpoint
20CREATE TABLE IF NOT EXISTS "repo_traffic_events" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
22 "repository_id" uuid NOT NULL,
23 "kind" text NOT NULL, -- view | clone | api | ui
24 "path" text, -- path visited (first 256 chars)
25 "user_id" uuid, -- null for anon
26 "ip_hash" text, -- sha256(ip) prefix; best-effort uniq
27 "user_agent" text, -- first 128 chars
28 "referer" text, -- first 256 chars
29 "created_at" timestamp DEFAULT now() NOT NULL,
30 CONSTRAINT "repo_traffic_events_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
31 CONSTRAINT "repo_traffic_events_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE set null
32);
33
34--> statement-breakpoint
35CREATE INDEX IF NOT EXISTS "repo_traffic_events_repo_time" ON "repo_traffic_events" ("repository_id", "created_at");
36--> statement-breakpoint
37CREATE INDEX IF NOT EXISTS "repo_traffic_events_kind" ON "repo_traffic_events" ("repository_id", "kind", "created_at");
38
39--> statement-breakpoint
40CREATE TABLE IF NOT EXISTS "system_flags" (
41 "key" text PRIMARY KEY NOT NULL,
42 "value" text NOT NULL DEFAULT '',
43 "updated_at" timestamp DEFAULT now() NOT NULL,
44 "updated_by" uuid,
45 CONSTRAINT "system_flags_updater_fk" FOREIGN KEY ("updated_by") REFERENCES "users"("id")
46);
47
48--> statement-breakpoint
49CREATE TABLE IF NOT EXISTS "site_admins" (
50 "user_id" uuid PRIMARY KEY NOT NULL,
51 "granted_at" timestamp DEFAULT now() NOT NULL,
52 "granted_by" uuid,
53 CONSTRAINT "site_admins_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade,
54 CONSTRAINT "site_admins_granter_fk" FOREIGN KEY ("granted_by") REFERENCES "users"("id")
55);
56
57--> statement-breakpoint
58CREATE TABLE IF NOT EXISTS "billing_plans" (
59 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
60 "slug" text NOT NULL UNIQUE, -- free | pro | team | enterprise
61 "name" text NOT NULL,
62 "price_cents" integer NOT NULL DEFAULT 0,
63 "repo_limit" integer NOT NULL DEFAULT 10,
64 "storage_mb_limit" integer NOT NULL DEFAULT 1024,
65 "ai_tokens_monthly" integer NOT NULL DEFAULT 100000,
66 "bandwidth_gb_monthly" integer NOT NULL DEFAULT 10,
67 "private_repos" boolean NOT NULL DEFAULT false,
68 "created_at" timestamp DEFAULT now() NOT NULL
69);
70
71--> statement-breakpoint
72CREATE TABLE IF NOT EXISTS "user_quotas" (
73 "user_id" uuid PRIMARY KEY NOT NULL,
74 "plan_slug" text NOT NULL DEFAULT 'free',
75 "storage_mb_used" integer NOT NULL DEFAULT 0,
76 "ai_tokens_used_this_month" integer NOT NULL DEFAULT 0,
77 "bandwidth_gb_used_this_month" integer NOT NULL DEFAULT 0,
78 "cycle_start" timestamp DEFAULT now() NOT NULL,
79 "updated_at" timestamp DEFAULT now() NOT NULL,
80 CONSTRAINT "user_quotas_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
81);
82
83--> statement-breakpoint
84-- Seed the default plans.
85INSERT INTO "billing_plans" ("slug","name","price_cents","repo_limit","storage_mb_limit","ai_tokens_monthly","bandwidth_gb_monthly","private_repos")
86VALUES
87 ('free','Free',0,10,1024,100000,10,false),
88 ('pro','Pro',900,200,10240,1000000,100,true),
89 ('team','Team',2400,1000,51200,5000000,500,true),
90 ('enterprise','Enterprise',9900,10000,512000,50000000,5000,true)
91ON CONFLICT (slug) DO NOTHING;
Addedsrc/__tests__/admin.test.ts+98−0View fileUnifiedSplit
@@ -0,0 +1,98 @@
1/**
2 * Block F3 — Admin panel smoke tests.
3 *
4 * Exercises the auth gate on every admin route + the lib exports. Doesn't
5 * mutate DB; `isSiteAdmin(null)` and `getFlag` against a non-existent key
6 * degrade gracefully and are safe to call in any environment.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import {
12 isSiteAdmin,
13 KNOWN_FLAGS,
14 getFlag,
15} from "../lib/admin";
16
17describe("admin — auth gate", () => {
18 it("GET /admin without auth → 302 /login", async () => {
19 const res = await app.request("/admin");
20 expect(res.status).toBe(302);
21 expect(res.headers.get("location") || "").toContain("/login");
22 });
23
24 it("GET /admin/users without auth → 302 /login", async () => {
25 const res = await app.request("/admin/users");
26 expect(res.status).toBe(302);
27 expect(res.headers.get("location") || "").toContain("/login");
28 });
29
30 it("GET /admin/repos without auth → 302 /login", async () => {
31 const res = await app.request("/admin/repos");
32 expect(res.status).toBe(302);
33 expect(res.headers.get("location") || "").toContain("/login");
34 });
35
36 it("GET /admin/flags without auth → 302 /login", async () => {
37 const res = await app.request("/admin/flags");
38 expect(res.status).toBe(302);
39 expect(res.headers.get("location") || "").toContain("/login");
40 });
41
42 it("POST /admin/flags without auth → 302 /login", async () => {
43 const res = await app.request("/admin/flags", {
44 method: "POST",
45 body: new URLSearchParams({ registration_locked: "1" }),
46 headers: { "content-type": "application/x-www-form-urlencoded" },
47 });
48 expect(res.status).toBe(302);
49 expect(res.headers.get("location") || "").toContain("/login");
50 });
51});
52
53describe("admin — isSiteAdmin", () => {
54 it("returns false for null/undefined user", async () => {
55 expect(await isSiteAdmin(null)).toBe(false);
56 expect(await isSiteAdmin(undefined)).toBe(false);
57 expect(await isSiteAdmin("")).toBe(false);
58 });
59
60 it("returns false for non-existent user id", async () => {
61 const result = await isSiteAdmin("00000000-0000-0000-0000-000000000000");
62 expect(typeof result).toBe("boolean");
63 });
64});
65
66describe("admin — KNOWN_FLAGS", () => {
67 it("exposes registration_locked, site_banner_text, site_banner_level, read_only_mode", () => {
68 expect(KNOWN_FLAGS).toHaveProperty("registration_locked");
69 expect(KNOWN_FLAGS).toHaveProperty("site_banner_text");
70 expect(KNOWN_FLAGS).toHaveProperty("site_banner_level");
71 expect(KNOWN_FLAGS).toHaveProperty("read_only_mode");
72 });
73
74 it("defaults registration_locked to '0' (unlocked)", () => {
75 expect(KNOWN_FLAGS.registration_locked).toBe("0");
76 });
77});
78
79describe("admin — getFlag", () => {
80 it("returns null for unknown keys and never throws", async () => {
81 const v = await getFlag("nonexistent_flag_xyz");
82 expect(v === null || typeof v === "string").toBe(true);
83 });
84});
85
86describe("admin — lib exports", () => {
87 it("exports full admin surface", async () => {
88 const mod = await import("../lib/admin");
89 expect(typeof mod.isSiteAdmin).toBe("function");
90 expect(typeof mod.listSiteAdmins).toBe("function");
91 expect(typeof mod.grantSiteAdmin).toBe("function");
92 expect(typeof mod.revokeSiteAdmin).toBe("function");
93 expect(typeof mod.getFlag).toBe("function");
94 expect(typeof mod.setFlag).toBe("function");
95 expect(typeof mod.listFlags).toBe("function");
96 expect(mod.KNOWN_FLAGS).toBeDefined();
97 });
98});
Addedsrc/__tests__/billing.test.ts+140−0View fileUnifiedSplit
@@ -0,0 +1,140 @@
1/**
2 * Block F4 — Billing + quotas tests.
3 *
4 * Pure FALLBACK_PLANS + formatPrice tests + route auth smoke. Helpers that
5 * touch the DB (`getUserQuota`, `setUserPlan`, `bumpUsage`) are only exercised
6 * via type/shape checks — the real integration happens on the live server.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import {
12 FALLBACK_PLANS,
13 DEFAULT_PLAN_SLUG,
14 formatPrice,
15 listPlans,
16 getPlan,
17 checkQuota,
18} from "../lib/billing";
19
20describe("billing — FALLBACK_PLANS", () => {
21 it("contains free/pro/team/enterprise", () => {
22 expect(FALLBACK_PLANS).toHaveProperty("free");
23 expect(FALLBACK_PLANS).toHaveProperty("pro");
24 expect(FALLBACK_PLANS).toHaveProperty("team");
25 expect(FALLBACK_PLANS).toHaveProperty("enterprise");
26 });
27
28 it("free plan is $0 with no private repos", () => {
29 expect(FALLBACK_PLANS.free.priceCents).toBe(0);
30 expect(FALLBACK_PLANS.free.privateRepos).toBe(false);
31 });
32
33 it("paid plans unlock private repos", () => {
34 expect(FALLBACK_PLANS.pro.privateRepos).toBe(true);
35 expect(FALLBACK_PLANS.team.privateRepos).toBe(true);
36 expect(FALLBACK_PLANS.enterprise.privateRepos).toBe(true);
37 });
38
39 it("limits scale up across tiers", () => {
40 expect(FALLBACK_PLANS.pro.repoLimit).toBeGreaterThan(
41 FALLBACK_PLANS.free.repoLimit
42 );
43 expect(FALLBACK_PLANS.team.repoLimit).toBeGreaterThan(
44 FALLBACK_PLANS.pro.repoLimit
45 );
46 expect(FALLBACK_PLANS.enterprise.repoLimit).toBeGreaterThan(
47 FALLBACK_PLANS.team.repoLimit
48 );
49 });
50
51 it("DEFAULT_PLAN_SLUG is 'free'", () => {
52 expect(DEFAULT_PLAN_SLUG).toBe("free");
53 });
54});
55
56describe("billing — formatPrice", () => {
57 it("returns 'Free' for 0 cents", () => {
58 expect(formatPrice(0)).toBe("Free");
59 });
60
61 it("formats non-zero prices as $N.NN/mo", () => {
62 expect(formatPrice(900)).toBe("$9.00/mo");
63 expect(formatPrice(2900)).toBe("$29.00/mo");
64 expect(formatPrice(150)).toBe("$1.50/mo");
65 });
66});
67
68describe("billing — listPlans / getPlan", () => {
69 it("listPlans returns at least the 4 fallback plans", async () => {
70 const plans = await listPlans();
71 expect(plans.length).toBeGreaterThanOrEqual(4);
72 });
73
74 it("getPlan('free') returns a plan object", async () => {
75 const plan = await getPlan("free");
76 expect(plan.slug).toBe("free");
77 expect(plan.priceCents).toBe(0);
78 });
79
80 it("getPlan for unknown slug falls back to free", async () => {
81 const plan = await getPlan("no-such-plan");
82 expect(plan.slug).toBe("free");
83 });
84});
85
86describe("billing — checkQuota", () => {
87 it("fails-open on unknown user id", async () => {
88 const ok = await checkQuota(
89 "00000000-0000-0000-0000-000000000000",
90 "aiTokensUsedThisMonth",
91 100
92 );
93 expect(typeof ok).toBe("boolean");
94 });
95});
96
97describe("billing — route smoke", () => {
98 it("GET /settings/billing without auth → 302 /login", async () => {
99 const res = await app.request("/settings/billing");
100 expect(res.status).toBe(302);
101 expect(res.headers.get("location") || "").toContain("/login");
102 });
103
104 it("GET /admin/billing without auth → 302 /login", async () => {
105 const res = await app.request("/admin/billing");
106 expect(res.status).toBe(302);
107 expect(res.headers.get("location") || "").toContain("/login");
108 });
109
110 it("POST /admin/billing/:id/plan without auth → 302 /login", async () => {
111 const res = await app.request(
112 "/admin/billing/00000000-0000-0000-0000-000000000000/plan",
113 {
114 method: "POST",
115 body: new URLSearchParams({ slug: "pro" }),
116 headers: { "content-type": "application/x-www-form-urlencoded" },
117 }
118 );
119 expect(res.status).toBe(302);
120 expect(res.headers.get("location") || "").toContain("/login");
121 });
122});
123
124describe("billing — lib exports", () => {
125 it("exports the full surface", async () => {
126 const mod = await import("../lib/billing");
127 expect(typeof mod.listPlans).toBe("function");
128 expect(typeof mod.getPlan).toBe("function");
129 expect(typeof mod.getUserQuota).toBe("function");
130 expect(typeof mod.setUserPlan).toBe("function");
131 expect(typeof mod.bumpUsage).toBe("function");
132 expect(typeof mod.checkQuota).toBe("function");
133 expect(typeof mod.repoCountForUser).toBe("function");
134 expect(typeof mod.wouldExceedRepoLimit).toBe("function");
135 expect(typeof mod.resetIfCycleExpired).toBe("function");
136 expect(typeof mod.formatPrice).toBe("function");
137 expect(mod.FALLBACK_PLANS).toBeDefined();
138 expect(mod.DEFAULT_PLAN_SLUG).toBe("free");
139 });
140});
Addedsrc/__tests__/org-insights.test.ts+28−0View fileUnifiedSplit
@@ -0,0 +1,28 @@
1/**
2 * Block F2 — Org insights smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import { computeOrgInsights } from "../routes/org-insights";
8
9describe("org-insights — route smoke", () => {
10 it("GET /orgs/:slug/insights without auth → 302 /login", async () => {
11 const res = await app.request("/orgs/nobody/insights");
12 expect(res.status).toBe(302);
13 const loc = res.headers.get("location") || "";
14 expect(loc.startsWith("/login")).toBe(true);
15 });
16});
17
18describe("org-insights — computeOrgInsights", () => {
19 it("returns empty summary for unknown org id", async () => {
20 const s = await computeOrgInsights(
21 "00000000-0000-0000-0000-000000000000"
22 );
23 expect(s.repoCount).toBe(0);
24 expect(s.gateRunsTotal).toBe(0);
25 expect(s.greenRate).toBe(0);
26 expect(s.perRepo).toEqual([]);
27 });
28});
Addedsrc/__tests__/traffic.test.ts+76−0View fileUnifiedSplit
@@ -0,0 +1,76 @@
1/**
2 * Block F1 — Traffic analytics tests.
3 *
4 * Pure bucketDaily tests + route auth smoke.
5 */
6
7import { describe, it, expect } from "bun:test";
8import app from "../app";
9import { bucketDaily } from "../lib/traffic";
10
11describe("traffic — bucketDaily", () => {
12 it("returns empty array for no events", () => {
13 expect(bucketDaily([])).toEqual([]);
14 });
15
16 it("buckets views and clones separately", () => {
17 const buckets = bucketDaily([
18 { createdAt: "2026-04-14T10:00:00Z", kind: "view" },
19 { createdAt: "2026-04-14T12:00:00Z", kind: "view" },
20 { createdAt: "2026-04-14T15:00:00Z", kind: "clone" },
21 { createdAt: "2026-04-15T09:00:00Z", kind: "view" },
22 ]);
23 expect(buckets).toEqual([
24 { day: "2026-04-14", views: 2, clones: 1 },
25 { day: "2026-04-15", views: 1, clones: 0 },
26 ]);
27 });
28
29 it("counts UI kind as views", () => {
30 const buckets = bucketDaily([
31 { createdAt: "2026-04-14T00:00:00Z", kind: "ui" },
32 ]);
33 expect(buckets).toEqual([{ day: "2026-04-14", views: 1, clones: 0 }]);
34 });
35
36 it("ignores api kind in view/clone buckets", () => {
37 const buckets = bucketDaily([
38 { createdAt: "2026-04-14T00:00:00Z", kind: "api" },
39 ]);
40 expect(buckets).toEqual([{ day: "2026-04-14", views: 0, clones: 0 }]);
41 });
42
43 it("sorts buckets by day ascending", () => {
44 const buckets = bucketDaily([
45 { createdAt: "2026-04-15T00:00:00Z", kind: "view" },
46 { createdAt: "2026-04-14T00:00:00Z", kind: "view" },
47 { createdAt: "2026-04-16T00:00:00Z", kind: "view" },
48 ]);
49 expect(buckets.map((b) => b.day)).toEqual([
50 "2026-04-14",
51 "2026-04-15",
52 "2026-04-16",
53 ]);
54 });
55});
56
57describe("traffic — route smoke", () => {
58 it("GET /:owner/:repo/traffic without auth → 302 /login", async () => {
59 const res = await app.request("/any/repo/traffic");
60 expect(res.status).toBe(302);
61 const loc = res.headers.get("location") || "";
62 expect(loc.startsWith("/login")).toBe(true);
63 });
64});
65
66describe("traffic — lib exports", () => {
67 it("exports track + helpers", async () => {
68 const mod = await import("../lib/traffic");
69 expect(typeof mod.track).toBe("function");
70 expect(typeof mod.trackView).toBe("function");
71 expect(typeof mod.trackClone).toBe("function");
72 expect(typeof mod.trackByName).toBe("function");
73 expect(typeof mod.summarise).toBe("function");
74 expect(typeof mod.bucketDaily).toBe("function");
75 });
76});
Modifiedsrc/app.tsx+8−0View fileUnifiedSplit
@@ -56,6 +56,10 @@ import wikiRoutes from "./routes/wikis";
5656import mergeQueueRoutes from "./routes/merge-queue";
5757import requiredChecksRoutes from "./routes/required-checks";
5858import protectedTagsRoutes from "./routes/protected-tags";
59import trafficRoutes from "./routes/traffic";
60import orgInsightsRoutes from "./routes/org-insights";
61import adminRoutes from "./routes/admin";
62import billingRoutes from "./routes/billing";
5963import webRoutes from "./routes/web";
6064
6165const app = new Hono();
@@ -193,6 +197,10 @@ app.route("/", wikiRoutes); // E3 — /:owner/:repo/wiki
193197app.route("/", mergeQueueRoutes); // E5 — /:owner/:repo/queue
194198app.route("/", requiredChecksRoutes); // E6 — /:owner/:repo/gates/protection/:id/checks
195199app.route("/", protectedTagsRoutes); // E7 — /:owner/:repo/settings/protected-tags
200app.route("/", trafficRoutes); // F1 — /:owner/:repo/traffic
201app.route("/", orgInsightsRoutes); // F2 — /orgs/:slug/insights
202app.route("/", adminRoutes); // F3 — /admin
203app.route("/", billingRoutes); // F4 — /settings/billing + /admin/billing
196204
197205// Insights + milestones
198206app.route("/", insightsRoutes);
Modifiedsrc/db/schema.ts+89−0View fileUnifiedSplit
@@ -1698,3 +1698,92 @@ export const protectedTags = pgTable(
16981698);
16991699
17001700export type ProtectedTag = typeof protectedTags.$inferSelect;
1701
1702// ---------------------------------------------------------------------------
1703// Block F — Observability + admin (migration 0020)
1704// ---------------------------------------------------------------------------
1705
1706// F1 — Traffic analytics per repo
1707export const repoTrafficEvents = pgTable(
1708 "repo_traffic_events",
1709 {
1710 id: uuid("id").primaryKey().defaultRandom(),
1711 repositoryId: uuid("repository_id")
1712 .notNull()
1713 .references(() => repositories.id, { onDelete: "cascade" }),
1714 kind: text("kind").notNull(), // view | clone | api | ui
1715 path: text("path"),
1716 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
1717 ipHash: text("ip_hash"),
1718 userAgent: text("user_agent"),
1719 referer: text("referer"),
1720 createdAt: timestamp("created_at").defaultNow().notNull(),
1721 },
1722 (table) => [
1723 index("repo_traffic_events_repo_time").on(
1724 table.repositoryId,
1725 table.createdAt
1726 ),
1727 index("repo_traffic_events_kind").on(
1728 table.repositoryId,
1729 table.kind,
1730 table.createdAt
1731 ),
1732 ]
1733);
1734
1735export type RepoTrafficEvent = typeof repoTrafficEvents.$inferSelect;
1736
1737// F3 — Admin panel (site admins + toggleable flags)
1738export const systemFlags = pgTable("system_flags", {
1739 key: text("key").primaryKey(),
1740 value: text("value").notNull().default(""),
1741 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1742 updatedBy: uuid("updated_by").references(() => users.id),
1743});
1744
1745export type SystemFlag = typeof systemFlags.$inferSelect;
1746
1747export const siteAdmins = pgTable("site_admins", {
1748 userId: uuid("user_id")
1749 .primaryKey()
1750 .references(() => users.id, { onDelete: "cascade" }),
1751 grantedAt: timestamp("granted_at").defaultNow().notNull(),
1752 grantedBy: uuid("granted_by").references(() => users.id),
1753});
1754
1755export type SiteAdmin = typeof siteAdmins.$inferSelect;
1756
1757// F4 — Billing + quotas
1758export const billingPlans = pgTable("billing_plans", {
1759 id: uuid("id").primaryKey().defaultRandom(),
1760 slug: text("slug").notNull().unique(),
1761 name: text("name").notNull(),
1762 priceCents: integer("price_cents").notNull().default(0),
1763 repoLimit: integer("repo_limit").notNull().default(10),
1764 storageMbLimit: integer("storage_mb_limit").notNull().default(1024),
1765 aiTokensMonthly: integer("ai_tokens_monthly").notNull().default(100000),
1766 bandwidthGbMonthly: integer("bandwidth_gb_monthly").notNull().default(10),
1767 privateRepos: boolean("private_repos").notNull().default(false),
1768 createdAt: timestamp("created_at").defaultNow().notNull(),
1769});
1770
1771export type BillingPlan = typeof billingPlans.$inferSelect;
1772
1773export const userQuotas = pgTable("user_quotas", {
1774 userId: uuid("user_id")
1775 .primaryKey()
1776 .references(() => users.id, { onDelete: "cascade" }),
1777 planSlug: text("plan_slug").notNull().default("free"),
1778 storageMbUsed: integer("storage_mb_used").notNull().default(0),
1779 aiTokensUsedThisMonth: integer("ai_tokens_used_this_month")
1780 .notNull()
1781 .default(0),
1782 bandwidthGbUsedThisMonth: integer("bandwidth_gb_used_this_month")
1783 .notNull()
1784 .default(0),
1785 cycleStart: timestamp("cycle_start").defaultNow().notNull(),
1786 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1787});
1788
1789export type UserQuota = typeof userQuotas.$inferSelect;
Addedsrc/lib/admin.ts+139−0View fileUnifiedSplit
@@ -0,0 +1,139 @@
1/**
2 * Block F3 — Site admin helpers.
3 *
4 * A site admin is a user with a row in `site_admins`. If the table is empty,
5 * the very first registered user is the bootstrap admin. This mirrors how
6 * many self-hosted apps handle the first-install case without requiring
7 * `env`-based provisioning.
8 *
9 * Writes to system flags go through `setFlag` which records the writer.
10 */
11
12import { asc, eq } from "drizzle-orm";
13import { db } from "../db";
14import { siteAdmins, systemFlags, users } from "../db/schema";
15
16/**
17 * Is this user a site admin? Returns true if any of:
18 * - they have a row in `site_admins`, OR
19 * - no rows exist in `site_admins` and they are the oldest-created user
20 * (bootstrap rule).
21 */
22export async function isSiteAdmin(
23 userId: string | null | undefined
24): Promise<boolean> {
25 if (!userId) return false;
26 try {
27 const [row] = await db
28 .select({ userId: siteAdmins.userId })
29 .from(siteAdmins)
30 .where(eq(siteAdmins.userId, userId))
31 .limit(1);
32 if (row) return true;
33 // Bootstrap: empty site_admins → oldest user is admin.
34 const [anyAdmin] = await db.select().from(siteAdmins).limit(1);
35 if (anyAdmin) return false;
36 const [first] = await db
37 .select({ id: users.id })
38 .from(users)
39 .orderBy(asc(users.createdAt))
40 .limit(1);
41 return !!first && first.id === userId;
42 } catch {
43 return false;
44 }
45}
46
47export async function listSiteAdmins() {
48 try {
49 return await db
50 .select({
51 userId: siteAdmins.userId,
52 username: users.username,
53 grantedAt: siteAdmins.grantedAt,
54 grantedBy: siteAdmins.grantedBy,
55 })
56 .from(siteAdmins)
57 .innerJoin(users, eq(siteAdmins.userId, users.id));
58 } catch {
59 return [];
60 }
61}
62
63export async function grantSiteAdmin(
64 userId: string,
65 grantedBy: string | null
66): Promise<boolean> {
67 try {
68 await db
69 .insert(siteAdmins)
70 .values({ userId, grantedBy: grantedBy || null })
71 .onConflictDoNothing();
72 return true;
73 } catch {
74 return false;
75 }
76}
77
78export async function revokeSiteAdmin(userId: string): Promise<boolean> {
79 try {
80 const res = await db
81 .delete(siteAdmins)
82 .where(eq(siteAdmins.userId, userId))
83 .returning({ userId: siteAdmins.userId });
84 return res.length > 0;
85 } catch {
86 return false;
87 }
88}
89
90export async function getFlag(key: string): Promise<string | null> {
91 try {
92 const [row] = await db
93 .select({ value: systemFlags.value })
94 .from(systemFlags)
95 .where(eq(systemFlags.key, key))
96 .limit(1);
97 return row?.value ?? null;
98 } catch {
99 return null;
100 }
101}
102
103export async function setFlag(
104 key: string,
105 value: string,
106 updatedBy: string | null
107): Promise<boolean> {
108 try {
109 await db
110 .insert(systemFlags)
111 .values({ key, value, updatedBy: updatedBy || null })
112 .onConflictDoUpdate({
113 target: systemFlags.key,
114 set: { value, updatedBy: updatedBy || null, updatedAt: new Date() },
115 });
116 return true;
117 } catch (err) {
118 console.error("[admin] setFlag:", err);
119 return false;
120 }
121}
122
123export async function listFlags() {
124 try {
125 return await db.select().from(systemFlags);
126 } catch {
127 return [];
128 }
129}
130
131/** Known flag keys with defaults (used by callers + UI rendering). */
132export const KNOWN_FLAGS = {
133 registration_locked: "0", // "1" to block new sign-ups
134 site_banner_text: "", // non-empty → show a banner at the top
135 site_banner_level: "info", // info | warn | error
136 read_only_mode: "0",
137} as const;
138
139export type FlagKey = keyof typeof KNOWN_FLAGS;
Addedsrc/lib/billing.ts+308−0View fileUnifiedSplit
@@ -0,0 +1,308 @@
1/**
2 * Block F4 — Billing + quotas.
3 *
4 * Plans live in `billing_plans` (seeded with free/pro/team/enterprise by
5 * migration 0020). Each user has a row in `user_quotas` keyed by
6 * `plan_slug` + running usage counters (storage, AI tokens, bandwidth).
7 *
8 * getPlan(slug) — load a plan by slug
9 * getUserQuota(userId) — row + plan join, initialises on first read
10 * listPlans() — admin UI
11 * setUserPlan(userId, slug, byId) — admin override (audit-logged outside)
12 * bumpUsage(userId, field, delta) — fire-and-forget counter increment
13 * checkQuota(userId, field, amount) — boolean "allowed?" for pre-write gating
14 * repoCountForUser(userId) — counts owned repos (enforced at create)
15 * resetIfCycleExpired(userId) — flips cycleStart each month
16 *
17 * All helpers swallow DB errors (plan goes to "free" on failure) so billing is
18 * never a hard dependency for the primary request path.
19 */
20
21import { and, eq, sql } from "drizzle-orm";
22import { db } from "../db";
23import {
24 billingPlans,
25 userQuotas,
26 repositories,
27 type BillingPlan,
28 type UserQuota,
29} from "../db/schema";
30
31export const DEFAULT_PLAN_SLUG = "free";
32
33/** Mirrors the seed rows in migration 0020 so billing works even pre-migration. */
34export const FALLBACK_PLANS: Record<string, Omit<BillingPlan, "id" | "createdAt">> = {
35 free: {
36 slug: "free",
37 name: "Free",
38 priceCents: 0,
39 repoLimit: 5,
40 storageMbLimit: 500,
41 aiTokensMonthly: 50_000,
42 bandwidthGbMonthly: 5,
43 privateRepos: false,
44 },
45 pro: {
46 slug: "pro",
47 name: "Pro",
48 priceCents: 900,
49 repoLimit: 50,
50 storageMbLimit: 5_000,
51 aiTokensMonthly: 500_000,
52 bandwidthGbMonthly: 50,
53 privateRepos: true,
54 },
55 team: {
56 slug: "team",
57 name: "Team",
58 priceCents: 2900,
59 repoLimit: 200,
60 storageMbLimit: 20_000,
61 aiTokensMonthly: 2_000_000,
62 bandwidthGbMonthly: 200,
63 privateRepos: true,
64 },
65 enterprise: {
66 slug: "enterprise",
67 name: "Enterprise",
68 priceCents: 9900,
69 repoLimit: 10_000,
70 storageMbLimit: 500_000,
71 aiTokensMonthly: 50_000_000,
72 bandwidthGbMonthly: 5_000,
73 privateRepos: true,
74 },
75};
76
77export async function listPlans(): Promise<
78 Array<Omit<BillingPlan, "id" | "createdAt">>
79> {
80 try {
81 const rows = await db.select().from(billingPlans).orderBy(billingPlans.priceCents);
82 if (rows.length > 0) return rows;
83 } catch {
84 // fall through
85 }
86 return Object.values(FALLBACK_PLANS);
87}
88
89export async function getPlan(
90 slug: string
91): Promise<Omit<BillingPlan, "id" | "createdAt">> {
92 try {
93 const [row] = await db
94 .select()
95 .from(billingPlans)
96 .where(eq(billingPlans.slug, slug))
97 .limit(1);
98 if (row) return row;
99 } catch {
100 // fall through
101 }
102 return FALLBACK_PLANS[slug] || FALLBACK_PLANS.free;
103}
104
105export type QuotaField =
106 | "storageMbUsed"
107 | "aiTokensUsedThisMonth"
108 | "bandwidthGbUsedThisMonth";
109
110export interface QuotaView {
111 planSlug: string;
112 plan: Omit<BillingPlan, "id" | "createdAt">;
113 usage: {
114 storageMbUsed: number;
115 aiTokensUsedThisMonth: number;
116 bandwidthGbUsedThisMonth: number;
117 };
118 cycleStart: Date | null;
119 percent: {
120 storage: number;
121 aiTokens: number;
122 bandwidth: number;
123 };
124}
125
126/** Loads the quota row, inserting a free-plan row on first read. */
127export async function getUserQuota(userId: string): Promise<QuotaView> {
128 let row: UserQuota | undefined;
129 try {
130 const [r] = await db
131 .select()
132 .from(userQuotas)
133 .where(eq(userQuotas.userId, userId))
134 .limit(1);
135 row = r;
136 if (!row) {
137 await db
138 .insert(userQuotas)
139 .values({ userId, planSlug: DEFAULT_PLAN_SLUG })
140 .onConflictDoNothing();
141 const [r2] = await db
142 .select()
143 .from(userQuotas)
144 .where(eq(userQuotas.userId, userId))
145 .limit(1);
146 row = r2;
147 }
148 } catch {
149 // fall through
150 }
151
152 const planSlug = row?.planSlug || DEFAULT_PLAN_SLUG;
153 const plan = await getPlan(planSlug);
154 const usage = {
155 storageMbUsed: row?.storageMbUsed || 0,
156 aiTokensUsedThisMonth: row?.aiTokensUsedThisMonth || 0,
157 bandwidthGbUsedThisMonth: row?.bandwidthGbUsedThisMonth || 0,
158 };
159 const pct = (used: number, limit: number) =>
160 limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
161
162 return {
163 planSlug,
164 plan,
165 usage,
166 cycleStart: (row?.cycleStart as Date | null) || null,
167 percent: {
168 storage: pct(usage.storageMbUsed, plan.storageMbLimit),
169 aiTokens: pct(usage.aiTokensUsedThisMonth, plan.aiTokensMonthly),
170 bandwidth: pct(usage.bandwidthGbUsedThisMonth, plan.bandwidthGbMonthly),
171 },
172 };
173}
174
175export async function setUserPlan(
176 userId: string,
177 planSlug: string
178): Promise<boolean> {
179 try {
180 await db
181 .insert(userQuotas)
182 .values({ userId, planSlug })
183 .onConflictDoUpdate({
184 target: userQuotas.userId,
185 set: { planSlug, updatedAt: new Date() },
186 });
187 return true;
188 } catch (err) {
189 console.error("[billing] setUserPlan:", err);
190 return false;
191 }
192}
193
194/** Fire-and-forget counter bump. Returns the new value on success. */
195export async function bumpUsage(
196 userId: string,
197 field: QuotaField,
198 delta: number
199): Promise<number | null> {
200 if (!userId || delta === 0) return null;
201 const column =
202 field === "storageMbUsed"
203 ? userQuotas.storageMbUsed
204 : field === "aiTokensUsedThisMonth"
205 ? userQuotas.aiTokensUsedThisMonth
206 : userQuotas.bandwidthGbUsedThisMonth;
207 try {
208 await db
209 .insert(userQuotas)
210 .values({
211 userId,
212 planSlug: DEFAULT_PLAN_SLUG,
213 [field]: delta,
214 } as any)
215 .onConflictDoUpdate({
216 target: userQuotas.userId,
217 set: {
218 [field]: sql`${column} + ${delta}`,
219 updatedAt: new Date(),
220 } as any,
221 });
222 const [r] = await db
223 .select({ n: column })
224 .from(userQuotas)
225 .where(eq(userQuotas.userId, userId))
226 .limit(1);
227 return Number(r?.n || 0);
228 } catch (err) {
229 console.error("[billing] bumpUsage:", err);
230 return null;
231 }
232}
233
234/** True if the user has budget left for an action costing `amount` against `field`. */
235export async function checkQuota(
236 userId: string,
237 field: QuotaField,
238 amount: number = 1
239): Promise<boolean> {
240 try {
241 const { plan, usage } = await getUserQuota(userId);
242 if (field === "storageMbUsed")
243 return usage.storageMbUsed + amount <= plan.storageMbLimit;
244 if (field === "aiTokensUsedThisMonth")
245 return usage.aiTokensUsedThisMonth + amount <= plan.aiTokensMonthly;
246 if (field === "bandwidthGbUsedThisMonth")
247 return usage.bandwidthGbUsedThisMonth + amount <= plan.bandwidthGbMonthly;
248 return true;
249 } catch {
250 return true; // fail-open on billing errors
251 }
252}
253
254export async function repoCountForUser(userId: string): Promise<number> {
255 try {
256 const [r] = await db
257 .select({ n: sql<number>`count(*)::int` })
258 .from(repositories)
259 .where(eq(repositories.ownerId, userId));
260 return Number(r?.n || 0);
261 } catch {
262 return 0;
263 }
264}
265
266/** True if creating another repo would exceed the plan's repoLimit. */
267export async function wouldExceedRepoLimit(userId: string): Promise<boolean> {
268 try {
269 const [quota, count] = await Promise.all([
270 getUserQuota(userId),
271 repoCountForUser(userId),
272 ]);
273 return count >= quota.plan.repoLimit;
274 } catch {
275 return false;
276 }
277}
278
279/** Resets monthly counters if >30 days have passed since cycleStart. */
280export async function resetIfCycleExpired(userId: string): Promise<boolean> {
281 try {
282 const [row] = await db
283 .select({ cycleStart: userQuotas.cycleStart })
284 .from(userQuotas)
285 .where(eq(userQuotas.userId, userId))
286 .limit(1);
287 if (!row?.cycleStart) return false;
288 const age = Date.now() - new Date(row.cycleStart).getTime();
289 if (age < 30 * 24 * 60 * 60 * 1000) return false;
290 await db
291 .update(userQuotas)
292 .set({
293 aiTokensUsedThisMonth: 0,
294 bandwidthGbUsedThisMonth: 0,
295 cycleStart: new Date(),
296 updatedAt: new Date(),
297 })
298 .where(eq(userQuotas.userId, userId));
299 return true;
300 } catch {
301 return false;
302 }
303}
304
305export function formatPrice(cents: number): string {
306 if (cents === 0) return "Free";
307 return `$${(cents / 100).toFixed(2)}/mo`;
308}
Addedsrc/lib/traffic.ts+242−0View fileUnifiedSplit
@@ -0,0 +1,242 @@
1/**
2 * Block F1 — Traffic analytics helpers.
3 *
4 * Records + rolls up per-repo visit / clone / API hits. We record a row per
5 * event (cheap) and aggregate in-memory for the chart. `trackView` and
6 * `trackClone` are fire-and-forget — they never throw and never slow the
7 * user-facing request.
8 */
9
10import { and, eq, gte, sql } from "drizzle-orm";
11import { createHash } from "node:crypto";
12import { db } from "../db";
13import { repoTrafficEvents, repositories, users } from "../db/schema";
14
15export type TrafficKind = "view" | "clone" | "api" | "ui";
16
17export interface TrackArgs {
18 repositoryId: string;
19 kind: TrafficKind;
20 path?: string | null;
21 userId?: string | null;
22 ip?: string | null;
23 userAgent?: string | null;
24 referer?: string | null;
25}
26
27function hashIp(ip: string | null | undefined): string | null {
28 if (!ip) return null;
29 return createHash("sha256")
30 .update(ip)
31 .digest("hex")
32 .slice(0, 16); // 64 bits is plenty for uniqueness within a day
33}
34
35/**
36 * Record a traffic event. Never throws. Returns quickly — callers don't need
37 * to await, but may if they want backpressure.
38 */
39export async function track(args: TrackArgs): Promise<void> {
40 try {
41 await db.insert(repoTrafficEvents).values({
42 repositoryId: args.repositoryId,
43 kind: args.kind,
44 path: args.path ? args.path.slice(0, 256) : null,
45 userId: args.userId || null,
46 ipHash: hashIp(args.ip),
47 userAgent: args.userAgent ? args.userAgent.slice(0, 128) : null,
48 referer: args.referer ? args.referer.slice(0, 256) : null,
49 });
50 } catch {
51 // swallow
52 }
53}
54
55/**
56 * Convenience wrappers. Kept separate so call sites read semantically.
57 */
58export async function trackView(
59 args: Omit<TrackArgs, "kind">
60): Promise<void> {
61 return track({ ...args, kind: "view" });
62}
63
64export async function trackClone(
65 args: Omit<TrackArgs, "kind">
66): Promise<void> {
67 return track({ ...args, kind: "clone" });
68}
69
70/**
71 * Look up `(owner, repo)` and record a traffic event. Safe to fire-and-forget
72 * from request handlers; never throws. Returns void.
73 */
74export async function trackByName(
75 owner: string,
76 repo: string,
77 kind: TrafficKind,
78 meta: Omit<TrackArgs, "kind" | "repositoryId"> = {}
79): Promise<void> {
80 try {
81 const [row] = await db
82 .select({ id: repositories.id })
83 .from(repositories)
84 .innerJoin(users, eq(repositories.ownerId, users.id))
85 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
86 .limit(1);
87 if (!row) return;
88 await track({ ...meta, kind, repositoryId: row.id });
89 } catch {
90 // swallow
91 }
92}
93
94export interface TrafficSummary {
95 totalViews: number;
96 totalClones: number;
97 uniqueVisitorsApprox: number;
98 daily: Array<{ day: string; views: number; clones: number }>;
99 topReferers: Array<{ referer: string; n: number }>;
100 topPaths: Array<{ path: string; n: number }>;
101}
102
103/**
104 * Build a 14-day traffic summary for a repo. Approximation for
105 * uniqueVisitorsApprox: distinct `ip_hash` over the window.
106 */
107export async function summarise(
108 repositoryId: string,
109 windowDays = 14
110): Promise<TrafficSummary> {
111 const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
112 const empty: TrafficSummary = {
113 totalViews: 0,
114 totalClones: 0,
115 uniqueVisitorsApprox: 0,
116 daily: [],
117 topReferers: [],
118 topPaths: [],
119 };
120
121 try {
122 const rows = await db
123 .select({
124 day: sql<string>`to_char(date_trunc('day', ${repoTrafficEvents.createdAt}), 'YYYY-MM-DD')`,
125 kind: repoTrafficEvents.kind,
126 n: sql<number>`count(*)::int`,
127 })
128 .from(repoTrafficEvents)
129 .where(
130 and(
131 eq(repoTrafficEvents.repositoryId, repositoryId),
132 gte(repoTrafficEvents.createdAt, since)
133 )
134 )
135 .groupBy(
136 sql`date_trunc('day', ${repoTrafficEvents.createdAt})`,
137 repoTrafficEvents.kind
138 );
139
140 const dayMap = new Map<string, { views: number; clones: number }>();
141 let totalViews = 0;
142 let totalClones = 0;
143 for (const r of rows) {
144 const day = r.day;
145 const bucket = dayMap.get(day) || { views: 0, clones: 0 };
146 if (r.kind === "view" || r.kind === "ui") {
147 bucket.views += Number(r.n);
148 totalViews += Number(r.n);
149 } else if (r.kind === "clone") {
150 bucket.clones += Number(r.n);
151 totalClones += Number(r.n);
152 }
153 dayMap.set(day, bucket);
154 }
155
156 const daily = Array.from(dayMap.entries())
157 .map(([day, v]) => ({ day, views: v.views, clones: v.clones }))
158 .sort((a, b) => a.day.localeCompare(b.day));
159
160 const [uv] = await db
161 .select({
162 n: sql<number>`count(distinct ${repoTrafficEvents.ipHash})::int`,
163 })
164 .from(repoTrafficEvents)
165 .where(
166 and(
167 eq(repoTrafficEvents.repositoryId, repositoryId),
168 gte(repoTrafficEvents.createdAt, since)
169 )
170 );
171
172 const refRows = await db
173 .select({
174 referer: repoTrafficEvents.referer,
175 n: sql<number>`count(*)::int`,
176 })
177 .from(repoTrafficEvents)
178 .where(
179 and(
180 eq(repoTrafficEvents.repositoryId, repositoryId),
181 gte(repoTrafficEvents.createdAt, since),
182 sql`${repoTrafficEvents.referer} IS NOT NULL AND ${repoTrafficEvents.referer} <> ''`
183 )
184 )
185 .groupBy(repoTrafficEvents.referer)
186 .orderBy(sql`count(*) desc`)
187 .limit(8);
188
189 const pathRows = await db
190 .select({
191 path: repoTrafficEvents.path,
192 n: sql<number>`count(*)::int`,
193 })
194 .from(repoTrafficEvents)
195 .where(
196 and(
197 eq(repoTrafficEvents.repositoryId, repositoryId),
198 gte(repoTrafficEvents.createdAt, since),
199 sql`${repoTrafficEvents.path} IS NOT NULL`
200 )
201 )
202 .groupBy(repoTrafficEvents.path)
203 .orderBy(sql`count(*) desc`)
204 .limit(8);
205
206 return {
207 totalViews,
208 totalClones,
209 uniqueVisitorsApprox: Number(uv?.n || 0),
210 daily,
211 topReferers: refRows
212 .filter((r) => !!r.referer)
213 .map((r) => ({ referer: r.referer as string, n: Number(r.n) })),
214 topPaths: pathRows
215 .filter((r) => !!r.path)
216 .map((r) => ({ path: r.path as string, n: Number(r.n) })),
217 };
218 } catch {
219 return empty;
220 }
221}
222
223/**
224 * Pure helper for unit tests — turn a list of events into the same daily
225 * bucket structure as `summarise` without needing a DB.
226 */
227export function bucketDaily(
228 events: Array<{ createdAt: Date | string; kind: string }>
229): Array<{ day: string; views: number; clones: number }> {
230 const dayMap = new Map<string, { views: number; clones: number }>();
231 for (const e of events) {
232 const t = typeof e.createdAt === "string" ? new Date(e.createdAt) : e.createdAt;
233 const day = t.toISOString().slice(0, 10);
234 const bucket = dayMap.get(day) || { views: 0, clones: 0 };
235 if (e.kind === "view" || e.kind === "ui") bucket.views++;
236 else if (e.kind === "clone") bucket.clones++;
237 dayMap.set(day, bucket);
238 }
239 return Array.from(dayMap.entries())
240 .map(([day, v]) => ({ day, ...v }))
241 .sort((a, b) => a.day.localeCompare(b.day));
242}
Addedsrc/routes/admin.tsx+420−0View fileUnifiedSplit
@@ -0,0 +1,420 @@
1/**
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";
22import { softAuth, requireAuth } from "../middleware/auth";
23import 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";
34
35const admin = new Hono<AuthEnv>();
36admin.use("*", softAuth);
37
38async function gate(c: any): Promise<{ user: any } | Response> {
39 const user = c.get("user");
40 if (!user) return c.redirect("/login?next=/admin");
41 if (!(await isSiteAdmin(user.id))) {
42 return c.html(
43 <Layout title="Forbidden" user={user}>
44 <div class="empty-state">
45 <h2>403 — Not a site admin</h2>
46 <p>You don't have permission to view this page.</p>
47 </div>
48 </Layout>,
49 403
50 );
51 }
52 return { user };
53}
54
55admin.get("/admin", async (c) => {
56 const g = await gate(c);
57 if (g instanceof Response) return g;
58 const { user } = g;
59
60 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
61 const [rc] = await db
62 .select({ n: sql<number>`count(*)::int` })
63 .from(repositories);
64
65 const recent = await db
66 .select({
67 id: users.id,
68 username: users.username,
69 createdAt: users.createdAt,
70 })
71 .from(users)
72 .orderBy(desc(users.createdAt))
73 .limit(10);
74
75 const admins = await listSiteAdmins();
76
77 return c.html(
78 <Layout title="Admin — Gluecron" user={user}>
79 <h2>Site admin</h2>
80
81 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
82 <div class="panel" style="padding:12px;text-align:center">
83 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
84 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
85 Users
86 </div>
87 </div>
88 <div class="panel" style="padding:12px;text-align:center">
89 <div style="font-size:22px;font-weight:700">{Number(rc?.n || 0)}</div>
90 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
91 Repos
92 </div>
93 </div>
94 <div class="panel" style="padding:12px;text-align:center">
95 <div style="font-size:22px;font-weight:700">{admins.length}</div>
96 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
97 Site admins
98 </div>
99 </div>
100 </div>
101
102 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:20px">
103 <a href="/admin/users" class="btn">
104 Manage users
105 </a>
106 <a href="/admin/repos" class="btn">
107 Manage repos
108 </a>
109 <a href="/admin/flags" class="btn">
110 Site flags
111 </a>
112 </div>
113
114 <h3>Recent signups</h3>
115 <div class="panel" style="margin-bottom:20px">
116 {recent.map((u) => (
117 <div class="panel-item" style="justify-content:space-between">
118 <a href={`/${u.username}`}>{u.username}</a>
119 <span style="font-size:12px;color:var(--text-muted)">
120 {u.createdAt
121 ? new Date(u.createdAt as unknown as string).toLocaleString()
122 : ""}
123 </span>
124 </div>
125 ))}
126 </div>
127
128 <h3>Site admins</h3>
129 <div class="panel">
130 {admins.length === 0 ? (
131 <div class="panel-empty">
132 No admins (bootstrap mode — oldest user is admin).
133 </div>
134 ) : (
135 admins.map((a) => (
136 <div class="panel-item" style="justify-content:space-between">
137 <a href={`/${a.username}`}>{a.username}</a>
138 <span style="font-size:12px;color:var(--text-muted)">
139 Granted{" "}
140 {a.grantedAt
141 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
142 : ""}
143 </span>
144 </div>
145 ))
146 )}
147 </div>
148 </Layout>
149 );
150});
151
152// ----- Users -----
153
154admin.get("/admin/users", async (c) => {
155 const g = await gate(c);
156 if (g instanceof Response) return g;
157 const { user } = g;
158 const q = c.req.query("q") || "";
159 const rows = await db
160 .select({
161 id: users.id,
162 username: users.username,
163 email: users.email,
164 createdAt: users.createdAt,
165 })
166 .from(users)
167 .where(
168 q
169 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
170 : sql`1=1`
171 )
172 .orderBy(desc(users.createdAt))
173 .limit(200);
174
175 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
176
177 return c.html(
178 <Layout title="Admin — Users" user={user}>
179 <h2>Users</h2>
180 <form method="GET" action="/admin/users" style="margin-bottom:16px">
181 <input
182 type="text"
183 name="q"
184 value={q}
185 placeholder="Search username or email"
186 style="width:320px"
187 />{" "}
188 <button type="submit" class="btn">
189 Search
190 </button>
191 <a href="/admin" class="btn" style="margin-left:6px">
192 Back
193 </a>
194 </form>
195 <div class="panel">
196 {rows.length === 0 ? (
197 <div class="panel-empty">No users found.</div>
198 ) : (
199 rows.map((u) => {
200 const isAdmin = adminIds.has(u.id);
201 return (
202 <div class="panel-item" style="justify-content:space-between">
203 <div>
204 <a href={`/${u.username}`} style="font-weight:600">
205 {u.username}
206 </a>{" "}
207 <span style="color:var(--text-muted)">{u.email}</span>
208 {isAdmin && (
209 <span
210 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
211 >
212 ADMIN
213 </span>
214 )}
215 </div>
216 <form
217 method="POST"
218 action={`/admin/users/${u.id}/admin`}
219 onsubmit={
220 isAdmin
221 ? "return confirm('Revoke site admin?')"
222 : "return confirm('Grant site admin?')"
223 }
224 >
225 <button type="submit" class="btn btn-sm">
226 {isAdmin ? "Revoke admin" : "Grant admin"}
227 </button>
228 </form>
229 </div>
230 );
231 })
232 )}
233 </div>
234 </Layout>
235 );
236});
237
238admin.post("/admin/users/:id/admin", async (c) => {
239 const g = await gate(c);
240 if (g instanceof Response) return g;
241 const { user } = g;
242 const id = c.req.param("id");
243 const admins = await listSiteAdmins();
244 const isAlready = admins.some((a) => a.userId === id);
245 if (isAlready) {
246 await revokeSiteAdmin(id);
247 await audit({
248 userId: user.id,
249 action: "site_admin.revoke",
250 targetType: "user",
251 targetId: id,
252 });
253 } else {
254 await grantSiteAdmin(id, user.id);
255 await audit({
256 userId: user.id,
257 action: "site_admin.grant",
258 targetType: "user",
259 targetId: id,
260 });
261 }
262 return c.redirect("/admin/users");
263});
264
265// ----- Repos -----
266
267admin.get("/admin/repos", async (c) => {
268 const g = await gate(c);
269 if (g instanceof Response) return g;
270 const { user } = g;
271 const rows = await db
272 .select({
273 id: repositories.id,
274 name: repositories.name,
275 ownerUsername: users.username,
276 visibility: repositories.visibility,
277 createdAt: repositories.createdAt,
278 starCount: repositories.starCount,
279 })
280 .from(repositories)
281 .innerJoin(users, eq(repositories.ownerId, users.id))
282 .orderBy(desc(repositories.createdAt))
283 .limit(200);
284
285 return c.html(
286 <Layout title="Admin — Repos" user={user}>
287 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
288 <h2>Repositories</h2>
289 <a href="/admin" class="btn btn-sm">
290 Back
291 </a>
292 </div>
293 <div class="panel">
294 {rows.length === 0 ? (
295 <div class="panel-empty">No repositories.</div>
296 ) : (
297 rows.map((r) => (
298 <div class="panel-item" style="justify-content:space-between">
299 <div>
300 <a
301 href={`/${r.ownerUsername}/${r.name}`}
302 style="font-weight:600"
303 >
304 {r.ownerUsername}/{r.name}
305 </a>
306 <span
307 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
308 >
309 {r.visibility}
310 </span>
311 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
312 {r.starCount} stars ·{" "}
313 {r.createdAt
314 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
315 : ""}
316 </div>
317 </div>
318 <form
319 method="POST"
320 action={`/admin/repos/${r.id}/delete`}
321 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
322 >
323 <button type="submit" class="btn btn-sm btn-danger">
324 Delete
325 </button>
326 </form>
327 </div>
328 ))
329 )}
330 </div>
331 </Layout>
332 );
333});
334
335admin.post("/admin/repos/:id/delete", async (c) => {
336 const g = await gate(c);
337 if (g instanceof Response) return g;
338 const { user } = g;
339 const id = c.req.param("id");
340 try {
341 await db.delete(repositories).where(eq(repositories.id, id));
342 } catch (err) {
343 console.error("[admin] repo delete:", err);
344 }
345 await audit({
346 userId: user.id,
347 action: "admin.repo.delete",
348 targetType: "repository",
349 targetId: id,
350 });
351 return c.redirect("/admin/repos");
352});
353
354// ----- Flags -----
355
356admin.get("/admin/flags", async (c) => {
357 const g = await gate(c);
358 if (g instanceof Response) return g;
359 const { user } = g;
360
361 const existing = await listFlags();
362 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
363 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
364
365 return c.html(
366 <Layout title="Admin — Flags" user={user}>
367 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
368 <h2>Site flags</h2>
369 <a href="/admin" class="btn btn-sm">
370 Back
371 </a>
372 </div>
373 <form
374 method="POST"
375 action="/admin/flags"
376 class="panel"
377 style="padding:16px"
378 >
379 {keys.map((k) => {
380 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
381 return (
382 <div class="form-group">
383 <label>{k}</label>
384 <input
385 type="text"
386 name={k}
387 value={current}
388 style="font-family:var(--font-mono)"
389 />
390 <div
391 style="font-size:11px;color:var(--text-muted);margin-top:2px"
392 >
393 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
394 </div>
395 </div>
396 );
397 })}
398 <button type="submit" class="btn btn-primary">
399 Save
400 </button>
401 </form>
402 </Layout>
403 );
404});
405
406admin.post("/admin/flags", async (c) => {
407 const g = await gate(c);
408 if (g instanceof Response) return g;
409 const { user } = g;
410 const body = await c.req.parseBody();
411 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
412 for (const k of keys) {
413 const v = String(body[k] ?? "");
414 await setFlag(k, v, user.id);
415 }
416 await audit({ userId: user.id, action: "admin.flags.save" });
417 return c.redirect("/admin/flags");
418});
419
420export default admin;
Addedsrc/routes/billing.tsx+259−0View fileUnifiedSplit
@@ -0,0 +1,259 @@
1/**
2 * Block F4 — Billing + quota UI.
3 *
4 * GET /settings/billing — personal quota view + plan table
5 * GET /admin/billing — site admin: user list + overrides
6 * POST /admin/billing/:userId/plan — set user's plan (audit-logged)
7 *
8 * All read operations degrade gracefully if the billing tables are empty
9 * (FALLBACK_PLANS in lib/billing.ts mirror the seed rows). Plan assignment
10 * is site-admin only; there is no self-service purchase flow here — that's
11 * Stripe's job, and deliberately out-of-scope for the v1 panel.
12 */
13
14import { Hono } from "hono";
15import { desc, eq } from "drizzle-orm";
16import { db } from "../db";
17import { users, userQuotas } from "../db/schema";
18import { Layout } from "../views/layout";
19import { softAuth, requireAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import { isSiteAdmin } from "../lib/admin";
22import { audit } from "../lib/notify";
23import {
24 formatPrice,
25 getUserQuota,
26 listPlans,
27 setUserPlan,
28} from "../lib/billing";
29
30const billing = new Hono<AuthEnv>();
31billing.use("*", softAuth);
32
33// ----- Personal billing page -----
34
35billing.get("/settings/billing", requireAuth, async (c) => {
36 const user = c.get("user")!;
37 const [quota, plans] = await Promise.all([
38 getUserQuota(user.id),
39 listPlans(),
40 ]);
41
42 const bar = (pct: number) => {
43 const color = pct >= 90 ? "var(--red)" : pct >= 70 ? "#f0b72f" : "var(--green)";
44 return (
45 <div
46 style="background:var(--bg-secondary);height:8px;border-radius:4px;overflow:hidden"
47 >
48 <div
49 style={`width:${pct}%;height:100%;background:${color};transition:width .2s`}
50 />
51 </div>
52 );
53 };
54
55 return c.html(
56 <Layout title="Billing — Gluecron" user={user}>
57 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
58 <h2>Billing & usage</h2>
59 <a href="/settings" class="btn btn-sm">
60 Back to settings
61 </a>
62 </div>
63
64 <div class="panel" style="padding:16px;margin-bottom:20px">
65 <div style="display:flex;justify-content:space-between;align-items:center">
66 <div>
67 <div style="font-size:12px;color:var(--text-muted);text-transform:uppercase">
68 Current plan
69 </div>
70 <div style="font-size:22px;font-weight:700">{quota.plan.name}</div>
71 <div style="font-size:13px;color:var(--text-muted)">
72 {formatPrice(quota.plan.priceCents)}
73 </div>
74 </div>
75 <div style="text-align:right;font-size:12px;color:var(--text-muted)">
76 {quota.cycleStart
77 ? `Cycle started ${new Date(quota.cycleStart).toLocaleDateString()}`
78 : "No cycle recorded"}
79 </div>
80 </div>
81 </div>
82
83 <h3>Usage this cycle</h3>
84 <div class="panel" style="padding:16px;margin-bottom:20px">
85 <div class="form-group">
86 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
87 <span>Storage</span>
88 <span style="color:var(--text-muted);font-family:var(--font-mono)">
89 {quota.usage.storageMbUsed} / {quota.plan.storageMbLimit} MB
90 </span>
91 </div>
92 {bar(quota.percent.storage)}
93 </div>
94 <div class="form-group">
95 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
96 <span>AI tokens (monthly)</span>
97 <span style="color:var(--text-muted);font-family:var(--font-mono)">
98 {quota.usage.aiTokensUsedThisMonth.toLocaleString()} /{" "}
99 {quota.plan.aiTokensMonthly.toLocaleString()}
100 </span>
101 </div>
102 {bar(quota.percent.aiTokens)}
103 </div>
104 <div class="form-group" style="margin-bottom:0">
105 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
106 <span>Bandwidth (monthly)</span>
107 <span style="color:var(--text-muted);font-family:var(--font-mono)">
108 {quota.usage.bandwidthGbUsedThisMonth} /{" "}
109 {quota.plan.bandwidthGbMonthly} GB
110 </span>
111 </div>
112 {bar(quota.percent.bandwidth)}
113 </div>
114 </div>
115
116 <h3>Available plans</h3>
117 <div
118 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px"
119 >
120 {plans.map((p) => {
121 const isCurrent = p.slug === quota.planSlug;
122 return (
123 <div
124 class="panel"
125 style={`padding:16px${isCurrent ? ";border-color:var(--green)" : ""}`}
126 >
127 <div style="font-size:16px;font-weight:700">{p.name}</div>
128 <div style="font-size:18px;margin:6px 0">
129 {formatPrice(p.priceCents)}
130 </div>
131 <div style="font-size:12px;color:var(--text-muted);line-height:1.6">
132 <div>{p.repoLimit.toLocaleString()} repos</div>
133 <div>{p.storageMbLimit.toLocaleString()} MB storage</div>
134 <div>{p.aiTokensMonthly.toLocaleString()} AI tokens/mo</div>
135 <div>{p.bandwidthGbMonthly} GB bandwidth/mo</div>
136 <div>
137 {p.privateRepos ? "Private repos ✓" : "Public repos only"}
138 </div>
139 </div>
140 {isCurrent && (
141 <div
142 style="margin-top:10px;font-size:11px;color:var(--green);font-weight:600"
143 >
144 CURRENT PLAN
145 </div>
146 )}
147 </div>
148 );
149 })}
150 </div>
151 <p style="font-size:12px;color:var(--text-muted);margin-top:12px">
152 To change plans, contact a site administrator.
153 </p>
154 </Layout>
155 );
156});
157
158// ----- Admin billing panel -----
159
160billing.get("/admin/billing", async (c) => {
161 const user = c.get("user");
162 if (!user) return c.redirect("/login?next=/admin/billing");
163 if (!(await isSiteAdmin(user.id))) {
164 return c.html(
165 <Layout title="Forbidden" user={user}>
166 <div class="empty-state">
167 <h2>403 — Not a site admin</h2>
168 </div>
169 </Layout>,
170 403
171 );
172 }
173
174 const plans = await listPlans();
175 const rows = await db
176 .select({
177 id: users.id,
178 username: users.username,
179 planSlug: userQuotas.planSlug,
180 storageMbUsed: userQuotas.storageMbUsed,
181 aiTokensUsedThisMonth: userQuotas.aiTokensUsedThisMonth,
182 })
183 .from(users)
184 .leftJoin(userQuotas, eq(users.id, userQuotas.userId))
185 .orderBy(desc(users.createdAt))
186 .limit(200);
187
188 return c.html(
189 <Layout title="Admin — Billing" user={user}>
190 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
191 <h2>Billing — all users</h2>
192 <a href="/admin" class="btn btn-sm">
193 Back
194 </a>
195 </div>
196 <div class="panel">
197 {rows.length === 0 ? (
198 <div class="panel-empty">No users.</div>
199 ) : (
200 rows.map((r) => (
201 <div class="panel-item" style="justify-content:space-between">
202 <div style="flex:1;min-width:0">
203 <a href={`/${r.username}`} style="font-weight:600">
204 {r.username}
205 </a>
206 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
207 Plan: <strong>{r.planSlug || "free"}</strong> ·{" "}
208 {r.storageMbUsed || 0} MB ·{" "}
209 {(r.aiTokensUsedThisMonth || 0).toLocaleString()} tokens
210 </div>
211 </div>
212 <form
213 method="POST"
214 action={`/admin/billing/${r.id}/plan`}
215 style="display:flex;gap:6px;align-items:center"
216 >
217 <select name="slug" style="font-size:12px">
218 {plans.map((p) => (
219 <option
220 value={p.slug}
221 selected={(r.planSlug || "free") === p.slug}
222 >
223 {p.name}
224 </option>
225 ))}
226 </select>
227 <button type="submit" class="btn btn-sm">
228 Set
229 </button>
230 </form>
231 </div>
232 ))
233 )}
234 </div>
235 </Layout>
236 );
237});
238
239billing.post("/admin/billing/:userId/plan", async (c) => {
240 const user = c.get("user");
241 if (!user) return c.redirect("/login?next=/admin/billing");
242 if (!(await isSiteAdmin(user.id))) {
243 return c.text("Forbidden", 403);
244 }
245 const userId = c.req.param("userId");
246 const body = await c.req.parseBody();
247 const slug = String(body.slug || "free");
248 await setUserPlan(userId, slug);
249 await audit({
250 userId: user.id,
251 action: "admin.billing.set_plan",
252 targetType: "user",
253 targetId: userId,
254 metadata: { plan: slug },
255 });
256 return c.redirect("/admin/billing");
257});
258
259export default billing;
Modifiedsrc/routes/git.ts+6−0View fileUnifiedSplit
@@ -9,6 +9,7 @@ import { getInfoRefs, serviceRpc } from "../git/protocol";
99import { repoExists } from "../git/repository";
1010import { onPostReceive } from "../hooks/post-receive";
1111import { invalidateRepoCache } from "../lib/cache";
12import { trackByName } from "../lib/traffic";
1213
1314const git = new Hono();
1415
@@ -46,6 +47,11 @@ git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
4647 if (!(await repoExists(owner, repo))) {
4748 return c.text("Repository not found", 404);
4849 }
50 // F1 — fire-and-forget clone tracking.
51 trackByName(owner, repo, "clone", {
52 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
53 userAgent: c.req.header("user-agent") || null,
54 }).catch(() => {});
4955 return serviceRpc(owner, repo, "git-upload-pack", c.req.raw.body);
5056});
5157
Addedsrc/routes/org-insights.tsx+346−0View fileUnifiedSplit
@@ -0,0 +1,346 @@
1/**
2 * Block F2 — Org-wide insights.
3 *
4 * GET /orgs/:slug/insights — rollup across every repo owned by the org:
5 * gate green-rate, open/merged PR counts, open
6 * issue count, recent gate activity, per-repo
7 * rows sorted by activity.
8 *
9 * No new tables — computed live from existing `repositories`, `gate_runs`,
10 * `pull_requests`, `issues`.
11 */
12
13import { Hono } from "hono";
14import { and, desc, eq, gte, sql } from "drizzle-orm";
15import { db } from "../db";
16import {
17 gateRuns,
18 issues,
19 organizations,
20 orgMembers,
21 pullRequests,
22 repositories,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27
28const orgInsights = new Hono<AuthEnv>();
29orgInsights.use("*", softAuth);
30
31export interface OrgInsightsSummary {
32 repoCount: number;
33 gateRunsTotal: number;
34 gatePassed: number;
35 gateFailed: number;
36 gateRepaired: number;
37 greenRate: number; // 0..1
38 openIssues: number;
39 openPrs: number;
40 mergedPrs30d: number;
41 perRepo: Array<{
42 id: string;
43 name: string;
44 runs: number;
45 greenRate: number;
46 openPrs: number;
47 openIssues: number;
48 }>;
49}
50
51export async function computeOrgInsights(
52 orgId: string
53): Promise<OrgInsightsSummary> {
54 const empty: OrgInsightsSummary = {
55 repoCount: 0,
56 gateRunsTotal: 0,
57 gatePassed: 0,
58 gateFailed: 0,
59 gateRepaired: 0,
60 greenRate: 0,
61 openIssues: 0,
62 openPrs: 0,
63 mergedPrs30d: 0,
64 perRepo: [],
65 };
66
67 try {
68 const repos = await db
69 .select({ id: repositories.id, name: repositories.name })
70 .from(repositories)
71 .where(eq(repositories.orgId, orgId));
72 if (repos.length === 0) return empty;
73
74 const repoIds = repos.map((r) => r.id);
75 const idList = sql.raw(
76 repoIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(",")
77 );
78
79 // Aggregate gate runs across repos
80 const gateRows = await db
81 .select({
82 repoId: gateRuns.repositoryId,
83 status: gateRuns.status,
84 n: sql<number>`count(*)::int`,
85 })
86 .from(gateRuns)
87 .where(sql`${gateRuns.repositoryId} IN (${idList})`)
88 .groupBy(gateRuns.repositoryId, gateRuns.status);
89
90 const totals = {
91 passed: 0,
92 failed: 0,
93 repaired: 0,
94 skipped: 0,
95 } as Record<string, number>;
96 const byRepo = new Map<
97 string,
98 { runs: number; passed: number; failed: number; repaired: number }
99 >();
100 for (const r of gateRows) {
101 const n = Number(r.n);
102 totals[r.status] = (totals[r.status] || 0) + n;
103 const b = byRepo.get(r.repoId) || {
104 runs: 0,
105 passed: 0,
106 failed: 0,
107 repaired: 0,
108 };
109 b.runs += n;
110 if (r.status === "passed") b.passed += n;
111 else if (r.status === "failed") b.failed += n;
112 else if (r.status === "repaired") b.repaired += n;
113 byRepo.set(r.repoId, b);
114 }
115 const gateRunsTotal = Object.values(totals).reduce((a, b) => a + b, 0);
116 const gatePassed = totals.passed || 0;
117 const gateFailed = totals.failed || 0;
118 const gateRepaired = totals.repaired || 0;
119 const greenRate = gateRunsTotal
120 ? (gatePassed + gateRepaired) / gateRunsTotal
121 : 0;
122
123 // Open issues/PRs across org repos
124 const issueRows = await db
125 .select({
126 repoId: issues.repositoryId,
127 state: issues.state,
128 n: sql<number>`count(*)::int`,
129 })
130 .from(issues)
131 .where(sql`${issues.repositoryId} IN (${idList})`)
132 .groupBy(issues.repositoryId, issues.state);
133
134 const openIssuesByRepo = new Map<string, number>();
135 let openIssues = 0;
136 for (const r of issueRows) {
137 if (r.state === "open") {
138 openIssuesByRepo.set(r.repoId, Number(r.n));
139 openIssues += Number(r.n);
140 }
141 }
142
143 const prRows = await db
144 .select({
145 repoId: pullRequests.repositoryId,
146 state: pullRequests.state,
147 n: sql<number>`count(*)::int`,
148 })
149 .from(pullRequests)
150 .where(sql`${pullRequests.repositoryId} IN (${idList})`)
151 .groupBy(pullRequests.repositoryId, pullRequests.state);
152
153 const openPrsByRepo = new Map<string, number>();
154 let openPrs = 0;
155 for (const r of prRows) {
156 if (r.state === "open") {
157 openPrsByRepo.set(r.repoId, Number(r.n));
158 openPrs += Number(r.n);
159 }
160 }
161
162 // Merged PRs in last 30d
163 const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
164 const [mergedRow] = await db
165 .select({ n: sql<number>`count(*)::int` })
166 .from(pullRequests)
167 .where(
168 and(
169 sql`${pullRequests.repositoryId} IN (${idList})`,
170 eq(pullRequests.state, "merged"),
171 gte(pullRequests.mergedAt, since)
172 )
173 );
174
175 const perRepo = repos.map((r) => {
176 const b = byRepo.get(r.id) || {
177 runs: 0,
178 passed: 0,
179 failed: 0,
180 repaired: 0,
181 };
182 const green = b.runs
183 ? (b.passed + b.repaired) / b.runs
184 : 0;
185 return {
186 id: r.id,
187 name: r.name,
188 runs: b.runs,
189 greenRate: green,
190 openPrs: openPrsByRepo.get(r.id) || 0,
191 openIssues: openIssuesByRepo.get(r.id) || 0,
192 };
193 });
194 perRepo.sort((a, b) => b.runs - a.runs);
195
196 return {
197 repoCount: repos.length,
198 gateRunsTotal,
199 gatePassed,
200 gateFailed,
201 gateRepaired,
202 greenRate,
203 openIssues,
204 openPrs,
205 mergedPrs30d: Number(mergedRow?.n || 0),
206 perRepo,
207 };
208 } catch {
209 return empty;
210 }
211}
212
213async function loadOrg(slug: string) {
214 try {
215 const [o] = await db
216 .select()
217 .from(organizations)
218 .where(eq(organizations.slug, slug))
219 .limit(1);
220 return o || null;
221 } catch {
222 return null;
223 }
224}
225
226async function isOrgMember(orgId: string, userId: string): Promise<boolean> {
227 try {
228 const [row] = await db
229 .select({ id: orgMembers.id })
230 .from(orgMembers)
231 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
232 .limit(1);
233 return !!row;
234 } catch {
235 return false;
236 }
237}
238
239orgInsights.get("/orgs/:slug/insights", requireAuth, async (c) => {
240 const user = c.get("user")!;
241 const slug = c.req.param("slug");
242 const org = await loadOrg(slug);
243 if (!org) return c.notFound();
244 const member = await isOrgMember(org.id, user.id);
245 if (!member) return c.redirect(`/orgs/${slug}`);
246
247 const summary = await computeOrgInsights(org.id);
248 const pct = (n: number) => Math.round(n * 100);
249
250 return c.html(
251 <Layout title={`${org.name} — Insights`} user={user}>
252 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
253 <h2>{org.name} · Insights</h2>
254 <a href={`/orgs/${slug}`} class="btn btn-sm">
255 Back to {slug}
256 </a>
257 </div>
258
259 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
260 <div class="panel" style="padding:12px;text-align:center">
261 <div style="font-size:22px;font-weight:700">{summary.repoCount}</div>
262 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
263 Repos
264 </div>
265 </div>
266 <div class="panel" style="padding:12px;text-align:center">
267 <div style="font-size:22px;font-weight:700;color:var(--green)">
268 {pct(summary.greenRate)}%
269 </div>
270 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
271 Green rate
272 </div>
273 </div>
274 <div class="panel" style="padding:12px;text-align:center">
275 <div style="font-size:22px;font-weight:700;color:#79c0ff">
276 {summary.openPrs}
277 </div>
278 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
279 Open PRs
280 </div>
281 </div>
282 <div class="panel" style="padding:12px;text-align:center">
283 <div style="font-size:22px;font-weight:700;color:#d2a8ff">
284 {summary.mergedPrs30d}
285 </div>
286 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
287 Merged 30d
288 </div>
289 </div>
290 </div>
291
292 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
293 <div class="panel" style="padding:12px;text-align:center">
294 <div style="font-size:18px;font-weight:600">{summary.gateRunsTotal}</div>
295 <div style="font-size:11px;color:var(--text-muted)">Total gate runs</div>
296 </div>
297 <div class="panel" style="padding:12px;text-align:center">
298 <div style="font-size:18px;font-weight:600;color:var(--green)">
299 {summary.gatePassed}
300 </div>
301 <div style="font-size:11px;color:var(--text-muted)">Passed</div>
302 </div>
303 <div class="panel" style="padding:12px;text-align:center">
304 <div style="font-size:18px;font-weight:600;color:#bc8cff">
305 {summary.gateRepaired}
306 </div>
307 <div style="font-size:11px;color:var(--text-muted)">Repaired</div>
308 </div>
309 <div class="panel" style="padding:12px;text-align:center">
310 <div style="font-size:18px;font-weight:600;color:var(--red)">
311 {summary.gateFailed}
312 </div>
313 <div style="font-size:11px;color:var(--text-muted)">Failed</div>
314 </div>
315 </div>
316
317 <h3>Per-repo breakdown</h3>
318 <div class="panel" style="margin-bottom:20px">
319 {summary.perRepo.length === 0 ? (
320 <div class="panel-empty">This org has no repositories yet.</div>
321 ) : (
322 summary.perRepo.map((r) => (
323 <div class="panel-item" style="justify-content:space-between">
324 <div style="flex:1;min-width:0">
325 <a href={`/${slug}/${r.name}`} style="font-weight:600">
326 {slug}/{r.name}
327 </a>
328 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
329 {r.runs} runs · {r.openPrs} open PRs · {r.openIssues} open
330 issues
331 </div>
332 </div>
333 <span
334 style={`font-family:var(--font-mono);color:${r.greenRate >= 0.9 ? "var(--green)" : r.greenRate >= 0.7 ? "#f0b72f" : "var(--red)"}`}
335 >
336 {r.runs > 0 ? `${pct(r.greenRate)}%` : "—"}
337 </span>
338 </div>
339 ))
340 )}
341 </div>
342 </Layout>
343 );
344});
345
346export default orgInsights;
Addedsrc/routes/traffic.tsx+195−0View fileUnifiedSplit
@@ -0,0 +1,195 @@
1/**
2 * Block F1 — Traffic analytics UI.
3 *
4 * GET /:owner/:repo/traffic — owner-only 14-day views/clones chart,
5 * unique visitors, top paths + referers.
6 */
7
8import { Hono } from "hono";
9import { and, eq } from "drizzle-orm";
10import { db } from "../db";
11import { repositories, users } from "../db/schema";
12import { Layout } from "../views/layout";
13import { RepoHeader, RepoNav } from "../views/components";
14import { softAuth, requireAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16import { summarise } from "../lib/traffic";
17
18const traffic = new Hono<AuthEnv>();
19traffic.use("*", softAuth);
20
21async function loadRepo(owner: string, repo: string) {
22 try {
23 const [row] = await db
24 .select({
25 id: repositories.id,
26 name: repositories.name,
27 ownerId: repositories.ownerId,
28 starCount: repositories.starCount,
29 forkCount: repositories.forkCount,
30 })
31 .from(repositories)
32 .innerJoin(users, eq(repositories.ownerId, users.id))
33 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
34 .limit(1);
35 return row || null;
36 } catch {
37 return null;
38 }
39}
40
41traffic.get("/:owner/:repo/traffic", requireAuth, async (c) => {
42 const user = c.get("user")!;
43 const { owner, repo } = c.req.param();
44 const repoRow = await loadRepo(owner, repo);
45 if (!repoRow) return c.notFound();
46 if (repoRow.ownerId !== user.id) {
47 return c.redirect(`/${owner}/${repo}`);
48 }
49
50 const windowDays = Math.max(
51 1,
52 Math.min(90, parseInt(c.req.query("days") || "14", 10) || 14)
53 );
54 const summary = await summarise(repoRow.id, windowDays);
55
56 // Simple ascii-bar chart scaled to the max day.
57 const maxN = Math.max(
58 1,
59 ...summary.daily.map((d) => d.views + d.clones)
60 );
61
62 return c.html(
63 <Layout title={`Traffic — ${owner}/${repo}`} user={user}>
64 <RepoHeader
65 owner={owner}
66 repo={repo}
67 starCount={repoRow.starCount}
68 forkCount={repoRow.forkCount}
69 currentUser={user.username}
70 />
71 <RepoNav owner={owner} repo={repo} active="insights" />
72
73 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
74 <h3>Traffic ({windowDays}d)</h3>
75 <div>
76 {[7, 14, 30, 90].map((d) => (
77 <a
78 href={`/${owner}/${repo}/traffic?days=${d}`}
79 class={`btn btn-sm ${d === windowDays ? "btn-primary" : ""}`}
80 style="margin-left:4px"
81 >
82 {d}d
83 </a>
84 ))}
85 </div>
86 </div>
87
88 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
89 <div class="panel" style="padding:12px;text-align:center">
90 <div style="font-size:22px;font-weight:700;color:#79c0ff">
91 {summary.totalViews}
92 </div>
93 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
94 Views
95 </div>
96 </div>
97 <div class="panel" style="padding:12px;text-align:center">
98 <div style="font-size:22px;font-weight:700;color:#d2a8ff">
99 {summary.totalClones}
100 </div>
101 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
102 Clones
103 </div>
104 </div>
105 <div class="panel" style="padding:12px;text-align:center">
106 <div style="font-size:22px;font-weight:700;color:var(--green)">
107 {summary.uniqueVisitorsApprox}
108 </div>
109 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
110 Unique (approx)
111 </div>
112 </div>
113 </div>
114
115 <h4>Daily</h4>
116 <div class="panel" style="margin-bottom:20px;padding:12px">
117 {summary.daily.length === 0 ? (
118 <p style="color:var(--text-muted);font-size:13px">
119 No traffic recorded yet. Views are tracked automatically as people
120 visit this repo; clones + API hits are tracked on git-http access.
121 </p>
122 ) : (
123 summary.daily.map((d) => {
124 const total = d.views + d.clones;
125 const pct = Math.round((total / maxN) * 100);
126 return (
127 <div style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0">
128 <span
129 style="font-family:var(--font-mono);color:var(--text-muted);width:88px"
130 >
131 {d.day}
132 </span>
133 <div
134 style={`flex:1;height:14px;background:var(--bg-tertiary);border-radius:3px;position:relative;overflow:hidden`}
135 >
136 <div
137 style={`position:absolute;left:0;top:0;bottom:0;width:${pct}%;background:linear-gradient(90deg,#79c0ff ${d.views / Math.max(1, total) * 100}%,#d2a8ff ${d.views / Math.max(1, total) * 100}%)`}
138 />
139 </div>
140 <span style="font-family:var(--font-mono);width:56px;text-align:right">
141 {d.views}v / {d.clones}c
142 </span>
143 </div>
144 );
145 })
146 )}
147 </div>
148
149 <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
150 <div>
151 <h4>Top paths</h4>
152 <div class="panel">
153 {summary.topPaths.length === 0 ? (
154 <div class="panel-empty">No paths recorded.</div>
155 ) : (
156 summary.topPaths.map((p) => (
157 <div class="panel-item" style="justify-content:space-between">
158 <code style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block">
159 {p.path}
160 </code>
161 <span style="font-family:var(--font-mono);color:var(--text-muted)">
162 {p.n}
163 </span>
164 </div>
165 ))
166 )}
167 </div>
168 </div>
169 <div>
170 <h4>Top referers</h4>
171 <div class="panel">
172 {summary.topReferers.length === 0 ? (
173 <div class="panel-empty">No external referers.</div>
174 ) : (
175 summary.topReferers.map((r) => (
176 <div class="panel-item" style="justify-content:space-between">
177 <span
178 style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block"
179 >
180 {r.referer}
181 </span>
182 <span style="font-family:var(--font-mono);color:var(--text-muted)">
183 {r.n}
184 </span>
185 </div>
186 ))
187 )}
188 </div>
189 </div>
190 </div>
191 </Layout>
192 );
193});
194
195export default traffic;
Modifiedsrc/routes/web.tsx+10−0View fileUnifiedSplit
@@ -41,6 +41,7 @@ import { renderMarkdown, markdownCss } from "../lib/markdown";
4141import { highlightCode } from "../lib/highlight";
4242import { softAuth, requireAuth } from "../middleware/auth";
4343import type { AuthEnv } from "../middleware/auth";
44import { trackByName } from "../lib/traffic";
4445
4546const web = new Hono<AuthEnv>();
4647
@@ -313,6 +314,15 @@ web.get("/:owner/:repo", async (c) => {
313314 const { owner, repo } = c.req.param();
314315 const user = c.get("user");
315316
317 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
318 trackByName(owner, repo, "view", {
319 userId: user?.id || null,
320 path: `/${owner}/${repo}`,
321 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
322 userAgent: c.req.header("user-agent") || null,
323 referer: c.req.header("referer") || null,
324 }).catch(() => {});
325
316326 if (!(await repoExists(owner, repo))) {
317327 return c.html(
318328 <Layout title="Not Found" user={user}>
319329