@flink-app/jwt-auth-plugin 1.0.0 → 2.0.0-alpha.100

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/readme.md CHANGED
@@ -94,6 +94,44 @@ jwtAuthPlugin({
94
94
  })
95
95
  ```
96
96
 
97
+ ## Permission Matching Semantics
98
+
99
+ The static `rolePermissions` map supports three forms of matching:
100
+
101
+ 1. **Exact match** — `"car.read"` in a role's scope list matches a route declaring `permissions: ["car.read"]`.
102
+ 2. **Role superuser** — a role whose scope list contains `"*"` is granted every permission unconditionally.
103
+ 3. **Route allows any authenticated user** — a route declaring `permissions: ["*"]` accepts any role as long as the user is authenticated.
104
+ 4. **Suffix wildcard scopes** — a scope entry ending in `.*` (e.g. `"license.subscription.*"`) matches any required route permission whose literal string starts with the prefix including the trailing dot.
105
+
106
+ ### Suffix wildcard scopes
107
+
108
+ ```typescript
109
+ rolePermissions: {
110
+ restricted: [
111
+ "license.subscription.*", // matches license.subscription.get, license.subscription.cancel, ...
112
+ "license.unsubscribe", // exact match
113
+ ],
114
+ admin: ["*"], // every permission (superuser)
115
+ }
116
+ ```
117
+
118
+ Matching rules:
119
+
120
+ | Scope entry | Route permission | Result |
121
+ | ---------------------------- | -------------------------------------- | ----------- |
122
+ | `"license.subscription.*"` | `"license.subscription.get"` | match |
123
+ | `"license.subscription.*"` | `"license.subscription.cancel"` | match |
124
+ | `"license.subscription.*"` | `"license.subscription"` (no suffix) | **no match** — wildcard requires a segment after the prefix |
125
+ | `"license.subscription.*"` | `"license.subscriptions.get"` | **no match** — segment-boundary safety, prevents accidental escalation through similarly-named scopes |
126
+ | `"a.b.c.*"` | `"a.b.c.x.y"` | match |
127
+
128
+ Notes:
129
+
130
+ - The wildcard is **segment-scoped**, not substring-scoped. `foo.*` matches `foo.bar` and `foo.bar.baz` but never `foobar.x`.
131
+ - Only a **trailing** `.*` is special. Infix forms like `*.get` or `foo.*.bar` are not supported.
132
+ - Wildcard scopes can be mixed freely with exact-match entries in the same role.
133
+ - All required route permissions must be matched (the check is `every`), so a single wildcard can cover multiple required scopes only if they all fall under its prefix.
134
+
97
135
  ## Custom Token Extraction
98
136
 
99
137
  By default, the plugin extracts JWT tokens from the `Authorization` header as Bearer tokens:
@@ -443,6 +481,282 @@ jwtAuthPlugin({
443
481
 
444
482
  **Solution**: Clear permission cache or reduce cache TTL
445
483
 
484
+ ## `req.token` & `req.rawToken` - Accessing Token Claims
485
+
486
+ After successful authentication the plugin attaches the verified token to the request:
487
+
488
+ - **`req.token`** - the decoded, **signature-verified** token payload (claims). Same object passed to `getUser`.
489
+ - **`req.rawToken`** - the original token string that authenticated the request (Bearer header or custom `tokenExtractor` output).
490
+
491
+ This lets handlers read token claims (e.g. `jti`, custom session ids) without re-extracting and re-decoding the bearer token themselves - and without an unverified decode.
492
+
493
+ ```typescript
494
+ const handler: Handler<Ctx, any, any> = async ({ req }) => {
495
+ // Read claims straight off the verified token
496
+ const { jti } = (req.token as { jti?: string }) ?? {};
497
+ const sessionId = jti ?? legacySessionId(req.user._id);
498
+
499
+ // Forward the raw token to a downstream service if needed
500
+ await downstream.call({ headers: { authorization: `Bearer ${req.rawToken}` } });
501
+
502
+ return { data: { sessionId } };
503
+ };
504
+ ```
505
+
506
+ **Notes:**
507
+
508
+ - Both fields are only set once authentication **succeeds** (a rejected request never reaches the handler).
509
+ - `req.token` is the exact decoded payload, including framework-added claims like `iat` and `exp`.
510
+ - Signature is verified during decode; unlike a bare `jsonwebtoken.decode(...)`, you don't need to (and shouldn't) re-verify.
511
+ - `req.rawToken` reflects whatever `tokenExtractor` returned, so it works for cookie/query-based tokens too.
512
+
513
+ ## `req.userPermissions` - AI Permissions Integration
514
+
515
+ Starting in **v1.1.0**, the JWT auth plugin automatically populates `req.userPermissions` with the resolved permissions array. This field is specifically designed for use with Flink's AI agents and tools system, providing a standardized way to handle permissions across handlers, agents, and tools.
516
+
517
+ ### Why `req.userPermissions`?
518
+
519
+ **Before v1.1.0**: Tools and agents had to manually convert roles to permissions or query the database in permission functions, leading to:
520
+ - Duplicated role-to-permission mapping logic
521
+ - Performance issues (N tools × M agent steps = many DB queries)
522
+ - No support for multi-tenant permission contexts
523
+ - Inconsistent permission resolution across the app
524
+
525
+ **After v1.1.0**: The auth plugin populates `req.userPermissions` once during authentication, and this resolved array is passed through to agents and tools:
526
+ - ✅ Single source of truth for permissions
527
+ - ✅ Performance optimization (computed once per request)
528
+ - ✅ Reuses all auth plugin features (roles, dynamic roles, custom checkers)
529
+ - ✅ Consistent permission checking across handlers, agents, and tools
530
+
531
+ ### How It Works
532
+
533
+ The JWT auth plugin automatically populates `req.userPermissions` based on the configuration strategy:
534
+
535
+ **Strategy 1: Direct permissions from user object**
536
+
537
+ ```typescript
538
+ jwtAuthPlugin({
539
+ secret: process.env.JWT_SECRET,
540
+ getUser: async (tokenData) => {
541
+ const user = await userRepo.getById(tokenData.userId);
542
+ return {
543
+ id: user.id,
544
+ permissions: user.permissions, // Direct permissions array
545
+ };
546
+ },
547
+ rolePermissions: {},
548
+ });
549
+
550
+ // Result: req.userPermissions = user.permissions
551
+ ```
552
+
553
+ **Strategy 2: Static role mapping**
554
+
555
+ ```typescript
556
+ jwtAuthPlugin({
557
+ secret: process.env.JWT_SECRET,
558
+ getUser: async (tokenData) => {
559
+ return { id: tokenData.userId };
560
+ },
561
+ rolePermissions: {
562
+ admin: ["car:read", "car:create", "car:delete"],
563
+ user: ["car:read"],
564
+ premium: ["car:read", "car:create", "premium-features"],
565
+ },
566
+ });
567
+
568
+ // JWT token has roles: ["user", "premium"]
569
+ // Result: req.userPermissions = ["car:read", "car:create", "premium-features"]
570
+ ```
571
+
572
+ **Strategy 3: Dynamic roles (multi-tenant)**
573
+
574
+ ```typescript
575
+ jwtAuthPlugin({
576
+ secret: process.env.JWT_SECRET,
577
+ useDynamicRoles: true,
578
+ getUser: async (tokenData, req) => {
579
+ const orgId = req.headers['x-organization-id'];
580
+ const membership = await orgMembershipRepo.getByUserAndOrg(
581
+ tokenData.userId,
582
+ orgId
583
+ );
584
+ return {
585
+ id: tokenData.userId,
586
+ roles: [membership.role], // Org-specific role
587
+ };
588
+ },
589
+ rolePermissions: {
590
+ admin: ["car:read", "car:create", "car:delete"],
591
+ user: ["car:read"],
592
+ },
593
+ });
594
+
595
+ // Same user, different orgs → different permissions
596
+ // Org A: admin role → req.userPermissions = ["car:read", "car:create", "car:delete"]
597
+ // Org B: user role → req.userPermissions = ["car:read"]
598
+ ```
599
+
600
+ ### Using with AI Agents and Tools
601
+
602
+ Pass `req.userPermissions` to agents in your handlers:
603
+
604
+ ```typescript
605
+ // Handler
606
+ const handler: Handler<AppCtx, RequestBody, ResponseBody> = async ({ req, ctx }) => {
607
+ const agent = ctx.agents.carAgent
608
+ .withUser(req.user)
609
+ .withPermissions(req.userPermissions); // Pass resolved permissions
610
+
611
+ return await agent.execute({ message: req.body.message });
612
+ };
613
+
614
+ // Agent
615
+ export default class CarAgent extends FlinkAgent<AppCtx> {
616
+ description = "Expert in car models";
617
+ instructions = "You are a car expert...";
618
+ tools = ["get-cars-tool", "create-car-tool"];
619
+ permissions = "car:access"; // Agent-level permission
620
+ }
621
+
622
+ // Tool
623
+ export const Tool: FlinkToolProps = {
624
+ id: "create-car",
625
+ description: "Create a new car",
626
+ permissions: "car:create", // Tool-level permission
627
+ inputSchema: z.object({
628
+ brand: z.string(),
629
+ model: z.string(),
630
+ }),
631
+ };
632
+ ```
633
+
634
+ **What happens:**
635
+ 1. Auth plugin resolves permissions → `req.userPermissions = ["car:access", "car:read", "car:create"]`
636
+ 2. Agent checks if user has `"car:access"` → ✅ allowed
637
+ 3. LLM only sees tools user has permission for (`"create-car"` is shown, others hidden)
638
+ 4. Tool execution checks `"car:create"` permission → ✅ allowed
639
+
640
+ ### Permission Resolution Priority
641
+
642
+ The plugin computes `req.userPermissions` in this order:
643
+
644
+ 1. **Direct permissions**: If `user.permissions` exists (from `getUser`), use it directly
645
+ 2. **Dynamic roles**: If `useDynamicRoles: true`, map `user.roles` to permissions via `rolePermissions`
646
+ 3. **Static roles**: Map token roles to permissions via `rolePermissions`
647
+ 4. **Empty array**: If no permissions found, return `[]`
648
+
649
+ ### Backward Compatibility
650
+
651
+ `req.userPermissions` is **optional** - existing apps continue to work:
652
+
653
+ - If not using AI agents/tools → field is populated but not used
654
+ - Tools/agents fall back to `user.permissions` if `req.userPermissions` is not passed
655
+ - No breaking changes to existing code
656
+
657
+ ### Performance Benefits
658
+
659
+ **Before** (function-based permissions without `userPermissions`):
660
+ ```typescript
661
+ // Permission checked N times (once per tool use)
662
+ permissions: async (input, user) => {
663
+ const perms = await fetchUserPermissions(user.id); // DB query per tool!
664
+ return perms.includes("car:create");
665
+ }
666
+ ```
667
+
668
+ **After** (using `userPermissions`):
669
+ ```typescript
670
+ // Permissions computed ONCE during authentication
671
+ req.userPermissions = ["car:read", "car:create"]; // Happens once
672
+
673
+ // Tools check the array (fast O(1) lookup)
674
+ permissions: "car:create"
675
+ ```
676
+
677
+ **Result**: Single DB query/computation per request, regardless of how many tools or agent steps are used.
678
+
679
+ ### Example: Complete Integration
680
+
681
+ ```typescript
682
+ // 1. Configure JWT auth plugin
683
+ jwtAuthPlugin({
684
+ secret: process.env.JWT_SECRET,
685
+ rolePermissions: {
686
+ admin: ["car:read", "car:create", "car:delete", "premium-features"],
687
+ user: ["car:read"],
688
+ premium: ["car:read", "car:create", "premium-features"],
689
+ },
690
+ getUser: async (tokenData) => {
691
+ const user = await userRepo.getById(tokenData.userId);
692
+ return {
693
+ id: user.id,
694
+ email: user.email,
695
+ // Roles from DB or token will be mapped to permissions automatically
696
+ };
697
+ },
698
+ });
699
+
700
+ // 2. Handler passes userPermissions to agent
701
+ const handler: Handler<AppCtx, { question: string }, { answer: string }> = async ({
702
+ req,
703
+ ctx,
704
+ }) => {
705
+ const agent = ctx.agents.carAgent
706
+ .withUser(req.user)
707
+ .withPermissions(req.userPermissions); // Resolved permissions passed here
708
+
709
+ const response = await agent.execute({ message: req.body.question });
710
+ return { data: { answer: response.message } };
711
+ };
712
+
713
+ // 3. Agent defines permissions
714
+ export default class CarAgent extends FlinkAgent<AppCtx> {
715
+ description = "Car inventory expert";
716
+ instructions = "Help users with car-related queries";
717
+ tools = ["get-cars", "create-car", "premium-report"];
718
+ permissions = "car:read"; // User must have car:read
719
+ }
720
+
721
+ // 4. Tools define individual permissions
722
+ export const GetCarsTool: FlinkToolProps = {
723
+ id: "get-cars",
724
+ description: "Search car inventory",
725
+ permissions: "car:read", // Basic read access
726
+ inputSchema: z.object({ brand: z.string().optional() }),
727
+ };
728
+
729
+ export const CreateCarTool: FlinkToolProps = {
730
+ id: "create-car",
731
+ description: "Add new car to inventory",
732
+ permissions: "car:create", // Requires create permission
733
+ inputSchema: z.object({ brand: z.string(), model: z.string() }),
734
+ };
735
+
736
+ export const PremiumReportTool: FlinkToolProps = {
737
+ id: "premium-report",
738
+ description: "Generate advanced analytics",
739
+ permissions: ["car:read", "premium-features"], // Requires BOTH
740
+ inputSchema: z.object({ carId: z.string() }),
741
+ };
742
+ ```
743
+
744
+ **Flow**:
745
+ 1. User with JWT token containing `roles: ["user", "premium"]` makes a request
746
+ 2. Auth plugin resolves: `req.userPermissions = ["car:read", "car:create", "premium-features"]`
747
+ 3. Handler passes `req.userPermissions` to agent
748
+ 4. Agent checks `"car:read"` → ✅ user has it
749
+ 5. Agent filters tools:
750
+ - `get-cars` (requires `"car:read"`) → ✅ shown to LLM
751
+ - `create-car` (requires `"car:create"`) → ✅ shown to LLM
752
+ - `premium-report` (requires `["car:read", "premium-features"]`) → ✅ shown to LLM (has both)
753
+ 6. LLM can use all three tools
754
+
755
+ If user only had `roles: ["user"]`:
756
+ - `req.userPermissions = ["car:read"]`
757
+ - Only `get-cars` tool would be shown to LLM
758
+ - LLM cannot hallucinate using `create-car` or `premium-report` (they're hidden)
759
+
446
760
  ## Multi-Tenant & Dynamic Roles
447
761
 
448
762
  The JWT auth plugin supports multi-tenant scenarios where users have different roles depending on the organization or context they're accessing. This is achieved through the `useDynamicRoles` option combined with the `req` parameter in `getUser`.
@@ -102,6 +102,62 @@ describe("FlinkJwtAuthPlugin", () => {
102
102
  expect(authenticated).toBeTruthy();
103
103
  });
104
104
 
105
+ it("should expose decoded token on req.token and raw string on req.rawToken", async () => {
106
+ const secret = "secret";
107
+ const userId = "123";
108
+ const encodedToken = jwtSimple.encode(
109
+ { id: userId, jti: "session-abc", roles: ["user"] },
110
+ secret
111
+ );
112
+
113
+ const plugin = jwtAuthPlugin({
114
+ secret,
115
+ getUser: async ({ id }: { id: string }, req) => ({ id, username: "username" }),
116
+ rolePermissions: {
117
+ user: ["*"],
118
+ },
119
+ });
120
+
121
+ const mockRequest = {
122
+ headers: {
123
+ authorization: "Bearer " + encodedToken,
124
+ },
125
+ } as FlinkRequest;
126
+
127
+ const authenticated = await plugin.authenticateRequest(mockRequest, "foo");
128
+
129
+ expect(authenticated).toBeTruthy();
130
+ expect(mockRequest.rawToken).toBe(encodedToken);
131
+ expect(mockRequest.token?.jti).toBe("session-abc");
132
+ expect(mockRequest.token?.id).toBe(userId);
133
+ });
134
+
135
+ it("should set req.rawToken to the tokenExtractor output, not just Bearer", async () => {
136
+ const secret = "secret";
137
+ const customToken = jwtSimple.encode(
138
+ { id: "1", jti: "from-query", roles: ["user"] },
139
+ secret
140
+ );
141
+
142
+ const plugin = jwtAuthPlugin({
143
+ secret,
144
+ getUser: async ({ id }: { id: string }, req) => ({ id, username: "username" }),
145
+ rolePermissions: { user: ["*"] },
146
+ tokenExtractor: (req) => req.query?.token as string,
147
+ });
148
+
149
+ const mockRequest = {
150
+ headers: {},
151
+ query: { token: customToken },
152
+ } as unknown as FlinkRequest;
153
+
154
+ const authenticated = await plugin.authenticateRequest(mockRequest, "foo");
155
+
156
+ expect(authenticated).toBeTruthy();
157
+ expect(mockRequest.rawToken).toBe(customToken);
158
+ expect(mockRequest.token?.jti).toBe("from-query");
159
+ });
160
+
105
161
  it("should generate token", async () => {
106
162
  const secret = "secret";
107
163
  const plugin = jwtAuthPlugin({
@@ -813,4 +869,110 @@ describe("FlinkJwtAuthPlugin", () => {
813
869
  expect(capturedHeaders["x-custom-header"]).toBe("custom-value");
814
870
  });
815
871
  });
872
+
873
+ describe("resolveTokenTTL", () => {
874
+ it("should use resolved TTL when callback returns a number", async () => {
875
+ const secret = "secret";
876
+ const ttl = 1000 * 60 * 60 * 8; // 8 hours
877
+
878
+ const plugin = jwtAuthPlugin({
879
+ secret,
880
+ getUser: async () => ({ id: "1", username: "u" }),
881
+ rolePermissions: {},
882
+ resolveTokenTTL: () => ttl,
883
+ });
884
+
885
+ const token = await plugin.createToken({ id: "1" }, ["admin"]);
886
+ const decoded = jwtSimple.decode(token, secret);
887
+ const expectedExp = Math.floor((Date.now() + ttl) / 1000);
888
+
889
+ expect(decoded.exp).toBeCloseTo(expectedExp, -1);
890
+ });
891
+
892
+ it("should fall back to static tokenTTL when callback returns undefined", async () => {
893
+ const secret = "secret";
894
+ const staticTTL = 1000 * 60 * 60 * 2; // 2 hours
895
+
896
+ const plugin = jwtAuthPlugin({
897
+ secret,
898
+ getUser: async () => ({ id: "1", username: "u" }),
899
+ rolePermissions: {},
900
+ tokenTTL: staticTTL,
901
+ resolveTokenTTL: () => undefined,
902
+ });
903
+
904
+ const token = await plugin.createToken({ id: "1" }, ["user"]);
905
+ const decoded = jwtSimple.decode(token, secret);
906
+ const expectedExp = Math.floor((Date.now() + staticTTL) / 1000);
907
+
908
+ expect(decoded.exp).toBeCloseTo(expectedExp, -1);
909
+ });
910
+
911
+ it("should fall back to default TTL when neither option is provided", async () => {
912
+ const secret = "secret";
913
+ const hundredYears = 1000 * 60 * 60 * 24 * 365 * 100;
914
+
915
+ const plugin = jwtAuthPlugin({
916
+ secret,
917
+ getUser: async () => ({ id: "1", username: "u" }),
918
+ rolePermissions: {},
919
+ });
920
+
921
+ const token = await plugin.createToken({ id: "1" }, ["user"]);
922
+ const decoded = jwtSimple.decode(token, secret);
923
+ const expectedExp = Math.floor((Date.now() + hundredYears) / 1000);
924
+
925
+ expect(decoded.exp).toBeCloseTo(expectedExp, -1);
926
+ });
927
+
928
+ it("should receive roles and payload in callback", async () => {
929
+ const secret = "secret";
930
+ let capturedRoles: string[] | undefined;
931
+ let capturedPayload: any;
932
+
933
+ const plugin = jwtAuthPlugin({
934
+ secret,
935
+ getUser: async () => ({ id: "1", username: "u" }),
936
+ rolePermissions: {},
937
+ resolveTokenTTL: (roles, payload) => {
938
+ capturedRoles = roles;
939
+ capturedPayload = payload;
940
+ return 60000;
941
+ },
942
+ });
943
+
944
+ await plugin.createToken({ id: "user-1", email: "a@b.com" }, ["admin", "editor"]);
945
+
946
+ expect(capturedRoles).toEqual(["admin", "editor"]);
947
+ expect(capturedPayload?.id).toBe("user-1");
948
+ expect(capturedPayload?.email).toBe("a@b.com");
949
+ });
950
+
951
+ it("should produce different TTLs for different roles", async () => {
952
+ const secret = "secret";
953
+
954
+ const plugin = jwtAuthPlugin({
955
+ secret,
956
+ getUser: async () => ({ id: "1", username: "u" }),
957
+ rolePermissions: {},
958
+ resolveTokenTTL: (roles) => {
959
+ if (roles.includes("admin")) return 1000 * 60 * 60; // 1 hour
960
+ return 1000 * 60 * 60 * 24 * 30; // 30 days
961
+ },
962
+ });
963
+
964
+ const adminToken = await plugin.createToken({ id: "1" }, ["admin"]);
965
+ const userToken = await plugin.createToken({ id: "2" }, ["user"]);
966
+
967
+ const adminDecoded = jwtSimple.decode(adminToken, secret);
968
+ const userDecoded = jwtSimple.decode(userToken, secret);
969
+
970
+ // User token should expire much later than admin token
971
+ expect(userDecoded.exp).toBeGreaterThan(adminDecoded.exp);
972
+
973
+ // Difference should be roughly 30 days minus 1 hour ≈ 29.96 days in seconds
974
+ const diffSeconds = userDecoded.exp - adminDecoded.exp;
975
+ expect(diffSeconds).toBeGreaterThan(29 * 24 * 60 * 60);
976
+ });
977
+ });
816
978
  });
@@ -69,4 +69,96 @@ describe("PermissionValidator", () => {
69
69
  const valid = hasValidPermissions([], {}, ["*"]);
70
70
  expect(valid).toBe(false);
71
71
  });
72
+
73
+ describe("suffix wildcard scopes", () => {
74
+ it("should match a single-segment suffix under the wildcard", () => {
75
+ const valid = hasValidPermissions(
76
+ ["user"],
77
+ { user: ["a.*"] },
78
+ ["a.b"]
79
+ );
80
+ expect(valid).toBe(true);
81
+ });
82
+
83
+ it("should match a multi-segment suffix under the wildcard", () => {
84
+ const valid = hasValidPermissions(
85
+ ["user"],
86
+ { user: ["a.*"] },
87
+ ["a.b.c"]
88
+ );
89
+ expect(valid).toBe(true);
90
+ });
91
+
92
+ it("should NOT match the wildcard prefix without a trailing segment", () => {
93
+ const valid = hasValidPermissions(
94
+ ["user"],
95
+ { user: ["a.*"] },
96
+ ["a"]
97
+ );
98
+ expect(valid).toBe(false);
99
+ });
100
+
101
+ it("should NOT match across a segment boundary (privilege escalation guard)", () => {
102
+ const valid = hasValidPermissions(
103
+ ["user"],
104
+ { user: ["a.*"] },
105
+ ["ab.c"]
106
+ );
107
+ expect(valid).toBe(false);
108
+ });
109
+
110
+ it("should allow mixing wildcard and exact scopes when the required perm hits the exact one", () => {
111
+ const valid = hasValidPermissions(
112
+ ["user"],
113
+ { user: ["a.*", "b.get"] },
114
+ ["b.get"]
115
+ );
116
+ expect(valid).toBe(true);
117
+ });
118
+
119
+ it("should deny when the required perm matches neither the wildcard nor the exact entry", () => {
120
+ const valid = hasValidPermissions(
121
+ ["user"],
122
+ { user: ["a.*", "b.get"] },
123
+ ["b.put"]
124
+ );
125
+ expect(valid).toBe(false);
126
+ });
127
+
128
+ it("should still honor route '*' when the role only has a suffix wildcard", () => {
129
+ const valid = hasValidPermissions(
130
+ ["user"],
131
+ { user: ["a.*"] },
132
+ ["*"]
133
+ );
134
+ expect(valid).toBe(true);
135
+ });
136
+
137
+ it("should match deeply nested prefixes", () => {
138
+ const valid = hasValidPermissions(
139
+ ["user"],
140
+ { user: ["a.b.c.*"] },
141
+ ["a.b.c.x.y"]
142
+ );
143
+ expect(valid).toBe(true);
144
+ });
145
+
146
+ it("should allow a single wildcard to cover multiple required perms", () => {
147
+ const valid = hasValidPermissions(
148
+ ["user"],
149
+ { user: ["a.*"] },
150
+ ["a.b", "a.c"]
151
+ );
152
+ expect(valid).toBe(true);
153
+ });
154
+
155
+ it("should deny when one required perm is outside the wildcard namespace", () => {
156
+ const valid = hasValidPermissions(
157
+ ["user"],
158
+ { user: ["a.*"] },
159
+ ["a.b", "c.d"]
160
+ );
161
+ expect(valid).toBe(false);
162
+ });
163
+ });
72
164
  });