People and profile rules agree everywhere; org base permission; view transitions (#28)
Visibility "internal" gets the same 422 on create and update. One pending invitation per address and org or repository (any letter case). Org pictures are owner-only. Blank display names fall back to the username. Username fields match the server (no dots). Organizations get a GitHub-style base permission (new orgs read, existing orgs migrated to write). Page changes use a cross-document view transition under 100 ms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QMFviVKD61QaDPxdrh7C3f
Canberk Demirci authored · GitHub committed signed1 parent aa4f8e0commit 3dce95c60c30c6b2a8db3abcd35ba73ee9a0d181Browse files
13 files changed+239−46
Files (13)
- apps/diffinery/migrations/0009_org_base_permission.sql+6−0
- apps/diffinery/src/http/api.ts+28−3
- apps/diffinery/src/http/avatars.ts+3−2
- apps/diffinery/src/lib/directory.ts+57−19
- apps/diffinery/src/mail/send.ts+1−1
- apps/diffinery/src/mail/web.tsx+1−1
- apps/diffinery/src/web/account.tsx+27−15
- apps/diffinery/src/web/assets.ts+18−0
- apps/diffinery/src/web/auth.tsx+2−2
- apps/diffinery/src/web/repo-settings.tsx+1−1
- apps/diffinery/src/web/ui.tsx+3−0
- apps/diffinery/test/e2e/e2e.test.mjs+45−1
- apps/diffinery/test/e2e/security.test.mjs+47−1
addedapps/diffinery/migrations/0009_org_base_permission.sql+6−0View file
| @@ -1,0 +1,6 @@ | ||
| 1 | +-- Organization base permission (GitHub's "base permissions"): the role every member gets on the | |
| 2 | +-- organization's repositories, one of none, read, write or admin. Owners and admins always get admin, | |
| 3 | +-- and per-repository grants add to it. New organizations start at read; existing ones keep the | |
| 4 | +-- write access their members already had. | |
| 5 | +ALTER TABLE accounts ADD COLUMN base_role TEXT; | |
| 6 | +UPDATE accounts SET base_role = 'write' WHERE kind = 'org'; |
modifiedapps/diffinery/src/http/api.ts+28−3View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | import { fromUtf8 } from "@diffinery/git-core"; |
| 3 | 3 | import type { Env } from "../env.js"; |
| 4 | 4 | import { parseScopes, requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js"; |
| 5 | -import type { Account, Repo, TokenScope } from "../lib/directory.js"; | |
| 5 | +import { baseRole, type Account, type Repo, type TokenScope } from "../lib/directory.js"; | |
| 6 | 6 | import { encryptSecret } from "../lib/crypto.js"; |
| 7 | 7 | import { publicJwk } from "../lib/jwt.js"; |
| 8 | 8 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | // Mounted first: some of their paths (like /issues/comments) would otherwise match routes below. |
| 27 | 27 | api.route("/", compat); |
| 28 | 28 | |
| 29 | -const userJson = (c: AppContext, a: Pick<Account, "id" | "login" | "kind" | "name">) => ({ ...ghUser(baseOf(c), a)!, name: a.name }); | |
| 29 | +const userJson = (c: AppContext, a: Pick<Account, "id" | "login" | "kind" | "name">) => ({ ...ghUser(baseOf(c), a)!, name: a.name || null }); | |
| 30 | 30 | |
| 31 | 31 | const repoJson = repoOut; |
| 32 | 32 | |
| @@ -71,6 +71,32 @@ | ||
| 71 | 71 | const b = await body<{ login: string; name?: string }>(c); |
| 72 | 72 | return c.json(userJson(c, await c.get("dir").createOrg(b.login, b.name ?? b.login, a)), 201); |
| 73 | 73 | }); |
| 74 | +// GitHub's organization object; members also see the base permission (default_repository_permission). | |
| 75 | +const orgJson = async (c: AppContext, org: Account, me: Account) => { | |
| 76 | + const role = await c.get("dir").orgRole(org.id, me.id); | |
| 77 | + return { ...userJson(c, org), ...(role || me.site_admin ? { default_repository_permission: baseRole(org) } : {}) }; | |
| 78 | +}; | |
| 79 | +api.get("/orgs/:org", async (c) => { | |
| 80 | + const a = requireAccount(c); | |
| 81 | + const org = await c.get("dir").accountByLogin(c.req.param("org")); | |
| 82 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 83 | + return c.json(await orgJson(c, org, a)); | |
| 84 | +}); | |
| 85 | +api.patch("/orgs/:org", async (c) => { | |
| 86 | + requireAccountScope(c, "admin"); | |
| 87 | + const a = requireAccount(c); | |
| 88 | + const dir = c.get("dir"); | |
| 89 | + const org = await dir.accountByLogin(c.req.param("org")); | |
| 90 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 91 | + const role = await dir.orgRole(org.id, a.id); | |
| 92 | + if (!role && !a.site_admin) throw new HttpError(404, "not found"); | |
| 93 | + if (role !== "owner" && !a.site_admin) throw new HttpError(403, "only organization owners can change its settings"); | |
| 94 | + const b = await body<{ name?: string; default_repository_permission?: string }>(c); | |
| 95 | + if (b.default_repository_permission !== undefined) await dir.setBaseRole(org.id, b.default_repository_permission); | |
| 96 | + if (b.name !== undefined) await dir.updateAccount(org.id, { name: b.name }); | |
| 97 | + c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.update", target: org.login, detail: { name: b.name, base_role: b.default_repository_permission } })); | |
| 98 | + return c.json(await orgJson(c, (await dir.account(org.id))!, a)); | |
| 99 | +}); | |
| 74 | 100 | api.get("/orgs/:org/members", async (c) => { |
| 75 | 101 | const a = requireAccount(c); |
| 76 | 102 | const dir = c.get("dir"); |
| @@ -149,7 +175,6 @@ | ||
| 149 | 175 | api.patch("/repos/:owner/:repo", async (c) => { |
| 150 | 176 | const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 151 | 177 | const b = await body<{ name?: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string; archived?: boolean }>(c); |
| 152 | - if (b.visibility !== undefined && b.visibility !== "public" && b.visibility !== "private") throw new HttpError(422, "visibility must be public or private", { errors: [{ resource: "Repository", code: "invalid", field: "visibility" }] }); | |
| 153 | 178 | if (b.private !== undefined && typeof b.private !== "boolean") throw new HttpError(422, "private must be a boolean", { errors: [{ resource: "Repository", code: "invalid", field: "private" }] }); |
| 154 | 179 | // GitHub's `private: false` makes the repository public. |
| 155 | 180 | const visibility = b.visibility ?? (b.private === true ? "private" : b.private === false ? "public" : undefined); |
modifiedapps/diffinery/src/http/avatars.ts+3−2View file
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | * GET /api/avatars/:login?s=64|128|256[&v=<version>] the picture (WebP) or generated initials (SVG) |
| 12 | 12 | * PUT /api/v3/user/avatar raw image body (Diffinery extension) |
| 13 | 13 | * DELETE /api/v3/user/avatar |
| 14 | - * PUT /api/v3/orgs/:org/avatar org owners and admins | |
| 14 | + * PUT /api/v3/orgs/:org/avatar org owners | |
| 15 | 15 | * DELETE /api/v3/orgs/:org/avatar |
| 16 | 16 | * |
| 17 | 17 | * Uploads become square WebPs in R2 at avatars/<account id>/<version>/<size>.webp, and |
| @@ -190,7 +190,8 @@ | ||
| 190 | 190 | if (!org || org.kind !== "org") throw new HttpError(404, "not found"); |
| 191 | 191 | const role = await dir.orgRole(org.id, me.id); |
| 192 | 192 | if (!role && !me.site_admin) throw new HttpError(404, "not found"); |
| 193 | - if (role !== "owner" && role !== "admin" && !me.site_admin) throw new HttpError(403, "only organization owners and admins can change its picture"); | |
| 193 | + // Like the organization's name, its picture is the owners' to change (the web settings page agrees). | |
| 194 | + if (role !== "owner" && !me.site_admin) throw new HttpError(403, "only organization owners can change its picture"); | |
| 194 | 195 | return org; |
| 195 | 196 | } |
| 196 | 197 |
modifiedapps/diffinery/src/lib/directory.ts+57−19View file
| @@ -19,6 +19,8 @@ | ||
| 19 | 19 | avatar_version: number | null; |
| 20 | 20 | /** "light", "dark" or null (follow the system). */ |
| 21 | 21 | appearance: string | null; |
| 22 | + /** Organizations: the role every member gets on its repositories ("none", "read", "write" or "admin"). */ | |
| 23 | + base_role: string | null; | |
| 22 | 24 | created_at: string; |
| 23 | 25 | } |
| 24 | 26 | |
| @@ -41,12 +43,19 @@ | ||
| 41 | 43 | |
| 42 | 44 | export type TokenScope = "read" | "write" | "admin"; |
| 43 | 45 | |
| 44 | -/** Repositories are public or private. GitHub's "internal" (private to the organization) maps to private. */ | |
| 46 | +export type BaseRole = "none" | "read" | "write" | "admin"; | |
| 47 | +export const BASE_ROLES: readonly BaseRole[] = ["none", "read", "write", "admin"]; | |
| 48 | + | |
| 49 | +/** An organization's base permission; unset means read, the default for new organizations. */ | |
| 50 | +export function baseRole(org: Pick<Account, "base_role">): BaseRole { | |
| 51 | + return BASE_ROLES.find((r) => r === org.base_role) ?? "read"; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** Repositories are public or private; GitHub's "internal" is refused like any other value. */ | |
| 45 | 55 | export function repoVisibility(v: unknown): Repo["visibility"] | undefined { |
| 46 | 56 | if (v === undefined || v === null || v === "") return undefined; |
| 47 | - if (v === "public") return "public"; | |
| 48 | - if (v === "private" || v === "internal") return "private"; | |
| 49 | - throw new HttpError(422, "visibility must be public or private"); | |
| 57 | + if (v === "public" || v === "private") return v; | |
| 58 | + throw new HttpError(422, "visibility must be public or private", { errors: [{ resource: "Repository", code: "invalid", field: "visibility" }] }); | |
| 50 | 59 | } |
| 51 | 60 | |
| 52 | 61 | /** Data access for the global directory in D1. */ |
| @@ -114,7 +123,12 @@ | ||
| 114 | 123 | const clash = await this.accountByLogin(patch.login); |
| 115 | 124 | if (clash && clash.id !== id) throw new HttpError(422, "login already taken"); |
| 116 | 125 | } |
| 117 | - await this.db.prepare("UPDATE accounts SET login = COALESCE(?, login), name = COALESCE(?, name) WHERE id = ?").bind(patch.login ?? null, patch.name ?? null, id).run(); | |
| 126 | + // A blank display name clears it, so pages fall back to the login. | |
| 127 | + const name = patch.name === undefined ? undefined : patch.name.trim() || null; | |
| 128 | + await this.db | |
| 129 | + .prepare("UPDATE accounts SET login = COALESCE(?, login), name = CASE WHEN ? THEN ? ELSE name END WHERE id = ?") | |
| 130 | + .bind(patch.login ?? null, name === undefined ? 0 : 1, name ?? null, id) | |
| 131 | + .run(); | |
| 118 | 132 | if (patch.login) { |
| 119 | 133 | // Repos keep their ids; their Durable Objects cache the owner login, so refresh them. |
| 120 | 134 | const repos = await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(id).all<{ id: string }>(); |
| @@ -137,12 +151,17 @@ | ||
| 137 | 151 | const id = newId(); |
| 138 | 152 | const t = now(); |
| 139 | 153 | await this.db.batch([ |
| 140 | - this.db.prepare("INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES (?, ?, 'org', ?, 'active', ?)").bind(id, login, name || login, t), | |
| 154 | + this.db.prepare("INSERT INTO accounts (id, login, kind, name, status, base_role, created_at) VALUES (?, ?, 'org', ?, 'active', 'read', ?)").bind(id, login, name || login, t), | |
| 141 | 155 | this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)").bind(id, creator.id, t), |
| 142 | 156 | ]); |
| 143 | 157 | return (await this.account(id))!; |
| 144 | 158 | } |
| 145 | 159 | |
| 160 | + async setBaseRole(orgId: string, role: string): Promise<void> { | |
| 161 | + if (!BASE_ROLES.includes(role as BaseRole)) throw new HttpError(422, "base permission must be none, read, write or admin", { errors: [{ resource: "Organization", code: "invalid", field: "default_repository_permission" }] }); | |
| 162 | + await this.db.prepare("UPDATE accounts SET base_role = ? WHERE id = ? AND kind = 'org'").bind(role, orgId).run(); | |
| 163 | + } | |
| 164 | + | |
| 146 | 165 | async orgRole(orgId: string, userId: string): Promise<"owner" | "admin" | "member" | null> { |
| 147 | 166 | const r = await this.db.prepare("SELECT role FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).first<{ role: "owner" | "admin" | "member" }>(); |
| 148 | 167 | return r?.role ?? null; |
| @@ -191,8 +210,6 @@ | ||
| 191 | 210 | return { added: user }; |
| 192 | 211 | } |
| 193 | 212 | if (!email) throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`); |
| 194 | - const pending = await this.db.prepare("SELECT 1 FROM invites WHERE org_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?").bind(org.id, q, now()).first(); | |
| 195 | - if (pending) throw new HttpError(422, `${q} already has a pending invitation; revoke it first to send a new one`); | |
| 196 | 213 | const inv = await this.createInvite(by, { email: q, orgId: org.id, role }); |
| 197 | 214 | return { invited: { email: q, url: inv.url } }; |
| 198 | 215 | } |
| @@ -266,7 +283,7 @@ | ||
| 266 | 283 | const policy = { maxRole: "write", canMergeProtected: false, budgetUsdMonthly: null, ...input.policy }; |
| 267 | 284 | await this.db |
| 268 | 285 | .prepare("INSERT INTO accounts (id, login, kind, name, status, owner_id, agent_policy, created_at) VALUES (?, ?, 'agent', ?, 'active', ?, ?, ?)") |
| 269 | - .bind(id, login, input.name ?? login, ownerId, JSON.stringify(policy), now()) | |
| 286 | + .bind(id, login, input.name?.trim() || login, ownerId, JSON.stringify(policy), now()) | |
| 270 | 287 | .run(); |
| 271 | 288 | return (await this.account(id))!; |
| 272 | 289 | } |
| @@ -319,6 +336,15 @@ | ||
| 319 | 336 | if (input.role !== "member" && input.role !== "admin" && input.role !== "owner") throw new HttpError(422, "role must be member, admin or owner"); |
| 320 | 337 | if (input.role === "owner" && !inviter.site_admin && (await this.orgRole(input.orgId, inviter.id)) !== "owner") throw new HttpError(403, "only owners can invite owners"); |
| 321 | 338 | } |
| 339 | + // One open invitation per address and organization or repository. The email column is NOCASE, | |
| 340 | + // so Bob@x.com and bob@x.com count as the same person. | |
| 341 | + const pending = await this.db | |
| 342 | + .prepare("SELECT 1 FROM invites WHERE email = ? AND org_id IS ? AND repo_id IS ? AND accepted_at IS NULL AND expires_at > ?") | |
| 343 | + .bind(input.email, input.orgId ?? null, input.repoId ?? null, now()) | |
| 344 | + .first(); | |
| 345 | + if (pending) throw new HttpError(422, `${input.email} already has a pending invitation; revoke it first to send a new one`); | |
| 346 | + const existing = await this.accountByEmail(input.email); | |
| 347 | + if (existing && input.orgId && (await this.orgRole(input.orgId, existing.id))) throw new HttpError(422, `${existing.login} is already a member of this organization`); | |
| 322 | 348 | const token = randomToken(24); |
| 323 | 349 | const id = newId(); |
| 324 | 350 | await this.db |
| @@ -326,7 +352,6 @@ | ||
| 326 | 352 | .bind(id, input.orgId ?? null, input.repoId ?? null, input.email, input.role, await sha256(token), inviter.id, now(), new Date(Date.now() + 14 * 86400_000).toISOString()) |
| 327 | 353 | .run(); |
| 328 | 354 | // If the person already exists, activate them now so the invite also works from the dashboard. |
| 329 | - const existing = await this.accountByEmail(input.email); | |
| 330 | 355 | if (existing?.status === "pending") await this.setStatus(existing.id, "active"); |
| 331 | 356 | const url = `${this.env.PUBLIC_URL}/invite/${token}`; |
| 332 | 357 | const org = input.orgId ? await this.account(input.orgId) : null; |
| @@ -417,7 +442,7 @@ | ||
| 417 | 442 | let q = "SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id"; |
| 418 | 443 | const subjectId = account.kind === "agent" ? account.owner_id! : account.id; |
| 419 | 444 | if (!account.site_admin) { |
| 420 | - q += ` WHERE (r.visibility = 'public' OR r.owner_id = ? OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`; | |
| 445 | + q += ` WHERE (r.visibility = 'public' OR r.owner_id = ? OR r.owner_id IN (SELECT m.org_id FROM org_members m JOIN accounts o ON o.id = m.org_id WHERE m.user_id = ? AND (m.role != 'member' OR COALESCE(o.base_role, 'read') != 'none')) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`; | |
| 421 | 446 | params.push(subjectId, subjectId, account.id, subjectId); |
| 422 | 447 | } else q += " WHERE 1 = 1"; |
| 423 | 448 | if (ownerId) { |
| @@ -440,18 +465,24 @@ | ||
| 440 | 465 | |
| 441 | 466 | async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: string; defaultBranch?: string }): Promise<Repo> { |
| 442 | 467 | if (!isValidName(input.name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'"); |
| 468 | + // Like GitHub, a member who creates an organization repository administers it, whatever the base permission. | |
| 469 | + let creatorGrant: string | null = null; | |
| 443 | 470 | if (owner.kind === "org") { |
| 444 | - const role = await this.orgRole(owner.id, creator.kind === "agent" ? creator.owner_id! : creator.id); | |
| 471 | + const subject = creator.kind === "agent" ? creator.owner_id! : creator.id; | |
| 472 | + const role = await this.orgRole(owner.id, subject); | |
| 445 | 473 | if (!role && !creator.site_admin) throw new HttpError(403, "you are not a member of this organization"); |
| 474 | + if (role === "member") creatorGrant = subject; | |
| 446 | 475 | } else if (owner.id !== creator.id && !creator.site_admin) throw new HttpError(403, "you can only create repositories for yourself or your organizations"); |
| 447 | 476 | if (await this.repo(owner.login, input.name)) throw new HttpError(422, "a repository with this name already exists"); |
| 448 | 477 | const id = newId(); |
| 449 | 478 | const t = now(); |
| 450 | 479 | const branch = input.defaultBranch || "main"; |
| 451 | - await this.db | |
| 452 | - .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 453 | - .bind(id, owner.id, input.name, input.description ?? "", repoVisibility(input.visibility) ?? "private", branch, t, t) | |
| 454 | - .run(); | |
| 480 | + await this.db.batch([ | |
| 481 | + this.db | |
| 482 | + .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 483 | + .bind(id, owner.id, input.name, input.description ?? "", repoVisibility(input.visibility) ?? "private", branch, t, t), | |
| 484 | + ...(creatorGrant ? [this.db.prepare("INSERT INTO repo_access (repo_id, account_id, role) VALUES (?, ?, 'admin')").bind(id, creatorGrant)] : []), | |
| 485 | + ]); | |
| 455 | 486 | const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user" }; |
| 456 | 487 | await this.env.REPO.get(this.env.REPO.idFromName(id)).init(meta); |
| 457 | 488 | return (await this.repoById(id))!; |
| @@ -515,9 +546,16 @@ | ||
| 515 | 546 | }; |
| 516 | 547 | if (repo.owner_id === account.id) bump("admin"); |
| 517 | 548 | if (repo.owner_kind === "org") { |
| 518 | - const orgRole = await this.orgRole(repo.owner_id, account.id); | |
| 519 | - if (orgRole === "owner" || orgRole === "admin") bump("admin"); | |
| 520 | - else if (orgRole === "member") bump("write"); | |
| 549 | + // Owners and admins administer every repository; members get the organization's base permission. | |
| 550 | + const m = await this.db | |
| 551 | + .prepare("SELECT m.role, o.base_role FROM org_members m JOIN accounts o ON o.id = m.org_id WHERE m.org_id = ? AND m.user_id = ?") | |
| 552 | + .bind(repo.owner_id, account.id) | |
| 553 | + .first<{ role: string; base_role: string | null }>(); | |
| 554 | + if (m?.role === "owner" || m?.role === "admin") bump("admin"); | |
| 555 | + else if (m?.role === "member") { | |
| 556 | + const base = baseRole(m); | |
| 557 | + if (base !== "none") bump(base); | |
| 558 | + } | |
| 521 | 559 | } |
| 522 | 560 | const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>(); |
| 523 | 561 | bump(explicit?.role ?? null); |
modifiedapps/diffinery/src/mail/send.ts+1−1View file
| @@ -113,7 +113,7 @@ | ||
| 113 | 113 | if (!a.email) return skip("no email address"); |
| 114 | 114 | if (a.status === "disabled" && job.kind !== "account_status") return skip("account disabled"); |
| 115 | 115 | if (!job.force && !isMandatory(category) && !wantsEmail(await store.prefs(a.id), category)) return skip(`turned off ${category} emails`); |
| 116 | - to = { email: a.email, name: a.name ?? undefined, login: a.login, accountId: a.id }; | |
| 116 | + to = { email: a.email, name: a.name || undefined, login: a.login, accountId: a.id }; | |
| 117 | 117 | } else { |
| 118 | 118 | to = { email: job.to.email, name: job.to.name }; |
| 119 | 119 | } |
modifiedapps/diffinery/src/mail/web.tsx+1−1View file
| @@ -425,7 +425,7 @@ | ||
| 425 | 425 | const me = requireSiteAdmin(c); |
| 426 | 426 | const s = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login).find((x) => x.id === c.req.param("id")); |
| 427 | 427 | if (!s) throw new HttpError(404, "no such template"); |
| 428 | - const msg = await buildMessage(c.env, s.job, { email: me.email ?? "you@example.com", name: me.name ?? undefined, login: me.login, accountId: me.id }); | |
| 428 | + const msg = await buildMessage(c.env, s.job, { email: me.email ?? "you@example.com", name: me.name || undefined, login: me.login, accountId: me.id }); | |
| 429 | 429 | c.header("content-security-policy", "default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; frame-ancestors 'self'; base-uri 'none'; form-action 'none'"); |
| 430 | 430 | c.header("x-frame-options", "SAMEORIGIN"); |
| 431 | 431 | c.header("x-content-type-options", "nosniff"); |
modifiedapps/diffinery/src/web/account.tsx+27−15View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | import { raw } from "hono/html"; |
| 3 | 3 | import type { Child, FC } from "hono/jsx"; |
| 4 | 4 | import { requireAccount, type AppContext } from "../auth/principal.js"; |
| 5 | -import type { Account, Repo, TokenScope } from "../lib/directory.js"; | |
| 5 | +import { baseRole, type Account, type Repo, type TokenScope } from "../lib/directory.js"; | |
| 6 | 6 | import { loadRepo } from "../http/common.js"; |
| 7 | 7 | import { HttpError } from "../lib/util.js"; |
| 8 | 8 | import type { Role } from "../repo/types.js"; |
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | import { repoCreated } from "../mail/notify.js"; |
| 14 | 14 | import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js"; |
| 15 | 15 | import { deleteCookie, setCookie } from "hono/cookie"; |
| 16 | -import { APPEARANCE_COOKIE, Actor, Avatar, CopyButton, Form, ROLES, Time, cap, act, ago, flash, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; | |
| 16 | +import { APPEARANCE_COOKIE, Actor, Avatar, CopyButton, Form, ROLES, Time, cap, LOGIN_PATTERN, act, ago, flash, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; | |
| 17 | 17 | import type { FormData } from "./csrf.js"; |
| 18 | 18 | import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js"; |
| 19 | 19 | import { MAX_AVATAR_BYTES } from "../lib/image.js"; |
| @@ -126,6 +126,12 @@ | ||
| 126 | 126 | } |
| 127 | 127 | |
| 128 | 128 | const SCOPES: TokenScope[] = ["read", "write", "admin"]; |
| 129 | +const BASE_ROLE_OPTIONS = [ | |
| 130 | + { value: "none", label: "No permission: members see only repositories they're given" }, | |
| 131 | + { value: "read", label: "Read: members can clone and view" }, | |
| 132 | + { value: "write", label: "Write: members can also push" }, | |
| 133 | + { value: "admin", label: "Admin: members can also change settings" }, | |
| 134 | +]; | |
| 129 | 135 | |
| 130 | 136 | // ---------------------------------------------------------------- landing / dashboard |
| 131 | 137 | |
| @@ -259,7 +265,7 @@ | ||
| 259 | 265 | {repos.length === 0 ? ( |
| 260 | 266 | <div class="box"> |
| 261 | 267 | <div class="body"> |
| 262 | - <h2>Welcome to Diffinery, {me.name ?? me.login}</h2> | |
| 268 | + <h2>Welcome to Diffinery, {me.name || me.login}</h2> | |
| 263 | 269 | <ol class="muted" style="padding-left:20px"> |
| 264 | 270 | <li> |
| 265 | 271 | <a href="/new">Create a repository</a> (or an <a href="/settings/orgs">organization</a> to share work with friends). |
| @@ -580,12 +586,12 @@ | ||
| 580 | 586 | export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "orgs" | "notifications" | "admin" | "email" | "audit"; |
| 581 | 587 | |
| 582 | 588 | export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => ( |
| 583 | - <aside class="side"> | |
| 589 | + <aside class="side vt-side"> | |
| 584 | 590 | <section> |
| 585 | 591 | <div class="row mb"> |
| 586 | 592 | <Avatar login={me.login} size="lg" version={me.avatar_version} /> |
| 587 | 593 | <div> |
| 588 | - <b>{me.name ?? me.login}</b> | |
| 594 | + <b>{me.name || me.login}</b> | |
| 589 | 595 | <div class="muted small">{me.login}</div> |
| 590 | 596 | </div> |
| 591 | 597 | </div> |
| @@ -679,7 +685,7 @@ | ||
| 679 | 685 | <div class="body"> |
| 680 | 686 | <div class="field"> |
| 681 | 687 | <label for="login">Username</label> |
| 682 | - <input id="login" name="login" type="text" value={me.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> | |
| 688 | + <input id="login" name="login" type="text" value={me.login} required pattern={LOGIN_PATTERN} /> | |
| 683 | 689 | <div class="hint">Changing your username changes the URLs of your repositories. Existing clones need a new remote URL.</div> |
| 684 | 690 | </div> |
| 685 | 691 | <div class="field"> |
| @@ -1117,7 +1123,7 @@ | ||
| 1117 | 1123 | <div class="row" style="align-items:flex-start"> |
| 1118 | 1124 | <div class="field grow"> |
| 1119 | 1125 | <label for="alogin">Agent username</label> |
| 1120 | - <input id="alogin" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="review-bot" /> | |
| 1126 | + <input id="alogin" name="login" type="text" required pattern={LOGIN_PATTERN} placeholder="review-bot" /> | |
| 1121 | 1127 | </div> |
| 1122 | 1128 | <div class="field grow"> |
| 1123 | 1129 | <label for="aname">Display name</label> |
| @@ -1210,7 +1216,7 @@ | ||
| 1210 | 1216 | "Organizations", |
| 1211 | 1217 | <> |
| 1212 | 1218 | <h1>Organizations</h1> |
| 1213 | - <p class="muted">Organizations group people, agents and repositories. Members get write access to the organization's repositories; owners and admins manage everything.</p> | |
| 1219 | + <p class="muted">Organizations group people, agents and repositories. Members get the organization's base permission (read for new organizations) on its repositories; owners and admins manage everything.</p> | |
| 1214 | 1220 | <div class="box"> |
| 1215 | 1221 | {orgs.length ? ( |
| 1216 | 1222 | <ul class="list"> |
| @@ -1239,7 +1245,7 @@ | ||
| 1239 | 1245 | <div class="row" style="align-items:flex-start"> |
| 1240 | 1246 | <div class="field grow"> |
| 1241 | 1247 | <label for="olog">Organization name</label> |
| 1242 | - <input id="olog" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="my-team" /> | |
| 1248 | + <input id="olog" name="login" type="text" required pattern={LOGIN_PATTERN} placeholder="my-team" /> | |
| 1243 | 1249 | </div> |
| 1244 | 1250 | <div class="field grow"> |
| 1245 | 1251 | <label for="oname">Display name</label> |
| @@ -1487,14 +1493,14 @@ | ||
| 1487 | 1493 | <div class="row mb" style="gap:16px"> |
| 1488 | 1494 | <Avatar login={org.login} size="lg" version={org.avatar_version} /> |
| 1489 | 1495 | <div> |
| 1490 | - <h1 class="m0">{org.name ?? org.login}</h1> | |
| 1496 | + <h1 class="m0">{org.name || org.login}</h1> | |
| 1491 | 1497 | <div class="muted small"> |
| 1492 | 1498 | Organization · {org.login} |
| 1493 | 1499 | {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""} |
| 1494 | 1500 | </div> |
| 1495 | 1501 | </div> |
| 1496 | 1502 | </div> |
| 1497 | - <nav class="tabs mb" style="border-bottom:1px solid var(--border)"> | |
| 1503 | + <nav class="tabs mb vt-tabs" style="border-bottom:1px solid var(--border)"> | |
| 1498 | 1504 | {( |
| 1499 | 1505 | [ |
| 1500 | 1506 | ["repositories", "Repositories", true], |
| @@ -1565,7 +1571,7 @@ | ||
| 1565 | 1571 | <Avatar login={acct.login} kind="agent" size="xl" version={acct.avatar_version} /> |
| 1566 | 1572 | <div> |
| 1567 | 1573 | <h1 class="m0"> |
| 1568 | - {acct.name ?? acct.login} <span class="badge agent">agent</span> | |
| 1574 | + {acct.name || acct.login} <span class="badge agent">agent</span> | |
| 1569 | 1575 | </h1> |
| 1570 | 1576 | <p class="muted">{acct.login}</p> |
| 1571 | 1577 | <dl class="kv"> |
| @@ -1596,7 +1602,7 @@ | ||
| 1596 | 1602 | <aside class="side"> |
| 1597 | 1603 | <section> |
| 1598 | 1604 | <Avatar login={acct.login} size="xl" version={acct.avatar_version} /> |
| 1599 | - <h1 class="mt m0">{acct.name ?? acct.login}</h1> | |
| 1605 | + <h1 class="mt m0">{acct.name || acct.login}</h1> | |
| 1600 | 1606 | <p class="muted">{acct.login}</p> |
| 1601 | 1607 | {acct.id === me.id ? ( |
| 1602 | 1608 | <a class="btn sm" href="/settings"> |
| @@ -1812,13 +1818,18 @@ | ||
| 1812 | 1818 | <div class="body"> |
| 1813 | 1819 | <div class="field"> |
| 1814 | 1820 | <label>Organization name</label> |
| 1815 | - <input name="login" type="text" value={acct.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> | |
| 1821 | + <input name="login" type="text" value={acct.login} required pattern={LOGIN_PATTERN} /> | |
| 1816 | 1822 | <div class="hint">Renaming changes every repository URL in this organization.</div> |
| 1817 | 1823 | </div> |
| 1818 | 1824 | <div class="field"> |
| 1819 | 1825 | <label>Display name</label> |
| 1820 | 1826 | <input name="name" type="text" value={acct.name ?? ""} /> |
| 1821 | 1827 | </div> |
| 1828 | + <div class="field"> | |
| 1829 | + <label for="base_role">Base permission</label> | |
| 1830 | + <KSelect id="base_role" name="base_role" block label="Base permission" value={baseRole(acct)} options={BASE_ROLE_OPTIONS} /> | |
| 1831 | + <div class="hint">What every member can do in all of this organization's repositories. Owners and admins always have admin access, and repository settings can give people more.</div> | |
| 1832 | + </div> | |
| 1822 | 1833 | <button class="btn primary">Save</button> |
| 1823 | 1834 | </div> |
| 1824 | 1835 | </Form> |
| @@ -1898,8 +1909,9 @@ | ||
| 1898 | 1909 | if (!isOwner) throw new HttpError(403, "organization owners only"); |
| 1899 | 1910 | const login = f.s("login").trim(); |
| 1900 | 1911 | await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() }); |
| 1912 | + if (f.s("base_role")) await dir.setBaseRole(org.id, f.s("base_role")); | |
| 1901 | 1913 | if (login && login !== org.login) c.executionCtx.waitUntil(purgeAvatarCache(c, org.login, [org.avatar_version])); |
| 1902 | - c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login } })); | |
| 1914 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login, base_role: f.s("base_role") || undefined } })); | |
| 1903 | 1915 | return `/${login || org.login}?tab=settings`; |
| 1904 | 1916 | }, "Organization updated."); |
| 1905 | 1917 | }); |
modifiedapps/diffinery/src/web/assets.ts+18−0View file
| @@ -309,6 +309,24 @@ | ||
| 309 | 309 | .theme-prev.dark{background:#1c1c1e}.theme-prev.dark i:not(:first-child){background:#52525b} |
| 310 | 310 | .theme-prev.auto{background:linear-gradient(135deg,#fff 50%,#1c1c1e 50%)} |
| 311 | 311 | } |
| 312 | +/* Page changes: a cross-document view transition under 100 ms. The header, repository header, organization | |
| 313 | + tabs and settings menu keep their place and swap instantly; only the page content fades. Browsers without support, and people | |
| 314 | + who ask for reduced motion, just navigate. */ | |
| 315 | +@media (prefers-reduced-motion:no-preference){ | |
| 316 | +@view-transition{navigation:auto} | |
| 317 | +header.top{view-transition-name:dfy-top} | |
| 318 | +.repohead{view-transition-name:dfy-repohead} | |
| 319 | +main.wrap{view-transition-name:dfy-main} | |
| 320 | +.vt-tabs{view-transition-name:dfy-tabs} | |
| 321 | +.vt-side{view-transition-name:dfy-side} | |
| 322 | +::view-transition-group(*){animation-duration:70ms;animation-timing-function:ease-out} | |
| 323 | +::view-transition-old(dfy-top),::view-transition-new(dfy-top),::view-transition-old(dfy-repohead),::view-transition-new(dfy-repohead), | |
| 324 | +::view-transition-old(dfy-tabs),::view-transition-new(dfy-tabs),::view-transition-old(dfy-side),::view-transition-new(dfy-side){animation:none} | |
| 325 | +::view-transition-old(root),::view-transition-old(dfy-main){animation:70ms ease-out both dfy-vt-out} | |
| 326 | +::view-transition-new(root),::view-transition-new(dfy-main){animation:70ms ease-out both dfy-vt-in} | |
| 327 | +@keyframes dfy-vt-out{to{opacity:0}} | |
| 328 | +@keyframes dfy-vt-in{from{opacity:0;transform:translateY(3px)}} | |
| 329 | +} | |
| 312 | 330 | `; |
| 313 | 331 | |
| 314 | 332 | /** Client behaviour: palette, shortcuts, copy buttons, live updates, line comments. */ |
modifiedapps/diffinery/src/web/auth.tsx+2−2View file
| @@ -301,12 +301,12 @@ | ||
| 301 | 301 | if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired."); |
| 302 | 302 | if (link.account.status === "disabled") throw new HttpError(403, "This account is disabled."); |
| 303 | 303 | subject = { kind: "link", linkHash: link.hash, accountId: link.account.id }; |
| 304 | - user = { id: link.account.id, name: link.account.login, displayName: link.account.name ?? link.account.login }; | |
| 304 | + user = { id: link.account.id, name: link.account.login, displayName: link.account.name || link.account.login }; | |
| 305 | 305 | existing = await auth.passkeys(link.account.id); |
| 306 | 306 | } else { |
| 307 | 307 | const me = browserAccount(c); |
| 308 | 308 | subject = { kind: "account", accountId: me.id }; |
| 309 | - user = { id: me.id, name: me.login, displayName: me.name ?? me.login }; | |
| 309 | + user = { id: me.id, name: me.login, displayName: me.name || me.login }; | |
| 310 | 310 | existing = await auth.passkeys(me.id); |
| 311 | 311 | } |
| 312 | 312 | const opts = await generateRegistrationOptions({ |
modifiedapps/diffinery/src/web/repo-settings.tsx+1−1View file
| @@ -272,7 +272,7 @@ | ||
| 272 | 272 | <p class="muted"> |
| 273 | 273 | {repo.owner_kind === "org" ? ( |
| 274 | 274 | <> |
| 275 | - Members of <a href={`/${repo.owner_login}`}>{repo.owner_login}</a> have write access (owners and admins: admin). Add people here for extra or narrower access. | |
| 275 | + Members of <a href={`/${repo.owner_login}`}>{repo.owner_login}</a> have the organization's base permission (owners and admins: admin). Add people here for more access. | |
| 276 | 276 | </> |
| 277 | 277 | ) : ( |
| 278 | 278 | <> |
modifiedapps/diffinery/src/web/ui.tsx+3−0View file
| @@ -873,3 +873,6 @@ | ||
| 873 | 873 | |
| 874 | 874 | /** "write" → "Write", for showing a stored value as a label. */ |
| 875 | 875 | export const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1); |
| 876 | + | |
| 877 | +/** The browser's check for usernames, organization and agent names; the server's isValidLogin agrees (no dots). */ | |
| 878 | +export const LOGIN_PATTERN = "[A-Za-z0-9][A-Za-z0-9_\\-]{0,99}"; |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+45−1View file
| @@ -1074,6 +1074,50 @@ | ||
| 1074 | 1074 | return null; |
| 1075 | 1075 | } |
| 1076 | 1076 | |
| 1077 | +test("people and profiles: visibility, repository invitations, blank names and usernames", async () => { | |
| 1078 | + const me = await ok("GET", "/user"); | |
| 1079 | + // A form post as the signed-in dev user, with the CSRF double-submit pair taken from `page`. | |
| 1080 | + const submit = async (page, action, fields) => { | |
| 1081 | + const res = await fetch(`${base}${page}`); | |
| 1082 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; | |
| 1083 | + const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; | |
| 1084 | + assert.ok(csrf && cookie, `no CSRF pair on ${page}`); | |
| 1085 | + return fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" } }); | |
| 1086 | + }; | |
| 1087 | + const flashOf = (res) => decodeURIComponent(decodeURIComponent(res.headers.get("set-cookie") ?? "")); | |
| 1088 | + | |
| 1089 | + // "internal" is gone: creating and updating refuse it the same way. | |
| 1090 | + const created = await api("POST", "/user/repos", { name: "people-rules", visibility: "internal" }); | |
| 1091 | + assert.equal(created.status, 422); | |
| 1092 | + await ok("POST", "/user/repos", { name: "people-rules" }); | |
| 1093 | + const patched = await api("PATCH", `/repos/${me.login}/people-rules`, { visibility: "internal" }); | |
| 1094 | + assert.equal(patched.status, 422); | |
| 1095 | + assert.equal(created.json.message, patched.json.message); | |
| 1096 | + assert.deepEqual(created.json.errors, patched.json.errors); | |
| 1097 | + | |
| 1098 | + // Inviting the same email to a repository twice (any letter case) is refused. | |
| 1099 | + const access = `/${me.login}/people-rules/settings/access`; | |
| 1100 | + const first = await submit(access, `${access}/people`, { who: "newcomer@example.com", role: "read" }); | |
| 1101 | + assert.equal(first.status, 200, flashOf(first)); | |
| 1102 | + assert.match(await first.text(), /newcomer@example\.com/); | |
| 1103 | + const second = await submit(access, `${access}/people`, { who: "NewComer@Example.com", role: "write" }); | |
| 1104 | + assert.equal(second.status, 303); | |
| 1105 | + assert.match(flashOf(second), /already has a pending invitation/); | |
| 1106 | + | |
| 1107 | + // A blank display name falls back to the username. | |
| 1108 | + const saved = await submit("/settings", "/settings/profile", { login: me.login, name: " " }); | |
| 1109 | + assert.match(flashOf(saved), /Profile saved\./); | |
| 1110 | + assert.equal((await ok("GET", `/users/${me.login}`)).name, null); | |
| 1111 | + assert.match(await (await fetch(`${base}/${me.login}`)).text(), new RegExp(`<h1[^>]*>${me.login}</h1>`)); | |
| 1112 | + | |
| 1113 | + // Usernames: the browser's pattern and the server agree that dots aren't allowed. | |
| 1114 | + const settings = await (await fetch(`${base}/settings`)).text(); | |
| 1115 | + const pattern = /name="login"[^>]*pattern="([^"]+)"/.exec(settings)?.[1]; | |
| 1116 | + assert.ok(pattern, "the username field has a pattern"); | |
| 1117 | + assert.equal(new RegExp(`^(?:${pattern.replaceAll("&", "&")})$`, "v").test("dot.name"), false); | |
| 1118 | + assert.equal((await api("POST", "/user/orgs", { login: "dot.name" })).status, 422); | |
| 1119 | +}); | |
| 1120 | + | |
| 1077 | 1121 | test("profile pictures: upload, resize, serve, cache headers and removal", async () => { |
| 1078 | 1122 | const me = await ok("GET", "/user"); |
| 1079 | 1123 | const avatar = async (login, query = "") => { |
| @@ -1131,7 +1175,7 @@ | ||
| 1131 | 1175 | // An old version's URL gets the current picture, but not as an immutable response. |
| 1132 | 1176 | assert.match((await avatar(me.login, `?s=64&v=${version}`)).cache, /max-age=600/); |
| 1133 | 1177 | |
| 1134 | - // Organizations: owners and admins. | |
| 1178 | + // Organizations: owners (admins get a 403; see the security tests). | |
| 1135 | 1179 | await ok("POST", "/user/orgs", { login: "picture-org" }); |
| 1136 | 1180 | const org = await put("/orgs/picture-org/avatar", pngGradient(64, 64)); |
| 1137 | 1181 | assert.equal(org.status, 200, await org.clone().text()); |
modifiedapps/diffinery/test/e2e/security.test.mjs+47−1View file
| @@ -31,7 +31,7 @@ | ||
| 31 | 31 | |
| 32 | 32 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 33 | 33 | const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; |
| 34 | -const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin") }; | |
| 34 | +const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo") }; | |
| 35 | 35 | const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}` }; |
| 36 | 36 | const OWNER = "olive"; |
| 37 | 37 | |
| @@ -112,6 +112,9 @@ | ||
| 112 | 112 | `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}'), ('o-guild', 'u-rita', 'admin', '${at}')`, |
| 113 | 113 | token("t6", "u-olive", T.reader, ["read"]), |
| 114 | 114 | token("t7", "u-rita", T.ritaAdmin, ["admin"]), |
| 115 | + account("u-milo", "milo", "user", "active"), | |
| 116 | + token("t8", "u-milo", T.milo, ["admin"]), | |
| 117 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-milo', 'member', '${at}')`, | |
| 115 | 118 | session("s1", "u-penny", S.penny), |
| 116 | 119 | session("s2", "u-dora", S.dora), |
| 117 | 120 | ].join("; "), |
| @@ -403,3 +406,46 @@ | ||
| 403 | 406 | assert.ok(events.some((e) => e.event === "ready_for_review"), JSON.stringify(events)); |
| 404 | 407 | assert.equal((await ok("PUT", `${R}/pulls/${draft.number}/merge`, { merge_method: "squash" })).merged, true); |
| 405 | 408 | }); |
| 409 | + | |
| 410 | +test("security: organization pictures are the owners' to change, and invitations aren't sent twice", async () => { | |
| 411 | + // rita is an admin of guild: like its name, the picture is for owners (the settings page agrees). | |
| 412 | + const denied = await status(403, "DELETE", "/orgs/guild/avatar", undefined, T.ritaAdmin); | |
| 413 | + assert.match(denied.message, /only organization owners/); | |
| 414 | + await status(200, "DELETE", "/orgs/guild/avatar", undefined, T.olive); | |
| 415 | + | |
| 416 | + // A second invitation to the same address, in any letter case, is refused. | |
| 417 | + await status(201, "POST", "/orgs/guild/invitations", { email: "twice@example.com" }, T.olive); | |
| 418 | + const again = await status(422, "POST", "/orgs/guild/invitations", { email: "Twice@Example.COM" }, T.olive); | |
| 419 | + assert.match(again.message, /already has a pending invitation/); | |
| 420 | +}); | |
| 421 | + | |
| 422 | +test("security: organization members get the base permission, which only owners change", async () => { | |
| 423 | + const can = async (path, as) => { | |
| 424 | + const { admin, push, pull } = (await ok("GET", path, undefined, as)).permissions; | |
| 425 | + return { admin, push, pull }; | |
| 426 | + }; | |
| 427 | + // New organizations start at read (guild's is unset, which also means read). | |
| 428 | + await ok("POST", "/user/orgs", { login: "fresh-org" }); | |
| 429 | + assert.equal((await ok("GET", "/orgs/fresh-org")).default_repository_permission, "read"); | |
| 430 | + assert.equal((await ok("GET", "/orgs/guild", undefined, T.milo)).default_repository_permission, "read"); | |
| 431 | + await ok("POST", "/orgs/guild/repos", { name: "vault", private: true }); | |
| 432 | + assert.deepEqual(await can("/repos/guild/vault", T.milo), { admin: false, push: false, pull: true }); | |
| 433 | + | |
| 434 | + // Only owners change it, to one of none, read, write or admin. | |
| 435 | + await status(403, "PATCH", "/orgs/guild", { default_repository_permission: "write" }, T.ritaAdmin); | |
| 436 | + await status(403, "PATCH", "/orgs/guild", { default_repository_permission: "write" }, T.milo); | |
| 437 | + await status(422, "PATCH", "/orgs/guild", { default_repository_permission: "maintain" }); | |
| 438 | + assert.equal((await ok("PATCH", "/orgs/guild", { default_repository_permission: "write" })).default_repository_permission, "write"); | |
| 439 | + assert.deepEqual(await can("/repos/guild/vault", T.milo), { admin: false, push: true, pull: true }); | |
| 440 | + | |
| 441 | + // With none, members don't see private repositories they weren't given; admins still administer them. | |
| 442 | + await ok("PATCH", "/orgs/guild", { default_repository_permission: "none" }); | |
| 443 | + await status(404, "GET", "/repos/guild/vault", undefined, T.milo); | |
| 444 | + assert.ok(!(await ok("GET", "/orgs/guild/repos", undefined, T.milo)).some((r) => r.name === "vault")); | |
| 445 | + assert.deepEqual(await can("/repos/guild/vault", T.ritaAdmin), { admin: true, push: true, pull: true }); | |
| 446 | + | |
| 447 | + // A member administers the repositories they create, whatever the base permission. | |
| 448 | + await ok("POST", "/orgs/guild/repos", { name: "milo-tools", private: true }, T.milo); | |
| 449 | + assert.deepEqual(await can("/repos/guild/milo-tools", T.milo), { admin: true, push: true, pull: true }); | |
| 450 | + await ok("PATCH", "/orgs/guild", { default_repository_permission: "read" }); | |
| 451 | +}); |