@apart-tech/intelligence-core 1.11.4 → 1.11.6

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