@apart-tech/intelligence-core 1.11.4 → 1.11.5

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 (85) hide show
  1. package/dist/auth/ability.d.ts +148 -0
  2. package/dist/auth/ability.d.ts.map +1 -0
  3. package/dist/auth/ability.js +285 -0
  4. package/dist/auth/ability.js.map +1 -0
  5. package/dist/auth/ability.test.d.ts +2 -0
  6. package/dist/auth/ability.test.d.ts.map +1 -0
  7. package/dist/auth/ability.test.js +680 -0
  8. package/dist/auth/ability.test.js.map +1 -0
  9. package/dist/auth/delegation-jwt.d.ts +167 -0
  10. package/dist/auth/delegation-jwt.d.ts.map +1 -0
  11. package/dist/auth/delegation-jwt.js +237 -0
  12. package/dist/auth/delegation-jwt.js.map +1 -0
  13. package/dist/auth/delegation-jwt.test.d.ts +2 -0
  14. package/dist/auth/delegation-jwt.test.d.ts.map +1 -0
  15. package/dist/auth/delegation-jwt.test.js +283 -0
  16. package/dist/auth/delegation-jwt.test.js.map +1 -0
  17. package/dist/auth/principal.d.ts +94 -0
  18. package/dist/auth/principal.d.ts.map +1 -0
  19. package/dist/auth/principal.js +33 -0
  20. package/dist/auth/principal.js.map +1 -0
  21. package/dist/config/config.test.d.ts +2 -0
  22. package/dist/config/config.test.d.ts.map +1 -0
  23. package/dist/config/config.test.js +57 -0
  24. package/dist/config/config.test.js.map +1 -0
  25. package/dist/config/index.d.ts.map +1 -1
  26. package/dist/config/index.js +17 -0
  27. package/dist/config/index.js.map +1 -1
  28. package/dist/index.d.ts +13 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +6 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/lib/__tests__/jwt.test.d.ts +2 -0
  33. package/dist/lib/__tests__/jwt.test.d.ts.map +1 -0
  34. package/dist/lib/__tests__/jwt.test.js +97 -0
  35. package/dist/lib/__tests__/jwt.test.js.map +1 -0
  36. package/dist/lib/jwt.d.ts +20 -0
  37. package/dist/lib/jwt.d.ts.map +1 -1
  38. package/dist/lib/jwt.js +56 -3
  39. package/dist/lib/jwt.js.map +1 -1
  40. package/dist/services/__tests__/delegation-cleanup-service.test.d.ts +2 -0
  41. package/dist/services/__tests__/delegation-cleanup-service.test.d.ts.map +1 -0
  42. package/dist/services/__tests__/delegation-cleanup-service.test.js +211 -0
  43. package/dist/services/__tests__/delegation-cleanup-service.test.js.map +1 -0
  44. package/dist/services/agent-run-service.d.ts +44 -7
  45. package/dist/services/agent-run-service.d.ts.map +1 -1
  46. package/dist/services/agent-run-service.js +14 -0
  47. package/dist/services/agent-run-service.js.map +1 -1
  48. package/dist/services/agent-schedule-service.d.ts +21 -0
  49. package/dist/services/agent-schedule-service.d.ts.map +1 -1
  50. package/dist/services/agent-schedule-service.js +12 -0
  51. package/dist/services/agent-schedule-service.js.map +1 -1
  52. package/dist/services/audit-event-service.d.ts +76 -0
  53. package/dist/services/audit-event-service.d.ts.map +1 -0
  54. package/dist/services/audit-event-service.js +48 -0
  55. package/dist/services/audit-event-service.js.map +1 -0
  56. package/dist/services/delegation-cleanup-service.d.ts +133 -0
  57. package/dist/services/delegation-cleanup-service.d.ts.map +1 -0
  58. package/dist/services/delegation-cleanup-service.js +111 -0
  59. package/dist/services/delegation-cleanup-service.js.map +1 -0
  60. package/dist/services/edge-service.d.ts.map +1 -1
  61. package/dist/services/edge-service.js +3 -0
  62. package/dist/services/edge-service.js.map +1 -1
  63. package/dist/services/org-agent-type-service.d.ts +15 -0
  64. package/dist/services/org-agent-type-service.d.ts.map +1 -1
  65. package/dist/services/org-agent-type-service.js +2 -0
  66. package/dist/services/org-agent-type-service.js.map +1 -1
  67. package/dist/services/usage-service.d.ts +48 -0
  68. package/dist/services/usage-service.d.ts.map +1 -0
  69. package/dist/services/usage-service.js +116 -0
  70. package/dist/services/usage-service.js.map +1 -0
  71. package/dist/services/user-service.d.ts.map +1 -1
  72. package/dist/services/user-service.js +24 -6
  73. package/dist/services/user-service.js.map +1 -1
  74. package/dist/services/user-service.test.d.ts +2 -0
  75. package/dist/services/user-service.test.d.ts.map +1 -0
  76. package/dist/services/user-service.test.js +86 -0
  77. package/dist/services/user-service.test.js.map +1 -0
  78. package/dist/types/index.d.ts +13 -0
  79. package/dist/types/index.d.ts.map +1 -1
  80. package/package.json +3 -2
  81. package/prisma/schema.prisma +158 -82
  82. package/dist/db/schema.d.ts +0 -507
  83. package/dist/db/schema.d.ts.map +0 -1
  84. package/dist/db/schema.js +0 -77
  85. package/dist/db/schema.js.map +0 -1
