= (arg: T) => boolean;
275 | type PolicyConditionArg = P extends PolicyConditionWithArg ? T : never;
276 | type PolicyConditionTypeGuard = (arg: T) => arg is U;
277 | type PolicyConditionTypeGuardResult = P extends PolicyConditionTypeGuard
278 | ? U
279 | : PolicyConditionArg;
280 | type PolicyConditionNoArg = (() => boolean) | boolean;
281 | type PolicyCondition =
282 | | PolicyConditionTypeGuard
283 | | PolicyConditionWithArg
284 | | PolicyConditionNoArg;
285 |
286 | function definePolicy(name: string, condition: PolicyCondition, errorFactory?: PolicyErrorFactory)
287 | ```
288 |
289 | Example:
290 | ```ts
291 | const postHasCommentsPolicy = definePolicy(
292 | "post has comments",
293 | (post: Post) => post.comments.length > 0,
294 | () => new Error("Post has no comments")
295 | );
296 | ```
297 |
298 | ### `definePolicies`
299 | Core primitive to define a policy set (collection of policies).
300 |
301 |
302 | ```typescript
303 | function definePolicies(policies: T): PolicySet;
304 |
305 | type AnyPolicy = Policy;
306 | type AnyPolicies = AnyPolicy[];
307 | type PolicyFactory = (...args: any[]) => AnyPolicies;
308 | type PoliciesOrFactory = AnyPolicies | PolicyFactory;
309 | type PolicySetOrFactory = T extends AnyPolicies
310 | ? PolicySet
311 | : T extends PolicyFactory
312 | ? (...args: Parameters) => PolicySet>
313 | : never;
314 | type WithRequiredContext = T extends (arg: infer A) => any ? (unknown extends A ? never : T) : never;
315 |
316 | function definePolicies(
317 | define: WithRequiredContext<(context: Context) => T>
318 | ): (context: Context) => PolicySetOrFactory;
319 | ```
320 | Example:
321 | ```ts
322 | const PostPolicies = definePolicies((context: Context) => [
323 | definePolicy("post has comments", (post: Post) => post.comments.length > 0),
324 | definePolicy("is author", (post: Post) => post.authorId === context.userId)
325 | ]);
326 |
327 | export const Guard = (context: Context) => ({
328 | post: PostPolicies(context),
329 | });
330 | ```
331 |
332 | ### `assert`
333 | ```typescript
334 | function assert(name: string, condition: PolicyConditionNoArg): void;
335 |
336 | function assert | PolicyConditionWithArg>(
337 | name: string,
338 | condition: TPolicyCondition,
339 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg
340 | ): asserts arg is TPolicyCondition extends PolicyConditionNoArg
341 | ? never
342 | : PolicyConditionTypeGuardResult;
343 |
344 | function assert(
345 | policy: Policy
346 | ): void;
347 |
348 | function assert | PolicyConditionWithArg>(
349 | policy: Policy,
350 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg
351 | ): asserts arg is TPolicyCondition extends PolicyConditionNoArg
352 | ? never
353 | : PolicyConditionTypeGuardResult;
354 | ```
355 |
356 | Example:
357 | ```ts
358 | const guard = Guard(context);
359 |
360 | const post = await fetchPost(id);
361 |
362 | assert(guard.post.policy("is author"), post);
363 | // post.authorId === context.userId
364 | ```
365 |
366 | ### `check`
367 | ```typescript
368 | function check(name: string, condition: PolicyConditionNoArg): boolean;
369 |
370 | function check | PolicyConditionWithArg>(
371 | name: string,
372 | condition: TPolicyCondition,
373 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg
374 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionTypeGuardResult;
375 |
376 | function check(
377 | policy: Policy
378 | ): boolean;
379 |
380 | function check | PolicyConditionWithArg>(
381 | policy: Policy,
382 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg
383 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionTypeGuardResult;
384 | ```
385 |
386 | Example:
387 | ```ts
388 | const guard = Guard(context);
389 |
390 | const post = await fetchPost(id);
391 |
392 | if (check(guard.post.policy("post has comments"), post)) {
393 | console.log("Post has comments");
394 | }
395 | ```
396 |
397 | ### `checkAllSettle`
398 | Evaluates all the policies with `check` and returns a snapshot with the results.
399 |
400 | It's useful to serialize policies.
401 |
402 | It takes an array of policies. If a policy does not take an argument, it can be passed as is. Policies that take an argument have to be passed as a tuple with the argument.
403 |
404 | ```ts
405 | type PolicyTuple =
406 | | Policy
407 | | readonly [string, PolicyConditionNoArg]
408 | | readonly [Policy]
409 | | readonly [Policy, any];
410 | type InferPolicyName = TPolicyTuple extends readonly [infer name, any]
411 | ? name extends Policy
412 | ? Name
413 | : name extends string
414 | ? name
415 | : never
416 | : TPolicyTuple extends readonly [Policy]
417 | ? Name
418 | : TPolicyTuple extends Policy
419 | ? Name
420 | : never;
421 | type PoliciesSnapshot = { [K in TPolicyName]: boolean };
422 |
423 | export function checkAllSettle<
424 | const TPolicies extends readonly PolicyTuple[],
425 | TPolicyTuple extends TPolicies[number],
426 | TPolicyName extends InferPolicyName,
427 | >(policies: TPolicies): PoliciesSnapshot
428 | ```
429 |
430 | Example:
431 | ```ts
432 | // TLDR
433 | const snapshot = checkAllSettle([
434 | [guard.post.policy("is my post"), post], // Policy with argument
435 | ["post has comments", post.comments.length > 0], // Implicit policy with no argument
436 | definePolicy("post has likes", post.likes.length > 0), // Policy without argument
437 | ]);
438 |
439 | // Example
440 | const PostPolicies = definePolicies((context: Context) => {
441 | const myPostPolicy = definePolicy(
442 | "is my post",
443 | (post: Post) => post.userId === context.userId,
444 | () => new Error("Not the author")
445 | );
446 |
447 | return [
448 | myPostPolicy,
449 | definePolicy("published post or mine", (post: Post) =>
450 | or(check(myPostPolicy, post), post.status === "published")
451 | ),
452 | ];
453 | });
454 |
455 | const guard = {
456 | post: PostPolicies(context),
457 | };
458 |
459 | const snapshot = checkAllSettle([
460 | [guard.post.policy("is my post"), post],
461 | ["post has comments", post.comments.length > 0],
462 | definePolicy("post has likes", post.likes.length > 0),
463 | ]);
464 |
465 | console.log(snapshot); // { "is my post": boolean; "post has comments": boolean; "post has likes": boolean }
466 | console.log(snapshot["is my post"]) // boolean
467 | ```
468 |
469 | ### Condition helpers
470 | #### `or`
471 | Logical OR operator for policy conditions.
472 |
473 | ```typescript
474 | function or(...conditions: (() => Policy> | boolean)[])
475 | ```
476 |
477 | Example:
478 | ```ts
479 | const PostPolicies = definePolicies((context: Context) => {
480 | const myPostPolicy = definePolicy(
481 | "my post",
482 | (post: Post) => post.userId === context.userId,
483 | () => new Error("Not the author")
484 | );
485 |
486 | return [
487 | myPostPolicy,
488 | definePolicy("all published posts or mine", (post: Post) =>
489 | or(
490 | () => check(myPostPolicy, post),
491 | () => post.status === "published"
492 | )
493 | ),
494 | definePolicy("[lazy] all published posts or mine", (post: Post) =>
495 | or(check(myPostPolicy, post), post.status === "published")
496 | ),
497 | ];
498 | });
499 | ```
500 |
501 | #### `and`
502 | Logical AND operator for policy conditions.
503 |
504 |
505 | ```typescript
506 | function and(
507 | ...conditions: (() => Policy> | boolean)[]
508 | )
509 | ```
510 |
511 | Example:
512 | ```ts
513 | const PostPolicies = definePolicies((context: Context) => {
514 | const myPostPolicy = definePolicy(
515 | "my post",
516 | (post: Post) => post.userId === context.userId,
517 | () => new Error("Not the author")
518 | );
519 |
520 | return [
521 | myPostPolicy,
522 | definePolicy("my published post", (post: Post) =>
523 | and(
524 | () => check(myPostPolicy, post),
525 | () => post.status === "published"
526 | )
527 | ),
528 | definePolicy("[lazy] my published post", (post: Post) =>
529 | and(check(myPostPolicy, post), post.status === "published")
530 | ),
531 | ];
532 | });
533 | ```
534 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import type { z } from "zod";
2 |
3 | /* -------------------------------------------------------------------------- */
4 | /* Policy; */
5 | /* -------------------------------------------------------------------------- */
6 |
7 | type PolicyError = Error;
8 |
9 | type PolicyErrorFactory = (arg: unknown) => T;
10 |
11 | type PolicyConditionWithArg = (arg: T) => boolean;
12 |
13 | type PolicyConditionArg = P extends PolicyConditionWithArg ? T : never;
14 |
15 | type PolicyConditionTypeGuard = (arg: T) => arg is U;
16 |
17 | type PolicyConditionTypeGuardResult = P extends PolicyConditionTypeGuard
18 | ? U
19 | : PolicyConditionArg;
20 |
21 | type PolicyConditionNoArg = (() => boolean) | boolean;
22 |
23 | type PolicyCondition =
24 | | PolicyConditionTypeGuard
25 | | PolicyConditionWithArg
26 | | PolicyConditionNoArg;
27 |
28 | type PolicyName = string;
29 |
30 | class Policy<
31 | TPolicyName extends PolicyName,
32 | TPolicyCondition extends PolicyCondition,
33 | TPolicyErrorFactory extends PolicyErrorFactory = PolicyErrorFactory,
34 | TPolicyConditionArg = PolicyConditionArg,
35 | TResult extends TPolicyConditionArg = PolicyConditionTypeGuardResult,
36 | > {
37 | constructor(
38 | readonly name: TPolicyName,
39 | readonly condition: TPolicyCondition,
40 | readonly errorFactory: TPolicyErrorFactory
41 | ) {
42 | this.name = name;
43 | this.condition = condition;
44 | this.errorFactory = errorFactory;
45 | }
46 |
47 | // assert(
48 | // arg: TPolicyCondition extends PolicyConditionNoArg ? void : TPolicyConditionArg
49 | // ): asserts arg is TPolicyCondition extends PolicyConditionNoArg ? void : TResult {
50 | // if (!this.condition(arg)) {
51 | // throw this.errorFactory(arg);
52 | // }
53 | // }
54 |
55 | check(
56 | arg: TPolicyCondition extends PolicyConditionNoArg ? void : TPolicyConditionArg
57 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? void : TResult {
58 | return typeof this.condition === "boolean" ? this.condition : this.condition(arg);
59 | }
60 | }
61 |
62 | /**
63 | * Define a policy
64 | *
65 | * @param name - The name of the policy
66 | * @param condition - The condition that the policy checks
67 | * @param errorFactoryOrMessage - The error factory or message of the policy
68 | *
69 | * @example
70 | * ```ts
71 | const postHasCommentsPolicy = definePolicy(
72 | "post has comments",
73 | (post: Post) => post.comments.length > 0,
74 | () => new Error("Post has no comments")
75 | );
76 | * ```
77 | */
78 | export function definePolicy<
79 | TPolicyName extends PolicyName,
80 | TPolicyCondition extends PolicyCondition,
81 | TPolicyErrorFactory extends PolicyErrorFactory = PolicyErrorFactory,
82 | >(name: TPolicyName, condition: TPolicyCondition, errorFactoryOrMessage?: TPolicyErrorFactory | string) {
83 | const errorFactory =
84 | typeof errorFactoryOrMessage === "function"
85 | ? errorFactoryOrMessage
86 | : (arg: unknown) => {
87 | const error = new Error(
88 | errorFactoryOrMessage ||
89 | `[${name}] policy is not met for the argument: ${arg ? JSON.stringify(arg) : ""}`
90 | );
91 | error.name = `PolicyRejection: [${name}]`;
92 | return error;
93 | };
94 |
95 | return new Policy(name, condition, errorFactory);
96 | }
97 |
98 | /* -------------------------------------------------------------------------- */
99 | /* Policy Set; */
100 | /* -------------------------------------------------------------------------- */
101 |
102 | class PolicySet<
103 | TPolicies extends Policy[],
104 | TPolicy extends TPolicies[number] = TPolicies[number],
105 | TPolicyName extends TPolicy["name"] = TPolicy["name"],
106 | > {
107 | private readonly set = {} as Record;
108 |
109 | constructor(policies: TPolicies) {
110 | for (const policy of policies) {
111 | this.set[policy.name as TPolicyName] = policy as TPolicy;
112 | }
113 | }
114 |
115 | policy(name: TName): Extract {
116 | return this.set[name] as Extract;
117 | }
118 | }
119 |
120 | type AnyPolicy = Policy;
121 |
122 | type AnyPolicies = AnyPolicy[];
123 |
124 | type PolicyFactory = (...args: any[]) => AnyPolicies;
125 |
126 | type PoliciesOrFactory = AnyPolicies | PolicyFactory;
127 |
128 | type PolicySetOrFactory = T extends AnyPolicies
129 | ? PolicySet
130 | : T extends PolicyFactory
131 | ? (...args: Parameters) => PolicySet>
132 | : never;
133 |
134 | type WithRequiredArg = T extends (arg: infer A) => any ? (unknown extends A ? never : T) : never;
135 |
136 | /**
137 | * Create a set of policies
138 | *
139 | * @param policies - Policies to be added to the set
140 | *
141 | * @example
142 | *
143 | * ```ts
144 | // define a policy set
145 | const Guard = () => ({
146 | post: definePolicies([
147 | definePolicy("post has comments", (post: Post) => post.comments.length > 0),
148 | // unlock type inference with type guards 👇
149 | definePolicy("post is draft", (post: Post) => post.status === "draft"),
150 | ]),
151 | });
152 |
153 | const guard = Guard();
154 |
155 | // use it
156 | if (check(guard.post.policy("post has comments"), post)) {
157 | // post has comments
158 | }
159 |
160 | if (check(guard.post.policy("post is draft"), post)) {
161 | // post.status === "draft" + inferred type
162 | }
163 |
164 | assert(guard.post.policy("post has comments"), post); // throws if the condition is not met
165 | // post has comments
166 | * ```
167 | */
168 | export function definePolicies(policies: T): PolicySet;
169 |
170 | /**
171 | * Create a set of policies from a factory function which takes a `context` argument.
172 | *
173 | * The factory function should return a policy set or a policy set factory.
174 | *
175 | * @param define - A function that takes a context and returns a policy set or a policy set factory
176 | *
177 | * @example
178 | * **Returns a policy set**
179 | * ```ts
180 | * // define a policy set
181 | const PostPolicies = definePolicies((context: Context) => [
182 | definePolicy(
183 | "my post",
184 | (post: Post) => post.userId === context.userId,
185 | ),
186 | ]);
187 |
188 | const Guard = () => ({
189 | post: PostPolicies(context),
190 | });
191 |
192 | const guard = Guard();
193 |
194 | // use it
195 | if (check(guard.post.policy("my post"), post)) {
196 | // post.userId === context.userId
197 | }
198 |
199 | assert(guard.post.policy("my post"), post); // throws if the condition is not met
200 | // post.userId === context.userId
201 | * ```
202 | * **Returns a policy set factory**
203 | * ```ts
204 | * // define a policy set
205 | const PostPolicies = definePolicies((context: Context) => {
206 | return (orgId: string) => [
207 | definePolicy("can administrate org", () => context.rolesByOrg[orgId] === "admin"),
208 | ];
209 | });
210 |
211 | const Guard = () => ({
212 | org: PostPolicies(context),
213 | });
214 |
215 | // use it
216 | if (check(guard.org("it-department").policy("can administrate org"))) {
217 | // context.rolesByOrg["it-department"] === "admin"
218 | }
219 |
220 | assert(guard.org("it-department").policy("can administrate org")); // throws if the condition is not met
221 | // context.rolesByOrg["it-department"] === "admin"
222 | * ```
223 | */
224 | export function definePolicies(
225 | define: WithRequiredArg<(context: Context) => T>
226 | ): (context: Context) => PolicySetOrFactory;
227 |
228 | export function definePolicies(defineOrPolicies: T | ((context: Context) => T)) {
229 | if (Array.isArray(defineOrPolicies)) {
230 | return new PolicySet(defineOrPolicies);
231 | }
232 |
233 | return (context: Context) => {
234 | const policiesOrFactory = defineOrPolicies(context);
235 |
236 | if (typeof policiesOrFactory === "function") {
237 | return (...args: any[]) => new PolicySet(policiesOrFactory(...args));
238 | }
239 |
240 | return new PolicySet(policiesOrFactory);
241 | };
242 | }
243 |
244 | /**
245 | * Logical OR operator for policy conditions.
246 | *
247 | * At least one of the policies or conditions must be met for the result to be true
248 | *
249 | * @example
250 | * ```ts
251 | const PostPolicies = definePolicies((context: Context) => {
252 | const myPostPolicy = definePolicy(
253 | "my post",
254 | (post: Post) => post.userId === context.userId,
255 | () => new Error("Not the author")
256 | );
257 |
258 | return [
259 | myPostPolicy,
260 | definePolicy("all published posts or mine", (post: Post) =>
261 | or(
262 | () => check(myPostPolicy, post),
263 | () => post.status === "published"
264 | )
265 | ),
266 | ];
267 | });
268 |
269 | const guard = {
270 | post: PostPolicies(context),
271 | };
272 |
273 | if (check(guard.post.policy("all published posts or mine"), post)) {
274 | // post.status === "published" || post.userId === context.userId && post.status === "published" | ...
275 | }
276 |
277 | assert(guard.post.policy("all published posts or mine"), post); // throws if the condition is not met
278 | // post.status === "published" || post.userId === context.userId && post.status === "published" | ...
279 | *```
280 | */
281 | export function or(
282 | ...conditions: (
283 | | (() => Policy> | boolean)
284 | | boolean
285 | )[]
286 | ) {
287 | return conditions.some((predicate) => (typeof predicate === "function" ? predicate() : predicate));
288 | }
289 |
290 | /**
291 | * Logical AND operator for policy conditions.
292 | *
293 | * All the policies or conditions must be met for the result to be true
294 | *
295 | * @example
296 | * ```ts
297 | const PostPolicies = definePolicies((context: Context) => {
298 | const myPostPolicy = definePolicy(
299 | "my post",
300 | (post: Post) => post.userId === context.userId,
301 | () => new Error("Not the author")
302 | );
303 |
304 | return [
305 | myPostPolicy,
306 | definePolicy("my published post", (post: Post) =>
307 | and(
308 | () => check(myPostPolicy, post),
309 | () => post.status === "published"
310 | )
311 | ),
312 | ];
313 | });
314 |
315 | const guard = {
316 | post: PostPolicies(context),
317 | };
318 |
319 | if (check(guard.post.policy("my published post"), post)) {
320 | // post.status === "published" && post.userId === context.userId
321 | }
322 |
323 | assert(guard.post.policy("my published post"), post); // throws if the condition is not met
324 | // post.status === "published" && post.userId === context.userId
325 | *```
326 | */
327 | export function and(
328 | ...conditions: (
329 | | (() => Policy> | boolean)
330 | | boolean
331 | )[]
332 | ) {
333 | return conditions.every((predicate) => (typeof predicate === "function" ? predicate() : predicate));
334 | }
335 |
336 | /* -------------------------------------------------------------------------- */
337 | /* Guards; */
338 | /* -------------------------------------------------------------------------- */
339 |
340 | /* --------------------------------- Assert; -------------------------------- */
341 |
342 | /**
343 | * Assert an implicit policy with a no-arg condition function (lazy evaluation) or a boolean value
344 | *
345 | * @param name - The name of the policy
346 | * @param condition - The condition to assert (no-arg) or a boolean value
347 | *
348 | * @example
349 | * ```ts
350 | * const post = await getPost(id);
351 | *
352 | * // lazy evaluation
353 | * assert("post has comments", () => post.comments.length > 0);
354 | *
355 | * // boolean value
356 | * assert("post has comments", post.comments.length > 0);
357 | * ```
358 | */
359 | export function assert(name: string, condition: PolicyConditionNoArg): void;
360 |
361 | /**
362 | * Assert an implicit policy with a condition function that takes an argument (lazy evaluation) or a boolean value
363 | *
364 | * The condition function can be a type guard or a predicate
365 | *
366 | * @param name - The name of the policy
367 | * @param condition - The condition to assert (with arg) or a boolean value
368 | * @param arg - The argument to pass to the condition
369 | *
370 | * @example
371 | * ```ts
372 | * // lazy evaluation
373 | * assert("post has comments", (post: Post) => post.comments.length > 0, await getPost(id));
374 | *
375 | * // type guard
376 | * assert("post is draft", (post: Post): post is Post & { status: "draft" } => post.status === "draft", await getPost(id));
377 | *
378 | * // boolean value
379 | * assert("post has comments",(await getPost(id)).comments.length > 0);
380 | * ```
381 | */
382 | export function assert | PolicyConditionWithArg>(
383 | name: string,
384 | condition: TPolicyCondition,
385 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg
386 | ): asserts arg is TPolicyCondition extends PolicyConditionNoArg
387 | ? never
388 | : PolicyConditionTypeGuardResult;
389 |
390 | /**
391 | * Assert a policy with a no-arg condition function (lazy evaluation) or a boolean value
392 | *
393 | * @param policy - The policy to assert or a boolean value
394 | *
395 | * @example
396 | * ```ts
397 | * const AdminPolicies = definePolicies((context: Context) => [
398 | * definePolicy("is admin", context.role === "admin"),
399 | * // lazy evaluation
400 | * definePolicy("is admin", () => context.role === "admin"),
401 | * ]);
402 | *
403 | * const Guard = (context: Context) => ({
404 | * admin: AdminPolicies(context),
405 | * });
406 | *
407 | * assert(guard.admin.policy("is admin"));
408 | * ```
409 | */
410 | export function assert(
411 | policy: Policy
412 | ): void;
413 |
414 | /**
415 | * Assert a policy with a condition function that takes an argument
416 | *
417 | * The condition function can be a type guard or a predicate
418 | *
419 | * @param policy - The policy to assert
420 | * @param arg - The argument to pass to the condition
421 | *
422 | * @example
423 | * ```ts
424 | * const PostPolicies = definePolicies((context: Context) => [
425 | * definePolicy("is author", (post: Post) => post.userId === context.userId),
426 | * ]);
427 | *
428 | * const Guard = (context: Context) => ({
429 | * post: PostPolicies(context),
430 | * });
431 | *
432 | * assert(guard.post.policy("is author"), post);
433 | * ```
434 | */
435 | export function assert | PolicyConditionWithArg>(
436 | policy: Policy,
437 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg
438 | ): asserts arg is TPolicyCondition extends PolicyConditionNoArg
439 | ? never
440 | : PolicyConditionTypeGuardResult;
441 |
442 | /**
443 | * Implementation of the assert function
444 | */
445 | export function assert(
446 | policyOrName: Policy | string,
447 | ...args: any[]
448 | ): void {
449 | let policy: AnyPolicy;
450 | let arg: any;
451 |
452 | if (typeof policyOrName === "string") {
453 | policy = definePolicy(policyOrName, args[0]);
454 | arg = args[1];
455 | } else {
456 | policy = policyOrName;
457 | arg = args[0];
458 | }
459 |
460 | if (typeof policy.condition === "boolean" ? policy.condition : policy.condition(arg)) {
461 | return;
462 | }
463 |
464 | throw policy.errorFactory(arg);
465 | }
466 |
467 | /* --------------------------------- Check; --------------------------------- */
468 |
469 | /**
470 | * Check an implicit policy with a no-arg condition function or a boolean value
471 | *
472 | * @param name - The name of the policy
473 | * @param condition - The condition to check (no-arg) or a boolean value
474 | *
475 | * @example
476 | * ```ts
477 | * const post = await getPost(id);
478 | *
479 | * // lazy evaluation
480 | * if (check("post has comments", () => post.comments.length > 0)) {
481 | * // post has comments
482 | * }
483 | *
484 | * // boolean value
485 | * if (check("post has comments", post.comments.length > 0)) {
486 | * // post has comments
487 | * }
488 | * ```
489 | */
490 | export function check(name: string, condition: PolicyConditionNoArg): boolean;
491 |
492 | /**
493 | * Check an implicit policy with a condition function that takes an argument
494 | *
495 | * The condition function can be a type guard or a predicate
496 | *
497 | * @param name - The name of the policy
498 | * @param condition - The condition to check (with arg)
499 | * @param arg - The argument to pass to the condition
500 | *
501 | * @example
502 | * ```ts
503 | * if (check("post has comments", (post: Post) => post.comments.length > 0, post)) {
504 | * // post has comments
505 | * }
506 | *
507 | * // type guard
508 | * if (check("post is draft", (post: Post): post is Post & { status: "draft" } => post.status === "draft", post)) {
509 | * // post.status === "draft"
510 | * }
511 | * ```
512 | */
513 | export function check | PolicyConditionWithArg>(
514 | name: string,
515 | condition: TPolicyCondition,
516 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg
517 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionTypeGuardResult;
518 |
519 | /**
520 | * Check a policy with a no-arg condition function
521 | *
522 | * @param policy - The policy to check
523 | *
524 | * @example
525 | * ```ts
526 | * const AdminPolicies = definePolicies((context: Context) => [
527 | * definePolicy("is admin", () => context.role === "admin"),
528 | * ]);
529 | *
530 | * const Guard = (context: Context) => ({
531 | * admin: AdminPolicies(context),
532 | * });
533 | *
534 | * if (check(guard.admin.policy("is admin"))) {
535 | * // ...
536 | * }
537 | * ```
538 | */
539 | export function check(
540 | policy: Policy
541 | ): boolean;
542 |
543 | /**
544 | * Check a policy with a condition function that takes an argument
545 | *
546 | * The condition function can be a type guard or a predicate
547 | *
548 | * @param policy - The policy to check
549 | * @param arg - The argument to pass to the condition
550 | *
551 | * @example
552 | * ```ts
553 | * const PostPolicies = definePolicies((context: Context) => [
554 | * definePolicy("is author", (post: Post) => post.userId === context.userId),
555 | * ]);
556 | *
557 | * const Guard = (context: Context) => ({
558 | * post: PostPolicies(context),
559 | * });
560 | *
561 | * if (check(guard.post.policy("is author"), post)) {
562 | * // ...
563 | * }
564 | * ```
565 | */
566 | export function check | PolicyConditionWithArg>(
567 | policy: Policy,
568 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg
569 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionTypeGuardResult;
570 |
571 | /**
572 | * Implementation of the check function
573 | */
574 | export function check(
575 | policyOrName: Policy | string,
576 | ...args: any[]
577 | ): boolean {
578 | let policy: AnyPolicy;
579 | let arg: any;
580 |
581 | if (typeof policyOrName === "string") {
582 | policy = definePolicy(policyOrName, args[0]);
583 | arg = args[1];
584 | } else {
585 | policy = policyOrName;
586 | arg = args[0];
587 | }
588 |
589 | return typeof policy.condition === "boolean" ? policy.condition : policy.condition(arg);
590 | }
591 |
592 | type PolicyTuple =
593 | | Policy
594 | | readonly [string, PolicyConditionNoArg]
595 | | readonly [string, PolicyConditionWithArg, any]
596 | | readonly [Policy]
597 | | readonly [Policy, any];
598 |
599 | type InferPolicyName = TPolicyTuple extends readonly [infer NameOrPolicy, ...any[]]
600 | ? NameOrPolicy extends Policy
601 | ? Name
602 | : NameOrPolicy extends string
603 | ? NameOrPolicy
604 | : never
605 | : TPolicyTuple extends readonly [Policy]
606 | ? Name
607 | : TPolicyTuple extends Policy
608 | ? Name
609 | : never;
610 |
611 | type PoliciesSnapshot = { [K in TPolicyName]: boolean };
612 |
613 | /**
614 | * Create a snapshot of policies and their evaluation results
615 | *
616 | * It evaluates all the policies with `check`
617 | *
618 | * If a policy does not take an argument, it can be passed as is. Policies that take an argument have to be passed as a tuple with the argument.
619 | *
620 | * @param policies - A tuple of policies and their arguments (if needed)
621 | *
622 | * @example
623 | * ```ts
624 | * // TLDR
625 | const snapshot = checkAllSettle([
626 | [guard.post.policy("is my post"), post], // Policy with argument
627 | ["post has comments", post.comments.length > 0], // Implicit policy with no argument
628 | definePolicy("post has likes", post.likes.length > 0), // Policy without argument. Can be used as is
629 | ]);
630 |
631 | // Example
632 | const PostPolicies = definePolicies((context: Context) => {
633 | const myPostPolicy = definePolicy(
634 | "is my post",
635 | (post: Post) => post.userId === context.userId,
636 | () => new Error("Not the author")
637 | );
638 |
639 | return [
640 | myPostPolicy,
641 | definePolicy("published post or mine", (post: Post) =>
642 | or(check(myPostPolicy, post), post.status === "published")
643 | ),
644 | ];
645 | });
646 |
647 | const guard = {
648 | post: PostPolicies(context),
649 | };
650 |
651 | const snapshot = checkAllSettle([
652 | [guard.post.policy("is my post"), post], // A policy with an argument
653 | ["post has comments", post.comments.length > 0], // An implicit policy with no argument
654 | definePolicy("post has likes", post.likes.length > 0), // A policy without argument. Can be used as is
655 | ]);
656 |
657 | console.log(snapshot); // { "is my post": boolean; "post has comments": boolean; "post has likes": boolean }
658 | console.log(snapshot["is my post"]) // boolean
659 | * ```
660 | */
661 | export function checkAllSettle<
662 | const TPolicies extends readonly PolicyTuple[],
663 | TPolicyTuple extends TPolicies[number],
664 | TPolicyName extends InferPolicyName,
665 | >(policies: TPolicies): PoliciesSnapshot {
666 | return policies.reduce(
667 | (acc, policyOrTuple) => {
668 | let policyName: string;
669 | let result: boolean;
670 |
671 | if (policyOrTuple instanceof Policy) {
672 | // Policy without argument
673 | policyName = policyOrTuple.name;
674 | result = policyOrTuple.check();
675 | } else {
676 | // Policy with argument
677 | const [policyOrName, conditionOrArg, implicitPolicyArg] = policyOrTuple;
678 | policyName = typeof policyOrName === "string" ? policyOrName : policyOrName.name;
679 | result =
680 | typeof policyOrName === "string"
681 | ? typeof conditionOrArg === "function"
682 | ? conditionOrArg(implicitPolicyArg)
683 | : conditionOrArg
684 | : policyOrName.check(conditionOrArg);
685 | }
686 |
687 | acc[policyName as TPolicyName] = result;
688 |
689 | return acc;
690 | },
691 | {} as PoliciesSnapshot
692 | );
693 | }
694 |
695 | /* -------------------------------------------------------------------------- */
696 | /* Helpers; */
697 | /* -------------------------------------------------------------------------- */
698 |
699 | /**
700 | * Match a value against a schema
701 | *
702 | * @param schema - The schema to match against (type guard)
703 | *
704 | * @example
705 | * ```ts
706 | * if (check("params are valid", matchSchema(z.object({ id: z.string() })), params)) {
707 | * // params is { id: string }
708 | * }
709 | * ```
710 | */
711 | export function matchSchema(schema: Schema) {
712 | return (value: unknown): value is z.infer => schema.safeParse(value).success;
713 | }
714 |
715 | /**
716 | * Check if a value is not null (type guard)
717 | *
718 | * @param v - The value to check
719 | *
720 | * @example
721 | * ```ts
722 | * const value: string | null = "hello";
723 | *
724 | * if (check("value is not null", notNull, value)) {
725 | * // value is not null
726 | * }
727 | * ```
728 | */
729 | export function notNull(v: unknown | null | undefined): v is NonNullable {
730 | return v != null;
731 | }
732 |
--------------------------------------------------------------------------------
/src/index.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, expectTypeOf, it } from "vitest";
2 | import { z } from "zod";
3 | import { assert, and, check, checkAllSettle, definePolicies, definePolicy, matchSchema, notNull, or } from ".";
4 |
5 | describe("Define policy", () => {
6 | type Post = { userId: string; comments: string[] };
7 |
8 | it("should define a policy", () => {
9 | const postHasCommentsPolicy = definePolicy(
10 | "post has comments",
11 | (post: Post) => post.comments.length > 0,
12 | () => new Error("Post has no comments")
13 | );
14 |
15 | expect(postHasCommentsPolicy.name).toBe("post has comments");
16 | expect(postHasCommentsPolicy.condition).toBeInstanceOf(Function);
17 | expect(postHasCommentsPolicy.errorFactory).toBeInstanceOf(Function);
18 | });
19 |
20 | it("should check a policy", () => {
21 | const postHasCommentsPolicy = definePolicy(
22 | "post has comments",
23 | (post: Post) => post.comments.length > 0,
24 | () => new Error("Post has no comments")
25 | );
26 |
27 | expect(postHasCommentsPolicy.check({ userId: "1", comments: [] })).toBe(false);
28 | expect(postHasCommentsPolicy.check({ userId: "1", comments: ["comment 1"] })).toBe(true);
29 |
30 | expect(check(postHasCommentsPolicy, { userId: "1", comments: [] })).toBe(false);
31 | expect(check(postHasCommentsPolicy, { userId: "1", comments: ["comment 1"] })).toBe(true);
32 | });
33 |
34 | it("should assert a policy", () => {
35 | const postHasCommentsPolicy = definePolicy(
36 | "post has comments",
37 | (post: Post) => post.comments.length > 0,
38 | () => new Error("Post has no comments")
39 | );
40 |
41 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: [] })).toThrowError(
42 | new Error("Post has no comments")
43 | );
44 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: ["comment 1"] })).not.toThrowError();
45 | });
46 |
47 | it("should accept a message as error factory", () => {
48 | const postHasCommentsPolicy = definePolicy(
49 | "post has comments",
50 | (post: Post) => post.comments.length > 0,
51 | "Post has no comments"
52 | );
53 |
54 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: [] })).toThrowError(
55 | new Error("Post has no comments")
56 | );
57 |
58 | try {
59 | assert(postHasCommentsPolicy, { userId: "1", comments: [] });
60 | } catch (error) {
61 | expect((error as Error).name).toBe("PolicyRejection: [post has comments]");
62 | }
63 | });
64 |
65 | it("should define a policy without error factory", () => {
66 | const postHasCommentsPolicy = definePolicy("post has comments", (post: Post) => post.comments.length > 0);
67 |
68 | expect(postHasCommentsPolicy.name).toBe("post has comments");
69 | expect(postHasCommentsPolicy.condition).toBeInstanceOf(Function);
70 | expect(postHasCommentsPolicy.errorFactory).toBeInstanceOf(Function);
71 |
72 | expect(postHasCommentsPolicy.check({ userId: "1", comments: [] })).toBe(false);
73 | expect(postHasCommentsPolicy.check({ userId: "1", comments: ["comment 1"] })).toBe(true);
74 |
75 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: [] })).toThrowError(
76 | new Error(
77 | `[post has comments] policy is not met for the argument: ${JSON.stringify({ userId: "1", comments: [] })}`
78 | )
79 | );
80 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: ["comment 1"] })).not.toThrowError();
81 | });
82 |
83 | it("can define a policy with a condition that takes no argument", () => {
84 | const truePolicy = definePolicy("is true", () => true);
85 |
86 | expect(truePolicy.check()).toBe(true);
87 |
88 | expect(() => assert(truePolicy)).not.toThrowError();
89 | });
90 |
91 | it("can define a policy with a boolean condition", () => {
92 | const truePolicy = definePolicy("is true", true);
93 |
94 | expect(truePolicy.check()).toBe(true);
95 |
96 | expect(() => assert(truePolicy)).not.toThrowError();
97 | });
98 |
99 | it("should allow defining a policy on the fly", () => {
100 | const params = { id: "123" };
101 |
102 | expect(check(definePolicy("params are valid", matchSchema(z.object({ id: z.string() }))), params)).toBe(true);
103 |
104 | expect(check("params are valid", matchSchema(z.object({ id: z.string() })), params)).toBe(true);
105 | });
106 | });
107 |
108 | describe("Define policies", () => {
109 | type Context = { userId: string };
110 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" };
111 |
112 | it("should define a policy set", () => {
113 | const postGuard = definePolicies([
114 | definePolicy("post has comments", (post: Post) => post.comments.length > 0),
115 | definePolicy("post is draft", (post: Post): post is Post & { status: "draft" } => post.status === "draft"),
116 | ]);
117 |
118 | expect(postGuard.policy("post has comments").name).toBe("post has comments");
119 | expect(postGuard.policy("post has comments").condition).toBeInstanceOf(Function);
120 | expect(postGuard.policy("post has comments").errorFactory).toBeInstanceOf(Function);
121 | });
122 |
123 | it("should check a policy from a policy set", () => {
124 | const guard = {
125 | post: definePolicies([
126 | definePolicy("post has comments", (post: Post) => post.comments.length > 0),
127 | definePolicy("post is draft", (post: Post) => post.status === "draft"),
128 | ]),
129 | };
130 |
131 | expect(guard.post.policy("post has comments").check({ userId: "1", comments: [], status: "published" })).toBe(
132 | false
133 | );
134 | expect(check(guard.post.policy("post has comments"), { userId: "1", comments: [], status: "published" })).toBe(
135 | false
136 | );
137 |
138 | expect(
139 | guard.post.policy("post has comments").check({ userId: "1", comments: ["comment 1"], status: "published" })
140 | ).toBe(true);
141 | expect(
142 | check(guard.post.policy("post has comments"), { userId: "1", comments: ["comment 1"], status: "published" })
143 | ).toBe(true);
144 |
145 | expect(guard.post.policy("post is draft").check({ userId: "1", comments: [], status: "published" })).toBe(false);
146 | expect(check(guard.post.policy("post is draft"), { userId: "1", comments: [], status: "published" })).toBe(false);
147 |
148 | expect(guard.post.policy("post is draft").check({ userId: "1", comments: ["comment 1"], status: "draft" })).toBe(
149 | true
150 | );
151 | expect(check(guard.post.policy("post is draft"), { userId: "1", comments: ["comment 1"], status: "draft" })).toBe(
152 | true
153 | );
154 | });
155 |
156 | it("should assert a policy from a policy set", () => {
157 | const guard = {
158 | post: definePolicies([
159 | definePolicy(
160 | "post has comments",
161 | (post: Post) => post.comments.length > 0,
162 | () => new Error("Post has no comments")
163 | ),
164 | definePolicy("post is draft", (post: Post) => post.status === "draft"),
165 | ]),
166 | };
167 |
168 | expect(() =>
169 | assert(guard.post.policy("post has comments"), { userId: "1", comments: [], status: "published" })
170 | ).toThrowError(new Error("Post has no comments"));
171 | expect(() =>
172 | assert(guard.post.policy("post has comments"), { userId: "1", comments: ["comment 1"], status: "published" })
173 | ).not.toThrowError();
174 | });
175 |
176 | it("should define a policy set with a factory function which takes a `context` argument", () => {
177 | const PostPolicies = definePolicies((context: Context) => [
178 | definePolicy(
179 | "my post",
180 | (post: Post) => post.userId === context.userId,
181 | () => new Error("Not the author")
182 | ),
183 | ]);
184 |
185 | const context: Context = { userId: "1" };
186 |
187 | const guard = {
188 | post: PostPolicies(context),
189 | };
190 |
191 | expect(check(guard.post.policy("my post"), { userId: "1", comments: [], status: "published" })).toBe(true);
192 | expect(check(guard.post.policy("my post"), { userId: "2", comments: [], status: "published" })).toBe(false);
193 |
194 | expect(() =>
195 | assert(guard.post.policy("my post"), { userId: "1", comments: [], status: "published" })
196 | ).not.toThrowError();
197 | expect(() => assert(guard.post.policy("my post"), { userId: "2", comments: [], status: "published" })).toThrowError(
198 | new Error("Not the author")
199 | );
200 | });
201 |
202 | it("should define a policy set with compound conditions that require every condition to be met", () => {
203 | const PostPolicies = definePolicies((context: Context) => {
204 | const myPostPolicy = definePolicy(
205 | "my post",
206 | (post: Post) => post.userId === context.userId,
207 | () => new Error("Not the author")
208 | );
209 |
210 | return [
211 | myPostPolicy,
212 | definePolicy("my published post", (post: Post) =>
213 | and(
214 | () => check(myPostPolicy, post),
215 | () => post.status === "published"
216 | )
217 | ),
218 | ];
219 | });
220 |
221 | const context: Context = { userId: "1" };
222 |
223 | const guard = {
224 | post: PostPolicies(context),
225 | };
226 |
227 | expect(check(guard.post.policy("my published post"), { userId: "1", comments: [], status: "published" })).toBe(
228 | true
229 | );
230 | expect(check(guard.post.policy("my published post"), { userId: "1", comments: [], status: "draft" })).toBe(false);
231 | expect(check(guard.post.policy("my published post"), { userId: "2", comments: [], status: "published" })).toBe(
232 | false
233 | );
234 |
235 | expect(() =>
236 | assert(guard.post.policy("my published post"), { userId: "1", comments: [], status: "published" })
237 | ).not.toThrowError();
238 | expect(() =>
239 | assert(guard.post.policy("my published post"), { userId: "1", comments: [], status: "draft" })
240 | ).toThrowError();
241 | expect(() =>
242 | assert(guard.post.policy("my published post"), { userId: "2", comments: [], status: "published" })
243 | ).toThrowError();
244 | });
245 |
246 | it("should define a policy set with compound conditions that require at least one condition to be met", () => {
247 | const PostPolicies = definePolicies((context: Context) => {
248 | const myPostPolicy = definePolicy(
249 | "my post",
250 | (post: Post) => post.userId === context.userId,
251 | () => new Error("Not the author")
252 | );
253 |
254 | return [
255 | myPostPolicy,
256 | definePolicy("all published posts or mine", (post: Post) =>
257 | or(
258 | () => check(myPostPolicy, post),
259 | () => post.status === "published"
260 | )
261 | ),
262 | ];
263 | });
264 |
265 | const context: Context = { userId: "1" };
266 |
267 | const guard = {
268 | post: PostPolicies(context),
269 | };
270 |
271 | expect(
272 | check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "published" })
273 | ).toBe(true);
274 | expect(
275 | check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" })
276 | ).toBe(true);
277 | expect(
278 | check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "published" })
279 | ).toBe(true);
280 | expect(
281 | check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" })
282 | ).toBe(false);
283 |
284 | expect(() =>
285 | assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "published" })
286 | ).not.toThrowError();
287 | expect(() =>
288 | assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" })
289 | ).not.toThrowError();
290 | expect(() =>
291 | assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "published" })
292 | ).not.toThrowError();
293 | expect(() =>
294 | assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" })
295 | ).toThrowError();
296 | });
297 |
298 | it("should define a policy set that comprises other policy sets only", () => {
299 | type Context = { role: "admin" | "user" | "bot" };
300 |
301 | const AdminPolicies = definePolicies((context: Context) => [
302 | definePolicy("has admin role", () => context.role === "admin"),
303 | ]);
304 |
305 | const PostPolicies = definePolicies((context: Context) => {
306 | const adminGuard = AdminPolicies(context);
307 |
308 | return [
309 | definePolicy("can moderate comments", or(context.role === "bot", check(adminGuard.policy("has admin role")))),
310 | ];
311 | });
312 |
313 | expect(check(PostPolicies({ role: "admin" }).policy("can moderate comments"))).toBe(true);
314 | expect(check(PostPolicies({ role: "bot" }).policy("can moderate comments"))).toBe(true);
315 | expect(check(PostPolicies({ role: "user" }).policy("can moderate comments"))).toBe(false);
316 |
317 | expect(() => assert(PostPolicies({ role: "admin" }).policy("can moderate comments"))).not.toThrowError();
318 | expect(() => assert(PostPolicies({ role: "bot" }).policy("can moderate comments"))).not.toThrowError();
319 | expect(() => assert(PostPolicies({ role: "user" }).policy("can moderate comments"))).toThrowError();
320 | });
321 |
322 | it("should define a policy set that comprises other policy sets", () => {
323 | type Context = { userId: string; role: "admin" | "user" };
324 |
325 | const AdminPolicies = definePolicies((context: Context) => [
326 | definePolicy("has admin role", () => context.role === "admin"),
327 | ]);
328 |
329 | const PostPolicies = definePolicies((context: Context) => {
330 | const adminGuard = AdminPolicies(context);
331 |
332 | return [
333 | definePolicy("can edit comments", (post: Post) =>
334 | or(
335 | () => post.userId === context.userId,
336 | () => check(adminGuard.policy("has admin role"))
337 | )
338 | ),
339 | ];
340 | });
341 |
342 | const context: Context = { userId: "1", role: "admin" };
343 |
344 | const guard = {
345 | admin: AdminPolicies(context),
346 | post: PostPolicies(context),
347 | };
348 |
349 | expect(check(guard.post.policy("can edit comments"), { userId: "1", comments: [], status: "published" })).toBe(
350 | true
351 | );
352 | expect(check(guard.post.policy("can edit comments"), { userId: "2", comments: [], status: "published" })).toBe(
353 | true
354 | );
355 |
356 | expect(() =>
357 | assert(guard.post.policy("can edit comments"), { userId: "1", comments: [], status: "published" })
358 | ).not.toThrowError();
359 | expect(() =>
360 | assert(guard.post.policy("can edit comments"), { userId: "2", comments: [], status: "published" })
361 | ).not.toThrowError();
362 | });
363 |
364 | it("should define a policy set that returns a policy set factory", () => {
365 | type Context = { userId: string; rolesByOrg: Record };
366 |
367 | const orgPolicies = definePolicies((context: Context) => (orgId: string) => {
368 | const currentUserOrgRole = context.rolesByOrg[orgId];
369 |
370 | return [definePolicy("can administrate org", () => currentUserOrgRole === "admin")];
371 | });
372 |
373 | const context: Context = { userId: "1", rolesByOrg: { "it-department": "admin", "sales-team": "user" } };
374 |
375 | const guard = {
376 | org: orgPolicies(context),
377 | };
378 |
379 | expect(check(guard.org("it-department").policy("can administrate org"))).toBe(true);
380 | expect(check(guard.org("sales-team").policy("can administrate org"))).toBe(false);
381 |
382 | expect(() => assert(guard.org("it-department").policy("can administrate org"))).not.toThrowError();
383 | expect(() => assert(guard.org("sales-team").policy("can administrate org"))).toThrowError();
384 | });
385 |
386 | it("should work with resolved async params", async () => {
387 | // Note: This is just to demonstrate that we can mix async validation with policy conditions until TypeScript enables async type guards
388 | // https://github.com/microsoft/TypeScript/issues/37681
389 | // We need that to fully support async condition that still preserve inference
390 | type Context = { userId: string; rolesByOrg: Record };
391 |
392 | const orgPolicies = definePolicies((context: Context) => {
393 | return (orgId: string) => [
394 | definePolicy("can administrate org", (stillOrgAdmin: boolean) =>
395 | and(
396 | () => context.rolesByOrg[orgId] === "admin",
397 | () => stillOrgAdmin
398 | )
399 | ),
400 | ];
401 | });
402 |
403 | const context: Context = { userId: "1", rolesByOrg: { "it-department": "admin", "sales-team": "user" } };
404 |
405 | const guard = {
406 | org: orgPolicies(context),
407 | };
408 |
409 | // fake server check
410 | async function checkIfStillOrgAdmin(orgId: string) {
411 | return await Promise.resolve(orgId === "it-department");
412 | }
413 |
414 | expect(
415 | check(guard.org("it-department").policy("can administrate org"), await checkIfStillOrgAdmin("it-department"))
416 | ).toBe(true);
417 | expect(
418 | check(guard.org("sales-team").policy("can administrate org"), await checkIfStillOrgAdmin("sales-team"))
419 | ).toBe(false);
420 | });
421 | });
422 |
423 | describe("Inference", () => {
424 | it("should infer scalar from policy", () => {
425 | type Label = string | null;
426 |
427 | const guard = {
428 | input: definePolicies([definePolicy("not null", notNull)]),
429 | };
430 |
431 | const label: Label = "label";
432 |
433 | function test(label: Label) {
434 | if (check(guard.input.policy("not null"), label)) {
435 | expect(label).not.toBeNull();
436 | expectTypeOf(label).toEqualTypeOf();
437 | }
438 |
439 | expect(() => {
440 | assert(guard.input.policy("not null"), label);
441 | expect(label).not.toBeNull();
442 | expectTypeOf(label).toEqualTypeOf();
443 | }).not.toThrowError();
444 | }
445 |
446 | test(label);
447 |
448 | expect.assertions(3);
449 | });
450 |
451 | it("should infer scalar from implicit policy", () => {
452 | type Label = string | null;
453 | const label: Label = "label";
454 |
455 | function test(label: Label) {
456 | if (check("not null", notNull, label)) {
457 | expect(label).not.toBeNull();
458 | expectTypeOf(label).toEqualTypeOf();
459 | }
460 |
461 | expect(() => {
462 | assert("not null", notNull, label);
463 | expect(label).not.toBeNull();
464 | expectTypeOf(label).toEqualTypeOf();
465 | }).not.toThrowError();
466 | }
467 |
468 | test(label);
469 |
470 | expect.assertions(3);
471 | });
472 |
473 | it("should infer object from policy", () => {
474 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" };
475 |
476 | const guard = {
477 | post: definePolicies([
478 | definePolicy(
479 | "published post",
480 | (post: Post): post is Post & { status: "published" } => post.status === "published"
481 | ),
482 | ]),
483 | };
484 |
485 | const post: Post = { userId: "1", comments: [], status: "published" };
486 |
487 | function test(post: Post) {
488 | if (check(guard.post.policy("published post"), post)) {
489 | expect(post.status).toBe("published");
490 | expectTypeOf(post.status).toEqualTypeOf<"published">();
491 | }
492 |
493 | expect(() => {
494 | assert(guard.post.policy("published post"), post);
495 | expect(post.status).toBe("published");
496 | expectTypeOf(post.status).toEqualTypeOf<"published">();
497 | }).not.toThrowError();
498 | }
499 |
500 | test(post);
501 |
502 | expect.assertions(3);
503 | });
504 |
505 | it("should infer object from implicit policy", () => {
506 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" };
507 |
508 | const post: Post = { userId: "1", comments: [], status: "published" };
509 |
510 | function test(post: Post) {
511 | // type predicate
512 | if (
513 | check(
514 | "published post",
515 | (post: Post): post is Post & { status: "published" } => post.status === "published",
516 | post
517 | )
518 | ) {
519 | expect(post.status).toBe("published");
520 | expectTypeOf(post.status).toEqualTypeOf<"published">();
521 | }
522 |
523 | expect(() => {
524 | assert(
525 | "published post",
526 | (post: Post): post is Post & { status: "published" } => post.status === "published",
527 | post
528 | );
529 | expect(post.status).toBe("published");
530 | expectTypeOf(post.status).toEqualTypeOf<"published">();
531 | }).not.toThrowError();
532 | }
533 |
534 | test(post);
535 |
536 | expect.assertions(3);
537 | });
538 |
539 | it("should infer a zod schema from a policy", () => {
540 | const PostSchema = z.object({
541 | userId: z.string(),
542 | comments: z.array(z.string()),
543 | status: z.union([z.literal("published"), z.literal("draft"), z.literal("archived")]),
544 | });
545 | type Post = z.infer;
546 |
547 | const guard = {
548 | post: definePolicies([
549 | definePolicy("published post", matchSchema(PostSchema.extend({ status: z.literal("published") }))),
550 | ]),
551 | };
552 |
553 | const post: Post = { userId: "1", comments: [], status: "published" };
554 |
555 | function test(post: Post) {
556 | if (check(guard.post.policy("published post"), post)) {
557 | expect(post.status).toBe("published");
558 | expectTypeOf(post.status).toEqualTypeOf<"published">();
559 | }
560 |
561 | expect(() => {
562 | assert(guard.post.policy("published post"), post);
563 | expect(post.status).toBe("published");
564 | expectTypeOf(post.status).toEqualTypeOf<"published">();
565 | }).not.toThrowError();
566 | }
567 |
568 | test(post);
569 |
570 | expect.assertions(3);
571 | });
572 |
573 | it("should infer a zod schema from an implicit policy", () => {
574 | const PostSchema = z.object({
575 | userId: z.string(),
576 | comments: z.array(z.string()),
577 | status: z.union([z.literal("published"), z.literal("draft"), z.literal("archived")]),
578 | });
579 | type Post = z.infer;
580 |
581 | const post: Post = { userId: "1", comments: [], status: "published" };
582 |
583 | function test(post: Post) {
584 | if (check("published post", matchSchema(PostSchema.extend({ status: z.literal("published") })), post)) {
585 | expect(post.status).toBe("published");
586 | expectTypeOf(post.status).toEqualTypeOf<"published">();
587 | }
588 |
589 | expect(() => {
590 | assert("published post", matchSchema(PostSchema.extend({ status: z.literal("published") })), post);
591 | expect(post.status).toBe("published");
592 | expectTypeOf(post.status).toEqualTypeOf<"published">();
593 | }).not.toThrowError();
594 | }
595 |
596 | test(post);
597 |
598 | expect.assertions(3);
599 | });
600 |
601 | it("should error if check is called with wrong signature", () => {
602 | // no condition arg
603 | expectTypeOf(check("policy", () => true)).toEqualTypeOf();
604 | expectTypeOf(check(definePolicy("policy", () => true))).toEqualTypeOf();
605 |
606 | // extra arg
607 | expectTypeOf(
608 | /** @ts-expect-error */
609 | check("policy", () => true, {})
610 | ).toEqualTypeOf();
611 | expectTypeOf(
612 | /** @ts-expect-error */
613 | check(
614 | definePolicy("policy", () => true),
615 | {}
616 | )
617 | ).toEqualTypeOf();
618 |
619 | // missing arg
620 | expectTypeOf(
621 | /** @ts-expect-error */
622 | check("policy", (d: any) => d)
623 | ).toEqualTypeOf();
624 | expectTypeOf(
625 | /** @ts-expect-error */
626 | check(definePolicy("policy", (d: any) => d))
627 | ).toEqualTypeOf();
628 |
629 | // missing arg type guard
630 | expectTypeOf(
631 | /** @ts-expect-error */
632 | check("policy", (d: string | null): d is string => d)
633 | ).toEqualTypeOf();
634 | expectTypeOf(
635 | /** @ts-expect-error */
636 | check(definePolicy("policy", (d: string | null): d is string => d))
637 | ).toEqualTypeOf();
638 | });
639 |
640 | it("should error if assert is called with wrong signature", () => {
641 | try {
642 | // no condition arg
643 | expectTypeOf(assert("policy", () => true)).toEqualTypeOf();
644 | expectTypeOf(assert(definePolicy("policy", () => true))).toEqualTypeOf();
645 |
646 | // extra arg
647 | expectTypeOf(
648 | /** @ts-expect-error */
649 | assert("policy", () => true, {})
650 | ).toEqualTypeOf();
651 | expectTypeOf(
652 | /** @ts-expect-error */
653 | assert(
654 | definePolicy("policy", () => true),
655 | {}
656 | )
657 | ).toEqualTypeOf();
658 |
659 | // missing arg
660 | expectTypeOf(
661 | /** @ts-expect-error */
662 | assert("policy", (d: any) => d)
663 | ).toEqualTypeOf();
664 | expectTypeOf(
665 | /** @ts-expect-error */
666 | assert(definePolicy("policy", (d: any) => d))
667 | ).toEqualTypeOf();
668 |
669 | // missing arg type guard
670 | expectTypeOf(
671 | /** @ts-expect-error */
672 | assert("policy", (d: string | null): d is string => d)
673 | ).toEqualTypeOf();
674 | expectTypeOf(
675 | /** @ts-expect-error */
676 | assert(definePolicy("policy", (d: string | null): d is string => d))
677 | ).toEqualTypeOf();
678 | } catch (e) {
679 | // We only test types here
680 | }
681 | });
682 | });
683 |
684 | describe("Logical operators", () => {
685 | type Context = { userId: string };
686 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" };
687 |
688 | it("should [or] accept predicates", () => {
689 | const PostPolicies = definePolicies((context: Context) => {
690 | const myPostPolicy = definePolicy(
691 | "my post",
692 | (post: Post) => post.userId === context.userId,
693 | () => new Error("Not the author")
694 | );
695 |
696 | return [
697 | myPostPolicy,
698 | definePolicy("all published posts or mine", (post: Post) =>
699 | or(
700 | () => check(myPostPolicy, post),
701 | () => post.status === "published"
702 | )
703 | ),
704 | ];
705 | });
706 |
707 | const guard = {
708 | post: PostPolicies({ userId: "1" }),
709 | };
710 |
711 | expect(
712 | check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" })
713 | ).toBe(true);
714 | expect(
715 | check(guard.post.policy("all published posts or mine"), {
716 | userId: "2",
717 | comments: [],
718 | status: "published",
719 | })
720 | ).toBe(true);
721 | expect(
722 | check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" })
723 | ).toBe(false);
724 |
725 | expect(() =>
726 | assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" })
727 | ).not.toThrowError();
728 | expect(() =>
729 | assert(guard.post.policy("all published posts or mine"), {
730 | userId: "2",
731 | comments: [],
732 | status: "published",
733 | })
734 | ).not.toThrowError();
735 | expect(() =>
736 | assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" })
737 | ).toThrowError();
738 | });
739 |
740 | it("should [or] accept booleans", () => {
741 | const PostPolicies = definePolicies((context: Context) => {
742 | const myPostPolicy = definePolicy(
743 | "my post",
744 | (post: Post) => post.userId === context.userId,
745 | () => new Error("Not the author")
746 | );
747 |
748 | return [
749 | myPostPolicy,
750 | definePolicy("all published posts or mine", (post: Post) =>
751 | or(check(myPostPolicy, post), post.status === "published")
752 | ),
753 | ];
754 | });
755 |
756 | const guard = {
757 | post: PostPolicies({ userId: "1" }),
758 | };
759 |
760 | expect(
761 | check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" })
762 | ).toBe(true);
763 | expect(
764 | check(guard.post.policy("all published posts or mine"), {
765 | userId: "2",
766 | comments: [],
767 | status: "published",
768 | })
769 | ).toBe(true);
770 | expect(
771 | check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" })
772 | ).toBe(false);
773 |
774 | expect(() =>
775 | assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" })
776 | ).not.toThrowError();
777 | expect(() =>
778 | assert(guard.post.policy("all published posts or mine"), {
779 | userId: "2",
780 | comments: [],
781 | status: "published",
782 | })
783 | ).not.toThrowError();
784 | expect(() =>
785 | assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" })
786 | ).toThrowError();
787 | });
788 |
789 | it("should [and] accept predicates", () => {
790 | const PostPolicies = definePolicies((context: Context) => {
791 | const myPostPolicy = definePolicy(
792 | "my post",
793 | (post: Post) => post.userId === context.userId,
794 | () => new Error("Not the author")
795 | );
796 |
797 | return [
798 | myPostPolicy,
799 | definePolicy("all my published posts", (post: Post) =>
800 | and(
801 | () => check(myPostPolicy, post),
802 | () => post.status === "published"
803 | )
804 | ),
805 | ];
806 | });
807 |
808 | const guard = {
809 | post: PostPolicies({ userId: "1" }),
810 | };
811 |
812 | expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" })).toBe(
813 | true
814 | );
815 | expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" })).toBe(
816 | false
817 | );
818 | expect(
819 | check(guard.post.policy("all my published posts"), {
820 | userId: "2",
821 | comments: [],
822 | status: "published",
823 | })
824 | ).toBe(false);
825 |
826 | expect(() =>
827 | assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" })
828 | ).not.toThrowError();
829 | expect(() =>
830 | assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" })
831 | ).toThrowError();
832 | expect(() =>
833 | assert(guard.post.policy("all my published posts"), {
834 | userId: "2",
835 | comments: [],
836 | status: "published",
837 | })
838 | ).toThrowError();
839 | });
840 |
841 | it("should [and] accept booleans", () => {
842 | const PostPolicies = definePolicies((context: Context) => {
843 | const myPostPolicy = definePolicy(
844 | "my post",
845 | (post: Post) => post.userId === context.userId,
846 | () => new Error("Not the author")
847 | );
848 |
849 | return [
850 | myPostPolicy,
851 | definePolicy("all my published posts", (post: Post) =>
852 | and(check(myPostPolicy, post), post.status === "published")
853 | ),
854 | ];
855 | });
856 |
857 | const guard = {
858 | post: PostPolicies({ userId: "1" }),
859 | };
860 |
861 | expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" })).toBe(
862 | true
863 | );
864 | expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" })).toBe(
865 | false
866 | );
867 | expect(
868 | check(guard.post.policy("all my published posts"), {
869 | userId: "2",
870 | comments: [],
871 | status: "published",
872 | })
873 | ).toBe(false);
874 |
875 | expect(() =>
876 | assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" })
877 | ).not.toThrowError();
878 | expect(() =>
879 | assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" })
880 | ).toThrowError();
881 | expect(() =>
882 | assert(guard.post.policy("all my published posts"), {
883 | userId: "2",
884 | comments: [],
885 | status: "published",
886 | })
887 | ).toThrowError();
888 | });
889 | });
890 |
891 | describe("Check all settle", () => {
892 | type Context = { userId: string };
893 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" };
894 |
895 | it("should snapshot policies", () => {
896 | const PostPolicies = definePolicies((context: Context) => {
897 | const myPostPolicy = definePolicy(
898 | "shared policy within policy set",
899 | (post: Post) => post.userId === context.userId,
900 | () => new Error("Not the author")
901 | );
902 |
903 | return [
904 | myPostPolicy,
905 | definePolicy("policy from policy set", (post: Post) =>
906 | and(check(myPostPolicy, post), post.status === "published")
907 | ),
908 | ];
909 | });
910 |
911 | const guard = {
912 | post: PostPolicies({ userId: "1" }),
913 | };
914 |
915 | const snapshot = checkAllSettle([
916 | [definePolicy("policy with arg", notNull), "not null"],
917 | ["implicit policy with arg", notNull, "not null"],
918 | [definePolicy("policy with no arg", true)],
919 | definePolicy("policy with no arg simple version", true),
920 | ["implicit policy with boolean", true],
921 | ["implicit policy with no arg function", () => true],
922 | [guard.post.policy("shared policy within policy set"), { userId: "1", comments: [], status: "published" }],
923 | [guard.post.policy("policy from policy set"), { userId: "1", comments: [], status: "published" }],
924 | ]);
925 |
926 | expect(snapshot).toStrictEqual({
927 | "policy with arg": true,
928 | "implicit policy with arg": true,
929 | "policy with no arg": true,
930 | "policy with no arg simple version": true,
931 | "implicit policy with boolean": true,
932 | "implicit policy with no arg function": true,
933 | "shared policy within policy set": true,
934 | "policy from policy set": true,
935 | });
936 |
937 | expectTypeOf(snapshot).toEqualTypeOf<{
938 | "policy with arg": boolean;
939 | "implicit policy with arg": boolean;
940 | "policy with no arg": boolean;
941 | "policy with no arg simple version": boolean;
942 | "implicit policy with boolean": boolean;
943 | "implicit policy with no arg function": boolean;
944 | "shared policy within policy set": boolean;
945 | "policy from policy set": boolean;
946 | }>();
947 |
948 | expectTypeOf(
949 | checkAllSettle([
950 | [
951 | /** @ts-expect-error */
952 | definePolicy("is not null", notNull),
953 | ],
954 | ])
955 | ).toEqualTypeOf<{ [x: string]: boolean }>();
956 | expectTypeOf(
957 | checkAllSettle([
958 | [
959 | /** @ts-expect-error */
960 | definePolicy("is true", true),
961 | "extra arg",
962 | ],
963 | ])
964 | ).toEqualTypeOf<{
965 | [x: string]: boolean;
966 | }>();
967 | expectTypeOf(
968 | checkAllSettle([
969 | /** @ts-expect-error */
970 | definePolicy("is true", (v: unknown) => true),
971 | ])
972 | ).toEqualTypeOf<{
973 | [x: string]: boolean;
974 | }>();
975 | });
976 | });
977 |
--------------------------------------------------------------------------------