From 93c95e1846aea78f8eac08de04dff53810ac0bb8 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 14:29:09 +0200 Subject: [PATCH] refactor: rely on PocketBase rate limiting --- .../1784240000_workshop_widgets.js | 31 ---------- apps/workshop/tests/workshop-contracts.mjs | 8 --- apps/workshop/tests/workshop-migration.mjs | 13 +--- .../tests/workshop-rollback.integration.mjs | 60 +------------------ apps/workshop/tests/workshop.integration.mjs | 9 --- 5 files changed, 3 insertions(+), 118 deletions(-) diff --git a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js index 729b7a8a7..0d8860607 100644 --- a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js +++ b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js @@ -22,21 +22,6 @@ const moderatorSubmissionFieldsUnchanged = [ "@request.body.changelog:changed = false", "@request.body.screenshots:changed = false", ].join(" && "); -const workshopRateLimits = [ - { label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 }, - { label: "submissions:update", audience: "@auth", duration: 60, maxRequests: 30 }, - { label: "submissions:delete", audience: "@auth", duration: 60, maxRequests: 10 }, - { label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 }, - { label: "votes:update", audience: "@auth", duration: 10, maxRequests: 20 }, - { label: "votes:delete", audience: "@auth", duration: 10, maxRequests: 20 }, - { label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 }, - { label: "comments:update", audience: "@auth", duration: 60, maxRequests: 20 }, - { label: "comments:delete", audience: "@auth", duration: 60, maxRequests: 20 }, - { label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 }, - { label: "reports:update", audience: "@auth", duration: 60, maxRequests: 30 }, - { label: "reports:delete", audience: "@auth", duration: 60, maxRequests: 30 }, - { label: "users:update", audience: "@auth", duration: 60, maxRequests: 20 }, -]; migrate( (app) => { @@ -49,7 +34,6 @@ migrate( users = new Collection({ type: "auth", name: "users" }); } - const settings = app.settings(); const stateCollection = new Collection({ type: "base", name: "workshop_migration_state", @@ -75,7 +59,6 @@ migrate( } : null, addedUserFields: [], - rateLimits: cloneJson(settings.rateLimits), }; const addUserField = (field) => { if (users.fields.getByName(field.name)) return; @@ -280,15 +263,6 @@ migrate( `, }); app.save(listings); - - settings.rateLimits.enabled = true; - settings.rateLimits.rules = [ - ...settings.rateLimits.rules.filter( - (rule) => !workshopRateLimits.some((workshopRule) => workshopRule.label === rule.label), - ), - ...workshopRateLimits, - ]; - app.save(settings); }, (app) => { let stateCollection; @@ -306,11 +280,6 @@ migrate( } catch {} } - const settings = app.settings(); - settings.rateLimits.enabled = state.rateLimits.enabled; - settings.rateLimits.rules = state.rateLimits.rules; - app.save(settings); - const users = app.findCollectionByNameOrId("users"); if (state.usersExisted) { users.passwordAuth = state.users.passwordAuth; diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs index 9086192a1..0a236e0f5 100644 --- a/apps/workshop/tests/workshop-contracts.mjs +++ b/apps/workshop/tests/workshop-contracts.mjs @@ -89,7 +89,6 @@ for (const required of [ "cloneRule(users.listRule)", "CREATE TRIGGER submissions_revision_cas", "NEW.expectedRevision != OLD.revision", - "state.rateLimits.enabled", "users.passwordAuth = state.users.passwordAuth", "users.oauth2 = state.users.oauth2", "app.delete(users)", @@ -97,13 +96,6 @@ for (const required of [ if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`); } -for (const operation of ["create", "update", "delete"]) { - for (const collection of ["submissions", "votes", "comments", "reports"]) { - const label = `${collection}:${operation}`; - if (!migration.includes(label)) throw new Error(`Workshop write rate limit is missing: ${label}`); - } -} -if (!migration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited"); for (const protectedField of [ "email:changed", "displayName:changed", diff --git a/apps/workshop/tests/workshop-migration.mjs b/apps/workshop/tests/workshop-migration.mjs index 6170e59c4..97cad695d 100644 --- a/apps/workshop/tests/workshop-migration.mjs +++ b/apps/workshop/tests/workshop-migration.mjs @@ -58,13 +58,11 @@ const original = { updateRule: "original-update", deleteRule: "original-delete", }, - rateLimits: { enabled: false, rules: [{ label: "original", audience: "", duration: 1, maxRequests: 1 }] }, }; const users = new Collection({ type: "auth", name: "users", fields: [{ type: "text", name: "existing" }] }); users.passwordAuth = structuredClone(original.passwordAuth); users.oauth2 = structuredClone(original.oauth2); Object.assign(users, original.rules); -const settings = { rateLimits: structuredClone(original.rateLimits) }; const collections = new Map([[users.id, users]]); const records = new Map(); const rawQueries = []; @@ -99,9 +97,6 @@ const app = { collections.delete(collection.id); records.delete(collection.id); }, - settings() { - return settings; - }, }; let migration; @@ -122,7 +117,7 @@ vm.runInNewContext(source, sandbox); if (!migration) throw new Error("Workshop migration did not register"); migration.up(app); -if (!collections.has("workshop_migration_state") || settings.rateLimits.enabled !== true) { +if (!collections.has("workshop_migration_state")) { throw new Error("Workshop migration did not capture state before applying changes"); } if ( @@ -145,8 +140,6 @@ for (const [name, value] of Object.entries(original.rules)) { } if (users.fields.values.map((field) => field.name).join(",") !== "existing") throw new Error("Workshop user fields were not removed on rollback"); -if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits)) - throw new Error("Rate limits were not restored"); for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) { if (collections.has(name)) throw new Error(`Rollback left collection ${name}`); } @@ -154,7 +147,6 @@ for (const name of ["workshop_migration_state", "workshop_listings", "reports", collections.clear(); records.clear(); rawQueries.length = 0; -settings.rateLimits = structuredClone(original.rateLimits); migration.up(app); if (!collections.has("users")) throw new Error("Fresh migration did not create the users collection"); @@ -170,8 +162,5 @@ for (const name of [ ]) { if (collections.has(name)) throw new Error(`Fresh rollback left collection ${name}`); } -if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits)) { - throw new Error("Fresh rollback did not restore rate limits"); -} console.log("Workshop migration up/down restoration passed"); diff --git a/apps/workshop/tests/workshop-rollback.integration.mjs b/apps/workshop/tests/workshop-rollback.integration.mjs index cde9f9d9f..45815459d 100644 --- a/apps/workshop/tests/workshop-rollback.integration.mjs +++ b/apps/workshop/tests/workshop-rollback.integration.mjs @@ -19,7 +19,6 @@ const baselineUser = { password: "WorkshopRollbackUser123!", note: "retained across the Workshop migration chain", }; -const baselineRateLimitLabel = "rollback_sentinel:create"; const workshopCollections = [ "workshop_migration_state", "submissions", @@ -51,7 +50,6 @@ const collectionList = async () => { return new Map(result.items.map((collection) => [collection.name, collection])); }; const collection = (name) => request(`/api/collections/${name}`, { headers: rootHeaders }); -const settings = () => request("/api/settings", { headers: rootHeaders }); const records = (name) => request(`/api/collections/${name}/records?perPage=200`, { headers: rootHeaders, @@ -76,18 +74,6 @@ const userConfig = (users) => ({ }); const readSnapshot = async () => JSON.parse(await readFile(snapshotPath, "utf8")); -const assertManagedRateLimits = (currentSettings) => { - assert.equal(currentSettings.rateLimits?.enabled, true, "Workshop migration did not enable rate limits"); - const rateLimitLabels = new Set(currentSettings.rateLimits?.rules?.map((rule) => rule.label)); - for (const operation of ["create", "update", "delete"]) { - for (const name of ["submissions", "votes", "comments", "reports"]) { - assert.ok(rateLimitLabels.has(`${name}:${operation}`), `Missing migrated rate limit ${name}:${operation}`); - } - } - assert.ok(rateLimitLabels.has("users:update"), "Missing migrated users:update rate limit"); - return rateLimitLabels; -}; - const assertBaselineData = async () => { const userRecords = await records("users"); const retainedUser = userRecords.items.find((record) => record.email === baselineUser.email); @@ -124,10 +110,6 @@ const assertMigrated = async () => { "OAuth identity update rule is not hardened", ); - const currentSettings = await settings(); - const rateLimitLabels = assertManagedRateLimits(currentSettings); - assert.ok(rateLimitLabels.has(baselineRateLimitLabel), "Workshop migration removed an unrelated rate-limit rule"); - const stateRecords = await records("workshop_migration_state"); assert.equal(stateRecords.items.length, 1, "Workshop migration state snapshot is missing"); const migrationState = JSON.parse(stateRecords.items[0].snapshot); @@ -150,7 +132,6 @@ const assertMigrated = async () => { "rollback-baseline-secret", "Workshop migration state did not retain the OAuth secret required for rollback", ); - assert.deepEqual(migrationState.rateLimits, before.rateLimits); await assertBaselineData(); }; @@ -163,10 +144,7 @@ if (phase === "fresh-prepare") { for (const name of [...workshopCollections, "users", "rollback_sentinel"]) { assert.ok(!collections.has(name), `Fresh database unexpectedly contains collection ${name}`); } - const currentSettings = await settings(); - await writeFile(snapshotPath, JSON.stringify({ freshRateLimits: normalized(currentSettings.rateLimits) }), { - mode: 0o600, - }); + await writeFile(snapshotPath, "{}", { mode: 0o600 }); console.log("Workshop fresh rollback baseline captured"); } else if (phase === "fresh-migrated") { const collections = await collectionList(); @@ -180,7 +158,6 @@ if (phase === "fresh-prepare") { `Fresh migration is missing user field ${name}`, ); } - assertManagedRateLimits(await settings()); const stateRecords = await records("workshop_migration_state"); assert.equal(stateRecords.items.length, 1, "Fresh migration state snapshot is missing"); const migrationState = JSON.parse(stateRecords.items[0].snapshot); @@ -188,18 +165,11 @@ if (phase === "fresh-prepare") { assert.equal(migrationState.users, null, "Fresh migration retained nonexistent user settings"); console.log("Workshop fresh migration chain verified"); } else if (phase === "fresh-restored") { - const before = await readSnapshot(); const collections = await collectionList(); for (const name of [...workshopCollections, "users", "rollback_sentinel"]) { assert.ok(!collections.has(name), `Fresh rollback left collection ${name}`); } assert.ok(collections.has("_superusers"), "Fresh rollback removed PocketBase superuser data"); - const currentSettings = await settings(); - assert.deepEqual( - normalized(currentSettings.rateLimits), - before.freshRateLimits, - "Fresh rollback did not restore the original rate-limit settings", - ); console.log("Workshop fresh rollback state verified"); } else if (phase === "prepare") { const collections = await collectionList(); @@ -246,16 +216,6 @@ if (phase === "fresh-prepare") { fields: [{ type: "text", name: "value", required: true, max: 100 }], }), }); - await request("/api/settings", { - method: "PATCH", - headers: rootHeaders, - body: JSON.stringify({ - rateLimits: { - enabled: false, - rules: [{ label: baselineRateLimitLabel, audience: "", duration: 17, maxRequests: 23 }], - }, - }), - }); await request("/api/collections/users/records", { method: "POST", headers: rootHeaders, @@ -275,21 +235,11 @@ if (phase === "fresh-prepare") { }); const users = await collection("users"); - const currentSettings = await settings(); assert.equal(users.passwordAuth?.enabled, true); assert.equal(users.oauth2?.enabled, true); assert.ok(users.oauth2?.providers?.some((provider) => provider.name === "github")); - assert.equal(currentSettings.rateLimits?.enabled, false); - assert.deepEqual( - currentSettings.rateLimits?.rules?.map((rule) => rule.label), - [baselineRateLimitLabel], - ); await assertBaselineData(); - await writeFile( - snapshotPath, - JSON.stringify({ users: userConfig(users), rateLimits: normalized(currentSettings.rateLimits) }), - { mode: 0o600 }, - ); + await writeFile(snapshotPath, JSON.stringify({ users: userConfig(users) }), { mode: 0o600 }); console.log("Workshop rollback baseline prepared"); } else if (phase === "migrated") { await assertMigrated(); @@ -306,12 +256,6 @@ if (phase === "fresh-prepare") { const users = await collection("users"); assert.deepEqual(userConfig(users), before.users, "Rollback did not restore user rules, fields, password, or OAuth"); - const currentSettings = await settings(); - assert.deepEqual( - normalized(currentSettings.rateLimits), - before.rateLimits, - "Rollback did not restore the original rate-limit settings", - ); await assertBaselineData(); console.log("Workshop full rollback state verified"); } diff --git a/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs index 6bd4f2d16..be597e96a 100644 --- a/apps/workshop/tests/workshop.integration.mjs +++ b/apps/workshop/tests/workshop.integration.mjs @@ -34,15 +34,6 @@ for (const required of ["submissions", "votes", "comments", "reports", "workshop for (const removed of ["workshop_admin_actions", "workshop_admins"]) { if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`); } -const settings = await request("/api/settings", { headers: rootHeaders }); -const rateLimitLabels = new Set(settings.rateLimits?.rules?.map((rule) => rule.label)); -for (const operation of ["create", "update", "delete"]) { - for (const collection of ["submissions", "votes", "comments", "reports"]) { - const label = `${collection}:${operation}`; - if (!rateLimitLabels.has(label)) throw new Error(`Missing Workshop write rate limit: ${label}`); - } -} -if (!rateLimitLabels.has("users:update")) throw new Error("Workshop user/file updates are not rate limited"); // Production uses GitHub OAuth. Password auth is enabled only in this disposable database. const usersCollection = await request("/api/collections/users", { headers: rootHeaders });