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
336 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.

Docs: inbox threads, reasons, subscriptions, watching, email, API and MCP1/**
2 * Reading sudo's forms. Everything typed is checked here before it goes
3 * to the billing service, which checks it again.
4 */
5import type { Allowances, SalesStage, Terms } from "@g1t/contracts";
6
7import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts";
8import { isStage } from "./signals.ts";
9
10/** A workspace slug, as identity allows them (GitHub's rules). */
11const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
12/** An account id (`ws_<slug>`, `ent_…`) or a workspace slug. */
13const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/;
14
15/** The most one credit can be, against a slipped finger. */
16export const MAX_CREDIT_MICROS = 10_000 * MICROS_PER_DOLLAR;
17const MAX_NOTE = 500;
18
19export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
20
21export function text(form: FormData, name: string): string {
22 const value = form.get(name);
23 return typeof value === "string" ? value.trim() : "";
24}
25
26/** The form's own fields, to carry through a confirmation step. */
27export function fields(form: FormData, ...names: string[]): Record<string, string> {
28 return Object.fromEntries(names.map((name) => [name, text(form, name)]));
29}
30
31export function isSlug(value: string): boolean {
32 return SLUG.test(value);
33}
34
35export function isAccountId(value: string): boolean {
36 return ACCOUNT_ID.test(value);
37}
38
39export function parseSlug(raw: string): Parsed<string> {
40 const slug = raw.trim().toLowerCase();
41 return isSlug(slug) ? { ok: true, value: slug } : { ok: false, error: `“${raw}” is not a workspace slug.` };
42}
43
44/** Slugs separated by commas, spaces or lines; each once. */
45export function parseSlugList(raw: string): Parsed<string[]> {
46 const slugs: string[] = [];
47 for (const part of raw.split(/[\s,]+/).filter(Boolean)) {
48 const slug = parseSlug(part);
49 if (!slug.ok) return slug;
50 if (!slugs.includes(slug.value)) slugs.push(slug.value);
51 }
52 return { ok: true, value: slugs };
53}
54
55export function parseNote(raw: string): Parsed<string> {
56 if (!raw) return { ok: false, error: "A note is required: say why, for whoever looks next." };
57 if (raw.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` };
58 return { ok: true, value: raw };
59}
60
61/**
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging62 * Terms from the terms form: a discount (a preset, 0, 25, 50 or 100%, or
63 * a custom whole percent), a limit, an end date and why. No discount and no
64 * limit is standard, and clears everything else; anything else is custom
65 * terms. A 100% discount charges nothing (what was "comped"), and its limit
66 * is g1t's monthly budget for it, at cost. An end date is a day, and the
67 * terms last to its end, UTC.
Docs: inbox threads, reasons, subscriptions, watching, email, API and MCP68 */
69export function parseTerms(form: FormData, by: string, now = new Date()): Parsed<Terms> {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging70 // A form from before discounts said `kind=comped`.
71 const preset = text(form, "kind") === "comped" ? "100" : text(form, "preset") || "0";
72 let discountPercent: number;
73 if (preset === "custom") {
Docs: inbox threads, reasons, subscriptions, watching, email, API and MCP74 const raw = text(form, "discount");
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging75 if (!/^\d{1,3}$/.test(raw) || Number(raw) > 100) return { ok: false, error: "The discount is a whole percent from 0 to 100." };
76 discountPercent = Number(raw);
77 } else if (/^\d{1,3}$/.test(preset) && Number(preset) <= 100) {
78 discountPercent = Number(preset);
79 } else {
80 return { ok: false, error: "Choose a discount: 0, 25, 50 or 100%, or Custom." };
Docs: inbox threads, reasons, subscriptions, watching, email, API and MCP81 }
82
83 let ceilingMicros: number | null = null;
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging84 const rawCeiling = text(form, "ceiling");
85 if (rawCeiling !== "") {
86 const micros = parseDollars(rawCeiling);
87 if (micros == null) return { ok: false, error: "The limit is a dollar amount, such as 250 or 1,000.00." };
88 ceilingMicros = micros;
Docs: inbox threads, reasons, subscriptions, watching, email, API and MCP89 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging90 const kind: Terms["kind"] = discountPercent > 0 || ceilingMicros != null ? "custom" : "standard";
91 const note = parseNote(text(form, "note"));
92 if (!note.ok) return note;
Docs: inbox threads, reasons, subscriptions, watching, email, API and MCP93
94 let until: string | null = null;
95 if (kind !== "standard") {
96 const raw = text(form, "until");
97 if (raw !== "") {
98 const end = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T23:59:59Z`) : null;
99 if (!end || Number.isNaN(end.getTime()) || end.toISOString().slice(0, 10) !== raw) {
100 return { ok: false, error: "The end date is not a date." };
101 }
102 if (end.getTime() <= now.getTime()) return { ok: false, error: "The end date has to be in the future." };
103 until = end.toISOString().replace(".000Z", "Z");
104 }
105 }
106
107 return {
108 ok: true,
109 value: { kind, discountPercent, ceilingMicros, note: note.value, until, setBy: by, setAt: now.toISOString() },
110 };
111}
112
113/** An email address for invoices: one address, lowercased. */
114export function parseEmail(raw: string): Parsed<string> {
115 const email = raw.trim().toLowerCase();
116 const [local, domain, ...rest] = email.split("@");
117 const ok =
118 rest.length === 0 &&
119 email.length <= 254 &&
120 !!local &&
121 !!domain &&
122 /^[^\s@,;<>"]+$/.test(local) &&
123 /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain);
124 return ok ? { ok: true, value: email } : { ok: false, error: "Enter one email address, such as billing@acme.com." };
125}
126
127export type SalesUpdate = { stage: SalesStage; owner: string | null; nextStep: string | null; nextAt: string | null };
128
129const MAX_NEXT_STEP = 200;
130const MAX_SALES_NOTE = 2000;
131
132/**
133 * A workspace's sales record from its form. The owner is a staff member's
134 * email, or nobody; a next step may have a date (a day, UTC) or not.
135 */
136export function parseSales(form: FormData): Parsed<SalesUpdate> {
137 const stage = text(form, "stage");
138 if (!isStage(stage)) return { ok: false, error: "Choose a stage." };
139
140 let owner: string | null = null;
141 const rawOwner = text(form, "owner");
142 if (rawOwner !== "") {
143 const email = parseEmail(rawOwner);
144 if (!email.ok) return { ok: false, error: "The owner is a staff member's email, such as you@g1t.sh, or blank for nobody." };
145 owner = email.value;
146 }
147
148 const nextStep = text(form, "nextStep").replace(/\s+/g, " ");
149 if (nextStep.length > MAX_NEXT_STEP) return { ok: false, error: `Keep the next step under ${MAX_NEXT_STEP} characters.` };
150
151 let nextAt: string | null = null;
152 const rawDate = text(form, "nextAt");
153 if (rawDate !== "") {
154 const day = /^\d{4}-\d{2}-\d{2}$/.test(rawDate) ? new Date(`${rawDate}T00:00:00Z`) : null;
155 if (!day || Number.isNaN(day.getTime()) || day.toISOString().slice(0, 10) !== rawDate) {
156 return { ok: false, error: "The follow-up date is not a date." };
157 }
158 nextAt = rawDate;
159 }
160 if (nextAt && !nextStep) return { ok: false, error: "Say what the next step is, as well as when." };
161
162 return { ok: true, value: { stage, owner, nextStep: nextStep || null, nextAt } };
163}
164
165/** A note on a workspace's sales record. */
166export function parseSalesNote(raw: string): Parsed<string> {
167 if (!raw) return { ok: false, error: "Write the note first." };
168 if (raw.length > MAX_SALES_NOTE) return { ok: false, error: `Keep a note under ${MAX_SALES_NOTE} characters.` };
169 return { ok: true, value: raw };
170}
171
172/** A credit's amount: more than nothing, and no more than the cap. */
173export function parseCredit(raw: string): Parsed<number> {
174 const micros = parseDollars(raw);
175 if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 25 or 120.50." };
176 if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "One credit is at most $10,000. Issue more than one if it really is more." };
177 return { ok: true, value: micros };
178}
179
180/** The most staff can set an account's share of a pool to, against a slipped finger. */
181export const MAX_POOL_SHARE_MICROS = 1_000 * MICROS_PER_DOLLAR;
182/**
183 * The most days of audit log staff can give one account: billing's
184 * AUDIT_MAX_DAYS. The events service deletes anything older for everyone.
185 */
186export const MAX_AUDIT_DAYS = 400;
187const MAX_HOLD = 200;
188
189/**
190 * Allowances from the plan-and-pools form: the g1t plan without its price,
191 * the account's share of the open-source pool and of trials, and staff's
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.192 * override of the days of audit log kept. A blank amount means the default
193 * (for the audit log, the plan's). A hold is a line saying why new compute
194 * is held; blank is no hold. The agent overrides (agents at once, the run
195 * and issue caps) are retired with g1t's own agents: they are always sent
196 * cleared.
Docs: inbox threads, reasons, subscriptions, watching, email, API and MCP197 */
198export function parseAllowances(form: FormData): Parsed<Allowances> {
199 const amount = (name: string, what: string, max: number, maxText: string, min = 0): Parsed<number | null> => {
200 const raw = text(form, name);
201 if (!raw) return { ok: true, value: null };
202 const micros = parseDollars(raw);
203 if (micros == null) return { ok: false, error: `${what} is dollars and cents, such as 5 or 2.50, or blank for the default.` };
204 if (micros < min) return { ok: false, error: `${what} is at least $${(min / MICROS_PER_DOLLAR).toFixed(2)}, or blank for the default.` };
205 if (micros > max) return { ok: false, error: `${what} is at most ${maxText}.` };
206 return { ok: true, value: micros };
207 };
208 const oss = amount("oss", "The open-source share", MAX_POOL_SHARE_MICROS, "$1,000");
209 if (!oss.ok) return oss;
210 const trial = amount("trial", "The trial credit", MAX_POOL_SHARE_MICROS, "$1,000");
211 if (!trial.ok) return trial;
212 let auditRetentionDays: number | null = null;
213 const rawAudit = text(form, "auditDays");
214 if (rawAudit) {
215 if (!/^\d{1,3}$/.test(rawAudit) || Number(rawAudit) < 1 || Number(rawAudit) > MAX_AUDIT_DAYS) {
216 return { ok: false, error: `Audit log days is a whole number from 1 to ${MAX_AUDIT_DAYS}, or blank for the plan's.` };
217 }
218 auditRetentionDays = Number(rawAudit);
219 }
220
221 const hold = text(form, "hold").replace(/\s+/g, " ");
222 if (hold.length > MAX_HOLD) return { ok: false, error: `Keep the hold's reason under ${MAX_HOLD} characters.` };
223
224 return {
225 ok: true,
226 value: {
227 plan: text(form, "plan") === "on",
228 ossRepoMicros: oss.value,
229 trialMicros: trial.value,
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.230 maxConcurrentAgents: null,
Docs: inbox threads, reasons, subscriptions, watching, email, API and MCP231 auditRetentionDays,
232 hold: hold || null,
233 },
234 };
235}
236
237/** The most one recorded payment can be; billing refuses more. */
238export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR;
239const MAX_REFERENCE = 100;
240
241export type PaymentInput = { amountMicros: number; reference: string; note: string };
242
243/**
244 * A bank transfer that reached g1t outside Stripe's page, from its form:
245 * the amount, the transfer's reference (each is recorded once), a note,
246 * and the workspace's slug typed out to confirm.
247 */
248export function parsePayment(form: FormData, workspace: string): Parsed<PaymentInput> {
249 const amountMicros = parseDollars(text(form, "amount"));
250 if (amountMicros == null || amountMicros <= 0) {
251 return { ok: false, error: "The amount is dollars and cents, more than zero, such as 1,500 or 2400.50." };
252 }
253 if (amountMicros > MAX_PAYMENT_MICROS) {
254 return { ok: false, error: "One payment is at most $100,000. Record a larger transfer in parts, each with its own reference." };
255 }
256 const reference = text(form, "reference").replace(/\s+/g, " ");
257 if (!reference) return { ok: false, error: "Give the transfer's reference, as the bank shows it, so it is recorded once." };
258 if (reference.length > MAX_REFERENCE) return { ok: false, error: `Keep the reference under ${MAX_REFERENCE} characters.` };
259 const note = parseNote(text(form, "note"));
260 if (!note.ok) return note;
261 if (text(form, "confirmation") !== workspace) {
262 return { ok: false, error: `Type the workspace's slug, ${workspace}, exactly, to record the payment.` };
263 }
264 return { ok: true, value: { amountMicros, reference, note: note.value } };
265}
266
267/** The most staff can approve on a request; billing refuses more. */
268export const MAX_REQUEST_MICROS = 1_000_000 * MICROS_PER_DOLLAR;
269
270export type Decision = { id: string; decision: "approve" | "decline"; amountMicros: number | null; note: string };
271
272/**
273 * A decision on a limit or overage request. Approve takes the amount asked
274 * (blank) or another; decline needs a note, which the owner reads.
275 */
276export function parseDecision(form: FormData): Parsed<Decision> {
277 const id = text(form, "id");
278 if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: "That is not a request." };
279 const decision = text(form, "decision");
280 if (decision !== "approve" && decision !== "decline") return { ok: false, error: "Approve or decline." };
281 const note = text(form, "note");
282 if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` };
283 if (decision === "decline") {
284 if (!note) return { ok: false, error: "Say why, for the owner: they read it in the app and by email." };
285 return { ok: true, value: { id, decision, amountMicros: null, note } };
286 }
287 let amountMicros: number | null = null;
288 const raw = text(form, "amount");
289 if (raw) {
290 const micros = parseDollars(raw);
291 if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "The amount is dollars and cents, at least $1, such as 750." };
292 if (micros > MAX_REQUEST_MICROS) return { ok: false, error: "Approve at most $1,000,000." };
293 amountMicros = micros;
294 }
295 return { ok: true, value: { id, decision, amountMicros, note } };
296}
297
298export type GoodwillInput = { amountMicros: number | null; reason: string; day: string | null };
299
300/** A typed reason is a sentence: billing asks for at least this many characters. */
301export const MIN_REASON = 10;
302
303/**
304 * A goodwill credit from its form. No amount is the one-click credit. A
305 * reason is required when `needsReason` says so (more than the one-click
306 * credit, or a second within 12 months). The day is when the accidental
307 * usage happened; blank lets billing choose it.
308 */
309export function parseGoodwill(
310 form: FormData,
311 needsReason: (amountMicros: number | null) => boolean,
312 now = new Date(),
313): Parsed<GoodwillInput> {
314 let amountMicros: number | null = null;
315 const raw = text(form, "amount");
316 if (raw) {
317 const micros = parseDollars(raw);
318 if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 40 or 12.50." };
319 if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "A goodwill credit is at most $10,000." };
320 amountMicros = micros;
321 }
322 const reason = text(form, "reason").replace(/\s+/g, " ");
323 if (reason.length > MAX_NOTE) return { ok: false, error: `Keep the reason under ${MAX_NOTE} characters.` };
324 if (needsReason(amountMicros) && reason.length < MIN_REASON) {
325 return { ok: false, error: "This credit needs a reason: say why, in a sentence, for whoever looks next." };
326 }
327 let day: string | null = null;
328 const rawDay = text(form, "day");
329 if (rawDay) {
330 const date = /^\d{4}-\d{2}-\d{2}$/.test(rawDay) ? new Date(`${rawDay}T00:00:00Z`) : null;
331 if (!date || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== rawDay) return { ok: false, error: "The day is not a date." };
332 if (date.getTime() > now.getTime()) return { ok: false, error: "The day of the usage cannot be in the future." };
333 day = rawDay;
334 }
335 return { ok: true, value: { amountMicros, reason, day } };
336}

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