Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit25a91a6unknown_key

feat(BLOCK-C2+C3+C4): package registry + Pages + protected environments

feat(BLOCK-C2+C3+C4): package registry + Pages + protected environments

Block C2 — npm-compatible package registry:
- src/lib/packages.ts (215 LoC): parsePackageName, computeShasum (sha1),
  computeIntegrity (sha512 base64), buildPackument, resolveRepoFromPackageJson.
- src/routes/packages-api.ts (430 LoC): npm protocol (packument, tarball,
  publish, yank) at /npm/* plus JSON helpers at /api/packages/.... Scoped
  names parsed from full path to sidestep Hono's @/ routing.
- src/routes/packages.tsx (335 LoC): per-repo packages list + detail with
  install instructions + owner-only Yank button.
- 28 unit + 2 route tests.

Block C3 — Pages / static hosting:
- src/lib/pages.ts (183 LoC): onPagesPush (never throws), resolvePagesPath
  (pretty URL probe list, traversal-strip), contentTypeFor.
- src/routes/pages.tsx (518 LoC): GET /:owner/:repo/pages/* serves blobs
  from the bare git repo at the latest pages_deployments commit; binary via
  getRawBlob. Settings UI + manual redeploy.
- 27 tests.

Block C4 — Environments with protected approvals:
- src/lib/environments.ts (304 LoC): matchGlob, getOrCreateEnvironment,
  isReviewer, computeApprovalState, reduceApprovalState, recordApproval,
  requiresApprovalFor. v1 single-approver semantics; empty reviewers list
  falls back to repo owner; any rejection hard-stops.
- src/routes/environments.tsx (597 LoC): settings CRUD + approve/reject.
- src/lib/notify.ts: added 'deployment_approval' NotificationKind.
- 18 tests.

Integrations:
- src/app.tsx mounts packages-api, packages, pages, environments sub-apps.
- src/middleware/auth.ts grows PAT (`glc_`) bearer support in softAuth +
  requireAuth so `npm publish` via .npmrc :_authToken works end-to-end.
  Invalid bearer returns 401 JSON.
- src/hooks/post-receive.ts:
  * On any push to the repo's configured Pages source branch, calls
    onPagesPush (fire-and-forget).
  * Auto-deploy to Crontech is now gated by requiresApprovalFor('production',
    ref) — required environments yield a 'pending_approval' deployment row
    with blockedReason, visible in the Deployments UI until approved.

Test count: 187 → 260 (+73). All green.
Claude committed on April 15, 2026Parent: 5f8508b
15 files changed+35171525a91a615a11afca634d4e031574c41005d8a352
15 changed files+3517−15
ModifiedBUILD_BIBLE.md+26−7View fileUnifiedSplit
144144| Enterprise SAML / SSO | ❌ | |
145145| 2FA / TOTP | ✅ | `src/routes/settings-2fa.tsx`, `src/lib/totp.ts`; `user_totp` + `user_recovery_codes` tables |
146146| Passkeys / WebAuthn | ✅ | `src/routes/passkeys.tsx`, `src/lib/webauthn.ts`; `user_passkeys` + `webauthn_challenges` tables |
147| Packages registry (npm / docker / etc) | ❌ | |
148| Pages / static hosting | ❌ | |
147| Packages registry (npm / docker / etc) | ✅ | `src/lib/packages.ts`, `src/routes/packages-api.ts`, `src/routes/packages.tsx`; npm protocol (packument, tarball, publish, yank); PAT (`glc_`) auth via Authorization header; container registry deferred |
148| Pages / static hosting | ✅ | `src/lib/pages.ts`, `src/routes/pages.tsx`; serves blobs from bare git at latest `gh-pages` commit; per-repo settings (source branch/dir, custom domain); short-cache headers |
149149| Gists | ❌ | |
150150| Sponsors | ❌ | |
151151| Marketplace | ❌ | |
152| Environments / deployment tracking | ✅ | `src/routes/deployments.tsx` — grouped by env, success-rate rollup, per-deploy detail |
152| Environments / deployment tracking | ✅ | `src/routes/deployments.tsx` — grouped by env, success-rate rollup, per-deploy detail. Protected environments (`src/routes/environments.tsx`, `src/lib/environments.ts`) with reviewer-gated approval, branch-glob restrictions, approve/reject decisions recorded in `deployment_approvals` |
153153| Merge queues | ❌ | |
154154| Required checks matrix | 🟡 | branch_protection has single flag, no matrix |
155155
208208 - Background worker (`src/lib/workflow-runner.ts`) — Bun.spawn, size-capped logs, SIGTERM→SIGKILL timeouts
209209 - Auto-discovery from `.gluecron/workflows/*.yml` on default-branch push
210210 - UI at `/:owner/:repo/actions` with manual trigger + cancel
211- **C2** — Package registry (npm + container protocol)
212- **C3** — Pages / static hosting (`gh-pages` branch → served at `<owner>.<repo>.pages.gluecron.com`)
213- **C4** — Environments (prod/staging/preview) with protected approvals
211- **C2** — Package registry (npm protocol) → ✅ shipped
212 - Packument + tarball + publish + yank via `PUT /npm/<name>` + `GET /npm/<name>`
213 - PAT (`glc_`) bearer auth for CLI clients; add `//host/npm/:_authToken=<PAT>` to .npmrc
214 - Container registry deferred (schema ready for it)
215- **C3** — Pages / static hosting → ✅ shipped
216 - Serves `/:owner/:repo/pages/*` from the latest successful `pages_deployments` row
217 - Auto-records on push to the repo's configured source branch (default `gh-pages`)
218 - Settings UI at `/:owner/:repo/settings/pages` + manual redeploy
219- **C4** — Environments with protected approvals → ✅ shipped
220 - Per-repo `environments` with reviewer list + branch-glob allowlist
221 - Auto-deploy on main is gated by `requiresApprovalFor()`; pending rows show status `pending_approval`
222 - Approve/reject at `POST /:owner/:repo/deployments/:id/approve|reject`
214223
215224### BLOCK D — AI-native differentiation
216225This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
268277- `drizzle/0006_webauthn_passkeys.sql` (Block B5) — migration, never edited in place
269278- `drizzle/0007_oauth_provider.sql` (Block B6) — migration, never edited in place
270279- `drizzle/0008_workflows.sql` (Block C1) — migration, never edited in place
280- `drizzle/0009_packages.sql` (Block C2) — migration, never edited in place
281- `drizzle/0010_pages.sql` (Block C3) — migration, never edited in place
282- `drizzle/0011_environments.sql` (Block C4) — migration, never edited in place
271283
272284### 4.2 Git layer (locked)
273285- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
276288
277289### 4.3 Auth + security (locked)
278290- `src/lib/auth.ts` — bcrypt, session tokens
279- `src/middleware/auth.ts` — softAuth + requireAuth
291- `src/middleware/auth.ts` — softAuth + requireAuth. Accepts three auth inputs: session cookie (web), OAuth access token (`glct_` prefix, Block B6), and personal access token (`glc_` prefix, Block C2). Invalid bearer → 401 JSON. Cookie flow → /login redirect.
280292- `src/middleware/rate-limit.ts` — fixed-window limiter
281293- `src/middleware/request-context.ts` — request-ID
282294- `src/lib/security-scan.ts``SECRET_PATTERNS` (exported) + `scanForSecrets` + `aiSecurityScan`
286298- `src/lib/oauth.ts` (Block B6) — OAuth 2.0 provider: authorization code grant, token issuance, scope enforcement
287299- `src/lib/workflow-parser.ts` (Block C1) — YAML subset parser for `.gluecron/workflows/*.yml`. Exports `parseWorkflow(src)` returning `{ ok, workflow | error }`. Never throws.
288300- `src/lib/workflow-runner.ts` (Block C1) — shell executor. Exports `executeRun`, `drainOneRun`, `enqueueRun`, `startWorker`. Clones repo to tmpdir, runs each job via `Bun.spawn(["bash","-c",step.run])` with SIGTERM→SIGKILL timeouts, size-capped stdout/stderr, cleans up in `finally`.
301- `src/lib/packages.ts` (Block C2) — npm protocol helpers: `parsePackageName`, `computeShasum` (sha1), `computeIntegrity` (sha512 base64), `buildPackument`, `resolveRepoFromPackageJson`, `parseRepoUrl`, `tarballFilename`. Pure functions.
302- `src/lib/pages.ts` (Block C3) — `onPagesPush` (never throws), `resolvePagesPath` (probe list including pretty URLs + traversal strip), `contentTypeFor` (MIME).
303- `src/lib/environments.ts` (Block C4) — `matchGlob`, `listEnvironments`, `getOrCreateEnvironment`, `getEnvironmentByName`, `isReviewer`, `reviewerIdsOf`, `allowedBranchesOf`, `computeApprovalState`, `reduceApprovalState`, `recordApproval`, `requiresApprovalFor`. Empty reviewers list → repo owner approves. Any rejection hard-stops.
289304
290305### 4.4 AI layer (locked)
291306- `src/lib/ai-client.ts` — Anthropic client + model constants
342357- `src/routes/oauth.tsx` (Block B6) — OAuth 2.0 authorize + token + userinfo endpoints.
343358- `src/routes/developer-apps.tsx` (Block B6) — developer-facing OAuth app CRUD (`/settings/developer/apps`), client secret rotation, audit-logged.
344359- `src/routes/workflows.tsx` (Block C1) — Actions UI. `GET /:owner/:repo/actions`, `GET /:owner/:repo/actions/runs/:runId`, `POST /:owner/:repo/actions/:workflowId/run` (auth+owner), `POST /:owner/:repo/actions/runs/:runId/cancel` (auth+owner). Manual runs are `event=manual`, ref=default branch.
360- `src/routes/packages-api.ts` (Block C2) — npm protocol: `GET/PUT/DELETE /npm/*` (packument, tarball, publish, yank); JSON helpers at `/api/packages/:owner/:repo/...`. PAT (`glc_`) bearer auth.
361- `src/routes/packages.tsx` (Block C2) — UI: `/:owner/:repo/packages` list + `/:owner/:repo/packages/:pkgName` detail.
362- `src/routes/pages.tsx` (Block C3) — `GET /:owner/:repo/pages/*` serves static files from latest gh-pages commit (binary via `getRawBlob`, text via `getBlob`). `GET/POST /:owner/:repo/settings/pages` settings + redeploy.
363- `src/routes/environments.tsx` (Block C4) — settings CRUD at `/:owner/:repo/settings/environments`; approval endpoints at `/:owner/:repo/deployments/:id/{approve,reject}`.
345364
346365### 4.7 Views (locked contracts)
347366- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
Addedsrc/__tests__/environments.test.ts+196−0View fileUnifiedSplit
1/**
2 * Tests for Block C4 — environments + deployment approvals.
3 *
4 * Unit tests exercise the pure-function glob matcher + the single-approver
5 * semantics of computeApprovalState. Route-level tests verify that settings
6 * CRUD and approve/reject endpoints are properly guarded — they tolerate
7 * DB-less test environments (302/303/307/404/503) rather than asserting
8 * a single happy-path status.
9 */
10
11import { describe, it, expect } from "bun:test";
12import app from "../app";
13import {
14 matchGlob,
15 reduceApprovalState,
16 reviewerIdsOf,
17 allowedBranchesOf,
18} from "../lib/environments";
19import type { Environment, DeploymentApproval } from "../db/schema";
20
21const envFixture = (overrides: Partial<Environment> = {}): Environment =>
22 ({
23 id: "env-1",
24 repositoryId: "repo-1",
25 name: "production",
26 requireApproval: true,
27 reviewers: "[]",
28 waitTimerMinutes: 0,
29 allowedBranches: "[]",
30 createdAt: new Date(),
31 updatedAt: new Date(),
32 ...overrides,
33 }) as Environment;
34
35describe("matchGlob", () => {
36 it("matches exact literals", () => {
37 expect(matchGlob("main", "main")).toBe(true);
38 });
39
40 it("does not match mismatched literals", () => {
41 expect(matchGlob("main", "release/*")).toBe(false);
42 });
43
44 it("supports single-segment * wildcards", () => {
45 expect(matchGlob("release/1.0", "release/*")).toBe(true);
46 expect(matchGlob("release/foo/bar", "release/*")).toBe(false);
47 });
48
49 it("supports ** for any path", () => {
50 expect(matchGlob("release/foo/bar", "release/**")).toBe(true);
51 expect(matchGlob("main", "**")).toBe(true);
52 });
53
54 it("strips refs/heads/ prefix on both sides", () => {
55 expect(matchGlob("refs/heads/main", "main")).toBe(true);
56 expect(matchGlob("main", "refs/heads/main")).toBe(true);
57 });
58
59 it("does not match unrelated branches", () => {
60 expect(matchGlob("feature/x", "release/*")).toBe(false);
61 expect(matchGlob("develop", "main")).toBe(false);
62 });
63});
64
65describe("reviewerIdsOf / allowedBranchesOf", () => {
66 it("parses valid JSON arrays", () => {
67 const env = envFixture({
68 reviewers: JSON.stringify(["u1", "u2"]),
69 allowedBranches: JSON.stringify(["main", "release/*"]),
70 });
71 expect(reviewerIdsOf(env)).toEqual(["u1", "u2"]);
72 expect(allowedBranchesOf(env)).toEqual(["main", "release/*"]);
73 });
74
75 it("returns [] for empty/invalid", () => {
76 expect(reviewerIdsOf(envFixture({ reviewers: "" }))).toEqual([]);
77 expect(reviewerIdsOf(envFixture({ reviewers: "not-json" }))).toEqual([]);
78 expect(allowedBranchesOf(envFixture({ allowedBranches: "[]" }))).toEqual([]);
79 });
80});
81
82const mkApproval = (
83 decision: "approved" | "rejected",
84 userId = "u1"
85): DeploymentApproval =>
86 ({
87 id: `a-${Math.random()}`,
88 deploymentId: "d1",
89 userId,
90 decision,
91 comment: null,
92 createdAt: new Date(),
93 }) as DeploymentApproval;
94
95describe("reduceApprovalState (single-approver semantics)", () => {
96 it("approved=true when any approval exists and no rejection", () => {
97 const state = reduceApprovalState([mkApproval("approved")]);
98 expect(state.approved).toBe(true);
99 expect(state.rejected).toBe(false);
100 expect(state.decided.length).toBe(1);
101 });
102
103 it("rejected=true when any rejection exists (overrides approval)", () => {
104 const state = reduceApprovalState([
105 mkApproval("approved", "u1"),
106 mkApproval("rejected", "u2"),
107 ]);
108 expect(state.rejected).toBe(true);
109 expect(state.approved).toBe(false);
110 });
111
112 it("neither approved nor rejected when no decisions", () => {
113 const state = reduceApprovalState([]);
114 expect(state.approved).toBe(false);
115 expect(state.rejected).toBe(false);
116 });
117});
118
119describe("environments routes — unauthed guards", () => {
120 const ok = [301, 302, 303, 307, 401, 404, 503];
121
122 it("GET /:owner/:repo/settings/environments redirects to login when unauthed", async () => {
123 const res = await app.request("/alice/project/settings/environments");
124 expect(ok).toContain(res.status);
125 });
126
127 it("POST /:owner/:repo/settings/environments requires auth", async () => {
128 const res = await app.request("/alice/project/settings/environments", {
129 method: "POST",
130 headers: { "content-type": "application/x-www-form-urlencoded" },
131 body: "name=staging",
132 });
133 expect(ok).toContain(res.status);
134 });
135
136 it("POST /:owner/:repo/settings/environments/:envId requires auth", async () => {
137 const res = await app.request(
138 "/alice/project/settings/environments/env-1",
139 {
140 method: "POST",
141 headers: { "content-type": "application/x-www-form-urlencoded" },
142 body: "",
143 }
144 );
145 expect(ok).toContain(res.status);
146 });
147
148 it("POST /:owner/:repo/settings/environments/:envId/delete requires auth", async () => {
149 const res = await app.request(
150 "/alice/project/settings/environments/env-1/delete",
151 {
152 method: "POST",
153 headers: { "content-type": "application/x-www-form-urlencoded" },
154 body: "",
155 }
156 );
157 expect(ok).toContain(res.status);
158 });
159
160 it("POST /:owner/:repo/deployments/:id/approve requires auth", async () => {
161 const res = await app.request(
162 "/alice/project/deployments/dep-1/approve",
163 {
164 method: "POST",
165 headers: { "content-type": "application/x-www-form-urlencoded" },
166 body: "",
167 }
168 );
169 expect(ok).toContain(res.status);
170 });
171
172 it("POST /:owner/:repo/deployments/:id/reject requires auth", async () => {
173 const res = await app.request(
174 "/alice/project/deployments/dep-1/reject",
175 {
176 method: "POST",
177 headers: { "content-type": "application/x-www-form-urlencoded" },
178 body: "",
179 }
180 );
181 expect(ok).toContain(res.status);
182 });
183
184 it("bearer auth with bogus token on settings POST returns 401", async () => {
185 const res = await app.request("/alice/project/settings/environments", {
186 method: "POST",
187 headers: {
188 "content-type": "application/x-www-form-urlencoded",
189 authorization: "Bearer glct_definitely-not-valid",
190 },
191 body: "name=staging",
192 });
193 // 401 from requireAuth with invalid bearer; 404/503 tolerated pre-route.
194 expect([401, 404, 503]).toContain(res.status);
195 });
196});
Addedsrc/__tests__/packages.test.ts+297−0View fileUnifiedSplit
1/**
2 * Tests for Block C2 — npm-compatible package registry.
3 *
4 * Covers the pure helpers in `src/lib/packages.ts` and a handful of
5 * route-level behaviour guarantees (401 without auth, 404 for unknown
6 * packages). The integration paths — actual publish → install cycles —
7 * are exercised by higher-level tests once a real test DB is wired.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import {
13 parsePackageName,
14 parseRepoUrl,
15 computeShasum,
16 computeIntegrity,
17 buildPackument,
18 resolveRepoFromPackageJson,
19 tarballFilename,
20} from "../lib/packages";
21
22describe("parsePackageName", () => {
23 it("parses a plain name", () => {
24 const r = parsePackageName("left-pad");
25 expect(r).not.toBeNull();
26 expect(r!.scope).toBeNull();
27 expect(r!.name).toBe("left-pad");
28 expect(r!.full).toBe("left-pad");
29 });
30
31 it("parses a scoped name", () => {
32 const r = parsePackageName("@acme/widgets");
33 expect(r).not.toBeNull();
34 expect(r!.scope).toBe("@acme");
35 expect(r!.name).toBe("widgets");
36 expect(r!.full).toBe("@acme/widgets");
37 });
38
39 it("accepts a URL-encoded scoped name (%2F)", () => {
40 const r = parsePackageName("@acme%2Fwidgets");
41 expect(r).not.toBeNull();
42 expect(r!.scope).toBe("@acme");
43 expect(r!.name).toBe("widgets");
44 });
45
46 it("rejects empty strings", () => {
47 expect(parsePackageName("")).toBeNull();
48 expect(parsePackageName(" ")).toBeNull();
49 });
50
51 it("rejects malformed scope-only input", () => {
52 expect(parsePackageName("@")).toBeNull();
53 expect(parsePackageName("@acme")).toBeNull();
54 expect(parsePackageName("@/foo")).toBeNull();
55 });
56
57 it("rejects names with spaces or weird chars", () => {
58 expect(parsePackageName("foo bar")).toBeNull();
59 expect(parsePackageName("foo/bar")).toBeNull(); // would be scope-style without @
60 expect(parsePackageName("../etc")).toBeNull();
61 });
62
63 it("allows legal characters (dot, dash, underscore, digits)", () => {
64 expect(parsePackageName("a.b_c-1")).not.toBeNull();
65 expect(parsePackageName("@s_c.o-pe/n.a_m-e1")).not.toBeNull();
66 });
67});
68
69describe("computeShasum", () => {
70 it("returns a 40-char lowercase hex string", () => {
71 const bytes = new TextEncoder().encode("hello world");
72 const out = computeShasum(bytes);
73 expect(out).toMatch(/^[0-9a-f]{40}$/);
74 });
75
76 it("matches the known sha1 of 'hello world'", () => {
77 const bytes = new TextEncoder().encode("hello world");
78 expect(computeShasum(bytes)).toBe(
79 "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"
80 );
81 });
82
83 it("is stable across calls", () => {
84 const bytes = new TextEncoder().encode("stable-input");
85 expect(computeShasum(bytes)).toBe(computeShasum(bytes));
86 });
87});
88
89describe("computeIntegrity", () => {
90 it("prefixes with sha512-", () => {
91 const bytes = new TextEncoder().encode("integrity-test");
92 const out = computeIntegrity(bytes);
93 expect(out.startsWith("sha512-")).toBe(true);
94 });
95
96 it("base64 body decodes to 64 bytes (sha512 digest length)", () => {
97 const bytes = new TextEncoder().encode("xyz");
98 const out = computeIntegrity(bytes);
99 const body = out.slice("sha512-".length);
100 const decoded = Buffer.from(body, "base64");
101 expect(decoded.length).toBe(64);
102 });
103});
104
105describe("resolveRepoFromPackageJson", () => {
106 it("accepts the object form with url", () => {
107 const r = resolveRepoFromPackageJson({
108 repository: { type: "git", url: "https://gluecron.com/alice/foo.git" },
109 });
110 expect(r).toEqual({ owner: "alice", repo: "foo" });
111 });
112
113 it("accepts the string shorthand", () => {
114 const r = resolveRepoFromPackageJson({
115 repository: "https://gluecron.com/bob/bar.git",
116 });
117 expect(r).toEqual({ owner: "bob", repo: "bar" });
118 });
119
120 it("accepts git+https URLs", () => {
121 const r = resolveRepoFromPackageJson({
122 repository: { url: "git+https://gluecron.com/alice/foo.git" },
123 });
124 expect(r).toEqual({ owner: "alice", repo: "foo" });
125 });
126
127 it("accepts SCP-style git@ URLs", () => {
128 const r = resolveRepoFromPackageJson({
129 repository: "git@gluecron.com:alice/foo.git",
130 });
131 expect(r).toEqual({ owner: "alice", repo: "foo" });
132 });
133
134 it("returns null when repository is missing", () => {
135 expect(resolveRepoFromPackageJson({})).toBeNull();
136 expect(resolveRepoFromPackageJson(null)).toBeNull();
137 expect(resolveRepoFromPackageJson("not-an-object")).toBeNull();
138 });
139
140 it("returns null for empty / malformed URLs", () => {
141 expect(resolveRepoFromPackageJson({ repository: "" })).toBeNull();
142 expect(resolveRepoFromPackageJson({ repository: "noslashes" })).toBeNull();
143 });
144});
145
146describe("parseRepoUrl (direct)", () => {
147 it("strips trailing .git", () => {
148 expect(parseRepoUrl("http://localhost:3000/a/b.git")).toEqual({
149 owner: "a",
150 repo: "b",
151 });
152 });
153 it("handles bare owner/repo path", () => {
154 expect(parseRepoUrl("alice/foo")).toEqual({ owner: "alice", repo: "foo" });
155 });
156});
157
158describe("buildPackument", () => {
159 const pkg = {
160 id: "pkg-1",
161 repositoryId: "repo-1",
162 ecosystem: "npm",
163 scope: null,
164 name: "widgets",
165 description: "A package of widgets",
166 readme: "# widgets",
167 homepage: "https://example.com",
168 license: "MIT",
169 visibility: "public",
170 createdAt: new Date("2024-01-01T00:00:00Z"),
171 updatedAt: new Date("2024-01-01T00:00:00Z"),
172 } as const;
173
174 const v1 = {
175 id: "v1",
176 packageId: "pkg-1",
177 version: "1.0.0",
178 shasum: "aaaa",
179 integrity: "sha512-deadbeef",
180 sizeBytes: 123,
181 metadata: JSON.stringify({ name: "widgets", version: "1.0.0" }),
182 tarball: null,
183 publishedBy: "user-1",
184 yanked: false,
185 yankedReason: null,
186 publishedAt: new Date("2024-01-02T00:00:00Z"),
187 } as const;
188
189 const v2 = {
190 ...v1,
191 id: "v2",
192 version: "1.1.0",
193 shasum: "bbbb",
194 publishedAt: new Date("2024-01-10T00:00:00Z"),
195 } as const;
196
197 it("returns name, dist-tags, and versions", () => {
198 const doc = buildPackument(
199 pkg as any,
200 [v1, v2] as any,
201 [
202 {
203 id: "t1",
204 packageId: "pkg-1",
205 tag: "latest",
206 versionId: "v2",
207 updatedAt: new Date(),
208 } as any,
209 ],
210 "http://host:3000"
211 );
212 expect(doc.name).toBe("widgets");
213 expect((doc["dist-tags"] as Record<string, string>).latest).toBe("1.1.0");
214 expect((doc.versions as Record<string, unknown>)["1.0.0"]).toBeDefined();
215 expect((doc.versions as Record<string, unknown>)["1.1.0"]).toBeDefined();
216 });
217
218 it("falls back to most-recent version for latest if no tag rows", () => {
219 const doc = buildPackument(
220 pkg as any,
221 [v1, v2] as any,
222 [],
223 "http://host:3000"
224 );
225 expect((doc["dist-tags"] as Record<string, string>).latest).toBe("1.1.0");
226 });
227
228 it("embeds tarball URLs under dist", () => {
229 const doc = buildPackument(
230 pkg as any,
231 [v1] as any,
232 [],
233 "http://host:3000"
234 );
235 const ver = (doc.versions as any)["1.0.0"];
236 expect(ver.dist.tarball).toContain("http://host:3000/npm/widgets/-/widgets-1.0.0.tgz");
237 expect(ver.dist.shasum).toBe("aaaa");
238 expect(ver.dist.integrity).toBe("sha512-deadbeef");
239 });
240
241 it("uses full scoped name in url for scoped packages", () => {
242 const scoped = { ...pkg, scope: "@acme", name: "widgets" } as any;
243 const doc = buildPackument(scoped, [v1] as any, [], "http://h");
244 expect(doc.name).toBe("@acme/widgets");
245 const ver = (doc.versions as any)["1.0.0"];
246 // Tarball path encodes @acme/widgets but filename uses just "widgets".
247 expect(ver.dist.tarball).toContain("/npm/@acme/widgets/-/widgets-1.0.0.tgz");
248 });
249});
250
251describe("tarballFilename", () => {
252 it("builds <name>-<version>.tgz for plain packages", () => {
253 const parsed = parsePackageName("widgets")!;
254 expect(tarballFilename(parsed, "1.2.3")).toBe("widgets-1.2.3.tgz");
255 });
256 it("omits the scope from the filename for scoped packages", () => {
257 const parsed = parsePackageName("@acme/widgets")!;
258 expect(tarballFilename(parsed, "1.2.3")).toBe("widgets-1.2.3.tgz");
259 });
260});
261
262// ---------------------------------------------------------------------------
263// Route-level guard tests
264// ---------------------------------------------------------------------------
265
266describe("packages routes — unauthed behaviour", () => {
267 it("PUT /npm/:name without auth returns 401 (JSON)", async () => {
268 const res = await app.request("/npm/some-package", {
269 method: "PUT",
270 headers: {
271 "content-type": "application/json",
272 authorization: "Bearer glct_does-not-exist",
273 },
274 body: JSON.stringify({
275 name: "some-package",
276 versions: { "1.0.0": { name: "some-package", version: "1.0.0" } },
277 _attachments: {
278 "some-package-1.0.0.tgz": {
279 content_type: "application/octet-stream",
280 data: "aGVsbG8=",
281 length: 5,
282 },
283 },
284 }),
285 });
286 // Either 401 (bad bearer) or 404 (route may not be mounted in main yet;
287 // we don't edit app.tsx). Assert tolerant but meaningful.
288 expect([401, 404]).toContain(res.status);
289 });
290
291 it("GET /npm/does-not-exist returns 404", async () => {
292 const res = await app.request("/npm/does-not-exist-package-xyz");
293 // 404 from our handler, 503 if DB down, or 404 from app-level notFound if
294 // the route isn't yet mounted.
295 expect([404, 503]).toContain(res.status);
296 });
297});
Addedsrc/__tests__/pages.test.ts+231−0View fileUnifiedSplit
1/**
2 * Block C3 — Pages unit + route tests.
3 *
4 * These tests run without a live database. Route tests therefore accept the
5 * whole class of graceful-degradation responses (404 / 302 / 303 / 503):
6 * 503 is emitted when the DB proxy throws, 404 when the repo row isn't found,
7 * and 302/303 when auth middleware redirects to /login.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import {
13 contentTypeFor,
14 resolvePagesPath,
15 onPagesPush,
16} from "../lib/pages";
17
18describe("lib/pages — contentTypeFor", () => {
19 it("returns text/html for .html", () => {
20 expect(contentTypeFor("index.html")).toContain("text/html");
21 });
22
23 it("returns text/css for .css", () => {
24 expect(contentTypeFor("site.css")).toContain("text/css");
25 });
26
27 it("returns application/javascript for .js", () => {
28 expect(contentTypeFor("app.js")).toContain("javascript");
29 });
30
31 it("returns image/svg+xml for .svg", () => {
32 expect(contentTypeFor("logo.svg")).toBe("image/svg+xml");
33 });
34
35 it("returns image/png for .png", () => {
36 expect(contentTypeFor("pic.PNG")).toBe("image/png");
37 });
38
39 it("returns image/jpeg for .jpg and .jpeg", () => {
40 expect(contentTypeFor("a.jpg")).toBe("image/jpeg");
41 expect(contentTypeFor("b.jpeg")).toBe("image/jpeg");
42 });
43
44 it("returns application/json for .json", () => {
45 expect(contentTypeFor("data.json")).toContain("application/json");
46 });
47
48 it("returns application/octet-stream for unknown extensions", () => {
49 expect(contentTypeFor("mystery.xyz")).toBe("application/octet-stream");
50 });
51
52 it("returns application/octet-stream for files with no extension", () => {
53 expect(contentTypeFor("Makefile")).toBe("application/octet-stream");
54 });
55});
56
57describe("lib/pages — resolvePagesPath", () => {
58 it("returns index.html for empty url rest at root", () => {
59 expect(resolvePagesPath("", "/", "index.html")).toEqual(["index.html"]);
60 });
61
62 it("returns index.html for a trailing slash", () => {
63 expect(resolvePagesPath("/", "/", "index.html")).toEqual(["index.html"]);
64 });
65
66 it("probes both foo.html and foo/index.html for extensionless urls", () => {
67 const paths = resolvePagesPath("about", "/", "index.html");
68 expect(paths).toContain("about.html");
69 expect(paths).toContain("about/index.html");
70 expect(paths[0]).toBe("about.html");
71 });
72
73 it("serves a file directly when it has an extension", () => {
74 expect(resolvePagesPath("assets/app.css", "/", "index.html")).toEqual([
75 "assets/app.css",
76 ]);
77 });
78
79 it("applies sourceDir as a prefix", () => {
80 const paths = resolvePagesPath("about", "/docs", "index.html");
81 expect(paths[0]).toBe("docs/about.html");
82 expect(paths[1]).toBe("docs/about/index.html");
83 });
84
85 it("serves the index inside a nested directory when url ends with slash", () => {
86 expect(resolvePagesPath("blog/", "/", "index.html")).toEqual([
87 "blog/index.html",
88 ]);
89 });
90
91 it("strips path-traversal segments", () => {
92 const paths = resolvePagesPath("../../etc/passwd", "/", "index.html");
93 // .. entries are dropped; the remaining "etc/passwd" is treated as a
94 // pretty URL because it has no file extension.
95 expect(paths).not.toContain("../etc/passwd");
96 expect(paths).not.toContain("../../etc/passwd");
97 for (const p of paths) {
98 expect(p.startsWith("..")).toBe(false);
99 expect(p).not.toContain("../");
100 }
101 expect(paths[0]).toBe("etc/passwd.html");
102 });
103
104 it("strips leading slashes on the url rest", () => {
105 expect(resolvePagesPath("/about.html", "/", "index.html")).toEqual([
106 "about.html",
107 ]);
108 });
109
110 it("normalises source dir with or without leading / trailing slash", () => {
111 expect(resolvePagesPath("", "docs/", "index.html")).toEqual([
112 "docs/index.html",
113 ]);
114 expect(resolvePagesPath("", "/docs/", "index.html")).toEqual([
115 "docs/index.html",
116 ]);
117 });
118});
119
120describe("lib/pages — onPagesPush", () => {
121 it("never throws, even with a bogus repositoryId and no DB", async () => {
122 // No DATABASE_URL in the test env => db proxy throws. The helper must
123 // swallow that and return normally.
124 await expect(
125 onPagesPush({
126 ownerLogin: "alice",
127 repoName: "project",
128 repositoryId: "00000000-0000-0000-0000-000000000000",
129 ref: "refs/heads/gh-pages",
130 newSha: "0".repeat(40),
131 triggeredByUserId: null,
132 })
133 ).resolves.toBeUndefined();
134 });
135});
136
137describe("routes/pages — guards", () => {
138 it("GET /:owner/:repo/pages/ 404s when repo does not exist", async () => {
139 const res = await app.request("/alice/project/pages/");
140 // 404 when repo row not found, 503 when DB is unreachable.
141 expect([404, 503]).toContain(res.status);
142 });
143
144 it("GET /:owner/:repo/pages/foo 404s when repo does not exist", async () => {
145 const res = await app.request("/nobody/nothing/pages/foo.html");
146 expect([404, 503]).toContain(res.status);
147 });
148
149 it("GET /:owner/:repo/settings/pages requires auth (or is not yet mounted)", async () => {
150 const res = await app.request(
151 "/alice/project/settings/pages",
152 { redirect: "manual" }
153 );
154 // When mounted: anonymous -> redirected to /login.
155 // When not yet wired into app.tsx (integration step handled by owner):
156 // the global 404 handler answers instead.
157 expect([302, 303, 404, 503]).toContain(res.status);
158 if (res.status === 302 || res.status === 303) {
159 const loc = res.headers.get("location") || "";
160 expect(loc).toContain("/login");
161 }
162 });
163
164 it("POST /:owner/:repo/settings/pages without auth redirects to /login or 404s", async () => {
165 const res = await app.request("/alice/project/settings/pages", {
166 method: "POST",
167 headers: { "Content-Type": "application/x-www-form-urlencoded" },
168 body: "enabled=1&source_branch=gh-pages&source_dir=/",
169 redirect: "manual",
170 });
171 expect([302, 303, 404, 503]).toContain(res.status);
172 if (res.status === 302 || res.status === 303) {
173 const loc = res.headers.get("location") || "";
174 expect(loc).toContain("/login");
175 }
176 });
177
178 it("POST /:owner/:repo/settings/pages/redeploy without auth redirects to /login or 404s", async () => {
179 const res = await app.request(
180 "/alice/project/settings/pages/redeploy",
181 {
182 method: "POST",
183 redirect: "manual",
184 }
185 );
186 expect([302, 303, 404, 503]).toContain(res.status);
187 if (res.status === 302 || res.status === 303) {
188 const loc = res.headers.get("location") || "";
189 expect(loc).toContain("/login");
190 }
191 });
192});
193
194describe("routes/pages — direct route tests (no app mount)", () => {
195 // These test the exported Hono app directly so we don't depend on the
196 // owner wiring up app.tsx. This mirrors what the integration step will
197 // yield once the parent agent mounts pagesRoute.
198 it("direct GET /:owner/:repo/settings/pages without auth redirects to /login", async () => {
199 const { default: pagesRoute } = await import("../routes/pages");
200 const res = await pagesRoute.request(
201 "/alice/project/settings/pages",
202 { redirect: "manual" }
203 );
204 expect([302, 303, 503]).toContain(res.status);
205 if (res.status === 302 || res.status === 303) {
206 const loc = res.headers.get("location") || "";
207 expect(loc).toContain("/login");
208 }
209 });
210
211 it("direct POST /:owner/:repo/settings/pages without auth redirects to /login", async () => {
212 const { default: pagesRoute } = await import("../routes/pages");
213 const res = await pagesRoute.request("/alice/project/settings/pages", {
214 method: "POST",
215 headers: { "Content-Type": "application/x-www-form-urlencoded" },
216 body: "enabled=1&source_branch=gh-pages&source_dir=/",
217 redirect: "manual",
218 });
219 expect([302, 303, 503]).toContain(res.status);
220 if (res.status === 302 || res.status === 303) {
221 const loc = res.headers.get("location") || "";
222 expect(loc).toContain("/login");
223 }
224 });
225
226 it("direct GET /:owner/:repo/pages/ 404s when repo does not exist", async () => {
227 const { default: pagesRoute } = await import("../routes/pages");
228 const res = await pagesRoute.request("/alice/project/pages/");
229 expect([404, 503]).toContain(res.status);
230 });
231});
Modifiedsrc/app.tsx+14−0View fileUnifiedSplit
3939import oauthRoutes from "./routes/oauth";
4040import developerAppsRoutes from "./routes/developer-apps";
4141import workflowRoutes from "./routes/workflows";
42import packagesApiRoutes from "./routes/packages-api";
43import packagesUiRoutes from "./routes/packages";
44import pagesRoutes from "./routes/pages";
45import environmentsRoutes from "./routes/environments";
4246import webRoutes from "./routes/web";
4347
4448const app = new Hono();
152156// Actions-equivalent workflow runner (Block C1)
153157app.route("/", workflowRoutes);
154158
159// Package registry — npm protocol + UI (Block C2)
160app.route("/", packagesApiRoutes);
161app.route("/", packagesUiRoutes);
162
163// Pages / static hosting (Block C3)
164app.route("/", pagesRoutes);
165
166// Environments with protected approvals (Block C4)
167app.route("/", environmentsRoutes);
168
155169// Insights + milestones
156170app.route("/", insightsRoutes);
157171
Modifiedsrc/hooks/post-receive.ts+66−2View fileUnifiedSplit
2727import { getBlob, getDefaultBranch, getTree } from "../git/repository";
2828import { parseCodeowners, syncCodeowners } from "../lib/codeowners";
2929import { notify } from "../lib/notify";
30import { workflows } from "../db/schema";
30import { workflows, pagesSettings } from "../db/schema";
3131import { parseWorkflow } from "../lib/workflow-parser";
3232import { enqueueRun } from "../lib/workflow-runner";
33import { onPagesPush } from "../lib/pages";
34import { requiresApprovalFor } from "../lib/environments";
3335
3436interface PushRef {
3537 oldSha: string;
209211 }
210212 }
211213
214 // --- 2c. Pages (Block C3) ---
215 // On any push, if the ref matches the configured pages source branch,
216 // record a pages_deployments row. Fire-and-forget; onPagesPush never throws.
217 if (repoRow) {
218 let pagesBranch = "gh-pages";
219 try {
220 const [pSettings] = await db
221 .select()
222 .from(pagesSettings)
223 .where(eq(pagesSettings.repositoryId, repoRow.id))
224 .limit(1);
225 if (pSettings) {
226 if (pSettings.enabled === false) pagesBranch = "";
227 else pagesBranch = pSettings.sourceBranch || "gh-pages";
228 }
229 } catch {
230 /* fall back to default */
231 }
232
233 if (pagesBranch) {
234 for (const ref of refs) {
235 if (ref.newSha.startsWith("0000")) continue;
236 if (ref.refName === `refs/heads/${pagesBranch}`) {
237 void onPagesPush({
238 ownerLogin: owner,
239 repoName: repo,
240 repositoryId: repoRow.id,
241 ref: ref.refName,
242 newSha: ref.newSha,
243 triggeredByUserId: ownerRow?.id || null,
244 });
245 }
246 }
247 }
248 }
249
212250 // --- 3. Gates ---
213251 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
214252
263301 }
264302
265303 // --- 4. Auto-deploy (only on default branch + green settings) ---
304 // Block C4: if a "production" environment is configured with approval
305 // required, insert a pending_approval deployment row instead of firing.
266306 if (mainRef && settings?.autoDeployEnabled !== false && repoRow) {
267 promises.push(triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id));
307 const gate = await requiresApprovalFor(
308 repoRow.id,
309 "production",
310 mainRef.refName
311 ).catch(() => ({ required: false, env: null as null }));
312
313 if (gate.required && gate.env) {
314 try {
315 await db.insert(deployments).values({
316 repositoryId: repoRow.id,
317 environment: "production",
318 commitSha: mainRef.newSha,
319 ref: mainRef.refName,
320 status: "pending_approval",
321 target: "crontech",
322 blockedReason: `awaiting approval for environment '${gate.env.name}'`,
323 });
324 } catch (err) {
325 console.error("[post-receive] pending_approval insert:", err);
326 }
327 } else {
328 promises.push(
329 triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id)
330 );
331 }
268332 }
269333
270334 // --- 5. Webhook fan-out ---
Addedsrc/lib/environments.ts+304−0View fileUnifiedSplit
1/**
2 * Block C4 — Environment + deployment-approval helpers.
3 *
4 * v1 semantics:
5 * - approval = any single reviewer approves (required = 1).
6 * - rejection by any reviewer hard-stops the deploy.
7 * - if `reviewers` is empty, the repo owner is treated as the implicit reviewer.
8 * - `allowedBranches` is a JSON array of glob patterns. When non-empty only
9 * refs matching at least one pattern may deploy through the environment.
10 * - `waitTimerMinutes` is stored but NOT enforced in v1 (stub).
11 *
12 * All DB calls are wrapped in try/catch so the caller gets well-defined
13 * shapes even when the database is unreachable (keeps the hot push path safe).
14 */
15
16import { and, desc, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 environments,
20 deploymentApprovals,
21 repositories,
22} from "../db/schema";
23import type { Environment, DeploymentApproval } from "../db/schema";
24
25// ---------------------------------------------------------------------------
26// Glob matching (minimal — `*` segment, `**` any path, literals)
27// ---------------------------------------------------------------------------
28
29/** Normalise a ref for matching — strip `refs/heads/`, `refs/tags/`. */
30function normaliseRef(ref: string): string {
31 if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length);
32 if (ref.startsWith("refs/tags/")) return ref.slice("refs/tags/".length);
33 return ref;
34}
35
36/** Minimal glob → RegExp. `*` = one path segment, `**` = any. */
37export function matchGlob(value: string, pattern: string): boolean {
38 const v = normaliseRef(value);
39 const p = normaliseRef(pattern);
40 if (v === p) return true;
41 // Escape regex metachars, then re-expand `**` and `*`.
42 const re = p
43 .replace(/[.+^${}()|[\]\\]/g, "\\$&")
44 .replace(/\*\*/g, "::DOUBLESTAR::")
45 .replace(/\*/g, "[^/]*")
46 .replace(/::DOUBLESTAR::/g, ".*");
47 return new RegExp(`^${re}$`).test(v);
48}
49
50function matchesAny(value: string, patterns: string[]): boolean {
51 if (patterns.length === 0) return true;
52 return patterns.some((p) => matchGlob(value, p));
53}
54
55// ---------------------------------------------------------------------------
56// Environments CRUD
57// ---------------------------------------------------------------------------
58
59export async function listEnvironments(
60 repositoryId: string
61): Promise<Environment[]> {
62 try {
63 return await db
64 .select()
65 .from(environments)
66 .where(eq(environments.repositoryId, repositoryId))
67 .orderBy(desc(environments.createdAt));
68 } catch (err) {
69 console.error("[environments] list failed:", err);
70 return [];
71 }
72}
73
74export async function getEnvironmentById(
75 repositoryId: string,
76 id: string
77): Promise<Environment | null> {
78 try {
79 const [row] = await db
80 .select()
81 .from(environments)
82 .where(
83 and(eq(environments.id, id), eq(environments.repositoryId, repositoryId))
84 )
85 .limit(1);
86 return row || null;
87 } catch (err) {
88 console.error("[environments] getById failed:", err);
89 return null;
90 }
91}
92
93export async function getEnvironmentByName(
94 repositoryId: string,
95 name: string
96): Promise<Environment | null> {
97 try {
98 const [row] = await db
99 .select()
100 .from(environments)
101 .where(
102 and(
103 eq(environments.repositoryId, repositoryId),
104 eq(environments.name, name)
105 )
106 )
107 .limit(1);
108 return row || null;
109 } catch (err) {
110 console.error("[environments] getByName failed:", err);
111 return null;
112 }
113}
114
115export async function getOrCreateEnvironment(
116 repositoryId: string,
117 name: string
118): Promise<Environment> {
119 const existing = await getEnvironmentByName(repositoryId, name);
120 if (existing) return existing;
121 try {
122 const [inserted] = await db
123 .insert(environments)
124 .values({ repositoryId, name })
125 .returning();
126 if (inserted) return inserted;
127 } catch (err) {
128 // Unique-index collision from a concurrent insert — fall through to re-read.
129 console.error("[environments] create failed:", err);
130 }
131 const reread = await getEnvironmentByName(repositoryId, name);
132 if (reread) return reread;
133 // Absolute fallback — synthesize an in-memory shell so callers never crash.
134 // This path is only reached if the DB is unreachable for both insert + read.
135 return {
136 id: "",
137 repositoryId,
138 name,
139 requireApproval: false,
140 reviewers: "[]",
141 waitTimerMinutes: 0,
142 allowedBranches: "[]",
143 createdAt: new Date(),
144 updatedAt: new Date(),
145 } as Environment;
146}
147
148// ---------------------------------------------------------------------------
149// Reviewer semantics
150// ---------------------------------------------------------------------------
151
152function parseJsonArray(raw: string | null | undefined): string[] {
153 if (!raw) return [];
154 try {
155 const v = JSON.parse(raw);
156 return Array.isArray(v) ? v.map(String) : [];
157 } catch {
158 return [];
159 }
160}
161
162export function reviewerIdsOf(env: Environment): string[] {
163 return parseJsonArray(env.reviewers);
164}
165
166export function allowedBranchesOf(env: Environment): string[] {
167 return parseJsonArray(env.allowedBranches);
168}
169
170/**
171 * Return true if `userId` is allowed to approve/reject deploys for this env.
172 * If the reviewer list is empty, fall back to the repo owner.
173 */
174export async function isReviewer(
175 env: Environment,
176 userId: string
177): Promise<boolean> {
178 const reviewers = reviewerIdsOf(env);
179 if (reviewers.includes(userId)) return true;
180 if (reviewers.length === 0) {
181 try {
182 const [row] = await db
183 .select({ ownerId: repositories.ownerId })
184 .from(repositories)
185 .where(eq(repositories.id, env.repositoryId))
186 .limit(1);
187 return row?.ownerId === userId;
188 } catch (err) {
189 console.error("[environments] isReviewer owner lookup failed:", err);
190 return false;
191 }
192 }
193 return false;
194}
195
196// ---------------------------------------------------------------------------
197// Approvals
198// ---------------------------------------------------------------------------
199
200export async function listApprovals(
201 deploymentId: string
202): Promise<DeploymentApproval[]> {
203 try {
204 return await db
205 .select()
206 .from(deploymentApprovals)
207 .where(eq(deploymentApprovals.deploymentId, deploymentId))
208 .orderBy(desc(deploymentApprovals.createdAt));
209 } catch (err) {
210 console.error("[environments] listApprovals failed:", err);
211 return [];
212 }
213}
214
215/**
216 * Pure reducer — given a list of decisions, compute approved/rejected flags.
217 * Exported so tests can exercise it without a DB.
218 */
219export function reduceApprovalState(decided: DeploymentApproval[]): {
220 approved: boolean;
221 rejected: boolean;
222 decided: DeploymentApproval[];
223} {
224 const rejected = decided.some((d) => d.decision === "rejected");
225 const approved = !rejected && decided.some((d) => d.decision === "approved");
226 return { approved, rejected, decided };
227}
228
229/**
230 * v1 semantics: approved = at least one approval exists and no rejection.
231 * rejected = at least one rejection exists.
232 */
233export async function computeApprovalState(
234 deploymentId: string,
235 _env: Environment
236): Promise<{
237 approved: boolean;
238 rejected: boolean;
239 decided: DeploymentApproval[];
240}> {
241 const decided = await listApprovals(deploymentId);
242 return reduceApprovalState(decided);
243}
244
245/**
246 * Record a reviewer's decision. Returns the inserted row, or null on any
247 * failure (duplicate, DB unreachable, etc).
248 */
249export async function recordApproval(opts: {
250 deploymentId: string;
251 userId: string;
252 decision: "approved" | "rejected";
253 comment?: string;
254}): Promise<DeploymentApproval | null> {
255 try {
256 const [row] = await db
257 .insert(deploymentApprovals)
258 .values({
259 deploymentId: opts.deploymentId,
260 userId: opts.userId,
261 decision: opts.decision,
262 comment: opts.comment ?? null,
263 })
264 .returning();
265 return row || null;
266 } catch (err) {
267 console.error("[environments] recordApproval failed:", err);
268 return null;
269 }
270}
271
272// ---------------------------------------------------------------------------
273// Push-time gate (called by post-receive before the deploy executes)
274// ---------------------------------------------------------------------------
275
276/**
277 * Pure read — returns whether a deploy to (repo, envName, ref) needs approval.
278 *
279 * { required: true, env } → caller should create the deployment row with
280 * status="pending_approval" (or "blocked" if
281 * env?.blockedReason would apply — that's the
282 * caller's call; we only flag required=true).
283 * { required: false, env } → caller may proceed to status="pending".
284 *
285 * Branch-glob enforcement: if the env's `allowedBranches` is non-empty and the
286 * ref does not match any pattern, we still return `required: true` so the
287 * caller knows to block. The caller can read `allowedBranchesOf(env)` to set
288 * `blockedReason: "branch not allowed for environment"` on the deployment row.
289 */
290export async function requiresApprovalFor(
291 repositoryId: string,
292 envName: string,
293 ref: string
294): Promise<{ required: boolean; env: Environment | null }> {
295 const env = await getEnvironmentByName(repositoryId, envName);
296 if (!env) return { required: false, env: null };
297
298 const allowed = allowedBranchesOf(env);
299 if (allowed.length > 0 && !matchesAny(ref, allowed)) {
300 return { required: true, env };
301 }
302 if (env.requireApproval) return { required: true, env };
303 return { required: false, env };
304}
Modifiedsrc/lib/notify.ts+1−0View fileUnifiedSplit
2929 | "security_alert"
3030 | "deploy_success"
3131 | "deploy_failed"
32 | "deployment_approval"
3233 | "release_published"
3334 | "repo_archived";
3435
Modifiedsrc/lib/packages.ts+4−5View fileUnifiedSplit
107107 // Strip a "git+" prefix ("git+https://..."").
108108 if (url.startsWith("git+")) url = url.slice(4);
109109
110 // Shorthand "github:owner/repo" — we accept any "host:owner/repo" form and
111 // treat the bit after ':' as "owner/repo" if it contains a slash.
112 if (/^[a-z0-9_-]+:/i.test(url) && !url.startsWith("http")) {
113 const colon = url.indexOf(":");
110 // SCP-style: "user@host:owner/repo.git" — everything after the colon is
111 // the path we want. Accept either "git@host:a/b" or just "host:a/b".
112 if (!url.includes("://") && url.includes(":") && url.includes("/")) {
113 const colon = url.lastIndexOf(":");
114114 const tail = url.slice(colon + 1);
115 // SCP-style git@host:owner/repo.git
116115 if (tail.includes("/")) {
117116 return splitOwnerRepo(tail);
118117 }
Addedsrc/lib/pages.ts+183−0View fileUnifiedSplit
1/**
2 * Block C3 — Pages / static hosting helpers.
3 *
4 * Exposes:
5 * - onPagesPush() — called from post-receive after a gh-pages push
6 * - resolvePagesPath() — URL-rest -> list of blob paths to probe
7 * - contentTypeFor() — extension -> mime string
8 *
9 * Deployment model: every accepted push to the configured source branch
10 * (default "gh-pages") records a row in pages_deployments. Serving reads the
11 * most recent deployment's commit sha and pulls blobs directly out of the
12 * bare repo — there is no on-disk export step.
13 */
14
15import { db } from "../db";
16import { pagesDeployments } from "../db/schema";
17
18/**
19 * Minimal extension -> MIME lookup used by the pages server. Returns
20 * "application/octet-stream" for anything not in the map so the browser
21 * will at least offer the bytes as a download instead of mis-rendering.
22 */
23export function contentTypeFor(filename: string): string {
24 const lower = filename.toLowerCase();
25 const dot = lower.lastIndexOf(".");
26 if (dot < 0) return "application/octet-stream";
27 const ext = lower.slice(dot + 1);
28 switch (ext) {
29 case "html":
30 case "htm":
31 return "text/html; charset=utf-8";
32 case "css":
33 return "text/css; charset=utf-8";
34 case "js":
35 case "mjs":
36 return "application/javascript; charset=utf-8";
37 case "json":
38 return "application/json; charset=utf-8";
39 case "svg":
40 return "image/svg+xml";
41 case "png":
42 return "image/png";
43 case "jpg":
44 case "jpeg":
45 return "image/jpeg";
46 case "gif":
47 return "image/gif";
48 case "webp":
49 return "image/webp";
50 case "ico":
51 return "image/x-icon";
52 case "txt":
53 case "md":
54 return "text/plain; charset=utf-8";
55 case "pdf":
56 return "application/pdf";
57 case "xml":
58 return "application/xml; charset=utf-8";
59 case "wasm":
60 return "application/wasm";
61 case "woff":
62 return "font/woff";
63 case "woff2":
64 return "font/woff2";
65 case "ttf":
66 return "font/ttf";
67 case "otf":
68 return "font/otf";
69 case "map":
70 return "application/json; charset=utf-8";
71 default:
72 return "application/octet-stream";
73 }
74}
75
76/**
77 * Normalise a source-dir setting to a plain prefix with no leading/trailing
78 * slashes (empty string for root).
79 */
80function normaliseSourceDir(sourceDir: string): string {
81 let d = (sourceDir || "/").trim();
82 d = d.replace(/^\/+/, "").replace(/\/+$/, "");
83 return d;
84}
85
86/**
87 * Normalise a URL-rest component. Keeps internal slashes but drops leading
88 * ones and any `..` path-traversal attempts. Returns "" for empty / pure "/".
89 */
90function normaliseUrlRest(urlRest: string): string {
91 let r = (urlRest || "").trim();
92 r = r.replace(/^\/+/, "");
93 // Strip ../ segments — cheap sanity, not a full path resolver.
94 const parts = r
95 .split("/")
96 .filter((p) => p.length > 0 && p !== "." && p !== "..");
97 return parts.join("/");
98}
99
100/**
101 * Given a URL rest-path (e.g. "", "about", "blog/first/", "assets/x.png"),
102 * return the ordered list of repo paths to try in the pages blob store.
103 * The first existing blob wins.
104 *
105 * "" -> ["index.html"]
106 * "about" -> ["about.html", "about/index.html"]
107 * "about/" -> ["about/index.html"]
108 * "a/b.css" -> ["a/b.css"]
109 * sourceDir="docs" prefixes every entry with "docs/".
110 */
111export function resolvePagesPath(
112 urlRest: string,
113 sourceDir: string,
114 indexHtml = "index.html"
115): string[] {
116 const prefix = normaliseSourceDir(sourceDir);
117 const rest = normaliseUrlRest(urlRest);
118 const endsWithSlash = /\/$/.test(urlRest || "") || urlRest === "";
119
120 const join = (p: string) => (prefix ? `${prefix}/${p}` : p);
121
122 // Root / directory-style URL -> serve the index.
123 if (rest === "") {
124 return [join(indexHtml)];
125 }
126
127 // Trailing slash or explicit dir -> only try the index inside it.
128 if (endsWithSlash) {
129 return [join(`${rest}/${indexHtml}`)];
130 }
131
132 // Has a file extension -> serve exactly that path.
133 const base = rest.split("/").pop() || "";
134 if (base.includes(".")) {
135 return [join(rest)];
136 }
137
138 // Extensionless -> pretty URL. Try foo.html first, then foo/index.html.
139 return [join(`${rest}.html`), join(`${rest}/${indexHtml}`)];
140}
141
142/**
143 * Record a pages deployment. Never throws — post-receive calls this and must
144 * not have its primary push path broken by pages bookkeeping.
145 */
146export async function onPagesPush(opts: {
147 ownerLogin: string;
148 repoName: string;
149 repositoryId: string;
150 ref: string;
151 newSha: string;
152 triggeredByUserId: string | null;
153}): Promise<void> {
154 try {
155 await db.insert(pagesDeployments).values({
156 repositoryId: opts.repositoryId,
157 ref: opts.ref,
158 commitSha: opts.newSha,
159 status: "success",
160 triggeredBy: opts.triggeredByUserId,
161 });
162 console.log(
163 `[pages] deployed ${opts.ownerLogin}/${opts.repoName} ${opts.ref}@${opts.newSha.slice(0, 7)}`
164 );
165 } catch (err) {
166 console.error(
167 `[pages] failed to record deployment for ${opts.ownerLogin}/${opts.repoName}:`,
168 err
169 );
170 // Try to record a failure row so the settings UI can surface it.
171 try {
172 await db.insert(pagesDeployments).values({
173 repositoryId: opts.repositoryId,
174 ref: opts.ref,
175 commitSha: opts.newSha,
176 status: "failed",
177 triggeredBy: opts.triggeredByUserId,
178 });
179 } catch {
180 /* swallow */
181 }
182 }
183}
Modifiedsrc/middleware/auth.ts+57−1View fileUnifiedSplit
1111import { getCookie } from "hono/cookie";
1212import { eq, gt } from "drizzle-orm";
1313import { db } from "../db";
14import { sessions, users, oauthAccessTokens } from "../db/schema";
14import { sessions, users, oauthAccessTokens, apiTokens } from "../db/schema";
1515import type { User } from "../db/schema";
1616import { sessionCache } from "../lib/cache";
1717import { sha256Hex } from "../lib/oauth";
6161 }
6262}
6363
64/**
65 * C2: Personal access tokens (`glc_` prefix) for CLI clients like `npm publish`.
66 * PAT storage lives in `api_tokens`; token body is stored as SHA-256 hex.
67 * Scopes are comma-separated in the row; return as array so callers can check.
68 */
69async function loadUserFromPat(
70 token: string
71): Promise<{ user: User; scopes: string[] } | null> {
72 if (!token.startsWith("glc_")) return null;
73 try {
74 const hash = await sha256Hex(token);
75 const [row] = await db
76 .select()
77 .from(apiTokens)
78 .where(eq(apiTokens.tokenHash, hash))
79 .limit(1);
80 if (!row) return null;
81 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
82 const [user] = await db
83 .select()
84 .from(users)
85 .where(eq(users.id, row.userId))
86 .limit(1);
87 if (!user) return null;
88 db
89 .update(apiTokens)
90 .set({ lastUsedAt: new Date() })
91 .where(eq(apiTokens.id, row.id))
92 .catch(() => {});
93 return {
94 user,
95 scopes: row.scopes ? row.scopes.split(/[,\s]+/).filter(Boolean) : [],
96 };
97 } catch {
98 return null;
99 }
100}
101
64102/**
65103 * Soft auth — sets c.get("user") to the current user or null.
66104 * Does NOT block unauthenticated requests.
79117 c.set("oauthAppId", result.appId);
80118 return next();
81119 }
120 } else if (bearer.startsWith("glc_")) {
121 // C2: Personal access tokens for CLI clients (npm, git HTTP, etc.).
122 const result = await loadUserFromPat(bearer);
123 if (result) {
124 c.set("user", result.user);
125 c.set("oauthScopes", result.scopes);
126 return next();
127 }
82128 }
83129 }
84130
148194 // redirecting to /login (API clients don't follow HTML redirects).
149195 return c.json({ error: "Invalid or expired token" }, 401);
150196 }
197 if (bearer.startsWith("glc_")) {
198 // C2: Personal access tokens for CLI clients.
199 const result = await loadUserFromPat(bearer);
200 if (result) {
201 c.set("user", result.user);
202 c.set("oauthScopes", result.scopes);
203 return next();
204 }
205 return c.json({ error: "Invalid or expired token" }, 401);
206 }
151207 }
152208
153209 const token = getCookie(c, "session");
Addedsrc/routes/environments.tsx+597−0View fileUnifiedSplit
1/**
2 * Environments settings + approval routes (Block C4).
3 *
4 * GET /:owner/:repo/settings/environments list + create form (owner-only)
5 * POST /:owner/:repo/settings/environments create
6 * POST /:owner/:repo/settings/environments/:envId update
7 * POST /:owner/:repo/settings/environments/:envId/delete
8 *
9 * POST /:owner/:repo/deployments/:deploymentId/approve approve a pending deploy
10 * POST /:owner/:repo/deployments/:deploymentId/reject reject a pending deploy
11 *
12 * Approve/reject live under /deployments/:id/... so they don't collide with
13 * the existing `GET /:owner/:repo/deployments/:id` detail page.
14 */
15
16import { Hono } from "hono";
17import type { Context } from "hono";
18import { and, eq, inArray } from "drizzle-orm";
19import { db } from "../db";
20import {
21 environments,
22 deployments,
23 repositories,
24 users,
25} from "../db/schema";
26import type { Environment } from "../db/schema";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { Layout } from "../views/layout";
30import { RepoHeader, RepoNav } from "../views/components";
31import { getUnreadCount } from "../lib/unread";
32import { audit, notify } from "../lib/notify";
33import {
34 allowedBranchesOf,
35 computeApprovalState,
36 getEnvironmentById,
37 getEnvironmentByName,
38 isReviewer,
39 listEnvironments,
40 recordApproval,
41 reviewerIdsOf,
42} from "../lib/environments";
43
44const r = new Hono<AuthEnv>();
45r.use("*", softAuth);
46
47// ---------------------------------------------------------------------------
48// helpers
49// ---------------------------------------------------------------------------
50
51async function loadRepo(owner: string, repo: string) {
52 try {
53 const [row] = await db
54 .select({
55 id: repositories.id,
56 name: repositories.name,
57 defaultBranch: repositories.defaultBranch,
58 ownerId: repositories.ownerId,
59 starCount: repositories.starCount,
60 forkCount: repositories.forkCount,
61 })
62 .from(repositories)
63 .innerJoin(users, eq(repositories.ownerId, users.id))
64 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
65 .limit(1);
66 return row || null;
67 } catch (err) {
68 console.error("[environments] loadRepo failed:", err);
69 return null;
70 }
71}
72
73function splitCsv(raw: unknown): string[] {
74 if (typeof raw !== "string") return [];
75 return raw
76 .split(",")
77 .map((s) => s.trim())
78 .filter(Boolean);
79}
80
81async function resolveUsernamesToIds(usernames: string[]): Promise<string[]> {
82 if (usernames.length === 0) return [];
83 try {
84 const rows = await db
85 .select({ id: users.id, username: users.username })
86 .from(users)
87 .where(inArray(users.username, usernames));
88 return rows.map((r) => r.id);
89 } catch (err) {
90 console.error("[environments] resolve usernames failed:", err);
91 return [];
92 }
93}
94
95async function idsToUsernames(ids: string[]): Promise<string[]> {
96 if (ids.length === 0) return [];
97 try {
98 const rows = await db
99 .select({ id: users.id, username: users.username })
100 .from(users)
101 .where(inArray(users.id, ids));
102 const map = new Map(rows.map((r) => [r.id, r.username]));
103 return ids.map((id) => map.get(id) || id);
104 } catch {
105 return ids;
106 }
107}
108
109// ---------------------------------------------------------------------------
110// GET /:owner/:repo/settings/environments
111// ---------------------------------------------------------------------------
112
113r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
114 const user = c.get("user")!;
115 const { owner, repo } = c.req.param();
116 const repoRow = await loadRepo(owner, repo);
117 if (!repoRow) return c.notFound();
118 if (repoRow.ownerId !== user.id) {
119 return c.redirect(`/${owner}/${repo}`);
120 }
121
122 const envs = await listEnvironments(repoRow.id);
123 const unread = await getUnreadCount(user.id);
124 const success = c.req.query("success");
125 const err = c.req.query("error");
126
127 // Resolve reviewer IDs → usernames per env for display.
128 const envUsernames: Record<string, string[]> = {};
129 for (const env of envs) {
130 envUsernames[env.id] = await idsToUsernames(reviewerIdsOf(env));
131 }
132
133 return c.html(
134 <Layout
135 title={`Environments — ${owner}/${repo}`}
136 user={user}
137 notificationCount={unread}
138 >
139 <RepoHeader
140 owner={owner}
141 repo={repo}
142 starCount={repoRow.starCount}
143 forkCount={repoRow.forkCount}
144 currentUser={user.username}
145 />
146 <RepoNav owner={owner} repo={repo} active="code" />
147 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
148 <h3>Environments</h3>
149 <a href={`/${owner}/${repo}/deployments`} class="btn btn-sm">
150 Back to deployments
151 </a>
152 </div>
153 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
154 {err && <div class="auth-error">{decodeURIComponent(err)}</div>}
155
156 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px">
157 Require human approval before a deploy to this environment runs.
158 Branch patterns restrict which refs may target the environment.
159 </p>
160
161 <div class="panel" style="margin-bottom: 24px">
162 {envs.length === 0 ? (
163 <div class="panel-empty">No environments yet.</div>
164 ) : (
165 envs.map((env) => {
166 const reviewers = envUsernames[env.id] || [];
167 const branches = allowedBranchesOf(env);
168 return (
169 <form
170 method="POST"
171 action={`/${owner}/${repo}/settings/environments/${env.id}`}
172 class="panel-item"
173 style="flex-direction: column; align-items: stretch; gap: 8px"
174 >
175 <div
176 style="display: flex; justify-content: space-between; align-items: center"
177 >
178 <strong style="font-size: 15px">{env.name}</strong>
179 <div style="display: flex; gap: 6px">
180 <button type="submit" class="btn btn-sm btn-primary">
181 Save
182 </button>
183 </div>
184 </div>
185 <div class="form-group" style="margin: 0">
186 <label style="display: flex; align-items: center; gap: 6px">
187 <input
188 type="checkbox"
189 name="requireApproval"
190 value="1"
191 checked={env.requireApproval}
192 />
193 Require approval before deploy
194 </label>
195 </div>
196 <div class="form-group" style="margin: 0">
197 <label>Reviewers (comma-separated usernames)</label>
198 <input
199 type="text"
200 name="reviewers"
201 value={reviewers.join(", ")}
202 placeholder="alice, bob"
203 />
204 </div>
205 <div class="form-group" style="margin: 0">
206 <label>Wait timer (minutes)</label>
207 <input
208 type="number"
209 name="waitTimerMinutes"
210 min="0"
211 max="1440"
212 value={String(env.waitTimerMinutes)}
213 style="width: 120px"
214 />
215 </div>
216 <div class="form-group" style="margin: 0">
217 <label>Allowed branches (comma-separated glob patterns)</label>
218 <input
219 type="text"
220 name="allowedBranches"
221 value={branches.join(", ")}
222 placeholder="main, release/*"
223 />
224 </div>
225 <div style="display: flex; justify-content: flex-end">
226 <button
227 type="submit"
228 formaction={`/${owner}/${repo}/settings/environments/${env.id}/delete`}
229 class="btn btn-sm btn-danger"
230 onclick="return confirm('Delete this environment?')"
231 >
232 Delete
233 </button>
234 </div>
235 </form>
236 );
237 })
238 )}
239 </div>
240
241 <h3 style="margin-top: 24px; margin-bottom: 12px">New environment</h3>
242 <form
243 method="POST"
244 action={`/${owner}/${repo}/settings/environments`}
245 class="panel"
246 style="padding: 16px"
247 >
248 <div class="form-group">
249 <label>Name</label>
250 <input
251 type="text"
252 name="name"
253 required
254 placeholder="production"
255 />
256 </div>
257 <div class="form-group">
258 <label style="display: flex; align-items: center; gap: 6px">
259 <input
260 type="checkbox"
261 name="requireApproval"
262 value="1"
263 checked
264 />
265 Require approval
266 </label>
267 </div>
268 <div class="form-group">
269 <label>Reviewers (comma-separated usernames)</label>
270 <input type="text" name="reviewers" placeholder="alice, bob" />
271 </div>
272 <div class="form-group">
273 <label>Wait timer (minutes)</label>
274 <input
275 type="number"
276 name="waitTimerMinutes"
277 min="0"
278 max="1440"
279 value="0"
280 style="width: 120px"
281 />
282 </div>
283 <div class="form-group">
284 <label>Allowed branches (comma-separated glob patterns)</label>
285 <input
286 type="text"
287 name="allowedBranches"
288 placeholder="main, release/*"
289 />
290 </div>
291 <button type="submit" class="btn btn-primary">
292 Create environment
293 </button>
294 </form>
295 </Layout>
296 );
297});
298
299// ---------------------------------------------------------------------------
300// POST /:owner/:repo/settings/environments (create)
301// ---------------------------------------------------------------------------
302
303r.post("/:owner/:repo/settings/environments", requireAuth, async (c) => {
304 const user = c.get("user")!;
305 const { owner, repo } = c.req.param();
306 const repoRow = await loadRepo(owner, repo);
307 if (!repoRow) return c.notFound();
308 if (repoRow.ownerId !== user.id) {
309 return c.redirect(`/${owner}/${repo}`);
310 }
311
312 const body = await c.req.parseBody();
313 const name = String(body.name || "").trim();
314 if (!name) {
315 return c.redirect(
316 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
317 "Name required"
318 )}`
319 );
320 }
321 const requireApproval = body.requireApproval === "1" || body.requireApproval === "on";
322 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
323 const waitTimerMinutes = Math.max(
324 0,
325 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
326 );
327 const allowedBranches = splitCsv(body.allowedBranches);
328
329 try {
330 await db.insert(environments).values({
331 repositoryId: repoRow.id,
332 name,
333 requireApproval,
334 reviewers: JSON.stringify(reviewers),
335 waitTimerMinutes,
336 allowedBranches: JSON.stringify(allowedBranches),
337 });
338 } catch (err) {
339 console.error("[environments] create failed:", err);
340 return c.redirect(
341 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
342 "Could not create (duplicate name?)"
343 )}`
344 );
345 }
346
347 await audit({
348 userId: user.id,
349 repositoryId: repoRow.id,
350 action: "environment.create",
351 targetType: "environment",
352 metadata: { name, requireApproval, reviewers, allowedBranches },
353 });
354
355 return c.redirect(
356 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
357 "Environment created"
358 )}`
359 );
360});
361
362// ---------------------------------------------------------------------------
363// POST /:owner/:repo/settings/environments/:envId (update)
364// ---------------------------------------------------------------------------
365
366r.post("/:owner/:repo/settings/environments/:envId", requireAuth, async (c) => {
367 const user = c.get("user")!;
368 const { owner, repo, envId } = c.req.param();
369 const repoRow = await loadRepo(owner, repo);
370 if (!repoRow) return c.notFound();
371 if (repoRow.ownerId !== user.id) {
372 return c.redirect(`/${owner}/${repo}`);
373 }
374
375 const env = await getEnvironmentById(repoRow.id, envId);
376 if (!env) return c.notFound();
377
378 const body = await c.req.parseBody();
379 const requireApproval =
380 body.requireApproval === "1" || body.requireApproval === "on";
381 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
382 const waitTimerMinutes = Math.max(
383 0,
384 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
385 );
386 const allowedBranches = splitCsv(body.allowedBranches);
387
388 try {
389 await db
390 .update(environments)
391 .set({
392 requireApproval,
393 reviewers: JSON.stringify(reviewers),
394 waitTimerMinutes,
395 allowedBranches: JSON.stringify(allowedBranches),
396 updatedAt: new Date(),
397 })
398 .where(
399 and(
400 eq(environments.id, envId),
401 eq(environments.repositoryId, repoRow.id)
402 )
403 );
404 } catch (err) {
405 console.error("[environments] update failed:", err);
406 }
407
408 await audit({
409 userId: user.id,
410 repositoryId: repoRow.id,
411 action: "environment.update",
412 targetType: "environment",
413 targetId: envId,
414 metadata: { requireApproval, reviewers, allowedBranches, waitTimerMinutes },
415 });
416
417 return c.redirect(
418 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
419 "Environment updated"
420 )}`
421 );
422});
423
424// ---------------------------------------------------------------------------
425// POST /:owner/:repo/settings/environments/:envId/delete
426// ---------------------------------------------------------------------------
427
428r.post(
429 "/:owner/:repo/settings/environments/:envId/delete",
430 requireAuth,
431 async (c) => {
432 const user = c.get("user")!;
433 const { owner, repo, envId } = c.req.param();
434 const repoRow = await loadRepo(owner, repo);
435 if (!repoRow) return c.notFound();
436 if (repoRow.ownerId !== user.id) {
437 return c.redirect(`/${owner}/${repo}`);
438 }
439
440 try {
441 await db
442 .delete(environments)
443 .where(
444 and(
445 eq(environments.id, envId),
446 eq(environments.repositoryId, repoRow.id)
447 )
448 );
449 } catch (err) {
450 console.error("[environments] delete failed:", err);
451 }
452
453 await audit({
454 userId: user.id,
455 repositoryId: repoRow.id,
456 action: "environment.delete",
457 targetType: "environment",
458 targetId: envId,
459 });
460
461 return c.redirect(
462 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
463 "Environment removed"
464 )}`
465 );
466 }
467);
468
469// ---------------------------------------------------------------------------
470// Approve/reject a pending deployment
471// ---------------------------------------------------------------------------
472
473async function loadDeployment(repositoryId: string, deploymentId: string) {
474 try {
475 const [row] = await db
476 .select()
477 .from(deployments)
478 .where(
479 and(
480 eq(deployments.id, deploymentId),
481 eq(deployments.repositoryId, repositoryId)
482 )
483 )
484 .limit(1);
485 return row || null;
486 } catch (err) {
487 console.error("[environments] loadDeployment failed:", err);
488 return null;
489 }
490}
491
492async function decide(
493 c: Context<AuthEnv>,
494 decision: "approved" | "rejected"
495) {
496 const user = c.get("user")!;
497 const { owner, repo, deploymentId } = c.req.param();
498 const repoRow = await loadRepo(owner, repo);
499 if (!repoRow) return c.notFound();
500
501 const deployment = await loadDeployment(repoRow.id, deploymentId);
502 if (!deployment) return c.notFound();
503
504 const envName = deployment.environment;
505 const env = await getEnvironmentByName(repoRow.id, envName);
506 if (!env) {
507 // No env configured — nothing to approve. Treat as 404 for safety.
508 return c.notFound();
509 }
510
511 const allowed = await isReviewer(env, user.id);
512 if (!allowed) {
513 return c.redirect(
514 `/${owner}/${repo}/deployments/${deploymentId}?error=${encodeURIComponent(
515 "Not a reviewer"
516 )}`
517 );
518 }
519
520 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
521 const comment = typeof body.comment === "string" ? body.comment : undefined;
522
523 const inserted = await recordApproval({
524 deploymentId,
525 userId: user.id,
526 decision,
527 comment,
528 });
529
530 // Re-read state and flip the deployment row accordingly.
531 const state = await computeApprovalState(deploymentId, env);
532 let newStatus: string | null = null;
533 if (state.rejected) {
534 newStatus = "rejected";
535 } else if (state.approved && deployment.status === "pending_approval") {
536 newStatus = "pending"; // hand off to existing deployer
537 }
538
539 if (newStatus) {
540 try {
541 await db
542 .update(deployments)
543 .set({
544 status: newStatus,
545 blockedReason: newStatus === "rejected" ? "rejected by reviewer" : null,
546 })
547 .where(eq(deployments.id, deploymentId));
548 } catch (err) {
549 console.error("[environments] deployment status flip failed:", err);
550 }
551 }
552
553 await audit({
554 userId: user.id,
555 repositoryId: repoRow.id,
556 action: decision === "approved" ? "deployment.approve" : "deployment.reject",
557 targetType: "deployment",
558 targetId: deploymentId,
559 metadata: { recorded: !!inserted, newStatus },
560 });
561
562 if (deployment.triggeredBy && deployment.triggeredBy !== user.id) {
563 try {
564 await notify(deployment.triggeredBy, {
565 kind: "deployment_approval",
566 title:
567 decision === "approved"
568 ? `Deploy to ${envName} approved`
569 : `Deploy to ${envName} rejected`,
570 body:
571 decision === "approved"
572 ? `${user.username} approved the deploy of ${deployment.commitSha.slice(0, 7)}.`
573 : `${user.username} rejected the deploy of ${deployment.commitSha.slice(0, 7)}.`,
574 url: `/${owner}/${repo}/deployments/${deploymentId}`,
575 repositoryId: repoRow.id,
576 });
577 } catch (err) {
578 console.error("[environments] notify triggeredBy failed:", err);
579 }
580 }
581
582 return c.redirect(`/${owner}/${repo}/deployments/${deploymentId}`);
583}
584
585r.post(
586 "/:owner/:repo/deployments/:deploymentId/approve",
587 requireAuth,
588 async (c) => decide(c, "approved")
589);
590
591r.post(
592 "/:owner/:repo/deployments/:deploymentId/reject",
593 requireAuth,
594 async (c) => decide(c, "rejected")
595);
596
597export default r;
Addedsrc/routes/packages-api.ts+581−0View fileUnifiedSplit
1/**
2 * npm-compatible package registry HTTP endpoints (Block C2).
3 *
4 * Two surfaces on one sub-app:
5 * 1) /api/packages/... — JSON helpers the UI uses
6 * 2) /npm/... — the actual npm client protocol
7 *
8 * The npm client URL-encodes scoped names like `@acme/foo` as
9 * `@acme%2Ffoo`, and also may send them un-encoded. Both work because we
10 * parse the full tail of the path ourselves instead of relying on Hono's
11 * parameter extraction (which struggles with `@` and `/` in names).
12 */
13
14import { Hono } from "hono";
15import { and, desc, eq } from "drizzle-orm";
16import { db } from "../db";
17import {
18 packages,
19 packageVersions,
20 packageTags,
21 repositories,
22 users,
23} from "../db/schema";
24import type { Package, PackageVersion } from "../db/schema";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { audit } from "../lib/notify";
28import {
29 parsePackageName,
30 computeShasum,
31 computeIntegrity,
32 buildPackument,
33 resolveRepoFromPackageJson,
34 tarballFilename,
35} from "../lib/packages";
36
37const api = new Hono<AuthEnv>();
38api.use("*", softAuth);
39
40// ---------------------------------------------------------------------------
41// Helpers
42// ---------------------------------------------------------------------------
43
44type NpmPublishBody = {
45 name?: string;
46 "dist-tags"?: Record<string, string>;
47 versions?: Record<string, Record<string, unknown>>;
48 _attachments?: Record<
49 string,
50 { content_type?: string; data: string; length?: number }
51 >;
52};
53
54/**
55 * Extract the package name and optional trailing segment from a path like
56 * /npm/@scope/foo
57 * /npm/@scope%2Ffoo
58 * /npm/foo
59 * /npm/@scope/foo/-/foo-1.0.0.tgz
60 * /npm/foo/-/foo-1.0.0.tgz
61 * /npm/foo/-rev/42
62 */
63function parseNpmPath(path: string): {
64 nameRaw: string;
65 tail: string | null;
66 revTail: string | null;
67} {
68 const rest = path.replace(/^\/+/, "").replace(/^npm\/+/, "");
69
70 // Split on "/-/" (tarball endpoint) first.
71 const dashIdx = rest.indexOf("/-/");
72 if (dashIdx !== -1) {
73 return {
74 nameRaw: rest.slice(0, dashIdx),
75 tail: rest.slice(dashIdx + 3),
76 revTail: null,
77 };
78 }
79 // "-rev" style (npm unpublish).
80 const revIdx = rest.indexOf("/-rev/");
81 if (revIdx !== -1) {
82 return {
83 nameRaw: rest.slice(0, revIdx),
84 tail: null,
85 revTail: rest.slice(revIdx + 6),
86 };
87 }
88 return { nameRaw: rest, tail: null, revTail: null };
89}
90
91function baseUrlFrom(req: Request): string {
92 try {
93 const u = new URL(req.url);
94 return `${u.protocol}//${u.host}`;
95 } catch {
96 return "";
97 }
98}
99
100async function loadRepo(owner: string, repo: string) {
101 const [row] = await db
102 .select({
103 id: repositories.id,
104 name: repositories.name,
105 ownerId: repositories.ownerId,
106 visibility: repositories.visibility,
107 })
108 .from(repositories)
109 .innerJoin(users, eq(repositories.ownerId, users.id))
110 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
111 .limit(1);
112 return row || null;
113}
114
115async function loadPackage(
116 repoId: string,
117 scope: string | null,
118 name: string
119): Promise<Package | null> {
120 const conds = [
121 eq(packages.repositoryId, repoId),
122 eq(packages.ecosystem, "npm"),
123 eq(packages.name, name),
124 ];
125 const rows = await db
126 .select()
127 .from(packages)
128 .where(and(...conds))
129 .limit(20);
130 // Manual scope equality because drizzle's eq + null is awkward.
131 const match = rows.find((p) => (p.scope ?? null) === (scope ?? null));
132 return match || null;
133}
134
135async function loadPackageByName(
136 scope: string | null,
137 name: string
138): Promise<Package | null> {
139 const rows = await db
140 .select()
141 .from(packages)
142 .where(and(eq(packages.ecosystem, "npm"), eq(packages.name, name)))
143 .limit(50);
144 const match = rows.find((p) => (p.scope ?? null) === (scope ?? null));
145 return match || null;
146}
147
148// ---------------------------------------------------------------------------
149// UI-facing JSON helpers
150// ---------------------------------------------------------------------------
151
152api.get("/api/packages/:owner/:repo", async (c) => {
153 const { owner, repo } = c.req.param();
154 try {
155 const repoRow = await loadRepo(owner, repo);
156 if (!repoRow) return c.json({ error: "repo not found" }, 404);
157 const rows = await db
158 .select()
159 .from(packages)
160 .where(
161 and(
162 eq(packages.repositoryId, repoRow.id),
163 eq(packages.ecosystem, "npm")
164 )
165 )
166 .orderBy(desc(packages.updatedAt));
167 return c.json({ packages: rows });
168 } catch (err) {
169 console.error("[packages] list:", err);
170 return c.json({ error: "service unavailable" }, 503);
171 }
172});
173
174api.get("/api/packages/:owner/:repo/:pkgName{.+}", async (c) => {
175 const { owner, repo, pkgName } = c.req.param();
176 const parsed = parsePackageName(pkgName);
177 if (!parsed) return c.json({ error: "invalid package name" }, 400);
178 try {
179 const repoRow = await loadRepo(owner, repo);
180 if (!repoRow) return c.json({ error: "repo not found" }, 404);
181 const pkg = await loadPackage(repoRow.id, parsed.scope, parsed.name);
182 if (!pkg) return c.json({ error: "package not found" }, 404);
183 const versions = await db
184 .select()
185 .from(packageVersions)
186 .where(eq(packageVersions.packageId, pkg.id))
187 .orderBy(desc(packageVersions.publishedAt));
188 const tags = await db
189 .select()
190 .from(packageTags)
191 .where(eq(packageTags.packageId, pkg.id));
192 return c.json({ package: pkg, versions, tags });
193 } catch (err) {
194 console.error("[packages] detail:", err);
195 return c.json({ error: "service unavailable" }, 503);
196 }
197});
198
199// Yank endpoint (owner-only; marks a version as yanked but leaves it
200// downloadable so existing installs don't break — matches npm semantics).
201api.post(
202 "/api/packages/:owner/:repo/:pkgName/:version/yank",
203 requireAuth,
204 async (c) => {
205 const user = c.get("user")!;
206 const { owner, repo, pkgName, version } = c.req.param();
207 const parsed = parsePackageName(pkgName);
208 if (!parsed) return c.json({ error: "invalid package name" }, 400);
209 try {
210 const repoRow = await loadRepo(owner, repo);
211 if (!repoRow) return c.json({ error: "repo not found" }, 404);
212 if (repoRow.ownerId !== user.id) {
213 return c.json({ error: "forbidden" }, 403);
214 }
215 const pkg = await loadPackage(repoRow.id, parsed.scope, parsed.name);
216 if (!pkg) return c.json({ error: "package not found" }, 404);
217
218 await db
219 .update(packageVersions)
220 .set({ yanked: true, yankedReason: "yanked by owner" })
221 .where(
222 and(
223 eq(packageVersions.packageId, pkg.id),
224 eq(packageVersions.version, version)
225 )
226 );
227
228 await audit({
229 userId: user.id,
230 repositoryId: repoRow.id,
231 action: "package.yank",
232 targetType: "package_version",
233 targetId: pkg.id,
234 metadata: { version, name: parsed.full },
235 });
236
237 return c.json({ ok: true });
238 } catch (err) {
239 console.error("[packages] yank:", err);
240 return c.json({ error: "service unavailable" }, 503);
241 }
242 }
243);
244
245// ---------------------------------------------------------------------------
246// npm protocol: packument + tarball
247// ---------------------------------------------------------------------------
248
249api.get("/npm/*", async (c) => {
250 const { nameRaw, tail } = parseNpmPath(c.req.path);
251 if (!nameRaw) return c.json({ error: "not found" }, 404);
252
253 const parsed = parsePackageName(nameRaw);
254 if (!parsed) return c.json({ error: "invalid package name" }, 400);
255
256 try {
257 const pkg = await loadPackageByName(parsed.scope, parsed.name);
258 if (!pkg) return c.json({ error: "not found" }, 404);
259
260 // Tarball request?
261 if (tail) {
262 const filename = decodeURIComponent(tail);
263 const versions = await db
264 .select()
265 .from(packageVersions)
266 .where(eq(packageVersions.packageId, pkg.id));
267
268 // Match by filename → version. Filename is `<name>-<version>.tgz`.
269 const match = versions.find(
270 (v) => tarballFilename(parsed, v.version) === filename
271 );
272 if (!match || !match.tarball) {
273 return c.json({ error: "tarball not found" }, 404);
274 }
275 const bytes = Buffer.from(match.tarball, "base64");
276 return new Response(bytes, {
277 status: 200,
278 headers: {
279 "Content-Type": "application/octet-stream",
280 "Content-Length": String(bytes.length),
281 "Cache-Control": "public, max-age=31536000, immutable",
282 },
283 });
284 }
285
286 // Packument request.
287 const versions = await db
288 .select()
289 .from(packageVersions)
290 .where(eq(packageVersions.packageId, pkg.id))
291 .orderBy(desc(packageVersions.publishedAt));
292 const tags = await db
293 .select()
294 .from(packageTags)
295 .where(eq(packageTags.packageId, pkg.id));
296
297 const doc = buildPackument(pkg, versions, tags, baseUrlFrom(c.req.raw));
298 return c.json(doc);
299 } catch (err) {
300 console.error("[packages] npm get:", err);
301 return c.json({ error: "service unavailable" }, 503);
302 }
303});
304
305// ---------------------------------------------------------------------------
306// npm protocol: publish (`npm publish` → PUT /<name>)
307// ---------------------------------------------------------------------------
308
309api.put("/npm/*", requireAuth, async (c) => {
310 const user = c.get("user")!;
311 const { nameRaw } = parseNpmPath(c.req.path);
312 const parsed = parsePackageName(nameRaw);
313 if (!parsed) {
314 return c.json({ error: "invalid package name" }, 400);
315 }
316
317 let body: NpmPublishBody;
318 try {
319 body = await c.req.json<NpmPublishBody>();
320 } catch {
321 return c.json({ error: "invalid JSON body" }, 400);
322 }
323
324 const versionsObj = body.versions || {};
325 const versionKeys = Object.keys(versionsObj);
326 if (versionKeys.length === 0) {
327 return c.json({ error: "no version in payload" }, 400);
328 }
329 // npm always sends exactly one version per publish.
330 const version = versionKeys[0];
331 const versionMeta = versionsObj[version] || {};
332
333 const attachments = body._attachments || {};
334 const attachKeys = Object.keys(attachments);
335 if (attachKeys.length === 0) {
336 return c.json({ error: "no tarball attachment" }, 400);
337 }
338 const attachment = attachments[attachKeys[0]];
339 if (!attachment || !attachment.data) {
340 return c.json({ error: "empty tarball attachment" }, 400);
341 }
342
343 const tarballBytes = Buffer.from(attachment.data, "base64");
344 if (tarballBytes.length === 0) {
345 return c.json({ error: "tarball decoded to zero bytes" }, 400);
346 }
347
348 // Resolve owner+repo from the metadata's repository.url.
349 const repoRef = resolveRepoFromPackageJson(versionMeta);
350 if (!repoRef) {
351 return c.json(
352 {
353 error:
354 "repository.url must point to a gluecron repo you own (e.g. http://host/:owner/:repo.git)",
355 },
356 400
357 );
358 }
359
360 try {
361 const repoRow = await loadRepo(repoRef.owner, repoRef.repo);
362 if (!repoRow) {
363 return c.json(
364 { error: `repo ${repoRef.owner}/${repoRef.repo} not found` },
365 404
366 );
367 }
368 if (repoRow.ownerId !== user.id) {
369 return c.json(
370 { error: "you do not own the repository named in repository.url" },
371 403
372 );
373 }
374
375 // Upsert the package row.
376 let pkg = await loadPackage(repoRow.id, parsed.scope, parsed.name);
377 if (!pkg) {
378 const description =
379 typeof versionMeta.description === "string"
380 ? (versionMeta.description as string)
381 : null;
382 const homepage =
383 typeof versionMeta.homepage === "string"
384 ? (versionMeta.homepage as string)
385 : null;
386 const license =
387 typeof versionMeta.license === "string"
388 ? (versionMeta.license as string)
389 : null;
390 const readme =
391 typeof (body as Record<string, unknown>).readme === "string"
392 ? ((body as Record<string, unknown>).readme as string)
393 : typeof versionMeta.readme === "string"
394 ? (versionMeta.readme as string)
395 : null;
396
397 const [inserted] = await db
398 .insert(packages)
399 .values({
400 repositoryId: repoRow.id,
401 ecosystem: "npm",
402 scope: parsed.scope,
403 name: parsed.name,
404 description,
405 readme,
406 homepage,
407 license,
408 visibility: repoRow.visibility === "private" ? "private" : "public",
409 })
410 .returning();
411 pkg = inserted;
412 }
413 if (!pkg) {
414 return c.json({ error: "failed to create package" }, 503);
415 }
416
417 // Reject duplicate version.
418 const [existing] = await db
419 .select()
420 .from(packageVersions)
421 .where(
422 and(
423 eq(packageVersions.packageId, pkg.id),
424 eq(packageVersions.version, version)
425 )
426 )
427 .limit(1);
428 if (existing) {
429 return c.json(
430 {
431 error: `You cannot publish over the previously published version ${version}.`,
432 },
433 409
434 );
435 }
436
437 const shasum = computeShasum(tarballBytes);
438 const integrity = computeIntegrity(tarballBytes);
439
440 const [insertedVersion] = await db
441 .insert(packageVersions)
442 .values({
443 packageId: pkg.id,
444 version,
445 shasum,
446 integrity,
447 sizeBytes: tarballBytes.length,
448 metadata: JSON.stringify(versionMeta),
449 tarball: tarballBytes.toString("base64"),
450 publishedBy: user.id,
451 })
452 .returning();
453
454 // Upsert "latest" dist-tag (and any other tags from the payload).
455 const distTags = body["dist-tags"] || { latest: version };
456 for (const [tag, tagVersion] of Object.entries(distTags)) {
457 if (tagVersion !== version) continue; // Only set tags pointing at this publish.
458 const [existingTag] = await db
459 .select()
460 .from(packageTags)
461 .where(
462 and(
463 eq(packageTags.packageId, pkg.id),
464 eq(packageTags.tag, tag)
465 )
466 )
467 .limit(1);
468 if (existingTag) {
469 await db
470 .update(packageTags)
471 .set({ versionId: insertedVersion.id, updatedAt: new Date() })
472 .where(eq(packageTags.id, existingTag.id));
473 } else {
474 await db.insert(packageTags).values({
475 packageId: pkg.id,
476 tag,
477 versionId: insertedVersion.id,
478 });
479 }
480 }
481
482 // Update package bookkeeping fields on every publish (license/description
483 // may evolve version-to-version; we keep the most recent).
484 await db
485 .update(packages)
486 .set({
487 updatedAt: new Date(),
488 description:
489 typeof versionMeta.description === "string"
490 ? (versionMeta.description as string)
491 : pkg.description,
492 homepage:
493 typeof versionMeta.homepage === "string"
494 ? (versionMeta.homepage as string)
495 : pkg.homepage,
496 license:
497 typeof versionMeta.license === "string"
498 ? (versionMeta.license as string)
499 : pkg.license,
500 })
501 .where(eq(packages.id, pkg.id));
502
503 await audit({
504 userId: user.id,
505 repositoryId: repoRow.id,
506 action: "package.publish",
507 targetType: "package_version",
508 targetId: insertedVersion.id,
509 metadata: {
510 name: parsed.full,
511 version,
512 size: tarballBytes.length,
513 },
514 });
515
516 return c.json({ ok: true, id: parsed.full, version }, 201);
517 } catch (err) {
518 console.error("[packages] publish:", err);
519 return c.json({ error: "service unavailable" }, 503);
520 }
521});
522
523// npm unpublish: DELETE /npm/<name>/-rev/<rev> — we treat this as a yank.
524api.delete("/npm/*", requireAuth, async (c) => {
525 const user = c.get("user")!;
526 const { nameRaw, revTail } = parseNpmPath(c.req.path);
527 const parsed = parsePackageName(nameRaw);
528 if (!parsed) return c.json({ error: "invalid package name" }, 400);
529 if (!revTail) {
530 return c.json(
531 { error: "unpublish without rev is not supported" },
532 400
533 );
534 }
535
536 try {
537 const pkg = await loadPackageByName(parsed.scope, parsed.name);
538 if (!pkg) return c.json({ error: "not found" }, 404);
539 const [repoRow] = await db
540 .select()
541 .from(repositories)
542 .where(eq(repositories.id, pkg.repositoryId))
543 .limit(1);
544 if (!repoRow || repoRow.ownerId !== user.id) {
545 return c.json({ error: "forbidden" }, 403);
546 }
547
548 // Yank the latest version.
549 const [latest] = await db
550 .select()
551 .from(packageVersions)
552 .where(eq(packageVersions.packageId, pkg.id))
553 .orderBy(desc(packageVersions.publishedAt))
554 .limit(1);
555 if (latest) {
556 await db
557 .update(packageVersions)
558 .set({ yanked: true, yankedReason: "unpublished" })
559 .where(eq(packageVersions.id, latest.id));
560 }
561
562 await audit({
563 userId: user.id,
564 repositoryId: repoRow.id,
565 action: "package.unpublish",
566 targetType: "package",
567 targetId: pkg.id,
568 metadata: { name: parsed.full },
569 });
570
571 return c.json({ ok: true });
572 } catch (err) {
573 console.error("[packages] unpublish:", err);
574 return c.json({ error: "service unavailable" }, 503);
575 }
576});
577
578export default api;
579
580// Re-export helpers internally for the UI route.
581export type { Package, PackageVersion };
Addedsrc/routes/packages.tsx+442−0View fileUnifiedSplit
1/**
2 * Packages UI — lists packages for a repo, per-package detail (Block C2).
3 *
4 * GET /:owner/:repo/packages — list of published packages
5 * GET /:owner/:repo/packages/:pkg{.+} — detail + version list + install help
6 *
7 * Packages doesn't yet have a tab in RepoNav — we render with active="code"
8 * so the page still lays out correctly.
9 */
10
11import { Hono } from "hono";
12import { and, desc, eq } from "drizzle-orm";
13import { db } from "../db";
14import {
15 packages,
16 packageVersions,
17 packageTags,
18 repositories,
19 users,
20} from "../db/schema";
21import type { Package, PackageVersion, PackageTag } from "../db/schema";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24import { softAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { getUnreadCount } from "../lib/unread";
27import { parsePackageName } from "../lib/packages";
28
29const ui = new Hono<AuthEnv>();
30ui.use("*", softAuth);
31
32async function loadRepo(owner: string, repo: string) {
33 const [row] = await db
34 .select({
35 id: repositories.id,
36 name: repositories.name,
37 ownerId: repositories.ownerId,
38 defaultBranch: repositories.defaultBranch,
39 starCount: repositories.starCount,
40 forkCount: repositories.forkCount,
41 })
42 .from(repositories)
43 .innerJoin(users, eq(repositories.ownerId, users.id))
44 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
45 .limit(1);
46 return row || null;
47}
48
49function relTime(d: Date | string | null): string {
50 if (!d) return "";
51 const t = typeof d === "string" ? new Date(d) : d;
52 const diff = Date.now() - t.getTime();
53 const mins = Math.floor(diff / 60000);
54 if (mins < 1) return "just now";
55 if (mins < 60) return `${mins}m ago`;
56 const hrs = Math.floor(mins / 60);
57 if (hrs < 24) return `${hrs}h ago`;
58 const days = Math.floor(hrs / 24);
59 if (days < 30) return `${days}d ago`;
60 return t.toLocaleDateString();
61}
62
63function fullPkgName(pkg: { scope: string | null; name: string }): string {
64 return pkg.scope ? `${pkg.scope}/${pkg.name}` : pkg.name;
65}
66
67function humanSize(bytes: number): string {
68 if (bytes < 1024) return `${bytes} B`;
69 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
70 return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
71}
72
73// ---------------------------------------------------------------------------
74// List page
75// ---------------------------------------------------------------------------
76
77ui.get("/:owner/:repo/packages", async (c) => {
78 const user = c.get("user");
79 const { owner, repo } = c.req.param();
80 const repoRow = await loadRepo(owner, repo);
81 if (!repoRow) return c.notFound();
82
83 let rows: (Package & { latestVersion: string | null })[] = [];
84 try {
85 const pkgs = await db
86 .select()
87 .from(packages)
88 .where(
89 and(
90 eq(packages.repositoryId, repoRow.id),
91 eq(packages.ecosystem, "npm")
92 )
93 )
94 .orderBy(desc(packages.updatedAt));
95
96 // Fetch the "latest" tag for each package.
97 const latest = await Promise.all(
98 pkgs.map(async (p) => {
99 try {
100 const [tag] = await db
101 .select({
102 version: packageVersions.version,
103 })
104 .from(packageTags)
105 .innerJoin(
106 packageVersions,
107 eq(packageTags.versionId, packageVersions.id)
108 )
109 .where(
110 and(
111 eq(packageTags.packageId, p.id),
112 eq(packageTags.tag, "latest")
113 )
114 )
115 .limit(1);
116 return tag?.version || null;
117 } catch {
118 return null;
119 }
120 })
121 );
122 rows = pkgs.map((p, i) => ({ ...p, latestVersion: latest[i] }));
123 } catch (err) {
124 console.error("[packages] ui list:", err);
125 return c.text("Service unavailable", 503);
126 }
127
128 const unread = user ? await getUnreadCount(user.id) : 0;
129 const host = new URL(c.req.url).host;
130 const registryUrl = `${new URL(c.req.url).protocol}//${host}/npm/`;
131
132 return c.html(
133 <Layout
134 title={`Packages — ${owner}/${repo}`}
135 user={user}
136 notificationCount={unread}
137 >
138 <RepoHeader
139 owner={owner}
140 repo={repo}
141 starCount={repoRow.starCount}
142 forkCount={repoRow.forkCount}
143 currentUser={user?.username || null}
144 />
145 <RepoNav owner={owner} repo={repo} active="code" />
146
147 <div style="max-width: 900px">
148 <h2 style="margin: 0 0 16px 0">Packages</h2>
149
150 {rows.length === 0 ? (
151 <div class="empty-state">
152 <p style="margin-bottom: 12px">
153 No npm packages published from this repository yet.
154 </p>
155 <div
156 style="text-align: left; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 16px; font-size: 13px; max-width: 640px; margin: 0 auto"
157 >
158 <strong>To publish:</strong>
159 <ol style="padding-left: 20px; margin: 8px 0 0 0; line-height: 1.6">
160 <li>
161 Create a personal access token at{" "}
162 <a href="/settings/tokens">/settings/tokens</a>.
163 </li>
164 <li>
165 Add to your <code>.npmrc</code>:
166 <pre style="background: #0b0d0f; color: #c7ccd1; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin: 6px 0">
167 registry={registryUrl}
168 {"\n"}
169 //{host}/npm/:_authToken=YOUR_PAT
170 </pre>
171 </li>
172 <li>
173 In <code>package.json</code>, point{" "}
174 <code>repository.url</code> at this repo
175 (<code>
176 {`http://${host}/${owner}/${repo}.git`}
177 </code>
178 ).
179 </li>
180 <li>
181 Run <code>npm publish</code>.
182 </li>
183 </ol>
184 </div>
185 </div>
186 ) : (
187 <div class="panel" style="overflow: hidden">
188 {rows.map((p) => {
189 const fullName = fullPkgName(p);
190 return (
191 <a
192 href={`/${owner}/${repo}/packages/${encodeURIComponent(fullName)}`}
193 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit"
194 >
195 <div style="display: flex; justify-content: space-between; align-items: baseline; gap: 12px">
196 <div style="flex: 1; min-width: 0">
197 <div style="font-weight: 600">{fullName}</div>
198 {p.description && (
199 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
200 {p.description}
201 </div>
202 )}
203 </div>
204 <div style="font-size: 12px; color: var(--text-muted); white-space: nowrap">
205 {p.latestVersion ? (
206 <span>
207 <code>{p.latestVersion}</code>
208 </span>
209 ) : (
210 <span>no versions</span>
211 )}
212 {" · "}
213 <span>{relTime(p.updatedAt)}</span>
214 </div>
215 </div>
216 </a>
217 );
218 })}
219 </div>
220 )}
221 </div>
222 </Layout>
223 );
224});
225
226// ---------------------------------------------------------------------------
227// Detail page
228// ---------------------------------------------------------------------------
229
230ui.get("/:owner/:repo/packages/:pkgName{.+}", async (c) => {
231 const user = c.get("user");
232 const { owner, repo, pkgName } = c.req.param();
233 const parsed = parsePackageName(pkgName);
234 if (!parsed) {
235 return c.text("Invalid package name", 400);
236 }
237
238 const repoRow = await loadRepo(owner, repo);
239 if (!repoRow) return c.notFound();
240
241 let pkg: Package | null = null;
242 let versions: PackageVersion[] = [];
243 let tags: PackageTag[] = [];
244 try {
245 const candidates = await db
246 .select()
247 .from(packages)
248 .where(
249 and(
250 eq(packages.repositoryId, repoRow.id),
251 eq(packages.ecosystem, "npm"),
252 eq(packages.name, parsed.name)
253 )
254 )
255 .limit(10);
256 pkg =
257 candidates.find((p) => (p.scope ?? null) === (parsed.scope ?? null)) ||
258 null;
259 if (pkg) {
260 versions = await db
261 .select()
262 .from(packageVersions)
263 .where(eq(packageVersions.packageId, pkg.id))
264 .orderBy(desc(packageVersions.publishedAt));
265 tags = await db
266 .select()
267 .from(packageTags)
268 .where(eq(packageTags.packageId, pkg.id));
269 }
270 } catch (err) {
271 console.error("[packages] ui detail:", err);
272 return c.text("Service unavailable", 503);
273 }
274
275 if (!pkg) return c.notFound();
276
277 const unread = user ? await getUnreadCount(user.id) : 0;
278 const fullName = fullPkgName(pkg);
279 const latestTag = tags.find((t) => t.tag === "latest");
280 const latestVersion =
281 latestTag && versions.find((v) => v.id === latestTag.versionId);
282 const isOwner = !!user && user.id === repoRow.ownerId;
283 const host = new URL(c.req.url).host;
284
285 return c.html(
286 <Layout
287 title={`${fullName} — ${owner}/${repo}`}
288 user={user}
289 notificationCount={unread}
290 >
291 <RepoHeader
292 owner={owner}
293 repo={repo}
294 starCount={repoRow.starCount}
295 forkCount={repoRow.forkCount}
296 currentUser={user?.username || null}
297 />
298 <RepoNav owner={owner} repo={repo} active="code" />
299
300 <div style="max-width: 900px">
301 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px">
302 <a href={`/${owner}/${repo}/packages`}>Packages</a>
303 {" / "}
304 <span>{fullName}</span>
305 </div>
306 <h2 style="margin: 0 0 4px 0">{fullName}</h2>
307 {pkg.description && (
308 <p style="color: var(--text-muted); margin: 0 0 16px 0">
309 {pkg.description}
310 </p>
311 )}
312
313 <div style="display: grid; grid-template-columns: 1fr 280px; gap: 24px">
314 <div>
315 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 0 0 8px 0">
316 Install
317 </h3>
318 <pre style="background: #0b0d0f; color: #c7ccd1; padding: 10px 14px; border-radius: 6px; font-size: 13px; overflow-x: auto">
319 npm install {fullName}
320 {latestVersion ? `@${latestVersion.version}` : ""}
321 </pre>
322
323 {pkg.readme && (
324 <>
325 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 20px 0 8px 0">
326 Readme
327 </h3>
328 <pre
329 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; white-space: pre-wrap; font-size: 13px; line-height: 1.5"
330 >
331 {pkg.readme}
332 </pre>
333 </>
334 )}
335
336 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 20px 0 8px 0">
337 Versions
338 </h3>
339 {versions.length === 0 ? (
340 <div class="empty-state">
341 <p>No versions yet.</p>
342 </div>
343 ) : (
344 <div class="panel" style="overflow: hidden">
345 {versions.map((v) => (
346 <div
347 style="padding: 10px 14px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 12px"
348 >
349 <div style="flex: 1; min-width: 0">
350 <div style="font-weight: 500">
351 <code>{v.version}</code>
352 {v.yanked && (
353 <span
354 style="margin-left: 8px; font-size: 11px; color: var(--red); text-transform: uppercase"
355 >
356 yanked
357 </span>
358 )}
359 </div>
360 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
361 {humanSize(v.sizeBytes)} · published{" "}
362 {relTime(v.publishedAt)}
363 {v.shasum && (
364 <>
365 {" · sha1 "}
366 <code style="font-size: 11px">
367 {v.shasum.slice(0, 12)}
368 </code>
369 </>
370 )}
371 </div>
372 </div>
373 <a
374 class="btn btn-sm"
375 href={`/npm/${encodeURIComponent(fullName)}/-/${pkg.name}-${v.version}.tgz`}
376 >
377 Download
378 </a>
379 {isOwner && !v.yanked && (
380 <form
381 method="POST"
382 action={`/api/packages/${owner}/${repo}/${encodeURIComponent(fullName)}/${v.version}/yank`}
383 onsubmit="return confirm('Yank this version? It will still download, but will be flagged as yanked.')"
384 style="margin: 0"
385 >
386 <button
387 type="submit"
388 class="btn btn-sm btn-danger"
389 >
390 Yank
391 </button>
392 </form>
393 )}
394 </div>
395 ))}
396 </div>
397 )}
398 </div>
399
400 <aside>
401 <div class="panel" style="padding: 12px 14px">
402 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
403 Registry
404 </div>
405 <code style="font-size: 12px">http://{host}/npm/</code>
406
407 {pkg.homepage && (
408 <>
409 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
410 Homepage
411 </div>
412 <a href={pkg.homepage} style="font-size: 13px; word-break: break-all">
413 {pkg.homepage}
414 </a>
415 </>
416 )}
417 {pkg.license && (
418 <>
419 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
420 License
421 </div>
422 <div style="font-size: 13px">{pkg.license}</div>
423 </>
424 )}
425 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
426 Repository
427 </div>
428 <a
429 href={`/${owner}/${repo}`}
430 style="font-size: 13px; word-break: break-all"
431 >
432 {owner}/{repo}
433 </a>
434 </div>
435 </aside>
436 </div>
437 </div>
438 </Layout>
439 );
440});
441
442export default ui;
Addedsrc/routes/pages.tsx+518−0View fileUnifiedSplit
1/**
2 * Block C3 — Pages / static hosting routes.
3 *
4 * GET /:owner/:repo/pages/* — serve a static file from the
5 * latest successful gh-pages
6 * deployment
7 * GET /:owner/:repo/settings/pages — settings UI (owner-only)
8 * POST /:owner/:repo/settings/pages — upsert settings
9 * POST /:owner/:repo/settings/pages/redeploy — manual redeploy trigger
10 *
11 * The serving endpoint reads blobs directly out of the bare git repo at the
12 * commit sha of the most recent pages_deployments row for that repo. There is
13 * no on-disk export — the git store IS the CDN.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq } from "drizzle-orm";
18import { db } from "../db";
19import {
20 pagesDeployments,
21 pagesSettings,
22 repositories,
23 users,
24} from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader, RepoNav } from "../views/components";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { getBlob, getRawBlob, resolveRef } from "../git/repository";
30import { audit } from "../lib/notify";
31import { getUnreadCount } from "../lib/unread";
32import { config } from "../lib/config";
33import {
34 contentTypeFor,
35 onPagesPush,
36 resolvePagesPath,
37} from "../lib/pages";
38
39const pagesRoute = new Hono<AuthEnv>();
40pagesRoute.use("*", softAuth);
41
42interface LoadedRepo {
43 id: string;
44 name: string;
45 ownerId: string;
46 ownerUsername: string;
47}
48
49async function loadRepo(
50 owner: string,
51 repo: string
52): Promise<LoadedRepo | null> {
53 try {
54 const [row] = await db
55 .select({
56 id: repositories.id,
57 name: repositories.name,
58 ownerId: repositories.ownerId,
59 ownerUsername: users.username,
60 })
61 .from(repositories)
62 .innerJoin(users, eq(repositories.ownerId, users.id))
63 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
64 .limit(1);
65 return row || null;
66 } catch {
67 return null;
68 }
69}
70
71async function getEffectiveSettings(repositoryId: string) {
72 try {
73 const [row] = await db
74 .select()
75 .from(pagesSettings)
76 .where(eq(pagesSettings.repositoryId, repositoryId))
77 .limit(1);
78 if (row) return row;
79 } catch {
80 /* fall through to defaults */
81 }
82 // Synthesise defaults when the row doesn't exist.
83 return {
84 repositoryId,
85 enabled: true,
86 sourceBranch: "gh-pages",
87 sourceDir: "/",
88 customDomain: null as string | null,
89 updatedAt: new Date(),
90 };
91}
92
93// ---------------------------------------------------------------------------
94// Serve: GET /:owner/:repo/pages/*
95// ---------------------------------------------------------------------------
96
97pagesRoute.get("/:owner/:repo/pages/*", async (c) => {
98 const { owner, repo } = c.req.param();
99
100 // Hono gives us the full path via c.req.path; extract whatever sits after
101 // the "/pages/" segment. This is the only path component we treat as the
102 // user-facing URL.
103 const full = c.req.path;
104 const marker = `/${owner}/${repo}/pages/`;
105 const idx = full.indexOf(marker);
106 const urlRest = idx >= 0 ? full.slice(idx + marker.length) : "";
107
108 const repoRow = await loadRepo(owner, repo);
109 if (!repoRow) {
110 return c.text("No Pages site published for this repository.", 404);
111 }
112
113 const settings = await getEffectiveSettings(repoRow.id);
114 if (!settings.enabled) {
115 return c.text("No Pages site published for this repository.", 404);
116 }
117
118 let deployment:
119 | { commitSha: string; createdAt: Date; status: string }
120 | null = null;
121 try {
122 const [row] = await db
123 .select({
124 commitSha: pagesDeployments.commitSha,
125 createdAt: pagesDeployments.createdAt,
126 status: pagesDeployments.status,
127 })
128 .from(pagesDeployments)
129 .where(
130 and(
131 eq(pagesDeployments.repositoryId, repoRow.id),
132 eq(pagesDeployments.status, "success")
133 )
134 )
135 .orderBy(desc(pagesDeployments.createdAt))
136 .limit(1);
137 deployment = row || null;
138 } catch {
139 return c.text("Service unavailable", 503);
140 }
141
142 if (!deployment) {
143 return c.text(
144 "No Pages site published for this repository. Push to the configured source branch to publish.",
145 404
146 );
147 }
148
149 const candidates = resolvePagesPath(urlRest, settings.sourceDir);
150
151 for (const candidate of candidates) {
152 // Try as text first — getBlob fills in isBinary for us.
153 const blob = await getBlob(owner, repo, deployment.commitSha, candidate);
154 if (!blob) continue;
155
156 const headers: Record<string, string> = {
157 "Content-Type": contentTypeFor(candidate),
158 "Cache-Control": "public, max-age=60",
159 "X-Gluecron-Pages-Sha": deployment.commitSha.slice(0, 7),
160 };
161
162 if (blob.isBinary) {
163 // getBlob blanks the content for binary — re-read the raw bytes.
164 const raw = await getRawBlob(
165 owner,
166 repo,
167 deployment.commitSha,
168 candidate
169 );
170 if (!raw) continue;
171 return new Response(raw, { status: 200, headers });
172 }
173
174 return new Response(blob.content, { status: 200, headers });
175 }
176
177 return c.text("Not found in Pages site.", 404);
178});
179
180// ---------------------------------------------------------------------------
181// Settings UI: GET /:owner/:repo/settings/pages
182// ---------------------------------------------------------------------------
183
184pagesRoute.get(
185 "/:owner/:repo/settings/pages",
186 requireAuth,
187 async (c) => {
188 const { owner: ownerName, repo: repoName } = c.req.param();
189 const user = c.get("user")!;
190 const success = c.req.query("success");
191 const error = c.req.query("error");
192 const info = c.req.query("info");
193
194 const repoRow = await loadRepo(ownerName, repoName);
195 if (!repoRow) return c.notFound();
196 if (repoRow.ownerId !== user.id) {
197 return c.html(
198 <Layout title="Unauthorized" user={user}>
199 <div class="empty-state">
200 <h2>Unauthorized</h2>
201 <p>Only the repository owner can configure Pages.</p>
202 </div>
203 </Layout>,
204 403
205 );
206 }
207
208 const settings = await getEffectiveSettings(repoRow.id);
209
210 let recent: Array<{
211 id: string;
212 ref: string;
213 commitSha: string;
214 status: string;
215 createdAt: Date;
216 }> = [];
217 try {
218 recent = await db
219 .select({
220 id: pagesDeployments.id,
221 ref: pagesDeployments.ref,
222 commitSha: pagesDeployments.commitSha,
223 status: pagesDeployments.status,
224 createdAt: pagesDeployments.createdAt,
225 })
226 .from(pagesDeployments)
227 .where(eq(pagesDeployments.repositoryId, repoRow.id))
228 .orderBy(desc(pagesDeployments.createdAt))
229 .limit(10);
230 } catch {
231 /* fall through; render with empty list */
232 }
233
234 const unread = await getUnreadCount(user.id);
235 const siteUrl = `${config.appBaseUrl}/${ownerName}/${repoName}/pages/`;
236
237 return c.html(
238 <Layout
239 title={`Pages — ${ownerName}/${repoName}`}
240 user={user}
241 notificationCount={unread}
242 >
243 <RepoHeader owner={ownerName} repo={repoName} />
244 <RepoNav owner={ownerName} repo={repoName} active="code" />
245 <div style="max-width: 720px">
246 <h2 style="margin-bottom: 16px">Pages</h2>
247 {success && (
248 <div class="auth-success">{decodeURIComponent(success)}</div>
249 )}
250 {info && <div class="auth-success">{decodeURIComponent(info)}</div>}
251 {error && (
252 <div class="auth-error">{decodeURIComponent(error)}</div>
253 )}
254
255 <p style="color: var(--text-muted); margin-bottom: 20px">
256 Publish a static site from this repository. Push to the source
257 branch and every successful push becomes a new deployment.
258 </p>
259
260 <div
261 style="padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 24px; background: var(--bg-muted)"
262 >
263 <div style="font-size: 13px; color: var(--text-muted)">
264 Your site is published at:
265 </div>
266 <div style="margin-top: 4px">
267 <a href={siteUrl}>{siteUrl}</a>
268 </div>
269 </div>
270
271 <form
272 method="POST"
273 action={`/${ownerName}/${repoName}/settings/pages`}
274 >
275 <div class="form-group">
276 <label>
277 <input
278 type="checkbox"
279 name="enabled"
280 value="1"
281 checked={settings.enabled}
282 />
283 {" "}Enable GitHub Pages
284 </label>
285 </div>
286 <div class="form-group">
287 <label for="source_branch">Source branch</label>
288 <input
289 type="text"
290 id="source_branch"
291 name="source_branch"
292 value={settings.sourceBranch}
293 placeholder="gh-pages"
294 />
295 </div>
296 <div class="form-group">
297 <label for="source_dir">Source directory</label>
298 <input
299 type="text"
300 id="source_dir"
301 name="source_dir"
302 value={settings.sourceDir}
303 placeholder="/"
304 />
305 <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px">
306 Use "/" to serve from the repo root, or e.g. "/docs".
307 </div>
308 </div>
309 <div class="form-group">
310 <label for="custom_domain">Custom domain (optional)</label>
311 <input
312 type="text"
313 id="custom_domain"
314 name="custom_domain"
315 value={settings.customDomain || ""}
316 placeholder="example.com"
317 />
318 </div>
319 <button type="submit" class="btn btn-primary">
320 Save
321 </button>
322 </form>
323
324 <div style="margin-top: 32px">
325 <div
326 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px"
327 >
328 <h3>Recent deployments</h3>
329 <form
330 method="POST"
331 action={`/${ownerName}/${repoName}/settings/pages/redeploy`}
332 style="display: inline"
333 >
334 <button type="submit" class="btn">
335 Redeploy from HEAD
336 </button>
337 </form>
338 </div>
339 {recent.length === 0 ? (
340 <div class="empty-state">
341 <p>
342 No deployments yet — push to{" "}
343 <code>{settings.sourceBranch}</code> to publish.
344 </p>
345 </div>
346 ) : (
347 <table
348 style="width: 100%; border-collapse: collapse; font-size: 13px"
349 >
350 <thead>
351 <tr style="text-align: left; color: var(--text-muted)">
352 <th style="padding: 6px 0">When</th>
353 <th>Ref</th>
354 <th>Commit</th>
355 <th>Status</th>
356 </tr>
357 </thead>
358 <tbody>
359 {recent.map((d) => (
360 <tr style="border-top: 1px solid var(--border)">
361 <td style="padding: 6px 0">
362 {new Date(d.createdAt).toISOString()}
363 </td>
364 <td>
365 <code>{d.ref}</code>
366 </td>
367 <td>
368 <code>{d.commitSha.slice(0, 7)}</code>
369 </td>
370 <td
371 style={`color: ${d.status === "success" ? "var(--green)" : "var(--red)"}`}
372 >
373 {d.status}
374 </td>
375 </tr>
376 ))}
377 </tbody>
378 </table>
379 )}
380 </div>
381 </div>
382 </Layout>
383 );
384 }
385);
386
387// ---------------------------------------------------------------------------
388// Save settings: POST /:owner/:repo/settings/pages
389// ---------------------------------------------------------------------------
390
391pagesRoute.post(
392 "/:owner/:repo/settings/pages",
393 requireAuth,
394 async (c) => {
395 const { owner: ownerName, repo: repoName } = c.req.param();
396 const user = c.get("user")!;
397 const body = await c.req.parseBody();
398
399 const repoRow = await loadRepo(ownerName, repoName);
400 if (!repoRow) return c.notFound();
401 if (repoRow.ownerId !== user.id) {
402 return c.redirect(`/${ownerName}/${repoName}`);
403 }
404
405 const enabled = body.enabled === "1" || body.enabled === "on";
406 const sourceBranch =
407 String(body.source_branch || "gh-pages").trim() || "gh-pages";
408 let sourceDir = String(body.source_dir || "/").trim() || "/";
409 if (!sourceDir.startsWith("/")) sourceDir = `/${sourceDir}`;
410 const customDomainRaw = String(body.custom_domain || "").trim();
411 const customDomain = customDomainRaw === "" ? null : customDomainRaw;
412
413 try {
414 const [existing] = await db
415 .select({ repositoryId: pagesSettings.repositoryId })
416 .from(pagesSettings)
417 .where(eq(pagesSettings.repositoryId, repoRow.id))
418 .limit(1);
419 if (existing) {
420 await db
421 .update(pagesSettings)
422 .set({
423 enabled,
424 sourceBranch,
425 sourceDir,
426 customDomain,
427 updatedAt: new Date(),
428 })
429 .where(eq(pagesSettings.repositoryId, repoRow.id));
430 } else {
431 await db.insert(pagesSettings).values({
432 repositoryId: repoRow.id,
433 enabled,
434 sourceBranch,
435 sourceDir,
436 customDomain,
437 });
438 }
439 } catch (err) {
440 console.error("[pages] save settings:", err);
441 return c.redirect(
442 `/${ownerName}/${repoName}/settings/pages?error=${encodeURIComponent("Could not save settings")}`
443 );
444 }
445
446 await audit({
447 userId: user.id,
448 repositoryId: repoRow.id,
449 action: "pages.settings.update",
450 metadata: { enabled, sourceBranch, sourceDir, customDomain },
451 });
452
453 return c.redirect(
454 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Pages settings saved")}`
455 );
456 }
457);
458
459// ---------------------------------------------------------------------------
460// Manual redeploy: POST /:owner/:repo/settings/pages/redeploy
461// ---------------------------------------------------------------------------
462
463pagesRoute.post(
464 "/:owner/:repo/settings/pages/redeploy",
465 requireAuth,
466 async (c) => {
467 const { owner: ownerName, repo: repoName } = c.req.param();
468 const user = c.get("user")!;
469
470 const repoRow = await loadRepo(ownerName, repoName);
471 if (!repoRow) return c.notFound();
472 if (repoRow.ownerId !== user.id) {
473 return c.redirect(`/${ownerName}/${repoName}`);
474 }
475
476 const settings = await getEffectiveSettings(repoRow.id);
477 const branch = settings.sourceBranch || "gh-pages";
478 const ref = `refs/heads/${branch}`;
479
480 // Try to resolve the current head of the source branch. If the branch
481 // doesn't exist yet, tell the owner to push something to it instead of
482 // recording a bogus deployment row.
483 const sha = await resolveRef(ownerName, repoName, ref);
484 if (!sha) {
485 await audit({
486 userId: user.id,
487 repositoryId: repoRow.id,
488 action: "pages.redeploy",
489 metadata: { ref, result: "no-branch" },
490 });
491 return c.redirect(
492 `/${ownerName}/${repoName}/settings/pages?info=${encodeURIComponent(`Branch ${branch} has no commits yet — push to it to deploy.`)}`
493 );
494 }
495
496 await onPagesPush({
497 ownerLogin: ownerName,
498 repoName,
499 repositoryId: repoRow.id,
500 ref,
501 newSha: sha,
502 triggeredByUserId: user.id,
503 });
504
505 await audit({
506 userId: user.id,
507 repositoryId: repoRow.id,
508 action: "pages.redeploy",
509 metadata: { ref, sha },
510 });
511
512 return c.redirect(
513 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Redeploy recorded")}`
514 );
515 }
516);
517
518export default pagesRoute;
0519