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.
| Cache hits are counted, not only their bytes | 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"; |
| Cache hits are counted, not only their bytes | 6 | import type { ServiceBinding } from "./clients"; |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 7 | import type { Suspension, User } from "./identity"; |
| Cache hits are counted, not only their bytes | 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; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 39 | /** The workspace the account's invite invites it to, by slug: an invitation now waits for its answer. */ |
| 40 | invitedTo?: string | null; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 41 | /** Why the invite the account signed up with no longer applies; the address is confirmed all the same. */ |
| 42 | inviteLapsed?: string | null; | |
| 43 | }; | |
| Cache hits are counted, not only their bytes | 44 | |
| 45 | /** | |
| 46 | * Proof that the person making a sensitive change is the account's owner: | |
| 47 | * the session they signed in to within `RECENT_AUTH_SECONDS`, or their | |
| 48 | * password. Without it the answer is `reauth_required`. | |
| 49 | */ | |
| 50 | export type Reauth = { sessionToken?: string | null; password?: string | null; client?: string | null }; | |
| 51 | ||
| 52 | /** One of a person's addresses. */ | |
| 53 | export type AccountEmail = { | |
| 54 | /** As typed when it was added. */ | |
| 55 | email: string; | |
| 56 | verified: boolean; | |
| 57 | primary: boolean; | |
| 58 | /** Gets security notices as well as the primary. */ | |
| 59 | backup: boolean; | |
| 60 | /** RFC 3339. */ | |
| 61 | createdAt: string; | |
| 62 | /** RFC 3339. */ | |
| 63 | verifiedAt: string | null; | |
| 64 | }; | |
| 65 | ||
| 66 | export type AccountEmails = { | |
| 67 | /** The primary first, then confirmed addresses, then the rest. */ | |
| 68 | emails: AccountEmail[]; | |
| 69 | /** Commits g1t makes for the person use `noreply`. */ | |
| 70 | privateEmail: boolean; | |
| 71 | /** Refuse pushes whose commits carry one of the person's addresses. */ | |
| 72 | blockPrivatePushes: boolean; | |
| 73 | /** `<id suffix>+<username>@users.noreply.g1t.sh`. */ | |
| 74 | noreply: string; | |
| 75 | /** The address commits g1t makes for the person carry now. */ | |
| 76 | commitEmail: string; | |
| 77 | limit: number; | |
| 78 | }; | |
| 79 | ||
| 80 | /** What `updateEmailSettings` can change; each field given is changed. */ | |
| 81 | export type EmailSettings = { | |
| 82 | /** A confirmed address to make primary. */ | |
| 83 | primary?: string; | |
| 84 | /** A confirmed address for security notices too, or "" for the primary only. */ | |
| 85 | backup?: string; | |
| 86 | privateEmail?: boolean; | |
| 87 | blockPrivatePushes?: boolean; | |
| 88 | }; | |
| 89 | ||
| 90 | export type SecurityEvent = { | |
| 91 | kind: | |
| 92 | | "email_added" | |
| 93 | | "email_verified" | |
| 94 | | "email_removed" | |
| 95 | | "primary_email_changed" | |
| 96 | | "backup_email_changed" | |
| 97 | | "email_privacy_changed" | |
| 98 | | "password_changed" | |
| 99 | | "password_locked" | |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 100 | | "ssh_key_added" |
| 101 | | "ssh_key_removed" | |
| Cache hits are counted, not only their bytes | 102 | | (string & {}); |
| 103 | detail: string | null; | |
| 104 | byStaff: boolean; | |
| 105 | reason: string | null; | |
| 106 | /** The staff member; only in staff views. */ | |
| 107 | staff?: string | null; | |
| 108 | /** RFC 3339. */ | |
| 109 | createdAt: string; | |
| 110 | }; | |
| 111 | ||
| 112 | /** The account a commit's author address belongs to. */ | |
| 113 | export type EmailOwner = { id: string; username: string; avatar: string | null }; | |
| 114 | ||
| 115 | /** One account's addresses and security log, as staff see them. */ | |
| 116 | export type AdminUser = { | |
| 117 | id: string; | |
| 118 | username: string; | |
| 119 | /** RFC 3339. */ | |
| 120 | createdAt: string; | |
| 121 | emails: AccountEmail[]; | |
| 122 | privateEmail: boolean; | |
| 123 | log: SecurityEvent[]; | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 124 | /** What deleting it would take, and what stands in the way (billing is not asked for staff). */ |
| 125 | deletion: AccountDeletion; | |
| 126 | /** Set while it is deleted and not yet purged. */ | |
| 127 | deleted: DeletedAccount | null; | |
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 128 | /** The shared invite link it was made with, if it was: sudo shows "Joined through <label>". */ |
| 129 | joinedThrough: { id: string; label: string } | null; | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 130 | /** Set while staff have it suspended. */ |
| 131 | suspended?: Suspension | null; | |
| Cache hits are counted, not only their bytes | 132 | }; |
| 133 | ||
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 134 | /** Where an account's two-factor authentication stands. */ |
| 135 | export type TwoFactorStatus = { | |
| 136 | enabled: boolean; | |
| 137 | /** RFC 3339. */ | |
| 138 | enabled_at: string | null; | |
| 139 | /** Recovery codes not used yet. */ | |
| 140 | recovery_codes_left: number; | |
| 141 | /** The workspaces the person belongs to that require it. */ | |
| 142 | required_by: string[]; | |
| 143 | }; | |
| 144 | ||
| 145 | /** What an authenticator app needs: the secret in base32, and the same as an `otpauth://` address for a QR code. */ | |
| 146 | export type TwoFactorSetup = { secret: string; uri: string }; | |
| 147 | ||
| 148 | /** How many recovery codes an account gets. */ | |
| 149 | export const RECOVERY_CODES = 10; | |
| 150 | ||
| Cache hits are counted, not only their bytes | 151 | export interface AccountsApi { |
| 152 | /** The person's own addresses. People only, never an agent's or a workspace's token. */ | |
| 153 | listEmails(user: User): Promise<Result<AccountEmails>>; | |
| 154 | /** Adds an address and emails it a confirmation link. Needs `reauth`. */ | |
| 155 | addEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>; | |
| 156 | /** Removes an address; never the primary nor the last confirmed one. Needs `reauth`. */ | |
| 157 | removeEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 158 | /** Sends a new confirmation code and link, at most once a minute; the ones before stop working. */ |
| Cache hits are counted, not only their bytes | 159 | resendEmailVerification(user: User, email: string): Promise<Result<boolean>>; |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 160 | /** |
| 161 | * The code from a confirmation email, typed by the signed-in person it was | |
| 162 | * sent to. Wrong codes are counted against the account and `client`. | |
| 163 | */ | |
| 164 | confirmEmailCode(user: User, code: string, client?: string | null): Promise<Result<EmailConfirmed>>; | |
| 165 | /** | |
| 166 | * For an account with no confirmed address: replaces the address it signed | |
| 167 | * up with, and sends a new code and link there. | |
| 168 | */ | |
| 169 | changePendingEmail(user: User, email: string): Promise<Result<AccountEmails>>; | |
| Cache hits are counted, not only their bytes | 170 | /** Primary and backup need `reauth`; the privacy switches do not. */ |
| 171 | updateEmailSettings(user: User, settings: EmailSettings, reauth: Reauth): Promise<Result<AccountEmails>>; | |
| 172 | /** The person typed their password again for this session. */ | |
| 173 | reauthenticate(sessionToken: string, password: string, client?: string | null): Promise<Result<boolean>>; | |
| 174 | /** The newest entries of the person's security log. */ | |
| 175 | securityLog(user: User): Promise<Result<SecurityEvent[]>>; | |
| 176 | /** Whose commits these are, by author address: confirmed and noreply addresses only. */ | |
| 177 | emailOwners(emails: string[]): Promise<Record<string, EmailOwner>>; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 178 | /** Whether two-factor authentication is on, and which workspaces require it. */ |
| 179 | twoFactorStatus(user: User): Promise<Result<TwoFactorStatus>>; | |
| 180 | /** Begins turning it on: a new secret for the app. Needs `reauth`. */ | |
| 181 | twoFactorStart(user: User, reauth: Reauth): Promise<Result<TwoFactorSetup>>; | |
| 182 | /** A code from the app confirms it; returns the recovery codes, shown once. Needs `reauth`. */ | |
| 183 | twoFactorEnable(user: User, code: string, reauth: Reauth): Promise<Result<{ codes: string[] }>>; | |
| 184 | /** Turns it off with a code (or a recovery code). Needs `reauth`. */ | |
| 185 | twoFactorDisable(user: User, code: string, reauth: Reauth): Promise<Result<boolean>>; | |
| 186 | /** New recovery codes, replacing the old ones. Needs `reauth`. */ | |
| 187 | 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) | 188 | /** What deleting the person's own account would take, and what stands in the way, changing nothing. People only. */ |
| 189 | checkAccountDeletion(user: User): Promise<Result<AccountDeletion>>; | |
| 190 | /** | |
| 191 | * Deletes the person's own account. `confirm` is their username, typed | |
| 192 | * out; needs `reauth`. Refused for a protected account and while they are | |
| 193 | * the only owner of a live workspace. Kept `ACCOUNT_RESTORE_DAYS` for | |
| 194 | * staff to restore. Publishes `user.deleting`. Not offered by the API. | |
| 195 | */ | |
| 196 | deleteAccount(user: User, confirm: string, reauth: Reauth): Promise<Result<boolean>>; | |
| Cache hits are counted, not only their bytes | 197 | } |
| 198 | ||
| 199 | /** Staff only, for sudo.g1t.sh. */ | |
| 200 | export interface AccountsAdminApi { | |
| 201 | user(username: string): Promise<AdminUser | null>; | |
| 202 | /** Removes an address with a reason the person sees; never the last confirmed one. */ | |
| 203 | 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) | 204 | /** |
| 205 | * 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 | 206 | * for a protected account, and while it is the only owner of a live |
| 207 | * workspace unless `withSoleWorkspaces`: then each of those is deleted | |
| 208 | * first, as its owner would, and the account last. Refused whole while any | |
| 209 | * of them is protected or its billing cannot settle; a workspace failing | |
| 210 | * on the way stops it before the account. Recorded in sudo's audit log | |
| 211 | * (`workspace_deleted` for each, `account_deleted`). | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 212 | */ |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 213 | deleteAccount( |
| 214 | username: string, | |
| 215 | reason: string, | |
| 216 | confirm: string, | |
| 217 | staff: string, | |
| 218 | withSoleWorkspaces?: boolean, | |
| 219 | ): Promise<Result<boolean>>; | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 220 | /** Deleted accounts not purged yet, newest first. */ |
| 221 | deletedAccounts(): Promise<DeletedAccount[]>; | |
| 222 | /** Brings a deleted account back within its window, with the memberships it left. Publishes `user.restored`. */ | |
| 223 | restoreAccount(userId: string, staff: string): Promise<Result<boolean>>; | |
| 224 | /** Purges a deleted account now; `confirm` is its username. Publishes `user.deleted`. */ | |
| 225 | purgeAccount(userId: string, staff: string, confirm: string): Promise<Result<boolean>>; | |
| Cache hits are counted, not only their bytes | 226 | } |
| 227 | ||
| 228 | async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| 229 | const response = await service.fetch(`https://service/rpc/${method}`, { | |
| 230 | method: "POST", | |
| 231 | headers: { "content-type": "application/json" }, | |
| 232 | body: JSON.stringify(args), | |
| 233 | }); | |
| 234 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); | |
| 235 | return (await response.json()) as T; | |
| 236 | } | |
| 237 | ||
| 238 | export function accountsClient(identity: ServiceBinding): AccountsApi { | |
| 239 | return { | |
| 240 | listEmails: (user) => call(identity, "list_emails", { user }), | |
| 241 | addEmail: (user, email, reauth) => call(identity, "add_email", { user, email, reauth }), | |
| 242 | removeEmail: (user, email, reauth) => call(identity, "remove_email", { user, email, reauth }), | |
| 243 | resendEmailVerification: (user, email) => call(identity, "resend_email_verification", { user, email }), | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 244 | confirmEmailCode: (user, code, client) => call(identity, "confirm_email_code", { user, code, client: client ?? null }), |
| 245 | changePendingEmail: (user, email) => call(identity, "change_pending_email", { user, email }), | |
| Cache hits are counted, not only their bytes | 246 | updateEmailSettings: (user, settings, reauth) => call(identity, "update_email_settings", { user, ...settings, reauth }), |
| 247 | reauthenticate: (sessionToken, password, client) => call(identity, "reauthenticate", { sessionToken, password, client: client ?? null }), | |
| 248 | securityLog: (user) => call(identity, "security_log", { user }), | |
| 249 | emailOwners: (emails) => call(identity, "email_owners", { emails }), | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 250 | twoFactorStatus: (user) => call(identity, "two_factor_status", { user }), |
| 251 | twoFactorStart: (user, reauth) => call(identity, "two_factor_start", { user, reauth }), | |
| 252 | twoFactorEnable: (user, code, reauth) => call(identity, "two_factor_enable", { user, code, reauth }), | |
| 253 | twoFactorDisable: (user, code, reauth) => call(identity, "two_factor_disable", { user, code, reauth }), | |
| 254 | 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) | 255 | checkAccountDeletion: (user) => call(identity, "check_account_deletion", { user }), |
| 256 | deleteAccount: (user, confirm, reauth) => call(identity, "delete_account", { user, confirm, reauth }), | |
| Cache hits are counted, not only their bytes | 257 | }; |
| 258 | } | |
| 259 | ||
| 260 | export function accountsAdminClient(identity: ServiceBinding): AccountsAdminApi { | |
| 261 | return { | |
| 262 | user: (username) => call(identity, "admin_user", { username }), | |
| 263 | 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 | 264 | deleteAccount: (username, reason, confirm, staff, withSoleWorkspaces) => |
| 265 | 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) | 266 | deletedAccounts: () => call(identity, "admin_deleted_accounts", {}), |
| 267 | restoreAccount: (userId, staff) => call(identity, "admin_restore_account", { userId, staff }), | |
| 268 | purgeAccount: (userId, staff, confirm) => call(identity, "admin_purge_account", { userId, staff, confirm }), | |
| Cache hits are counted, not only their bytes | 269 | }; |
| 270 | } | |
| 271 | ||
| 272 | /** Words for a security log entry, as the person reads it. */ | |
| 273 | export function securityEventLabel(event: Pick<SecurityEvent, "kind" | "detail">): string { | |
| 274 | const detail = event.detail ?? ""; | |
| 275 | switch (event.kind) { | |
| 276 | case "email_added": | |
| 277 | return `Added ${detail}`; | |
| 278 | case "email_verified": | |
| 279 | return `Confirmed ${detail}`; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 280 | case "email_changed_before_confirming": |
| 281 | return `Changed the address to confirm to ${detail}`; | |
| Cache hits are counted, not only their bytes | 282 | case "email_removed": |
| 283 | return `Removed ${detail}`; | |
| 284 | case "primary_email_changed": | |
| 285 | return `Made ${detail} primary`; | |
| 286 | case "backup_email_changed": | |
| 287 | return detail === "primary only" ? "Security notices go to the primary only" : `Made ${detail} the backup`; | |
| 288 | case "email_privacy_changed": | |
| 289 | return `Email privacy: ${detail}`; | |
| 290 | case "password_changed": | |
| 291 | return "Changed the password"; | |
| 292 | case "password_locked": | |
| 293 | return `Password sign-in paused after ${detail}`; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 294 | case "two_factor_enabled": |
| 295 | return "Turned on two-factor authentication"; | |
| 296 | case "two_factor_disabled": | |
| 297 | return "Turned off two-factor authentication"; | |
| 298 | case "recovery_codes_regenerated": | |
| 299 | return "Made new recovery codes"; | |
| 300 | case "recovery_code_used": | |
| 301 | return "Signed in with a recovery code"; | |
| 302 | case "token_created": | |
| 303 | return `Created access token ${detail}`; | |
| 304 | case "token_deleted": | |
| 305 | return `Deleted access token ${detail}`; | |
| 306 | case "token_rescoped": | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 307 | return `Changed the permissions of access token ${detail}`; |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 308 | case "ssh_key_added": |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 309 | return `Added SSH key ${detail}`; |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 310 | case "ssh_key_removed": |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 311 | return `Removed SSH key ${detail}`; |
| 312 | case "oauth_grant_created": | |
| 313 | return `Authorized ${detail}`; | |
| 314 | case "oauth_grant_revoked": | |
| 315 | return `Revoked ${detail}`; | |
| 316 | case "oauth_grant_rescoped": | |
| 317 | return `Changed what ${detail} may do`; | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 318 | case "account_deleted": |
| 319 | return "Deleted the account"; | |
| 320 | case "account_restored": | |
| 321 | return "Restored the account"; | |
| Cache hits are counted, not only their bytes | 322 | default: |
| 323 | return detail ? `${event.kind}: ${detail}` : event.kind; | |
| 324 | } | |
| 325 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.