import { getPayload } from "payload"; import config from "@payload-config"; import { ALL_PERMISSION_VALUES, type Permission } from "@/permissions"; const resolvedConfig = await config; const COLLECTION_SLUGS = resolvedConfig.collections.map((c) => c.slug); const ALL_COLLECTION_READS: Permission[] = COLLECTION_SLUGS.map( (slug) => `${slug}:read` as Permission, ); const ALL_ADMIN_PAGE_MANAGE: Permission[] = COLLECTION_SLUGS.map( (slug) => `admin:${slug}:manage` as Permission, ); /** * Registry grants only: the Roles field's select options come from the * permission registry, so collection reads generated for plugin-injected * collections (story-beats, voice-submissions, ...) are not grantable and * would fail validation. */ const GRANTABLE = new Set(ALL_PERMISSION_VALUES); const grantable = (permissions: Permission[]): Permission[] => permissions.filter((p) => GRANTABLE.has(p)); /** * Read-only baseline (grantable collection reads + global reads). No write * permissions on purpose: admin-page visibility must stay scoped to division * roles' manage grants. `:read` alone never grants admin panel entry * (that needs a superuser role or any `admin::manage`). */ const USER_PERMISSIONS: Permission[] = [ ...grantable(ALL_COLLECTION_READS), "game-rules:read", "shims:read", ]; const ADMIN_PERMISSIONS: Permission[] = [ ...new Set([ ...USER_PERMISSIONS, "system:admin-access", "users:read", "users:update", "users:delete", "technologies:create", "technologies:read", "technologies:update", "technologies:delete", "tickets:read", "tickets:update", "tickets:staff", "bank-accounts:create", "bank-accounts:update", "user-notifications:read", "user-notifications:update", "mission-attendances:create", "mission-attendances:read", "mission-attendances:update", "mission-attendances:delete", "missions:read", "market-negotiations:read", "market-negotiations:update", "logistics:manage", "banking:manage", "discord:staff", "discord:announce", "forms:create", "forms:read", "forms:update", "forms:delete", "form-submissions:read", "structures:create", "structures:update", "maps:place", "map-roads:author", "map-zones:author", "resource-nodes:author", ...ALL_COLLECTION_READS, ...ALL_ADMIN_PAGE_MANAGE, ]), ]; interface BuiltinRole { slug: string; name: string; description: string; permissions: Permission[]; isSystem: boolean; isSuperuser: boolean; } const BUILTIN_ROLES: BuiltinRole[] = [ { slug: "guest", name: "Guest", description: "Default role for unauthenticated or basic users. No permissions.", permissions: [], isSystem: true, isSuperuser: false, }, { slug: "user", name: "User", description: "Standard authenticated user. Can manage shipments, structures, and read profiles.", permissions: [...USER_PERMISSIONS], isSystem: true, isSuperuser: false, }, { slug: "admin", name: "Admin", description: "Administrator. Can manage users, tickets, banking, logistics, and most collections.", permissions: [...ADMIN_PERMISSIONS], isSystem: true, isSuperuser: false, }, { slug: "developer", name: "Developer", description: "Superuser. Bypasses all permission checks. Full access to everything.", permissions: [], isSystem: true, isSuperuser: true, }, ]; const ENUM_TO_SLUG: Record = { guest: "guest", user: "user", trusted: "user", admin: "admin", developer: "developer", }; export const seedRoles = async () => { const payload = await getPayload({ config }); payload.logger.info("Seeding built-in RBAC roles..."); const roleIdMap = new Map(); for (const role of BUILTIN_ROLES) { const existing = await payload.find({ collection: "roles", where: { slug: { equals: role.slug } }, limit: 1, overrideAccess: true, }); if (existing.docs.length > 0) { const doc = existing.docs[0] as { id: number; isSystem?: boolean; permissions?: Permission[] | null; }; roleIdMap.set(role.slug, doc.id); if (role.isSystem) { const existingPerms = (doc.permissions ?? []).filter((p) => GRANTABLE.has(p)); const missing = grantable(role.permissions).filter((p) => !existingPerms.includes(p)); if (missing.length > 0) { await payload.update({ collection: "roles", id: doc.id, data: { permissions: [...existingPerms, ...missing] }, overrideAccess: true, }); payload.logger.info( ` Role "${role.slug}" (id=${doc.id}) updated with ${missing.length} missing built-in permission(s).`, ); } else { payload.logger.info( ` Role "${role.slug}" already exists (id=${doc.id}), permissions up to date.`, ); } } else { payload.logger.info( ` Role "${role.slug}" already exists (id=${doc.id}), skipping (non-system).`, ); } continue; } const created = await payload.create({ collection: "roles", data: { name: role.name, slug: role.slug, description: role.description, permissions: grantable(role.permissions), isSystem: role.isSystem, isSuperuser: role.isSuperuser, }, overrideAccess: true, }); roleIdMap.set(role.slug, created.id); payload.logger.info(` Created role "${role.slug}" (id=${created.id}).`); } payload.logger.info("Migrating existing users from roles enum to roleDocs..."); const users = await payload.find({ collection: "users", limit: 0, depth: 0, overrideAccess: true, select: { roles: true, roleDocs: true }, }); let migrated = 0; let skipped = 0; for (const user of users.docs) { const u = user as { id: number; roles?: string[] | null; roleDocs?: unknown[] | null }; if (u.roleDocs && Array.isArray(u.roleDocs) && u.roleDocs.length > 0) { skipped++; continue; } const enumRoles = u.roles ?? []; if (enumRoles.length === 0) { skipped++; continue; } const roleDocIds: number[] = []; const seenSlugs = new Set(); for (const enumRole of enumRoles) { const slug = ENUM_TO_SLUG[enumRole]; if (!slug || seenSlugs.has(slug)) continue; seenSlugs.add(slug); const roleId = roleIdMap.get(slug); if (roleId) roleDocIds.push(roleId); } if (roleDocIds.length === 0) { skipped++; continue; } await payload.update({ collection: "users", id: user.id, data: { roleDocs: roleDocIds }, overrideAccess: true, }); migrated++; } payload.logger.info(`Migration complete: ${migrated} users migrated, ${skipped} users skipped.`); }; await seedRoles(); // CLI script: the Payload pool keeps the event loop alive after completion; // exit explicitly (same convention as the game-tick bins). process.exit(0);