Skip to content
338 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.

Project settings say who created the project, by name1/**
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.
Project settings say who created the project, by name68 */
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") {
Project settings say who created the project, by name74 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." };
Project settings say who created the project, by name81 }
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;
Project settings say who created the project, by name89 }
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;
Project settings say who created the project, by name93
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.
Project settings say who created the project, by name197 */
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,
231 runCapMicros: null,
232 issueCapMicros: null,
Project settings say who created the project, by name233 auditRetentionDays,
234 hold: hold || null,
235 },
236 };
237}
238
239/** The most one recorded payment can be; billing refuses more. */
240export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR;
241const MAX_REFERENCE = 100;
242
243export type PaymentInput = { amountMicros: number; reference: string; note: string };
244
245/**
246 * A bank transfer that reached g1t outside Stripe's page, from its form:
247 * the amount, the transfer's reference (each is recorded once), a note,
248 * and the workspace's slug typed out to confirm.
249 */
250export function parsePayment(form: FormData, workspace: string): Parsed<PaymentInput> {
251 const amountMicros = parseDollars(text(form, "amount"));
252 if (amountMicros == null || amountMicros <= 0) {
253 return { ok: false, error: "The amount is dollars and cents, more than zero, such as 1,500 or 2400.50." };
254 }
255 if (amountMicros > MAX_PAYMENT_MICROS) {
256 return { ok: false, error: "One payment is at most $100,000. Record a larger transfer in parts, each with its own reference." };
257 }
258 const reference = text(form, "reference").replace(/\s+/g, " ");
259 if (!reference) return { ok: false, error: "Give the transfer's reference, as the bank shows it, so it is recorded once." };
260 if (reference.length > MAX_REFERENCE) return { ok: false, error: `Keep the reference under ${MAX_REFERENCE} characters.` };
261 const note = parseNote(text(form, "note"));
262 if (!note.ok) return note;
263 if (text(form, "confirmation") !== workspace) {
264 return { ok: false, error: `Type the workspace's slug, ${workspace}, exactly, to record the payment.` };
265 }
266 return { ok: true, value: { amountMicros, reference, note: note.value } };
267}
268
269/** The most staff can approve on a request; billing refuses more. */
270export const MAX_REQUEST_MICROS = 1_000_000 * MICROS_PER_DOLLAR;
271
272export type Decision = { id: string; decision: "approve" | "decline"; amountMicros: number | null; note: string };
273
274/**
275 * A decision on a limit or overage request. Approve takes the amount asked
276 * (blank) or another; decline needs a note, which the owner reads.
277 */
278export function parseDecision(form: FormData): Parsed<Decision> {
279 const id = text(form, "id");
280 if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: "That is not a request." };
281 const decision = text(form, "decision");
282 if (decision !== "approve" && decision !== "decline") return { ok: false, error: "Approve or decline." };
283 const note = text(form, "note");
284 if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` };
285 if (decision === "decline") {
286 if (!note) return { ok: false, error: "Say why, for the owner: they read it in the app and by email." };
287 return { ok: true, value: { id, decision, amountMicros: null, note } };
288 }
289 let amountMicros: number | null = null;
290 const raw = text(form, "amount");
291 if (raw) {
292 const micros = parseDollars(raw);
293 if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "The amount is dollars and cents, at least $1, such as 750." };
294 if (micros > MAX_REQUEST_MICROS) return { ok: false, error: "Approve at most $1,000,000." };
295 amountMicros = micros;
296 }
297 return { ok: true, value: { id, decision, amountMicros, note } };
298}
299
300export type GoodwillInput = { amountMicros: number | null; reason: string; day: string | null };
301
302/** A typed reason is a sentence: billing asks for at least this many characters. */
303export const MIN_REASON = 10;
304
305/**
306 * A goodwill credit from its form. No amount is the one-click credit. A
307 * reason is required when `needsReason` says so (more than the one-click
308 * credit, or a second within 12 months). The day is when the accidental
309 * usage happened; blank lets billing choose it.
310 */
311export function parseGoodwill(
312 form: FormData,
313 needsReason: (amountMicros: number | null) => boolean,
314 now = new Date(),
315): Parsed<GoodwillInput> {
316 let amountMicros: number | null = null;
317 const raw = text(form, "amount");
318 if (raw) {
319 const micros = parseDollars(raw);
320 if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 40 or 12.50." };
321 if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "A goodwill credit is at most $10,000." };
322 amountMicros = micros;
323 }
324 const reason = text(form, "reason").replace(/\s+/g, " ");
325 if (reason.length > MAX_NOTE) return { ok: false, error: `Keep the reason under ${MAX_NOTE} characters.` };
326 if (needsReason(amountMicros) && reason.length < MIN_REASON) {
327 return { ok: false, error: "This credit needs a reason: say why, in a sentence, for whoever looks next." };
328 }
329 let day: string | null = null;
330 const rawDay = text(form, "day");
331 if (rawDay) {
332 const date = /^\d{4}-\d{2}-\d{2}$/.test(rawDay) ? new Date(`${rawDay}T00:00:00Z`) : null;
333 if (!date || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== rawDay) return { ok: false, error: "The day is not a date." };
334 if (date.getTime() > now.getTime()) return { ok: false, error: "The day of the usage cannot be in the future." };
335 day = rawDay;
336 }
337 return { ok: true, value: { amountMicros, reason, day } };
338}

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