/** * 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 { 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 ): Promise { 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 { 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; } if (!have.has("active")) { fields.push({ name: "active", type: "bool", required: false, presentable: false, hidden: false, system: false, }); changed = true; } if (changed) { await pb.collections.update(users.id, { fields }); console.log(" + users fields: role, client, active"); } else { console.log(" = users fields already present"); } // 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 }); } } } const OWNER = '@request.auth.role = "owner"'; const AUTHED = '@request.auth.id != ""'; const scopedRead = (pathToClient: string) => `${AUTHED} && (${OWNER} || ${pathToClient} = @request.auth.client)`; /** For the users collection: the owner sees everyone, everyone else sees only themselves. */ const OWNER_OR_SELF = `${AUTHED} && (${OWNER} || id = @request.auth.id)`; async function applyRules( pb: PocketBase, name: string, rules: { listRule: string | null; viewRule: string | null; createRule: string | null; updateRule: string | null; deleteRule: string | null; } ): Promise { const c = await pb.collections.getOne(name); await pb.collections.update(c.id, rules); console.log(` = rules for ${name}`); } async function ensureProjectIngestFields(pb: PocketBase): Promise { const projects = await pb.collections.getOne("projects"); const have = new Set(projects.fields.map((f: { name: string }) => f.name)); const additions = [ { name: "webhook_secret", type: "text", required: false, max: 80 }, { name: "last_ingest_at", type: "date", required: false }, { name: "session_gap_minutes", type: "number", required: false, onlyInt: true, }, { name: "pre_session_minutes", type: "number", required: false, onlyInt: true, }, { name: "post_session_minutes", type: "number", required: false, onlyInt: true, }, ]; const fields = [...(projects.fields as unknown[])]; let changed = false; for (const field of additions) { if (!have.has(field.name)) { fields.push({ ...field, presentable: false, hidden: false, system: false }); changed = true; } } if (changed) { await pb.collections.update(projects.id, { fields }); console.log( ` + projects fields: ${additions .map((f) => f.name) .join(", ")}` ); } else { console.log(" = projects ingest fields already present"); } } /** * Add the `summary`, `summary_version`, `summary_attempts` fields to commits. * * Stored alongside each generated one-liner: * - summary_version — which PROMPT_VERSION produced it, so a stale summary * can be detected and re-generated rather than served to a client as if it * were current. * - summary_attempts — consecutive failures. A message that trips the * provider every time must not be retried forever on every ingest, so * summarise.ts caps it. * * Re-runs are safe: fields are not duplicated. */ async function ensureCommitSummaryFields(pb: PocketBase): Promise { const commits = await pb.collections.getOne("commits"); const have = new Set(commits.fields.map((f: { name: string }) => f.name)); const additions = [ { name: "summary", type: "text", required: false, max: 500 }, { name: "summary_version", type: "number", required: false, onlyInt: true }, { name: "summary_attempts", type: "number", required: false, onlyInt: true }, ]; const fields = [...(commits.fields as unknown[])]; let changed = false; for (const field of additions) { if (!have.has(field.name)) { fields.push({ ...field, presentable: false, hidden: false, system: false }); changed = true; } } if (changed) { await pb.collections.update(commits.id, { fields }); console.log(" + commits fields: summary, summary_version, summary_attempts"); } else { console.log(" = commit summary fields already present"); } } /** * Add the `source` and `manual_minutes` fields to commits, for Phase 7's * logged work entries. * * Backfilled to "git" for every existing row — every commit ingested before * this phase came from the repository, by definition. * * Re-runs are safe: fields are not duplicated, and backfill only touches rows * where source is still unset. */ async function ensureCommitSourceFields(pb: PocketBase): Promise { const commits = await pb.collections.getOne("commits"); const have = new Set(commits.fields.map((f: { name: string }) => f.name)); const additions = [ { name: "source", type: "select", required: false, maxSelect: 1, values: ["git", "manual"], }, { name: "manual_minutes", type: "number", required: false, onlyInt: true, min: 0 }, ]; const fields = [...(commits.fields as unknown[])]; let changed = false; for (const field of additions) { if (!have.has(field.name)) { fields.push({ ...field, presentable: false, hidden: false, system: false }); changed = true; } } if (changed) { await pb.collections.update(commits.id, { fields }); console.log(" + commits fields: source, manual_minutes"); } else { console.log(" = commit source fields already present"); } const unset = await pb.collection("commits").getFullList({ filter: 'source = ""', fields: "id", }); for (const commit of unset) { await pb.collection("commits").update(commit.id, { source: "git" }); } const prefix = unset.length > 0 ? "+" : "="; console.log(` ${prefix} commit source backfilled (${unset.length} row(s))`); } /** * Add the `narrative`, `narrative_version`, `narrative_shas` fields to cards. * * `narrative` is the AI-generated client-facing progress update. `narrative_shas` * is the set fingerprint (see shaFingerprint in src/lib/ai/narrative.ts) of * the linked commits that produced it, so the service can detect when the * commit set changes and re-generate rather than overwriting fresh text with * stale text. `narrative_version` is the prompt version, same idea as * commit summary_version. * * Re-runs are safe: fields are not duplicated. */ /** * Add the `brief`, `transcript`, `size_band` fields used by Phase 4b's intake. * * `brief` is the JSON object the client confirmed in the wizard, kept verbatim * after re-parsing through the same parseBrief whitelist. `transcript` is the * question-and-answer pairs. `size_band` is derived in code from the * decomposition, never taken from the model's output. * * Re-runs are safe: fields are not duplicated. */ async function ensureCardIntakeFields(pb: PocketBase): Promise { const cards = await pb.collections.getOne("cards"); const have = new Set(cards.fields.map((f: { name: string }) => f.name)); const additions = [ { name: "brief", type: "json", required: false, maxSize: 200_000 }, { name: "transcript", type: "json", required: false, maxSize: 200_000 }, { name: "size_band", type: "select", required: false, maxSelect: 1, values: ["small", "medium", "large"], }, ]; const fields = [...(cards.fields as unknown[])]; let changed = false; for (const field of additions) { if (!have.has(field.name)) { fields.push({ ...field, presentable: false, hidden: false, system: false }); changed = true; } } if (changed) { await pb.collections.update(cards.id, { fields }); console.log(" + cards fields: brief, transcript, size_band"); } else { console.log(" = card intake fields already present"); } } async function ensureCardNarrativeFields(pb: PocketBase): Promise { const cards = await pb.collections.getOne("cards"); const have = new Set(cards.fields.map((f: { name: string }) => f.name)); const additions = [ { name: "narrative", type: "text", required: false, max: 8000 }, { name: "narrative_version", type: "number", required: false, onlyInt: true }, { name: "narrative_shas", type: "text", required: false, max: 20000 }, ]; const fields = [...(cards.fields as unknown[])]; let changed = false; for (const field of additions) { if (!have.has(field.name)) { fields.push({ ...field, presentable: false, hidden: false, system: false }); changed = true; } } if (changed) { await pb.collections.update(cards.id, { fields }); console.log(" + cards fields: narrative, narrative_version, narrative_shas"); } else { console.log(" = card narrative fields already present"); } } async function ensureCardNumbers(pb: PocketBase): Promise { const cards = await pb.collections.getOne("cards"); const have = new Set(cards.fields.map((f: { name: string }) => f.name)); if (!have.has("number")) { const fields = [...(cards.fields as unknown[])]; fields.push({ name: "number", type: "number", required: false, onlyInt: true, presentable: false, hidden: false, system: false, }); await pb.collections.update(cards.id, { fields }); console.log(" + cards field: number"); } else { console.log(" = cards.number already present"); } // Backfill per project, oldest card first, so numbering matches the order // the work was created in rather than the order PocketBase returns. const projects = await pb.collection("projects").getFullList({ fields: "id" }); for (const project of projects) { const all = await pb.collection("cards").getFullList({ filter: `project = "${project.id}"`, sort: "created", fields: "id,number", }); let next = 1; for (const card of all) { if (typeof card.number === "number" && card.number > 0) { next = Math.max(next, card.number + 1); continue; } await pb.collection("cards").update(card.id, { number: next }); next += 1; } } console.log(" = card numbers backfilled"); // A unique index makes "never reused" enforceable rather than hoped for. const cardsCollection = await pb.collections.getOne("cards"); const indexes = new Set((cardsCollection.indexes as string[]) ?? []); const unique = "CREATE UNIQUE INDEX idx_cards_project_number ON cards (project, number)"; if (!indexes.has(unique)) { indexes.add(unique); await pb.collections.update(cardsCollection.id, { indexes: [...indexes], }); console.log(" + unique index idx_cards_project_number"); } } /** * Add the `kind` field to projects, for Phase 6's project/prototype * distinction — a prototype is a full project (board, dashboard, same * permissions), just tagged as not yet a committed piece of work. * * Backfilled to "project" for every existing row. Nothing already in * production should read as a prototype just because the field is new. * * Re-runs are safe: the field is not duplicated, and backfill only touches * rows where kind is still unset. */ /** * Add the Plan Puzzle v1 proposal fields to `cards` (proposed_target_date, * proposed_by, proposed_at) and the partial index that backs the * "cards with pending proposals" query. * * `proposed_by` is a relation to users. `cascadeDelete: false` because a * deactivated user must not silently drop their pending proposals — the * history keeps "Someone" tied to a still-existing user record. * * Re-runs are safe: fields and the index are not duplicated. */ async function ensureCardProposalFields(pb: PocketBase): Promise { const cards = await pb.collections.getOne("cards"); const users = await pb.collections.getOne("users"); const have = new Set(cards.fields.map((f: { name: string }) => f.name)); const fields = [...(cards.fields as unknown[])]; let fieldChanged = false; if (!have.has("proposed_target_date")) { fields.push({ name: "proposed_target_date", type: "date", required: false, presentable: false, hidden: false, system: false, }); fieldChanged = true; } if (!have.has("proposed_by")) { fields.push({ name: "proposed_by", type: "relation", required: false, collectionId: users.id, cascadeDelete: false, minSelect: 0, maxSelect: 1, presentable: false, hidden: false, system: false, }); fieldChanged = true; } if (!have.has("proposed_at")) { fields.push({ name: "proposed_at", type: "date", required: false, presentable: false, hidden: false, system: false, }); fieldChanged = true; } if (fieldChanged) { await pb.collections.update(cards.id, { fields }); console.log(" + cards fields: proposed_target_date, proposed_by, proposed_at"); } else { console.log(" = card proposal fields already present"); } const reloaded = await pb.collections.getOne("cards"); const indexes = new Set((reloaded.indexes as string[]) ?? []); const proposalIndex = "CREATE INDEX idx_cards_proposal ON cards (proposed_target_date) WHERE proposed_target_date IS NOT NULL"; if (!indexes.has(proposalIndex)) { indexes.add(proposalIndex); await pb.collections.update(reloaded.id, { indexes: [...indexes] }); console.log(" + cards index: idx_cards_proposal"); } } /** * Extend the activity `action` select with Plan Puzzle v1's three new values: * `column_changed`, `date_proposed`, `date_proposal_resolved`. Existing rows * are unaffected; only the schema's allowed values list grows. * * `column_changed` is new despite the plan referring to it as "already * existing" — the kanban owner's drag-from-board move route today writes no * activity row at all, so the value had never been added. The Puzzle's * "return to scheduled" action is its first real consumer. * * Re-runs are safe: values are not duplicated. */ async function ensureActivityActionValues(pb: PocketBase): Promise { const activity = await pb.collections.getOne("activity"); const field = activity.fields.find( (f: { name: string }) => f.name === "action" ) as { values?: string[] } | undefined; if (!field) { throw new Error("activity.action field missing — cannot extend values"); } const additions = [ "column_changed", "date_proposed", "date_proposal_resolved", "card_submitted_anonymously", ]; const existing = new Set(field.values ?? []); const missing = additions.filter((v) => !existing.has(v)); if (missing.length === 0) { console.log(" = activity.action values already up to date"); return; } const fields = activity.fields.map((f: { name: string; values?: string[] }) => { if (f.name !== "action") return f; return { ...f, values: [...(f.values ?? []), ...missing] }; }); await pb.collections.update(activity.id, { fields }); console.log(` + activity.action values: ${missing.join(", ")}`); } /** * Extend `cards.created_by_role` to include `"anonymous"` so the public * intake route can produce Inbox cards without a session. The base set * `["owner", "client"]` was set at first collection creation; re-running * `getOrCreateCollection` on an existing `cards` row is a no-op, so the * value set has to be patched explicitly here. Safe to re-run: values * are not duplicated. */ async function ensureCardsCreatedByRoleValues(pb: PocketBase): Promise { const cards = await pb.collections.getOne("cards"); const field = cards.fields.find( (f: { name: string }) => f.name === "created_by_role" ) as { values?: string[] } | undefined; if (!field) { throw new Error("cards.created_by_role field missing — cannot extend values"); } const additions = ["anonymous"]; const existing = new Set(field.values ?? []); const missing = additions.filter((v) => !existing.has(v)); if (missing.length === 0) { console.log(" = cards.created_by_role values already include anonymous"); return; } const fields = cards.fields.map((f: { name: string; values?: string[] }) => { if (f.name !== "created_by_role") return f; return { ...f, values: [...(f.values ?? []), ...missing] }; }); await pb.collections.update(cards.id, { fields }); console.log(` + cards.created_by_role values: ${missing.join(", ")}`); } async function ensureProjectKindField(pb: PocketBase): Promise { const projects = await pb.collections.getOne("projects"); const have = new Set(projects.fields.map((f: { name: string }) => f.name)); if (!have.has("kind")) { const fields = [...(projects.fields as unknown[])]; fields.push({ name: "kind", type: "select", required: false, maxSelect: 1, values: ["project", "prototype"], presentable: false, hidden: false, system: false, }); await pb.collections.update(projects.id, { fields }); console.log(" + projects field: kind"); } else { console.log(" = projects.kind already present"); } const unset = await pb.collection("projects").getFullList({ filter: 'kind = ""', fields: "id", }); for (const project of unset) { await pb.collection("projects").update(project.id, { kind: "project" }); } console.log(` = project kind backfilled (${unset.length} row(s))`); } async function main(): Promise { 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", "anonymous"], }, { 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", "column_changed", "card_updated", "priority_changed", "date_changed", "date_proposed", "date_proposal_resolved", "card_accepted", "comment_added", "subtask_toggled", "card_archived", "card_submitted_anonymously", ], }, { 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 }, ], }); // 11. intake_links — public, per-token entry points to a project's intake. // Owner-only at the rule level: the public route reads and writes through // the superuser connection, so PocketBase rules are not what protects // them. They exist so a signed-in client manager cannot enumerate them. await getOrCreateCollection(pb, { name: "intake_links", type: "base", listRule: OWNER, viewRule: OWNER, createRule: null, updateRule: null, deleteRule: OWNER, fields: [ { name: "project", type: "relation", required: true, collectionId: (await pb.collections.getOne("projects")).id, cascadeDelete: true, minSelect: 0, maxSelect: 1, }, { name: "label", type: "text", required: true, max: 80 }, { name: "token", type: "text", required: true, max: 64, pattern: "^[A-Za-z0-9_-]{32}$", }, { name: "active", type: "bool", required: true }, { name: "expires_at", type: "date", required: false }, { name: "max_submissions_per_day", type: "number", required: false, onlyInt: true, min: 0, max: 10000, }, { name: "submission_count", type: "number", required: true, onlyInt: true, min: 0, }, { name: "submission_count_today", type: "number", required: true, onlyInt: true, min: 0, }, { name: "submission_count_today_date", type: "date", required: false, }, { name: "last_submission_at", type: "date", required: false }, { name: "notes", type: "text", required: false, max: 1000 }, { name: "created", type: "autodate", onCreate: true, onUpdate: false }, { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, ], indexes: [ "CREATE UNIQUE INDEX idx_intake_links_token ON intake_links (token)", "CREATE INDEX idx_intake_links_project ON intake_links (project, active)", ], }); 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, }); await applyRules(pb, "intake_links", { listRule: OWNER, viewRule: OWNER, createRule: null, updateRule: null, deleteRule: OWNER, }); // 9. commits — ingest target. Created via webhook/poll/backfill only; never // from a browser. await getOrCreateCollection(pb, { name: "commits", type: "base", listRule: AUTHED, viewRule: AUTHED, createRule: null, updateRule: null, deleteRule: null, fields: [ { name: "project", type: "relation", required: true, collectionId: (await pb.collections.getOne("projects")).id, cascadeDelete: true, minSelect: 0, maxSelect: 1, }, { name: "sha", type: "text", required: true, max: 64 }, { name: "short_sha", type: "text", required: false, max: 12 }, { name: "message", type: "text", required: false, max: 50000 }, { name: "subject", type: "text", required: false, max: 300, presentable: true, }, { name: "type", type: "text", required: false, max: 30 }, { name: "scope", type: "text", required: false, max: 60 }, { name: "author_name", type: "text", required: false, max: 200 }, { name: "author_email", type: "text", required: false, max: 200 }, { name: "authored_at", type: "date", required: true }, { name: "is_merge", type: "bool", required: false }, { name: "additions", type: "number", required: false }, { name: "deletions", type: "number", required: false }, { name: "card", type: "relation", required: false, collectionId: (await pb.collections.getOne("cards")).id, cascadeDelete: false, minSelect: 0, maxSelect: 1, }, { name: "link_source", type: "select", required: false, maxSelect: 1, values: ["explicit", "scope", "manual", "none"], }, { name: "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_commits_project_sha ON commits (project, sha)", "CREATE INDEX idx_commits_project_date ON commits (project, authored_at)", "CREATE INDEX idx_commits_card ON commits (card)", ], }); /** * 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, }); await applyRules(pb, "commits", { listRule: scopedRead("project.client"), viewRule: scopedRead("project.client"), createRule: null, updateRule: null, deleteRule: null, }); // 10. project_metrics — a computed cache, rebuilt on ingest. The dashboard // reads this rather than aggregating hundreds of commit rows per page view. await getOrCreateCollection(pb, { name: "project_metrics", type: "base", listRule: AUTHED, viewRule: AUTHED, createRule: null, updateRule: null, deleteRule: null, fields: [ { name: "project", type: "relation", required: true, collectionId: (await pb.collections.getOne("projects")).id, cascadeDelete: true, minSelect: 0, maxSelect: 1, }, { name: "computed_at", type: "date", required: true }, { name: "payload", type: "json", required: true, maxSize: 4_000_000 }, { name: "created", type: "autodate", onCreate: true, onUpdate: false }, { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, ], indexes: [ "CREATE UNIQUE INDEX idx_metrics_project ON project_metrics (project)", ], }); await applyRules(pb, "project_metrics", { listRule: scopedRead("project.client"), viewRule: scopedRead("project.client"), createRule: null, updateRule: null, deleteRule: null, }); // Add ingest-related fields to projects (only if missing — the projects // collection already exists from earlier migrations). await ensureProjectIngestFields(pb); // Add the project/prototype kind field for Phase 6. await ensureProjectKindField(pb); // Add the per-commit summary fields used by Phase 3b's AI one-liner job. await ensureCommitSummaryFields(pb); // Add the source/manual_minutes fields for Phase 7's logged work entries. await ensureCommitSourceFields(pb); // Add the card number field and backfill existing cards. await ensureCardNumbers(pb); // Add the AI-generated card narrative fields used by Phase 3c. await ensureCardNarrativeFields(pb); // Add the intake wizard's fields (brief, transcript, size_band) for Phase 4b. await ensureCardIntakeFields(pb); // Add the Plan Puzzle v1 proposal fields and the partial index. await ensureCardProposalFields(pb); // Extend activity.action with the three new puzzle actions. await ensureActivityActionValues(pb); // Extend cards.created_by_role to include "anonymous" for the public route. await ensureCardsCreatedByRoleValues(pb); console.log("Done."); } main().catch((error) => { console.error(error); process.exit(1); });