Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Passkey sign-in, Kumo design tokens, and Access only for recovery

People now sign in with passkeys (WebAuthn) and get a session cookie.
Invite links let new people pick a username and create a passkey in one
step. Site admins can issue one-time sign-in links for lost passkeys,
and Cloudflare Access now guards only /auth/access as email recovery.
Settings gains a Passkeys and sessions page. The web UI uses Kumo's
color tokens, radii and focus styles.

A new end-to-end test drives Chromium with a virtual authenticator
through invites, adding and removing passkeys, signing out and in, and
sign-in links. Team preferences are written down in docs/PREFERENCES.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent aa9f371commit 9593c9d1b17d86d0458d3c9c9647cab185164c06Browse files
18 files changed+1537−112
Files (18)
modified.github/workflows/ci.yml+2−1View file
@@ -36,7 +36,7 @@
3636 run: pnpm exec wrangler deploy --dry-run --env dev --outdir dist
3737
3838 e2e:
39- name: End-to-end (git, PRs, Actions)
39+ name: End-to-end (git, PRs, Actions, passkeys)
4040 runs-on: ubuntu-latest
4141 timeout-minutes: 20
4242 steps:
@@ -48,4 +48,5 @@
4848 cache: pnpm
4949 - run: pnpm install --frozen-lockfile
5050 - run: pnpm -r --if-present build
51+ - run: pnpm --filter @diffinery/app exec playwright install --with-deps chromium
5152 - run: pnpm --filter @diffinery/app e2e
modifiedREADME.md+34−18View file
@@ -13,8 +13,8 @@
1313 branches, and their pull requests always need a human approval. Every commit
1414 they make is stamped with who they act for. They work through MCP
1515 (`/mcp`) or the REST API, and can start sandboxes to build and test.
16-- **Secure by default.** Cloudflare Access handles sign-in, while invites,
17- roles and branch protection decide what people can do. Pushes are scanned for
16+- **Secure by default.** People sign in with passkeys, with no passwords to
17+ leak or phish. Invites, roles and branch protection decide what they can do. Pushes are scanned for
1818 leaked credentials. Secrets use envelope encryption, and every sensitive
1919 action goes to an append-only audit log.
2020
@@ -31,7 +31,7 @@
3131 | Job runners and agent sandboxes | Containers (Ubuntu 24.04 image in `containers/runner`) |
3232 | Scheduled workflows (`on: schedule`) | Cron Triggers |
3333 | Live run logs and PR updates | Durable Object WebSockets (hibernation) |
34-| Sign-in | Cloudflare Access (GitHub, Google, email one-time PIN, etc.) |
34+| Sign-in | Passkeys (WebAuthn) and sessions in D1. Cloudflare Access guards only `/auth/access`, the email recovery path |
3535
3636 ```
3737 apps/diffinery the Worker (src/index.ts), D1 migrations, wrangler config, e2e test
@@ -54,9 +54,8 @@
5454 - `CLOUDFLARE_ACCOUNT_ID`
5555
5656 On each deploy, `scripts/bootstrap.sh` creates anything that is missing: the D1
57-database, R2 bucket, queues and the two Access applications (sign-in for the
58-site, and a bypass for the git, API, MCP and runner paths, which check
59-Diffinery tokens themselves). It then applies D1 migrations. `wrangler deploy`
57+database, R2 bucket, queues and one Access application that covers only
58+`/auth/access` (email-based account recovery). It then applies D1 migrations. `wrangler deploy`
6059 builds and pushes the runner image. On the first deploy, `ensure-secrets.sh`
6160 generates `MASTER_KEY` and the OIDC signing key inside the job, so neither
6261 value is ever printed.
@@ -64,21 +63,32 @@
6463 If the token can't read your Zero Trust organization, set a repository
6564 **variable** `ACCESS_TEAM_DOMAIN` (for example `codician.cloudflareaccess.com`).
6665
67-To let people sign in with GitHub, add GitHub as a login method under Zero
68-Trust → Settings → Authentication. Email one-time PIN works without any setup.
66+Email recovery uses Access's email one-time PIN, which works without any setup.
67+You can also add GitHub as a login method under Zero Trust → Settings → Authentication.
6968
70-## Inviting people
69+## Signing in and inviting people
7170
72-1. The emails in `BOOTSTRAP_ADMINS` (in `apps/diffinery/wrangler.jsonc`)
73- become site admins the first time they sign in.
74-2. Anyone else who signs in lands on a waiting page. They can request access,
75- which admins approve, or an admin or org owner can send an invite from
76- *Settings → People* or with `diffinery invite friend@example.com --org codician`.
77-3. Repository and org roles (read, triage, write, maintain, admin) decide what
71+Everyone signs in with a passkey. There are no passwords.
72+
73+1. **First sign-in for admins.** The emails in `BOOTSTRAP_ADMINS` (in
74+ `apps/diffinery/wrangler.jsonc`) become site admins. Open
75+ `https://diffinery.com/auth/access`, confirm your email with the one-time
76+ code, and add a passkey on the page that follows.
77+2. **Inviting someone.** An admin or org owner creates an invite from
78+ *Settings → Site administration*, an organization's *People* tab, or
79+ `diffinery invite friend@example.com --org codician`. The person opens the
80+ link, picks a username and creates a passkey, and they're in.
81+3. **Lost passkey.** A site admin clicks *Sign-in link* next to the person in
82+ *Settings → Site administration* and sends them the one-time link, which
83+ works for 24 hours. People can also recover through `/auth/access` by email.
84+4. **More devices.** Add passkeys under *Settings → Passkeys and sessions*, or
85+ keep one in a password manager that syncs. The same page signs out other
86+ browsers.
87+5. Repository and org roles (read, triage, write, maintain, admin) decide what
7888 each person can do.
7989
80-Nobody needs a Cloudflare account. They need an Access login and, for git and
81-the CLI, a personal token from *Settings → Tokens*.
90+Nobody needs a Cloudflare account. Git and the CLI use a personal token from
91+*Settings → Tokens*.
8292
8393 ```sh
8494 git clone https://diffinery.com/codician/website.git # username: anything, password: your dfy_ token
@@ -143,7 +153,7 @@
143153 pnpm -r build && pnpm -r typecheck && pnpm -r test
144154 pnpm --filter @diffinery/app db:migrate:local
145155 pnpm dev # http://localhost:8787, signed in as dev@diffinery.local
146-pnpm --filter @diffinery/app e2e # wrangler dev + real git + the runner agent
156+pnpm --filter @diffinery/app e2e # wrangler dev + real git + the runner agent + Chromium
147157 ```
148158
149159 The end-to-end test does the following:
@@ -155,3 +165,9 @@
155165 check, then merges it.
156166 5. Clones shallow, then deepens the clone.
157167 6. Drives the MCP server.
168+7. In Chromium with a virtual authenticator, joins from an invite with a new
169+ passkey, adds and removes passkeys, signs out and in, and uses an admin
170+ sign-in link.
171+
172+Team preferences for product and design decisions are in
173+[docs/PREFERENCES.md](docs/PREFERENCES.md).
addedapps/diffinery/migrations/0002_passkeys.sql+46−0View file
@@ -1,0 +1,46 @@
1+-- Passkey (WebAuthn) sign-in, browser sessions and one-time sign-in links.
2+
3+CREATE TABLE passkeys (
4+ id TEXT PRIMARY KEY, -- credential id, base64url
5+ account_id TEXT NOT NULL REFERENCES accounts(id),
6+ public_key TEXT NOT NULL, -- COSE public key, base64url
7+ counter INTEGER NOT NULL DEFAULT 0,
8+ transports TEXT, -- JSON array
9+ device_type TEXT NOT NULL, -- singleDevice | multiDevice
10+ backed_up INTEGER NOT NULL DEFAULT 0,
11+ name TEXT NOT NULL,
12+ created_at TEXT NOT NULL,
13+ last_used_at TEXT
14+);
15+CREATE INDEX passkeys_account ON passkeys(account_id);
16+
17+CREATE TABLE sessions (
18+ id TEXT PRIMARY KEY, -- sha256 of the cookie value
19+ account_id TEXT NOT NULL REFERENCES accounts(id),
20+ method TEXT NOT NULL, -- passkey | access | link
21+ passkey_id TEXT,
22+ user_agent TEXT,
23+ created_at TEXT NOT NULL,
24+ last_seen_at TEXT NOT NULL,
25+ expires_at TEXT NOT NULL
26+);
27+CREATE INDEX sessions_account ON sessions(account_id);
28+
29+CREATE TABLE webauthn_challenges (
30+ id TEXT PRIMARY KEY, -- sha256 of the browser cookie value
31+ purpose TEXT NOT NULL CHECK (purpose IN ('login', 'register')),
32+ challenge TEXT NOT NULL,
33+ subject TEXT NOT NULL, -- JSON: who the passkey is for
34+ expires_at TEXT NOT NULL
35+);
36+
37+-- Admin-issued, single-use links that let someone add a passkey to an existing account.
38+CREATE TABLE signin_links (
39+ id TEXT PRIMARY KEY,
40+ token_hash TEXT NOT NULL UNIQUE,
41+ account_id TEXT NOT NULL REFERENCES accounts(id),
42+ created_by TEXT NOT NULL REFERENCES accounts(id),
43+ created_at TEXT NOT NULL,
44+ expires_at TEXT NOT NULL,
45+ used_at TEXT
46+);
modifiedapps/diffinery/package.json+3−1View file
@@ -9,7 +9,7 @@
99 "typecheck": "tsc --noEmit",
1010 "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'",
1111 "cf-typegen": "wrangler types",
12- "e2e": "node --test --test-timeout=600000 test/e2e/e2e.test.mjs",
12+ "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs",
1313 "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev"
1414 },
1515 "dependencies": {
@@ -17,11 +17,13 @@
1717 "@diffinery/git-core": "workspace:*",
1818 "@diffinery/shared": "workspace:*",
1919 "@diffinery/workflow-yaml": "workspace:*",
20+ "@simplewebauthn/server": "14.0.3",
2021 "hono": "^4.13.0"
2122 },
2223 "devDependencies": {
2324 "@cloudflare/vitest-pool-workers": "^0.23.0",
2425 "@cloudflare/workers-types": "^5.20261012.1",
26+ "playwright": "1.56.1",
2527 "typescript": "^5.9.3",
2628 "vitest": "^4.1.0",
2729 "wrangler": "^4.149.0"
modifiedapps/diffinery/src/auth/principal.ts+9−12View file
@@ -1,13 +1,13 @@
11 import type { Context } from "hono";
22 import type { Env } from "../env.js";
33 import { Directory, type Account, type Repo, type TokenScope } from "../lib/directory.js";
4-import { verifyAccessJwt } from "../lib/jwt.js";
4+import { Auth, SESSION_COOKIE, cookieFrom } from "./sessions.js";
55 import { HttpError, fromB64 } from "../lib/util.js";
66 import type { Caller, Role } from "../repo/types.js";
77 import { ROLE_LEVEL } from "../repo/types.js";
88
99 export type Principal =
10- | { type: "account"; account: Account; via: "access" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null }
10+ | { type: "account"; account: Account; via: "session" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null; sessionId?: string }
1111 | { type: "repo-token"; repoId: string; token: string }
1212 | { type: "anonymous" };
1313
@@ -35,7 +35,8 @@
3535 return null;
3636 }
3737
38-function accessToken(req: Request): string | null {
38+/** The Cloudflare Access assertion, present only on the Access-protected recovery path. */
39+export function accessToken(req: Request): string | null {
3940 const h = req.headers.get("cf-access-jwt-assertion");
4041 if (h) return h;
4142 const cookie = req.headers.get("cookie") ?? "";
@@ -58,15 +59,11 @@
5859 }
5960 throw new HttpError(401, "unrecognised token; create one under Settings → Tokens");
6061 }
61- const jwt = accessToken(req);
62- if (jwt && env.ACCESS_TEAM_DOMAIN && env.ACCESS_AUD) {
63- const claims = await verifyAccessJwt(jwt, env.ACCESS_TEAM_DOMAIN, env.ACCESS_AUD).catch((e) => {
64- throw new HttpError(401, `Access token rejected: ${(e as Error).message}`);
65- });
66- const email = typeof claims.email === "string" ? claims.email : null;
67- if (!email) throw new HttpError(401, "Access token has no email; service tokens must use a Diffinery token");
68- const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined });
69- return { type: "account", account, via: "access", scopes: ["admin"], repoIds: null };
62+ // Browsers sign in with a passkey (or the Access recovery path) and carry a session cookie.
63+ const session = cookieFrom(req, SESSION_COOKIE);
64+ if (session) {
65+ const s = await new Auth(env).session(session);
66+ if (s) return { type: "account", account: s.account, via: "session", scopes: ["admin"], repoIds: null, sessionId: s.session.id };
7067 }
7168 if (env.DEV_AUTH && !env.ACCESS_AUD) {
7269 const account = await dir.userForEmail(env.DEV_AUTH);
addedapps/diffinery/src/auth/sessions.ts+209−0View file
@@ -1,0 +1,209 @@
1+import type { Env } from "../env.js";
2+import type { Account } from "../lib/directory.js";
3+import { newId, now, randomToken, sha256 } from "../lib/util.js";
4+
5+/** Browser session cookie. `__Host-` pins it to this origin: Secure, Path=/, no Domain. */
6+export const SESSION_COOKIE = "__Host-dfy_session";
7+/** Binds an in-flight WebAuthn ceremony to the browser that started it. */
8+export const CHALLENGE_COOKIE = "__Host-dfy_webauthn";
9+export const SESSION_DAYS = 30;
10+const CHALLENGE_MINUTES = 5;
11+const LINK_HOURS = 24;
12+
13+export type PasskeyRow = {
14+ id: string;
15+ account_id: string;
16+ public_key: string;
17+ counter: number;
18+ transports: string | null;
19+ device_type: string;
20+ backed_up: number;
21+ name: string;
22+ created_at: string;
23+ last_used_at: string | null;
24+};
25+
26+export type SessionRow = {
27+ id: string;
28+ account_id: string;
29+ method: string;
30+ passkey_id: string | null;
31+ user_agent: string | null;
32+ created_at: string;
33+ last_seen_at: string;
34+ expires_at: string;
35+};
36+
37+export type InviteRow = { id: string; email: string | null; org_id: string | null; repo_id: string | null; role: string };
38+
39+/** Who a passkey being registered will belong to. */
40+export type Subject =
41+ | { kind: "account"; accountId: string }
42+ | { kind: "invite"; inviteHash: string; email: string; login: string; name: string; accountId: string }
43+ | { kind: "link"; linkHash: string; accountId: string }
44+ | { kind: "login" };
45+
46+const days = (n: number): string => new Date(Date.now() + n * 86400_000).toISOString();
47+
48+/** Sessions, passkeys, WebAuthn challenges and sign-in links in D1. */
49+export class Auth {
50+ constructor(private env: Env) {}
51+
52+ private get db() {
53+ return this.env.DB;
54+ }
55+
56+ /** Relying party: passkeys are bound to the hostname of PUBLIC_URL. */
57+ rp(): { id: string; origin: string; name: string } {
58+ const u = new URL(this.env.PUBLIC_URL);
59+ return { id: u.hostname, origin: u.origin, name: "Diffinery" };
60+ }
61+
62+ // ---------------------------------------------------------------- sessions
63+
64+ async createSession(accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null, userAgent: string | null): Promise<string> {
65+ const token = randomToken(32);
66+ const at = now();
67+ await this.db
68+ .prepare("INSERT INTO sessions (id, account_id, method, passkey_id, user_agent, created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
69+ .bind(await sha256(token), accountId, method, passkeyId, userAgent?.slice(0, 300) ?? null, at, at, days(SESSION_DAYS))
70+ .run();
71+ return token;
72+ }
73+
74+ /** Resolve a session cookie. Sessions slide: each day of use extends them by another 30 days. */
75+ async session(token: string): Promise<{ session: SessionRow; account: Account } | null> {
76+ if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null;
77+ const row = await this.db.prepare("SELECT * FROM sessions WHERE id = ?").bind(await sha256(token)).first<SessionRow>();
78+ if (!row || row.expires_at < now()) return null;
79+ const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>();
80+ if (!account || account.kind !== "user") return null;
81+ if (Date.now() - Date.parse(row.last_seen_at) > 86400_000) {
82+ await this.db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").bind(now(), days(SESSION_DAYS), row.id).run();
83+ }
84+ return { session: row, account };
85+ }
86+
87+ async endSession(token: string): Promise<void> {
88+ await this.db.prepare("DELETE FROM sessions WHERE id = ?").bind(await sha256(token)).run();
89+ }
90+
91+ async sessions(accountId: string): Promise<SessionRow[]> {
92+ const r = await this.db.prepare("SELECT * FROM sessions WHERE account_id = ? AND expires_at > ? ORDER BY last_seen_at DESC LIMIT 100").bind(accountId, now()).all<SessionRow>();
93+ return r.results;
94+ }
95+
96+ async endOtherSessions(accountId: string, keepId: string | null): Promise<void> {
97+ await this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(accountId, keepId ?? "").run();
98+ }
99+
100+ async endAllSessions(accountId: string): Promise<void> {
101+ await this.db.prepare("DELETE FROM sessions WHERE account_id = ?").bind(accountId).run();
102+ }
103+
104+ // ---------------------------------------------------------------- passkeys
105+
106+ async passkeys(accountId: string): Promise<PasskeyRow[]> {
107+ const r = await this.db.prepare("SELECT * FROM passkeys WHERE account_id = ? ORDER BY created_at").bind(accountId).all<PasskeyRow>();
108+ return r.results;
109+ }
110+
111+ async passkey(id: string): Promise<PasskeyRow | null> {
112+ return this.db.prepare("SELECT * FROM passkeys WHERE id = ?").bind(id).first<PasskeyRow>();
113+ }
114+
115+ async addPasskey(p: Omit<PasskeyRow, "created_at" | "last_used_at">): Promise<void> {
116+ await this.db
117+ .prepare("INSERT INTO passkeys (id, account_id, public_key, counter, transports, device_type, backed_up, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
118+ .bind(p.id, p.account_id, p.public_key, p.counter, p.transports, p.device_type, p.backed_up, p.name.slice(0, 100), now())
119+ .run();
120+ }
121+
122+ async passkeyUsed(id: string, counter: number, backedUp: boolean): Promise<void> {
123+ await this.db.prepare("UPDATE passkeys SET counter = ?, backed_up = ?, last_used_at = ? WHERE id = ?").bind(counter, backedUp ? 1 : 0, now(), id).run();
124+ }
125+
126+ async renamePasskey(accountId: string, id: string, name: string): Promise<void> {
127+ await this.db.prepare("UPDATE passkeys SET name = ? WHERE id = ? AND account_id = ?").bind(name.slice(0, 100), id, accountId).run();
128+ }
129+
130+ /** Remove a passkey and every other session it signed in (a lost device's browsers). */
131+ async deletePasskey(accountId: string, id: string, keepSessionId: string | null): Promise<void> {
132+ await this.db.batch([
133+ this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND passkey_id = ? AND id != ?").bind(accountId, id, keepSessionId ?? ""),
134+ this.db.prepare("DELETE FROM passkeys WHERE id = ? AND account_id = ?").bind(id, accountId),
135+ ]);
136+ }
137+
138+ // ---------------------------------------------------------------- WebAuthn challenges
139+
140+ /** Store a challenge; returns the value for the browser's challenge cookie. */
141+ async saveChallenge(purpose: "login" | "register", challenge: string, subject: Subject): Promise<string> {
142+ const token = randomToken(24);
143+ const expires = new Date(Date.now() + CHALLENGE_MINUTES * 60_000).toISOString();
144+ await this.db.batch([
145+ this.db.prepare("DELETE FROM webauthn_challenges WHERE expires_at < ?").bind(now()),
146+ this.db.prepare("INSERT INTO webauthn_challenges (id, purpose, challenge, subject, expires_at) VALUES (?, ?, ?, ?, ?)").bind(await sha256(token), purpose, challenge, JSON.stringify(subject), expires),
147+ ]);
148+ return token;
149+ }
150+
151+ /** Consume a challenge exactly once. */
152+ async takeChallenge(token: string | undefined, purpose: "login" | "register"): Promise<{ challenge: string; subject: Subject } | null> {
153+ if (!token) return null;
154+ const row = await this.db
155+ .prepare("DELETE FROM webauthn_challenges WHERE id = ? RETURNING purpose, challenge, subject, expires_at")
156+ .bind(await sha256(token))
157+ .first<{ purpose: string; challenge: string; subject: string; expires_at: string }>();
158+ if (!row || row.purpose !== purpose || row.expires_at < now()) return null;
159+ return { challenge: row.challenge, subject: JSON.parse(row.subject) as Subject };
160+ }
161+
162+ // ---------------------------------------------------------------- invites and sign-in links
163+
164+ /** An open invite by its secret token (null when used, expired or unknown). */
165+ async invite(token: string): Promise<(InviteRow & { hash: string }) | null> {
166+ const hash = await sha256(token);
167+ const row = await this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>();
168+ return row ? { ...row, hash } : null;
169+ }
170+
171+ async inviteByHash(hash: string): Promise<InviteRow | null> {
172+ return this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>();
173+ }
174+
175+ /** A single-use link that lets the holder add a passkey to `account` (account recovery, new devices). */
176+ async createSigninLink(by: Account, account: Account): Promise<{ url: string; expiresAt: string }> {
177+ const token = randomToken(32);
178+ const expiresAt = new Date(Date.now() + LINK_HOURS * 3600_000).toISOString();
179+ await this.db
180+ .prepare("INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
181+ .bind(newId(), await sha256(token), account.id, by.id, now(), expiresAt)
182+ .run();
183+ return { url: `${this.env.PUBLIC_URL}/signin/${token}`, expiresAt };
184+ }
185+
186+ async signinLink(token: string): Promise<{ hash: string; account: Account } | null> {
187+ const hash = await sha256(token);
188+ const row = await this.db.prepare("SELECT account_id FROM signin_links WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(hash, now()).first<{ account_id: string }>();
189+ if (!row) return null;
190+ const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>();
191+ return account && account.kind === "user" ? { hash, account } : null;
192+ }
193+
194+ /** Mark a link used; false when someone else used it first. */
195+ async useSigninLink(hash: string): Promise<boolean> {
196+ const r = await this.db.prepare("UPDATE signin_links SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(now(), hash, now()).run();
197+ return (r.meta.changes ?? 0) > 0;
198+ }
199+}
200+
201+/** Read one cookie from a raw request. */
202+export function cookieFrom(req: Request, name: string): string | undefined {
203+ const cookie = req.headers.get("cookie") ?? "";
204+ for (const part of cookie.split(";")) {
205+ const i = part.indexOf("=");
206+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
207+ }
208+ return undefined;
209+}
modifiedapps/diffinery/src/lib/directory.ts+3−3View file
@@ -70,8 +70,8 @@
7070 return candidate;
7171 }
7272
73- /** Find or create the user behind a verified email (Access sign-in). */
74- async userForEmail(email: string, hints: { name?: string; login?: string } = {}): Promise<Account> {
73+ /** Find or create the user behind a verified email (Access recovery, invite links, dev sign-in). */
74+ async userForEmail(email: string, hints: { name?: string; login?: string; id?: string } = {}): Promise<Account> {
7575 const existing = await this.accountByEmail(email);
7676 if (existing) {
7777 if (existing.status === "pending" && this.isBootstrapAdmin(email, existing.login)) {
@@ -82,7 +82,7 @@
8282 }
8383 const admin = this.isBootstrapAdmin(email, hints.login);
8484 const login = await this.uniqueLogin(hints.login ?? email.split("@")[0]!);
85- const id = newId();
85+ const id = hints.id ?? newId();
8686 const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first();
8787 await this.db
8888 .prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)")
modifiedapps/diffinery/src/lib/util.ts+1−1View file
@@ -67,7 +67,7 @@
6767 }
6868
6969 /** Top-level paths the app serves; no account may use them as its login. */
70-export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery"]);
70+export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "-"]);
7171
7272 export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase());
7373
modifiedapps/diffinery/src/web/account.tsx+156−13View file
@@ -8,6 +8,8 @@
88 import type { Role } from "../repo/types.js";
99 import { ICONS } from "./assets.js";
1010 import { csrfToken } from "./csrf.js";
11+import { Auth } from "../auth/sessions.js";
12+import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js";
1113 import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js";
1214
1315 // ---------------------------------------------------------------- shared bits
@@ -64,11 +66,11 @@
6466 <h1>Diffinery</h1>
6567 <p>Code hosting and CI for the agentic era. Git repositories, pull requests with intent, GitHub Actions–compatible workflows and AI agents as first-class collaborators, running on Cloudflare.</p>
6668 <p class="row" style="justify-content:center">
67- <a class="btn primary" href="/settings">
68- Sign in
69+ <a class="btn primary" href="/login">
70+ Sign in with a passkey
6971 </a>
7072 </p>
71- <p class="muted small">Access is through Cloudflare Access. Sign in with the email address you were invited with; new accounts wait for an invite from an administrator.</p>
73+ <p class="muted small">Diffinery is invite-only. Open the invite link someone sent you to create your passkey; there are no passwords.</p>
7274 </section>
7375 <div class="features">
7476 <div class="box">
@@ -402,7 +404,7 @@
402404
403405 web.get("/invite/:token", async (c) => {
404406 const p = c.get("principal");
405- if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite");
407+ if (p.type !== "account") return invitePasskeyPage(c, prm(c, "token"));
406408 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
407409 return render(
408410 c,
@@ -490,7 +492,7 @@
490492
491493 // ---------------------------------------------------------------- /settings
492494
493-type SettingsTab = "profile" | "tokens" | "agents" | "orgs" | "admin" | "audit";
495+type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "admin" | "audit";
494496
495497 const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
496498 <aside class="side">
@@ -506,6 +508,9 @@
506508 <a href="/settings" class={tab === "profile" ? "on" : ""}>
507509 Profile
508510 </a>
511+ <a href="/settings/security" class={tab === "security" ? "on" : ""}>
512+ Passkeys and sessions
513+ </a>
509514 <a href="/settings/tokens" class={tab === "tokens" ? "on" : ""}>
510515 Access tokens
511516 </a>
@@ -618,6 +623,128 @@
618623 );
619624 });
620625
626+ // Passkeys and sessions
627+ web.get("/settings/security", async (c) => {
628+ const me = requireAccount(c);
629+ const t = csrfToken(c);
630+ const auth = new Auth(c.env);
631+ const p = c.get("principal");
632+ const current = p.type === "account" ? p.sessionId : undefined;
633+ const [keys, sessions] = await Promise.all([auth.passkeys(me.id), auth.sessions(me.id)]);
634+ const recovery = !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD);
635+ return page(
636+ c,
637+ me,
638+ "security",
639+ "Passkeys and sessions",
640+ <>
641+ <h1>Passkeys and sessions</h1>
642+ <p class="muted">You sign in to Diffinery with passkeys instead of a password. A passkey lives on your device or in your password manager and is unlocked with your fingerprint, face or screen lock.</p>
643+ <div class="box">
644+ <div class="head">
645+ <h2>Passkeys</h2>
646+ <span class="badge">{keys.length}</span>
647+ </div>
648+ {keys.length ? (
649+ <ul class="list">
650+ {keys.map((k) => (
651+ <li>
652+ <span class="st faint">{raw(ICONS.key)}</span>
653+ <div class="grow">
654+ <b>{k.name}</b> {k.backed_up ? <span class="badge blue" title="Backed up by your password manager or platform, so it syncs to your other devices">synced</span> : <span class="badge" title="Stored only on one device or security key">this device only</span>}
655+ <div class="meta">
656+ Added <Time at={k.created_at} /> · {k.last_used_at ? <>last used <Time at={k.last_used_at} /></> : "never used to sign in"}
657+ </div>
658+ </div>
659+ {keys.length > 1 ? (
660+ <Form action={`/settings/security/passkeys/${encodeURIComponent(k.id)}/delete`} t={t} class="inline" confirm={`Remove the passkey "${k.name}"? Sessions it signed in are signed out.`}>
661+ <button class="btn sm danger">Remove</button>
662+ </Form>
663+ ) : (
664+ <span class="small faint" title="Add another passkey before removing this one">Only passkey</span>
665+ )}
666+ </li>
667+ ))}
668+ </ul>
669+ ) : (
670+ <div class="empty">No passkeys yet. Add one so you can sign in.</div>
671+ )}
672+ <div class="body" style="border-top:1px solid var(--border)">
673+ <form data-passkey="register" data-next="/settings/security" class="row" style="align-items:flex-end">
674+ <div class="field grow m0">
675+ <label for="pklabel">Add a passkey</label>
676+ <input id="pklabel" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
677+ <div class="hint">Add one on each device you use, or keep it in a password manager that syncs.</div>
678+ </div>
679+ <button class="btn primary">Add a passkey</button>
680+ </form>
681+ <div class="mt">
682+ <div class="flash error" data-passkey-msg role="alert" hidden></div>
683+ </div>
684+ </div>
685+ </div>
686+ <div class="box">
687+ <div class="head">
688+ <h2>Signed-in browsers</h2>
689+ <span class="badge">{sessions.length}</span>
690+ </div>
691+ {sessions.length ? (
692+ <ul class="list">
693+ {sessions.map((s) => (
694+ <li>
695+ <div class="grow">
696+ <b>{deviceLabel(s.user_agent ?? undefined)}</b> {s.id === current ? <span class="badge green">this browser</span> : null}
697+ <div class="meta">
698+ Signed in <Time at={s.created_at} /> with {s.method === "access" ? "email recovery" : s.method === "link" ? "a sign-in link" : "a passkey"} · active <Time at={s.last_seen_at} />
699+ </div>
700+ </div>
701+ </li>
702+ ))}
703+ </ul>
704+ ) : (
705+ <div class="empty">No browser sessions.</div>
706+ )}
707+ {sessions.some((s) => s.id !== current) ? (
708+ <div class="body" style="border-top:1px solid var(--border)">
709+ <Form action="/settings/security/sessions/others" t={t} class="inline" confirm="Sign out every other browser?">
710+ <button class="btn">Sign out everywhere else</button>
711+ </Form>
712+ </div>
713+ ) : null}
714+ </div>
715+ <p class="muted small">
716+ Lost every passkey? {recovery ? <>Use <a href="/auth/access">email recovery</a>, then add a new passkey here.</> : "Ask a site administrator for a one-time sign-in link."} Git, the CLI and agents use <a href="/settings/tokens">access tokens</a> instead of passkeys.
717+ </p>
718+ </>,
719+ );
720+ });
721+
722+ post(web, "/settings/security/passkeys/:id/delete", async (c) => {
723+ const me = browserAccount(c);
724+ const auth = new Auth(c.env);
725+ const id = prm(c, "id");
726+ return act(
727+ c,
728+ "/settings/security",
729+ async () => {
730+ const keys = await auth.passkeys(me.id);
731+ const key = keys.find((k) => k.id === id);
732+ if (!key) throw new HttpError(404, "passkey not found");
733+ if (keys.length < 2) throw new HttpError(422, "Add another passkey before removing your last one.");
734+ const p = c.get("principal");
735+ await auth.deletePasskey(me.id, id, p.type === "account" ? (p.sessionId ?? null) : null);
736+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "passkey.remove", detail: { name: key.name } }));
737+ },
738+ "Passkey removed.",
739+ );
740+ });
741+
742+ post(web, "/settings/security/sessions/others", async (c) => {
743+ const me = browserAccount(c);
744+ const p = c.get("principal");
745+ return act(c, "/settings/security", async () => new Auth(c.env).endOtherSessions(me.id, p.type === "account" ? (p.sessionId ?? null) : null), "Signed out of every other browser.");
746+ });
747+
621748 // Tokens
622749 const tokensPage = async (c: AppContext, me: Account, created?: { token: string }) => {
623750 const dir = c.get("dir");
@@ -997,7 +1124,7 @@
9971124 return me;
9981125 };
9991126
1000- const adminPage = async (c: AppContext, me: Account, invite?: { url: string; email: string }) => {
1127+ const adminPage = async (c: AppContext, me: Account, invite?: { url: string; title: string; note: string }) => {
10011128 const dir = c.get("dir");
10021129 const t = csrfToken(c);
10031130 const [users, requests] = await Promise.all([dir.users(), dir.accessRequests()]);
@@ -1011,8 +1138,8 @@
10111138 {invite ? (
10121139 <div class="box" style="border-color:var(--amber)">
10131140 <div class="body">
1014- <h3>Invite created for {invite.email}</h3>
1015- <p class="muted">Send this link. Signing in with that email through Cloudflare Access also activates the account automatically.</p>
1141+ <h3>{invite.title}</h3>
1142+ <p class="muted">{invite.note}</p>
10161143 <div class="row">
10171144 <div class="secret grow">{invite.url}</div>
10181145 <CopyButton text={invite.url} />
@@ -1057,7 +1184,7 @@
10571184 <div class="field grow m0">
10581185 <label for="iemail">Email address</label>
10591186 <input id="iemail" name="email" type="email" required placeholder="friend@example.com" />
1060- <div class="hint">They also need to be allowed by your Cloudflare Access policy.</div>
1187+ <div class="hint">Opening the link lets them pick a username and create a passkey.</div>
10611188 </div>
10621189 <button class="btn primary">Create invite</button>
10631190 </div>
@@ -1074,6 +1201,7 @@
10741201 <th>Email</th>
10751202 <th>Joined</th>
10761203 <th>Status</th>
1204+ <th />
10771205 </tr>
10781206 </thead>
10791207 <tbody>
@@ -1100,6 +1228,11 @@
11001228 </Form>
11011229 )}
11021230 </td>
1231+ <td class="right">
1232+ <Form action={`/settings/admin/users/${u.id}/signin-link`} t={t} class="inline" confirm={`Create a one-time link that adds a passkey to ${u.login}? Only send it to them.`}>
1233+ <button class="btn sm" title="For a lost passkey or a first sign-in">Sign-in link</button>
1234+ </Form>
1235+ </td>
11031236 </tr>
11041237 ))}
11051238 </tbody>
@@ -1130,13 +1263,24 @@
11301263 );
11311264 });
11321265
1266+ post(web, "/settings/admin/users/:id/signin-link", async (c) => {
1267+ const me = requireSiteAdmin(c);
1268+ const dir = c.get("dir");
1269+ const target = await dir.account(prm(c, "id"));
1270+ if (!target || target.kind !== "user") throw new HttpError(404, "user not found");
1271+ if (target.status === "disabled") throw new HttpError(422, "enable this account first");
1272+ const link = await new Auth(c.env).createSigninLink(me, target);
1273+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "signin_link.create", target: target.login }));
1274+ return adminPage(c, me, { url: link.url, title: `Sign-in link for ${target.login}`, note: "Send this only to them. It works once within 24 hours and adds a passkey to their account." });
1275+ });
1276+
11331277 post(web, "/settings/admin/invite", async (c, f) => {
11341278 const me = requireSiteAdmin(c);
11351279 const email = f.s("email").trim();
11361280 if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address");
11371281 const inv = await c.get("dir").createInvite(me, { email, role: "read" });
11381282 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: email }));
1139- return adminPage(c, me, { url: inv.url, email });
1283+ return adminPage(c, me, { url: inv.url, title: `Invite created for ${email}`, note: "Send them this link. It expires in 14 days and works once: they pick a username and create a passkey." });
11401284 });
11411285
11421286 web.get("/settings/audit", async (c) => {
@@ -1400,7 +1544,7 @@
14001544 </div>
14011545 <div class="body">
14021546 <p class="muted small">
1403- New people sign in through Cloudflare Access with their email and join automatically. <a href={`${base}?tab=invites`}>Create an invite →</a>
1547+ New people join from an invite link, where they create a passkey. <a href={`${base}?tab=invites`}>Create an invite →</a>
14041548 </p>
14051549 </div>
14061550 </div>
@@ -1417,7 +1561,7 @@
14171561 <div class="box" style="border-color:var(--amber)">
14181562 <div class="body">
14191563 <h3>Invite created for {invite.email}</h3>
1420- <p class="muted">Send them this link. It expires in 14 days and only works for that email address.</p>
1564+ <p class="muted">Send them this link. It expires in 14 days. Opening it lets them create a passkey and join.</p>
14211565 <div class="row">
14221566 <div class="secret grow">{invite.url}</div>
14231567 <CopyButton text={invite.url} />
@@ -1444,7 +1588,6 @@
14441588 </div>
14451589 <button class="btn primary">Create invite</button>
14461590 </div>
1447- <div class="body muted small" style="padding-top:0">The person must also be allowed by your Cloudflare Access policy to reach this site.</div>
14481591 </Form>
14491592 <div class="box">
14501593 {invites.length ? (
modifiedapps/diffinery/src/web/assets.ts+59−23View file
@@ -2,25 +2,35 @@
22
33 export const CSS = `
44 :root{color-scheme:light dark;
5---bg:#ffffff;--bg2:#f7f8fa;--bg3:#eff1f4;--bg-hover:#f3f4f7;--fg:#1c2024;--fg2:#5b6370;--fg3:#8b929c;
6---border:#dfe3e8;--border2:#ebeef1;--accent:#5e6ad2;--accent-hover:#4f5bc4;--accent-fg:#fff;--accent-soft:#eef0fc;
7---green:#1a7f37;--green-soft:#e3f6e8;--red:#cf222e;--red-soft:#fdecec;--amber:#9a6700;--amber-soft:#fff6d6;
8---purple:#8250df;--purple-soft:#f4edff;--blue:#0969da;--blue-soft:#e6f0fd;
9---add-bg:#e9f8ee;--add-ln:#cdf0d7;--del-bg:#fdeeee;--del-ln:#fbd5d5;--hunk-bg:#eef3fb;--hl:#fff8c5;
10---code-bg:#f5f6f8;--shadow:0 1px 2px rgba(20,24,31,.05),0 8px 24px rgba(20,24,31,.08);--r:8px;
11---mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
12---sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif}
5+/* Colors, radii and type follow Cloudflare's Kumo design system tokens (kumo-ui.com, MIT). */
6+--canvas:oklch(98.75% 0 0);--bg:#fff;--bg2:oklch(98% 0 0);--bg3:oklch(96% 0 0);--bg-hover:oklch(97% 0 0);
7+--fg:oklch(21% 0.006 285.885);--fg2:oklch(55.6% 0 0);--fg3:oklch(70.8% 0 0);
8+--border:oklch(14.5% 0 0 / .1);--border2:oklch(93.5% 0 0);
9+--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(93.2% 0.032 255.6 / .45);
10+--link:oklch(42.4% 0.199 265.638);--focus:oklch(15% 0 0);
11+--green:oklch(43.2% 0.095 166.913);--green-soft:oklch(96.2% 0.043 156.7 / .57);--red:oklch(50.5% 0.213 27.518);--red-soft:oklch(93.6% 0.032 17.7 / .42);
12+--amber:oklch(59.7% 0.144 57.5);--amber-soft:oklch(93.1% 0.107 94.6 / .2);
13+--purple:oklch(55.8% 0.288 302.321);--purple-soft:oklch(55.8% 0.288 302.321 / .1);--blue:oklch(42.4% 0.199 265.638);--blue-soft:oklch(93.2% 0.032 255.6 / .45);
14+--brand:#f6821f;
15+--add-bg:oklch(96.2% 0.043 156.7 / .57);--add-ln:oklch(90.5% 0.093 164.15 / .7);--del-bg:oklch(93.6% 0.032 17.7 / .42);--del-ln:oklch(88.5% 0.062 18.334 / .7);--hunk-bg:oklch(93.2% 0.032 255.6 / .45);--hl:oklch(97.3% 0.071 103.193);
16+--code-bg:oklch(96% 0 0);--shadow:0 0 0 1px oklch(0% 0 0 / .12),0 8px 24px oklch(0% 0 0 / .08);--r:.5rem;--r-sm:.375rem;
17+--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
18+--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"}
1319 @media (prefers-color-scheme:dark){:root{
14---bg:#0f1115;--bg2:#15181d;--bg3:#1c2027;--bg-hover:#1a1e24;--fg:#e6e8eb;--fg2:#a0a7b1;--fg3:#6f7782;
15---border:#2a2f37;--border2:#22262d;--accent:#7c86e8;--accent-hover:#8e97ef;--accent-fg:#0f1115;--accent-soft:#232744;
16---green:#3fb950;--green-soft:#15291c;--red:#f85149;--red-soft:#2d1517;--amber:#d29922;--amber-soft:#2b2312;
17---purple:#a371f7;--purple-soft:#241a35;--blue:#58a6ff;--blue-soft:#14233a;
18---add-bg:#12261a;--add-ln:#1a3a25;--del-bg:#2a1416;--del-ln:#45191d;--hunk-bg:#151f30;--hl:#3a3213;
19---code-bg:#171a20;--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px rgba(0,0,0,.35)}}
20+--canvas:oklch(10% 0 0);--bg:oklch(17% 0 0);--bg2:oklch(12% 0 0);--bg3:oklch(15% 0 0);--bg-hover:oklch(26.9% 0 0);
21+--fg:oklch(97% 0 0);--fg2:oklch(70.8% 0 0);--fg3:oklch(55.6% 0 0);
22+--border:oklch(32% 0 0);--border2:oklch(26.9% 0 0);
23+--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(38% 0.145 265.5 / .22);
24+--link:oklch(70.7% 0.165 254.624);--focus:oklch(93.5% 0 0);
25+--green:oklch(76.5% 0.177 163.223);--green-soft:oklch(39.3% 0.096 152.3 / .2);--red:oklch(70.4% 0.191 22.216);--red-soft:oklch(42.9% 0.176 28.7 / .17);
26+--amber:oklch(75% 0.183 55.934);--amber-soft:oklch(35.3% 0.079 65 / .37);
27+--purple:oklch(71.4% 0.203 305.504);--purple-soft:oklch(49.6% 0.265 301.924 / .2);--blue:oklch(70.7% 0.165 254.624);--blue-soft:oklch(38% 0.145 265.5 / .22);
28+--add-bg:oklch(39.3% 0.096 152.3 / .2);--add-ln:oklch(39.3% 0.096 152.3 / .4);--del-bg:oklch(42.9% 0.176 28.7 / .17);--del-ln:oklch(42.9% 0.176 28.7 / .35);--hunk-bg:oklch(38% 0.145 265.5 / .22);--hl:oklch(55.4% 0.135 66.442 / .5);
29+--code-bg:oklch(15% 0 0);--shadow:0 0 0 1px oklch(100% 0 0 / .1),0 8px 24px oklch(0% 0 0 / .3)}}
2030 *{box-sizing:border-box}
2131 html{-webkit-text-size-adjust:100%}
22-body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 var(--sans)}
23-a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
32+body{margin:0;background:var(--canvas);color:var(--fg);font:14px/1.5 var(--sans)}
33+a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
2434 code,pre,kbd,.mono{font-family:var(--mono);font-size:12.5px}
2535 kbd{border:1px solid var(--border);border-bottom-width:2px;border-radius:4px;padding:0 5px;background:var(--bg2);font-size:11px;color:var(--fg2)}
2636 h1,h2,h3,h4{line-height:1.25;margin:0 0 8px}h1{font-size:22px}h2{font-size:18px}h3{font-size:15px}
@@ -31,7 +41,7 @@
3141 .top{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:saturate(1.6) blur(10px);border-bottom:1px solid var(--border)}
3242 .top .wrap{display:flex;align-items:center;gap:14px;height:52px}
3343 .brand{display:flex;align-items:center;gap:8px;font-weight:650;color:var(--fg);letter-spacing:-.01em}
34-.brand:hover{text-decoration:none}.brand svg{color:var(--accent)}
44+.brand:hover{text-decoration:none}.brand svg{color:var(--brand)}
3545 .top nav{display:flex;align-items:center;gap:4px;margin-left:auto}
3646 .top nav a{color:var(--fg2);padding:6px 10px;border-radius:6px}.top nav a:hover{background:var(--bg-hover);color:var(--fg);text-decoration:none}
3747 .pal-btn{display:flex;align-items:center;gap:8px;min-width:220px;padding:5px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg2);color:var(--fg3);font:inherit;cursor:pointer}
@@ -43,17 +53,18 @@
4353 .flash.error{border-color:color-mix(in srgb,var(--red) 40%,transparent);background:var(--red-soft);color:var(--red)}
4454 .flash.notice{border-color:color-mix(in srgb,var(--green) 40%,transparent);background:var(--green-soft);color:var(--green)}
4555 .flash.info{border-color:color-mix(in srgb,var(--blue) 40%,transparent);background:var(--blue-soft)}
46-.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important}
56+.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important}
4757 .btn:hover{background:var(--bg-hover);border-color:var(--fg3)}
48-.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.primary:hover{background:var(--accent-hover)}
49-.btn.green{background:var(--green);border-color:var(--green);color:#fff}
58+.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg);box-shadow:inset 0 1px 0 0 oklch(100% 0 0 / .15)}.btn.primary:hover{background:var(--accent-hover)}
59+.btn.green{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.green:hover{background:var(--accent-hover)}
5060 .btn.danger{color:var(--red)}.btn.danger:hover{background:var(--red);border-color:var(--red);color:#fff}
5161 .btn.sm{padding:3px 8px;font-size:12px}.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0}
5262 .btn[disabled]{opacity:.5;cursor:not-allowed}
5363 input[type=text],input[type=search],input[type=email],input[type=number],input[type=url],input[type=password],input:not([type]),select,textarea{
54-width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--fg);font:inherit}
64+width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit}
5565 textarea{min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre}
56-input:focus,select:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--accent) 45%,transparent);outline-offset:0;border-color:var(--accent)}
66+input:focus,select:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--focus) 25%,transparent);outline-offset:0;border-color:color-mix(in srgb,var(--focus) 50%,transparent)}
67+:focus-visible{outline:2px solid color-mix(in srgb,var(--focus) 35%,transparent);outline-offset:2px}
5768 input[type=checkbox],input[type=radio]{accent-color:var(--accent)}
5869 label{display:block;font-weight:550;margin:0 0 4px}label.inline{display:inline-flex;align-items:center;gap:6px;font-weight:400}
5970 .field{margin:0 0 14px}.field .hint{font-size:12px;color:var(--fg2);margin-top:4px;font-weight:400}
@@ -213,6 +224,11 @@
213224 a.sha:hover{text-decoration:none;background:var(--accent-soft);color:var(--accent)}
214225 .right{text-align:right}.mt{margin-top:16px}.mb{margin-bottom:16px}.m0{margin:0}
215226 form.inline{display:inline}
227+.navbtn{background:none;border:0;color:var(--fg2);padding:6px 10px;border-radius:6px;font:inherit;cursor:pointer}.navbtn:hover{background:var(--bg-hover);color:var(--fg)}
228+.btn.block{display:flex;width:100%;justify-content:center}
229+.authcard{max-width:440px;margin:56px auto}.authcard .body{padding:28px}.authcard h1{font-size:24px;margin:12px 0 8px}
230+.authmark{width:44px;height:44px;border-radius:var(--r);display:grid;place-items:center;background:color-mix(in srgb,var(--brand) 14%,transparent);color:var(--brand)}.authmark svg{width:22px;height:22px}
231+.authfoot{margin-top:20px;padding-top:14px;border-top:1px solid var(--border)}.authfoot p{margin:0 0 6px}
216232 .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
217233 `;
218234
@@ -229,9 +245,29 @@
229245 const x=e.target.closest('[data-cancel-line]');if(x){e.preventDefault();const tr=x.closest('tr');tr&&tr.remove()}
230246 if(!e.target.closest('details.menu'))$$('details.menu[open]').forEach(m=>m.removeAttribute('open'));
231247 });
232-d.addEventListener('submit',e=>{const f=e.target;if(f.dataset.confirm&&!confirm(f.dataset.confirm)){e.preventDefault();return}
248+d.addEventListener('submit',e=>{const f=e.target;if(f.dataset.passkey){e.preventDefault();passkey(f);return}
249+ if(f.dataset.confirm&&!confirm(f.dataset.confirm)){e.preventDefault();return}
233250 if(f.dataset.match){const i=f.querySelector('[name=confirm]');if(i&&i.value!==f.dataset.match){e.preventDefault();alert('Type '+f.dataset.match+' to confirm.');return}}
234251 $$('button[type=submit],button:not([type])',f).forEach(x=>setTimeout(()=>{x.disabled=true},0));},true);
252+// Passkeys (WebAuthn): forms with data-passkey="login" or "register"
253+const b2u=buf=>{let s='';new Uint8Array(buf).forEach(x=>{s+=String.fromCharCode(x)});return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')};
254+const u2b=s=>{s=s.replaceAll('-','+').replaceAll('_','/');while(s.length%4)s+='=';const bin=atob(s);const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out.buffer};
255+async function pkPost(url,body){const r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
256+ let j={};try{j=await r.json()}catch(_){}if(!r.ok)throw new Error(j.message||('Request failed ('+r.status+')'));return j}
257+async function passkey(f){const mode=f.dataset.passkey,msg=$('[data-passkey-msg]',f)||$('[data-passkey-msg]'),btns=$$('button',f);
258+ const show=t=>{if(msg){msg.textContent=t;msg.hidden=!t}else if(t)alert(t)};
259+ if(!window.PublicKeyCredential||!navigator.credentials){show('This browser does not support passkeys. Try a current version of Chrome, Safari, Edge or Firefox.');return}
260+ show('');btns.forEach(x=>{x.disabled=true});
261+ const data={};new FormData(f).forEach((v,k)=>{if(typeof v==='string')data[k]=v});data.next=f.dataset.next||'/';
262+ try{const o=await pkPost('/auth/passkey/'+mode+'/options',data);o.challenge=u2b(o.challenge);let cred,res;
263+ if(mode==='login'){(o.allowCredentials||[]).forEach(c=>{c.id=u2b(c.id)});cred=await navigator.credentials.get({publicKey:o});const r=cred.response;
264+ res={authenticatorData:b2u(r.authenticatorData),clientDataJSON:b2u(r.clientDataJSON),signature:b2u(r.signature)};if(r.userHandle)res.userHandle=b2u(r.userHandle)}
265+ else{o.user.id=u2b(o.user.id);(o.excludeCredentials||[]).forEach(c=>{c.id=u2b(c.id)});cred=await navigator.credentials.create({publicKey:o});const r=cred.response;
266+ res={attestationObject:b2u(r.attestationObject),clientDataJSON:b2u(r.clientDataJSON),transports:r.getTransports?r.getTransports():[]}}
267+ const v=await pkPost('/auth/passkey/'+mode+'/verify',Object.assign({},data,{response:{id:cred.id,rawId:b2u(cred.rawId),type:cred.type,response:res,clientExtensionResults:cred.getClientExtensionResults(),authenticatorAttachment:cred.authenticatorAttachment||undefined}}));
268+ location.assign(v.redirect||'/')}
269+ catch(e){btns.forEach(x=>{x.disabled=false});
270+ show(e&&e.name==='NotAllowedError'?'The passkey prompt was closed or timed out. Try again when you are ready.':e&&e.name==='InvalidStateError'?'This device already has a passkey for this account.':(e&&e.message)||String(e))}}
235271 function lineComment(btn){
236272 const tpl=$('#line-comment-tpl');if(!tpl)return;
237273 const tr=btn.closest('tr');const next=tr.nextElementSibling;
addedapps/diffinery/src/web/auth.tsx+424−0View file
@@ -1,0 +1,424 @@
1+/** @jsxImportSource hono/jsx */
2+import {
3+ generateAuthenticationOptions,
4+ generateRegistrationOptions,
5+ verifyAuthenticationResponse,
6+ verifyRegistrationResponse,
7+ type AuthenticationResponseJSON,
8+ type AuthenticatorTransport,
9+ type RegistrationResponseJSON,
10+} from "@simplewebauthn/server";
11+import { deleteCookie, getCookie, setCookie } from "hono/cookie";
12+import { raw } from "hono/html";
13+import type { FC, PropsWithChildren } from "hono/jsx";
14+import { accessToken, type AppContext } from "../auth/principal.js";
15+import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js";
16+import type { Account } from "../lib/directory.js";
17+import { verifyAccessJwt } from "../lib/jwt.js";
18+import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js";
19+import { ICONS } from "./assets.js";
20+import { flash, post, prm, render, statusOf, type WebApp } from "./ui.js";
21+
22+/**
23+ * Passkey (WebAuthn) sign-in. Browsers sign in with a passkey and get a
24+ * session cookie. New people create their passkey from an invite link, and
25+ * admins can issue one-time sign-in links to add a passkey to an existing
26+ * account. Cloudflare Access, when configured, protects only /auth/access and
27+ * serves as an email-based recovery path.
28+ */
29+
30+const CEREMONY_TIMEOUT = 120_000;
31+
32+/** Only same-site relative paths are allowed as post-sign-in destinations. */
33+export function safeNext(next: unknown): string {
34+ return typeof next === "string" && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\") ? next : "/";
35+}
36+
37+const str = (v: unknown): string => (typeof v === "string" ? v : "");
38+
39+/** A login suggestion from an email address. */
40+export function loginFromEmail(email: string): string {
41+ return (email.split("@")[0] ?? "").replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user";
42+}
43+
44+/** A friendly default name for a new passkey, from the browser's user agent. */
45+export function deviceLabel(ua: string | undefined): string {
46+ const u = ua ?? "";
47+ const os = /iPhone/.test(u) ? "iPhone" : /iPad/.test(u) ? "iPad" : /Android/.test(u) ? "Android" : /Mac OS X|Macintosh/.test(u) ? "Mac" : /Windows/.test(u) ? "Windows" : /CrOS/.test(u) ? "ChromeOS" : /Linux/.test(u) ? "Linux" : "";
48+ const browser = /Edg\//.test(u) ? "Edge" : /Firefox\//.test(u) ? "Firefox" : /Chrome\//.test(u) ? "Chrome" : /Safari\//.test(u) ? "Safari" : "";
49+ return [os, browser].filter(Boolean).join(" · ") || "Passkey";
50+}
51+
52+async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<void> {
53+ const old = getCookie(c, SESSION_COOKIE);
54+ if (old) await auth.endSession(old);
55+ const token = await auth.createSession(accountId, method, passkeyId, c.req.header("user-agent") ?? null);
56+ setCookie(c, SESSION_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: SESSION_DAYS * 86400 });
57+}
58+
59+function setChallenge(c: AppContext, token: string): void {
60+ setCookie(c, CHALLENGE_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 300 });
61+}
62+
63+function takeChallengeCookie(c: AppContext): string | undefined {
64+ const v = getCookie(c, CHALLENGE_COOKIE);
65+ deleteCookie(c, CHALLENGE_COOKIE, { path: "/", secure: true });
66+ return v;
67+}
68+
69+/** JSON endpoint for the browser's passkey script: JSON in, JSON out, JSON errors. */
70+function jsonPost(web: WebApp, path: string, handler: (c: AppContext, body: Record<string, unknown>) => Promise<Response>): void {
71+ web.post(path, async (c) => {
72+ try {
73+ // A JSON content type forces a CORS preflight, so other sites cannot drive these endpoints.
74+ if (!/^application\/json\b/i.test(c.req.header("content-type") ?? "")) throw new HttpError(415, "expected JSON");
75+ const body = await c.req.json<Record<string, unknown>>().catch(() => {
76+ throw new HttpError(400, "invalid JSON");
77+ });
78+ if (!body || typeof body !== "object") throw new HttpError(400, "invalid JSON");
79+ return await handler(c, body);
80+ } catch (e) {
81+ const s = statusOf(e);
82+ if (s.status >= 500) console.error(e);
83+ return c.json({ message: s.status >= 500 ? "Something went wrong. Please try again." : s.message }, s.status as 400);
84+ }
85+ });
86+}
87+
88+const transportsOf = (k: PasskeyRow): AuthenticatorTransport[] | undefined => (k.transports ? (JSON.parse(k.transports) as AuthenticatorTransport[]) : undefined);
89+
90+// ---------------------------------------------------------------- pages
91+
92+const KeyMark: FC = () => (
93+ <div class="authmark" aria-hidden="true">
94+ {raw(ICONS.key)}
95+ </div>
96+);
97+
98+/** Error slot that the passkey script fills in. */
99+const PasskeyMessage: FC = () => <div class="flash error" data-passkey-msg role="alert" hidden></div>;
100+
101+const AuthCard: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => (
102+ <div class="authcard box">
103+ <div class="body">
104+ <KeyMark />
105+ <h1>{title}</h1>
106+ {children}
107+ </div>
108+ </div>
109+);
110+
111+function accessConfigured(c: AppContext): boolean {
112+ return !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD);
113+}
114+
115+/** Invite landing for someone without an account: create a passkey and join in one step. */
116+export async function invitePasskeyPage(c: AppContext, token: string): Promise<Response> {
117+ const auth = new Auth(c.env);
118+ const inv = await auth.invite(token);
119+ if (!inv) throw new HttpError(404, "this invite is invalid, already used, or expired");
120+ const exists = inv.email ? await c.get("dir").accountByEmail(inv.email) : null;
121+ const next = `/invite/${encodeURIComponent(token)}`;
122+ if (!inv.email || exists) {
123+ return render(
124+ c,
125+ { title: "Accept invite" },
126+ <AuthCard title="You have been invited">
127+ <p class="muted">
128+ {inv.email ? (
129+ <>
130+ <b>{inv.email}</b> already has a Diffinery account. Sign in with its passkey, and this page will let you accept the invite.
131+ </>
132+ ) : (
133+ "Sign in to accept this invite."
134+ )}
135+ </p>
136+ <a class="btn primary block" href={`/login?next=${encodeURIComponent(next)}`}>
137+ Sign in
138+ </a>
139+ </AuthCard>,
140+ );
141+ }
142+ return render(
143+ c,
144+ { title: "Join Diffinery" },
145+ <AuthCard title="Join Diffinery">
146+ <p class="muted">
147+ You were invited as <b>{inv.email}</b>. Pick a username, then create a passkey. Your device keeps it and unlocks it with your fingerprint, face or screen lock, so there is no password to remember.
148+ </p>
149+ <form data-passkey="register" data-next="/">
150+ <input type="hidden" name="invite" value={token} />
151+ <div class="field">
152+ <label for="login">Username</label>
153+ <input id="login" name="login" type="text" value={loginFromEmail(inv.email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" />
154+ <div class="hint">Your repositories live at /{"{username}"}/…</div>
155+ </div>
156+ <div class="field">
157+ <label for="name">Display name</label>
158+ <input id="name" name="name" type="text" maxlength={100} autocomplete="name" />
159+ </div>
160+ <PasskeyMessage />
161+ <button class="btn primary block">Create a passkey and join</button>
162+ </form>
163+ </AuthCard>,
164+ );
165+}
166+
167+const PasskeyUnsupported: FC = () => (
168+ <noscript>
169+ <p class="flash error">Passkeys need JavaScript.</p>
170+ </noscript>
171+);
172+
173+export function registerAuth(web: WebApp): void {
174+ web.get("/login", async (c) => {
175+ const next = safeNext(c.req.query("next"));
176+ const p = c.get("principal");
177+ if (p.type === "account" && p.via !== "dev") return c.redirect(next, 302);
178+ return render(
179+ c,
180+ { title: "Sign in" },
181+ <AuthCard title="Sign in to Diffinery">
182+ <p class="muted">Use the passkey saved on this device, your phone or your password manager.</p>
183+ <PasskeyUnsupported />
184+ <form data-passkey="login" data-next={next}>
185+ <PasskeyMessage />
186+ <button class="btn primary block">Sign in with a passkey</button>
187+ </form>
188+ <div class="authfoot muted small">
189+ <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p>
190+ {accessConfigured(c) ? (
191+ <p>
192+ Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>.
193+ </p>
194+ ) : (
195+ <p>Lost your passkey? Ask an administrator for a sign-in link.</p>
196+ )}
197+ </div>
198+ </AuthCard>,
199+ );
200+ });
201+
202+ // Admin-issued sign-in link: add a passkey to an existing account.
203+ web.get("/signin/:token", async (c) => {
204+ const token = prm(c, "token");
205+ const link = await new Auth(c.env).signinLink(token);
206+ if (!link) throw new HttpError(404, "this sign-in link is invalid, already used, or expired");
207+ return render(
208+ c,
209+ { title: "Add a passkey" },
210+ <AuthCard title="Add a passkey">
211+ <p class="muted">
212+ This link lets you add a passkey to <b>{link.account.login}</b>
213+ {link.account.email ? ` (${link.account.email})` : ""}. It works once. After that, sign in with the passkey.
214+ </p>
215+ <form data-passkey="register" data-next="/settings/security">
216+ <input type="hidden" name="link" value={token} />
217+ <div class="field">
218+ <label for="label">Name for this passkey</label>
219+ <input id="label" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
220+ </div>
221+ <PasskeyMessage />
222+ <button class="btn primary block">Create a passkey</button>
223+ </form>
224+ </AuthCard>,
225+ );
226+ });
227+
228+ // ------------------------------------------------------------ sign in
229+
230+ jsonPost(web, "/auth/passkey/login/options", async (c) => {
231+ const auth = new Auth(c.env);
232+ const rp = auth.rp();
233+ // No allowCredentials: the browser offers any passkey it holds for this site (discoverable credentials).
234+ const opts = await generateAuthenticationOptions({ rpID: rp.id, userVerification: "required", timeout: CEREMONY_TIMEOUT });
235+ setChallenge(c, await auth.saveChallenge("login", opts.challenge, { kind: "login" }));
236+ return c.json(opts);
237+ });
238+
239+ jsonPost(web, "/auth/passkey/login/verify", async (c, body) => {
240+ const auth = new Auth(c.env);
241+ const dir = c.get("dir");
242+ const rp = auth.rp();
243+ const ch = await auth.takeChallenge(takeChallengeCookie(c), "login");
244+ if (!ch) throw new HttpError(400, "This sign-in attempt expired. Please try again.");
245+ const response = body.response as AuthenticationResponseJSON | undefined;
246+ if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response");
247+ const key = await auth.passkey(response.id);
248+ if (!key) throw new HttpError(401, "This passkey is not registered with Diffinery. It may have been removed.");
249+ const result = await verifyAuthenticationResponse({
250+ response,
251+ expectedChallenge: ch.challenge,
252+ expectedOrigin: rp.origin,
253+ expectedRPID: rp.id,
254+ requireUserVerification: true,
255+ credential: { id: key.id, publicKey: new Uint8Array(fromB64url(key.public_key)), counter: key.counter, transports: transportsOf(key) },
256+ }).catch((e: Error) => {
257+ throw new HttpError(401, `Passkey check failed: ${e.message}`);
258+ });
259+ if (!result.verified) throw new HttpError(401, "Passkey check failed.");
260+ const account = await dir.account(key.account_id);
261+ if (!account) throw new HttpError(401, "This passkey's account no longer exists.");
262+ if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
263+ await auth.passkeyUsed(key.id, result.authenticationInfo.newCounter, result.authenticationInfo.credentialBackedUp);
264+ await startSession(c, auth, account.id, "passkey", key.id);
265+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "passkey", passkey: key.name } }));
266+ return c.json({ redirect: safeNext(body.next) });
267+ });
268+
269+ // ------------------------------------------------------------ register a passkey
270+
271+ jsonPost(web, "/auth/passkey/register/options", async (c, body) => {
272+ const auth = new Auth(c.env);
273+ const dir = c.get("dir");
274+ const rp = auth.rp();
275+ let subject: Subject;
276+ let user: { id: string; name: string; displayName: string };
277+ let existing: PasskeyRow[] = [];
278+ const inviteToken = str(body.invite);
279+ const linkToken = str(body.link);
280+ if (inviteToken) {
281+ const inv = await auth.invite(inviteToken);
282+ if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired.");
283+ if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again.");
284+ if (await dir.accountByEmail(inv.email)) throw new HttpError(409, `${inv.email} already has an account. Sign in with its passkey, then open this invite again.`);
285+ const login = str(body.login).trim() || loginFromEmail(inv.email);
286+ if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved.");
287+ if (await dir.accountByLogin(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`);
288+ const name = str(body.name).trim().slice(0, 100);
289+ const id = newId();
290+ subject = { kind: "invite", inviteHash: inv.hash, email: inv.email, login, name, accountId: id };
291+ user = { id, name: login, displayName: name || login };
292+ } else if (linkToken) {
293+ const link = await auth.signinLink(linkToken);
294+ if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired.");
295+ if (link.account.status === "disabled") throw new HttpError(403, "This account is disabled.");
296+ subject = { kind: "link", linkHash: link.hash, accountId: link.account.id };
297+ user = { id: link.account.id, name: link.account.login, displayName: link.account.name ?? link.account.login };
298+ existing = await auth.passkeys(link.account.id);
299+ } else {
300+ const me = browserAccount(c);
301+ subject = { kind: "account", accountId: me.id };
302+ user = { id: me.id, name: me.login, displayName: me.name ?? me.login };
303+ existing = await auth.passkeys(me.id);
304+ }
305+ const opts = await generateRegistrationOptions({
306+ rpName: rp.name,
307+ rpID: rp.id,
308+ userName: user.name,
309+ userDisplayName: user.displayName,
310+ userID: new Uint8Array(new TextEncoder().encode(user.id)),
311+ attestationType: "none",
312+ excludeCredentials: existing.map((k) => ({ id: k.id, transports: transportsOf(k) })),
313+ authenticatorSelection: { residentKey: "required", userVerification: "required" },
314+ timeout: CEREMONY_TIMEOUT,
315+ });
316+ setChallenge(c, await auth.saveChallenge("register", opts.challenge, subject));
317+ return c.json(opts);
318+ });
319+
320+ jsonPost(web, "/auth/passkey/register/verify", async (c, body) => {
321+ const auth = new Auth(c.env);
322+ const dir = c.get("dir");
323+ const rp = auth.rp();
324+ const ch = await auth.takeChallenge(takeChallengeCookie(c), "register");
325+ if (!ch) throw new HttpError(400, "This attempt expired. Please try again.");
326+ const response = body.response as RegistrationResponseJSON | undefined;
327+ if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response");
328+ const result = await verifyRegistrationResponse({ response, expectedChallenge: ch.challenge, expectedOrigin: rp.origin, expectedRPID: rp.id, requireUserVerification: true }).catch((e: Error) => {
329+ throw new HttpError(400, `The passkey could not be verified: ${e.message}`);
330+ });
331+ if (!result.verified) throw new HttpError(400, "The passkey could not be verified.");
332+ const info = result.registrationInfo;
333+ if (await auth.passkey(info.credential.id)) throw new HttpError(409, "This passkey is already registered.");
334+ const label = str(body.label).trim() || deviceLabel(c.req.header("user-agent"));
335+ const key = {
336+ id: info.credential.id,
337+ public_key: b64url(info.credential.publicKey),
338+ counter: info.credential.counter,
339+ transports: info.credential.transports?.length ? JSON.stringify(info.credential.transports) : null,
340+ device_type: info.credentialDeviceType,
341+ backed_up: info.credentialBackedUp ? 1 : 0,
342+ name: label,
343+ };
344+ const s = ch.subject;
345+ let account: Account;
346+ let redirect: string;
347+ if (s.kind === "account") {
348+ const me = browserAccount(c);
349+ if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey.");
350+ await auth.addPasskey({ ...key, account_id: me.id });
351+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label } }));
352+ flash(c, "notice", `Passkey "${label}" added.`);
353+ return c.json({ redirect: "/settings/security" });
354+ } else if (s.kind === "invite") {
355+ // Re-check: the invite or the username may have been taken while the browser was busy.
356+ if (!(await auth.inviteByHash(s.inviteHash))) throw new HttpError(404, "This invite was used or expired in the meantime.");
357+ if (await dir.accountByEmail(s.email)) throw new HttpError(409, `${s.email} already has an account.`);
358+ if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
359+ // Creating the account accepts every open invite for this email, including this one.
360+ account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId });
361+ await auth.addPasskey({ ...key, account_id: account.id });
362+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey" } }));
363+ flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
364+ redirect = "/";
365+ } else if (s.kind === "link") {
366+ if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime.");
367+ const found = await dir.account(s.accountId);
368+ if (!found || found.status === "disabled") throw new HttpError(403, "This account is disabled.");
369+ account = found;
370+ await auth.addPasskey({ ...key, account_id: account.id });
371+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "passkey.add", detail: { name: label, via: "signin-link" } }));
372+ flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on.`);
373+ redirect = "/settings/security";
374+ } else {
375+ throw new HttpError(400, "unexpected sign-in attempt");
376+ }
377+ await startSession(c, auth, account.id, s.kind === "link" ? "link" : "passkey", key.id);
378+ return c.json({ redirect });
379+ });
380+
381+ // ------------------------------------------------------------ sign out
382+
383+ post(web, "/auth/logout", async (c) => {
384+ const token = getCookie(c, SESSION_COOKIE);
385+ if (token) await new Auth(c.env).endSession(token);
386+ deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true });
387+ return c.redirect("/login", 303);
388+ });
389+
390+ // ------------------------------------------------------------ recovery through Cloudflare Access
391+
392+ // Cloudflare Access protects only this path. It verifies the person's email,
393+ // and this handler turns that into a session so they can add a new passkey.
394+ web.get("/auth/access", async (c) => {
395+ if (!accessConfigured(c)) throw new HttpError(404, "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.");
396+ const jwt = accessToken(c.req.raw);
397+ if (!jwt) throw new HttpError(401, "Cloudflare Access did not sign this request. Open this page again to start the email check.");
398+ const claims = await verifyAccessJwt(jwt, c.env.ACCESS_TEAM_DOMAIN, c.env.ACCESS_AUD).catch((e: Error) => {
399+ throw new HttpError(401, `Cloudflare Access sign-in was rejected: ${e.message}`);
400+ });
401+ const email = typeof claims.email === "string" ? claims.email : null;
402+ if (!email) throw new HttpError(401, "Cloudflare Access did not provide an email address.");
403+ const dir = c.get("dir");
404+ const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined });
405+ if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
406+ const auth = new Auth(c.env);
407+ await startSession(c, auth, account.id, "access", null);
408+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "access" } }));
409+ if (account.status === "pending") return c.redirect("/", 302);
410+ const keys = await auth.passkeys(account.id);
411+ if (keys.length) return c.redirect(safeNext(c.req.query("next")), 302);
412+ flash(c, "notice", "You're signed in. Add a passkey now so you can sign in directly next time.");
413+ return c.redirect("/settings/security", 302);
414+ });
415+}
416+
417+/** The account behind a browser session (or dev sign-in). API tokens cannot manage passkeys. */
418+export function browserAccount(c: AppContext): Account {
419+ const p = c.get("principal");
420+ if (p.type !== "account" || p.via === "token") throw new HttpError(401, "Sign in with your passkey first.");
421+ if (p.account.kind !== "user") throw new HttpError(403, "only people can have passkeys");
422+ if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
423+ return p.account;
424+}
modifiedapps/diffinery/src/web/repo-settings.tsx+1−1View file
@@ -235,7 +235,7 @@
235235 </>
236236 )}
237237 </p>
238- {invite ? <Once title={`Invite created for ${invite.email}`} value={invite.url} note="Send this link. It expires in 14 days and only works for that email address. The person must also be allowed by your Cloudflare Access policy." /> : null}
238+ {invite ? <Once title={`Invite created for ${invite.email}`} value={invite.url} note="Send this link. It expires in 14 days. Opening it lets them create a passkey and join." /> : null}
239239 <div class="box">
240240 {collaborators.length ? (
241241 <table class="tbl">
modifiedapps/diffinery/src/web/routes.tsx+4−2View file
@@ -3,6 +3,7 @@
33 import type { Env } from "../env.js";
44 import type { AppVars } from "../auth/principal.js";
55 import { registerAccount } from "./account.js";
6+import { registerAuth } from "./auth.js";
67 import { registerActions } from "./actions.js";
78 import { registerCode } from "./code.js";
89 import { registerCollab } from "./collab.js";
@@ -12,7 +13,7 @@
1213
1314 /**
1415 * Server-rendered web UI. Mount at "/" after the API, Git and runner routes:
15- * it owns `/`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`,
16+ * it owns `/`, `/login`, `/auth/*`, `/signin/*`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`,
1617 * `/:owner` and `/:owner/:repo/...` (including `/:owner/:repo/raw/*`).
1718 *
1819 * Expects the `principal` and `dir` variables to be set by an earlier middleware.
@@ -23,11 +24,12 @@
2324 web.onError((err, c) => errorPage(c, err));
2425
2526 // Browsers and crawlers ask for these on every origin; keep them off the /:owner route.
26-web.get("/favicon.ico", (c) => c.body(ICONS.logo.replace(/currentColor/g, "#5e6ad2"), 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" }));
27+web.get("/favicon.ico", (c) => c.body(ICONS.logo.replace(/currentColor/g, "#f6821f"), 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" }));
2728 web.get("/robots.txt", (c) => c.text("User-agent: *\nDisallow: /\n", 200, { "cache-control": "public, max-age=86400" }));
2829
2930 // Order matters: fixed paths (/settings/tokens, /notifications/:id, …) must be
3031 // registered before the /:owner/:repo patterns they would otherwise match.
32+registerAuth(web);
3133 registerAccount(web);
3234 registerRepoSettings(web);
3335 registerActions(web);
modifiedapps/diffinery/src/web/ui.tsx+28−6View file
@@ -335,7 +335,7 @@
335335 <meta name="viewport" content="width=device-width, initial-scale=1" />
336336 <meta name="color-scheme" content="light dark" />
337337 <title>{opts.title ? `${opts.title} · Diffinery` : "Diffinery"}</title>
338- <link rel="icon" href={`data:image/svg+xml,${encodeURIComponent(ICONS.logo.replace("currentColor", "#5e6ad2"))}`} />
338+ <link rel="icon" href={`data:image/svg+xml,${encodeURIComponent(ICONS.logo.replace("currentColor", "#f6821f"))}`} />
339339 <style>{raw(CSS)}</style>
340340 </head>
341341 <body
@@ -369,9 +369,21 @@
369369 <a href="/settings" title="Settings" class="who">
370370 <Avatar login={me.login} /> <span>{me.login}</span>
371371 </a>
372- {p?.type === "account" && p.via === "access" ? <a href="/cdn-cgi/access/logout">Sign out</a> : null}
373372 </>
374373 ) : null}
374+ {p?.type === "account" && p.via === "session" ? (
375+ <form method="post" action="/auth/logout" class="inline">
376+ <input type="hidden" name={CSRF_FIELD} value={csrfToken(c)} />
377+ <button class="navbtn">Sign out</button>
378+ </form>
379+ ) : null}
380+ {!p || p.type === "anonymous" ? (
381+ c.req.path === "/login" ? null : (
382+ <a href={`/login${c.req.path === "/" ? "" : `?next=${encodeURIComponent(c.req.path)}`}`} class="btn primary sm">
383+ Sign in
384+ </a>
385+ )
386+ ) : null}
375387 </nav>
376388 </div>
377389 </header>
@@ -408,6 +420,11 @@
408420 export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> {
409421 const s = statusOf(e);
410422 if (s.status >= 500) console.error(e);
423+ // Signed-out visitors who open a page go straight to sign-in, and come back afterwards.
424+ if (s.status === 401 && c.req.method === "GET" && c.get("principal")?.type === "anonymous" && !c.req.path.startsWith("/auth/")) {
425+ const url = new URL(c.req.url);
426+ return c.redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
427+ }
411428 const titles: Record<number, string> = { 401: "Sign in required", 403: "Access denied", 404: "Not found", 405: "Not allowed", 409: "Conflict", 422: "Could not complete the request" };
412429 const title = titles[s.status] ?? (s.status >= 500 ? "Something went wrong" : "Request failed");
413430 const p = c.get("principal");
@@ -418,9 +435,14 @@
418435 <div class="faint" style="font-size:48px;font-weight:700;letter-spacing:-.03em">{s.status}</div>
419436 <h1>{title}</h1>
420437 {s.status === 401 ? (
421- <p class="muted">
422- Sign in through Cloudflare Access to continue. If you were just signed out, <a href={c.req.path}>reload this page</a> to start the Access sign-in flow.
423- </p>
438+ <>
439+ <p class="muted">{p?.type === "account" ? s.message : "Sign in with your passkey to continue."}</p>
440+ <p>
441+ <a class="btn primary" href={`/login?next=${encodeURIComponent(c.req.path)}`}>
442+ Sign in
443+ </a>
444+ </p>
445+ </>
424446 ) : s.status === 404 ? (
425447 <p class="muted">The page or repository does not exist, or you do not have access to it.</p>
426448 ) : (
@@ -432,7 +454,7 @@
432454 Request access
433455 </a>
434456 </p>
435- ) : (
457+ ) : s.status === 401 ? null : (
436458 <p>
437459 <a class="btn" href="/">
438460 Go to dashboard
addedapps/diffinery/test/e2e/passkeys.test.mjs+236−0View file
@@ -1,0 +1,236 @@
1+// End-to-end passkey sign-in: a real `wrangler dev` Worker and a real Chromium
2+// with a virtual WebAuthn authenticator (Chrome DevTools Protocol).
3+//
4+// pnpm --filter @diffinery/app e2e
5+//
6+// Covers: signed-out redirects, joining from an invite link with a new passkey
7+// (which also applies the invite's org membership), adding and removing
8+// passkeys, signing out and back in, admin-issued one-time sign-in links,
9+// single-use invites and links, and refusing cross-site and token-driven calls.
10+import { spawn, execFileSync } from "node:child_process";
11+import { createHash } from "node:crypto";
12+import { mkdtempSync, rmSync } from "node:fs";
13+import { tmpdir } from "node:os";
14+import { join, dirname, resolve } from "node:path";
15+import { fileURLToPath } from "node:url";
16+import { createServer } from "node:net";
17+import { test, before, after } from "node:test";
18+import assert from "node:assert/strict";
19+import { chromium } from "playwright";
20+
21+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
22+const work = mkdtempSync(join(tmpdir(), "diffinery-passkeys-"));
23+let base;
24+let dev;
25+let devLog = "";
26+let browser;
27+
28+const INVITE = "invite-token-for-pal-0123456789abcdef";
29+const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef";
30+const hash = (s) => createHash("sha256").update(s).digest("hex");
31+
32+const freePort = () =>
33+ new Promise((res) => {
34+ const s = createServer();
35+ s.listen(0, "127.0.0.1", () => {
36+ const { port } = s.address();
37+ s.close(() => res(port));
38+ });
39+ });
40+
41+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
42+async function until(what, fn, timeoutMs = 60_000) {
43+ const end = Date.now() + timeoutMs;
44+ while (Date.now() < end) {
45+ const v = await fn();
46+ if (v) return v;
47+ await sleep(500);
48+ }
49+ assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
50+}
51+
52+/** A fresh browser profile with its own virtual platform authenticator (Touch ID-like, user-verifying). */
53+async function person() {
54+ const context = await browser.newContext();
55+ const page = await context.newPage();
56+ page.on("dialog", (d) => d.accept());
57+ const cdp = await context.newCDPSession(page);
58+ await cdp.send("WebAuthn.enable");
59+ const addAuthenticator = async () =>
60+ (
61+ await cdp.send("WebAuthn.addVirtualAuthenticator", {
62+ options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
63+ })
64+ ).authenticatorId;
65+ let authenticatorId = await addAuthenticator();
66+ return {
67+ context,
68+ page,
69+ /** Swap in a new, empty authenticator (another device). */
70+ async newDevice() {
71+ await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
72+ authenticatorId = await addAuthenticator();
73+ },
74+ async credentials() {
75+ return (await cdp.send("WebAuthn.getCredentials", { authenticatorId })).credentials;
76+ },
77+ };
78+}
79+
80+const text = async (page) => (await page.textContent("main")) ?? "";
81+
82+before(async () => {
83+ const port = await freePort();
84+ // WebAuthn needs a secure context and a domain RP ID: localhost qualifies, 127.0.0.1 does not.
85+ base = `http://localhost:${port}`;
86+ const persist = join(work, "state");
87+ const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
88+ wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
89+ const at = new Date().toISOString();
90+ const later = new Date(Date.now() + 86400_000).toISOString();
91+ wrangler(
92+ "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command",
93+ [
94+ `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('boss1', 'boss', 'user', 'The Boss', 'boss@example.com', 'active', 1, '${at}')`,
95+ `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('crew1', 'crew', 'org', 'Crew', 'active', '${at}')`,
96+ `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('crew1', 'boss1', 'owner', '${at}')`,
97+ `INSERT INTO invites (id, org_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ('inv1', 'crew1', 'pal@example.com', 'member', '${hash(INVITE)}', 'boss1', '${at}', '${later}')`,
98+ `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`,
99+ ].join("; "),
100+ );
101+ dev = spawn(
102+ "pnpm",
103+ ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"],
104+ { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
105+ );
106+ dev.stdout.on("data", (d) => (devLog += d));
107+ dev.stderr.on("data", (d) => (devLog += d));
108+ await until(
109+ "wrangler dev",
110+ async () => {
111+ try {
112+ return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok;
113+ } catch {
114+ return false;
115+ }
116+ },
117+ 120_000,
118+ );
119+ browser = await chromium.launch();
120+});
121+
122+after(async () => {
123+ await browser?.close();
124+ if (dev?.pid) {
125+ try {
126+ process.kill(-dev.pid, "SIGTERM");
127+ } catch {}
128+ }
129+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
130+});
131+
132+test("passkey sign-in, invites and sign-in links", async () => {
133+ // ------------------------------------------------------------ signed out
134+ const pal = await person();
135+ const { page } = pal;
136+ await page.goto(`${base}/settings`);
137+ assert.equal(new URL(page.url()).pathname, "/login");
138+ assert.equal(new URL(page.url()).searchParams.get("next"), "/settings");
139+ assert.match(await text(page), /Sign in with a passkey/);
140+
141+ // A passkey nobody registered is refused.
142+ await page.click("text=Sign in with a passkey");
143+ await page.waitForSelector("[data-passkey-msg]:not([hidden])");
144+ assert.match(await page.textContent("[data-passkey-msg]"), /passkey|cancelled|closed/i);
145+
146+ // ------------------------------------------------------------ join from an invite link
147+ await page.goto(`${base}/invite/${INVITE}`);
148+ assert.match(await text(page), /invited as pal@example\.com/);
149+ await page.fill("#login", "pal");
150+ await page.fill("#name", "Pal Friend");
151+ await Promise.all([page.waitForURL(`${base}/`), page.click("text=Create a passkey and join")]);
152+ assert.match(await text(page), /Welcome to Diffinery, pal/);
153+ assert.equal((await pal.credentials()).length, 1);
154+ const cookies = await pal.context.cookies(base);
155+ const session = cookies.find((c) => c.name === "__Host-dfy_session");
156+ assert.ok(session, `session cookie missing: ${JSON.stringify(cookies.map((c) => c.name))}`);
157+ assert.equal(session.httpOnly, true);
158+ assert.equal(session.sameSite, "Lax");
159+ // The invite's org membership was applied.
160+ await page.goto(`${base}/settings/orgs`);
161+ assert.match(await text(page), /crew/);
162+
163+ // Invites work once.
164+ const stranger = await person();
165+ const reused = await stranger.page.goto(`${base}/invite/${INVITE}`);
166+ assert.equal(reused.status(), 404);
167+
168+ // ------------------------------------------------------------ manage passkeys
169+ await page.goto(`${base}/settings/security`);
170+ let body = await text(page);
171+ assert.match(body, /Passkeys and sessions/);
172+ assert.match(body, /this browser/);
173+ assert.match(body, /Only passkey/);
174+ await pal.newDevice();
175+ await page.fill("#pklabel", "Laptop");
176+ await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Add a passkey')")]);
177+ await page.waitForSelector("text=Passkey \"Laptop\" added.");
178+ body = await text(page);
179+ assert.match(body, /Laptop/);
180+ assert.equal(await page.locator("button:has-text('Remove')").count(), 2);
181+ // Remove the first device's passkey (the one this browser no longer holds). This browser stays signed in.
182+ await Promise.all([page.waitForURL(`${base}/settings/security`), page.locator("li:not(:has-text('Laptop')) button:has-text('Remove')").first().click()]);
183+ await page.waitForSelector("text=Passkey removed.");
184+ body = await text(page);
185+ assert.match(body, /Only passkey/);
186+ assert.match(body, /Laptop/);
187+
188+ // ------------------------------------------------------------ sign out, then in with the remaining passkey
189+ await Promise.all([page.waitForURL(/\/login$/), page.click("header button:has-text('Sign out')")]);
190+ await page.goto(`${base}/settings`);
191+ assert.equal(new URL(page.url()).pathname, "/login");
192+ await page.goto(`${base}/login?next=/settings/security`);
193+ await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("text=Sign in with a passkey")]);
194+ assert.match(await text(page), /Laptop/);
195+
196+ // ------------------------------------------------------------ admin sign-in links
197+ const boss = await person();
198+ await boss.page.goto(`${base}/signin/${ADMIN_LINK}`);
199+ assert.match(await text(boss.page), /add a passkey to boss/);
200+ await Promise.all([boss.page.waitForURL(`${base}/settings/security`), boss.page.click("button:has-text('Create a passkey')")]);
201+ assert.match(await text(boss.page), /Use it to sign in from now on/);
202+ const usedLink = await stranger.page.goto(`${base}/signin/${ADMIN_LINK}`);
203+ assert.equal(usedLink.status(), 404);
204+
205+ // The admin issues a link for pal (say pal lost the laptop), and pal uses it on a new device.
206+ await boss.page.goto(`${base}/settings/admin`);
207+ await Promise.all([boss.page.waitForLoadState("load"), boss.page.locator("tr:has-text('pal@example.com') button:has-text('Sign-in link')").click()]);
208+ await boss.page.waitForSelector("text=Sign-in link for pal");
209+ const link = (await boss.page.textContent(".secret")).trim();
210+ assert.match(link, new RegExp(`^${base}/signin/`));
211+ await pal.newDevice();
212+ await page.goto(link);
213+ await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("button:has-text('Create a passkey')")]);
214+ assert.equal(await page.locator("button:has-text('Remove')").count(), 2);
215+
216+ // ------------------------------------------------------------ refused requests
217+ const cross = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "{}" });
218+ assert.equal(cross.status, 403);
219+ const form = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "a=b" });
220+ assert.equal(form.status, 415);
221+ const noChallenge = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response: { id: "x" } }) });
222+ assert.equal(noChallenge.status, 400);
223+ const anonymous = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
224+ assert.equal(anonymous.status, 401);
225+
226+ // Screenshots for review (kept with E2E_KEEP=1).
227+ if (process.env.E2E_SCREENSHOTS) {
228+ const out = process.env.E2E_SCREENSHOTS;
229+ await stranger.page.goto(`${base}/login`);
230+ await stranger.page.screenshot({ path: join(out, "login.png"), fullPage: true });
231+ await page.goto(`${base}/settings/security`);
232+ await page.screenshot({ path: join(out, "security.png"), fullPage: true });
233+ await boss.page.goto(`${base}/settings/admin`);
234+ await boss.page.screenshot({ path: join(out, "admin.png"), fullPage: true });
235+ }
236+});
addeddocs/PREFERENCES.md+24−0View file
@@ -1,0 +1,24 @@
1+# Team preferences
2+
3+These are strong defaults from the project owners. Follow them where they fit,
4+and use judgment where they don't.
5+
6+## Sign-in: passkeys first
7+
8+- People sign in with passkeys (WebAuthn). Don't add passwords.
9+- New people create their passkey from their invite link.
10+- Recovery goes through a one-time sign-in link from an admin, or email
11+ verification through Cloudflare Access at `/auth/access`. Access guards only
12+ that path.
13+- Machines (git, the CLI, MCP clients, runners) use Diffinery tokens, never
14+ browser sessions.
15+
16+## UI: Kumo
17+
18+- The look follows [Kumo](https://kumo-ui.com), Cloudflare's design system:
19+ its color tokens (light and dark), radii, type and focus styles. The tokens
20+ live as CSS variables in `apps/diffinery/src/web/assets.ts`.
21+- Kumo's components are React. Our pages are rendered on the server with
22+ `hono/jsx`, so for now we match Kumo's tokens and patterns rather than
23+ shipping its components. If a page grows into a client-side app, build it
24+ with Kumo components.
modifiedpnpm-lock.yaml+276−2View file
@@ -48,6 +48,9 @@
4848 '@diffinery/workflow-yaml':
4949 specifier: workspace:*
5050 version: link:../../packages/workflow-yaml
51+ '@simplewebauthn/server':
52+ specifier: 14.0.3
53+ version: 14.0.3
5154 hono:
5255 specifier: ^4.13.0
5356 version: 4.13.13
@@ -58,6 +61,9 @@
5861 '@cloudflare/workers-types':
5962 specifier: ^5.20261012.1
6063 version: 5.20261012.1
64+ playwright:
65+ specifier: 1.56.1
66+ version: 1.56.1
6167 typescript:
6268 specifier: ^5.9.3
6369 version: 5.9.3
@@ -704,6 +710,9 @@
704710 cpu: [x64]
705711 os: [win32]
706712
713+ '@hexagon/base64@1.1.28':
714+ resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
715+
707716 '@img/colour@1.1.0':
708717 resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
709718 engines: {node: '>=18'}
@@ -1002,9 +1011,71 @@
10021011 '@jridgewell/trace-mapping@0.3.9':
10031012 resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
10041013
1014+ '@levischuck/tiny-cbor@0.2.11':
1015+ resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
1016+
10051017 '@oxc-project/types@0.153.0':
10061018 resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
10071019
1020+ '@peculiar/asn1-android@2.10.0':
1021+ resolution: {integrity: sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA==}
1022+ engines: {node: '>=14'}
1023+
1024+ '@peculiar/asn1-asym-key@2.10.0':
1025+ resolution: {integrity: sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg==}
1026+ engines: {node: '>=14'}
1027+
1028+ '@peculiar/asn1-cms@2.10.0':
1029+ resolution: {integrity: sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==}
1030+ engines: {node: '>=14'}
1031+
1032+ '@peculiar/asn1-csr@2.10.0':
1033+ resolution: {integrity: sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==}
1034+ engines: {node: '>=14'}
1035+
1036+ '@peculiar/asn1-ecc@2.10.0':
1037+ resolution: {integrity: sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==}
1038+ engines: {node: '>=14'}
1039+
1040+ '@peculiar/asn1-pfx@2.10.0':
1041+ resolution: {integrity: sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==}
1042+ engines: {node: '>=14'}
1043+
1044+ '@peculiar/asn1-pkcs8@2.10.0':
1045+ resolution: {integrity: sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==}
1046+ engines: {node: '>=14'}
1047+
1048+ '@peculiar/asn1-pkcs9@2.10.0':
1049+ resolution: {integrity: sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==}
1050+ engines: {node: '>=14'}
1051+
1052+ '@peculiar/asn1-rsa@2.10.0':
1053+ resolution: {integrity: sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==}
1054+ engines: {node: '>=14'}
1055+
1056+ '@peculiar/asn1-schema@2.10.0':
1057+ resolution: {integrity: sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==}
1058+ engines: {node: '>=14'}
1059+
1060+ '@peculiar/asn1-x509-attr@2.10.0':
1061+ resolution: {integrity: sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==}
1062+ engines: {node: '>=14'}
1063+
1064+ '@peculiar/asn1-x509-post-quantum@2.10.0':
1065+ resolution: {integrity: sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w==}
1066+ engines: {node: '>=14'}
1067+
1068+ '@peculiar/asn1-x509@2.10.0':
1069+ resolution: {integrity: sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==}
1070+ engines: {node: '>=14'}
1071+
1072+ '@peculiar/utils@2.0.3':
1073+ resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
1074+
1075+ '@peculiar/x509@2.1.0':
1076+ resolution: {integrity: sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==}
1077+ engines: {node: '>=20.0.0'}
1078+
10081079 '@poppinss/colors@4.1.6':
10091080 resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
10101081
@@ -1107,6 +1178,10 @@
11071178 '@rolldown/pluginutils@1.0.1':
11081179 resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
11091180
1181+ '@simplewebauthn/server@14.0.3':
1182+ resolution: {integrity: sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==}
1183+ engines: {node: '>=20.0.0'}
1184+
11101185 '@sindresorhus/is@7.2.0':
11111186 resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
11121187 engines: {node: '>=18'}
@@ -1191,6 +1266,10 @@
11911266 '@vitest/utils@4.1.11':
11921267 resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
11931268
1269+ asn1js@3.0.10:
1270+ resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
1271+ engines: {node: '>=12.0.0'}
1272+
11941273 assertion-error@2.0.1:
11951274 resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
11961275 engines: {node: '>=12'}
@@ -1257,6 +1336,11 @@
12571336 picomatch:
12581337 optional: true
12591338
1339+ fsevents@2.3.2:
1340+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
1341+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
1342+ os: [darwin]
1343+
12601344 fsevents@2.3.3:
12611345 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
12621346 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1376,10 +1460,30 @@
13761460 resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
13771461 engines: {node: '>=12'}
13781462
1463+ playwright-core@1.56.1:
1464+ resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
1465+ engines: {node: '>=18'}
1466+ hasBin: true
1467+
1468+ playwright@1.56.1:
1469+ resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
1470+ engines: {node: '>=18'}
1471+ hasBin: true
1472+
13791473 postcss@8.5.29:
13801474 resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
13811475 engines: {node: ^10 || ^12 || >=14}
13821476
1477+ pvtsutils@1.3.6:
1478+ resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
1479+
1480+ pvutils@1.2.0:
1481+ resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==}
1482+ engines: {node: '>=16.0.0'}
1483+
1484+ reflect-metadata@0.2.2:
1485+ resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
1486+
13831487 rolldown@1.2.13:
13841488 resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==}
13851489 engines: {node: ^20.19.0 || >=22.12.0}
@@ -1435,9 +1539,16 @@
14351539 resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==}
14361540 engines: {node: '>=14.0.0'}
14371541
1542+ tslib@1.14.1:
1543+ resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
1544+
14381545 tslib@2.8.1:
14391546 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
14401547
1548+ tsyringe@4.10.0:
1549+ resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
1550+ engines: {node: '>= 6.0.0'}
1551+
14411552 turbo@2.11.7:
14421553 resolution: {integrity: sha512-RhXHbqfvY6o3xt/zSHCwSNwLrZlXiqHzewi4eAj9PtZ1T1CqgMJ2yIDb7SKPTG9IikTm925mfzL6hldQVdyNWw==}
14431554 hasBin: true
@@ -1914,6 +2025,8 @@
19142025 '@esbuild/win32-x64@0.28.2':
19152026 optional: true
19162027
2028+ '@hexagon/base64@1.1.28': {}
2029+
19172030 '@img/colour@1.1.0': {}
19182031
19192032 '@img/sharp-darwin-arm64@0.35.2':
@@ -2133,8 +2246,126 @@
21332246 '@jridgewell/resolve-uri': 3.1.2
21342247 '@jridgewell/sourcemap-codec': 1.6.0
21352248
2249+ '@levischuck/tiny-cbor@0.2.11': {}
2250+
21362251 '@oxc-project/types@0.153.0': {}
21372252
2253+ '@peculiar/asn1-android@2.10.0':
2254+ dependencies:
2255+ '@peculiar/asn1-schema': 2.10.0
2256+ asn1js: 3.0.10
2257+ tslib: 2.8.1
2258+
2259+ '@peculiar/asn1-asym-key@2.10.0':
2260+ dependencies:
2261+ '@peculiar/asn1-pkcs8': 2.10.0
2262+ '@peculiar/asn1-schema': 2.10.0
2263+ '@peculiar/asn1-x509': 2.10.0
2264+ asn1js: 3.0.10
2265+ tslib: 2.8.1
2266+
2267+ '@peculiar/asn1-cms@2.10.0':
2268+ dependencies:
2269+ '@peculiar/asn1-schema': 2.10.0
2270+ '@peculiar/asn1-x509': 2.10.0
2271+ '@peculiar/asn1-x509-attr': 2.10.0
2272+ asn1js: 3.0.10
2273+ tslib: 2.8.1
2274+
2275+ '@peculiar/asn1-csr@2.10.0':
2276+ dependencies:
2277+ '@peculiar/asn1-schema': 2.10.0
2278+ '@peculiar/asn1-x509': 2.10.0
2279+ asn1js: 3.0.10
2280+ tslib: 2.8.1
2281+
2282+ '@peculiar/asn1-ecc@2.10.0':
2283+ dependencies:
2284+ '@peculiar/asn1-schema': 2.10.0
2285+ '@peculiar/asn1-x509': 2.10.0
2286+ asn1js: 3.0.10
2287+ tslib: 2.8.1
2288+
2289+ '@peculiar/asn1-pfx@2.10.0':
2290+ dependencies:
2291+ '@peculiar/asn1-cms': 2.10.0
2292+ '@peculiar/asn1-pkcs8': 2.10.0
2293+ '@peculiar/asn1-rsa': 2.10.0
2294+ '@peculiar/asn1-schema': 2.10.0
2295+ asn1js: 3.0.10
2296+ tslib: 2.8.1
2297+
2298+ '@peculiar/asn1-pkcs8@2.10.0':
2299+ dependencies:
2300+ '@peculiar/asn1-schema': 2.10.0
2301+ '@peculiar/asn1-x509': 2.10.0
2302+ asn1js: 3.0.10
2303+ tslib: 2.8.1
2304+
2305+ '@peculiar/asn1-pkcs9@2.10.0':
2306+ dependencies:
2307+ '@peculiar/asn1-cms': 2.10.0
2308+ '@peculiar/asn1-pfx': 2.10.0
2309+ '@peculiar/asn1-pkcs8': 2.10.0
2310+ '@peculiar/asn1-schema': 2.10.0
2311+ '@peculiar/asn1-x509': 2.10.0
2312+ '@peculiar/asn1-x509-attr': 2.10.0
2313+ asn1js: 3.0.10
2314+ tslib: 2.8.1
2315+
2316+ '@peculiar/asn1-rsa@2.10.0':
2317+ dependencies:
2318+ '@peculiar/asn1-schema': 2.10.0
2319+ '@peculiar/asn1-x509': 2.10.0
2320+ asn1js: 3.0.10
2321+ tslib: 2.8.1
2322+
2323+ '@peculiar/asn1-schema@2.10.0':
2324+ dependencies:
2325+ '@peculiar/utils': 2.0.3
2326+ asn1js: 3.0.10
2327+ tslib: 2.8.1
2328+
2329+ '@peculiar/asn1-x509-attr@2.10.0':
2330+ dependencies:
2331+ '@peculiar/asn1-schema': 2.10.0
2332+ '@peculiar/asn1-x509': 2.10.0
2333+ asn1js: 3.0.10
2334+ tslib: 2.8.1
2335+
2336+ '@peculiar/asn1-x509-post-quantum@2.10.0':
2337+ dependencies:
2338+ '@peculiar/asn1-asym-key': 2.10.0
2339+ '@peculiar/asn1-schema': 2.10.0
2340+ '@peculiar/asn1-x509': 2.10.0
2341+ asn1js: 3.0.10
2342+ tslib: 2.8.1
2343+
2344+ '@peculiar/asn1-x509@2.10.0':
2345+ dependencies:
2346+ '@peculiar/asn1-schema': 2.10.0
2347+ '@peculiar/utils': 2.0.3
2348+ asn1js: 3.0.10
2349+ tslib: 2.8.1
2350+
2351+ '@peculiar/utils@2.0.3':
2352+ dependencies:
2353+ tslib: 2.8.1
2354+
2355+ '@peculiar/x509@2.1.0':
2356+ dependencies:
2357+ '@peculiar/asn1-cms': 2.10.0
2358+ '@peculiar/asn1-csr': 2.10.0
2359+ '@peculiar/asn1-ecc': 2.10.0
2360+ '@peculiar/asn1-pkcs9': 2.10.0
2361+ '@peculiar/asn1-rsa': 2.10.0
2362+ '@peculiar/asn1-schema': 2.10.0
2363+ '@peculiar/asn1-x509': 2.10.0
2364+ '@peculiar/asn1-x509-post-quantum': 2.10.0
2365+ pvtsutils: 1.3.6
2366+ tslib: 2.8.1
2367+ tsyringe: 4.10.0
2368+
21382369 '@poppinss/colors@4.1.6':
21392370 dependencies:
21402371 kleur: 4.1.5
@@ -2194,6 +2425,19 @@
21942425
21952426 '@rolldown/pluginutils@1.0.1': {}
21962427
2428+ '@simplewebauthn/server@14.0.3':
2429+ dependencies:
2430+ '@hexagon/base64': 1.1.28
2431+ '@levischuck/tiny-cbor': 0.2.11
2432+ '@peculiar/asn1-android': 2.10.0
2433+ '@peculiar/asn1-ecc': 2.10.0
2434+ '@peculiar/asn1-rsa': 2.10.0
2435+ '@peculiar/asn1-schema': 2.10.0
2436+ '@peculiar/asn1-x509': 2.10.0
2437+ '@peculiar/asn1-x509-post-quantum': 2.10.0
2438+ '@peculiar/x509': 2.1.0
2439+ reflect-metadata: 0.2.2
2440+
21972441 '@sindresorhus/is@7.2.0': {}
21982442
21992443 '@speed-highlight/core@1.2.24': {}
@@ -2282,6 +2526,12 @@
22822526 convert-source-map: 2.0.0
22832527 tinyrainbow: 3.2.0
22842528
2529+ asn1js@3.0.10:
2530+ dependencies:
2531+ pvtsutils: 1.3.6
2532+ pvutils: 1.2.0
2533+ tslib: 2.8.1
2534+
22852535 assertion-error@2.0.1: {}
22862536
22872537 blake3-wasm@2.1.5: {}
@@ -2399,6 +2649,9 @@
23992649 optionalDependencies:
24002650 picomatch: 4.0.7
24012651
2652+ fsevents@2.3.2:
2653+ optional: true
2654+
24022655 fsevents@2.3.3:
24032656 optional: true
24042657
@@ -2498,12 +2751,28 @@
24982751
24992752 picomatch@4.0.7: {}
25002753
2754+ playwright-core@1.56.1: {}
2755+
2756+ playwright@1.56.1:
2757+ dependencies:
2758+ playwright-core: 1.56.1
2759+ optionalDependencies:
2760+ fsevents: 2.3.2
2761+
25012762 postcss@8.5.29:
25022763 dependencies:
25032764 nanoid: 3.3.20
25042765 picocolors: 1.1.1
25052766 source-map-js: 1.2.2
25062767
2768+ pvtsutils@1.3.6:
2769+ dependencies:
2770+ tslib: 2.8.1
2771+
2772+ pvutils@1.2.0: {}
2773+
2774+ reflect-metadata@0.2.2: {}
2775+
25072776 rolldown@1.2.13:
25082777 dependencies:
25092778 '@oxc-project/types': 0.153.0
@@ -2613,9 +2882,14 @@
26132882
26142883 tinyrainbow@3.2.0: {}
26152884
2616- tslib@2.8.1:
2617- optional: true
2885+ tslib@1.14.1: {}
26182886
2887+ tslib@2.8.1: {}
2888+
2889+ tsyringe@4.10.0:
2890+ dependencies:
2891+ tslib: 1.14.1
2892+
26192893 turbo@2.11.7:
26202894 optionalDependencies:
26212895 '@turbo/darwin-64': 2.11.7
modifiedscripts/bootstrap.sh+22−29View file
@@ -2,8 +2,9 @@
22 # Idempotent production setup for Diffinery on a Cloudflare account.
33 #
44 # Creates (only when missing): the D1 database, R2 bucket, event queues and the
5-# Cloudflare Access applications that put sign-in in front of the web UI while
6-# letting git, the API, MCP and runners authenticate with Diffinery tokens.
5+# Cloudflare Access application for /auth/access, the email-based account
6+# recovery path. Everyday sign-in uses passkeys; git, the API, MCP and runners
7+# use Diffinery tokens.
78 # Then renders apps/diffinery/wrangler.deploy.jsonc and applies D1 migrations.
89 #
910 # Requires: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, node, jq, curl.
@@ -80,43 +81,35 @@
8081 APPS="$(cf GET "/access/apps?per_page=100" || echo '{}')"
8182 AUD=""
8283 if ! jq -e '.success' >/dev/null <<<"$APPS"; then
83- warn "Could not read Access applications (token needs Access: Apps and Policies Edit). Sign-in stays disabled until Access is configured."
84+ warn "Could not read Access applications (token needs Access: Apps and Policies Edit). Passkeys work; email recovery stays off."
8485 else
85- WEB_APP="$(jq -c --arg n "Diffinery" '.result[] | select(.name==$n)' <<<"$APPS" | head -n1)"
86- if [[ -z "$WEB_APP" ]]; then
87- WEB_APP="$(cf POST /access/apps "$(jq -nc --arg d "$DOMAIN" '{
88- name: "Diffinery", type: "self_hosted", domain: $d,
89- destinations: [{type: "public", uri: $d}],
90- session_duration: "168h", app_launcher_visible: true, http_only_cookie_attribute: true, same_site_cookie_attribute: "lax",
91- policies: [{name: "Anyone who signs in (Diffinery invites decide access)", decision: "allow", include: [{everyone: {}}], precedence: 1}]
92- }')" | jq -c '.result // empty')"
93- [[ -n "$WEB_APP" ]] && echo "created Access app for $DOMAIN" || warn "Could not create the Access application for $DOMAIN."
86+ # People sign in with passkeys. Access guards only /auth/access, the email-based
87+ # recovery path, so everything else (web, git, API, MCP, runners) reaches the Worker directly.
88+ WEB_BODY="$(jq -nc --arg d "$DOMAIN" '{
89+ name: "Diffinery account recovery", type: "self_hosted", domain: ($d + "/auth/access"),
90+ destinations: [{type: "public", uri: ($d + "/auth/access")}],
91+ session_duration: "15m", app_launcher_visible: false, http_only_cookie_attribute: true, same_site_cookie_attribute: "lax"
92+ }')"
93+ POLICY='[{"name": "Anyone who proves their email (Diffinery decides access)", "decision": "allow", "include": [{"everyone": {}}], "precedence": 1}]'
94+ WEB_ID="$(jq -r '.result[] | select(.name=="Diffinery account recovery" or .name=="Diffinery") | .id' <<<"$APPS" | head -n1)"
95+ if [[ -z "$WEB_ID" ]]; then
96+ WEB_APP="$(cf POST /access/apps "$(jq -c --argjson p "$POLICY" '. + {policies: $p}' <<<"$WEB_BODY")" | jq -c '.result // empty')"
97+ [[ -n "$WEB_APP" ]] && echo "created Access app for $DOMAIN/auth/access" || warn "Could not create the Access application; email recovery stays off."
9498 else
95- echo "Access app exists"
99+ WEB_APP="$(cf PUT "/access/apps/$WEB_ID" "$WEB_BODY" | jq -c '.result // empty')"
100+ [[ -n "$WEB_APP" ]] && echo "Access app covers $DOMAIN/auth/access" || warn "Could not update the Access application."
96101 fi
97102 AUD="$(jq -r '.aud // empty' <<<"${WEB_APP:-{}}")"
98103
99- # Machine endpoints authenticate with Diffinery tokens, so Access must not intercept them.
100- BYPASS_DESTS="$(jq -nc --arg d "$DOMAIN" '[
101- "/api/*", "/mcp", "/mcp/*", "/runner/*", "/.well-known/*", "/healthz",
102- "/*/*/info/refs", "/*/*/git-upload-pack", "/*/*/git-receive-pack", "/*/*/info/lfs/*",
103- "/*/*/actions/runs/*/artifacts/*"
104- ] | map({type: "public", uri: ($d + .)})')"
105- BYPASS_BODY="$(jq -nc --arg d "$DOMAIN" --argjson dests "$BYPASS_DESTS" '{
106- name: "Diffinery machine endpoints", type: "self_hosted", domain: ($d + "/api/*"), destinations: $dests,
107- app_launcher_visible: false,
108- policies: [{name: "Bypass: Diffinery verifies tokens itself", decision: "bypass", include: [{everyone: {}}], precedence: 1}]
109- }')"
104+ # Earlier versions put Access in front of the whole site and needed a bypass app; remove it.
110105 BYPASS_ID="$(jq -r '.result[] | select(.name=="Diffinery machine endpoints") | .id' <<<"$APPS" | head -n1)"
111- if [[ -z "$BYPASS_ID" ]]; then
112- cf POST /access/apps "$BYPASS_BODY" | jq -e '.success' >/dev/null && echo "created Access bypass for git/API/MCP/runner paths" || warn "Could not create the Access bypass application; git and API calls will be redirected to sign-in."
113- else
114- cf PUT "/access/apps/$BYPASS_ID" "$(jq -c 'del(.policies)' <<<"$BYPASS_BODY")" | jq -e '.success' >/dev/null && echo "updated Access bypass paths" || warn "Could not update the Access bypass application."
106+ if [[ -n "$BYPASS_ID" ]]; then
107+ cf DELETE "/access/apps/$BYPASS_ID" | jq -e '.success' >/dev/null && echo "removed the old Access bypass app" || warn "Could not remove the old Access bypass application."
115108 fi
116109 fi
117110 if [[ -z "$TEAM" ]]; then
118111 # Fall back to the sign-in redirect, which names the team domain.
119- TEAM="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://$DOMAIN/" | sed -nE 's#^https://([^/]+\.cloudflareaccess\.com)/.*#\1#p' || true)"
112+ TEAM="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://$DOMAIN/auth/access" | sed -nE 's#^https://([^/]+\.cloudflareaccess\.com)/.*#\1#p' || true)"
120113 fi
121114 [[ -n "$TEAM" ]] || warn "Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it)."
122115 echo "team=${TEAM:-?} aud=${AUD:+set}"