Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
325 linesCodeBlameRaw

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 bytes1/**
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)5import type { AccountDeletion, DeletedAccount } from "./account-deletion";
Cache hits are counted, not only their bytes6import 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.7import type { Suspension, User } from "./identity";
Cache hits are counted, not only their bytes8import type { Result } from "./result";
9
10/** The most addresses one account may have, confirmed or not. */
11export const MAX_EMAILS = 10;
12/** How long after signing in sensitive changes need no password, in seconds. */
13export const RECENT_AUTH_SECONDS = 10 * 60;
14/** The domain of each person's private commit address. */
15export 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. */
17export const CONFIRM_CODE_DIGITS = 6;
18/** How long a confirmation email's code and link work, in seconds. */
19export 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 */
25export 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`). */
31export 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 bytes44
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 */
50export type Reauth = { sessionToken?: string | null; password?: string | null; client?: string | null };
51
52/** One of a person's addresses. */
53export 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
66export 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. */
81export 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
90export 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-a16631325aecf58ca100 | "ssh_key_added"
101 | "ssh_key_removed"
Cache hits are counted, not only their bytes102 | (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. */
113export type EmailOwner = { id: string; username: string; avatar: string | null };
114
115/** One account's addresses and security log, as staff see them. */
116export 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 bytes132};
133
Merge main (membership, two-factor, GitHub repo roles) into tokens134/** Where an account's two-factor authentication stands. */
135export 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. */
146export type TwoFactorSetup = { secret: string; uri: string };
147
148/** How many recovery codes an account gets. */
149export const RECOVERY_CODES = 10;
150
Cache hits are counted, not only their bytes151export 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 bytes159 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 bytes170 /** 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 tokens178 /** 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 bytes197}
198
199/** Staff only, for sudo.g1t.sh. */
200export 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 each206 * 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 each213 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 bytes226}
227
228async 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
238export 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 bytes246 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 tokens250 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 bytes257 };
258}
259
260export 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 each264 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 bytes269 };
270}
271
272/** Words for a security log entry, as the person reads it. */
273export 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 bytes282 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 tokens294 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 managers307 return `Changed the permissions of access token ${detail}`;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca308 case "ssh_key_added":
Merge main (membership, two-factor, GitHub repo roles) into tokens309 return `Added SSH key ${detail}`;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca310 case "ssh_key_removed":
Merge main (membership, two-factor, GitHub repo roles) into tokens311 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 bytes322 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.