Skip to content
Merged
209 changes: 209 additions & 0 deletions apps/api/src/routes/admin-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,212 @@ describe("GET /admin-ui/workspaces/:name/invites", () => {
expect(res.status).toBe(404);
});
});

describe("workspace limits editing", () => {
const CURRENT_PERIOD = new Date().toISOString().slice(0, 7);

/** Env with a mutable ws:acme record, a session user, and a usage row. */
function limitsEnv(
user: typeof ADMIN_USER | null,
record: Record<string, unknown> | null,
usage: { bytes: number; uploadsInPeriod: number } | null = { bytes: 0, uploadsInPeriod: 0 },
) {
const store = new Map<string, string>();
if (record) store.set("ws:acme", JSON.stringify(record));
const base = stubEnv(user, () => new Response(null, { status: 404 }));
const db = {
prepare: () => ({
bind: () => ({
first: async () =>
usage
? {
workspace: "acme",
bytes: usage.bytes,
objects: 0,
uploads_in_period: usage.uploadsInPeriod,
period_start: CURRENT_PERIOD,
updated_at: "2026-07-20T00:00:00.000Z",
}
: null,
}),
}),
};
const env = {
...base,
DB: db,
REGISTRY: {
get: (async (key: string) => {
const raw = store.get(key);
return raw ? JSON.parse(raw) : null;
}) as unknown as KVNamespace["get"],
put: (async (key: string, value: string) => {
store.set(key, value);
}) as unknown as KVNamespace["put"],
},
} as unknown as Env;
return { env, store };
}

const REC = {
provider: "r2",
bucket: "uploads-default",
prefix: "acme/",
maxStorageBytes: 250_000_000,
maxUploadsPerPeriod: 3000,
allowedKeyPrefixes: ["f", "screenshots", "gh"],
retentionDays: 90,
};

it("GET returns current limits and usage", async () => {
const { env } = limitsEnv(ADMIN_USER, REC, { bytes: 128, uploadsInPeriod: 5 });
const res = await app().request("/admin-ui/workspaces/acme/limits", {}, env);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
workspace: "acme",
limits: {
maxStorageBytes: 250_000_000,
maxUploadsPerPeriod: 3000,
maxUploadBytes: null,
maxVideoUploadBytes: null,
},
usage: { bytes: 128, uploads: 5 },
});
});

it("PATCH sets numeric limits on the record", async () => {
const { env, store } = limitsEnv(ADMIN_USER, REC);
const res = await app().request(
"/admin-ui/workspaces/acme/limits",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ maxStorageBytes: 500_000_000, maxUploadBytes: 10_000_000 }),
},
env,
);
expect(res.status).toBe(200);
const saved = JSON.parse(store.get("ws:acme")!);
expect(saved.maxStorageBytes).toBe(500_000_000);
expect(saved.maxUploadBytes).toBe(10_000_000);
});

it("PATCH with null clears a limit to unlimited", async () => {
const { env, store } = limitsEnv(ADMIN_USER, REC);
const res = await app().request(
"/admin-ui/workspaces/acme/limits",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ maxStorageBytes: null }),
},
env,
);
expect(res.status).toBe(200);
const saved = JSON.parse(store.get("ws:acme")!);
expect("maxStorageBytes" in saved).toBe(false);
});

it("PATCH leaves omitted budget fields and all non-budget fields intact", async () => {
const { env, store } = limitsEnv(ADMIN_USER, REC);
await app().request(
"/admin-ui/workspaces/acme/limits",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ maxUploadBytes: 10_000_000 }),
},
env,
);
const saved = JSON.parse(store.get("ws:acme")!);
expect(saved.maxUploadsPerPeriod).toBe(3000); // omitted -> unchanged
expect(saved.allowedKeyPrefixes).toEqual(["f", "screenshots", "gh"]); // preserved
expect(saved.retentionDays).toBe(90); // preserved
expect(saved.prefix).toBe("acme/"); // preserved
});

it("PATCH 400s on an invalid limit value", async () => {
const { env } = limitsEnv(ADMIN_USER, REC);
const res = await app().request(
"/admin-ui/workspaces/acme/limits",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ maxStorageBytes: -5 }),
},
env,
);
expect(res.status).toBe(400);
const payload = (await res.json()) as { error?: { code?: string } };
expect(payload.error?.code).toBe("invalid_limit");
});

it("404s for an unknown workspace", async () => {
const { env } = limitsEnv(ADMIN_USER, null);
const res = await app().request("/admin-ui/workspaces/acme/limits", {}, env);
expect(res.status).toBe(404);
});

