Public: anyone can read this; the crew can change it.

A set app limit refuses one more app, and the refusal says "3 of 3 apps" #2136

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2094-a-set-app-limit-refuses-one-more-app-and → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/2094-a-set-app-limit-refuses-one-more-app-and was merged into main as d0fa1bc.

Review

Sign in to review this change.
9 files changed +253 −11
src/actions/app_limit.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * The app limit (`settings/quotas.ts`): when a forge admin has set one, an
3 * account may own that many apps and no more. A forge admin is never held
4 * to it, and with no limit set nothing is asked. The check and the insert
5 * are separate statements, so two concurrent creates can pass by one: the
6 * limit is a soft quota, not a lock.
7 */
8
9 import type { App } from "../app.ts";
10 import { getQuota } from "../settings/quotas.ts";
11 import type { User } from "../core/users.ts";
12 import { err, ok, type Result } from "../kernel/result.ts";
13
14 /** Apps whose repository belongs to the account. */
15 export function appsOwnedBy(app: App, ownerId: string): number {
16 return (
17 app.db
18 .query<{ n: number }, [string]>(
19 "SELECT count(*) AS n FROM app JOIN repo ON repo.id = app.repo_id WHERE repo.owner_id = ?1",
20 )
21 .get(ownerId)?.n ?? 0
22 );
23 }
24
25 /** The refusal, in words: the limit and the count, e.g. "3 of 3 apps". */
26 export function appLimitWords(app: App, ownerId: string): string {
27 const limit = getQuota(app.db, "apps") ?? 0;
28 return `This account is at its limit: ${Math.min(appsOwnedBy(app, ownerId), limit)} of ${limit} apps. Remove one to make room.`;
29 }
30
31 /** Room for one more app under `ownerId`, for `actor` to make. */
32 export function requireAppRoom(
33 app: App,
34 actor: User,
35 ownerId: string,
36 ): Result<void, "app_limit"> {
37 if (actor.isAdmin) return ok();
38 const limit = getQuota(app.db, "apps");
39 if (limit === null || appsOwnedBy(app, ownerId) < limit) return ok();
40 return err("app_limit");
41 }
src/actions/apps.ts +16 −3
@@ -10,6 +10,7 @@ import { pullDir } from "../core/pulls.ts";
1010 import { getIssue } from "../core/issues.ts";
1111 import { mayView } from "../core/sealed.ts";
1212 import { requireVerified } from "./verify.ts";
13 import { requireAppRoom } from "./app_limit.ts";
1314 import type { App } from "../app.ts";
1415 import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts";
1516 import { grantUrl, originFor } from "../apps/proxy.ts";
@@ -48,7 +49,7 @@ import type { Issue } from "../core/issues.ts";
4849 import { getPull } from "../core/pulls.ts";
4950 import { canEnterApp, canWrite, getRepo, type Repo } from "../core/repos.ts";
5051 import type { Run } from "../core/runs.ts";
51 import type { User } from "../core/users.ts";
52 import { getUserByName, type User } from "../core/users.ts";
5253 import { resolveOid } from "../git/merge.ts";
5354 import { repoDir } from "../git/repos.ts";
5455 import { err, ok, type Result } from "../kernel/result.ts";
@@ -64,6 +65,7 @@ import {
6465 commitToBranch,
6566 createRepo,
6667 deleteRepo,
68 resolveOwner,
6769 type CreateRepoActionError,
6870 } from "./repos.ts";
6971
@@ -79,11 +81,13 @@ export function enableApp(
7981 actor: User | undefined,
8082 repo: Repo,
8183 input: AppSettingsInput = {},
82 ): Result<AppRecord, "not_found" | CreateAppError | "unverified"> {
84 ): Result<AppRecord, "not_found" | CreateAppError | "unverified" | "app_limit"> {
8385 const admin = requireRepoAdmin(app, actor, repo);
8486 if (!admin.ok) return admin;
8587 const proven = requireVerified(app, admin.value);
8688 if (!proven.ok) return proven;
outdated

This works for newApp, but makeItYours only gets here after forkRepo has already made the copy. At the limit the copy stays, and the studio shows ...could not be registered: app_limit. Check room before the fork and return appLimitWords.

89 const room = requireAppRoom(app, admin.value, repo.ownerId);
90 if (!room.ok) return room;
8791 const created = insertApp(app.db, {
8892 repoId: repo.id,
8993 slug: input.slug ?? slugFor(repo.name),
@@ -271,7 +275,8 @@ export type NewAppError =
271275 | CreateRepoActionError
272276 | CreateAppError
273277 | "scaffold_failed"
274 | "no_such_agent";
278 | "no_such_agent"
279 | "app_limit";
275280
276281 /**
277282 * The golden path's first step, as one call: a repository with a working
@@ -293,6 +298,14 @@ export async function newApp(
293298 : getAgentByName(app.db, input.agent);
294299 if (input.agent && !agent) return err("no_such_agent");
295300
301 // Refuse at the limit before a repository exists to take back.
outdated

High: getUserByName(input.owner) takes any account name, and the room check runs before createRepo's owner authorization. A stranger who names someone else's account gets app_limit with that account's count instead of forbidden_owner. Use resolveOwner(app, user.value, input.owner ?? "") (exported from actions/repos.ts) and check room only on the id it returns.

302 // The owner is resolved with the actor's rights, as createRepo does, so
303 // the count of an account they may not create under is never told.
304 const ownerId = resolveOwner(app, user.value, input.owner ?? "");
305 if (ownerId === undefined) return err("forbidden_owner");
306 const room = requireAppRoom(app, user.value, ownerId);
307 if (!room.ok) return room;
308
296309 // Refuse a taken slug before anything exists to take back.
297310 // A name the owner already has stays `name_taken`, which createRepo says.
298311 if (
src/actions/make_it_yours.ts +9 −1
@@ -40,6 +40,7 @@ import {
4040 } from "../core/repos.ts";
4141 import { isActive, type User } from "../core/users.ts";
4242 import { err, ok, type Result } from "../kernel/result.ts";
43 import { requireAppRoom, appLimitWords } from "./app_limit.ts";
4344 import { enableApp } from "./apps.ts";
4445 import { FORKS_PER_DAY, forkRepo } from "./repos.ts";
4546
@@ -184,10 +185,17 @@ export async function makeItYours(
184185 ? err({ kind: "unverified" })
185186 : err({
186187 kind: "invalid",
187 message: `the app could not be registered: ${enabled.error}`,
188 message:
189 enabled.error === "app_limit"
190 ? appLimitWords(app, actor.id)
191 : `the app could not be registered: ${enabled.error}`,
188192 });
189193 return ok({ ...plan, made: { repo: copy, app: enabled.value } });
190194 }
195 // The copy is an app of the actor's: no room, no copy (before the fork,
196 // so no repository is left behind), in the plan as in the making.
197 if (!requireAppRoom(app, actor, actor.id).ok)
198 return err({ kind: "invalid", message: appLimitWords(app, actor.id) });
191199 const slug = freeSlug(app, { name }, actor.username);
192200 const plan = {
193201 dryRun,
src/actions/repos.ts +1 −1
@@ -88,7 +88,7 @@ function requireNewAccountGuard(
8888 }
8989
9090 /** Resolve the owner id for a new repo: the actor, or an org they can write to. */
91 function resolveOwner(
91 export function resolveOwner(
9292 app: App,
9393 actor: User,
9494 owner: string,
src/http/api.ts +32 −2
@@ -219,6 +219,8 @@ import { isPinned } from "../core/pins.ts";
219219 import { listWebhooks } from "../core/webhooks.ts";
220220 import { diffCommit } from "../git/diff.ts";
221221 import { runGit } from "../git/env.ts";
222 import { appLimitWords, appsOwnedBy } from "../actions/app_limit.ts";
223 import { getQuota } from "../settings/quotas.ts";
222224 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
223225 import { listBranches } from "../git/branches.ts";
224226 import {
@@ -1028,6 +1030,21 @@ function positiveInt(raw: string | undefined): number | undefined {
10281030 return Number.isInteger(n) && n > 0 ? n : undefined;
10291031 }
10301032
1033 /** 403 naming the limit and the count, for an account at its app limit. */
1034 function appLimitResponse(ctx: Ctx, ownerName: string): Response {
1035 const owner = getUserByName(ctx.app.db, ownerName);
1036 const limit = getQuota(ctx.app.db, "apps");
outdated

This went in under /** The repo a route names, if the viewer may see it. */, which is loadRepo's doc comment. Now appLimitResponse has two doc comments and loadRepo has none. Move this function above the // ---- resolution ---- line, or below loadRepo.

1037 return json(
1038 {
1039 error: "app_limit",
1040 message: owner ? appLimitWords(ctx.app, owner.id) : "App limit reached.",
1041 limit,
1042 count: owner ? appsOwnedBy(ctx.app, owner.id) : null,

This count (and the sentence) is the other account's private app count. It is reachable by anyone while newApp checks the limit before owner authorization. Once the owner is resolved with the actor's rights, this is fine.

1043 },
1044 403,
1045 );
1046 }
1047
10311048 // ---- resolution ----
10321049
10331050 /** The repo a route names, if the viewer may see it. */
@@ -4351,7 +4368,16 @@ export function apiRoutes(router: Router<Handler>): void {
43514368 : {}),
43524369 ...(typeof input.agent === "string" ? { agent: input.agent } : {}),
43534370 });
4354 if (!made.ok) return respondAccess(made.error, 422);
4371 if (!made.ok) {
4372 if (made.error === "app_limit")
4373 return appLimitResponse(
4374 ctx,
4375 (typeof input.owner === "string" && input.owner) ||
4376 ctx.auth.user?.username ||
4377 "",
4378 );
4379 return respondAccess(made.error, 422);
4380 }
43554381 return json(
43564382 {
43574383 repo: repoJson(ctx, made.value.repo),
@@ -4461,7 +4487,11 @@ export function apiRoutes(router: Router<Handler>): void {
44614487 if (!repo) return notFound();
44624488 const input = await body(ctx.request);
44634489 const enabled = enableApp(ctx.app, ctx.auth.user, repo, appPatch(input));
4464 if (!enabled.ok) return respondAccess(enabled.error, 422);
4490 if (!enabled.ok) {
4491 if (enabled.error === "app_limit")
4492 return appLimitResponse(ctx, repo.ownerName);
4493 return respondAccess(enabled.error, 422);
4494 }
44654495 return json(
44664496 appJson(ctx, enabled.value, repo, appsRuntime(ctx.app)),
44674497 201,
src/settings/quotas.ts +2 −2
@@ -2,8 +2,8 @@
22 * Per-account quotas — how much one account may own. Stored in
33 * `forge_setting` under `quota.<key>`, and every change is kept in
44 * `forge_setting_change` (who, when, from what to what). The default for
5 * each is no limit (`null`), and this part enforces nothing: no create path
6 * asks yet. Room for more keys is the `QUOTA_KEYS` list.
5 * each is no limit (`null`), and creating an app asks the apps limit
6 * (`actions/app_limit.ts`); repositories are not enforced yet. Room for more keys is the `QUOTA_KEYS` list.

This line runs past the module's wrap width. The README makes the same claim and says the opposite ("Nothing enforces a quota yet"), so both need updating. Tracked in #2152.

77 */
88
99 import type { Database } from "bun:sqlite";
src/web/pages/apps.ts +9 −1
@@ -12,6 +12,8 @@
1212 * 404 for everyone.
1313 */
1414
15 import { appLimitWords } from "../../actions/app_limit.ts";
16 import { getUserByName } from "../../core/users.ts";
1517 import {
1618 appView,
1719 newApp,
@@ -406,6 +408,10 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
406408 if (!made.ok) {
407409 if (made.error === "forbidden_owner" || made.error === "unauthorized")
408410 return new Response("forbidden", { status: 403 });
411 const ownerId = getUserByName(
412 ctx.app.db,
413 values.owner || ctx.auth.user.username,
414 )?.id;
409415 return page(
410416 {
411417 ctx,
@@ -413,7 +419,9 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
413419 body: newAppForm(
414420 ctx,
415421 values,
416 NEW_APP_ERRORS[made.error] ?? "That app could not be created.",
422 made.error === "app_limit" && ownerId !== undefined
423 ? appLimitWords(ctx.app, ownerId)
424 : (NEW_APP_ERRORS[made.error] ?? "That app could not be created."),
417425 ),
418426 scriptJs: appNameJs,
419427 },
src/web/pages/settings.ts +4 −1
@@ -8,6 +8,7 @@
88 * same-origin POST and every one of them says what happened.
99 */
1010
11 import { appLimitWords } from "../../actions/app_limit.ts";
1112 import {
1213 deleteAppEnvVar,
1314 disableApp,
@@ -874,7 +875,9 @@ export async function appSettingsUpdate(ctx: Ctx): Promise<Response> {
874875 repo,
875876 record,
876877 values,
877 APP_ERRORS[saved.error] ?? "Those settings could not be saved.",
878 saved.error === "app_limit"
879 ? appLimitWords(ctx.app, repo.ownerId)
880 : (APP_ERRORS[saved.error] ?? "Those settings could not be saved."),
878881 ),
879882 undefined,
880883 422,
test/quotas.test.ts +139
@@ -11,6 +11,11 @@ import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { getQuotas, quotaChanges } from "../src/settings/quotas.ts";
1313 import { createToken } from "../src/core/tokens.ts";
14 import { newApp } from "../src/actions/apps.ts";
15 import { createRepo } from "../src/actions/repos.ts";
16 import { makeItYours } from "../src/actions/make_it_yours.ts";
17 import { getRepo } from "../src/core/repos.ts";
18 import { createSession } from "../src/core/sessions.ts";
1419 import { createUser, type User } from "../src/core/users.ts";
1520 import { serve, type Servers } from "../src/http/server.ts";
1621
@@ -177,4 +182,138 @@ describe("quotas", () => {
177182 expect(text).toContain("alice");
178183 expect(text).toContain("no limit");
179184 });
185
186 test("with the apps limit at 1, a fresh account's second app is refused, the owner's is not", async () => {
187 const admin = createToken(app.db, alice.id, "t", "admin").token;
188 const bobT = createToken(app.db, bob.id, "t", "admin").token;
189 const create = (token: string, name: string) =>
190 fetch(`${servers.url}/api/v1/apps`, {
191 method: "POST",
192 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
193 body: JSON.stringify({ name }),
194 });
195 // No limit set: nothing changes.
196 expect((await create(bobT, "free-one")).status).toBe(201);
197 expect((await create(bobT, "free-two")).status).toBe(201);
198 app.db.run("DELETE FROM app WHERE repo_id IN (SELECT id FROM repo WHERE owner_id = ?1)", [bob.id]);
199
200 await api(admin, "PATCH", { quotas: { apps: 1 } });
201 try {
202 expect((await create(bobT, "first")).status).toBe(201);
203 const second = await create(bobT, "second");
204 expect(second.status).toBe(403);
205 const refusal = (await second.json()) as any;
206 expect(refusal).toMatchObject({ error: "app_limit", limit: 1, count: 1 });
207 expect(refusal.message).toContain("1 of 1 apps");
208
209 // The same sentence on the page.
210 const signin = await fetch(`${servers.url}/signin`, {
211 method: "POST",
212 redirect: "manual",
213 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
214 body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }),
215 });
216 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
217 const form = await fetch(`${servers.url}/apps/new`, {
218 method: "POST",
219 redirect: "manual",
220 headers: {
221 cookie,
222 "content-type": "application/x-www-form-urlencoded",
223 origin: servers.url,
224 },
225 body: new URLSearchParams({ name: "third", visibility: "private" }),
226 });
227 expect(form.status).toBe(422);
228 expect(await form.text()).toContain("1 of 1 apps");
229
230 // The owner is never limited.
231 expect((await create(admin, "owner-one")).status).toBe(201);
232 expect((await create(admin, "owner-two")).status).toBe(201);
233 } finally {
234 await api(admin, "PATCH", { quotas: { apps: null } });
235 }
236 });
237
238 test("at the limit, make-it-yours, the repo app endpoint and the settings form refuse the same way", async () => {
239 const admin = createToken(app.db, alice.id, "t", "admin").token;
240 const made = await createUser(app.db, {
241 trusted: true,
242 username: "carol",
243 email: "[email protected]",
244 password: "a-good-passphrase",
245 });
246 if (!made.ok) throw new Error(made.error);
247 const carol = made.value;
248 const source = await newApp(app, alice, { name: "seed", visibility: "public" });
249 if (!source.ok) throw new Error(String(source.error));
250 await api(admin, "PATCH", { quotas: { apps: 1 } });
251 try {
252 expect((await newApp(app, carol, { name: "mine", visibility: "private" })).ok).toBe(true);
253 const bare = await createRepo(app, carol, { name: "bare" });
254 if (!bare.ok) throw new Error(String(bare.error));
255
256 // Make it yours: refused before the fork, no repository left behind.
257 for (const dryRun of [true, false]) {
258 const copied = await makeItYours(app, carol, source.value.repo, { dryRun });
259 expect(copied.ok).toBe(false);
260 if (!copied.ok) {
261 expect(copied.error.kind).toBe("invalid");
262 expect(JSON.stringify(copied.error)).toContain("1 of 1 apps");
263 }
264 }
265 expect(getRepo(app.db, "carol", "seed")).toBeUndefined();
266
267 // POST /api/v1/repos/:owner/:name/app
268 const carolT = createToken(app.db, carol.id, "t", "admin").token;
269 const enable = await fetch(`${servers.url}/api/v1/repos/carol/bare/app`, {
270 method: "POST",
271 headers: { authorization: `Bearer ${carolT}`, "content-type": "application/json" },
272 body: "{}",
273 });
274 expect(enable.status).toBe(403);
275 const refusal = (await enable.json()) as any;
276 expect(refusal).toMatchObject({ error: "app_limit", limit: 1, count: 1 });
277 expect(refusal.message).toContain("1 of 1 apps");
278
279 // The app settings form.
280 const cookie = `forge_session=${createSession(app.db, carol.id).token}`;
281 const form = await fetch(`${servers.url}/carol/bare/settings/app`, {
282 method: "POST",
283 redirect: "manual",
284 headers: {
285 cookie,
286 "content-type": "application/x-www-form-urlencoded",
287 origin: servers.url,
288 },
289 body: new URLSearchParams({ slug: "bare", access: "private", previewData: "copy" }),
290 });
291 expect(form.status).toBe(422);
292 expect(await form.text()).toContain("1 of 1 apps");
293 } finally {
294 await api(admin, "PATCH", { quotas: { apps: null } });
295 }
296 });
297
298 test("a stranger naming an account at its limit is told forbidden_owner, never its count", async () => {
299 const admin = createToken(app.db, alice.id, "t", "admin").token;
300 const bobT = createToken(app.db, bob.id, "t", "admin").token;
301 const owned = await newApp(app, alice, { name: "alices-one", visibility: "private" });
302 if (!owned.ok) throw new Error(String(owned.error));
303 await api(admin, "PATCH", { quotas: { apps: 1 } });
304 try {
305 const res = await fetch(`${servers.url}/api/v1/apps`, {
306 method: "POST",
307 headers: { authorization: `Bearer ${bobT}`, "content-type": "application/json" },
308 body: JSON.stringify({ name: "x", owner: "alice" }),
309 });
310 expect(res.status).toBe(422);
311 const body = (await res.json()) as any;
312 expect(body.error).toBe("forbidden_owner");
313 expect(body).not.toHaveProperty("count");
314 expect(JSON.stringify(body)).not.toContain("apps");
315 } finally {
316 await api(admin, "PATCH", { quotas: { apps: null } });
317 }
318 });
180319 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs