flagon-io/g1t

public

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/packages/contracts/src/identity.ts

369 lines14,748 bytesCodeBlame
1import type { Acting, CreateRunCredentialInput, RunBinding } from "./audit";
2import type { RepoPath } from "./repos";
3import type { Result } from "./result";
4
5export type User = {
6 id: string;
7 username: string;
8 /**
9 * `workspace` when a workspace is acting through one of its own access
10 * tokens: `id` is then the workspace's and `username` its slug. Absent
11 * means `user`.
12 */
13 kind?: "user" | "workspace" | "agent";
14 /**
15 * Whether the account's email address is confirmed. Only set on users
16 * resolved from credentials; unverified accounts cannot change anything.
17 */
18 verified?: boolean;
19 /**
20 * The workspaces this user belongs to. Set on users resolved from
21 * credentials, so any service can authorize from it.
22 */
23 workspaces?: Membership[];
24 /**
25 * The person's uploaded avatar: the SHA-256 of its bytes, served at
26 * `/avatars/<avatar>`. Only set on the signed-in person; absent means
27 * the generated letter avatar.
28 */
29 avatar?: string;
30 /**
31 * Set on an agent resolved from its token: who it acts for ("g1t-agent
32 * on behalf of syntaqx"), with which credential, and what it may do.
33 */
34 acting?: Acting;
35};
36
37/** What a member may do: an owner also manages the workspace's members. */
38export type Role = "owner" | "member";
39
40export type Membership = {
41 /** The workspace's name in URLs: `g1t.sh/<slug>`. */
42 slug: string;
43 role: Role;
44 /** Its display name. Set on users resolved from credentials. */
45 name?: string;
46 /** Its uploaded icon, as `Workspace.avatar`. */
47 avatar?: string;
48};
49
50/** How long an old workspace slug redirects, and stays reserved for it, after a rename. */
51export const SLUG_HOLD_DAYS = 90;
52
53/** How long a workspace must wait between renames. */
54export const RENAME_COOLDOWN_HOURS = 24;
55
56/** The largest avatar that can be uploaded, in bytes. */
57export const MAX_AVATAR_BYTES = 1024 * 1024;
58
59/**
60 * A workspace: the owner of repositories, and the first segment of their
61 * URLs. A person's own space and a team's are the same thing.
62 */
63export type Workspace = {
64 id: string;
65 slug: string;
66 name: string;
67 /** One line saying what the workspace is for. */
68 description: string | null;
69 /** RFC 3339. */
70 createdAt: string;
71 memberCount: number;
72 /**
73 * The workspace's uploaded icon: the SHA-256 of its bytes, served at
74 * `/avatars/<avatar>`. Null means the generated letter avatar.
75 */
76 avatar: string | null;
77};
78
79export type Member = { username: string; role: Role };
80
81/** An owner of a workspace, as staff see them. */
82export type AdminOwner = { username: string; email: string | null };
83
84/** A workspace as staff see it. Mirrors `AdminWorkspace` in `crates/contracts/src/identity.rs`. */
85export type AdminWorkspace = {
86 slug: string;
87 name: string;
88 /** RFC 3339. */
89 createdAt: string;
90 owners: AdminOwner[];
91 memberCount: number;
92};
93
94/** A member of a workspace, as staff see them. */
95export type AdminMember = { username: string; email: string | null; role: Role; /** RFC 3339. */ joined: string };
96
97export type AdminWorkspaceDetail = {
98 slug: string;
99 name: string;
100 description: string | null;
101 /** RFC 3339. */
102 createdAt: string;
103 /** Owners first, then by username. */
104 members: AdminMember[];
105};
106
107/** The most workspaces one `workspaces` call returns. */
108export const ADMIN_WORKSPACES_LIMIT = 500;
109
110/**
111 * Staff-only identity, for sudo.g1t.sh. It takes no viewer and checks no
112 * membership: only sudo calls it, over its service binding, once Cloudflare
113 * Access and its staff list have let someone in. Never call it on behalf of
114 * a customer.
115 */
116export interface IdentityAdminApi {
117 /** Every workspace, newest first, at most 500; `query` matches slug, name, or an owner's username or email. */
118 workspaces(query?: string): Promise<AdminWorkspace[]>;
119 /** One workspace with all its members, or null. */
120 workspace(slug: string): Promise<AdminWorkspaceDetail | null>;
121}
122
123/** Who is asking. Every read and write in every service takes one. */
124export type Viewer = User | null;
125
126export type SshKey = {
127 id: string;
128 title: string;
129 fingerprint: string;
130 /** RFC 3339. */
131 createdAt: string;
132};
133
134export type AccessToken = {
135 id: string;
136 name: string;
137 /** RFC 3339. */
138 createdAt: string;
139 /** RFC 3339, to within a few minutes. Null until it is first used. */
140 lastUsedAt: string | null;
141 /**
142 * For a workspace's token, the username of the member who made it. Null
143 * once that account is gone, and on personal tokens.
144 */
145 createdBy: string | null;
146};
147
148export type DeviceStart = {
149 /** Secret held by the tool and exchanged for a token once approved. */
150 deviceCode: string;
151 /** Short code shown to the person, e.g. `WDJB-MJHT`. */
152 userCode: string;
153 /** Seconds until both codes stop working. */
154 expiresIn: number;
155 /** Seconds the tool should wait between polls. */
156 interval: number;
157};
158
159export type DeviceRequest = { userCode: string; clientName: string };
160
161export type DeviceClaim =
162 | { status: "pending" | "denied" | "expired" }
163 | { status: "approved"; token: string; user: User };
164
165/** What the site passes on once a person has approved an application. */
166export type OAuthApproval = {
167 clientId: string;
168 /** Shown wherever the application's access is listed. */
169 clientName: string;
170 redirectUri: string;
171 /** PKCE challenge, method S256. */
172 codeChallenge: string;
173};
174
175export type OAuthTokens = {
176 accessToken: string;
177 /** Works once; using it returns the next one. */
178 refreshToken: string;
179 /** Seconds until the access token stops working. */
180 expiresIn: number;
181};
182
183/** An application a person has signed in to. */
184export type OAuthGrant = {
185 id: string;
186 clientName: string;
187 /** RFC 3339. */
188 createdAt: string;
189 /** RFC 3339. */
190 lastUsedAt: string;
191};
192
193/** Accounts, credentials and sessions. */
194export interface IdentityApi {
195 /** Creates an account and signs it in. */
196 register(username: string, email: string, password: string): Promise<Result<{ user: User; sessionToken: string }>>;
197 /** Verifies a username and password for website sign-in. */
198 signIn(username: string, password: string): Promise<Result<{ user: User; sessionToken: string }>>;
199 signOut(sessionToken: string): Promise<void>;
200
201 /** Sends the confirmation email again. */
202 resendVerification(user: User): Promise<Result<boolean>>;
203 /** Confirms the address the emailed token was sent to. */
204 verifyEmail(token: string): Promise<Result<User>>;
205 /** Emails a reset link if the address has an account. Always resolves. */
206 requestPasswordReset(email: string): Promise<boolean>;
207 /** Sets a new password from an emailed token and ends every session. */
208 resetPassword(token: string, password: string): Promise<Result<User>>;
209
210 /**
211 * Device sign-in (RFC 8628). A tool starts a request, a person approves
212 * its short code in a browser, and the tool claims an access token.
213 */
214 deviceStart(clientName: string): Promise<DeviceStart>;
215 /** What a user code is asking for, or null if it is not valid. */
216 deviceLookup(userCode: string): Promise<DeviceRequest | null>;
217 deviceResolve(userCode: string, user: User, approve: boolean): Promise<Result<boolean>>;
218 deviceClaim(deviceCode: string): Promise<DeviceClaim>;
219
220 /**
221 * OAuth 2.1 for applications that sign a person in through the browser.
222 * The caller has checked the client and its redirect address; this
223 * returns the one-time code the application exchanges for tokens.
224 */
225 oauthAuthorize(user: User, approval: OAuthApproval): Promise<{ code: string }>;
226 /** Redeems a code. It works once, for that client, with the PKCE verifier. */
227 oauthExchange(code: string, codeVerifier: string, clientId: string, redirectUri: string): Promise<Result<OAuthTokens>>;
228 /** Trades a refresh token for new tokens; the old ones stop working. */
229 oauthRefresh(refreshToken: string, clientId: string): Promise<Result<OAuthTokens>>;
230 /** Applications the user has signed in to, most recently used first. */
231 listOAuthGrants(user: User): Promise<OAuthGrant[]>;
232 /** Signs an application out. */
233 revokeOAuthGrant(user: User, id: string): Promise<void>;
234
235 createWorkspace(user: User, slug: string, name: string): Promise<Result<Workspace>>;
236 /** Public details of a workspace, or null. */
237 getWorkspace(slug: string): Promise<Workspace | null>;
238 /** Members only. */
239 listMembers(slug: string, viewer: Viewer): Promise<Result<Member[]>>;
240 /** Owners only. */
241 addMember(actor: User, slug: string, username: string): Promise<Result<boolean>>;
242 /** Owners only. */
243 removeMember(actor: User, slug: string, username: string): Promise<Result<boolean>>;
244 /** Owners only. An empty name falls back to the slug. */
245 updateWorkspace(actor: User, slug: string, details: { name: string; description: string }): Promise<Result<Workspace>>;
246 /**
247 * Owners only. Changes the slug, the first segment of the workspace's
248 * URLs; the display name is untouched. The old slug redirects to the new
249 * one, and stays reserved for this workspace, for `SLUG_HOLD_DAYS`.
250 * Publishes `workspace.renamed`.
251 */
252 renameWorkspace(actor: User, slug: string, newSlug: string): Promise<Result<Workspace>>;
253 /** Whether `renameWorkspace` would be allowed, changing nothing. */
254 checkWorkspaceRename(actor: User, slug: string, newSlug: string): Promise<Result<boolean>>;
255 /**
256 * The workspace's current slug when `slug` is one it was renamed from
257 * within `SLUG_HOLD_DAYS`; null otherwise, including for a slug in use.
258 */
259 resolveSlug(slug: string): Promise<string | null>;
260 /**
261 * Owners only. `image` is the file in base64: PNG, JPEG, WebP or GIF, at
262 * most `MAX_AVATAR_BYTES`, checked by its bytes. Null removes the icon.
263 */
264 setWorkspaceAvatar(actor: User, slug: string, image: string | null): Promise<Result<Workspace>>;
265 /** A person's own avatar, as `setWorkspaceAvatar`: the new one, or null. */
266 setUserAvatar(user: User, image: string | null): Promise<Result<string | null>>;
267
268 /**
269 * A workspace's own access tokens. They belong to the workspace, act as
270 * it, and keep working when the member who made one leaves. Members only.
271 */
272 listWorkspaceTokens(slug: string, viewer: Viewer): Promise<Result<AccessToken[]>>;
273 /** Owners only. The plaintext token is returned once and never stored. */
274 createWorkspaceToken(actor: User, slug: string, name: string): Promise<Result<{ token: string; info: AccessToken }>>;
275 /** Owners only. */
276 removeWorkspaceToken(actor: User, slug: string, id: string): Promise<Result<boolean>>;
277
278 userForSession(sessionToken: string): Promise<Viewer>;
279
280 /** Verifies git credentials: the account password or an access token. */
281 userForGitCredentials(username: string, secret: string): Promise<Viewer>;
282 /** Resolves a `g1t_…` access token, as sent to the API and MCP server. */
283 userForAccessToken(token: string): Promise<Viewer>;
284 userForSshKey(fingerprint: string): Promise<Viewer>;
285 userByUsername(username: string): Promise<Viewer>;
286 /** The names behind account and workspace ids; unknown ids are left out. */
287 usernames(ids: string[]): Promise<Record<string, string>>;
288
289 /** A person's public profile, or null if there is no such account. Never an email address. */
290 profile(username: string): Promise<Profile | null>;
291 /** A person changes their own profile. Every field is replaced; an empty one is cleared. */
292 updateProfile(actor: User, fields: ProfileFields): Promise<Result<Profile>>;
293 /**
294 * The workspaces a profile shows `viewer`: those the viewer belongs to
295 * as well, and those of `publicIn` (where the person made a public
296 * project) that the person really belongs to. Nothing else.
297 */
298 profileWorkspaces(username: string, viewer: Viewer, publicIn: string[]): Promise<ProfileWorkspace[]>;
299
300 listSshKeys(user: User): Promise<SshKey[]>;
301 /** Takes one line in OpenSSH public key format. */
302 addSshKey(user: User, title: string, publicKey: string): Promise<Result<SshKey>>;
303 removeSshKey(user: User, id: string): Promise<void>;
304
305 listAccessTokens(user: User): Promise<AccessToken[]>;
306 /**
307 * The plaintext token is returned once and never stored. With
308 * `ttlSeconds` the token expires and is left out of token lists; that
309 * form is used for hosted agents. A token made for a workspace acting
310 * through a token of its own belongs to that workspace too.
311 */
312 createAccessToken(user: User, name: string, ttlSeconds?: number): Promise<{ token: string; info: AccessToken }>;
313 /**
314 * A token for a g1t agent working for `onBehalfOf`: it acts as
315 * `g1t-agent`, in `scope.repo` only, and only for `scope.operations`.
316 */
317 createAgentToken(
318 onBehalfOf: User,
319 scope: AgentScope,
320 ttlSeconds: number,
321 ): Promise<{ token: string; info: AccessToken }>;
322 removeAccessToken(user: User, id: string): Promise<void>;
323 /**
324 * A token for one sandbox run: it acts as the agent on behalf of
325 * `onBehalfOf`, can do only what the run's kind needs in `repo`, and
326 * expires after `ttlSeconds`. See `audit.ts`.
327 */
328 createRunCredential(input: CreateRunCredentialInput): Promise<{ token: string; info: AccessToken }>;
329 /** Ties tokens, by the SHA-256 of their text in hex, to the agent run their sandbox recorded. */
330 bindRunCredentials(tokenHashes: string[], runId: string): Promise<boolean>;
331 /** Ends a sandbox's run credentials, by hash or by run. Never touches another token. */
332 revokeRunCredentials(target: { tokenHashes?: string[]; runId?: string | null }): Promise<boolean>;
333}
334
335
336/** What an agent's token may do: these operations, in this repository. */
337export type AgentScope = { repo: RepoPath; operations: string[]; run?: RunBinding };
338
339/** The most characters each profile field takes. Mirrors `crates/contracts/src/identity.rs`. */
340export const PROFILE_LIMITS = { name: 80, bio: 160, location: 80, website: 200, pronouns: 40 } as const;
341
342/** What anyone may see about a person, at `g1t.sh/u/<username>`. */
343export type Profile = {
344 username: string;
345 /** The name they go by, if they gave one. */
346 name: string | null;
347 bio: string | null;
348 location: string | null;
349 /** Always an `https://` address. */
350 website: string | null;
351 pronouns: string | null;
352 /** The uploaded avatar's hash, served at `/avatars/<avatar>`. */
353 avatar: string | null;
354 /** When the account was made. RFC 3339. */
355 createdAt: string;
356};
357
358/** What a person may change on their profile. Empty clears a field. */
359export type ProfileFields = {
360 name: string;
361 bio: string;
362 location: string;
363 /** `https://…`; a bare `example.com` is taken as `https://example.com`. */
364 website: string;
365 pronouns: string;
366};
367
368/** A workspace on a person's profile. */
369export type ProfileWorkspace = { slug: string; name: string; avatar: string | null };