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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2094-a-set-app-limit-refuses-one-more-app-and → main 3 commits fork 5 threads · 5 unresolved
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"; | ||
| 10 | 10 | import { getIssue } from "../core/issues.ts"; |
| 11 | 11 | import { mayView } from "../core/sealed.ts"; |
| 12 | 12 | import { requireVerified } from "./verify.ts"; |
| 13 | import { requireAppRoom } from "./app_limit.ts"; | |
| 13 | 14 | import type { App } from "../app.ts"; |
| 14 | 15 | import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts"; |
| 15 | 16 | import { grantUrl, originFor } from "../apps/proxy.ts"; |
| @@ -48,7 +49,7 @@ import type { Issue } from "../core/issues.ts"; | ||
| 48 | 49 | import { getPull } from "../core/pulls.ts"; |
| 49 | 50 | import { canEnterApp, canWrite, getRepo, type Repo } from "../core/repos.ts"; |
| 50 | 51 | import type { Run } from "../core/runs.ts"; |
| 51 | import type { User } from "../core/users.ts"; | |
| 52 | import { getUserByName, type User } from "../core/users.ts"; | |
| 52 | 53 | import { resolveOid } from "../git/merge.ts"; |
| 53 | 54 | import { repoDir } from "../git/repos.ts"; |
| 54 | 55 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -64,6 +65,7 @@ import { | ||
| 64 | 65 | commitToBranch, |
| 65 | 66 | createRepo, |
| 66 | 67 | deleteRepo, |
| 68 | resolveOwner, | |
| 67 | 69 | type CreateRepoActionError, |
| 68 | 70 | } from "./repos.ts"; |
| 69 | 71 | |
| @@ -79,11 +81,13 @@ export function enableApp( | ||
| 79 | 81 | actor: User | undefined, |
| 80 | 82 | repo: Repo, |
| 81 | 83 | input: AppSettingsInput = {}, |
| 82 | ): Result<AppRecord, "not_found" | CreateAppError | "unverified"> { | |
| 84 | ): Result<AppRecord, "not_found" | CreateAppError | "unverified" | "app_limit"> { | |
| 83 | 85 | const admin = requireRepoAdmin(app, actor, repo); |
| 84 | 86 | if (!admin.ok) return admin; |
| 85 | 87 | const proven = requireVerified(app, admin.value); |
| 86 | 88 | if (!proven.ok) return proven; |
| 89 | const room = requireAppRoom(app, admin.value, repo.ownerId); | |
| 90 | if (!room.ok) return room; | |
| 87 | 91 | const created = insertApp(app.db, { |
| 88 | 92 | repoId: repo.id, |
| 89 | 93 | slug: input.slug ?? slugFor(repo.name), |
| @@ -271,7 +275,8 @@ export type NewAppError = | ||
| 271 | 275 | | CreateRepoActionError |
| 272 | 276 | | CreateAppError |
| 273 | 277 | | "scaffold_failed" |
| 274 | | "no_such_agent"; | |
| 278 | | "no_such_agent" | |
| 279 | | "app_limit"; | |
| 275 | 280 | |
| 276 | 281 | /** |
| 277 | 282 | * The golden path's first step, as one call: a repository with a working |
| @@ -293,6 +298,14 @@ export async function newApp( | ||
| 293 | 298 | : getAgentByName(app.db, input.agent); |
| 294 | 299 | if (input.agent && !agent) return err("no_such_agent"); |
| 295 | 300 | |
| 301 | // Refuse at the limit before a repository exists to take back. | |
outdated
High: |
||
| 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 | ||
| 296 | 309 | // Refuse a taken slug before anything exists to take back. |
| 297 | 310 | // A name the owner already has stays `name_taken`, which createRepo says. |
| 298 | 311 | if ( |
src/actions/make_it_yours.ts +9 −1
| @@ -40,6 +40,7 @@ import { | ||
| 40 | 40 | } from "../core/repos.ts"; |
| 41 | 41 | import { isActive, type User } from "../core/users.ts"; |
| 42 | 42 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 43 | import { requireAppRoom, appLimitWords } from "./app_limit.ts"; | |
| 43 | 44 | import { enableApp } from "./apps.ts"; |
| 44 | 45 | import { FORKS_PER_DAY, forkRepo } from "./repos.ts"; |
| 45 | 46 | |
| @@ -184,10 +185,17 @@ export async function makeItYours( | ||
| 184 | 185 | ? err({ kind: "unverified" }) |
| 185 | 186 | : err({ |
| 186 | 187 | 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}`, | |
| 188 | 192 | }); |
| 189 | 193 | return ok({ ...plan, made: { repo: copy, app: enabled.value } }); |
| 190 | 194 | } |
| 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) }); | |
| 191 | 199 | const slug = freeSlug(app, { name }, actor.username); |
| 192 | 200 | const plan = { |
| 193 | 201 | dryRun, |
src/actions/repos.ts +1 −1
| @@ -88,7 +88,7 @@ function requireNewAccountGuard( | ||
| 88 | 88 | } |
| 89 | 89 | |
| 90 | 90 | /** Resolve the owner id for a new repo: the actor, or an org they can write to. */ |
| 91 | function resolveOwner( | |
| 91 | export function resolveOwner( | |
| 92 | 92 | app: App, |
| 93 | 93 | actor: User, |
| 94 | 94 | owner: string, |
src/http/api.ts +32 −2
| @@ -219,6 +219,8 @@ import { isPinned } from "../core/pins.ts"; | ||
| 219 | 219 | import { listWebhooks } from "../core/webhooks.ts"; |
| 220 | 220 | import { diffCommit } from "../git/diff.ts"; |
| 221 | 221 | import { runGit } from "../git/env.ts"; |
| 222 | import { appLimitWords, appsOwnedBy } from "../actions/app_limit.ts"; | |
| 223 | import { getQuota } from "../settings/quotas.ts"; | |
| 222 | 224 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; |
| 223 | 225 | import { listBranches } from "../git/branches.ts"; |
| 224 | 226 | import { |
| @@ -1028,6 +1030,21 @@ function positiveInt(raw: string | undefined): number | undefined { | ||
| 1028 | 1030 | return Number.isInteger(n) && n > 0 ? n : undefined; |
| 1029 | 1031 | } |
| 1030 | 1032 | |
| 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 |
||
| 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 |
||
| 1043 | }, | |
| 1044 | 403, | |
| 1045 | ); | |
| 1046 | } | |
| 1047 | ||
| 1031 | 1048 | // ---- resolution ---- |
| 1032 | 1049 | |
| 1033 | 1050 | /** The repo a route names, if the viewer may see it. */ |
| @@ -4351,7 +4368,16 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4351 | 4368 | : {}), |
| 4352 | 4369 | ...(typeof input.agent === "string" ? { agent: input.agent } : {}), |
| 4353 | 4370 | }); |
| 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 | } | |
| 4355 | 4381 | return json( |
| 4356 | 4382 | { |
| 4357 | 4383 | repo: repoJson(ctx, made.value.repo), |
| @@ -4461,7 +4487,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4461 | 4487 | if (!repo) return notFound(); |
| 4462 | 4488 | const input = await body(ctx.request); |
| 4463 | 4489 | 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 | } | |
| 4465 | 4495 | return json( |
| 4466 | 4496 | appJson(ctx, enabled.value, repo, appsRuntime(ctx.app)), |
| 4467 | 4497 | 201, |
src/settings/quotas.ts +2 −2
| @@ -2,8 +2,8 @@ | ||
| 2 | 2 | * Per-account quotas — how much one account may own. Stored in |
| 3 | 3 | * `forge_setting` under `quota.<key>`, and every change is kept in |
| 4 | 4 | * `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. |
||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
src/web/pages/apps.ts +9 −1
| @@ -12,6 +12,8 @@ | ||
| 12 | 12 | * 404 for everyone. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { appLimitWords } from "../../actions/app_limit.ts"; | |
| 16 | import { getUserByName } from "../../core/users.ts"; | |
| 15 | 17 | import { |
| 16 | 18 | appView, |
| 17 | 19 | newApp, |
| @@ -406,6 +408,10 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 406 | 408 | if (!made.ok) { |
| 407 | 409 | if (made.error === "forbidden_owner" || made.error === "unauthorized") |
| 408 | 410 | return new Response("forbidden", { status: 403 }); |
| 411 | const ownerId = getUserByName( | |
| 412 | ctx.app.db, | |
| 413 | values.owner || ctx.auth.user.username, | |
| 414 | )?.id; | |
| 409 | 415 | return page( |
| 410 | 416 | { |
| 411 | 417 | ctx, |
| @@ -413,7 +419,9 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 413 | 419 | body: newAppForm( |
| 414 | 420 | ctx, |
| 415 | 421 | 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."), | |
| 417 | 425 | ), |
| 418 | 426 | scriptJs: appNameJs, |
| 419 | 427 | }, |
src/web/pages/settings.ts +4 −1
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * same-origin POST and every one of them says what happened. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { appLimitWords } from "../../actions/app_limit.ts"; | |
| 11 | 12 | import { |
| 12 | 13 | deleteAppEnvVar, |
| 13 | 14 | disableApp, |
| @@ -874,7 +875,9 @@ export async function appSettingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 874 | 875 | repo, |
| 875 | 876 | record, |
| 876 | 877 | 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."), | |
| 878 | 881 | ), |
| 879 | 882 | undefined, |
| 880 | 883 | 422, |
test/quotas.test.ts +139
| @@ -11,6 +11,11 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { getQuotas, quotaChanges } from "../src/settings/quotas.ts"; |
| 13 | 13 | 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"; | |
| 14 | 19 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | 20 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 21 | |
| @@ -177,4 +182,138 @@ describe("quotas", () => { | ||
| 177 | 182 | expect(text).toContain("alice"); |
| 178 | 183 | expect(text).toContain("no limit"); |
| 179 | 184 | }); |
| 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 | }); | |
| 180 | 319 | }); |
This works for
newApp, butmakeItYoursonly gets here afterforkRepohas 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 returnappLimitWords.