Public: anyone can read this; the crew can change it.
A set repository limit refuses one more repository, fork or copy, and the refusal says "10 of 10 repositories" #2130
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2093-a-set-repository-limit-refuses-one-more → main 6 commits fork 8 threads · 8 unresolved
README.md +1 −1
| @@ -1126,7 +1126,7 @@ A forge admin sees every declared flag at `/settings/flags` (Settings → Flags) | ||
| 1126 | 1126 | |
| 1127 | 1127 | Every limit the owner can hit is declared in `src/settings/limits.ts` with its default, bounds and reason, and changed live by a forge admin at `/admin/limits`, `PUT /api/v1/admin/limits/:key` or the `limits` MCP tool (never by an agent). Each change is kept with who made it. |
| 1128 | 1128 | |
| 1129 | Per-account quotas (`src/settings/quotas.ts`) — how many `repositories` and `apps` one account may own — are set the same way: **Account quotas** on `/admin/limits`, `GET`/`PATCH /api/v1/admin/settings` (`{"quotas": {"repositories": 5, "apps": null}}`) or the `quotas` MCP tool (dry by default, admin scope, never an agent). A value is a positive whole number, or empty/`null` for no limit, which is the default; anything else is a 422. Every change is kept with who made it and listed beside the form. The `apps` limit is enforced (`src/actions/app_limit.ts`): with one set, an account owning that many apps is refused one more, unless the actor is a forge admin; with none set nothing is asked. The check and the insert are separate statements, so two concurrent creates can pass by one. The refusal reads "This account is at its limit: 3 of 3 apps. Remove one to make room." and each door answers it its own way: the API (`POST /api/v1/apps` and `POST /api/v1/repos/:owner/:name/app`) a 403 `{"error": "app_limit", "message", "limit", "count"}`; the new-app page and the app settings form a 422 with the sentence on the form; make-it-yours the same sentence as an invalid error, before the fork, so no repository is left behind. The `repositories` limit is not enforced yet (#2093): no create path reads it. | |
| 1129 | Per-account quotas (`src/settings/quotas.ts`) — how many `repositories` and `apps` one account may own — are set the same way: **Account quotas** on `/admin/limits`, `GET`/`PATCH /api/v1/admin/settings` (`{"quotas": {"repositories": 5, "apps": null}}`) or the `quotas` MCP tool (dry by default, admin scope, never an agent). A value is a positive whole number, or empty/`null` for no limit, which is the default; anything else is a 422. Every change is kept with who made it and listed beside the form. The `apps` limit is enforced (`src/actions/app_limit.ts`): with one set, an account owning that many apps is refused one more, unless the actor is a forge admin; with none set nothing is asked. The check and the insert are separate statements, so two concurrent creates can pass by one. The refusal reads "This account is at its limit: 3 of 3 apps. Remove one to make room." and each door answers it its own way: the API (`POST /api/v1/apps` and `POST /api/v1/repos/:owner/:name/app`) a 403 `{"error": "app_limit", "message", "limit", "count"}`; the new-app page and the app settings form a 422 with the sentence on the form; make-it-yours the same sentence as an invalid error, before the fork, so no repository is left behind. The `repositories` limit is enforced too (`src/actions/repos.ts`): with one set, an account owning that many repositories (sealed forks are not counted) is refused one more by create, fork, make-it-yours, mirror, migrate and new app, before anything is made on disk. Forge admins and agents are exempt, and creating under an organisation is not counted: the quota is per account, so only a repository the actor owns itself is asked. The refusal reads "2 of 2 repositories"; the API answers 403 `{"error": "repository_limit", "message"}`, the pages say "Not made: 2 of 2 repositories." on the form (a 422), and the terminal shows it as a toast. The check and the insert are separate statements here as well, so two concurrent creates can pass by one. | |
| 1130 | 1130 | |
| 1131 | 1131 | ## Choosing among variants |
| 1132 | 1132 |
src/actions/make_it_yours.ts +6 −2
| @@ -42,7 +42,7 @@ import { isActive, type User } from "../core/users.ts"; | ||
| 42 | 42 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 43 | 43 | import { requireAppRoom, appLimitWords } from "./app_limit.ts"; |
| 44 | 44 | import { enableApp } from "./apps.ts"; |
| 45 | import { FORKS_PER_DAY, forkRepo } from "./repos.ts"; | |
| 45 | import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "./repos.ts"; | |
| 46 | 46 | |
| 47 | 47 | export interface MakeItYoursPlan { |
| 48 | 48 | readonly dryRun: boolean; |
| @@ -69,7 +69,9 @@ export type MakeItYoursError = | ||
| 69 | 69 | | { readonly kind: "own" } |
| 70 | 70 | /** Today's copies are made; another may be made from `resetsAt`. */ |
| 71 | 71 | | { readonly kind: "limit"; readonly resetsAt: string } |
| 72 | | { readonly kind: "invalid"; readonly message: string }; | |
| 72 | | { readonly kind: "invalid"; readonly message: string } | |
| 73 | /** The owner's repositories limit is reached: `message` says "10 of 10 repositories". */ | |
| 74 | | { readonly kind: "repository_limit"; readonly message: string }; | |
| 73 | 75 | |
| 74 | 76 | export const COPIED = ["the code", "its history"] as const; |
| 75 | 77 | export const NOT_COPIED = [ |
| @@ -228,6 +230,8 @@ export async function makeItYours( | ||
| 228 | 230 | }); |
| 229 | 231 | case "limit": |
| 230 | 232 | return err({ kind: "limit", resetsAt: resetsAt(app, actor.id, now) }); |
| 233 | case "repository_limit": | |
| 234 | return err({ kind: "repository_limit", message: repositoryLimitMessage(app, actor) }); | |
| 231 | 235 | default: |
| 232 | 236 | return err({ |
| 233 | 237 | kind: "invalid", |
src/actions/migrate.ts +3 −1
| @@ -170,7 +170,7 @@ export function migrationJobFor( | ||
| 170 | 170 | } |
| 171 | 171 | |
| 172 | 172 | /** A refusal in words a person can act on. */ |
| 173 | export function migrateMessage(error: MigrateError): string { | |
| 173 | export function migrateMessage(error: MigrateError, limit = "at the repositories limit"): string { | |
| 174 | 174 | switch (error) { |
| 175 | 175 | case "invalid_url": |
| 176 | 176 | return "That is not a URL this forge will clone from. Paste an https:// (or http://, git://) address of a public repository, with no password in it."; |
| @@ -196,6 +196,8 @@ export function migrateMessage(error: MigrateError): string { | ||
| 196 | 196 | return "Verify your email first: follow the link we sent, or ask for another under Settings."; |
| 197 | 197 | case "refused": |
| 198 | 198 | return "New accounts cannot create repositories yet."; |
| 199 | case "repository_limit": | |
| 200 | return `Not made: ${limit}.`; | |
| 199 | 201 | } |
| 200 | 202 | } |
| 201 | 203 |
src/actions/repos.ts +26 −2
| @@ -9,6 +9,7 @@ import type { App } from "../app.ts"; | ||
| 9 | 9 | import { canSeeSealed } from "../core/sealed.ts"; |
| 10 | 10 | import { capacityValue } from "../core/capacity.ts"; |
| 11 | 11 | import { NEW_FOR_MS } from "./accounts.ts"; |
| 12 | import { repositoryLimitSentence } from "../settings/quotas.ts"; | |
| 12 | 13 | import { |
| 13 | 14 | createLabel as insertLabel, |
| 14 | 15 | deleteLabel as removeLabel, |
| @@ -69,7 +70,27 @@ export type CreateRepoActionError = | ||
| 69 | 70 | | CreateRepoError |
| 70 | 71 | | "unauthorized" |
| 71 | 72 | | "forbidden_owner" |
| 72 | | "refused"; | |
| 73 | | "refused" | |
| 74 | | "repository_limit"; | |
| 75 | ||
| 76 | /** | |
| 77 | * The repositories limit (#2093): when the owner has set one, an account at | |
| 78 | * it makes no more, by create, fork or copy. Admins and agents are not held | |
| 79 | * to it. Only a repository the account owns itself is counted and asked: creating | |
| 80 | * under an organisation is not, since the quota is per account. Returns the refusal sentence ("10 of 10 repositories") or nothing. | |
| 81 | */ | |
| 82 | export function repositoryLimitRefusal(app: App, user: User, ownerId = user.id): string | undefined { | |
| 83 | if (user.isAdmin || user.kind === "agent") return undefined; | |
| 84 | return repositoryLimitSentence(app.db, ownerId); | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * The sentence for a refusal just given. Always words: if a repository went | |
| 89 | * between the refusal and now, it still says the limit was the reason. | |
| 90 | */ | |
| 91 | export function repositoryLimitMessage(app: App, user: User, ownerId = user.id): string { | |
| 92 | return repositoryLimitRefusal(app, user, ownerId) ?? "at the repositories limit"; | |
| 93 | } | |
| 73 | 94 | |
| 74 | 95 | /** |
| 75 | 96 | * The new-account guard (`guard_new_accounts`): when it is on, an account |
| @@ -117,6 +138,8 @@ export async function createRepo( | ||
| 117 | 138 | if (!guarded.ok) return guarded; |
| 118 | 139 | const ownerId = resolveOwner(app, user.value, input.owner ?? ""); |
| 119 | 140 | if (ownerId === undefined) return err("forbidden_owner"); |
outdated
The limit is only asked when the owner is the actor, so creating under an org is never counted. That is defensible, because the quota is per account, but write it down: in the doc comment above |
||
| 141 | if (ownerId === user.value.id && repositoryLimitRefusal(app, user.value) !== undefined) | |
| 142 | return err("repository_limit"); | |
| 120 | 143 | const created = await initRepo(app.db, app.reposRoot, { |
| 121 | 144 | ownerId, |
| 122 | 145 | name: input.name, |
| @@ -148,13 +171,14 @@ export async function forkRepo( | ||
| 148 | 171 | parent: Repo, |
| 149 | 172 | name?: string, |
| 150 | 173 | options: { visibility?: "public" | "private"; now?: number } = {}, |
| 151 | ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused">> { | |
| 174 | ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused" | "repository_limit">> { | |
| 152 | 175 | const user = requireParticipant(app, actor, parent); |
| 153 | 176 | if (!user.ok) return user; |
| 154 | 177 | const proven = requireVerified(app, user.value); |
| 155 | 178 | if (!proven.ok) return proven; |
| 156 | 179 | const guarded = requireNewAccountGuard(app, user.value); |
| 157 | 180 | if (!guarded.ok) return guarded; |
| 181 | if (repositoryLimitRefusal(app, user.value) !== undefined) return err("repository_limit"); | |
| 158 | 182 | const forked = await cloneRepo( |
| 159 | 183 | app.db, |
| 160 | 184 | app.reposRoot, |
src/http/api.ts +11 −2
| @@ -133,6 +133,7 @@ import { | ||
| 133 | 133 | deleteWebhook, |
| 134 | 134 | FORKS_PER_DAY, |
| 135 | 135 | forkRepo, |
| 136 | repositoryLimitMessage, | |
| 136 | 137 | privateRepoForSealed, |
| 137 | 138 | updateRepoSettings, |
| 138 | 139 | } from "../actions/repos.ts"; |
| @@ -1876,6 +1877,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1876 | 1877 | if (!created.ok) { |
| 1877 | 1878 | if (created.error === "forbidden_owner") |
| 1878 | 1879 | return json({ error: "forbidden_owner" }, 403); |
| 1880 | if (created.error === "repository_limit") | |
| 1881 | return apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)); | |
| 1879 | 1882 | return respondAccess(created.error, 422); |
| 1880 | 1883 | } |
| 1881 | 1884 | return json(created.value, 201); |
| @@ -1986,7 +1989,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1986 | 1989 | str(input.name) || undefined, |
| 1987 | 1990 | ); |
| 1988 | 1991 | if (!forked.ok) |
| 1989 | return forked.error === "limit" | |
| 1992 | return forked.error === "repository_limit" | |
| 1993 | ? apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)) | |
| 1994 | : forked.error === "limit" | |
| 1990 | 1995 | ? apiError("limit", 429, `at most ${FORKS_PER_DAY} forks a day: try again tomorrow`) |
| 1991 | 1996 | : respondAccess(forked.error, 422); |
| 1992 | 1997 | return json(forked.value, 201); |
| @@ -4242,6 +4247,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4242 | 4247 | }, |
| 4243 | 4248 | 429, |
| 4244 | 4249 | ) |
| 4250 | : made.error.kind === "repository_limit" | |
| 4251 | ? apiError("repository_limit", 403, made.error.message) | |
| 4245 | 4252 | : made.error.kind === "invalid" |
| 4246 | 4253 | ? apiError("invalid", 422, made.error.message) |
| 4247 | 4254 | : made.error.kind === "own" |
| @@ -4371,6 +4378,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4371 | 4378 | ...(typeof input.agent === "string" ? { agent: input.agent } : {}), |
| 4372 | 4379 | }); |
| 4373 | 4380 | if (!made.ok) { |
| 4381 | if (made.error === "repository_limit") | |
| 4382 | return apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)); | |
| 4374 | 4383 | if (made.error === "app_limit") |
| 4375 | 4384 | return appLimitResponse( |
| 4376 | 4385 | ctx, |
| @@ -4445,7 +4454,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4445 | 4454 | if (!started.ok) |
| 4446 | 4455 | return started.error === "unauthorized" |
| 4447 | 4456 | ? respondAccess(started.error, 401) |
| 4448 | : apiError(started.error, 422, migrateMessage(started.error)); | |
| 4457 | : apiError(started.error, 422, migrateMessage(started.error, repositoryLimitMessage(ctx.app, ctx.auth.user!))); | |
Here |
||
| 4449 | 4458 | return json( |
| 4450 | 4459 | { |
| 4451 | 4460 | repo: repoJson(ctx, started.value.repo), |
src/http/mcp.ts +2
| @@ -1396,6 +1396,8 @@ const TOOLS: readonly Tool[] = [ | ||
| 1396 | 1396 | if (!made.ok) |
| 1397 | 1397 | return made.error.kind === "limit" |
| 1398 | 1398 | ? bad("limit", `at most ${COPIES_PER_DAY} copies a day; another after ${made.error.resetsAt}`) |
| 1399 | : made.error.kind === "repository_limit" | |
| 1400 | ? bad("repository_limit", made.error.message) | |
| 1399 | 1401 | : made.error.kind === "invalid" |
| 1400 | 1402 | ? bad("invalid", made.error.message) |
| 1401 | 1403 | : made.error.kind === "own" |
src/origins/mirror.ts +13 −2
| @@ -25,6 +25,7 @@ import { repoDir } from "../git/repos.ts"; | ||
| 25 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 26 | 26 | import { bustOrigin } from "../view/cache.ts"; |
| 27 | 27 | import { providerForOrigin } from "./credential.ts"; |
| 28 | import { repositoryLimitMessage, repositoryLimitRefusal } from "../actions/repos.ts"; | |
| 28 | 29 | import { redact, safeRemoteUrl } from "./credentials.ts"; |
| 29 | 30 | import { adoptDefaultBranch, configureMirror } from "./git-mirror.ts"; |
| 30 | 31 | import { advance, createJob, runningJob, type MirrorJob } from "./jobs.ts"; |
| @@ -37,7 +38,8 @@ export type MirrorError = | ||
| 37 | 38 | | "name_taken" |
| 38 | 39 | | "already_mirrored" |
| 39 | 40 | | "git_init_failed" |
| 40 | | "unreachable"; | |
| 41 | | "unreachable" | |
| 42 | | "repository_limit"; | |
| 41 | 43 | |
| 42 | 44 | export interface MirrorResult { |
| 43 | 45 | readonly repo: Repo; |
| @@ -119,6 +121,10 @@ export async function mirrorRepo( | ||
| 119 | 121 | const chosen = availableName(app, actor.id, wanted, origin); |
| 120 | 122 | if (chosen === undefined) return err("name_taken"); |
| 121 | 123 | |
| 124 | // A mirror is a repository the account owns: the repositories limit asks | |
| 125 | // here as it does at create and fork (#2093). | |
| 126 | if (repositoryLimitRefusal(app, actor) !== undefined) return err("repository_limit"); | |
| 127 | ||
| 122 | 128 | const created = await createRepo(app.db, app.reposRoot, { |
| 123 | 129 | ownerId: actor.id, |
| 124 | 130 | name: chosen, |
| @@ -228,7 +234,10 @@ export function startMirror( | ||
| 228 | 234 | advance(job.id, { |
| 229 | 235 | stage: "failed", |
| 230 | 236 | detail: "", |
| 231 | error: mirrorMessage(result.error), | |
| 237 | error: | |
| 238 | result.error === "repository_limit" | |
| 239 | ? `Not mirrored: ${repositoryLimitMessage(app, actor)}.` | |
| 240 | : mirrorMessage(result.error), | |
| 232 | 241 | }); |
| 233 | 242 | return; |
| 234 | 243 | } |
| @@ -284,6 +293,8 @@ export function mirrorMessage(error: MirrorError): string { | ||
| 284 | 293 | return "the local repository could not be created"; |
| 285 | 294 | case "unreachable": |
| 286 | 295 | return "the origin could not be reached"; |
| 296 | case "repository_limit": | |
| 297 | return "this account is at the forge's repositories limit"; | |
| 287 | 298 | } |
| 288 | 299 | } |
| 289 | 300 |
src/settings/quotas.ts +21 −2
| @@ -3,8 +3,8 @@ | ||
| 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 | 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 | |
| 7 | * keys is the `QUOTA_KEYS` list. | |
| 6 | * (`actions/app_limit.ts`); creating, forking and copying a repository ask the | |
This header now says the limit is enforced, but README.md:1129 still says "not enforced yet: no create path reads it". Update the README paragraph in this pull so the two agree. |
||
| 7 | * repositories limit first (`actions/repos.ts`). Room for more keys is the `QUOTA_KEYS` list. | |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| @@ -49,6 +49,25 @@ export function getQuotas(db: Database): Quotas { | ||
| 49 | 49 | return { repositories: getQuota(db, "repositories"), apps: getQuota(db, "apps") }; |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | /** Repositories an account owns, a sealed fork (nobody's to count) aside. */ | |
| 53 | export function repositoriesOwned(db: Database, ownerId: string): number { | |
| 54 | return ( | |
| 55 | db | |
| 56 | .query<{ n: number }, [string]>( | |
| 57 | "SELECT count(*) AS n FROM repo WHERE owner_id = ?1 AND sealed_for IS NULL", | |
| 58 | ) | |
| 59 | .get(ownerId)?.n ?? 0 | |
| 60 | ); | |
| 61 | } | |
| 62 | ||
| 63 | /** The sentence a refused creation says, or nothing when there is room. */ | |
| 64 | export function repositoryLimitSentence(db: Database, ownerId: string): string | undefined { | |
| 65 | const limit = getQuota(db, "repositories"); | |
| 66 | if (limit === null) return undefined; | |
| 67 | const owned = repositoriesOwned(db, ownerId); | |
| 68 | return owned >= limit ? `${owned} of ${limit} repositories` : undefined; | |
| 69 | } | |
| 70 | ||
| 52 | 71 | /** Set `key` (`null` clears it) and keep the change. */ |
| 53 | 72 | export function setQuota( |
| 54 | 73 | db: Database, |
src/tui/screens/newrepo.ts +9 −3
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | * because the next thing anyone does is look at it. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { createRepo, forkRepo } from "../../actions/repos.ts"; | |
| 6 | import { createRepo, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts"; | |
| 7 | 7 | import { listOrgsForUser } from "../../core/orgs.ts"; |
| 8 | 8 | import type { Repo } from "../../core/repos.ts"; |
| 9 | 9 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -42,7 +42,10 @@ export function newRepoScreen(f: Session, ctx: Ctx, owner?: string): Screen { | ||
| 42 | 42 | visibility, |
| 43 | 43 | }); |
| 44 | 44 | if (!created.ok) { |
| 45 | error = errorText(created.error); | |
| 45 | error = | |
| 46 | created.error === "repository_limit" | |
| 47 | ? `not made: ${repositoryLimitMessage(f.app, f.user!)}` | |
| 48 | : errorText(created.error); | |
| 46 | 49 | ctx.toast(error, "danger"); |
| 47 | 50 | return; |
| 48 | 51 | } |
| @@ -154,7 +157,10 @@ export function forkScreen(f: Session, ctx: Ctx, parent: Repo): Screen { | ||
| 154 | 157 | runAsync(ctx, async () => { |
| 155 | 158 | const forked = await forkRepo(f.app, f.user, parent, name); |
| 156 | 159 | if (!forked.ok) { |
| 157 | error = errorText(forked.error); | |
| 160 | error = | |
| 161 | forked.error === "repository_limit" | |
| 162 | ? `not made: ${repositoryLimitMessage(f.app, f.user!)}` | |
| 163 | : errorText(forked.error); | |
| 158 | 164 | ctx.toast(error, "danger"); |
| 159 | 165 | return; |
| 160 | 166 | } |
src/web/pages/apps.ts +4 −1
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * 404 for everyone. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { repositoryLimitMessage } from "../../actions/repos.ts"; | |
| 15 | 16 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| 16 | 17 | import { getUserByName } from "../../core/users.ts"; |
| 17 | 18 | import { |
| @@ -419,7 +420,9 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 419 | 420 | body: newAppForm( |
| 420 | 421 | ctx, |
| 421 | 422 | values, |
| 422 | made.error === "app_limit" && ownerId !== undefined | |
| 423 | made.error === "repository_limit" | |
| 424 | ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user!)}.` | |
| 425 | : made.error === "app_limit" && ownerId !== undefined | |
| 423 | 426 | ? appLimitWords(ctx.app, ownerId) |
| 424 | 427 | : (NEW_APP_ERRORS[made.error] ?? "That app could not be created."), |
| 425 | 428 | ), |
src/web/pages/migrate.ts +4 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * Every mutation is `migrateRepo`; the page only puts its answer into words. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { repositoryLimitMessage } from "../../actions/repos.ts"; | |
| 10 | 11 | import { listAllAgents } from "../../actions/crew.ts"; |
| 11 | 12 | import { |
| 12 | 13 | migrateMessage, |
| @@ -354,7 +355,9 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> { | ||
| 354 | 355 | if (!started.ok) { |
| 355 | 356 | if (started.error === "forbidden_owner" || started.error === "unauthorized") |
| 356 | 357 | return new Response("forbidden", { status: 403 }); |
| 357 | return again(migrateMessage(started.error)); | |
| 358 | return again( | |
| 359 | migrateMessage(started.error, repositoryLimitMessage(ctx.app, ctx.auth.user!)), | |
| 360 | ); | |
| 358 | 361 | } |
| 359 | 362 | return redirect(`/migrate/jobs/${started.value.job.id}`); |
| 360 | 363 | } |
src/web/pages/repo.ts +3 −1
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { canRead, listForks, type Repo } from "../../core/repos.ts"; |
| 12 | import { FORKS_PER_DAY, forkRepo } from "../../actions/repos.ts"; | |
| 12 | import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts"; | |
| 13 | 13 | import { forkDrift, takeUpdates, type ForkDrift } from "../../actions/forks.ts"; |
| 14 | 14 | import { lineageOf } from "../../core/following.ts"; |
| 15 | 15 | import { |
| @@ -195,6 +195,8 @@ export async function forkSubmit(ctx: Ctx): Promise<Response> { | ||
| 195 | 195 | const reason = |
| 196 | 196 | forked.error === "name_taken" |
| 197 | 197 | ? "You already have a repository by that name." |
| 198 | : forked.error === "repository_limit" | |
| 199 | ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.` | |
| 198 | 200 | : forked.error === "limit" |
| 199 | 201 | ? `That is today's forks made (${FORKS_PER_DAY} a day). Try again tomorrow.` |
| 200 | 202 | : "That fork could not be created."; |
src/web/pages/site.ts +2 −1
| @@ -13,7 +13,7 @@ import { signOutHere } from "../../actions/signins.ts"; | ||
| 13 | 13 | import { clientAddress } from "../../http/peer.ts"; |
| 14 | 14 | import { verifyEmail } from "../../actions/verify.ts"; |
| 15 | 15 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 16 | import { createRepo } from "../../actions/repos.ts"; | |
| 16 | import { createRepo, repositoryLimitMessage } from "../../actions/repos.ts"; | |
| 17 | 17 | import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, signUp } from "../../actions/users.ts"; |
| 18 | 18 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 19 | 19 | import { listPins } from "../../core/pins.ts"; |
| @@ -689,6 +689,7 @@ export async function newRepoSubmit(ctx: Ctx): Promise<Response> { | ||
| 689 | 689 | if (created.error === "forbidden_owner" || created.error === "unauthorized") |
| 690 | 690 | return new Response("forbidden", { status: 403 }); |
| 691 | 691 | const messages: Record<string, string> = { |
| 692 | repository_limit: `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`, | |
| 692 | 693 | name_taken: "That owner already has a repository by that name.", |
| 693 | 694 | invalid_name: "Names start with a letter or digit; no spaces, no .git.", |
| 694 | 695 | git_init_failed: "The repository could not be initialised on disk.", |
src/web/studio/make_it_yours.ts +2
| @@ -128,6 +128,8 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> { | ||
| 128 | 128 | const message = !made.ok |
| 129 | 129 | ? made.error.kind === "invalid" |
| 130 | 130 | ? made.error.message |
| 131 | : made.error.kind === "repository_limit" | |
| 132 | ? `Not made: ${made.error.message}.` | |
| 131 | 133 | : made.error.kind === "own" |
| 132 | 134 | ? "This app is already yours." |
| 133 | 135 | : made.error.kind === "unverified" |
test/quotas.test.ts +180 −4
| @@ -9,15 +9,22 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import { newApp } from "../src/actions/apps.ts"; | |
| 13 | import { createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { makeItYours } from "../src/actions/make_it_yours.ts"; | |
| 15 | import { createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 16 | import { getRepo } from "../src/core/repos.ts"; | |
| 17 | import { mirrorAndWait, newFakeState, serveFakeForge } from "./fake-origin.ts"; | |
| 18 | import { proveAll } from "./helpers/verified.ts"; | |
| 12 | 19 | import { getQuotas, quotaChanges } from "../src/settings/quotas.ts"; |
| 13 | 20 | 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 | 21 | import { createSession } from "../src/core/sessions.ts"; |
| 19 | 22 | import { createUser, type User } from "../src/core/users.ts"; |
| 20 | 23 | import { serve, type Servers } from "../src/http/server.ts"; |
| 24 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 25 | import { newRepoScreen } from "../src/tui/screens/newrepo.ts"; | |
| 26 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 27 | import { settleApp } from "./helpers/tui.ts"; | |
| 21 | 28 | |
| 22 | 29 | describe("quotas", () => { |
| 23 | 30 | let dataDir: string; |
| @@ -317,3 +324,172 @@ describe("quotas", () => { | ||
| 317 | 324 | } |
outdated
These tests cover the action and the JSON API. They don't cover the HTML forms ( |
||
| 318 | 325 | }); |
| 319 | 326 | }); |
| 327 | ||
| 328 | describe("the repositories limit", () => { | |
| 329 | let dataDir: string; | |
| 330 | let app: App; | |
| 331 | let servers: Servers; | |
| 332 | let admin: string; | |
| 333 | let fresh: string; | |
| 334 | let freshUser: User; | |
| 335 | let boss: User; | |
| 336 | ||
| 337 | beforeAll(async () => { | |
| 338 | dataDir = await mkdtemp(join(tmpdir(), "forge-quotas-limit-")); | |
| 339 | app = createApp({ dataDir, port: 0 }); | |
| 340 | servers = await serve(app); | |
| 341 | const users: User[] = []; | |
| 342 | for (const username of ["boss", "fresh"]) { | |
| 343 | const made = await createUser(app.db, { | |
| 344 | trusted: true, | |
| 345 | username, | |
| 346 | email: `${username}@forge.local`, | |
| 347 | password: "a-good-passphrase", | |
| 348 | adminIfFirst: username === "boss", | |
| 349 | }); | |
| 350 | if (!made.ok) throw new Error(made.error); | |
| 351 | users.push(made.value); | |
| 352 | } | |
| 353 | freshUser = users[1]!; | |
| 354 | boss = users[0]!; | |
| 355 | admin = createToken(app.db, users[0]!.id, "t", "admin").token; | |
| 356 | fresh = createToken(app.db, freshUser.id, "t", "rw").token; | |
| 357 | }); | |
| 358 | afterAll(async () => { | |
| 359 | await servers.stop(); | |
| 360 | await rm(dataDir, { recursive: true, force: true }); | |
| 361 | }); | |
| 362 | ||
| 363 | const post = (token: string, path: string, body: unknown) => | |
| 364 | fetch(`${servers.url}${path}`, { | |
| 365 | method: "POST", | |
| 366 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 367 | body: JSON.stringify(body), | |
| 368 | }); | |
| 369 | const setLimit = (value: number | null) => | |
| 370 | fetch(`${servers.url}/api/v1/admin/settings`, { | |
| 371 | method: "PATCH", | |
| 372 | headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" }, | |
| 373 | body: JSON.stringify({ quotas: { repositories: value } }), | |
| 374 | }); | |
| 375 | ||
| 376 | test("past the limit create and fork are refused with the sentence; admins and no limit are free", async () => { | |
| 377 | // No limit: nothing changes. | |
| 378 | expect((await post(admin, "/api/v1/repos", { name: "base", visibility: "public" })).status).toBe(201); | |
| 379 | expect((await setLimit(2)).status).toBe(200); | |
| 380 | ||
| 381 | for (const name of ["one", "two"]) | |
| 382 | expect((await post(fresh, "/api/v1/repos", { name })).status).toBe(201); | |
| 383 | const third = await post(fresh, "/api/v1/repos", { name: "three" }); | |
| 384 | expect(third.status).toBe(403); | |
| 385 | expect(await third.json()).toMatchObject({ | |
| 386 | error: "repository_limit", | |
| 387 | message: "2 of 2 repositories", | |
| 388 | }); | |
| 389 | const fork = await post(fresh, "/api/v1/repos/boss/base/forks", {}); | |
| 390 | expect(fork.status).toBe(403); | |
| 391 | expect(((await fork.json()) as any).message).toBe("2 of 2 repositories"); | |
| 392 | ||
| 393 | // The owner (an admin) is never limited. | |
| 394 | for (const name of ["a", "b", "c"]) | |
| 395 | expect((await post(admin, "/api/v1/repos", { name })).status).toBe(201); | |
| 396 | ||
| 397 | // Clearing the limit lets the fresh account make one more. | |
| 398 | expect((await setLimit(null)).status).toBe(200); | |
| 399 | expect((await post(fresh, "/api/v1/repos", { name: "three" })).status).toBe(201); | |
| 400 | }); | |
| 401 | test("a copy and a mirror say the sentence; an agent is not held to it", async () => { | |
| 402 | // alice's public app, owned by the admin, for the copy. | |
| 403 | const made = await newApp(app, boss, { name: "garden", visibility: "public" }); | |
| 404 | if (!made.ok) throw new Error(String(made.error)); | |
| 405 | expect((await setLimit(2)).status).toBe(200); | |
| 406 | // fresh owns three here, so it is over the line. | |
| 407 | const copy = await makeItYours(app, freshUser, made.value.repo, { dryRun: false }); | |
| 408 | expect(copy.ok).toBe(false); | |
| 409 | if (!copy.ok) expect(copy.error).toEqual({ kind: "repository_limit", message: "3 of 2 repositories" }); | |
| 410 | const viaApi = await post(fresh, "/api/v1/repos/boss/garden/make-it-yours", { dryRun: false }); | |
| 411 | expect(viaApi.status).toBe(403); | |
| 412 | expect(((await viaApi.json()) as any).message).toBe("3 of 2 repositories"); | |
| 413 | ||
| 414 | // A mirror is a repository too. | |
| 415 | const state = newFakeState("forgejo", "someone", "tok"); | |
| 416 | state.repos.push({ | |
| 417 | id: 1, | |
| 418 | fullName: "someone/gears", | |
| 419 | cloneUrl: "http://127.0.0.1:1/someone/gears.git", | |
| 420 | defaultBranch: "main", | |
| 421 | }); | |
| 422 | const fake = serveFakeForge(state); | |
| 423 | try { | |
| 424 | proveAll(app); | |
| 425 | const freshAdmin = createToken(app.db, freshUser.id, "o", "admin").token; | |
| 426 | const connected = await post(freshAdmin, "/api/v1/user/origins", { host: fake.url, token: "tok" }); | |
| 427 | expect(connected.status).toBe(201); | |
| 428 | const originId = ((await connected.json()) as { id: string }).id; | |
| 429 | const job = await mirrorAndWait( | |
| 430 | await post(freshAdmin, `/api/v1/user/origins/${originId}/mirror`, { fullName: "someone/gears" }), | |
| 431 | ); | |
| 432 | expect(job?.stage).toBe("failed"); | |
| 433 | expect(job?.error).toBe("Not mirrored: 3 of 2 repositories."); | |
| 434 | } finally { | |
| 435 | fake.stop(); | |
| 436 | } | |
| 437 | ||
| 438 | // An agent is held to nothing: it forks per issue to work. | |
| 439 | const agent = await createAgent(app, boss, { username: "worker" }); | |
| 440 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 441 | const forked = await forkRepo(app, agent.value.user, getRepo(app.db, "boss", "garden")!); | |
| 442 | expect(forked.ok).toBe(true); | |
| 443 | ||
| 444 | expect((await setLimit(null)).status).toBe(200); | |
| 445 | }); | |
| 446 | test("owning exactly the limit is refused with 'N of N'; the pages and the terminal say so too", async () => { | |
| 447 | const made = await createUser(app.db, { | |
| 448 | trusted: true, | |
| 449 | username: "exact", | |
| 450 | email: "[email protected]", | |
| 451 | password: "a-good-passphrase", | |
| 452 | }); | |
| 453 | if (!made.ok) throw new Error(made.error); | |
| 454 | const exact = made.value; | |
| 455 | expect((await setLimit(2)).status).toBe(200); | |
| 456 | for (const name of ["x1", "x2"]) expect((await createRepo(app, exact, { name })).ok).toBe(true); | |
| 457 | ||
| 458 | const refused = await createRepo(app, exact, { name: "x3" }); | |
| 459 | expect(refused).toEqual({ ok: false, error: "repository_limit" }); | |
| 460 | ||
| 461 | // The new-repository page: a 422 with the sentence on the form. | |
| 462 | const cookie = `forge_session=${createSession(app.db, exact.id).token}`; | |
| 463 | const form = await fetch(`${servers.url}/new`, { | |
| 464 | method: "POST", | |
| 465 | headers: { cookie, origin: servers.url }, | |
| 466 | body: new URLSearchParams({ owner: "exact", name: "x3" }), | |
| 467 | redirect: "manual", | |
| 468 | }); | |
| 469 | expect(form.status).toBe(422); | |
| 470 | expect(await form.text()).toContain("Not made: 2 of 2 repositories."); | |
| 471 | expect(getRepo(app.db, "exact", "x3")).toBeUndefined(); | |
| 472 | ||
| 473 | // The terminal's new-repository screen: the same sentence, as a toast. | |
| 474 | const terminal = headlessTerminal(120, 30); | |
| 475 | const { app: tui, session } = createForgeApp( | |
| 476 | app, | |
| 477 | { sshUsername: "exact", remoteAddress: "127.0.0.1", fingerprint: "SHA256:exact", user: exact }, | |
| 478 | { terminal, exitHooks: false }, | |
| 479 | ); | |
| 480 | tui.start(); | |
| 481 | try { | |
| 482 | tui.ctx.push(newRepoScreen(session, tui.ctx)); | |
| 483 | await settleApp(tui); | |
| 484 | for (const ch of "x3") | |
| 485 | tui.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 486 | tui.dispatch({ type: "key", key: "d", ctrl: true, alt: false, shift: false }); | |
| 487 | await settleApp(tui); | |
| 488 | expect(tui.screen.toText()).toContain("not made: 2 of 2 repositories"); | |
| 489 | } finally { | |
| 490 | tui.stop(); | |
| 491 | } | |
| 492 | expect(getRepo(app.db, "exact", "x3")).toBeUndefined(); | |
| 493 | expect((await setLimit(null)).status).toBe(200); | |
| 494 | }); | |
| 495 | }); |
The issue asks for the refusal to say "10 of 10 repositories". Show the sentence here as the other doors do.