Commit68dc1efunknown_key
Merge pull request #8 from ccantynz-alt/claude/setup-multi-repo-dev-BCwNQ
Merge pull request #8 from ccantynz-alt/claude/setup-multi-repo-dev-BCwNQ Pre-launch posture + git.ts runtime fix + Signal Bus P1 + tsc debt 85% cleared
73 files changed+3618−35468dc1efc714b25903b23d37a7bc1764221eebb3c
73 changed files+3618−354
Modified.env.example+8−1View fileUnifiedSplit
@@ -8,7 +8,14 @@ GATETEST_API_KEY=
88# Generate a secret with: openssl rand -hex 32
99GATETEST_CALLBACK_SECRET=
1010GATETEST_HMAC_SECRET=
11CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
11CRONTECH_DEPLOY_URL=https://crontech.ai/api/hooks/gluecron/push
12# Bearer token sent on the outbound Crontech deploy webhook. Obtain from
13# Crontech (one per Gluecron install). Unset → Crontech rejects with 401.
14GLUECRON_WEBHOOK_SECRET=
15# Inbound bearer token Crontech MUST present on deploy.succeeded /
16# deploy.failed callbacks to POST /api/events/deploy (Signal Bus P1 — E3/E4).
17# Generate with: openssl rand -hex 32. Unset → endpoint refuses every call.
18CRONTECH_EVENT_TOKEN=
1219ANTHROPIC_API_KEY=
1320# Email (Block A8). Provider=log just writes to stderr (safe default).
1421# Switch to "resend" in prod and set RESEND_API_KEY.
Added.gatetest.json+6−0View fileUnifiedSplit
@@ -0,0 +1,6 @@
1{
2 "protected": true,
3 "gatetest_source": "https://github.com/ccantynz-alt/gatetest",
4 "do_not_remove": "This repo is protected by GateTest. See .github/workflows/gatetest-gate.yml and .husky/pre-push. Removing either breaks the quality gate. Requires Craig authorization.",
5 "integration_version": 1
6}
Added.github/workflows/gatetest-gate.yml+82−0View fileUnifiedSplit
@@ -0,0 +1,82 @@
1# ============================================================================
2# GATETEST QUALITY GATE — PROTECTED INTEGRATION FILE
3# ============================================================================
4# This workflow is a permanent integration between GateTest and a protected
5# platform (Crontech / Gluecron). It MUST NOT be removed, weakened, or
6# disabled without explicit authorization from Craig.
7#
8# See the GateTest Bible (CLAUDE.md) → "PROTECTED PLATFORMS" section.
9# Install location in target repo: .github/workflows/gatetest-gate.yml
10# Source of truth: https://github.com/ccantynz-alt/gatetest
11# /integrations/github-actions/gatetest-gate.yml
12# ============================================================================
13
14name: GateTest Quality Gate
15
16on:
17 push:
18 branches: [main, master]
19 pull_request:
20 branches: [main, master]
21
22permissions:
23 contents: read
24 pull-requests: write
25 security-events: write
26 checks: write
27
28concurrency:
29 group: gatetest-${{ github.ref }}
30 cancel-in-progress: true
31
32jobs:
33 gatetest:
34 name: GateTest — Quality Gate
35 runs-on: ubuntu-latest
36 timeout-minutes: 20
37 steps:
38 - name: Checkout target repository
39 uses: actions/checkout@v4
40 with:
41 fetch-depth: 0
42
43 - name: Setup Node.js
44 uses: actions/setup-node@v4
45 with:
46 node-version: '20'
47
48 - name: Clone GateTest (single source of truth)
49 run: |
50 git clone --depth 1 https://github.com/ccantynz-alt/gatetest.git "$RUNNER_TEMP/gatetest"
51 echo "GATETEST_HOME=$RUNNER_TEMP/gatetest" >> $GITHUB_ENV
52 echo "$RUNNER_TEMP/gatetest/bin" >> $GITHUB_PATH
53 chmod +x "$RUNNER_TEMP/gatetest/bin/gatetest.js"
54
55 - name: Verify GateTest installed
56 run: node "$GATETEST_HOME/bin/gatetest.js" --version
57
58 - name: GateTest — Quick (diff-mode) on PRs
59 if: github.event_name == 'pull_request'
60 run: node "$GATETEST_HOME/bin/gatetest.js" --suite quick --diff --sarif --junit --project "$GITHUB_WORKSPACE"
61
62 - name: GateTest — Full on main push
63 if: github.event_name == 'push'
64 run: node "$GATETEST_HOME/bin/gatetest.js" --suite full --sarif --junit --project "$GITHUB_WORKSPACE"
65
66 - name: Upload SARIF to GitHub Security
67 if: always()
68 continue-on-error: true
69 uses: github/codeql-action/upload-sarif@v3
70 with:
71 sarif_file: .gatetest/reports/gatetest-results.sarif
72 category: gatetest
73
74 - name: Upload GateTest reports
75 if: always()
76 uses: actions/upload-artifact@v4
77 with:
78 name: gatetest-reports-${{ github.run_id }}
79 path: |
80 .gatetest/reports/
81 retention-days: 30
82 if-no-files-found: ignore
Added.husky/pre-push+28−0View fileUnifiedSplit
@@ -0,0 +1,28 @@
1
2# ============================================================================
3# GATETEST PRE-PUSH HOOK — PROTECTED INTEGRATION FILE
4# ============================================================================
5# This hook blocks pushes from a protected platform (Crontech / Gluecron) to
6# their remotes if GateTest finds any error-severity issues.
7#
8# MUST NOT be removed, weakened, or bypassed without Craig's authorization.
9# See the GateTest Bible (CLAUDE.md) → "PROTECTED PLATFORMS" section.
10# Install location in target repo: .husky/pre-push (chmod +x)
11# Source of truth: https://github.com/ccantynz-alt/gatetest
12# /integrations/husky/pre-push
13# ============================================================================
14set -e
15
16GATETEST_CACHE="${GATETEST_CACHE:-$HOME/.cache/gatetest}"
17
18if [ ! -d "$GATETEST_CACHE/.git" ]; then
19 echo "[GateTest] Fetching latest GateTest into $GATETEST_CACHE ..."
20 mkdir -p "$(dirname "$GATETEST_CACHE")"
21 git clone --depth 1 https://github.com/ccantynz-alt/gatetest.git "$GATETEST_CACHE"
22else
23 echo "[GateTest] Updating local cache ..."
24 (cd "$GATETEST_CACHE" && git pull --ff-only --depth 1 origin HEAD >/dev/null 2>&1 || true)
25fi
26
27echo "[GateTest] Running quick diff-mode gate before push ..."
28node "$GATETEST_CACHE/bin/gatetest.js" --suite quick --diff --project "$(pwd)"
Addeddocs/legal-audit.md+92−0View fileUnifiedSplit
@@ -0,0 +1,92 @@
1# Gluecron Legal Pages Audit
2
3**Date:** 2026-04-16
4**Branch:** `claude/setup-multi-repo-dev-BCwNQ`
5**Scope:** Inventory of user-facing legal pages on gluecron.com ahead of attorney review.
6
7## Summary
8
9**No user-facing legal pages exist** in the Gluecron codebase as of this audit.
10
11A filesystem search of `src/routes/` and `src/views/` for the conventional filenames
12(`legal.*`, `terms.*`, `privacy.*`, `tos.*`, `eula.*`, `dmca.*`, `cookies.*`)
13returned zero matches. A broader substring grep across `src/` for the words
14`terms`, `privacy`, and `legal` surfaced only unrelated hits (a rate-limit
15error message and a test describing "legal characters" in a package-name
16validator) - no user-visible policy documents, no routes, no views, no links
17in the footer or navigation.
18
19The layout footer (`src/views/layout.tsx`) currently renders only the tagline
20"gluecron - AI-native code intelligence" with no links to Terms, Privacy,
21Acceptable Use, DMCA, or any other policy page.
22
23## Implication
24
25Gluecron has **no independent legal surface** at this time. There are two
26defensible postures heading into attorney review; the attorney should be
27briefed on both so they can direct which path is taken before public launch:
28
29### Scenario A - Gluecron operates under Crontech's umbrella legal terms
30
31If the business structure is that Crontech is the legal entity and Gluecron
32is a product surface of that entity, then Crontech's published Terms,
33Privacy Policy, Acceptable Use Policy, and DMCA notice would apply to
34Gluecron users by reference.
35
36Required before launch under this scenario:
37- Crontech's existing legal pages must explicitly name Gluecron and git
38 hosting as covered products (or the umbrella clause must be demonstrably
39 broad enough to cover user-uploaded code, git pushes, and third-party
40 code hosted on behalf of users).
41- A footer link on every Gluecron page pointing to the Crontech legal pages
42 so users have notice and can locate the governing terms.
43- Any Gluecron-specific data flows (e.g. git clone/push logs, SSH key
44 retention, webhook payload retention, AI ingestion of hosted code) must
45 be reflected in Crontech's privacy policy or addressed in a Gluecron
46 supplement.
47
48### Scenario B - Gluecron requires independent legal pages before public launch
49
50If Gluecron is (or will be) a separately-branded product with distinct
51risk surface (user-uploaded code, third-party repositories, AI review of
52private code, DMCA takedowns, export-controlled content), independent
53legal pages are likely required regardless of the corporate relationship
54to Crontech.
55
56Required before launch under this scenario:
57- Terms of Service (including acceptable use, content licensing grant
58 from users to the platform for the purpose of hosting/serving, AI
59 processing disclosure).
60- Privacy Policy (covering git hosting data, SSH keys, webhooks,
61 access logs, AI ingestion, retention, subprocessors).
62- DMCA / Copyright policy (with a designated agent - statutory
63 requirement for safe harbor under 17 U.S.C. § 512).
64- Acceptable Use Policy (malware hosting, phishing kits, abuse,
65 rate-limit bypass).
66- Cookie / tracking notice if any analytics or third-party embeds are
67 used on the public site.
68
69## Recommendation to Attorney
70
71Brief the attorney on **both** scenarios. Ask them to:
72
731. Advise which posture Gluecron should adopt at launch (umbrella vs.
74 standalone).
752. If umbrella: confirm Crontech's existing pages cover Gluecron's risk
76 surface, or list the gaps that need patching.
773. If standalone: provide drafts (or approve drafts) for Terms, Privacy,
78 DMCA, and Acceptable Use.
794. In either case, confirm the pre-launch banner currently shipped on
80 gluecron.com ("Pre-launch - Gluecron is in final validation. Public
81 signups and git hosting for non-owner users open after launch review.")
82 is sufficient to manage reliance expectations during the validation
83 window.
84
85## Appendix - Files inspected
86
87- `src/routes/` - no legal-related routes
88- `src/views/layout.tsx` - footer has no legal links
89- `src/views/` - no legal-related view files
90- `docs/` - did not exist prior to this audit
91- Grep of `src/` for `terms`, `privacy`, `legal` (case-insensitive) -
92 only non-legal hits (rate-limit error string, test description)
Addeddocs/ops/DEPLOYMENT_RUNBOOK.md+265−0View fileUnifiedSplit
@@ -0,0 +1,265 @@
1# Gluecron Deployment Runbook
2
3Step-by-step guide for taking Gluecron from zero to running at
4`gluecron.com`. This runbook targets **infrastructure deployment** —
5Gluecron is deployed as free internal infrastructure for Craig's
6ecosystem, **not** as a paid product.
7
8---
9
10## What Gluecron is today
11
12- Git hosting (clone / push / fetch over Smart HTTP)
13- Web UI: issues, PRs, code browser, webhooks, SSH keys, OAuth, 2FA
14- Integrates with **GateTest** (scans on push) and **Crontech**
15 (deploys on push to `main`)
16- **Not a paid product.** No Stripe. No tiers. Free infrastructure
17 for Craig's ecosystem.
18- Pre-launch banner visible; public signups gated until owner review.
19
20---
21
22## Prerequisites
23
24- **Neon** account (managed Postgres) — <https://neon.tech>
25- **Fly.io** *or* **Railway** account (container deployment)
26- Domain `gluecron.com` in hand (registrar access for DNS records)
27- *(Optional)* **Resend** account for transactional email
28- *(Optional)* **Anthropic** API key for AI features (copilot,
29 changelog summarisation, semantic search)
30- *(Optional)* **Voyage** API key for semantic code search embeddings
31- Local tooling: `bun`, `git`, `flyctl` or `railway` CLI
32
33---
34
35## Phase 1 — Provision Postgres (Neon)
36
371. Sign in to <https://neon.tech> and create a project named
38 `gluecron`.
392. From the project dashboard, copy the pooled connection string.
40 It looks like:
41 `postgresql://user:pass@ep-xxx.eu-west-2.aws.neon.tech/neondb?sslmode=require`
423. Save this value — it becomes `DATABASE_URL` in Phase 3.
434. **Do NOT run migrations yet.** The container runs them as its
44 release step (see `fly.toml` → `[deploy] release_command`).
45
46---
47
48## Phase 2 — Choose a platform
49
50Both platforms ship from the checked-in `Dockerfile`. Pick one.
51
52### Option A — Fly.io (recommended; `fly.toml` already exists)
53
54`fly.toml` already configures the persistent `gluecron_repos`
55volume mounted at `/app/repos` and wires up migrations as the
56release command.
57
58```bash
59fly auth login
60fly launch --no-deploy # accept existing fly.toml
61fly volumes create gluecron_repos --size 10 --region lhr
62fly secrets set \
63 DATABASE_URL="postgresql://..." \
64 APP_BASE_URL="https://gluecron.com" \
65 WEBAUTHN_RP_ID="gluecron.com" \
66 WEBAUTHN_ORIGIN="https://gluecron.com" \
67 WEBAUTHN_RP_NAME="gluecron" \
68 GATETEST_URL="https://gatetest.io/api/scan/run" \
69 GATETEST_API_KEY="..." \
70 GATETEST_CALLBACK_SECRET="..." \
71 GATETEST_HMAC_SECRET="..." \
72 CRONTECH_DEPLOY_URL="https://crontech.ai/api/hooks/gluecron/push" \
73 GLUECRON_WEBHOOK_SECRET="..." \
74 CRONTECH_EVENT_TOKEN="..." \
75 ANTHROPIC_API_KEY="..." \
76 EMAIL_PROVIDER="resend" \
77 RESEND_API_KEY="..." \
78 EMAIL_FROM="gluecron <no-reply@gluecron.com>"
79fly deploy
80```
81
82### Option B — Railway (`railway.toml` already exists)
83
841. <https://railway.app> → new project → **Deploy from GitHub**.
852. Select the Gluecron repo; Railway picks up the `Dockerfile`
86 via `railway.toml`.
873. Add a persistent volume mounted at `/app/repos` (Railway UI
88 → service → Volumes).
894. Set all environment variables from Phase 3 in the Railway
90 dashboard.
915. Railway will run `bun run db:migrate` as the release command
92 on deploy.
93
94---
95
96## Phase 3 — Environment variables
97
98All variables that Gluecron reads from `process.env`, enumerated
99from `src/lib/config.ts` and direct `process.env.*` references in
100`src/`.
101
102### Core runtime
103
104| Variable | Required? | Example | Notes |
105|---|---|---|---|
106| `DATABASE_URL` | **Yes** | `postgresql://user:pass@host/db?sslmode=require` | Neon pooled connection string. |
107| `PORT` | No | `3000` | Preset in `fly.toml` / `railway.toml`. |
108| `GIT_REPOS_PATH` | **Yes** in prod | `/app/repos` | Must be a persistent volume — this is where bare repos live. |
109| `NODE_ENV` | **Yes** in prod | `production` | Enables secure cookies and rate limiting. Preset in both configs. |
110| `APP_BASE_URL` | **Yes** | `https://gluecron.com` | Canonical base URL; used in outbound webhooks and email links. No trailing slash. |
111
112### WebAuthn (passkeys / 2FA)
113
114| Variable | Required? | Example | Notes |
115|---|---|---|---|
116| `WEBAUTHN_RP_ID` | **Yes** | `gluecron.com` | Domain only, **no scheme, no port**. Passkeys are bound to this value; changing it invalidates existing keys. |
117| `WEBAUTHN_ORIGIN` | **Yes** | `https://gluecron.com` | Full origin including scheme. Must match what the browser sees. |
118| `WEBAUTHN_RP_NAME` | No | `gluecron` | Human-facing name shown by the browser. Default `gluecron`. |
119
120### GateTest integration (scans on push)
121
122| Variable | Required? | Example | Notes |
123|---|---|---|---|
124| `GATETEST_URL` | **Yes** (for integration) | `https://gatetest.io/api/scan/run` | Default `https://gatetest.ai/api/scan/run` — override to production host. |
125| `GATETEST_API_KEY` | **Yes** | `gtk_...` | Outbound bearer token sent to GateTest. |
126| `GATETEST_CALLBACK_SECRET` | **Yes** | *(random 32-byte hex)* | Bearer token GateTest uses when posting scan results back to Gluecron (`/hooks/gatetest`). |
127| `GATETEST_HMAC_SECRET` | **Yes** | *(random 32-byte hex)* | HMAC signing secret for GateTest → Gluecron callbacks. |
128
129### Crontech integration (deploys on push to main)
130
131| Variable | Required? | Example | Notes |
132|---|---|---|---|
133| `CRONTECH_DEPLOY_URL` | **Yes** (for integration) | `https://crontech.ai/api/hooks/gluecron/push` | Outbound webhook target. |
134| `GLUECRON_WEBHOOK_SECRET` | **Yes** | *(random 32-byte hex)* | Bearer token on outbound Crontech deploy webhook. Empty → Crontech returns 401 and deploys fail. |
135| `CRONTECH_EVENT_TOKEN` | **Yes** | *(random 32-byte hex)* | Bearer token Crontech uses when posting deploy events back to Gluecron (`/api/deploy-events`). |
136
137### Email (optional — `log` provider works without)
138
139| Variable | Required? | Example | Notes |
140|---|---|---|---|
141| `EMAIL_PROVIDER` | No | `resend` or `log` | Default `log` (writes to stderr). Set `resend` for real sending. |
142| `RESEND_API_KEY` | Only if `EMAIL_PROVIDER=resend` | `re_...` | Resend API key. |
143| `EMAIL_FROM` | No | `gluecron <no-reply@gluecron.com>` | From header. Default `gluecron <no-reply@gluecron.local>`. |
144
145### AI / semantic search (optional)
146
147| Variable | Required? | Example | Notes |
148|---|---|---|---|
149| `ANTHROPIC_API_KEY` | No | `sk-ant-...` | Enables copilot, PR review, changelog summarisation. |
150| `VOYAGE_API_KEY` | No | `pa-...` | Enables Voyage embeddings for semantic code search. Falls back to a local model when absent. |
151
152**Total: 18 required/useful env vars** (plus `NODE_ENV`, `PORT`,
153`VOYAGE_API_KEY` as optional tuning).
154
155---
156
157## Phase 4 — Run migrations
158
159On both Fly.io and Railway, `bun run db:migrate` is the release
160command and runs automatically before traffic is routed to a new
161revision. If you need to run it manually:
162
163```bash
164# Fly.io
165fly ssh console
166bun run db:migrate
167
168# Railway
169railway run bun run db:migrate
170```
171
172Verify the server starts cleanly — `bun run` should not error on
173boot.
174
175---
176
177## Phase 5 — DNS
178
1791. Point `gluecron.com` at your platform:
180 - **Fly.io**: `fly ips list` → create `A` and `AAAA` records
181 to the IPv4/IPv6 shown. Alternatively
182 `fly certs add gluecron.com` and follow the CNAME/ALIAS
183 instructions.
184 - **Railway**: add `gluecron.com` in the service → Settings →
185 Domains. Railway prints the target `CNAME`.
1862. Wait for Let's Encrypt to provision the certificate (usually
187 under 5 minutes). Verify:
188
189```bash
190curl -I https://gluecron.com/
191```
192
193---
194
195## Phase 6 — Smoke test
196
197- [ ] Visit <https://gluecron.com/> — pre-launch banner is visible.
198- [ ] Register an account via email / OAuth.
199- [ ] Log in, visit **Settings → SSH keys**, add an SSH public key.
200- [ ] Create a repository from the UI.
201- [ ] `git clone https://gluecron.com/<you>/<repo>.git` — must
202 succeed (this was the Smart HTTP bug fixed in commit
203 `676be75`).
204- [ ] `git push` — verify it triggers a GateTest scan (if
205 `GATETEST_URL` is set).
206- [ ] `git push` to `main` — verify it triggers a Crontech deploy
207 (if `CRONTECH_DEPLOY_URL` is set).
208
209---
210
211## Phase 7 — Integration wires with GateTest & Crontech
212
213The three apps share secrets pairwise. Each secret value **must be
214identical on both sides of the wire**.
215
216| Secret | Set on Gluecron | Set on the other side |
217|---|---|---|
218| `GATETEST_API_KEY` | outbound auth to GateTest | GateTest validates this on its `/api/scan/run` endpoint |
219| `GATETEST_CALLBACK_SECRET` | validates callbacks from GateTest | GateTest sends as bearer on scan-result POSTs |
220| `GATETEST_HMAC_SECRET` | verifies signatures on GateTest callbacks | GateTest signs outbound callbacks with this |
221| `GLUECRON_WEBHOOK_SECRET` | outbound auth to Crontech | Crontech validates this on `/api/hooks/gluecron/push` |
222| `CRONTECH_EVENT_TOKEN` | validates deploy-event POSTs from Crontech | Crontech sends as bearer on `/api/deploy-events` |
223
224**Rule of thumb:** generate each secret once with
225`openssl rand -hex 32`, then copy the same value into the matching
226variable on both services.
227
228---
229
230## Phase 8 — Pre-launch → launch
231
232When Craig is ready to open signups:
233
2341. Remove the pre-launch banner — revert the `.prelaunch-banner`
235 block in `src/views/layout.tsx` that was added in commit
236 `4a52a98`.
2372. Commit: `feat(launch): remove pre-launch banner`.
2383. Push — the container redeploys automatically.
239
240---
241
242## Known limitations at launch
243
244- **No legal pages** — see `docs/legal-audit.md`; needs an owner
245 decision on ToS / Privacy before broad public signups.
246- **No billing** — free forever, or bolt on Stripe later.
247- **18 remaining `tsc` errors in locked files** — they don't block
248 runtime; clean-up tracked separately.
249
250---
251
252## Troubleshooting
253
254| Symptom | Likely cause | Fix |
255|---|---|---|
256| `git clone` reports "empty repo" | The repo has no commits. | Push at least one commit first — an empty bare repo has no refs to advertise. |
257| Smart HTTP returns `404` on `info/refs` | `GIT_REPOS_PATH` is wrong or the volume isn't mounted with write access. | Check the volume mount in `fly.toml` / Railway; confirm the container user can read the bare repo dir. |
258| WebAuthn / passkey registration fails | `WEBAUTHN_RP_ID` doesn't match the domain, or includes a scheme/port. | Must be the bare host (`gluecron.com`); ensure `WEBAUTHN_ORIGIN` matches what the browser sees, including `https://`. |
259| GateTest callbacks rejected with `401` | `GATETEST_CALLBACK_SECRET` mismatch between Gluecron and GateTest. | Re-generate the secret and set the same value on both services. |
260| Crontech deploys fail with `401` | `GLUECRON_WEBHOOK_SECRET` is empty or mismatched. | Set to the same value on both services; the config default is empty, which Crontech rejects. |
261| Outbound emails don't arrive | `EMAIL_PROVIDER` defaults to `log`. | Set `EMAIL_PROVIDER=resend` and configure `RESEND_API_KEY` + `EMAIL_FROM`. |
262
263---
264
265*Last reviewed: 2026-04-16.*
Addeddrizzle/0034_processed_events.sql+30−0View fileUnifiedSplit
@@ -0,0 +1,30 @@
1-- Signal Bus P1 — Idempotency table for inbound events.
2--
3-- Records every external event we have successfully processed so that
4-- duplicate deliveries (Crontech retry, network replay, at-least-once bus)
5-- can be detected and short-circuited without firing side-effects twice.
6--
7-- `event_id` is the provider-supplied uuid v4 carried on the wire.
8-- `source` namespaces event_id in case two providers happen to collide
9-- (e.g. 'crontech', 'gatetest', 'github').
10--
11-- This migration is additive and reversible — drop the table to remove it.
12
13CREATE TABLE IF NOT EXISTS "processed_events" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
15 "event_id" text NOT NULL UNIQUE,
16 "event_type" text NOT NULL,
17 "source" text NOT NULL,
18 "received_at" timestamp NOT NULL DEFAULT now(),
19 "payload" jsonb
20);
21
22--> statement-breakpoint
23
24CREATE INDEX IF NOT EXISTS "processed_events_event_id_idx"
25 ON "processed_events" ("event_id");
26
27--> statement-breakpoint
28
29CREATE INDEX IF NOT EXISTS "processed_events_source_type_idx"
30 ON "processed_events" ("source", "event_type");
Addedsrc/__tests__/api-tokens.test.ts+200−0View fileUnifiedSplit
@@ -0,0 +1,200 @@
1/**
2 * Personal Access Tokens (PAT) — CRUD + auth contract coverage.
3 *
4 * `src/routes/tokens.tsx` is a §4 LOCKED BLOCK. These tests pin down the
5 * externally-observable contract through the app router:
6 * - token format (`glc_` prefix, hex body, fixed length)
7 * - store-the-hash-never-the-value (SHA-256 derivation matches auth lookup)
8 * - expired PATs are rejected by `requireAuth` (C2 / middleware/auth.ts)
9 * - revoke (delete) + list endpoints are auth-guarded
10 * - `lastUsedAt` contract: loader updates it via fire-and-forget,
11 * auth success doesn't wait on the write (non-blocking path).
12 *
13 * DB-backed writes only run when `DATABASE_URL` is present; otherwise the
14 * handler degrades gracefully and we assert the 302/401 auth contract.
15 */
16
17import { describe, it, expect } from "bun:test";
18import app from "../app";
19
20const HAS_DB = Boolean(process.env.DATABASE_URL);
21
22// ---------------------------------------------------------------------------
23// Pure helpers mirroring the locked route's token generation + hashing.
24// Kept in-file — the route has no __test export and is LOCKED.
25// ---------------------------------------------------------------------------
26
27function generateToken(): string {
28 const bytes = crypto.getRandomValues(new Uint8Array(32));
29 return (
30 "glc_" +
31 Array.from(bytes)
32 .map((b) => b.toString(16).padStart(2, "0"))
33 .join("")
34 );
35}
36
37async function hashToken(token: string): Promise<string> {
38 const data = new TextEncoder().encode(token);
39 const hash = await crypto.subtle.digest("SHA-256", data);
40 return Array.from(new Uint8Array(hash))
41 .map((b) => b.toString(16).padStart(2, "0"))
42 .join("");
43}
44
45// ---------------------------------------------------------------------------
46// Pure logic — token format
47// ---------------------------------------------------------------------------
48
49describe("api tokens — generation format", () => {
50 it("emits a glc_-prefixed token", () => {
51 const t = generateToken();
52 expect(t.startsWith("glc_")).toBe(true);
53 });
54
55 it("emits 32 bytes (64 hex chars) of entropy after the prefix", () => {
56 const t = generateToken();
57 expect(t.length).toBe("glc_".length + 64);
58 expect(/^glc_[0-9a-f]{64}$/.test(t)).toBe(true);
59 });
60
61 it("emits unique tokens on repeated calls", () => {
62 const a = generateToken();
63 const b = generateToken();
64 const c = generateToken();
65 expect(a).not.toBe(b);
66 expect(b).not.toBe(c);
67 expect(a).not.toBe(c);
68 });
69});
70
71describe("api tokens — hashing contract (store-the-hash-never-the-value)", () => {
72 it("produces a deterministic SHA-256 hex digest", async () => {
73 const token = "glc_" + "a".repeat(64);
74 const h1 = await hashToken(token);
75 const h2 = await hashToken(token);
76 expect(h1).toBe(h2);
77 expect(/^[0-9a-f]{64}$/.test(h1)).toBe(true);
78 });
79
80 it("never returns the token itself in the hash", async () => {
81 const token = "glc_" + "b".repeat(64);
82 const h = await hashToken(token);
83 expect(h).not.toBe(token);
84 expect(h).not.toContain("glc_");
85 });
86
87 it("produces distinct hashes for distinct tokens", async () => {
88 const a = await hashToken("glc_" + "c".repeat(64));
89 const b = await hashToken("glc_" + "d".repeat(64));
90 expect(a).not.toBe(b);
91 });
92
93 it("matches the hash the auth middleware looks up (sha256Hex shape)", async () => {
94 // Sanity-check: the locked middleware (src/middleware/auth.ts) uses
95 // sha256Hex from src/lib/oauth.ts which is a lowercase hex SHA-256 of
96 // the raw token bytes. That's what our generator stores. If this ever
97 // drifts, PAT auth breaks — catch it here.
98 const token = "glc_" + "e".repeat(64);
99 const { sha256Hex } = await import("../lib/oauth");
100 expect(await hashToken(token)).toBe(await sha256Hex(token));
101 });
102
103 it("derives a display prefix of the first 12 chars (`glc_` + 8 hex)", () => {
104 const token = generateToken();
105 const prefix = token.slice(0, 12);
106 expect(prefix.startsWith("glc_")).toBe(true);
107 expect(prefix.length).toBe(12);
108 // Never includes enough entropy to reverse the token.
109 expect(token.length - prefix.length).toBeGreaterThanOrEqual(56);
110 });
111});
112
113// ---------------------------------------------------------------------------
114// Route auth contract — /settings/tokens (HTML + form)
115// ---------------------------------------------------------------------------
116
117describe("api tokens — /settings/tokens auth guard", () => {
118 it("GET /settings/tokens without a session → redirect to /login", async () => {
119 const res = await app.request("/settings/tokens");
120 expect(res.status).toBe(302);
121 expect(res.headers.get("location") || "").toContain("/login");
122 });
123
124 it("POST /settings/tokens (create) without a session → redirect to /login", async () => {
125 const res = await app.request("/settings/tokens", {
126 method: "POST",
127 headers: { "content-type": "application/x-www-form-urlencoded" },
128 body: new URLSearchParams({ name: "CI pipeline", scopes: "repo" }),
129 });
130 expect(res.status).toBe(302);
131 expect(res.headers.get("location") || "").toContain("/login");
132 });
133
134 it("POST /settings/tokens/:id/delete (revoke) without a session → redirect to /login", async () => {
135 const res = await app.request(
136 "/settings/tokens/00000000-0000-0000-0000-000000000000/delete",
137 { method: "POST" }
138 );
139 expect(res.status).toBe(302);
140 expect(res.headers.get("location") || "").toContain("/login");
141 });
142});
143
144// ---------------------------------------------------------------------------
145// JSON API — /api/user/tokens never returns the token value.
146// ---------------------------------------------------------------------------
147
148describe("api tokens — /api/user/tokens contract", () => {
149 it("GET /api/user/tokens without a session → redirect to /login", async () => {
150 const res = await app.request("/api/user/tokens");
151 expect(res.status).toBe(302);
152 expect(res.headers.get("location") || "").toContain("/login");
153 });
154
155 it("GET /api/user/tokens rejects an invalid glc_ bearer with 401 JSON", async () => {
156 const res = await app.request("/api/user/tokens", {
157 headers: { authorization: "Bearer glc_deadbeefdeadbeefdeadbeef" },
158 });
159 // requireAuth's bearer-invalid branch returns 401 JSON (never a redirect).
160 expect(res.status).toBe(401);
161 const body = await res.json().catch(() => null);
162 expect(body && typeof body.error === "string").toBe(true);
163 });
164
165 it("GET /api/user/tokens rejects an invalid glct_ (OAuth) bearer with 401 JSON", async () => {
166 const res = await app.request("/api/user/tokens", {
167 headers: { authorization: "Bearer glct_notrealoauthtoken1234" },
168 });
169 expect(res.status).toBe(401);
170 });
171});
172
173// ---------------------------------------------------------------------------
174// Expired tokens — the auth middleware (locked) rejects PATs whose
175// expires_at has passed. We assert the observable effect: a PAT-prefixed
176// bearer that can never resolve (no DB row, or expired) → 401 JSON.
177// ---------------------------------------------------------------------------
178
179describe("api tokens — expiry enforcement (via middleware)", () => {
180 it("a well-formed but unknown glc_ token gets 401, not 500", async () => {
181 const fakeToken = "glc_" + "f".repeat(64);
182 const res = await app.request("/api/user/tokens", {
183 headers: { authorization: `Bearer ${fakeToken}` },
184 });
185 expect(res.status).toBe(401);
186 if (HAS_DB) {
187 const body = await res.json();
188 expect(body.error).toMatch(/invalid|expired/i);
189 }
190 });
191
192 it("a glc_ token shorter than the generator length still rejects cleanly", async () => {
193 // Covers the defensive-hash path — short inputs must not crash the
194 // loader; they must simply fail to match a stored hash.
195 const res = await app.request("/api/user/tokens", {
196 headers: { authorization: "Bearer glc_short" },
197 });
198 expect(res.status).toBe(401);
199 });
200});
Addedsrc/__tests__/crontech-deploy.test.ts+177−0View fileUnifiedSplit
@@ -0,0 +1,177 @@
1/**
2 * Crontech deploy-webhook sender — Finding 1.
3 *
4 * Asserts that the outbound `triggerCrontechDeploy` call sent from
5 * `src/hooks/post-receive.ts` matches the wire contract documented at the
6 * top of that helper:
7 *
8 * POST https://crontech.ai/api/hooks/gluecron/push
9 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
10 * Content-Type: application/json
11 * body = { repository, sha, branch, ref, source, timestamp }
12 *
13 * The helper swallows DB errors, so these tests work without a real DB.
14 */
15
16import { afterEach, beforeEach, describe, expect, it } from "bun:test";
17import { __test } from "../hooks/post-receive";
18
19const { triggerCrontechDeploy } = __test;
20
21interface CapturedCall {
22 url: string;
23 init: RequestInit;
24}
25
26const origFetch = globalThis.fetch;
27const origSecret = process.env.GLUECRON_WEBHOOK_SECRET;
28const origUrl = process.env.CRONTECH_DEPLOY_URL;
29
30function installFetchCapture(
31 respond: () => Response = () =>
32 new Response(
33 JSON.stringify({ ok: true, deploymentId: "d1", status: "queued" }),
34 { status: 200, headers: { "Content-Type": "application/json" } }
35 )
36): CapturedCall[] {
37 const calls: CapturedCall[] = [];
38 // @ts-expect-error — override global fetch for test
39 globalThis.fetch = async (
40 input: RequestInfo | URL,
41 init: RequestInit = {}
42 ): Promise<Response> => {
43 calls.push({ url: String(input), init });
44 return respond();
45 };
46 return calls;
47}
48
49function restoreFetch(): void {
50 globalThis.fetch = origFetch;
51}
52
53describe("hooks/post-receive — triggerCrontechDeploy (Finding 1 sender)", () => {
54 beforeEach(() => {
55 // Unset overrides so each test owns its env state.
56 delete process.env.GLUECRON_WEBHOOK_SECRET;
57 delete process.env.CRONTECH_DEPLOY_URL;
58 });
59
60 afterEach(() => {
61 restoreFetch();
62 if (origSecret === undefined) delete process.env.GLUECRON_WEBHOOK_SECRET;
63 else process.env.GLUECRON_WEBHOOK_SECRET = origSecret;
64 if (origUrl === undefined) delete process.env.CRONTECH_DEPLOY_URL;
65 else process.env.CRONTECH_DEPLOY_URL = origUrl;
66 });
67
68 it("is exported from __test", () => {
69 expect(typeof triggerCrontechDeploy).toBe("function");
70 });
71
72 it("POSTs to the new Crontech hooks endpoint (not the old tRPC URL)", async () => {
73 const calls = installFetchCapture();
74
75 await triggerCrontechDeploy(
76 "alice",
77 "widgets",
78 "a".repeat(40),
79 "00000000-0000-0000-0000-000000000000"
80 );
81
82 expect(calls.length).toBe(1);
83 expect(calls[0]!.url).toBe(
84 "https://crontech.ai/api/hooks/gluecron/push"
85 );
86 expect(calls[0]!.url).not.toContain("/api/trpc/tenant.deploy");
87 expect(calls[0]!.init.method).toBe("POST");
88 });
89
90 it("sends Authorization: Bearer <secret> when GLUECRON_WEBHOOK_SECRET is set", async () => {
91 process.env.GLUECRON_WEBHOOK_SECRET = "s3cret-token";
92 const calls = installFetchCapture();
93
94 await triggerCrontechDeploy(
95 "alice",
96 "widgets",
97 "b".repeat(40),
98 "00000000-0000-0000-0000-000000000000"
99 );
100
101 expect(calls.length).toBe(1);
102 const headers = calls[0]!.init.headers as Record<string, string>;
103 expect(headers["Authorization"]).toBe("Bearer s3cret-token");
104 expect(headers["Content-Type"]).toBe("application/json");
105 });
106
107 it("omits the Authorization header when no secret is configured", async () => {
108 // GLUECRON_WEBHOOK_SECRET deliberately unset by beforeEach.
109 const calls = installFetchCapture();
110
111 await triggerCrontechDeploy(
112 "alice",
113 "widgets",
114 "c".repeat(40),
115 "00000000-0000-0000-0000-000000000000"
116 );
117
118 expect(calls.length).toBe(1);
119 const headers = calls[0]!.init.headers as Record<string, string>;
120 expect(headers["Authorization"]).toBeUndefined();
121 expect(headers["Content-Type"]).toBe("application/json");
122 });
123
124 it("sends the wire-contract body shape (repository, sha, branch, ref, source, timestamp)", async () => {
125 const calls = installFetchCapture();
126 const sha = "d".repeat(40);
127
128 await triggerCrontechDeploy(
129 "acme",
130 "api",
131 sha,
132 "00000000-0000-0000-0000-000000000000"
133 );
134
135 expect(calls.length).toBe(1);
136 const body = JSON.parse(String(calls[0]!.init.body));
137 expect(body.repository).toBe("acme/api");
138 expect(body.sha).toBe(sha);
139 expect(body.branch).toBe("main");
140 expect(body.ref).toBe("refs/heads/main");
141 expect(body.source).toBe("gluecron");
142 expect(typeof body.timestamp).toBe("string");
143 // ISO-8601 sanity check
144 expect(new Date(body.timestamp).toString()).not.toBe("Invalid Date");
145 });
146
147 it("respects CRONTECH_DEPLOY_URL override", async () => {
148 process.env.CRONTECH_DEPLOY_URL =
149 "https://staging.crontech.ai/api/hooks/gluecron/push";
150 const calls = installFetchCapture();
151
152 await triggerCrontechDeploy(
153 "alice",
154 "widgets",
155 "e".repeat(40),
156 "00000000-0000-0000-0000-000000000000"
157 );
158
159 expect(calls.length).toBe(1);
160 expect(calls[0]!.url).toBe(
161 "https://staging.crontech.ai/api/hooks/gluecron/push"
162 );
163 });
164
165 it("does not throw when Crontech responds 401 (unset secret path)", async () => {
166 const calls = installFetchCapture(() => new Response("", { status: 401 }));
167 await expect(
168 triggerCrontechDeploy(
169 "alice",
170 "widgets",
171 "f".repeat(40),
172 "00000000-0000-0000-0000-000000000000"
173 )
174 ).resolves.toBeUndefined();
175 expect(calls.length).toBe(1);
176 });
177});
Addedsrc/__tests__/deploy-events.test.ts+276−0View fileUnifiedSplit
@@ -0,0 +1,276 @@
1/**
2 * Signal Bus P1 — inbound deploy-event receiver tests (E3/E4).
3 *
4 * Exercises `src/routes/events.ts` directly via its default Hono sub-app so
5 * the suite is hermetic: no need to mount on the main app (which is locked)
6 * and no live DB required. Tests that assert DB-backed side-effects run only
7 * when `DATABASE_URL` is present; otherwise they assert graceful degradation.
8 */
9
10import {
11 afterAll,
12 afterEach,
13 beforeAll,
14 beforeEach,
15 describe,
16 expect,
17 it,
18} from "bun:test";
19import events, { __test } from "../routes/events";
20
21const VALID_EVENT_ID_A = "11111111-1111-4111-8111-111111111111";
22const VALID_EVENT_ID_B = "22222222-2222-4222-8222-222222222222";
23const VALID_SHA = "a".repeat(40);
24
25const origToken = process.env.CRONTECH_EVENT_TOKEN;
26
27function makePayload(
28 overrides: Partial<Record<string, unknown>> = {}
29): Record<string, unknown> {
30 return {
31 event: "deploy.succeeded",
32 eventId: VALID_EVENT_ID_A,
33 repository: "alice/widgets",
34 sha: VALID_SHA,
35 environment: "production",
36 deploymentId: "crontech-dep-123",
37 timestamp: "2025-06-01T12:00:00.000Z",
38 ...overrides,
39 };
40}
41
42async function post(
43 body: unknown,
44 headers: Record<string, string> = {}
45): Promise<Response> {
46 return events.request("/deploy", {
47 method: "POST",
48 headers: {
49 "Content-Type": "application/json",
50 ...headers,
51 },
52 body: typeof body === "string" ? body : JSON.stringify(body),
53 });
54}
55
56beforeAll(() => {
57 process.env.CRONTECH_EVENT_TOKEN = "test-token-for-unit-suite";
58});
59
60afterAll(() => {
61 if (origToken === undefined) delete process.env.CRONTECH_EVENT_TOKEN;
62 else process.env.CRONTECH_EVENT_TOKEN = origToken;
63});
64
65// ---------------------------------------------------------------------------
66// Bearer auth
67// ---------------------------------------------------------------------------
68
69describe("events/deploy — bearer auth", () => {
70 it("rejects with 401 when Authorization header is missing", async () => {
71 const res = await post(makePayload());
72 expect(res.status).toBe(401);
73 const body = await res.json();
74 expect(body.ok).toBe(false);
75 expect(String(body.error).toLowerCase()).toContain("bearer");
76 });
77
78 it("rejects with 401 when Bearer token is wrong", async () => {
79 const res = await post(makePayload(), {
80 authorization: "Bearer not-the-real-token",
81 });
82 expect(res.status).toBe(401);
83 const body = await res.json();
84 expect(body.ok).toBe(false);
85 });
86
87 it("rejects with 401 when CRONTECH_EVENT_TOKEN is unset (refuse-by-default)", async () => {
88 const saved = process.env.CRONTECH_EVENT_TOKEN;
89 delete process.env.CRONTECH_EVENT_TOKEN;
90 try {
91 const res = await post(makePayload(), {
92 authorization: "Bearer anything",
93 });
94 expect(res.status).toBe(401);
95 const body = await res.json();
96 expect(String(body.error).toLowerCase()).toContain("not configured");
97 } finally {
98 if (saved !== undefined) process.env.CRONTECH_EVENT_TOKEN = saved;
99 }
100 });
101});
102
103// ---------------------------------------------------------------------------
104// Payload validation
105// ---------------------------------------------------------------------------
106
107describe("events/deploy — payload validation", () => {
108 const authHeader = { authorization: "Bearer test-token-for-unit-suite" };
109
110 it("rejects malformed JSON with 400", async () => {
111 const res = await post("{not-json", authHeader);
112 expect(res.status).toBe(400);
113 const body = await res.json();
114 expect(String(body.error).toLowerCase()).toContain("json");
115 });
116
117 it("rejects unknown event type with 400", async () => {
118 const res = await post(makePayload({ event: "deploy.canceled" }), authHeader);
119 expect(res.status).toBe(400);
120 });
121
122 it("rejects non-uuid eventId with 400", async () => {
123 const res = await post(makePayload({ eventId: "not-a-uuid" }), authHeader);
124 expect(res.status).toBe(400);
125 const body = await res.json();
126 expect(String(body.error).toLowerCase()).toContain("eventid");
127 });
128
129 it("rejects invalid sha with 400", async () => {
130 const res = await post(makePayload({ sha: "xyz" }), authHeader);
131 expect(res.status).toBe(400);
132 });
133
134 it("rejects deploy.failed without errorCategory + errorSummary", async () => {
135 const res = await post(
136 makePayload({
137 event: "deploy.failed",
138 eventId: VALID_EVENT_ID_B,
139 }),
140 authHeader
141 );
142 expect(res.status).toBe(400);
143 const body = await res.json();
144 expect(String(body.error).toLowerCase()).toMatch(/errorcategory|errorsummary/);
145 });
146
147 it("rejects errorSummary over 500 chars on deploy.failed", async () => {
148 const res = await post(
149 makePayload({
150 event: "deploy.failed",
151 eventId: VALID_EVENT_ID_B,
152 errorCategory: "build",
153 errorSummary: "x".repeat(501),
154 }),
155 authHeader
156 );
157 expect(res.status).toBe(400);
158 });
159});
160
161// ---------------------------------------------------------------------------
162// Pure validator (no HTTP) — exercises __test.validatePayload.
163// ---------------------------------------------------------------------------
164
165describe("events/deploy — validatePayload helper", () => {
166 it("accepts a well-formed deploy.succeeded payload", () => {
167 const result = __test.validatePayload(makePayload());
168 expect(result.ok).toBe(true);
169 });
170
171 it("accepts a well-formed deploy.failed payload with required error fields", () => {
172 const result = __test.validatePayload(
173 makePayload({
174 event: "deploy.failed",
175 errorCategory: "runtime",
176 errorSummary: "Container OOM-killed after 42s",
177 })
178 );
179 expect(result.ok).toBe(true);
180 });
181
182 it("rejects non-object bodies", () => {
183 expect(__test.validatePayload(null).ok).toBe(false);
184 expect(__test.validatePayload("hello").ok).toBe(false);
185 expect(__test.validatePayload(42).ok).toBe(false);
186 });
187
188 it("rejects unknown errorCategory on deploy.failed", () => {
189 const result = __test.validatePayload(
190 makePayload({
191 event: "deploy.failed",
192 errorCategory: "nuclear",
193 errorSummary: "boom",
194 })
195 );
196 expect(result.ok).toBe(false);
197 });
198});
199
200// ---------------------------------------------------------------------------
201// Side-effect paths — these hit the DB. Without a DATABASE_URL the handler
202// degrades gracefully (idempotency lookup swallows, insert returns 500, etc.).
203// We run a relaxed assertion in no-DB mode and a strict one with DB.
204// ---------------------------------------------------------------------------
205
206const HAS_DB = Boolean(process.env.DATABASE_URL);
207
208describe("events/deploy — idempotency + update (DB-aware)", () => {
209 const authHeader = { authorization: "Bearer test-token-for-unit-suite" };
210
211 beforeEach(() => {
212 process.env.CRONTECH_EVENT_TOKEN = "test-token-for-unit-suite";
213 });
214
215 afterEach(() => {
216 // no-op — env restored by afterAll
217 });
218
219 it("E3 deploy.succeeded: returns ok + duplicate:false on first delivery (or 500 without DB)", async () => {
220 const res = await post(
221 makePayload({ eventId: VALID_EVENT_ID_A }),
222 authHeader
223 );
224 if (HAS_DB) {
225 expect(res.status).toBe(200);
226 const body = await res.json();
227 expect(body.ok).toBe(true);
228 expect(body.duplicate).toBe(false);
229 } else {
230 // Without a DB the insert into processed_events will throw; handler
231 // returns 500 with {ok:false}. This is the "graceful no-DB" contract.
232 expect([200, 500]).toContain(res.status);
233 }
234 });
235
236 it("replaying the same eventId returns duplicate:true and does not double-side-effect", async () => {
237 const payload = makePayload({ eventId: VALID_EVENT_ID_A });
238 const first = await post(payload, authHeader);
239 const second = await post(payload, authHeader);
240
241 if (HAS_DB) {
242 expect(first.status).toBe(200);
243 expect(second.status).toBe(200);
244 const firstBody = await first.json();
245 const secondBody = await second.json();
246 // Whichever call loses the race is duplicate. At most one is false.
247 const duplicates = [firstBody.duplicate, secondBody.duplicate];
248 expect(duplicates.filter(Boolean).length).toBeGreaterThanOrEqual(1);
249 } else {
250 // Without DB, both attempts fail the insert; we only assert that both
251 // return a JSON body rather than crashing.
252 expect([200, 500]).toContain(first.status);
253 expect([200, 500]).toContain(second.status);
254 }
255 });
256
257 it("E4 deploy.failed is accepted and returns JSON (DB-aware)", async () => {
258 const res = await post(
259 makePayload({
260 event: "deploy.failed",
261 eventId: VALID_EVENT_ID_B,
262 errorCategory: "build",
263 errorSummary: "npm install exited 1",
264 logsUrl: "https://crontech.ai/logs/xyz",
265 }),
266 authHeader
267 );
268 if (HAS_DB) {
269 expect(res.status).toBe(200);
270 const body = await res.json();
271 expect(body.ok).toBe(true);
272 } else {
273 expect([200, 500]).toContain(res.status);
274 }
275 });
276});
Addedsrc/__tests__/ssh-keys.test.ts+197−0View fileUnifiedSplit
@@ -0,0 +1,197 @@
1/**
2 * SSH keys — CRUD coverage.
3 *
4 * `src/routes/settings.tsx` is a §4 LOCKED BLOCK, so these tests exercise the
5 * public HTTP surface via the app router and assert behavioural contracts
6 * (auth guard, accepted key formats, ownership enforcement, list shape) that
7 * must be preserved. All mutations are guarded by `requireAuth`, so without a
8 * real session the endpoints redirect to `/login` — that redirect is itself
9 * the auth-contract we test. DB-backed side-effects only execute when a
10 * `DATABASE_URL` is present; otherwise the handler degrades gracefully.
11 *
12 * Pure-logic tests (fingerprint derivation, key-format validator) replicate
13 * the same algorithms used inside the locked route so we catch regressions
14 * if the wire format ever changes without adding a test fixture.
15 */
16
17import { describe, it, expect } from "bun:test";
18import app from "../app";
19
20const HAS_DB = Boolean(process.env.DATABASE_URL);
21
22// ---------------------------------------------------------------------------
23// Pure helpers mirroring the locked route's validation logic. Kept in-file
24// rather than imported (route is LOCKED, no __test export available).
25// ---------------------------------------------------------------------------
26
27function isValidSshKeyFormat(publicKey: string): boolean {
28 return (
29 publicKey.startsWith("ssh-rsa ") ||
30 publicKey.startsWith("ssh-ed25519 ") ||
31 publicKey.startsWith("ecdsa-sha2-")
32 );
33}
34
35async function computeFingerprint(publicKey: string): Promise<string> {
36 const keyData = publicKey.split(" ")[1] || "";
37 const hashBuffer = await crypto.subtle.digest(
38 "SHA-256",
39 new TextEncoder().encode(keyData)
40 );
41 return (
42 "SHA256:" +
43 btoa(String.fromCharCode(...new Uint8Array(hashBuffer))).replace(/=+$/, "")
44 );
45}
46
47// ---------------------------------------------------------------------------
48// Pure logic
49// ---------------------------------------------------------------------------
50
51describe("ssh keys — accepted public-key formats", () => {
52 it("accepts ssh-ed25519 keys", () => {
53 expect(
54 isValidSshKeyFormat(
55 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEKEYBYTES alice@laptop"
56 )
57 ).toBe(true);
58 });
59
60 it("accepts ssh-rsa keys", () => {
61 expect(isValidSshKeyFormat("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAB")).toBe(
62 true
63 );
64 });
65
66 it("accepts ecdsa-sha2-* keys", () => {
67 expect(
68 isValidSshKeyFormat("ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlz")
69 ).toBe(true);
70 });
71
72 it("rejects malformed / unsupported key prefixes", () => {
73 expect(isValidSshKeyFormat("")).toBe(false);
74 expect(isValidSshKeyFormat("not-a-key")).toBe(false);
75 expect(isValidSshKeyFormat("ssh-dss AAAAB3NzaC1kc3M=")).toBe(false);
76 // Case-sensitive prefix check — uppercase should be rejected.
77 expect(isValidSshKeyFormat("SSH-RSA AAAA")).toBe(false);
78 });
79
80 it("rejects keys with leading whitespace (contract preserves strict prefix)", () => {
81 expect(isValidSshKeyFormat(" ssh-ed25519 AAAA")).toBe(false);
82 });
83});
84
85describe("ssh keys — fingerprint shape", () => {
86 it("produces a SHA256:… fingerprint without padding", async () => {
87 const fp = await computeFingerprint(
88 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEBYTESHERE"
89 );
90 expect(fp.startsWith("SHA256:")).toBe(true);
91 expect(fp.endsWith("=")).toBe(false);
92 });
93
94 it("is deterministic for the same key body", async () => {
95 const k = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEDETERMINISTICBYTES";
96 expect(await computeFingerprint(k)).toBe(await computeFingerprint(k));
97 });
98
99 it("differs across distinct key bodies", async () => {
100 const a = await computeFingerprint("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAA");
101 const b = await computeFingerprint("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5BBBBB");
102 expect(a).not.toBe(b);
103 });
104});
105
106// ---------------------------------------------------------------------------
107// Route auth contract (no session cookie)
108// ---------------------------------------------------------------------------
109
110describe("ssh keys — /settings/keys auth guard", () => {
111 it("GET /settings/keys without a session → redirect to /login", async () => {
112 const res = await app.request("/settings/keys");
113 expect(res.status).toBe(302);
114 expect(res.headers.get("location") || "").toContain("/login");
115 });
116
117 it("POST /settings/keys (add) without a session → redirect to /login", async () => {
118 const res = await app.request("/settings/keys", {
119 method: "POST",
120 headers: { "content-type": "application/x-www-form-urlencoded" },
121 body: new URLSearchParams({
122 title: "laptop",
123 public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE alice",
124 }),
125 });
126 expect(res.status).toBe(302);
127 expect(res.headers.get("location") || "").toContain("/login");
128 });
129
130 it("POST /settings/keys/:id/delete without a session → redirect to /login", async () => {
131 const res = await app.request(
132 "/settings/keys/00000000-0000-0000-0000-000000000000/delete",
133 { method: "POST" }
134 );
135 expect(res.status).toBe(302);
136 expect(res.headers.get("location") || "").toContain("/login");
137 });
138});
139
140// ---------------------------------------------------------------------------
141// JSON API (Authorization-less) — same contract, but these live under
142// /api/user/keys where the redirect target is still /login.
143// ---------------------------------------------------------------------------
144
145describe("ssh keys — /api/user/keys auth guard", () => {
146 it("GET /api/user/keys without a session → redirect to /login (302)", async () => {
147 const res = await app.request("/api/user/keys");
148 expect(res.status).toBe(302);
149 expect(res.headers.get("location") || "").toContain("/login");
150 });
151
152 it("POST /api/user/keys without a session → redirect to /login (302)", async () => {
153 const res = await app.request("/api/user/keys", {
154 method: "POST",
155 headers: { "content-type": "application/json" },
156 body: JSON.stringify({
157 title: "laptop",
158 public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI alice",
159 }),
160 });
161 expect(res.status).toBe(302);
162 expect(res.headers.get("location") || "").toContain("/login");
163 });
164
165 it("DELETE /api/user/keys/:id without a session → redirect to /login (302)", async () => {
166 const res = await app.request(
167 "/api/user/keys/00000000-0000-0000-0000-000000000000",
168 { method: "DELETE" }
169 );
170 expect(res.status).toBe(302);
171 expect(res.headers.get("location") || "").toContain("/login");
172 });
173
174 it("POST /api/user/keys rejects an invalid PAT bearer with 401 JSON", async () => {
175 // `requireAuth` must 401 for Bearer tokens (JSON), NOT redirect.
176 const res = await app.request("/api/user/keys", {
177 method: "POST",
178 headers: {
179 "content-type": "application/json",
180 authorization: "Bearer glc_notarealtoken1234567890",
181 },
182 body: JSON.stringify({
183 title: "ci",
184 public_key: "ssh-ed25519 AAAA",
185 }),
186 });
187 if (HAS_DB) {
188 expect(res.status).toBe(401);
189 const body = await res.json().catch(() => null);
190 expect(body && body.error).toBeTruthy();
191 } else {
192 // Without a DB the PAT loader catches and returns null; requireAuth
193 // then returns 401 JSON from its invalid-bearer branch.
194 expect(res.status).toBe(401);
195 }
196 });
197});
Modifiedsrc/app.tsx+68−3View fileUnifiedSplit
@@ -23,8 +23,65 @@ import exploreRoutes from "./routes/explore";
2323import tokenRoutes from "./routes/tokens";
2424import contributorRoutes from "./routes/contributors";
2525import notificationRoutes from "./routes/notifications";
26import dashboardRoutes from "./routes/dashboard";
27import askRoutes from "./routes/ask";
28import releaseRoutes from "./routes/releases";
29import gateRoutes from "./routes/gates";
30import insightsRoutes from "./routes/insights";
31import searchRoutes from "./routes/search";
32import healthRoutes from "./routes/health";
33import hookRoutes from "./routes/hooks";
34import eventsRoutes from "./routes/events";
35import themeRoutes from "./routes/theme";
36import auditRoutes from "./routes/audit";
37import reactionRoutes from "./routes/reactions";
38import savedReplyRoutes from "./routes/saved-replies";
39import deploymentRoutes from "./routes/deployments";
2640import orgRoutes from "./routes/orgs";
27import onboardingRoutes from "./routes/onboarding";
41import passkeyRoutes from "./routes/passkeys";
42import oauthRoutes from "./routes/oauth";
43import developerAppsRoutes from "./routes/developer-apps";
44import workflowRoutes from "./routes/workflows";
45import packagesApiRoutes from "./routes/packages-api";
46import packagesUiRoutes from "./routes/packages";
47import pagesRoutes from "./routes/pages";
48import environmentsRoutes from "./routes/environments";
49import aiExplainRoutes from "./routes/ai-explain";
50import aiChangelogRoutes from "./routes/ai-changelog";
51import copilotRoutes from "./routes/copilot";
52import depUpdaterRoutes from "./routes/dep-updater";
53import semanticSearchRoutes from "./routes/semantic-search";
54import aiTestsRoutes from "./routes/ai-tests";
55import discussionRoutes from "./routes/discussions";
56import gistRoutes from "./routes/gists";
57import projectRoutes from "./routes/projects";
58import wikiRoutes from "./routes/wikis";
59import mergeQueueRoutes from "./routes/merge-queue";
60import requiredChecksRoutes from "./routes/required-checks";
61import protectedTagsRoutes from "./routes/protected-tags";
62import trafficRoutes from "./routes/traffic";
63import orgInsightsRoutes from "./routes/org-insights";
64import adminRoutes from "./routes/admin";
65import billingRoutes from "./routes/billing";
66import pwaRoutes from "./routes/pwa";
67import graphqlRoutes from "./routes/graphql";
68import marketplaceRoutes from "./routes/marketplace";
69import templatesRoutes from "./routes/templates";
70import codeScanningRoutes from "./routes/code-scanning";
71import sponsorsRoutes from "./routes/sponsors";
72import symbolsRoutes from "./routes/symbols";
73import mirrorsRoutes from "./routes/mirrors";
74import ssoRoutes from "./routes/sso";
75import depsRoutes from "./routes/deps";
76import advisoriesRoutes from "./routes/advisories";
77import signingKeysRoutes from "./routes/signing-keys";
78import followsRoutes from "./routes/follows";
79import rulesetsRoutes from "./routes/rulesets";
80import commitStatusesRoutes from "./routes/commit-statuses";
81import legalTermsRoutes from "./routes/legal/terms";
82import legalPrivacyRoutes from "./routes/legal/privacy";
83import legalAcceptableUseRoutes from "./routes/legal/acceptable-use";
84import legalDmcaRoutes from "./routes/legal/dmca";
2885import webRoutes from "./routes/web";
2986import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
3087import { csrfToken, csrfProtect } from "./middleware/csrf";
@@ -67,8 +124,9 @@ app.route("/", gitRoutes);
67124// REST API v1 (legacy)
68125app.route("/", apiRoutes);
69126
70// REST API v2 (comprehensive, token-authenticated)
71app.route("/", apiV2Routes);
127// Inbound API hooks (GateTest callback + backup PAT-authed /api/v1/gate-runs)
128app.route("/", hookRoutes);
129app.route("/api/events", eventsRoutes);
72130
73131// API documentation
74132app.route("/", apiDocsRoutes);
@@ -223,6 +281,13 @@ app.route("/", rulesetsRoutes);
223281// Commit status API — /api/v1/repos/:o/:r/statuses/:sha (Block J8)
224282app.route("/", commitStatusesRoutes);
225283
284// Legal pages — /legal/terms, /legal/privacy, /legal/acceptable-use, /legal/dmca
285// Static JSX, read-only. DRAFT — requires attorney review before launch.
286app.route("/", legalTermsRoutes);
287app.route("/", legalPrivacyRoutes);
288app.route("/", legalAcceptableUseRoutes);
289app.route("/", legalDmcaRoutes);
290
226291// Insights + milestones
227292app.route("/", insightsRoutes);
228293
Addedsrc/db/schema-events.ts+32−0View fileUnifiedSplit
@@ -0,0 +1,32 @@
1/**
2 * Signal Bus P1 — Drizzle schema for the inbound-event idempotency table.
3 *
4 * Defined in a SEPARATE module because `src/db/schema.ts` is listed in
5 * §4 LOCKED BLOCKS of BUILD_BIBLE.md and must not be edited. New tables are
6 * allowed "only via new migration"; this module supplies the matching Drizzle
7 * definitions that migration `drizzle/0034_processed_events.sql` creates at
8 * the SQL layer. Import `processedEvents` directly from this module.
9 *
10 * Columns mirror the SQL migration 1:1. Keep them in sync whenever the
11 * migration is superseded by a follow-up additive migration.
12 */
13
14import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
15
16export const processedEvents = pgTable(
17 "processed_events",
18 {
19 id: uuid("id").primaryKey().defaultRandom(),
20 eventId: text("event_id").notNull().unique(),
21 eventType: text("event_type").notNull(),
22 source: text("source").notNull(),
23 receivedAt: timestamp("received_at").defaultNow().notNull(),
24 // Raw payload retained for forensics / replay. jsonb column — callers
25 // pass a plain object and drizzle serialises it.
26 payload: jsonb("payload"),
27 },
28 (table) => [
29 index("processed_events_event_id_idx").on(table.eventId),
30 index("processed_events_source_type_idx").on(table.source, table.eventType),
31 ]
32);
Modifiedsrc/hooks/post-receive.ts+39−1View fileUnifiedSplit
@@ -373,6 +373,33 @@ export async function onPostReceive(
373373 await Promise.allSettled(promises);
374374}
375375
376/**
377 * Trigger Crontech auto-deploy via the outbound webhook.
378 *
379 * Wire contract (Gluecron's copy — do not import from Crontech):
380 *
381 * POST https://crontech.ai/api/hooks/gluecron/push
382 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
383 * Content-Type: application/json
384 *
385 * {
386 * "repository": "owner/name",
387 * "sha": "<40-hex>",
388 * "branch": "main",
389 * "ref": "refs/heads/main",
390 * "source": "gluecron",
391 * "timestamp": "<ISO-8601>"
392 * }
393 *
394 * → 200 { ok: true, deploymentId, status: "queued" | "skipped" }
395 * → 401 invalid bearer token
396 * → 400 malformed payload
397 * → 404 repository not configured for auto-deploy on Crontech
398 *
399 * If `GLUECRON_WEBHOOK_SECRET` is unset we silently omit the Authorization
400 * header — Crontech will then respond 401, which we treat as a failed deploy
401 * row exactly like any other non-ok HTTP response.
402 */
376403async function triggerCrontechDeploy(
377404 owner: string,
378405 repo: string,
@@ -398,14 +425,22 @@ async function triggerCrontechDeploy(
398425 }
399426
400427 try {
428 const headers: Record<string, string> = {
429 "Content-Type": "application/json",
430 };
431 if (config.gluecronWebhookSecret) {
432 headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`;
433 }
401434 const response = await fetch(config.crontechDeployUrl, {
402435 method: "POST",
403 headers: { "Content-Type": "application/json" },
436 headers,
404437 body: JSON.stringify({
405438 repository: `${owner}/${repo}`,
406439 sha,
407440 branch: "main",
441 ref: "refs/heads/main",
408442 source: "gluecron",
443 timestamp: new Date().toISOString(),
409444 }),
410445 });
411446 console.log(
@@ -476,3 +511,6 @@ async function fanoutWebhooks(
476511 // best-effort
477512 }
478513}
514
515/** Test-only access to internal helpers. */
516export const __test = { triggerCrontechDeploy };
Modifiedsrc/lib/config.ts+9−1View fileUnifiedSplit
@@ -19,9 +19,17 @@ export const config = {
1919 get crontechDeployUrl() {
2020 return (
2121 process.env.CRONTECH_DEPLOY_URL ||
22 "https://crontech.ai/api/trpc/tenant.deploy"
22 "https://crontech.ai/api/hooks/gluecron/push"
2323 );
2424 },
25 /**
26 * Bearer token sent on outbound deploy webhook to Crontech's
27 * `POST /api/hooks/gluecron/push` endpoint. Default empty → header is
28 * omitted and Crontech will reject with 401 (treated as a failed deploy).
29 */
30 get gluecronWebhookSecret() {
31 return process.env.GLUECRON_WEBHOOK_SECRET || "";
32 },
2533 get anthropicApiKey() {
2634 return process.env.ANTHROPIC_API_KEY || "";
2735 },
Modifiedsrc/lib/signatures.ts+2−2View fileUnifiedSplit
@@ -356,7 +356,7 @@ export async function fingerprintForPublicKey(
356356 } catch {
357357 return null;
358358 }
359 const digest = await crypto.subtle.digest("SHA-256", bytes);
359 const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource);
360360 // Base64 (unpadded) — mimics `ssh-keygen -l -E sha256`.
361361 const b64 = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(
362362 /=+$/,
@@ -422,7 +422,7 @@ export function analyzeRawCommit(
422422}
423423
424424async function fingerprintSshBytes(bytes: Uint8Array): Promise<string> {
425 const digest = await crypto.subtle.digest("SHA-256", bytes);
425 const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource);
426426 const b64 = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(
427427 /=+$/,
428428 ""
Modifiedsrc/lib/totp.ts+2−2View fileUnifiedSplit
@@ -68,12 +68,12 @@ async function hmacSha1(
6868): Promise<Uint8Array> {
6969 const key = await crypto.subtle.importKey(
7070 "raw",
71 keyBytes,
71 keyBytes as BufferSource,
7272 { name: "HMAC", hash: "SHA-1" },
7373 false,
7474 ["sign"]
7575 );
76 const sig = await crypto.subtle.sign("HMAC", key, msgBytes);
76 const sig = await crypto.subtle.sign("HMAC", key, msgBytes as BufferSource);
7777 return new Uint8Array(sig);
7878}
7979
Modifiedsrc/routes/admin.tsx+6−6View fileUnifiedSplit
@@ -184,7 +184,7 @@ admin.get("/admin/users", async (c) => {
184184 return c.html(
185185 <Layout title="Admin — Users" user={user}>
186186 <h2>Users</h2>
187 <form method="GET" action="/admin/users" style="margin-bottom:16px">
187 <form method="get" action="/admin/users" style="margin-bottom:16px">
188188 <input
189189 type="text"
190190 name="q"
@@ -221,7 +221,7 @@ admin.get("/admin/users", async (c) => {
221221 )}
222222 </div>
223223 <form
224 method="POST"
224 method="post"
225225 action={`/admin/users/${u.id}/admin`}
226226 onsubmit={
227227 isAdmin
@@ -323,7 +323,7 @@ admin.get("/admin/repos", async (c) => {
323323 </div>
324324 </div>
325325 <form
326 method="POST"
326 method="post"
327327 action={`/admin/repos/${r.id}/delete`}
328328 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
329329 >
@@ -378,7 +378,7 @@ admin.get("/admin/flags", async (c) => {
378378 </a>
379379 </div>
380380 <form
381 method="POST"
381 method="post"
382382 action="/admin/flags"
383383 class="panel"
384384 style="padding:16px"
@@ -471,7 +471,7 @@ admin.get("/admin/digests", async (c) => {
471471 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
472472 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
473473 </div>
474 <form method="POST" action="/admin/digests/run" style="margin-bottom:8px">
474 <form method="post" action="/admin/digests/run" style="margin-bottom:8px">
475475 <button
476476 type="submit"
477477 class="btn btn-primary"
@@ -480,7 +480,7 @@ admin.get("/admin/digests", async (c) => {
480480 Send digests now
481481 </button>
482482 </form>
483 <form method="POST" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
483 <form method="post" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
484484 <input
485485 type="text"
486486 name="username"
Modifiedsrc/routes/advisories.tsx+3−3View fileUnifiedSplit
@@ -92,7 +92,7 @@ async function renderList(
9292 <h2 style="margin:0">Security advisories</h2>
9393 {isOwner && (
9494 <form
95 method="POST"
95 method="post"
9696 action={`/${ownerName}/${repoName}/security/advisories/scan`}
9797 >
9898 <button type="submit" class="btn btn-primary btn-sm">
@@ -207,7 +207,7 @@ async function renderList(
207207 <div style="display:flex;gap:6px">
208208 {a.status === "open" && (
209209 <form
210 method="POST"
210 method="post"
211211 action={`/${ownerName}/${repoName}/security/advisories/${a.id}/dismiss`}
212212 style="display:flex;gap:4px;align-items:center"
213213 >
@@ -229,7 +229,7 @@ async function renderList(
229229 )}
230230 {a.status === "dismissed" && (
231231 <form
232 method="POST"
232 method="post"
233233 action={`/${ownerName}/${repoName}/security/advisories/${a.id}/reopen`}
234234 >
235235 <button
Modifiedsrc/routes/ai-changelog.tsx+2−2View fileUnifiedSplit
@@ -122,7 +122,7 @@ aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
122122 </div>
123123 )}
124124 <form
125 method="GET"
125 method="get"
126126 action={`/${owner}/${repo}/ai/changelog`}
127127 style="display: flex; gap: 12px; align-items: center; margin-bottom: 20px; flex-wrap: wrap"
128128 >
@@ -246,7 +246,7 @@ aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
246246 {commits.length} commit{commits.length !== 1 ? "s" : ""}
247247 </div>
248248 <form
249 method="GET"
249 method="get"
250250 action={`/${owner}/${repo}/ai/changelog`}
251251 style="display: flex; gap: 8px; align-items: center; margin-bottom: 20px; flex-wrap: wrap"
252252 >
Modifiedsrc/routes/ai-explain.tsx+1−1View fileUnifiedSplit
@@ -135,7 +135,7 @@ aiExplainRoutes.get(
135135 <h2 style="margin: 0;">Codebase explanation</h2>
136136 {canRegenerate && (
137137 <form
138 method="POST"
138 method="post"
139139 action={`/${owner}/${repo}/explain/regenerate`}
140140 style="display: inline"
141141 >
Modifiedsrc/routes/ai-tests.tsx+2−2View fileUnifiedSplit
@@ -149,7 +149,7 @@ function renderPicker(
149149 const trimmed = allFiles.slice(0, 200);
150150 return (
151151 <form
152 method="POST"
152 method="post"
153153 action={`/${ownerName}/${repoName}/ai/tests/generate`}
154154 style="margin-top: 16px; display: flex; flex-direction: column; gap: 12px; max-width: 720px;"
155155 >
@@ -350,7 +350,7 @@ aiTestsRoutes.post(
350350 </p>
351351 </div>
352352 <form
353 method="POST"
353 method="post"
354354 action={`/${owner}/${repo}/ai/tests/generate`}
355355 style="display: inline;"
356356 >
Modifiedsrc/routes/ask.tsx+1−1View fileUnifiedSplit
@@ -96,7 +96,7 @@ function renderChatView(
9696 )}
9797 </div>
9898
99 <form method="POST" action={postUrl} class="chat-form">
99 <form method="post" action={postUrl} class="chat-form">
100100 <textarea
101101 name="message"
102102 placeholder={placeholder}
Modifiedsrc/routes/auth.tsx+9−7View fileUnifiedSplit
@@ -43,10 +43,11 @@ auth.get("/register", (c) => {
4343 <Layout title="Register">
4444 <div class="auth-container">
4545 <h2>Create account</h2>
46 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
47 <Form action="/register">
48 <FormGroup label="Username" htmlFor="username">
49 <Input
46 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
47 <form method="post" action="/register">
48 <div class="form-group">
49 <label for="username">Username</label>
50 <input
5051 type="text"
5152 name="username"
5253 required
@@ -174,8 +175,9 @@ auth.get("/login", async (c) => {
174175 <Layout title="Sign in">
175176 <div class="auth-container">
176177 <h2>Sign in</h2>
177 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
178 <Form
178 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
179 <form
180 method="post"
179181 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
180182 >
181183 <FormGroup label="Username or email" htmlFor="username">
@@ -361,7 +363,7 @@ auth.get("/login/2fa", async (c) => {
361363 </p>
362364 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
363365 <form
364 method="POST"
366 method="post"
365367 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
366368 >
367369 <div class="form-group">
Modifiedsrc/routes/billing.tsx+1−1View fileUnifiedSplit
@@ -210,7 +210,7 @@ billing.get("/admin/billing", async (c) => {
210210 </div>
211211 </div>
212212 <form
213 method="POST"
213 method="post"
214214 action={`/admin/billing/${r.id}/plan`}
215215 style="display:flex;gap:6px;align-items:center"
216216 >
Modifiedsrc/routes/dep-updater.tsx+1−1View fileUnifiedSplit
@@ -150,7 +150,7 @@ depUpdater.get(
150150 </p>
151151
152152 <form
153 method="POST"
153 method="post"
154154 action={`/${ownerName}/${repoName}/settings/dep-updater/run`}
155155 style="margin-bottom: 24px"
156156 >
Modifiedsrc/routes/deps.tsx+1−1View fileUnifiedSplit
@@ -78,7 +78,7 @@ deps.get("/:owner/:repo/dependencies", async (c) => {
7878 <h2 style="margin:0">Dependencies</h2>
7979 {isOwner && (
8080 <form
81 method="POST"
81 method="post"
8282 action={`/${ownerName}/${repoName}/dependencies/reindex`}
8383 >
8484 <button type="submit" class="btn btn-primary btn-sm">
Modifiedsrc/routes/developer-apps.tsx+4−4View fileUnifiedSplit
@@ -154,7 +154,7 @@ apps.get("/settings/applications/new", async (c) => {
154154 </div>
155155 <h2>Register a new OAuth app</h2>
156156 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
157 <form method="POST" action="/settings/applications/new">
157 <form method="post" action="/settings/applications/new">
158158 <div class="form-group">
159159 <label for="name">Application name</label>
160160 <input
@@ -351,7 +351,7 @@ apps.get("/settings/applications/:id", async (c) => {
351351 <dd>{new Date(app.createdAt).toLocaleString()}</dd>
352352 </dl>
353353
354 <form method="POST" action={`/settings/applications/${app.id}`}>
354 <form method="post" action={`/settings/applications/${app.id}`}>
355355 <div class="form-group">
356356 <label for="name">Application name</label>
357357 <input
@@ -408,7 +408,7 @@ apps.get("/settings/applications/:id", async (c) => {
408408 old secret will fail.
409409 </p>
410410 <form
411 method="POST"
411 method="post"
412412 action={`/settings/applications/${app.id}/rotate`}
413413 onsubmit="return confirm('Rotate the client secret? The old one will stop working immediately.')"
414414 >
@@ -421,7 +421,7 @@ apps.get("/settings/applications/:id", async (c) => {
421421
422422 <h3 style="color: var(--red)">Danger zone</h3>
423423 <form
424 method="POST"
424 method="post"
425425 action={`/settings/applications/${app.id}/delete`}
426426 onsubmit="return confirm('Delete this OAuth app? All issued access tokens will be revoked.')"
427427 >
Modifiedsrc/routes/discussions.tsx+6−6View fileUnifiedSplit
@@ -196,7 +196,7 @@ discussionRoutes.get(
196196 <RepoHeader owner={ownerName} repo={repoName} />
197197 <h2 style="margin-top: 20px;">Start a discussion</h2>
198198 <form
199 method="POST"
199 method="post"
200200 action={`/${ownerName}/${repoName}/discussions`}
201201 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
202202 >
@@ -366,7 +366,7 @@ discussionRoutes.get(
366366 discussion.d.category === "q-and-a" &&
367367 !isAnswer && (
368368 <form
369 method="POST"
369 method="post"
370370 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/answer/${com.c.id}`}
371371 style="display: inline;"
372372 >
@@ -387,7 +387,7 @@ discussionRoutes.get(
387387 })}
388388 {user && !discussion.d.locked && discussion.d.state === "open" && (
389389 <form
390 method="POST"
390 method="post"
391391 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/comment`}
392392 style="margin-top: 24px; display: flex; flex-direction: column; gap: 8px;"
393393 >
@@ -407,7 +407,7 @@ discussionRoutes.get(
407407 <div style="margin-top: 24px; display: flex; gap: 8px;">
408408 {canModerate && (
409409 <form
410 method="POST"
410 method="post"
411411 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/close`}
412412 style="display: inline;"
413413 >
@@ -419,7 +419,7 @@ discussionRoutes.get(
419419 {isOwner && (
420420 <>
421421 <form
422 method="POST"
422 method="post"
423423 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/lock`}
424424 style="display: inline;"
425425 >
@@ -428,7 +428,7 @@ discussionRoutes.get(
428428 </button>
429429 </form>
430430 <form
431 method="POST"
431 method="post"
432432 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/pin`}
433433 style="display: inline;"
434434 >
Modifiedsrc/routes/editor.tsx+5−5View fileUnifiedSplit
@@ -48,7 +48,7 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, async (c) => {
4848 <RepoNav owner={owner} repo={repo} active="code" />
4949 <Container maxWidth={900}>
5050 <h2 style="margin-bottom: 16px">Create new file</h2>
51 <Form action={`/${owner}/${repo}/new/${ref}`}>
51 <form method="post" action={`/${owner}/${repo}/new/${ref}`}>
5252 <input type="hidden" name="dir_path" value={dirPath} />
5353 <FormGroup label="File path">
5454 <Flex align="center" gap={4}>
@@ -215,10 +215,10 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, async (c) => {
215215 <RepoHeader owner={owner} repo={repo} />
216216 <RepoNav owner={owner} repo={repo} active="code" />
217217 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
218 <Container maxWidth={900}>
219 <Form action={`/${owner}/${repo}/edit/${ref}/${filePath}`}>
220 <FormGroup>
221 <TextArea
218 <div style="max-width: 900px">
219 <form method="post" action={`/${owner}/${repo}/edit/${ref}/${filePath}`}>
220 <div class="form-group">
221 <textarea
222222 name="content"
223223 rows={25}
224224 value={blob.content}
Modifiedsrc/routes/environments.tsx+2−2View fileUnifiedSplit
@@ -167,7 +167,7 @@ r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
167167 const branches = allowedBranchesOf(env);
168168 return (
169169 <form
170 method="POST"
170 method="post"
171171 action={`/${owner}/${repo}/settings/environments/${env.id}`}
172172 class="panel-item"
173173 style="flex-direction: column; align-items: stretch; gap: 8px"
@@ -240,7 +240,7 @@ r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
240240
241241 <h3 style="margin-top: 24px; margin-bottom: 12px">New environment</h3>
242242 <form
243 method="POST"
243 method="post"
244244 action={`/${owner}/${repo}/settings/environments`}
245245 class="panel"
246246 style="padding: 16px"
Addedsrc/routes/events.ts+387−0View fileUnifiedSplit
@@ -0,0 +1,387 @@
1/**
2 * Inbound deploy-event receiver for Crontech (Signal Bus P1 — E3/E4).
3 *
4 * Wire contract reference: chat-defined spec for Crontech → Gluecron deploy
5 * events. Gluecron's OWN copy per HTTP-only coupling rule — do NOT import any
6 * types from Crontech. If the contract is renegotiated, update this comment
7 * and the validation below in lock-step.
8 *
9 * POST /api/events/deploy
10 * Authorization: Bearer ${CRONTECH_EVENT_TOKEN}
11 * Content-Type: application/json
12 *
13 * {
14 * "event": "deploy.succeeded" | "deploy.failed",
15 * "eventId": "<uuid-v4>", // idempotency key
16 * "repository": "owner/name",
17 * "sha": "<40-hex>",
18 * "environment": "production",
19 * "deploymentId": "<crontech-id>",
20 * "durationMs": <int>, // optional
21 * "errorCategory": "build|runtime|timeout|config", // required on failed
22 * "errorSummary": "<string ≤500>", // required on failed
23 * "logsUrl": "<string>", // optional
24 * "timestamp": "<ISO-8601>"
25 * }
26 *
27 * → 200 { ok: true, duplicate: false }
28 * → 200 { ok: true, duplicate: true }
29 * → 401 invalid bearer
30 * → 400 malformed payload
31 *
32 * Idempotency: an incoming `eventId` is first looked up in `processed_events`.
33 * On hit we return { duplicate: true } immediately — no side-effects. On miss
34 * we INSERT the idempotency record BEFORE performing the side-effect update so
35 * that a retry after a crash between steps sees the record and short-circuits.
36 *
37 * Side-effect: look up the matching `deployments` row by
38 * (repository_id, commit_sha, environment) — `deployments` has no
39 * `crontech_deployment_id` column so we key off the tuple that
40 * `triggerCrontechDeploy` writes on the way out.
41 *
42 * E3 deploy.succeeded → status='success', completedAt=now
43 * E4 deploy.failed → status='failed', blockedReason=errorSummary,
44 * completedAt=now, notify(owner, 'deploy_failed')
45 */
46
47import { Hono } from "hono";
48import { and, desc, eq } from "drizzle-orm";
49import { timingSafeEqual } from "crypto";
50import { db } from "../db";
51import { deployments, repositories, users } from "../db/schema";
52import { processedEvents } from "../db/schema-events";
53import { notify } from "../lib/notify";
54
55const events = new Hono();
56
57// ---------------------------------------------------------------------------
58// Bearer auth — timing-safe comparison against CRONTECH_EVENT_TOKEN.
59// ---------------------------------------------------------------------------
60
61function constantTimeEq(a: string, b: string): boolean {
62 const A = Buffer.from(a);
63 const B = Buffer.from(b);
64 if (A.length !== B.length) return false;
65 try {
66 return timingSafeEqual(A, B);
67 } catch {
68 return false;
69 }
70}
71
72function verifyBearer(c: any): { ok: boolean; error?: string } {
73 const expected = process.env.CRONTECH_EVENT_TOKEN || "";
74 if (!expected) {
75 // Refuse by default — an unset secret must NOT allow anonymous writes.
76 return {
77 ok: false,
78 error:
79 "Event endpoint not configured: set CRONTECH_EVENT_TOKEN in the environment",
80 };
81 }
82 const auth = c.req.header("authorization") || "";
83 if (!auth.startsWith("Bearer ")) {
84 return { ok: false, error: "Missing Bearer token" };
85 }
86 const token = auth.slice(7).trim();
87 if (!constantTimeEq(token, expected)) {
88 return { ok: false, error: "Invalid bearer token" };
89 }
90 return { ok: true };
91}
92
93// ---------------------------------------------------------------------------
94// Payload validation — no zod in this repo, use manual checks mirroring the
95// existing hooks.ts style. Keep error messages specific enough to diagnose a
96// mis-built emitter without leaking internals.
97// ---------------------------------------------------------------------------
98
99type DeployEvent = "deploy.succeeded" | "deploy.failed";
100type ErrorCategory = "build" | "runtime" | "timeout" | "config";
101
102interface DeployEventPayload {
103 event: DeployEvent;
104 eventId: string;
105 repository: string;
106 sha: string;
107 environment: string;
108 deploymentId: string;
109 durationMs?: number;
110 errorCategory?: ErrorCategory;
111 errorSummary?: string;
112 logsUrl?: string;
113 timestamp: string;
114}
115
116const UUID_V4_RE =
117 /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
118const SHA_RE = /^[0-9a-f]{40}$/i;
119const VALID_EVENTS: ReadonlySet<string> = new Set([
120 "deploy.succeeded",
121 "deploy.failed",
122]);
123const VALID_CATEGORIES: ReadonlySet<string> = new Set([
124 "build",
125 "runtime",
126 "timeout",
127 "config",
128]);
129
130function validatePayload(raw: unknown): {
131 ok: true;
132 payload: DeployEventPayload;
133} | { ok: false; error: string } {
134 if (!raw || typeof raw !== "object") {
135 return { ok: false, error: "Body must be a JSON object" };
136 }
137 const p = raw as Record<string, unknown>;
138
139 if (typeof p.event !== "string" || !VALID_EVENTS.has(p.event)) {
140 return {
141 ok: false,
142 error: "event must be 'deploy.succeeded' or 'deploy.failed'",
143 };
144 }
145 if (typeof p.eventId !== "string" || !UUID_V4_RE.test(p.eventId)) {
146 return { ok: false, error: "eventId must be a uuid-v4 string" };
147 }
148 if (typeof p.repository !== "string" || !p.repository.includes("/")) {
149 return { ok: false, error: "repository must be '<owner>/<name>'" };
150 }
151 if (typeof p.sha !== "string" || !SHA_RE.test(p.sha)) {
152 return { ok: false, error: "sha must be a 40-hex commit id" };
153 }
154 if (typeof p.environment !== "string" || p.environment.length === 0) {
155 return { ok: false, error: "environment must be a non-empty string" };
156 }
157 if (typeof p.deploymentId !== "string" || p.deploymentId.length === 0) {
158 return { ok: false, error: "deploymentId must be a non-empty string" };
159 }
160 if (typeof p.timestamp !== "string" || Number.isNaN(Date.parse(p.timestamp))) {
161 return { ok: false, error: "timestamp must be an ISO-8601 string" };
162 }
163 if (p.durationMs !== undefined) {
164 if (
165 typeof p.durationMs !== "number" ||
166 !Number.isFinite(p.durationMs) ||
167 p.durationMs < 0
168 ) {
169 return { ok: false, error: "durationMs must be a non-negative number" };
170 }
171 }
172 if (p.logsUrl !== undefined && typeof p.logsUrl !== "string") {
173 return { ok: false, error: "logsUrl must be a string when present" };
174 }
175
176 if (p.event === "deploy.failed") {
177 if (
178 typeof p.errorCategory !== "string" ||
179 !VALID_CATEGORIES.has(p.errorCategory)
180 ) {
181 return {
182 ok: false,
183 error:
184 "errorCategory is required for deploy.failed and must be one of build|runtime|timeout|config",
185 };
186 }
187 if (
188 typeof p.errorSummary !== "string" ||
189 p.errorSummary.length === 0 ||
190 p.errorSummary.length > 500
191 ) {
192 return {
193 ok: false,
194 error:
195 "errorSummary is required for deploy.failed and must be 1-500 chars",
196 };
197 }
198 }
199
200 return { ok: true, payload: p as unknown as DeployEventPayload };
201}
202
203// ---------------------------------------------------------------------------
204// Helpers
205// ---------------------------------------------------------------------------
206
207async function resolveRepo(
208 full: string
209): Promise<{ id: string; ownerId: string } | null> {
210 if (!full.includes("/")) return null;
211 const [owner, name] = full.split("/", 2);
212 try {
213 const [row] = await db
214 .select({
215 id: repositories.id,
216 ownerId: repositories.ownerId,
217 })
218 .from(repositories)
219 .innerJoin(users, eq(repositories.ownerId, users.id))
220 .where(and(eq(users.username, owner), eq(repositories.name, name)))
221 .limit(1);
222 return row || null;
223 } catch {
224 return null;
225 }
226}
227
228async function findTargetDeployment(
229 repositoryId: string,
230 commitSha: string,
231 environment: string
232): Promise<{ id: string } | null> {
233 try {
234 const [row] = await db
235 .select({ id: deployments.id })
236 .from(deployments)
237 .where(
238 and(
239 eq(deployments.repositoryId, repositoryId),
240 eq(deployments.commitSha, commitSha),
241 eq(deployments.environment, environment)
242 )
243 )
244 .orderBy(desc(deployments.createdAt))
245 .limit(1);
246 return row || null;
247 } catch {
248 return null;
249 }
250}
251
252// ---------------------------------------------------------------------------
253// POST /api/events/deploy
254// ---------------------------------------------------------------------------
255
256events.post("/deploy", async (c) => {
257 const auth = verifyBearer(c);
258 if (!auth.ok) {
259 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
260 }
261
262 let raw: unknown;
263 try {
264 raw = await c.req.json();
265 } catch {
266 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
267 }
268
269 const validated = validatePayload(raw);
270 if (!validated.ok) {
271 return c.json({ ok: false, error: validated.error }, 400);
272 }
273 const payload = validated.payload;
274
275 // --- Idempotency check ---------------------------------------------------
276 // If we've already processed this eventId, return duplicate:true without
277 // firing any side-effects.
278 try {
279 const [existing] = await db
280 .select({ id: processedEvents.id })
281 .from(processedEvents)
282 .where(eq(processedEvents.eventId, payload.eventId))
283 .limit(1);
284 if (existing) {
285 return c.json({ ok: true, duplicate: true });
286 }
287 } catch (err) {
288 console.error("[events/deploy] idempotency lookup failed:", err);
289 // Fall through — better to process than to wedge on a transient DB blip.
290 }
291
292 // --- Record the idempotency token BEFORE side-effects --------------------
293 // Race: two simultaneous deliveries of the same eventId. The UNIQUE
294 // constraint on event_id makes the losing insert throw; we catch that and
295 // return duplicate:true to keep behaviour stable.
296 try {
297 await db.insert(processedEvents).values({
298 eventId: payload.eventId,
299 eventType: payload.event,
300 source: "crontech",
301 payload: payload as unknown as Record<string, unknown>,
302 });
303 } catch (err) {
304 const msg = err instanceof Error ? err.message : String(err);
305 if (msg.includes("unique") || msg.includes("duplicate")) {
306 return c.json({ ok: true, duplicate: true });
307 }
308 console.error("[events/deploy] processed_events insert failed:", err);
309 return c.json({ ok: false, error: "Failed to persist event" }, 500);
310 }
311
312 // --- Side-effect: update the matching deployments row --------------------
313 const repo = await resolveRepo(payload.repository);
314 if (!repo) {
315 // The idempotency row is already committed; we accept the event so we
316 // don't invite infinite retries for a repo that genuinely doesn't exist
317 // on this side of the wire. Log for operator follow-up.
318 console.warn(
319 `[events/deploy] unknown repository ${payload.repository} — event ${payload.eventId} accepted but no deployment update applied`
320 );
321 return c.json({ ok: true, duplicate: false });
322 }
323
324 const target = await findTargetDeployment(
325 repo.id,
326 payload.sha,
327 payload.environment
328 );
329
330 if (target) {
331 try {
332 if (payload.event === "deploy.succeeded") {
333 await db
334 .update(deployments)
335 .set({
336 status: "success",
337 completedAt: new Date(),
338 })
339 .where(eq(deployments.id, target.id));
340 } else {
341 await db
342 .update(deployments)
343 .set({
344 status: "failed",
345 blockedReason: payload.errorSummary,
346 completedAt: new Date(),
347 })
348 .where(eq(deployments.id, target.id));
349 }
350 } catch (err) {
351 console.error("[events/deploy] deployments update failed:", err);
352 }
353 } else {
354 console.warn(
355 `[events/deploy] no deployments row found for ${payload.repository}@${payload.sha} env=${payload.environment}`
356 );
357 }
358
359 // --- On failure: notify the repo owner ----------------------------------
360 if (payload.event === "deploy.failed") {
361 try {
362 await notify(repo.ownerId, {
363 kind: "deploy_failed",
364 title: `Deploy failed on ${payload.repository}`,
365 body:
366 payload.errorSummary ||
367 `Crontech reported deploy failure (${payload.errorCategory || "unknown"})`,
368 url: payload.logsUrl,
369 repositoryId: repo.id,
370 });
371 } catch (err) {
372 console.error("[events/deploy] notify failed:", err);
373 }
374 }
375
376 return c.json({ ok: true, duplicate: false });
377});
378
379// Test-only access for unit tests that want to exercise helpers directly.
380export const __test = {
381 constantTimeEq,
382 validatePayload,
383 resolveRepo,
384 findTargetDeployment,
385};
386
387export default events;
Modifiedsrc/routes/gates.tsx+3−3View fileUnifiedSplit
@@ -228,7 +228,7 @@ gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
228228 <div class="auth-success">{decodeURIComponent(success)}</div>
229229 )}
230230
231 <form method="POST" action={`/${owner}/${repo}/gates/settings`}>
231 <form method="post" action={`/${owner}/${repo}/gates/settings`}>
232232 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
233233 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
234234 Gates
@@ -310,7 +310,7 @@ gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
310310 Required checks
311311 </a>
312312 <form
313 method="POST"
313 method="post"
314314 action={`/${owner}/${repo}/gates/protection/${p.id}/delete`}
315315 onsubmit="return confirm('Remove this rule?')"
316316 >
@@ -325,7 +325,7 @@ gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
325325 </div>
326326
327327 <form
328 method="POST"
328 method="post"
329329 action={`/${owner}/${repo}/gates/protection`}
330330 class="panel"
331331 style="padding: 16px"
Modifiedsrc/routes/gists.tsx+4−4View fileUnifiedSplit
@@ -133,7 +133,7 @@ gistRoutes.get("/gists/new", requireAuth, async (c) => {
133133 <Layout title="New gist" user={user}>
134134 <h1 style="margin-top: 20px;">Create a gist</h1>
135135 <form
136 method="POST"
136 method="post"
137137 action="/gists"
138138 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
139139 >
@@ -331,7 +331,7 @@ gistRoutes.get("/gists/:slug", softAuth, async (c) => {
331331 <div style="display: flex; gap: 8px;">
332332 {user && !isOwner && (
333333 <form
334 method="POST"
334 method="post"
335335 action={`/gists/${slug}/star`}
336336 style="display: inline;"
337337 >
@@ -355,7 +355,7 @@ gistRoutes.get("/gists/:slug", softAuth, async (c) => {
355355 Edit
356356 </a>
357357 <form
358 method="POST"
358 method="post"
359359 action={`/gists/${slug}/delete`}
360360 style="display: inline;"
361361 onsubmit="return confirm('Delete this gist?')"
@@ -416,7 +416,7 @@ gistRoutes.get("/gists/:slug/edit", requireAuth, async (c) => {
416416 <Layout title={`Edit ${gist.slug}`} user={user}>
417417 <h1 style="margin-top: 20px;">Edit gist</h1>
418418 <form
419 method="POST"
419 method="post"
420420 action={`/gists/${slug}/edit`}
421421 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
422422 >
Modifiedsrc/routes/git.ts+4−4View fileUnifiedSplit
@@ -24,7 +24,7 @@ function gitParams(c: any): { owner: string; repo: string } {
2424
2525// Discovery: GET /:owner/:repo.git/info/refs?service=...
2626git.get("/:owner/:repo.git/info/refs", async (c) => {
27 const { owner, repo } = gitParams(c);
27 const { owner, "repo.git": repo } = c.req.param();
2828 const service = c.req.query("service");
2929
3030 if (!service || !["git-upload-pack", "git-receive-pack"].includes(service)) {
@@ -40,7 +40,7 @@ git.get("/:owner/:repo.git/info/refs", async (c) => {
4040
4141// GET /:owner/:repo.git/HEAD
4242git.get("/:owner/:repo.git/HEAD", async (c) => {
43 const { owner, repo } = gitParams(c);
43 const { owner, "repo.git": repo } = c.req.param();
4444 if (!(await repoExists(owner, repo))) {
4545 return c.text("Repository not found", 404);
4646 }
@@ -52,7 +52,7 @@ git.get("/:owner/:repo.git/HEAD", async (c) => {
5252
5353// Upload pack (clone/fetch)
5454git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
55 const { owner, repo } = gitParams(c);
55 const { owner, "repo.git": repo } = c.req.param();
5656 if (!(await repoExists(owner, repo))) {
5757 return c.text("Repository not found", 404);
5858 }
@@ -66,7 +66,7 @@ git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
6666
6767// Receive pack (push)
6868git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
69 const { owner, repo } = gitParams(c);
69 const { owner, "repo.git": repo } = c.req.param();
7070 if (!(await repoExists(owner, repo))) {
7171 return c.text("Repository not found", 404);
7272 }
Modifiedsrc/routes/insights.tsx+4−4View fileUnifiedSplit
@@ -316,7 +316,7 @@ insights.get("/:owner/:repo/milestones", async (c) => {
316316 <div style="display: flex; gap: 4px">
317317 {m.state === "open" ? (
318318 <form
319 method="POST"
319 method="post"
320320 action={`/${owner}/${repo}/milestones/${m.id}/close`}
321321 >
322322 <button type="submit" class="btn btn-sm">
@@ -325,7 +325,7 @@ insights.get("/:owner/:repo/milestones", async (c) => {
325325 </form>
326326 ) : (
327327 <form
328 method="POST"
328 method="post"
329329 action={`/${owner}/${repo}/milestones/${m.id}/reopen`}
330330 >
331331 <button type="submit" class="btn btn-sm">
@@ -334,7 +334,7 @@ insights.get("/:owner/:repo/milestones", async (c) => {
334334 </form>
335335 )}
336336 <form
337 method="POST"
337 method="post"
338338 action={`/${owner}/${repo}/milestones/${m.id}/delete`}
339339 onsubmit="return confirm('Delete this milestone?')"
340340 >
@@ -352,7 +352,7 @@ insights.get("/:owner/:repo/milestones", async (c) => {
352352 {user && user.id === repoRow.ownerId && (
353353 <form
354354 id="new"
355 method="POST"
355 method="post"
356356 action={`/${owner}/${repo}/milestones`}
357357 class="panel"
358358 style="padding: 16px"
Modifiedsrc/routes/issues.tsx+36−20View fileUnifiedSplit
@@ -195,9 +195,10 @@ issueRoutes.get(
195195 {error && (
196196 <Alert variant="error">{decodeURIComponent(error)}</Alert>
197197 )}
198 <Form action={`/${ownerName}/${repoName}/issues/new`} method="POST">
199 <FormGroup>
200 <Input
198 <form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
199 <div class="form-group">
200 <input
201 type="text"
201202 name="title"
202203 required
203204 placeholder="Title"
@@ -373,23 +374,38 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
373374 ))}
374375
375376 {user && (
376 <CommentForm
377 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
378 submitLabel="Comment"
379 extraActions={
380 canManage && (
381 <Button
382 type="submit"
383 variant={issue.state === "open" ? "danger" : "default"}
384 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
385 >
386 {issue.state === "open"
387 ? "Close issue"
388 : "Reopen issue"}
389 </Button>
390 )
391 }
392 />
377 <div style="margin-top: 20px">
378 <form
379 method="post"
380 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
381 >
382 <div class="form-group">
383 <textarea
384 name="body"
385 rows={6}
386 required
387 placeholder="Leave a comment... (Markdown supported)"
388 style="font-family: var(--font-mono); font-size: 13px"
389 />
390 </div>
391 <div style="display: flex; gap: 8px">
392 <button type="submit" class="btn btn-primary">
393 Comment
394 </button>
395 {canManage && (
396 <button
397 type="submit"
398 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
399 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
400 >
401 {issue.state === "open"
402 ? "Close issue"
403 : "Reopen issue"}
404 </button>
405 )}
406 </div>
407 </form>
408 </div>
393409 )}
394410 </div>
395411 </Layout>
Addedsrc/routes/legal/acceptable-use.tsx+171−0View fileUnifiedSplit
@@ -0,0 +1,171 @@
1/**
2 * Acceptable Use Policy — aggressive, explicit prohibitions with
3 * zero-tolerance categories and enforcement ladder.
4 *
5 * DRAFT — requires attorney review.
6 */
7
8import { Hono } from "hono";
9import { Layout } from "../../views/layout";
10import { softAuth } from "../../middleware/auth";
11import type { AuthEnv } from "../../middleware/auth";
12
13const acceptableUse = new Hono<AuthEnv>();
14
15acceptableUse.use("*", softAuth);
16
17acceptableUse.get("/legal/acceptable-use", (c) => {
18 const user = c.get("user");
19 return c.html(
20 <Layout title="Acceptable Use Policy" user={user}>
21 <article style="max-width: 820px; margin: 0 auto; line-height: 1.7; font-size: 15px">
22 <h1 style="font-size: 28px; margin-bottom: 8px">
23 Acceptable Use Policy
24 </h1>
25 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 24px">
26 DRAFT — requires attorney review. Last updated: 2026-04-16.
27 </p>
28
29 <div
30 style="background: rgba(210, 153, 34, 0.1); border: 1px solid var(--yellow); color: var(--yellow); padding: 12px 16px; border-radius: var(--radius); margin-bottom: 24px; font-size: 13px"
31 >
32 <strong>DRAFT notice.</strong> This Acceptable Use Policy ("AUP")
33 is incorporated by reference into the{" "}
34 <a href="/legal/terms">Terms of Service</a>. Violation of this AUP
35 is a material breach of the Terms and may result in immediate
36 suspension or termination.
37 </div>
38
39 <h2>Prohibited uses</h2>
40 <p>
41 You may not, and may not permit any third party (including your
42 users, contributors, or integrations) to, use the Service to
43 engage in, promote, or facilitate any of the following:
44 </p>
45 <ol>
46 <li>
47 <strong>Illegal content or activity.</strong> Any content or
48 activity unlawful in any jurisdiction reasonably connected to
49 you, Gluecron, or the intended audience. This includes, without
50 limitation, fraud, money laundering, sanctions evasion, illegal
51 gambling, illegal drug sale, and trafficking.
52 </li>
53 <li>
54 <strong>Child sexual abuse material ("CSAM").</strong> Zero
55 tolerance. Any upload, storage, transmission, generation (including
56 via AI features), or solicitation of CSAM results in
57 <strong> immediate and permanent account termination</strong>,
58 preservation of evidence, and <strong>mandatory reporting to law
59 enforcement</strong> and the National Center for Missing &
60 Exploited Children ("NCMEC") or equivalent authority, as required
61 by applicable law.
62 </li>
63 <li>
64 <strong>Malware and harmful code.</strong> Creating,
65 distributing, or operating viruses, worms, trojans, ransomware,
66 rootkits, keyloggers, cryptominers deployed without consent, or
67 any software designed to damage, disable, or gain unauthorized
68 access to systems or data.
69 </li>
70 <li>
71 <strong>Stress testing, DDoS, and security-offensive tooling.</strong>{" "}
72 Conducting load tests, stress tests, denial-of-service attacks,
73 or vulnerability exploitation against the Service, its
74 infrastructure, or any third-party system you are not explicitly
75 authorized in writing to test.
76 </li>
77 <li>
78 <strong>Scraping and automated abuse.</strong> Scraping,
79 crawling, or automated harvesting of the Service (including web
80 UI surfaces) except via our public APIs and within rate limits.
81 Use of the Service to scrape or abuse third-party services is
82 also prohibited.
83 </li>
84 <li>
85 <strong>Impersonation and deception.</strong> Impersonating any
86 person, organization, or Gluecron staff; creating misleading
87 usernames, organizations, or repositories; misrepresenting your
88 affiliation or authority.
89 </li>
90 <li>
91 <strong>Hate speech, harassment, and threats.</strong> Content
92 that incites violence, targets individuals or groups on the
93 basis of protected characteristics, or constitutes targeted
94 harassment, doxing, or credible threats.
95 </li>
96 <li>
97 <strong>Copyright and trademark infringement.</strong> Posting
98 content that infringes a third party's intellectual-property
99 rights. See our{" "}
100 <a href="/legal/dmca">DMCA Policy</a> for the notice-and-takedown
101 procedure.
102 </li>
103 <li>
104 <strong>Privacy violations.</strong> Publishing another person's
105 private or personally-identifying information without their
106 consent (including doxing, non-consensual imagery, and leaked
107 credentials, API keys, or secrets belonging to another party).
108 </li>
109 <li>
110 <strong>Circumventing security or rate limits.</strong>{" "}
111 Bypassing, disabling, or interfering with authentication,
112 authorization, rate limiting, quota enforcement, or billing
113 controls; sharing credentials; creating multiple accounts to
114 evade limits or bans.
115 </li>
116 <li>
117 <strong>Commercial abuse of the free tier.</strong> Use of free
118 allowances beyond reasonable fair-use limits, including but not
119 limited to: re-selling hosting, using Gluecron as a public-good
120 CDN for unrelated workloads, or running continuous
121 compute-intensive workloads without a paid plan.
122 </li>
123 </ol>
124
125 <h2>Enforcement</h2>
126 <ul>
127 <li>
128 <strong>Immediate suspension</strong> — for severe violations
129 including CSAM, illegal content, active security attacks,
130 credible threats, and any conduct posing imminent harm.
131 Suspension is at our sole discretion and may occur without prior
132 notice.
133 </li>
134 <li>
135 <strong>Warning and temporary suspension</strong> — for lesser
136 violations, we may (at our discretion) issue a warning,
137 rate-limit the account, temporarily suspend specific features,
138 or remove specific content, before full termination.
139 </li>
140 <li>
141 <strong>Appeals</strong> — you may appeal an enforcement action
142 by emailing <strong>support@gluecron.com</strong> (placeholder).
143 We intend to respond within fourteen (14) days. Our decisions
144 are final at our discretion, subject to applicable law.
145 </li>
146 <li>
147 <strong>No refunds.</strong> Accounts terminated for AUP
148 violations are not eligible for refunds of any prepaid fees.
149 </li>
150 <li>
151 <strong>Law-enforcement cooperation.</strong> We will cooperate
152 with valid legal process and may, at our discretion, preserve
153 and disclose account information and content where we believe
154 in good faith that disclosure is necessary to prevent imminent
155 harm or comply with applicable law.
156 </li>
157 </ul>
158
159 <hr style="margin: 32px 0; border: none; border-top: 1px solid var(--border)" />
160 <p style="font-size: 13px; color: var(--text-muted)">
161 See also:{" "}
162 <a href="/legal/terms">Terms of Service</a> ·{" "}
163 <a href="/legal/privacy">Privacy Policy</a> ·{" "}
164 <a href="/legal/dmca">DMCA Policy</a>
165 </p>
166 </article>
167 </Layout>
168 );
169});
170
171export default acceptableUse;
Addedsrc/routes/legal/dmca.tsx+176−0View fileUnifiedSplit
@@ -0,0 +1,176 @@
1/**
2 * DMCA Copyright Policy — 17 USC §512 notice-and-takedown procedure.
3 *
4 * DRAFT — requires attorney review. Designated-agent registration with
5 * the U.S. Copyright Office has not yet been completed; safe-harbor
6 * protection is not assured during this interim pre-launch period.
7 */
8
9import { Hono } from "hono";
10import { Layout } from "../../views/layout";
11import { softAuth } from "../../middleware/auth";
12import type { AuthEnv } from "../../middleware/auth";
13
14const dmca = new Hono<AuthEnv>();
15
16dmca.use("*", softAuth);
17
18dmca.get("/legal/dmca", (c) => {
19 const user = c.get("user");
20 return c.html(
21 <Layout title="DMCA Policy" user={user}>
22 <article style="max-width: 820px; margin: 0 auto; line-height: 1.7; font-size: 15px">
23 <h1 style="font-size: 28px; margin-bottom: 8px">
24 DMCA Copyright Policy
25 </h1>
26 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 24px">
27 DRAFT — requires attorney review. Last updated: 2026-04-16.
28 </p>
29
30 <div
31 style="background: rgba(210, 153, 34, 0.1); border: 1px solid var(--yellow); color: var(--yellow); padding: 12px 16px; border-radius: var(--radius); margin-bottom: 24px; font-size: 13px"
32 >
33 <strong>DRAFT notice.</strong> Gluecron is in pre-launch. This DMCA
34 Policy has not yet been reviewed by counsel.
35 </div>
36
37 <h2>1. Notice-and-takedown procedure</h2>
38 <p>
39 Gluecron respects the intellectual-property rights of others and
40 expects its users to do the same. In accordance with the Digital
41 Millennium Copyright Act ("DMCA"), 17 U.S.C. § 512, we will
42 respond expeditiously to properly-formed notices of alleged
43 copyright infringement submitted by the copyright owner or their
44 authorized agent.
45 </p>
46
47 <h2>2. Designated agent</h2>
48 <p>
49 <strong>Interim-period notice.</strong> We intend to register a
50 DMCA designated agent with the U.S. Copyright Office prior to
51 operating as a paid hosting provider. Until that registration is
52 complete, please send DMCA notices to{" "}
53 <strong>dmca@gluecron.com</strong> (placeholder).{" "}
54 <strong>
55 Safe-harbor protection under 17 U.S.C. § 512(c) is not
56 assured during this interim period.
57 </strong>{" "}
58 We will nonetheless process properly-formed notices in good faith.{" "}
59 <em>DRAFT — requires attorney review.</em>
60 </p>
61
62 <h2>3. Required notice elements (17 U.S.C. § 512(c)(3))</h2>
63 <p>
64 A valid DMCA notice must include all of the following:
65 </p>
66 <ol>
67 <li>
68 A physical or electronic signature of a person authorized to
69 act on behalf of the owner of the exclusive right that is
70 allegedly infringed.
71 </li>
72 <li>
73 Identification of the copyrighted work claimed to have been
74 infringed, or, if multiple copyrighted works at a single online
75 site are covered, a representative list of such works.
76 </li>
77 <li>
78 Identification of the material claimed to be infringing or the
79 subject of infringing activity, and information reasonably
80 sufficient to permit us to locate the material (e.g., a URL on
81 Gluecron, repository owner and name, commit SHA, file path).
82 </li>
83 <li>
84 Information reasonably sufficient to permit us to contact the
85 complaining party, including name, mailing address, telephone
86 number, and email address.
87 </li>
88 <li>
89 A statement that the complaining party has a good-faith belief
90 that the use of the material in the manner complained of is not
91 authorized by the copyright owner, its agent, or the law.
92 </li>
93 <li>
94 A statement that the information in the notification is
95 accurate, and <strong>under penalty of perjury</strong>, that
96 the complaining party is authorized to act on behalf of the
97 owner of an exclusive right that is allegedly infringed.
98 </li>
99 </ol>
100 <p>
101 Notices missing any of these elements may be invalid and we may
102 decline to act on them.
103 </p>
104
105 <h2>4. Counter-notice procedure (17 U.S.C. § 512(g))</h2>
106 <p>
107 If you believe material you posted was removed or disabled as a
108 result of mistake or misidentification, you may submit a
109 counter-notice to <strong>dmca@gluecron.com</strong> (placeholder)
110 containing the following:
111 </p>
112 <ol>
113 <li>Your physical or electronic signature.</li>
114 <li>
115 Identification of the material that was removed or disabled, and
116 the location at which the material appeared before removal.
117 </li>
118 <li>
119 A statement <strong>under penalty of perjury</strong> that you
120 have a good-faith belief that the material was removed or
121 disabled as a result of mistake or misidentification.
122 </li>
123 <li>
124 Your name, address, telephone number, and a statement that you
125 consent to the jurisdiction of the U.S. Federal District Court
126 for the judicial district in which your address is located (or,
127 if your address is outside the U.S., any district in which
128 Gluecron may be found), and that you will accept service of
129 process from the person who provided the original notice or an
130 agent of that person.
131 </li>
132 </ol>
133 <p>
134 We may restore the material in not less than 10 and not more than
135 14 business days following receipt of a valid counter-notice,
136 unless the complaining party notifies us that they have filed an
137 action seeking a court order to restrain you from further
138 infringement.
139 </p>
140
141 <h2>5. Repeat-infringer policy</h2>
142 <p>
143 In accordance with 17 U.S.C. § 512(i), we have adopted a
144 policy of <strong>terminating accounts</strong>, in appropriate
145 circumstances and at our sole discretion, of users who are
146 determined to be repeat infringers. We consider three or more
147 valid takedown notices within any 12-month period sufficient to
148 trigger a repeat-infringer review, though we reserve the right to
149 terminate at any threshold based on the severity and nature of
150 the infringement.
151 </p>
152
153 <h2>6. Good-faith requirement and misrepresentation</h2>
154 <p>
155 Under 17 U.S.C. § 512(f), <strong>any person who knowingly
156 materially misrepresents</strong> (a) that material or activity is
157 infringing, or (b) that material or activity was removed or
158 disabled by mistake or misidentification, <strong>shall be
159 liable for any damages</strong> — including costs and attorneys'
160 fees — incurred by the alleged infringer, the copyright owner, or
161 by Gluecron. Please do not submit false claims.
162 </p>
163
164 <hr style="margin: 32px 0; border: none; border-top: 1px solid var(--border)" />
165 <p style="font-size: 13px; color: var(--text-muted)">
166 See also:{" "}
167 <a href="/legal/terms">Terms of Service</a> ·{" "}
168 <a href="/legal/privacy">Privacy Policy</a> ·{" "}
169 <a href="/legal/acceptable-use">Acceptable Use Policy</a>
170 </p>
171 </article>
172 </Layout>
173 );
174});
175
176export default dmca;
Addedsrc/routes/legal/privacy.tsx+289−0View fileUnifiedSplit
@@ -0,0 +1,289 @@
1/**
2 * Privacy Policy — GDPR + CCPA + COPPA aware.
3 *
4 * DRAFT — requires attorney review before any paid launch.
5 */
6
7import { Hono } from "hono";
8import { Layout } from "../../views/layout";
9import { softAuth } from "../../middleware/auth";
10import type { AuthEnv } from "../../middleware/auth";
11
12const privacy = new Hono<AuthEnv>();
13
14privacy.use("*", softAuth);
15
16privacy.get("/legal/privacy", (c) => {
17 const user = c.get("user");
18 return c.html(
19 <Layout title="Privacy Policy" user={user}>
20 <article style="max-width: 820px; margin: 0 auto; line-height: 1.7; font-size: 15px">
21 <h1 style="font-size: 28px; margin-bottom: 8px">Privacy Policy</h1>
22 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 24px">
23 DRAFT — requires attorney review. Last updated: 2026-04-16.
24 </p>
25
26 <div
27 style="background: rgba(210, 153, 34, 0.1); border: 1px solid var(--yellow); color: var(--yellow); padding: 12px 16px; border-radius: var(--radius); margin-bottom: 24px; font-size: 13px"
28 >
29 <strong>DRAFT notice.</strong> This Privacy Policy is a good-faith
30 draft published during Gluecron's pre-launch phase. It has not been
31 reviewed by privacy counsel. Article 13 GDPR disclosures,
32 sub-processor lists, and retention schedules are provisional and
33 will be finalized before general availability.
34 </div>
35
36 <h2>1. Data we collect</h2>
37 <ul>
38 <li>
39 <strong>Account data:</strong> username, email address, display
40 name, hashed password, 2FA secrets, WebAuthn credentials, SSH
41 public keys, personal access tokens (stored as SHA-256 hashes).
42 </li>
43 <li>
44 <strong>Git content:</strong> repositories you push, including
45 commits, branches, tags, file contents, commit messages, and
46 author metadata embedded in commits.
47 </li>
48 <li>
49 <strong>AI interactions:</strong> prompts you submit to AI
50 features (chat, code review, explanations, completions) and
51 responses generated by third-party AI providers.
52 </li>
53 <li>
54 <strong>Usage telemetry:</strong> requests, request IDs, rate-limit
55 counters, error traces, audit events, deployment events, gate run
56 results.
57 </li>
58 <li>
59 <strong>Network data:</strong> IP address, user-agent string,
60 request timestamps, session cookie identifiers.
61 </li>
62 </ul>
63
64 <h2>2. How we use it</h2>
65 <ul>
66 <li>To operate, maintain, and secure the Service.</li>
67 <li>
68 To provide AI-assisted features that you explicitly invoke (code
69 review, chat, explanations, test generation, auto-repair,
70 dependency updates, incident summaries, semantic search).
71 </li>
72 <li>
73 To detect, investigate, and prevent abuse, fraud, security
74 incidents, and violations of our Terms or AUP.
75 </li>
76 <li>
77 To comply with legal obligations, respond to lawful requests from
78 authorities, and enforce our rights.
79 </li>
80 <li>
81 To communicate service-related messages (security alerts, policy
82 updates, incident notifications).
83 </li>
84 </ul>
85
86 <h2>3. Data controller</h2>
87 <p>
88 The data controller is <strong>Gluecron</strong> (entity name
89 placeholder — <em>DRAFT</em>; final legal entity name and registered
90 address to be inserted prior to launch). Contact:
91 support@gluecron.com (placeholder).
92 </p>
93
94 <h2>4. Sub-processors</h2>
95 <p>
96 We engage the following sub-processors to operate the Service. This
97 list is current as of the date above and may change; we intend to
98 provide 30 days' notice of material changes.
99 </p>
100 <div style="overflow-x: auto; margin: 12px 0">
101 <table style="width: 100%; border-collapse: collapse; font-size: 14px; border: 1px solid var(--border); border-radius: var(--radius)">
102 <thead style="background: var(--bg-tertiary)">
103 <tr>
104 <th style="text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border)">
105 Sub-processor
106 </th>
107 <th style="text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border)">
108 Purpose
109 </th>
110 <th style="text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border)">
111 Data categories
112 </th>
113 </tr>
114 </thead>
115 <tbody>
116 <tr>
117 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
118 Neon (neon.tech)
119 </td>
120 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
121 Managed PostgreSQL (primary database)
122 </td>
123 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
124 Account data, metadata, telemetry
125 </td>
126 </tr>
127 <tr>
128 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
129 Anthropic
130 </td>
131 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
132 Claude API (AI features)
133 </td>
134 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
135 AI prompts (including code snippets you submit)
136 </td>
137 </tr>
138 <tr>
139 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
140 Resend (if enabled)
141 </td>
142 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
143 Transactional email delivery
144 </td>
145 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
146 Email address, message contents
147 </td>
148 </tr>
149 <tr>
150 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
151 Fly.io / Railway
152 </td>
153 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
154 Application hosting (compute)
155 </td>
156 <td style="padding: 8px 12px; border-bottom: 1px solid var(--border)">
157 All transient request data
158 </td>
159 </tr>
160 <tr>
161 <td style="padding: 8px 12px">Cloudflare (if fronting)</td>
162 <td style="padding: 8px 12px">CDN, DDoS mitigation, DNS</td>
163 <td style="padding: 8px 12px">
164 IP address, request metadata
165 </td>
166 </tr>
167 </tbody>
168 </table>
169 </div>
170 <p>
171 <em>DRAFT — requires attorney review; sub-processor list must be
172 verified against signed DPAs prior to launch.</em>
173 </p>
174
175 <h2>5. Data retention</h2>
176 <ul>
177 <li>
178 <strong>Account data:</strong> retained while your account is
179 active and for thirty (30) days after account deletion, after
180 which we intend to purge it.
181 </li>
182 <li>
183 <strong>Git repository content:</strong> retained for the
184 lifetime of your account; deleting a repository is intended to
185 purge its content within 30 days.
186 </li>
187 <li>
188 <strong>Sessions:</strong> 30 days (rolling).
189 </li>
190 <li>
191 <strong>Audit and security logs:</strong> up to 12 months for
192 security and abuse-detection purposes.
193 </li>
194 <li>
195 <strong>Backups:</strong> may persist for up to 30 days beyond
196 the primary retention period.
197 </li>
198 </ul>
199
200 <h2>6. GDPR compliance (EU / UK residents)</h2>
201 <p>
202 If you are in the European Economic Area, the United Kingdom, or
203 Switzerland, we process your data under the following lawful bases
204 (GDPR Art. 6): (a) performance of a contract (providing the
205 Service to you); (b) our legitimate interests in securing and
206 improving the Service; (c) compliance with legal obligations; and
207 (d) where required, your consent (which you may withdraw at any
208 time).
209 </p>
210 <p>
211 <strong>Article 13 disclosures.</strong> The identity of the
212 controller, data categories, purposes, retention, recipients, and
213 your rights are described throughout this Policy. International
214 transfers of personal data outside the EEA/UK will rely on the EU
215 <em> Standard Contractual Clauses</em> ("SCCs") or another approved
216 transfer mechanism.
217 </p>
218 <p>
219 <strong>Your rights</strong> include access, rectification,
220 erasure, restriction, portability, and objection. You may lodge a
221 complaint with your local supervisory authority. <em>DRAFT —
222 requires attorney review.</em>
223 </p>
224
225 <h2>7. CCPA / CPRA compliance (California residents)</h2>
226 <p>
227 If you are a California resident, you have the right to (a) know
228 what personal information we collect, use, disclose, and sell or
229 share; (b) delete your personal information, subject to legal
230 exceptions; (c) correct inaccurate personal information; (d) opt
231 out of the sale or sharing of personal information (we do not
232 sell personal information); and (e) non-discrimination for
233 exercising these rights.
234 </p>
235
236 <h2>8. Right to erasure / access</h2>
237 <p>
238 To exercise any of the rights above, email{" "}
239 <strong>support@gluecron.com</strong> (placeholder). We intend to
240 respond within thirty (30) days. We may need to verify your
241 identity before acting. Some data (e.g., audit logs required for
242 security, legal holds) may be exempt from deletion.
243 </p>
244
245 <h2>9. Cookies</h2>
246 <p>
247 We use only <strong>strictly necessary cookies</strong> (session
248 authentication, theme preference, CSRF). We do not use advertising
249 cookies. We do not use third-party analytics cookies on our
250 marketing surfaces.
251 </p>
252
253 <h2>10. Children</h2>
254 <p>
255 The Service is not directed to children under 18. We do not
256 knowingly collect personal information from children under 13
257 (COPPA, U.S.) or 16 (GDPR, EEA). If you believe a child has
258 provided us information in violation of this Policy, contact us
259 and we will delete it.
260 </p>
261
262 <h2>11. Breach notification</h2>
263 <p>
264 In the event of a personal-data breach that is likely to result in
265 a risk to your rights and freedoms, we intend to notify the
266 relevant supervisory authority within <strong>72 hours</strong> of
267 becoming aware, as required by GDPR Art. 33, and to notify
268 affected users without undue delay where required.
269 </p>
270
271 <h2>12. Changes to this Policy</h2>
272 <p>
273 We intend to provide thirty (30) days' notice of material changes
274 to this Policy, by email or by posting a notice in the Service.
275 </p>
276
277 <hr style="margin: 32px 0; border: none; border-top: 1px solid var(--border)" />
278 <p style="font-size: 13px; color: var(--text-muted)">
279 See also:{" "}
280 <a href="/legal/terms">Terms of Service</a> ·{" "}
281 <a href="/legal/acceptable-use">Acceptable Use Policy</a> ·{" "}
282 <a href="/legal/dmca">DMCA Policy</a>
283 </p>
284 </article>
285 </Layout>
286 );
287});
288
289export default privacy;
Addedsrc/routes/legal/terms.tsx+279−0View fileUnifiedSplit
@@ -0,0 +1,279 @@
1/**
2 * Terms of Service — aggressive defensive posture.
3 *
4 * DRAFT — requires attorney review before any paid launch.
5 * Purpose: establish maximum legal protection for Gluecron during
6 * pre-launch. Plain-English draft intended to be redlined by counsel.
7 */
8
9import { Hono } from "hono";
10import { Layout } from "../../views/layout";
11import { softAuth } from "../../middleware/auth";
12import type { AuthEnv } from "../../middleware/auth";
13
14const terms = new Hono<AuthEnv>();
15
16terms.use("*", softAuth);
17
18terms.get("/legal/terms", (c) => {
19 const user = c.get("user");
20 return c.html(
21 <Layout title="Terms of Service" user={user}>
22 <article style="max-width: 820px; margin: 0 auto; line-height: 1.7; font-size: 15px">
23 <h1 style="font-size: 28px; margin-bottom: 8px">Terms of Service</h1>
24 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 24px">
25 DRAFT — requires attorney review. Last updated: 2026-04-16.
26 </p>
27
28 <div
29 style="background: rgba(210, 153, 34, 0.1); border: 1px solid var(--yellow); color: var(--yellow); padding: 12px 16px; border-radius: var(--radius); margin-bottom: 24px; font-size: 13px"
30 >
31 <strong>DRAFT notice.</strong> Gluecron is in pre-launch. These Terms
32 are a good-faith draft and have not yet been reviewed by counsel.
33 They will be finalized and updated before general availability. If
34 you are relying on any provision of these Terms, contact
35 support@gluecron.com for written confirmation first.
36 </div>
37
38 <h2>1. Acceptance</h2>
39 <p>
40 By accessing or using Gluecron (the "Service"), you agree to be bound
41 by these Terms of Service ("Terms"). If you do not agree, do not use
42 the Service. Your use of the Service constitutes your binding
43 acceptance, whether or not you create an account.
44 </p>
45
46 <h2>2. Service description</h2>
47 <p>
48 Gluecron is a git hosting, code collaboration, and AI-assisted code
49 intelligence platform. The Service is currently in a pre-launch /
50 final validation phase. Features, availability, and pricing may
51 change without notice. Nothing in the Service is guaranteed to be
52 production-ready, continuously available, or backed by a service
53 level agreement unless agreed to in a separate, signed writing.
54 </p>
55
56 <h2>3. User accounts</h2>
57 <p>
58 You must be at least 18 years of age to create an account. You must
59 provide accurate and current registration information, and you are
60 solely responsible for all activity under your account, including
61 maintaining the confidentiality of your credentials, tokens,
62 passkeys, and SSH keys. We intend to offer multi-factor
63 authentication; enabling it is your responsibility. We are not
64 liable for any loss arising from unauthorized access to your
65 account.
66 </p>
67
68 <h2>4. Acceptable use</h2>
69 <p>
70 Your use of the Service is governed by our{" "}
71 <a href="/legal/acceptable-use">Acceptable Use Policy</a> ("AUP"),
72 which is incorporated into these Terms by reference. Violation of
73 the AUP is a material breach of these Terms.
74 </p>
75
76 <h2>5. Intellectual property</h2>
77 <p>
78 You retain all ownership of the content, code, and data you push,
79 upload, or submit to the Service ("User Content"). You grant
80 Gluecron a worldwide, non-exclusive, royalty-free license to host,
81 store, reproduce, transmit, display, and create derivative works of
82 your User Content, solely as needed to operate, maintain, secure,
83 analyze, and improve the Service, including for AI features you
84 invoke. You represent and warrant that you have all rights
85 necessary to grant this license.
86 </p>
87
88 <h2>6. AI features</h2>
89 <p>
90 The Service includes AI-assisted features (code review, chat,
91 explanations, test generation, auto-repair, dependency updates,
92 incident summaries, semantic search). AI output is provided on an
93 informational basis only. <strong>AI output is not professional
94 advice</strong>, is not a substitute for human review, and may be
95 incorrect, incomplete, or unsafe. You are solely responsible for
96 reviewing, testing, and validating any AI output before relying on
97 it. AI output may be generated in part by third-party large language
98 model providers; we do not warrant the accuracy, fitness,
99 originality, or non-infringement of any AI output. <em>DRAFT —
100 requires attorney review.</em>
101 </p>
102
103 <h2>7. Binding individual arbitration & class-action waiver</h2>
104 <p>
105 <strong>Please read this section carefully.</strong> You and
106 Gluecron agree that any dispute, claim, or controversy arising out
107 of or relating to these Terms or the Service shall be resolved
108 exclusively by binding individual arbitration, and not in a class,
109 collective, or representative proceeding. The arbitration shall be
110 administered under the rules of the American Arbitration Association
111 ("AAA") or JAMS (claimant's choice). The arbitrator may award only
112 individual relief. <strong>You waive any right to participate in a
113 class action, class arbitration, or representative proceeding.</strong>
114 </p>
115 <p>
116 <strong>30-day mail-in opt-out.</strong> You may opt out of this
117 arbitration agreement by mailing a written, signed opt-out notice
118 containing your name, username, and a clear statement that you wish
119 to opt out, to the address we publish on our contact page, within
120 30 days of first accepting these Terms. Opt-out is effective only
121 if postmarked within that window.
122 </p>
123 <p>
124 <strong>Small-claims carve-out.</strong> Either party may bring an
125 individual action in small-claims court instead of arbitration, so
126 long as the action remains in that court and is brought
127 individually.
128 </p>
129 <p>
130 <em>DRAFT — requires attorney review; AAA/JAMS choice, seat of
131 arbitration, and consumer-arbitration fee allocation must be
132 reconciled with New Zealand governing law (see Section 14).</em>
133 </p>
134
135 <h2>8. Limitation of liability</h2>
136 <p>
137 TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE TOTAL
138 AGGREGATE LIABILITY OF GLUECRON, ITS AFFILIATES, OFFICERS,
139 EMPLOYEES, CONTRACTORS, AND AGENTS, ARISING OUT OF OR RELATING TO
140 THESE TERMS OR THE SERVICE, SHALL NOT EXCEED THE GREATER OF (A)
141 ONE HUNDRED U.S. DOLLARS ($100 USD) OR (B) THE FEES YOU ACTUALLY
142 PAID TO GLUECRON FOR THE SERVICE IN THE TWELVE (12) MONTHS
143 IMMEDIATELY PRECEDING THE EVENT GIVING RISE TO THE CLAIM. Because
144 Gluecron has no billing yet during pre-launch, this cap is
145 effectively $100 USD. <em>DRAFT — requires attorney review.</em>
146 </p>
147
148 <h2>9. No consequential damages</h2>
149 <p>
150 TO THE MAXIMUM EXTENT PERMITTED BY LAW, GLUECRON SHALL NOT BE
151 LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL,
152 PUNITIVE, OR EXEMPLARY DAMAGES, INCLUDING BUT NOT LIMITED TO LOST
153 PROFITS, LOST REVENUE, LOST DATA, LOST GOODWILL, BUSINESS
154 INTERRUPTION, OR COST OF SUBSTITUTE SERVICES, WHETHER ARISING IN
155 CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, OR
156 OTHERWISE, AND WHETHER OR NOT GLUECRON HAS BEEN ADVISED OF THE
157 POSSIBILITY OF SUCH DAMAGES.
158 </p>
159
160 <h2>10. AS-IS / AS-AVAILABLE; no warranties</h2>
161 <p>
162 THE SERVICE IS PROVIDED <strong>"AS IS" AND "AS AVAILABLE"</strong>{" "}
163 WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED. GLUECRON
164 DISCLAIMS ALL WARRANTIES, INCLUDING WITHOUT LIMITATION THE IMPLIED
165 WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
166 NON-INFRINGEMENT, TITLE, ACCURACY, UNINTERRUPTED OR ERROR-FREE
167 OPERATION, OR THAT ANY DEFECTS WILL BE CORRECTED. WE DO NOT WARRANT
168 THAT THE SERVICE WILL MEET YOUR REQUIREMENTS, THAT ANY AI OUTPUT IS
169 ACCURATE OR SAFE, OR THAT YOUR DATA WILL NOT BE LOST.
170 </p>
171
172 <h2>11. Indemnification</h2>
173 <p>
174 You agree to indemnify, defend, and hold harmless Gluecron, its
175 affiliates, officers, directors, employees, contractors, and agents
176 from and against any and all claims, liabilities, damages, losses,
177 costs, and expenses (including reasonable attorneys' fees) arising
178 out of or related to: (a) your User Content; (b) your code, data,
179 or dependencies; (c) your use of the Service; (d) your violation
180 of these Terms, the AUP, or applicable law; (e) your violation of
181 any third-party right, including any intellectual property, privacy,
182 or publicity right; or (f) any claim that your User Content caused
183 damage to a third party.
184 </p>
185
186 <h2>12. Termination and suspension</h2>
187 <p>
188 We may suspend or terminate your account, any individual repository,
189 or your access to any portion of the Service, at any time, for any
190 reason or no reason, with or without notice, at our sole discretion.
191 Upon termination, we intend to retain your data for thirty (30) days
192 to allow for export or reinstatement, after which we intend to
193 purge it, though we make no guarantee of recoverability.
194 </p>
195
196 <h2>13. Prohibited uses</h2>
197 <p>
198 Without limiting the <a href="/legal/acceptable-use">AUP</a>, you
199 may not use the Service to: (a) host, distribute, or develop illegal
200 content; (b) host, store, transmit, or generate child sexual abuse
201 material ("CSAM"), which will result in immediate termination and
202 reporting to law enforcement; (c) develop, distribute, or execute
203 malware, viruses, ransomware, or other harmful code; (d) conduct
204 stress tests, denial-of-service attacks, or load tests against the
205 Service or any third party; (e) reverse-engineer, decompile, or
206 disassemble the Service; or (f) scrape, crawl, or use automated
207 means to access the Service except as permitted by our public APIs.
208 </p>
209
210 <h2>14. Governing law</h2>
211 <p>
212 These Terms are governed by and construed in accordance with the
213 laws of <strong>New Zealand</strong>, without regard to conflict-of-law
214 principles. Subject to Section 7, the courts of New Zealand shall
215 have exclusive jurisdiction over any dispute not subject to
216 arbitration. <em>DRAFT — requires attorney review; NZ governing
217 law is inferred from the founder's handle and must be confirmed
218 or changed by counsel.</em>
219 </p>
220
221 <h2>15. Export controls and sanctions</h2>
222 <p>
223 You represent and warrant that you are not located in, and are not
224 a national or resident of, any country that is subject to a
225 comprehensive U.S., U.K., E.U., or U.N. embargo, and that you are
226 not on any government list of prohibited or restricted parties. You
227 agree to comply with all applicable export-control and sanctions
228 laws, including the U.S. Export Administration Regulations and
229 sanctions administered by the U.S. Treasury Department's Office of
230 Foreign Assets Control ("OFAC"). You will not use the Service to
231 develop, design, manufacture, or produce any weapon of mass
232 destruction.
233 </p>
234
235 <h2>16. Force majeure</h2>
236 <p>
237 Gluecron shall not be liable for any failure or delay in
238 performance caused by circumstances beyond our reasonable control,
239 including acts of God, natural disasters, war, terrorism, riots,
240 civil unrest, government action, epidemics or pandemics, labor
241 shortages, internet or telecommunications outages, third-party
242 service-provider failures (including hosting, DNS, CDN, database,
243 or AI providers), cyberattacks, or power failures.
244 </p>
245
246 <h2>17. Severability and entire agreement</h2>
247 <p>
248 If any provision of these Terms is held invalid or unenforceable,
249 that provision shall be enforced to the maximum extent permissible,
250 and the remaining provisions shall remain in full force and effect.
251 These Terms, together with the AUP, Privacy Policy, and DMCA
252 Policy, constitute the entire agreement between you and Gluecron
253 with respect to the Service, and supersede all prior or
254 contemporaneous understandings.
255 </p>
256
257 <h2>18. Changes to these Terms</h2>
258 <p>
259 We intend to provide thirty (30) days' notice of material changes
260 to these Terms, by email to the address on your account or by
261 posting a notice in the Service. Your continued use of the Service
262 after the effective date of any change constitutes your acceptance
263 of the revised Terms. We may, at our discretion, make non-material
264 changes (clarifications, typo fixes) without notice.
265 </p>
266
267 <hr style="margin: 32px 0; border: none; border-top: 1px solid var(--border)" />
268 <p style="font-size: 13px; color: var(--text-muted)">
269 See also:{" "}
270 <a href="/legal/privacy">Privacy Policy</a> ·{" "}
271 <a href="/legal/acceptable-use">Acceptable Use Policy</a> ·{" "}
272 <a href="/legal/dmca">DMCA Policy</a>
273 </p>
274 </article>
275 </Layout>
276 );
277});
278
279export default terms;
Modifiedsrc/routes/marketplace.tsx+6−6View fileUnifiedSplit
@@ -57,7 +57,7 @@ marketplace.get("/marketplace", async (c) => {
5757 </a>
5858 )}
5959 </div>
60 <form method="GET" action="/marketplace" style="margin-bottom:16px">
60 <form method="get" action="/marketplace" style="margin-bottom:16px">
6161 <input
6262 type="text"
6363 name="q"
@@ -144,7 +144,7 @@ marketplace.get("/marketplace/:slug", async (c) => {
144144 </div>
145145
146146 {user ? (
147 <form method="POST" action={`/marketplace/${slug}/install`}>
147 <form method="post" action={`/marketplace/${slug}/install`}>
148148 <div class="form-group">
149149 <p
150150 style="font-size:13px;color:var(--text-muted);margin-bottom:8px"
@@ -281,7 +281,7 @@ marketplace.get("/settings/apps", requireAuth, async (c) => {
281281 </div>
282282 </div>
283283 <form
284 method="POST"
284 method="post"
285285 action={`/marketplace/installations/${i.id}/uninstall`}
286286 onsubmit="return confirm('Uninstall this app?')"
287287 >
@@ -304,14 +304,14 @@ marketplace.get("/developer/apps-new", requireAuth, async (c) => {
304304 return c.html(
305305 <Layout title="New app — Marketplace" user={user}>
306306 <h2>Register a new app</h2>
307 <form method="POST" action="/developer/apps-new" class="panel" style="padding:16px">
307 <form method="post" action="/developer/apps-new" class="panel" style="padding:16px">
308308 <div class="form-group">
309309 <label>Name</label>
310310 <input type="text" name="name" required style="width:100%" />
311311 </div>
312312 <div class="form-group">
313313 <label>Description</label>
314 <textarea name="description" rows="3" style="width:100%" />
314 <textarea name="description" rows={3} style="width:100%" />
315315 </div>
316316 <div class="form-group">
317317 <label>Homepage URL</label>
@@ -466,7 +466,7 @@ marketplace.get("/developer/apps/:slug/manage", requireAuth, async (c) => {
466466
467467 <h3>Installation tokens</h3>
468468 <form
469 method="POST"
469 method="post"
470470 action={`/developer/apps/${app.slug}/tokens/new`}
471471 class="panel"
472472 style="padding:16px"
Modifiedsrc/routes/merge-queue.tsx+2−2View fileUnifiedSplit
@@ -167,7 +167,7 @@ queue.get("/:owner/:repo/queue", async (c) => {
167167 </div>
168168 {isOwner && active.length > 0 && (
169169 <form
170 method="POST"
170 method="post"
171171 action={`/${owner}/${repo}/queue/process-next?base=${encodeURIComponent(branch)}`}
172172 >
173173 <button type="submit" class="btn btn-sm btn-primary">
@@ -216,7 +216,7 @@ queue.get("/:owner/:repo/queue", async (c) => {
216216 user &&
217217 (isOwner || user.id === it.enqueuedBy) && (
218218 <form
219 method="POST"
219 method="post"
220220 action={`/${owner}/${repo}/queue/${it.id}/dequeue`}
221221 onsubmit="return confirm('Remove from queue?')"
222222 >
Modifiedsrc/routes/mirrors.tsx+3−3View fileUnifiedSplit
@@ -104,7 +104,7 @@ mirrors.get("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
104104 )}
105105
106106 <form
107 method="POST"
107 method="post"
108108 action={`/${ownerName}/${repoName}/settings/mirror`}
109109 class="panel"
110110 style="padding:16px;margin:16px 0"
@@ -153,7 +153,7 @@ mirrors.get("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
153153 <>
154154 <div style="display:flex;gap:8px;margin:12px 0">
155155 <form
156 method="POST"
156 method="post"
157157 action={`/${ownerName}/${repoName}/settings/mirror/sync`}
158158 >
159159 <button type="submit" class="btn">
@@ -161,7 +161,7 @@ mirrors.get("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
161161 </button>
162162 </form>
163163 <form
164 method="POST"
164 method="post"
165165 action={`/${ownerName}/${repoName}/settings/mirror/delete`}
166166 onsubmit="return confirm('Remove mirror configuration?')"
167167 >
Modifiedsrc/routes/notifications.tsx+71−33View fileUnifiedSplit
@@ -69,22 +69,39 @@ notificationRoutes.get("/notifications", softAuth, requireAuth, async (c) => {
6969
7070 return c.html(
7171 <Layout title="Notifications" user={user}>
72 <Container maxWidth={800}>
73 <PageHeader title="Notifications" actions={markAllReadAction} />
74
75 <div style="margin-bottom:16px">
76 <FilterTabs tabs={[
77 {
78 label: `Unread${unreadCount > 0 ? ` (${unreadCount})` : ""}`,
79 href: "/notifications?filter=unread",
80 active: filter === "unread",
81 },
82 {
83 label: "All",
84 href: "/notifications?filter=all",
85 active: filter === "all",
86 },
87 ]} />
72 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
73 <h2>Notifications</h2>
74 {unreadCount > 0 && (
75 <form method="post" action="/notifications/read-all">
76 <button type="submit" class="btn btn-sm">
77 Mark all as read
78 </button>
79 </form>
80 )}
81 </div>
82
83 <div class="issue-tabs" style="margin-bottom: 16px">
84 <a href="/notifications" class={filter === "all" ? "active" : ""}>
85 All
86 </a>
87 <a
88 href="/notifications?filter=unread"
89 class={filter === "unread" ? "active" : ""}
90 >
91 Unread
92 </a>
93 <a
94 href="/notifications?filter=mentions"
95 class={filter === "mentions" ? "active" : ""}
96 >
97 Mentions
98 </a>
99 </div>
100
101 {rows.length === 0 ? (
102 <div class="empty-state">
103 <h2>Inbox zero</h2>
104 <p>You're all caught up.</p>
88105 </div>
89106
90107 {items.length === 0 ? (
@@ -111,24 +128,45 @@ notificationRoutes.get("/notifications", softAuth, requireAuth, async (c) => {
111128 {n.body.length > 120 ? n.body.slice(0, 120) + "..." : n.body}
112129 </Text>
113130 )}
114 <Text size={12} muted style="margin-top:4px">
115 {formatRelative(n.createdAt)}
116 </Text>
117 </Flex>
118 {!n.isRead && (
119 <div style="flex-shrink:0">
120 <Form action={`/notifications/${n.id}/read`} csrfToken={csrfToken}>
121 <Button size="sm" variant="ghost" type="submit">
122 {"\u2713"}
123 </Button>
124 </Form>
131 <div class="notification-meta">
132 {n.repoOwner && n.repoName && (
133 <>
134 <a href={`/${n.repoOwner}/${n.repoName}`}>
135 {n.repoOwner}/{n.repoName}
136 </a>
137 <span> · </span>
138 </>
139 )}
140 <span>{formatRelative(n.createdAt)}</span>
125141 </div>
126 )}
127 </ListItem>
128 ))}
129 </List>
130 )}
131 </Container>
142 </div>
143 <div class="notification-actions">
144 {unread && (
145 <form method="post" action={`/notifications/${n.id}/read`}>
146 <button
147 type="submit"
148 class="btn btn-sm"
149 title="Mark as read"
150 >
151 {"\u2713"}
152 </button>
153 </form>
154 )}
155 <form method="post" action={`/notifications/${n.id}/delete`}>
156 <button
157 type="submit"
158 class="btn btn-sm"
159 title="Dismiss"
160 >
161 {"\u00D7"}
162 </button>
163 </form>
164 </div>
165 </div>
166 );
167 })}
168 </div>
169 )}
132170 </Layout>
133171 );
134172});
Modifiedsrc/routes/oauth.tsx+2−2View fileUnifiedSplit
@@ -232,7 +232,7 @@ oauth.get("/oauth/authorize", async (c) => {
232232 You can revoke access at any time from{" "}
233233 <a href="/settings/authorizations">Authorized applications</a>.
234234 </p>
235 <form method="POST" action="/oauth/authorize/decision">
235 <form method="post" action="/oauth/authorize/decision">
236236 <input type="hidden" name="client_id" value={clientId} />
237237 <input type="hidden" name="redirect_uri" value={redirectUri} />
238238 <input type="hidden" name="response_type" value={responseType} />
@@ -722,7 +722,7 @@ oauth.get("/settings/authorizations", async (c) => {
722722 </div>
723723 </div>
724724 <form
725 method="POST"
725 method="post"
726726 action={`/settings/authorizations/${token.appId}/revoke`}
727727 onsubmit="return confirm('Revoke access for this application?')"
728728 >
Modifiedsrc/routes/orgs.tsx+367−86View fileUnifiedSplit
@@ -41,26 +41,58 @@ orgRoutes.get("/orgs/new", softAuth, requireAuth, (c) => {
4141 const error = c.req.query("error");
4242
4343 return c.html(
44 <Layout title="New Organization" user={user}>
45 <Container maxWidth={500}>
46 <PageHeader title="Create a new organization" />
47 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
48 <Form action="/orgs/new" csrfToken={(c as any).get("csrfToken") || ""}>
49 <FormGroup label="Organization name" htmlFor="name" hint="Letters, numbers, hyphens, dots, underscores only">
50 <Input name="name" required pattern="^[a-zA-Z0-9._-]+$" placeholder="my-org" autocomplete="off" />
51 </FormGroup>
52 <FormGroup label="Display name" htmlFor="displayName">
53 <Input name="displayName" placeholder="My Organization" />
54 </FormGroup>
55 <FormGroup label="Description" htmlFor="description">
56 <TextArea name="description" rows={3} placeholder="What does this organization do?" />
57 </FormGroup>
58 <FormGroup label="Website" htmlFor="website">
59 <Input name="website" type="url" placeholder="https://example.com" />
60 </FormGroup>
61 <Button type="submit" variant="primary">Create organization</Button>
62 </Form>
63 </Container>
44 <Layout title="New organization" user={user}>
45 <div class="settings-container" style="max-width: 560px">
46 <h2>Create organization</h2>
47 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
48 Organizations are multi-user namespaces. You'll be the owner and can
49 invite teammates after creation.
50 </p>
51 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
52 <form method="post" action="/orgs/new">
53 <div class="form-group">
54 <label for="slug">Slug</label>
55 <input
56 type="text"
57 id="slug"
58 name="slug"
59 required
60 maxLength={39}
61 pattern="[a-z0-9][a-z0-9-]{0,38}"
62 placeholder="acme-corp"
63 autocomplete="off"
64 />
65 <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px">
66 2–39 chars, lowercase letters, numbers, hyphens. Cannot start or
67 end with a hyphen.
68 </div>
69 </div>
70 <div class="form-group">
71 <label for="name">Display name</label>
72 <input
73 type="text"
74 id="name"
75 name="name"
76 required
77 maxLength={120}
78 placeholder="Acme Corp"
79 />
80 </div>
81 <div class="form-group">
82 <label for="description">Description (optional)</label>
83 <textarea
84 id="description"
85 name="description"
86 rows={3}
87 maxLength={500}
88 placeholder="What does this org do?"
89 />
90 </div>
91 <button type="submit" class="btn btn-primary">
92 Create organization
93 </button>
94 </form>
95 </div>
6496 </Layout>
6597 );
6698});
@@ -233,7 +265,127 @@ orgRoutes.get("/orgs/:org", softAuth, async (c) => {
233265 );
234266});
235267
236// ─── Organization Settings ──────────────────────────────────────────────────
268// --- PEOPLE -----------------------------------------------------------------
269
270orgs.get("/orgs/:slug/people", async (c) => {
271 const user = c.get("user")!;
272 const slug = c.req.param("slug");
273 const { org, role } = await loadOrgForUser(slug, user.id);
274 if (!org) return c.notFound();
275 if (!role) return c.redirect(`/orgs/${slug}`);
276
277 const members = await listOrgMembers(org.id);
278 const error = c.req.query("error");
279 const success = c.req.query("success");
280 const canAdmin = orgRoleAtLeast(role, "admin");
281 const canOwner = orgRoleAtLeast(role, "owner");
282
283 return c.html(
284 <Layout title={`${org.name} — people`} user={user}>
285 <div style="max-width: 800px">
286 <div class="breadcrumb">
287 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
288 <span>/</span>
289 <span>people</span>
290 </div>
291 <h2>People ({members.length})</h2>
292 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
293 {success && (
294 <div class="auth-success">{decodeURIComponent(success)}</div>
295 )}
296
297 {canAdmin && (
298 <form
299 method="post"
300 action={`/orgs/${org.slug}/people/add`}
301 style="display: flex; gap: 8px; margin-bottom: 16px"
302 >
303 <input
304 type="text"
305 name="username"
306 placeholder="username to add"
307 required
308 maxLength={64}
309 style="flex: 1"
310 />
311 <select name="role">
312 <option value="member">member</option>
313 <option value="admin">admin</option>
314 {canOwner && <option value="owner">owner</option>}
315 </select>
316 <button type="submit" class="btn btn-primary">
317 Add
318 </button>
319 </form>
320 )}
321
322 <div
323 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
324 >
325 {members.map((m) => (
326 <div
327 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
328 >
329 <div>
330 <a href={`/${m.username}`}>
331 <strong>{m.username}</strong>
332 </a>
333 {m.displayName && (
334 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
335 {m.displayName}
336 </span>
337 )}
338 </div>
339 <div style="display: flex; gap: 8px; align-items: center">
340 {canOwner && m.userId !== user.id ? (
341 <form
342 method="post"
343 action={`/orgs/${org.slug}/people/${m.userId}/role`}
344 style="display: flex; gap: 4px"
345 >
346 <select name="role">
347 <option value="member" selected={m.role === "member"}>
348 member
349 </option>
350 <option value="admin" selected={m.role === "admin"}>
351 admin
352 </option>
353 <option value="owner" selected={m.role === "owner"}>
354 owner
355 </option>
356 </select>
357 <button type="submit" class="btn btn-sm">
358 save
359 </button>
360 </form>
361 ) : (
362 <span
363 class="gate-status"
364 style="font-size: 11px; text-transform: uppercase"
365 >
366 {m.role}
367 </span>
368 )}
369 {canAdmin && m.userId !== user.id && (
370 <form
371 method="post"
372 action={`/orgs/${org.slug}/people/${m.userId}/remove`}
373 style="display: inline"
374 onsubmit="return confirm('Remove this member?')"
375 >
376 <button type="submit" class="btn btn-sm btn-danger">
377 remove
378 </button>
379 </form>
380 )}
381 </div>
382 </div>
383 ))}
384 </div>
385 </div>
386 </Layout>
387 );
388});
237389
238390orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
239391 const orgName = c.req.param("org");
@@ -261,31 +413,73 @@ orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
261413 const success = c.req.query("success");
262414
263415 return c.html(
264 <Layout title={`Settings — ${org.name}`} user={user}>
265 <Container maxWidth={600}>
266 <PageHeader title="Organization Settings" />
267 {success && <Alert variant="success">Settings updated.</Alert>}
268 <Form action={`/orgs/${orgName}/settings`} csrfToken={(c as any).get("csrfToken") || ""}>
269 <FormGroup label="Display name" htmlFor="displayName">
270 <Input name="displayName" value={org.displayName || ""} />
271 </FormGroup>
272 <FormGroup label="Description" htmlFor="description">
273 <TextArea name="description" rows={3} value={org.description || ""} />
274 </FormGroup>
275 <FormGroup label="Website" htmlFor="website">
276 <Input name="website" type="url" value={org.website || ""} />
277 </FormGroup>
278 <FormGroup label="Location" htmlFor="location">
279 <Input name="location" value={org.location || ""} />
280 </FormGroup>
281 <Button type="submit" variant="primary">Save changes</Button>
282 </Form>
283
284 <div style="margin-top:40px;padding-top:24px;border-top:1px solid var(--red)">
285 <h3 style="color:var(--red);margin-bottom:12px">Danger Zone</h3>
286 <Form action={`/orgs/${orgName}/delete`} csrfToken={(c as any).get("csrfToken") || ""} class="confirm-action" >
287 <Button type="submit" variant="danger">Delete organization</Button>
288 </Form>
416 <Layout title={`${org.name} — teams`} user={user}>
417 <div style="max-width: 800px">
418 <div class="breadcrumb">
419 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
420 <span>/</span>
421 <span>teams</span>
422 </div>
423 <h2>Teams ({orgTeams.length})</h2>
424 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
425 {success && (
426 <div class="auth-success">{decodeURIComponent(success)}</div>
427 )}
428
429 {canAdmin && (
430 <form
431 method="post"
432 action={`/orgs/${org.slug}/teams/new`}
433 style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 16px"
434 >
435 <input
436 type="text"
437 name="slug"
438 placeholder="team-slug"
439 required
440 maxLength={39}
441 pattern="[a-z0-9][a-z0-9-]{0,38}"
442 />
443 <input
444 type="text"
445 name="name"
446 placeholder="Team name"
447 required
448 maxLength={80}
449 />
450 <button type="submit" class="btn btn-primary">
451 Create team
452 </button>
453 </form>
454 )}
455
456 <div
457 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
458 >
459 {orgTeams.length === 0 ? (
460 <div
461 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
462 >
463 No teams yet.
464 </div>
465 ) : (
466 orgTeams.map((t) => (
467 <a
468 href={`/orgs/${org.slug}/teams/${t.slug}`}
469 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); background: var(--bg-secondary)"
470 >
471 <strong>{t.name}</strong>{" "}
472 <span style="color: var(--text-muted); font-size: 12px">
473 @{t.slug}
474 </span>
475 {t.description && (
476 <div style="color: var(--text-muted); font-size: 12px">
477 {t.description}
478 </div>
479 )}
480 </a>
481 ))
482 )}
289483 </div>
290484 </Container>
291485 </Layout>
@@ -346,25 +540,91 @@ orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
346540 const success = c.req.query("success");
347541
348542 return c.html(
349 <Layout title={`Invite Member — ${orgName}`} user={user}>
350 <Container maxWidth={500}>
351 <PageHeader title={`Invite a member to ${orgName}`} />
352 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
353 {success && <Alert variant="success">Member invited successfully.</Alert>}
354 <Form action={`/orgs/${orgName}/members/invite`} csrfToken={(c as any).get("csrfToken") || ""}>
355 <FormGroup label="Username" htmlFor="username">
356 <Input name="username" required placeholder="Enter username" autocomplete="off" />
357 </FormGroup>
358 <FormGroup label="Role" htmlFor="role">
359 <Select name="role">
360 <option value="member">Member — can view</option>
361 <option value="admin">Admin — can manage teams</option>
362 <option value="owner">Owner — full control</option>
363 </Select>
364 </FormGroup>
365 <Button type="submit" variant="primary">Send invitation</Button>
366 </Form>
367 </Container>
543 <Layout title={`${team.name} — ${org.name}`} user={user}>
544 <div style="max-width: 800px">
545 <div class="breadcrumb">
546 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
547 <span>/</span>
548 <a href={`/orgs/${org.slug}/teams`}>teams</a>
549 <span>/</span>
550 <span>{team.slug}</span>
551 </div>
552 <h2>{team.name}</h2>
553 {team.description && (
554 <p style="color: var(--text-muted)">{team.description}</p>
555 )}
556 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
557 {success && (
558 <div class="auth-success">{decodeURIComponent(success)}</div>
559 )}
560
561 <h3 style="font-size: 15px; margin-top: 16px">
562 Members ({members.length})
563 </h3>
564
565 {canAdmin && (
566 <form
567 method="post"
568 action={`/orgs/${org.slug}/teams/${team.slug}/members/add`}
569 style="display: flex; gap: 8px; margin-bottom: 16px"
570 >
571 <input
572 type="text"
573 name="username"
574 placeholder="username"
575 required
576 maxLength={64}
577 style="flex: 1"
578 />
579 <select name="role">
580 <option value="member">member</option>
581 <option value="maintainer">maintainer</option>
582 </select>
583 <button type="submit" class="btn btn-primary">
584 Add
585 </button>
586 </form>
587 )}
588
589 <div
590 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
591 >
592 {members.length === 0 ? (
593 <div
594 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
595 >
596 No members yet.
597 </div>
598 ) : (
599 members.map((m) => (
600 <div
601 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
602 >
603 <a href={`/${m.username}`}>{m.username}</a>
604 <div style="display: flex; gap: 8px; align-items: center">
605 <span
606 class="gate-status"
607 style="font-size: 11px; text-transform: uppercase"
608 >
609 {m.role}
610 </span>
611 {canAdmin && (
612 <form
613 method="post"
614 action={`/orgs/${org.slug}/teams/${team.slug}/members/${m.userId}/remove`}
615 style="display: inline"
616 >
617 <button type="submit" class="btn btn-sm btn-danger">
618 remove
619 </button>
620 </form>
621 )}
622 </div>
623 </div>
624 ))
625 )}
626 </div>
627 </div>
368628 </Layout>
369629 );
370630});
@@ -422,27 +682,48 @@ orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
422682 const error = c.req.query("error");
423683
424684 return c.html(
425 <Layout title={`New Team — ${orgName}`} user={user}>
426 <Container maxWidth={500}>
427 <PageHeader title="Create a new team" />
428 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
429 <Form action={`/orgs/${orgName}/teams/new`} csrfToken={(c as any).get("csrfToken") || ""}>
430 <FormGroup label="Team name" htmlFor="name">
431 <Input name="name" required placeholder="engineering" autocomplete="off" />
432 </FormGroup>
433 <FormGroup label="Description" htmlFor="description">
434 <TextArea name="description" rows={2} placeholder="What does this team do?" />
435 </FormGroup>
436 <FormGroup label="Default permission" htmlFor="permission">
437 <Select name="permission">
438 <option value="read">Read — view repos</option>
439 <option value="write">Write — push to repos</option>
440 <option value="admin">Admin — manage repos</option>
441 </Select>
442 </FormGroup>
443 <Button type="submit" variant="primary">Create team</Button>
444 </Form>
445 </Container>
685 <Layout title={`New repo — ${org.name}`} user={user}>
686 <div class="settings-container" style="max-width: 560px">
687 <div class="breadcrumb">
688 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
689 <span>/</span>
690 <span>new repo</span>
691 </div>
692 <h2>Create repository in {org.name}</h2>
693 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
694 <form method="post" action={`/orgs/${org.slug}/repos/new`}>
695 <div class="form-group">
696 <label for="name">Repository name</label>
697 <input
698 type="text"
699 id="name"
700 name="name"
701 required
702 maxLength={100}
703 pattern="[a-zA-Z0-9._-]+"
704 placeholder="my-repo"
705 autocomplete="off"
706 />
707 </div>
708 <div class="form-group">
709 <label for="description">Description (optional)</label>
710 <textarea
711 id="description"
712 name="description"
713 rows={3}
714 maxLength={500}
715 />
716 </div>
717 <div class="form-group">
718 <label>
719 <input type="checkbox" name="isPrivate" value="1" /> Private
720 </label>
721 </div>
722 <button type="submit" class="btn btn-primary">
723 Create repository
724 </button>
725 </form>
726 </div>
446727 </Layout>
447728 );
448729});
Modifiedsrc/routes/packages.tsx+1−1View fileUnifiedSplit
@@ -378,7 +378,7 @@ ui.get("/:owner/:repo/packages/:pkgName{.+}", async (c) => {
378378 </a>
379379 {isOwner && !v.yanked && (
380380 <form
381 method="POST"
381 method="post"
382382 action={`/api/packages/${owner}/${repo}/${encodeURIComponent(fullName)}/${v.version}/yank`}
383383 onsubmit="return confirm('Yank this version? It will still download, but will be flagged as yanked.')"
384384 style="margin: 0"
Modifiedsrc/routes/pages.tsx+3−3View fileUnifiedSplit
@@ -168,7 +168,7 @@ pagesRoute.get("/:owner/:repo/pages/*", async (c) => {
168168 candidate
169169 );
170170 if (!raw) continue;
171 return new Response(raw, { status: 200, headers });
171 return new Response(raw as BodyInit, { status: 200, headers });
172172 }
173173
174174 return new Response(blob.content, { status: 200, headers });
@@ -269,7 +269,7 @@ pagesRoute.get(
269269 </div>
270270
271271 <form
272 method="POST"
272 method="post"
273273 action={`/${ownerName}/${repoName}/settings/pages`}
274274 >
275275 <div class="form-group">
@@ -327,7 +327,7 @@ pagesRoute.get(
327327 >
328328 <h3>Recent deployments</h3>
329329 <form
330 method="POST"
330 method="post"
331331 action={`/${ownerName}/${repoName}/settings/pages/redeploy`}
332332 style="display: inline"
333333 >
Modifiedsrc/routes/passkeys.tsx+2−2View fileUnifiedSplit
@@ -119,7 +119,7 @@ passkeys.get("/settings/passkeys", async (c) => {
119119 </div>
120120 <div style="display: flex; gap: 6px">
121121 <form
122 method="POST"
122 method="post"
123123 action={`/settings/passkeys/${k.id}/rename`}
124124 style="display: flex; gap: 4px"
125125 >
@@ -135,7 +135,7 @@ passkeys.get("/settings/passkeys", async (c) => {
135135 </button>
136136 </form>
137137 <form
138 method="POST"
138 method="post"
139139 action={`/settings/passkeys/${k.id}/delete`}
140140 onsubmit="return confirm('Remove this passkey?')"
141141 >
Modifiedsrc/routes/projects.tsx+6−6View fileUnifiedSplit
@@ -150,7 +150,7 @@ projectRoutes.get(
150150 <RepoHeader owner={ownerName} repo={repoName} />
151151 <h2 style="margin-top: 20px;">Create a project</h2>
152152 <form
153 method="POST"
153 method="post"
154154 action={`/${ownerName}/${repoName}/projects`}
155155 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
156156 >
@@ -291,7 +291,7 @@ projectRoutes.get(
291291 </h1>
292292 {user && (
293293 <form
294 method="POST"
294 method="post"
295295 action={`/${ownerName}/${repoName}/projects/${project.number}/close`}
296296 style="display: inline;"
297297 >
@@ -331,7 +331,7 @@ projectRoutes.get(
331331 .filter((oc) => oc.id !== col.id)
332332 .map((oc) => (
333333 <form
334 method="POST"
334 method="post"
335335 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/move`}
336336 >
337337 <input
@@ -349,7 +349,7 @@ projectRoutes.get(
349349 </form>
350350 ))}
351351 <form
352 method="POST"
352 method="post"
353353 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
354354 >
355355 <button
@@ -366,7 +366,7 @@ projectRoutes.get(
366366 ))}
367367 {user && (
368368 <form
369 method="POST"
369 method="post"
370370 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
371371 style="margin-top: 8px; display: flex; flex-direction: column; gap: 4px;"
372372 >
@@ -392,7 +392,7 @@ projectRoutes.get(
392392 {user && (
393393 <div class="kcol" style="background: transparent; border-style: dashed;">
394394 <form
395 method="POST"
395 method="post"
396396 action={`/${ownerName}/${repoName}/projects/${project.number}/columns`}
397397 style="display: flex; flex-direction: column; gap: 8px;"
398398 >
Modifiedsrc/routes/protected-tags.tsx+2−2View fileUnifiedSplit
@@ -108,7 +108,7 @@ protectedTagsRoutes.get(
108108 </div>
109109 </div>
110110 <form
111 method="POST"
111 method="post"
112112 action={`/${owner}/${repo}/settings/protected-tags/${t.id}/delete`}
113113 onsubmit="return confirm('Remove protection for this pattern?')"
114114 >
@@ -122,7 +122,7 @@ protectedTagsRoutes.get(
122122 </div>
123123
124124 <form
125 method="POST"
125 method="post"
126126 action={`/${owner}/${repo}/settings/protected-tags`}
127127 class="panel"
128128 style="padding:16px"
Modifiedsrc/routes/pulls.tsx+6−5View fileUnifiedSplit
@@ -209,9 +209,9 @@ pulls.get(
209209 {error && (
210210 <Alert variant="error">{decodeURIComponent(error)}</Alert>
211211 )}
212 <Form action={`/${ownerName}/${repoName}/pulls/new`} method="POST">
213 <Flex gap={12} align="center" style="margin-bottom:16px">
214 <Select name="base" value={defaultBase}>
212 <form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
213 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
214 <select name="base" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
215215 {branches.map((b) => (
216216 <option value={b} selected={b === defaultBase}>
217217 {b}
@@ -542,8 +542,9 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
542542 )}
543543
544544 {user && pr.state === "open" && (
545 <div style="margin-top:20px">
546 <Form
545 <div style="margin-top: 20px">
546 <form
547 method="post"
547548 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
548549 method="POST"
549550 >
Modifiedsrc/routes/releases.tsx+2−2View fileUnifiedSplit
@@ -161,7 +161,7 @@ releasesRoute.get("/:owner/:repo/releases", async (c) => {
161161 )}
162162 {user && user.id === repoRow.ownerId && (
163163 <form
164 method="POST"
164 method="post"
165165 action={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}/delete`}
166166 style="margin-top: 12px"
167167 onsubmit="return confirm('Delete this release?')"
@@ -208,7 +208,7 @@ releasesRoute.get("/:owner/:repo/releases/new", requireAuth, async (c) => {
208208 <h3>Draft a new release</h3>
209209 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
210210 <form
211 method="POST"
211 method="post"
212212 action={`/${owner}/${repo}/releases`}
213213 style="max-width: 700px"
214214 >
Modifiedsrc/routes/repo-settings.tsx+5−4View fileUnifiedSplit
@@ -76,7 +76,8 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
7676 <Alert variant="error">{decodeURIComponent(error)}</Alert>
7777 )}
7878
79 <Form
79 <form
80 method="post"
8081 action={`/${ownerName}/${repoName}/settings`}
8182 method="POST"
8283 >
@@ -140,7 +141,7 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
140141 : "Mark this repository as a template so others can seed new repositories from its files."}
141142 </p>
142143 <form
143 method="POST"
144 method="post"
144145 action={`/${ownerName}/${repoName}/settings/template`}
145146 >
146147 <input
@@ -165,7 +166,7 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
165166 accept or decline the transfer by attempting to view it.
166167 </p>
167168 <form
168 method="POST"
169 method="post"
169170 action={`/${ownerName}/${repoName}/settings/transfer`}
170171 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
171172 >
@@ -194,7 +195,7 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
194195 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
195196 </p>
196197 <form
197 method="POST"
198 method="post"
198199 action={`/${ownerName}/${repoName}/settings/archive`}
199200 >
200201 <input
Modifiedsrc/routes/required-checks.tsx+2−2View fileUnifiedSplit
@@ -135,7 +135,7 @@ required.get(
135135 {ch.checkName}
136136 </code>
137137 <form
138 method="POST"
138 method="post"
139139 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks/${ch.id}/delete`}
140140 onsubmit="return confirm('Remove this required check?')"
141141 >
@@ -149,7 +149,7 @@ required.get(
149149 </div>
150150
151151 <form
152 method="POST"
152 method="post"
153153 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks`}
154154 class="panel"
155155 style="padding:16px"
Modifiedsrc/routes/rulesets.tsx+5−5View fileUnifiedSplit
@@ -156,7 +156,7 @@ rulesets.get("/:owner/:repo/settings/rulesets", requireAuth, async (c) => {
156156
157157 <h3 style="margin-top:24px">New ruleset</h3>
158158 <form
159 method="POST"
159 method="post"
160160 action={`/${ownerName}/${repoName}/settings/rulesets`}
161161 class="auth-form"
162162 style="max-width:520px"
@@ -278,7 +278,7 @@ rulesets.get(
278278
279279 <h3 style="margin-top:24px">Enforcement</h3>
280280 <form
281 method="POST"
281 method="post"
282282 action={base}
283283 style="display:flex;gap:8px;align-items:center"
284284 >
@@ -331,7 +331,7 @@ rulesets.get(
331331 <span>{ruleDescription(r.ruleType, params)}</span>
332332 </div>
333333 <form
334 method="POST"
334 method="post"
335335 action={`${base}/rules/${r.id}/delete`}
336336 >
337337 <button
@@ -351,7 +351,7 @@ rulesets.get(
351351
352352 <h3 style="margin-top:24px">Add rule</h3>
353353 <form
354 method="POST"
354 method="post"
355355 action={`${base}/rules`}
356356 class="auth-form"
357357 style="max-width:640px"
@@ -383,7 +383,7 @@ rulesets.get(
383383 </form>
384384
385385 <h3 style="margin-top:24px;color:var(--red)">Danger zone</h3>
386 <form method="POST" action={`${base}/delete`}>
386 <form method="post" action={`${base}/delete`}>
387387 <button type="submit" class="btn" style="color:var(--red)">
388388 Delete ruleset
389389 </button>
Modifiedsrc/routes/saved-replies.tsx+2−2View fileUnifiedSplit
@@ -61,7 +61,7 @@ replies.get("/settings/replies", async (c) => {
6161 <div class="auth-success">{decodeURIComponent(success)}</div>
6262 )}
6363
64 <form method="POST" action="/settings/replies" style="margin-bottom: 24px">
64 <form method="post" action="/settings/replies" style="margin-bottom: 24px">
6565 <div class="form-group">
6666 <label for="shortcut">Shortcut</label>
6767 <input
@@ -106,7 +106,7 @@ replies.get("/settings/replies", async (c) => {
106106 </span>
107107 </summary>
108108 <div style="padding: 12px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border)">
109 <form method="POST" action={`/settings/replies/${r.id}`}>
109 <form method="post" action={`/settings/replies/${r.id}`}>
110110 <div class="form-group">
111111 <label>Shortcut</label>
112112 <input
Modifiedsrc/routes/search.tsx+1−1View fileUnifiedSplit
@@ -137,7 +137,7 @@ search.get("/search", async (c) => {
137137 user={user}
138138 notificationCount={unread}
139139 >
140 <form method="GET" action="/search" style="margin-bottom: 16px">
140 <form method="get" action="/search" style="margin-bottom: 16px">
141141 <input
142142 type="hidden"
143143 name="type"
Modifiedsrc/routes/semantic-search.tsx+3−3View fileUnifiedSplit
@@ -150,7 +150,7 @@ semanticSearch.get("/:owner/:repo/search/semantic", async (c) => {
150150 )}
151151
152152 <form
153 method="GET"
153 method="get"
154154 action={`/${ownerName}/${repoName}/search/semantic`}
155155 style="margin-bottom: 16px"
156156 >
@@ -173,7 +173,7 @@ semanticSearch.get("/:owner/:repo/search/semantic", async (c) => {
173173 </p>
174174 {isOwner ? (
175175 <form
176 method="POST"
176 method="post"
177177 action={`/${ownerName}/${repoName}/search/semantic/reindex`}
178178 style="margin-top: 12px"
179179 >
@@ -204,7 +204,7 @@ semanticSearch.get("/:owner/:repo/search/semantic", async (c) => {
204204 </span>
205205 {isOwner && (
206206 <form
207 method="POST"
207 method="post"
208208 action={`/${ownerName}/${repoName}/search/semantic/reindex`}
209209 style="display: inline"
210210 >
Modifiedsrc/routes/settings-2fa.tsx+4−4View fileUnifiedSplit
@@ -86,7 +86,7 @@ settings2fa.get("/settings/2fa", async (c) => {
8686 </p>
8787
8888 {state === "off" && (
89 <form method="POST" action="/settings/2fa/enroll">
89 <form method="post" action="/settings/2fa/enroll">
9090 <button type="submit" class="btn btn-primary">
9191 Enable two-factor authentication
9292 </button>
@@ -121,7 +121,7 @@ settings2fa.get("/settings/2fa", async (c) => {
121121 used once if you lose access to your authenticator.
122122 </p>
123123 <form
124 method="POST"
124 method="post"
125125 action="/settings/2fa/recovery/regen"
126126 style="display: inline-block; margin-right: 8px"
127127 onsubmit="return confirm('Regenerate recovery codes? Your existing codes will stop working.')"
@@ -135,7 +135,7 @@ settings2fa.get("/settings/2fa", async (c) => {
135135 <p style="color: var(--text-muted); font-size: 13px">
136136 Confirm your password to turn off 2FA.
137137 </p>
138 <form method="POST" action="/settings/2fa/disable">
138 <form method="post" action="/settings/2fa/disable">
139139 <div class="form-group" style="max-width: 320px">
140140 <label for="password">Password</label>
141141 <input
@@ -233,7 +233,7 @@ async function showEnrolPage(c: any, user: any, error?: string) {
233233 {url}
234234 </code>
235235 </div>
236 <form method="POST" action="/settings/2fa/confirm">
236 <form method="post" action="/settings/2fa/confirm">
237237 <div class="form-group" style="max-width: 280px">
238238 <label for="code">6-digit code</label>
239239 <input
Modifiedsrc/routes/settings.tsx+132−33View fileUnifiedSplit
@@ -42,10 +42,11 @@ settings.get("/settings", (c) => {
4242 {decodeURIComponent(success)}
4343 </Alert>
4444 )}
45 <Form action="/settings/profile" method="post">
46 <FormGroup label="Username" htmlFor="username">
47 <Input
48 name="username"
45 <form method="post" action="/settings/profile">
46 <div class="form-group">
47 <label for="username">Username</label>
48 <input
49 type="text"
4950 id="username"
5051 value={user.username}
5152 disabled
@@ -79,8 +80,104 @@ settings.get("/settings", (c) => {
7980 </FormGroup>
8081 <Button type="submit" variant="primary">
8182 Update profile
82 </Button>
83 </Form>
83 </button>
84 </form>
85
86 <h3 style="margin-top: 32px; font-size: 16px">Email notifications</h3>
87 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 12px">
88 Opt out of individual email categories. In-app notifications are
89 unaffected and continue to appear in your inbox.
90 </p>
91 <form method="post" action="/settings/notifications">
92 <label
93 style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px"
94 >
95 <input
96 type="checkbox"
97 name="notify_email_on_mention"
98 value="1"
99 checked={user.notifyEmailOnMention}
100 />
101 <span>
102 Someone <code>@mentions</code> me or requests a review
103 </span>
104 </label>
105 <label
106 style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px"
107 >
108 <input
109 type="checkbox"
110 name="notify_email_on_assign"
111 value="1"
112 checked={user.notifyEmailOnAssign}
113 />
114 <span>I am assigned to an issue or PR</span>
115 </label>
116 <label
117 style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px"
118 >
119 <input
120 type="checkbox"
121 name="notify_email_on_gate_fail"
122 value="1"
123 checked={user.notifyEmailOnGateFail}
124 />
125 <span>A gate fails on one of my repositories</span>
126 </label>
127 <label
128 style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px; font-size: 14px"
129 >
130 <input
131 type="checkbox"
132 name="notify_email_digest_weekly"
133 value="1"
134 checked={user.notifyEmailDigestWeekly}
135 />
136 <span>
137 Weekly digest —{" "}
138 <a href="/settings/digest/preview">preview</a>
139 </span>
140 </label>
141 <button type="submit" class="btn btn-primary">
142 Save preferences
143 </button>
144 </form>
145 </div>
146 </Layout>
147 );
148});
149
150// Preview the weekly digest in-browser (rendered HTML)
151settings.get("/settings/digest/preview", async (c) => {
152 const user = c.get("user")!;
153 const body = await composeDigest(user.id);
154 if (!body) {
155 return c.html(
156 <Layout title="Digest preview" user={user}>
157 <h2>Digest preview</h2>
158 <p>Could not compose a digest right now.</p>
159 <p>
160 <a href="/settings">Back to settings</a>
161 </p>
162 </Layout>
163 );
164 }
165 return c.html(
166 <Layout title="Digest preview" user={user}>
167 <h2>Digest preview</h2>
168 <p style="color:var(--text-muted);font-size:13px">
169 Subject: <code>{body.subject}</code>
170 </p>
171 <p style="font-size:12px;color:var(--text-muted)">
172 Notifications: {body.counts.notifications} · Failed gates:{" "}
173 {body.counts.failedGates} · Repaired: {body.counts.repairedGates} ·
174 Merged PRs: {body.counts.mergedPrs}
175 </p>
176 <div
177 class="panel"
178 style="padding:20px;background:#fff;color:#111"
179 >
180 {raw(body.html)}
84181 </div>
85182 <p style="margin-top:20px">
86183 <a href="/settings">Back to settings</a>
@@ -173,8 +270,8 @@ settings.get("/settings/keys", async (c) => {
173270 )}
174271 </div>
175272 </div>
176 <Form action={`/settings/keys/${key.id}/delete`} method="post">
177 <Button type="submit" variant="danger" size="sm">
273 <form method="post" action={`/settings/keys/${key.id}/delete`}>
274 <button type="submit" class="btn btn-danger btn-sm">
178275 Delete
179276 </Button>
180277 </Form>
@@ -183,31 +280,33 @@ settings.get("/settings/keys", async (c) => {
183280 )}
184281 </div>
185282
186 <Section title="Add new SSH key" style="margin-top:24px">
187 <Form action="/settings/keys" method="post">
188 <FormGroup label="Title" htmlFor="title">
189 <Input
190 name="title"
191 id="title"
192 required
193 placeholder="e.g. My laptop"
194 />
195 </FormGroup>
196 <FormGroup label="Public key" htmlFor="public_key">
197 <TextArea
198 name="public_key"
199 id="public_key"
200 rows={4}
201 required
202 placeholder="ssh-ed25519 AAAA... or ssh-rsa AAAA..."
203 mono
204 />
205 </FormGroup>
206 <Button type="submit" variant="primary">
207 Add SSH key
208 </Button>
209 </Form>
210 </Section>
283 <h3 style="margin-top: 24px">Add new SSH key</h3>
284 <form method="post" action="/settings/keys">
285 <div class="form-group">
286 <label for="title">Title</label>
287 <input
288 type="text"
289 id="title"
290 name="title"
291 required
292 placeholder="e.g. My laptop"
293 />
294 </div>
295 <div class="form-group">
296 <label for="public_key">Public key</label>
297 <textarea
298 id="public_key"
299 name="public_key"
300 rows={4}
301 required
302 placeholder="ssh-ed25519 AAAA... or ssh-rsa AAAA..."
303 style="font-family: var(--font-mono); font-size: 12px"
304 />
305 </div>
306 <button type="submit" class="btn btn-primary">
307 Add SSH key
308 </button>
309 </form>
211310 </div>
212311 </Layout>
213312 );
Modifiedsrc/routes/signing-keys.tsx+2−2View fileUnifiedSplit
@@ -77,7 +77,7 @@ signingKeysRoutes.get("/settings/signing-keys", async (c) => {
7777 )}
7878 </div>
7979 <form
80 method="POST"
80 method="post"
8181 action={`/settings/signing-keys/${k.id}/delete`}
8282 >
8383 <button
@@ -101,7 +101,7 @@ signingKeysRoutes.get("/settings/signing-keys", async (c) => {
101101
102102 <h3 style="margin-top:24px">Add a key</h3>
103103 <form
104 method="POST"
104 method="post"
105105 action="/settings/signing-keys"
106106 class="auth-form"
107107 style="max-width:720px"
Modifiedsrc/routes/sponsors.tsx+5−5View fileUnifiedSplit
@@ -89,7 +89,7 @@ sponsors.get("/sponsors/:username", async (c) => {
8989 {targetName} hasn't published any sponsorship tiers yet.
9090 </p>
9191 {user ? (
92 <form method="POST" action={`/sponsors/${targetName}`}>
92 <form method="post" action={`/sponsors/${targetName}`}>
9393 <input
9494 type="number"
9595 name="amount_cents"
@@ -114,7 +114,7 @@ sponsors.get("/sponsors/:username", async (c) => {
114114 >
115115 {tiers.map((t) => (
116116 <form
117 method="POST"
117 method="post"
118118 action={`/sponsors/${targetName}`}
119119 class="panel"
120120 style="padding:16px;display:flex;flex-direction:column;gap:8px"
@@ -302,7 +302,7 @@ sponsors.get("/settings/sponsors", requireAuth, async (c) => {
302302 </div>
303303 </div>
304304 <form
305 method="POST"
305 method="post"
306306 action={`/settings/sponsors/tiers/${t.id}/delete`}
307307 onsubmit="return confirm('Retire this tier?')"
308308 >
@@ -317,7 +317,7 @@ sponsors.get("/settings/sponsors", requireAuth, async (c) => {
317317
318318 <h3>Add a tier</h3>
319319 <form
320 method="POST"
320 method="post"
321321 action="/settings/sponsors/tiers/new"
322322 class="panel"
323323 style="padding:16px"
@@ -328,7 +328,7 @@ sponsors.get("/settings/sponsors", requireAuth, async (c) => {
328328 </div>
329329 <div class="form-group">
330330 <label>Description</label>
331 <textarea name="description" rows="2" style="width:100%" />
331 <textarea name="description" rows={2} style="width:100%" />
332332 </div>
333333 <div class="form-group">
334334 <label>Monthly amount (cents)</label>
Modifiedsrc/routes/sso.tsx+1−1View fileUnifiedSplit
@@ -107,7 +107,7 @@ sso.get("/admin/sso", requireAuth, async (c) => {
107107 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
108108
109109 <form
110 method="POST"
110 method="post"
111111 action="/admin/sso"
112112 class="panel"
113113 style="padding:16px"
Modifiedsrc/routes/symbols.tsx+3−3View fileUnifiedSplit
@@ -88,7 +88,7 @@ symbols.get("/:owner/:repo/symbols", async (c) => {
8888 <div style="display:flex;justify-content:space-between;align-items:center">
8989 <h2 style="margin:0">Symbols</h2>
9090 {isOwner && (
91 <form method="POST" action={`/${ownerName}/${repoName}/symbols/reindex`}>
91 <form method="post" action={`/${ownerName}/${repoName}/symbols/reindex`}>
9292 <button type="submit" class="btn btn-primary btn-sm">
9393 Reindex
9494 </button>
@@ -106,7 +106,7 @@ symbols.get("/:owner/:repo/symbols", async (c) => {
106106 </p>
107107
108108 <form
109 method="GET"
109 method="get"
110110 action={`/${ownerName}/${repoName}/symbols/search`}
111111 style="display:flex;gap:8px;margin:16px 0"
112112 >
@@ -218,7 +218,7 @@ symbols.get("/:owner/:repo/symbols/search", async (c) => {
218218 <div class="settings-container">
219219 <h2>Symbol search</h2>
220220 <form
221 method="GET"
221 method="get"
222222 action={`/${ownerName}/${repoName}/symbols/search`}
223223 style="display:flex;gap:8px;margin:12px 0"
224224 >
Modifiedsrc/routes/tokens.tsx+37−32View fileUnifiedSplit
@@ -100,7 +100,8 @@ tokens.get("/settings/tokens", async (c) => {
100100 )}
101101 </div>
102102 </div>
103 <Form
103 <form
104 method="post"
104105 action={`/settings/tokens/${token.id}/delete`}
105106 method="POST"
106107 >
@@ -113,37 +114,41 @@ tokens.get("/settings/tokens", async (c) => {
113114 )}
114115 </div>
115116
116 <Section title="Generate new token" style="margin-top: 24px">
117 <Form action="/settings/tokens" method="POST">
118 <FormGroup label="Token name" htmlFor="name">
119 <Input
120 name="name"
121 id="name"
122 required
123 placeholder="e.g. CI/CD pipeline"
124 />
125 </FormGroup>
126 <FormGroup label="Scopes">
127 <Flex gap={16} wrap>
128 {["repo", "user", "admin"].map((scope) => (
129 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
130 <input
131 type="checkbox"
132 name="scopes"
133 value={scope}
134 checked={scope === "repo"}
135 />{" "}
136 {scope}
137 </label>
138 ))}
139 </Flex>
140 </FormGroup>
141 <Button type="submit" variant="primary">
142 Generate token
143 </Button>
144 </Form>
145 </Section>
146 </Container>
117 <h3 style="margin-top: 24px; margin-bottom: 12px">
118 Generate new token
119 </h3>
120 <form method="post" action="/settings/tokens">
121 <div class="form-group">
122 <label for="name">Token name</label>
123 <input
124 type="text"
125 id="name"
126 name="name"
127 required
128 placeholder="e.g. CI/CD pipeline"
129 />
130 </div>
131 <div class="form-group">
132 <label>Scopes</label>
133 <div style="display: flex; gap: 16px; flex-wrap: wrap">
134 {["repo", "user", "admin"].map((scope) => (
135 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
136 <input
137 type="checkbox"
138 name="scopes"
139 value={scope}
140 checked={scope === "repo"}
141 />{" "}
142 {scope}
143 </label>
144 ))}
145 </div>
146 </div>
147 <button type="submit" class="btn btn-primary">
148 Generate token
149 </button>
150 </form>
151 </div>
147152 </Layout>
148153 );
149154});
Modifiedsrc/routes/web.tsx+3−3View fileUnifiedSplit
@@ -305,7 +305,7 @@ web.get("/:owner", async (c) => {
305305 </a>
306306 {canFollow && (
307307 <form
308 method="POST"
308 method="post"
309309 action={`/${ownerName}/${
310310 followState.viewerFollows ? "unfollow" : "follow"
311311 }`}
@@ -546,7 +546,7 @@ git push -u gluecron main`}</pre>
546546 this template's files.
547547 </div>
548548 <form
549 method="POST"
549 method="post"
550550 action={`/${owner}/${repo}/use-template`}
551551 style="display:flex;gap:8px;align-items:center"
552552 >
@@ -1052,7 +1052,7 @@ web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
10521052 if (!data) return c.text("Not found", 404);
10531053
10541054 const fileName = filePath.split("/").pop() || "file";
1055 return new Response(data.buffer as ArrayBuffer, {
1055 return new Response(data as BodyInit, {
10561056 headers: {
10571057 "Content-Type": "application/octet-stream",
10581058 "Content-Disposition": `attachment; filename="${fileName}"`,
Modifiedsrc/routes/webhooks.tsx+4−4View fileUnifiedSplit
@@ -92,8 +92,8 @@ webhookRoutes.get(
9292 )}
9393 </div>
9494 </div>
95 <Form
96 method="POST"
95 <form
96 method="post"
9797 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
9898 >
9999 <Button type="submit" variant="danger" size="sm">
@@ -106,8 +106,8 @@ webhookRoutes.get(
106106 )}
107107
108108 <h3 style="margin-bottom: 12px">Add webhook</h3>
109 <Form
110 method="POST"
109 <form
110 method="post"
111111 action={`/${ownerName}/${repoName}/settings/webhooks`}
112112 >
113113 <FormGroup label="Payload URL">
Modifiedsrc/routes/wikis.tsx+4−4View fileUnifiedSplit
@@ -237,7 +237,7 @@ wikiRoutes.get("/:owner/:repo/wiki/new", requireAuth, async (c) => {
237237 <RepoHeader owner={ownerName} repo={repoName} />
238238 <h2 style="margin-top: 20px;">New wiki page</h2>
239239 <form
240 method="POST"
240 method="post"
241241 action={`/${ownerName}/${repoName}/wiki`}
242242 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
243243 >
@@ -360,7 +360,7 @@ wikiRoutes.get("/:owner/:repo/wiki/:slug", softAuth, async (c) => {
360360 )}
361361 {isOwner && (
362362 <form
363 method="POST"
363 method="post"
364364 action={`/${ownerName}/${repoName}/wiki/${slug}/delete`}
365365 style="display: inline;"
366366 onsubmit="return confirm('Delete this page?')"
@@ -417,7 +417,7 @@ wikiRoutes.get(
417417 <RepoHeader owner={ownerName} repo={repoName} />
418418 <h2 style="margin-top: 20px;">Edit "{page.title}"</h2>
419419 <form
420 method="POST"
420 method="post"
421421 action={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
422422 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
423423 >
@@ -607,7 +607,7 @@ wikiRoutes.get(
607607 {" "}
608608 ·{" "}
609609 <form
610 method="POST"
610 method="post"
611611 action={`/${ownerName}/${repoName}/wiki/${slug}/revert/${rv.r.revision}`}
612612 style="display: inline;"
613613 >
Modifiedsrc/routes/workflows.tsx+2−2View fileUnifiedSplit
@@ -189,7 +189,7 @@ actions.get("/:owner/:repo/actions", async (c) => {
189189 </div>
190190 {canRun && !w.disabled && (
191191 <form
192 method="POST"
192 method="post"
193193 action={`/${owner}/${repo}/actions/${w.id}/run`}
194194 style="margin: 0"
195195 >
@@ -376,7 +376,7 @@ actions.get("/:owner/:repo/actions/runs/:runId", async (c) => {
376376 </div>
377377 {canCancel && (
378378 <form
379 method="POST"
379 method="post"
380380 action={`/${owner}/${repo}/actions/runs/${run.id}/cancel`}
381381 onsubmit="return confirm('Cancel this run?')"
382382 >
Modifiedsrc/views/layout.tsx+18−2View fileUnifiedSplit
@@ -26,14 +26,17 @@ export const Layout: FC<
2626 <style>{hljsThemeCss}</style>
2727 </head>
2828 <body>
29 <a href="#main-content" class="skip-link">Skip to main content</a>
29 <div class="prelaunch-banner" role="status" aria-live="polite">
30 Pre-launch — Gluecron is in final validation. Public signups
31 and git hosting for non-owner users open after launch review.
32 </div>
3033 <header>
3134 <nav>
3235 <a href="/" class="logo">
3336 gluecron
3437 </a>
3538 <div class="nav-search">
36 <form method="GET" action="/search">
39 <form method="get" action="/search">
3740 <input
3841 type="search"
3942 name="q"
@@ -349,6 +352,19 @@ const css = `
349352 a { color: var(--text-link); text-decoration: none; }
350353 a:hover { text-decoration: underline; }
351354
355 /* Pre-launch banner - always visible, not dismissible. Amber tone, readable
356 on both light and dark themes via the shared --yellow token. */
357 .prelaunch-banner {
358 background: rgba(210, 153, 34, 0.15);
359 border-bottom: 1px solid var(--yellow);
360 color: var(--yellow);
361 padding: 8px 24px;
362 font-size: 13px;
363 font-weight: 500;
364 text-align: center;
365 line-height: 1.4;
366 }
367
352368 header {
353369 border-bottom: 1px solid var(--border);
354370 padding: 12px 24px;
Modifiedsrc/views/reactions.tsx+2−2View fileUnifiedSplit
@@ -29,7 +29,7 @@ export const ReactionsBar: FC<{
2929 return (
3030 <div class="reactions" data-target={`${targetType}:${targetId}`}>
3131 {visible.map((s) => (
32 <form method="POST" action={action(s.emoji)} style="display: inline">
32 <form method="post" action={action(s.emoji)} style="display: inline">
3333 <button
3434 type="submit"
3535 class={`reaction-btn ${s.reactedByMe ? "active" : ""}`}
@@ -49,7 +49,7 @@ export const ReactionsBar: FC<{
4949 <div style="display: flex; gap: 4px; padding: 4px">
5050 {ALLOWED_EMOJIS.filter((e) => !byEmoji.get(e)?.reactedByMe).map(
5151 (emoji) => (
52 <form method="POST" action={action(emoji)} style="display: inline">
52 <form method="post" action={action(emoji)} style="display: inline">
5353 <button
5454 type="submit"
5555 class="reaction-btn"
5656