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

Mirror, migrate and sync fetches count against the storage limit, so no account can mirror past it #2207

Merged opened this

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

Merged

scout/forge:scout/2200-mirror-migrate-and-sync-fetches-count-ag → main 5 commits fork 6 threads · 6 unresolved

  • Merged. scout/2200-mirror-migrate-and-sync-fetches-count-ag was merged into main as eb1a1ec.

Review

Sign in to review this change.
17 files changed +272 −26
README.md +1 −1

Diff not shown — a line 4,879 characters wide (limit 2,000). +1 −1

View file
src/actions/make_it_yours.ts +6 −1
@@ -23,6 +23,7 @@
2323 * already made does not count.
2424 */
2525
26 import { storageLimitMessage } from "../core/storage.ts";
2627 import type { App } from "../app.ts";
2728 import {
2829 getAppByRepo,
@@ -71,7 +72,9 @@ export type MakeItYoursError =
7172 | { readonly kind: "limit"; readonly resetsAt: string }
7273 | { readonly kind: "invalid"; readonly message: string }
7374 /** The owner's repositories limit is reached: `message` says "10 of 10 repositories". */
74 | { readonly kind: "repository_limit"; readonly message: string };
75 | { readonly kind: "repository_limit"; readonly message: string }
76 /** The account's storage limit is reached: `message` says "1.9 GB of 2 GB". */
77 | { readonly kind: "storage_limit"; readonly message: string };
7578
7679 export const COPIED = ["the code", "its history"] as const;
7780 export const NOT_COPIED = [
@@ -232,6 +235,8 @@ export async function makeItYours(
232235 return err({ kind: "limit", resetsAt: resetsAt(app, actor.id, now) });
233236 case "repository_limit":
234237 return err({ kind: "repository_limit", message: repositoryLimitMessage(app, actor) });
238 case "storage_limit":
239 return err({ kind: "storage_limit", message: storageLimitMessage(app, actor.id) });
235240 default:
236241 return err({
237242 kind: "invalid",
src/actions/migrate.ts +4
@@ -20,6 +20,7 @@
2020 import { requireVerified } from "./verify.ts";
2121 import type { App } from "../app.ts";
2222 import { getAgentByName } from "../core/agents.ts";
23 import { storageStale } from "../core/storage.ts";
2324 import { getOriginFor } from "../core/origins.ts";
2425 import { getRepoById, type Repo } from "../core/repos.ts";
2526 import type { User } from "../core/users.ts";
@@ -198,6 +199,8 @@ export function migrateMessage(error: MigrateError, limit = "at the repositories
198199 return "New accounts cannot create repositories yet.";
199200 case "repository_limit":
200201 return `Not made: ${limit}.`;
202 case "storage_limit":
203 return "Not made: this account is at the forge's storage limit. Delete a repository or app data first.";
201204 }
202205 }
203206
@@ -399,6 +402,7 @@ async function runMigration(
399402 advance(jobId, { stage: "fetching", detail: "every branch and tag" });
400403 const cloned = await cloneInto(app, repo, source);
401404 if (!cloned.ok) return await fail(cloned.error);
405 storageStale(app, repo.ownerId);

No test covers this mark. Deleting the line leaves every test green. Add a migrate-over-the-limit test shaped like the mirror one (empty account, figure read at 0, migrate the 1.5 MB origin, next create is storage_limit).

402406 let current = getRepoById(app.db, repo.id) ?? repo;
403407
404408 // The source's default branch is kept; a source that never said which
src/actions/repos.ts +5 −4
@@ -72,7 +72,8 @@ export type CreateRepoActionError =
7272 | "unauthorized"
7373 | "forbidden_owner"
7474 | "refused"
75 | "repository_limit";
75 | "repository_limit"
76 | "storage_limit";
7677
7778 /**
7879 * The repositories limit (#2093): when the owner has set one, an account at
@@ -145,7 +146,7 @@ export async function createRepo(
145146 if (ownerId === undefined) return err("forbidden_owner");
146147 if (ownerId === user.value.id && repositoryLimitRefusal(app, user.value) !== undefined)
147148 return err("repository_limit");
148 if ((await storageRefusal(app, user.value, ownerId)) !== undefined) return err("repository_limit");
149 if ((await storageRefusal(app, user.value, ownerId)) !== undefined) return err("storage_limit");
149150 const created = await initRepo(app.db, app.reposRoot, {
150151 ownerId,
151152 name: input.name,
@@ -178,7 +179,7 @@ export async function forkRepo(
178179 parent: Repo,
179180 name?: string,
180181 options: { visibility?: "public" | "private"; now?: number } = {},
181 ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused" | "repository_limit">> {
182 ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused" | "repository_limit" | "storage_limit">> {
182183 const user = requireParticipant(app, actor, parent);
183184 if (!user.ok) return user;
184185 const proven = requireVerified(app, user.value);
@@ -187,7 +188,7 @@ export async function forkRepo(
187188 if (!guarded.ok) return guarded;
188189 if (repositoryLimitRefusal(app, user.value) !== undefined) return err("repository_limit");
189190 if ((await storageRefusal(app, user.value, user.value.id)) !== undefined)
190 return err("repository_limit");
191 return err("storage_limit");
191192 const forked = await cloneRepo(
192193 app.db,
193194 app.reposRoot,
src/core/storage.ts +28 −2
@@ -25,6 +25,8 @@ export const BYTES_PER_MB = 1_000_000;
2525 interface Entry {
2626 bytes: number;
2727 stale: boolean;
28 /** Bumped by every stale mark, so a walk can tell one arrived while it ran. */
29 marks: number;
2830 /** When the last walk finished, in ms. */
2931 walkedAt?: number;
3032 walking?: Promise<number>;
@@ -58,10 +60,12 @@ export async function measureAccount(app: App, ownerId: string): Promise<number>
5860 }
5961
6062 function walk(app: App, ownerId: string, entry: Entry): Promise<number> {
63 const marked = entry.marks;
6164 entry.walking ??= measureAccount(app, ownerId)
6265 .then((bytes) => {
6366 entry.bytes = bytes;
64 entry.stale = false;
67 // A mark that arrived mid-walk is not answered by this walk.
68 entry.stale = entry.marks !== marked;
6569 entry.walkedAt = Date.now();
6670 return bytes;
6771 })
@@ -76,7 +80,7 @@ export async function accountBytes(app: App, ownerId: string): Promise<number> {
7680 const cache = cacheOf(app);
7781 let entry = cache.get(ownerId);
7882 if (entry === undefined) {
79 entry = { bytes: 0, stale: true };
83 entry = { bytes: 0, stale: true, marks: 0 };
8084 cache.set(ownerId, entry);
8185 }
8286 // A figure marked stale is never judged on: the walk a change began in the
@@ -98,6 +102,7 @@ async function freshBytes(app: App, ownerId: string): Promise<number> {
98102 if (entry.walkedAt !== undefined && Date.now() - entry.walkedAt < REWALK_MS)
99103 return entry.bytes;
100104 entry.stale = true;
105 entry.marks++;
101106 }
102107 return accountBytes(app, ownerId);
103108 }
@@ -107,12 +112,25 @@ export function cachedBytes(app: App, ownerId: string): number | undefined {
107112 return cacheOf(app).get(ownerId)?.bytes;
108113 }
109114
115 /**
116 * Fetched objects arrived without a push (a mirror's sync, a migrate import):
117 * the figure is stale. Only marked: the walk is left to the next check, so a
118 * sync never pays for walking the repository.
119 */
120 export function storageStale(app: App, ownerId: string): void {
121 const entry = cacheOf(app).get(ownerId);
122 if (entry === undefined) return;
123 entry.stale = true;
124 entry.marks++;
125 }
126
110127 /** A push, create, fork, mirror or delete changed the disk: measure again, off the request path. */
111128 export function storageChanged(app: App, ownerId: string): void {
112129 const cache = cacheOf(app);
113130 const entry = cache.get(ownerId);
114131 if (entry === undefined) return;
115132 entry.stale = true;
133 entry.marks++;
116134 void walk(app, ownerId, entry).catch(() => {});
117135 }
118136
@@ -155,6 +173,14 @@ export async function storageRefusal(
155173 return used >= limit ? sentence(used, limit) : undefined;
156174 }
157175
176 /** What a refused create, fork or mirror says, from the figure it was refused on. */
177 export function storageLimitMessage(app: App, ownerId: string): string {
178 const sentence = storageSentence(app, ownerId);
179 return sentence === undefined
180 ? "this account is at the forge's storage limit"
181 : `this account is at the forge's storage limit: ${sentence}`;
182 }
183
158184 /** What a refused push says, over HTTP and SSH alike. */
159185 export const storageRefusedText = (used: string): string =>
160186 `storage limit reached: ${used}; delete a repository or app data to push`;
src/http/api.ts +16
@@ -8,6 +8,7 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { storageLimitMessage } from "../core/storage.ts";
1112 import { voteVariant } from "../actions/variants.ts";
1213 import { parentForgeFor } from "../core/parent_releases.ts";
1314 import { readFlow } from "../flow/read.ts";
@@ -139,6 +140,7 @@ import {
139140 FORKS_PER_DAY,
140141 forkRepo,
141142 repositoryLimitMessage,
143 resolveOwner,
142144 privateRepoForSealed,
143145 updateRepoSettings,
144146 } from "../actions/repos.ts";
@@ -1922,6 +1924,12 @@ export function apiRoutes(router: Router<Handler>): void {
19221924 return json({ error: "forbidden_owner" }, 403);
19231925 if (created.error === "repository_limit")
19241926 return apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!));
1927 if (created.error === "storage_limit")
1928 return apiError(
1929 "storage_limit",
1930 403,
1931 storageLimitMessage(ctx.app, resolveOwner(ctx.app, ctx.auth.user!, str(input.owner)) ?? ctx.auth.user!.id),
1932 );
19251933 return respondAccess(created.error, 422);
19261934 }
19271935 return json(created.value, 201);
@@ -2034,6 +2042,8 @@ export function apiRoutes(router: Router<Handler>): void {
20342042 if (!forked.ok)
20352043 return forked.error === "repository_limit"
20362044 ? apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!))
2045 : forked.error === "storage_limit"
2046 ? apiError("storage_limit", 403, storageLimitMessage(ctx.app, ctx.auth.user!.id))
20372047 : forked.error === "limit"
20382048 ? apiError("limit", 429, `at most ${FORKS_PER_DAY} forks a day: try again tomorrow`)
20392049 : respondAccess(forked.error, 422);
@@ -4363,6 +4373,8 @@ export function apiRoutes(router: Router<Handler>): void {
43634373 )
43644374 : made.error.kind === "repository_limit"
43654375 ? apiError("repository_limit", 403, made.error.message)
4376 : made.error.kind === "storage_limit"
4377 ? apiError("storage_limit", 403, made.error.message)
43664378 : made.error.kind === "invalid"
43674379 ? apiError("invalid", 422, made.error.message)
43684380 : made.error.kind === "own"
@@ -4494,6 +4506,8 @@ export function apiRoutes(router: Router<Handler>): void {
44944506 if (!made.ok) {
44954507 if (made.error === "repository_limit")
44964508 return apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!));
4509 if (made.error === "storage_limit")
4510 return apiError("storage_limit", 403, storageLimitMessage(ctx.app, ctx.auth.user!.id));
44974511 if (made.error === "app_limit")
44984512 return appLimitResponse(
44994513 ctx,
@@ -4570,6 +4584,8 @@ export function apiRoutes(router: Router<Handler>): void {
45704584 ? respondAccess(started.error, 401)
45714585 : started.error === "repository_limit"
45724586 ? apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!))
4587 : started.error === "storage_limit"
4588 ? apiError("storage_limit", 403, storageLimitMessage(ctx.app, ctx.auth.user!.id))
45734589 : apiError(started.error, 422, migrateMessage(started.error));
45744590 return json(
45754591 {
src/http/mcp.ts +2
@@ -1547,6 +1547,8 @@ const TOOLS: readonly Tool[] = [
15471547 ? bad("limit", `at most ${COPIES_PER_DAY} copies a day; another after ${made.error.resetsAt}`)
15481548 : made.error.kind === "repository_limit"
15491549 ? bad("repository_limit", made.error.message)
1550 : made.error.kind === "storage_limit"
1551 ? bad("storage_limit", made.error.message)
15501552 : made.error.kind === "invalid"
15511553 ? bad("invalid", made.error.message)
15521554 : made.error.kind === "own"
src/http/server.ts +5 −1
@@ -63,7 +63,7 @@ import { edgeIdentity } from "./edge.ts";
6363 import { liveResponse, readyResponse } from "./health.ts";
6464 import { runGit } from "../git/env.ts";
6565 import { noteRefusedInternal } from "../kernel/internal_refused.ts";
66 import { pushCap, storageChanged, storageRefusal, storageRefusedText } from "../core/storage.ts";
66 import { pushCap, storageChanged, storageRefusal, storageRefusedText, storageSentence } from "../core/storage.ts";
6767 import { hookEnv, ZERO_OID, type HookRequest } from "../git/hooks.ts";
6868 import { repoDir } from "../git/repos.ts";
6969 import {
@@ -1349,6 +1349,10 @@ async function withGitAccess(
13491349 visibility: "private",
13501350 });
13511351 if (created.ok) repo = created.value;
1352 else if (created.error === "storage_limit") {
1353 const ownerId = resolveOwner(ctx.app, ctx.auth.user, ctx.params.owner!) ?? ctx.auth.user.id;
1354 return gitRefused(ctx, storageRefusedText(storageSentence(ctx.app, ownerId) ?? "at the limit"));
1355 }
13521356 else if (created.error === "repository_limit")
13531357 return gitRefused(
13541358 ctx,
src/origins/mirror.ts +9 −5
@@ -18,7 +18,7 @@ import {
1818 rememberOriginRepo,
1919 type Origin,
2020 } from "../core/origins.ts";
21 import { storageChanged, storageRefusal } from "../core/storage.ts";
21 import { storageLimitMessage, storageRefusal } from "../core/storage.ts";
2222 import { createRepo, getRepoById, type Repo } from "../core/repos.ts";
2323 import type { User } from "../core/users.ts";
2424 import { isSafeName, isSafeRef } from "../git/guard.ts";
@@ -40,7 +40,8 @@ export type MirrorError =
4040 | "already_mirrored"
4141 | "git_init_failed"
4242 | "unreachable"
43 | "repository_limit";
43 | "repository_limit"
44 | "storage_limit";
4445
4546 export interface MirrorResult {
4647 readonly repo: Repo;
@@ -125,7 +126,7 @@ export async function mirrorRepo(
125126 // A mirror is a repository the account owns: the repositories limit asks
126127 // here as it does at create and fork (#2093).
127128 if (repositoryLimitRefusal(app, actor) !== undefined) return err("repository_limit");
128 if ((await storageRefusal(app, actor, actor.id)) !== undefined) return err("repository_limit");
129 if ((await storageRefusal(app, actor, actor.id)) !== undefined) return err("storage_limit");
129130
130131 const created = await createRepo(app.db, app.reposRoot, {
131132 ownerId: actor.id,
@@ -135,7 +136,6 @@ export async function mirrorRepo(
135136 defaultBranch,
136137 });
137138 if (!created.ok) return err(created.error);
138 storageChanged(app, actor.id);
139139
140140 linkRepoToOrigin(app.db, created.value.id, {
141141 originId: origin.id,
@@ -240,7 +240,9 @@ export function startMirror(
240240 error:
241241 result.error === "repository_limit"
242242 ? `Not mirrored: ${repositoryLimitMessage(app, actor)}.`
243 : mirrorMessage(result.error),
243 : result.error === "storage_limit"
244 ? `Not mirrored: ${storageLimitMessage(app, actor.id)}.`
245 : mirrorMessage(result.error),
244246 });
245247 return;
246248 }
@@ -298,6 +300,8 @@ export function mirrorMessage(error: MirrorError): string {
298300 return "the origin could not be reached";
299301 case "repository_limit":
300302 return "this account is at the forge's repositories limit";
303 case "storage_limit":
304 return "this account is at the forge's storage limit";
301305 }
302306 }
303307
src/origins/sync.ts +3
@@ -36,6 +36,7 @@ import {
3636 setCursor,
3737 type Origin,
3838 } from "../core/origins.ts";
39 import { storageStale } from "../core/storage.ts";
3940 import { getRepoById } from "../core/repos.ts";
4041 import { pullDiff, type Pull, getPull } from "../core/pulls.ts";
4142 import { repoDir } from "../git/repos.ts";
@@ -353,6 +354,8 @@ async function syncMirrorUnguarded(
353354 recordFetch(app.db, repo.id, { at: runStarted });
354355 out.refsMoved = fetched.value.changes.length;
outdated

Low (cost): this marks the figure stale after every successful fetch, including a periodic sync that moved nothing. The next check on the account (any push, create or fork) then waits for a full disk walk. Move it inside if (fetched.value.changes.length > 0): a fetch that moved no ref wrote nothing worth counting.

355356 if (fetched.value.changes.length > 0) {
357 // Objects were written without a push: measure again at the next check.
358 storageStale(app, repo.ownerId);
356359 // Shaped exactly like a native push event, plus the flag that says
357360 // where it came from — so webhooks, SSE and the activity feed see
358361 // upstream work without learning a new event kind.
src/tui/screens/newrepo.ts +10 −4
@@ -3,7 +3,8 @@
33 * because the next thing anyone does is look at it.
44 */
55
6 import { createRepo, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts";
6 import { storageLimitMessage } from "../../core/storage.ts";
7 import { createRepo, forkRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts";
78 import { listOrgsForUser } from "../../core/orgs.ts";
89 import type { Repo } from "../../core/repos.ts";
910 import type { Ctx, Screen } from "../app/app.ts";
@@ -35,15 +36,18 @@ export function newRepoScreen(f: Session, ctx: Ctx, owner?: string): Screen {
3536
3637 const submit = (): void => {
3738 runAsync(ctx, async () => {
39 const ownerName = owners[ownerIndex] ?? "";
3840 const created = await createRepo(f.app, f.user, {
39 owner: owners[ownerIndex] ?? "",
41 owner: ownerName,
4042 name,
4143 description,
4244 visibility,
4345 });
4446 if (!created.ok) {
4547 error =
46 created.error === "repository_limit"
48 created.error === "storage_limit"
outdated

Same issue here: when the screen creates under an org, storageLimitMessage should take the owner that was refused, not f.user!.id.

49 ? `not made: ${storageLimitMessage(f.app, resolveOwner(f.app, f.user!, ownerName) ?? f.user!.id)}`
50 : created.error === "repository_limit"
4751 ? `not made: ${repositoryLimitMessage(f.app, f.user!)}`
4852 : errorText(created.error);
4953 ctx.toast(error, "danger");
@@ -158,7 +162,9 @@ export function forkScreen(f: Session, ctx: Ctx, parent: Repo): Screen {
158162 const forked = await forkRepo(f.app, f.user, parent, name);
159163 if (!forked.ok) {
160164 error =
161 forked.error === "repository_limit"
165 forked.error === "storage_limit"
166 ? `not made: ${storageLimitMessage(f.app, f.user!.id)}`
167 : forked.error === "repository_limit"
162168 ? `not made: ${repositoryLimitMessage(f.app, f.user!)}`
163169 : errorText(forked.error);
164170 ctx.toast(error, "danger");
src/tui/ssh.ts +9 −2
@@ -19,7 +19,7 @@ import { getUserById, isActive, type User } from "../core/users.ts";
1919 import { createRepo, repositoryLimitMessage, resolveOwner } from "../actions/repos.ts";
2020 import { hookEnv } from "../git/hooks.ts";
2121 import { repoDir } from "../git/repos.ts";
22 import { pushCap, storageChanged, storageRefusal, storageRefusedText } from "../core/storage.ts";
22 import { pushCap, storageChanged, storageRefusal, storageRefusedText, storageSentence } from "../core/storage.ts";
2323 import { parseGitCommand, runGitService } from "../git/smart-ssh.ts";
2424 import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts";
2525 import { err, ok, type Result } from "../kernel/result.ts";
@@ -257,7 +257,14 @@ export async function serveGitCommand(
257257 visibility: "private",
258258 });
259259 if (created.ok) repo = created.value;
260 else if (created.error === "repository_limit") {
260 else if (created.error === "storage_limit") {
261 const ownerId = resolveOwner(forge, user, parsed.owner) ?? user.id;
262 session.writeStderr(
263 `forge: ${storageRefusedText(storageSentence(forge, ownerId) ?? "at the limit")}${CRLF}`,
264 );
265 session.exit(1);
266 return;
267 } else if (created.error === "repository_limit") {
261268 session.writeStderr(
262269 `forge: Not made: ${repositoryLimitMessage(forge, user, resolveOwner(forge, user, parsed.owner) ?? user.id)}${CRLF}`,
263270 );
src/web/pages/apps.ts +3
@@ -12,6 +12,7 @@
1212 * 404 for everyone.
1313 */
1414
15 import { storageLimitMessage } from "../../core/storage.ts";
1516 import { repositoryLimitMessage } from "../../actions/repos.ts";
1617 import { appLimitWords } from "../../actions/app_limit.ts";
1718 import { getUserByName } from "../../core/users.ts";
@@ -422,6 +423,8 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
422423 values,
423424 made.error === "repository_limit"
424425 ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user!)}.`
426 : made.error === "storage_limit"
427 ? `Not made: ${storageLimitMessage(ctx.app, ctx.auth.user!.id)}.`

Low: a new app created under an organisation is checked against the org's storage, but this sentence names the actor's figure (ctx.auth.user!.id). ownerId is already in scope here; pass ownerId ?? ctx.auth.user!.id, as site.ts does. The same applies to the storage_limit branch of POST /api/v1/apps in src/http/api.ts.

425428 : made.error === "app_limit" && ownerId !== undefined
426429 ? appLimitWords(ctx.app, ownerId)
427430 : (NEW_APP_ERRORS[made.error] ?? "That app could not be created."),
src/web/pages/repo.ts +3
@@ -8,6 +8,7 @@
88 * path segment and can never smuggle a path of its own.
99 */
1010
11 import { storageLimitMessage } from "../../core/storage.ts";
1112 import { canRead, listForks, type Repo } from "../../core/repos.ts";
1213 import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts";
1314 import { forkDrift, takeUpdates, type ForkDrift } from "../../actions/forks.ts";
@@ -197,6 +198,8 @@ export async function forkSubmit(ctx: Ctx): Promise<Response> {
197198 ? "You already have a repository by that name."
198199 : forked.error === "repository_limit"
199200 ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`
201 : forked.error === "storage_limit"
202 ? `Not made: ${storageLimitMessage(ctx.app, ctx.auth.user.id)}.`
200203 : forked.error === "limit"
201204 ? `That is today's forks made (${FORKS_PER_DAY} a day). Try again tomorrow.`
202205 : "That fork could not be created.";
src/web/pages/site.ts +3 −1
@@ -8,12 +8,13 @@
88 * typed still in it. A dead end that loses input is a bug, not a message.
99 */
1010
11 import { storageLimitMessage } from "../../core/storage.ts";
1112 import { limitValue } from "../../settings/limits.ts";
1213 import { signOutHere } from "../../actions/signins.ts";
1314 import { clientAddress } from "../../http/peer.ts";
1415 import { verifyEmail } from "../../actions/verify.ts";
1516 import { STRANGER_MAX } from "../../http/limits.ts";
16 import { createRepo, repositoryLimitMessage } from "../../actions/repos.ts";
17 import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts";
1718 import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, signUp } from "../../actions/users.ts";
1819 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
1920 import { listPins } from "../../core/pins.ts";
@@ -690,6 +691,7 @@ export async function newRepoSubmit(ctx: Ctx): Promise<Response> {
690691 return new Response("forbidden", { status: 403 });
691692 const messages: Record<string, string> = {
692693 repository_limit: `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`,
694 storage_limit: `Not made: ${storageLimitMessage(ctx.app, resolveOwner(ctx.app, ctx.auth.user, values.owner) ?? ctx.auth.user.id)}.`,
outdated

createRepo refused on the resolved owner's storage (possibly an org), but this names ctx.auth.user.id's figure. Resolve the owner as the API does: resolveOwner(ctx.app, ctx.auth.user, owner) ?? ctx.auth.user.id.

693695 name_taken: "That owner already has a repository by that name.",
694696 invalid_name: "Names start with a letter or digit; no spaces, no .git.",
695697 git_init_failed: "The repository could not be initialised on disk.",
src/web/studio/make_it_yours.ts +1 −1
@@ -128,7 +128,7 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> {
128128 const message = !made.ok
129129 ? made.error.kind === "invalid"
130130 ? made.error.message
131 : made.error.kind === "repository_limit"
131 : made.error.kind === "repository_limit" || made.error.kind === "storage_limit"
132132 ? `Not made: ${made.error.message}.`
133133 : made.error.kind === "own"
134134 ? "This app is already yours."
test/quotas-storage.test.ts +164 −4
@@ -4,21 +4,25 @@
44 * held to it, and no limit set means no refusal.
55 */
66
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
88 import { mkdtemp, rm, writeFile } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { commitToBranch, createRepo, deleteRepo, forkRepo } from "../src/actions/repos.ts";
13 import { makeItYours } from "../src/actions/make_it_yours.ts";
14 import { newApp } from "../src/actions/apps.ts";
1315 import { signUp } from "../src/actions/users.ts";
1416 import { createToken } from "../src/core/tokens.ts";
15 import { accountBytes, formatBytes, storageRefusal } from "../src/core/storage.ts";
17 import { REWALK_MS, accountBytes, formatBytes, storageRefusal } from "../src/core/storage.ts";
1618 import { getRepo } from "../src/core/repos.ts";
1719 import { createAgent } from "../src/actions/crew.ts";
1820 import { mirrorAndWait, newFakeState, serveFakeForge } from "./fake-origin.ts";
1921 import { serveGitCommand } from "../src/tui/ssh.ts";
2022 import type { SshSession } from "../src/ssh/channels.ts";
2123 import { getUserById } from "../src/core/users.ts";
24 import { createOrg } from "../src/core/orgs.ts";
25 import { createSession } from "../src/core/sessions.ts";
2226 import { setQuota } from "../src/settings/quotas.ts";
2327 import { serve, type Servers } from "../src/http/server.ts";
2428 import { gitClientEnv } from "./helpers/git-client.ts";
@@ -119,7 +123,7 @@ describe("storage quota", () => {
119123
120124 const made = await createRepo(app, getUserById(app.db, bobId)!, { name: "more", visibility: "private" });
121125 expect(made.ok).toBe(false);
122 if (!made.ok) expect(made.error).toBe("repository_limit");
126 if (!made.ok) expect(made.error).toBe("storage_limit");
123127 });
124128
125129 test("push-to-create over the limit is a git error naming the use, not a not-found", async () => {
@@ -169,7 +173,7 @@ describe("storage quota", () => {
169173 else refused = forked.error;
170174 }
171175 expect(made).toBeGreaterThan(0);
172 expect(refused).toBe("repository_limit");
176 expect(refused).toBe("storage_limit");
173177 });
174178
175179 test("an agent is not held to the limit, the account beside it is", async () => {
@@ -229,6 +233,162 @@ describe("storage quota", () => {
229233 expect(refs("ada", "huge")).not.toBe("");
230234 });
231235
236 test("a mirror bigger than the limit is allowed in, and the next create is refused as storage_limit", async () => {
237 // The origin: a public repository of 1.5 MB on this same forge.
238 expect((await push("ada", adaToken, "bigorigin", 1_500_000)).code).toBe(0);
239 app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'bigorigin'").run();
240 const dave = await signUp(app, { username: "dave", email: "[email protected]", password: "dave-passphrase" });
241 if (!dave.ok) throw new Error("signup");
242 proveAll(app);
243 const daveToken = createToken(app.db, dave.value.id, "t", "admin").token;
244 const state = newFakeState("forgejo", "someone", "tok");
245 state.repos.push({
246 id: 2,
247 fullName: "ada/bigorigin",
248 cloneUrl: `${servers.url}/ada/bigorigin.git`,
249 defaultBranch: "main",
250 });
251 const fake = serveFakeForge(state);
252 try {
253 const post = (path: string, body: unknown) =>
254 fetch(`${servers.url}${path}`, {
255 method: "POST",
256 headers: { authorization: `Bearer ${daveToken}`, "content-type": "application/json" },
257 body: JSON.stringify(body),
258 });
259 const connected = await post("/api/v1/user/origins", { host: fake.url, token: "tok" });
260 const originId = ((await connected.json()) as { id: string }).id;
261 // Empty at the start, so the mirror is let in; the figure is read once.
262 expect(await accountBytes(app, dave.value.id)).toBe(0);
263 const job = await mirrorAndWait(
264 await post(`/api/v1/user/origins/${originId}/mirror`, { fullName: "ada/bigorigin" }),
265 );
266 expect(job?.stage).toBe("done");
267 // The fetch marked the figure stale, so this check walks it and refuses.
268 const made = await createRepo(app, getUserById(app.db, dave.value.id)!, {
269 name: "more",
270 visibility: "private",
271 });
272 expect(made).toEqual({ ok: false, error: "storage_limit" });
273 const api = await post("/api/v1/repos", { name: "more" });
274 expect(api.status).toBe(403);
275 expect(await api.json()).toMatchObject({ error: "storage_limit" });
276 } finally {
277 fake.stop();
278 }
279 });
280
281 test("a migrate import bigger than the limit is allowed in, and the next create is refused as storage_limit", async () => {
282 const gina = await signUp(app, { username: "gina", email: "[email protected]", password: "gina-passphrase" });
283 if (!gina.ok) throw new Error("signup");
284 proveAll(app);
285 const token = createToken(app.db, gina.value.id, "t", "admin").token;
286 const state = newFakeState("forgejo", "someone", "tok");
287 state.repos.push({
288 id: 3,
289 fullName: "ada/bigorigin",
290 cloneUrl: `${servers.url}/ada/bigorigin.git`,
291 defaultBranch: "main",
292 });
293 const fake = serveFakeForge(state);
294 try {
295 const post = (path: string, body: unknown) =>
296 fetch(`${servers.url}${path}`, {
297 method: "POST",
298 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
299 body: JSON.stringify(body),
300 });
301 const connected = await post("/api/v1/user/origins", { host: fake.url, token: "tok" });
302 const originId = ((await connected.json()) as { id: string }).id;
303 expect(await accountBytes(app, gina.value.id)).toBe(0);
304 const job = await mirrorAndWait(
305 await post("/api/v1/migrate", { source: { originId, fullName: "ada/bigorigin" }, name: "moved" }),
306 );
307 expect(job?.stage).toBe("done");
308 const made = await createRepo(app, getUserById(app.db, gina.value.id)!, {
309 name: "more",
310 visibility: "private",
311 });
312 expect(made).toEqual({ ok: false, error: "storage_limit" });
313 } finally {
314 fake.stop();
315 }
316 });
317
318 /** A fresh account holding 1.5 MB, its figure measured, against the 1 MB limit. */
319 async function fullAccount(username: string) {
320 const made = await signUp(app, { username, email: `${username}@f.dev`, password: `${username}-passphrase` });
321 if (!made.ok) throw new Error("signup");
322 proveAll(app);
323 setQuota(app.db, "storage", 2, adaId);
324 const token = createToken(app.db, made.value.id, "t", "admin").token;
325 expect((await push(username, token, "data", 1_500_000)).code).toBe(0);
326 for (let i = 0; i < 100 && (await accountBytes(app, made.value.id)) < 1_500_000; i++)
327 await Bun.sleep(20);
328 setQuota(app.db, "storage", 1, adaId);
329 return getUserById(app.db, made.value.id)!;
330 }
331
332 test("an account at the limit is measured again at most every REWALK_MS", async () => {
333 expect(REWALK_MS).toBe(5_000);
334 const erin = await fullAccount("erin");
335 expect(await storageRefusal(app, erin, erin.id)).toContain("MB of 1 MB");
336 // The disk is emptied behind the cache's back.
337 await rm(join(dataDir, "repos", "erin"), { recursive: true, force: true });
338 const now = Date.now();
339 const clock = spyOn(Date, "now");
340 try {
341 clock.mockReturnValue(now + REWALK_MS - 1_000);
342 expect(await storageRefusal(app, erin, erin.id)).toContain("MB of 1 MB");
343 clock.mockReturnValue(now + REWALK_MS + 1_000);
344 expect(await storageRefusal(app, erin, erin.id)).toBeUndefined();
345 } finally {
346 clock.mockRestore();
347 }
348 });
349
350 test("a copy over the storage limit is a storage_limit, not a raw code", async () => {
351 const source = await newApp(app, getUserById(app.db, adaId)!, { name: "garden", visibility: "public" });
352 if (!source.ok) throw new Error(String(source.error));
353 const frank = await fullAccount("frank");
354 const copy = await makeItYours(app, frank, source.value.repo, { dryRun: false });
355 expect(copy.ok).toBe(false);
356 if (!copy.ok) {
357 expect(copy.error.kind).toBe("storage_limit");
358 if (copy.error.kind === "storage_limit") expect(copy.error.message).toContain("MB of 1 MB");
359 }
360 });
361
362 test("a create under a full organisation names the organisation's figure on the page", async () => {
363 const gwen = await signUp(app, { username: "gwen", email: "[email protected]", password: "gwen-passphrase" });
364 if (!gwen.ok) throw new Error("signup");
365 proveAll(app);
366 const org = createOrg(app.db, "guild", gwen.value.id);
367 if (!org.ok) throw new Error("org");
368 setQuota(app.db, "storage", 2, adaId);
369 const token = createToken(app.db, gwen.value.id, "t", "admin").token;
370 const made = await createRepo(app, gwen.value, { owner: "guild", name: "big", visibility: "private", description: "" });
371 expect(made.ok).toBe(true);
372 expect((await push("guild", token, "big", 1_500_000)).code).toBe(0);
373 for (let i = 0; i < 100 && (await accountBytes(app, org.value.id)) < 1_500_000; i++)
374 await Bun.sleep(20);
375 setQuota(app.db, "storage", 1, adaId);
376 // Gwen herself holds nothing: only the organisation is over.
377 expect(await accountBytes(app, gwen.value.id)).toBe(0);
378 const res = await fetch(`${servers.url}/new`, {
379 method: "POST",
380 headers: {
381 cookie: `forge_session=${createSession(app.db, gwen.value.id).token}`,
382 origin: servers.url,
383 "content-type": "application/x-www-form-urlencoded",
384 },
385 body: new URLSearchParams({ owner: "guild", name: "more", visibility: "private" }),
386 redirect: "manual",
387 });
388 expect(res.status).toBe(422);
389 expect(await res.text()).toContain("MB of 1 MB");
390 });
391
232392 test("with no limit set nothing is refused", async () => {
233393 setQuota(app.db, "storage", null, adaId);
234394 const pushed = await push("bob", bobToken, "free", 1_500_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs