simplify v2 migration history

This commit is contained in:
ajnart committed 2026-07-29 20:19:47 +02:00
1 parent 6ae109e8f5
commit 7a2d6009f7
34 files changed
+725 -9091

No files matched your search

+7
View File
@@ -44,6 +44,13 @@ docker compose -f apps/workshop/docker-compose.yml logs -f workshop
docker compose -f apps/workshop/docker-compose.yml stop workshop
```
### Pre-release migration reset
The v2 branch consolidates the unreleased Workshop migrations into one initial migration. A PocketBase volume that ran
an earlier v2 Workshop migration chain is unsupported. Back up anything you need, then use the intentional local reset
command near the end of this document and start the stack again. Startup never deletes a volume automatically. This
exception applies only to disposable pre-release volumes and must not be used for production data.
## Connect the Homarr application
Use the same URL contract in the repository-root `.env`, then restart Homarr:
@@ -2,6 +2,41 @@
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const cloneRule = (value) => (value === null ? null : String(value));
const adminRule = "@request.auth.isAdmin = true";
const identityFieldsUnchanged = [
"@request.body.email:changed = false",
"@request.body.displayName:changed = false",
"@request.body.avatarUrl:changed = false",
"@request.body.githubUsername:changed = false",
"@request.body.githubProfileUrl:changed = false",
].join(" && ");
const immutableSubmissionFields = [
"@request.body.author:changed = false",
"@request.body.type:changed = false",
"@request.body.widgetSchema:changed = false",
].join(" && ");
const moderatorSubmissionFieldsUnchanged = [
"@request.body.title:changed = false",
"@request.body.description:changed = false",
"@request.body.content:changed = false",
"@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) => {
@@ -63,11 +98,10 @@ migrate(
addUserField(new URLField({ name: "githubProfileUrl" }));
addUserField(new BoolField({ name: "isAdmin" }));
const adminRule = "@request.auth.isAdmin = true";
users.listRule = "";
users.viewRule = "";
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
users.deleteRule = null;
app.save(users);
const stateRecord = new Record(stateCollection);
@@ -80,7 +114,9 @@ migrate(
listRule: "",
viewRule: "",
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
updateRule: `(author = @request.auth.id || ${adminRule}) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false`,
updateRule:
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
`(${adminRule} && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`,
deleteRule: `author = @request.auth.id || ${adminRule}`,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["customWidget", "customCss"] },
@@ -183,7 +219,12 @@ migrate(
listRule: adminRule,
viewRule: adminRule,
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
updateRule:
`${adminRule} && ` +
"@request.body.reporter:changed = false && " +
"@request.body.submission:changed = false && " +
"@request.body.category:changed = false && " +
"@request.body.explanation:changed = false",
deleteRule: adminRule,
fields: [
{
@@ -243,12 +284,9 @@ migrate(
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter(
(rule) => !["submissions:create", "votes:create", "comments:create", "reports:create"].includes(rule.label),
(rule) => !workshopRateLimits.some((workshopRule) => workshopRule.label === rule.label),
),
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
...workshopRateLimits,
];
app.save(settings);
},
@@ -1,92 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
const managedLabels = [
"submissions:create",
"submissions:update",
"submissions:delete",
"votes:create",
"votes:update",
"votes:delete",
"comments:create",
"comments:update",
"comments:delete",
"reports:create",
"reports:update",
"reports:delete",
"users:update",
];
const legacyCreateRules = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
];
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const withoutManagedRules = (rules) => rules.filter((rule) => !managedLabels.includes(rule.label));
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
const deriveLegacyWriteRateLimitState = (snapshot) => ({
enabled: true,
managedRules: managedRules([
...snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
...legacyCreateRules,
]),
});
const findMigrationState = (app) => {
try {
const collection = app.findCollectionByNameOrId("workshop_migration_state");
const record = app.findFirstRecordByFilter(collection.id, "");
const snapshot = JSON.parse(record.getString("snapshot"));
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
throw new Error("Workshop migration state snapshot is invalid");
}
return { record, snapshot };
} catch {
throw new Error("Workshop write rate-limit migration state is unavailable");
}
};
migrate(
(app) => {
const settings = app.settings();
const state = findMigrationState(app);
state.snapshot.writeRateLimits = {
enabled: settings.rateLimits.enabled,
managedRules: cloneJson(managedRules(settings.rateLimits.rules)),
};
state.record.set("snapshot", JSON.stringify(state.snapshot));
app.save(state.record);
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...withoutManagedRules(settings.rateLimits.rules),
{ 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 },
];
app.save(settings);
},
(app) => {
const settings = app.settings();
const snapshot = findMigrationState(app).snapshot;
const state = snapshot.writeRateLimits || deriveLegacyWriteRateLimitState(snapshot);
if (!state || typeof state.enabled !== "boolean" || !Array.isArray(state.managedRules)) {
throw new Error("Workshop write rate-limit rollback snapshot is missing or invalid");
}
settings.rateLimits.enabled = state.enabled;
settings.rateLimits.rules = [...withoutManagedRules(settings.rateLimits.rules), ...cloneJson(state.managedRules)];
app.save(settings);
},
);
@@ -1,65 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
const legacyUserUpdateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
const legacySubmissionUpdateRule =
"(author = @request.auth.id || @request.auth.isAdmin = true) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false";
const legacyReportUpdateRule =
"@request.auth.isAdmin = true && @request.body.reporter:changed = false && @request.body.submission:changed = false";
const identityFieldsUnchanged = [
"@request.body.email:changed = false",
"@request.body.displayName:changed = false",
"@request.body.avatarUrl:changed = false",
"@request.body.githubUsername:changed = false",
"@request.body.githubProfileUrl:changed = false",
].join(" && ");
const immutableSubmissionFields = [
"@request.body.author:changed = false",
"@request.body.type:changed = false",
"@request.body.widgetSchema:changed = false",
].join(" && ");
const moderatorSubmissionFieldsUnchanged = [
"@request.body.title:changed = false",
"@request.body.description:changed = false",
"@request.body.content:changed = false",
"@request.body.changelog:changed = false",
"@request.body.screenshots:changed = false",
].join(" && ");
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
app.save(users);
const submissions = app.findCollectionByNameOrId("submissions");
submissions.updateRule =
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
`(@request.auth.isAdmin = true && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`;
app.save(submissions);
const reports = app.findCollectionByNameOrId("reports");
reports.updateRule =
"@request.auth.isAdmin = true && " +
"@request.body.reporter:changed = false && " +
"@request.body.submission:changed = false && " +
"@request.body.category:changed = false && " +
"@request.body.explanation:changed = false";
app.save(reports);
},
(app) => {
const users = app.findCollectionByNameOrId("users");
users.updateRule = legacyUserUpdateRule;
app.save(users);
const submissions = app.findCollectionByNameOrId("submissions");
submissions.updateRule = legacySubmissionUpdateRule;
app.save(submissions);
const reports = app.findCollectionByNameOrId("reports");
reports.updateRule = legacyReportUpdateRule;
app.save(reports);
},
);
@@ -1,61 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
const managedLabels = [
"submissions:create",
"submissions:update",
"submissions:delete",
"votes:create",
"votes:update",
"votes:delete",
"comments:create",
"comments:update",
"comments:delete",
"reports:create",
"reports:update",
"reports:delete",
"users:update",
];
const legacyCreateRules = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
];
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
const findMigrationState = (app) => {
try {
const collection = app.findCollectionByNameOrId("workshop_migration_state");
const record = app.findFirstRecordByFilter(collection.id, "");
const snapshot = JSON.parse(record.getString("snapshot"));
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
throw new Error("Workshop migration state snapshot is invalid");
}
return { record, snapshot };
} catch {
throw new Error("Workshop rate-limit compatibility state is unavailable");
}
};
migrate(
(app) => {
const state = findMigrationState(app);
if (state.snapshot.writeRateLimits) return;
if (!state.snapshot.rateLimits || !Array.isArray(state.snapshot.rateLimits.rules)) {
throw new Error("Workshop legacy rate-limit snapshot is missing or invalid");
}
state.snapshot.writeRateLimits = {
enabled: true,
managedRules: managedRules([
...state.snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
...legacyCreateRules,
]),
};
state.record.set("snapshot", JSON.stringify(state.snapshot));
app.save(state.record);
},
() => {
// Keep the backfill until 1784240001 is rolled back; 1784240000 removes the state collection afterward.
},
);
-1
View File
@@ -13,7 +13,6 @@ ROLLBACK_CONTAINER=""
node apps/workshop/tests/workshop-contracts.mjs
node apps/workshop/tests/workshop-migration.mjs
node apps/workshop/tests/workshop-rate-limit-migration.mjs
node apps/workshop/tests/entrypoint-url.mjs
node apps/workshop/tests/workshop-identity.mjs
node apps/workshop/tests/workshop-oauth-hook.mjs
+6 -27
View File
@@ -97,34 +97,13 @@ for (const required of [
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
const hardeningMigration = await read("apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js");
for (const operation of ["create", "update", "delete"]) {
for (const collection of ["submissions", "votes", "comments", "reports"]) {
const label = `${collection}:${operation}`;
if (!hardeningMigration.includes(label)) throw new Error(`Workshop write rate limit is missing: ${label}`);
}
}
if (!hardeningMigration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited");
for (const required of [
"writeRateLimits",
"managedRules(settings.rateLimits.rules)",
"settings.rateLimits.enabled = state.enabled",
"...cloneJson(state.managedRules)",
]) {
if (!hardeningMigration.includes(required)) {
throw new Error(`Workshop write rate-limit rollback is missing persistent state restoration: ${required}`);
}
}
const accessHardeningMigration = await read("apps/workshop/pb_migrations/1784240002_workshop_access_hardening.js");
const rateLimitCompatibilityMigration = await read(
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
);
for (const required of ["writeRateLimits", "legacyCreateRules", "rate-limit compatibility state is unavailable"]) {
if (!rateLimitCompatibilityMigration.includes(required)) {
throw new Error(`Workshop rate-limit compatibility migration is missing: ${required}`);
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",
@@ -132,11 +111,11 @@ for (const protectedField of [
"githubUsername:changed",
"githubProfileUrl:changed",
]) {
if (!accessHardeningMigration.includes(protectedField)) {
if (!migration.includes(protectedField)) {
throw new Error(`Workshop OAuth identity field remains caller-controlled: ${protectedField}`);
}
}
if (accessHardeningMigration.includes("@request.body.avatar:changed")) {
if (migration.includes("@request.body.avatar:changed")) {
throw new Error("Workshop access rules must not use the unsupported :changed modifier for file fields");
}
if (
@@ -153,12 +132,12 @@ for (const protectedField of [
"changelog:changed",
"screenshots:changed",
]) {
if (!accessHardeningMigration.includes(protectedField)) {
if (!migration.includes(protectedField)) {
throw new Error(`Workshop moderators can still rewrite submissions: ${protectedField}`);
}
}
for (const protectedField of ["category:changed", "explanation:changed"]) {
if (!accessHardeningMigration.includes(protectedField)) {
if (!migration.includes(protectedField)) {
throw new Error(`Workshop moderators can still rewrite reports: ${protectedField}`);
}
}
@@ -1,205 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
const normalized = (value) => JSON.parse(JSON.stringify(value));
const managedLabels = [
"submissions:create",
"submissions:update",
"submissions:delete",
"votes:create",
"votes:update",
"votes:delete",
"comments:create",
"comments:update",
"comments:delete",
"reports:create",
"reports:update",
"reports:delete",
"users:update",
];
const originalManagedRules = managedLabels.map((label, index) => ({
label,
audience: index % 2 === 0 ? "@auth" : "",
duration: index + 1,
maxRequests: index + 10,
}));
const originalUnmanagedRule = {
label: "unrelated:create",
audience: "",
duration: 41,
maxRequests: 42,
};
const stateCollection = { id: "workshop_migration_state" };
let stateSnapshot = JSON.stringify({ marker: "retain-existing-migration-state" });
const stateRecord = {
getString(name) {
assert.equal(name, "snapshot");
return stateSnapshot;
},
set(name, value) {
assert.equal(name, "snapshot");
stateSnapshot = value;
},
};
const settings = {
rateLimits: {
enabled: false,
rules: [originalUnmanagedRule, ...originalManagedRules],
},
};
const app = {
findCollectionByNameOrId(name) {
if (name !== stateCollection.id) throw new Error(`Missing collection ${name}`);
return stateCollection;
},
findFirstRecordByFilter(collectionId) {
if (collectionId !== stateCollection.id) throw new Error(`Missing state record ${collectionId}`);
return stateRecord;
},
save() {},
settings() {
return settings;
},
};
const loadMigration = async (path) => {
let loaded;
const source = await readFile(path, "utf8");
vm.runInNewContext(source, {
console,
migrate: (up, down) => {
loaded = { up, down };
},
});
if (!loaded) throw new Error(`Workshop migration did not register: ${path}`);
return loaded;
};
const migration = await loadMigration("apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js");
migration.up(app);
const savedState = JSON.parse(stateSnapshot);
assert.equal(savedState.marker, "retain-existing-migration-state");
assert.deepEqual(savedState.writeRateLimits, {
enabled: false,
managedRules: originalManagedRules,
});
assert.equal(settings.rateLimits.enabled, true);
assert.deepEqual(
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
[originalUnmanagedRule],
"Applying Workshop rate limits changed unrelated rules",
);
assert.deepEqual(
new Set(settings.rateLimits.rules.map((rule) => rule.label)),
new Set([...managedLabels, originalUnmanagedRule.label]),
"Applying Workshop rate limits did not replace every managed label",
);
const laterUnmanagedRule = {
label: "added-after-migration:update",
audience: "@auth",
duration: 51,
maxRequests: 52,
};
settings.rateLimits.rules = [
laterUnmanagedRule,
...settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label)),
];
migration.down(app);
assert.equal(settings.rateLimits.enabled, false, "Rollback did not restore the original enabled flag");
assert.deepEqual(
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
[laterUnmanagedRule],
"Rollback changed unrelated rules added after the migration",
);
assert.deepEqual(
normalized(settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label))),
originalManagedRules,
"Rollback did not restore every original managed-label rule",
);
assert.throws(
() =>
migration.up({
...app,
findCollectionByNameOrId() {
throw new Error("missing");
},
}),
/migration state is unavailable/u,
"Migration must fail closed when its persistent rollback snapshot is unavailable",
);
const legacyOriginalRules = [
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
{ label: "submissions:create", audience: "", duration: 73, maxRequests: 74 },
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
];
const legacyStateCollection = { id: "workshop_migration_state" };
let legacyStateSnapshot = JSON.stringify({
marker: "already-applied-old-0001",
rateLimits: { enabled: false, rules: legacyOriginalRules },
});
const legacyStateRecord = {
getString() {
return legacyStateSnapshot;
},
set(_name, value) {
legacyStateSnapshot = value;
},
};
const legacySettings = {
rateLimits: {
enabled: true,
rules: [
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
...managedLabels.map((label) => ({ label, audience: "@auth", duration: 60, maxRequests: 30 })),
],
},
};
const legacyApp = {
findCollectionByNameOrId() {
return legacyStateCollection;
},
findFirstRecordByFilter() {
return legacyStateRecord;
},
save() {},
settings() {
return legacySettings;
},
};
const compatibilityMigration = await loadMigration(
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
);
compatibilityMigration.up(legacyApp);
compatibilityMigration.down(legacyApp);
const backfilledState = JSON.parse(legacyStateSnapshot);
assert.equal(backfilledState.marker, "already-applied-old-0001");
assert.deepEqual(backfilledState.writeRateLimits, {
enabled: true,
managedRules: [
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
],
});
migration.down(legacyApp);
assert.equal(legacySettings.rateLimits.enabled, true);
assert.deepEqual(
normalized(legacySettings.rateLimits.rules),
[
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
...backfilledState.writeRateLimits.managedRules,
],
"An already-applied legacy migration could not be rolled back after the compatibility backfill",
);
console.log("Workshop write rate-limit migration restoration passed");