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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/623-commits-the-forge-makes-for-a-person-car → main 1 commit fork
20 files
changed +517
−57
README.md +6
| @@ -58,6 +58,12 @@ curl -sX POST localhost:3000/api/v1/repos -H "authorization: Bearer $TOKEN" \ | ||
| 58 | 58 | git clone "http://you:@localhost:3000/you/hello.git" |
| 59 | 59 | |
| 60 | 60 | |
| 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 | ||
| 61 | 67 | ## Live in the terminal |
| 62 | 68 | |
| 63 | 69 | `ssh` into the forge and you get the whole thing — repositories, code, issues, |
scripts/gate.ts +1
| @@ -23,6 +23,7 @@ export type Exclusion = { | ||
| 23 | 23 | |
| 24 | 24 | export const GATE: readonly string[] = [ |
| 25 | 25 | "test/crew.test.ts", |
| 26 | "test/commit-identity.test.ts", | |
| 26 | 27 | "test/review-route.test.ts", |
| 27 | 28 | "test/review-lines.test.ts", |
| 28 | 29 | "test/review-gate.test.ts", |
src/actions/contents.ts +2 −4
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | import type { App } from "../app.ts"; |
| 17 | 17 | import { canAdmin, type Repo } from "../core/repos.ts"; |
| 18 | 18 | import { trackPullHead, type Pull } from "../core/pulls.ts"; |
| 19 | import { commitIdentity } from "../core/commit_identity.ts"; | |
| 19 | 20 | import type { User } from "../core/users.ts"; |
| 20 | 21 | import { isSafeTreePath } from "../git/guard.ts"; |
| 21 | 22 | import { repoDir } from "../git/repos.ts"; |
| @@ -187,10 +188,7 @@ export async function editFiles( | ||
| 187 | 188 | ...(branch !== base ? { startPoint: base } : {}), |
| 188 | 189 | message: input.message?.trim() || "Update files", |
| 189 | 190 | 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), | |
| 194 | 192 | ...(send === undefined |
| 195 | 193 | ? {} |
| 196 | 194 | : { |
src/actions/forks.ts +2 −2
| @@ -16,6 +16,7 @@ import { | ||
| 16 | 16 | type TakeError, |
| 17 | 17 | } from "../core/following.ts"; |
| 18 | 18 | import { canRead, getRepoById, type Repo } from "../core/repos.ts"; |
| 19 | import { commitIdentity } from "../core/commit_identity.ts"; | |
| 19 | 20 | import type { User } from "../core/users.ts"; |
| 20 | 21 | import { divergence } from "../git/branches.ts"; |
| 21 | 22 | import { resolveOid } from "../git/merge.ts"; |
| @@ -81,8 +82,7 @@ export async function takeUpdates( | ||
| 81 | 82 | const who = writer.value; |
| 82 | 83 | return take(app, fork, parent, { |
| 83 | 84 | id: who.id, |
| 84 | name: who.displayName || who.username, | |
| 85 | email: who.email, | |
| 85 | ...commitIdentity(app.config, who), | |
| 86 | 86 | }); |
| 87 | 87 | } |
| 88 | 88 |
src/actions/pulls.ts +4 −14
| @@ -51,6 +51,7 @@ import { | ||
| 51 | 51 | type Side, |
| 52 | 52 | type ThreadState, |
| 53 | 53 | } from "../core/reviews.ts"; |
| 54 | import { commitIdentity } from "../core/commit_identity.ts"; | |
| 54 | 55 | import { getUserById, getUserByName, type User } from "../core/users.ts"; |
| 55 | 56 | import type { MergeStrategy } from "../git/merge.ts"; |
| 56 | 57 | import { fetchInto, resolveOid, writeRef } from "../git/merge.ts"; |
| @@ -339,31 +340,20 @@ export async function mergePull( | ||
| 339 | 340 | // A fork may have pushed since the pull opened; merge what is being |
| 340 | 341 | // proposed now, not the snapshot taken when it was. |
| 341 | 342 | 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); | |
| 346 | 344 | // The agent that wrote the change is the commit's author; the person who |
| 347 | 345 | // asked is named in `Requested-by`, the person who landed it in |
| 348 | 346 | // `Co-authored-by`. |
| 349 | 347 | const writtenBy = getUserById(app.db, pull.authorId); |
| 350 | 348 | const agentAuthor = |
| 351 | 349 | writtenBy?.kind === "agent" |
| 352 | ? { | |
| 353 | name: writtenBy.displayName || writtenBy.username, | |
| 354 | email: writtenBy.email, | |
| 355 | } | |
| 350 | ? commitIdentity(app.config, writtenBy) | |
| 356 | 351 | : undefined; |
| 357 | 352 | const asked = toldBy(app.db, pull); |
| 358 | 353 | const requester = |
| 359 | 354 | asked === null ? undefined : getUserByName(app.db, asked); |
| 360 | 355 | 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, | |
| 367 | 357 | lander, |
| 368 | 358 | ); |
| 369 | 359 | const merged = await landPull( |
src/actions/repos.ts +2 −4
| @@ -22,6 +22,7 @@ import { | ||
| 22 | 22 | type Repo, |
| 23 | 23 | type RepoSettings, |
| 24 | 24 | } from "../core/repos.ts"; |
| 25 | import { commitIdentity } from "../core/commit_identity.ts"; | |
| 25 | 26 | import type { User } from "../core/users.ts"; |
| 26 | 27 | import { |
| 27 | 28 | createWebhook as insertWebhook, |
| @@ -334,10 +335,7 @@ export async function commitToBranch( | ||
| 334 | 335 | ...(branch !== startPoint ? { startPoint } : {}), |
| 335 | 336 | message: input.message || "Update files", |
| 336 | 337 | 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), | |
| 341 | 339 | }, |
| 342 | 340 | ); |
| 343 | 341 | if (!committed.ok) return committed; |
src/actions/revert.ts +2 −4
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | 11 | import { revertOf, type Pull } from "../core/pulls.ts"; |
| 12 | 12 | import type { Repo } from "../core/repos.ts"; |
| 13 | import { commitIdentity } from "../core/commit_identity.ts"; | |
| 13 | 14 | import type { User } from "../core/users.ts"; |
| 14 | 15 | import { resolveOid } from "../git/merge.ts"; |
| 15 | 16 | import { repoDir } from "../git/repos.ts"; |
| @@ -58,10 +59,7 @@ export async function revertPull( | ||
| 58 | 59 | commitOid: pull.mergedOid, |
| 59 | 60 | ontoOid: onto, |
| 60 | 61 | 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), | |
| 65 | 63 | }); |
| 66 | 64 | if (!built.ok) return built; |
| 67 | 65 |
src/actions/users.ts +1 −1
| @@ -94,7 +94,7 @@ export function updateProfile( | ||
| 94 | 94 | if (!user.ok) return user; |
| 95 | 95 | const updated = writeProfile(app.db, user.value.id, patch); |
| 96 | 96 | if (!updated.ok) return updated; |
| 97 | const fields = (["displayName", "email"] as const).filter( | |
| 97 | const fields = (["displayName", "email", "commitEmail"] as const).filter( | |
| 98 | 98 | (k) => patch[k] !== undefined, |
| 99 | 99 | ); |
| 100 | 100 | 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[] = [ | ||
| 1189 | 1189 | created_at TEXT NOT NULL |
| 1190 | 1190 | ); |
| 1191 | 1191 | `, |
| 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 | `, | |
| 1192 | 1198 | ]; |
src/core/users.ts +20 −6
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | |
| 6 | 6 | import type { Database } from "bun:sqlite"; |
| 7 | 7 | import { isSafeName } from "../git/guard.ts"; |
| 8 | import { noreplyAddress } from "./commit_identity.ts"; | |
| 8 | 9 | import { id } from "../kernel/id.ts"; |
| 9 | 10 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 10 | 11 | |
| @@ -20,9 +21,17 @@ export interface User { | ||
| 20 | 21 | * tokens the forge mints for a run (docs/APPS.md). |
| 21 | 22 | */ |
| 22 | 23 | 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; | |
| 23 | 30 | readonly createdAt: string; |
| 24 | 31 | } |
| 25 | 32 | |
| 33 | export type CommitEmail = "noreply" | "account"; | |
| 34 | ||
| 26 | 35 | interface UserRow { |
| 27 | 36 | id: string; |
| 28 | 37 | username: string; |
| @@ -32,6 +41,7 @@ interface UserRow { | ||
| 32 | 41 | password_hash: string; |
| 33 | 42 | kind: "user" | "org"; |
| 34 | 43 | is_agent: number; |
| 44 | commit_email: CommitEmail; | |
| 35 | 45 | created_at: string; |
| 36 | 46 | } |
| 37 | 47 | |
| @@ -45,6 +55,7 @@ function toUser(row: UserRow): User { | ||
| 45 | 55 | // The flag, not the column, decides: see migration 14 for why an agent |
| 46 | 56 | // is stored as kind 'user' with is_agent set. |
| 47 | 57 | kind: row.is_agent === 1 ? "agent" : row.kind, |
| 58 | commitEmail: row.commit_email === "account" ? "account" : "noreply", | |
| 48 | 59 | createdAt: row.created_at, |
| 49 | 60 | }; |
| 50 | 61 | } |
| @@ -159,11 +170,13 @@ function isUniqueViolation(error: unknown): boolean { | ||
| 159 | 170 | export interface ProfilePatch { |
| 160 | 171 | displayName?: string; |
| 161 | 172 | email?: string; |
| 173 | commitEmail?: CommitEmail; | |
| 162 | 174 | } |
| 163 | 175 | |
| 164 | 176 | export type UpdateProfileError = |
| 165 | 177 | | "email_taken" |
| 166 | 178 | | "invalid_email" |
| 179 | | "invalid_commit_email" | |
| 167 | 180 | | "no_such_user"; |
| 168 | 181 | |
| 169 | 182 | /** Change the mutable profile fields. Absent keys are left untouched. */ |
| @@ -186,11 +199,12 @@ export function updateProfile( | ||
| 186 | 199 | const displayName = (patch.displayName ?? current.displayName) |
| 187 | 200 | .trim() |
| 188 | 201 | .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); | |
| 194 | 208 | return ok(getUserById(db, userId)!); |
| 195 | 209 | } |
| 196 | 210 | |
| @@ -244,7 +258,7 @@ export async function ensureEdgeUser( | ||
| 244 | 258 | if (existing) return existing.kind === "user" ? existing : undefined; |
| 245 | 259 | const made = await createUser(db, { |
| 246 | 260 | username: login, |
| 247 | email: `${login}@users.noreply.${host}`, | |
| 261 | email: noreplyAddress(login, host), | |
| 248 | 262 | adminIfFirst: true, |
| 249 | 263 | }); |
| 250 | 264 | 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"; | ||
| 27 | 27 | import { closesFromBody, listPulls, type Pull } from "../core/pulls.ts"; |
| 28 | 28 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 29 | 29 | import { activeRunFor, listRuns } from "../core/runs.ts"; |
| 30 | import { commitIdentity } from "../core/commit_identity.ts"; | |
| 30 | 31 | import { getUserById, type User } from "../core/users.ts"; |
| 31 | 32 | import { createLog } from "../kernel/log.ts"; |
| 32 | 33 | import { parkedCount } from "./autonomy.ts"; |
| @@ -221,8 +222,7 @@ async function diverged(app: App, fork: Repo, parent: Repo): Promise<void> { | ||
| 221 | 222 | if (by === undefined) return; |
| 222 | 223 | const took = await takeUpdates(app, fork, parent, { |
| 223 | 224 | id: by.id, |
| 224 | name: by.displayName || by.username, | |
| 225 | email: by.email, | |
| 225 | ...commitIdentity(app.config, by), | |
| 226 | 226 | }); |
| 227 | 227 | log.info(took.ok ? "took the parent's updates" : "could not take updates", { |
| 228 | 228 | fork: `${fork.ownerName}/${fork.name}`, |
src/http/api.ts +9
| @@ -924,11 +924,20 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 924 | 924 | const denied = requireAccount(ctx, { adminToken: true }); |
| 925 | 925 | if (denied) return denied; |
| 926 | 926 | 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); | |
| 927 | 933 | const updated = updateProfile(ctx.app, ctx.auth.user, { |
| 928 | 934 | ...(typeof input.displayName === "string" |
| 929 | 935 | ? { displayName: input.displayName } |
| 930 | 936 | : {}), |
| 931 | 937 | ...(typeof input.email === "string" ? { email: input.email } : {}), |
| 938 | ...(input.commitEmail === "noreply" || input.commitEmail === "account" | |
| 939 | ? { commitEmail: input.commitEmail } | |
| 940 | : {}), | |
| 932 | 941 | }); |
| 933 | 942 | if (!updated.ok) return respondAccess(updated.error, 422); |
| 934 | 943 | return json(publicUser(updated.value)); |
src/http/respond.ts +2
| @@ -211,6 +211,8 @@ export function publicUser(user: User) { | ||
| 211 | 211 | username: user.username, |
| 212 | 212 | displayName: user.displayName, |
| 213 | 213 | isAdmin: user.isAdmin, |
| 214 | // Which address commits carry — the choice, never the address itself. | |
| 215 | commitEmail: user.commitEmail, | |
| 214 | 216 | }; |
| 215 | 217 | } |
| 216 | 218 |
src/http/server.ts +2 −5
| @@ -10,6 +10,7 @@ import { trackPullHead } from "../core/pulls.ts"; | ||
| 10 | 10 | import { createRepo } from "../actions/repos.ts"; |
| 11 | 11 | import { sessionUser } from "../core/sessions.ts"; |
| 12 | 12 | import { scopeAllows, verifyToken } from "../core/tokens.ts"; |
| 13 | import { forgeHost } from "../core/commit_identity.ts"; | |
| 13 | 14 | import { ensureEdgeUser, verifyPassword } from "../core/users.ts"; |
| 14 | 15 | import { edgeIdentity } from "./edge.ts"; |
| 15 | 16 | import { healthResponse } from "./health.ts"; |
| @@ -324,11 +325,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 324 | 325 | // in; its word outranks a cookie of ours. |
| 325 | 326 | const login = edgeIdentity(request, app.config.edgeToken); |
| 326 | 327 | 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)); | |
| 332 | 329 | if (user) return { user, scope: "admin", via: "edge" }; |
| 333 | 330 | } |
| 334 | 331 |
src/web/pages/account.ts +51 −11
| @@ -23,8 +23,10 @@ import { | ||
| 23 | 23 | notifyUrlOf, |
| 24 | 24 | setNotifyUrl, |
| 25 | 25 | } from "../../crew/notify.ts"; |
| 26 | import { forgeHost, noreplyAddress } from "../../core/commit_identity.ts"; | |
| 26 | 27 | import { listKeys } from "../../core/keys.ts"; |
| 27 | 28 | import { listTokens, type TokenScope } from "../../core/tokens.ts"; |
| 29 | import type { CommitEmail, User } from "../../core/users.ts"; | |
| 28 | 30 | import type { Ctx } from "../../http/context.ts"; |
| 29 | 31 | import { sshCommand } from "../../ssh/command.ts"; |
| 30 | 32 | import { |
| @@ -78,9 +80,17 @@ export function accountShell( | ||
| 78 | 80 | interface ProfileForm { |
| 79 | 81 | displayName: string; |
| 80 | 82 | email: string; |
| 83 | commitEmail: CommitEmail; | |
| 81 | 84 | errors?: { displayName?: string; email?: string }; |
| 82 | 85 | } |
| 83 | 86 | |
| 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 | ||
| 84 | 94 | function notifyView(ctx: Ctx, error?: string): Html { |
| 85 | 95 | const host = notifyHost(notifyUrlOf(ctx.app.db, ctx.auth.user!.id)); |
| 86 | 96 | return card( |
| @@ -138,9 +148,26 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 138 | 148 | type: "email", |
| 139 | 149 | value: values.email, |
| 140 | 150 | 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.", | |
| 142 | 152 | ...(values.errors?.email ? { error: values.errors.email } : {}), |
| 143 | 153 | })} |
| 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 | })} | |
| 144 | 171 | <div class="form-actions"> |
| 145 | 172 | ${btn("Save profile", { variant: "primary" })} |
| 146 | 173 | </div> |
| @@ -156,10 +183,7 @@ export function accountPage(ctx: Ctx): Response { | ||
| 156 | 183 | ctx, |
| 157 | 184 | "profile", |
| 158 | 185 | "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)}`, | |
| 163 | 187 | ); |
| 164 | 188 | } |
| 165 | 189 | |
| @@ -174,10 +198,10 @@ export async function notifyUpdate(ctx: Ctx): Promise<Response> { | ||
| 174 | 198 | ctx, |
| 175 | 199 | "profile", |
| 176 | 200 | "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 | )}`, | |
| 181 | 205 | 422, |
| 182 | 206 | ); |
| 183 | 207 | // 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> { | ||
| 195 | 219 | if (form instanceof Response) return form; |
| 196 | 220 | const displayName = field(form, "displayName").trim(); |
| 197 | 221 | 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; | |
| 198 | 226 | |
| 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 | }); | |
| 200 | 232 | if (!updated.ok) { |
| 201 | 233 | const errors: ProfileForm["errors"] = |
| 202 | 234 | updated.error === "email_taken" |
| @@ -208,7 +240,15 @@ export async function accountUpdate(ctx: Ctx): Promise<Response> { | ||
| 208 | 240 | ctx, |
| 209 | 241 | "profile", |
| 210 | 242 | "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 | }), | |
| 212 | 252 | 422, |
| 213 | 253 | ); |
| 214 | 254 | } |
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", () => { | ||
| 253 | 253 | expect(await refs()).toEqual([...before, "refs/heads/proposal"].sort()); |
| 254 | 254 | }); |
| 255 | 255 | |
| 256 | // The actor's, at the address they publish — the no-reply one by default. | |
| 256 | 257 | test("commit identity is the actor's, whatever the body claims", async () => { |
| 257 | 258 | const r = await edit({ |
| 258 | 259 | author: { name: "Mallory", email: "[email protected]" }, |
| @@ -263,7 +264,9 @@ describe("edits: refusals that must leave nothing behind", () => { | ||
| 263 | 264 | const who = ( |
| 264 | 265 | await git(["log", "-1", "--format=%an <%ae>|%cn <%ce>", "main"]) |
| 265 | 266 | ).stdout.trim(); |
| 266 | expect(who).toBe("alice <[email protected]>|alice <[email protected]>"); | |
| 267 | expect(who).toBe( | |
| 268 | "alice <[email protected]>|alice <[email protected]>", | |
| 269 | ); | |
| 267 | 270 | }); |
| 268 | 271 | |
| 269 | 272 | 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", () => { | ||
| 127 | 127 | }); |
| 128 | 128 | |
| 129 | 129 | 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"))!; | |
| 131 | 132 | expect(ddl).toContain("oauth_client"); |
| 132 | 133 | expect(() => app.db.exec(ddl)).not.toThrow(); |
| 133 | 134 | expect(() => app.db.exec(ddl)).not.toThrow(); |
test/told-by.test.ts +2 −2
| @@ -146,8 +146,8 @@ describe("who told and who shipped", () => { | ||
| 146 | 146 | { cwd: repoDir(app.reposRoot, "alice", "notes") }, |
| 147 | 147 | ).stdout.toString(); |
| 148 | 148 | 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>"); | |
| 151 | 151 | |
| 152 | 152 | const board = await (await get("/board")).text(); |
| 153 | 153 | expect(board).toContain("told by bob"); |
new above
offline