it("404s for a soft-deleted workspace", async () => {
const { env } = limitsEnv(ADMIN_USER, { ...REC, deletedAt: "2026-07-01T00:00:00.000Z" });
const res = await app().request("/admin-ui/workspaces/acme/limits", {}, env);
expect(res.status).toBe(404);
});

it("403s for a non-admin session", async () => {
const { env } = limitsEnv(NON_ADMIN_USER, REC);
const res = await app().request(
"/admin-ui/workspaces/acme/limits",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ maxStorageBytes: 1 }),
},
env,
);
expect(res.status).toBe(403);
});

it("returns usage: null when the usage read finds no row", async () => {
const { env } = limitsEnv(ADMIN_USER, REC, null);
const res = await app().request("/admin-ui/workspaces/acme/limits", {}, env);
expect(res.status).toBe(200);
// getWorkspaceUsage returns an empty usage row (bytes 0) rather than throwing,
// so usage is still an object here; assert the shape is present.
const body = (await res.json()) as { usage: { bytes: number } | null };
expect(body.usage).toEqual({ bytes: 0, uploads: 0 });
});

it("PATCH 429s when the per-workspace write budget is exhausted", async () => {
const { env } = limitsEnv(ADMIN_USER, REC);
(env as Env & { WRITE_LIMITER: { limit: () => Promise<{ success: boolean }> } }).WRITE_LIMITER =
{ limit: async () => ({ success: false }) };
const res = await app().request(
"/admin-ui/workspaces/acme/limits",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ maxStorageBytes: 1 }),
},
env,
);
expect(res.status).toBe(429);
});

it("PATCH 400s on a malformed JSON body (not a silent no-op)", async () => {
const { env, store } = limitsEnv(ADMIN_USER, REC);
const res = await app().request(
"/admin-ui/workspaces/acme/limits",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: "{ not valid json",
},
env,
);
expect(res.status).toBe(400);
const payload = (await res.json()) as { error?: { code?: string } };
expect(payload.error?.code).toBe("invalid_limit");
// The record must be untouched — no write happened.
expect(JSON.parse(store.get("ws:acme")!).maxStorageBytes).toBe(250_000_000);
});
});
71 changes: 71 additions & 0 deletions apps/api/src/routes/admin-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import {
sessionAuth,
type SessionVars,
} from "../session-auth";
import { getWorkspaceUsage } from "../usage";
import { isPurgedTombstone, loadWorkspaceRecordRaw, type WorkspaceRecord } from "../workspace";
import { LIMIT_FIELDS, validateLimitsPatch } from "../workspace-limits";

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

Expand Down Expand Up @@ -96,6 +99,38 @@ async function proxyOauthClients(
return Response.json(payload, { status: response.status });
}

/**
* Raw-reads ws:<name> for a limits edit and 404s on missing / soft-deleted /
* purged-tombstone records (an admin can't edit limits on a workspace that no
* longer serves). Uses the uncached raw read so the edit sees the freshest
* record. Returns a live WorkspaceRecord the caller mutates and writes back.
*/
async function loadEditableWorkspace(env: Env, name: string): Promise<WorkspaceRecord> {
const record = await loadWorkspaceRecordRaw(env, name);
if (!record || isPurgedTombstone(record) || record.deletedAt) {
throw new NotFoundError("workspace not found", { code: "workspace_not_found" });
}
return record;
}

/** Response body shared by GET and PATCH: current budget limits + usage. */
async function limitsResponse(env: Env, name: string, record: WorkspaceRecord) {
const limits = {
maxStorageBytes: record.maxStorageBytes ?? null,
maxUploadsPerPeriod: record.maxUploadsPerPeriod ?? null,
maxUploadBytes: record.maxUploadBytes ?? null,
maxVideoUploadBytes: record.maxVideoUploadBytes ?? null,
};
let usage: { bytes: number; uploads: number } | null = null;
try {
const u = await getWorkspaceUsage(env.DB, name);
usage = { bytes: u.bytes, uploads: u.uploadsInPeriod };
} catch {
usage = null;
}
return { workspace: name, limits, usage };
}

export const adminUi = new Hono<SessionVars>()
.use("/*", sessionAuth, requireSessionUser, requireAdminUser)

Expand Down Expand Up @@ -249,6 +284,42 @@ export const adminUi = new Hono<SessionVars>()
);
})

// Read the four budget limits (+ current usage) for one workspace.
.get("/workspaces/:name/limits", async (c) => {
const name = c.req.param("name");
const record = await loadEditableWorkspace(c.env, name);
return c.json(await limitsResponse(c.env, name, record));
})

