Skip to content
321 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1/**
2 * A person's email addresses and the security of their account, on the
3 * identity service. Mirrors `crates/contracts/src/accounts.rs`.
4 */
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)5import type { AccountDeletion, DeletedAccount } from "./account-deletion";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6import type { ServiceBinding } from "./clients";
7import type { User } from "./identity";
8import type { Result } from "./result";
9
10/** The most addresses one account may have, confirmed or not. */
11export const MAX_EMAILS = 10;
12/** How long after signing in sensitive changes need no password, in seconds. */
13export const RECENT_AUTH_SECONDS = 10 * 60;
14/** The domain of each person's private commit address. */
15export const NOREPLY_DOMAIN = "users.noreply.g1t.sh";
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)16/** How many digits the code in a confirmation email has. */
17export const CONFIRM_CODE_DIGITS = 6;
18/** How long a confirmation email's code and link work, in seconds. */
19export const CONFIRM_TTL_SECONDS = 60 * 60;
20
21/**
22 * A confirmation code as typed or pasted, with spaces and hyphens taken
23 * out; null unless that leaves exactly six digits.
24 */
25export function tidyConfirmCode(code: string): string | null {
26 const digits = code.replace(/[\s-]/g, "");
27 return /^\d{6}$/.test(digits) ? digits : null;
28}
29
30/** What confirming an address did: by its link (`verifyEmail`) or its code (`confirmEmailCode`). */
31export type EmailConfirmed = {
32 username: string;
33 /** The address confirmed, as typed when it was added. */
34 email: string;
35 /** Whether the account is confirmed now: whether its primary is. */
36 verified: boolean;
37 /** The workspace the account's invite joined it to, by slug. */
38 joined?: string | null;
39 /** Why the invite the account signed up with no longer applies; the address is confirmed all the same. */
40 inviteLapsed?: string | null;
41};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look42
43/**
44 * Proof that the person making a sensitive change is the account's owner:
45 * the session they signed in to within `RECENT_AUTH_SECONDS`, or their
46 * password. Without it the answer is `reauth_required`.
47 */
48export type Reauth = { sessionToken?: string | null; password?: string | null; client?: string | null };
49
50/** One of a person's addresses. */
51export type AccountEmail = {
52 /** As typed when it was added. */
53 email: string;
54 verified: boolean;
55 primary: boolean;
56 /** Gets security notices as well as the primary. */
57 backup: boolean;
58 /** RFC 3339. */
59 createdAt: string;
60 /** RFC 3339. */
61 verifiedAt: string | null;
62};
63
64export type AccountEmails = {
65 /** The primary first, then confirmed addresses, then the rest. */
66 emails: AccountEmail[];
67 /** Commits g1t makes for the person use `noreply`. */
68 privateEmail: boolean;
69 /** Refuse pushes whose commits carry one of the person's addresses. */
70 blockPrivatePushes: boolean;
71 /** `<id suffix>+<username>@users.noreply.g1t.sh`. */
72 noreply: string;
73 /** The address commits g1t makes for the person carry now. */
74 commitEmail: string;
75 limit: number;
76};
77
78/** What `updateEmailSettings` can change; each field given is changed. */
79export type EmailSettings = {
80 /** A confirmed address to make primary. */
81 primary?: string;
82 /** A confirmed address for security notices too, or "" for the primary only. */
83 backup?: string;
84 privateEmail?: boolean;
85 blockPrivatePushes?: boolean;
86};
87
88export type SecurityEvent = {
89 kind:
90 | "email_added"
91 | "email_verified"
92 | "email_removed"
93 | "primary_email_changed"
94 | "backup_email_changed"
95 | "email_privacy_changed"
96 | "password_changed"
97 | "password_locked"
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca98 | "ssh_key_added"
99 | "ssh_key_removed"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look100 | (string & {});
101 detail: string | null;
102 byStaff: boolean;
103 reason: string | null;
104 /** The staff member; only in staff views. */
105 staff?: string | null;
106 /** RFC 3339. */
107 createdAt: string;
108};
109
110/** The account a commit's author address belongs to. */
111export type EmailOwner = { id: string; username: string; avatar: string | null };
112
113/** One account's addresses and security log, as staff see them. */
114export type AdminUser = {
115 id: string;
116 username: string;
117 /** RFC 3339. */
118 createdAt: string;
119 emails: AccountEmail[];
120 privateEmail: boolean;
121 log: SecurityEvent[];
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)122 /** What deleting it would take, and what stands in the way (billing is not asked for staff). */
123 deletion: AccountDeletion;
124 /** Set while it is deleted and not yet purged. */
125 deleted: DeletedAccount | null;
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)126 /** The shared invite link it was made with, if it was: sudo shows "Joined through <label>". */
127 joinedThrough: { id: string; label: string } | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look128};
129
Merge main (membership, two-factor, GitHub repo roles) into tokens130/** Where an account's two-factor authentication stands. */
131export type TwoFactorStatus = {
132 enabled: boolean;
133 /** RFC 3339. */
134 enabled_at: string | null;
135 /** Recovery codes not used yet. */
136 recovery_codes_left: number;
137 /** The workspaces the person belongs to that require it. */
138 required_by: string[];
139};
140
141/** What an authenticator app needs: the secret in base32, and the same as an `otpauth://` address for a QR code. */
142export type TwoFactorSetup = { secret: string; uri: string };
143
144/** How many recovery codes an account gets. */
145export const RECOVERY_CODES = 10;
146
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look147export interface AccountsApi {
148 /** The person's own addresses. People only, never an agent's or a workspace's token. */
149 listEmails(user: User): Promise<Result<AccountEmails>>;
150 /** Adds an address and emails it a confirmation link. Needs `reauth`. */
151 addEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>;
152 /** Removes an address; never the primary nor the last confirmed one. Needs `reauth`. */
153 removeEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)154 /** Sends a new confirmation code and link, at most once a minute; the ones before stop working. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look155 resendEmailVerification(user: User, email: string): Promise<Result<boolean>>;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)156 /**
157 * The code from a confirmation email, typed by the signed-in person it was
158 * sent to. Wrong codes are counted against the account and `client`.
159 */
160 confirmEmailCode(user: User, code: string, client?: string | null): Promise<Result<EmailConfirmed>>;
161 /**
162 * For an account with no confirmed address: replaces the address it signed
163 * up with, and sends a new code and link there.
164 */
165 changePendingEmail(user: User, email: string): Promise<Result<AccountEmails>>;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look166 /** Primary and backup need `reauth`; the privacy switches do not. */
167 updateEmailSettings(user: User, settings: EmailSettings, reauth: Reauth): Promise<Result<AccountEmails>>;
168 /** The person typed their password again for this session. */
169 reauthenticate(sessionToken: string, password: string, client?: string | null): Promise<Result<boolean>>;
170 /** The newest entries of the person's security log. */
171 securityLog(user: User): Promise<Result<SecurityEvent[]>>;
172 /** Whose commits these are, by author address: confirmed and noreply addresses only. */
173 emailOwners(emails: string[]): Promise<Record<string, EmailOwner>>;
Merge main (membership, two-factor, GitHub repo roles) into tokens174 /** Whether two-factor authentication is on, and which workspaces require it. */
175 twoFactorStatus(user: User): Promise<Result<TwoFactorStatus>>;
176 /** Begins turning it on: a new secret for the app. Needs `reauth`. */
177 twoFactorStart(user: User, reauth: Reauth): Promise<Result<TwoFactorSetup>>;
178 /** A code from the app confirms it; returns the recovery codes, shown once. Needs `reauth`. */
179 twoFactorEnable(user: User, code: string, reauth: Reauth): Promise<Result<{ codes: string[] }>>;
180 /** Turns it off with a code (or a recovery code). Needs `reauth`. */
181 twoFactorDisable(user: User, code: string, reauth: Reauth): Promise<Result<boolean>>;
182 /** New recovery codes, replacing the old ones. Needs `reauth`. */
183 twoFactorRecoveryCodes(user: User, reauth: Reauth): Promise<Result<{ codes: string[] }>>;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)184 /** What deleting the person's own account would take, and what stands in the way, changing nothing. People only. */
185 checkAccountDeletion(user: User): Promise<Result<AccountDeletion>>;
186 /**
187 * Deletes the person's own account. `confirm` is their username, typed
188 * out; needs `reauth`. Refused for a protected account and while they are
189 * the only owner of a live workspace. Kept `ACCOUNT_RESTORE_DAYS` for
190 * staff to restore. Publishes `user.deleting`. Not offered by the API.
191 */
192 deleteAccount(user: User, confirm: string, reauth: Reauth): Promise<Result<boolean>>;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look193}
194
195/** Staff only, for sudo.g1t.sh. */
196export interface AccountsAdminApi {
197 user(username: string): Promise<AdminUser | null>;
198 /** Removes an address with a reason the person sees; never the last confirmed one. */
199 removeEmail(username: string, email: string, reason: string, staff: string): Promise<Result<AdminUser>>;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)200 /**
201 * Deletes an account, with the reason and the username typed out. Refused
Merge sudo: delete an account with the workspaces it alone owns, purge each202 * for a protected account, and while it is the only owner of a live
203 * workspace unless `withSoleWorkspaces`: then each of those is deleted
204 * first, as its owner would, and the account last. Refused whole while any
205 * of them is protected or its billing cannot settle; a workspace failing
206 * on the way stops it before the account. Recorded in sudo's audit log
207 * (`workspace_deleted` for each, `account_deleted`).
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)208 */
Merge sudo: delete an account with the workspaces it alone owns, purge each209 deleteAccount(
210 username: string,
211 reason: string,
212 confirm: string,
213 staff: string,
214 withSoleWorkspaces?: boolean,
215 ): Promise<Result<boolean>>;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)216 /** Deleted accounts not purged yet, newest first. */
217 deletedAccounts(): Promise<DeletedAccount[]>;
218 /** Brings a deleted account back within its window, with the memberships it left. Publishes `user.restored`. */
219 restoreAccount(userId: string, staff: string): Promise<Result<boolean>>;
220 /** Purges a deleted account now; `confirm` is its username. Publishes `user.deleted`. */
221 purgeAccount(userId: string, staff: string, confirm: string): Promise<Result<boolean>>;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look222}
223
224async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
225 const response = await service.fetch(`https://service/rpc/${method}`, {
226 method: "POST",
227 headers: { "content-type": "application/json" },
228 body: JSON.stringify(args),
229 });
230 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
231 return (await response.json()) as T;
232}
233
234export function accountsClient(identity: ServiceBinding): AccountsApi {
235 return {
236 listEmails: (user) => call(identity, "list_emails", { user }),
237 addEmail: (user, email, reauth) => call(identity, "add_email", { user, email, reauth }),
238 removeEmail: (user, email, reauth) => call(identity, "remove_email", { user, email, reauth }),
239 resendEmailVerification: (user, email) => call(identity, "resend_email_verification", { user, email }),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)240 confirmEmailCode: (user, code, client) => call(identity, "confirm_email_code", { user, code, client: client ?? null }),
241 changePendingEmail: (user, email) => call(identity, "change_pending_email", { user, email }),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look242 updateEmailSettings: (user, settings, reauth) => call(identity, "update_email_settings", { user, ...settings, reauth }),
243 reauthenticate: (sessionToken, password, client) => call(identity, "reauthenticate", { sessionToken, password, client: client ?? null }),
244 securityLog: (user) => call(identity, "security_log", { user }),
245 emailOwners: (emails) => call(identity, "email_owners", { emails }),
Merge main (membership, two-factor, GitHub repo roles) into tokens246 twoFactorStatus: (user) => call(identity, "two_factor_status", { user }),
247 twoFactorStart: (user, reauth) => call(identity, "two_factor_start", { user, reauth }),
248 twoFactorEnable: (user, code, reauth) => call(identity, "two_factor_enable", { user, code, reauth }),
249 twoFactorDisable: (user, code, reauth) => call(identity, "two_factor_disable", { user, code, reauth }),
250 twoFactorRecoveryCodes: (user, reauth) => call(identity, "two_factor_recovery_codes", { user, reauth }),
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)251 checkAccountDeletion: (user) => call(identity, "check_account_deletion", { user }),
252 deleteAccount: (user, confirm, reauth) => call(identity, "delete_account", { user, confirm, reauth }),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look253 };
254}
255
256export function accountsAdminClient(identity: ServiceBinding): AccountsAdminApi {
257 return {
258 user: (username) => call(identity, "admin_user", { username }),
259 removeEmail: (username, email, reason, staff) => call(identity, "admin_remove_email", { username, email, reason, staff }),
Merge sudo: delete an account with the workspaces it alone owns, purge each260 deleteAccount: (username, reason, confirm, staff, withSoleWorkspaces) =>
261 call(identity, "admin_delete_account", { username, reason, confirm, staff, withSoleWorkspaces: withSoleWorkspaces ?? false }),
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)262 deletedAccounts: () => call(identity, "admin_deleted_accounts", {}),
263 restoreAccount: (userId, staff) => call(identity, "admin_restore_account", { userId, staff }),
264 purgeAccount: (userId, staff, confirm) => call(identity, "admin_purge_account", { userId, staff, confirm }),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look265 };
266}
267
268/** Words for a security log entry, as the person reads it. */
269export function securityEventLabel(event: Pick<SecurityEvent, "kind" | "detail">): string {
270 const detail = event.detail ?? "";
271 switch (event.kind) {
272 case "email_added":
273 return `Added ${detail}`;
274 case "email_verified":
275 return `Confirmed ${detail}`;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)276 case "email_changed_before_confirming":
277 return `Changed the address to confirm to ${detail}`;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look278 case "email_removed":
279 return `Removed ${detail}`;
280 case "primary_email_changed":
281 return `Made ${detail} primary`;
282 case "backup_email_changed":
283 return detail === "primary only" ? "Security notices go to the primary only" : `Made ${detail} the backup`;
284 case "email_privacy_changed":
285 return `Email privacy: ${detail}`;
286 case "password_changed":
287 return "Changed the password";
288 case "password_locked":
289 return `Password sign-in paused after ${detail}`;
Merge main (membership, two-factor, GitHub repo roles) into tokens290 case "two_factor_enabled":
291 return "Turned on two-factor authentication";
292 case "two_factor_disabled":
293 return "Turned off two-factor authentication";
294 case "recovery_codes_regenerated":
295 return "Made new recovery codes";
296 case "recovery_code_used":
297 return "Signed in with a recovery code";
298 case "token_created":
299 return `Created access token ${detail}`;
300 case "token_deleted":
301 return `Deleted access token ${detail}`;
302 case "token_rescoped":
303 return `Changed the scopes of access token ${detail}`;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca304 case "ssh_key_added":
Merge main (membership, two-factor, GitHub repo roles) into tokens305 return `Added SSH key ${detail}`;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca306 case "ssh_key_removed":
Merge main (membership, two-factor, GitHub repo roles) into tokens307 return `Removed SSH key ${detail}`;
308 case "oauth_grant_created":
309 return `Authorized ${detail}`;
310 case "oauth_grant_revoked":
311 return `Revoked ${detail}`;
312 case "oauth_grant_rescoped":
313 return `Changed what ${detail} may do`;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)314 case "account_deleted":
315 return "Deleted the account";
316 case "account_restored":
317 return "Restored the account";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look318 default:
319 return detail ? `${event.kind}: ${detail}` : event.kind;
320 }
321}

This file's history is long; its oldest lines are credited to the oldest commit read.