Public: anyone can read this; the crew can change it.

Commits the forge makes for a person carry a public commit address, never the account email #672

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/623-commits-the-forge-makes-for-a-person-car → main 1 commit fork

  • Merged. torque/623-commits-the-forge-makes-for-a-person-car was merged into main as 322dca3.

Review

Sign in to review this change.
20 files changed +517 −57
README.md +6
@@ -58,6 +58,12 @@ curl -sX POST localhost:3000/api/v1/repos -H "authorization: Bearer $TOKEN" \
5858 git clone "http://you:$TOKEN@localhost:3000/you/hello.git"
5959 ```
6060
61 The commits the forge writes for you — merges, squashes, reverts, web edits —
62 carry `[email protected].<forge host>`, never your account email. Choosing your
63 real address is an opt-in under **Commit email** in `/settings` (or
64 `PATCH /api/v1/user` with `{"commitEmail":"account"}`); commits you push
65 yourself carry whatever your own git says.
66
6167 ## Live in the terminal
6268
6369 `ssh` into the forge and you get the whole thing — repositories, code, issues,
scripts/gate.ts +1
@@ -23,6 +23,7 @@ export type Exclusion = {
2323
2424 export const GATE: readonly string[] = [
2525 "test/crew.test.ts",
26 "test/commit-identity.test.ts",
2627 "test/review-route.test.ts",
2728 "test/review-lines.test.ts",
2829 "test/review-gate.test.ts",
src/actions/contents.ts +2 −4
@@ -16,6 +16,7 @@
1616 import type { App } from "../app.ts";
1717 import { canAdmin, type Repo } from "../core/repos.ts";
1818 import { trackPullHead, type Pull } from "../core/pulls.ts";
19 import { commitIdentity } from "../core/commit_identity.ts";
1920 import type { User } from "../core/users.ts";
2021 import { isSafeTreePath } from "../git/guard.ts";
2122 import { repoDir } from "../git/repos.ts";
@@ -187,10 +188,7 @@ export async function editFiles(
187188 ...(branch !== base ? { startPoint: base } : {}),
188189 message: input.message?.trim() || "Update files",
189190 files: input.files,
190 author: {
191 name: writer.value.displayName || writer.value.username,
192 email: writer.value.email,
193 },
191 author: commitIdentity(app.config, writer.value),
194192 ...(send === undefined
195193 ? {}
196194 : {
src/actions/forks.ts +2 −2
@@ -16,6 +16,7 @@ import {
1616 type TakeError,
1717 } from "../core/following.ts";
1818 import { canRead, getRepoById, type Repo } from "../core/repos.ts";
19 import { commitIdentity } from "../core/commit_identity.ts";
1920 import type { User } from "../core/users.ts";
2021 import { divergence } from "../git/branches.ts";
2122 import { resolveOid } from "../git/merge.ts";
@@ -81,8 +82,7 @@ export async function takeUpdates(
8182 const who = writer.value;
8283 return take(app, fork, parent, {
8384 id: who.id,
84 name: who.displayName || who.username,
85 email: who.email,
85 ...commitIdentity(app.config, who),
8686 });
8787 }
8888
src/actions/pulls.ts +4 −14
@@ -51,6 +51,7 @@ import {
5151 type Side,
5252 type ThreadState,
5353 } from "../core/reviews.ts";
54 import { commitIdentity } from "../core/commit_identity.ts";
5455 import { getUserById, getUserByName, type User } from "../core/users.ts";
5556 import type { MergeStrategy } from "../git/merge.ts";
5657 import { fetchInto, resolveOid, writeRef } from "../git/merge.ts";
@@ -339,31 +340,20 @@ export async function mergePull(
339340 // A fork may have pushed since the pull opened; merge what is being
340341 // proposed now, not the snapshot taken when it was.
341342 await refreshPullHead(app, repo, pull);
342 const lander = {
343 name: writer.value.displayName || writer.value.username,
344 email: writer.value.email,
345 };
343 const lander = commitIdentity(app.config, writer.value);
346344 // The agent that wrote the change is the commit's author; the person who
347345 // asked is named in `Requested-by`, the person who landed it in
348346 // `Co-authored-by`.
349347 const writtenBy = getUserById(app.db, pull.authorId);
350348 const agentAuthor =
351349 writtenBy?.kind === "agent"
352 ? {
353 name: writtenBy.displayName || writtenBy.username,
354 email: writtenBy.email,
355 }
350 ? commitIdentity(app.config, writtenBy)
356351 : undefined;
357352 const asked = toldBy(app.db, pull);
358353 const requester =
359354 asked === null ? undefined : getUserByName(app.db, asked);
360355 const trailers = landingTrailers(
361 requester
362 ? {
363 name: requester.displayName || requester.username,
364 email: requester.email,
365 }
366 : null,
356 requester ? commitIdentity(app.config, requester) : null,
367357 lander,
368358 );
369359 const merged = await landPull(
src/actions/repos.ts +2 −4
@@ -22,6 +22,7 @@ import {
2222 type Repo,
2323 type RepoSettings,
2424 } from "../core/repos.ts";
25 import { commitIdentity } from "../core/commit_identity.ts";
2526 import type { User } from "../core/users.ts";
2627 import {
2728 createWebhook as insertWebhook,
@@ -334,10 +335,7 @@ export async function commitToBranch(
334335 ...(branch !== startPoint ? { startPoint } : {}),
335336 message: input.message || "Update files",
336337 files: input.files,
337 author: {
338 name: writer.value.displayName || writer.value.username,
339 email: writer.value.email,
340 },
338 author: commitIdentity(app.config, writer.value),
341339 },
342340 );
343341 if (!committed.ok) return committed;
src/actions/revert.ts +2 −4
@@ -10,6 +10,7 @@
1010 import type { App } from "../app.ts";
1111 import { revertOf, type Pull } from "../core/pulls.ts";
1212 import type { Repo } from "../core/repos.ts";
13 import { commitIdentity } from "../core/commit_identity.ts";
1314 import type { User } from "../core/users.ts";
1415 import { resolveOid } from "../git/merge.ts";
1516 import { repoDir } from "../git/repos.ts";
@@ -58,10 +59,7 @@ export async function revertPull(
5859 commitOid: pull.mergedOid,
5960 ontoOid: onto,
6061 message: `Revert "${pull.title}" (#${pull.number})`,
61 author: {
62 name: writer.value.displayName || writer.value.username,
63 email: writer.value.email,
64 },
62 author: commitIdentity(app.config, writer.value),
6563 });
6664 if (!built.ok) return built;
6765
src/actions/users.ts +1 −1
@@ -94,7 +94,7 @@ export function updateProfile(
9494 if (!user.ok) return user;
9595 const updated = writeProfile(app.db, user.value.id, patch);
9696 if (!updated.ok) return updated;
97 const fields = (["displayName", "email"] as const).filter(
97 const fields = (["displayName", "email", "commitEmail"] as const).filter(
9898 (k) => patch[k] !== undefined,
9999 );
100100 app.bus.append({
src/core/commit_identity.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * The name and address a commit the forge writes for a person carries.
3 *
4 * A commit is published in every clone of a public repository, forever, and
5 * an address in it cannot be taken back without rewriting history. So the
6 * address is one the person chose to publish: a no-reply address on this
7 * forge's own host unless they opted in to their account email on the
8 * settings page (#623). The account email itself is never the default.
9 */
10
11 import type { ForgeConfig } from "../app.ts";
12 import type { User } from "./users.ts";
13
14 export interface CommitIdentity {
15 readonly name: string;
16 readonly email: string;
17 }
18
19 /** The host this forge is reached at: its public URL's, else its own name. */
20 export function forgeHost(config: Pick<ForgeConfig, "url" | "host">): string {
21 return config.url !== undefined
22 ? new URL(config.url).hostname
23 : (config.host ?? "localhost");
24 }
25
26 /** `<username>@users.noreply.<host>` — an address that reaches nobody. */
27 export function noreplyAddress(username: string, host: string): string {
28 return `${username}@users.noreply.${host}`;
29 }
30
31 /**
32 * Who a commit says wrote it. An agent's address is the forge's own
33 * invention (`…@agents.invalid`) and names nobody's mailbox, so it is used
34 * as it is; a person's is their no-reply address unless they opted in.
35 */
36 export function commitIdentity(
37 config: Pick<ForgeConfig, "url" | "host">,
38 user: Pick<User, "username" | "displayName" | "email" | "kind" | "commitEmail">,
39 ): CommitIdentity {
40 const name = user.displayName || user.username;
41 if (user.kind === "agent" || user.commitEmail === "account")
42 return { name, email: user.email };
43 return { name, email: noreplyAddress(user.username, forgeHost(config)) };
44 }
src/core/schema.ts +6
@@ -1189,4 +1189,10 @@ export const migrations: readonly string[] = [
11891189 created_at TEXT NOT NULL
11901190 );
11911191 `,
1192 // 46 — which address the forge's commits for a person carry (#623). The
1193 // default is the no-reply one; the account email is an explicit opt-in.
1194 `
1195 ALTER TABLE user ADD COLUMN commit_email TEXT NOT NULL DEFAULT 'noreply'
1196 CHECK (commit_email IN ('noreply','account'));
1197 `,
11921198 ];
src/core/users.ts +20 −6
@@ -5,6 +5,7 @@
55
66 import type { Database } from "bun:sqlite";
77 import { isSafeName } from "../git/guard.ts";
8 import { noreplyAddress } from "./commit_identity.ts";
89 import { id } from "../kernel/id.ts";
910 import { err, ok, type Result } from "../kernel/result.ts";
1011
@@ -20,9 +21,17 @@ export interface User {
2021 * tokens the forge mints for a run (docs/APPS.md).
2122 */
2223 readonly kind: "user" | "org" | "agent";
24 /**
25 * Which address the commits the forge writes for this person carry: the
26 * no-reply one on the forge's host, or — by explicit opt-in — `email`
27 * (`core/commit_identity.ts`).
28 */
29 readonly commitEmail: CommitEmail;
2330 readonly createdAt: string;
2431 }
2532
33 export type CommitEmail = "noreply" | "account";
34
2635 interface UserRow {
2736 id: string;
2837 username: string;
@@ -32,6 +41,7 @@ interface UserRow {
3241 password_hash: string;
3342 kind: "user" | "org";
3443 is_agent: number;
44 commit_email: CommitEmail;
3545 created_at: string;
3646 }
3747
@@ -45,6 +55,7 @@ function toUser(row: UserRow): User {
4555 // The flag, not the column, decides: see migration 14 for why an agent
4656 // is stored as kind 'user' with is_agent set.
4757 kind: row.is_agent === 1 ? "agent" : row.kind,
58 commitEmail: row.commit_email === "account" ? "account" : "noreply",
4859 createdAt: row.created_at,
4960 };
5061 }
@@ -159,11 +170,13 @@ function isUniqueViolation(error: unknown): boolean {
159170 export interface ProfilePatch {
160171 displayName?: string;
161172 email?: string;
173 commitEmail?: CommitEmail;
162174 }
163175
164176 export type UpdateProfileError =
165177 | "email_taken"
166178 | "invalid_email"
179 | "invalid_commit_email"
167180 | "no_such_user";
168181
169182 /** Change the mutable profile fields. Absent keys are left untouched. */
@@ -186,11 +199,12 @@ export function updateProfile(
186199 const displayName = (patch.displayName ?? current.displayName)
187200 .trim()
188201 .slice(0, MAX_DISPLAY_NAME);
189 db.query("UPDATE user SET email = ?1, display_name = ?2 WHERE id = ?3").run(
190 email,
191 displayName,
192 userId,
193 );
202 const commitEmail = patch.commitEmail ?? current.commitEmail;
203 if (commitEmail !== "noreply" && commitEmail !== "account")
204 return err("invalid_commit_email");
205 db.query(
206 "UPDATE user SET email = ?1, display_name = ?2, commit_email = ?3 WHERE id = ?4",
207 ).run(email, displayName, commitEmail, userId);
194208 return ok(getUserById(db, userId)!);
195209 }
196210
@@ -244,7 +258,7 @@ export async function ensureEdgeUser(
244258 if (existing) return existing.kind === "user" ? existing : undefined;
245259 const made = await createUser(db, {
246260 username: login,
247 email: `${login}@users.noreply.${host}`,
261 email: noreplyAddress(login, host),
248262 adminIfFirst: true,
249263 });
250264 return made.ok ? made.value : undefined;
src/crew/converge.ts +2 −2
@@ -27,6 +27,7 @@ import { listIssues, setAssignee, type Issue } from "../core/issues.ts";
2727 import { closesFromBody, listPulls, type Pull } from "../core/pulls.ts";
2828 import { getRepoById, type Repo } from "../core/repos.ts";
2929 import { activeRunFor, listRuns } from "../core/runs.ts";
30 import { commitIdentity } from "../core/commit_identity.ts";
3031 import { getUserById, type User } from "../core/users.ts";
3132 import { createLog } from "../kernel/log.ts";
3233 import { parkedCount } from "./autonomy.ts";
@@ -221,8 +222,7 @@ async function diverged(app: App, fork: Repo, parent: Repo): Promise<void> {
221222 if (by === undefined) return;
222223 const took = await takeUpdates(app, fork, parent, {
223224 id: by.id,
224 name: by.displayName || by.username,
225 email: by.email,
225 ...commitIdentity(app.config, by),
226226 });
227227 log.info(took.ok ? "took the parent's updates" : "could not take updates", {
228228 fork: `${fork.ownerName}/${fork.name}`,
src/http/api.ts +9
@@ -924,11 +924,20 @@ export function apiRoutes(router: Router<Handler>): void {
924924 const denied = requireAccount(ctx, { adminToken: true });
925925 if (denied) return denied;
926926 const input = await body(ctx.request);
927 if (
928 input.commitEmail !== undefined &&
929 input.commitEmail !== "noreply" &&
930 input.commitEmail !== "account"
931 )
932 return json({ error: "invalid_commit_email" }, 422);
927933 const updated = updateProfile(ctx.app, ctx.auth.user, {
928934 ...(typeof input.displayName === "string"
929935 ? { displayName: input.displayName }
930936 : {}),
931937 ...(typeof input.email === "string" ? { email: input.email } : {}),
938 ...(input.commitEmail === "noreply" || input.commitEmail === "account"
939 ? { commitEmail: input.commitEmail }
940 : {}),
932941 });
933942 if (!updated.ok) return respondAccess(updated.error, 422);
934943 return json(publicUser(updated.value));
src/http/respond.ts +2
@@ -211,6 +211,8 @@ export function publicUser(user: User) {
211211 username: user.username,
212212 displayName: user.displayName,
213213 isAdmin: user.isAdmin,
214 // Which address commits carry — the choice, never the address itself.
215 commitEmail: user.commitEmail,
214216 };
215217 }
216218
src/http/server.ts +2 −5
@@ -10,6 +10,7 @@ import { trackPullHead } from "../core/pulls.ts";
1010 import { createRepo } from "../actions/repos.ts";
1111 import { sessionUser } from "../core/sessions.ts";
1212 import { scopeAllows, verifyToken } from "../core/tokens.ts";
13 import { forgeHost } from "../core/commit_identity.ts";
1314 import { ensureEdgeUser, verifyPassword } from "../core/users.ts";
1415 import { edgeIdentity } from "./edge.ts";
1516 import { healthResponse } from "./health.ts";
@@ -324,11 +325,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
324325 // in; its word outranks a cookie of ours.
325326 const login = edgeIdentity(request, app.config.edgeToken);
326327 if (login !== undefined) {
327 const host =
328 app.config.url !== undefined
329 ? new URL(app.config.url).hostname
330 : (app.config.host ?? "localhost");
331 const user = await ensureEdgeUser(app.db, login, host);
328 const user = await ensureEdgeUser(app.db, login, forgeHost(app.config));
332329 if (user) return { user, scope: "admin", via: "edge" };
333330 }
334331
src/web/pages/account.ts +51 −11
@@ -23,8 +23,10 @@ import {
2323 notifyUrlOf,
2424 setNotifyUrl,
2525 } from "../../crew/notify.ts";
26 import { forgeHost, noreplyAddress } from "../../core/commit_identity.ts";
2627 import { listKeys } from "../../core/keys.ts";
2728 import { listTokens, type TokenScope } from "../../core/tokens.ts";
29 import type { CommitEmail, User } from "../../core/users.ts";
2830 import type { Ctx } from "../../http/context.ts";
2931 import { sshCommand } from "../../ssh/command.ts";
3032 import {
@@ -78,9 +80,17 @@ export function accountShell(
7880 interface ProfileForm {
7981 displayName: string;
8082 email: string;
83 commitEmail: CommitEmail;
8184 errors?: { displayName?: string; email?: string };
8285 }
8386
87 /** The profile form as the account stands now. */
88 const currentProfile = (user: User): ProfileForm => ({
89 displayName: user.displayName,
90 email: user.email,
91 commitEmail: user.commitEmail,
92 });
93
8494 function notifyView(ctx: Ctx, error?: string): Html {
8595 const host = notifyHost(notifyUrlOf(ctx.app.db, ctx.auth.user!.id));
8696 return card(
@@ -138,9 +148,26 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
138148 type: "email",
139149 value: values.email,
140150 autocomplete: "email",
141 hint: "Used to attribute commits. Never shown to other people.",
151 hint: "Where the forge reaches you. Never shown to other people, and never in a commit unless you choose it below.",
142152 ...(values.errors?.email ? { error: values.errors.email } : {}),
143153 })}
154 ${selectField({
155 label: "Commit email",
156 name: "commitEmail",
157 options: [
158 {
159 value: "noreply",
160 label: noreplyAddress(user.username, forgeHost(ctx.app.config)),
161 selected: values.commitEmail === "noreply",
162 },
163 {
164 value: "account",
165 label: "My email above — published in every clone",
166 selected: values.commitEmail === "account",
167 },
168 ],
169 hint: "The address on commits the forge makes for you: merges, reverts, web edits. A commit in a public repository cannot be taken back, so the default reaches nobody.",
170 })}
144171 <div class="form-actions">
145172 ${btn("Save profile", { variant: "primary" })}
146173 </div>
@@ -156,10 +183,7 @@ export function accountPage(ctx: Ctx): Response {
156183 ctx,
157184 "profile",
158185 "Settings",
159 html`${profileView(ctx, {
160 displayName: ctx.auth.user.displayName,
161 email: ctx.auth.user.email,
162 })}${notifyView(ctx)}`,
186 html`${profileView(ctx, currentProfile(ctx.auth.user))}${notifyView(ctx)}`,
163187 );
164188 }
165189
@@ -174,10 +198,10 @@ export async function notifyUpdate(ctx: Ctx): Promise<Response> {
174198 ctx,
175199 "profile",
176200 "Settings",
177 html`${profileView(ctx, {
178 displayName: ctx.auth.user.displayName,
179 email: ctx.auth.user.email,
180 })}${notifyView(ctx, NOTIFY_ERRORS[saved.error])}`,
201 html`${profileView(ctx, currentProfile(ctx.auth.user))}${notifyView(
202 ctx,
203 NOTIFY_ERRORS[saved.error],
204 )}`,
181205 422,
182206 );
183207 // The notifier starts from what asks now, so only what comes after is new.
@@ -195,8 +219,16 @@ export async function accountUpdate(ctx: Ctx): Promise<Response> {
195219 if (form instanceof Response) return form;
196220 const displayName = field(form, "displayName").trim();
197221 const email = field(form, "email").trim();
222 // Absent (an older form) leaves the choice as it stands; anything but the
223 // two offered values is refused by the profile write.
224 const chosen = field(form, "commitEmail");
225 const commitEmail = (chosen || ctx.auth.user.commitEmail) as CommitEmail;
198226
199 const updated = updateProfile(ctx.app, ctx.auth.user, { displayName, email });
227 const updated = updateProfile(ctx.app, ctx.auth.user, {
228 displayName,
229 email,
230 commitEmail,
231 });
200232 if (!updated.ok) {
201233 const errors: ProfileForm["errors"] =
202234 updated.error === "email_taken"
@@ -208,7 +240,15 @@ export async function accountUpdate(ctx: Ctx): Promise<Response> {
208240 ctx,
209241 "profile",
210242 "Settings",
211 profileView(ctx, { displayName, email, errors }),
243 profileView(ctx, {
244 displayName,
245 email,
246 commitEmail:
247 commitEmail === "noreply" || commitEmail === "account"
248 ? commitEmail
249 : ctx.auth.user.commitEmail,
250 errors,
251 }),
212252 422,
213253 );
214254 }
test/commit-identity.test.ts added +353
@@ -0,0 +1,353 @@
1 /**
2 * The address on commits the forge writes for a person (#623). A commit in a
3 * public repository is published in every clone forever, so it carries a
4 * no-reply address on the forge's host unless the person opted in to their
5 * account email — through the settings page or the API, never by default.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { editFiles } from "../src/actions/contents.ts";
14 import { mergePull, openPull } from "../src/actions/pulls.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { revertPull } from "../src/actions/revert.ts";
17 import { updateProfile } from "../src/actions/users.ts";
18 import {
19 commitIdentity,
20 forgeHost,
21 noreplyAddress,
22 } from "../src/core/commit_identity.ts";
23 import type { Repo } from "../src/core/repos.ts";
24 import {
25 createUser,
26 getUserByName,
27 updateProfile as writeProfile,
28 type User,
29 } from "../src/core/users.ts";
30 import { repoDir } from "../src/git/repos.ts";
31 import { serve, type Servers } from "../src/http/server.ts";
32
33 describe("commitIdentity", () => {
34 const person = {
35 username: "alice",
36 displayName: "Alice",
37 email: "[email protected]",
38 kind: "user" as const,
39 };
40
41 test("a person's default is the no-reply address on the forge's host", () => {
42 expect(
43 commitIdentity(
44 { host: "forge.example" },
45 { ...person, commitEmail: "noreply" },
46 ),
47 ).toEqual({ name: "Alice", email: "[email protected]" });
48 });
49
50 test("the account email only when the person opted in", () => {
51 expect(
52 commitIdentity({}, { ...person, commitEmail: "account" }).email,
53 ).toBe("[email protected]");
54 });
55
56 test("the public URL's host outranks the bind host, and has no port", () => {
57 expect(
58 forgeHost({ url: "https://forge.open-platform.sh:8443", host: "0.0.0.0" }),
59 ).toBe("forge.open-platform.sh");
60 expect(forgeHost({})).toBe("localhost");
61 expect(noreplyAddress("bob", "f.dev")).toBe("[email protected]");
62 });
63
64 test("an agent keeps the address the forge invented for it", () => {
65 expect(
66 commitIdentity(
67 {},
68 {
69 username: "scout",
70 displayName: "",
71 email: "[email protected]",
72 kind: "agent",
73 commitEmail: "noreply",
74 },
75 ),
76 ).toEqual({ name: "scout", email: "[email protected]" });
77 });
78 });
79
80 describe("commits the forge makes for a person", () => {
81 let dataDir: string;
82 let app: App;
83 let alice: User;
84 let carol: User;
85 let repo: Repo;
86 let carols: Repo;
87 let dir: string;
88
89 const dirOf = (r: Repo) => repoDir(app.reposRoot, r.ownerName, r.name);
90 const whoMade = (rev: string, r: Repo = repo): string =>
91 Bun.spawnSync(["git", "log", "-1", "--format=%ae|%ce", rev], {
92 cwd: dirOf(r),
93 })
94 .stdout.toString()
95 .trim();
96
97 const madeRepo = async (owner: User, name: string): Promise<Repo> => {
98 const made = await createRepo(app, owner, { name, visibility: "public" });
99 if (!made.ok) throw new Error(String(made.error));
100 await commitToBranch(app, owner, made.value, {
101 branch: "main",
102 message: "base",
103 files: [{ path: "README.md", content: "hi\n" }],
104 });
105 return made.value;
106 };
107
108 const shipped = async (
109 by: User,
110 branch: string,
111 strategy: "merge" | "squash",
112 repo: Repo,
113 ) => {
114 const wrote = await commitToBranch(app, by, repo, {
115 branch,
116 base: "main",
117 message: `work on ${branch}`,
118 files: [{ path: `${branch}.txt`, content: branch }],
119 });
120 if (!wrote.ok) throw new Error(JSON.stringify(wrote.error));
121 const opened = await openPull(app, by, repo, {
122 title: branch,
123 baseBranch: "main",
124 headBranch: branch,
125 });
126 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
127 const merged = await mergePull(app, by, repo, opened.value, strategy);
128 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
129 return { pull: opened.value, oid: merged.value.mergeOid };
130 };
131
132 beforeAll(async () => {
133 dataDir = await mkdtemp(join(tmpdir(), "forge-commit-identity-"));
134 app = createApp({ dataDir, port: 0, host: "forge.example" });
135 const a = await createUser(app.db, {
136 username: "alice",
137 email: "[email protected]",
138 password: "a-good-passphrase",
139 adminIfFirst: true,
140 });
141 const c = await createUser(app.db, {
142 username: "carol",
143 email: "[email protected]",
144 password: "a-good-passphrase",
145 });
146 if (!a.ok || !c.ok) throw new Error("users");
147 alice = a.value;
148 const opted = updateProfile(app, c.value, { commitEmail: "account" });
149 if (!opted.ok) throw new Error(opted.error);
150 carol = opted.value;
151 repo = await madeRepo(alice, "app");
152 carols = await madeRepo(carol, "notes");
153 dir = dirOf(repo);
154 });
155
156 afterAll(async () => {
157 app.close();
158 await rm(dataDir, { recursive: true, force: true });
159 });
160
161 test("a new account defaults to the no-reply address", () => {
162 expect(alice.commitEmail).toBe("noreply");
163 expect(getUserByName(app.db, "alice")?.commitEmail).toBe("noreply");
164 });
165
166 test("a merge by the default setting is authored and committed at the no-reply address", async () => {
167 const { oid } = await shipped(alice, "feature", "merge", repo);
168 expect(whoMade(oid)).toBe(
169 "[email protected]|[email protected]",
170 );
171 });
172
173 test("a squash and a revert carry it too", async () => {
174 const { pull, oid } = await shipped(alice, "squashed", "squash", repo);
175 expect(whoMade(oid)).toBe(
176 "[email protected]|[email protected]",
177 );
178 const { getPull } = await import("../src/core/pulls.ts");
179 const landed = getPull(app.db, repo.id, pull.number)!;
180 const reverted = await revertPull(app, alice, repo, landed);
181 if (!reverted.ok) throw new Error(JSON.stringify(reverted.error));
182 expect(whoMade(`refs/heads/revert-${pull.number}`)).toBe(
183 "[email protected]|[email protected]",
184 );
185 });
186
187 test("a web edit, a new file and a delete by the default setting carry it", async () => {
188 for (const files of [
189 [{ path: "README.md", content: "edited\n" }],
190 [{ path: "new.txt", content: "new\n" }],
191 [{ path: "new.txt", content: null }],
192 ]) {
193 const edited = await editFiles(app, alice, repo, {
194 branch: "main",
195 message: "web edit",
196 files,
197 });
198 if (!edited.ok) throw new Error(JSON.stringify(edited.error));
199 expect(whoMade(edited.value.commit.commitOid)).toBe(
200 "[email protected]|[email protected]",
201 );
202 }
203 });
204
205 test("a person who opted in gets their account email, on a merge and a web edit", async () => {
206 const { oid } = await shipped(carol, "carols", "merge", carols);
207 expect(whoMade(oid, carols)).toBe("[email protected]|[email protected]");
208 const edited = await editFiles(app, carol, carols, {
209 branch: "main",
210 files: [{ path: "carol.txt", content: "c" }],
211 });
212 if (!edited.ok) throw new Error(JSON.stringify(edited.error));
213 expect(whoMade(edited.value.commit.commitOid, carols)).toBe(
214 "[email protected]|[email protected]",
215 );
216 });
217
218 test("the private account email is in no git object at all", () => {
219 const everything = Bun.spawnSync(
220 ["git", "cat-file", "--batch-all-objects", "--batch"],
221 { cwd: dir },
222 ).stdout.toString();
223 expect(everything).toContain("[email protected]");
224 expect(everything).not.toContain("[email protected]");
225 });
226
227 test("the setting refuses anything but its two values", () => {
228 const bad = writeProfile(app.db, alice.id, {
229 commitEmail: "everyone" as never,
230 });
231 expect(bad.ok).toBe(false);
232 if (!bad.ok) expect(bad.error).toBe("invalid_commit_email");
233 expect(getUserByName(app.db, "alice")?.commitEmail).toBe("noreply");
234 });
235 });
236
237 describe("choosing the commit address", () => {
238 let dataDir: string;
239 let app: App;
240 let servers: Servers;
241 let base: string;
242 let cookie = "";
243
244 const form = (fields: Record<string, string>) => new URLSearchParams(fields);
245
246 beforeAll(async () => {
247 dataDir = await mkdtemp(join(tmpdir(), "forge-commit-email-page-"));
248 app = createApp({ dataDir, port: 0, host: "forge.example" });
249 servers = await serve(app);
250 base = servers.url;
251 const res = await fetch(`${base}/signup`, {
252 method: "POST",
253 body: form({
254 username: "dana",
255 email: "[email protected]",
256 password: "a-good-passphrase",
257 }),
258 redirect: "manual",
259 });
260 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
261 });
262
263 afterAll(async () => {
264 servers.stop();
265 app.close();
266 await rm(dataDir, { recursive: true, force: true });
267 });
268
269 test("the settings page offers the no-reply address, selected", async () => {
270 const page = await (
271 await fetch(`${base}/settings`, { headers: { cookie } })
272 ).text();
273 expect(page).toContain('name="commitEmail"');
274 expect(page).toMatch(
275 /value="noreply"\s+selected\s*>\s*dana@users\.noreply\.forge\.example/,
276 );
277 });
278
279 test("opting in is an explicit choice on the page, and opting out undoes it", async () => {
280 const post = (commitEmail: string) =>
281 fetch(`${base}/settings`, {
282 method: "POST",
283 headers: { cookie },
284 body: form({
285 displayName: "",
286 email: "[email protected]",
287 commitEmail,
288 }),
289 redirect: "manual",
290 });
291 expect((await post("account")).status).toBe(303);
292 expect(getUserByName(app.db, "dana")?.commitEmail).toBe("account");
293 expect((await post("noreply")).status).toBe(303);
294 expect(getUserByName(app.db, "dana")?.commitEmail).toBe("noreply");
295 expect((await post("<script>")).status).toBe(422);
296 expect(getUserByName(app.db, "dana")?.commitEmail).toBe("noreply");
297 });
298
299 test("a profile save without the field leaves the choice alone", async () => {
300 writeProfile(app.db, getUserByName(app.db, "dana")!.id, {
301 commitEmail: "account",
302 });
303 const res = await fetch(`${base}/settings`, {
304 method: "POST",
305 headers: { cookie },
306 body: form({ displayName: "Dana", email: "[email protected]" }),
307 redirect: "manual",
308 });
309 expect(res.status).toBe(303);
310 expect(getUserByName(app.db, "dana")?.commitEmail).toBe("account");
311 writeProfile(app.db, getUserByName(app.db, "dana")!.id, {
312 commitEmail: "noreply",
313 });
314 });
315
316 test("a cross-site form cannot flip it", async () => {
317 const res = await fetch(`${base}/settings`, {
318 method: "POST",
319 headers: { cookie, origin: "https://evil.test" },
320 body: form({ email: "[email protected]", commitEmail: "account" }),
321 redirect: "manual",
322 });
323 expect(res.status).toBe(403);
324 expect(getUserByName(app.db, "dana")?.commitEmail).toBe("noreply");
325 });
326
327 test("the API names the choice, never the address, and validates it", async () => {
328 const res = await fetch(`${base}/api/v1/user`, { headers: { cookie } });
329 const me = (await res.json()) as { commitEmail?: string };
330 expect(me.commitEmail).toBe("noreply");
331 expect(JSON.stringify(me)).not.toContain("[email protected]");
332 const bad = await fetch(`${base}/api/v1/user`, {
333 method: "PATCH",
334 headers: { cookie, "content-type": "application/json", origin: base },
335 body: JSON.stringify({ commitEmail: "everyone" }),
336 });
337 expect(bad.status).toBe(422);
338 const good = await fetch(`${base}/api/v1/user`, {
339 method: "PATCH",
340 headers: { cookie, "content-type": "application/json", origin: base },
341 body: JSON.stringify({ commitEmail: "account" }),
342 });
343 expect(good.status).toBe(200);
344 expect(((await good.json()) as { commitEmail?: string }).commitEmail).toBe("account");
345 });
346
347 test("the public profile and the feed never carry the account email", async () => {
348 for (const path of ["/dana", "/api/v1/users/dana"]) {
349 const res = await fetch(`${base}${path}`);
350 expect(await res.text()).not.toContain("[email protected]");
351 }
352 });
353 });
test/edits-security.test.ts +4 −1
@@ -253,6 +253,7 @@ describe("edits: refusals that must leave nothing behind", () => {
253253 expect(await refs()).toEqual([...before, "refs/heads/proposal"].sort());
254254 });
255255
256 // The actor's, at the address they publish — the no-reply one by default.
256257 test("commit identity is the actor's, whatever the body claims", async () => {
257258 const r = await edit({
258259 author: { name: "Mallory", email: "[email protected]" },
@@ -263,7 +264,9 @@ describe("edits: refusals that must leave nothing behind", () => {
263264 const who = (
264265 await git(["log", "-1", "--format=%an <%ae>|%cn <%ce>", "main"])
265266 ).stdout.trim();
266 expect(who).toBe("alice <[email protected]>|alice <[email protected]>");
267 expect(who).toBe(
268 "alice <[email protected]>|alice <[email protected]>",
269 );
267270 });
268271
269272 test("racing edits to one branch: exactly one lands, the rest hear branch_moved", async () => {
test/oauth-discovery.test.ts +2 −1
@@ -127,7 +127,8 @@ describe("oauth discovery and registration", () => {
127127 });
128128
129129 test("the migration is idempotent", () => {
130 const ddl = migrations[migrations.length - 1]!;
130 // Found by what it says, not where it sits: a later migration moves it.
131 const ddl = migrations.find((m) => m.includes("TABLE IF NOT EXISTS oauth_client"))!;
131132 expect(ddl).toContain("oauth_client");
132133 expect(() => app.db.exec(ddl)).not.toThrow();
133134 expect(() => app.db.exec(ddl)).not.toThrow();
test/told-by.test.ts +2 −2
@@ -146,8 +146,8 @@ describe("who told and who shipped", () => {
146146 { cwd: repoDir(app.reposRoot, "alice", "notes") },
147147 ).stdout.toString();
148148 expect(log).toContain("scout|alice");
149 expect(log).toContain("Requested-by: bob <bob@forge.local>");
150 expect(log).toContain("Co-authored-by: alice <alice@forge.local>");
149 expect(log).toContain("Requested-by: bob <bob@users.noreply.localhost>");
150 expect(log).toContain("Co-authored-by: alice <alice@users.noreply.localhost>");
151151
152152 const board = await (await get("/board")).text();
153153 expect(board).toContain("told by bob");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs