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 name | 1 | /** |
| 2 | * Reading sudo's forms. Everything typed is checked here before it goes | |
| 3 | * to the billing service, which checks it again. | |
| 4 | */ | |
| 5 | import type { Allowances, SalesStage, Terms } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts"; | |
| 8 | import { isStage } from "./signals.ts"; | |
| 9 | ||
| 10 | /** A workspace slug, as identity allows them (GitHub's rules). */ | |
| 11 | const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/; | |
| 12 | /** An account id (`ws_<slug>`, `ent_…`) or a workspace slug. */ | |
| 13 | const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/; | |
| 14 | ||
| 15 | /** The most one credit can be, against a slipped finger. */ | |
| 16 | export const MAX_CREDIT_MICROS = 10_000 * MICROS_PER_DOLLAR; | |
| 17 | const MAX_NOTE = 500; | |
| 18 | ||
| 19 | export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }; | |
| 20 | ||
| 21 | export 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. */ | |
| 27 | export function fields(form: FormData, ...names: string[]): Record<string, string> { | |
| 28 | return Object.fromEntries(names.map((name) => [name, text(form, name)])); | |
| 29 | } | |
| 30 | ||
| 31 | export function isSlug(value: string): boolean { | |
| 32 | return SLUG.test(value); | |
| 33 | } | |
| 34 | ||
| 35 | export function isAccountId(value: string): boolean { | |
| 36 | return ACCOUNT_ID.test(value); | |
| 37 | } | |
| 38 | ||
| 39 | export 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. */ | |
| 45 | export 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 | ||
| 55 | export 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 charging | 62 | * 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 name | 68 | */ |
| 69 | export function parseTerms(form: FormData, by: string, now = new Date()): Parsed<Terms> { | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 70 | // 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 name | 74 | const raw = text(form, "discount"); |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 75 | 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 name | 81 | } |
| 82 | ||
| 83 | let ceilingMicros: number | null = null; | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 84 | 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 name | 89 | } |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 90 | 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 name | 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. */ | |
| 114 | export 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 | ||
| 127 | export type SalesUpdate = { stage: SalesStage; owner: string | null; nextStep: string | null; nextAt: string | null }; | |
| 128 | ||
| 129 | const MAX_NEXT_STEP = 200; | |
| 130 | const 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 | */ | |
| 136 | export 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. */ | |
| 166 | export 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. */ | |
| 173 | export 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. */ | |
| 181 | export 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 | */ | |
| 186 | export const MAX_AUDIT_DAYS = 400; | |
| 187 | const 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 name | 197 | */ |
| 198 | export 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 name | 233 | auditRetentionDays, |
| 234 | hold: hold || null, | |
| 235 | }, | |
| 236 | }; | |
| 237 | } | |
| 238 | ||
| 239 | /** The most one recorded payment can be; billing refuses more. */ | |
| 240 | export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR; | |
| 241 | const MAX_REFERENCE = 100; | |
| 242 | ||
| 243 | export 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 | */ | |
| 250 | export 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. */ | |
| 270 | export const MAX_REQUEST_MICROS = 1_000_000 * MICROS_PER_DOLLAR; | |
| 271 | ||
| 272 | export 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 | */ | |
| 278 | export 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 | ||
| 300 | export 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. */ | |
| 303 | export 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 | */ | |
| 311 | export 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.