/** * Per-member access permissions (settings > team). * * The company owner stores a sparse list of DENIED feature keys per member; everything * else is inherited (allowed, still bounded by the plan). Denying a parent key denies * its descendants, mirroring billing.MemberDeniesFeature in * apps/api/internal/billing/member_permissions.go. * * Kept free of $lib/i18n and $app imports so node:test can exercise it directly — * labels come from $lib/plan-feature-catalog at render time. */ /** One grantable feature key as returned by GET /api/team/permission-catalog. */ export type PermissionCatalogEntry = { key: string; section: string; /** Nearest grantable ancestor key, or "" for a top-level area. */ parent?: string; /** False when the company's own plan already denies the key (nothing to grant). */ plan_allowed: boolean; }; export type PermissionCatalogSection = { id: string; entries: PermissionCatalogEntry[]; }; export type PermissionCatalog = { sections: PermissionCatalogSection[]; }; /** GET/PUT /api/team/{userID}/permissions. */ export type MemberPermissionsView = { user_id: string; role: string; is_owner: boolean; denied: string[]; restricted: boolean; }; /** Ancestor keys of "a.b.c" → ["a", "a.b"]. */ export function featureAncestors(key: string): string[] { const parts = key.split("."); const out: string[] = []; for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join(".")); return out; } /** True when key, or any ancestor of it, is in the denied set. */ export function isDenied(denied: Iterable, key: string): boolean { const set = denied instanceof Set ? denied : new Set(denied); if (set.size === 0) return false; if (set.has(key)) return true; return featureAncestors(key).some((parent) => set.has(parent)); } /** Every catalog key, flattened in section order. */ export function catalogKeys(catalog: PermissionCatalog | null | undefined): string[] { if (!catalog?.sections) return []; return catalog.sections.flatMap((section) => section.entries.map((entry) => entry.key)); } function descendantsOf(key: string, allKeys: string[]): string[] { return allKeys.filter((candidate) => candidate.startsWith(`${key}.`)); } /** Direct children of key within allKeys (no grandchildren). */ function childrenOf(key: string, allKeys: string[]): string[] { const depth = key.split(".").length; return allKeys.filter( (candidate) => candidate.startsWith(`${key}.`) && candidate.split(".").length === depth + 1 ); } /** * Toggle one key and return the normalized denied list. * * Normalization keeps the stored overlay minimal and unambiguous: * - denying a key drops its now-redundant descendants, * - allowing a key whose ancestor is denied lifts that ancestor and pushes the denial * down onto the ancestor's other branches, so no other area silently re-opens. */ export function setPermissionAllowed( denied: Iterable, key: string, allowed: boolean, allKeys: string[] ): string[] { const next = new Set(denied); if (!allowed) { for (const descendant of descendantsOf(key, allKeys)) next.delete(descendant); next.add(key); return normalizeDenied(next, allKeys); } next.delete(key); // Lift every denied ancestor, re-denying its other branches so only `key` opens up. for (const ancestor of featureAncestors(key)) { if (!next.has(ancestor)) continue; next.delete(ancestor); const openPath = new Set([key, ...featureAncestors(key)]); for (const sibling of childrenOf(ancestor, allKeys)) { if (!openPath.has(sibling)) next.add(sibling); } } return normalizeDenied(next, allKeys); } /** Drop keys outside the catalog and denials already implied by a denied ancestor. */ export function normalizeDenied(denied: Set, allKeys: string[]): string[] { const known = new Set(allKeys); const out: string[] = []; for (const key of denied) { if (!known.has(key)) continue; if (featureAncestors(key).some((parent) => denied.has(parent))) continue; out.push(key); } return out.sort(); } /** Deny every grantable key (the always-on shell keys are not in the catalog). */ export function denyAll(catalog: PermissionCatalog | null | undefined): string[] { const all = catalogKeys(catalog); return normalizeDenied(new Set(all), all); } /** Allowed / total counts for the summary line, ignoring keys the plan already denies. */ export function permissionSummary( catalog: PermissionCatalog | null | undefined, denied: Iterable ): { allowed: number; total: number } { const set = new Set(denied); let allowed = 0; let total = 0; for (const section of catalog?.sections ?? []) { for (const entry of section.entries) { if (!entry.plan_allowed) continue; total += 1; if (!isDenied(set, entry.key)) allowed += 1; } } return { allowed, total }; } /** True when two denied lists describe the same overlay (order-insensitive). */ export function samePermissions(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; const set = new Set(a); return b.every((key) => set.has(key)); } /** Filter a catalog to entries matching a free-text query over key and label. */ export function filterCatalog( catalog: PermissionCatalog | null | undefined, query: string, labelFor: (key: string) => string ): PermissionCatalogSection[] { const needle = query.trim().toLowerCase(); const sections = catalog?.sections ?? []; if (!needle) return sections; const out: PermissionCatalogSection[] = []; for (const section of sections) { const entries = section.entries.filter( (entry) => entry.key.toLowerCase().includes(needle) || labelFor(entry.key).toLowerCase().includes(needle) ); if (entries.length > 0) out.push({ ...section, entries }); } return out; } /** * Role presets. * * Raw feature keys ("catalog.products.tab_error") are precise but unreadable to most * owners, so the editor leads with a role and derives the checkbox state from it. * A role grants whole dashboard sections, with a few key-level exceptions; anything * not granted is denied, and parent-prefix denial collapses the stored list. * * Roles describe which AREAS a teammate can open — the model is page-level, not * verb-level, so a role never implies "read-only" within an area it grants. */ export type PermissionRole = { id: string; /** Sections fully granted, or "all" for every section. */ sections: "all" | string[]; /** Extra keys granted outside the granted sections. */ allowKeys?: string[]; /** Keys denied even though their section is granted. */ denyKeys?: string[]; }; export const PERMISSION_ROLES: PermissionRole[] = [ { id: "full", sections: "all" }, { // Runs the whole product operation, but money and account access stay with the owner. id: "manager", sections: "all", denyKeys: [ "settings.api_keys", "settings.team", "settings.team_invite", "billing.checkout", "billing.customer_portal", "billing.quick_upgrade" ] }, { id: "catalog_editor", sections: ["dashboard", "catalog", "support"] }, { id: "feed_operator", sections: ["dashboard", "catalog", "feeds", "processing", "support"] }, { id: "marketing", sections: ["dashboard", "catalog", "marketing", "support"], allowKeys: ["integrations.email"] }, { id: "viewer", sections: ["dashboard", "support"], allowKeys: [ "catalog.products", "catalog.categories", "catalog.attributes", "catalog.standard_fields", "feeds.list" ] } ]; /** Sentinel returned by detectRole when the selection matches no preset. */ export const CUSTOM_ROLE_ID = "custom"; export function roleById(id: string): PermissionRole | null { return PERMISSION_ROLES.find((role) => role.id === id) ?? null; } /** Top-level grantable entries (those with no grantable ancestor in the catalog). */ function rootEntries(catalog: PermissionCatalog | null | undefined): PermissionCatalogEntry[] { return (catalog?.sections ?? []).flatMap((section) => section.entries.filter((entry) => !entry.parent) ); } /** The denied list a role produces for this company's catalog. */ export function rolePresetDenied( catalog: PermissionCatalog | null | undefined, role: PermissionRole | null ): string[] { if (!role) return []; const all = catalogKeys(catalog); const allowKeys = new Set(role.allowKeys ?? []); const denyKeys = new Set(role.denyKeys ?? []); const denied = new Set(); for (const entry of rootEntries(catalog)) { const sectionGranted = role.sections === "all" || role.sections.includes(entry.section); if (denyKeys.has(entry.key) || (!sectionGranted && !allowKeys.has(entry.key))) { denied.add(entry.key); } } // Key-level exceptions below a granted root (e.g. deny settings.api_keys under settings). for (const key of denyKeys) { if (!isDenied(denied, key)) denied.add(key); } return normalizeDenied(denied, all); } /** Which role the current selection corresponds to, or CUSTOM_ROLE_ID. */ export function detectRole( catalog: PermissionCatalog | null | undefined, denied: string[] ): string { for (const role of PERMISSION_ROLES) { if (samePermissions(rolePresetDenied(catalog, role), denied)) return role.id; } return CUSTOM_ROLE_ID; }