Commit6561c0cunknown_key
Merge pull request #6 from ccantynz-alt/claude/continue-work-XMTlI
Merge pull request #6 from ccantynz-alt/claude/continue-work-XMTlI Claude/continue work xm tl i
23 files changed+4830−366561c0cd91063c1809ed024d2e6a320c4f43b6c0
23 changed files+4830−36
ModifiedBUILD_BIBLE.md+8−2View fileUnifiedSplit
@@ -133,7 +133,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
133133| OAuth app provider | ❌ | |
134134| GitHub Apps equivalent | ❌ | |
135135| GraphQL API | ❌ | REST only |
136| Organizations + teams | ❌ | user-owned only |
136| Organizations + teams | 🟡 | schema + routes shipped (B1): `/orgs`, `/orgs/new`, `/orgs/:slug/people`, `/orgs/:slug/teams`, last-owner guard, audit logged. Org-owned repos = B2 next |
137137| Enterprise SAML / SSO | ❌ | |
138138| 2FA / TOTP | ❌ | |
139139| Passkeys / WebAuthn | ❌ | |
@@ -184,7 +184,11 @@ Polish what's shipped before adding more. **Priority: do this first if parity ga
184184**BLOCK A COMPLETE.** Next: BLOCK B (Identity + orgs).
185185
186186### BLOCK B — Identity + orgs
187- **B1** — Organizations (schema: `organizations`, `org_members`, `teams`, `team_members`)
187- **B1** — Organizations (schema: `organizations`, `org_members`, `teams`, `team_members`) ✅
188 - Helpers in `src/lib/orgs.ts`: slug validation, role rank, reserved-slug set, loaders
189 - Routes in `src/routes/orgs.tsx`: list / create / profile / people / teams / team detail
190 - Role-based guards: admin adds members, owner grants owner, last-owner demote/remove blocked
191 - All sensitive actions `audit()`'d (org.create, member.add/role/remove, team.create, team.member.add/remove)
188192- **B2** — Repos owned by orgs (nullable `repositories.orgId`)
189193- **B3** — Team-based CODEOWNERS (`@org/team` resolution)
190194- **B4** — 2FA / TOTP (enroll, recovery codes)
@@ -314,6 +318,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
314318- `src/routes/insights.tsx` — insights + milestones
315319- `src/routes/search.tsx` — global search + `/shortcuts`
316320- `src/routes/health.ts` — `/healthz` `/readyz` `/metrics`
321- `src/routes/orgs.tsx` — `/orgs` list, `/orgs/new` create, `/orgs/:slug` profile, `/orgs/:slug/people` + add/role/remove, `/orgs/:slug/teams` + create, `/orgs/:slug/teams/:teamSlug` + member add/remove. All require auth. Role guards via `orgRoleAtLeast`; last-owner cannot be demoted or removed; every write path `audit()`'d.
322- `src/lib/orgs.ts` — `isValidSlug` (rejects reserved + too-short/long + consecutive/leading/trailing hyphens), `normalizeSlug`, `orgRoleAtLeast` (owner>admin>member), `isValidOrgRole`, `isValidTeamRole`, `loadOrgForUser`, `listOrgsForUser`, `listOrgMembers`, `listTeamsForOrg`, `listTeamMembers`, `__test` export for unit tests.
317323
318324### 4.7 Views (locked contracts)
319325- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Modifiedbun.lock+45−0View fileUnifiedSplit
@@ -8,6 +8,7 @@
88 "@anthropic-ai/sdk": "^0.88.0",
99 "@hono/node-server": "^1.13.0",
1010 "@neondatabase/serverless": "^0.10.0",
11 "@simplewebauthn/server": "^13.3.0",
1112 "drizzle-orm": "^0.39.0",
1213 "highlight.js": "^11.11.0",
1314 "hono": "^4.7.0",
@@ -77,18 +78,50 @@
7778
7879 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
7980
81 "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
82
8083 "@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
8184
85 "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
86
8287 "@neondatabase/serverless": ["@neondatabase/serverless@0.10.4", "", { "dependencies": { "@types/pg": "8.11.6" } }, "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA=="],
8388
89 "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
90
91 "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw=="],
92
93 "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w=="],
94
95 "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g=="],
96
97 "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw=="],
98
99 "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw=="],
100
101 "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pfx": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw=="],
102
103 "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA=="],
104
105 "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
106
107 "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA=="],
108
109 "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ=="],
110
111 "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="],
112
84113 "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
85114
115 "@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="],
116
86117 "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
87118
88119 "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
89120
90121 "@types/pg": ["@types/pg@8.11.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ=="],
91122
123 "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
124
92125 "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
93126
94127 "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
@@ -141,6 +174,12 @@
141174
142175 "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="],
143176
177 "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
178
179 "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
180
181 "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
182
144183 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
145184
146185 "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -153,6 +192,10 @@
153192
154193 "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
155194
195 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
196
197 "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
198
156199 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
157200
158201 "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
@@ -161,6 +204,8 @@
161204
162205 "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
163206
207 "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
208
164209 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
165210
166211 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
Addeddrizzle/0004_org_owned_repos.sql+36−0View fileUnifiedSplit
@@ -0,0 +1,36 @@
1-- Gluecron migration 0004: Block B2 — repositories can be owned by an org.
2-- Adds repositories.org_id (nullable) + partial unique indexes so a user and
3-- an org can each have a repo named "web" without collision, but two orgs
4-- (or two users) still cannot.
5
6--> statement-breakpoint
7ALTER TABLE "repositories" ADD COLUMN IF NOT EXISTS "org_id" uuid;
8
9--> statement-breakpoint
10ALTER TABLE "repositories"
11 DROP CONSTRAINT IF EXISTS "repositories_org_id_fk";
12
13--> statement-breakpoint
14ALTER TABLE "repositories"
15 ADD CONSTRAINT "repositories_org_id_fk"
16 FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE;
17
18--> statement-breakpoint
19-- Existing unique index was on (owner_id, name) across all rows. That would
20-- block the same user from creating a personal "web" AND an org-owned "web"
21-- where they happen to be the listed creator. Make it partial so it only
22-- enforces uniqueness within the user namespace.
23DROP INDEX IF EXISTS "repos_owner_name";
24
25--> statement-breakpoint
26CREATE UNIQUE INDEX IF NOT EXISTS "repos_owner_name"
27 ON "repositories" ("owner_id", "name")
28 WHERE "org_id" IS NULL;
29
30--> statement-breakpoint
31CREATE UNIQUE INDEX IF NOT EXISTS "repos_org_name"
32 ON "repositories" ("org_id", "name")
33 WHERE "org_id" IS NOT NULL;
34
35--> statement-breakpoint
36CREATE INDEX IF NOT EXISTS "repos_org" ON "repositories" ("org_id");
Addeddrizzle/0005_totp_2fa.sql+30−0View fileUnifiedSplit
@@ -0,0 +1,30 @@
1-- Gluecron migration 0005: Block B4 — TOTP 2FA + recovery codes.
2
3--> statement-breakpoint
4ALTER TABLE "sessions" ADD COLUMN IF NOT EXISTS "requires_2fa" boolean DEFAULT false NOT NULL;
5
6--> statement-breakpoint
7CREATE TABLE IF NOT EXISTS "user_totp" (
8 "user_id" uuid PRIMARY KEY NOT NULL,
9 "secret" text NOT NULL,
10 "enabled_at" timestamp,
11 "last_used_at" timestamp,
12 "created_at" timestamp DEFAULT now() NOT NULL,
13 CONSTRAINT "user_totp_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
14);
15
16--> statement-breakpoint
17CREATE TABLE IF NOT EXISTS "user_recovery_codes" (
18 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
19 "user_id" uuid NOT NULL,
20 "code_hash" text NOT NULL,
21 "used_at" timestamp,
22 "created_at" timestamp DEFAULT now() NOT NULL,
23 CONSTRAINT "user_recovery_codes_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
24);
25
26--> statement-breakpoint
27CREATE INDEX IF NOT EXISTS "recovery_codes_user" ON "user_recovery_codes" ("user_id");
28
29--> statement-breakpoint
30CREATE UNIQUE INDEX IF NOT EXISTS "recovery_codes_user_hash" ON "user_recovery_codes" ("user_id", "code_hash");
Addeddrizzle/0006_webauthn_passkeys.sql+33−0View fileUnifiedSplit
@@ -0,0 +1,33 @@
1-- Gluecron migration 0006: Block B5 — WebAuthn passkeys.
2
3--> statement-breakpoint
4CREATE TABLE IF NOT EXISTS "user_passkeys" (
5 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
6 "user_id" uuid NOT NULL,
7 "credential_id" text NOT NULL UNIQUE,
8 "public_key" text NOT NULL,
9 "counter" integer DEFAULT 0 NOT NULL,
10 "transports" text,
11 "name" text NOT NULL DEFAULT 'Passkey',
12 "last_used_at" timestamp,
13 "created_at" timestamp DEFAULT now() NOT NULL,
14 CONSTRAINT "user_passkeys_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
15);
16
17--> statement-breakpoint
18CREATE INDEX IF NOT EXISTS "passkeys_user" ON "user_passkeys" ("user_id");
19
20--> statement-breakpoint
21CREATE TABLE IF NOT EXISTS "webauthn_challenges" (
22 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
23 "user_id" uuid,
24 "session_key" text NOT NULL UNIQUE,
25 "challenge" text NOT NULL,
26 "kind" text NOT NULL,
27 "expires_at" timestamp NOT NULL,
28 "created_at" timestamp DEFAULT now() NOT NULL,
29 CONSTRAINT "webauthn_challenges_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
30);
31
32--> statement-breakpoint
33CREATE INDEX IF NOT EXISTS "webauthn_challenges_expires" ON "webauthn_challenges" ("expires_at");
Addeddrizzle/0007_oauth_provider.sql+78−0View fileUnifiedSplit
@@ -0,0 +1,78 @@
1-- Gluecron migration 0007: Block B6 — OAuth 2.0 provider.
2--
3-- Tables:
4-- oauth_apps — third-party apps registered by developers
5-- oauth_authorizations — short-lived authorization codes (single-use)
6-- oauth_access_tokens — long-lived bearer tokens + refresh tokens
7--
8-- All secrets / code / token values are stored as SHA-256 hex hashes.
9-- Only the plaintext `clientSecret` is shown to the developer once at
10-- creation; the plaintext auth code and tokens are returned to the client
11-- in the OAuth response and never persisted.
12
13--> statement-breakpoint
14CREATE TABLE IF NOT EXISTS "oauth_apps" (
15 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
16 "owner_id" uuid NOT NULL,
17 "name" text NOT NULL,
18 "client_id" text NOT NULL UNIQUE,
19 "client_secret_hash" text NOT NULL,
20 "client_secret_prefix" text NOT NULL,
21 "redirect_uris" text NOT NULL,
22 "homepage_url" text,
23 "description" text,
24 "confidential" boolean DEFAULT true NOT NULL,
25 "revoked_at" timestamp,
26 "created_at" timestamp DEFAULT now() NOT NULL,
27 "updated_at" timestamp DEFAULT now() NOT NULL,
28 CONSTRAINT "oauth_apps_owner_fk" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE cascade
29);
30
31--> statement-breakpoint
32CREATE INDEX IF NOT EXISTS "oauth_apps_owner" ON "oauth_apps" ("owner_id");
33
34--> statement-breakpoint
35CREATE TABLE IF NOT EXISTS "oauth_authorizations" (
36 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
37 "app_id" uuid NOT NULL,
38 "user_id" uuid NOT NULL,
39 "code_hash" text NOT NULL UNIQUE,
40 "redirect_uri" text NOT NULL,
41 "scopes" text NOT NULL DEFAULT '',
42 "code_challenge" text,
43 "code_challenge_method" text,
44 "expires_at" timestamp NOT NULL,
45 "used_at" timestamp,
46 "created_at" timestamp DEFAULT now() NOT NULL,
47 CONSTRAINT "oauth_authorizations_app_fk" FOREIGN KEY ("app_id") REFERENCES "oauth_apps"("id") ON DELETE cascade,
48 CONSTRAINT "oauth_authorizations_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
49);
50
51--> statement-breakpoint
52CREATE INDEX IF NOT EXISTS "oauth_authorizations_expires" ON "oauth_authorizations" ("expires_at");
53
54--> statement-breakpoint
55CREATE TABLE IF NOT EXISTS "oauth_access_tokens" (
56 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
57 "app_id" uuid NOT NULL,
58 "user_id" uuid NOT NULL,
59 "access_token_hash" text NOT NULL UNIQUE,
60 "refresh_token_hash" text UNIQUE,
61 "scopes" text NOT NULL DEFAULT '',
62 "expires_at" timestamp NOT NULL,
63 "refresh_expires_at" timestamp,
64 "revoked_at" timestamp,
65 "last_used_at" timestamp,
66 "created_at" timestamp DEFAULT now() NOT NULL,
67 CONSTRAINT "oauth_access_tokens_app_fk" FOREIGN KEY ("app_id") REFERENCES "oauth_apps"("id") ON DELETE cascade,
68 CONSTRAINT "oauth_access_tokens_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
69);
70
71--> statement-breakpoint
72CREATE INDEX IF NOT EXISTS "oauth_access_tokens_user" ON "oauth_access_tokens" ("user_id");
73
74--> statement-breakpoint
75CREATE INDEX IF NOT EXISTS "oauth_access_tokens_app" ON "oauth_access_tokens" ("app_id");
76
77--> statement-breakpoint
78CREATE INDEX IF NOT EXISTS "oauth_access_tokens_expires" ON "oauth_access_tokens" ("expires_at");
Modifiedpackage.json+1−0View fileUnifiedSplit
@@ -15,6 +15,7 @@
1515 "@anthropic-ai/sdk": "^0.88.0",
1616 "@hono/node-server": "^1.13.0",
1717 "@neondatabase/serverless": "^0.10.0",
18 "@simplewebauthn/server": "^13.3.0",
1819 "drizzle-orm": "^0.39.0",
1920 "highlight.js": "^11.11.0",
2021 "hono": "^4.7.0",
Modifiedsrc/__tests__/green-ecosystem.test.ts+392−0View fileUnifiedSplit
@@ -12,6 +12,8 @@ import { scanForSecrets, SECRET_PATTERNS } from "../lib/security-scan";
1212import {
1313 parseCodeowners,
1414 ownersForPath,
15 isTeamToken,
16 expandOwnerTokens,
1517} from "../lib/codeowners";
1618import { generateCommitMessage } from "../lib/ai-generators";
1719import { isAiAvailable } from "../lib/ai-client";
@@ -23,6 +25,24 @@ import {
2325} from "../lib/reactions";
2426import { sendEmail, absoluteUrl } from "../lib/email";
2527import { __internal as notifyInternal } from "../lib/notify";
28import {
29 isValidSlug,
30 normalizeSlug,
31 orgRoleAtLeast,
32 isValidOrgRole,
33 isValidTeamRole,
34 __test as orgsInternal,
35} from "../lib/orgs";
36import {
37 base32Encode,
38 base32Decode,
39 generateTotpSecret,
40 totpCode,
41 verifyTotpCode,
42 otpauthUrl,
43 generateRecoveryCodes,
44 hashRecoveryCode,
45} from "../lib/totp";
2646
2747describe("secret scanner", () => {
2848 it("detects AWS access keys", () => {
@@ -103,6 +123,30 @@ describe("codeowners parser", () => {
103123 expect(rules.length).toBe(1);
104124 expect(rules[0].owners).toEqual(["ghost"]);
105125 });
126
127 it("preserves @org/team tokens (B3)", () => {
128 const rules = parseCodeowners(
129 "api/** @acme/backend @alice\nweb/** @acme/frontend\n"
130 );
131 expect(rules[0].owners).toEqual(["acme/backend", "alice"]);
132 expect(rules[1].owners).toEqual(["acme/frontend"]);
133 expect(isTeamToken("acme/backend")).toBe(true);
134 expect(isTeamToken("alice")).toBe(false);
135 });
136
137 it("expandOwnerTokens passes plain usernames through and drops unknown teams gracefully", async () => {
138 // Real team lookup requires DB rows. Without DB the helper must still
139 // resolve without throwing; plain usernames must always pass through.
140 const result = await expandOwnerTokens([
141 "alice",
142 "bob",
143 "nonexistent-org-xyz/some-team",
144 ]);
145 expect(result).toContain("alice");
146 expect(result).toContain("bob");
147 // Unknown team silently drops (no throw, no crash).
148 expect(result).not.toContain("nonexistent-org-xyz/some-team");
149 });
106150});
107151
108152describe("AI generator fallbacks", () => {
@@ -402,3 +446,351 @@ describe("settings email preferences", () => {
402446 expect(loc.startsWith("/login")).toBe(true);
403447 });
404448});
449
450describe("orgs helpers (B1)", () => {
451 describe("isValidSlug", () => {
452 it("accepts simple slugs", () => {
453 expect(isValidSlug("acme")).toBe(true);
454 expect(isValidSlug("acme-corp")).toBe(true);
455 expect(isValidSlug("a1")).toBe(true);
456 expect(isValidSlug("a-b-c-1-2-3")).toBe(true);
457 });
458
459 it("rejects too-short or too-long", () => {
460 expect(isValidSlug("")).toBe(false);
461 expect(isValidSlug("a")).toBe(false);
462 expect(isValidSlug("a".repeat(40))).toBe(false);
463 });
464
465 it("rejects leading/trailing hyphen", () => {
466 expect(isValidSlug("-acme")).toBe(false);
467 expect(isValidSlug("acme-")).toBe(false);
468 });
469
470 it("rejects consecutive hyphens", () => {
471 expect(isValidSlug("foo--bar")).toBe(false);
472 });
473
474 it("rejects uppercase + invalid chars", () => {
475 expect(isValidSlug("Acme")).toBe(false);
476 expect(isValidSlug("acme_corp")).toBe(false);
477 expect(isValidSlug("acme.corp")).toBe(false);
478 expect(isValidSlug("acme corp")).toBe(false);
479 });
480
481 it("rejects reserved words", () => {
482 expect(isValidSlug("api")).toBe(false);
483 expect(isValidSlug("admin")).toBe(false);
484 expect(isValidSlug("settings")).toBe(false);
485 expect(isValidSlug("orgs")).toBe(false);
486 expect(isValidSlug("new")).toBe(false);
487 });
488 });
489
490 describe("normalizeSlug", () => {
491 it("lowercases and trims", () => {
492 expect(normalizeSlug(" ACME ")).toBe("acme");
493 expect(normalizeSlug("Acme-Corp")).toBe("acme-corp");
494 });
495 });
496
497 describe("orgRoleAtLeast", () => {
498 it("owner beats admin beats member", () => {
499 expect(orgRoleAtLeast("owner", "member")).toBe(true);
500 expect(orgRoleAtLeast("owner", "admin")).toBe(true);
501 expect(orgRoleAtLeast("owner", "owner")).toBe(true);
502 expect(orgRoleAtLeast("admin", "member")).toBe(true);
503 expect(orgRoleAtLeast("admin", "admin")).toBe(true);
504 expect(orgRoleAtLeast("admin", "owner")).toBe(false);
505 expect(orgRoleAtLeast("member", "admin")).toBe(false);
506 expect(orgRoleAtLeast("member", "owner")).toBe(false);
507 });
508
509 it("treats unknown role as rank 0", () => {
510 expect(orgRoleAtLeast("", "member")).toBe(false);
511 expect(orgRoleAtLeast("banana", "member")).toBe(false);
512 });
513 });
514
515 describe("role type guards", () => {
516 it("isValidOrgRole", () => {
517 expect(isValidOrgRole("owner")).toBe(true);
518 expect(isValidOrgRole("admin")).toBe(true);
519 expect(isValidOrgRole("member")).toBe(true);
520 expect(isValidOrgRole("maintainer")).toBe(false);
521 expect(isValidOrgRole("banana")).toBe(false);
522 });
523
524 it("isValidTeamRole", () => {
525 expect(isValidTeamRole("maintainer")).toBe(true);
526 expect(isValidTeamRole("member")).toBe(true);
527 expect(isValidTeamRole("owner")).toBe(false);
528 expect(isValidTeamRole("banana")).toBe(false);
529 });
530 });
531
532 describe("internal", () => {
533 it("rank table orders correctly", () => {
534 const r = orgsInternal.ORG_ROLE_RANK;
535 expect(r.owner).toBeGreaterThan(r.admin);
536 expect(r.admin).toBeGreaterThan(r.member);
537 });
538
539 it("reserved set contains the app's top-level paths", () => {
540 expect(orgsInternal.RESERVED_SLUGS.has("api")).toBe(true);
541 expect(orgsInternal.RESERVED_SLUGS.has("settings")).toBe(true);
542 expect(orgsInternal.RESERVED_SLUGS.has("login")).toBe(true);
543 });
544 });
545});
546
547describe("orgs routes (B1)", () => {
548 it("GET /orgs redirects unauthenticated users to /login", async () => {
549 const res = await app.request("/orgs");
550 expect([301, 302, 303, 307]).toContain(res.status);
551 const loc = res.headers.get("location") || "";
552 expect(loc.startsWith("/login")).toBe(true);
553 });
554
555 it("GET /orgs/new redirects unauthenticated users to /login", async () => {
556 const res = await app.request("/orgs/new");
557 expect([301, 302, 303, 307]).toContain(res.status);
558 const loc = res.headers.get("location") || "";
559 expect(loc.startsWith("/login")).toBe(true);
560 });
561
562 it("POST /orgs/new redirects unauthenticated users to /login", async () => {
563 const res = await app.request("/orgs/new", {
564 method: "POST",
565 headers: { "content-type": "application/x-www-form-urlencoded" },
566 body: "slug=acme&name=Acme",
567 });
568 expect([301, 302, 303, 307]).toContain(res.status);
569 const loc = res.headers.get("location") || "";
570 expect(loc.startsWith("/login")).toBe(true);
571 });
572
573 it("GET /orgs/:slug redirects unauthenticated users to /login", async () => {
574 const res = await app.request("/orgs/some-org");
575 expect([301, 302, 303, 307]).toContain(res.status);
576 const loc = res.headers.get("location") || "";
577 expect(loc.startsWith("/login")).toBe(true);
578 });
579
580 it("POST /orgs/:slug/people/add redirects unauthenticated users to /login", async () => {
581 const res = await app.request("/orgs/some-org/people/add", {
582 method: "POST",
583 headers: { "content-type": "application/x-www-form-urlencoded" },
584 body: "username=alice&role=member",
585 });
586 expect([301, 302, 303, 307]).toContain(res.status);
587 const loc = res.headers.get("location") || "";
588 expect(loc.startsWith("/login")).toBe(true);
589 });
590});
591
592describe("org-owned repos (B2)", () => {
593 it("GET /orgs/:slug/repos redirects unauthenticated users to /login", async () => {
594 const res = await app.request("/orgs/some-org/repos");
595 expect([301, 302, 303, 307]).toContain(res.status);
596 const loc = res.headers.get("location") || "";
597 expect(loc.startsWith("/login")).toBe(true);
598 });
599
600 it("GET /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
601 const res = await app.request("/orgs/some-org/repos/new");
602 expect([301, 302, 303, 307]).toContain(res.status);
603 const loc = res.headers.get("location") || "";
604 expect(loc.startsWith("/login")).toBe(true);
605 });
606
607 it("POST /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
608 const res = await app.request("/orgs/some-org/repos/new", {
609 method: "POST",
610 headers: { "content-type": "application/x-www-form-urlencoded" },
611 body: "name=web",
612 });
613 expect([301, 302, 303, 307]).toContain(res.status);
614 const loc = res.headers.get("location") || "";
615 expect(loc.startsWith("/login")).toBe(true);
616 });
617
618 it("POST /api/repos with orgSlug still validates required fields", async () => {
619 const res = await app.request("/api/repos", {
620 method: "POST",
621 headers: { "Content-Type": "application/json" },
622 body: JSON.stringify({ orgSlug: "acme" }),
623 });
624 // Missing name + owner → 400 before any DB access.
625 expect(res.status).toBe(400);
626 });
627
628 it("POST /api/repos rejects invalid repo names before DB access", async () => {
629 const res = await app.request("/api/repos", {
630 method: "POST",
631 headers: { "Content-Type": "application/json" },
632 body: JSON.stringify({
633 name: "bad name with spaces",
634 owner: "alice",
635 }),
636 });
637 expect(res.status).toBe(400);
638 });
639});
640
641describe("TOTP / 2FA (B4)", () => {
642 it("base32 round-trips bytes", () => {
643 const bytes = new Uint8Array([0x74, 0x65, 0x73, 0x74]); // "test"
644 const enc = base32Encode(bytes);
645 const dec = base32Decode(enc);
646 expect(Array.from(dec)).toEqual(Array.from(bytes));
647 });
648
649 it("generateTotpSecret returns 32-char Base32", () => {
650 const s = generateTotpSecret();
651 expect(s.length).toBe(32);
652 expect(/^[A-Z2-7]+$/.test(s)).toBe(true);
653 });
654
655 it("totpCode is 6 digits", async () => {
656 const s = generateTotpSecret();
657 const c = await totpCode(s);
658 expect(/^\d{6}$/.test(c)).toBe(true);
659 });
660
661 it("verifyTotpCode accepts a freshly-generated code", async () => {
662 const s = generateTotpSecret();
663 const c = await totpCode(s);
664 expect(await verifyTotpCode(s, c)).toBe(true);
665 });
666
667 it("verifyTotpCode tolerates a ±30s drift", async () => {
668 const s = generateTotpSecret();
669 const now = Math.floor(Date.now() / 1000);
670 const past = await totpCode(s, now - 30);
671 const future = await totpCode(s, now + 30);
672 expect(await verifyTotpCode(s, past, now)).toBe(true);
673 expect(await verifyTotpCode(s, future, now)).toBe(true);
674 });
675
676 it("verifyTotpCode rejects a wrong code", async () => {
677 const s = generateTotpSecret();
678 expect(await verifyTotpCode(s, "000000")).toBe(false);
679 });
680
681 it("verifyTotpCode rejects non-6-digit input", async () => {
682 const s = generateTotpSecret();
683 expect(await verifyTotpCode(s, "abc")).toBe(false);
684 expect(await verifyTotpCode(s, "12345")).toBe(false);
685 expect(await verifyTotpCode(s, "1234567")).toBe(false);
686 });
687
688 it("otpauthUrl has the expected shape", () => {
689 const u = otpauthUrl({
690 secret: "JBSWY3DPEHPK3PXP",
691 accountName: "alice@example.com",
692 issuer: "gluecron",
693 });
694 expect(u.startsWith("otpauth://totp/")).toBe(true);
695 expect(u).toContain("secret=JBSWY3DPEHPK3PXP");
696 expect(u).toContain("issuer=gluecron");
697 expect(u).toContain("period=30");
698 expect(u).toContain("digits=6");
699 });
700
701 it("generateRecoveryCodes returns the expected count + format", () => {
702 const codes = generateRecoveryCodes(5);
703 expect(codes.length).toBe(5);
704 for (const c of codes) {
705 expect(/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/.test(c)).toBe(true);
706 }
707 // Uniqueness: ~70 bits of entropy each, collisions should be astronomical.
708 expect(new Set(codes).size).toBe(5);
709 });
710
711 it("hashRecoveryCode is deterministic + normalised", async () => {
712 const a = await hashRecoveryCode("ABCD-1234-efgh");
713 const b = await hashRecoveryCode("abcd-1234-efgh");
714 const c = await hashRecoveryCode(" abcd-1234-efgh ");
715 expect(a).toBe(b);
716 expect(a).toBe(c);
717 expect(a.length).toBe(64); // SHA-256 hex
718 });
719});
720
721describe("2FA routes (B4)", () => {
722 it("GET /settings/2fa redirects unauthenticated users to /login", async () => {
723 const res = await app.request("/settings/2fa");
724 expect([301, 302, 303, 307]).toContain(res.status);
725 const loc = res.headers.get("location") || "";
726 expect(loc.startsWith("/login")).toBe(true);
727 });
728
729 it("POST /settings/2fa/enroll redirects unauthenticated users to /login", async () => {
730 const res = await app.request("/settings/2fa/enroll", {
731 method: "POST",
732 headers: { "content-type": "application/x-www-form-urlencoded" },
733 body: "",
734 });
735 expect([301, 302, 303, 307]).toContain(res.status);
736 const loc = res.headers.get("location") || "";
737 expect(loc.startsWith("/login")).toBe(true);
738 });
739
740 it("GET /login/2fa redirects to /login when no session cookie", async () => {
741 const res = await app.request("/login/2fa");
742 expect([301, 302, 303, 307]).toContain(res.status);
743 const loc = res.headers.get("location") || "";
744 expect(loc.startsWith("/login")).toBe(true);
745 });
746});
747
748describe("passkeys routes (B5)", () => {
749 it("GET /settings/passkeys redirects unauthenticated users to /login", async () => {
750 const res = await app.request("/settings/passkeys");
751 expect([301, 302, 303, 307]).toContain(res.status);
752 const loc = res.headers.get("location") || "";
753 expect(loc.startsWith("/login")).toBe(true);
754 });
755
756 it("POST /api/passkeys/register/options redirects unauthenticated users to /login", async () => {
757 const res = await app.request("/api/passkeys/register/options", {
758 method: "POST",
759 headers: { "content-type": "application/json" },
760 body: "{}",
761 });
762 expect([301, 302, 303, 307]).toContain(res.status);
763 const loc = res.headers.get("location") || "";
764 expect(loc.startsWith("/login")).toBe(true);
765 });
766
767 it("POST /api/passkeys/auth/verify returns 400 when body is invalid", async () => {
768 const res = await app.request("/api/passkeys/auth/verify", {
769 method: "POST",
770 headers: { "content-type": "application/json" },
771 body: "not json",
772 });
773 // Either 400 (bad JSON) or 503 (DB down) — never a 500.
774 expect([400, 503]).toContain(res.status);
775 });
776
777 it("POST /api/passkeys/auth/verify rejects missing fields", async () => {
778 const res = await app.request("/api/passkeys/auth/verify", {
779 method: "POST",
780 headers: { "content-type": "application/json" },
781 body: "{}",
782 });
783 expect([400, 503]).toContain(res.status);
784 });
785
786 it("POST /settings/passkeys/:id/delete redirects unauthenticated users to /login", async () => {
787 const res = await app.request("/settings/passkeys/abc/delete", {
788 method: "POST",
789 headers: { "content-type": "application/x-www-form-urlencoded" },
790 body: "",
791 });
792 expect([301, 302, 303, 307]).toContain(res.status);
793 const loc = res.headers.get("location") || "";
794 expect(loc.startsWith("/login")).toBe(true);
795 });
796});
Modifiedsrc/app.tsx+12−0View fileUnifiedSplit
@@ -9,6 +9,7 @@ import gitRoutes from "./routes/git";
99import apiRoutes from "./routes/api";
1010import authRoutes from "./routes/auth";
1111import settingsRoutes from "./routes/settings";
12import settings2faRoutes from "./routes/settings-2fa";
1213import issueRoutes from "./routes/issues";
1314import repoSettings from "./routes/repo-settings";
1415import compareRoutes from "./routes/compare";
@@ -33,6 +34,8 @@ import auditRoutes from "./routes/audit";
3334import reactionRoutes from "./routes/reactions";
3435import savedReplyRoutes from "./routes/saved-replies";
3536import deploymentRoutes from "./routes/deployments";
37import orgRoutes from "./routes/orgs";
38import passkeyRoutes from "./routes/passkeys";
3639import webRoutes from "./routes/web";
3740
3841const app = new Hono();
@@ -70,6 +73,12 @@ app.route("/", authRoutes);
7073// Settings routes (profile, SSH keys)
7174app.route("/", settingsRoutes);
7275
76// 2FA / TOTP settings (Block B4)
77app.route("/", settings2faRoutes);
78
79// WebAuthn / passkey routes (Block B5)
80app.route("/", passkeyRoutes);
81
7382// Theme toggle (dark/light cookie)
7483app.route("/", themeRoutes);
7584
@@ -85,6 +94,9 @@ app.route("/", savedReplyRoutes);
8594// Environments + deployment history UI
8695app.route("/", deploymentRoutes);
8796
97// Organizations + teams (Block B1)
98app.route("/", orgRoutes);
99
88100// API tokens
89101app.route("/", tokenRoutes);
90102
Modifiedsrc/db/schema.ts+211−1View fileUnifiedSplit
@@ -33,6 +33,10 @@ export const sessions = pgTable("sessions", {
3333 .references(() => users.id, { onDelete: "cascade" }),
3434 token: text("token").notNull().unique(),
3535 expiresAt: timestamp("expires_at").notNull(),
36 // B4: true when the user has entered their password but not yet their TOTP
37 // code. softAuth/requireAuth treat such sessions as anonymous; only
38 // /login/2fa can consume them. Flips to false on successful 2FA.
39 requires2fa: boolean("requires_2fa").default(false).notNull(),
3640 createdAt: timestamp("created_at").defaultNow().notNull(),
3741});
3842
@@ -41,9 +45,14 @@ export const repositories = pgTable(
4145 {
4246 id: uuid("id").primaryKey().defaultRandom(),
4347 name: text("name").notNull(),
48 // ownerId = creator / user-owner. Always set (for attribution + user
49 // namespace uniqueness). For org-owned repos, also represents "created by".
4450 ownerId: uuid("owner_id")
4551 .notNull()
4652 .references(() => users.id),
53 // Block B2: nullable org owner. When set, the repo lives in the org
54 // namespace and URL resolution routes `/:orgSlug/:repo` to it.
55 orgId: uuid("org_id"),
4756 description: text("description"),
4857 isPrivate: boolean("is_private").default(false).notNull(),
4958 isArchived: boolean("is_archived").default(false).notNull(),
@@ -59,7 +68,12 @@ export const repositories = pgTable(
5968 forkCount: integer("fork_count").default(0).notNull(),
6069 issueCount: integer("issue_count").default(0).notNull(),
6170 },
62 (table) => [uniqueIndex("repos_owner_name").on(table.ownerId, table.name)]
71 (table) => [
72 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
73 // Matches the partial index in migration 0004.
74 uniqueIndex("repos_owner_name").on(table.ownerId, table.name),
75 index("repos_org").on(table.orgId),
76 ]
6377);
6478
6579/**
@@ -763,3 +777,199 @@ export type Team = typeof teams.$inferSelect;
763777export type TeamMember = typeof teamMembers.$inferSelect;
764778export type OrgRole = "owner" | "admin" | "member";
765779export type TeamRole = "maintainer" | "member";
780
781/**
782 * 2FA / TOTP (Block B4).
783 *
784 * Secret is stored in plain Base32 for now — the DB has row-level-secure
785 * access and the app boundary is the only code that reads it. A follow-up
786 * (B4.1) will wrap it with AES-GCM at rest once we standardise the KEK.
787 *
788 * `enabledAt` is set only after the user has successfully entered their
789 * first code (confirming the authenticator was set up correctly). Rows with
790 * `enabledAt = NULL` represent pending enrolment.
791 */
792export const userTotp = pgTable("user_totp", {
793 userId: uuid("user_id")
794 .primaryKey()
795 .references(() => users.id, { onDelete: "cascade" }),
796 secret: text("secret").notNull(),
797 enabledAt: timestamp("enabled_at"),
798 lastUsedAt: timestamp("last_used_at"),
799 createdAt: timestamp("created_at").defaultNow().notNull(),
800});
801
802/**
803 * Recovery codes — single-use fallback when the authenticator is lost.
804 * Stored as SHA-256 hashes; used rows are marked with `usedAt` rather than
805 * deleted so the audit log keeps the full history.
806 */
807export const userRecoveryCodes = pgTable(
808 "user_recovery_codes",
809 {
810 id: uuid("id").primaryKey().defaultRandom(),
811 userId: uuid("user_id")
812 .notNull()
813 .references(() => users.id, { onDelete: "cascade" }),
814 codeHash: text("code_hash").notNull(),
815 usedAt: timestamp("used_at"),
816 createdAt: timestamp("created_at").defaultNow().notNull(),
817 },
818 (table) => [
819 index("recovery_codes_user").on(table.userId),
820 uniqueIndex("recovery_codes_user_hash").on(table.userId, table.codeHash),
821 ]
822);
823
824export type UserTotp = typeof userTotp.$inferSelect;
825export type UserRecoveryCode = typeof userRecoveryCodes.$inferSelect;
826
827/**
828 * WebAuthn passkeys (Block B5).
829 *
830 * Each row is one registered authenticator. The `credentialId` is the
831 * globally-unique identifier the browser returns; `publicKey` is the
832 * COSE-encoded public key we use to verify signatures. `counter` tracks
833 * the authenticator's signature counter for replay-protection.
834 *
835 * `transports` is a JSON array (stored as text) of the
836 * AuthenticatorTransport values ("usb" | "nfc" | "ble" | "internal" | "hybrid").
837 */
838export const userPasskeys = pgTable(
839 "user_passkeys",
840 {
841 id: uuid("id").primaryKey().defaultRandom(),
842 userId: uuid("user_id")
843 .notNull()
844 .references(() => users.id, { onDelete: "cascade" }),
845 credentialId: text("credential_id").notNull().unique(),
846 publicKey: text("public_key").notNull(), // base64url of COSE key
847 counter: integer("counter").default(0).notNull(),
848 transports: text("transports"), // JSON array string
849 name: text("name").notNull().default("Passkey"),
850 lastUsedAt: timestamp("last_used_at"),
851 createdAt: timestamp("created_at").defaultNow().notNull(),
852 },
853 (table) => [index("passkeys_user").on(table.userId)]
854);
855
856/**
857 * Short-lived WebAuthn challenges. A row is written when we issue options
858 * (registration or authentication) and deleted after the verify step or when
859 * it expires (5 min). Keeping them in the DB lets us verify without sticky
860 * sessions or client-side state.
861 */
862export const webauthnChallenges = pgTable(
863 "webauthn_challenges",
864 {
865 id: uuid("id").primaryKey().defaultRandom(),
866 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
867 // For passwordless login we don't know the user yet, so userId is nullable
868 // and we bind the challenge to a short-lived cookie token instead.
869 sessionKey: text("session_key").notNull().unique(),
870 challenge: text("challenge").notNull(),
871 kind: text("kind").notNull(), // "register" | "authenticate"
872 expiresAt: timestamp("expires_at").notNull(),
873 createdAt: timestamp("created_at").defaultNow().notNull(),
874 },
875 (table) => [index("webauthn_challenges_expires").on(table.expiresAt)]
876);
877
878export type UserPasskey = typeof userPasskeys.$inferSelect;
879export type WebauthnChallenge = typeof webauthnChallenges.$inferSelect;
880
881/**
882 * OAuth 2.0 provider (Block B6).
883 *
884 * `oauthApps` is a third-party app registered by a developer. Each app has
885 * a public `client_id`, a hashed `client_secret`, and one or more allowed
886 * `redirect_uris` (newline-separated). The plaintext secret is shown to the
887 * developer exactly once at creation and cannot be recovered; they can
888 * rotate it instead.
889 *
890 * `oauthAuthorizations` is a short-lived authorization code issued after
891 * the user consents at /oauth/authorize. Single-use: `usedAt` is set on
892 * redemption so a replay after-the-fact fails.
893 *
894 * `oauthAccessTokens` is a long-lived bearer token plus an optional
895 * refresh token. Both are stored as SHA-256 hashes; the plaintext values
896 * are only returned to the client once in the /oauth/token response.
897 */
898export const oauthApps = pgTable(
899 "oauth_apps",
900 {
901 id: uuid("id").primaryKey().defaultRandom(),
902 ownerId: uuid("owner_id")
903 .notNull()
904 .references(() => users.id, { onDelete: "cascade" }),
905 name: text("name").notNull(),
906 clientId: text("client_id").notNull().unique(),
907 clientSecretHash: text("client_secret_hash").notNull(),
908 clientSecretPrefix: text("client_secret_prefix").notNull(), // first 8 chars for display
909 /** Newline-separated list of allowed redirect URIs. */
910 redirectUris: text("redirect_uris").notNull(),
911 homepageUrl: text("homepage_url"),
912 description: text("description"),
913 /**
914 * If `true`, the app must present its client_secret at /oauth/token.
915 * Public SPA/mobile apps should set this to `false` and use PKCE.
916 */
917 confidential: boolean("confidential").default(true).notNull(),
918 revokedAt: timestamp("revoked_at"),
919 createdAt: timestamp("created_at").defaultNow().notNull(),
920 updatedAt: timestamp("updated_at").defaultNow().notNull(),
921 },
922 (table) => [index("oauth_apps_owner").on(table.ownerId)]
923);
924
925export const oauthAuthorizations = pgTable(
926 "oauth_authorizations",
927 {
928 id: uuid("id").primaryKey().defaultRandom(),
929 appId: uuid("app_id")
930 .notNull()
931 .references(() => oauthApps.id, { onDelete: "cascade" }),
932 userId: uuid("user_id")
933 .notNull()
934 .references(() => users.id, { onDelete: "cascade" }),
935 codeHash: text("code_hash").notNull().unique(),
936 redirectUri: text("redirect_uri").notNull(),
937 scopes: text("scopes").notNull().default(""),
938 codeChallenge: text("code_challenge"),
939 codeChallengeMethod: text("code_challenge_method"), // "S256" | "plain"
940 expiresAt: timestamp("expires_at").notNull(),
941 usedAt: timestamp("used_at"),
942 createdAt: timestamp("created_at").defaultNow().notNull(),
943 },
944 (table) => [index("oauth_authorizations_expires").on(table.expiresAt)]
945);
946
947export const oauthAccessTokens = pgTable(
948 "oauth_access_tokens",
949 {
950 id: uuid("id").primaryKey().defaultRandom(),
951 appId: uuid("app_id")
952 .notNull()
953 .references(() => oauthApps.id, { onDelete: "cascade" }),
954 userId: uuid("user_id")
955 .notNull()
956 .references(() => users.id, { onDelete: "cascade" }),
957 accessTokenHash: text("access_token_hash").notNull().unique(),
958 refreshTokenHash: text("refresh_token_hash").unique(),
959 scopes: text("scopes").notNull().default(""),
960 expiresAt: timestamp("expires_at").notNull(),
961 refreshExpiresAt: timestamp("refresh_expires_at"),
962 revokedAt: timestamp("revoked_at"),
963 lastUsedAt: timestamp("last_used_at"),
964 createdAt: timestamp("created_at").defaultNow().notNull(),
965 },
966 (table) => [
967 index("oauth_access_tokens_user").on(table.userId),
968 index("oauth_access_tokens_app").on(table.appId),
969 index("oauth_access_tokens_expires").on(table.expiresAt),
970 ]
971);
972
973export type OauthApp = typeof oauthApps.$inferSelect;
974export type OauthAuthorization = typeof oauthAuthorizations.$inferSelect;
975export type OauthAccessToken = typeof oauthAccessTokens.$inferSelect;
Modifiedsrc/lib/codeowners.ts+81−8View fileUnifiedSplit
@@ -6,17 +6,37 @@
66 * *
77 * src/api/** @bob @carol
88 * /docs @alice
9 * api/** @acme/backend # Block B3: team reference
910 *
10 * Ownership is resolved by last-matching rule (that's how GitHub does it).
11 * Ownership is resolved by last-matching rule (GitHub parity).
12 *
13 * Tokens containing a `/` are treated as team references of the form
14 * `@orgSlug/teamSlug`. They are stored as-is and expanded to the team's
15 * current membership at review-request time.
1116 */
1217
13import { eq } from "drizzle-orm";
18import { and, eq } from "drizzle-orm";
1419import { db } from "../db";
15import { codeOwners } from "../db/schema";
20import {
21 codeOwners,
22 organizations,
23 teams,
24 teamMembers,
25 users,
26} from "../db/schema";
1627
1728export interface OwnerRule {
1829 pattern: string;
19 owners: string[]; // usernames, stripped of leading @
30 /**
31 * Owner tokens. Usernames are stored without the leading `@`;
32 * team references are stored as `org/team` (also no `@`).
33 * Use `isTeamToken(tok)` to distinguish.
34 */
35 owners: string[];
36}
37
38export function isTeamToken(token: string): boolean {
39 return token.includes("/");
2040}
2141
2242export function parseCodeowners(content: string): OwnerRule[] {
@@ -92,9 +112,62 @@ export async function syncCodeowners(
92112 }
93113}
94114
115/**
116 * Resolve a single `org/team` token to the set of usernames currently on
117 * the team. Returns `[]` on unknown org, unknown team, or DB error — never
118 * throws. Pure helper; exported for unit tests.
119 */
120export async function expandTeamToken(token: string): Promise<string[]> {
121 if (!isTeamToken(token)) return [];
122 const [orgSlug, teamSlug] = token.split("/", 2);
123 if (!orgSlug || !teamSlug) return [];
124 try {
125 const [org] = await db
126 .select({ id: organizations.id })
127 .from(organizations)
128 .where(eq(organizations.slug, orgSlug))
129 .limit(1);
130 if (!org) return [];
131 const [team] = await db
132 .select({ id: teams.id })
133 .from(teams)
134 .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
135 .limit(1);
136 if (!team) return [];
137 const rows = await db
138 .select({ username: users.username })
139 .from(teamMembers)
140 .innerJoin(users, eq(users.id, teamMembers.userId))
141 .where(eq(teamMembers.teamId, team.id));
142 return rows.map((r) => r.username);
143 } catch (err) {
144 console.error("[codeowners] expandTeamToken:", err);
145 return [];
146 }
147}
148
149/**
150 * Expand a list of owner tokens to concrete usernames.
151 * - Plain usernames pass through.
152 * - `org/team` tokens are expanded to the team's current members.
153 * - Unknown tokens are dropped.
154 */
155export async function expandOwnerTokens(tokens: string[]): Promise<string[]> {
156 const out = new Set<string>();
157 for (const t of tokens) {
158 if (!t) continue;
159 if (isTeamToken(t)) {
160 for (const u of await expandTeamToken(t)) out.add(u);
161 } else {
162 out.add(t);
163 }
164 }
165 return [...out];
166}
167
95168/**
96169 * Given a PR's changed file list, return all unique owner usernames to
97 * auto-request review from.
170 * auto-request review from. Team references are expanded.
98171 */
99172export async function reviewersForChangedFiles(
100173 repositoryId: string,
@@ -109,11 +182,11 @@ export async function reviewersForChangedFiles(
109182 pattern: r.pathPattern,
110183 owners: r.ownerUsernames.split(",").filter(Boolean),
111184 }));
112 const result = new Set<string>();
185 const tokens = new Set<string>();
113186 for (const p of paths) {
114 for (const u of ownersForPath(p, parsed)) result.add(u);
187 for (const u of ownersForPath(p, parsed)) tokens.add(u);
115188 }
116 return [...result];
189 return await expandOwnerTokens([...tokens]);
117190 } catch {
118191 return [];
119192 }
Modifiedsrc/lib/config.ts+21−0View fileUnifiedSplit
@@ -45,4 +45,25 @@ export const config = {
4545 ""
4646 );
4747 },
48 /**
49 * WebAuthn relying-party ID (domain only, no scheme/port). Derived from
50 * appBaseUrl unless overridden. Passkeys issued for one RP ID can't be
51 * replayed against another, so this must be stable.
52 */
53 get webauthnRpId() {
54 if (process.env.WEBAUTHN_RP_ID) return process.env.WEBAUTHN_RP_ID;
55 try {
56 return new URL(this.appBaseUrl).hostname;
57 } catch {
58 return "localhost";
59 }
60 },
61 /** WebAuthn expected origin (must include scheme + port). */
62 get webauthnOrigin() {
63 return process.env.WEBAUTHN_ORIGIN || this.appBaseUrl;
64 },
65 /** Human-facing RP name shown by the browser. */
66 get webauthnRpName() {
67 return process.env.WEBAUTHN_RP_NAME || "gluecron";
68 },
4869};
Addedsrc/lib/namespace.ts+113−0View fileUnifiedSplit
@@ -0,0 +1,113 @@
1/**
2 * Namespace resolution (Block B2).
3 *
4 * The URL path `/:slug` can resolve to either a user or an organization.
5 * Usernames and org slugs occupy the same routing namespace; at creation
6 * time we refuse an org slug that collides with a username (and vice-versa
7 * at register time — see `routes/auth.tsx`).
8 *
9 * Helpers here are read-only and swallow DB errors: they return `null` on
10 * failure so page handlers can fall through to 404 instead of 500.
11 */
12
13import { and, eq, isNull } from "drizzle-orm";
14import { db } from "../db";
15import { users, organizations, repositories } from "../db/schema";
16
17export type Namespace =
18 | { kind: "user"; id: string; slug: string }
19 | { kind: "org"; id: string; slug: string };
20
21/**
22 * Resolve a URL slug to either a user or an org. User lookups win first
23 * (usernames are the legacy, most-used namespace). Returns `null` if neither
24 * exists or the DB is unreachable.
25 */
26export async function resolveNamespace(
27 slug: string
28): Promise<Namespace | null> {
29 if (!slug) return null;
30 try {
31 const [u] = await db
32 .select({ id: users.id, slug: users.username })
33 .from(users)
34 .where(eq(users.username, slug))
35 .limit(1);
36 if (u) return { kind: "user", id: u.id, slug: u.slug };
37
38 const [o] = await db
39 .select({ id: organizations.id, slug: organizations.slug })
40 .from(organizations)
41 .where(eq(organizations.slug, slug))
42 .limit(1);
43 if (o) return { kind: "org", id: o.id, slug: o.slug };
44
45 return null;
46 } catch (err) {
47 console.error("[namespace] resolveNamespace:", err);
48 return null;
49 }
50}
51
52/**
53 * Load a repo by its URL path `:owner/:repo`. Works for both user-owned
54 * and org-owned repos.
55 */
56export async function loadRepoByPath(
57 ownerSlug: string,
58 repoName: string
59): Promise<typeof repositories.$inferSelect | null> {
60 const ns = await resolveNamespace(ownerSlug);
61 if (!ns) return null;
62 try {
63 if (ns.kind === "user") {
64 const [r] = await db
65 .select()
66 .from(repositories)
67 .where(
68 and(
69 eq(repositories.ownerId, ns.id),
70 eq(repositories.name, repoName),
71 isNull(repositories.orgId)
72 )
73 )
74 .limit(1);
75 return r || null;
76 }
77 const [r] = await db
78 .select()
79 .from(repositories)
80 .where(
81 and(eq(repositories.orgId, ns.id), eq(repositories.name, repoName))
82 )
83 .limit(1);
84 return r || null;
85 } catch (err) {
86 console.error("[namespace] loadRepoByPath:", err);
87 return null;
88 }
89}
90
91/**
92 * List all repos (user or org) for a URL slug. Used by the profile page
93 * to render a unified "repos owned by X" list.
94 */
95export async function listReposForNamespace(ns: Namespace) {
96 try {
97 if (ns.kind === "user") {
98 return await db
99 .select()
100 .from(repositories)
101 .where(
102 and(eq(repositories.ownerId, ns.id), isNull(repositories.orgId))
103 );
104 }
105 return await db
106 .select()
107 .from(repositories)
108 .where(eq(repositories.orgId, ns.id));
109 } catch (err) {
110 console.error("[namespace] listReposForNamespace:", err);
111 return [];
112 }
113}
Addedsrc/lib/oauth.ts+197−0View fileUnifiedSplit
@@ -0,0 +1,197 @@
1/**
2 * OAuth 2.0 helpers (Block B6).
3 *
4 * Stateless utilities for the OAuth provider implemented in
5 * `src/routes/oauth.tsx`:
6 *
7 * - token / code / secret generation
8 * - constant-time SHA-256 hashing (matches how we store PATs)
9 * - PKCE (RFC 7636) code_challenge verification
10 * - scope parsing + validation
11 * - redirect-URI matching (exact match, no wildcards)
12 *
13 * All outputs that end up in URLs or Authorization headers are prefix-tagged
14 * so they're greppable in logs without leaking the secret portion.
15 */
16
17/** Supported OAuth scopes. Add new ones here + document on the consent screen. */
18export const SUPPORTED_SCOPES = [
19 "read:user",
20 "read:repo",
21 "write:repo",
22 "read:org",
23 "write:org",
24 "read:issue",
25 "write:issue",
26 "read:pr",
27 "write:pr",
28] as const;
29
30export type OauthScope = (typeof SUPPORTED_SCOPES)[number];
31
32/** Default access token TTL (1 hour). */
33export const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
34/** Refresh token TTL (30 days). */
35export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
36/** Authorization code TTL (10 min — well within RFC 6749's 10-minute max). */
37export const AUTH_CODE_TTL_MS = 10 * 60 * 1000;
38
39function randomHex(byteLen: number): string {
40 const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
41 let s = "";
42 for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
43 return s;
44}
45
46/** Returns a 20-char client_id like `glc_app_<12 hex>`. */
47export function generateClientId(): string {
48 return "glc_app_" + randomHex(12);
49}
50
51/** Returns a 40-char client secret like `glcs_<32 hex>`. */
52export function generateClientSecret(): string {
53 return "glcs_" + randomHex(32);
54}
55
56export function generateAuthCode(): string {
57 return "glca_" + randomHex(24);
58}
59
60export function generateAccessToken(): string {
61 return "glct_" + randomHex(32);
62}
63
64export function generateRefreshToken(): string {
65 return "glcr_" + randomHex(32);
66}
67
68/** SHA-256 hex digest. Same algorithm as src/routes/tokens.ts. */
69export async function sha256Hex(input: string): Promise<string> {
70 const data = new TextEncoder().encode(input);
71 const digest = await crypto.subtle.digest("SHA-256", data);
72 const bytes = new Uint8Array(digest);
73 let s = "";
74 for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
75 return s;
76}
77
78/** Base64url (no padding) — used by PKCE. */
79export function b64urlFromBytes(bytes: Uint8Array): string {
80 let bin = "";
81 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
82 return btoa(bin)
83 .replace(/\+/g, "-")
84 .replace(/\//g, "_")
85 .replace(/=+$/, "");
86}
87
88/**
89 * PKCE verification (RFC 7636).
90 * For "S256": base64url(SHA-256(verifier)) must equal the challenge.
91 * For "plain": the verifier must equal the challenge literally.
92 * Returns true when the method is unrecognized but `challenge` is empty —
93 * callers should refuse before calling this if PKCE is required.
94 */
95export async function verifyPkce(opts: {
96 challenge: string | null | undefined;
97 method: string | null | undefined;
98 verifier: string;
99}): Promise<boolean> {
100 const challenge = (opts.challenge || "").trim();
101 if (!challenge) return false;
102 const method = (opts.method || "plain").toLowerCase();
103
104 if (method === "s256") {
105 const data = new TextEncoder().encode(opts.verifier);
106 const digest = await crypto.subtle.digest("SHA-256", data);
107 const produced = b64urlFromBytes(new Uint8Array(digest));
108 return timingSafeEqual(produced, challenge);
109 }
110 if (method === "plain") {
111 return timingSafeEqual(opts.verifier, challenge);
112 }
113 return false;
114}
115
116/** Constant-time string comparison. */
117export function timingSafeEqual(a: string, b: string): boolean {
118 if (a.length !== b.length) return false;
119 let diff = 0;
120 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
121 return diff === 0;
122}
123
124/**
125 * Parse a scope string (space-separated per RFC 6749 or comma-separated for
126 * convenience). Unknown scopes are dropped silently; duplicates collapsed.
127 */
128export function parseScopes(input: string | null | undefined): OauthScope[] {
129 if (!input) return [];
130 const parts = input.split(/[\s,]+/).filter(Boolean);
131 const seen = new Set<string>();
132 const out: OauthScope[] = [];
133 for (const p of parts) {
134 const s = p.trim().toLowerCase();
135 if (!s || seen.has(s)) continue;
136 if ((SUPPORTED_SCOPES as readonly string[]).includes(s)) {
137 out.push(s as OauthScope);
138 seen.add(s);
139 }
140 }
141 return out;
142}
143
144/** Serialize scopes back to a space-separated string. */
145export function serializeScopes(scopes: readonly OauthScope[]): string {
146 return scopes.join(" ");
147}
148
149/**
150 * Parse an app's stored `redirectUris` column (newline-separated).
151 * Empty / whitespace-only lines are ignored.
152 */
153export function parseRedirectUris(stored: string): string[] {
154 return stored
155 .split(/\r?\n/)
156 .map((s) => s.trim())
157 .filter(Boolean);
158}
159
160/**
161 * Validate a single redirect URI for storage:
162 * - must be absolute http(s):// (http only for localhost)
163 * - no fragment (`#...`)
164 * - no wildcards
165 */
166export function isValidRedirectUri(uri: string): boolean {
167 try {
168 const u = new URL(uri);
169 if (u.protocol !== "http:" && u.protocol !== "https:") return false;
170 if (u.protocol === "http:") {
171 if (!["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) {
172 return false;
173 }
174 }
175 if (u.hash) return false;
176 if (uri.includes("*")) return false;
177 return true;
178 } catch {
179 return false;
180 }
181}
182
183/** Exact-match check against the app's registered list. */
184export function redirectUriAllowed(
185 candidate: string,
186 registered: readonly string[]
187): boolean {
188 if (!candidate) return false;
189 for (const r of registered) {
190 if (timingSafeEqual(candidate, r)) return true;
191 }
192 return false;
193}
194
195export const __test = {
196 randomHex,
197};
Addedsrc/lib/totp.ts+185−0View fileUnifiedSplit
@@ -0,0 +1,185 @@
1/**
2 * TOTP (RFC 6238) — standalone, no external deps.
3 *
4 * Used for 2FA (Block B4). Generates + verifies 6-digit codes with a 30-second
5 * step. Verification accepts the current step ±1 to tolerate clock skew.
6 *
7 * Secrets are stored as Base32 strings (the standard QR-code encoding) and
8 * converted to bytes on each verify. At rest the secret is further encrypted
9 * (see `src/lib/crypto.ts` for the AES-GCM wrapper introduced in this block).
10 */
11
12const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
13
14/** Encode random bytes as a Base32 string with no padding (TOTP standard). */
15export function base32Encode(bytes: Uint8Array): string {
16 let bits = 0;
17 let value = 0;
18 let output = "";
19 for (let i = 0; i < bytes.length; i++) {
20 value = (value << 8) | bytes[i]!;
21 bits += 8;
22 while (bits >= 5) {
23 output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
24 bits -= 5;
25 }
26 }
27 if (bits > 0) {
28 output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
29 }
30 return output;
31}
32
33/** Decode a Base32 string back into bytes. Permissive about case + padding. */
34export function base32Decode(input: string): Uint8Array {
35 const clean = input
36 .toUpperCase()
37 .replace(/=+$/g, "")
38 .replace(/\s+/g, "");
39 let bits = 0;
40 let value = 0;
41 const out: number[] = [];
42 for (let i = 0; i < clean.length; i++) {
43 const idx = BASE32_ALPHABET.indexOf(clean[i]!);
44 if (idx === -1) {
45 throw new Error(`Invalid Base32 character: ${clean[i]}`);
46 }
47 value = (value << 5) | idx;
48 bits += 5;
49 if (bits >= 8) {
50 bits -= 8;
51 out.push((value >>> bits) & 0xff);
52 }
53 }
54 return new Uint8Array(out);
55}
56
57/**
58 * Generate a cryptographically random TOTP secret. 20 bytes → 32 Base32 chars,
59 * the length most auth apps expect and RFC 4226 recommends.
60 */
61export function generateTotpSecret(): string {
62 return base32Encode(crypto.getRandomValues(new Uint8Array(20)));
63}
64
65async function hmacSha1(
66 keyBytes: Uint8Array,
67 msgBytes: Uint8Array
68): Promise<Uint8Array> {
69 const key = await crypto.subtle.importKey(
70 "raw",
71 keyBytes,
72 { name: "HMAC", hash: "SHA-1" },
73 false,
74 ["sign"]
75 );
76 const sig = await crypto.subtle.sign("HMAC", key, msgBytes);
77 return new Uint8Array(sig);
78}
79
80/** Dynamic-truncate the HMAC output into a 6-digit number (RFC 4226). */
81function hotpCode(hmac: Uint8Array): string {
82 const offset = hmac[hmac.length - 1]! & 0x0f;
83 const bin =
84 ((hmac[offset]! & 0x7f) << 24) |
85 ((hmac[offset + 1]! & 0xff) << 16) |
86 ((hmac[offset + 2]! & 0xff) << 8) |
87 (hmac[offset + 3]! & 0xff);
88 return String(bin % 1_000_000).padStart(6, "0");
89}
90
91/** Generate the TOTP code for a given secret + unix time (seconds). */
92export async function totpCode(
93 secretBase32: string,
94 timeSec: number = Math.floor(Date.now() / 1000)
95): Promise<string> {
96 const step = Math.floor(timeSec / 30);
97 const msg = new Uint8Array(8);
98 // Big-endian 8-byte counter.
99 new DataView(msg.buffer).setBigUint64(0, BigInt(step), false);
100 const hmac = await hmacSha1(base32Decode(secretBase32), msg);
101 return hotpCode(hmac);
102}
103
104/**
105 * Verify a 6-digit code against a secret with ±1 step tolerance.
106 * Constant-time-ish string compare (both sides same length).
107 */
108export async function verifyTotpCode(
109 secretBase32: string,
110 code: string,
111 timeSec: number = Math.floor(Date.now() / 1000)
112): Promise<boolean> {
113 if (!/^\d{6}$/.test(code)) return false;
114 const candidates = await Promise.all([
115 totpCode(secretBase32, timeSec - 30),
116 totpCode(secretBase32, timeSec),
117 totpCode(secretBase32, timeSec + 30),
118 ]);
119 let ok = false;
120 for (const c of candidates) {
121 // Avoid short-circuit: keep timing close.
122 if (constantTimeEqual(c, code)) ok = true;
123 }
124 return ok;
125}
126
127function constantTimeEqual(a: string, b: string): boolean {
128 if (a.length !== b.length) return false;
129 let diff = 0;
130 for (let i = 0; i < a.length; i++) {
131 diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
132 }
133 return diff === 0;
134}
135
136/**
137 * Build an otpauth:// URI suitable for QR codes. Most authenticator apps
138 * (Google Authenticator, 1Password, Bitwarden, Authy) accept this format.
139 */
140export function otpauthUrl(opts: {
141 secret: string;
142 accountName: string;
143 issuer: string;
144}): string {
145 const label = encodeURIComponent(`${opts.issuer}:${opts.accountName}`);
146 const params = new URLSearchParams({
147 secret: opts.secret,
148 issuer: opts.issuer,
149 algorithm: "SHA1",
150 digits: "6",
151 period: "30",
152 });
153 return `otpauth://totp/${label}?${params.toString()}`;
154}
155
156/**
157 * Generate N random recovery codes in the format xxxx-xxxx-xxxx (lowercase
158 * alphanumeric). Each code is ~70 bits of entropy and single-use.
159 */
160export function generateRecoveryCodes(count = 10): string[] {
161 const codes: string[] = [];
162 for (let i = 0; i < count; i++) {
163 const parts: string[] = [];
164 for (let j = 0; j < 3; j++) {
165 const bytes = crypto.getRandomValues(new Uint8Array(3));
166 parts.push(
167 Array.from(bytes)
168 .map((b) => b.toString(36).padStart(2, "0"))
169 .join("")
170 .slice(0, 4)
171 );
172 }
173 codes.push(parts.join("-"));
174 }
175 return codes;
176}
177
178/** Hash a recovery code with SHA-256 for storage. */
179export async function hashRecoveryCode(code: string): Promise<string> {
180 const bytes = new TextEncoder().encode(code.trim().toLowerCase());
181 const digest = await crypto.subtle.digest("SHA-256", bytes);
182 return Array.from(new Uint8Array(digest))
183 .map((b) => b.toString(16).padStart(2, "0"))
184 .join("");
185}
Addedsrc/lib/webauthn.ts+230−0View fileUnifiedSplit
@@ -0,0 +1,230 @@
1/**
2 * WebAuthn / passkey helpers (Block B5).
3 *
4 * Thin wrapper over @simplewebauthn/server that:
5 * - reads RP config from `src/lib/config.ts`
6 * - persists short-lived challenges in `webauthn_challenges`
7 * - converts between base64url (used in the browser) and bytes as needed
8 */
9
10import { and, eq, lt } from "drizzle-orm";
11import {
12 generateRegistrationOptions,
13 verifyRegistrationResponse,
14 generateAuthenticationOptions,
15 verifyAuthenticationResponse,
16} from "@simplewebauthn/server";
17import type {
18 RegistrationResponseJSON,
19 AuthenticationResponseJSON,
20} from "@simplewebauthn/server";
21import { db } from "../db";
22import { webauthnChallenges, userPasskeys } from "../db/schema";
23import { config } from "./config";
24
25const CHALLENGE_TTL_MS = 5 * 60 * 1000;
26
27function newSessionKey(): string {
28 const bytes = crypto.getRandomValues(new Uint8Array(24));
29 return Array.from(bytes)
30 .map((b) => b.toString(16).padStart(2, "0"))
31 .join("");
32}
33
34async function gcExpiredChallenges(): Promise<void> {
35 try {
36 await db
37 .delete(webauthnChallenges)
38 .where(lt(webauthnChallenges.expiresAt, new Date()));
39 } catch {
40 /* best-effort GC */
41 }
42}
43
44export async function startRegistration(opts: {
45 userId: string;
46 userName: string;
47 userDisplayName?: string;
48 excludeCredentialIds?: string[];
49}) {
50 await gcExpiredChallenges();
51 const options = await generateRegistrationOptions({
52 rpName: config.webauthnRpName,
53 rpID: config.webauthnRpId,
54 userName: opts.userName,
55 userDisplayName: opts.userDisplayName || opts.userName,
56 userID: new TextEncoder().encode(opts.userId),
57 attestationType: "none",
58 excludeCredentials: (opts.excludeCredentialIds || []).map((id) => ({
59 id,
60 })),
61 authenticatorSelection: {
62 residentKey: "preferred",
63 userVerification: "preferred",
64 },
65 });
66
67 const sessionKey = newSessionKey();
68 await db.insert(webauthnChallenges).values({
69 userId: opts.userId,
70 sessionKey,
71 challenge: options.challenge,
72 kind: "register",
73 expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
74 });
75
76 return { options, sessionKey };
77}
78
79export async function finishRegistration(opts: {
80 sessionKey: string;
81 response: RegistrationResponseJSON;
82}): Promise<
83 | { ok: true; credentialId: string; publicKey: string; counter: number }
84 | { ok: false; error: string }
85> {
86 try {
87 const [chal] = await db
88 .select()
89 .from(webauthnChallenges)
90 .where(
91 and(
92 eq(webauthnChallenges.sessionKey, opts.sessionKey),
93 eq(webauthnChallenges.kind, "register")
94 )
95 )
96 .limit(1);
97 if (!chal) return { ok: false, error: "Challenge not found or expired" };
98 if (new Date(chal.expiresAt) < new Date()) {
99 return { ok: false, error: "Challenge expired" };
100 }
101
102 const verification = await verifyRegistrationResponse({
103 response: opts.response,
104 expectedChallenge: chal.challenge,
105 expectedOrigin: config.webauthnOrigin,
106 expectedRPID: config.webauthnRpId,
107 requireUserVerification: false,
108 });
109
110 // One-shot: remove the challenge whether verification passed or not.
111 await db
112 .delete(webauthnChallenges)
113 .where(eq(webauthnChallenges.sessionKey, opts.sessionKey));
114
115 if (!verification.verified || !verification.registrationInfo) {
116 return { ok: false, error: "Registration did not verify" };
117 }
118
119 const { credential } = verification.registrationInfo;
120 return {
121 ok: true,
122 credentialId: credential.id,
123 publicKey: Buffer.from(credential.publicKey).toString("base64url"),
124 counter: credential.counter,
125 };
126 } catch (err) {
127 console.error("[webauthn] finishRegistration:", err);
128 return { ok: false, error: "Verification failed" };
129 }
130}
131
132export async function startAuthentication(opts: {
133 userId?: string;
134 allowCredentialIds?: string[];
135}) {
136 await gcExpiredChallenges();
137 const options = await generateAuthenticationOptions({
138 rpID: config.webauthnRpId,
139 allowCredentials: (opts.allowCredentialIds || []).map((id) => ({ id })),
140 userVerification: "preferred",
141 });
142
143 const sessionKey = newSessionKey();
144 await db.insert(webauthnChallenges).values({
145 userId: opts.userId || null,
146 sessionKey,
147 challenge: options.challenge,
148 kind: "authenticate",
149 expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
150 });
151
152 return { options, sessionKey };
153}
154
155export async function finishAuthentication(opts: {
156 sessionKey: string;
157 response: AuthenticationResponseJSON;
158}): Promise<
159 | {
160 ok: true;
161 userId: string;
162 credentialId: string;
163 newCounter: number;
164 }
165 | { ok: false; error: string }
166> {
167 try {
168 const [chal] = await db
169 .select()
170 .from(webauthnChallenges)
171 .where(
172 and(
173 eq(webauthnChallenges.sessionKey, opts.sessionKey),
174 eq(webauthnChallenges.kind, "authenticate")
175 )
176 )
177 .limit(1);
178 if (!chal) return { ok: false, error: "Challenge not found or expired" };
179 if (new Date(chal.expiresAt) < new Date()) {
180 return { ok: false, error: "Challenge expired" };
181 }
182
183 const credentialId = opts.response.id;
184 const [pk] = await db
185 .select()
186 .from(userPasskeys)
187 .where(eq(userPasskeys.credentialId, credentialId))
188 .limit(1);
189 if (!pk) {
190 return { ok: false, error: "Unknown credential" };
191 }
192
193 const verification = await verifyAuthenticationResponse({
194 response: opts.response,
195 expectedChallenge: chal.challenge,
196 expectedOrigin: config.webauthnOrigin,
197 expectedRPID: config.webauthnRpId,
198 credential: {
199 id: pk.credentialId,
200 publicKey: new Uint8Array(Buffer.from(pk.publicKey, "base64url")),
201 counter: pk.counter,
202 },
203 requireUserVerification: false,
204 });
205
206 await db
207 .delete(webauthnChallenges)
208 .where(eq(webauthnChallenges.sessionKey, opts.sessionKey));
209
210 if (!verification.verified) {
211 return { ok: false, error: "Authentication did not verify" };
212 }
213
214 const newCounter = verification.authenticationInfo.newCounter;
215 await db
216 .update(userPasskeys)
217 .set({ counter: newCounter, lastUsedAt: new Date() })
218 .where(eq(userPasskeys.id, pk.id));
219
220 return {
221 ok: true,
222 userId: pk.userId,
223 credentialId: pk.credentialId,
224 newCounter,
225 };
226 } catch (err) {
227 console.error("[webauthn] finishAuthentication:", err);
228 return { ok: false, error: "Verification failed" };
229 }
230}
Modifiedsrc/middleware/auth.ts+13−1View fileUnifiedSplit
@@ -43,7 +43,11 @@ export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
4343 .where(eq(sessions.token, token))
4444 .limit(1);
4545
46 if (!session || new Date(session.expiresAt) < new Date()) {
46 if (
47 !session ||
48 new Date(session.expiresAt) < new Date() ||
49 session.requires2fa
50 ) {
4751 sessionCache.set(token, null as any);
4852 c.set("user", null);
4953 return next();
@@ -85,6 +89,14 @@ export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
8589 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
8690 }
8791
92 // 2FA pending — route the user to the code prompt instead of letting
93 // them access protected pages.
94 if (session.requires2fa) {
95 return c.redirect(
96 `/login/2fa?redirect=${encodeURIComponent(c.req.path)}`
97 );
98 }
99
88100 const [user] = await db
89101 .select()
90102 .from(users)
Modifiedsrc/routes/api.ts+66−21View fileUnifiedSplit
@@ -3,11 +3,12 @@
33 */
44
55import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
6import { eq, and, isNull } from "drizzle-orm";
77import { db } from "../db";
8import { users, repositories } from "../db/schema";
8import { users, repositories, organizations, orgMembers } from "../db/schema";
99import { initBareRepo, repoExists } from "../git/repository";
1010import { hashPassword } from "../lib/auth";
11import { orgRoleAtLeast } from "../lib/orgs";
1112
1213const api = new Hono().basePath("/api");
1314
@@ -16,6 +17,7 @@ api.post("/repos", async (c) => {
1617 let body: {
1718 name: string;
1819 owner: string;
20 orgSlug?: string;
1921 description?: string;
2022 isPrivate?: boolean;
2123 };
@@ -35,7 +37,7 @@ api.post("/repos", async (c) => {
3537 }
3638
3739 try {
38 // Find owner
40 // Find creator (user who is performing the action)
3941 const [owner] = await db
4042 .select()
4143 .from(users)
@@ -45,13 +47,66 @@ api.post("/repos", async (c) => {
4547 return c.json({ error: "Owner not found" }, 404);
4648 }
4749
48 // Check duplicate
49 if (await repoExists(body.owner, body.name)) {
50 // B2: if orgSlug supplied, place the repo in the org namespace.
51 // Requires the creator to be an admin+ of the org.
52 let orgId: string | null = null;
53 let namespaceSlug = body.owner;
54 if (body.orgSlug) {
55 const [org] = await db
56 .select({ id: organizations.id, slug: organizations.slug })
57 .from(organizations)
58 .where(eq(organizations.slug, body.orgSlug))
59 .limit(1);
60 if (!org) return c.json({ error: "Organization not found" }, 404);
61
62 const [mem] = await db
63 .select({ role: orgMembers.role })
64 .from(orgMembers)
65 .where(
66 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, owner.id))
67 )
68 .limit(1);
69 if (!mem || !orgRoleAtLeast(mem.role, "admin")) {
70 return c.json({ error: "Admin rights required on org" }, 403);
71 }
72 orgId = org.id;
73 namespaceSlug = org.slug;
74 }
75
76 // Duplicate check: scoped to the right namespace.
77 if (orgId) {
78 const [existing] = await db
79 .select({ id: repositories.id })
80 .from(repositories)
81 .where(
82 and(eq(repositories.orgId, orgId), eq(repositories.name, body.name))
83 )
84 .limit(1);
85 if (existing) {
86 return c.json({ error: "Repository already exists" }, 409);
87 }
88 } else {
89 const [existing] = await db
90 .select({ id: repositories.id })
91 .from(repositories)
92 .where(
93 and(
94 eq(repositories.ownerId, owner.id),
95 eq(repositories.name, body.name),
96 isNull(repositories.orgId)
97 )
98 )
99 .limit(1);
100 if (existing) {
101 return c.json({ error: "Repository already exists" }, 409);
102 }
103 }
104 if (await repoExists(namespaceSlug, body.name)) {
50105 return c.json({ error: "Repository already exists" }, 409);
51106 }
52107
53 // Init bare repo on disk
54 const diskPath = await initBareRepo(body.owner, body.name);
108 // Init bare repo on disk, keyed by the namespace slug (user or org).
109 const diskPath = await initBareRepo(namespaceSlug, body.name);
55110
56111 // Insert into DB
57112 const [repo] = await db
@@ -59,6 +114,7 @@ api.post("/repos", async (c) => {
59114 .values({
60115 name: body.name,
61116 ownerId: owner.id,
117 orgId,
62118 description: body.description || null,
63119 isPrivate: body.isPrivate || false,
64120 diskPath,
@@ -103,24 +159,13 @@ api.get("/users/:username/repos", async (c) => {
103159 }
104160});
105161
106// Get single repository
162// Get single repository (resolves both user- and org-owned namespaces)
107163api.get("/repos/:owner/:name", async (c) => {
108164 const { owner: ownerName, name } = c.req.param();
109165 try {
110 const [owner] = await db
111 .select()
112 .from(users)
113 .where(eq(users.username, ownerName));
114 if (!owner) return c.json({ error: "Not found" }, 404);
115
116 const [repo] = await db
117 .select()
118 .from(repositories)
119 .where(
120 and(eq(repositories.ownerId, owner.id), eq(repositories.name, name))
121 );
166 const { loadRepoByPath } = await import("../lib/namespace");
167 const repo = await loadRepoByPath(ownerName, name);
122168 if (!repo) return c.json({ error: "Not found" }, 404);
123
124169 return c.json(repo);
125170 } catch (err) {
126171 console.error("[api] /repos/:owner/:name:", err);
Modifiedsrc/routes/auth.tsx+274−3View fileUnifiedSplit
@@ -3,10 +3,16 @@
33 */
44
55import { Hono } from "hono";
6import { setCookie, deleteCookie } from "hono/cookie";
7import { eq } from "drizzle-orm";
6import { setCookie, deleteCookie, getCookie } from "hono/cookie";
7import { and, eq, isNull } from "drizzle-orm";
88import { db } from "../db";
9import { users, sessions } from "../db/schema";
9import {
10 users,
11 sessions,
12 organizations,
13 userTotp,
14 userRecoveryCodes,
15} from "../db/schema";
1016import {
1117 hashPassword,
1218 verifyPassword,
@@ -14,6 +20,7 @@ import {
1420 sessionCookieOptions,
1521 sessionExpiry,
1622} from "../lib/auth";
23import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
1724import { Layout } from "../views/layout";
1825import type { AuthEnv } from "../middleware/auth";
1926
@@ -108,6 +115,16 @@ auth.post("/register", async (c) => {
108115 return c.redirect("/register?error=Username+already+taken");
109116 }
110117
118 // B2: usernames share the URL namespace with org slugs; refuse collisions.
119 const [existingOrg] = await db
120 .select({ id: organizations.id })
121 .from(organizations)
122 .where(eq(organizations.slug, username.toLowerCase()))
123 .limit(1);
124 if (existingOrg) {
125 return c.redirect("/register?error=Username+already+taken");
126 }
127
111128 const [existingEmail] = await db
112129 .select()
113130 .from(users)
@@ -176,9 +193,105 @@ auth.get("/login", (c) => {
176193 Sign in
177194 </button>
178195 </form>
196 <div
197 style="margin: 16px 0; text-align: center; color: var(--text-muted); font-size: 12px"
198 >
199 — or —
200 </div>
201 <div style="text-align: center">
202 <button
203 type="button"
204 id="pk-signin-btn"
205 class="btn"
206 style="width: 100%"
207 >
208 Sign in with passkey
209 </button>
210 <div
211 id="pk-signin-status"
212 style="margin-top: 8px; color: var(--text-muted); font-size: 12px; min-height: 16px"
213 />
214 </div>
179215 <p class="auth-switch">
180216 New to gluecron? <a href="/register">Create an account</a>
181217 </p>
218 <script
219 dangerouslySetInnerHTML={{
220 __html: /* js */ `
221 (function () {
222 const btn = document.getElementById('pk-signin-btn');
223 const status = document.getElementById('pk-signin-status');
224 const userInput = document.getElementById('username');
225 const redirect = ${JSON.stringify(redirect || "/")};
226 if (!btn) return;
227 function b64uToBuf(s) {
228 s = s.replace(/-/g,'+').replace(/_/g,'/');
229 while (s.length % 4) s += '=';
230 const bin = atob(s);
231 const buf = new Uint8Array(bin.length);
232 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
233 return buf.buffer;
234 }
235 function bufToB64u(buf) {
236 const bytes = new Uint8Array(buf);
237 let bin = '';
238 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
239 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
240 }
241 btn.addEventListener('click', async function () {
242 if (!window.PublicKeyCredential) {
243 status.textContent = 'Passkeys not supported in this browser.';
244 return;
245 }
246 status.textContent = 'Preparing…';
247 try {
248 const username = (userInput && userInput.value || '').trim();
249 const optsRes = await fetch('/api/passkeys/auth/options', {
250 method: 'POST',
251 headers: { 'content-type': 'application/json' },
252 body: JSON.stringify(username ? { username: username } : {})
253 });
254 if (!optsRes.ok) throw new Error('options failed');
255 const { options, sessionKey } = await optsRes.json();
256 options.challenge = b64uToBuf(options.challenge);
257 if (options.allowCredentials) {
258 options.allowCredentials = options.allowCredentials.map(function (c) {
259 return Object.assign({}, c, { id: b64uToBuf(c.id) });
260 });
261 }
262 status.textContent = 'Touch your authenticator…';
263 const cred = await navigator.credentials.get({ publicKey: options });
264 const resp = {
265 id: cred.id,
266 rawId: bufToB64u(cred.rawId),
267 type: cred.type,
268 response: {
269 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
270 authenticatorData: bufToB64u(cred.response.authenticatorData),
271 signature: bufToB64u(cred.response.signature),
272 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
273 },
274 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
275 };
276 const verifyRes = await fetch('/api/passkeys/auth/verify', {
277 method: 'POST',
278 headers: { 'content-type': 'application/json' },
279 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
280 });
281 if (!verifyRes.ok) {
282 const j = await verifyRes.json().catch(function () { return {}; });
283 throw new Error(j.error || 'verify failed');
284 }
285 status.textContent = 'Signed in. Redirecting…';
286 window.location.href = redirect;
287 } catch (e) {
288 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
289 }
290 });
291 })();
292 `,
293 }}
294 />
182295 </div>
183296 </Layout>
184297 );
@@ -215,17 +328,175 @@ auth.post("/login", async (c) => {
215328 return c.redirect("/login?error=Invalid+credentials");
216329 }
217330
331 // B4: if the user has TOTP enabled, issue a pending-2fa session and
332 // redirect to the code prompt.
333 const [totp] = await db
334 .select({ enabledAt: userTotp.enabledAt })
335 .from(userTotp)
336 .where(eq(userTotp.userId, user.id))
337 .limit(1);
338 const needs2fa = !!(totp && totp.enabledAt);
339
218340 const token = generateSessionToken();
219341 await db.insert(sessions).values({
220342 userId: user.id,
221343 token,
222344 expiresAt: sessionExpiry(),
345 requires2fa: needs2fa,
223346 });
224347
225348 setCookie(c, "session", token, sessionCookieOptions());
349 if (needs2fa) {
350 return c.redirect(
351 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
352 );
353 }
226354 return c.redirect(redirect);
227355});
228356
357// --- 2FA verify (B4) ---
358auth.get("/login/2fa", async (c) => {
359 const token = getCookie(c, "session");
360 if (!token) return c.redirect("/login");
361 const error = c.req.query("error");
362 const redirect = c.req.query("redirect") || "/";
363 return c.html(
364
365 <div class="auth-container">
366 <h2>Enter your code</h2>
367 <p
368 class="auth-switch"
369 style="margin-bottom: 16px; margin-top: 0"
370 >
371 Open your authenticator app and enter the 6-digit code. Lost your
372 device? Paste a recovery code instead.
373 </p>
374 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
375 <form
376 method="POST"
377 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
378 >
379 <div class="form-group">
380 <label for="code">Code</label>
381 <input
382 type="text"
383 id="code"
384 name="code"
385 required
386 autocomplete="one-time-code"
387 inputmode="numeric"
388 maxLength={24}
389 placeholder="123456 or xxxx-xxxx-xxxx"
390 />
391 </div>
392 <button type="submit" class="btn btn-primary">
393 Verify
394 </button>
395 </form>
396 <p class="auth-switch">
397 <a href="/logout">Cancel</a>
398 </p>
399 </div>
400 </Layout>
401 );
402});
403
404auth.post("/login/2fa", async (c) => {
405 const token = getCookie(c, "session");
406 if (!token) return c.redirect("/login");
407 const body = await c.req.parseBody();
408 const code = String(body.code || "").trim();
409 const redirect = c.req.query("redirect") || "/";
410
411 if (!code) {
412 return c.redirect(
413 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
414 );
415 }
416
417 try {
418 const [session] = await db
419 .select()
420 .from(sessions)
421 .where(eq(sessions.token, token))
422 .limit(1);
423 if (
424 !session ||
425 new Date(session.expiresAt) < new Date() ||
426 !session.requires2fa
427 ) {
428 return c.redirect("/login");
429 }
430
431 const [totp] = await db
432 .select()
433 .from(userTotp)
434 .where(eq(userTotp.userId, session.userId))
435 .limit(1);
436 if (!totp || !totp.enabledAt) {
437 // User doesn't have 2FA actually enabled — clear the flag and let
438 // them in. This can only happen if 2FA was disabled in another
439 // session between password check and code prompt.
440 await db
441 .update(sessions)
442 .set({ requires2fa: false })
443 .where(eq(sessions.token, token));
444 return c.redirect(redirect);
445 }
446
447 // Try TOTP code first.
448 const isSix = /^\d{6}$/.test(code);
449 let ok = false;
450 if (isSix) {
451 ok = await verifyTotpCode(totp.secret, code);
452 }
453 // Fall through to recovery code.
454 if (!ok) {
455 const hash = await hashRecoveryCode(code);
456 const [rec] = await db
457 .select()
458 .from(userRecoveryCodes)
459 .where(
460 and(
461 eq(userRecoveryCodes.userId, session.userId),
462 eq(userRecoveryCodes.codeHash, hash),
463 isNull(userRecoveryCodes.usedAt)
464 )
465 )
466 .limit(1);
467 if (rec) {
468 await db
469 .update(userRecoveryCodes)
470 .set({ usedAt: new Date() })
471 .where(eq(userRecoveryCodes.id, rec.id));
472 ok = true;
473 }
474 }
475
476 if (!ok) {
477 return c.redirect(
478 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
479 );
480 }
481
482 await db
483 .update(sessions)
484 .set({ requires2fa: false })
485 .where(eq(sessions.token, token));
486 await db
487 .update(userTotp)
488 .set({ lastUsedAt: new Date() })
489 .where(eq(userTotp.userId, session.userId));
490
491 return c.redirect(redirect);
492 } catch (err) {
493 console.error("[auth] 2fa verify:", err);
494 return c.redirect(
495 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
496 );
497 }
498});
499
229500auth.get("/logout", async (c) => {
230501 deleteCookie(c, "session", { path: "/" });
231502 return c.redirect("/");
Addedsrc/routes/developer-apps.tsx+558−0View fileUnifiedSplit
@@ -0,0 +1,558 @@
1/**
2 * Developer Apps UI (Block B6).
3 *
4 * Lets authenticated users register + manage their OAuth 2.0 apps:
5 * GET /settings/applications list + new button
6 * GET /settings/applications/new form
7 * POST /settings/applications/new create (returns client_secret once)
8 * GET /settings/applications/:id edit / rotate secret / delete
9 * POST /settings/applications/:id update
10 * POST /settings/applications/:id/rotate generate a new client secret
11 * POST /settings/applications/:id/delete remove app + all tokens
12 *
13 * All writes audit()' the action. Read-only responses are HTML (SSR JSX).
14 */
15
16import { Hono } from "hono";
17import { and, eq } from "drizzle-orm";
18import { db } from "../db";
19import { oauthApps } from "../db/schema";
20import { Layout } from "../views/layout";
21import { requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import {
24 generateClientId,
25 generateClientSecret,
26 sha256Hex,
27 isValidRedirectUri,
28 parseRedirectUris,
29} from "../lib/oauth";
30import { audit } from "../lib/notify";
31
32const apps = new Hono<AuthEnv>();
33
34apps.use("/settings/applications", requireAuth);
35apps.use("/settings/applications/*", requireAuth);
36
37function normaliseRedirectUris(raw: string): {
38 ok: boolean;
39 value?: string;
40 error?: string;
41} {
42 const lines = raw
43 .split(/\r?\n/)
44 .map((s) => s.trim())
45 .filter(Boolean);
46 if (lines.length === 0) {
47 return { ok: false, error: "At least one redirect URI is required" };
48 }
49 if (lines.length > 10) {
50 return { ok: false, error: "At most 10 redirect URIs allowed" };
51 }
52 for (const u of lines) {
53 if (!isValidRedirectUri(u)) {
54 return { ok: false, error: `Invalid redirect URI: ${u}` };
55 }
56 }
57 return { ok: true, value: lines.join("\n") };
58}
59
60apps.get("/settings/applications", async (c) => {
61 const user = c.get("user")!;
62 const error = c.req.query("error");
63 const success = c.req.query("success");
64
65 let rows: (typeof oauthApps.$inferSelect)[] = [];
66 try {
67 rows = await db
68 .select()
69 .from(oauthApps)
70 .where(eq(oauthApps.ownerId, user.id));
71 } catch (err) {
72 console.error("[oauth-apps] list:", err);
73 }
74
75 return c.html(
76 <Layout title="OAuth applications" user={user}>
77 <div class="settings-container">
78 <div class="breadcrumb">
79 <a href="/settings">settings</a>
80 <span>/</span>
81 <span>applications</span>
82 </div>
83 <h2>OAuth applications</h2>
84 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
85 {success && (
86 <div class="auth-success">{decodeURIComponent(success)}</div>
87 )}
88 <p style="color: var(--text-muted); font-size: 13px">
89 Register third-party apps that can request access to gluecron on
90 behalf of users via the OAuth 2.0 authorization code flow.
91 </p>
92 <div style="margin: 16px 0">
93 <a href="/settings/applications/new" class="btn btn-primary">
94 New OAuth app
95 </a>
96 </div>
97 <div
98 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
99 >
100 {rows.length === 0 ? (
101 <div
102 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
103 >
104 No OAuth apps registered yet.
105 </div>
106 ) : (
107 rows.map((app) => (
108 <div
109 style="padding: 12px 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary)"
110 >
111 <div style="display: flex; justify-content: space-between; align-items: center">
112 <div>
113 <strong>
114 <a href={`/settings/applications/${app.id}`}>{app.name}</a>
115 </strong>
116 {app.revokedAt && (
117 <span style="color: var(--red); font-size: 12px; margin-left: 8px">
118 revoked
119 </span>
120 )}
121 <div
122 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
123 >
124 <code>{app.clientId}</code>
125 {" · "}added {new Date(app.createdAt).toLocaleDateString()}
126 </div>
127 </div>
128 <a
129 href={`/settings/applications/${app.id}`}
130 class="btn btn-sm"
131 >
132 manage
133 </a>
134 </div>
135 </div>
136 ))
137 )}
138 </div>
139 </div>
140 </Layout>
141 );
142});
143
144apps.get("/settings/applications/new", async (c) => {
145 const user = c.get("user")!;
146 const error = c.req.query("error");
147 return c.html(
148 <Layout title="New OAuth app" user={user}>
149 <div class="settings-container">
150 <div class="breadcrumb">
151 <a href="/settings/applications">applications</a>
152 <span>/</span>
153 <span>new</span>
154 </div>
155 <h2>Register a new OAuth app</h2>
156 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
157 <form method="POST" action="/settings/applications/new">
158 <div class="form-group">
159 <label for="name">Application name</label>
160 <input
161 type="text"
162 id="name"
163 name="name"
164 required
165 maxLength={80}
166 placeholder="My Awesome Integration"
167 />
168 </div>
169 <div class="form-group">
170 <label for="homepage_url">Homepage URL</label>
171 <input
172 type="url"
173 id="homepage_url"
174 name="homepage_url"
175 placeholder="https://example.com"
176 />
177 </div>
178 <div class="form-group">
179 <label for="description">Description</label>
180 <textarea
181 id="description"
182 name="description"
183 rows={3}
184 maxLength={500}
185 />
186 </div>
187 <div class="form-group">
188 <label for="redirect_uris">Authorization callback URLs</label>
189 <textarea
190 id="redirect_uris"
191 name="redirect_uris"
192 rows={4}
193 required
194 placeholder="https://example.com/oauth/callback"
195 />
196 <small style="color: var(--text-muted)">
197 One URL per line. HTTPS required (HTTP allowed for localhost).
198 Exact match; no wildcards.
199 </small>
200 </div>
201 <div class="form-group">
202 <label>
203 <input
204 type="checkbox"
205 name="confidential"
206 value="on"
207 checked
208 />
209 {" "}Confidential client (server-side app)
210 </label>
211 <br />
212 <small style="color: var(--text-muted)">
213 Uncheck for public SPA / mobile apps — they must use PKCE
214 instead of a client secret.
215 </small>
216 </div>
217 <button type="submit" class="btn btn-primary">
218 Register app
219 </button>
220 <a
221 href="/settings/applications"
222 class="btn"
223 style="margin-left: 8px"
224 >
225 Cancel
226 </a>
227 </form>
228 </div>
229 </Layout>
230 );
231});
232
233apps.post("/settings/applications/new", async (c) => {
234 const user = c.get("user")!;
235 const body = await c.req.parseBody();
236 const name = String(body.name || "").trim().slice(0, 80);
237 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
238 const description = String(body.description || "").trim().slice(0, 500);
239 const confidential = String(body.confidential || "") === "on";
240 const redirectRaw = String(body.redirect_uris || "");
241
242 if (!name) {
243 return c.redirect("/settings/applications/new?error=Name+is+required");
244 }
245 const parsed = normaliseRedirectUris(redirectRaw);
246 if (!parsed.ok) {
247 return c.redirect(
248 `/settings/applications/new?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
249 );
250 }
251
252 const clientId = generateClientId();
253 const clientSecret = generateClientSecret();
254 const clientSecretHash = await sha256Hex(clientSecret);
255
256 try {
257 const [row] = await db
258 .insert(oauthApps)
259 .values({
260 ownerId: user.id,
261 name,
262 clientId,
263 clientSecretHash,
264 clientSecretPrefix: clientSecret.slice(0, 8),
265 redirectUris: parsed.value!,
266 homepageUrl: homepageUrl || null,
267 description: description || null,
268 confidential,
269 })
270 .returning();
271 await audit({
272 userId: user.id,
273 action: "oauth_app.create",
274 targetType: "oauth_app",
275 targetId: row.id,
276 metadata: { clientId },
277 });
278 // Redirect to the manage page with the plaintext secret appended once.
279 return c.redirect(
280 `/settings/applications/${row.id}?secret=${encodeURIComponent(clientSecret)}&success=App+created`
281 );
282 } catch (err) {
283 console.error("[oauth-apps] create:", err);
284 return c.redirect(
285 "/settings/applications/new?error=Service+unavailable"
286 );
287 }
288});
289
290apps.get("/settings/applications/:id", async (c) => {
291 const user = c.get("user")!;
292 const id = c.req.param("id");
293 const error = c.req.query("error");
294 const success = c.req.query("success");
295 const secret = c.req.query("secret");
296
297 let app: typeof oauthApps.$inferSelect | undefined;
298 try {
299 const [row] = await db
300 .select()
301 .from(oauthApps)
302 .where(and(eq(oauthApps.id, id), eq(oauthApps.ownerId, user.id)))
303 .limit(1);
304 app = row;
305 } catch (err) {
306 console.error("[oauth-apps] get:", err);
307 }
308 if (!app) {
309 return c.redirect("/settings/applications?error=Not+found");
310 }
311
312 return c.html(
313 <Layout title={app.name} user={user}>
314 <div class="settings-container">
315 <div class="breadcrumb">
316 <a href="/settings/applications">applications</a>
317 <span>/</span>
318 <span>{app.name}</span>
319 </div>
320 <h2>{app.name}</h2>
321 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
322 {success && (
323 <div class="auth-success">{decodeURIComponent(success)}</div>
324 )}
325
326 {secret && (
327 <div
328 style="padding: 12px; border: 1px solid var(--yellow); background: rgba(255,193,7,0.1); border-radius: var(--radius); margin-bottom: 16px"
329 >
330 <strong>Save this client secret — it will not be shown again:</strong>
331 <pre
332 style="margin-top: 8px; padding: 8px; background: var(--bg); border-radius: 4px; overflow-x: auto; user-select: all"
333 >
334 {secret}
335 </pre>
336 </div>
337 )}
338
339 <dl style="display: grid; grid-template-columns: 200px 1fr; gap: 8px 16px; margin-bottom: 16px">
340 <dt style="color: var(--text-muted)">Client ID</dt>
341 <dd>
342 <code style="user-select: all">{app.clientId}</code>
343 </dd>
344 <dt style="color: var(--text-muted)">Client secret prefix</dt>
345 <dd>
346 <code>{app.clientSecretPrefix}…</code>
347 </dd>
348 <dt style="color: var(--text-muted)">Type</dt>
349 <dd>{app.confidential ? "Confidential" : "Public (PKCE)"}</dd>
350 <dt style="color: var(--text-muted)">Created</dt>
351 <dd>{new Date(app.createdAt).toLocaleString()}</dd>
352 </dl>
353
354 <form method="POST" action={`/settings/applications/${app.id}`}>
355 <div class="form-group">
356 <label for="name">Application name</label>
357 <input
358 type="text"
359 id="name"
360 name="name"
361 required
362 maxLength={80}
363 defaultValue={app.name}
364 />
365 </div>
366 <div class="form-group">
367 <label for="homepage_url">Homepage URL</label>
368 <input
369 type="url"
370 id="homepage_url"
371 name="homepage_url"
372 defaultValue={app.homepageUrl || ""}
373 />
374 </div>
375 <div class="form-group">
376 <label for="description">Description</label>
377 <textarea
378 id="description"
379 name="description"
380 rows={3}
381 maxLength={500}
382 >
383 {app.description || ""}
384 </textarea>
385 </div>
386 <div class="form-group">
387 <label for="redirect_uris">Authorization callback URLs</label>
388 <textarea
389 id="redirect_uris"
390 name="redirect_uris"
391 rows={4}
392 required
393 >
394 {app.redirectUris}
395 </textarea>
396 </div>
397 <button type="submit" class="btn btn-primary">
398 Save changes
399 </button>
400 </form>
401
402 <hr style="margin: 24px 0; border-color: var(--border)" />
403
404 <h3>Rotate client secret</h3>
405 <p style="color: var(--text-muted); font-size: 13px">
406 Generate a new secret. The old one is invalidated immediately —
407 existing access tokens keep working, but token exchange with the
408 old secret will fail.
409 </p>
410 <form
411 method="POST"
412 action={`/settings/applications/${app.id}/rotate`}
413 onsubmit="return confirm('Rotate the client secret? The old one will stop working immediately.')"
414 >
415 <button type="submit" class="btn">
416 Rotate secret
417 </button>
418 </form>
419
420 <hr style="margin: 24px 0; border-color: var(--border)" />
421
422 <h3 style="color: var(--red)">Danger zone</h3>
423 <form
424 method="POST"
425 action={`/settings/applications/${app.id}/delete`}
426 onsubmit="return confirm('Delete this OAuth app? All issued access tokens will be revoked.')"
427 >
428 <button type="submit" class="btn btn-danger">
429 Delete app
430 </button>
431 </form>
432 </div>
433 </Layout>
434 );
435});
436
437apps.post("/settings/applications/:id", async (c) => {
438 const user = c.get("user")!;
439 const id = c.req.param("id");
440 const body = await c.req.parseBody();
441 const name = String(body.name || "").trim().slice(0, 80);
442 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
443 const description = String(body.description || "").trim().slice(0, 500);
444 const redirectRaw = String(body.redirect_uris || "");
445
446 if (!name) {
447 return c.redirect(
448 `/settings/applications/${id}?error=Name+is+required`
449 );
450 }
451 const parsed = normaliseRedirectUris(redirectRaw);
452 if (!parsed.ok) {
453 return c.redirect(
454 `/settings/applications/${id}?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
455 );
456 }
457 try {
458 const [existing] = await db
459 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
460 .from(oauthApps)
461 .where(eq(oauthApps.id, id))
462 .limit(1);
463 if (!existing || existing.ownerId !== user.id) {
464 return c.redirect("/settings/applications?error=Not+found");
465 }
466 await db
467 .update(oauthApps)
468 .set({
469 name,
470 homepageUrl: homepageUrl || null,
471 description: description || null,
472 redirectUris: parsed.value!,
473 updatedAt: new Date(),
474 })
475 .where(eq(oauthApps.id, id));
476 await audit({
477 userId: user.id,
478 action: "oauth_app.update",
479 targetType: "oauth_app",
480 targetId: id,
481 });
482 return c.redirect(`/settings/applications/${id}?success=Saved`);
483 } catch (err) {
484 console.error("[oauth-apps] update:", err);
485 return c.redirect(
486 `/settings/applications/${id}?error=Service+unavailable`
487 );
488 }
489});
490
491apps.post("/settings/applications/:id/rotate", async (c) => {
492 const user = c.get("user")!;
493 const id = c.req.param("id");
494 try {
495 const [existing] = await db
496 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
497 .from(oauthApps)
498 .where(eq(oauthApps.id, id))
499 .limit(1);
500 if (!existing || existing.ownerId !== user.id) {
501 return c.redirect("/settings/applications?error=Not+found");
502 }
503 const newSecret = generateClientSecret();
504 const newHash = await sha256Hex(newSecret);
505 await db
506 .update(oauthApps)
507 .set({
508 clientSecretHash: newHash,
509 clientSecretPrefix: newSecret.slice(0, 8),
510 updatedAt: new Date(),
511 })
512 .where(eq(oauthApps.id, id));
513 await audit({
514 userId: user.id,
515 action: "oauth_app.rotate_secret",
516 targetType: "oauth_app",
517 targetId: id,
518 });
519 return c.redirect(
520 `/settings/applications/${id}?secret=${encodeURIComponent(newSecret)}&success=Secret+rotated`
521 );
522 } catch (err) {
523 console.error("[oauth-apps] rotate:", err);
524 return c.redirect(
525 `/settings/applications/${id}?error=Service+unavailable`
526 );
527 }
528});
529
530apps.post("/settings/applications/:id/delete", async (c) => {
531 const user = c.get("user")!;
532 const id = c.req.param("id");
533 try {
534 const [existing] = await db
535 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
536 .from(oauthApps)
537 .where(eq(oauthApps.id, id))
538 .limit(1);
539 if (!existing || existing.ownerId !== user.id) {
540 return c.redirect("/settings/applications?error=Not+found");
541 }
542 await db.delete(oauthApps).where(eq(oauthApps.id, id));
543 await audit({
544 userId: user.id,
545 action: "oauth_app.delete",
546 targetType: "oauth_app",
547 targetId: id,
548 });
549 return c.redirect("/settings/applications?success=App+deleted");
550 } catch (err) {
551 console.error("[oauth-apps] delete:", err);
552 return c.redirect(
553 `/settings/applications/${id}?error=Service+unavailable`
554 );
555 }
556});
557
558export default apps;
Addedsrc/routes/orgs.tsx+1372−0View fileUnifiedSplit
Large file (1,372 lines). Load full file
Addedsrc/routes/passkeys.tsx+428−0View fileUnifiedSplit
@@ -0,0 +1,428 @@
1/**
2 * WebAuthn passkey routes (Block B5).
3 *
4 * Registration (authed):
5 * POST /api/passkeys/register/options → challenge + pubkey-cred-params
6 * POST /api/passkeys/register/verify → save credential
7 * GET /settings/passkeys → list + add + rename + delete
8 * POST /settings/passkeys/:id/delete
9 * POST /settings/passkeys/:id/rename
10 *
11 * Authentication (unauthed):
12 * POST /api/passkeys/auth/options → challenge (username optional)
13 * POST /api/passkeys/auth/verify → issues full session on success
14 *
15 * The browser-side glue lives in `/views/components.tsx`
16 * (`PasskeyScript`) — vanilla JS using the native `navigator.credentials` API.
17 */
18
19import { Hono } from "hono";
20import { setCookie } from "hono/cookie";
21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
23import { users, userPasskeys, sessions } from "../db/schema";
24import type { AuthEnv } from "../middleware/auth";
25import { requireAuth } from "../middleware/auth";
26import { Layout } from "../views/layout";
27import {
28 startRegistration,
29 finishRegistration,
30 startAuthentication,
31 finishAuthentication,
32} from "../lib/webauthn";
33import {
34 generateSessionToken,
35 sessionCookieOptions,
36 sessionExpiry,
37} from "../lib/auth";
38import { audit } from "../lib/notify";
39
40const passkeys = new Hono<AuthEnv>();
41
42passkeys.use("/settings/passkeys", requireAuth);
43passkeys.use("/settings/passkeys/*", requireAuth);
44passkeys.use("/api/passkeys/register/*", requireAuth);
45
46// --- Settings UI ------------------------------------------------------------
47
48passkeys.get("/settings/passkeys", async (c) => {
49 const user = c.get("user")!;
50 const error = c.req.query("error");
51 const success = c.req.query("success");
52
53 let keys: (typeof userPasskeys.$inferSelect)[] = [];
54 try {
55 keys = await db
56 .select()
57 .from(userPasskeys)
58 .where(eq(userPasskeys.userId, user.id));
59 } catch (err) {
60 console.error("[passkeys] list:", err);
61 }
62
63 return c.html(
64 <Layout title="Passkeys" user={user}>
65 <div class="settings-container">
66 <div class="breadcrumb">
67 <a href="/settings">settings</a>
68 <span>/</span>
69 <span>passkeys</span>
70 </div>
71 <h2>Passkeys</h2>
72 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
73 {success && (
74 <div class="auth-success">{decodeURIComponent(success)}</div>
75 )}
76 <p style="color: var(--text-muted); font-size: 13px">
77 Passkeys are a phishing-resistant replacement for passwords. Your
78 device stores the private key and never shares it — sign-in is a
79 single Touch ID / Face ID / security-key tap.
80 </p>
81
82 <div style="margin: 16px 0">
83 <button
84 type="button"
85 id="pk-add-btn"
86 class="btn btn-primary"
87 >
88 Add a passkey
89 </button>
90 <span
91 id="pk-add-status"
92 style="color: var(--text-muted); font-size: 13px; margin-left: 8px"
93 />
94 </div>
95
96 <div
97 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
98 >
99 {keys.length === 0 ? (
100 <div
101 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
102 >
103 No passkeys registered yet.
104 </div>
105 ) : (
106 keys.map((k) => (
107 <div
108 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
109 >
110 <div>
111 <strong>{k.name}</strong>
112 <div
113 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
114 >
115 added {new Date(k.createdAt).toLocaleDateString()}
116 {k.lastUsedAt &&
117 ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}`}
118 </div>
119 </div>
120 <div style="display: flex; gap: 6px">
121 <form
122 method="POST"
123 action={`/settings/passkeys/${k.id}/rename`}
124 style="display: flex; gap: 4px"
125 >
126 <input
127 type="text"
128 name="name"
129 defaultValue={k.name}
130 maxLength={60}
131 style="width: 160px"
132 />
133 <button type="submit" class="btn btn-sm">
134 save
135 </button>
136 </form>
137 <form
138 method="POST"
139 action={`/settings/passkeys/${k.id}/delete`}
140 onsubmit="return confirm('Remove this passkey?')"
141 >
142 <button type="submit" class="btn btn-sm btn-danger">
143 remove
144 </button>
145 </form>
146 </div>
147 </div>
148 ))
149 )}
150 </div>
151
152 <script
153 dangerouslySetInnerHTML={{
154 __html: /* js */ `
155 (function () {
156 const btn = document.getElementById('pk-add-btn');
157 const status = document.getElementById('pk-add-status');
158 if (!btn) return;
159 function b64uToBuf(s) {
160 s = s.replace(/-/g,'+').replace(/_/g,'/');
161 while (s.length % 4) s += '=';
162 const bin = atob(s);
163 const buf = new Uint8Array(bin.length);
164 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
165 return buf.buffer;
166 }
167 function bufToB64u(buf) {
168 const bytes = new Uint8Array(buf);
169 let bin = '';
170 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
171 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
172 }
173 btn.addEventListener('click', async function () {
174 if (!window.PublicKeyCredential) {
175 status.textContent = 'Passkeys not supported in this browser.';
176 return;
177 }
178 status.textContent = 'Preparing…';
179 try {
180 const optsRes = await fetch('/api/passkeys/register/options', {
181 method: 'POST',
182 headers: { 'content-type': 'application/json' },
183 body: '{}'
184 });
185 if (!optsRes.ok) throw new Error('options failed');
186 const { options, sessionKey } = await optsRes.json();
187 options.challenge = b64uToBuf(options.challenge);
188 options.user.id = b64uToBuf(options.user.id);
189 if (options.excludeCredentials) {
190 options.excludeCredentials = options.excludeCredentials.map(function (c) {
191 return Object.assign({}, c, { id: b64uToBuf(c.id) });
192 });
193 }
194 status.textContent = 'Touch your authenticator…';
195 const cred = await navigator.credentials.create({ publicKey: options });
196 const resp = {
197 id: cred.id,
198 rawId: bufToB64u(cred.rawId),
199 type: cred.type,
200 response: {
201 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
202 attestationObject: bufToB64u(cred.response.attestationObject),
203 transports: cred.response.getTransports ? cred.response.getTransports() : []
204 },
205 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
206 };
207 const verifyRes = await fetch('/api/passkeys/register/verify', {
208 method: 'POST',
209 headers: { 'content-type': 'application/json' },
210 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
211 });
212 if (!verifyRes.ok) {
213 const j = await verifyRes.json().catch(() => ({}));
214 throw new Error(j.error || 'verify failed');
215 }
216 status.textContent = 'Saved. Reloading…';
217 window.location.reload();
218 } catch (e) {
219 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
220 }
221 });
222 })();
223 `,
224 }}
225 />
226 </div>
227 </Layout>
228 );
229});
230
231passkeys.post("/settings/passkeys/:id/delete", async (c) => {
232 const user = c.get("user")!;
233 const id = c.req.param("id");
234 try {
235 const [row] = await db
236 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
237 .from(userPasskeys)
238 .where(eq(userPasskeys.id, id))
239 .limit(1);
240 if (!row || row.userId !== user.id) {
241 return c.redirect("/settings/passkeys?error=Not+found");
242 }
243 await db.delete(userPasskeys).where(eq(userPasskeys.id, id));
244 await audit({
245 userId: user.id,
246 action: "passkey.delete",
247 targetType: "passkey",
248 targetId: id,
249 });
250 return c.redirect("/settings/passkeys?success=Passkey+removed");
251 } catch (err) {
252 console.error("[passkeys] delete:", err);
253 return c.redirect("/settings/passkeys?error=Service+unavailable");
254 }
255});
256
257passkeys.post("/settings/passkeys/:id/rename", async (c) => {
258 const user = c.get("user")!;
259 const id = c.req.param("id");
260 const body = await c.req.parseBody();
261 const name = String(body.name || "").trim().slice(0, 60);
262 if (!name) {
263 return c.redirect("/settings/passkeys?error=Name+required");
264 }
265 try {
266 const [row] = await db
267 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
268 .from(userPasskeys)
269 .where(eq(userPasskeys.id, id))
270 .limit(1);
271 if (!row || row.userId !== user.id) {
272 return c.redirect("/settings/passkeys?error=Not+found");
273 }
274 await db
275 .update(userPasskeys)
276 .set({ name })
277 .where(eq(userPasskeys.id, id));
278 return c.redirect("/settings/passkeys?success=Renamed");
279 } catch (err) {
280 console.error("[passkeys] rename:", err);
281 return c.redirect("/settings/passkeys?error=Service+unavailable");
282 }
283});
284
285// --- Registration JSON endpoints (authed) -----------------------------------
286
287passkeys.post("/api/passkeys/register/options", async (c) => {
288 const user = c.get("user")!;
289 try {
290 const existing = await db
291 .select({ credentialId: userPasskeys.credentialId })
292 .from(userPasskeys)
293 .where(eq(userPasskeys.userId, user.id));
294 const { options, sessionKey } = await startRegistration({
295 userId: user.id,
296 userName: user.username,
297 userDisplayName: user.displayName || user.username,
298 excludeCredentialIds: existing.map((e) => e.credentialId),
299 });
300 return c.json({ options, sessionKey });
301 } catch (err) {
302 console.error("[passkeys] register/options:", err);
303 return c.json({ error: "Service unavailable" }, 503);
304 }
305});
306
307passkeys.post("/api/passkeys/register/verify", async (c) => {
308 const user = c.get("user")!;
309 let body: { sessionKey: string; response: any };
310 try {
311 body = await c.req.json();
312 } catch {
313 return c.json({ error: "Invalid JSON" }, 400);
314 }
315 if (!body.sessionKey || !body.response) {
316 return c.json({ error: "sessionKey and response required" }, 400);
317 }
318 const result = await finishRegistration({
319 sessionKey: body.sessionKey,
320 response: body.response,
321 });
322 if (!result.ok) return c.json({ error: result.error }, 400);
323
324 try {
325 const transports = Array.isArray(body.response?.response?.transports)
326 ? JSON.stringify(body.response.response.transports)
327 : null;
328 await db.insert(userPasskeys).values({
329 userId: user.id,
330 credentialId: result.credentialId,
331 publicKey: result.publicKey,
332 counter: result.counter,
333 transports,
334 });
335 await audit({
336 userId: user.id,
337 action: "passkey.create",
338 targetType: "passkey",
339 metadata: { credentialId: result.credentialId },
340 });
341 return c.json({ ok: true });
342 } catch (err: any) {
343 if (String(err?.message || err).includes("user_passkeys")) {
344 return c.json({ error: "Credential already registered" }, 409);
345 }
346 console.error("[passkeys] register save:", err);
347 return c.json({ error: "Service unavailable" }, 503);
348 }
349});
350
351// --- Authentication JSON endpoints (unauthed) -------------------------------
352
353passkeys.post("/api/passkeys/auth/options", async (c) => {
354 let body: { username?: string };
355 try {
356 body = await c.req.json();
357 } catch {
358 body = {};
359 }
360 try {
361 let userId: string | undefined;
362 let allowCreds: string[] = [];
363 if (body.username) {
364 const [u] = await db
365 .select({ id: users.id })
366 .from(users)
367 .where(eq(users.username, body.username.trim().toLowerCase()))
368 .limit(1);
369 if (u) {
370 userId = u.id;
371 const rows = await db
372 .select({ credentialId: userPasskeys.credentialId })
373 .from(userPasskeys)
374 .where(eq(userPasskeys.userId, u.id));
375 allowCreds = rows.map((r) => r.credentialId);
376 }
377 }
378 const { options, sessionKey } = await startAuthentication({
379 userId,
380 allowCredentialIds: allowCreds,
381 });
382 return c.json({ options, sessionKey });
383 } catch (err) {
384 console.error("[passkeys] auth/options:", err);
385 return c.json({ error: "Service unavailable" }, 503);
386 }
387});
388
389passkeys.post("/api/passkeys/auth/verify", async (c) => {
390 let body: { sessionKey: string; response: any };
391 try {
392 body = await c.req.json();
393 } catch {
394 return c.json({ error: "Invalid JSON" }, 400);
395 }
396 if (!body.sessionKey || !body.response) {
397 return c.json({ error: "sessionKey and response required" }, 400);
398 }
399 const result = await finishAuthentication({
400 sessionKey: body.sessionKey,
401 response: body.response,
402 });
403 if (!result.ok) return c.json({ error: result.error }, 400);
404
405 try {
406 // Passkey is phishing-resistant + user-verifying; skip TOTP prompt.
407 const token = generateSessionToken();
408 await db.insert(sessions).values({
409 userId: result.userId,
410 token,
411 expiresAt: sessionExpiry(),
412 requires2fa: false,
413 });
414 setCookie(c, "session", token, sessionCookieOptions());
415 await audit({
416 userId: result.userId,
417 action: "passkey.login",
418 targetType: "passkey",
419 metadata: { credentialId: result.credentialId },
420 });
421 return c.json({ ok: true });
422 } catch (err) {
423 console.error("[passkeys] auth/verify:", err);
424 return c.json({ error: "Service unavailable" }, 503);
425 }
426});
427
428export default passkeys;
Addedsrc/routes/settings-2fa.tsx+446−0View fileUnifiedSplit
@@ -0,0 +1,446 @@
1/**
2 * 2FA settings (Block B4).
3 *
4 * Routes:
5 * GET /settings/2fa status + recovery code management
6 * POST /settings/2fa/enroll generate a pending secret, show QR
7 * GET /settings/2fa/enroll same as POST (for bookmarks)
8 * POST /settings/2fa/confirm verify first code, flip enabled
9 * POST /settings/2fa/disable require password + disable + wipe
10 * POST /settings/2fa/recovery/regen regenerate recovery codes
11 */
12
13import { Hono } from "hono";
14import { eq } from "drizzle-orm";
15import { db } from "../db";
16import { users, userTotp, userRecoveryCodes } from "../db/schema";
17import type { AuthEnv } from "../middleware/auth";
18import { requireAuth } from "../middleware/auth";
19import { Layout } from "../views/layout";
20import { verifyPassword } from "../lib/auth";
21import {
22 generateTotpSecret,
23 otpauthUrl,
24 verifyTotpCode,
25 generateRecoveryCodes,
26 hashRecoveryCode,
27} from "../lib/totp";
28import { audit } from "../lib/notify";
29import { config } from "../lib/config";
30
31const settings2fa = new Hono<AuthEnv>();
32
33settings2fa.use("/settings/2fa", requireAuth);
34settings2fa.use("/settings/2fa/*", requireAuth);
35
36function errorRedirect(path: string, msg: string) {
37 return `${path}?error=${encodeURIComponent(msg)}`;
38}
39
40/** Status page: either "off" (offer enroll), "pending" (finish enrol), "on" (disable + manage codes). */
41settings2fa.get("/settings/2fa", async (c) => {
42 const user = c.get("user")!;
43 const error = c.req.query("error");
44 const success = c.req.query("success");
45
46 let state: "off" | "pending" | "on" = "off";
47 try {
48 const [row] = await db
49 .select({ enabledAt: userTotp.enabledAt })
50 .from(userTotp)
51 .where(eq(userTotp.userId, user.id))
52 .limit(1);
53 if (row) state = row.enabledAt ? "on" : "pending";
54 } catch (err) {
55 console.error("[2fa] status:", err);
56 }
57
58 let unusedRecovery = 0;
59 try {
60 const rows = await db
61 .select({ usedAt: userRecoveryCodes.usedAt })
62 .from(userRecoveryCodes)
63 .where(eq(userRecoveryCodes.userId, user.id));
64 unusedRecovery = rows.filter((r) => !r.usedAt).length;
65 } catch {
66 /* ignore */
67 }
68
69 return c.html(
70 <Layout title="Two-factor authentication" user={user}>
71 <div class="settings-container">
72 <div class="breadcrumb">
73 <a href="/settings">settings</a>
74 <span>/</span>
75 <span>2fa</span>
76 </div>
77 <h2>Two-factor authentication</h2>
78 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
79 {success && (
80 <div class="auth-success">{decodeURIComponent(success)}</div>
81 )}
82 <p style="color: var(--text-muted); font-size: 13px">
83 Require a 6-digit code from your authenticator app on every sign-in.
84 Works with Google Authenticator, 1Password, Bitwarden, Authy, and any
85 other TOTP-compatible app.
86 </p>
87
88 {state === "off" && (
89 <form method="POST" action="/settings/2fa/enroll">
90 <button type="submit" class="btn btn-primary">
91 Enable two-factor authentication
92 </button>
93 </form>
94 )}
95
96 {state === "pending" && (
97 <>
98 <p
99 style="background: rgba(210, 153, 34, 0.1); border: 1px solid var(--yellow, #d29922); padding: 8px 12px; border-radius: var(--radius); color: var(--yellow, #d29922); font-size: 13px"
100 >
101 Enrolment started but not confirmed. Finish by entering a code
102 from your authenticator below.
103 </p>
104 <a href="/settings/2fa/enroll" class="btn btn-primary">
105 Continue enrolment
106 </a>
107 </>
108 )}
109
110 {state === "on" && (
111 <>
112 <div
113 style="background: rgba(63, 185, 80, 0.1); border: 1px solid var(--green); padding: 8px 12px; border-radius: var(--radius); color: var(--green); font-size: 13px; margin-bottom: 16px"
114 >
115 Two-factor authentication is enabled.
116 </div>
117 <h3 style="font-size: 15px; margin-top: 16px">Recovery codes</h3>
118 <p style="color: var(--text-muted); font-size: 13px">
119 {unusedRecovery} unused recovery code
120 {unusedRecovery === 1 ? "" : "s"} remaining. Each code can be
121 used once if you lose access to your authenticator.
122 </p>
123 <form
124 method="POST"
125 action="/settings/2fa/recovery/regen"
126 style="display: inline-block; margin-right: 8px"
127 onsubmit="return confirm('Regenerate recovery codes? Your existing codes will stop working.')"
128 >
129 <button type="submit" class="btn">
130 Regenerate recovery codes
131 </button>
132 </form>
133
134 <h3 style="font-size: 15px; margin-top: 24px">Disable</h3>
135 <p style="color: var(--text-muted); font-size: 13px">
136 Confirm your password to turn off 2FA.
137 </p>
138 <form method="POST" action="/settings/2fa/disable">
139 <div class="form-group" style="max-width: 320px">
140 <label for="password">Password</label>
141 <input
142 type="password"
143 name="password"
144 required
145 autocomplete="current-password"
146 />
147 </div>
148 <button type="submit" class="btn btn-danger">
149 Disable two-factor authentication
150 </button>
151 </form>
152 </>
153 )}
154 </div>
155 </Layout>
156 );
157});
158
159/** Generate (or re-use pending) secret + show the QR enrolment page. */
160async function showEnrolPage(c: any, user: any, error?: string) {
161 let secret: string;
162 try {
163 const [existing] = await db
164 .select()
165 .from(userTotp)
166 .where(eq(userTotp.userId, user.id))
167 .limit(1);
168 if (existing && !existing.enabledAt) {
169 secret = existing.secret;
170 } else if (existing && existing.enabledAt) {
171 return c.redirect(
172 errorRedirect("/settings/2fa", "2FA is already enabled")
173 );
174 } else {
175 secret = generateTotpSecret();
176 await db.insert(userTotp).values({ userId: user.id, secret });
177 }
178 } catch (err) {
179 console.error("[2fa] enroll:", err);
180 return c.redirect(errorRedirect("/settings/2fa", "Service unavailable"));
181 }
182
183 const url = otpauthUrl({
184 secret,
185 accountName: user.email || user.username,
186 issuer: "gluecron",
187 });
188 // Render a simple data URL QR via a public chart service fallback —
189 // but we avoid external deps and instead show the secret + URL so any
190 // authenticator can be set up manually. Apps scan otpauth:// directly.
191 return c.html(
192 <Layout title="Enable 2FA" user={user}>
193 <div class="settings-container">
194 <div class="breadcrumb">
195 <a href="/settings">settings</a>
196 <span>/</span>
197 <a href="/settings/2fa">2fa</a>
198 <span>/</span>
199 <span>enrol</span>
200 </div>
201 <h2>Set up your authenticator</h2>
202 {error && <div class="auth-error">{error}</div>}
203 <ol
204 style="color: var(--text-muted); font-size: 14px; line-height: 1.6; padding-left: 20px"
205 >
206 <li>
207 Open your authenticator app (Google Authenticator, 1Password,
208 Bitwarden, Authy, etc).
209 </li>
210 <li>
211 Add a new entry. Either scan the otpauth link below with a QR
212 reader, or type in the secret key manually.
213 </li>
214 <li>Enter the 6-digit code the app shows to confirm.</li>
215 </ol>
216 <div
217 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; margin: 16px 0"
218 >
219 <div style="font-size: 12px; color: var(--text-muted)">Secret key</div>
220 <code
221 style="font-size: 14px; font-family: monospace; word-break: break-all"
222 >
223 {secret}
224 </code>
225 <div
226 style="font-size: 12px; color: var(--text-muted); margin-top: 12px"
227 >
228 otpauth URL (for QR apps)
229 </div>
230 <code
231 style="font-size: 12px; font-family: monospace; word-break: break-all; color: var(--text)"
232 >
233 {url}
234 </code>
235 </div>
236 <form method="POST" action="/settings/2fa/confirm">
237 <div class="form-group" style="max-width: 280px">
238 <label for="code">6-digit code</label>
239 <input
240 type="text"
241 name="code"
242 required
243 pattern="[0-9]{6}"
244 inputmode="numeric"
245 autocomplete="one-time-code"
246 maxLength={6}
247 />
248 </div>
249 <button type="submit" class="btn btn-primary">
250 Confirm + enable
251 </button>
252 </form>
253 </div>
254 </Layout>
255 );
256}
257
258settings2fa.get("/settings/2fa/enroll", async (c) => {
259 const user = c.get("user")!;
260 return showEnrolPage(c, user);
261});
262
263settings2fa.post("/settings/2fa/enroll", async (c) => {
264 const user = c.get("user")!;
265 return showEnrolPage(c, user);
266});
267
268/** Verify the first code + flip enabled. Also mint recovery codes. */
269settings2fa.post("/settings/2fa/confirm", async (c) => {
270 const user = c.get("user")!;
271 const body = await c.req.parseBody();
272 const code = String(body.code || "").trim();
273
274 if (!/^\d{6}$/.test(code)) {
275 return c.redirect(
276 errorRedirect("/settings/2fa/enroll", "Enter the 6-digit code")
277 );
278 }
279
280 try {
281 const [row] = await db
282 .select()
283 .from(userTotp)
284 .where(eq(userTotp.userId, user.id))
285 .limit(1);
286 if (!row || row.enabledAt) {
287 return c.redirect("/settings/2fa");
288 }
289 const ok = await verifyTotpCode(row.secret, code);
290 if (!ok) {
291 return c.redirect(
292 errorRedirect(
293 "/settings/2fa/enroll",
294 "Code did not verify — try again"
295 )
296 );
297 }
298 await db
299 .update(userTotp)
300 .set({ enabledAt: new Date(), lastUsedAt: new Date() })
301 .where(eq(userTotp.userId, user.id));
302
303 // Mint + store recovery codes
304 const codes = generateRecoveryCodes(10);
305 const hashes = await Promise.all(codes.map(hashRecoveryCode));
306 await db.delete(userRecoveryCodes).where(eq(userRecoveryCodes.userId, user.id));
307 await db.insert(userRecoveryCodes).values(
308 hashes.map((h) => ({ userId: user.id, codeHash: h }))
309 );
310
311 await audit({
312 userId: user.id,
313 action: "2fa.enable",
314 targetType: "user",
315 targetId: user.id,
316 });
317
318 return c.html(
319 <Layout title="Save your recovery codes" user={user}>
320 <div class="settings-container">
321 <div class="breadcrumb">
322 <a href="/settings">settings</a>
323 <span>/</span>
324 <a href="/settings/2fa">2fa</a>
325 <span>/</span>
326 <span>recovery codes</span>
327 </div>
328 <h2>Save your recovery codes</h2>
329 <div
330 style="background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); color: var(--red); padding: 8px 12px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px"
331 >
332 These codes are shown only once. Store them somewhere safe — a
333 password manager, a printed copy. Each code works once.
334 </div>
335 <pre
336 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-family: monospace; font-size: 14px; line-height: 1.6"
337 >
338{codes.join("\n")}
339 </pre>
340 <a href="/settings/2fa" class="btn btn-primary">
341 I've saved them
342 </a>
343 </div>
344 </Layout>
345 );
346 } catch (err) {
347 console.error("[2fa] confirm:", err);
348 return c.redirect(
349 errorRedirect("/settings/2fa/enroll", "Service unavailable")
350 );
351 }
352});
353
354settings2fa.post("/settings/2fa/disable", async (c) => {
355 const user = c.get("user")!;
356 const body = await c.req.parseBody();
357 const password = String(body.password || "");
358 if (!password) {
359 return c.redirect(
360 errorRedirect("/settings/2fa", "Password is required")
361 );
362 }
363 try {
364 const [u] = await db
365 .select({ passwordHash: users.passwordHash })
366 .from(users)
367 .where(eq(users.id, user.id))
368 .limit(1);
369 if (!u || !(await verifyPassword(password, u.passwordHash))) {
370 return c.redirect(errorRedirect("/settings/2fa", "Invalid password"));
371 }
372 await db.delete(userTotp).where(eq(userTotp.userId, user.id));
373 await db
374 .delete(userRecoveryCodes)
375 .where(eq(userRecoveryCodes.userId, user.id));
376 await audit({
377 userId: user.id,
378 action: "2fa.disable",
379 targetType: "user",
380 targetId: user.id,
381 });
382 return c.redirect("/settings/2fa?success=Two-factor+disabled");
383 } catch (err) {
384 console.error("[2fa] disable:", err);
385 return c.redirect(errorRedirect("/settings/2fa", "Service unavailable"));
386 }
387});
388
389settings2fa.post("/settings/2fa/recovery/regen", async (c) => {
390 const user = c.get("user")!;
391 try {
392 const [row] = await db
393 .select({ enabledAt: userTotp.enabledAt })
394 .from(userTotp)
395 .where(eq(userTotp.userId, user.id))
396 .limit(1);
397 if (!row || !row.enabledAt) {
398 return c.redirect(
399 errorRedirect("/settings/2fa", "Enable 2FA first")
400 );
401 }
402 const codes = generateRecoveryCodes(10);
403 const hashes = await Promise.all(codes.map(hashRecoveryCode));
404 await db
405 .delete(userRecoveryCodes)
406 .where(eq(userRecoveryCodes.userId, user.id));
407 await db.insert(userRecoveryCodes).values(
408 hashes.map((h) => ({ userId: user.id, codeHash: h }))
409 );
410 await audit({
411 userId: user.id,
412 action: "2fa.recovery.regenerate",
413 targetType: "user",
414 targetId: user.id,
415 });
416 return c.html(
417 <Layout title="New recovery codes" user={user}>
418 <div class="settings-container">
419 <h2>New recovery codes</h2>
420 <div
421 style="background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); color: var(--red); padding: 8px 12px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px"
422 >
423 Store these somewhere safe — the previous codes no longer work.
424 </div>
425 <pre
426 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-family: monospace; font-size: 14px; line-height: 1.6"
427 >
428{codes.join("\n")}
429 </pre>
430 <a href="/settings/2fa" class="btn btn-primary">
431 Done
432 </a>
433 </div>
434 </Layout>
435 );
436 } catch (err) {
437 console.error("[2fa] regen:", err);
438 return c.redirect(errorRedirect("/settings/2fa", "Service unavailable"));
439 }
440});
441
442// Keep the import-check happy — config is intentionally available for
443// future issuer customisation.
444void config;
445
446export default settings2fa;
0447