UNPKG

permix

Version:

Permix is a lightweight, framework-agnostic, type-safe permissions management library for JavaScript applications on the client and server sides.

307 lines (306 loc) 8.28 kB
//#region src/core/errors.ts var PermixError = class extends Error { constructor(message) { super(`[Permix]: ${message}`); this.name = "PermixError"; } }; var PermixNotReadyError = class extends PermixError { constructor() { super("Call setup() before using check() or dehydrate()."); this.name = "PermixNotReadyError"; } }; var PermixRuleNotDefinedError = class extends PermixError { path; constructor(path) { super(`Rule "${path}" is not defined.`); this.name = "PermixRuleNotDefinedError"; this.path = path; } }; var PermixNotFoundError = class extends PermixError { key; constructor(key) { super("Instance not found. Please setup the permix instance first."); this.name = "PermixNotFoundError"; this.key = key; } }; var PermixForbiddenError = class extends PermixError { constructor() { super("Forbidden."); this.name = "PermixForbiddenError"; } }; //#endregion //#region src/core/check.ts function isSpecialSymbol(value) { return value === "~any" || value === "~all"; } function pathFromArgs(args) { return args.filter((a) => typeof a === "string").join("."); } /** * Invoke a rule with no check data, treating a thrown error as `false`. * * `~any`/`~all` aggregation and `dehydrate()` evaluate every rule without an * entity. Entity-required validators (e.g. `post => post.authorId === id`) * throw on `undefined`, so we treat that as a denied permission instead of * letting the whole operation crash. */ function callRuleWithoutData(rule) { try { return Boolean(rule()); } catch { return false; } } function walk(rules, args) { const first = args[0]; if (typeof first === "string") { const parts = first.split("."); const last = parts[parts.length - 1]; if (isSpecialSymbol(last)) { let subtree = rules; for (let i = 0; i < parts.length - 1; i++) if (subtree && typeof subtree === "object") subtree = subtree[parts[i]]; if (subtree === void 0) throw new PermixRuleNotDefinedError(parts.slice(0, -1).join(".")); const out = []; const visit = (rule) => { if (typeof rule === "boolean") return void out.push(rule); if (typeof rule === "function") return void out.push(callRuleWithoutData(rule)); for (const key in rule) visit(rule[key]); }; visit(subtree); return last === "~all" ? out.every(Boolean) : out.some(Boolean); } if (first.includes(".")) args = [...parts, ...args.slice(1)]; } let rule = rules; let i = 0; for (; i < args.length && typeof rule === "object"; i++) rule = rule[String(args[i])]; if (typeof rule === "boolean") { if (args.slice(i).some((a) => typeof a === "string")) throw new PermixRuleNotDefinedError(pathFromArgs(args)); return rule; } if (typeof rule === "function") return Boolean(rule(args[i])); throw new PermixRuleNotDefinedError(args.slice(0, i + 1).join(".")); } function createCheck(rules) { return (...args) => { const r = typeof rules === "function" ? rules() : rules; if (!r) throw new PermixNotReadyError(); if (typeof args[0] === "function") return Boolean(args[0]((path, ...data) => walk(r, [path, ...data]))); return walk(r, args); }; } function createCheckContext(...params) { const first = params[0]; if (typeof first === "function") return { path: null }; if (isSpecialSymbol(first.split(".").pop())) return { path: first }; return { path: first, data: params[1] }; } //#endregion //#region src/core/hooks.ts function createHooks() { const hooks = /* @__PURE__ */ new Map(); function getList(name) { let list = hooks.get(name); if (!list) { list = []; hooks.set(name, list); } return list; } const hook = (name, fn) => { getList(name).push(fn); return () => { const list = hooks.get(name); if (!list) return; const index = list.indexOf(fn); if (index !== -1) list.splice(index, 1); }; }; const hookOnce = (name, fn) => { let remove; const wrapper = (...args) => { remove?.(); fn(...args); }; remove = hook(name, wrapper); }; const removeHook = (name, fn) => { const list = hooks.get(name); if (!list) return; const index = list.indexOf(fn); if (index !== -1) list.splice(index, 1); }; const callHook = (name, ...args) => { const list = hooks.get(name); if (!list) return; for (const fn of [...list]) fn(...args); }; const clearHook = (name) => { hooks.delete(name); }; const clearAllHooks = () => { hooks.clear(); }; return { hook, hookOnce, removeHook, callHook, clearHook, clearAllHooks }; } //#endregion //#region src/core/rules.ts /** * Recursively collapse a rules tree into its JSON-safe {@link DehydratedState}. * * Function-based rules are invoked once with no data; entity-required * validators that throw on `undefined` are treated as `false`. */ function dehydrateRules(node) { if (typeof node === "boolean") return node; if (typeof node === "function") return callRuleWithoutData(node); if (node && typeof node === "object") { const result = {}; for (const key in node) result[key] = dehydrateRules(node[key]); return result; } return node; } /** * Rebuild a {@link Rules} tree from a {@link DehydratedState} produced by * {@link dehydrateRules}. Only the serialized booleans are restored. */ function hydrateRules(state) { const result = {}; for (const key in state) { const value = state[key]; result[key] = typeof value === "boolean" ? value : hydrateRules(value); } return result; } /** * Build a typed {@link Rules} object for a given {@link Definition}. * * Returns the input unchanged — useful for declaring rules in a separate * location with full type inference. * * @example * ```ts * const rules = createRules<{ post: ['create', 'read'] }>({ * post: { create: true, read: false }, * }) * * permix.setup(rules) * ``` */ function createRules(rules) { return rules; } //#endregion //#region src/core/template.ts function createTemplate(rules) { if (typeof rules === "function") return (param) => rules(param); return () => rules; } //#endregion //#region src/core/permix.ts /** * Create a type-safe Permix instance. * * @example Flat definition * ```ts * const permix = createPermix<['read', 'write']>() * permix.setup({ read: true, write: false }) * permix.check('read') // true * ``` * * @example Nested definition * ```ts * const permix = createPermix<{ * post: ['create', 'read'] * user: ['invite'] * }>() * permix.setup({ * post: { create: true, read: true }, * user: { invite: false }, * }) * permix.check('post.create') // true * permix.check('user.invite') // false * ``` * * @example Per-action data types * ```ts * const permix = createPermix<{ * post: [ * 'create', * 'read', * { name: 'edit', type: { authorId: string }, required: true }, * ] * }>() * permix.setup({ * post: { * create: true, * read: true, * edit: post => post.authorId === me.id, * }, * }) * permix.check('post.create') // true * permix.check('post.edit', { authorId: '1' }) // true/false * ``` */ function createPermix(initialRules) { let rules = initialRules ?? null; let ready = !!initialRules; const hooks = createHooks(); const { promise: readyPromise, resolve: resolveReady } = ready ? { promise: Promise.resolve(), resolve: () => {} } : Promise.withResolvers(); const checkFn = createCheck(() => rules); return { setup(r) { rules = createRules(r); hooks.callHook("setup"); if (!ready) { ready = true; resolveReady(); hooks.callHook("ready"); } }, check(...args) { const context = createCheckContext(...args); hooks.callHook("check", context); return checkFn(...args); }, dehydrate() { if (!rules) throw new PermixNotReadyError(); return dehydrateRules(rules); }, hydrate(state) { rules = hydrateRules(state); hooks.callHook("setup"); }, template(rules) { return createTemplate(rules); }, hook: hooks.hook, hookOnce: hooks.hookOnce, isReady: () => ready, isReadyAsync: () => readyPromise, getRules: () => rules, $inferDefinition: void 0, $inferPath: void 0 }; } //#endregion export { PermixError, PermixForbiddenError, PermixNotFoundError, PermixNotReadyError, PermixRuleNotDefinedError, callRuleWithoutData, createCheck, createCheckContext, createHooks, createPermix, createRules, createTemplate, dehydrateRules, hydrateRules };