Skip to content

g1t/packages/contracts/src/accounts.ts

230 lines9,846 bytesCodeBlame

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 */
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
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 */
21export type Reauth = { sessionToken?: string | null; password?: string | null; client?: string | null };
22
23/** One of a person's addresses. */
24export 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
37export 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. */
52export 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
61export 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. */
82export type EmailOwner = { id: string; username: string; avatar: string | null };
83
84/** One account's addresses and security log, as staff see them. */
85export 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
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA95/** Where an account's two-factor authentication stands. */
96export type TwoFactorStatus = {
97 enabled: boolean;
98 /** RFC 3339. */
99 enabled_at: string | null;
100 /** Recovery codes not used yet. */
101 recovery_codes_left: number;
102 /** The workspaces the person belongs to that require it. */
103 required_by: string[];
104};
105
106/** What an authenticator app needs: the secret in base32, and the same as an `otpauth://` address for a QR code. */
107export type TwoFactorSetup = { secret: string; uri: string };
108
109/** How many recovery codes an account gets. */
110export const RECOVERY_CODES = 10;
111
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look112export interface AccountsApi {
113 /** The person's own addresses. People only, never an agent's or a workspace's token. */
114 listEmails(user: User): Promise<Result<AccountEmails>>;
115 /** Adds an address and emails it a confirmation link. Needs `reauth`. */
116 addEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>;
117 /** Removes an address; never the primary nor the last confirmed one. Needs `reauth`. */
118 removeEmail(user: User, email: string, reauth: Reauth): Promise<Result<AccountEmails>>;
119 /** Sends a confirmation link again, at most once a minute. */
120 resendEmailVerification(user: User, email: string): Promise<Result<boolean>>;
121 /** Primary and backup need `reauth`; the privacy switches do not. */
122 updateEmailSettings(user: User, settings: EmailSettings, reauth: Reauth): Promise<Result<AccountEmails>>;
123 /** The person typed their password again for this session. */
124 reauthenticate(sessionToken: string, password: string, client?: string | null): Promise<Result<boolean>>;
125 /** The newest entries of the person's security log. */
126 securityLog(user: User): Promise<Result<SecurityEvent[]>>;
127 /** Whose commits these are, by author address: confirmed and noreply addresses only. */
128 emailOwners(emails: string[]): Promise<Record<string, EmailOwner>>;
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA129 /** Whether two-factor authentication is on, and which workspaces require it. */
130 twoFactorStatus(user: User): Promise<Result<TwoFactorStatus>>;
131 /** Begins turning it on: a new secret for the app. Needs `reauth`. */
132 twoFactorStart(user: User, reauth: Reauth): Promise<Result<TwoFactorSetup>>;
133 /** A code from the app confirms it; returns the recovery codes, shown once. Needs `reauth`. */
134 twoFactorEnable(user: User, code: string, reauth: Reauth): Promise<Result<{ codes: string[] }>>;
135 /** Turns it off with a code (or a recovery code). Needs `reauth`. */
136 twoFactorDisable(user: User, code: string, reauth: Reauth): Promise<Result<boolean>>;
137 /** New recovery codes, replacing the old ones. Needs `reauth`. */
138 twoFactorRecoveryCodes(user: User, reauth: Reauth): Promise<Result<{ codes: string[] }>>;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look139}
140
141/** Staff only, for sudo.g1t.sh. */
142export interface AccountsAdminApi {
143 user(username: string): Promise<AdminUser | null>;
144 /** Removes an address with a reason the person sees; never the last confirmed one. */
145 removeEmail(username: string, email: string, reason: string, staff: string): Promise<Result<AdminUser>>;
146}
147
148async function call<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
149 const response = await service.fetch(`https://service/rpc/${method}`, {
150 method: "POST",
151 headers: { "content-type": "application/json" },
152 body: JSON.stringify(args),
153 });
154 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
155 return (await response.json()) as T;
156}
157
158export function accountsClient(identity: ServiceBinding): AccountsApi {
159 return {
160 listEmails: (user) => call(identity, "list_emails", { user }),
161 addEmail: (user, email, reauth) => call(identity, "add_email", { user, email, reauth }),
162 removeEmail: (user, email, reauth) => call(identity, "remove_email", { user, email, reauth }),
163 resendEmailVerification: (user, email) => call(identity, "resend_email_verification", { user, email }),
164 updateEmailSettings: (user, settings, reauth) => call(identity, "update_email_settings", { user, ...settings, reauth }),
165 reauthenticate: (sessionToken, password, client) => call(identity, "reauthenticate", { sessionToken, password, client: client ?? null }),
166 securityLog: (user) => call(identity, "security_log", { user }),
167 emailOwners: (emails) => call(identity, "email_owners", { emails }),
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA168 twoFactorStatus: (user) => call(identity, "two_factor_status", { user }),
169 twoFactorStart: (user, reauth) => call(identity, "two_factor_start", { user, reauth }),
170 twoFactorEnable: (user, code, reauth) => call(identity, "two_factor_enable", { user, code, reauth }),
171 twoFactorDisable: (user, code, reauth) => call(identity, "two_factor_disable", { user, code, reauth }),
172 twoFactorRecoveryCodes: (user, reauth) => call(identity, "two_factor_recovery_codes", { user, reauth }),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look173 };
174}
175
176export function accountsAdminClient(identity: ServiceBinding): AccountsAdminApi {
177 return {
178 user: (username) => call(identity, "admin_user", { username }),
179 removeEmail: (username, email, reason, staff) => call(identity, "admin_remove_email", { username, email, reason, staff }),
180 };
181}
182
183/** Words for a security log entry, as the person reads it. */
184export function securityEventLabel(event: Pick<SecurityEvent, "kind" | "detail">): string {
185 const detail = event.detail ?? "";
186 switch (event.kind) {
187 case "email_added":
188 return `Added ${detail}`;
189 case "email_verified":
190 return `Confirmed ${detail}`;
191 case "email_removed":
192 return `Removed ${detail}`;
193 case "primary_email_changed":
194 return `Made ${detail} primary`;
195 case "backup_email_changed":
196 return detail === "primary only" ? "Security notices go to the primary only" : `Made ${detail} the backup`;
197 case "email_privacy_changed":
198 return `Email privacy: ${detail}`;
199 case "password_changed":
200 return "Changed the password";
201 case "password_locked":
202 return `Password sign-in paused after ${detail}`;
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA203 case "two_factor_enabled":
204 return "Turned on two-factor authentication";
205 case "two_factor_disabled":
206 return "Turned off two-factor authentication";
207 case "recovery_codes_regenerated":
208 return "Made new recovery codes";
209 case "recovery_code_used":
210 return "Signed in with a recovery code";
211 case "token_created":
212 return `Created access token ${detail}`;
213 case "token_deleted":
214 return `Deleted access token ${detail}`;
215 case "token_rescoped":
216 return `Changed the scopes of access token ${detail}`;
217 case "ssh_key_added":
218 return `Added SSH key ${detail}`;
219 case "ssh_key_removed":
220 return `Removed SSH key ${detail}`;
221 case "oauth_grant_created":
222 return `Authorized ${detail}`;
223 case "oauth_grant_revoked":
224 return `Revoked ${detail}`;
225 case "oauth_grant_rescoped":
226 return `Changed what ${detail} may do`;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look227 default:
228 return detail ? `${event.kind}: ${detail}` : event.kind;
229 }
230}

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