Skip to content
272 linesCodeBlameRaw
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 */
5import type { ServiceBinding } from "./clients";
6import type { User } from "./identity";
7import type { Result } from "./result";
8
9/** The most addresses one account may have, confirmed or not. */
10export const MAX_EMAILS = 10;
11/** How long after signing in sensitive changes need no password, in seconds. */
12export const RECENT_AUTH_SECONDS = 10 * 60;
13/** The domain of each person's private commit address. */
14export const NOREPLY_DOMAIN = "users.noreply.g1t.sh";
15/** How many digits the code in a confirmation email has. */
16export const CONFIRM_CODE_DIGITS = 6;
17/** How long a confirmation email's code and link work, in seconds. */
18export const CONFIRM_TTL_SECONDS = 60 * 60;
19
20/**
21 * A confirmation code as typed or pasted, with spaces and hyphens taken
22 * out; null unless that leaves exactly six digits.
23 */
24export function tidyConfirmCode(code: string): string | null {
25 const digits = code.replace(/[\s-]/g, "");
26 return /^\d{6}$/.test(digits) ? digits : null;
27}
28
29/** What confirming an address did: by its link (`verifyEmail`) or its code (`confirmEmailCode`). */
30export type EmailConfirmed = {
31 username: string;
32 /** The address confirmed, as typed when it was added. */
33 email: string;
34 /** Whether the account is confirmed now: whether its primary is. */
35 verified: boolean;
36 /** The workspace the account's invite joined it to, by slug. */
37 joined?: string | null;
38 /** Why the invite the account signed up with no longer applies; the address is confirmed all the same. */
39 inviteLapsed?: string | null;
40};
41
42/**
43 * Proof that the person making a sensitive change is the account's owner:
44 * the session they signed in to within `RECENT_AUTH_SECONDS`, or their
45 * password. Without it the answer is `reauth_required`.
46 */
47export type Reauth = { sessionToken?: string | null; password?: string | null; client?: string | null };
48
49/** One of a person's addresses. */
50export type AccountEmail = {
51 /** As typed when it was added. */
52 email: string;
53 verified: boolean;
54 primary: boolean;
55 /** Gets security notices as well as the primary. */
56 backup: boolean;
57 /** RFC 3339. */
58 createdAt: string;
59 /** RFC 3339. */
60 verifiedAt: string | null;
61};
62
63export type AccountEmails = {
64 /** The primary first, then confirmed addresses, then the rest. */
65 emails: AccountEmail[];
66 /** Commits g1t makes for the person use `noreply`. */
67 privateEmail: boolean;
68 /** Refuse pushes whose commits carry one of the person's addresses. */
69 blockPrivatePushes: boolean;
70 /** `<id suffix>+<username>@users.noreply.g1t.sh`. */
71 noreply: string;
72 /** The address commits g1t makes for the person carry now. */
73 commitEmail: string;
74 limit: number;
75};
76
77/** What `updateEmailSettings` can change; each field given is changed. */
78export type EmailSettings = {
79 /** A confirmed address to make primary. */
80 primary?: string;
81 /** A confirmed address for security notices too, or "" for the primary only. */
82 backup?: string;
83 privateEmail?: boolean;
84 blockPrivatePushes?: boolean;
85};
86
87export type SecurityEvent = {
88 kind:
89 | "email_added"
90 | "email_verified"
91 | "email_removed"
92 | "primary_email_changed"
93 | "backup_email_changed"
94 | "email_privacy_changed"
95 | "password_changed"
96 | "password_locked"
97 | "ssh_key_added"
98 | "ssh_key_removed"
99 | (string & {});
100 detail: string | null;
101 byStaff: boolean;
102 reason: string | null;
103 /** The staff member; only in staff views. */
104 staff?: string | null;
105 /** RFC 3339. */
106 createdAt: string;
107};
108
109/** The account a commit's author address belongs to. */
110export type EmailOwner = { id: string; username: string; avatar: string | null };
111
112/** One account's addresses and security log, as staff see them. */
113export type AdminUser = {
114 id: string;
115 username: string;
116 /** RFC 3339. */
117 createdAt: string;
118 emails: AccountEmail[];
119 privateEmail: boolean;
120 log: SecurityEvent[];
121};
122
123/** Where an account's two-factor authentication stands. */
124export type TwoFactorStatus = {
125 enabled: boolean;
126 /** RFC 3339. */
127 enabled_at: string | null;
128 /** Recovery codes not used yet. */
129 recovery_codes_left: number;
130 /** The workspaces the person belongs to that require it. */
131 required_by: string[];
132};
133
134/** What an authenticator app needs: the secret in base32, and the same as an `otpauth://` address for a QR code. */
135export type TwoFactorSetup = { secret: string; uri: string };
136
137/** How many recovery codes an account gets. */
138export const RECOVERY_CODES = 10;
139
140export interface AccountsApi {
141 /** The person's own addresses. People only, never an agent's or a workspace's token. */
142 listEmails(user: User): Promise<Result<AccountEmails>>;
143 /** Adds an address and emails it a confirmation link. Needs `reauth`. */
144 addEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>;
145 /** Removes an address; never the primary nor the last confirmed one. Needs `reauth`. */
146 removeEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>;
147 /** Sends a new confirmation code and link, at most once a minute; the ones before stop working. */
148 resendEmailVerification(user: User, email: string): Promise<Result<boolean>>;
149 /**
150 * The code from a confirmation email, typed by the signed-in person it was
151 * sent to. Wrong codes are counted against the account and `client`.
152 */
153 confirmEmailCode(user: User, code: string, client?: string | null): Promise<Result<EmailConfirmed>>;
154 /**
155 * For an account with no confirmed address: replaces the address it signed
156 * up with, and sends a new code and link there.
157 */
158 changePendingEmail(user: User, email: string): Promise<Result<AccountEmails>>;
159 /** Primary and backup need `reauth`; the privacy switches do not. */
160 updateEmailSettings(user: User, settings: EmailSettings, reauth: Reauth): Promise<Result<AccountEmails>>;
161 /** The person typed their password again for this session. */
162 reauthenticate(sessionToken: string, password: string, client?: string | null): Promise<Result<boolean>>;
163 /** The newest entries of the person's security log. */
164 securityLog(user: User): Promise<Result<SecurityEvent[]>>;
165 /** Whose commits these are, by author address: confirmed and noreply addresses only. */
166 emailOwners(emails: string[]): Promise<Record<string, EmailOwner>>;
167 /** Whether two-factor authentication is on, and which workspaces require it. */
168 twoFactorStatus(user: User): Promise<Result<TwoFactorStatus>>;
169 /** Begins turning it on: a new secret for the app. Needs `reauth`. */
170 twoFactorStart(user: User, reauth: Reauth): Promise<Result<TwoFactorSetup>>;
171 /** A code from the app confirms it; returns the recovery codes, shown once. Needs `reauth`. */
172 twoFactorEnable(user: User, code: string, reauth: Reauth): Promise<Result<{ codes: string[] }>>;
173 /** Turns it off with a code (or a recovery code). Needs `reauth`. */
174 twoFactorDisable(user: User, code: string, reauth: Reauth): Promise<Result<boolean>>;
175 /** New recovery codes, replacing the old ones. Needs `reauth`. */
176 twoFactorRecoveryCodes(user: User, reauth: Reauth): Promise<Result<{ codes: string[] }>>;
177}
178
179/** Staff only, for sudo.g1t.sh. */
180export interface AccountsAdminApi {
181 user(username: string): Promise<AdminUser | null>;
182 /** Removes an address with a reason the person sees; never the last confirmed one. */
183 removeEmail(username: string, email: string, reason: string, staff: string): Promise<Result<AdminUser>>;
184}
185
186async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
187 const response = await service.fetch(`https://service/rpc/${method}`, {
188 method: "POST",
189 headers: { "content-type": "application/json" },
190 body: JSON.stringify(args),
191 });
192 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
193 return (await response.json()) as T;
194}
195
196export function accountsClient(identity: ServiceBinding): AccountsApi {
197 return {
198 listEmails: (user) => call(identity, "list_emails", { user }),
199 addEmail: (user, email, reauth) => call(identity, "add_email", { user, email, reauth }),
200 removeEmail: (user, email, reauth) => call(identity, "remove_email", { user, email, reauth }),
201 resendEmailVerification: (user, email) => call(identity, "resend_email_verification", { user, email }),
202 confirmEmailCode: (user, code, client) => call(identity, "confirm_email_code", { user, code, client: client ?? null }),
203 changePendingEmail: (user, email) => call(identity, "change_pending_email", { user, email }),
204 updateEmailSettings: (user, settings, reauth) => call(identity, "update_email_settings", { user, ...settings, reauth }),
205 reauthenticate: (sessionToken, password, client) => call(identity, "reauthenticate", { sessionToken, password, client: client ?? null }),
206 securityLog: (user) => call(identity, "security_log", { user }),
207 emailOwners: (emails) => call(identity, "email_owners", { emails }),
208 twoFactorStatus: (user) => call(identity, "two_factor_status", { user }),
209 twoFactorStart: (user, reauth) => call(identity, "two_factor_start", { user, reauth }),
210 twoFactorEnable: (user, code, reauth) => call(identity, "two_factor_enable", { user, code, reauth }),
211 twoFactorDisable: (user, code, reauth) => call(identity, "two_factor_disable", { user, code, reauth }),
212 twoFactorRecoveryCodes: (user, reauth) => call(identity, "two_factor_recovery_codes", { user, reauth }),
213 };
214}
215
216export function accountsAdminClient(identity: ServiceBinding): AccountsAdminApi {
217 return {
218 user: (username) => call(identity, "admin_user", { username }),
219 removeEmail: (username, email, reason, staff) => call(identity, "admin_remove_email", { username, email, reason, staff }),
220 };
221}
222
223/** Words for a security log entry, as the person reads it. */
224export function securityEventLabel(event: Pick<SecurityEvent, "kind" | "detail">): string {
225 const detail = event.detail ?? "";
226 switch (event.kind) {
227 case "email_added":
228 return `Added ${detail}`;
229 case "email_verified":
230 return `Confirmed ${detail}`;
231 case "email_changed_before_confirming":
232 return `Changed the address to confirm to ${detail}`;
233 case "email_removed":
234 return `Removed ${detail}`;
235 case "primary_email_changed":
236 return `Made ${detail} primary`;
237 case "backup_email_changed":
238 return detail === "primary only" ? "Security notices go to the primary only" : `Made ${detail} the backup`;
239 case "email_privacy_changed":
240 return `Email privacy: ${detail}`;
241 case "password_changed":
242 return "Changed the password";
243 case "password_locked":
244 return `Password sign-in paused after ${detail}`;
245 case "two_factor_enabled":
246 return "Turned on two-factor authentication";
247 case "two_factor_disabled":
248 return "Turned off two-factor authentication";
249 case "recovery_codes_regenerated":
250 return "Made new recovery codes";
251 case "recovery_code_used":
252 return "Signed in with a recovery code";
253 case "token_created":
254 return `Created access token ${detail}`;
255 case "token_deleted":
256 return `Deleted access token ${detail}`;
257 case "token_rescoped":
258 return `Changed the scopes of access token ${detail}`;
259 case "ssh_key_added":
260 return `Added SSH key ${detail}`;
261 case "ssh_key_removed":
262 return `Removed SSH key ${detail}`;
263 case "oauth_grant_created":
264 return `Authorized ${detail}`;
265 case "oauth_grant_revoked":
266 return `Revoked ${detail}`;
267 case "oauth_grant_rescoped":
268 return `Changed what ${detail} may do`;
269 default:
270 return detail ? `${event.kind}: ${detail}` : event.kind;
271 }
272}