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
JavaScript
//#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 };