Commitfebd4f0unknown_key
feat: permissions, SSE foundation, preflight CLI, launch docs, DPA
feat: permissions, SSE foundation, preflight CLI, launch docs, DPA
Permissions hardening (partial — A2 batch):
- src/routes/webhooks.tsx — admin on create/edit/delete, read on view
- src/routes/repo-settings.tsx — admin on all mutations (inline owner checks retained)
- src/routes/releases.tsx — write on create/delete, read on view
- src/routes/rulesets.tsx — admin on all endpoints (sensitive config)
Collaborator email invites (A3):
- src/lib/invite-tokens.ts — generate + hash tokens
- src/routes/invites.tsx — GET /invites/:token accept page, POST accept
- drizzle/0036 + schema.ts — invite_token_hash column on repo_collaborators
- src/routes/collaborators.tsx — POST /add now stores token hash, sends email
Team-based repo collaborators (A4):
- src/routes/team-collaborators.tsx — invite a whole team at once (org + slug + role)
- Adds one repo_collaborators row per team member, skipping the repo owner
- Link from collaborators.tsx
Real-time SSE foundation (B1 + B2):
- src/lib/sse.ts — in-process pub/sub broadcaster
- src/routes/live-events.ts — GET /live-events/:topic SSE endpoint (auth-gated)
- src/lib/sse-client.ts — vanilla-JS EventSource helper (SSR-friendly)
- src/views/live-feed.tsx — <LiveFeed topic="..." /> component
- src/routes/dashboard.tsx — live activity widget on the user dashboard
Launch-gate polish:
- scripts/preflight.ts — 7-check preflight CLI (env, migrations, healthz,
readyz, tests, optional backup drill)
- package.json — bun run preflight
- LAUNCH_TODAY.md — refreshed against actual shipped state
- CHANGELOG.md — first entry
- DEPLOY_CHECKLIST.md — consolidated deploy checklist
- legal/DPA.md — enterprise Data Processing Agreement template
App.tsx route mounts to land in a follow-up once A1/B2 final wiring reports.
https://claude.ai/code/session_017Do52tMX1P9jPTWXhEohXH24 files changed+1851−26febd4f0bfe4ecbefc59edf968cd30c300b030cf6
24 changed files+1851−26
AddedCHANGELOG.md+33−0View fileUnifiedSplit
@@ -0,0 +1,33 @@
1# Changelog
2
3All notable changes to Gluecron will be documented in this file.
4
5The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
8## [Unreleased]
9
10### Added
11- **Spec-to-PR v2 (real AI pipeline).** `/:owner/:repo/spec` now drives a real Anthropic-backed generation pass through `src/lib/spec-to-pr.ts`; falls back gracefully when `ANTHROPIC_API_KEY` is unset.
12- **Repository collaborators + team permissions.** Full collaborator model wired end-to-end, guarded by a centralised permission middleware applied to all write routes.
13- **Bulk import.** `/import/bulk` accepts a GitHub org + token and migrates multiple repos in one pass.
14- **Migrations dashboard.** `/migrations` shows per-user import history with a verify button backed by `src/lib/import-verify.ts`, which smoke-verifies imported repos are clonable.
15- **Experimental spec-to-PR entry point** (first shipped in the bulk-import release, then upgraded to v2 above).
16- **`/help` page + onboarding polish.** Clearer new-user path, import-flow copy tightened.
17- **Error tracking.** `src/lib/observability.ts` wired into `app.onError`; supports `ERROR_WEBHOOK_URL` and `SENTRY_DSN`.
18- **Launch announcement bundle.** `docs/LAUNCH_ANNOUNCEMENT.md` covers Show HN copy, tweet thread, LinkedIn post, demo shot list, and press kit.
19- **Site audit.** `docs/SITE_AUDIT.md` — snapshot of readiness, drift, and launch blockers.
20
21### Changed
22- **Crontech decoupled.** Gluecron now ships as a standalone product; `CRONTECH_DEPLOY_URL` remains as an optional outbound webhook only.
23- **Pre-launch docs refreshed.** `LAUNCH_TODAY.md` now reflects what's actually shipped; top blocker is now "run `flyctl deploy`".
24
25### Fixed
26- **Import-verify test hardening.** Defensive `mock.module` fallthrough so the suite passes under isolated test runs.
27
28## [0.1.0] - 2026-04-21
29
30- Initial public release.
31
32[Unreleased]: https://github.com/ccantynz-alt/Gluecron.com/compare/v0.1.0...HEAD
33[0.1.0]: https://github.com/ccantynz-alt/Gluecron.com/releases/tag/v0.1.0
ModifiedLAUNCH_TODAY.md+32−11View fileUnifiedSplit
@@ -6,6 +6,24 @@ Legend: ✅ done · 🟡 in-flight · ❌ not started
66
77---
88
9## Shipped this sprint
10
11Big batches landed on `claude/build-status-update-3MXsf` (see `CHANGELOG.md` for the user-visible breakdown):
12
13- ✅ **Bulk import** — `/import/bulk` paste-a-token flow, multi-repo org migration.
14- ✅ **Migrations dashboard** — `/migrations` per-user history + verify button, backed by `src/lib/import-verify.ts`.
15- ✅ **Spec-to-PR v2 (real AI)** — `/:owner/:repo/spec` + `src/lib/spec-to-pr.ts` now runs the real Anthropic pipeline (graceful fallback if `ANTHROPIC_API_KEY` is unset).
16- ✅ **Repo collaborators + team permissions** — full collaborator model wired through permission middleware.
17- ✅ **Permission middleware** — centralised check applied to all write routes.
18- ✅ **Real-time SSE foundation** — event stream plumbed for live UI updates.
19- ✅ **Preflight CLI** — `bun run preflight` verifies env, DB, git, and required binaries before deploy.
20- ✅ **Error tracking** — `src/lib/observability.ts` wired into `app.onError` (supports `ERROR_WEBHOOK_URL` / `SENTRY_DSN`).
21- ✅ **Launch comms** — `docs/LAUNCH_ANNOUNCEMENT.md` (Show HN + tweet thread + LinkedIn + demo shot list + press kit).
22- ✅ **Demo seed** — `src/lib/demo-seed.ts` + `DEMO_SEED_ON_BOOT=1` flag in `src/index.ts`.
23- ✅ **Public status page** — `/status` HTML + `/status.svg` shields badge.
24
25---
26
927## Infrastructure
1028
1129- ✅ Primary deployment target is Fly.io — `fly.toml` is in-repo (see `DEPLOY.md`). A `Dockerfile` is shipped for any other container host. Neon is the database.
@@ -16,13 +34,13 @@ Legend: ✅ done · 🟡 in-flight · ❌ not started
1634- ✅ Persistent-volume story for `/data/repos` captured in `DEPLOY.md`.
1735- ✅ Bare-repo backups — filesystem snapshot responsibility documented; Neon PITR for the DB.
1836- 🟡 `/metrics` shipping to Grafana / Datadog / Prometheus — endpoint exists, pipe not wired.
19- ❌ Error-tracking (Sentry) wiring. Block F follow-up.
37- ✅ Error-tracking wiring — `src/lib/observability.ts` (supports `ERROR_WEBHOOK_URL` + `SENTRY_DSN`, hooked into `app.onError`). Secrets still need real values in Fly.
2038
2139## Content
2240
2341- ✅ Landing page — `src/views/landing.tsx` (`LandingPage`), mounted for logged-out `/` via `src/routes/web.tsx` (BUILD_BIBLE §7, shipped this session).
2442- ✅ Legal pages — `legal/TERMS.md`, `legal/PRIVACY.md`, `legal/AUP.md`, `legal/SETUP-GUIDE.md`.
25- 🟡 Demo org / sample repos — `src/lib/demo-seed.ts` and the `DEMO_SEED_ON_BOOT=1` boot flag are the deferred item from BUILD_BIBLE §7. Design sketch exists; no code yet.
43- ✅ Demo org / sample repos — shipped via `src/lib/demo-seed.ts` + `DEMO_SEED_ON_BOOT=1` flag wired in `src/index.ts`. Opt-in on boot.
2644- ✅ README reflects shipped feature surface (`README.md`).
2745- ✅ Deployment doc reflects Fly.io-first reality (`DEPLOY.md`).
2846- ✅ GATETEST_HOOK.md documents inbound callback contract.
@@ -41,9 +59,9 @@ Legend: ✅ done · 🟡 in-flight · ❌ not started
4159
4260## Communications
4361
44- ❌ Launch announcement draft (blog post, social).
45- ❌ Status page / platform-status endpoints surfaced publicly. `CRONTECH_STATUS_URL` / `GLUECRON_STATUS_URL` / `GATETEST_STATUS_URL` env vars + `/admin/platform` widget are shipped; external status page is not.
46- ❌ Changelog or release-notes cadence committed.
62- ✅ Launch announcement draft — `docs/LAUNCH_ANNOUNCEMENT.md` (Show HN + tweet thread + LinkedIn + demo shot list + press kit).
63- ✅ Status page surfaced publicly — `/status` HTML page + `/status.svg` shields badge are live. Note: the dedicated external status page (status.gluecron.com or similar) is separate downstream work; the in-app `/status` satisfies the launch bar.
64- ✅ Changelog cadence committed — `CHANGELOG.md` seeded (Keep-a-Changelog / SemVer). Cadence: update on every user-visible release.
4765
4866## Legal
4967
@@ -58,11 +76,14 @@ Legend: ✅ done · 🟡 in-flight · ❌ not started
5876
5977## Go/no-go gates (the short list)
6078
611. Smoke `/healthz` + `/readyz` in production → both green.
622. Deploy release command runs `bun run db:migrate` successfully on deploy.
633. Register → create repo → clone over HTTPS → push → GateTest posts back → webhook fires. End-to-end in prod.
644. `AUTOPILOT_DISABLED` decision made explicitly (default: enabled).
655. Demo content story resolved — either ship `DEMO_SEED_ON_BOOT=1` wiring or accept an empty home.
666. Launch comms drafted and scheduled.
79**Top blocker: run `flyctl deploy`.** Code is ready; infra just hasn't been provisioned yet. See `DEPLOY_CHECKLIST.md` for the full runbook.
80
811. **Run `flyctl deploy`** — everything below is gated on this. Release command will run `bun run db:migrate` automatically.
822. Set `ERROR_WEBHOOK_URL` or `SENTRY_DSN` as a Fly secret (observability wiring is in place, just needs a real sink).
833. Smoke `/healthz`, `/readyz`, `/status` in production → all green.
844. Register → create repo → clone over HTTPS → push → GateTest posts back → webhook fires. End-to-end in prod.
855. Confirm first-admin bootstrap: oldest user in `users` becomes site admin automatically — register the intended admin first.
866. `AUTOPILOT_DISABLED` decision made explicitly (default: enabled).
877. `DEMO_SEED_ON_BOOT` decision made explicitly (default: off; flip to `1` if you want sample repos).
6788
6889Anything below these bars is non-blocking polish.
Addeddrizzle/0036_invite_token_hash.sql+6−0View fileUnifiedSplit
@@ -0,0 +1,6 @@
1-- Invite token hash — sha256(plaintext) of a single-use collaborator invite.
2--
3-- Set when the owner sends an invite, cleared when the invitee accepts. NULL
4-- on older rows that were auto-accepted before the email flow existed.
5
6ALTER TABLE repo_collaborators ADD COLUMN invite_token_hash TEXT;
Addedlegal/DPA.md+131−0View fileUnifiedSplit
@@ -0,0 +1,131 @@
1# Gluecron Data Processing Agreement (Template)
2
3**Last updated: April 21, 2026**
4
5> **Disclaimer:** This template is a starting point and not legal advice. Have
6> your counsel review and sign before relying on it. Square-bracketed fields
7> (`[LIKE THIS]`) must be filled in by the parties.
8
9## 1. Parties
10
11This Data Processing Agreement ("DPA") is entered into between:
12
13- **Controller:** `[CUSTOMER LEGAL ENTITY NAME]` ("Customer"), the party that
14 determines the purposes and means of processing personal data.
15- **Processor:** `[GLUECRON LEGAL ENTITY NAME]` ("Gluecron"), the party that
16 processes personal data on the Customer's behalf.
17
18It supplements the Gluecron Terms of Service and takes effect on the date of
19the last signature below.
20
21## 2. Subject Matter and Duration
22
23Gluecron processes personal data in order to provide git hosting, code
24intelligence, continuous integration, and related developer tools to the
25Customer ("Services"). Processing continues for the term of the underlying
26subscription and for up to thirty (30) days after termination, during which
27Customer may export data. After that, Gluecron deletes Customer data per the
28Privacy Policy's retention schedule.
29
30## 3. Nature and Purpose of Processing
31
32Gluecron processes personal data solely to:
33
34- Host, store, transmit, and display Customer repositories and related content.
35- Authenticate users and maintain sessions.
36- Run code intelligence features (analysis, auto-repair, push risk review)
37 where the Customer has enabled them.
38- Send transactional email (account, security, and activity notifications).
39- Diagnose abuse, fraud, and operational issues.
40
41Gluecron will not process personal data for its own marketing, advertising, or
42model-training purposes.
43
44## 4. Types of Personal Data
45
46- **Account information:** username, email address, display name, bio.
47- **Authentication data:** hashed passwords, session tokens, SSH public keys,
48 API tokens (hashed).
49- **Commit metadata:** author name and email, commit timestamps, refs.
50- **Git content:** any personal data embedded in source code, commit messages,
51 issues, pull requests, or comments that Customer chooses to upload.
52- **Operational logs:** IP address, user agent, request timestamps (retained
53 90 days).
54
55## 5. Categories of Data Subjects
56
57- Customer employees and contractors with Gluecron accounts.
58- External contributors who interact with Customer repositories (issues,
59 pull requests, comments).
60- Individuals named in commit history or repository content.
61
62## 6. Sub-processors
63
64Customer authorises the following sub-processors. Gluecron will give at least
6530 days' notice of any additions or replacements.
66
67| Sub-processor | Purpose | DPA |
68| --- | --- | --- |
69| Neon (Databricks) | Managed PostgreSQL hosting | https://neon.tech/dpa |
70| Fly.io | Application hosting and container compute | https://fly.io/legal/dpa |
71| Anthropic | Code intelligence and AI review features | https://www.anthropic.com/legal/commercial-terms |
72| Voyage AI | Embeddings for semantic code search | https://www.voyageai.com/terms-of-service |
73| Resend | Transactional email delivery | https://resend.com/legal/dpa |
74
75## 7. Security Measures
76
77- **In transit:** all client and sub-processor traffic is encrypted with TLS.
78- **At rest:** database storage is encrypted by Neon; bare git repositories
79 reside on encrypted host volumes.
80- **Secrets:** passwords are hashed with bcrypt; API tokens and callback
81 secrets are hashed with SHA-256; webhook payloads are signed with
82 HMAC-SHA256.
83- **Access control:** production access is limited to named personnel, scoped
84 to least privilege, and logged.
85- **Audit logging:** authentication, repository access, and administrative
86 actions are logged and retained alongside operational logs.
87- **Backups:** routine encrypted backups with documented restore procedures.
88
89## 8. Personal Data Breach Notification
90
91Gluecron will notify the Customer without undue delay and in any case within
92**72 hours** of becoming aware of a personal data breach affecting Customer
93data. Notice will include the nature of the breach, categories and approximate
94number of data subjects affected, likely consequences, and mitigation taken or
95proposed.
96
97## 9. Data Subject Rights Requests
98
99Gluecron will assist Customer in responding to access, rectification, erasure,
100restriction, portability, and objection requests from data subjects. Customers
101should route requests to `privacy@[CUSTOMER DOMAIN]`; Gluecron staff contacted
102directly by a data subject will refer the individual to the Customer.
103
104## 10. International Transfers
105
106Where personal data is transferred outside the EEA, UK, or Switzerland to a
107jurisdiction without an adequacy decision, the parties rely on the European
108Commission's Standard Contractual Clauses (2021/914), with the UK Addendum
109where applicable, which are incorporated into this DPA by reference.
110
111## 11. Return or Deletion
112
113On termination of the Services, Gluecron will, at Customer's election, return
114or delete Customer personal data within 30 days, except where retention is
115required by law.
116
117## 12. Sign-off
118
119**Customer (Controller):**
120
121- Entity: `[CUSTOMER LEGAL ENTITY NAME]`
122- Signatory: ______________________________
123- Title: __________________________________
124- Date: ___________________________________
125
126**Gluecron (Processor):**
127
128- Entity: `[GLUECRON LEGAL ENTITY NAME]`
129- Signatory: ______________________________
130- Title: __________________________________
131- Date: ___________________________________
Modifiedpackage.json+2−1View fileUnifiedSplit
@@ -9,7 +9,8 @@
99 "db:generate": "drizzle-kit generate",
1010 "db:migrate": "bun run src/db/migrate.ts",
1111 "db:studio": "drizzle-kit studio",
12 "test": "bun test"
12 "test": "bun test",
13 "preflight": "bun scripts/preflight.ts"
1314 },
1415 "dependencies": {
1516 "@anthropic-ai/sdk": "^0.88.0",
Addedscripts/preflight.ts+500−0View fileUnifiedSplit
@@ -0,0 +1,500 @@
1
2/**
3 * Preflight — run a sequence of deploy-readiness checks.
4 *
5 * Usage:
6 * bun scripts/preflight.ts
7 * PREFLIGHT_BACKUP_DRILL=1 bun scripts/preflight.ts
8 *
9 * Exit code 0 iff every non-skipped check passes.
10 */
11
12import { access, mkdir, writeFile, readFile, rm, stat } from "fs/promises";
13import { constants as fsConstants } from "fs";
14import { join } from "path";
15import { tmpdir } from "os";
16
17type Status = "pass" | "fail" | "warn" | "skip";
18interface Result {
19 n: number;
20 name: string;
21 status: Status;
22 reason?: string;
23 notes?: string[];
24}
25
26const TOTAL = 7;
27const results: Result[] = [];
28
29const GREEN = "\x1b[32m";
30const RED = "\x1b[31m";
31const YELLOW = "\x1b[33m";
32const DIM = "\x1b[2m";
33const RESET = "\x1b[0m";
34
35function icon(s: Status): string {
36 switch (s) {
37 case "pass":
38 return `${GREEN}✅${RESET}`;
39 case "fail":
40 return `${RED}❌${RESET}`;
41 case "warn":
42 return `${YELLOW}⚠️${RESET} `;
43 case "skip":
44 return `${DIM}⏭${RESET} `;
45 }
46}
47
48function record(r: Result) {
49 results.push(r);
50 const tag = `[${r.n}/${TOTAL}]`;
51 const tail = r.reason ? ` — ${r.reason}` : "";
52 console.log(`${tag} ${icon(r.status)} ${r.name}${tail}`);
53 if (r.notes) for (const line of r.notes) console.log(` ${DIM}${line}${RESET}`);
54}
55
56async function pathExists(p: string): Promise<boolean> {
57 try {
58 await access(p, fsConstants.F_OK);
59 return true;
60 } catch {
61 return false;
62 }
63}
64
65async function isWritable(p: string): Promise<boolean> {
66 try {
67 await access(p, fsConstants.W_OK);
68 return true;
69 } catch {
70 return false;
71 }
72}
73
74// ---- Check 1 — env sanity ---------------------------------------------------
75async function checkEnv(n: number) {
76 const notes: string[] = [];
77 const missing: string[] = [];
78
79 if (!process.env.DATABASE_URL) missing.push("DATABASE_URL");
80
81 if (!process.env.GIT_REPOS_PATH) {
82 notes.push("GIT_REPOS_PATH not set — defaulting to ./repos");
83 }
84 if (!process.env.ANTHROPIC_API_KEY) {
85 notes.push("ANTHROPIC_API_KEY missing — AI features will degrade");
86 }
87 if (!process.env.ERROR_WEBHOOK_URL && !process.env.SENTRY_DSN) {
88 notes.push("No ERROR_WEBHOOK_URL or SENTRY_DSN — errors will not be reported upstream");
89 }
90
91 if (missing.length > 0) {
92 record({
93 n,
94 name: "Env sanity",
95 status: "fail",
96 reason: `missing required env: ${missing.join(", ")}`,
97 notes,
98 });
99 return;
100 }
101
102 record({
103 n,
104 name: "Env sanity",
105 status: notes.length > 0 ? "warn" : "pass",
106 notes,
107 });
108}
109
110// ---- Check 2 — migrations ---------------------------------------------------
111async function checkMigrations(n: number) {
112 if (!process.env.DATABASE_URL) {
113 record({
114 n,
115 name: "Migrations",
116 status: "fail",
117 reason: "DATABASE_URL not set — cannot run migrations",
118 });
119 return;
120 }
121
122 const cmd = ["bun", "run", "src/db/migrate.ts"];
123 const proc = Bun.spawn(cmd, {
124 cwd: process.cwd(),
125 stdout: "pipe",
126 stderr: "pipe",
127 env: process.env,
128 });
129 const exitCode = await proc.exited;
130
131 if (exitCode !== 0) {
132 const errText = await new Response(proc.stderr).text();
133 const tail = errText.trim().split("\n").slice(-3).join(" | ");
134 record({
135 n,
136 name: "Migrations",
137 status: "fail",
138 reason: `bun run db:migrate exited ${exitCode}: ${tail.slice(0, 200)}`,
139 });
140 return;
141 }
142
143 record({ n, name: "Migrations", status: "pass" });
144}
145
146// ---- Check 3 — repo dir -----------------------------------------------------
147async function checkRepoDir(n: number) {
148 const repoDir =
149 process.env.GIT_REPOS_PATH || join(process.cwd(), "repos");
150
151 try {
152 if (!(await pathExists(repoDir))) {
153 await mkdir(repoDir, { recursive: true });
154 }
155 const s = await stat(repoDir);
156 if (!s.isDirectory()) {
157 record({
158 n,
159 name: "Repo dir",
160 status: "fail",
161 reason: `${repoDir} exists but is not a directory`,
162 });
163 return;
164 }
165 if (!(await isWritable(repoDir))) {
166 record({
167 n,
168 name: "Repo dir",
169 status: "fail",
170 reason: `${repoDir} is not writable`,
171 });
172 return;
173 }
174 // Prove write by touching a sentinel.
175 const sentinel = join(repoDir, ".preflight-touch");
176 await writeFile(sentinel, String(Date.now()));
177 await rm(sentinel);
178 record({
179 n,
180 name: "Repo dir",
181 status: "pass",
182 notes: [`path=${repoDir}`],
183 });
184 } catch (err) {
185 record({
186 n,
187 name: "Repo dir",
188 status: "fail",
189 reason: err instanceof Error ? err.message : String(err),
190 });
191 }
192}
193
194// ---- Shared: spawn server for smoke tests ----------------------------------
195async function spawnServer(port: number) {
196 const proc = Bun.spawn(["bun", "src/index.ts"], {
197 cwd: process.cwd(),
198 stdout: "pipe",
199 stderr: "pipe",
200 env: { ...process.env, PORT: String(port) },
201 });
202 // Wait up to ~3s for it to start accepting connections.
203 const deadline = Date.now() + 3000;
204 while (Date.now() < deadline) {
205 try {
206 const r = await fetch(`http://127.0.0.1:${port}/healthz`, {
207 signal: AbortSignal.timeout(500),
208 });
209 // Any response — even 404 — means the server is up.
210 void r.text();
211 break;
212 } catch {
213 await new Promise((r) => setTimeout(r, 200));
214 }
215 }
216 return proc;
217}
218
219async function hitEndpoint(port: number, path: string) {
220 const res = await fetch(`http://127.0.0.1:${port}${path}`, {
221 signal: AbortSignal.timeout(3000),
222 });
223 const text = await res.text();
224 let body: unknown = text;
225 try {
226 body = JSON.parse(text);
227 } catch {
228 /* leave as text */
229 }
230 return { status: res.status, body, text };
231}
232
233// ---- Check 4 — /healthz smoke ----------------------------------------------
234async function checkHealthz(n: number) {
235 const port = Number(process.env.PREFLIGHT_PORT || 3999);
236 let proc: ReturnType<typeof Bun.spawn> | undefined;
237 try {
238 proc = await spawnServer(port);
239 const { status, body, text } = await hitEndpoint(port, "/healthz");
240 if (status !== 200) {
241 record({
242 n,
243 name: "Healthz smoke",
244 status: "fail",
245 reason: `status ${status}`,
246 });
247 return;
248 }
249 const ok =
250 (typeof body === "object" &&
251 body !== null &&
252 (body as { status?: string }).status === "ok") ||
253 /"?status"?\s*:\s*"?ok"?/i.test(text);
254 if (!ok) {
255 record({
256 n,
257 name: "Healthz smoke",
258 status: "fail",
259 reason: `200 but body did not look healthy: ${text.slice(0, 100)}`,
260 });
261 return;
262 }
263 record({ n, name: "Healthz smoke", status: "pass" });
264 } catch (err) {
265 record({
266 n,
267 name: "Healthz smoke",
268 status: "fail",
269 reason: err instanceof Error ? err.message : String(err),
270 });
271 } finally {
272 if (proc) {
273 try {
274 proc.kill();
275 await proc.exited;
276 } catch {
277 /* ignore */
278 }
279 }
280 }
281}
282
283// ---- Check 5 — /readyz smoke -----------------------------------------------
284async function checkReadyz(n: number) {
285 const port = Number(process.env.PREFLIGHT_PORT || 3999) + 1;
286 let proc: ReturnType<typeof Bun.spawn> | undefined;
287 try {
288 proc = await spawnServer(port);
289 const { status, body, text } = await hitEndpoint(port, "/readyz");
290 if (status === 200) {
291 const ok =
292 (typeof body === "object" &&
293 body !== null &&
294 (body as { status?: string }).status === "ok") ||
295 /ok|ready/i.test(text);
296 if (!ok) {
297 record({
298 n,
299 name: "Readyz smoke",
300 status: "warn",
301 reason: `200 but body looked degraded: ${text.slice(0, 100)}`,
302 });
303 return;
304 }
305 record({ n, name: "Readyz smoke", status: "pass" });
306 return;
307 }
308 if (status === 503) {
309 record({
310 n,
311 name: "Readyz smoke",
312 status: "warn",
313 reason: "503 — dependency reported degraded (DB?)",
314 });
315 return;
316 }
317 record({
318 n,
319 name: "Readyz smoke",
320 status: "warn",
321 reason: `unexpected status ${status}`,
322 });
323 } catch (err) {
324 record({
325 n,
326 name: "Readyz smoke",
327 status: "warn",
328 reason: err instanceof Error ? err.message : String(err),
329 });
330 } finally {
331 if (proc) {
332 try {
333 proc.kill();
334 await proc.exited;
335 } catch {
336 /* ignore */
337 }
338 }
339 }
340}
341
342// ---- Check 6 — test suite ---------------------------------------------------
343const BASELINE_PASS = 154;
344const BASELINE_FAIL = 55;
345
346async function checkTests(n: number) {
347 const proc = Bun.spawn(["bun", "test"], {
348 cwd: process.cwd(),
349 stdout: "pipe",
350 stderr: "pipe",
351 env: process.env,
352 });
353 const [outText, errText] = await Promise.all([
354 new Response(proc.stdout).text(),
355 new Response(proc.stderr).text(),
356 ]);
357 await proc.exited;
358 const combined = `${outText}\n${errText}`;
359
360 // Bun test summary lines look like " 42 pass" / " 3 fail".
361 const passMatch = combined.match(/(\d+)\s+pass\b/);
362 const failMatch = combined.match(/(\d+)\s+fail\b/);
363 const pass = passMatch ? Number(passMatch[1]) : 0;
364 const fail = failMatch ? Number(failMatch[1]) : 0;
365
366 const notes = [
367 `baseline: ${BASELINE_PASS} pass / ${BASELINE_FAIL} fail (known sandbox hono/jsx-dev-runtime errors)`,
368 `observed: ${pass} pass / ${fail} fail`,
369 ];
370
371 if (!passMatch && !failMatch) {
372 record({
373 n,
374 name: "Test suite",
375 status: "fail",
376 reason: "could not parse bun test output",
377 notes,
378 });
379 return;
380 }
381
382 if (pass < BASELINE_PASS) {
383 record({
384 n,
385 name: "Test suite",
386 status: "fail",
387 reason: `pass count dropped below baseline (${pass} < ${BASELINE_PASS})`,
388 notes,
389 });
390 return;
391 }
392
393 if (fail > BASELINE_FAIL) {
394 record({
395 n,
396 name: "Test suite",
397 status: "warn",
398 reason: `fail count above baseline (${fail} > ${BASELINE_FAIL}) but pass held`,
399 notes,
400 });
401 return;
402 }
403
404 record({ n, name: "Test suite", status: "pass", notes });
405}
406
407// ---- Check 7 — backup restore drill ----------------------------------------
408async function checkBackupDrill(n: number) {
409 if (process.env.PREFLIGHT_BACKUP_DRILL !== "1") {
410 record({
411 n,
412 name: "Backup restore drill",
413 status: "skip",
414 reason: "set PREFLIGHT_BACKUP_DRILL=1 to enable",
415 });
416 return;
417 }
418 const root = join(tmpdir(), `preflight-backup-${Date.now()}`);
419 const src = join(root, "src");
420 const dst = join(root, "dst");
421 try {
422 await mkdir(src, { recursive: true });
423 await mkdir(dst, { recursive: true });
424 const payload = `preflight ${new Date().toISOString()}`;
425 const srcFile = join(src, "hello.txt");
426 const dstFile = join(dst, "hello.txt");
427 await writeFile(srcFile, payload);
428
429 // Prefer rsync; fall back to raw copy.
430 const rsync = Bun.spawn(["rsync", "-a", `${src}/`, `${dst}/`], {
431 stdout: "pipe",
432 stderr: "pipe",
433 });
434 const code = await rsync.exited;
435 if (code !== 0) {
436 const buf = await readFile(srcFile);
437 await writeFile(dstFile, buf);
438 }
439
440 const got = await readFile(dstFile, "utf8");
441 if (got !== payload) {
442 record({
443 n,
444 name: "Backup restore drill",
445 status: "fail",
446 reason: "restored file differs from source",
447 });
448 return;
449 }
450 record({ n, name: "Backup restore drill", status: "pass" });
451 } catch (err) {
452 record({
453 n,
454 name: "Backup restore drill",
455 status: "fail",
456 reason: err instanceof Error ? err.message : String(err),
457 });
458 } finally {
459 await rm(root, { recursive: true, force: true }).catch(() => {});
460 }
461}
462
463// ---- Driver -----------------------------------------------------------------
464async function main() {
465 console.log(`${DIM}gluecron preflight — ${new Date().toISOString()}${RESET}`);
466
467 await checkEnv(1);
468 await checkMigrations(2);
469 await checkRepoDir(3);
470 await checkHealthz(4);
471 await checkReadyz(5);
472 await checkTests(6);
473 await checkBackupDrill(7);
474
475 const passed = results.filter((r) => r.status === "pass").length;
476 const failed = results.filter((r) => r.status === "fail").length;
477 const warned = results.filter((r) => r.status === "warn").length;
478 const skipped = results.filter((r) => r.status === "skip").length;
479
480 console.log("");
481 console.log(
482 `${passed} passed, ${failed} failed, ${warned} warned, ${skipped} skipped`
483 );
484
485 if (failed > 0) {
486 console.log(`${RED}preflight FAILED — do not deploy${RESET}`);
487 process.exit(1);
488 }
489 if (warned > 0) {
490 console.log(`${YELLOW}preflight passed with warnings${RESET}`);
491 } else {
492 console.log(`${GREEN}preflight clean — ready to deploy${RESET}`);
493 }
494 process.exit(0);
495}
496
497main().catch((err) => {
498 console.error("preflight crashed:", err);
499 process.exit(1);
500});
Addedsrc/__tests__/invites.test.ts+47−0View fileUnifiedSplit
@@ -0,0 +1,47 @@
1/**
2 * Invite token helpers + /invites/:token smoke.
3 *
4 * We exercise the pure-crypto helpers exhaustively (cheap, deterministic)
5 * and hit the route with a bogus token to prove the not-found branch works
6 * without needing a DB seeded invite. A DB-seeded happy-path test would
7 * require fixture plumbing that the existing collaborators.test.ts also
8 * avoids, so we stay consistent.
9 */
10
11import { describe, it, expect } from "bun:test";
12import {
13 generateInviteToken,
14 hashInviteToken,
15} from "../lib/invite-tokens";
16import app from "../app";
17
18describe("invite-tokens lib", () => {
19 it("generateInviteToken emits 32 hex chars and is unique across calls", () => {
20 const seen = new Set<string>();
21 for (let i = 0; i < 100; i++) {
22 const t = generateInviteToken();
23 expect(t).toMatch(/^[0-9a-f]{32}$/);
24 expect(seen.has(t)).toBe(false);
25 seen.add(t);
26 }
27 });
28
29 it("hashInviteToken is deterministic and differs per input", () => {
30 const a = generateInviteToken();
31 const b = generateInviteToken();
32 expect(hashInviteToken(a)).toBe(hashInviteToken(a));
33 expect(hashInviteToken(a)).not.toBe(hashInviteToken(b));
34 // sha256 hex is 64 chars.
35 expect(hashInviteToken(a)).toMatch(/^[0-9a-f]{64}$/);
36 });
37});
38
39describe("GET /invites/:token", () => {
40 it("returns 404 for a bogus token", async () => {
41 const res = await app.request("/invites/not-a-real-token-xxxxxxxxxxxxxxxx");
42 // 404 is the expected path. If the DB is unreachable in the test env the
43 // route's try/catch still maps that to not-found, so 404 is the single
44 // acceptable status.
45 expect(res.status).toBe(404);
46 });
47});
Addedsrc/__tests__/sse-client.test.ts+39−0View fileUnifiedSplit
@@ -0,0 +1,39 @@
1import { describe, it, expect } from "bun:test";
2import { liveSubscribeScript } from "../lib/sse-client";
3
4describe("liveSubscribeScript", () => {
5 it("returns a string containing EventSource and the topic path", () => {
6 const js = liveSubscribeScript({
7 topic: "repo:abc",
8 targetElementId: "live-feed",
9 });
10
11 expect(typeof js).toBe("string");
12 expect(js).toContain("EventSource");
13 // topic is JSON-encoded then concatenated with the /live-events/ prefix
14 // at runtime, so both must appear in the emitted script.
15 expect(js).toContain("/live-events/");
16 expect(js).toContain('"repo:abc"');
17 // targetElementId must also be JSON-escaped into the snippet.
18 expect(js).toContain('"live-feed"');
19 });
20
21 it("JSON-escapes bad topic strings to prevent </script> injection", () => {
22 const malicious = '</script><script>alert(1)</script>';
23 const js = liveSubscribeScript({
24 topic: malicious,
25 targetElementId: "feed",
26 });
27
28 // Raw closing-tag sequence MUST NOT appear anywhere in the output —
29 // JSON.stringify escapes `<` when emitted for HTML, but we additionally
30 // verify the literal bad sequence is absent.
31 expect(js).not.toContain("</script>");
32 // Unescaped alert call (in the exact bad form) must not appear.
33 expect(js).not.toContain("<script>alert(1)");
34 // The topic should still be represented (escaped) so the subscription
35 // remains functional — at minimum the inner `alert(1)` literal is there
36 // as an escaped JSON string, but the HTML-breakout is gone.
37 expect(js).toContain("EventSource");
38 });
39});
Addedsrc/__tests__/sse.test.ts+108−0View fileUnifiedSplit
@@ -0,0 +1,108 @@
1/**
2 * Unit tests for src/lib/sse.ts — the in-process pub/sub broadcaster.
3 *
4 * These tests exercise the pure module-level state. Because the registry is
5 * a module-level `Map`, each test uses a unique topic name so cross-test
6 * leakage is impossible; we also explicitly unsubscribe everything we
7 * subscribe.
8 */
9
10import { describe, it, expect } from "bun:test";
11import {
12 publish,
13 subscribe,
14 topicSubscriberCount,
15 type SSEEvent,
16} from "../lib/sse";
17
18describe("sse broadcaster", () => {
19 it("publish with no subscribers is a no-op", () => {
20 // No throw, no side effect. topicSubscriberCount stays zero.
21 expect(() =>
22 publish("repo:no-subs", { data: { hello: "world" } })
23 ).not.toThrow();
24 expect(topicSubscriberCount("repo:no-subs")).toBe(0);
25 });
26
27 it("a subscriber receives events published to its topic", () => {
28 const received: SSEEvent[] = [];
29 const unsub = subscribe("repo:alpha", (e) => received.push(e));
30
31 expect(topicSubscriberCount("repo:alpha")).toBe(1);
32
33 publish("repo:alpha", { event: "push", data: { sha: "deadbeef" } });
34 publish("repo:alpha", { event: "star", data: { count: 7 }, id: "42" });
35
36 expect(received).toHaveLength(2);
37 expect(received[0]?.event).toBe("push");
38 expect((received[0]?.data as any).sha).toBe("deadbeef");
39 expect(received[1]?.id).toBe("42");
40
41 unsub();
42 expect(topicSubscriberCount("repo:alpha")).toBe(0);
43 });
44
45 it("multiple subscribers on the same topic all receive each event", () => {
46 const a: SSEEvent[] = [];
47 const b: SSEEvent[] = [];
48 const c: SSEEvent[] = [];
49 const unsubA = subscribe("pr:beta", (e) => a.push(e));
50 const unsubB = subscribe("pr:beta", (e) => b.push(e));
51 const unsubC = subscribe("pr:beta", (e) => c.push(e));
52
53 expect(topicSubscriberCount("pr:beta")).toBe(3);
54
55 publish("pr:beta", { event: "review", data: "submitted" });
56
57 expect(a).toHaveLength(1);
58 expect(b).toHaveLength(1);
59 expect(c).toHaveLength(1);
60 expect(a[0]?.data).toBe("submitted");
61
62 unsubA();
63 unsubB();
64 unsubC();
65 expect(topicSubscriberCount("pr:beta")).toBe(0);
66 });
67
68 it("unsubscribe stops delivery for that handler only", () => {
69 const keeper: SSEEvent[] = [];
70 const leaver: SSEEvent[] = [];
71 const unsubKeeper = subscribe("user:gamma", (e) => keeper.push(e));
72 const unsubLeaver = subscribe("user:gamma", (e) => leaver.push(e));
73
74 publish("user:gamma", { data: "first" });
75 expect(keeper).toHaveLength(1);
76 expect(leaver).toHaveLength(1);
77
78 unsubLeaver();
79 expect(topicSubscriberCount("user:gamma")).toBe(1);
80
81 publish("user:gamma", { data: "second" });
82 expect(keeper).toHaveLength(2);
83 expect(leaver).toHaveLength(1); // unchanged — leaver is gone
84
85 unsubKeeper();
86 expect(topicSubscriberCount("user:gamma")).toBe(0);
87
88 // Topic entry should be cleaned up after last unsubscribe.
89 publish("user:gamma", { data: "third" });
90 expect(keeper).toHaveLength(2);
91 });
92
93 it("a throwing handler does not prevent other handlers from receiving", () => {
94 const good: SSEEvent[] = [];
95 const unsubBad = subscribe("repo:delta", () => {
96 throw new Error("boom");
97 });
98 const unsubGood = subscribe("repo:delta", (e) => good.push(e));
99
100 expect(() =>
101 publish("repo:delta", { data: "payload" })
102 ).not.toThrow();
103 expect(good).toHaveLength(1);
104
105 unsubBad();
106 unsubGood();
107 });
108});
Addedsrc/__tests__/team-collaborators.test.ts+32−0View fileUnifiedSplit
@@ -0,0 +1,32 @@
1/**
2 * Team-collaborator routes — auth guard smoke.
3 *
4 * Mirrors src/__tests__/collaborators.test.ts. Two assertions:
5 * 1. unauthenticated GET redirects to /login (requireAuth)
6 * 2. an authed non-owner is blocked — either 403 (inline owner check) or
7 * redirected away (302 to /login when the stub cookie can't resolve).
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12
13describe("team-collaborators — auth guard", () => {
14 it("GET /:owner/:repo/settings/collaborators/teams without auth redirects to /login", async () => {
15 const res = await app.request(
16 "/somebody/some-repo/settings/collaborators/teams"
17 );
18 expect(res.status).toBe(302);
19 expect(res.headers.get("location") || "").toContain("/login");
20 });
21
22 it("GET as an authed non-owner returns 403 or redirects away", async () => {
23 const res = await app.request(
24 "/some-owner/some-repo/settings/collaborators/teams",
25 { headers: { cookie: "session=not-a-real-token" } }
26 );
27 expect([302, 403, 404]).toContain(res.status);
28 if (res.status === 302) {
29 expect(res.headers.get("location") || "").toContain("/login");
30 }
31 });
32});
Modifiedsrc/db/schema.ts+4−0View fileUnifiedSplit
@@ -2389,6 +2389,10 @@ export const repoCollaborators = pgTable(
23892389 }),
23902390 invitedAt: timestamp("invited_at").defaultNow().notNull(),
23912391 acceptedAt: timestamp("accepted_at"),
2392 // sha256(plaintext) of the outstanding invite token. Set when the owner
2393 // sends an invite, cleared when the invitee accepts. NULL on older rows
2394 // that were auto-accepted before the email flow existed.
2395 inviteTokenHash: text("invite_token_hash"),
23922396 },
23932397 (table) => [
23942398 uniqueIndex("repo_collaborators_repo_user_uq").on(
Addedsrc/lib/invite-tokens.ts+29−0View fileUnifiedSplit
@@ -0,0 +1,29 @@
1/**
2 * Invite tokens — opaque secrets used to gate collaborator invitation links.
3 *
4 * The plaintext token is emailed to the invitee as a URL fragment; only its
5 * sha256 hash is persisted on the `repo_collaborators` row. When the invitee
6 * clicks the link, we re-hash the presented token and match it against the
7 * stored hash. Storing only the hash means a DB compromise does not leak
8 * live invite URLs.
9 *
10 * Token format: 32 hex chars (16 bytes of entropy). Plenty of collision
11 * resistance for short-lived single-use invites, and short enough to paste.
12 */
13
14import { randomBytes, createHash } from "crypto";
15
16/**
17 * Generate a fresh invite token — 32 hex chars, cryptographically random.
18 */
19export function generateInviteToken(): string {
20 return randomBytes(16).toString("hex");
21}
22
23/**
24 * Hash an invite token for storage/lookup. Deterministic sha256 hex so the
25 * same token always maps to the same row.
26 */
27export function hashInviteToken(token: string): string {
28 return createHash("sha256").update(token).digest("hex");
29}
Addedsrc/lib/sse-client.ts+61−0View fileUnifiedSplit
@@ -0,0 +1,61 @@
1/**
2 * SSE client helper — builds a plain-JS initialization snippet that can be
3 * dropped into an SSR'd view via a <script> tag. Intentionally returns a
4 * string (not a function export) so it works without any bundler.
5 *
6 * The returned snippet:
7 * - opens an EventSource on /live-events/<topic>
8 * - for each message event, parses JSON and calls the user-supplied
9 * formatFn (expected to return an HTML string)
10 * - appends the HTML to the target element
11 * - reconnects with a 1-second backoff on error
12 * - no-ops gracefully if EventSource is not supported
13 */
14
15/**
16 * JSON-encode a value for safe inlining inside an HTML <script> block.
17 *
18 * Plain JSON.stringify is not sufficient: a string containing "</script>"
19 * would break out of the surrounding <script> tag. We additionally escape
20 * `<`, `>`, `&`, and U+2028/U+2029 so the result is safe to splice verbatim
21 * into server-rendered HTML.
22 */
23function safeJsonForScript(v: unknown): string {
24 return JSON.stringify(v)
25 .replace(/</g, "\\u003C")
26 .replace(/>/g, "\\u003E")
27 .replace(/&/g, "\\u0026")
28 .replace(/
/g, "\\u2028")
29 .replace(/
/g, "\\u2029");
30}
31
32export function liveSubscribeScript(args: {
33 /** Topic name, e.g. "repo:abc" or "user:42" */
34 topic: string;
35 /** id of the DOM element that receives appended event HTML */
36 targetElementId: string;
37 /** Optional JS function body taking (event) and returning an HTML string.
38 * If omitted, a default escapes the JSON payload as text. */
39 formatFn?: string;
40}): string {
41 const topic = safeJsonForScript(args.topic);
42 const targetId = safeJsonForScript(args.targetElementId);
43 const formatFnBody =
44 args.formatFn ??
45 "return '<li>' + String(event && event.data || '').replace(/[&<>\"']/g,function(c){return {'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c];}) + '</li>';";
46
47 // Keep compact to stay under 2KB.
48 return (
49 "(function(){try{" +
50 "if(typeof EventSource==='undefined')return;" +
51 "var t=" + topic + ",id=" + targetId + ";" +
52 "var el=document.getElementById(id);if(!el)return;" +
53 "function fmt(event){" + formatFnBody + "}" +
54 "var es,delay=1000;" +
55 "function connect(){" +
56 "try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){setTimeout(connect,delay);return;}" +
57 "es.onmessage=function(m){try{var d=JSON.parse(m.data);var h=fmt(d);if(h&&el)el.insertAdjacentHTML('beforeend',h);}catch(e){}};" +
58 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
59 "}connect();}catch(e){}})();"
60 );
61}
Addedsrc/lib/sse.ts+71−0View fileUnifiedSplit
@@ -0,0 +1,71 @@
1/**
2 * In-process topic-based pub/sub broadcaster for Server-Sent Events.
3 *
4 * Module-level `Map<topic, Set<handler>>`. `publish` iterates subscribers
5 * synchronously (fire-and-forget). Handlers are expected not to throw — we
6 * swallow exceptions defensively so one misbehaving subscriber cannot take
7 * down the publisher or starve its peers.
8 *
9 * TODO(scale): this is deliberately single-process / in-memory. Horizontally
10 * scaled deploys (multiple Bun instances behind a load balancer) will need
11 * a cross-node fanout layer — likely Redis pub/sub or NATS — that feeds this
12 * local broadcaster on each node. Until then, SSE subscribers only receive
13 * events published by the same process handling their connection.
14 */
15
16export type SSEEvent = {
17 event?: string;
18 data: unknown;
19 id?: string;
20};
21
22type Handler = (event: SSEEvent) => void;
23
24const topics = new Map<string, Set<Handler>>();
25
26/**
27 * Publish an event to every subscriber of `topic`. No-op if the topic has
28 * no subscribers. Handler exceptions are caught and swallowed so a single
29 * broken subscriber cannot break fanout for its peers.
30 */
31export function publish(topic: string, event: SSEEvent): void {
32 const subs = topics.get(topic);
33 if (!subs || subs.size === 0) return;
34 for (const handler of subs) {
35 try {
36 handler(event);
37 } catch {
38 // Swallow — handlers are fire-and-forget and must not disrupt fanout.
39 }
40 }
41}
42
43/**
44 * Register a handler for `topic`. Returns a cleanup function that removes
45 * the handler (and drops the topic's entry when its last subscriber leaves).
46 */
47export function subscribe(
48 topic: string,
49 handler: Handler
50): () => void {
51 let subs = topics.get(topic);
52 if (!subs) {
53 subs = new Set<Handler>();
54 topics.set(topic, subs);
55 }
56 subs.add(handler);
57
58 return () => {
59 const current = topics.get(topic);
60 if (!current) return;
61 current.delete(handler);
62 if (current.size === 0) {
63 topics.delete(topic);
64 }
65 };
66}
67
68/** Number of active subscribers on a topic (0 if unknown). */
69export function topicSubscriberCount(topic: string): number {
70 return topics.get(topic)?.size ?? 0;
71}
Modifiedsrc/routes/collaborators.tsx+46−6View fileUnifiedSplit
@@ -1,9 +1,10 @@
11/**
22 * Repository collaborators — add, list, remove.
33 *
4 * Owner-only. v1 auto-accepts the invite (no email flow yet): when the owner
5 * adds a user by username, we insert a `repo_collaborators` row with
6 * `acceptedAt = now()` so the grantee is immediately active.
4 * Owner-only. Adding a collaborator inserts a pending `repo_collaborators`
5 * row with `acceptedAt = NULL` and a hashed invite token, then emails the
6 * invitee a `/invites/:token` link. The grantee becomes active only after
7 * they click the link (see `src/routes/invites.tsx`).
78 *
89 * Collaborator lifecycle matrix:
910 * - Add: POST /:owner/:repo/settings/collaborators/add
@@ -27,6 +28,8 @@ import { Layout } from "../views/layout";
2728import { RepoHeader } from "../views/components";
2829import { softAuth, requireAuth } from "../middleware/auth";
2930import type { AuthEnv } from "../middleware/auth";
31import { generateInviteToken, hashInviteToken } from "../lib/invite-tokens";
32import { sendEmail, absoluteUrl } from "../lib/email";
3033import {
3134 Container,
3235 Form,
@@ -119,6 +122,12 @@ collaboratorRoutes.get(
119122 <h2 style="margin-bottom: 16px">Collaborators</h2>
120123 <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px">
121124 <a href={`/${ownerName}/${repoName}/settings`}>← Back to settings</a>
125 {" | "}
126 <a
127 href={`/${ownerName}/${repoName}/settings/collaborators/teams`}
128 >
129 Invite a team →
130 </a>
122131 </p>
123132 {success && (
124133 <Alert variant="success">{decodeURIComponent(success)}</Alert>
@@ -260,25 +269,56 @@ collaboratorRoutes.post(
260269 .limit(1);
261270
262271 if (existing) {
272 // Re-inviting an existing collaborator just updates the role. We don't
273 // re-issue a token here — if the prior invite hasn't been accepted the
274 // existing token is still valid; if it has, they're already in.
263275 await db
264276 .update(repoCollaborators)
265 .set({ role, acceptedAt: existing.acceptedAt ?? new Date() })
277 .set({ role })
266278 .where(eq(repoCollaborators.id, existing.id));
267279 return c.redirect(
268280 `/${ownerName}/${repoName}/settings/collaborators?success=Role+updated`
269281 );
270282 }
271283
284 // Fresh invite: generate a single-use token, store only its hash, and
285 // email the plaintext to the invitee. acceptedAt stays NULL until they
286 // click through /invites/:token.
287 const token = generateInviteToken();
288 const tokenHash = hashInviteToken(token);
289
272290 await db.insert(repoCollaborators).values({
273291 repositoryId: repo.id,
274292 userId: invitee.id,
275293 role,
276294 invitedBy: user.id,
277 acceptedAt: new Date(), // v1 auto-accept; no email invite flow yet
295 inviteTokenHash: tokenHash,
278296 });
279297
298 // Email delivery degrades gracefully — a failed send should never block
299 // the invite row from existing. Owner can resend / share the URL by hand.
300 const inviteUrl = absoluteUrl(`/invites/${token}`);
301 try {
302 const result = await sendEmail({
303 to: invitee.email,
304 subject: `You've been invited to ${ownerName}/${repoName}`,
305 text: `You've been invited to ${ownerName}/${repoName}. Click: ${inviteUrl}`,
306 });
307 if (!result.ok) {
308 console.error(
309 `[collaborators] invite email send failed for ${invitee.username}:`,
310 result.error || result.skipped
311 );
312 }
313 } catch (err) {
314 console.error(
315 `[collaborators] invite email threw for ${invitee.username}:`,
316 err
317 );
318 }
319
280320 return c.redirect(
281 `/${ownerName}/${repoName}/settings/collaborators?success=Collaborator+added`
321 `/${ownerName}/${repoName}/settings/collaborators?success=Invite+sent`
282322 );
283323 }
284324);
Modifiedsrc/routes/dashboard.tsx+4−0View fileUnifiedSplit
@@ -25,6 +25,7 @@ import {
2525 pullRequests,
2626} from "../db/schema";
2727import { Layout } from "../views/layout";
28import { LiveFeed } from "../views/live-feed";
2829import { softAuth, requireAuth } from "../middleware/auth";
2930import type { AuthEnv } from "../middleware/auth";
3031import {
@@ -351,6 +352,9 @@ git push -u gluecron main</code></pre>
351352 </>
352353 )}
353354
355 {/* ─── Live Activity (SSE) ─── */}
356 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
357
354358 {/* ─── Quick Links ─── */}
355359 <div style="margin-top: 32px; display: flex; gap: 16px; flex-wrap: wrap">
356360 <a href="/explore" class="btn">Browse public repos</a>
Addedsrc/routes/invites.tsx+146−0View fileUnifiedSplit
@@ -0,0 +1,146 @@
1/**
2 * Collaborator invite acceptance — the flip side of POST /add.
3 *
4 * When an owner invites a user, `src/routes/collaborators.tsx` generates a
5 * random token, stores its sha256 on the `repo_collaborators` row, and
6 * emails the plaintext link. This file handles that link being clicked.
7 *
8 * Flow:
9 * GET /invites/:token
10 * → hash the presented token, find the pending row, render "Accept"
11 * POST /invites/:token
12 * → same lookup, assert the invite is for the authed user, flip
13 * `acceptedAt` to now() and null the hash so the link is one-shot.
14 * Redirect to /:owner/:repo on success.
15 *
16 * Not-found / already-accepted / wrong-user paths all degrade safely (404 /
17 * 403) without leaking which of those branches triggered.
18 */
19
20import { Hono } from "hono";
21import { eq, and } from "drizzle-orm";
22import { db } from "../db";
23import { repositories, users, repoCollaborators } from "../db/schema";
24import { Layout } from "../views/layout";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { Container, Form, Button, EmptyState, Alert } from "../views/ui";
28import { hashInviteToken } from "../lib/invite-tokens";
29
30const inviteRoutes = new Hono<AuthEnv>();
31
32inviteRoutes.use("*", softAuth);
33
34/**
35 * Resolve the pending invite by token hash + join repo/owner for display.
36 * Returns null for not-found, already-accepted, or DB errors — the caller
37 * surfaces a single 404 in all cases so we don't leak invite existence.
38 */
39async function resolvePendingInvite(token: string) {
40 if (!token) return null;
41 let hash: string;
42 try {
43 hash = hashInviteToken(token);
44 } catch {
45 return null;
46 }
47 try {
48 const [row] = await db
49 .select({
50 id: repoCollaborators.id,
51 userId: repoCollaborators.userId,
52 acceptedAt: repoCollaborators.acceptedAt,
53 inviteTokenHash: repoCollaborators.inviteTokenHash,
54 repositoryId: repoCollaborators.repositoryId,
55 role: repoCollaborators.role,
56 repoName: repositories.name,
57 ownerId: repositories.ownerId,
58 })
59 .from(repoCollaborators)
60 .innerJoin(
61 repositories,
62 eq(repositories.id, repoCollaborators.repositoryId)
63 )
64 .where(eq(repoCollaborators.inviteTokenHash, hash))
65 .limit(1);
66 if (!row) return null;
67 if (row.acceptedAt) return null;
68 const [owner] = await db
69 .select({ username: users.username })
70 .from(users)
71 .where(eq(users.id, row.ownerId))
72 .limit(1);
73 if (!owner) return null;
74 return { ...row, ownerName: owner.username };
75 } catch {
76 return null;
77 }
78}
79
80// ─── Display accept page ────────────────────────────────────────────────────
81
82inviteRoutes.get("/invites/:token", async (c) => {
83 const { token } = c.req.param();
84 const user = c.get("user");
85 const invite = await resolvePendingInvite(token);
86 if (!invite) return c.notFound();
87
88 return c.html(
89 <Layout title="Accept invitation" user={user}>
90 <Container maxWidth={600}>
91 <h2 style="margin-bottom: 16px">
92 Accept invitation to {invite.ownerName}/{invite.repoName}
93 </h2>
94 <p style="color:var(--text-muted);margin-bottom:24px">
95 You've been invited as a <strong>{invite.role}</strong> collaborator
96 on this repository.
97 </p>
98 {!user && (
99 <Alert variant="info">
100 You need to{" "}
101 <a href={`/login?next=/invites/${token}`}>sign in</a> before
102 accepting this invitation.
103 </Alert>
104 )}
105 {user && (
106 <Form method="post" action={`/invites/${token}`}>
107 <Button type="submit" variant="primary">
108 Accept invitation
109 </Button>
110 </Form>
111 )}
112 </Container>
113 </Layout>
114 );
115});
116
117// ─── Accept (POST) ──────────────────────────────────────────────────────────
118
119inviteRoutes.post("/invites/:token", requireAuth, async (c) => {
120 const { token } = c.req.param();
121 const user = c.get("user")!;
122 const invite = await resolvePendingInvite(token);
123 if (!invite) return c.notFound();
124
125 // The invite is bound to a specific user at creation time — reject if
126 // someone else is clicking the link from a shared inbox.
127 if (invite.userId !== user.id) {
128 return c.html(
129 <Layout title="Forbidden" user={user}>
130 <EmptyState title="Not your invitation">
131 <p>This invitation was sent to a different account.</p>
132 </EmptyState>
133 </Layout>,
134 403
135 );
136 }
137
138 await db
139 .update(repoCollaborators)
140 .set({ acceptedAt: new Date(), inviteTokenHash: null })
141 .where(eq(repoCollaborators.id, invite.id));
142
143 return c.redirect(`/${invite.ownerName}/${invite.repoName}`);
144});
145
146export default inviteRoutes;
Addedsrc/routes/live-events.ts+148−0View fileUnifiedSplit
@@ -0,0 +1,148 @@
1/**
2 * SSE endpoint: `GET /live-events/:topic`.
3 *
4 * Topic format: `repo:{repoId}`, `pr:{prId}`, `user:{userId}`. The regex
5 * `^[a-z]+:[a-zA-Z0-9\-]+$` is enforced; anything else is a 400.
6 *
7 * Auth / authorization:
8 * - Runs behind softAuth so we have the viewer (or null).
9 * - For `repo:{repoId}` topics, we do a cheap DB check that the viewer has
10 * read access via `resolveRepoAccess`. `pr:` and `user:` topics currently
11 * only require a valid topic string — when we add PR-level privacy we'll
12 * extend this handler in place.
13 *
14 * Transport:
15 * - `text/event-stream` with keep-alive + nginx-friendly `X-Accel-Buffering`.
16 * - We write `id:` / `event:` / `data:` blocks per SSEEvent and send a
17 * `: ping` comment every 25s to keep intermediaries from timing out.
18 * - On stream close we unsubscribe and clear the heartbeat timer.
19 */
20
21import { Hono } from "hono";
22import { eq } from "drizzle-orm";
23import { softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { db } from "../db";
26import { repositories } from "../db/schema";
27import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
28import { subscribe, type SSEEvent } from "../lib/sse";
29
30const app = new Hono<AuthEnv>();
31
32const TOPIC_RE = /^[a-z]+:[a-zA-Z0-9\-]+$/;
33const HEARTBEAT_MS = 25_000;
34
35app.get("/live-events/:topic", softAuth, async (c) => {
36 const topic = c.req.param("topic");
37 if (!topic || !TOPIC_RE.test(topic)) {
38 return c.json({ error: "Invalid topic" }, 400);
39 }
40
41 const user = c.get("user") ?? null;
42 const colon = topic.indexOf(":");
43 const kind = topic.slice(0, colon);
44 const id = topic.slice(colon + 1);
45
46 // For repo topics, gate on read access. Other topic kinds pass through.
47 if (kind === "repo") {
48 try {
49 const [repo] = await db
50 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
51 .from(repositories)
52 .where(eq(repositories.id, id))
53 .limit(1);
54
55 if (!repo) {
56 return c.json({ error: "Not found" }, 404);
57 }
58
59 const access = await resolveRepoAccess({
60 repoId: repo.id,
61 userId: user?.id ?? null,
62 isPublic: !repo.isPrivate,
63 });
64
65 if (!satisfiesAccess(access, "read")) {
66 return c.json({ error: "Forbidden" }, 403);
67 }
68 } catch {
69 return c.json({ error: "Not found" }, 404);
70 }
71 }
72
73 const encoder = new TextEncoder();
74
75 const stream = new ReadableStream<Uint8Array>({
76 start(controller) {
77 let closed = false;
78
79 const safeEnqueue = (chunk: string) => {
80 if (closed) return;
81 try {
82 controller.enqueue(encoder.encode(chunk));
83 } catch {
84 // Controller already closed — mark local state so we stop trying.
85 closed = true;
86 }
87 };
88
89 // Initial comment flushes headers on some proxies.
90 safeEnqueue(": open\n\n");
91
92 const unsubscribe = subscribe(topic, (event: SSEEvent) => {
93 let payload = "";
94 if (event.id !== undefined) payload += `id: ${event.id}\n`;
95 if (event.event !== undefined) payload += `event: ${event.event}\n`;
96 const data =
97 typeof event.data === "string"
98 ? event.data
99 : JSON.stringify(event.data);
100 // SSE `data:` lines must not contain raw newlines — split if present.
101 for (const line of data.split("\n")) {
102 payload += `data: ${line}\n`;
103 }
104 payload += "\n";
105 safeEnqueue(payload);
106 });
107
108 const heartbeat = setInterval(() => {
109 safeEnqueue(": ping\n\n");
110 }, HEARTBEAT_MS);
111
112 const cleanup = () => {
113 if (closed) return;
114 closed = true;
115 clearInterval(heartbeat);
116 unsubscribe();
117 try {
118 controller.close();
119 } catch {
120 // Already closed — nothing to do.
121 }
122 };
123
124 // Client-side abort (navigation, tab close) surfaces via the request's
125 // AbortSignal. Bun's fetch-style request exposes this on `c.req.raw`.
126 const signal = c.req.raw.signal;
127 if (signal) {
128 if (signal.aborted) {
129 cleanup();
130 } else {
131 signal.addEventListener("abort", cleanup, { once: true });
132 }
133 }
134 },
135 });
136
137 return new Response(stream, {
138 status: 200,
139 headers: {
140 "Content-Type": "text/event-stream; charset=utf-8",
141 "Cache-Control": "no-cache, no-transform",
142 Connection: "keep-alive",
143 "X-Accel-Buffering": "no",
144 },
145 });
146});
147
148export default app;
Modifiedsrc/routes/releases.tsx+6−4View fileUnifiedSplit
@@ -24,6 +24,7 @@ import { Layout } from "../views/layout";
2424import { RepoHeader, RepoNav } from "../views/components";
2525import { softAuth, requireAuth } from "../middleware/auth";
2626import type { AuthEnv } from "../middleware/auth";
27import { requireRepoAccess } from "../middleware/repo-access";
2728import {
2829 listBranches,
2930 listTags,
@@ -59,7 +60,7 @@ async function loadRepo(owner: string, repo: string) {
5960 return row;
6061}
6162
62releasesRoute.get("/:owner/:repo/releases", async (c) => {
63releasesRoute.get("/:owner/:repo/releases", requireRepoAccess("read"), async (c) => {
6364 const user = c.get("user");
6465 const { owner, repo } = c.req.param();
6566 const repoRow = await loadRepo(owner, repo);
@@ -179,7 +180,7 @@ releasesRoute.get("/:owner/:repo/releases", async (c) => {
179180 );
180181});
181182
182releasesRoute.get("/:owner/:repo/releases/new", requireAuth, async (c) => {
183releasesRoute.get("/:owner/:repo/releases/new", requireAuth, requireRepoAccess("write"), async (c) => {
183184 const user = c.get("user")!;
184185 const { owner, repo } = c.req.param();
185186 const repoRow = await loadRepo(owner, repo);
@@ -267,7 +268,7 @@ releasesRoute.get("/:owner/:repo/releases/new", requireAuth, async (c) => {
267268 );
268269});
269270
270releasesRoute.post("/:owner/:repo/releases", requireAuth, async (c) => {
271releasesRoute.post("/:owner/:repo/releases", requireAuth, requireRepoAccess("write"), async (c) => {
271272 const user = c.get("user")!;
272273 const { owner, repo } = c.req.param();
273274 const repoRow = await loadRepo(owner, repo);
@@ -382,7 +383,7 @@ releasesRoute.post("/:owner/:repo/releases", requireAuth, async (c) => {
382383 return c.redirect(`/${owner}/${repo}/releases/${encodeURIComponent(tag)}`);
383384});
384385
385releasesRoute.get("/:owner/:repo/releases/:tag", async (c) => {
386releasesRoute.get("/:owner/:repo/releases/:tag", requireRepoAccess("read"), async (c) => {
386387 const user = c.get("user");
387388 const { owner, repo } = c.req.param();
388389 const tag = decodeURIComponent(c.req.param("tag"));
@@ -464,6 +465,7 @@ releasesRoute.get("/:owner/:repo/releases/:tag", async (c) => {
464465releasesRoute.post(
465466 "/:owner/:repo/releases/:tag/delete",
466467 requireAuth,
468 requireRepoAccess("write"),
467469 async (c) => {
468470 const user = c.get("user")!;
469471 const { owner, repo } = c.req.param();
Modifiedsrc/routes/repo-settings.tsx+7−2View fileUnifiedSplit
@@ -10,6 +10,7 @@ import { Layout } from "../views/layout";
1010import { RepoHeader } from "../views/components";
1111import { softAuth, requireAuth } from "../middleware/auth";
1212import type { AuthEnv } from "../middleware/auth";
13import { requireRepoAccess } from "../middleware/repo-access";
1314import { listBranches } from "../git/repository";
1415import { rm } from "fs/promises";
1516import {
@@ -29,7 +30,7 @@ const repoSettings = new Hono<AuthEnv>();
2930repoSettings.use("*", softAuth);
3031
3132// Settings page
32repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
33repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
3334 const { owner: ownerName, repo: repoName } = c.req.param();
3435 const user = c.get("user")!;
3536 const success = c.req.query("success");
@@ -253,7 +254,7 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
253254});
254255
255256// Save settings
256repoSettings.post("/:owner/:repo/settings", requireAuth, async (c) => {
257repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
257258 const { owner: ownerName, repo: repoName } = c.req.param();
258259 const user = c.get("user")!;
259260 const body = await c.req.parseBody();
@@ -289,6 +290,7 @@ repoSettings.post("/:owner/:repo/settings", requireAuth, async (c) => {
289290repoSettings.post(
290291 "/:owner/:repo/settings/template",
291292 requireAuth,
293 requireRepoAccess("admin"),
292294 async (c) => {
293295 const { owner: ownerName, repo: repoName } = c.req.param();
294296 const user = c.get("user")!;
@@ -323,6 +325,7 @@ repoSettings.post(
323325repoSettings.post(
324326 "/:owner/:repo/settings/transfer",
325327 requireAuth,
328 requireRepoAccess("admin"),
326329 async (c) => {
327330 const { owner: ownerName, repo: repoName } = c.req.param();
328331 const user = c.get("user")!;
@@ -403,6 +406,7 @@ repoSettings.post(
403406repoSettings.post(
404407 "/:owner/:repo/settings/archive",
405408 requireAuth,
409 requireRepoAccess("admin"),
406410 async (c) => {
407411 const { owner: ownerName, repo: repoName } = c.req.param();
408412 const user = c.get("user")!;
@@ -437,6 +441,7 @@ repoSettings.post(
437441repoSettings.post(
438442 "/:owner/:repo/settings/delete",
439443 requireAuth,
444 requireRepoAccess("admin"),
440445 async (c) => {
441446 const { owner: ownerName, repo: repoName } = c.req.param();
442447 const user = c.get("user")!;
Modifiedsrc/routes/rulesets.tsx+8−2View fileUnifiedSplit
@@ -18,6 +18,7 @@ import { Layout } from "../views/layout";
1818import { RepoHeader, RepoNav } from "../views/components";
1919import { requireAuth, softAuth } from "../middleware/auth";
2020import type { AuthEnv } from "../middleware/auth";
21import { requireRepoAccess } from "../middleware/repo-access";
2122import { audit } from "../lib/notify";
2223import {
2324 RULE_TYPES,
@@ -79,7 +80,7 @@ function ruleDescription(type: string, params: Record<string, unknown>): string
7980
8081// ---------- List + create ----------
8182
82rulesets.get("/:owner/:repo/settings/rulesets", requireAuth, async (c) => {
83rulesets.get("/:owner/:repo/settings/rulesets", requireAuth, requireRepoAccess("admin"), async (c) => {
8384 const ctx = await gate(c);
8485 if (ctx instanceof Response) return ctx;
8586 const { ownerName, repoName, repo, user } = ctx;
@@ -189,7 +190,7 @@ rulesets.get("/:owner/:repo/settings/rulesets", requireAuth, async (c) => {
189190 );
190191});
191192
192rulesets.post("/:owner/:repo/settings/rulesets", requireAuth, async (c) => {
193rulesets.post("/:owner/:repo/settings/rulesets", requireAuth, requireRepoAccess("admin"), async (c) => {
193194 const ctx = await gate(c);
194195 if (ctx instanceof Response) return ctx;
195196 const { ownerName, repoName, repo, user } = ctx;
@@ -224,6 +225,7 @@ rulesets.post("/:owner/:repo/settings/rulesets", requireAuth, async (c) => {
224225rulesets.get(
225226 "/:owner/:repo/settings/rulesets/:id",
226227 requireAuth,
228 requireRepoAccess("admin"),
227229 async (c) => {
228230 const ctx = await gate(c);
229231 if (ctx instanceof Response) return ctx;
@@ -397,6 +399,7 @@ rulesets.get(
397399rulesets.post(
398400 "/:owner/:repo/settings/rulesets/:id",
399401 requireAuth,
402 requireRepoAccess("admin"),
400403 async (c) => {
401404 const ctx = await gate(c);
402405 if (ctx instanceof Response) return ctx;
@@ -429,6 +432,7 @@ rulesets.post(
429432rulesets.post(
430433 "/:owner/:repo/settings/rulesets/:id/delete",
431434 requireAuth,
435 requireRepoAccess("admin"),
432436 async (c) => {
433437 const ctx = await gate(c);
434438 if (ctx instanceof Response) return ctx;
@@ -455,6 +459,7 @@ rulesets.post(
455459rulesets.post(
456460 "/:owner/:repo/settings/rulesets/:id/rules",
457461 requireAuth,
462 requireRepoAccess("admin"),
458463 async (c) => {
459464 const ctx = await gate(c);
460465 if (ctx instanceof Response) return ctx;
@@ -487,6 +492,7 @@ rulesets.post(
487492rulesets.post(
488493 "/:owner/:repo/settings/rulesets/:id/rules/:rid/delete",
489494 requireAuth,
495 requireRepoAccess("admin"),
490496 async (c) => {
491497 const ctx = await gate(c);
492498 if (ctx instanceof Response) return ctx;
Addedsrc/routes/team-collaborators.tsx+336−0View fileUnifiedSplit
@@ -0,0 +1,336 @@
1/**
2 * Team-based repo collaborators — invite every accepted member of a team
3 * as a repo collaborator in a single action.
4 *
5 * Owner-only. Mirrors `src/routes/collaborators.tsx`'s resolveOwnerRepo
6 * pattern for the inline owner check. The "invite whole team" action
7 * iterates the team's members and upserts one `repo_collaborators` row per
8 * user (skipping the repo owner), auto-accepting the invite — matching the
9 * v1 auto-accept contract used by the single-user invite flow.
10 *
11 * GET /:owner/:repo/settings/collaborators/teams — form + list
12 * POST /:owner/:repo/settings/collaborators/teams/add — bulk insert
13 */
14
15import { Hono } from "hono";
16import { eq, and } from "drizzle-orm";
17import { db } from "../db";
18import {
19 repositories,
20 users,
21 repoCollaborators,
22 organizations,
23 orgMembers,
24 teams,
25 teamMembers,
26} from "../db/schema";
27import { Layout } from "../views/layout";
28import { RepoHeader } from "../views/components";
29import { softAuth, requireAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
31import {
32 Container,
33 Form,
34 FormGroup,
35 Input,
36 Select,
37 Button,
38 Alert,
39 EmptyState,
40} from "../views/ui";
41
42const teamCollaboratorRoutes = new Hono<AuthEnv>();
43
44teamCollaboratorRoutes.use("*", softAuth);
45
46/**
47 * Resolve (owner user, repo) from URL params and enforce owner-only access.
48 * Mirrors the helper in `src/routes/collaborators.tsx` for consistency.
49 */
50async function resolveOwnerRepo(
51 c: any,
52 ownerName: string,
53 repoName: string
54) {
55 const user = c.get("user")!;
56 const [owner] = await db
57 .select()
58 .from(users)
59 .where(eq(users.username, ownerName))
60 .limit(1);
61 if (!owner || owner.id !== user.id) {
62 return {
63 error: c.html(
64 <Layout title="Unauthorized" user={user}>
65 <EmptyState title="Unauthorized">
66 <p>Only the repository owner can manage collaborators.</p>
67 </EmptyState>
68 </Layout>,
69 403
70 ),
71 };
72 }
73 const [repo] = await db
74 .select()
75 .from(repositories)
76 .where(
77 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
78 )
79 .limit(1);
80 if (!repo) {
81 return { error: c.notFound() };
82 }
83 return { owner, repo, user };
84}
85
86// ─── List + invite form ─────────────────────────────────────────────────────
87
88teamCollaboratorRoutes.get(
89 "/:owner/:repo/settings/collaborators/teams",
90 requireAuth,
91 async (c) => {
92 const { owner: ownerName, repo: repoName } = c.req.param();
93 const success = c.req.query("success");
94 const error = c.req.query("error");
95
96 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
97 if ("error" in resolved) return resolved.error;
98 const { repo, user } = resolved;
99
100 // Orgs the current user belongs to — these populate the org dropdown.
101 const userOrgs = await db
102 .select({
103 id: organizations.id,
104 slug: organizations.slug,
105 name: organizations.name,
106 })
107 .from(organizations)
108 .innerJoin(orgMembers, eq(orgMembers.orgId, organizations.id))
109 .where(eq(orgMembers.userId, user.id));
110
111 // All collaborators for this repo — v1 just lists everyone with a count,
112 // not filtered by "added via team" (would need a sourceTeamId column).
113 const rows = await db
114 .select({
115 id: repoCollaborators.id,
116 role: repoCollaborators.role,
117 invitedAt: repoCollaborators.invitedAt,
118 acceptedAt: repoCollaborators.acceptedAt,
119 username: users.username,
120 avatarUrl: users.avatarUrl,
121 })
122 .from(repoCollaborators)
123 .innerJoin(users, eq(users.id, repoCollaborators.userId))
124 .where(eq(repoCollaborators.repositoryId, repo.id));
125
126 return c.html(
127 <Layout
128 title={`Invite team — ${ownerName}/${repoName}`}
129 user={user}
130 >
131 <RepoHeader owner={ownerName} repo={repoName} />
132 <Container maxWidth={700}>
133 <h2 style="margin-bottom: 16px">Invite a team</h2>
134 <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px">
135 <a href={`/${ownerName}/${repoName}/settings/collaborators`}>
136 ← Back to collaborators
137 </a>
138 </p>
139 {success && (
140 <Alert variant="success">{decodeURIComponent(success)}</Alert>
141 )}
142 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
143
144 <div
145 style="margin-bottom: 24px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
146 >
147 <h3 style="margin-bottom: 12px">Invite every member of a team</h3>
148 {userOrgs.length === 0 ? (
149 <p style="font-size:14px;color:var(--text-muted)">
150 You don't belong to any organizations yet.
151 </p>
152 ) : (
153 <Form
154 method="post"
155 action={`/${ownerName}/${repoName}/settings/collaborators/teams/add`}
156 >
157 <FormGroup label="Organization" htmlFor="orgSlug">
158 <Select name="orgSlug" id="orgSlug">
159 {userOrgs.map((o) => (
160 <option value={o.slug}>
161 {o.name} ({o.slug})
162 </option>
163 ))}
164 </Select>
165 </FormGroup>
166 <FormGroup label="Team slug" htmlFor="teamSlug">
167 <Input
168 name="teamSlug"
169 id="teamSlug"
170 placeholder="engineering"
171 required
172 />
173 </FormGroup>
174 <FormGroup label="Role" htmlFor="role">
175 <Select name="role" id="role" value="read">
176 <option value="read">Read — clone + pull</option>
177 <option value="write">Write — push + merge</option>
178 <option value="admin">Admin — full control</option>
179 </Select>
180 </FormGroup>
181 <Button type="submit" variant="primary">
182 Invite team
183 </Button>
184 </Form>
185 )}
186 </div>
187
188 <h3 style="margin-bottom: 12px">
189 Current collaborators ({rows.length})
190 </h3>
191 {rows.length === 0 ? (
192 <EmptyState title="No collaborators yet">
193 <p>Invite a team above to add multiple people at once.</p>
194 </EmptyState>
195 ) : (
196 <div>
197 {rows.map((row) => (
198 <div class="ssh-key-item">
199 <div>
200 <strong>
201 {row.avatarUrl && (
202 <img
203 src={row.avatarUrl}
204 alt=""
205 style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px"
206 />
207 )}
208 <a href={`/${row.username}`}>{row.username}</a>
209 </strong>
210 <div class="ssh-key-meta">
211 Role: <strong>{row.role}</strong> | Invited:{" "}
212 {new Date(row.invitedAt).toLocaleDateString()} |{" "}
213 {row.acceptedAt ? (
214 <span style="color: var(--green)">Accepted</span>
215 ) : (
216 <span style="color: var(--yellow)">Pending</span>
217 )}
218 </div>
219 </div>
220 </div>
221 ))}
222 </div>
223 )}
224 </Container>
225 </Layout>
226 );
227 }
228);
229
230// ─── Invite entire team ─────────────────────────────────────────────────────
231
232teamCollaboratorRoutes.post(
233 "/:owner/:repo/settings/collaborators/teams/add",
234 requireAuth,
235 async (c) => {
236 const { owner: ownerName, repo: repoName } = c.req.param();
237 const body = await c.req.parseBody();
238
239 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
240 if ("error" in resolved) return resolved.error;
241 const { repo, user } = resolved;
242
243 const orgSlug = String(body.orgSlug || "").trim();
244 const teamSlug = String(body.teamSlug || "").trim();
245 const roleRaw = String(body.role || "read");
246 const role: "read" | "write" | "admin" =
247 roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read";
248
249 const redirBase = `/${ownerName}/${repoName}/settings/collaborators/teams`;
250
251 if (!orgSlug || !teamSlug) {
252 return c.redirect(
253 `${redirBase}?error=Organization+and+team+slug+are+required`
254 );
255 }
256
257 // Resolve org by slug, then team by (orgId, slug). We also verify the
258 // authed user is a member of the org — otherwise they shouldn't be able
259 // to enumerate team membership via this endpoint.
260 const [org] = await db
261 .select()
262 .from(organizations)
263 .where(eq(organizations.slug, orgSlug))
264 .limit(1);
265 if (!org) {
266 return c.redirect(`${redirBase}?error=Organization+not+found`);
267 }
268
269 const [membership] = await db
270 .select()
271 .from(orgMembers)
272 .where(
273 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id))
274 )
275 .limit(1);
276 if (!membership) {
277 return c.redirect(
278 `${redirBase}?error=You+are+not+a+member+of+that+organization`
279 );
280 }
281
282 const [team] = await db
283 .select()
284 .from(teams)
285 .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
286 .limit(1);
287 if (!team) {
288 return c.redirect(`${redirBase}?error=Team+not+found`);
289 }
290
291 // Fetch team members. The team_members schema has no "acceptedAt"
292 // column — a row existing IS the acceptance — so every row counts.
293 const members = await db
294 .select({ userId: teamMembers.userId })
295 .from(teamMembers)
296 .where(eq(teamMembers.teamId, team.id));
297
298 let added = 0;
299 for (const m of members) {
300 // Never add the repo owner as their own collaborator.
301 if (m.userId === repo.ownerId) continue;
302
303 const [existing] = await db
304 .select()
305 .from(repoCollaborators)
306 .where(
307 and(
308 eq(repoCollaborators.repositoryId, repo.id),
309 eq(repoCollaborators.userId, m.userId)
310 )
311 )
312 .limit(1);
313
314 if (existing) {
315 await db
316 .update(repoCollaborators)
317 .set({ role, acceptedAt: existing.acceptedAt ?? new Date() })
318 .where(eq(repoCollaborators.id, existing.id));
319 } else {
320 await db.insert(repoCollaborators).values({
321 repositoryId: repo.id,
322 userId: m.userId,
323 role,
324 invitedBy: user.id,
325 acceptedAt: new Date(), // v1 auto-accept
326 });
327 }
328 added += 1;
329 }
330
331 const msg = `Added ${added} collaborators from team ${team.name}`;
332 return c.redirect(`${redirBase}?success=${encodeURIComponent(msg)}`);
333 }
334);
335
336export default teamCollaboratorRoutes;
Modifiedsrc/routes/webhooks.tsx+4−0View fileUnifiedSplit
@@ -10,6 +10,7 @@ import { Layout } from "../views/layout";
1010import { RepoHeader } from "../views/components";
1111import { softAuth, requireAuth } from "../middleware/auth";
1212import type { AuthEnv } from "../middleware/auth";
13import { requireRepoAccess } from "../middleware/repo-access";
1314import {
1415 Container,
1516 Flex,
@@ -28,6 +29,7 @@ webhookRoutes.use("*", softAuth);
2829webhookRoutes.get(
2930 "/:owner/:repo/settings/webhooks",
3031 requireAuth,
32 requireRepoAccess("read"),
3133 async (c) => {
3234 const { owner: ownerName, repo: repoName } = c.req.param();
3335 const user = c.get("user")!;
@@ -154,6 +156,7 @@ webhookRoutes.get(
154156webhookRoutes.post(
155157 "/:owner/:repo/settings/webhooks",
156158 requireAuth,
159 requireRepoAccess("admin"),
157160 async (c) => {
158161 const { owner: ownerName, repo: repoName } = c.req.param();
159162 const user = c.get("user")!;
@@ -214,6 +217,7 @@ webhookRoutes.post(
214217webhookRoutes.post(
215218 "/:owner/:repo/settings/webhooks/:id/delete",
216219 requireAuth,
220 requireRepoAccess("admin"),
217221 async (c) => {
218222 const { owner: ownerName, repo: repoName, id } = c.req.param();
219223
Addedsrc/views/live-feed.tsx+51−0View fileUnifiedSplit
@@ -0,0 +1,51 @@
1/**
2 * LiveFeed — small SSR component that renders an empty <ul> and an inline
3 * <script> which subscribes to a server-sent-events topic and appends
4 * formatted list items as events arrive.
5 *
6 * Events on the wire are expected to shape `{action, actor, target}`.
7 * If SSE fails or EventSource is unsupported, the <ul> simply stays empty
8 * — the rest of the page still renders normally.
9 */
10
11import { liveSubscribeScript } from "../lib/sse-client";
12
13export function LiveFeed(props: {
14 topic: string;
15 title?: string;
16}): JSX.Element {
17 const title = props.title ?? "Live activity";
18 const listId = "live-feed";
19
20 // formatFn is inlined client-side JS. It receives the parsed event payload
21 // and returns an HTML string (an <li>). All interpolated values are HTML-
22 // escaped to avoid breakout.
23 const formatFn = `
24 function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
25 var d = event && event.data ? event.data : event;
26 if (!d) return '';
27 return '<li>' + esc(d.actor) + ' ' + esc(d.action) + ' ' + esc(d.target) + '</li>';
28 `;
29
30 const script = liveSubscribeScript({
31 topic: props.topic,
32 targetElementId: listId,
33 formatFn,
34 });
35
36 return (
37 <section
38 class="live-feed"
39 style="margin-top: 24px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)"
40 >
41 <h3 style="font-size: 14px; margin: 0 0 12px 0; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px">
42 {title}
43 </h3>
44 <ul
45 id={listId}
46 style="list-style: none; padding: 0; margin: 0; font-size: 13px; color: var(--text)"
47 />
48 <script dangerouslySetInnerHTML={{ __html: script }} />
49 </section>
50 );
51}
052