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/apps/sudo/app/lib/forms.ts

342 lines15,268 bytesCodeBlame
1/**
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/**
62 * Terms from the terms form. Standard clears everything else; a ceiling
63 * applies to comped and custom; a discount to custom only. An end date is
64 * a day, and the terms last to its end, UTC.
65 */
66export function parseTerms(form: FormData, by: string, now = new Date()): Parsed<Terms> {
67 const kind = text(form, "kind");
68 if (kind !== "standard" && kind !== "comped" && kind !== "custom") return { ok: false, error: "Choose standard, comped or custom terms." };
69 const note = parseNote(text(form, "note"));
70 if (!note.ok) return note;
71
72 let discountPercent = 0;
73 if (kind === "custom") {
74 const raw = text(form, "discount");
75 if (raw !== "") {
76 if (!/^\d{1,3}$/.test(raw) || Number(raw) > 100) return { ok: false, error: "The discount is a whole percent from 0 to 100." };
77 discountPercent = Number(raw);
78 }
79 }
80
81 let ceilingMicros: number | null = null;
82 if (kind !== "standard") {
83 const raw = text(form, "ceiling");
84 if (raw !== "") {
85 const micros = parseDollars(raw);
86 if (micros == null) return { ok: false, error: "The ceiling is a dollar amount, such as 250 or 1,000.00." };
87 ceilingMicros = micros;
88 }
89 }
90 if (kind === "custom" && discountPercent === 0 && ceilingMicros == null) {
91 return { ok: false, error: "Custom terms need a discount, a ceiling, or both." };
92 }
93
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/** The most agents at once staff can allow one account. */
183export const MAX_AGENTS_AT_ONCE = 100;
184/** Staff's overrides of the owners' caps: one run, and one issue's agents in all. */
185export const MAX_RUN_CAP_MICROS = 1_000 * MICROS_PER_DOLLAR;
186export const MAX_ISSUE_CAP_MICROS = 10_000 * MICROS_PER_DOLLAR;
187/** The smallest cap: ten cents, as owners may set. */
188const MIN_CAP_MICROS = 100_000;
189const MAX_HOLD = 200;
190
191/**
192 * Allowances from the plan-and-pools form: the g1t plan without its price,
193 * the account's share of the open-source pool and of trials, and staff's
194 * overrides of agents at once, the run cap and the issue cap. A blank
195 * amount means the default (for a cap, no override). A hold is a line
196 * saying why new compute is held; blank is no hold.
197 */
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 const runCap = amount("runCap", "The run cap", MAX_RUN_CAP_MICROS, "$1,000", MIN_CAP_MICROS);
213 if (!runCap.ok) return runCap;
214 const issueCap = amount("issueCap", "The issue cap", MAX_ISSUE_CAP_MICROS, "$10,000", MIN_CAP_MICROS);
215 if (!issueCap.ok) return issueCap;
216
217 let maxConcurrentAgents: number | null = null;
218 const rawAgents = text(form, "agents");
219 if (rawAgents) {
220 if (!/^\d{1,3}$/.test(rawAgents) || Number(rawAgents) < 1 || Number(rawAgents) > MAX_AGENTS_AT_ONCE) {
221 return { ok: false, error: `Agents at once is a whole number from 1 to ${MAX_AGENTS_AT_ONCE}, or blank for the default.` };
222 }
223 maxConcurrentAgents = Number(rawAgents);
224 }
225
226 const hold = text(form, "hold").replace(/\s+/g, " ");
227 if (hold.length > MAX_HOLD) return { ok: false, error: `Keep the hold's reason under ${MAX_HOLD} characters.` };
228
229 return {
230 ok: true,
231 value: {
232 plan: text(form, "plan") === "on",
233 ossRepoMicros: oss.value,
234 trialMicros: trial.value,
235 maxConcurrentAgents,
236 runCapMicros: runCap.value,
237 issueCapMicros: issueCap.value,
238 hold: hold || null,
239 },
240 };
241}
242
243/** The most one recorded payment can be; billing refuses more. */
244export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR;
245const MAX_REFERENCE = 100;
246
247export type PaymentInput = { amountMicros: number; reference: string; note: string };
248
249/**
250 * A bank transfer that reached g1t outside Stripe's page, from its form:
251 * the amount, the transfer's reference (each is recorded once), a note,
252 * and the workspace's slug typed out to confirm.
253 */
254export function parsePayment(form: FormData, workspace: string): Parsed<PaymentInput> {
255 const amountMicros = parseDollars(text(form, "amount"));
256 if (amountMicros == null || amountMicros <= 0) {
257 return { ok: false, error: "The amount is dollars and cents, more than zero, such as 1,500 or 2400.50." };
258 }
259 if (amountMicros > MAX_PAYMENT_MICROS) {
260 return { ok: false, error: "One payment is at most $100,000. Record a larger transfer in parts, each with its own reference." };
261 }
262 const reference = text(form, "reference").replace(/\s+/g, " ");
263 if (!reference) return { ok: false, error: "Give the transfer's reference, as the bank shows it, so it is recorded once." };
264 if (reference.length > MAX_REFERENCE) return { ok: false, error: `Keep the reference under ${MAX_REFERENCE} characters.` };
265 const note = parseNote(text(form, "note"));
266 if (!note.ok) return note;
267 if (text(form, "confirmation") !== workspace) {
268 return { ok: false, error: `Type the workspace's slug, ${workspace}, exactly, to record the payment.` };
269 }
270 return { ok: true, value: { amountMicros, reference, note: note.value } };
271}
272
273/** The most staff can approve on a request; billing refuses more. */
274export const MAX_REQUEST_MICROS = 1_000_000 * MICROS_PER_DOLLAR;
275
276export type Decision = { id: string; decision: "approve" | "decline"; amountMicros: number | null; note: string };
277
278/**
279 * A decision on a limit or overage request. Approve takes the amount asked
280 * (blank) or another; decline needs a note, which the owner reads.
281 */
282export function parseDecision(form: FormData): Parsed<Decision> {
283 const id = text(form, "id");
284 if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: "That is not a request." };
285 const decision = text(form, "decision");
286 if (decision !== "approve" && decision !== "decline") return { ok: false, error: "Approve or decline." };
287 const note = text(form, "note");
288 if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` };
289 if (decision === "decline") {
290 if (!note) return { ok: false, error: "Say why, for the owner: they read it in the app and by email." };
291 return { ok: true, value: { id, decision, amountMicros: null, note } };
292 }
293 let amountMicros: number | null = null;
294 const raw = text(form, "amount");
295 if (raw) {
296 const micros = parseDollars(raw);
297 if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "The amount is dollars and cents, at least $1, such as 750." };
298 if (micros > MAX_REQUEST_MICROS) return { ok: false, error: "Approve at most $1,000,000." };
299 amountMicros = micros;
300 }
301 return { ok: true, value: { id, decision, amountMicros, note } };
302}
303
304export type GoodwillInput = { amountMicros: number | null; reason: string; day: string | null };
305
306/** A typed reason is a sentence: billing asks for at least this many characters. */
307export const MIN_REASON = 10;
308
309/**
310 * A goodwill credit from its form. No amount is the one-click credit. A
311 * reason is required when `needsReason` says so (more than the one-click
312 * credit, or a second within 12 months). The day is when the accidental
313 * usage happened; blank lets billing choose it.
314 */
315export function parseGoodwill(
316 form: FormData,
317 needsReason: (amountMicros: number | null) => boolean,
318 now = new Date(),
319): Parsed<GoodwillInput> {
320 let amountMicros: number | null = null;
321 const raw = text(form, "amount");
322 if (raw) {
323 const micros = parseDollars(raw);
324 if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 40 or 12.50." };
325 if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "A goodwill credit is at most $10,000." };
326 amountMicros = micros;
327 }
328 const reason = text(form, "reason").replace(/\s+/g, " ");
329 if (reason.length > MAX_NOTE) return { ok: false, error: `Keep the reason under ${MAX_NOTE} characters.` };
330 if (needsReason(amountMicros) && reason.length < MIN_REASON) {
331 return { ok: false, error: "This credit needs a reason: say why, in a sentence, for whoever looks next." };
332 }
333 let day: string | null = null;
334 const rawDay = text(form, "day");
335 if (rawDay) {
336 const date = /^\d{4}-\d{2}-\d{2}$/.test(rawDay) ? new Date(`${rawDay}T00:00:00Z`) : null;
337 if (!date || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== rawDay) return { ok: false, error: "The day is not a date." };
338 if (date.getTime() > now.getTime()) return { ok: false, error: "The day of the usage cannot be in the future." };
339 day = rawDay;
340 }
341 return { ok: true, value: { amountMicros, reason, day } };
342}