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

Mission control: a place to ask g1t, shown and not on yet, with the actions under it1/**
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.
Mission control: a place to ask g1t, shown and not on yet, with the actions under it68 */
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") {
Mission control: a place to ask g1t, shown and not on yet, with the actions under it74 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." };
Mission control: a place to ask g1t, shown and not on yet, with the actions under it81 }
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;
Mission control: a place to ask g1t, shown and not on yet, with the actions under it89 }
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;
Mission control: a place to ask g1t, shown and not on yet, with the actions under it93
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
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.194 * is held; blank is no hold. A run's cap is not staff's to set here: it is
195 * always sent cleared, so the plan's applies. The overrides retired with
196 * g1t's own agents (agents at once, an issue's cap) control nothing and are
197 * not sent; billing keeps them empty.
Mission control: a place to ask g1t, shown and not on yet, with the actions under it198 */
199export function parseAllowances(form: FormData): Parsed<Allowances> {
200 const amount = (name: string, what: string, max: number, maxText: string, min = 0): Parsed<number | null> => {
201 const raw = text(form, name);
202 if (!raw) return { ok: true, value: null };
203 const micros = parseDollars(raw);
204 if (micros == null) return { ok: false, error: `${what} is dollars and cents, such as 5 or 2.50, or blank for the default.` };
205 if (micros < min) return { ok: false, error: `${what} is at least $${(min / MICROS_PER_DOLLAR).toFixed(2)}, or blank for the default.` };
206 if (micros > max) return { ok: false, error: `${what} is at most ${maxText}.` };
207 return { ok: true, value: micros };
208 };
209 const oss = amount("oss", "The open-source share", MAX_POOL_SHARE_MICROS, "$1,000");
210 if (!oss.ok) return oss;
211 const trial = amount("trial", "The trial credit", MAX_POOL_SHARE_MICROS, "$1,000");
212 if (!trial.ok) return trial;
213 let auditRetentionDays: number | null = null;
214 const rawAudit = text(form, "auditDays");
215 if (rawAudit) {
216 if (!/^\d{1,3}$/.test(rawAudit) || Number(rawAudit) < 1 || Number(rawAudit) > MAX_AUDIT_DAYS) {
217 return { ok: false, error: `Audit log days is a whole number from 1 to ${MAX_AUDIT_DAYS}, or blank for the plan's.` };
218 }
219 auditRetentionDays = Number(rawAudit);
220 }
221
222 const hold = text(form, "hold").replace(/\s+/g, " ");
223 if (hold.length > MAX_HOLD) return { ok: false, error: `Keep the hold's reason under ${MAX_HOLD} characters.` };
224
225 return {
226 ok: true,
227 value: {
228 plan: text(form, "plan") === "on",
229 ossRepoMicros: oss.value,
230 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.231 maxConcurrentAgents: null,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it232 auditRetentionDays,
233 hold: hold || null,
234 },
235 };
236}
237
238/** The most one recorded payment can be; billing refuses more. */
239export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR;
240const MAX_REFERENCE = 100;
241
242export type PaymentInput = { amountMicros: number; reference: string; note: string };
243
244/**
245 * A bank transfer that reached g1t outside Stripe's page, from its form:
246 * the amount, the transfer's reference (each is recorded once), a note,
247 * and the workspace's slug typed out to confirm.
248 */
249export function parsePayment(form: FormData, workspace: string): Parsed<PaymentInput> {
250 const amountMicros = parseDollars(text(form, "amount"));
251 if (amountMicros == null || amountMicros <= 0) {
252 return { ok: false, error: "The amount is dollars and cents, more than zero, such as 1,500 or 2400.50." };
253 }
254 if (amountMicros > MAX_PAYMENT_MICROS) {
255 return { ok: false, error: "One payment is at most $100,000. Record a larger transfer in parts, each with its own reference." };
256 }
257 const reference = text(form, "reference").replace(/\s+/g, " ");
258 if (!reference) return { ok: false, error: "Give the transfer's reference, as the bank shows it, so it is recorded once." };
259 if (reference.length > MAX_REFERENCE) return { ok: false, error: `Keep the reference under ${MAX_REFERENCE} characters.` };
260 const note = parseNote(text(form, "note"));
261 if (!note.ok) return note;
262 if (text(form, "confirmation") !== workspace) {
263 return { ok: false, error: `Type the workspace's slug, ${workspace}, exactly, to record the payment.` };
264 }
265 return { ok: true, value: { amountMicros, reference, note: note.value } };
266}
267
268/** The most staff can approve on a request; billing refuses more. */
269export const MAX_REQUEST_MICROS = 1_000_000 * MICROS_PER_DOLLAR;
270
271export type Decision = { id: string; decision: "approve" | "decline"; amountMicros: number | null; note: string };
272
273/**
274 * A decision on a limit or overage request. Approve takes the amount asked
275 * (blank) or another; decline needs a note, which the owner reads.
276 */
277export function parseDecision(form: FormData): Parsed<Decision> {
278 const id = text(form, "id");
279 if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: "That is not a request." };
280 const decision = text(form, "decision");
281 if (decision !== "approve" && decision !== "decline") return { ok: false, error: "Approve or decline." };
282 const note = text(form, "note");
283 if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` };
284 if (decision === "decline") {
285 if (!note) return { ok: false, error: "Say why, for the owner: they read it in the app and by email." };
286 return { ok: true, value: { id, decision, amountMicros: null, note } };
287 }
288 let amountMicros: number | null = null;
289 const raw = text(form, "amount");
290 if (raw) {
291 const micros = parseDollars(raw);
292 if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "The amount is dollars and cents, at least $1, such as 750." };
293 if (micros > MAX_REQUEST_MICROS) return { ok: false, error: "Approve at most $1,000,000." };
294 amountMicros = micros;
295 }
296 return { ok: true, value: { id, decision, amountMicros, note } };
297}
298
299export type GoodwillInput = { amountMicros: number | null; reason: string; day: string | null };
300
301/** A typed reason is a sentence: billing asks for at least this many characters. */
302export const MIN_REASON = 10;
303
304/**
305 * A goodwill credit from its form. No amount is the one-click credit. A
306 * reason is required when `needsReason` says so (more than the one-click
307 * credit, or a second within 12 months). The day is when the accidental
308 * usage happened; blank lets billing choose it.
309 */
310export function parseGoodwill(
311 form: FormData,
312 needsReason: (amountMicros: number | null) => boolean,
313 now = new Date(),
314): Parsed<GoodwillInput> {
315 let amountMicros: number | null = null;
316 const raw = text(form, "amount");
317 if (raw) {
318 const micros = parseDollars(raw);
319 if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 40 or 12.50." };
320 if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "A goodwill credit is at most $10,000." };
321 amountMicros = micros;
322 }
323 const reason = text(form, "reason").replace(/\s+/g, " ");
324 if (reason.length > MAX_NOTE) return { ok: false, error: `Keep the reason under ${MAX_NOTE} characters.` };
325 if (needsReason(amountMicros) && reason.length < MIN_REASON) {
326 return { ok: false, error: "This credit needs a reason: say why, in a sentence, for whoever looks next." };
327 }
328 let day: string | null = null;
329 const rawDay = text(form, "day");
330 if (rawDay) {
331 const date = /^\d{4}-\d{2}-\d{2}$/.test(rawDay) ? new Date(`${rawDay}T00:00:00Z`) : null;
332 if (!date || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== rawDay) return { ok: false, error: "The day is not a date." };
333 if (date.getTime() > now.getTime()) return { ok: false, error: "The day of the usage cannot be in the future." };
334 day = rawDay;
335 }
336 return { ok: true, value: { amountMicros, reason, day } };
337}

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