g1t/packages/contracts/src/accounts.ts
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 | */ | |
| 5 | import type { ServiceBinding } from "./clients"; | |
| 6 | import type { User } from "./identity"; | |
| 7 | import type { Result } from "./result"; | |
| 8 | ||
| 9 | /** The most addresses one account may have, confirmed or not. */ | |
| 10 | export const MAX_EMAILS = 10; | |
| 11 | /** How long after signing in sensitive changes need no password, in seconds. */ | |
| 12 | export const RECENT_AUTH_SECONDS = 10 * 60; | |
| 13 | /** The domain of each person's private commit address. */ | |
| 14 | export const NOREPLY_DOMAIN = "users.noreply.g1t.sh"; | |
| 15 | ||
| 16 | /** | |
| 17 | * Proof that the person making a sensitive change is the account's owner: | |
| 18 | * the session they signed in to within `RECENT_AUTH_SECONDS`, or their | |
| 19 | * password. Without it the answer is `reauth_required`. | |
| 20 | */ | |
| 21 | export type Reauth = { sessionToken?: string | null; password?: string | null; client?: string | null }; | |
| 22 | ||
| 23 | /** One of a person's addresses. */ | |
| 24 | export type AccountEmail = { | |
| 25 | /** As typed when it was added. */ | |
| 26 | email: string; | |
| 27 | verified: boolean; | |
| 28 | primary: boolean; | |
| 29 | /** Gets security notices as well as the primary. */ | |
| 30 | backup: boolean; | |
| 31 | /** RFC 3339. */ | |
| 32 | createdAt: string; | |
| 33 | /** RFC 3339. */ | |
| 34 | verifiedAt: string | null; | |
| 35 | }; | |
| 36 | ||
| 37 | export type AccountEmails = { | |
| 38 | /** The primary first, then confirmed addresses, then the rest. */ | |
| 39 | emails: AccountEmail[]; | |
| 40 | /** Commits g1t makes for the person use `noreply`. */ | |
| 41 | privateEmail: boolean; | |
| 42 | /** Refuse pushes whose commits carry one of the person's addresses. */ | |
| 43 | blockPrivatePushes: boolean; | |
| 44 | /** `<id suffix>+<username>@users.noreply.g1t.sh`. */ | |
| 45 | noreply: string; | |
| 46 | /** The address commits g1t makes for the person carry now. */ | |
| 47 | commitEmail: string; | |
| 48 | limit: number; | |
| 49 | }; | |
| 50 | ||
| 51 | /** What `updateEmailSettings` can change; each field given is changed. */ | |
| 52 | export type EmailSettings = { | |
| 53 | /** A confirmed address to make primary. */ | |
| 54 | primary?: string; | |
| 55 | /** A confirmed address for security notices too, or "" for the primary only. */ | |
| 56 | backup?: string; | |
| 57 | privateEmail?: boolean; | |
| 58 | blockPrivatePushes?: boolean; | |
| 59 | }; | |
| 60 | ||
| 61 | export type SecurityEvent = { | |
| 62 | kind: | |
| 63 | | "email_added" | |
| 64 | | "email_verified" | |
| 65 | | "email_removed" | |
| 66 | | "primary_email_changed" | |
| 67 | | "backup_email_changed" | |
| 68 | | "email_privacy_changed" | |
| 69 | | "password_changed" | |
| 70 | | "password_locked" | |
| 71 | | (string & {}); | |
| 72 | detail: string | null; | |
| 73 | byStaff: boolean; | |
| 74 | reason: string | null; | |
| 75 | /** The staff member; only in staff views. */ | |
| 76 | staff?: string | null; | |
| 77 | /** RFC 3339. */ | |
| 78 | createdAt: string; | |
| 79 | }; | |
| 80 | ||
| 81 | /** The account a commit's author address belongs to. */ | |
| 82 | export type EmailOwner = { id: string; username: string; avatar: string | null }; | |
| 83 | ||
| 84 | /** One account's addresses and security log, as staff see them. */ | |
| 85 | export type AdminUser = { | |
| 86 | id: string; | |
| 87 | username: string; | |
| 88 | /** RFC 3339. */ | |
| 89 | createdAt: string; | |
| 90 | emails: AccountEmail[]; | |
| 91 | privateEmail: boolean; | |
| 92 | log: SecurityEvent[]; | |
| 93 | }; | |
| 94 | ||
| 95 | export interface AccountsApi { | |
| 96 | /** The person's own addresses. People only, never an agent's or a workspace's token. */ | |
| 97 | listEmails(user: User): Promise<Result<AccountEmails>>; | |
| 98 | /** Adds an address and emails it a confirmation link. Needs `reauth`. */ | |
| 99 | addEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>; | |
| 100 | /** Removes an address; never the primary nor the last confirmed one. Needs `reauth`. */ | |
| 101 | removeEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>; | |
| 102 | /** Sends a confirmation link again, at most once a minute. */ | |
| 103 | resendEmailVerification(user: User, email: string): Promise<Result<boolean>>; | |
| 104 | /** Primary and backup need `reauth`; the privacy switches do not. */ | |
| 105 | updateEmailSettings(user: User, settings: EmailSettings, reauth: Reauth): Promise<Result<AccountEmails>>; | |
| 106 | /** The person typed their password again for this session. */ | |
| 107 | reauthenticate(sessionToken: string, password: string, client?: string | null): Promise<Result<boolean>>; | |
| 108 | /** The newest entries of the person's security log. */ | |
| 109 | securityLog(user: User): Promise<Result<SecurityEvent[]>>; | |
| 110 | /** Whose commits these are, by author address: confirmed and noreply addresses only. */ | |
| 111 | emailOwners(emails: string[]): Promise<Record<string, EmailOwner>>; | |
| 112 | } | |
| 113 | ||
| 114 | /** Staff only, for sudo.g1t.sh. */ | |
| 115 | export interface AccountsAdminApi { | |
| 116 | user(username: string): Promise<AdminUser | null>; | |
| 117 | /** Removes an address with a reason the person sees; never the last confirmed one. */ | |
| 118 | removeEmail(username: string, email: string, reason: string, staff: string): Promise<Result<AdminUser>>; | |
| 119 | } | |
| 120 | ||
| 121 | async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| 122 | const response = await service.fetch(`https://service/rpc/${method}`, { | |
| 123 | method: "POST", | |
| 124 | headers: { "content-type": "application/json" }, | |
| 125 | body: JSON.stringify(args), | |
| 126 | }); | |
| 127 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); | |
| 128 | return (await response.json()) as T; | |
| 129 | } | |
| 130 | ||
| 131 | export function accountsClient(identity: ServiceBinding): AccountsApi { | |
| 132 | return { | |
| 133 | listEmails: (user) => call(identity, "list_emails", { user }), | |
| 134 | addEmail: (user, email, reauth) => call(identity, "add_email", { user, email, reauth }), | |
| 135 | removeEmail: (user, email, reauth) => call(identity, "remove_email", { user, email, reauth }), | |
| 136 | resendEmailVerification: (user, email) => call(identity, "resend_email_verification", { user, email }), | |
| 137 | updateEmailSettings: (user, settings, reauth) => call(identity, "update_email_settings", { user, ...settings, reauth }), | |
| 138 | reauthenticate: (sessionToken, password, client) => call(identity, "reauthenticate", { sessionToken, password, client: client ?? null }), | |
| 139 | securityLog: (user) => call(identity, "security_log", { user }), | |
| 140 | emailOwners: (emails) => call(identity, "email_owners", { emails }), | |
| 141 | }; | |
| 142 | } | |
| 143 | ||
| 144 | export function accountsAdminClient(identity: ServiceBinding): AccountsAdminApi { | |
| 145 | return { | |
| 146 | user: (username) => call(identity, "admin_user", { username }), | |
| 147 | removeEmail: (username, email, reason, staff) => call(identity, "admin_remove_email", { username, email, reason, staff }), | |
| 148 | }; | |
| 149 | } | |
| 150 | ||
| 151 | /** Words for a security log entry, as the person reads it. */ | |
| 152 | export function securityEventLabel(event: Pick<SecurityEvent, "kind" | "detail">): string { | |
| 153 | const detail = event.detail ?? ""; | |
| 154 | switch (event.kind) { | |
| 155 | case "email_added": | |
| 156 | return `Added ${detail}`; | |
| 157 | case "email_verified": | |
| 158 | return `Confirmed ${detail}`; | |
| 159 | case "email_removed": | |
| 160 | return `Removed ${detail}`; | |
| 161 | case "primary_email_changed": | |
| 162 | return `Made ${detail} primary`; | |
| 163 | case "backup_email_changed": | |
| 164 | return detail === "primary only" ? "Security notices go to the primary only" : `Made ${detail} the backup`; | |
| 165 | case "email_privacy_changed": | |
| 166 | return `Email privacy: ${detail}`; | |
| 167 | case "password_changed": | |
| 168 | return "Changed the password"; | |
| 169 | case "password_locked": | |
| 170 | return `Password sign-in paused after ${detail}`; | |
| 171 | default: | |
| 172 | return detail ? `${event.kind}: ${detail}` : event.kind; | |
| 173 | } | |
| 174 | } |