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

Commit

A free workspace keeps up to 1 GB of files in its docs and artifacts. Images and attachments were capped at 25 MB each with no total, kept in R2 and served from g1tusercontent.com, so a free workspace could use g1t as a file host. Each upload, from the page editor, an artifact or an agent, now adds up the workspace's files (migration 0008 indexes them by workspace) and, past FREE_DOC_FILES_BYTES (1 GiB, a wrangler var; 0 is no cap), asks billing whether the workspace is free; if it is, the file is refused with a message saying how much it holds and how to make room. The artifacts service gains a BILLING binding for that. Billing is asked only past the cap, and when it does not answer the upload goes ahead. Files already kept are not touched. The artifacts guide says so.

syntaqxcommitted Parentcbd1fe0Browse files
7 files+112−40/7 viewed
+4−1
204204 by title; it lists yours under **Linked from**.
205205 - **Add icon** above the title gives the doc an emoji.
206206 - Images and files are served from `g1tusercontent.com`, never from `g1t.sh`,
207− up to 25 MB each.
207+ up to 25 MB each. A workspace on the free plan keeps up to 1 GB of them,
208+ across all its docs and artifacts; past it, a new file is refused and the
209+ upload says so. Files already there stay. On the g1t plan there is no
210+ such cap.
208211
209212 Docs are live: everyone on one sees each other's changes and cursors, and the
210213 faces at the top are who's there now, agents included. When your connection
+3−0
1+-- What a workspace's files hold, added up before each upload (src/quota.ts).
2+CREATE INDEX IF NOT EXISTS files_by_workspace ON files (workspace_id);
3+CREATE INDEX IF NOT EXISTS folio_files_by_workspace ON folio_files (workspace_id);
+6−1
1111 * more than that person can, narrowed to their audience
1212 * (src/folios/agents.ts).
1313 */
14+import { type QuotaEnv, uploadRefused } from "../quota.ts";
1415 import {
1516 DOCS_VIEWER_HEADER,
1617 DOC_MAX_FILE_BYTES,
116117 import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts";
117118 import { deleteSpaceRows, eraseFolios, landingFor } from "./trash.ts";
118119
119−export type FoliosEnv = WhoEnv & {
120+export type FoliosEnv = WhoEnv & QuotaEnv & {
120121 FOLIOS: DurableObjectNamespace<FolioRoom>;
121122 NOTIFY?: ServiceBinding;
122123 EVENTS?: ServiceBinding;
23022303 }
23032304 if (!bytes.length) return fail("invalid", "The file is empty.");
23042305 if (bytes.length > DOC_MAX_FILE_BYTES) return fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`);
2306+ const refused = await uploadRefused(this.db, this.env, actx.workspace, bytes.length);
2307+ if (refused) return fail("limit", refused);
23052308 const name = safeName(a.file?.name ?? "file");
23062309 const contentType = servedType(a.file?.content_type ?? "");
23072310 const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
25802583 if (!opened.ok) return Response.json(opened);
25812584 const bytes = Number(request.headers.get("content-length") ?? "0");
25822585 if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`));
2586+ const refused = await uploadRefused(this.db, this.env, ctx.workspace, bytes);
2587+ if (refused) return Response.json(fail("limit", refused));
25832588 const name = safeName(url.searchParams.get("name") ?? "file");
25842589 const contentType = servedType(request.headers.get("content-type") ?? "");
25852590 const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
+4−1
2020 * tables until Phase 7 retires them.
2121 */
2222
23+import { type QuotaEnv, uploadRefused } from "./quota.ts";
2324 import {
2425 DOCS_VIEWER_HEADER,
2526 DOC_MAX_FILE_BYTES,
109110 export { PageRoom } from "./room.ts";
110111 export { FolioRoom } from "./folios/room.ts";
111112
112−type Env = FileStoreEnv & {
113+type Env = FileStoreEnv & QuotaEnv & {
113114 DB: D1Database;
114115 IDENTITY: ServiceBinding;
115116 AGENTS: ServiceBinding;
25872588 if (!found.ok) return Response.json(found);
25882589 const bytes = Number(request.headers.get("content-length") ?? "0");
25892590 if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`));
2591+ const refused = await uploadRefused(this.db, this.env, found.value.workspace, bytes);
2592+ if (refused) return Response.json(fail("limit", refused));
25902593 const name = safeName(url.searchParams.get("name") ?? "file");
25912594 const contentType = servedType(request.headers.get("content-type") ?? "");
25922595 const random = crypto.getRandomValues(new Uint8Array(32));
+24−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { DEFAULT_FREE_DOC_FILES_BYTES, filesRefusal, freeFilesCap, shownBytes } from "./quota.ts";
5+
6+const GB = 1024 * 1024 * 1024;
7+
8+test("the cap comes from its var, 1 GB unless set, and 0 is none", () => {
9+ assert.equal(freeFilesCap(undefined), DEFAULT_FREE_DOC_FILES_BYTES);
10+ assert.equal(freeFilesCap(""), DEFAULT_FREE_DOC_FILES_BYTES);
11+ assert.equal(freeFilesCap("lots"), DEFAULT_FREE_DOC_FILES_BYTES);
12+ assert.equal(freeFilesCap(" 5368709120 "), 5 * GB);
13+ assert.equal(freeFilesCap("0"), 0);
14+});
15+
16+test("a free workspace's upload past the cap is refused, and says how to make room", () => {
17+ assert.equal(filesRefusal(0.5 * GB, 0.25 * GB, GB), null);
18+ const refused = filesRefusal(GB - 100, 1000, GB);
19+ assert.ok(refused?.includes("of the 1 GB"), refused ?? "");
20+ assert.ok(refused?.includes("start the g1t plan"), refused ?? "");
21+ assert.equal(filesRefusal(100 * GB, GB, 0), null);
22+ assert.equal(shownBytes(300 * 1024 * 1024), "300 MB");
23+ assert.equal(shownBytes(1.5 * GB), "1.5 GB");
24+});
+64−0
1+/**
2+ * What a workspace without the g1t plan may keep in files put in its pages
3+ * and artifacts (images, attachments): `FREE_DOC_FILES_BYTES` (1 GiB, a
4+ * wrangler var; `0` is no cap). Past it, a new upload is refused with a
5+ * message saying so; nothing already kept is touched. Billing is asked
6+ * only once the workspace is past the cap, and when it does not answer the
7+ * upload goes ahead.
8+ */
9+import type { ServiceBinding } from "@g1t/contracts";
10+
11+export const DEFAULT_FREE_DOC_FILES_BYTES = 1024 * 1024 * 1024;
12+
13+export type QuotaEnv = { BILLING?: ServiceBinding; FREE_DOC_FILES_BYTES?: string };
14+
15+/** The cap from its var: the default when unset or not a whole number; 0 is none. */
16+export function freeFilesCap(raw: string | undefined): number {
17+ const value = Number((raw ?? "").trim());
18+ return raw !== undefined && raw.trim() !== "" && Number.isInteger(value) && value >= 0 ? value : DEFAULT_FREE_DOC_FILES_BYTES;
19+}
20+
21+/** `1 GB`, `1.5 GB`, `300 MB`. */
22+export function shownBytes(bytes: number): string {
23+ const gb = 1024 * 1024 * 1024;
24+ if (bytes >= gb) {
25+ const tenths = Math.ceil((bytes * 10) / gb);
26+ return tenths % 10 === 0 ? `${tenths / 10} GB` : `${(tenths / 10).toFixed(1)} GB`;
27+ }
28+ return `${Math.ceil(bytes / (1024 * 1024))} MB`;
29+}
30+
31+/** What a free workspace is told when `adding` would take it past `cap`; null while it fits. */
32+export function filesRefusal(held: number, adding: number, cap: number): string | null {
33+ if (cap <= 0 || held + adding <= cap) return null;
34+ return `This workspace's files hold ${shownBytes(held)} of the ${shownBytes(cap)} a workspace on the free plan keeps, so this file is not kept. Delete files you no longer need, or start the g1t plan, which has no such cap.`;
35+}
36+
37+/** Why an upload of `adding` bytes to `workspace` is refused, or null. */
38+export async function uploadRefused(
39+ db: D1Database,
40+ env: QuotaEnv,
41+ workspace: { id: string; slug: string },
42+ adding: number,
43+): Promise<string | null> {
44+ const cap = freeFilesCap(env.FREE_DOC_FILES_BYTES);
45+ if (cap <= 0) return null;
46+ const row = await db
47+ .prepare(
48+ `SELECT (SELECT COALESCE(SUM(bytes), 0) FROM files WHERE workspace_id = ?1)
49+ + (SELECT COALESCE(SUM(bytes), 0) FROM folio_files WHERE workspace_id = ?1) AS bytes`,
50+ )
51+ .bind(workspace.id)
52+ .first<{ bytes: number }>();
53+ const why = filesRefusal(row?.bytes ?? 0, adding, cap);
54+ if (!why || !env.BILLING) return null;
55+ try {
56+ // Loaded here, so the rules above test under Node without the contracts' runtime.
57+ const { billingClient } = await import("@g1t/contracts");
58+ const free = await billingClient(env.BILLING).freeWorkspaces([workspace.slug]);
59+ return free.map((slug) => slug.toLowerCase()).includes(workspace.slug.toLowerCase()) ? why : null;
60+ } catch (error) {
61+ console.error("artifacts: billing did not say whether the workspace is free", workspace.slug, error);
62+ return null;
63+ }
64+}
+7−1
6767 // What a merged pull request changed.
6868 { "binding": "WORK", "service": "g1t-work" },
6969 // doc.page.* events on the bus (src/events.ts).
70− { "binding": "EVENTS", "service": "g1t-events" }
70+ { "binding": "EVENTS", "service": "g1t-events" },
71+ // Whether a workspace has the plan, once its files pass the free cap (src/quota.ts).
72+ { "binding": "BILLING", "service": "g1t-billing" }
7173 ],
74+ // What a workspace without the plan may keep in files put in its pages
75+ // and artifacts, in bytes; past it, new uploads are refused (src/quota.ts).
76+ // 0 is no cap.
77+ "vars": { "FREE_DOC_FILES_BYTES": "1073741824" },
7278 // Merges and pushes to default branches: pages whose cited code
7379 // changed, and projects' docs read again (src/staleness.ts). The events
7480 // service sends it what g1t_contracts::subscribers routes to

Comments

Sign in to comment.