plugin-ai-api 1.0.25 → 1.1.0

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.
Files changed (115) hide show
  1. package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
  2. package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
  3. package/dist/client/562.44b16aad4718b4c7.js +10 -0
  4. package/dist/client/685.ae483e17b6b49c98.js +10 -0
  5. package/dist/client/{757.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
  6. package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.3971233415999b2c.js +10 -0
  9. package/dist/client-v2/562.45d5c504433be38b.js +10 -0
  10. package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
  11. package/dist/client-v2/{757.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
  12. package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
  13. package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +2 -5
  16. package/dist/locale/en-US.json +26 -8
  17. package/dist/locale/vi-VN.json +26 -8
  18. package/dist/locale/zh-CN.json +26 -8
  19. package/dist/server/billing.js +25 -32
  20. package/dist/server/collections/ai-api-config.js +1 -7
  21. package/dist/server/collections/ai-api-group-members.js +62 -0
  22. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  24. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  25. package/dist/server/collections/ai-api-usage-records.js +1 -0
  26. package/dist/server/middleware/rate-limit.js +7 -6
  27. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  28. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  29. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  30. package/dist/server/plugin.js +90 -22
  31. package/dist/server/quota-groups.js +108 -0
  32. package/dist/server/resource/ai-api-config.js +0 -3
  33. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  34. package/dist/server/routes/agent-completions.js +2 -1
  35. package/dist/server/routes/chat-completions.js +32 -32
  36. package/dist/server/routes/completions.js +16 -19
  37. package/dist/server/routes/embeddings.js +2 -1
  38. package/dist/server/routes/models.js +2 -1
  39. package/dist/server/routes/router.js +3 -2
  40. package/dist/server/services/file-processor.js +186 -22
  41. package/dist/server/usage.js +5 -1
  42. package/dist/server/utils/direct-llm-context.js +13 -11
  43. package/dist/server/utils/rate-limiter.js +1 -1
  44. package/dist/server/utils/request-cache.js +61 -0
  45. package/dist/server/utils/resolve-service.js +2 -1
  46. package/dist/server/utils/user-permissions.js +25 -39
  47. package/dist/server/validation.js +7 -0
  48. package/dist/swagger.js +6 -7
  49. package/package.json +1 -1
  50. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  51. package/src/client/plugin.tsx +5 -16
  52. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  53. package/src/client-v2/locale.ts +3 -1
  54. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  55. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  56. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  57. package/src/client-v2/plugin.tsx +4 -13
  58. package/src/constants.ts +0 -7
  59. package/src/locale/en-US.json +26 -8
  60. package/src/locale/vi-VN.json +26 -8
  61. package/src/locale/zh-CN.json +26 -8
  62. package/src/server/__tests__/billing-quota.test.ts +28 -9
  63. package/src/server/__tests__/direct-llm-context.test.ts +122 -4
  64. package/src/server/__tests__/file-processor.test.ts +225 -0
  65. package/src/server/__tests__/models.test.ts +1 -1
  66. package/src/server/__tests__/permission-sync.test.ts +34 -35
  67. package/src/server/__tests__/usage-groups.test.ts +160 -0
  68. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  69. package/src/server/__tests__/usage-route.test.ts +262 -2
  70. package/src/server/__tests__/usage.test.ts +38 -0
  71. package/src/server/__tests__/user-permissions.test.ts +214 -133
  72. package/src/server/__tests__/validation.test.ts +11 -0
  73. package/src/server/billing.ts +30 -38
  74. package/src/server/collections/ai-api-config.ts +1 -7
  75. package/src/server/collections/ai-api-group-members.ts +41 -0
  76. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  77. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  78. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  79. package/src/server/collections/ai-api-usage-records.ts +1 -0
  80. package/src/server/middleware/rate-limit.ts +10 -12
  81. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  82. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  83. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  84. package/src/server/plugin.ts +101 -30
  85. package/src/server/quota-groups.ts +117 -0
  86. package/src/server/resource/ai-api-config.ts +0 -3
  87. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  88. package/src/server/routes/agent-completions.ts +2 -1
  89. package/src/server/routes/chat-completions.ts +39 -36
  90. package/src/server/routes/completions.ts +18 -21
  91. package/src/server/routes/embeddings.ts +2 -1
  92. package/src/server/routes/models.ts +4 -3
  93. package/src/server/routes/router.ts +4 -3
  94. package/src/server/services/file-processor.ts +214 -24
  95. package/src/server/usage.ts +5 -1
  96. package/src/server/utils/direct-llm-context.ts +20 -11
  97. package/src/server/utils/rate-limiter.ts +1 -1
  98. package/src/server/utils/request-cache.ts +59 -0
  99. package/src/server/utils/resolve-service.ts +2 -1
  100. package/src/server/utils/user-permissions.ts +49 -69
  101. package/src/server/validation.ts +7 -0
  102. package/src/swagger.ts +7 -8
  103. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  104. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  105. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  106. package/dist/client/902.e74518750f1e4201.js +0 -10
  107. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  108. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  109. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  110. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  111. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  112. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  113. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  114. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  115. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -9,10 +9,11 @@
9
9
 
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import type { AiApiUsageGroup } from '../quota-groups';
12
13
  import {
13
14
  buildAccessScope,
14
15
  enforceModelAccess,
15
- invalidateUserPermissionCache,
16
+ invalidateGroupAccessCache,
16
17
  isModelAllowed,
17
18
  isServiceAllowed,
18
19
  resolveUserAccessScope,
@@ -21,48 +22,112 @@ import {
21
22
  const OPENAI = { name: 'openai', title: 'OpenAI' };
22
23
  const ANTHROPIC = { name: 'anthropic', title: 'Anthropic' };
23
24
 
24
- /** Sequelize instances only expose columns via .get(); rows must be shaped that way. */
25
+ function group(values: Partial<AiApiUsageGroup> = {}): AiApiUsageGroup {
26
+ return {
27
+ id: 1,
28
+ name: 'Test group',
29
+ isDefault: false,
30
+ quotaMode: 'per_user',
31
+ rateLimitPerMinute: 60,
32
+ enabled: false,
33
+ periodType: 'monthly',
34
+ timezone: 'UTC',
35
+ requestLimit: null,
36
+ totalTokenLimit: null,
37
+ costLimit: null,
38
+ currency: 'USD',
39
+ rejectUnpricedModel: true,
40
+ missingUsageBehavior: 'use_reserved',
41
+ contextOverflowBehavior: 'reject',
42
+ allowedLlmServices: [],
43
+ allowAllModels: true,
44
+ allowedModels: [],
45
+ ...values,
46
+ };
47
+ }
48
+
49
+ /** Sequelize models expose columns via .get(); .get() with no argument returns the full record. */
25
50
  function row(values: Record<string, unknown>) {
26
- return { get: (key: string) => values[key] };
51
+ return { get: (key?: string) => (key === undefined ? values : values[key]) };
52
+ }
53
+
54
+ const DEFAULT_GROUP_ROW = row({
55
+ id: 99,
56
+ name: 'Default',
57
+ isDefault: true,
58
+ allowedLlmServices: [],
59
+ allowAllModels: true,
60
+ allowedModels: [],
61
+ });
62
+
63
+ interface MockOptions {
64
+ /** Default 1; null simulates an unauthenticated caller. */
65
+ userId?: number | null;
66
+ /** The group row attached to the caller's membership; omit = no membership row. */
67
+ memberGroup?: unknown;
68
+ /** Membership exists but points at a missing group. */
69
+ memberWithoutGroup?: boolean;
70
+ /** Default group row; omit = DEFAULT_GROUP_ROW, null = missing (lazy-create path). */
71
+ defaultGroup?: unknown;
72
+ throws?: boolean;
73
+ appName?: string;
27
74
  }
28
75
 
29
- function mockContext(options: { userId?: number | null; row?: unknown; throws?: boolean; appName?: string } = {}) {
30
- const findOne = vi.fn(async () => {
76
+ function mockContext(options: MockOptions = {}) {
77
+ const memberGroup = options.memberGroup ?? null;
78
+ const memberFindOne = vi.fn(async () => {
79
+ if (options.throws) throw new Error('collection unavailable');
80
+ if (options.memberWithoutGroup) return row({ group: null });
81
+ if (!memberGroup) return null;
82
+ return row({ group: memberGroup });
83
+ });
84
+ const hasDefault = 'defaultGroup' in options;
85
+ const groupFindOne = vi.fn(async () => {
31
86
  if (options.throws) throw new Error('collection unavailable');
32
- return options.row ?? null;
87
+ return hasDefault ? options.defaultGroup : DEFAULT_GROUP_ROW;
33
88
  });
89
+ const groupCreate = vi.fn(async ({ values }: { values: Record<string, unknown> }) =>
90
+ row({ id: 99, isDefault: true, allowedLlmServices: [], allowAllModels: true, allowedModels: [], ...values }),
91
+ );
34
92
  const ctx = {
35
93
  state: { currentUser: options.userId === null ? undefined : { id: options.userId ?? 1 } },
36
- db: { getRepository: () => ({ findOne }) },
94
+ db: {
95
+ getRepository: (name: string) =>
96
+ name === 'aiApiGroupMembers' ? { findOne: memberFindOne } : { findOne: groupFindOne, create: groupCreate },
97
+ },
37
98
  app: { name: options.appName ?? 'main' },
38
99
  log: { warn: vi.fn(), error: vi.fn() },
39
100
  status: 200,
40
101
  body: undefined,
41
102
  } as unknown as Context;
42
- return { ctx, findOne };
103
+ return {
104
+ ctx,
105
+ memberFindOne,
106
+ groupFindOne,
107
+ groupCreate,
108
+ };
43
109
  }
44
110
 
45
111
  beforeEach(() => {
46
- invalidateUserPermissionCache();
112
+ invalidateGroupAccessCache();
47
113
  });
48
114
 
49
115
  describe('buildAccessScope', () => {
50
- it('treats a missing row as "no user-level narrowing"', () => {
51
- const scope = buildAccessScope(null);
52
- expect(scope.hasUserRecord).toBe(false);
53
- expect(scope.denyAll).toBe(false);
54
- expect(scope.allowedServices).toBeNull();
116
+ it('treats empty group lists as no narrowing', () => {
117
+ const scope = buildAccessScope(group());
118
+ expect(scope.allowedServices).toEqual([]);
119
+ expect(scope.allowAllModels).toBe(true);
120
+ expect(scope.allowedModels.size).toBe(0);
121
+ expect(scope.lookupFailed).toBe(false);
55
122
  });
56
123
 
57
- it('marks a disabled row as deny-all', () => {
58
- const scope = buildAccessScope(row({ enabled: false, allowedLlmServices: ['openai'] }));
59
- expect(scope.denyAll).toBe(true);
60
- expect(scope.allowedServices).toEqual([]);
124
+ it('carries the group id for cache keying', () => {
125
+ expect(buildAccessScope(group({ id: 7 })).groupId).toBe(7);
61
126
  });
62
127
 
63
- it('reads columns through .get() rather than plain property access', () => {
128
+ it('narrows services and models from the group settings', () => {
64
129
  const scope = buildAccessScope(
65
- row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
130
+ group({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
66
131
  );
67
132
  expect(scope.allowedServices).toEqual(['openai']);
68
133
  expect(scope.allowAllModels).toBe(false);
@@ -70,12 +135,15 @@ describe('buildAccessScope', () => {
70
135
  });
71
136
 
72
137
  it('defaults allowAllModels to true when the column is unset', () => {
73
- expect(buildAccessScope(row({ allowedLlmServices: [] })).allowAllModels).toBe(true);
138
+ expect(buildAccessScope(group({ allowAllModels: undefined })).allowAllModels).toBe(true);
74
139
  });
75
140
 
76
141
  it('discards non-string entries in the json arrays', () => {
77
142
  const scope = buildAccessScope(
78
- row({ allowedLlmServices: ['openai', null, 42, ''], allowedModels: [{}, 'openai/gpt-4o'] }),
143
+ group({
144
+ allowedLlmServices: ['openai', null, 42, ''] as unknown as string[],
145
+ allowedModels: [{}, 'openai/gpt-4o'] as unknown as string[],
146
+ }),
79
147
  );
80
148
  expect(scope.allowedServices).toEqual(['openai']);
81
149
  expect([...scope.allowedModels]).toEqual(['openai/gpt-4o']);
@@ -83,174 +151,185 @@ describe('buildAccessScope', () => {
83
151
  });
84
152
 
85
153
  describe('isServiceAllowed', () => {
86
- const noRecord = buildAccessScope(null);
87
-
88
- it('leaves behaviour unchanged for a user with no record', () => {
89
- expect(isServiceAllowed(noRecord, ['openai'], OPENAI)).toBe(true);
90
- expect(isServiceAllowed(noRecord, ['openai'], ANTHROPIC)).toBe(false);
91
- expect(isServiceAllowed(noRecord, [], ANTHROPIC)).toBe(true);
92
- });
154
+ const open = buildAccessScope(group());
93
155
 
94
- it('denies everything when the record is disabled', () => {
95
- const scope = buildAccessScope(row({ enabled: false, allowedLlmServices: ['openai'] }));
96
- expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(false);
97
- });
98
-
99
- it('denies everything when the service list is empty', () => {
100
- const scope = buildAccessScope(row({ allowedLlmServices: [] }));
101
- expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(false);
102
- expect(isServiceAllowed(scope, [], OPENAI)).toBe(false);
156
+ it('an open group inherits the global whitelist', () => {
157
+ expect(isServiceAllowed(open, ['openai'], OPENAI)).toBe(true);
158
+ expect(isServiceAllowed(open, ['openai'], ANTHROPIC)).toBe(false);
159
+ expect(isServiceAllowed(open, [], ANTHROPIC)).toBe(true);
103
160
  });
104
161
 
105
162
  it('never widens the global whitelist (strict subset)', () => {
106
- const scope = buildAccessScope(row({ allowedLlmServices: ['openai', 'anthropic'] }));
163
+ const scope = buildAccessScope(group({ allowedLlmServices: ['openai', 'anthropic'] }));
107
164
  expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(true);
108
- // Granted to the user, but absent from the global whitelist → still denied.
165
+ // Granted to the group, but absent from the global whitelist → still denied.
109
166
  expect(isServiceAllowed(scope, ['openai'], ANTHROPIC)).toBe(false);
110
167
  });
111
168
 
112
- it('matches a service by title as well as by name', () => {
113
- const byTitle = buildAccessScope(row({ allowedLlmServices: ['OpenAI'] }));
114
- expect(isServiceAllowed(byTitle, ['OpenAI'], OPENAI)).toBe(true);
115
- expect(isServiceAllowed(byTitle, [], OPENAI)).toBe(true);
116
- expect(isServiceAllowed(byTitle, [], ANTHROPIC)).toBe(false);
169
+ it('narrows within an empty global whitelist', () => {
170
+ const scope = buildAccessScope(group({ allowedLlmServices: ['openai'] }));
171
+ expect(isServiceAllowed(scope, [], OPENAI)).toBe(true);
172
+ expect(isServiceAllowed(scope, [], ANTHROPIC)).toBe(false);
117
173
  });
118
174
 
119
- it('narrows within an empty global whitelist', () => {
120
- const scope = buildAccessScope(row({ allowedLlmServices: ['openai'] }));
175
+ it('matches a service by title as well as by name', () => {
176
+ const scope = buildAccessScope(group({ allowedLlmServices: ['OpenAI'] }));
121
177
  expect(isServiceAllowed(scope, [], OPENAI)).toBe(true);
122
178
  expect(isServiceAllowed(scope, [], ANTHROPIC)).toBe(false);
123
179
  });
124
180
 
181
+ it('denies everything when the lookup failed', () => {
182
+ const failed = { ...open, lookupFailed: true };
183
+ expect(isServiceAllowed(failed, [], OPENAI)).toBe(false);
184
+ expect(isServiceAllowed(failed, ['openai'], OPENAI)).toBe(false);
185
+ });
186
+
125
187
  it('tolerates a null or malformed global whitelist', () => {
126
- expect(isServiceAllowed(noRecord, null, OPENAI)).toBe(true);
127
- expect(isServiceAllowed(noRecord, 'openai', OPENAI)).toBe(true);
188
+ expect(isServiceAllowed(open, null, OPENAI)).toBe(true);
189
+ expect(isServiceAllowed(open, 'openai', OPENAI)).toBe(true);
128
190
  });
129
191
  });
130
192
 
131
193
  describe('isModelAllowed', () => {
132
- it('allows every model when the user has no record', () => {
133
- expect(isModelAllowed(buildAccessScope(null), 'openai/gpt-4o')).toBe(true);
134
- });
135
-
136
- it('allows every model of the granted services when allowAllModels is true', () => {
137
- const scope = buildAccessScope(row({ allowedLlmServices: ['openai'], allowAllModels: true }));
138
- expect(isModelAllowed(scope, 'openai/anything')).toBe(true);
194
+ it('allows every model when the group allows all', () => {
195
+ expect(isModelAllowed(buildAccessScope(group()), 'openai/gpt-4o')).toBe(true);
139
196
  });
140
197
 
141
198
  it('restricts to the listed models when allowAllModels is false', () => {
142
- const scope = buildAccessScope(
143
- row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
144
- );
199
+ const scope = buildAccessScope(group({ allowAllModels: false, allowedModels: ['openai/gpt-4o'] }));
145
200
  expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(true);
146
201
  expect(isModelAllowed(scope, 'openai/gpt-4o-mini')).toBe(false);
147
202
  });
148
203
 
149
- it('denies every model when the record is disabled', () => {
150
- const scope = buildAccessScope(row({ enabled: false, allowAllModels: true }));
151
- expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(false);
204
+ it('denies every model when the lookup failed', () => {
205
+ const failed = { ...buildAccessScope(group()), lookupFailed: true };
206
+ expect(isModelAllowed(failed, 'openai/gpt-4o')).toBe(false);
152
207
  });
153
208
  });
154
209
 
155
210
  describe('resolveUserAccessScope', () => {
156
- it('returns the no-record scope for an unauthenticated context', async () => {
157
- const { ctx, findOne } = mockContext({ userId: null });
158
- expect((await resolveUserAccessScope(ctx)).hasUserRecord).toBe(false);
159
- expect(findOne).not.toHaveBeenCalled();
211
+ it('resolves the default group for an unauthenticated caller without a membership lookup', async () => {
212
+ const { ctx, memberFindOne, groupFindOne } = mockContext({ userId: null });
213
+ const scope = await resolveUserAccessScope(ctx);
214
+ expect(scope.allowedServices).toEqual([]);
215
+ expect(scope.allowAllModels).toBe(true);
216
+ expect(memberFindOne).not.toHaveBeenCalled();
217
+ expect(groupFindOne).toHaveBeenCalledTimes(1);
160
218
  });
161
219
 
162
- it('caches the scope per user instead of querying on every request', async () => {
163
- const { ctx, findOne } = mockContext({ row: row({ allowedLlmServices: ['openai'] }) });
164
- await resolveUserAccessScope(ctx);
165
- await resolveUserAccessScope(ctx);
166
- expect(findOne).toHaveBeenCalledTimes(1);
220
+ it('lazily creates the default group when it is missing', async () => {
221
+ const { ctx, groupCreate } = mockContext({ userId: null, defaultGroup: null });
222
+ const scope = await resolveUserAccessScope(ctx);
223
+ expect(groupCreate).toHaveBeenCalledTimes(1);
224
+ expect(scope.allowAllModels).toBe(true);
167
225
  });
168
226
 
169
- it('re-queries after the cache is invalidated for that user', async () => {
170
- const { ctx, findOne } = mockContext({ userId: 7, row: row({ allowedLlmServices: ['openai'] }) });
171
- await resolveUserAccessScope(ctx);
172
- invalidateUserPermissionCache(7);
173
- await resolveUserAccessScope(ctx);
174
- expect(findOne).toHaveBeenCalledTimes(2);
227
+ it('narrows the scope by the membership group', async () => {
228
+ const { ctx } = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
229
+ const scope = await resolveUserAccessScope(ctx);
230
+ expect(scope.groupId).toBe(5);
231
+ expect(scope.allowedServices).toEqual(['openai']);
175
232
  });
176
233
 
177
- it('keeps other users cached when one user is invalidated', async () => {
178
- const first = mockContext({ userId: 1, row: row({ allowedLlmServices: ['openai'] }) });
179
- const second = mockContext({ userId: 2, row: row({ allowedLlmServices: ['openai'] }) });
180
- await resolveUserAccessScope(first.ctx);
181
- await resolveUserAccessScope(second.ctx);
182
- invalidateUserPermissionCache(2);
183
- await resolveUserAccessScope(first.ctx);
184
- expect(first.findOne).toHaveBeenCalledTimes(1);
234
+ it('falls back to the default group when the membership points at a missing group', async () => {
235
+ const { ctx, groupFindOne } = mockContext({ memberWithoutGroup: true });
236
+ const scope = await resolveUserAccessScope(ctx);
237
+ expect(scope.allowedServices).toEqual([]);
238
+ expect(groupFindOne).toHaveBeenCalledTimes(1);
185
239
  });
186
240
 
187
- it('fails closed when the collection is unavailable', async () => {
188
- const { ctx } = mockContext({ throws: true });
189
- const scope = await resolveUserAccessScope(ctx);
190
- // Treating a failed lookup as "no record" would silently lift every user's restrictions
191
- // during a rolling upgrade where the table does not exist yet.
192
- expect(scope.lookupFailed).toBe(true);
193
- expect(scope.denyAll).toBe(true);
194
- expect(ctx.log.error).toHaveBeenCalled();
241
+ it('keeps serving the cached scope when the group row changes within the TTL', async () => {
242
+ const first = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
243
+ expect((await resolveUserAccessScope(first.ctx)).allowedServices).toEqual(['openai']);
244
+
245
+ // The next request resolves membership live, but the scope for group 5 is still cached.
246
+ const second = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['anthropic'] })) });
247
+ expect((await resolveUserAccessScope(second.ctx)).allowedServices).toEqual(['openai']);
248
+ expect(second.memberFindOne).toHaveBeenCalledTimes(1);
249
+
250
+ invalidateGroupAccessCache(5);
251
+ const third = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['anthropic'] })) });
252
+ expect((await resolveUserAccessScope(third.ctx)).allowedServices).toEqual(['anthropic']);
253
+ });
254
+
255
+ it('needs no invalidation when a user moves to another group', async () => {
256
+ const first = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
257
+ expect((await resolveUserAccessScope(first.ctx)).allowedServices).toEqual(['openai']);
258
+
259
+ // The next request resolves the new membership live, so no cache invalidation is needed.
260
+ const second = mockContext({ memberGroup: row(group({ id: 6, allowedLlmServices: ['anthropic'] })) });
261
+ expect((await resolveUserAccessScope(second.ctx)).allowedServices).toEqual(['anthropic']);
262
+ });
263
+
264
+ it('shares one cache entry between members of the same group', async () => {
265
+ const first = mockContext({ userId: 1, memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
266
+ const second = mockContext({ userId: 2, memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
267
+ const a = await resolveUserAccessScope(first.ctx);
268
+ const b = await resolveUserAccessScope(second.ctx);
269
+ expect(b).toBe(a);
195
270
  });
196
271
 
197
- it('denies every service and model when the lookup failed', async () => {
272
+ it('fails closed when the membership lookup is unavailable', async () => {
198
273
  const { ctx } = mockContext({ throws: true });
199
274
  const scope = await resolveUserAccessScope(ctx);
275
+ // Treating a failed lookup as "open" would silently lift every user's restrictions
276
+ // during a rolling upgrade where the tables do not exist yet.
277
+ expect(scope.lookupFailed).toBe(true);
278
+ expect(ctx.log.error).toHaveBeenCalled();
200
279
  expect(isServiceAllowed(scope, [], OPENAI)).toBe(false);
201
280
  expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(false);
202
281
  });
203
282
 
204
283
  it('does not cache a failed lookup', async () => {
205
- const { ctx, findOne } = mockContext({ throws: true });
284
+ const { ctx, memberFindOne } = mockContext({ throws: true });
206
285
  await resolveUserAccessScope(ctx);
207
286
  await resolveUserAccessScope(ctx);
208
- expect(findOne).toHaveBeenCalledTimes(2);
209
- });
210
-
211
- it('does not share a cache entry between apps with the same user id', async () => {
212
- const main = mockContext({ userId: 1, appName: 'main', row: row({ allowedLlmServices: ['openai'] }) });
213
- const sub = mockContext({ userId: 1, appName: 'sub', row: row({ allowedLlmServices: ['anthropic'] }) });
214
- await resolveUserAccessScope(main.ctx);
215
- const subScope = await resolveUserAccessScope(sub.ctx);
216
- // Sub-apps share this process but have separate databases, so user 1 in "sub" is a
217
- // different person than user 1 in "main" and must not inherit their grant.
218
- expect(sub.findOne).toHaveBeenCalledTimes(1);
219
- expect(subScope.allowedServices).toEqual(['anthropic']);
220
- });
221
-
222
- it('invalidates a user across every app', async () => {
223
- const main = mockContext({ userId: 1, appName: 'main', row: row({ allowedLlmServices: ['openai'] }) });
224
- const sub = mockContext({ userId: 1, appName: 'sub', row: row({ allowedLlmServices: ['openai'] }) });
225
- await resolveUserAccessScope(main.ctx);
226
- await resolveUserAccessScope(sub.ctx);
227
- invalidateUserPermissionCache(1);
228
- await resolveUserAccessScope(main.ctx);
229
- await resolveUserAccessScope(sub.ctx);
230
- expect(main.findOne).toHaveBeenCalledTimes(2);
231
- expect(sub.findOne).toHaveBeenCalledTimes(2);
232
- });
233
-
234
- it('does not invalidate a user whose id is a suffix of another', async () => {
235
- const first = mockContext({ userId: 1, row: row({ allowedLlmServices: ['openai'] }) });
236
- const second = mockContext({ userId: 21, row: row({ allowedLlmServices: ['openai'] }) });
287
+ expect(memberFindOne).toHaveBeenCalledTimes(2);
288
+ });
289
+
290
+ it('does not share a cache entry between apps with the same group id', async () => {
291
+ const main = mockContext({ appName: 'main', memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
292
+ const sub = mockContext({ appName: 'sub', memberGroup: row(group({ id: 5, allowedLlmServices: ['anthropic'] })) });
293
+ expect((await resolveUserAccessScope(main.ctx)).allowedServices).toEqual(['openai']);
294
+ // Sub-apps share this process but have separate databases, so group 5 in "sub" is a
295
+ // different group than group 5 in "main" and must not inherit its cached scope.
296
+ expect((await resolveUserAccessScope(sub.ctx)).allowedServices).toEqual(['anthropic']);
297
+ expect(sub.memberFindOne).toHaveBeenCalledTimes(1);
298
+ });
299
+
300
+ it('invalidates a group across every app', async () => {
301
+ const main = mockContext({ appName: 'main', memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
302
+ const sub = mockContext({ appName: 'sub', memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
303
+ const mainBefore = await resolveUserAccessScope(main.ctx);
304
+ const subBefore = await resolveUserAccessScope(sub.ctx);
305
+
306
+ invalidateGroupAccessCache(5);
307
+
308
+ expect(await resolveUserAccessScope(main.ctx)).not.toBe(mainBefore);
309
+ expect(await resolveUserAccessScope(sub.ctx)).not.toBe(subBefore);
310
+ });
311
+
312
+ it('does not invalidate a group whose id is a suffix of another', async () => {
313
+ const first = mockContext({ userId: 1, memberGroup: row(group({ id: 1, allowedLlmServices: ['openai'] })) });
314
+ const second = mockContext({ userId: 2, memberGroup: row(group({ id: 21, allowedLlmServices: ['openai'] })) });
315
+ const secondBefore = await resolveUserAccessScope(second.ctx);
237
316
  await resolveUserAccessScope(first.ctx);
238
- await resolveUserAccessScope(second.ctx);
239
- invalidateUserPermissionCache(1);
240
- await resolveUserAccessScope(second.ctx);
241
- expect(second.findOne).toHaveBeenCalledTimes(1);
317
+
318
+ invalidateGroupAccessCache(1);
319
+
320
+ expect(await resolveUserAccessScope(second.ctx)).toBe(secondBefore);
242
321
  });
243
322
  });
244
323
 
245
324
  describe('enforceModelAccess', () => {
246
325
  it('passes a permitted service and model through untouched', async () => {
247
- const { ctx } = mockContext({ row: row({ allowedLlmServices: ['openai'] }) });
326
+ const { ctx } = mockContext({});
248
327
  expect(await enforceModelAccess(ctx, ['openai'], OPENAI, 'gpt-4o')).toBe(true);
249
328
  expect(ctx.status).toBe(200);
250
329
  });
251
330
 
252
331
  it('returns 403 model_not_available for a denied service', async () => {
253
- const { ctx } = mockContext({ row: row({ allowedLlmServices: ['openai'] }) });
332
+ const { ctx } = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['openai'] })) });
254
333
  expect(await enforceModelAccess(ctx, ['openai', 'anthropic'], ANTHROPIC, 'claude')).toBe(false);
255
334
  expect(ctx.status).toBe(403);
256
335
  // `permission_denied` is what every other 403 in this plugin reports; keeping the type
@@ -260,15 +339,17 @@ describe('enforceModelAccess', () => {
260
339
 
261
340
  it('returns 403 model_not_available for a denied model of a granted service', async () => {
262
341
  const { ctx } = mockContext({
263
- row: row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
342
+ memberGroup: row(
343
+ group({ id: 5, allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
344
+ ),
264
345
  });
265
346
  expect(await enforceModelAccess(ctx, ['openai'], OPENAI, 'gpt-4o-mini')).toBe(false);
266
347
  expect(ctx.status).toBe(403);
267
348
  expect(ctx.body).toMatchObject({ error: { code: 'model_not_available', type: 'permission_denied' } });
268
349
  });
269
350
 
270
- it('denies a user-granted service that the global whitelist excludes', async () => {
271
- const { ctx } = mockContext({ row: row({ allowedLlmServices: ['anthropic'] }) });
351
+ it('denies a group-granted service that the global whitelist excludes', async () => {
352
+ const { ctx } = mockContext({ memberGroup: row(group({ id: 5, allowedLlmServices: ['anthropic'] })) });
272
353
  expect(await enforceModelAccess(ctx, ['openai'], ANTHROPIC, 'claude')).toBe(false);
273
354
  expect(ctx.status).toBe(403);
274
355
  });
@@ -16,6 +16,7 @@ function quotaPolicy(values: Record<string, unknown>): Model {
16
16
  const requiredPolicy = {
17
17
  periodType: 'monthly',
18
18
  missingUsageBehavior: 'use_reserved',
19
+ quotaMode: 'per_user',
19
20
  timezone: 'UTC',
20
21
  };
21
22
 
@@ -33,4 +34,14 @@ describe('AI API quota policy validation', () => {
33
34
  'contextOverflowBehavior must be reject or truncate.',
34
35
  );
35
36
  });
37
+
38
+ it.each(['share', 'per_user'] as const)('accepts %s quota mode', (quotaMode) => {
39
+ expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, quotaMode }))).not.toThrow();
40
+ });
41
+
42
+ it('rejects an unsupported quota mode', () => {
43
+ expect(() => validateQuotaPolicy(quotaPolicy({ ...requiredPolicy, quotaMode: 'global' }))).toThrow(
44
+ 'quotaMode must be share or per_user.',
45
+ );
46
+ });
36
47
  });