2026-09-16 07:41:20 +00:00
|
|
|
/**
|
|
|
|
|
* One-shot remote schema setup.
|
|
|
|
|
*
|
|
|
|
|
* The plan's migrations live in pocketbase/pb_migrations/ and would auto-apply
|
|
|
|
|
* on a local PocketBase start. The shared instance is already running, so
|
|
|
|
|
* this script performs the equivalent actions over the admin API:
|
|
|
|
|
*
|
|
|
|
|
* - creates the 8 application collections
|
|
|
|
|
* - adds the role/client fields to users
|
|
|
|
|
* - applies the role-scoped API rules
|
|
|
|
|
*
|
|
|
|
|
* Re-runs are safe: existing collections are reused, fields are not
|
|
|
|
|
* duplicated, and rules are reapplied each time.
|
|
|
|
|
*
|
|
|
|
|
* Run with: npm run setup-remote (PocketBase must be reachable)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import PocketBase from "pocketbase";
|
|
|
|
|
|
|
|
|
|
const PB_URL =
|
|
|
|
|
process.env.NEXT_PUBLIC_PB_URL ??
|
|
|
|
|
"http://127.0.0.1:8090";
|
|
|
|
|
|
|
|
|
|
async function authAdmin(pb: PocketBase): Promise<void> {
|
|
|
|
|
const email = process.env.PB_SUPERUSER_EMAIL;
|
|
|
|
|
const password = process.env.PB_SUPERUSER_PASSWORD;
|
|
|
|
|
if (!email || !password) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
"PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD must be set. Copy .env.example to .env.local."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
pb.autoCancellation(false);
|
|
|
|
|
await pb.collection("_superusers").authWithPassword(email, password);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getOrCreateCollection(
|
|
|
|
|
pb: PocketBase,
|
|
|
|
|
body: Record<string, unknown>
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const name = body.name as string;
|
|
|
|
|
const existing = await pb.collections
|
|
|
|
|
.getOne(name)
|
|
|
|
|
.catch(() => null);
|
|
|
|
|
if (existing) return existing.id;
|
|
|
|
|
|
|
|
|
|
const created = await pb.collections.create(body);
|
|
|
|
|
console.log(` + collection ${name} (${created.id})`);
|
|
|
|
|
return created.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureUsersFields(pb: PocketBase): Promise<void> {
|
|
|
|
|
const users = await pb.collections.getOne("users");
|
|
|
|
|
const have = new Set(users.fields.map((f: { name: string }) => f.name));
|
|
|
|
|
|
|
|
|
|
let changed = false;
|
|
|
|
|
const fields = [...(users.fields as unknown[])];
|
|
|
|
|
|
|
|
|
|
if (!have.has("role")) {
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "role",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["owner", "client_manager"],
|
|
|
|
|
});
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!have.has("client")) {
|
|
|
|
|
const clients = await pb.collections.getOne("clients");
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "client",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: false,
|
|
|
|
|
collectionId: clients.id,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
});
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 09:26:16 +00:00
|
|
|
if (!have.has("active")) {
|
|
|
|
|
fields.push({
|
|
|
|
|
name: "active",
|
|
|
|
|
type: "bool",
|
|
|
|
|
required: false,
|
|
|
|
|
presentable: false,
|
|
|
|
|
hidden: false,
|
|
|
|
|
system: false,
|
|
|
|
|
});
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 07:41:20 +00:00
|
|
|
if (changed) {
|
|
|
|
|
await pb.collections.update(users.id, { fields });
|
2026-09-16 09:26:16 +00:00
|
|
|
console.log(" + users fields: role, client, active");
|
2026-09-16 07:41:20 +00:00
|
|
|
} else {
|
|
|
|
|
console.log(" = users fields already present");
|
|
|
|
|
}
|
2026-09-16 09:26:16 +00:00
|
|
|
|
|
|
|
|
// Backfill: an account with active unset must not be locked out by this
|
|
|
|
|
// field appearing.
|
|
|
|
|
const existing = await pb.collection("users").getFullList();
|
|
|
|
|
for (const user of existing) {
|
|
|
|
|
if (user.active !== true) {
|
|
|
|
|
await pb.collection("users").update(user.id, { active: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-16 07:41:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const OWNER = '@request.auth.role = "owner"';
|
|
|
|
|
const AUTHED = '@request.auth.id != ""';
|
|
|
|
|
const scopedRead = (pathToClient: string) =>
|
|
|
|
|
`${AUTHED} && (${OWNER} || ${pathToClient} = @request.auth.client)`;
|
|
|
|
|
|
2026-09-16 09:18:13 +00:00
|
|
|
/** For the users collection: the owner sees everyone, everyone else sees only themselves. */
|
|
|
|
|
const OWNER_OR_SELF = `${AUTHED} && (${OWNER} || id = @request.auth.id)`;
|
|
|
|
|
|
2026-09-16 07:41:20 +00:00
|
|
|
async function applyRules(
|
|
|
|
|
pb: PocketBase,
|
|
|
|
|
name: string,
|
|
|
|
|
rules: {
|
|
|
|
|
listRule: string | null;
|
|
|
|
|
viewRule: string | null;
|
|
|
|
|
createRule: string | null;
|
|
|
|
|
updateRule: string | null;
|
|
|
|
|
deleteRule: string | null;
|
|
|
|
|
}
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const c = await pb.collections.getOne(name);
|
|
|
|
|
await pb.collections.update(c.id, rules);
|
|
|
|
|
console.log(` = rules for ${name}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
|
|
|
const pb = new PocketBase(PB_URL);
|
|
|
|
|
await authAdmin(pb);
|
|
|
|
|
|
|
|
|
|
console.log("Creating collections...");
|
|
|
|
|
|
|
|
|
|
// 1. clients
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "clients",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{ name: "name", type: "text", required: true, max: 120, presentable: true },
|
|
|
|
|
{ name: "slug", type: "text", required: true, max: 60, pattern: "^[a-z0-9-]+$" },
|
|
|
|
|
{ name: "brand_color", type: "text", required: false, max: 7 },
|
|
|
|
|
{ name: "active", type: "bool", required: false },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: ["CREATE UNIQUE INDEX idx_clients_slug ON clients (slug)"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. projects
|
|
|
|
|
const clientsId = (await pb.collections.getOne("clients")).id;
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "projects",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "client",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: clientsId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "name", type: "text", required: true, max: 120, presentable: true },
|
|
|
|
|
{ name: "slug", type: "text", required: true, max: 60, pattern: "^[a-z0-9-]+$" },
|
|
|
|
|
{ name: "description", type: "text", required: false, max: 20000 },
|
|
|
|
|
{
|
|
|
|
|
name: "status",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["active", "paused", "completed", "archived"],
|
|
|
|
|
},
|
|
|
|
|
{ name: "color", type: "text", required: false, max: 7 },
|
|
|
|
|
{ name: "repo_owner", type: "text", required: false, max: 120 },
|
|
|
|
|
{ name: "repo_name", type: "text", required: false, max: 120 },
|
|
|
|
|
{ name: "repo_url", type: "url", required: false },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: [
|
|
|
|
|
"CREATE UNIQUE INDEX idx_projects_client_slug ON projects (client, slug)",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Extend users
|
|
|
|
|
await ensureUsersFields(pb);
|
|
|
|
|
|
|
|
|
|
// 3. columns
|
|
|
|
|
const projectsId = (await pb.collections.getOne("projects")).id;
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "columns",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "project",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: projectsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "name", type: "text", required: true, max: 60, presentable: true },
|
|
|
|
|
{ name: "position", type: "number", required: true },
|
|
|
|
|
{ name: "is_inbox", type: "bool", required: false },
|
|
|
|
|
{ name: "is_done", type: "bool", required: false },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: ["CREATE INDEX idx_columns_project ON columns (project, position)"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 4. cards
|
|
|
|
|
const columnsId = (await pb.collections.getOne("columns")).id;
|
|
|
|
|
const usersId = (await pb.collections.getOne("users")).id;
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "cards",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "project",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: projectsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "column",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: columnsId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "title", type: "text", required: true, max: 200, presentable: true },
|
|
|
|
|
{ name: "description", type: "text", required: false, max: 50000 },
|
|
|
|
|
{ name: "position", type: "number", required: true },
|
|
|
|
|
{
|
|
|
|
|
name: "priority",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["critical", "high", "medium", "low"],
|
|
|
|
|
},
|
|
|
|
|
{ name: "target_date", type: "date", required: false },
|
|
|
|
|
{
|
|
|
|
|
name: "created_by",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: false,
|
|
|
|
|
collectionId: usersId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "created_by_role",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["owner", "client"],
|
|
|
|
|
},
|
|
|
|
|
{ name: "accepted", type: "bool", required: false },
|
|
|
|
|
{ name: "start_date", type: "date", required: false },
|
|
|
|
|
{ name: "scope_rule", type: "text", required: false, max: 60 },
|
|
|
|
|
{ name: "archived", type: "bool", required: false },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: [
|
|
|
|
|
"CREATE INDEX idx_cards_column ON cards (`column`, position)",
|
|
|
|
|
"CREATE INDEX idx_cards_project ON cards (project, archived)",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 5. subtasks
|
|
|
|
|
const cardsId = (await pb.collections.getOne("cards")).id;
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "subtasks",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "card",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: cardsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "title", type: "text", required: true, max: 200, presentable: true },
|
|
|
|
|
{ name: "done", type: "bool", required: false },
|
|
|
|
|
{ name: "position", type: "number", required: true },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: ["CREATE INDEX idx_subtasks_card ON subtasks (card, position)"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 6. comments
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "comments",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "card",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: cardsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "author",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: usersId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{ name: "body", type: "text", required: true, max: 20000 },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
indexes: ["CREATE INDEX idx_comments_card ON comments (card, created)"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 7. activity
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "activity",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: AUTHED,
|
|
|
|
|
viewRule: AUTHED,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "project",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: projectsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "card",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: false,
|
|
|
|
|
collectionId: cardsId,
|
|
|
|
|
cascadeDelete: true,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "actor",
|
|
|
|
|
type: "relation",
|
|
|
|
|
required: true,
|
|
|
|
|
collectionId: usersId,
|
|
|
|
|
cascadeDelete: false,
|
|
|
|
|
minSelect: 0,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "action",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: true,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: [
|
|
|
|
|
"card_created",
|
|
|
|
|
"card_moved",
|
|
|
|
|
"card_updated",
|
|
|
|
|
"priority_changed",
|
|
|
|
|
"date_changed",
|
|
|
|
|
"card_accepted",
|
|
|
|
|
"comment_added",
|
|
|
|
|
"subtask_toggled",
|
|
|
|
|
"card_archived",
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{ name: "field", type: "text", required: false, max: 60 },
|
|
|
|
|
{ name: "old_value", type: "text", required: false, max: 2000 },
|
|
|
|
|
{ name: "new_value", type: "text", required: false, max: 2000 },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
],
|
|
|
|
|
indexes: [
|
|
|
|
|
"CREATE INDEX idx_activity_card ON activity (card, created)",
|
|
|
|
|
"CREATE INDEX idx_activity_project ON activity (project, created)",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 8. settings (superuser-only by design)
|
|
|
|
|
await getOrCreateCollection(pb, {
|
|
|
|
|
name: "settings",
|
|
|
|
|
type: "base",
|
|
|
|
|
listRule: null,
|
|
|
|
|
viewRule: null,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
name: "ai_provider",
|
|
|
|
|
type: "select",
|
|
|
|
|
required: false,
|
|
|
|
|
maxSelect: 1,
|
|
|
|
|
values: ["anthropic", "openai", "minimax"],
|
|
|
|
|
},
|
|
|
|
|
{ name: "ai_api_key", type: "text", required: false, max: 400 },
|
|
|
|
|
{ name: "ai_model", type: "text", required: false, max: 120 },
|
|
|
|
|
{ name: "ai_system_prompt_extra", type: "text", required: false, max: 20000 },
|
|
|
|
|
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
|
|
|
|
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.log("Applying API rules...");
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "clients", {
|
|
|
|
|
listRule: `${AUTHED} && (${OWNER} || id = @request.auth.client)`,
|
|
|
|
|
viewRule: `${AUTHED} && (${OWNER} || id = @request.auth.client)`,
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "projects", {
|
|
|
|
|
listRule: scopedRead("client"),
|
|
|
|
|
viewRule: scopedRead("client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "columns", {
|
|
|
|
|
listRule: scopedRead("project.client"),
|
|
|
|
|
viewRule: scopedRead("project.client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "cards", {
|
|
|
|
|
listRule: scopedRead("project.client"),
|
|
|
|
|
viewRule: scopedRead("project.client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "subtasks", {
|
|
|
|
|
listRule: scopedRead("card.project.client"),
|
|
|
|
|
viewRule: scopedRead("card.project.client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "comments", {
|
|
|
|
|
listRule: scopedRead("card.project.client"),
|
|
|
|
|
viewRule: scopedRead("card.project.client"),
|
|
|
|
|
createRule: OWNER,
|
|
|
|
|
updateRule: OWNER,
|
|
|
|
|
deleteRule: OWNER,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await applyRules(pb, "activity", {
|
|
|
|
|
listRule: scopedRead("project.client"),
|
|
|
|
|
viewRule: scopedRead("project.client"),
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-16 09:18:13 +00:00
|
|
|
/**
|
|
|
|
|
* The users collection. Do not leave this on PocketBase's defaults.
|
|
|
|
|
*
|
|
|
|
|
* Out of the box `users` has createRule "" — anyone may sign up — and
|
|
|
|
|
* updateRule "id = @request.auth.id" — anyone may edit their own record.
|
|
|
|
|
* Because `role` is an ordinary field here, each of those is a privilege
|
|
|
|
|
* escalation on its own:
|
|
|
|
|
*
|
|
|
|
|
* - an anonymous POST could create an account with role "owner"
|
|
|
|
|
* - a client manager could promote themselves by patching their own record
|
|
|
|
|
*
|
|
|
|
|
* Both were live and both were verified exploitable before this was added.
|
|
|
|
|
*
|
|
|
|
|
* Accounts are created and edited only by the owner, through server-side
|
|
|
|
|
* code running as a superuser, so all three write rules are superuser-only.
|
|
|
|
|
* Any future self-service profile editing must go through a route handler
|
|
|
|
|
* with an explicit field whitelist — the same reason /api/client/* exists.
|
|
|
|
|
*/
|
|
|
|
|
await applyRules(pb, "users", {
|
|
|
|
|
listRule: OWNER_OR_SELF,
|
|
|
|
|
viewRule: OWNER_OR_SELF,
|
|
|
|
|
createRule: null,
|
|
|
|
|
updateRule: null,
|
|
|
|
|
deleteRule: null,
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-16 07:41:20 +00:00
|
|
|
console.log("Done.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main().catch((error) => {
|
|
|
|
|
console.error(error);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|