@@ -0,0 +1,680 @@
1
+ import { createMongoAbility } from "@casl/ability";
2
+ import { describe, expect, it } from "vitest";
3
+ import { buildAbility, intersect, UnsupportedIntersectionError, } from "./ability.js";
4
+ // ── UserPrincipal ──────────────────────────────────────────────────────────
5
+ describe("buildAbility(UserPrincipal) — owner", () => {
6
+ const owner = {
7
+ type: "user",
8
+ id: "u-1",
9
+ email: "owner@example.com",
10
+ organizationId: "org-1",
11
+ role: "owner",
12
+ };
13
+ const ability = buildAbility(owner);
14
+ it("can manage everything via the wildcard", () => {
15
+ expect(ability.can("manage", "all")).toBe(true);
16
+ expect(ability.can("manage", "Organization")).toBe(true);
17
+ expect(ability.can("manage", "Membership")).toBe(true);
18
+ expect(ability.can("manage", "Invite")).toBe(true);
19
+ expect(ability.can("manage", "User")).toBe(true);
20
+ expect(ability.can("manage", "OrgConfig")).toBe(true);
21
+ expect(ability.can("manage", "AgentSchedule")).toBe(true);
22
+ expect(ability.can("manage", "Pii")).toBe(true);
23
+ });
24
+ it("can perform every specific action on every subject", () => {
25
+ for (const action of [
26
+ "create",
27
+ "read",
28
+ "update",
29
+ "delete",
30
+ "bypass",
31
+ ]) {
32
+ for (const subject of [
33
+ "Organization",
34
+ "Membership",
35
+ "Invite",
36
+ "User",
37
+ "OrgConfig",
38
+ "AgentSchedule",
39
+ "Pii",
40
+ "Node",
41
+ "Search",
42
+ "Import",
43
+ "Domain",
44
+ "Workspace",
45
+ "AgentRun",
46
+ "OrgAgentConfig",
47
+ "OrgEmbedding",
48
+ "UserSecret",
49
+ ]) {
50
+ expect(ability.can(action, subject)).toBe(true);
51
+ }
52
+ }
53
+ });
54
+ it("can bypass Pii via the manage wildcard (Phase 1e M3)", () => {
55
+ // Owners inherit `bypass` on every subject from `manage all`. This is
56
+ // the design call recorded in decision 500bfa31 — the bypass action is
57
+ // intentionally granted implicitly by `manage` so that owners and
58
+ // legacy api-keys keep their pre-Phase-1e behavior with no code change.
59
+ expect(ability.can("bypass", "Pii")).toBe(true);
60
+ });
61
+ });
62
+ describe("buildAbility(UserPrincipal) — admin", () => {
63
+ const admin = {
64
+ type: "user",
65
+ id: "u-2",
66
+ email: "admin@example.com",
67
+ organizationId: "org-1",
68
+ role: "admin",
69
+ };
70
+ const ability = buildAbility(admin);
71
+ it("can manage invites", () => {
72
+ expect(ability.can("create", "Invite")).toBe(true);
73
+ expect(ability.can("read", "Invite")).toBe(true);
74
+ expect(ability.can("update", "Invite")).toBe(true);
75
+ expect(ability.can("delete", "Invite")).toBe(true);
76
+ });
77
+ it("can manage AgentSchedule (Phase 1e H7)", () => {
78
+ // Admins are responsible for the operational side of scheduled
79
+ // delegated agents — creating, updating, and deleting schedules. The
80
+ // trigger endpoint stays api-key-authed at the network layer; this
81
+ // rule gates the CRUD routes.
82
+ expect(ability.can("create", "AgentSchedule")).toBe(true);
83
+ expect(ability.can("read", "AgentSchedule")).toBe(true);
84
+ expect(ability.can("update", "AgentSchedule")).toBe(true);
85
+ expect(ability.can("delete", "AgentSchedule")).toBe(true);
86
+ });
87
+ it("can read Organization, Membership, OrgConfig, and Pii", () => {
88
+ expect(ability.can("read", "Organization")).toBe(true);
89
+ expect(ability.can("read", "Membership")).toBe(true);
90
+ expect(ability.can("read", "OrgConfig")).toBe(true);
91
+ expect(ability.can("read", "Pii")).toBe(true);
92
+ });
93
+ it("cannot write Organization or Membership", () => {
94
+ expect(ability.can("update", "Organization")).toBe(false);
95
+ expect(ability.can("delete", "Organization")).toBe(false);
96
+ expect(ability.can("create", "Membership")).toBe(false);
97
+ expect(ability.can("update", "Membership")).toBe(false);
98
+ expect(ability.can("delete", "Membership")).toBe(false);
99
+ });
100
+ it("cannot write OrgConfig or Pii (Phase 1e H6/M3)", () => {
101
+ // Admins see the config surfaces but cannot change them — org-wide
102
+ // search/context config and PII policy are owner-only governance
103
+ // settings.
104
+ expect(ability.can("create", "OrgConfig")).toBe(false);
105
+ expect(ability.can("update", "OrgConfig")).toBe(false);
106
+ expect(ability.can("delete", "OrgConfig")).toBe(false);
107
+ expect(ability.can("create", "Pii")).toBe(false);
108
+ expect(ability.can("update", "Pii")).toBe(false);
109
+ expect(ability.can("delete", "Pii")).toBe(false);
110
+ });
111
+ it("cannot bypass Pii scrubbing (Phase 1e M3)", () => {
112
+ // The bypass action is reserved for manage-grade principals. Admins
113
+ // cannot bypass PII even though they can read the PII config.
114
+ expect(ability.can("bypass", "Pii")).toBe(false);
115
+ });
116
+ it("cannot bypass the wildcard", () => {
117
+ expect(ability.can("manage", "all")).toBe(false);
118
+ });
119
+ // Phase 1b: admin can manage graph, import, org config subjects
120
+ it("can manage Node, Import, Domain, Workspace, AgentRun, OrgAgentConfig, OrgEmbedding (Phase 1b)", () => {
121
+ for (const subject of [
122
+ "Node",
123
+ "Import",
124
+ "Domain",
125
+ "Workspace",
126
+ "AgentRun",
127
+ "OrgAgentConfig",
128
+ "OrgEmbedding",
129
+ "UserSecret",
130
+ ]) {
131
+ expect(ability.can("create", subject)).toBe(true);
132
+ expect(ability.can("read", subject)).toBe(true);
133
+ expect(ability.can("update", subject)).toBe(true);
134
+ expect(ability.can("delete", subject)).toBe(true);
135
+ }
136
+ });
137
+ it("can read Search (Phase 1b)", () => {
138
+ expect(ability.can("read", "Search")).toBe(true);
139
+ expect(ability.can("create", "Search")).toBe(false);
140
+ expect(ability.can("update", "Search")).toBe(false);
141
+ expect(ability.can("delete", "Search")).toBe(false);
142
+ });
143
+ });
144
+ describe("buildAbility(UserPrincipal) — member", () => {
145
+ const member = {
146
+ type: "user",
147
+ id: "u-3",
148
+ email: "member@example.com",
149
+ organizationId: "org-1",
150
+ role: "member",
151
+ };
152
+ const ability = buildAbility(member);
153
+ it("can read Organization, Membership, OrgConfig, AgentSchedule, and Pii", () => {
154
+ expect(ability.can("read", "Organization")).toBe(true);
155
+ expect(ability.can("read", "Membership")).toBe(true);
156
+ expect(ability.can("read", "OrgConfig")).toBe(true);
157
+ expect(ability.can("read", "AgentSchedule")).toBe(true);
158
+ expect(ability.can("read", "Pii")).toBe(true);
159
+ });
160
+ it("cannot touch invites", () => {
161
+ expect(ability.can("read", "Invite")).toBe(false);
162
+ expect(ability.can("create", "Invite")).toBe(false);
163
+ expect(ability.can("update", "Invite")).toBe(false);
164
+ expect(ability.can("delete", "Invite")).toBe(false);
165
+ });
166
+ it("cannot create AgentSchedule in Phase 1e (no self-ID CASL conditions)", () => {
167
+ // Members cannot create schedules this phase — see the risk accepted
168
+ // in story 1a630ff2. CASL conditions for "own schedules" are not in
169
+ // the starter vocabulary, and member-created schedules would need
170
+ // handler-level self-ID enforcement that this phase does not bring in.
171
+ expect(ability.can("create", "AgentSchedule")).toBe(false);
172
+ expect(ability.can("update", "AgentSchedule")).toBe(false);
173
+ expect(ability.can("delete", "AgentSchedule")).toBe(false);
174
+ });
175
+ it("cannot write OrgConfig or Pii, cannot bypass Pii (Phase 1e H6/M3)", () => {
176
+ expect(ability.can("update", "OrgConfig")).toBe(false);
177
+ expect(ability.can("delete", "OrgConfig")).toBe(false);
178
+ expect(ability.can("update", "Pii")).toBe(false);
179
+ expect(ability.can("delete", "Pii")).toBe(false);
180
+ expect(ability.can("bypass", "Pii")).toBe(false);
181
+ });
182
+ it("cannot write anything on Phase 1e subjects", () => {
183
+ expect(ability.can("update", "Organization")).toBe(false);
184
+ expect(ability.can("create", "Membership")).toBe(false);
185
+ expect(ability.can("delete", "Membership")).toBe(false);
186
+ });
187
+ // Phase 1b: member can manage Node and Workspace (core graph work)
188
+ it("can manage Node and Workspace (Phase 1b — graph CRUD is the core work)", () => {
189
+ for (const subject of ["Node", "Workspace"]) {
190
+ expect(ability.can("create", subject)).toBe(true);
191
+ expect(ability.can("read", subject)).toBe(true);
192
+ expect(ability.can("update", subject)).toBe(true);
193
+ expect(ability.can("delete", subject)).toBe(true);
194
+ }
195
+ });
196
+ it("can manage UserSecret (Phase 1b — self-service tokens)", () => {
197
+ expect(ability.can("create", "UserSecret")).toBe(true);
198
+ expect(ability.can("read", "UserSecret")).toBe(true);
199
+ expect(ability.can("update", "UserSecret")).toBe(true);
200
+ expect(ability.can("delete", "UserSecret")).toBe(true);
201
+ });
202
+ it("can create and read AgentRun (Phase 1b)", () => {
203
+ expect(ability.can("create", "AgentRun")).toBe(true);
204
+ expect(ability.can("read", "AgentRun")).toBe(true);
205
+ expect(ability.can("update", "AgentRun")).toBe(false);
206
+ expect(ability.can("delete", "AgentRun")).toBe(false);
207
+ });
208
+ it("can read but not write Import, Domain, OrgAgentConfig, OrgEmbedding, Search (Phase 1b)", () => {
209
+ for (const subject of [
210
+ "Import",
211
+ "Domain",
212
+ "OrgAgentConfig",
213
+ "OrgEmbedding",
214
+ "Search",
215
+ ]) {
216
+ expect(ability.can("read", subject)).toBe(true);
217
+ expect(ability.can("create", subject)).toBe(false);
218
+ expect(ability.can("update", subject)).toBe(false);
219
+ expect(ability.can("delete", subject)).toBe(false);
220
+ }
221
+ });
222
+ });
223
+ describe("buildAbility(UserPrincipal) — none (no active org)", () => {
224
+ const unscoped = {
225
+ type: "user",
226
+ id: "u-4",
227
+ email: "new@example.com",
228
+ organizationId: null,
229
+ role: "none",
230
+ };
231
+ const ability = buildAbility(unscoped);
232
+ it("can read User (handler must verify self-ID)", () => {
233
+ expect(ability.can("read", "User")).toBe(true);
234
+ });
235
+ it("can create Membership for the accept-invite flow", () => {
236
+ expect(ability.can("create", "Membership")).toBe(true);
237
+ });
238
+ it("cannot read Organization or existing Memberships", () => {
239
+ expect(ability.can("read", "Organization")).toBe(false);
240
+ expect(ability.can("read", "Membership")).toBe(false);
241
+ });
242
+ it("cannot touch invites as a subject (they're accepted via a separate token flow, not CASL-gated)", () => {
243
+ expect(ability.can("manage", "Invite")).toBe(false);
244
+ });
245
+ it("can manage UserSecret even without an active org (Phase 1b)", () => {
246
+ expect(ability.can("create", "UserSecret")).toBe(true);
247
+ expect(ability.can("read", "UserSecret")).toBe(true);
248
+ expect(ability.can("update", "UserSecret")).toBe(true);
249
+ expect(ability.can("delete", "UserSecret")).toBe(true);
250
+ });
251
+ it("cannot touch Phase 1b org-scoped subjects without an active org", () => {
252
+ expect(ability.can("read", "Node")).toBe(false);
253
+ expect(ability.can("read", "Search")).toBe(false);
254
+ expect(ability.can("read", "Import")).toBe(false);
255
+ expect(ability.can("read", "Domain")).toBe(false);
256
+ expect(ability.can("read", "Workspace")).toBe(false);
257
+ expect(ability.can("read", "AgentRun")).toBe(false);
258
+ expect(ability.can("read", "OrgAgentConfig")).toBe(false);
259
+ expect(ability.can("read", "OrgEmbedding")).toBe(false);
260
+ });
261
+ });
262
+ // ── OrgAgentPrincipal ──────────────────────────────────────────────────────
263
+ describe("buildAbility(OrgAgentPrincipal) — legacyApiKey (pre-Phase-1c)", () => {
264
+ const legacy = {
265
+ type: "org_agent",
266
+ id: "legacy-api-key:apikey-1",
267
+ organizationId: "org-1",
268
+ name: "legacy api key",
269
+ legacyApiKey: true,
270
+ };
271
+ const ability = buildAbility(legacy);
272
+ it("grants full org access matching current API-key behavior", () => {
273
+ expect(ability.can("manage", "all")).toBe(true);
274
+ expect(ability.can("read", "Organization")).toBe(true);
275
+ expect(ability.can("update", "Membership")).toBe(true);
276
+ expect(ability.can("delete", "Invite")).toBe(true);
277
+ });
278
+ // ── H4 interim-closure regression (Phase 1e) ────────────────────────────
279
+ //
280
+ // H4 ("API keys bound to agents with explicit permissions") is
281
+ // structurally blocked on the Phase 1c backfill of api_keys ->
282
+ // org_agent_types. Until that lands, the interim closure is that
283
+ // legacy api-keys synthesize an `OrgAgentPrincipal` with
284
+ // `legacyApiKey: true`, which grants `manage all`. The tests below
285
+ // lock in that interim behavior so that growing the CASL vocabulary
286
+ // (adding `OrgConfig`, `AgentSchedule`, `Pii`, and the new `bypass`
287
+ // action in Phase 1e) does NOT silently narrow what legacy api-keys
288
+ // can do. If any of these assertions ever flips to `false`, a
289
+ // production integration depending on the legacy api-key path has
290
+ // just lost access, and either the vocabulary change or the
291
+ // legacyApiKey branch in `buildAbility` needs to compensate.
292
+ it("H4 regression: legacy api-key can manage Phase 1e + 1b subjects", () => {
293
+ expect(ability.can("manage", "OrgConfig")).toBe(true);
294
+ expect(ability.can("manage", "AgentSchedule")).toBe(true);
295
+ expect(ability.can("manage", "Pii")).toBe(true);
296
+ // Phase 1b subjects — all must be accessible to legacy api-keys
297
+ expect(ability.can("manage", "Node")).toBe(true);
298
+ expect(ability.can("manage", "Search")).toBe(true);
299
+ expect(ability.can("manage", "Import")).toBe(true);
300
+ expect(ability.can("manage", "Domain")).toBe(true);
301
+ expect(ability.can("manage", "Workspace")).toBe(true);
302
+ expect(ability.can("manage", "AgentRun")).toBe(true);
303
+ expect(ability.can("manage", "OrgAgentConfig")).toBe(true);
304
+ expect(ability.can("manage", "OrgEmbedding")).toBe(true);
305
+ expect(ability.can("manage", "UserSecret")).toBe(true);
306
+ });
307
+ it("H4 regression: legacy api-key can bypass Pii via manage wildcard (M3 interim)", () => {
308
+ expect(ability.can("bypass", "Pii")).toBe(true);
309
+ expect(ability.can("bypass", "OrgConfig")).toBe(true);
310
+ expect(ability.can("bypass", "AgentSchedule")).toBe(true);
311
+ });
312
+ it("H4 regression: legacy api-key can do every concrete (action, subject) pair", () => {
313
+ for (const action of [
314
+ "create",
315
+ "read",
316
+ "update",
317
+ "delete",
318
+ "bypass",
319
+ ]) {
320
+ for (const subject of [
321
+ "Organization",
322
+ "Membership",
323
+ "Invite",
324
+ "User",
325
+ "OrgConfig",
326
+ "AgentSchedule",
327
+ "Pii",
328
+ "Node",
329
+ "Search",
330
+ "Import",
331
+ "Domain",
332
+ "Workspace",
333
+ "AgentRun",
334
+ "OrgAgentConfig",
335
+ "OrgEmbedding",
336
+ "UserSecret",
337
+ ]) {
338
+ expect(ability.can(action, subject)).toBe(true);
339
+ }
340
+ }
341
+ });
342
+ });
343
+ describe("buildAbility(OrgAgentPrincipal) — non-legacy (Phase 1c placeholder)", () => {
344
+ const modern = {
345
+ type: "org_agent",
346
+ id: "orgagent-1",
347
+ organizationId: "org-1",
348
+ name: "a real bound agent (Phase 1c)",
349
+ legacyApiKey: false,
350
+ };
351
+ const ability = buildAbility(modern);
352
+ it("denies by default — no rules until Phase 1c wires intrinsicPolicy", () => {
353
+ expect(ability.can("manage", "all")).toBe(false);
354
+ expect(ability.can("read", "Organization")).toBe(false);
355
+ expect(ability.can("read", "Membership")).toBe(false);
356
+ });
357
+ });
358
+ // ── DelegatedAgentPrincipal ────────────────────────────────────────────────
359
+ describe("buildAbility(DelegatedAgentPrincipal) — rehydrated from capturedAbility", () => {
360
+ it("applies the captured rules exactly", () => {
361
+ // Fake captured snapshot: the agent can only read Organization and
362
+ // Membership — nothing else. Simulates a spawn-time intersection
363
+ // between a member user and a read-only agent policy.
364
+ const captured = [
365
+ { action: "read", subject: "Organization" },
366
+ { action: "read", subject: "Membership" },
367
+ ];
368
+ const delegated = {
369
+ type: "delegated_agent",
370
+ agentRunId: "run-1",
371
+ behalfOfUserId: "u-3",
372
+ organizationId: "org-1",
373
+ capturedAbility: captured,
374
+ };
375
+ const ability = buildAbility(delegated);
376
+ expect(ability.can("read", "Organization")).toBe(true);
377
+ expect(ability.can("read", "Membership")).toBe(true);
378
+ expect(ability.can("update", "Organization")).toBe(false);
379
+ expect(ability.can("create", "Invite")).toBe(false);
380
+ expect(ability.can("manage", "all")).toBe(false);
381
+ });
382
+ it("yields deny-by-default when capturedAbility is garbage", () => {
383
+ const delegated = {
384
+ type: "delegated_agent",
385
+ agentRunId: "run-2",
386
+ behalfOfUserId: "u-3",
387
+ organizationId: "org-1",
388
+ capturedAbility: "this is not an array",
389
+ };
390
+ const ability = buildAbility(delegated);
391
+ expect(ability.can("manage", "all")).toBe(false);
392
+ expect(ability.can("read", "Organization")).toBe(false);
393
+ expect(ability.can("read", "Membership")).toBe(false);
394
+ });
395
+ it("yields deny-by-default when capturedAbility is null", () => {
396
+ const delegated = {
397
+ type: "delegated_agent",
398
+ agentRunId: "run-3",
399
+ behalfOfUserId: "u-3",
400
+ organizationId: "org-1",
401
+ capturedAbility: null,
402
+ };
403
+ const ability = buildAbility(delegated);
404
+ expect(ability.can("manage", "all")).toBe(false);
405
+ });
406
+ it("yields deny-by-default when capturedAbility is an empty array", () => {
407
+ const delegated = {
408
+ type: "delegated_agent",
409
+ agentRunId: "run-4",
410
+ behalfOfUserId: "u-3",
411
+ organizationId: "org-1",
412
+ capturedAbility: [],
413
+ };
414
+ const ability = buildAbility(delegated);
415
+ expect(ability.can("read", "Organization")).toBe(false);
416
+ });
417
+ it("handles a wildcard captured rule", () => {
418
+ // An agent whose intrinsic policy was "manage all" and whose invoking
419
+ // user was an owner — the intersection is still "manage all".
420
+ const captured = [{ action: "manage", subject: "all" }];
421
+ const delegated = {
422
+ type: "delegated_agent",
423
+ agentRunId: "run-5",
424
+ behalfOfUserId: "u-1",
425
+ organizationId: "org-1",
426
+ capturedAbility: captured,
427
+ };
428
+ const ability = buildAbility(delegated);
429
+ expect(ability.can("manage", "all")).toBe(true);
430
+ expect(ability.can("delete", "Invite")).toBe(true);
431
+ });
432
+ });
433
+ // ── intersect() rule-set intersection (Phase 1d) ───────────────────────────
434
+ /**
435
+ * Helper for the intersect() tests: instead of asserting on the raw rule
436
+ * structure (which would couple the tests to the uncompressed
437
+ * representation chosen in Phase 1d), we rehydrate the intersection into
438
+ * a CASL ability and check behavior via `.can()`. This is robust to any
439
+ * future change in how the intersection is serialized — compression,
440
+ * different ordering, wildcard collapsing — as long as the authorization
441
+ * semantics stay the same.
442
+ */
443
+ function rehydrate(rules) {
444
+ return createMongoAbility(rules);
445
+ }
446
+ describe("intersect() — identity cases", () => {
447
+ it("empty ∩ empty → deny-by-default", () => {
448
+ const result = intersect([], []);
449
+ const ability = rehydrate(result);
450
+ expect(ability.can("read", "Organization")).toBe(false);
451
+ expect(ability.can("manage", "all")).toBe(false);
452
+ });
453
+ it("X ∩ empty → deny-by-default (the empty side wins)", () => {
454
+ const result = intersect([{ action: "manage", subject: "all" }], []);
455
+ const ability = rehydrate(result);
456
+ expect(ability.can("read", "Organization")).toBe(false);
457
+ expect(ability.can("manage", "all")).toBe(false);
458
+ });
459
+ it("empty ∩ X → deny-by-default (direction does not matter)", () => {
460
+ const result = intersect([], [{ action: "manage", subject: "all" }]);
461
+ const ability = rehydrate(result);
462
+ expect(ability.can("read", "Organization")).toBe(false);
463
+ });
464
+ });
465
+ describe("intersect() — full overlap", () => {
466
+ it("manage-all ∩ manage-all → functionally manage-all", () => {
467
+ const result = intersect([{ action: "manage", subject: "all" }], [{ action: "manage", subject: "all" }]);
468
+ const ability = rehydrate(result);
469
+ // Every concrete (action, subject) pair should be allowed. We test
470
+ // the functional behavior rather than the rule count — the Phase 1d
471
+ // implementation emits 16 concrete rules, but a future compression
472
+ // pass might emit just one `manage all`, and either is correct.
473
+ for (const action of ["create", "read", "update", "delete"]) {
474
+ for (const subject of [
475
+ "Organization",
476
+ "Membership",
477
+ "Invite",
478
+ "User",
479
+ ]) {
480
+ expect(ability.can(action, subject)).toBe(true);
481
+ }
482
+ }
483
+ });
484
+ it("identical non-wildcard inputs → the same rules (functionally)", () => {
485
+ const input = [
486
+ { action: "read", subject: "Organization" },
487
+ { action: "read", subject: "Membership" },
488
+ ];
489
+ const ability = rehydrate(intersect(input, input));
490
+ expect(ability.can("read", "Organization")).toBe(true);
491
+ expect(ability.can("read", "Membership")).toBe(true);
492
+ expect(ability.can("update", "Organization")).toBe(false);
493
+ expect(ability.can("manage", "all")).toBe(false);
494
+ });
495
+ });
496
+ describe("intersect() — wildcards restrict to the narrower side", () => {
497
+ it("manage-all ∩ [read Organization] → read Organization", () => {
498
+ const ability = rehydrate(intersect([{ action: "manage", subject: "all" }], [{ action: "read", subject: "Organization" }]));
499
+ expect(ability.can("read", "Organization")).toBe(true);
500
+ expect(ability.can("update", "Organization")).toBe(false);
501
+ expect(ability.can("read", "Membership")).toBe(false);
502
+ expect(ability.can("create", "Invite")).toBe(false);
503
+ });
504
+ it("[read Org, read Membership] ∩ manage-all → read Org, read Membership", () => {
505
+ const ability = rehydrate(intersect([
506
+ { action: "read", subject: "Organization" },
507
+ { action: "read", subject: "Membership" },
508
+ ], [{ action: "manage", subject: "all" }]));
509
+ expect(ability.can("read", "Organization")).toBe(true);
510
+ expect(ability.can("read", "Membership")).toBe(true);
511
+ expect(ability.can("read", "Invite")).toBe(false);
512
+ expect(ability.can("update", "Organization")).toBe(false);
513
+ });
514
+ it("manage-Organization ∩ read-everything-concrete → read Organization only", () => {
515
+ // "manage Invite" on the user side, "read all concrete subjects" on
516
+ // the agent side — only `read Invite` should survive (no other
517
+ // subject appears on both sides, and the actions intersect to
518
+ // {read} on Invite).
519
+ const ability = rehydrate(intersect([{ action: "manage", subject: "Invite" }], [
520
+ { action: "read", subject: "Organization" },
521
+ { action: "read", subject: "Membership" },
522
+ { action: "read", subject: "Invite" },
523
+ { action: "read", subject: "User" },
524
+ ]));
525
+ expect(ability.can("read", "Invite")).toBe(true);
526
+ expect(ability.can("update", "Invite")).toBe(false);
527
+ expect(ability.can("read", "Organization")).toBe(false);
528
+ });
529
+ });
530
+ describe("intersect() — disjoint inputs", () => {
531
+ it("[read Organization] ∩ [read Invite] → deny-by-default", () => {
532
+ const ability = rehydrate(intersect([{ action: "read", subject: "Organization" }], [{ action: "read", subject: "Invite" }]));
533
+ expect(ability.can("read", "Organization")).toBe(false);
534
+ expect(ability.can("read", "Invite")).toBe(false);
535
+ expect(ability.can("manage", "all")).toBe(false);
536
+ });
537
+ it("[read Organization] ∩ [update Organization] → deny-by-default (same subject, different actions)", () => {
538
+ const ability = rehydrate(intersect([{ action: "read", subject: "Organization" }], [{ action: "update", subject: "Organization" }]));
539
+ expect(ability.can("read", "Organization")).toBe(false);
540
+ expect(ability.can("update", "Organization")).toBe(false);
541
+ });
542
+ });
543
+ describe("intersect() — realistic spawn scenarios", () => {
544
+ it("admin user ∩ invite-bot agent → the admin's Invite management, nothing else", () => {
545
+ // Mirrors the actual rule set buildAbility() emits for an admin
546
+ // UserPrincipal, intersected with an agent whose intrinsic policy
547
+ // is "manage Invite".
548
+ const adminRules = [
549
+ { action: "manage", subject: "Invite" },
550
+ { action: "read", subject: "Organization" },
551
+ { action: "read", subject: "Membership" },
552
+ ];
553
+ const inviteBotPolicy = [
554
+ { action: "manage", subject: "Invite" },
555
+ ];
556
+ const ability = rehydrate(intersect(adminRules, inviteBotPolicy));
557
+ expect(ability.can("create", "Invite")).toBe(true);
558
+ expect(ability.can("read", "Invite")).toBe(true);
559
+ expect(ability.can("update", "Invite")).toBe(true);
560
+ expect(ability.can("delete", "Invite")).toBe(true);
561
+ // Read-only affordances from the admin side should NOT leak through:
562
+ // the bot's intrinsic policy does not include them.
563
+ expect(ability.can("read", "Organization")).toBe(false);
564
+ expect(ability.can("read", "Membership")).toBe(false);
565
+ });
566
+ it("member user ∩ manage-all agent → member's read affordances only", () => {
567
+ // A read-only user runs a powerful agent: the intersection is
568
+ // bounded by the user, not by the agent. This is the whole reason
569
+ // the intersection is computed in the first place.
570
+ const memberRules = [
571
+ { action: "read", subject: "Organization" },
572
+ { action: "read", subject: "Membership" },
573
+ ];
574
+ const powerfulAgentPolicy = [
575
+ { action: "manage", subject: "all" },
576
+ ];
577
+ const ability = rehydrate(intersect(memberRules, powerfulAgentPolicy));
578
+ expect(ability.can("read", "Organization")).toBe(true);
579
+ expect(ability.can("read", "Membership")).toBe(true);
580
+ expect(ability.can("create", "Invite")).toBe(false);
581
+ expect(ability.can("manage", "all")).toBe(false);
582
+ expect(ability.can("update", "Organization")).toBe(false);
583
+ });
584
+ });
585
+ describe("intersect() — Phase 1e bypass semantics", () => {
586
+ // These tests lock in the delegation behavior for the bypass action
587
+ // added in Phase 1e. The crucial property is that `bypass Pii` is
588
+ // captured at spawn time only if BOTH the invoking user AND the agent's
589
+ // intrinsic policy include it. That is the whole point of the
590
+ // intersect — a member user running a manage-all agent never gets
591
+ // bypass authority, even though the agent's policy has it.
592
+ it("owner ∩ manage-all agent → bypass Pii survives", () => {
593
+ // Mirrors an owner spawning an agent with `manage all` intrinsic
594
+ // policy. Both sides grant bypass via the wildcard, so the
595
+ // intersection includes it.
596
+ const ownerRules = [{ action: "manage", subject: "all" }];
597
+ const agentPolicy = [{ action: "manage", subject: "all" }];
598
+ const ability = rehydrate(intersect(ownerRules, agentPolicy));
599
+ expect(ability.can("bypass", "Pii")).toBe(true);
600
+ });
601
+ it("member ∩ manage-all agent → bypass Pii does NOT survive (the whole point)", () => {
602
+ // The member's captured ability does not include bypass on any
603
+ // subject. Running a powerful agent does not broaden that — the
604
+ // delegated agent inherits the floor, not the ceiling.
605
+ const memberRules = [
606
+ { action: "read", subject: "Organization" },
607
+ { action: "read", subject: "Membership" },
608
+ { action: "read", subject: "OrgConfig" },
609
+ { action: "read", subject: "AgentSchedule" },
610
+ { action: "read", subject: "Pii" },
611
+ ];
612
+ const agentPolicy = [{ action: "manage", subject: "all" }];
613
+ const ability = rehydrate(intersect(memberRules, agentPolicy));
614
+ expect(ability.can("bypass", "Pii")).toBe(false);
615
+ // And the member's read affordances still come through.
616
+ expect(ability.can("read", "OrgConfig")).toBe(true);
617
+ expect(ability.can("read", "Pii")).toBe(true);
618
+ });
619
+ it("owner ∩ read-only agent → bypass does NOT survive (agent side blocks it)", () => {
620
+ // An agent whose intrinsic policy is `read Pii` does not grant
621
+ // bypass, regardless of how powerful the invoking user is. The
622
+ // intersection respects the agent's narrower grant.
623
+ const ownerRules = [{ action: "manage", subject: "all" }];
624
+ const readOnlyAgent = [
625
+ { action: "read", subject: "Pii" },
626
+ ];
627
+ const ability = rehydrate(intersect(ownerRules, readOnlyAgent));
628
+ expect(ability.can("bypass", "Pii")).toBe(false);
629
+ expect(ability.can("read", "Pii")).toBe(true);
630
+ expect(ability.can("update", "Pii")).toBe(false);
631
+ });
632
+ });
633
+ describe("intersect() — unsupported rule shapes", () => {
634
+ it("rejects a conditional rule on the left side", () => {
635
+ const leftWithCondition = [
636
+ {
637
+ action: "read",
638
+ subject: "Organization",
639
+ conditions: { organizationId: "org-1" },
640
+ },
641
+ ];
642
+ expect(() => intersect(leftWithCondition, [])).toThrow(UnsupportedIntersectionError);
643
+ expect(() => intersect(leftWithCondition, [])).toThrow(/left\[0\]/);
644
+ });
645
+ it("rejects a conditional rule on the right side", () => {
646
+ const rightWithCondition = [
647
+ {
648
+ action: "read",
649
+ subject: "Organization",
650
+ conditions: { organizationId: "org-1" },
651
+ },
652
+ ];
653
+ expect(() => intersect([], rightWithCondition)).toThrow(UnsupportedIntersectionError);
654
+ expect(() => intersect([], rightWithCondition)).toThrow(/right\[0\]/);
655
+ });
656
+ it("rejects a field-scoped rule", () => {
657
+ const withFields = [
658
+ {
659
+ action: "read",
660
+ subject: "Organization",
661
+ fields: ["name"],
662
+ },
663
+ ];
664
+ expect(() => intersect(withFields, [])).toThrow(UnsupportedIntersectionError);
665
+ expect(() => intersect(withFields, [])).toThrow(/field scoping/);
666
+ });
667
+ it("identifies the offending index in a multi-rule input", () => {
668
+ const rules = [
669
+ { action: "read", subject: "Organization" },
670
+ { action: "read", subject: "Membership" },
671
+ {
672
+ action: "read",
673
+ subject: "Invite",
674
+ conditions: { organizationId: "org-1" },
675
+ },
676
+ ];
677
+ expect(() => intersect(rules, [])).toThrow(/left\[2\]/);
678
+ });
679
+ });
680
+ //# sourceMappingURL=ability.test.js.map