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 look | 1 | /** |
| 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) | 5 | import type { AccountDeletion, DeletedAccount } from "./account-deletion"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 6 | import type { ServiceBinding } from "./clients"; |
| 7 | import type { User } from "./identity"; | |
| 8 | import type { Result } from "./result"; | |
| 9 | ||
| 10 | /** The most addresses one account may have, confirmed or not. */ | |
| 11 | export const MAX_EMAILS = 10; | |
| 12 | /** How long after signing in sensitive changes need no password, in seconds. */ | |
| 13 | export const RECENT_AUTH_SECONDS = 10 * 60; | |
| 14 | /** The domain of each person's private commit address. */ | |
| 15 | export 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. */ |
| 17 | export const CONFIRM_CODE_DIGITS = 6; | |
| 18 | /** How long a confirmation email's code and link work, in seconds. */ | |
| 19 | export 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 | */ | |
| 25 | export 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`). */ | |
| 31 | export 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 look | 42 | |
| 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 | */ | |
| 48 | export type Reauth = { sessionToken?: string | null; password?: string | null; client?: string | null }; | |
| 49 | ||
| 50 | /** One of a person's addresses. */ | |
| 51 | export 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 | ||
| 64 | export 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. */ | |
| 79 | export 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 | ||
| 88 | export 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-a16631325aecf58ca | 98 | | "ssh_key_added" |
| 99 | | "ssh_key_removed" | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 100 | | (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. */ | |
| 111 | export type EmailOwner = { id: string; username: string; avatar: string | null }; | |
| 112 | ||
| 113 | /** One account's addresses and security log, as staff see them. */ | |
| 114 | export 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 look | 128 | }; |
| 129 | ||
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 130 | /** Where an account's two-factor authentication stands. */ |
| 131 | export 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. */ | |
| 142 | export type TwoFactorSetup = { secret: string; uri: string }; | |
| 143 | ||
| 144 | /** How many recovery codes an account gets. */ | |
| 145 | export const RECOVERY_CODES = 10; | |
| 146 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 147 | export 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 look | 155 | 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 look | 166 | /** 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 tokens | 174 | /** 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 look | 193 | } |
| 194 | ||
| 195 | /** Staff only, for sudo.g1t.sh. */ | |
| 196 | export 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 each | 202 | * 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 each | 209 | 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 look | 222 | } |
| 223 | ||
| 224 | async 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 | ||
| 234 | export 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 look | 242 | 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 tokens | 246 | 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 look | 253 | }; |
| 254 | } | |
| 255 | ||
| 256 | export 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 each | 260 | 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 look | 265 | }; |
| 266 | } | |
| 267 | ||
| 268 | /** Words for a security log entry, as the person reads it. */ | |
| 269 | export 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 look | 278 | 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 tokens | 290 | 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-a16631325aecf58ca | 304 | case "ssh_key_added": |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 305 | return `Added SSH key ${detail}`; |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 306 | case "ssh_key_removed": |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 307 | 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 look | 318 | 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.