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.

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)
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 @@
22 import { fromUtf8 } from "@diffinery/git-core";
33 import type { Env } from "../env.js";
44 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";
66 import { encryptSecret } from "../lib/crypto.js";
77 import { publicJwk } from "../lib/jwt.js";
88 import { HttpError, b64, fromB64 } from "../lib/util.js";
@@ -26,7 +26,7 @@
2626 // Mounted first: some of their paths (like /issues/comments) would otherwise match routes below.
2727 api.route("/", compat);
2828
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 });
3030
3131 const repoJson = repoOut;
3232
@@ -71,6 +71,32 @@
7171 const b = await body<{ login: string; name?: string }>(c);
7272 return c.json(userJson(c, await c.get("dir").createOrg(b.login, b.name ?? b.login, a)), 201);
7373 });
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+});
74100 api.get("/orgs/:org/members", async (c) => {
75101 const a = requireAccount(c);
76102 const dir = c.get("dir");
@@ -149,7 +175,6 @@
149175 api.patch("/repos/:owner/:repo", async (c) => {
150176 const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
151177 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" }] });
153178 if (b.private !== undefined && typeof b.private !== "boolean") throw new HttpError(422, "private must be a boolean", { errors: [{ resource: "Repository", code: "invalid", field: "private" }] });
154179 // GitHub's `private: false` makes the repository public.
155180 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 @@
1111 * GET /api/avatars/:login?s=64|128|256[&v=<version>] the picture (WebP) or generated initials (SVG)
1212 * PUT /api/v3/user/avatar raw image body (Diffinery extension)
1313 * 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
1515 * DELETE /api/v3/orgs/:org/avatar
1616 *
1717 * Uploads become square WebPs in R2 at avatars/<account id>/<version>/<size>.webp, and
@@ -190,7 +190,8 @@
190190 if (!org || org.kind !== "org") throw new HttpError(404, "not found");
191191 const role = await dir.orgRole(org.id, me.id);
192192 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");
194195 return org;
195196 }
196197
modifiedapps/diffinery/src/lib/directory.ts+57−19View file
@@ -19,6 +19,8 @@
1919 avatar_version: number | null;
2020 /** "light", "dark" or null (follow the system). */
2121 appearance: string | null;
22+ /** Organizations: the role every member gets on its repositories ("none", "read", "write" or "admin"). */
23+ base_role: string | null;
2224 created_at: string;
2325 }
2426
@@ -41,12 +43,19 @@
4143
4244 export type TokenScope = "read" | "write" | "admin";
4345
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. */
4555 export function repoVisibility(v: unknown): Repo["visibility"] | undefined {
4656 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" }] });
5059 }
5160
5261 /** Data access for the global directory in D1. */
@@ -114,7 +123,12 @@
114123 const clash = await this.accountByLogin(patch.login);
115124 if (clash && clash.id !== id) throw new HttpError(422, "login already taken");
116125 }
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();
118132 if (patch.login) {
119133 // Repos keep their ids; their Durable Objects cache the owner login, so refresh them.
120134 const repos = await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(id).all<{ id: string }>();
@@ -137,12 +151,17 @@
137151 const id = newId();
138152 const t = now();
139153 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),
141155 this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)").bind(id, creator.id, t),
142156 ]);
143157 return (await this.account(id))!;
144158 }
145159
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+
146165 async orgRole(orgId: string, userId: string): Promise<"owner" | "admin" | "member" | null> {
147166 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" }>();
148167 return r?.role ?? null;
@@ -191,8 +210,6 @@
191210 return { added: user };
192211 }
193212 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`);
196213 const inv = await this.createInvite(by, { email: q, orgId: org.id, role });
197214 return { invited: { email: q, url: inv.url } };
198215 }
@@ -266,7 +283,7 @@
266283 const policy = { maxRole: "write", canMergeProtected: false, budgetUsdMonthly: null, ...input.policy };
267284 await this.db
268285 .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())
270287 .run();
271288 return (await this.account(id))!;
272289 }
@@ -319,6 +336,15 @@
319336 if (input.role !== "member" && input.role !== "admin" && input.role !== "owner") throw new HttpError(422, "role must be member, admin or owner");
320337 if (input.role === "owner" && !inviter.site_admin && (await this.orgRole(input.orgId, inviter.id)) !== "owner") throw new HttpError(403, "only owners can invite owners");
321338 }
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`);
322348 const token = randomToken(24);
323349 const id = newId();
324350 await this.db
@@ -326,7 +352,6 @@
326352 .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())
327353 .run();
328354 // If the person already exists, activate them now so the invite also works from the dashboard.
329- const existing = await this.accountByEmail(input.email);
330355 if (existing?.status === "pending") await this.setStatus(existing.id, "active");
331356 const url = `${this.env.PUBLIC_URL}/invite/${token}`;
332357 const org = input.orgId ? await this.account(input.orgId) : null;
@@ -417,7 +442,7 @@
417442 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";
418443 const subjectId = account.kind === "agent" ? account.owner_id! : account.id;
419444 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 (?, ?)))`;
421446 params.push(subjectId, subjectId, account.id, subjectId);
422447 } else q += " WHERE 1 = 1";
423448 if (ownerId) {
@@ -440,18 +465,24 @@
440465
441466 async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: string; defaultBranch?: string }): Promise<Repo> {
442467 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;
443470 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);
445473 if (!role && !creator.site_admin) throw new HttpError(403, "you are not a member of this organization");
474+ if (role === "member") creatorGrant = subject;
446475 } else if (owner.id !== creator.id && !creator.site_admin) throw new HttpError(403, "you can only create repositories for yourself or your organizations");
447476 if (await this.repo(owner.login, input.name)) throw new HttpError(422, "a repository with this name already exists");
448477 const id = newId();
449478 const t = now();
450479 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+ ]);
455486 const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user" };
456487 await this.env.REPO.get(this.env.REPO.idFromName(id)).init(meta);
457488 return (await this.repoById(id))!;
@@ -515,9 +546,16 @@
515546 };
516547 if (repo.owner_id === account.id) bump("admin");
517548 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+ }
521559 }
522560 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 }>();
523561 bump(explicit?.role ?? null);
modifiedapps/diffinery/src/mail/send.ts+1−1View file
@@ -113,7 +113,7 @@
113113 if (!a.email) return skip("no email address");
114114 if (a.status === "disabled" && job.kind !== "account_status") return skip("account disabled");
115115 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 };
117117 } else {
118118 to = { email: job.to.email, name: job.to.name };
119119 }
modifiedapps/diffinery/src/mail/web.tsx+1−1View file
@@ -425,7 +425,7 @@
425425 const me = requireSiteAdmin(c);
426426 const s = sampleJobs(c.env.PUBLIC_URL, { accountId: me.id }, me.login).find((x) => x.id === c.req.param("id"));
427427 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 });
429429 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'");
430430 c.header("x-frame-options", "SAMEORIGIN");
431431 c.header("x-content-type-options", "nosniff");
modifiedapps/diffinery/src/web/account.tsx+27−15View file
@@ -2,7 +2,7 @@
22 import { raw } from "hono/html";
33 import type { Child, FC } from "hono/jsx";
44 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";
66 import { loadRepo } from "../http/common.js";
77 import { HttpError } from "../lib/util.js";
88 import type { Role } from "../repo/types.js";
@@ -13,7 +13,7 @@
1313 import { repoCreated } from "../mail/notify.js";
1414 import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js";
1515 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";
1717 import type { FormData } from "./csrf.js";
1818 import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js";
1919 import { MAX_AVATAR_BYTES } from "../lib/image.js";
@@ -126,6 +126,12 @@
126126 }
127127
128128 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+];
129135
130136 // ---------------------------------------------------------------- landing / dashboard
131137
@@ -259,7 +265,7 @@
259265 {repos.length === 0 ? (
260266 <div class="box">
261267 <div class="body">
262- <h2>Welcome to Diffinery, {me.name ?? me.login}</h2>
268+ <h2>Welcome to Diffinery, {me.name || me.login}</h2>
263269 <ol class="muted" style="padding-left:20px">
264270 <li>
265271 <a href="/new">Create a repository</a> (or an <a href="/settings/orgs">organization</a> to share work with friends).
@@ -580,12 +586,12 @@
580586 export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "orgs" | "notifications" | "admin" | "email" | "audit";
581587
582588 export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
583- <aside class="side">
589+ <aside class="side vt-side">
584590 <section>
585591 <div class="row mb">
586592 <Avatar login={me.login} size="lg" version={me.avatar_version} />
587593 <div>
588- <b>{me.name ?? me.login}</b>
594+ <b>{me.name || me.login}</b>
589595 <div class="muted small">{me.login}</div>
590596 </div>
591597 </div>
@@ -679,7 +685,7 @@
679685 <div class="body">
680686 <div class="field">
681687 <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} />
683689 <div class="hint">Changing your username changes the URLs of your repositories. Existing clones need a new remote URL.</div>
684690 </div>
685691 <div class="field">
@@ -1117,7 +1123,7 @@
11171123 <div class="row" style="align-items:flex-start">
11181124 <div class="field grow">
11191125 <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" />
11211127 </div>
11221128 <div class="field grow">
11231129 <label for="aname">Display name</label>
@@ -1210,7 +1216,7 @@
12101216 "Organizations",
12111217 <>
12121218 <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>
12141220 <div class="box">
12151221 {orgs.length ? (
12161222 <ul class="list">
@@ -1239,7 +1245,7 @@
12391245 <div class="row" style="align-items:flex-start">
12401246 <div class="field grow">
12411247 <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" />
12431249 </div>
12441250 <div class="field grow">
12451251 <label for="oname">Display name</label>
@@ -1487,14 +1493,14 @@
14871493 <div class="row mb" style="gap:16px">
14881494 <Avatar login={org.login} size="lg" version={org.avatar_version} />
14891495 <div>
1490- <h1 class="m0">{org.name ?? org.login}</h1>
1496+ <h1 class="m0">{org.name || org.login}</h1>
14911497 <div class="muted small">
14921498 Organization · {org.login}
14931499 {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""}
14941500 </div>
14951501 </div>
14961502 </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)">
14981504 {(
14991505 [
15001506 ["repositories", "Repositories", true],
@@ -1565,7 +1571,7 @@
15651571 <Avatar login={acct.login} kind="agent" size="xl" version={acct.avatar_version} />
15661572 <div>
15671573 <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>
15691575 </h1>
15701576 <p class="muted">{acct.login}</p>
15711577 <dl class="kv">
@@ -1596,7 +1602,7 @@
15961602 <aside class="side">
15971603 <section>
15981604 <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>
16001606 <p class="muted">{acct.login}</p>
16011607 {acct.id === me.id ? (
16021608 <a class="btn sm" href="/settings">
@@ -1812,13 +1818,18 @@
18121818 <div class="body">
18131819 <div class="field">
18141820 <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} />
18161822 <div class="hint">Renaming changes every repository URL in this organization.</div>
18171823 </div>
18181824 <div class="field">
18191825 <label>Display name</label>
18201826 <input name="name" type="text" value={acct.name ?? ""} />
18211827 </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>
18221833 <button class="btn primary">Save</button>
18231834 </div>
18241835 </Form>
@@ -1898,8 +1909,9 @@
18981909 if (!isOwner) throw new HttpError(403, "organization owners only");
18991910 const login = f.s("login").trim();
19001911 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"));
19011913 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 } }));
19031915 return `/${login || org.login}?tab=settings`;
19041916 }, "Organization updated.");
19051917 });
modifiedapps/diffinery/src/web/assets.ts+18−0View file
@@ -309,6 +309,24 @@
309309 .theme-prev.dark{background:#1c1c1e}.theme-prev.dark i:not(:first-child){background:#52525b}
310310 .theme-prev.auto{background:linear-gradient(135deg,#fff 50%,#1c1c1e 50%)}
311311 }
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+}
312330 `;
313331
314332 /** Client behaviour: palette, shortcuts, copy buttons, live updates, line comments. */
modifiedapps/diffinery/src/web/auth.tsx+2−2View file
@@ -301,12 +301,12 @@
301301 if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired.");
302302 if (link.account.status === "disabled") throw new HttpError(403, "This account is disabled.");
303303 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 };
305305 existing = await auth.passkeys(link.account.id);
306306 } else {
307307 const me = browserAccount(c);
308308 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 };
310310 existing = await auth.passkeys(me.id);
311311 }
312312 const opts = await generateRegistrationOptions({
modifiedapps/diffinery/src/web/repo-settings.tsx+1−1View file
@@ -272,7 +272,7 @@
272272 <p class="muted">
273273 {repo.owner_kind === "org" ? (
274274 <>
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.
276276 </>
277277 ) : (
278278 <>
modifiedapps/diffinery/src/web/ui.tsx+3−0View file
@@ -873,3 +873,6 @@
873873
874874 /** "write" → "Write", for showing a stored value as a label. */
875875 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 @@
10741074 return null;
10751075 }
10761076
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("&amp;", "&")})$`, "v").test("dot.name"), false);
1118+ assert.equal((await api("POST", "/user/orgs", { login: "dot.name" })).status, 422);
1119+});
1120+
10771121 test("profile pictures: upload, resize, serve, cache headers and removal", async () => {
10781122 const me = await ok("GET", "/user");
10791123 const avatar = async (login, query = "") => {
@@ -1131,7 +1175,7 @@
11311175 // An old version's URL gets the current picture, but not as an immutable response.
11321176 assert.match((await avatar(me.login, `?s=64&v=${version}`)).cache, /max-age=600/);
11331177
1134- // Organizations: owners and admins.
1178+ // Organizations: owners (admins get a 403; see the security tests).
11351179 await ok("POST", "/user/orgs", { login: "picture-org" });
11361180 const org = await put("/orgs/picture-org/avatar", pngGradient(64, 64));
11371181 assert.equal(org.status, 200, await org.clone().text());
modifiedapps/diffinery/test/e2e/security.test.mjs+47−1View file
@@ -31,7 +31,7 @@
3131
3232 const hash = (s) => createHash("sha256").update(s).digest("hex");
3333 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") };
3535 const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}` };
3636 const OWNER = "olive";
3737
@@ -112,6 +112,9 @@
112112 `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}'), ('o-guild', 'u-rita', 'admin', '${at}')`,
113113 token("t6", "u-olive", T.reader, ["read"]),
114114 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}')`,
115118 session("s1", "u-penny", S.penny),
116119 session("s2", "u-dora", S.dora),
117120 ].join("; "),
@@ -403,3 +406,46 @@
403406 assert.ok(events.some((e) => e.event === "ready_for_review"), JSON.stringify(events));
404407 assert.equal((await ok("PUT", `${R}/pulls/${draft.number}/merge`, { merge_method: "squash" })).merged, true);
405408 });
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+});