@zap-studio/permit 1.1.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,39 +4,55 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.0.1]
8
+
9
+ ### Fixed
10
+
11
+ The `@opentelemetry/api` peer dependency was published as the raw pnpm `catalog:` protocol string instead of a resolved version range, an invalid semver range. This release republishes with it resolved.
12
+
13
+ ## [2.0.0]
14
+
15
+ ### Added
16
+
17
+ Added native OpenTelemetry support. Every `can(...)` call now gets an `INTERNAL` span (`permit.check {resourceType}:{action}`). The span has the decision (`"allow"`/`"deny"`) as an attribute. There is also a `permit.checks` counter with the same tag. `mergePoliciesAnd`/`mergePoliciesOr` get their own span around the combined check. See [OpenTelemetry](https://www.zapstudio.dev/permit/opentelemetry).
18
+
19
+ ### Changed
20
+
21
+ **Breaking:** `@opentelemetry/api` is now a required peer dependency. It is small, has no side effects, and does nothing until an app sets up a real SDK. So nothing changes at runtime if you don't set one up. But the package will not install without it: run `npm install @opentelemetry/api`.
22
+
7
23
  ## [1.1.1]
8
24
 
9
25
  ### Changed
10
26
 
11
- `@zap-studio/logger` is now an optional peer dependency instead of a regular dependency. Every import from it is type-only (`import type { Logger }`), so it was never pulled in at runtime pass any object matching the `Logger` shape (including `pino`) with no install required. Existing consumers of `logger?: Logger` are unaffected.
27
+ `@zap-studio/logger` is now an optional peer dependency, not a regular one. Every import from it is type-only (`import type { Logger }`), so it was never loaded at runtime. You can pass any object that matches the `Logger` shape (`pino` included) with no install needed. If you already use `logger?: Logger`, nothing changes for you.
12
28
 
13
29
  ## [1.1.0]
14
30
 
15
31
  ### Added
16
32
 
17
- `createPolicy(...)` gains an optional `logger?: Logger` option (from `@zap-studio/logger`). When provided, it logs allow decisions at `debug` and deny decisions at `info`. Internal-error warnings (resource validation and policy evaluation errors) route through the logger's `warn` instead of `console.warn` when a logger is provided; without one, they still print via `console.warn` as before. See [Logging](https://www.zapstudio.dev/permit/logging).
33
+ `createPolicy(...)` now takes an optional `logger?: Logger` option (from `@zap-studio/logger`). If you pass one, it logs allow decisions at `debug` and deny decisions at `info`. Internal-error warnings (from resource validation and policy checks) go through the logger's `warn` instead of `console.warn`, when a logger is set. With no logger, they still print with `console.warn`, as before. See [Logging](https://www.zapstudio.dev/permit/logging).
18
34
 
19
35
  ## [1.0.0]
20
36
 
21
37
  ### Changed
22
38
 
23
- - Clarified tree-shakeable design in the package description and README (no code change).
39
+ - Made the package description and README clearer about the tree-shakeable design. No code change.
24
40
 
25
41
  ### Removed
26
42
 
27
- `assertNever` is no longer exported it had no internal consumers and existed only as a generic exhaustiveness-check convenience. Use your own `never`-typed helper if you relied on it.
43
+ `assertNever` is no longer exported. Nothing inside the package used it it was only there as a generic exhaustiveness-check helper. If you used it, write your own `never`-typed helper instead.
28
44
 
29
45
  ### Fixed
30
46
 
31
- Neither merge strategy short-circuits anymore. All policies now run concurrently via `Promise.allSettled`, and every policy is invoked regardless of outcome, for both `mergePoliciesSome` (allow) and `mergePoliciesEvery` (deny). A rejecting policy no longer sinks the whole check it's treated as a deny (`false`) and logged with `console.warn`, consistent with how `createPolicy` handles internal validation/evaluation errors.
47
+ Neither merge strategy stops early anymore. All policies now run at the same time, with `Promise.allSettled`. Every policy runs, no matter the outcome, for both `mergePoliciesSome` (allow) and `mergePoliciesEvery` (deny). A policy that rejects no longer breaks the whole check. It is now treated as a deny (`false`) and logged with `console.warn`, the same way `createPolicy` handles its own internal errors.
32
48
 
33
49
  ## [0.3.4]
34
50
 
35
51
  ### Added
36
52
 
37
- The package root now re-exports the full public API, so everything can be imported from `@zap-studio/permit` directly, including `PolicyError`, `assertNever`, and all public types. All exports are side-effect free and tree-shakeable; granular subpath imports keep working.
53
+ The package root now re-exports the full public API. You can import everything from `@zap-studio/permit` directly, including `PolicyError`, `assertNever`, and all public types. All exports have no side effects and are tree-shakeable. Subpath imports still work too.
38
54
 
39
- - The implementation moved out of the entrypoint into two new subpaths: `./conditions` (`allow`, `deny`, `when`, `and`, `or`, `not`, `has`, `hasRole`, `collectInheritedRoles`) and `./policy` (`createPolicy`, `mergePolicies`, `mergePoliciesAny`).
55
+ - The code moved out of the entrypoint into two new subpaths: `./conditions` (`allow`, `deny`, `when`, `and`, `or`, `not`, `has`, `hasRole`, `collectInheritedRoles`) and `./policy` (`createPolicy`, `mergePolicies`, `mergePoliciesAny`).
40
56
 
41
57
  ## [0.3.3]
42
58
 
@@ -94,9 +110,9 @@ Internal formatting and lint cleanup only. No public API or behavior change.
94
110
 
95
111
  - f0f503e: Made policy evaluation asynchronous by default.
96
112
  - f0f503e: **Breaking:** `policy.can(...)` now returns `Promise<boolean>`.
97
- - f0f503e: **Breaking:** `createPolicy()` now uses async-safe Standard Schema validation for resource schemas (including async resource schemas); this is not a separate `Policy` schema API.
98
- - f0f503e: **Breaking:** The `Policy` interface changed so `can()` is async, and `mergePolicies`/`mergePoliciesAny` are async accordingly.
99
- - Action required: callers must `await policy.can(...)` and handle `mergePolicies`/`mergePoliciesAny` as async operations; also account for async-safe resource schema validation in `createPolicy()`.
113
+ - f0f503e: **Breaking:** `createPolicy()` now uses async-safe Standard Schema validation for resource schemas, including async ones. This is not a separate `Policy` schema API.
114
+ - f0f503e: **Breaking:** The `Policy` interface changed so `can()` is async, and `mergePolicies`/`mergePoliciesAny` are async too.
115
+ - Action required: callers must `await policy.can(...)` and handle `mergePolicies`/`mergePoliciesAny` as async operations. Also check that `createPolicy()` now validates resource schemas in an async-safe way.
100
116
  - f75b984: Updated dependency `@zap-studio/validation` to `0.3.0`.
101
117
 
102
118
  ## [0.1.3]
package/README.md CHANGED
@@ -108,7 +108,7 @@ Via `and`, `or`, and `not`.
108
108
  ```ts
109
109
  const isOwnerOrAdmin = or(
110
110
  (ctx, action, resource) => ctx.user.id === resource.authorId,
111
- (ctx, action, resource) => ctx.user.role === "admin"
111
+ (ctx, action, resource) => ctx.user.role === "admin",
112
112
  );
113
113
  ```
114
114
 
@@ -160,6 +160,27 @@ const policy = createPolicy({ resources, actions, rules, logger });
160
160
 
161
161
  Allow decisions log at `debug`, deny decisions log at `info`. Resource validation and policy evaluation errors log at `warn` through the logger when one is provided, instead of `console.warn`.
162
162
 
163
+ ## OpenTelemetry
164
+
165
+ `@opentelemetry/api` is a required peer dependency — tiny, side-effect-free, and a no-op until an app registers a real SDK, so installing it costs nothing at runtime for consumers who never set one up.
166
+
167
+ Every `can(...)` check gets an `INTERNAL` span named `permit.check {resourceType}:{action}`, with the decision (`"allow"` or `"deny"`) set as a span attribute, plus a `permit.checks` counter tagged the same way. `mergePoliciesAnd`/`mergePoliciesOr` get their own span around the composite check, on top of the spans each underlying policy already produces:
168
+
169
+ ```bash
170
+ npm install @opentelemetry/api
171
+ ```
172
+
173
+ ```ts
174
+ import { createPolicy } from "@zap-studio/permit";
175
+
176
+ const policy = createPolicy({ resources, actions, rules });
177
+
178
+ // If your app has registered an OpenTelemetry SDK, this call now produces a
179
+ // span attributed with the allow/deny decision. If not, it's a no-op — no
180
+ // wiring required either way.
181
+ await policy.can(ctx, "post:write", post);
182
+ ```
183
+
163
184
  ## Runtime Support
164
185
 
165
186
  | Runtime | Minimum version |
@@ -1 +1 @@
1
- {"version":3,"file":"conditions.d.ts","names":[],"sources":["../src/conditions.ts"],"mappings":";;;;;;;;;;;;;;;;;;cAgCa,QAET,iBAAiB,SACjB,iCACA,0BACG,SAAS,UAAU,SAAS;;;;;;;;;;;;;;;;;cAoBtB,OAET,iBAAiB,SACjB,iCACA,0BACG,SAAS,UAAU,SAAS;;;;;;;;;;;;;;;;;cAoBtB,OAET,iBAAiB,SACjB,iCACA,qBAEA,WAAW,YAAY,UAAU,SAAS,eACzC,SAAS,UAAU,SAAS;;;;;;;;;;;;;;;;;;cAqBpB,MAET,iBAAiB,SACjB,iCACA,wBAEG,YAAY,YAAY,UAAU,SAAS,iBAC7C,YAAY,UAAU,SAAS;;;;;;;;;;;;;;;;;;cAqBvB,KAET,iBAAiB,SACjB,iCACA,wBAEG,YAAY,YAAY,UAAU,SAAS,iBAC7C,YAAY,UAAU,SAAS;;;;;;;;;;;;;;;cAkBvB,MAET,iBAAiB,SACjB,iCACA,qBAEA,WAAW,YAAY,UAAU,SAAS,eACzC,YAAY,UAAU,SAAS;;;;;;;;;;;;;cAgBvB,MACV,iBAAiB,SAAS,gBAAgB,UACzC,KAAK,GACL,OAAO,SAAS,OACf,YAAY;;;;;;;;;;;;;;;;;;cAqBJ,wBAAyB,cAAc,OAAO,MACzD,OAAO,SACP,WAAW,cAAc,WACxB,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAgDM,SAAS"}
1
+ {"version":3,"file":"conditions.d.ts","names":[],"sources":["../src/conditions.ts"],"mappings":";;;;;;;;;;;;;;;;;;cAyBa,QACV,iBAAiB,SAAS,iCAAiC,0BAAwB,SAClF,UACA,SACA;;;;;;;;;;;;;;;;;cAqBS,OACV,iBAAiB,SAAS,iCAAiC,0BAAwB,SAClF,UACA,SACA;;;;;;;;;;;;;;;;;cAqBS,OACV,iBAAiB,SAAS,iCAAiC,qBAC1D,WAAW,YAAY,UAAU,SAAS,eACzC,SAAS,UAAU,SAAS;;;;;;;;;;;;;;;;;;cAqBpB,MACV,iBAAiB,SAAS,iCAAiC,wBACvD,YAAY,YAAY,UAAU,SAAS,iBAC7C,YAAY,UAAU,SAAS;;;;;;;;;;;;;;;;;;cAqBvB,KACV,iBAAiB,SAAS,iCAAiC,wBACvD,YAAY,YAAY,UAAU,SAAS,iBAC7C,YAAY,UAAU,SAAS;;;;;;;;;;;;;;;cAkBvB,MACV,iBAAiB,SAAS,iCAAiC,qBAC1D,WAAW,YAAY,UAAU,SAAS,eACzC,YAAY,UAAU,SAAS;;;;;;;;;;;;;cAgBvB,MACV,iBAAiB,SAAS,gBAAgB,UACzC,KAAK,GACL,OAAO,SAAS,OACf,YAAY;;;;;;;;;;;;;;;;;;cAqBJ,wBAAyB,cAAc,OAAO,MACzD,OAAO,SACP,WAAW,cAAc,WACxB,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAgDM,SAAS"}
@@ -1 +1 @@
1
- {"version":3,"file":"conditions.js","names":[],"sources":["../src/conditions.ts"],"sourcesContent":["/**\n * Policy and condition combinators: `allow`, `deny`, `when`, boolean\n * composition, and role helpers.\n *\n * @module @zap-studio/permit/conditions\n */\n\nimport type {\n ConditionFn,\n Context,\n HasRoleFn,\n PolicyFn,\n Role,\n RoleHierarchy,\n} from \"./types.js\";\n\n/**\n * Returns a policy function that always allows the action.\n *\n * @example\n * ```ts\n * const policy = createPolicy({\n * resources,\n * actions,\n * rules: {\n * post: {\n * read: allow(), // Always allow reading posts\n * },\n * },\n * });\n * ```\n */\nexport const allow =\n <\n TContext extends Context,\n TAction extends string = string,\n TResource = unknown,\n >(): PolicyFn<TContext, TAction, TResource> =>\n () =>\n \"allow\";\n\n/**\n * Returns a policy function that always denies the action.\n *\n * @example\n * ```ts\n * const policy = createPolicy({\n * resources,\n * actions,\n * rules: {\n * post: {\n * delete: deny(), // Never allow deleting posts\n * },\n * },\n * });\n * ```\n */\nexport const deny =\n <\n TContext extends Context,\n TAction extends string = string,\n TResource = unknown,\n >(): PolicyFn<TContext, TAction, TResource> =>\n () =>\n \"deny\";\n\n/**\n * Returns a policy function that allows or denies based on a condition.\n *\n * @example\n * ```ts\n * const policy = createPolicy({\n * resources,\n * actions,\n * rules: {\n * post: {\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * },\n * },\n * });\n * ```\n */\nexport const when =\n <\n TContext extends Context,\n TAction extends string = string,\n TResource = unknown,\n >(\n condition: ConditionFn<TContext, TAction, TResource>\n ): PolicyFn<TContext, TAction, TResource> =>\n (context, action, resource) =>\n condition(context, action, resource) ? \"allow\" : \"deny\";\n\n/**\n * Returns a condition function that returns `true` if all conditions are met.\n *\n * @example\n * ```ts\n * const isOwnerAndPublished = and(\n * (ctx, action, resource) => ctx.user.id === resource.authorId,\n * (ctx, action, resource) => resource.status === \"published\"\n * );\n *\n * rules: {\n * post: {\n * delete: when(isOwnerAndPublished),\n * },\n * }\n * ```\n */\nexport const and =\n <\n TContext extends Context,\n TAction extends string = string,\n TResource = unknown,\n >(\n ...conditions: ConditionFn<TContext, TAction, TResource>[]\n ): ConditionFn<TContext, TAction, TResource> =>\n (context, action, resource) =>\n conditions.every((condition) => condition(context, action, resource));\n\n/**\n * Returns a condition function that returns `true` if any condition is met.\n *\n * @example\n * ```ts\n * const isOwnerOrAdmin = or(\n * (ctx, action, resource) => ctx.user.id === resource.authorId,\n * (ctx, action, resource) => ctx.user.role === \"admin\"\n * );\n *\n * rules: {\n * post: {\n * write: when(isOwnerOrAdmin),\n * },\n * }\n * ```\n */\nexport const or =\n <\n TContext extends Context,\n TAction extends string = string,\n TResource = unknown,\n >(\n ...conditions: ConditionFn<TContext, TAction, TResource>[]\n ): ConditionFn<TContext, TAction, TResource> =>\n (context, action, resource) =>\n conditions.some((condition) => condition(context, action, resource));\n\n/**\n * Returns a condition function that negates another condition.\n *\n * @example\n * ```ts\n * const isNotOwner = not((ctx, action, resource) => ctx.user.id === resource.authorId);\n *\n * rules: {\n * post: {\n * like: when(isNotOwner), // Can only like posts you don't own\n * },\n * }\n * ```\n */\nexport const not =\n <\n TContext extends Context,\n TAction extends string = string,\n TResource = unknown,\n >(\n condition: ConditionFn<TContext, TAction, TResource>\n ): ConditionFn<TContext, TAction, TResource> =>\n (context, action, resource) =>\n !condition(context, action, resource);\n\n/**\n * Returns a condition function that checks if a context property equals a value.\n *\n * @example\n * ```ts\n * rules: {\n * post: {\n * write: when(has(\"role\", \"admin\")), // Only admins can write\n * },\n * }\n * ```\n */\nexport const has =\n <TContext extends Context, K extends keyof TContext>(\n key: K,\n value: TContext[K]\n ): ConditionFn<TContext> =>\n (context) =>\n context[key] === value;\n\n/**\n * Collects all roles including inherited ones from a role hierarchy.\n *\n * @example\n * ```ts\n * type Role = \"guest\" | \"user\" | \"admin\";\n *\n * const hierarchy: RoleHierarchy<Role> = {\n * guest: [],\n * user: [\"guest\"],\n * admin: [\"user\"],\n * };\n *\n * collectInheritedRoles([\"admin\"], hierarchy);\n * // Returns: Set { \"admin\", \"user\", \"guest\" }\n * ```\n */\nexport const collectInheritedRoles = <TRole extends Role = Role>(\n roles: TRole[],\n hierarchy: RoleHierarchy<TRole>\n): Set<TRole> => {\n const inherited = new Set<TRole>();\n\n const add = (role: TRole): void => {\n if (inherited.has(role)) {\n return;\n }\n\n inherited.add(role);\n const baseRoles = hierarchy[role] ?? [];\n for (const baseRole of baseRoles) {\n add(baseRole);\n }\n };\n\n for (const role of roles) {\n add(role);\n }\n return inherited;\n};\n\n/**\n * Returns a condition function that checks if the user has a specific role.\n * Supports role hierarchy for inherited permissions.\n *\n * @example\n * ```ts\n * // Without hierarchy\n * rules: {\n * post: {\n * delete: when(hasRole(\"admin\")),\n * },\n * }\n *\n * // With hierarchy\n * const hierarchy = {\n * guest: [],\n * user: [\"guest\"],\n * admin: [\"user\"],\n * };\n *\n * rules: {\n * post: {\n * read: when(hasRole(\"guest\", hierarchy)), // Admins and users can also read\n * },\n * }\n * ```\n */\nexport const hasRole: HasRoleFn =\n (\n role: Role,\n hierarchy?: RoleHierarchy\n ): ConditionFn<{ role: Role | Role[] }> =>\n (context) => {\n const userRoles = Array.isArray(context.role)\n ? context.role\n : [context.role];\n\n if (hierarchy === undefined) {\n return userRoles.includes(role);\n }\n\n const inherited = collectInheritedRoles(userRoles, hierarchy);\n return inherited.has(role);\n };\n"],"mappings":";;;;;;;;;;;;;;;;;AAgCA,MAAa,oBAOT;;;;;;;;;;;;;;;;;AAkBJ,MAAa,mBAOT;;;;;;;;;;;;;;;;;AAkBJ,MAAa,QAMT,eAED,SAAS,QAAQ,aAChB,UAAU,SAAS,QAAQ,QAAQ,IAAI,UAAU;;;;;;;;;;;;;;;;;;AAmBrD,MAAa,OAMT,GAAG,gBAEJ,SAAS,QAAQ,aAChB,WAAW,OAAO,cAAc,UAAU,SAAS,QAAQ,QAAQ,CAAC;;;;;;;;;;;;;;;;;;AAmBxE,MAAa,MAMT,GAAG,gBAEJ,SAAS,QAAQ,aAChB,WAAW,MAAM,cAAc,UAAU,SAAS,QAAQ,QAAQ,CAAC;;;;;;;;;;;;;;;AAgBvE,MAAa,OAMT,eAED,SAAS,QAAQ,aAChB,CAAC,UAAU,SAAS,QAAQ,QAAQ;;;;;;;;;;;;;AAcxC,MAAa,OAET,KACA,WAED,YACC,QAAQ,SAAS;;;;;;;;;;;;;;;;;;AAmBrB,MAAa,yBACX,OACA,cACe;CACf,MAAM,4BAAY,IAAI,IAAW;CAEjC,MAAM,OAAO,SAAsB;EACjC,IAAI,UAAU,IAAI,IAAI,GACpB;EAGF,UAAU,IAAI,IAAI;EAClB,MAAM,YAAY,UAAU,SAAS,CAAC;EACtC,KAAK,MAAM,YAAY,WACrB,IAAI,QAAQ;CAEhB;CAEA,KAAK,MAAM,QAAQ,OACjB,IAAI,IAAI;CAEV,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,MAAa,WAET,MACA,eAED,YAAY;CACX,MAAM,YAAY,MAAM,QAAQ,QAAQ,IAAI,IACxC,QAAQ,OACR,CAAC,QAAQ,IAAI;CAEjB,IAAI,cAAc,KAAA,GAChB,OAAO,UAAU,SAAS,IAAI;CAIhC,OADkB,sBAAsB,WAAW,SACpC,CAAC,CAAC,IAAI,IAAI;AAC3B"}
1
+ {"version":3,"file":"conditions.js","names":[],"sources":["../src/conditions.ts"],"sourcesContent":["/**\n * Policy and condition combinators: `allow`, `deny`, `when`, boolean\n * composition, and role helpers.\n *\n * @module @zap-studio/permit/conditions\n */\n\nimport type { ConditionFn, Context, HasRoleFn, PolicyFn, Role, RoleHierarchy } from \"./types.ts\";\n\n/**\n * Returns a policy function that always allows the action.\n *\n * @example\n * ```ts\n * const policy = createPolicy({\n * resources,\n * actions,\n * rules: {\n * post: {\n * read: allow(), // Always allow reading posts\n * },\n * },\n * });\n * ```\n */\nexport const allow =\n <TContext extends Context, TAction extends string = string, TResource = unknown>(): PolicyFn<\n TContext,\n TAction,\n TResource\n > =>\n () =>\n \"allow\";\n\n/**\n * Returns a policy function that always denies the action.\n *\n * @example\n * ```ts\n * const policy = createPolicy({\n * resources,\n * actions,\n * rules: {\n * post: {\n * delete: deny(), // Never allow deleting posts\n * },\n * },\n * });\n * ```\n */\nexport const deny =\n <TContext extends Context, TAction extends string = string, TResource = unknown>(): PolicyFn<\n TContext,\n TAction,\n TResource\n > =>\n () =>\n \"deny\";\n\n/**\n * Returns a policy function that allows or denies based on a condition.\n *\n * @example\n * ```ts\n * const policy = createPolicy({\n * resources,\n * actions,\n * rules: {\n * post: {\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * },\n * },\n * });\n * ```\n */\nexport const when =\n <TContext extends Context, TAction extends string = string, TResource = unknown>(\n condition: ConditionFn<TContext, TAction, TResource>,\n ): PolicyFn<TContext, TAction, TResource> =>\n (context, action, resource) =>\n condition(context, action, resource) ? \"allow\" : \"deny\";\n\n/**\n * Returns a condition function that returns `true` if all conditions are met.\n *\n * @example\n * ```ts\n * const isOwnerAndPublished = and(\n * (ctx, action, resource) => ctx.user.id === resource.authorId,\n * (ctx, action, resource) => resource.status === \"published\"\n * );\n *\n * rules: {\n * post: {\n * delete: when(isOwnerAndPublished),\n * },\n * }\n * ```\n */\nexport const and =\n <TContext extends Context, TAction extends string = string, TResource = unknown>(\n ...conditions: ConditionFn<TContext, TAction, TResource>[]\n ): ConditionFn<TContext, TAction, TResource> =>\n (context, action, resource) =>\n conditions.every((condition) => condition(context, action, resource));\n\n/**\n * Returns a condition function that returns `true` if any condition is met.\n *\n * @example\n * ```ts\n * const isOwnerOrAdmin = or(\n * (ctx, action, resource) => ctx.user.id === resource.authorId,\n * (ctx, action, resource) => ctx.user.role === \"admin\"\n * );\n *\n * rules: {\n * post: {\n * write: when(isOwnerOrAdmin),\n * },\n * }\n * ```\n */\nexport const or =\n <TContext extends Context, TAction extends string = string, TResource = unknown>(\n ...conditions: ConditionFn<TContext, TAction, TResource>[]\n ): ConditionFn<TContext, TAction, TResource> =>\n (context, action, resource) =>\n conditions.some((condition) => condition(context, action, resource));\n\n/**\n * Returns a condition function that negates another condition.\n *\n * @example\n * ```ts\n * const isNotOwner = not((ctx, action, resource) => ctx.user.id === resource.authorId);\n *\n * rules: {\n * post: {\n * like: when(isNotOwner), // Can only like posts you don't own\n * },\n * }\n * ```\n */\nexport const not =\n <TContext extends Context, TAction extends string = string, TResource = unknown>(\n condition: ConditionFn<TContext, TAction, TResource>,\n ): ConditionFn<TContext, TAction, TResource> =>\n (context, action, resource) =>\n !condition(context, action, resource);\n\n/**\n * Returns a condition function that checks if a context property equals a value.\n *\n * @example\n * ```ts\n * rules: {\n * post: {\n * write: when(has(\"role\", \"admin\")), // Only admins can write\n * },\n * }\n * ```\n */\nexport const has =\n <TContext extends Context, K extends keyof TContext>(\n key: K,\n value: TContext[K],\n ): ConditionFn<TContext> =>\n (context) =>\n context[key] === value;\n\n/**\n * Collects all roles including inherited ones from a role hierarchy.\n *\n * @example\n * ```ts\n * type Role = \"guest\" | \"user\" | \"admin\";\n *\n * const hierarchy: RoleHierarchy<Role> = {\n * guest: [],\n * user: [\"guest\"],\n * admin: [\"user\"],\n * };\n *\n * collectInheritedRoles([\"admin\"], hierarchy);\n * // Returns: Set { \"admin\", \"user\", \"guest\" }\n * ```\n */\nexport const collectInheritedRoles = <TRole extends Role = Role>(\n roles: TRole[],\n hierarchy: RoleHierarchy<TRole>,\n): Set<TRole> => {\n const inherited = new Set<TRole>();\n\n const add = (role: TRole): void => {\n if (inherited.has(role)) {\n return;\n }\n\n inherited.add(role);\n const baseRoles = hierarchy[role] ?? [];\n for (const baseRole of baseRoles) {\n add(baseRole);\n }\n };\n\n for (const role of roles) {\n add(role);\n }\n return inherited;\n};\n\n/**\n * Returns a condition function that checks if the user has a specific role.\n * Supports role hierarchy for inherited permissions.\n *\n * @example\n * ```ts\n * // Without hierarchy\n * rules: {\n * post: {\n * delete: when(hasRole(\"admin\")),\n * },\n * }\n *\n * // With hierarchy\n * const hierarchy = {\n * guest: [],\n * user: [\"guest\"],\n * admin: [\"user\"],\n * };\n *\n * rules: {\n * post: {\n * read: when(hasRole(\"guest\", hierarchy)), // Admins and users can also read\n * },\n * }\n * ```\n */\nexport const hasRole: HasRoleFn =\n (role: Role, hierarchy?: RoleHierarchy): ConditionFn<{ role: Role | Role[] }> =>\n (context) => {\n const userRoles = Array.isArray(context.role) ? context.role : [context.role];\n\n if (hierarchy === undefined) {\n return userRoles.includes(role);\n }\n\n const inherited = collectInheritedRoles(userRoles, hierarchy);\n return inherited.has(role);\n };\n"],"mappings":";;;;;;;;;;;;;;;;;AAyBA,MAAa,oBAOT;;;;;;;;;;;;;;;;;AAkBJ,MAAa,mBAOT;;;;;;;;;;;;;;;;;AAkBJ,MAAa,QAET,eAED,SAAS,QAAQ,aAChB,UAAU,SAAS,QAAQ,QAAQ,IAAI,UAAU;;;;;;;;;;;;;;;;;;AAmBrD,MAAa,OAET,GAAG,gBAEJ,SAAS,QAAQ,aAChB,WAAW,OAAO,cAAc,UAAU,SAAS,QAAQ,QAAQ,CAAC;;;;;;;;;;;;;;;;;;AAmBxE,MAAa,MAET,GAAG,gBAEJ,SAAS,QAAQ,aAChB,WAAW,MAAM,cAAc,UAAU,SAAS,QAAQ,QAAQ,CAAC;;;;;;;;;;;;;;;AAgBvE,MAAa,OAET,eAED,SAAS,QAAQ,aAChB,CAAC,UAAU,SAAS,QAAQ,QAAQ;;;;;;;;;;;;;AAcxC,MAAa,OAET,KACA,WAED,YACC,QAAQ,SAAS;;;;;;;;;;;;;;;;;;AAmBrB,MAAa,yBACX,OACA,cACe;CACf,MAAM,4BAAY,IAAI,IAAW;CAEjC,MAAM,OAAO,SAAsB;EACjC,IAAI,UAAU,IAAI,IAAI,GACpB;EAGF,UAAU,IAAI,IAAI;EAClB,MAAM,YAAY,UAAU,SAAS,CAAC;EACtC,KAAK,MAAM,YAAY,WACrB,IAAI,QAAQ;CAEhB;CAEA,KAAK,MAAM,QAAQ,OACjB,IAAI,IAAI;CAEV,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,MAAa,WACV,MAAY,eACZ,YAAY;CACX,MAAM,YAAY,MAAM,QAAQ,QAAQ,IAAI,IAAI,QAAQ,OAAO,CAAC,QAAQ,IAAI;CAE5E,IAAI,cAAc,KAAA,GAChB,OAAO,UAAU,SAAS,IAAI;CAIhC,OADkB,sBAAsB,WAAW,SACpC,CAAC,CAAC,IAAI,IAAI;AAC3B"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { allow, and, collectInheritedRoles, deny, has, hasRole, not, or, when } from "./conditions.js";
2
2
  import { PolicyError } from "./errors.js";
3
- import { createPolicy, mergePoliciesAnd, mergePoliciesOr } from "./policy.js";
3
+ import { n as mergePoliciesAnd, r as mergePoliciesOr, t as createPolicy } from "./policy-BBfZBocp.js";
4
4
  export { PolicyError, allow, and, collectInheritedRoles, createPolicy, deny, has, hasRole, mergePoliciesAnd, mergePoliciesOr, not, or, when };
@@ -0,0 +1,231 @@
1
+ import { PolicyError } from "./errors.js";
2
+ import { createStandardValidator } from "@zap-studio/validation";
3
+ import { SpanKind, context, metrics, trace } from "@opentelemetry/api";
4
+ //#region package.json
5
+ var name = "@zap-studio/permit";
6
+ var version = "2.0.1";
7
+ //#endregion
8
+ //#region src/_otel.ts
9
+ /**
10
+ * Internal OpenTelemetry wiring for the permit package: tracer resolution,
11
+ * the per-check span, and the checks counter. Kept out of `policy.ts` so
12
+ * authorization logic doesn't get tangled with tracing/metrics concerns.
13
+ *
14
+ * @module @zap-studio/permit/otel
15
+ */
16
+ /**
17
+ * OpenTelemetry tracer for this package. Resolved once against the global
18
+ * `TracerProvider`; a no-op provider (the default until an app registers an
19
+ * SDK) makes every span call below a no-op too.
20
+ */
21
+ const tracer = trace.getTracer(name, version);
22
+ /**
23
+ * Records one authorization check, tagged with `permit.decision: "allow" |
24
+ * "deny"`.
25
+ *
26
+ * Resolves the meter and counter fresh on every call instead of caching
27
+ * them at module scope: unlike `trace.getTracer()`, `metrics.getMeter()`
28
+ * has no proxy indirection — a reference grabbed before an app registers
29
+ * its `MeterProvider` (the common case, since ESM imports resolve before
30
+ * the importing module's own SDK-bootstrap code runs) would stay a no-op
31
+ * forever. Repeated `createCounter` calls with the same name are cheap and
32
+ * idempotent, so this costs nothing meaningful.
33
+ */
34
+ const recordPermitCheck = (decision) => {
35
+ metrics.getMeter(name, version).createCounter("permit.checks", { description: "Number of authorization checks made, tagged by decision." }).add(1, { "permit.decision": decision });
36
+ };
37
+ /**
38
+ * Wraps one authorization check in an `INTERNAL` span named
39
+ * `permit.check {permission}`, setting `permit.decision` and recording the
40
+ * `permit.checks` counter from `run`'s boolean result.
41
+ */
42
+ const withCheckSpan = async (permission, run) => {
43
+ const span = tracer.startSpan(`permit.check ${permission}`, { kind: SpanKind.INTERNAL });
44
+ try {
45
+ return await context.with(trace.setSpan(context.active(), span), async () => {
46
+ const allowed = await run();
47
+ const decision = allowed ? "allow" : "deny";
48
+ span.setAttribute("permit.decision", decision);
49
+ recordPermitCheck(decision);
50
+ return allowed;
51
+ });
52
+ } finally {
53
+ span.end();
54
+ }
55
+ };
56
+ //#endregion
57
+ //#region src/policy.ts
58
+ /**
59
+ * Splits a typed `resource:action` permission string into its parts.
60
+ * Returns `null` when the string is malformed (missing/empty part or extra
61
+ * segments) or `resourceType` is not one of `actions`' keys.
62
+ */
63
+ const parsePermission = (permission, actions) => {
64
+ const isValidResourceKey = (value) => Object.keys(actions).includes(value);
65
+ const [resourceTypeValue, actionValue, ...rest] = permission.split(":");
66
+ if (resourceTypeValue === void 0 || resourceTypeValue.length === 0 || actionValue === void 0 || actionValue.length === 0 || rest.length > 0 || !isValidResourceKey(resourceTypeValue)) return null;
67
+ return {
68
+ action: actionValue,
69
+ resourceType: resourceTypeValue
70
+ };
71
+ };
72
+ /**
73
+ * Creates a type-safe policy from resource schemas, actions, and rules.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * import { z } from "zod";
78
+ * import { createPolicy, allow, deny, when } from "@zap-studio/permit";
79
+ * import type { Resources, Actions } from "@zap-studio/permit/types";
80
+ *
81
+ * // Define resource schemas
82
+ * const resources = {
83
+ * post: z.object({
84
+ * id: z.string(),
85
+ * authorId: z.string(),
86
+ * visibility: z.enum(["public", "private"]),
87
+ * }),
88
+ * comment: z.object({
89
+ * id: z.string(),
90
+ * postId: z.string(),
91
+ * authorId: z.string(),
92
+ * }),
93
+ * } satisfies Resources;
94
+ *
95
+ * // Define actions per resource
96
+ * const actions = {
97
+ * post: ["read", "write", "delete"],
98
+ * comment: ["read", "write"],
99
+ * } as const satisfies Actions<typeof resources>;
100
+ *
101
+ * // Define context type
102
+ * type AppContext = { user: { id: string; role: string } };
103
+ *
104
+ * // Create the policy
105
+ * const policy = createPolicy<AppContext>({
106
+ * resources,
107
+ * actions,
108
+ * rules: {
109
+ * post: {
110
+ * read: when((ctx, action, resource) => resource.visibility === "public"),
111
+ * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),
112
+ * delete: deny(),
113
+ * },
114
+ * comment: {
115
+ * read: allow(),
116
+ * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),
117
+ * },
118
+ * },
119
+ * });
120
+ *
121
+ * // Check permissions
122
+ * const post = { id: "1", authorId: "user-1", visibility: "public" as const };
123
+ * await policy.can(ctx, "post:read", post); // true
124
+ * await policy.can(ctx, "post:write", post); // depends on ctx.user.id
125
+ * ```
126
+ */
127
+ const createPolicy = (config) => {
128
+ const { rules, resources, actions, logger } = config;
129
+ const validators = /* @__PURE__ */ new Map();
130
+ const getValidatedResource = async (resourceType, resource) => {
131
+ const validator = validators.get(resourceType);
132
+ if (validator === void 0) return null;
133
+ try {
134
+ const result = await validator(resource);
135
+ if (result.issues) return null;
136
+ return result.value;
137
+ } catch (error) {
138
+ logger?.warn(`Resource validation failed for ${String(resourceType)}: ${String(error)}`, {
139
+ error,
140
+ resourceType: String(resourceType)
141
+ });
142
+ return null;
143
+ }
144
+ };
145
+ const hasAllowedAction = (resourceType, action) => actions[resourceType]?.includes(action) ?? false;
146
+ const evaluatePolicy = (context, resourceType, action, resource) => {
147
+ const policyFn = rules[resourceType]?.[action];
148
+ if (policyFn === void 0) return false;
149
+ try {
150
+ const allowed = policyFn(context, action, resource) === "allow";
151
+ if (allowed) logger?.debug("permission allowed", {
152
+ action,
153
+ resourceType: String(resourceType)
154
+ });
155
+ else logger?.info("permission denied", {
156
+ action,
157
+ resourceType: String(resourceType)
158
+ });
159
+ return allowed;
160
+ } catch (error) {
161
+ logger?.warn(`Policy evaluation error for ${String(resourceType)}.${action}: ${String(error)}`, {
162
+ action,
163
+ error,
164
+ resourceType: String(resourceType)
165
+ });
166
+ return false;
167
+ }
168
+ };
169
+ for (const key in resources) {
170
+ if (!Object.hasOwn(resources, key)) continue;
171
+ const schema = resources[key];
172
+ if (schema === void 0) throw new PolicyError(`Missing schema for resource: ${String(key)}`);
173
+ const validator = createStandardValidator(schema);
174
+ validators.set(key, async (input) => await validator(input));
175
+ }
176
+ return { async can(context, permission, resource) {
177
+ return await withCheckSpan(permission, async () => {
178
+ const parsedPermission = parsePermission(permission, actions);
179
+ if (parsedPermission === null) return false;
180
+ const { action, resourceType } = parsedPermission;
181
+ if (!hasAllowedAction(resourceType, action)) return false;
182
+ const validatedResource = await getValidatedResource(resourceType, resource);
183
+ if (validatedResource === null) return false;
184
+ return evaluatePolicy(context, resourceType, action, validatedResource);
185
+ });
186
+ } };
187
+ };
188
+ const mergePoliciesWithStrategy = (policies, strategy) => ({ async can(context, permission, resource) {
189
+ return await withCheckSpan(permission, async () => {
190
+ if (policies.length === 0) return false;
191
+ const results = (await Promise.allSettled(policies.map(async (policy) => await policy.can(context, permission, resource)))).map((result) => {
192
+ if (result.status === "fulfilled") return result.value;
193
+ return false;
194
+ });
195
+ return strategy === "and" ? results.every(Boolean) : results.some(Boolean);
196
+ });
197
+ } });
198
+ /**
199
+ * Merges multiple policies into one, requiring every policy to allow.
200
+ * If any policy denies, the merged policy denies. Policies are evaluated
201
+ * in parallel; every policy is invoked regardless of outcome.
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * const basePolicy = createPolicy({ ... });
206
+ * const adminPolicy = createPolicy({ ... });
207
+ *
208
+ * const merged = mergePoliciesAnd(basePolicy, adminPolicy);
209
+ * // Both policies must allow for the action to be permitted
210
+ * ```
211
+ */
212
+ const mergePoliciesAnd = (...policies) => mergePoliciesWithStrategy(policies, "and");
213
+ /**
214
+ * Merges multiple policies into one, requiring at least one policy to allow.
215
+ * If every policy denies, the merged policy denies. Policies are evaluated
216
+ * in parallel; every policy is invoked regardless of outcome.
217
+ *
218
+ * @example
219
+ * ```ts
220
+ * const guestPolicy = createPolicy({ ... });
221
+ * const memberPolicy = createPolicy({ ... });
222
+ *
223
+ * const merged = mergePoliciesOr(guestPolicy, memberPolicy);
224
+ * // If either policy allows, the action is permitted
225
+ * ```
226
+ */
227
+ const mergePoliciesOr = (...policies) => mergePoliciesWithStrategy(policies, "or");
228
+ //#endregion
229
+ export { mergePoliciesAnd as n, mergePoliciesOr as r, createPolicy as t };
230
+
231
+ //# sourceMappingURL=policy-BBfZBocp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy-BBfZBocp.js","names":["pkg.name","pkg.version"],"sources":["../package.json","../src/_otel.ts","../src/policy.ts"],"sourcesContent":["","/**\n * Internal OpenTelemetry wiring for the permit package: tracer resolution,\n * the per-check span, and the checks counter. Kept out of `policy.ts` so\n * authorization logic doesn't get tangled with tracing/metrics concerns.\n *\n * @module @zap-studio/permit/otel\n */\n\nimport { SpanKind, context, metrics, trace } from \"@opentelemetry/api\";\n\nimport pkg from \"../package.json\" with { type: \"json\" };\n\n/**\n * OpenTelemetry tracer for this package. Resolved once against the global\n * `TracerProvider`; a no-op provider (the default until an app registers an\n * SDK) makes every span call below a no-op too.\n */\nconst tracer = trace.getTracer(pkg.name, pkg.version);\n\n/**\n * Records one authorization check, tagged with `permit.decision: \"allow\" |\n * \"deny\"`.\n *\n * Resolves the meter and counter fresh on every call instead of caching\n * them at module scope: unlike `trace.getTracer()`, `metrics.getMeter()`\n * has no proxy indirection — a reference grabbed before an app registers\n * its `MeterProvider` (the common case, since ESM imports resolve before\n * the importing module's own SDK-bootstrap code runs) would stay a no-op\n * forever. Repeated `createCounter` calls with the same name are cheap and\n * idempotent, so this costs nothing meaningful.\n */\nconst recordPermitCheck = (decision: \"allow\" | \"deny\"): void => {\n metrics\n .getMeter(pkg.name, pkg.version)\n .createCounter(\"permit.checks\", {\n description: \"Number of authorization checks made, tagged by decision.\",\n })\n .add(1, { \"permit.decision\": decision });\n};\n\n/**\n * Wraps one authorization check in an `INTERNAL` span named\n * `permit.check {permission}`, setting `permit.decision` and recording the\n * `permit.checks` counter from `run`'s boolean result.\n */\nexport const withCheckSpan = async (\n permission: string,\n run: () => Promise<boolean>,\n): Promise<boolean> => {\n const span = tracer.startSpan(`permit.check ${permission}`, {\n kind: SpanKind.INTERNAL,\n });\n\n try {\n return await context.with(trace.setSpan(context.active(), span), async () => {\n const allowed = await run();\n const decision = allowed ? \"allow\" : \"deny\";\n span.setAttribute(\"permit.decision\", decision);\n recordPermitCheck(decision);\n return allowed;\n });\n } finally {\n span.end();\n }\n};\n","/**\n * Policy creation and composition: `createPolicy`, `mergePoliciesAnd`, and\n * `mergePoliciesOr`.\n *\n * @module @zap-studio/permit/policy\n */\n\nimport type { StandardSchemaV1 } from \"@zap-studio/validation\";\n\nimport { createStandardValidator } from \"@zap-studio/validation\";\n\nimport type {\n Actions,\n Context,\n InferAction,\n InferResource,\n PermitConfig,\n Policy,\n Resources,\n} from \"./types.ts\";\n\nimport { withCheckSpan } from \"./_otel.ts\";\nimport { PolicyError } from \"./errors.ts\";\n\n/**\n * Splits a typed `resource:action` permission string into its parts.\n * Returns `null` when the string is malformed (missing/empty part or extra\n * segments) or `resourceType` is not one of `actions`' keys.\n */\nconst parsePermission = <\n TResources extends Resources,\n TActions extends Actions<TResources>,\n K extends keyof TResources & keyof TActions,\n>(\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n actions: TActions,\n): { action: InferAction<TResources, TActions, K>; resourceType: K } | null => {\n const isValidResourceKey = (value: string): value is K & string =>\n Object.keys(actions).includes(value);\n\n const [resourceTypeValue, actionValue, ...rest] = permission.split(\":\");\n if (\n resourceTypeValue === undefined ||\n resourceTypeValue.length === 0 ||\n actionValue === undefined ||\n actionValue.length === 0 ||\n rest.length > 0 ||\n !isValidResourceKey(resourceTypeValue)\n ) {\n return null;\n }\n\n return {\n action: actionValue,\n resourceType: resourceTypeValue,\n };\n};\n\n/**\n * Creates a type-safe policy from resource schemas, actions, and rules.\n *\n * @example\n * ```ts\n * import { z } from \"zod\";\n * import { createPolicy, allow, deny, when } from \"@zap-studio/permit\";\n * import type { Resources, Actions } from \"@zap-studio/permit/types\";\n *\n * // Define resource schemas\n * const resources = {\n * post: z.object({\n * id: z.string(),\n * authorId: z.string(),\n * visibility: z.enum([\"public\", \"private\"]),\n * }),\n * comment: z.object({\n * id: z.string(),\n * postId: z.string(),\n * authorId: z.string(),\n * }),\n * } satisfies Resources;\n *\n * // Define actions per resource\n * const actions = {\n * post: [\"read\", \"write\", \"delete\"],\n * comment: [\"read\", \"write\"],\n * } as const satisfies Actions<typeof resources>;\n *\n * // Define context type\n * type AppContext = { user: { id: string; role: string } };\n *\n * // Create the policy\n * const policy = createPolicy<AppContext>({\n * resources,\n * actions,\n * rules: {\n * post: {\n * read: when((ctx, action, resource) => resource.visibility === \"public\"),\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * delete: deny(),\n * },\n * comment: {\n * read: allow(),\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * },\n * },\n * });\n *\n * // Check permissions\n * const post = { id: \"1\", authorId: \"user-1\", visibility: \"public\" as const };\n * await policy.can(ctx, \"post:read\", post); // true\n * await policy.can(ctx, \"post:write\", post); // depends on ctx.user.id\n * ```\n */\nexport const createPolicy = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n config: PermitConfig<TContext, TResources, TActions>,\n): Policy<TContext, TResources, TActions> => {\n const { rules, resources, actions, logger } = config;\n const validators = new Map<\n keyof TResources,\n (input: unknown) => Promise<StandardSchemaV1.Result<unknown>>\n >();\n\n const getValidatedResource = async <K extends keyof TResources>(\n resourceType: K,\n resource: InferResource<TResources, K>,\n ): Promise<InferResource<TResources, K> | null> => {\n const validator = validators.get(resourceType);\n if (validator === undefined) {\n return null;\n }\n try {\n const result = await validator(resource);\n if (result.issues) {\n return null;\n }\n return result.value;\n } catch (error) {\n logger?.warn(`Resource validation failed for ${String(resourceType)}: ${String(error)}`, {\n error,\n resourceType: String(resourceType),\n });\n return null;\n }\n };\n\n const hasAllowedAction = <K extends keyof TResources & keyof TActions>(\n resourceType: K,\n action: InferAction<TResources, TActions, K>,\n ): boolean => actions[resourceType]?.includes(action) ?? false;\n\n const evaluatePolicy = <K extends keyof TResources & keyof TActions>(\n context: TContext,\n resourceType: K,\n action: InferAction<TResources, TActions, K>,\n resource: InferResource<TResources, K>,\n ): boolean => {\n const policyFn = rules[resourceType]?.[action];\n if (policyFn === undefined) {\n return false;\n }\n\n try {\n const allowed = policyFn(context, action, resource) === \"allow\";\n\n if (allowed) {\n logger?.debug(\"permission allowed\", {\n action,\n resourceType: String(resourceType),\n });\n } else {\n logger?.info(\"permission denied\", {\n action,\n resourceType: String(resourceType),\n });\n }\n\n return allowed;\n } catch (error) {\n logger?.warn(\n `Policy evaluation error for ${String(resourceType)}.${action}: ${String(error)}`,\n {\n action,\n error,\n resourceType: String(resourceType),\n },\n );\n return false;\n }\n };\n\n for (const key in resources) {\n if (!Object.hasOwn(resources, key)) {\n continue;\n }\n const schema = resources[key];\n if (schema === undefined) {\n throw new PolicyError(`Missing schema for resource: ${String(key)}`);\n }\n const validator = createStandardValidator(schema);\n validators.set(key, async (input: unknown) => await validator(input));\n }\n\n return {\n async can<K extends keyof TResources & keyof TActions>(\n context: TContext,\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n resource: InferResource<TResources, K>,\n ): Promise<boolean> {\n return await withCheckSpan(permission, async () => {\n const parsedPermission = parsePermission<TResources, TActions, K>(permission, actions);\n if (parsedPermission === null) {\n return false;\n }\n\n const { action, resourceType } = parsedPermission;\n if (!hasAllowedAction(resourceType, action)) {\n return false;\n }\n\n const validatedResource = await getValidatedResource(resourceType, resource);\n if (validatedResource === null) {\n return false;\n }\n\n return evaluatePolicy(context, resourceType, action, validatedResource);\n });\n },\n };\n};\n\nconst mergePoliciesWithStrategy = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n policies: Policy<TContext, TResources, TActions>[],\n strategy: \"and\" | \"or\",\n): Policy<TContext, TResources, TActions> => ({\n async can<K extends keyof TResources & keyof TActions>(\n context: TContext,\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n resource: InferResource<TResources, K>,\n ): Promise<boolean> {\n return await withCheckSpan(permission, async () => {\n if (policies.length === 0) {\n return false;\n }\n\n const settled = await Promise.allSettled(\n policies.map(async (policy) => await policy.can(context, permission, resource)),\n );\n\n const results = settled.map((result) => {\n if (result.status === \"fulfilled\") {\n return result.value;\n }\n return false;\n });\n\n return strategy === \"and\" ? results.every(Boolean) : results.some(Boolean);\n });\n },\n});\n\n/**\n * Merges multiple policies into one, requiring every policy to allow.\n * If any policy denies, the merged policy denies. Policies are evaluated\n * in parallel; every policy is invoked regardless of outcome.\n *\n * @example\n * ```ts\n * const basePolicy = createPolicy({ ... });\n * const adminPolicy = createPolicy({ ... });\n *\n * const merged = mergePoliciesAnd(basePolicy, adminPolicy);\n * // Both policies must allow for the action to be permitted\n * ```\n */\nexport const mergePoliciesAnd = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n ...policies: Policy<TContext, TResources, TActions>[]\n): Policy<TContext, TResources, TActions> => mergePoliciesWithStrategy(policies, \"and\");\n\n/**\n * Merges multiple policies into one, requiring at least one policy to allow.\n * If every policy denies, the merged policy denies. Policies are evaluated\n * in parallel; every policy is invoked regardless of outcome.\n *\n * @example\n * ```ts\n * const guestPolicy = createPolicy({ ... });\n * const memberPolicy = createPolicy({ ... });\n *\n * const merged = mergePoliciesOr(guestPolicy, memberPolicy);\n * // If either policy allows, the action is permitted\n * ```\n */\nexport const mergePoliciesOr = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n ...policies: Policy<TContext, TResources, TActions>[]\n): Policy<TContext, TResources, TActions> => mergePoliciesWithStrategy(policies, \"or\");\n"],"mappings":";;;;;;;;;;;;;;;;;;;;ACiBA,MAAM,SAAS,MAAM,UAAUA,MAAUC,OAAW;;;;;;;;;;;;;AAcpD,MAAM,qBAAqB,aAAqC;CAC9D,QACG,SAASD,MAAUC,OAAW,CAAC,CAC/B,cAAc,iBAAiB,EAC9B,aAAa,2DACf,CAAC,CAAC,CACD,IAAI,GAAG,EAAE,mBAAmB,SAAS,CAAC;AAC3C;;;;;;AAOA,MAAa,gBAAgB,OAC3B,YACA,QACqB;CACrB,MAAM,OAAO,OAAO,UAAU,gBAAgB,cAAc,EAC1D,MAAM,SAAS,SACjB,CAAC;CAED,IAAI;EACF,OAAO,MAAM,QAAQ,KAAK,MAAM,QAAQ,QAAQ,OAAO,GAAG,IAAI,GAAG,YAAY;GAC3E,MAAM,UAAU,MAAM,IAAI;GAC1B,MAAM,WAAW,UAAU,UAAU;GACrC,KAAK,aAAa,mBAAmB,QAAQ;GAC7C,kBAAkB,QAAQ;GAC1B,OAAO;EACT,CAAC;CACH,UAAU;EACR,KAAK,IAAI;CACX;AACF;;;;;;;;ACnCA,MAAM,mBAKJ,YACA,YAC6E;CAC7E,MAAM,sBAAsB,UAC1B,OAAO,KAAK,OAAO,CAAC,CAAC,SAAS,KAAK;CAErC,MAAM,CAAC,mBAAmB,aAAa,GAAG,QAAQ,WAAW,MAAM,GAAG;CACtE,IACE,sBAAsB,KAAA,KACtB,kBAAkB,WAAW,KAC7B,gBAAgB,KAAA,KAChB,YAAY,WAAW,KACvB,KAAK,SAAS,KACd,CAAC,mBAAmB,iBAAiB,GAErC,OAAO;CAGT,OAAO;EACL,QAAQ;EACR,cAAc;CAChB;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyDA,MAAa,gBAKX,WAC2C;CAC3C,MAAM,EAAE,OAAO,WAAW,SAAS,WAAW;CAC9C,MAAM,6BAAa,IAAI,IAGrB;CAEF,MAAM,uBAAuB,OAC3B,cACA,aACiD;EACjD,MAAM,YAAY,WAAW,IAAI,YAAY;EAC7C,IAAI,cAAc,KAAA,GAChB,OAAO;EAET,IAAI;GACF,MAAM,SAAS,MAAM,UAAU,QAAQ;GACvC,IAAI,OAAO,QACT,OAAO;GAET,OAAO,OAAO;EAChB,SAAS,OAAO;GACd,QAAQ,KAAK,kCAAkC,OAAO,YAAY,EAAE,IAAI,OAAO,KAAK,KAAK;IACvF;IACA,cAAc,OAAO,YAAY;GACnC,CAAC;GACD,OAAO;EACT;CACF;CAEA,MAAM,oBACJ,cACA,WACY,QAAQ,aAAa,EAAE,SAAS,MAAM,KAAK;CAEzD,MAAM,kBACJ,SACA,cACA,QACA,aACY;EACZ,MAAM,WAAW,MAAM,aAAa,GAAG;EACvC,IAAI,aAAa,KAAA,GACf,OAAO;EAGT,IAAI;GACF,MAAM,UAAU,SAAS,SAAS,QAAQ,QAAQ,MAAM;GAExD,IAAI,SACF,QAAQ,MAAM,sBAAsB;IAClC;IACA,cAAc,OAAO,YAAY;GACnC,CAAC;QAED,QAAQ,KAAK,qBAAqB;IAChC;IACA,cAAc,OAAO,YAAY;GACnC,CAAC;GAGH,OAAO;EACT,SAAS,OAAO;GACd,QAAQ,KACN,+BAA+B,OAAO,YAAY,EAAE,GAAG,OAAO,IAAI,OAAO,KAAK,KAC9E;IACE;IACA;IACA,cAAc,OAAO,YAAY;GACnC,CACF;GACA,OAAO;EACT;CACF;CAEA,KAAK,MAAM,OAAO,WAAW;EAC3B,IAAI,CAAC,OAAO,OAAO,WAAW,GAAG,GAC/B;EAEF,MAAM,SAAS,UAAU;EACzB,IAAI,WAAW,KAAA,GACb,MAAM,IAAI,YAAY,gCAAgC,OAAO,GAAG,GAAG;EAErE,MAAM,YAAY,wBAAwB,MAAM;EAChD,WAAW,IAAI,KAAK,OAAO,UAAmB,MAAM,UAAU,KAAK,CAAC;CACtE;CAEA,OAAO,EACL,MAAM,IACJ,SACA,YACA,UACkB;EAClB,OAAO,MAAM,cAAc,YAAY,YAAY;GACjD,MAAM,mBAAmB,gBAAyC,YAAY,OAAO;GACrF,IAAI,qBAAqB,MACvB,OAAO;GAGT,MAAM,EAAE,QAAQ,iBAAiB;GACjC,IAAI,CAAC,iBAAiB,cAAc,MAAM,GACxC,OAAO;GAGT,MAAM,oBAAoB,MAAM,qBAAqB,cAAc,QAAQ;GAC3E,IAAI,sBAAsB,MACxB,OAAO;GAGT,OAAO,eAAe,SAAS,cAAc,QAAQ,iBAAiB;EACxE,CAAC;CACH,EACF;AACF;AAEA,MAAM,6BAKJ,UACA,cAC4C,EAC5C,MAAM,IACJ,SACA,YACA,UACkB;CAClB,OAAO,MAAM,cAAc,YAAY,YAAY;EACjD,IAAI,SAAS,WAAW,GACtB,OAAO;EAOT,MAAM,WAAU,MAJM,QAAQ,WAC5B,SAAS,IAAI,OAAO,WAAW,MAAM,OAAO,IAAI,SAAS,YAAY,QAAQ,CAAC,CAChF,EAAA,CAEwB,KAAK,WAAW;GACtC,IAAI,OAAO,WAAW,aACpB,OAAO,OAAO;GAEhB,OAAO;EACT,CAAC;EAED,OAAO,aAAa,QAAQ,QAAQ,MAAM,OAAO,IAAI,QAAQ,KAAK,OAAO;CAC3E,CAAC;AACH,EACF;;;;;;;;;;;;;;;AAgBA,MAAa,oBAKX,GAAG,aACwC,0BAA0B,UAAU,KAAK;;;;;;;;;;;;;;;AAgBtF,MAAa,mBAKX,GAAG,aACwC,0BAA0B,UAAU,IAAI"}
@@ -1 +1 @@
1
- {"version":3,"file":"policy.d.ts","names":[],"sources":["../src/policy.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA8Ga,eACX,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,aAE/C,QAAQ,aAAa,UAAU,YAAY,cAC1C,OAAO,UAAU,YAAY;;;;;;;;;;;;;;;cAoKnB,mBACX,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,gBAE5C,UAAU,OAAO,UAAU,YAAY,gBACzC,OAAO,UAAU,YAAY;;;;;;;;;;;;;;;cAiBnB,kBACX,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,gBAE5C,UAAU,OAAO,UAAU,YAAY,gBACzC,OAAO,UAAU,YAAY"}
1
+ {"version":3,"file":"policy.d.ts","names":[],"sources":["../src/policy.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAiHa,eACX,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,aAE/C,QAAQ,aAAa,UAAU,YAAY,cAC1C,OAAO,UAAU,YAAY;;;;;;;;;;;;;;;cAmKnB,mBACX,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,gBAE5C,UAAU,OAAO,UAAU,YAAY,gBACzC,OAAO,UAAU,YAAY;;;;;;;;;;;;;;;cAgBnB,kBACX,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,gBAE5C,UAAU,OAAO,UAAU,YAAY,gBACzC,OAAO,UAAU,YAAY"}
package/dist/policy.js CHANGED
@@ -1,172 +1,3 @@
1
- import { PolicyError } from "./errors.js";
2
- import { createStandardValidator } from "@zap-studio/validation";
3
- //#region src/policy.ts
4
- /**
5
- * Splits a typed `resource:action` permission string into its parts.
6
- * Returns `null` when the string is malformed (missing/empty part or extra
7
- * segments) or `resourceType` is not one of `actions`' keys.
8
- */
9
- const parsePermission = (permission, actions) => {
10
- const isValidResourceKey = (value) => Object.keys(actions).includes(value);
11
- const [resourceTypeValue, actionValue, ...rest] = permission.split(":");
12
- if (resourceTypeValue === void 0 || resourceTypeValue.length === 0 || actionValue === void 0 || actionValue.length === 0 || rest.length > 0 || !isValidResourceKey(resourceTypeValue)) return null;
13
- return {
14
- action: actionValue,
15
- resourceType: resourceTypeValue
16
- };
17
- };
18
- /**
19
- * Creates a type-safe policy from resource schemas, actions, and rules.
20
- *
21
- * @example
22
- * ```ts
23
- * import { z } from "zod";
24
- * import { createPolicy, allow, deny, when } from "@zap-studio/permit";
25
- * import type { Resources, Actions } from "@zap-studio/permit/types";
26
- *
27
- * // Define resource schemas
28
- * const resources = {
29
- * post: z.object({
30
- * id: z.string(),
31
- * authorId: z.string(),
32
- * visibility: z.enum(["public", "private"]),
33
- * }),
34
- * comment: z.object({
35
- * id: z.string(),
36
- * postId: z.string(),
37
- * authorId: z.string(),
38
- * }),
39
- * } satisfies Resources;
40
- *
41
- * // Define actions per resource
42
- * const actions = {
43
- * post: ["read", "write", "delete"],
44
- * comment: ["read", "write"],
45
- * } as const satisfies Actions<typeof resources>;
46
- *
47
- * // Define context type
48
- * type AppContext = { user: { id: string; role: string } };
49
- *
50
- * // Create the policy
51
- * const policy = createPolicy<AppContext>({
52
- * resources,
53
- * actions,
54
- * rules: {
55
- * post: {
56
- * read: when((ctx, action, resource) => resource.visibility === "public"),
57
- * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),
58
- * delete: deny(),
59
- * },
60
- * comment: {
61
- * read: allow(),
62
- * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),
63
- * },
64
- * },
65
- * });
66
- *
67
- * // Check permissions
68
- * const post = { id: "1", authorId: "user-1", visibility: "public" as const };
69
- * await policy.can(ctx, "post:read", post); // true
70
- * await policy.can(ctx, "post:write", post); // depends on ctx.user.id
71
- * ```
72
- */
73
- const createPolicy = (config) => {
74
- const { rules, resources, actions, logger } = config;
75
- const validators = /* @__PURE__ */ new Map();
76
- const getValidatedResource = async (resourceType, resource) => {
77
- const validator = validators.get(resourceType);
78
- if (validator === void 0) return null;
79
- try {
80
- const result = await validator(resource);
81
- if (result.issues) return null;
82
- return result.value;
83
- } catch (error) {
84
- logger?.warn(`Resource validation failed for ${String(resourceType)}: ${String(error)}`, {
85
- error,
86
- resourceType: String(resourceType)
87
- });
88
- return null;
89
- }
90
- };
91
- const hasAllowedAction = (resourceType, action) => actions[resourceType]?.includes(action) ?? false;
92
- const evaluatePolicy = (context, resourceType, action, resource) => {
93
- const policyFn = rules[resourceType]?.[action];
94
- if (policyFn === void 0) return false;
95
- try {
96
- const allowed = policyFn(context, action, resource) === "allow";
97
- if (allowed) logger?.debug("permission allowed", {
98
- action,
99
- resourceType: String(resourceType)
100
- });
101
- else logger?.info("permission denied", {
102
- action,
103
- resourceType: String(resourceType)
104
- });
105
- return allowed;
106
- } catch (error) {
107
- logger?.warn(`Policy evaluation error for ${String(resourceType)}.${action}: ${String(error)}`, {
108
- action,
109
- error,
110
- resourceType: String(resourceType)
111
- });
112
- return false;
113
- }
114
- };
115
- for (const key of Object.keys(resources)) {
116
- const schema = resources[key];
117
- if (schema === void 0) throw new PolicyError(`Missing schema for resource: ${String(key)}`);
118
- const validator = createStandardValidator(schema);
119
- validators.set(key, async (input) => await validator(input));
120
- }
121
- return { async can(context, permission, resource) {
122
- const parsedPermission = parsePermission(permission, actions);
123
- if (parsedPermission === null) return false;
124
- const { action, resourceType } = parsedPermission;
125
- if (!hasAllowedAction(resourceType, action)) return false;
126
- const validatedResource = await getValidatedResource(resourceType, resource);
127
- if (validatedResource === null) return false;
128
- return evaluatePolicy(context, resourceType, action, validatedResource);
129
- } };
130
- };
131
- const mergePoliciesWithStrategy = (policies, strategy) => ({ async can(context, permission, resource) {
132
- if (policies.length === 0) return false;
133
- const results = (await Promise.allSettled(policies.map(async (policy) => await policy.can(context, permission, resource)))).map((result) => {
134
- if (result.status === "fulfilled") return result.value;
135
- return false;
136
- });
137
- return strategy === "and" ? results.every(Boolean) : results.some(Boolean);
138
- } });
139
- /**
140
- * Merges multiple policies into one, requiring every policy to allow.
141
- * If any policy denies, the merged policy denies. Policies are evaluated
142
- * in parallel; every policy is invoked regardless of outcome.
143
- *
144
- * @example
145
- * ```ts
146
- * const basePolicy = createPolicy({ ... });
147
- * const adminPolicy = createPolicy({ ... });
148
- *
149
- * const merged = mergePoliciesAnd(basePolicy, adminPolicy);
150
- * // Both policies must allow for the action to be permitted
151
- * ```
152
- */
153
- const mergePoliciesAnd = (...policies) => mergePoliciesWithStrategy(policies, "and");
154
- /**
155
- * Merges multiple policies into one, requiring at least one policy to allow.
156
- * If every policy denies, the merged policy denies. Policies are evaluated
157
- * in parallel; every policy is invoked regardless of outcome.
158
- *
159
- * @example
160
- * ```ts
161
- * const guestPolicy = createPolicy({ ... });
162
- * const memberPolicy = createPolicy({ ... });
163
- *
164
- * const merged = mergePoliciesOr(guestPolicy, memberPolicy);
165
- * // If either policy allows, the action is permitted
166
- * ```
167
- */
168
- const mergePoliciesOr = (...policies) => mergePoliciesWithStrategy(policies, "or");
169
- //#endregion
1
+ import "./errors.js";
2
+ import { n as mergePoliciesAnd, r as mergePoliciesOr, t as createPolicy } from "./policy-BBfZBocp.js";
170
3
  export { createPolicy, mergePoliciesAnd, mergePoliciesOr };
171
-
172
- //# sourceMappingURL=policy.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;;;;;;KAmBY;;;;;;;;;;KAWA,QAAQ,sBAAsB;;;;;;;;;KAU9B,KAAK,iCAAiC;;;;;;;;;;;;;;;;KAiBtC,cAAc,cAAc,OAAO,QAAQ,OAAO,OAAO;;;;;;;;;;;;;;;;KAiBzD,UAAU,wCAAwC,OAC5D,cACA;;;;;;;;;;;;;;;KAiBU,QAAQ,mBAAmB,gBACpC,WAAW;;;;;;;;;KAWF,cACV,mBAAmB,WACnB,2BAA2B,cACzB,iBAAiB,YAAY,WAAW;;;;;;;;;;KAWhC,YACV,mBAAmB,WACnB,iBAAiB,QAAQ,aACzB,gBAAgB,YACd,SAAS;;;;;;;;;;KAWD,gBACV,mBAAmB,WACnB,iBAAiB,QAAQ,kBAGvB,WAAW,mBAAmB,cAC1B,cAAc,YAAY,YAAY,UAAU,uBAChD,mBAAmB;;;;;;;;;;KAWf,SACV,iBAAiB,SACjB,iCACA,wBACG,SAAS,UAAU,QAAQ,SAAS,UAAU,cAAc;;;;;;;;;;KAWrD,YACV,iBAAiB,SACjB,iCACA,wBACG,SAAS,UAAU,QAAQ,SAAS,UAAU;;;;UAKlC;;;;;;GAOb;IAAmB,MAAM,OAAO;KAChC,iCACA,qBAEA,MAAM,OACL,YAAY,UAAU,SAAS;;;;;;;;GAShC;IAAmB,MAAM,QAAQ;KACjC,iCACA,qBACA,cAAc,OAAO,MAErB,MAAM,OACN,WAAW,cAAc,SACxB,YAAY,UAAU,SAAS;;;;;;;;;;;;;;;;;;KAmBxB,gBACV,iBAAiB,SACjB,iCACA,0BAEC,KAAK,WAAW,SAAS,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;KAuB7B,MACV,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,kBAE9C,WAAW,mBAAmB,WAAW,gBACxC,UACA,YAAY,YAAY,UAAU,IAClC,cAAc,YAAY;;;;;;;;;;;;;;;UAkBb,aACf,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ;;;;;EAM/C,SAAS;;;;;;;EAOT,SAAS;;;;;EAKT,WAAW;;;;;EAKX,OAAO,MAAM,UAAU,YAAY;;;;;;;;;;;;;;;;;UAkBpB,OACf,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ;;;;EAK/C,MAAM,gBAAgB,mBAAmB,UACvC,SAAS,UACT,eAAe,cAAc,YAAY,YAAY,UAAU,eAC/D,UAAU,cAAc,YAAY,OACjC"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;;;;;;KAmBY;;;;;;;;;;KAWA,QAAQ,sBAAsB;;;;;;;;;KAU9B,KAAK,iCAAiC;;;;;;;;;;;;;;;;KAiBtC,cAAc,cAAc,OAAO,QAAQ,OAAO,OAAO;;;;;;;;;;;;;;;;KAiBzD,UAAU,wCAAwC,OAC5D,cACA;;;;;;;;;;;;;;;KAiBU,QAAQ,mBAAmB,gBACpC,WAAW;;;;;;;;;KAWF,cACV,mBAAmB,WACnB,2BAA2B,cACzB,iBAAiB,YAAY,WAAW;;;;;;;;;;KAWhC,YACV,mBAAmB,WACnB,iBAAiB,QAAQ,aACzB,gBAAgB,YACd,SAAS;;;;;;;;;;KAWD,gBAAgB,mBAAmB,WAAW,iBAAiB,QAAQ,kBAE/E,WAAW,mBAAmB,cAC1B,cAAc,YAAY,YAAY,UAAU,uBAChD,mBAAmB;;;;;;;;;;KAWf,SACV,iBAAiB,SACjB,iCACA,wBACG,SAAS,UAAU,QAAQ,SAAS,UAAU,cAAc;;;;;;;;;;KAWrD,YACV,iBAAiB,SACjB,iCACA,wBACG,SAAS,UAAU,QAAQ,SAAS,UAAU;;;;UAKlC;;;;;;GAMd;IAAmB,MAAM,OAAO;KAAU,iCAAiC,qBAC1E,MAAM,OACL,YAAY,UAAU,SAAS;;;;;;;;GAShC;IAAmB,MAAM,QAAQ;KACjC,iCACA,qBACA,cAAc,OAAO,MAErB,MAAM,OACN,WAAW,cAAc,SACxB,YAAY,UAAU,SAAS;;;;;;;;;;;;;;;;;;KAmBxB,gBACV,iBAAiB,SACjB,iCACA,0BAEC,KAAK,WAAW,SAAS,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;KAuB7B,MACV,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,kBAE9C,WAAW,mBAAmB,WAAW,gBACxC,UACA,YAAY,YAAY,UAAU,IAClC,cAAc,YAAY;;;;;;;;;;;;;;;UAkBb,aACf,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ;;;;;EAM/C,SAAS;;;;;;;EAOT,SAAS;;;;;EAKT,WAAW;;;;;EAKX,OAAO,MAAM,UAAU,YAAY;;;;;;;;;;;;;;;;;UAkBpB,OACf,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ;;;;EAK/C,MAAM,gBAAgB,mBAAmB,UACvC,SAAS,UACT,eAAe,cAAc,YAAY,YAAY,UAAU,eAC/D,UAAU,cAAc,YAAY,OACjC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zap-studio/permit",
3
- "version": "1.1.1",
3
+ "version": "2.0.1",
4
4
  "private": false,
5
5
  "description": "A type-safe, declarative, tree-shakeable authorization library for TypeScript with Standard Schema support.",
6
6
  "keywords": [
@@ -44,17 +44,22 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@zap-studio/validation": "1.0.0"
47
+ "@zap-studio/validation": "1.1.1"
48
48
  },
49
49
  "devDependencies": {
50
+ "@opentelemetry/api": "^1.9.1",
51
+ "@opentelemetry/context-async-hooks": "^2.10.0",
52
+ "@opentelemetry/sdk-metrics": "^2.10.0",
53
+ "@opentelemetry/sdk-trace-base": "^2.10.0",
54
+ "@zap-studio/logger": "2.1.0",
55
+ "@zap-studio/typescript": "0.0.0",
50
56
  "tsdown": "^0.22.14",
51
57
  "typescript": "^7.0.2",
52
- "vitest": "^4.1.10",
53
- "@zap-studio/logger": "1.0.0",
54
- "@zap-studio/typescript": "0.0.0"
58
+ "vitest": "^4.1.11"
55
59
  },
56
60
  "peerDependencies": {
57
- "@zap-studio/logger": "1.0.0"
61
+ "@opentelemetry/api": "^1.9.1",
62
+ "@zap-studio/logger": "2.1.0"
58
63
  },
59
64
  "peerDependenciesMeta": {
60
65
  "@zap-studio/logger": {
@@ -63,5 +68,6 @@
63
68
  },
64
69
  "engines": {
65
70
  "node": ">=18.0.0"
66
- }
71
+ },
72
+ "scripts": {}
67
73
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"policy.js","names":[],"sources":["../src/policy.ts"],"sourcesContent":["/**\n * Policy creation and composition: `createPolicy`, `mergePoliciesAnd`, and\n * `mergePoliciesOr`.\n *\n * @module @zap-studio/permit/policy\n */\n\nimport type { StandardSchemaV1 } from \"@zap-studio/validation\";\nimport { createStandardValidator } from \"@zap-studio/validation\";\n\nimport { PolicyError } from \"./errors.js\";\nimport type {\n Actions,\n Context,\n InferAction,\n InferResource,\n PermitConfig,\n Policy,\n Resources,\n} from \"./types.js\";\n\n/**\n * Splits a typed `resource:action` permission string into its parts.\n * Returns `null` when the string is malformed (missing/empty part or extra\n * segments) or `resourceType` is not one of `actions`' keys.\n */\nconst parsePermission = <\n TResources extends Resources,\n TActions extends Actions<TResources>,\n K extends keyof TResources & keyof TActions,\n>(\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n actions: TActions\n): { action: InferAction<TResources, TActions, K>; resourceType: K } | null => {\n const isValidResourceKey = (value: string): value is K & string =>\n Object.keys(actions).includes(value);\n\n const [resourceTypeValue, actionValue, ...rest] = permission.split(\":\");\n if (\n resourceTypeValue === undefined ||\n resourceTypeValue.length === 0 ||\n actionValue === undefined ||\n actionValue.length === 0 ||\n rest.length > 0 ||\n !isValidResourceKey(resourceTypeValue)\n ) {\n return null;\n }\n\n return {\n action: actionValue,\n resourceType: resourceTypeValue,\n };\n};\n\n/**\n * Creates a type-safe policy from resource schemas, actions, and rules.\n *\n * @example\n * ```ts\n * import { z } from \"zod\";\n * import { createPolicy, allow, deny, when } from \"@zap-studio/permit\";\n * import type { Resources, Actions } from \"@zap-studio/permit/types\";\n *\n * // Define resource schemas\n * const resources = {\n * post: z.object({\n * id: z.string(),\n * authorId: z.string(),\n * visibility: z.enum([\"public\", \"private\"]),\n * }),\n * comment: z.object({\n * id: z.string(),\n * postId: z.string(),\n * authorId: z.string(),\n * }),\n * } satisfies Resources;\n *\n * // Define actions per resource\n * const actions = {\n * post: [\"read\", \"write\", \"delete\"],\n * comment: [\"read\", \"write\"],\n * } as const satisfies Actions<typeof resources>;\n *\n * // Define context type\n * type AppContext = { user: { id: string; role: string } };\n *\n * // Create the policy\n * const policy = createPolicy<AppContext>({\n * resources,\n * actions,\n * rules: {\n * post: {\n * read: when((ctx, action, resource) => resource.visibility === \"public\"),\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * delete: deny(),\n * },\n * comment: {\n * read: allow(),\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * },\n * },\n * });\n *\n * // Check permissions\n * const post = { id: \"1\", authorId: \"user-1\", visibility: \"public\" as const };\n * await policy.can(ctx, \"post:read\", post); // true\n * await policy.can(ctx, \"post:write\", post); // depends on ctx.user.id\n * ```\n */\nexport const createPolicy = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n config: PermitConfig<TContext, TResources, TActions>\n): Policy<TContext, TResources, TActions> => {\n const { rules, resources, actions, logger } = config;\n const validators = new Map<\n keyof TResources,\n (input: unknown) => Promise<StandardSchemaV1.Result<unknown>>\n >();\n\n const getValidatedResource = async <K extends keyof TResources>(\n resourceType: K,\n resource: InferResource<TResources, K>\n ): Promise<InferResource<TResources, K> | null> => {\n const validator = validators.get(resourceType);\n if (validator === undefined) {\n return null;\n }\n try {\n const result = await validator(resource);\n if (result.issues) {\n return null;\n }\n return result.value;\n } catch (error) {\n logger?.warn(\n `Resource validation failed for ${String(resourceType)}: ${String(error)}`,\n { error, resourceType: String(resourceType) }\n );\n return null;\n }\n };\n\n const hasAllowedAction = <K extends keyof TResources & keyof TActions>(\n resourceType: K,\n action: InferAction<TResources, TActions, K>\n ): boolean => actions[resourceType]?.includes(action) ?? false;\n\n const evaluatePolicy = <K extends keyof TResources & keyof TActions>(\n context: TContext,\n resourceType: K,\n action: InferAction<TResources, TActions, K>,\n resource: InferResource<TResources, K>\n ): boolean => {\n const policyFn = rules[resourceType]?.[action];\n if (policyFn === undefined) {\n return false;\n }\n\n try {\n const allowed = policyFn(context, action, resource) === \"allow\";\n\n if (allowed) {\n logger?.debug(\"permission allowed\", {\n action,\n resourceType: String(resourceType),\n });\n } else {\n logger?.info(\"permission denied\", {\n action,\n resourceType: String(resourceType),\n });\n }\n\n return allowed;\n } catch (error) {\n logger?.warn(\n `Policy evaluation error for ${String(resourceType)}.${action}: ${String(error)}`,\n {\n action,\n error,\n resourceType: String(resourceType),\n }\n );\n return false;\n }\n };\n\n for (const key of Object.keys(resources) as (keyof TResources)[]) {\n const schema = resources[key];\n if (schema === undefined) {\n throw new PolicyError(`Missing schema for resource: ${String(key)}`);\n }\n const validator = createStandardValidator(schema);\n validators.set(key, async (input: unknown) => await validator(input));\n }\n\n return {\n async can<K extends keyof TResources & keyof TActions>(\n context: TContext,\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n resource: InferResource<TResources, K>\n ): Promise<boolean> {\n const parsedPermission = parsePermission<TResources, TActions, K>(\n permission,\n actions\n );\n if (parsedPermission === null) {\n return false;\n }\n\n const { action, resourceType } = parsedPermission;\n if (!hasAllowedAction(resourceType, action)) {\n return false;\n }\n\n const validatedResource = await getValidatedResource(\n resourceType,\n resource\n );\n if (validatedResource === null) {\n return false;\n }\n\n return evaluatePolicy(context, resourceType, action, validatedResource);\n },\n };\n};\n\nconst mergePoliciesWithStrategy = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n policies: Policy<TContext, TResources, TActions>[],\n strategy: \"and\" | \"or\"\n): Policy<TContext, TResources, TActions> => ({\n async can<K extends keyof TResources & keyof TActions>(\n context: TContext,\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n resource: InferResource<TResources, K>\n ): Promise<boolean> {\n if (policies.length === 0) {\n return false;\n }\n\n const settled = await Promise.allSettled(\n policies.map(\n async (policy) => await policy.can(context, permission, resource)\n )\n );\n\n const results = settled.map((result) => {\n if (result.status === \"fulfilled\") {\n return result.value;\n }\n return false;\n });\n\n return strategy === \"and\" ? results.every(Boolean) : results.some(Boolean);\n },\n});\n\n/**\n * Merges multiple policies into one, requiring every policy to allow.\n * If any policy denies, the merged policy denies. Policies are evaluated\n * in parallel; every policy is invoked regardless of outcome.\n *\n * @example\n * ```ts\n * const basePolicy = createPolicy({ ... });\n * const adminPolicy = createPolicy({ ... });\n *\n * const merged = mergePoliciesAnd(basePolicy, adminPolicy);\n * // Both policies must allow for the action to be permitted\n * ```\n */\nexport const mergePoliciesAnd = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n ...policies: Policy<TContext, TResources, TActions>[]\n): Policy<TContext, TResources, TActions> =>\n mergePoliciesWithStrategy(policies, \"and\");\n\n/**\n * Merges multiple policies into one, requiring at least one policy to allow.\n * If every policy denies, the merged policy denies. Policies are evaluated\n * in parallel; every policy is invoked regardless of outcome.\n *\n * @example\n * ```ts\n * const guestPolicy = createPolicy({ ... });\n * const memberPolicy = createPolicy({ ... });\n *\n * const merged = mergePoliciesOr(guestPolicy, memberPolicy);\n * // If either policy allows, the action is permitted\n * ```\n */\nexport const mergePoliciesOr = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n ...policies: Policy<TContext, TResources, TActions>[]\n): Policy<TContext, TResources, TActions> =>\n mergePoliciesWithStrategy(policies, \"or\");\n"],"mappings":"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"}