From faa5f4d9dd0bf5f2a3a7941f5c62ae7e29f11c38 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:57:31 +0000 Subject: [PATCH] Return 413 for storage quota and expose per-doc storage in the listing --- lib/docs/api.ts | 28 +++++++++++++++++++++---- lib/docs/quota.test.ts | 44 +++++++++++++++++++++++++++++++++++++++ lib/docs/store.ts | 19 ++++++++++++++--- lib/skill-content.ts | 18 ++++++++++------ skills/just-html/SKILL.md | 18 ++++++++++------ 5 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 lib/docs/quota.test.ts diff --git a/lib/docs/api.ts b/lib/docs/api.ts index 9d565f7..03da209 100644 --- a/lib/docs/api.ts +++ b/lib/docs/api.ts @@ -73,18 +73,38 @@ export function consumerDomainRejected(domain: string): Response { ); } -/** 403 quota_exceeded — count or storage cap hit. */ +/** + * A count cap is 403 quota_exceeded: the account is not allowed another document + * or grant. Storage is 413 storage_quota_exceeded: nothing is forbidden, the + * request is too large for the space left. The storage case carries the numbers + * (in use, cap, headroom) and names the one thing that reliably reclaims space, + * because a caller reading "403" goes looking for a permissions problem and + * retries a write that cannot succeed. + */ export function quotaExceeded( kind: "doc_count" | "storage" | "grants", limit: number, current: number ): Response { + if (kind === "storage") { + const headroom = Math.max(0, limit - current); + return apiError( + 413, + "storage_quota_exceeded", + `This write would take the account past its storage limit. ${current} bytes in use against a ${limit}-byte cap, so ${headroom} bytes of headroom. Retained version history is almost always what fills this quota, and there is no version-delete API: publishing the current HTML as a new document resets that document's history to one snapshot.`, + { + limit: kind, + limit_value: limit, + current, + headroom, + suggestions: ["new_document", "delete_documents", "reduce_html"], + } + ); + } const msg = kind === "doc_count" ? "You have reached the maximum number of documents." - : kind === "grants" - ? "This document has reached the maximum number of grants. Revoke an existing grant before adding another." - : "You have reached your total storage limit."; + : "This document has reached the maximum number of grants. Revoke an existing grant before adding another."; return apiError(403, "quota_exceeded", msg, { limit: kind, limit_value: limit, current }); } diff --git a/lib/docs/quota.test.ts b/lib/docs/quota.test.ts new file mode 100644 index 0000000..6e27bb5 --- /dev/null +++ b/lib/docs/quota.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { quotaExceeded } from "./api"; +import { MAX_STORAGE_BYTES_PER_USER } from "./config"; + +async function body(res: Response) { + return (await res.json()) as Record; +} + +describe("quotaExceeded", () => { + it("answers a storage cap with 413 and the numbers, not 403", async () => { + const current = MAX_STORAGE_BYTES_PER_USER - 1024; + const res = quotaExceeded("storage", MAX_STORAGE_BYTES_PER_USER, current); + expect(res.status).toBe(413); + const b = await body(res); + expect(b.error).toBe("storage_quota_exceeded"); + expect(b.limit).toBe("storage"); + expect(b.limit_value).toBe(MAX_STORAGE_BYTES_PER_USER); + expect(b.current).toBe(current); + expect(b.headroom).toBe(1024); + expect(b.suggestions).toContain("new_document"); + }); + + it("says how to reclaim the space, because there is no version-delete API", async () => { + const res = quotaExceeded("storage", MAX_STORAGE_BYTES_PER_USER, MAX_STORAGE_BYTES_PER_USER * 2); + const b = await body(res); + expect(String(b.message)).toContain("version-delete"); + // over the cap: headroom floors at zero rather than going negative + expect(b.headroom).toBe(0); + }); + + it("keeps a count cap as 403 quota_exceeded", async () => { + const res = quotaExceeded("doc_count", 500, 500); + expect(res.status).toBe(403); + const b = await body(res); + expect(b.error).toBe("quota_exceeded"); + expect(b.limit).toBe("doc_count"); + }); + + it("keeps a grant cap as 403 quota_exceeded", async () => { + const res = quotaExceeded("grants", 50, 50); + expect(res.status).toBe(403); + expect((await body(res)).error).toBe("quota_exceeded"); + }); +}); diff --git a/lib/docs/store.ts b/lib/docs/store.ts index 67ebe34..d78b2a5 100644 --- a/lib/docs/store.ts +++ b/lib/docs/store.ts @@ -95,7 +95,7 @@ export function granteeView(doc: DocRow, includeHtml: boolean, role: string) { * `role`. Two near-identical shapes coexist by design; don't conflate them. */ export function listItemView( - doc: DocRow & { comment_count?: number | string }, + doc: DocRow & { comment_count?: number | string; storage_bytes?: number | string }, access: "owner" | "editor" | "commenter" | "viewer" ) { return { @@ -109,6 +109,9 @@ export function listItemView( // comment_count; the /docs dashboard rows surface it too). Defaults to 0 when // the row didn't carry the aggregate. comment_count: Number(doc.comment_count ?? 0), + // The bytes this document costs the account, so a listing can be sorted by + // what would actually be reclaimed. + storage_bytes: Number(doc.storage_bytes ?? 0), created_at: doc.created_at, updated_at: doc.updated_at, // Owned docs carry the view_token (the owner's shareable capability); shared @@ -577,7 +580,13 @@ export async function findBySlug(slug: string): Promise { * dashboard rows and GET /api/v1/docs items, birthday.md B11). comment_count is * the number of non-deleted comments + replies on the doc. */ -export type DocListRow = DocRow & { comment_count: number }; +export type DocListRow = DocRow & { + comment_count: number; + // Current html plus every retained snapshot for this doc, the same bytes that + // count against the storage quota. Without it a caller cannot tell which + // documents are holding the space and has to fetch each one to find out. + storage_bytes: number; +}; /** Correlated live-comment count, reused by both listing queries. */ const COMMENT_COUNT_SUBQUERY = ` @@ -587,7 +596,11 @@ const COMMENT_COUNT_SUBQUERY = ` /** List a user's live docs, newest first (with comment_count). */ export async function listDocs(ownerId: number, limit: number): Promise { const { rows } = await query( - `SELECT d.*, ${COMMENT_COUNT_SUBQUERY} + `SELECT d.*, ${COMMENT_COUNT_SUBQUERY}, + (octet_length(d.html) + COALESCE(( + SELECT sum(octet_length(v.html)) FROM doc_versions v + WHERE v.doc_id = d.id + ), 0))::bigint AS storage_bytes FROM documents d WHERE d.owner_id = $1 AND d.deleted_at IS NULL ORDER BY d.created_at DESC LIMIT $2`, diff --git a/lib/skill-content.ts b/lib/skill-content.ts index 09f3b12..85ce487 100644 --- a/lib/skill-content.ts +++ b/lib/skill-content.ts @@ -268,7 +268,7 @@ Resource quotas (per user): Max HTML size per doc 2 MB request rejected 413 payload_too_large Docs per user 500 soft-deleted don't count; 403 quota_exceeded Versions retained per doc 100 oldest snapshots pruned beyond this - Total storage per user 100 MiB current html + retained snapshots; 403 + Total storage per user 100 MiB current html + retained snapshots; 413 storage_quota_exceeded Grants per doc 50 403 quota_exceeded Comment body size 10 KB 413 payload_too_large Comments per doc 1,000 403 quota_exceeded @@ -276,9 +276,12 @@ Resource quotas (per user): ### Recovering from a storage or document-count quota -A 403 with { "error":"quota_exceeded", "limit":"storage", -"limit_value":104857600, "current":... } means the write would exceed the -account's 100 MiB storage quota (current and limit_value are bytes). +A 413 with { "error":"storage_quota_exceeded", "limit":"storage", +"limit_value":104857600, "current":..., "headroom":... } means the write would +exceed the account's 100 MiB storage quota. All three are bytes: current is in +use now, limit_value is the cap, headroom is what is left. A 403 +quota_exceeded with limit:"doc_count" is the 500-document cap, which is a +different problem with a different fix. limit:"doc_count" means the 500-document cap. This is not an authentication or rate-limit failure: do not re-register, repeatedly retry the write, or switch hosts without asking the user. @@ -297,8 +300,11 @@ Keep the unpublished HTML locally while resolving the quota. returns current html; GET /docs/:slug/versions returns versions[].bytes. Add the UTF-8 byte length of current html to the sum of ALL retained snapshot bytes (including the current version's snapshot). Both count toward quota. - The listing has no storage-size field. Shrinking HTML can add a snapshot, - so it is not a reliable way to reclaim space; there is no version-delete API. + The listing carries storage_bytes per document, so candidates sort by what + they would actually reclaim rather than by guesswork. Shrinking HTML can add + a snapshot, so it is not a reliable way to reclaim space; there is no + version-delete API. Publishing the current HTML as a new document is the only + way to drop a document's history, and it starts that document at version 1. 3. Offer 3-5 candidates (or fewer if fewer are suitable) as suggestions, with title, URL/slug, last-updated date, estimated reclaimable MiB, and why each diff --git a/skills/just-html/SKILL.md b/skills/just-html/SKILL.md index 351dc7d..186da5e 100644 --- a/skills/just-html/SKILL.md +++ b/skills/just-html/SKILL.md @@ -260,7 +260,7 @@ Resource quotas (per user): Max HTML size per doc 2 MB request rejected 413 payload_too_large Docs per user 500 soft-deleted don't count; 403 quota_exceeded Versions retained per doc 100 oldest snapshots pruned beyond this - Total storage per user 100 MiB current html + retained snapshots; 403 + Total storage per user 100 MiB current html + retained snapshots; 413 storage_quota_exceeded Grants per doc 50 403 quota_exceeded Comment body size 10 KB 413 payload_too_large Comments per doc 1,000 403 quota_exceeded @@ -268,9 +268,12 @@ Resource quotas (per user): ### Recovering from a storage or document-count quota -A 403 with { "error":"quota_exceeded", "limit":"storage", -"limit_value":104857600, "current":... } means the write would exceed the -account's 100 MiB storage quota (current and limit_value are bytes). +A 413 with { "error":"storage_quota_exceeded", "limit":"storage", +"limit_value":104857600, "current":..., "headroom":... } means the write would +exceed the account's 100 MiB storage quota. All three are bytes: current is in +use now, limit_value is the cap, headroom is what is left. A 403 +quota_exceeded with limit:"doc_count" is the 500-document cap, which is a +different problem with a different fix. limit:"doc_count" means the 500-document cap. This is not an authentication or rate-limit failure: do not re-register, repeatedly retry the write, or switch hosts without asking the user. @@ -289,8 +292,11 @@ Keep the unpublished HTML locally while resolving the quota. returns current html; GET /docs/:slug/versions returns versions[].bytes. Add the UTF-8 byte length of current html to the sum of ALL retained snapshot bytes (including the current version's snapshot). Both count toward quota. - The listing has no storage-size field. Shrinking HTML can add a snapshot, - so it is not a reliable way to reclaim space; there is no version-delete API. + The listing carries storage_bytes per document, so candidates sort by what + they would actually reclaim rather than by guesswork. Shrinking HTML can add + a snapshot, so it is not a reliable way to reclaim space; there is no + version-delete API. Publishing the current HTML as a new document is the only + way to drop a document's history, and it starts that document at version 1. 3. Offer 3-5 candidates (or fewer if fewer are suitable) as suggestions, with title, URL/slug, last-updated date, estimated reclaimable MiB, and why each