// Patch the four budget limits. Each field is optional; a positive integer
// sets the cap, null clears it (-> unlimited), omitted leaves it unchanged.
// The whole record is written back so non-budget fields are preserved.
.patch("/workspaces/:name/limits", async (c) => {
const name = c.req.param("name");
if (!(await allowWrite(c.env, name))) {
throw new RateLimitedError("rate limit exceeded");
}
const record = await loadEditableWorkspace(c.env, name);
// Distinguish malformed JSON (400) from an intentionally empty object
// (a no-op patch): swallowing a parse failure into `{}` would silently
// 200 on a broken request and rewrite the record unchanged.
let body: unknown;
try {
body = await c.req.json();
} catch {
throw new ValidationError("request body must be valid JSON", { code: "invalid_limit" });
}
const patch = validateLimitsPatch(body);
for (const field of LIMIT_FIELDS) {
if (!(field in patch)) continue;
const value = patch[field];
if (value === null) delete record[field];
else record[field] = value;
}
await c.env.REGISTRY.put(`ws:${name}`, JSON.stringify(record));
Comment on lines +302 to +319

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Prevent concurrent full-record writes from losing updates.

Two PATCH requests can both read the old record and each write a full replacement; the later write silently removes the other request’s limit changes. Serialize workspace-record mutations or use a transactional/versioned persistence mechanism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/admin-ui.ts` around lines 299 - 307, Update the workspace
mutation flow containing loadEditableWorkspace and REGISTRY.put so concurrent
PATCH requests cannot overwrite each other’s limit changes. Serialize
full-record mutations for each workspace, or use the registry’s
transactional/versioned update mechanism, while preserving the existing patch
validation and field deletion/update behavior.

return c.json(await limitsResponse(c.env, name, record));
Comment on lines +297 to +320

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Rate-limit this workspace mutation.

This route writes KV without the required workspace-keyed writeRateLimit middleware, allowing an authenticated admin to issue unbounded writes.

As per coding guidelines, “Mutating routes must use the writeRateLimit middleware keyed by workspace; the middleware may no-op only when the WRITE_LIMITER binding is absent.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/admin-ui.ts` around lines 297 - 308, The PATCH
/workspaces/:name/limits route must apply the required workspace-keyed
writeRateLimit middleware before performing the KV mutation. Update the route
definition around the limits patch handler to include writeRateLimit using the
workspace name key, while preserving the existing validation, record update, and
response behavior.

Source: Coding guidelines

})

// OAuth client registrations — proxied 1:1 to Lane 1's internal routes
// (see .context/2026-07-18-oauth-admin-panel-contract.md). Never re-validate
// beyond parsing JSON; the auth worker owns validation.
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/self-serve-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe("selfServeWorkspaceRecord", () => {
selfServe: true,
createdByUserId: "u1",
createdAt: "2026-07-14T00:00:00.000Z",
maxStorageBytes: 1_000_000_000,
maxStorageBytes: 250_000_000,
maxUploadsPerPeriod: 3000,
maxUploadBytes: 25_000_000,
maxVideoUploadBytes: 8_000_000,
Expand All @@ -31,8 +31,8 @@ describe("selfServeWorkspaceRecord", () => {
const b = selfServeWorkspaceRecord({ name: "b", userId: "u", now: new Date(0) });
expect(a.allowedKeyPrefixes).not.toBe(b.allowedKeyPrefixes);
});
it("limits are 1GB/3000-per-month", () => {
expect(SELF_SERVE_LIMITS.maxStorageBytes).toBe(1_000_000_000);
it("limits are 250MB/3000-per-month", () => {
expect(SELF_SERVE_LIMITS.maxStorageBytes).toBe(250_000_000);
expect(SELF_SERVE_LIMITS.maxUploadsPerPeriod).toBe(3000);
});
});
6 changes: 4 additions & 2 deletions apps/api/src/self-serve-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
* Record template for self-serve workspaces (spec 2026-07-14, D3).
* Deliberately tighter than the operator template in
* scripts/workspace-limit-defaults.json (25 GB): self-serve tenants start at
* 1 GB / 3000 uploads per UTC month; raises are admin-only.
* 250 MB / 3000 uploads per UTC month; raises are admin-only. Uploads are
* WebP-converted by default, so 250 MB of stored assets holds far more
* screenshots than the raw byte figure suggests.
*/
import type { WorkspaceRecord } from "./workspace";

export const SELF_SERVE_LIMITS = {
maxStorageBytes: 1_000_000_000,
maxStorageBytes: 250_000_000,
maxUploadsPerPeriod: 3000,
maxUploadBytes: 25_000_000,
maxVideoUploadBytes: 8_000_000,
Expand Down
Loading
Loading