@classytic/arc 2.11.4 → 2.13.1

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 (166) hide show
  1. package/README.md +16 -12
  2. package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
  3. package/dist/EventTransport-CT_52aWU.d.mts +34 -0
  4. package/dist/EventTransport-DLWoUMHy.mjs +103 -0
  5. package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
  6. package/dist/audit/index.d.mts +2 -2
  7. package/dist/audit/index.mjs +1 -1
  8. package/dist/auth/audit.d.mts +199 -0
  9. package/dist/auth/audit.mjs +288 -0
  10. package/dist/auth/index.d.mts +3 -3
  11. package/dist/auth/index.mjs +117 -191
  12. package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
  13. package/dist/buildHandler-olo-gt94.mjs +610 -0
  14. package/dist/cache/index.mjs +3 -3
  15. package/dist/cli/commands/describe.d.mts +89 -13
  16. package/dist/cli/commands/describe.mjs +56 -2
  17. package/dist/cli/commands/docs.mjs +2 -2
  18. package/dist/cli/commands/generate.mjs +147 -48
  19. package/dist/cli/commands/init.d.mts +13 -0
  20. package/dist/cli/commands/init.mjs +130 -87
  21. package/dist/cli/commands/introspect.mjs +8 -1
  22. package/dist/context/index.mjs +1 -1
  23. package/dist/core/index.d.mts +3 -3
  24. package/dist/core/index.mjs +5 -5
  25. package/dist/core-D72ia0EH.mjs +1399 -0
  26. package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
  27. package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
  28. package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
  29. package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
  30. package/dist/docs/index.d.mts +1 -1
  31. package/dist/docs/index.mjs +2 -2
  32. package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
  33. package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
  34. package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
  35. package/dist/errors-j4aJm1Wg.mjs +184 -0
  36. package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
  37. package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
  38. package/dist/events/index.d.mts +6 -6
  39. package/dist/events/index.mjs +11 -35
  40. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  41. package/dist/events/transports/redis.d.mts +1 -1
  42. package/dist/factory/index.d.mts +2 -2
  43. package/dist/factory/index.mjs +2 -2
  44. package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
  45. package/dist/hooks/index.d.mts +1 -1
  46. package/dist/hooks/index.mjs +1 -1
  47. package/dist/idempotency/index.d.mts +1 -1
  48. package/dist/idempotency/index.mjs +1 -20
  49. package/dist/idempotency/redis.mjs +1 -1
  50. package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
  51. package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
  52. package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
  53. package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
  54. package/dist/index.d.mts +6 -7
  55. package/dist/index.mjs +9 -10
  56. package/dist/integrations/event-gateway.d.mts +1 -1
  57. package/dist/integrations/event-gateway.mjs +1 -1
  58. package/dist/integrations/index.d.mts +1 -1
  59. package/dist/integrations/mcp/index.d.mts +2 -2
  60. package/dist/integrations/mcp/index.mjs +1 -1
  61. package/dist/integrations/mcp/testing.d.mts +1 -1
  62. package/dist/integrations/mcp/testing.mjs +1 -1
  63. package/dist/integrations/streamline.d.mts +60 -11
  64. package/dist/integrations/streamline.mjs +75 -85
  65. package/dist/integrations/websocket.mjs +2 -8
  66. package/dist/middleware/index.d.mts +1 -1
  67. package/dist/middleware/index.mjs +2 -2
  68. package/dist/migrations/index.d.mts +23 -3
  69. package/dist/migrations/index.mjs +0 -7
  70. package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
  71. package/dist/{openapi-D7G1V7ex.mjs → openapi-CiOMVW1p.mjs} +143 -13
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/org/index.mjs +1 -1
  74. package/dist/permissions/index.d.mts +3 -3
  75. package/dist/permissions/index.mjs +3 -3
  76. package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
  77. package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
  78. package/dist/pipeline/index.d.mts +1 -1
  79. package/dist/pipeline/index.mjs +1 -1
  80. package/dist/plugins/index.d.mts +16 -31
  81. package/dist/plugins/index.mjs +33 -13
  82. package/dist/plugins/response-cache.mjs +1 -1
  83. package/dist/plugins/tracing-entry.mjs +1 -1
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +6 -9
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +1 -1
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +2 -2
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +6 -8
  92. package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
  93. package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
  94. package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
  95. package/dist/registry/index.d.mts +1 -1
  96. package/dist/registry/index.mjs +2 -2
  97. package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
  98. package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-C5coh64w.mjs} +224 -71
  99. package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
  100. package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
  101. package/dist/schemas/index.d.mts +100 -30
  102. package/dist/schemas/index.mjs +86 -29
  103. package/dist/scim/index.d.mts +264 -0
  104. package/dist/scim/index.mjs +963 -0
  105. package/dist/scope/index.d.mts +3 -3
  106. package/dist/scope/index.mjs +4 -4
  107. package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
  108. package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
  109. package/dist/testing/index.d.mts +2 -8
  110. package/dist/testing/index.mjs +16 -24
  111. package/dist/types/index.d.mts +4 -4
  112. package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
  113. package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
  114. package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
  115. package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -2
  117. package/dist/utils/index.mjs +5 -5
  118. package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
  119. package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
  120. package/package.json +24 -34
  121. package/skills/arc/SKILL.md +147 -51
  122. package/skills/arc/references/agent-auth.md +238 -0
  123. package/skills/arc/references/api-reference.md +187 -0
  124. package/skills/arc/references/auth.md +354 -7
  125. package/skills/arc/references/enterprise-auth.md +94 -0
  126. package/skills/arc/references/events.md +8 -6
  127. package/skills/arc/references/mcp.md +2 -2
  128. package/skills/arc/references/multi-tenancy.md +11 -2
  129. package/skills/arc/references/production.md +10 -9
  130. package/skills/arc/references/scim.md +247 -0
  131. package/skills/arc/references/testing.md +1 -1
  132. package/skills/arc-code-review/SKILL.md +141 -0
  133. package/skills/arc-code-review/references/anti-patterns.md +911 -0
  134. package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
  135. package/skills/arc-code-review/references/migration-recipes.md +700 -0
  136. package/skills/arc-code-review/references/mongokit-migration.md +386 -0
  137. package/skills/arc-code-review/references/scaffolding.md +230 -0
  138. package/skills/arc-code-review/references/severity.md +127 -0
  139. package/dist/EventTransport-BFQjw9pB.mjs +0 -133
  140. package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
  141. package/dist/adapters/index.d.mts +0 -3
  142. package/dist/adapters/index.mjs +0 -2
  143. package/dist/adapters-DUUiiimH.mjs +0 -964
  144. package/dist/auth/mongoose.d.mts +0 -191
  145. package/dist/auth/mongoose.mjs +0 -73
  146. package/dist/core-CbcQRIch.mjs +0 -1054
  147. package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
  148. package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
  149. package/dist/errors-D5c-5BJL.mjs +0 -232
  150. package/dist/index-Rg8axYPz.d.mts +0 -370
  151. /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
  152. /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
  153. /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
  154. /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
  155. /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
  156. /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
  157. /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
  158. /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
  159. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  160. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
  161. /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
  162. /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
  163. /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
  164. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
  165. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  166. /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
@@ -0,0 +1,963 @@
1
+ import fp from "fastify-plugin";
2
+ //#region src/scim/errors.ts
3
+ var ScimError = class extends Error {
4
+ statusCode;
5
+ scimType;
6
+ constructor(statusCode, scimType, detail) {
7
+ super(detail);
8
+ this.statusCode = statusCode;
9
+ this.scimType = scimType;
10
+ this.name = "ScimError";
11
+ }
12
+ toResponse() {
13
+ return {
14
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
15
+ status: String(this.statusCode),
16
+ ...this.scimType ? { scimType: this.scimType } : {},
17
+ detail: this.message
18
+ };
19
+ }
20
+ };
21
+ //#endregion
22
+ //#region src/scim/helpers.ts
23
+ /** Combine plugin defaults with the host's per-resource mapping override. */
24
+ function mergeMapping(defaults, override) {
25
+ if (!override) return defaults;
26
+ return {
27
+ schema: override.schema ?? defaults.schema,
28
+ attributes: {
29
+ ...defaults.attributes,
30
+ ...override.attributes ?? {}
31
+ },
32
+ reverseAttributes: override.reverseAttributes,
33
+ fromScim: override.fromScim ?? defaults.fromScim,
34
+ toScim: override.toScim ?? defaults.toScim
35
+ };
36
+ }
37
+ /**
38
+ * Build the per-request auth check from `bearer` (static) or `verify` (callback).
39
+ * Throws at plugin construction if both / neither are configured.
40
+ */
41
+ function makeAuthCheck(opts) {
42
+ if (opts.bearer && opts.verify) throw new Error("scimPlugin: pass either `bearer` or `verify`, not both");
43
+ if (opts.bearer) {
44
+ const expected = `Bearer ${opts.bearer}`;
45
+ return async (request) => {
46
+ if (request.headers.authorization !== expected) throw new ScimError(401, void 0, "Invalid bearer token");
47
+ };
48
+ }
49
+ if (opts.verify) {
50
+ const verify = opts.verify;
51
+ return async (request) => {
52
+ if (!await verify(request)) throw new ScimError(401, void 0, "SCIM authentication failed");
53
+ };
54
+ }
55
+ throw new Error("scimPlugin: configure either `bearer` (static token) or `verify` (callback)");
56
+ }
57
+ /**
58
+ * Format any thrown value into the canonical SCIM 2.0 error envelope and
59
+ * write it to the reply. Always sends `application/scim+json`.
60
+ */
61
+ function sendScimError(reply, err) {
62
+ if (err instanceof ScimError) return reply.code(err.statusCode).header("Content-Type", "application/scim+json").send(err.toResponse());
63
+ const fallback = new ScimError(500, void 0, err instanceof Error ? err.message : "Internal SCIM error");
64
+ return reply.code(fallback.statusCode).header("Content-Type", "application/scim+json").send(fallback.toResponse());
65
+ }
66
+ function unwrapList(result) {
67
+ if (Array.isArray(result)) return {
68
+ items: result,
69
+ total: result.length
70
+ };
71
+ if (result && typeof result === "object") {
72
+ const r = result;
73
+ if (Array.isArray(r.data)) return {
74
+ items: r.data,
75
+ total: r.total ?? r.data.length
76
+ };
77
+ if (Array.isArray(r.docs)) return {
78
+ items: r.docs,
79
+ total: r.total ?? r.docs.length
80
+ };
81
+ }
82
+ return {
83
+ items: [],
84
+ total: 0
85
+ };
86
+ }
87
+ /** Coerce a Mongoose / Drizzle / plain doc to a plain `Record`. */
88
+ function asRecord(doc) {
89
+ if (!doc || typeof doc !== "object") return {};
90
+ if (typeof doc.toObject === "function") return doc.toObject();
91
+ return doc;
92
+ }
93
+ /**
94
+ * Register the `application/scim+json` Fastify content-type parser. Idempotent
95
+ * — second registration in the same scope is a no-op. Empty bodies (DELETE,
96
+ * GET) yield `undefined` rather than crashing on `JSON.parse("")`.
97
+ */
98
+ function ensureScimContentTypeParser(fastify) {
99
+ if (fastify.hasContentTypeParser("application/scim+json")) return;
100
+ fastify.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_req, body, done) => {
101
+ const raw = body;
102
+ if (!raw || raw.length === 0) {
103
+ done(null, void 0);
104
+ return;
105
+ }
106
+ try {
107
+ done(null, JSON.parse(raw));
108
+ } catch (err) {
109
+ done(err, void 0);
110
+ }
111
+ });
112
+ }
113
+ /**
114
+ * Does the repo expose `findOneAndUpdate` (StandardRepo optional)? Required
115
+ * for SCIM PATCH because operator-shaped updates ($set / $unset / $push /
116
+ * $pull) need to flow through unchanged.
117
+ */
118
+ function hasFindOneAndUpdate(repo) {
119
+ return typeof repo.findOneAndUpdate === "function";
120
+ }
121
+ /**
122
+ * Does the repo expose `bulkWrite` with `replaceOne` support? Required for
123
+ * SCIM PUT — full document replacement is not in MinimalRepo, only reachable
124
+ * via `bulkWrite([{ replaceOne }])`.
125
+ */
126
+ function hasBulkWrite(repo) {
127
+ return typeof repo.bulkWrite === "function";
128
+ }
129
+ //#endregion
130
+ //#region src/scim/discovery.ts
131
+ function mountDiscoveryRoutes(fastify, prefix, hasGroups, authCheck, maxResults, observe) {
132
+ fastify.get(`${prefix}/ServiceProviderConfig`, async (request, reply) => {
133
+ const start = Date.now();
134
+ try {
135
+ await authCheck(request);
136
+ const baseUrl = `${request.protocol}://${request.hostname}${prefix}`;
137
+ const payload = {
138
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
139
+ documentationUri: "https://datatracker.ietf.org/doc/html/rfc7644",
140
+ patch: { supported: true },
141
+ bulk: {
142
+ supported: false,
143
+ maxOperations: 0,
144
+ maxPayloadSize: 0
145
+ },
146
+ filter: {
147
+ supported: true,
148
+ maxResults
149
+ },
150
+ changePassword: { supported: false },
151
+ sort: { supported: true },
152
+ etag: { supported: false },
153
+ authenticationSchemes: [{
154
+ type: "oauthbearertoken",
155
+ name: "OAuth Bearer Token",
156
+ description: "Authentication via OAuth 2.0 bearer token",
157
+ specUri: "https://datatracker.ietf.org/doc/html/rfc6750",
158
+ primary: true
159
+ }],
160
+ meta: {
161
+ location: `${baseUrl}/ServiceProviderConfig`,
162
+ resourceType: "ServiceProviderConfig"
163
+ }
164
+ };
165
+ observe({
166
+ resourceType: "discovery",
167
+ op: "discovery.serviceProviderConfig",
168
+ status: 200,
169
+ durationMs: Date.now() - start,
170
+ path: "/ServiceProviderConfig"
171
+ });
172
+ return reply.code(200).header("Content-Type", "application/scim+json").send(payload);
173
+ } catch (err) {
174
+ observe({
175
+ resourceType: "discovery",
176
+ op: "discovery.serviceProviderConfig",
177
+ status: 401,
178
+ durationMs: Date.now() - start,
179
+ path: "/ServiceProviderConfig"
180
+ });
181
+ return sendScimError(reply, err);
182
+ }
183
+ });
184
+ fastify.get(`${prefix}/ResourceTypes`, async (request, reply) => {
185
+ const start = Date.now();
186
+ try {
187
+ await authCheck(request);
188
+ const baseUrl = `${request.protocol}://${request.hostname}${prefix}`;
189
+ const types = [{
190
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
191
+ id: "User",
192
+ name: "User",
193
+ endpoint: "/Users",
194
+ schema: "urn:ietf:params:scim:schemas:core:2.0:User",
195
+ meta: {
196
+ location: `${baseUrl}/ResourceTypes/User`,
197
+ resourceType: "ResourceType"
198
+ }
199
+ }];
200
+ if (hasGroups) types.push({
201
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
202
+ id: "Group",
203
+ name: "Group",
204
+ endpoint: "/Groups",
205
+ schema: "urn:ietf:params:scim:schemas:core:2.0:Group",
206
+ meta: {
207
+ location: `${baseUrl}/ResourceTypes/Group`,
208
+ resourceType: "ResourceType"
209
+ }
210
+ });
211
+ observe({
212
+ resourceType: "discovery",
213
+ op: "discovery.resourceTypes",
214
+ status: 200,
215
+ durationMs: Date.now() - start,
216
+ path: "/ResourceTypes"
217
+ });
218
+ return reply.code(200).header("Content-Type", "application/scim+json").send({
219
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
220
+ totalResults: types.length,
221
+ Resources: types
222
+ });
223
+ } catch (err) {
224
+ observe({
225
+ resourceType: "discovery",
226
+ op: "discovery.resourceTypes",
227
+ status: 401,
228
+ durationMs: Date.now() - start,
229
+ path: "/ResourceTypes"
230
+ });
231
+ return sendScimError(reply, err);
232
+ }
233
+ });
234
+ fastify.get(`${prefix}/Schemas`, async (request, reply) => {
235
+ const start = Date.now();
236
+ try {
237
+ await authCheck(request);
238
+ observe({
239
+ resourceType: "discovery",
240
+ op: "discovery.schemas",
241
+ status: 200,
242
+ durationMs: Date.now() - start,
243
+ path: "/Schemas"
244
+ });
245
+ return reply.code(200).header("Content-Type", "application/scim+json").send({
246
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
247
+ totalResults: hasGroups ? 2 : 1,
248
+ Resources: [{
249
+ id: "urn:ietf:params:scim:schemas:core:2.0:User",
250
+ name: "User"
251
+ }, ...hasGroups ? [{
252
+ id: "urn:ietf:params:scim:schemas:core:2.0:Group",
253
+ name: "Group"
254
+ }] : []]
255
+ });
256
+ } catch (err) {
257
+ observe({
258
+ resourceType: "discovery",
259
+ op: "discovery.schemas",
260
+ status: 401,
261
+ durationMs: Date.now() - start,
262
+ path: "/Schemas"
263
+ });
264
+ return sendScimError(reply, err);
265
+ }
266
+ });
267
+ }
268
+ //#endregion
269
+ //#region src/scim/mapping.ts
270
+ const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
271
+ const SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group";
272
+ const SCIM_ENTERPRISE_USER_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
273
+ const DEFAULT_USER_MAPPING = {
274
+ schema: SCIM_USER_SCHEMA,
275
+ attributes: {
276
+ id: "id",
277
+ userName: "email",
278
+ "name.formatted": "name",
279
+ displayName: "name",
280
+ "emails.value": "email",
281
+ active: "isActive",
282
+ externalId: "externalId",
283
+ "meta.created": "createdAt",
284
+ "meta.lastModified": "updatedAt"
285
+ }
286
+ };
287
+ const DEFAULT_GROUP_MAPPING = {
288
+ schema: SCIM_GROUP_SCHEMA,
289
+ attributes: {
290
+ id: "id",
291
+ displayName: "name",
292
+ externalId: "externalId",
293
+ "meta.created": "createdAt",
294
+ "meta.lastModified": "updatedAt"
295
+ }
296
+ };
297
+ function buildReverseMap(forward) {
298
+ const out = {};
299
+ for (const [scim, backend] of Object.entries(forward)) if (!(backend in out)) out[backend] = scim;
300
+ return out;
301
+ }
302
+ function getDeep(obj, path) {
303
+ const parts = path.split(".");
304
+ let cur = obj;
305
+ for (const p of parts) if (cur && typeof cur === "object") cur = cur[p];
306
+ else return void 0;
307
+ return cur;
308
+ }
309
+ function setDeep(obj, path, value) {
310
+ const parts = path.split(".");
311
+ let cur = obj;
312
+ for (let i = 0; i < parts.length - 1; i++) {
313
+ const k = parts[i];
314
+ if (typeof cur[k] !== "object" || cur[k] === null) cur[k] = {};
315
+ cur = cur[k];
316
+ }
317
+ cur[parts[parts.length - 1]] = value;
318
+ }
319
+ /** Translate inbound SCIM JSON → resource shape. */
320
+ function scimToResource(scim, mapping) {
321
+ const mapped = {};
322
+ for (const [scimAttr, backendField] of Object.entries(mapping.attributes)) {
323
+ const v = getDeep(scim, scimAttr);
324
+ if (v !== void 0 && v !== null && v !== "") mapped[backendField] = v;
325
+ }
326
+ if (Array.isArray(scim.emails)) {
327
+ const list = scim.emails;
328
+ const primary = list.find((e) => e.primary === true) ?? list[0];
329
+ if (primary?.value && mapping.attributes["emails.value"]) mapped[mapping.attributes["emails.value"]] = primary.value;
330
+ }
331
+ return mapping.fromScim ? mapping.fromScim(scim, mapped) : mapped;
332
+ }
333
+ /** Translate resource → SCIM JSON. */
334
+ function resourceToScim(resource, mapping, baseUrl) {
335
+ const reverse = mapping.reverseAttributes ?? buildReverseMap(mapping.attributes);
336
+ const out = { schemas: [mapping.schema] };
337
+ for (const [backendField, value] of Object.entries(resource)) {
338
+ if (value === void 0 || value === null) continue;
339
+ const scimAttr = reverse[backendField];
340
+ if (!scimAttr) continue;
341
+ setDeep(out, scimAttr, value);
342
+ }
343
+ const primaryEmail = resource[mapping.attributes["emails.value"] ?? "email"];
344
+ if (primaryEmail) out.emails = [{
345
+ value: primaryEmail,
346
+ primary: true,
347
+ type: "work"
348
+ }];
349
+ const id = resource.id ?? resource._id;
350
+ if (id) {
351
+ const resourceType = mapping.schema.endsWith("User") ? "User" : "Group";
352
+ const meta = out.meta ?? {};
353
+ meta.resourceType = resourceType;
354
+ meta.location = `${baseUrl}/${resourceType}s/${id}`;
355
+ out.meta = meta;
356
+ }
357
+ return mapping.toScim ? mapping.toScim(resource, out) : out;
358
+ }
359
+ //#endregion
360
+ //#region src/scim/filter.ts
361
+ /**
362
+ * SCIM 2.0 filter language parser (RFC 7644 §3.4.2.2)
363
+ *
364
+ * Translates SCIM filter expressions into arc's query DSL so existing
365
+ * resources can serve `/scim/v2/Users?filter=...` without per-resource glue.
366
+ *
367
+ * Supports the subset every IdP actually emits in production:
368
+ * - Comparison ops: eq, ne, co, sw, ew, gt, ge, lt, le, pr (present)
369
+ * - Logical: and, or, not
370
+ * - Grouping: ( )
371
+ * - Attribute paths: `userName`, `name.familyName`, `emails[type eq "work"].value`
372
+ *
373
+ * Out of scope (yields a 400 with a clear reason):
374
+ * - Complex value paths beyond one level
375
+ * - Sub-attribute traversal in operands
376
+ *
377
+ * @example
378
+ * parseScimFilter('userName eq "alice@acme.com"')
379
+ * → { userName: 'alice@acme.com' }
380
+ *
381
+ * parseScimFilter('active eq true and name.familyName sw "S"')
382
+ * → { $and: [{ active: true }, { 'name.familyName': { $regex: '^S' } }] }
383
+ */
384
+ const COMPARISON_OPS = new Set([
385
+ "eq",
386
+ "ne",
387
+ "co",
388
+ "sw",
389
+ "ew",
390
+ "gt",
391
+ "ge",
392
+ "lt",
393
+ "le",
394
+ "pr"
395
+ ]);
396
+ const LOGICAL_OPS = new Set([
397
+ "and",
398
+ "or",
399
+ "not"
400
+ ]);
401
+ function tokenize(input) {
402
+ const tokens = [];
403
+ let i = 0;
404
+ while (i < input.length) {
405
+ const c = input[i] ?? "";
406
+ if (c === " " || c === " " || c === "\n" || c === "\r") {
407
+ i++;
408
+ continue;
409
+ }
410
+ if (c === "(") {
411
+ tokens.push({ kind: "lparen" });
412
+ i++;
413
+ continue;
414
+ }
415
+ if (c === ")") {
416
+ tokens.push({ kind: "rparen" });
417
+ i++;
418
+ continue;
419
+ }
420
+ if (c === "[") {
421
+ tokens.push({ kind: "lbracket" });
422
+ i++;
423
+ continue;
424
+ }
425
+ if (c === "]") {
426
+ tokens.push({ kind: "rbracket" });
427
+ i++;
428
+ continue;
429
+ }
430
+ if (c === "\"") {
431
+ let j = i + 1;
432
+ let value = "";
433
+ while (j < input.length && input[j] !== "\"") if (input[j] === "\\" && j + 1 < input.length) {
434
+ value += input[j + 1];
435
+ j += 2;
436
+ } else {
437
+ value += input[j];
438
+ j++;
439
+ }
440
+ if (j >= input.length) throw new ScimError(400, "invalidFilter", "Unterminated string literal");
441
+ tokens.push({
442
+ kind: "string",
443
+ value
444
+ });
445
+ i = j + 1;
446
+ continue;
447
+ }
448
+ const next = input[i + 1] ?? "";
449
+ if (c >= "0" && c <= "9" || c === "-" && next >= "0" && next <= "9") {
450
+ let j = i;
451
+ if (input[j] === "-") j++;
452
+ while (j < input.length) {
453
+ const cj = input[j] ?? "";
454
+ if (!(cj >= "0" && cj <= "9" || cj === ".")) break;
455
+ j++;
456
+ }
457
+ const num = Number(input.slice(i, j));
458
+ if (Number.isNaN(num)) throw new ScimError(400, "invalidFilter", `Invalid number near "${input.slice(i, j)}"`);
459
+ tokens.push({
460
+ kind: "number",
461
+ value: num
462
+ });
463
+ i = j;
464
+ continue;
465
+ }
466
+ if (/[a-zA-Z_]/.test(c ?? "")) {
467
+ let j = i;
468
+ while (j < input.length && /[a-zA-Z0-9_.$:-]/.test(input[j] ?? "")) j++;
469
+ const word = input.slice(i, j);
470
+ const lower = word.toLowerCase();
471
+ if (lower === "true" || lower === "false") tokens.push({
472
+ kind: "bool",
473
+ value: lower === "true"
474
+ });
475
+ else if (lower === "null") tokens.push({ kind: "null" });
476
+ else if (COMPARISON_OPS.has(lower) || LOGICAL_OPS.has(lower)) tokens.push({
477
+ kind: "op",
478
+ value: lower
479
+ });
480
+ else tokens.push({
481
+ kind: "ident",
482
+ value: word
483
+ });
484
+ i = j;
485
+ continue;
486
+ }
487
+ throw new ScimError(400, "invalidFilter", `Unexpected character "${c}" at position ${i}`);
488
+ }
489
+ return tokens;
490
+ }
491
+ var Parser = class {
492
+ pos = 0;
493
+ tokens;
494
+ constructor(tokens) {
495
+ this.tokens = tokens;
496
+ }
497
+ parse() {
498
+ const node = this.parseOr();
499
+ if (this.pos < this.tokens.length) throw new ScimError(400, "invalidFilter", `Unexpected token at end of filter`);
500
+ return node;
501
+ }
502
+ peek() {
503
+ return this.tokens[this.pos];
504
+ }
505
+ consume() {
506
+ const t = this.tokens[this.pos++];
507
+ if (!t) throw new ScimError(400, "invalidFilter", "Unexpected end of filter");
508
+ return t;
509
+ }
510
+ parseOr() {
511
+ let left = this.parseAnd();
512
+ while (this.peek()?.kind === "op" && this.peek().value === "or") {
513
+ this.consume();
514
+ left = {
515
+ kind: "or",
516
+ left,
517
+ right: this.parseAnd()
518
+ };
519
+ }
520
+ return left;
521
+ }
522
+ parseAnd() {
523
+ let left = this.parseNot();
524
+ while (this.peek()?.kind === "op" && this.peek().value === "and") {
525
+ this.consume();
526
+ left = {
527
+ kind: "and",
528
+ left,
529
+ right: this.parseNot()
530
+ };
531
+ }
532
+ return left;
533
+ }
534
+ parseNot() {
535
+ if (this.peek()?.kind === "op" && this.peek().value === "not") {
536
+ this.consume();
537
+ const next = this.peek();
538
+ if (!next || next.kind !== "lparen") throw new ScimError(400, "invalidFilter", "Expected '(' after 'not'");
539
+ this.consume();
540
+ const child = this.parseOr();
541
+ if (this.consume().kind !== "rparen") throw new ScimError(400, "invalidFilter", "Expected ')' after 'not(...)'");
542
+ return {
543
+ kind: "not",
544
+ child
545
+ };
546
+ }
547
+ return this.parsePrimary();
548
+ }
549
+ parsePrimary() {
550
+ const t = this.peek();
551
+ if (!t) throw new ScimError(400, "invalidFilter", "Unexpected end of filter");
552
+ if (t.kind === "lparen") {
553
+ this.consume();
554
+ const inner = this.parseOr();
555
+ if (this.consume().kind !== "rparen") throw new ScimError(400, "invalidFilter", "Expected ')' after grouped expression");
556
+ return inner;
557
+ }
558
+ return this.parseComparison();
559
+ }
560
+ parseComparison() {
561
+ const attrTok = this.consume();
562
+ if (attrTok.kind !== "ident") throw new ScimError(400, "invalidFilter", "Expected attribute path");
563
+ let attr = attrTok.value;
564
+ if (this.peek()?.kind === "lbracket") {
565
+ this.consume();
566
+ let depth = 1;
567
+ while (depth > 0 && this.pos < this.tokens.length) {
568
+ const inner = this.consume();
569
+ if (inner.kind === "lbracket") depth++;
570
+ if (inner.kind === "rbracket") depth--;
571
+ }
572
+ const next = this.peek();
573
+ if (next?.kind === "ident" && next.value.startsWith(".")) {
574
+ this.consume();
575
+ attr += next.value;
576
+ }
577
+ }
578
+ const opTok = this.consume();
579
+ if (opTok.kind !== "op" || !COMPARISON_OPS.has(opTok.value)) throw new ScimError(400, "invalidFilter", `Expected comparison operator after "${attr}"`);
580
+ if (opTok.value === "pr") return {
581
+ kind: "present",
582
+ attr
583
+ };
584
+ const valTok = this.consume();
585
+ let value;
586
+ if (valTok.kind === "string") value = valTok.value;
587
+ else if (valTok.kind === "number") value = valTok.value;
588
+ else if (valTok.kind === "bool") value = valTok.value;
589
+ else if (valTok.kind === "null") value = null;
590
+ else throw new ScimError(400, "invalidFilter", `Expected literal after "${opTok.value}"`);
591
+ return {
592
+ kind: "compare",
593
+ attr,
594
+ op: opTok.value,
595
+ value
596
+ };
597
+ }
598
+ };
599
+ function escapeRegex(s) {
600
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
601
+ }
602
+ function nodeToQuery(node, mapAttr) {
603
+ switch (node.kind) {
604
+ case "and": return { $and: [nodeToQuery(node.left, mapAttr), nodeToQuery(node.right, mapAttr)] };
605
+ case "or": return { $or: [nodeToQuery(node.left, mapAttr), nodeToQuery(node.right, mapAttr)] };
606
+ case "not": return { $nor: [nodeToQuery(node.child, mapAttr)] };
607
+ case "present": {
608
+ const field = mapAttr(node.attr);
609
+ if (!field) throw new ScimError(400, "invalidFilter", `Attribute "${node.attr}" is not filterable`);
610
+ return { [field]: {
611
+ $exists: true,
612
+ $ne: null
613
+ } };
614
+ }
615
+ case "compare": {
616
+ const field = mapAttr(node.attr);
617
+ if (!field) throw new ScimError(400, "invalidFilter", `Attribute "${node.attr}" is not filterable`);
618
+ switch (node.op) {
619
+ case "eq": return { [field]: node.value };
620
+ case "ne": return { [field]: { $ne: node.value } };
621
+ case "co":
622
+ if (typeof node.value !== "string") throw new ScimError(400, "invalidFilter", "'co' requires a string operand");
623
+ return { [field]: {
624
+ $regex: escapeRegex(node.value),
625
+ $options: "i"
626
+ } };
627
+ case "sw":
628
+ if (typeof node.value !== "string") throw new ScimError(400, "invalidFilter", "'sw' requires a string operand");
629
+ return { [field]: {
630
+ $regex: `^${escapeRegex(node.value)}`,
631
+ $options: "i"
632
+ } };
633
+ case "ew":
634
+ if (typeof node.value !== "string") throw new ScimError(400, "invalidFilter", "'ew' requires a string operand");
635
+ return { [field]: {
636
+ $regex: `${escapeRegex(node.value)}$`,
637
+ $options: "i"
638
+ } };
639
+ case "gt": return { [field]: { $gt: node.value } };
640
+ case "ge": return { [field]: { $gte: node.value } };
641
+ case "lt": return { [field]: { $lt: node.value } };
642
+ case "le": return { [field]: { $lte: node.value } };
643
+ }
644
+ }
645
+ }
646
+ }
647
+ /**
648
+ * Parse a SCIM 2.0 filter expression and translate to arc/Mongo query shape.
649
+ *
650
+ * @param filter Raw filter string from `?filter=...`
651
+ * @param mapAttr Mapping function: SCIM attr → backend field name. Return
652
+ * `undefined` to deny (yields 400 invalidFilter). Use `IDENTITY_MAP` when
653
+ * the resource exposes SCIM-named attributes directly.
654
+ */
655
+ function parseScimFilter(filter, mapAttr) {
656
+ if (!filter || filter.trim().length === 0) return {};
657
+ return nodeToQuery(new Parser(tokenize(filter)).parse(), mapAttr);
658
+ }
659
+ /** Pass-through mapper for resources that already use SCIM attribute names. */
660
+ const IDENTITY_MAP = (a) => a;
661
+ //#endregion
662
+ //#region src/scim/patch.ts
663
+ /**
664
+ * SCIM 2.0 PATCH parser (RFC 7644 §3.5.2)
665
+ *
666
+ * Translates SCIM PATCH operations into a flat update object the resource's
667
+ * existing PATCH handler can apply. Supports the three operations every IdP
668
+ * actually emits: `add`, `replace`, `remove`.
669
+ *
670
+ * **Path support**:
671
+ * - Simple attribute: `userName` → `{ userName: <value> }`
672
+ * - Sub-attribute: `name.familyName` → `{ 'name.familyName': <value> }`
673
+ * - No path (op-level value): `replace` with object value → spread into update
674
+ * - Multi-value with filter: `emails[type eq "work"].value` — parsed but
675
+ * translated to a `$set` on the matching array element by index lookup
676
+ * (host resolves index via the supplied `lookupArrayIndex` callback)
677
+ *
678
+ * @example
679
+ * parseScimPatch({
680
+ * schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
681
+ * Operations: [
682
+ * { op: 'replace', path: 'displayName', value: 'Alice S.' },
683
+ * { op: 'add', path: 'emails', value: [{ type: 'work', value: 'a@x.com' }] },
684
+ * { op: 'remove', path: 'emails[type eq "old"]' },
685
+ * ],
686
+ * })
687
+ * → { $set: { displayName: 'Alice S.' }, $push: { emails: ... }, $pull: { emails: ... } }
688
+ */
689
+ function parseScimPatch(req) {
690
+ const ops = req.Operations ?? req.operations ?? [];
691
+ if (ops.length === 0) throw new ScimError(400, "invalidSyntax", "PATCH request must include at least one operation");
692
+ const out = {
693
+ $set: {},
694
+ $push: {},
695
+ $pull: {},
696
+ $unset: {}
697
+ };
698
+ for (const op of ops) {
699
+ const verb = (op.op ?? "").toLowerCase();
700
+ if (verb !== "add" && verb !== "replace" && verb !== "remove") throw new ScimError(400, "invalidSyntax", `Unsupported PATCH op "${op.op}" (allowed: add, replace, remove)`);
701
+ if (!op.path) {
702
+ if (verb === "remove") throw new ScimError(400, "noTarget", "remove operation requires a path");
703
+ if (op.value === void 0 || op.value === null || typeof op.value !== "object") throw new ScimError(400, "invalidValue", "Path-less add/replace must carry an object value");
704
+ Object.assign(out.$set, op.value);
705
+ continue;
706
+ }
707
+ const bracketIdx = op.path.indexOf("[");
708
+ if (bracketIdx >= 0) {
709
+ const closeBracket = op.path.indexOf("]", bracketIdx);
710
+ if (closeBracket < 0) throw new ScimError(400, "invalidPath", `Unterminated bracket in path "${op.path}"`);
711
+ const arrayField = op.path.slice(0, bracketIdx);
712
+ if (verb === "remove") out.$pull[arrayField] = { __scimFilter: op.path.slice(bracketIdx + 1, closeBracket) };
713
+ else if (verb === "add") out.$push[arrayField] = op.value;
714
+ else {
715
+ out.$pull[arrayField] = { __scimFilter: op.path.slice(bracketIdx + 1, closeBracket) };
716
+ out.$push[arrayField] = op.value;
717
+ }
718
+ continue;
719
+ }
720
+ if (verb === "remove") {
721
+ out.$unset[op.path] = true;
722
+ continue;
723
+ }
724
+ if (verb === "add" && Array.isArray(op.value)) {
725
+ out.$push[op.path] = { $each: op.value };
726
+ continue;
727
+ }
728
+ out.$set[op.path] = op.value;
729
+ }
730
+ return out;
731
+ }
732
+ /**
733
+ * Flatten a {@link ScimUpdate} into a plain `{ field: value }` object suitable
734
+ * for arc resource `PATCH` handlers that expect a partial document. Drops
735
+ * `$push` / `$pull` / `$unset` semantics — use {@link parseScimPatch} directly
736
+ * when the host needs the full op stream (e.g. to issue array mutations).
737
+ */
738
+ function scimUpdateToFlatPatch(update) {
739
+ const out = { ...update.$set };
740
+ for (const k of Object.keys(update.$unset)) out[k] = null;
741
+ return out;
742
+ }
743
+ //#endregion
744
+ //#region src/scim/routes.ts
745
+ /**
746
+ * Wrap a route body so every outcome (success, ScimError, unknown error)
747
+ * funnels through one observability path. Generic over the request type so
748
+ * Fastify's route-shape generics (`<{ Params, Body, Querystring }>`) narrow
749
+ * `request.params` / `request.body` natively — no `as FastifyRequest & {...}`
750
+ * casts at call sites.
751
+ */
752
+ function withObserve(fastify, observe, resourceType, op, path, body) {
753
+ return async (request, reply) => {
754
+ const start = Date.now();
755
+ try {
756
+ const result = await body(request);
757
+ reply.code(result.status).header("Content-Type", "application/scim+json");
758
+ if (result.headers) for (const [k, v] of Object.entries(result.headers)) reply.header(k, v);
759
+ observe({
760
+ resourceType,
761
+ op,
762
+ status: result.status,
763
+ durationMs: Date.now() - start,
764
+ path
765
+ });
766
+ return result.payload === void 0 ? reply.send() : reply.send(result.payload);
767
+ } catch (err) {
768
+ const scim = err instanceof ScimError ? err : null;
769
+ observe({
770
+ resourceType,
771
+ op,
772
+ status: scim?.statusCode ?? 500,
773
+ durationMs: Date.now() - start,
774
+ scimType: scim?.scimType,
775
+ path
776
+ });
777
+ fastify.log?.warn?.({
778
+ err,
779
+ resourceType,
780
+ op,
781
+ path
782
+ }, "SCIM request failed");
783
+ return sendScimError(reply, err);
784
+ }
785
+ };
786
+ }
787
+ /**
788
+ * Build a backend-shaped patch from SCIM ops, mapping SCIM attribute names
789
+ * onto backend field names per the resource's mapping. Returns canonical
790
+ * Mongo-style operators that flow through `findOneAndUpdate` unchanged.
791
+ *
792
+ * Returns `null` when the SCIM body parses but contains nothing the kit can
793
+ * apply through the canonical contract — caller surfaces 400 in that case.
794
+ */
795
+ function scimOpsToBackendOps(scimOps, attrMap) {
796
+ const $set = {};
797
+ const $unset = {};
798
+ const $push = {};
799
+ const $pull = {};
800
+ const map = (scimAttr) => attrMap[scimAttr] ?? scimAttr;
801
+ for (const [scimAttr, value] of Object.entries(scimOps.$set)) $set[map(scimAttr)] = value;
802
+ for (const scimAttr of Object.keys(scimOps.$unset)) $unset[map(scimAttr)] = true;
803
+ for (const [scimAttr, value] of Object.entries(scimOps.$push)) $push[map(scimAttr)] = value;
804
+ for (const [scimAttr, value] of Object.entries(scimOps.$pull)) $pull[map(scimAttr)] = value;
805
+ const ops = {};
806
+ if (Object.keys($set).length > 0) ops.$set = $set;
807
+ if (Object.keys($unset).length > 0) ops.$unset = $unset;
808
+ if (Object.keys($push).length > 0) ops.$push = $push;
809
+ if (Object.keys($pull).length > 0) ops.$pull = $pull;
810
+ return {
811
+ ops,
812
+ hasArrayOps: Object.keys($push).length > 0 || Object.keys($pull).length > 0,
813
+ arrayOpFields: [...Object.keys($push), ...Object.keys($pull)]
814
+ };
815
+ }
816
+ /** Pluck the id field a kit wrote on the document, accepting `id` or `_id`. */
817
+ function extractId(doc) {
818
+ const raw = doc.id ?? doc._id;
819
+ return raw == null ? "" : String(raw);
820
+ }
821
+ /**
822
+ * Mount the canonical SCIM CRUD surface for one resource type (Users /
823
+ * Groups) under the plugin's prefix. Authentication is enforced inside
824
+ * each handler so the observability span captures auth failures too.
825
+ */
826
+ function mountResourceRoutes(fastify, resourceTypeName, mounted, authCheck, maxResults, observe) {
827
+ const prefix = mounted.basePath;
828
+ const repo = mounted.binding.resource.adapter.repository;
829
+ const mapping = mounted.mapping;
830
+ const filterMapper = (attr) => mapping.attributes[attr] ?? attr;
831
+ const toScim = (doc, request) => resourceToScim(asRecord(doc), mapping, `${request.protocol}://${request.hostname}${prefix}`);
832
+ const supportsOperators = hasFindOneAndUpdate(repo);
833
+ const supportsReplace = hasBulkWrite(repo);
834
+ const notFound = () => new ScimError(404, void 0, `${resourceTypeName.slice(0, -1)} not found`);
835
+ const applyPatch = supportsOperators ? async (id, p) => {
836
+ try {
837
+ return await repo.findOneAndUpdate({ id }, p.ops);
838
+ } catch (err) {
839
+ if (p.hasArrayOps) throw new ScimError(400, "invalidValue", `Array mutations ($push/$pull) on field(s) ${p.arrayOpFields.join(", ")} are not supported by this kit's repository. Use a kit with native array-column support (e.g. @classytic/mongokit), or replace the field wholesale via PUT. Underlying error: ${err instanceof Error ? err.message : String(err)}`);
840
+ throw err;
841
+ }
842
+ } : async (id, p, raw) => {
843
+ if (Object.keys(raw.$unset).length > 0 || p.hasArrayOps) throw new ScimError(400, "invalidValue", "This kit's repository does not implement findOneAndUpdate; only $set-shaped PATCH operations are supported. Drop $unset / $push / $pull from the request, or use a kit that exposes findOneAndUpdate.");
844
+ const setData = p.ops.$set ?? {};
845
+ if (Object.keys(setData).length === 0) return repo.getById(id);
846
+ return repo.update(id, setData);
847
+ };
848
+ fastify.get(`${prefix}/${resourceTypeName}`, withObserve(fastify, observe, resourceTypeName, "list", `/${resourceTypeName}`, async (request) => {
849
+ await authCheck(request);
850
+ const q = request.query;
851
+ const startIndex = Math.max(1, Number.parseInt(q.startIndex ?? "1", 10) || 1);
852
+ const count = Math.min(maxResults, Number.parseInt(q.count ?? "100", 10) || 100);
853
+ const filters = q.filter ? parseScimFilter(q.filter, filterMapper) : {};
854
+ const sort = q.sortBy ? { [filterMapper(q.sortBy)]: q.sortOrder === "descending" ? -1 : 1 } : void 0;
855
+ const { items, total } = unwrapList(await repo.getAll({
856
+ filters,
857
+ page: Math.floor((startIndex - 1) / count) + 1,
858
+ limit: count,
859
+ sort
860
+ }));
861
+ const resources = items.map((item) => toScim(item, request));
862
+ return {
863
+ status: 200,
864
+ payload: {
865
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
866
+ totalResults: total,
867
+ startIndex,
868
+ itemsPerPage: resources.length,
869
+ Resources: resources
870
+ }
871
+ };
872
+ }));
873
+ fastify.get(`${prefix}/${resourceTypeName}/:id`, withObserve(fastify, observe, resourceTypeName, "get", `/${resourceTypeName}/:id`, async (request) => {
874
+ await authCheck(request);
875
+ const doc = await repo.getById(request.params.id);
876
+ if (!doc) throw notFound();
877
+ return {
878
+ status: 200,
879
+ payload: toScim(doc, request)
880
+ };
881
+ }));
882
+ fastify.post(`${prefix}/${resourceTypeName}`, withObserve(fastify, observe, resourceTypeName, "create", `/${resourceTypeName}`, async (request) => {
883
+ await authCheck(request);
884
+ const data = scimToResource(request.body ?? {}, mapping);
885
+ const created = await repo.create(data);
886
+ const id = extractId(asRecord(created));
887
+ const baseUrl = `${request.protocol}://${request.hostname}${prefix}`;
888
+ return {
889
+ status: 201,
890
+ payload: toScim(created, request),
891
+ headers: { Location: `${baseUrl}/${resourceTypeName}/${id}` }
892
+ };
893
+ }));
894
+ fastify.put(`${prefix}/${resourceTypeName}/:id`, withObserve(fastify, observe, resourceTypeName, "replace", `/${resourceTypeName}/:id`, async (request) => {
895
+ await authCheck(request);
896
+ if (!supportsReplace) throw new ScimError(501, void 0, "Full replacement (PUT) requires the underlying repository to expose bulkWrite([{ replaceOne }]). This kit does not implement bulkWrite. Use PATCH to apply partial updates instead.");
897
+ const data = scimToResource(request.body ?? {}, mapping);
898
+ await repo.bulkWrite([{ replaceOne: {
899
+ filter: { id: request.params.id },
900
+ replacement: data
901
+ } }]);
902
+ const updated = await repo.getById(request.params.id);
903
+ if (!updated) throw notFound();
904
+ return {
905
+ status: 200,
906
+ payload: toScim(updated, request)
907
+ };
908
+ }));
909
+ fastify.patch(`${prefix}/${resourceTypeName}/:id`, withObserve(fastify, observe, resourceTypeName, "patch", `/${resourceTypeName}/:id`, async (request) => {
910
+ await authCheck(request);
911
+ const scimOps = parseScimPatch(request.body);
912
+ const patchOps = scimOpsToBackendOps(scimOps, mapping.attributes);
913
+ const updated = await applyPatch(request.params.id, patchOps, scimOps);
914
+ if (!updated) throw notFound();
915
+ return {
916
+ status: 200,
917
+ payload: toScim(updated, request)
918
+ };
919
+ }));
920
+ fastify.delete(`${prefix}/${resourceTypeName}/:id`, withObserve(fastify, observe, resourceTypeName, "delete", `/${resourceTypeName}/:id`, async (request) => {
921
+ await authCheck(request);
922
+ await repo.delete(request.params.id);
923
+ return { status: 204 };
924
+ }));
925
+ }
926
+ //#endregion
927
+ //#region src/scim/index.ts
928
+ const scimPlugin = async (fastify, opts) => {
929
+ if (!opts.users) throw new Error("scimPlugin: `users` binding is required");
930
+ const prefix = opts.prefix ?? "/scim/v2";
931
+ const maxResults = opts.maxResults ?? 200;
932
+ const authCheck = makeAuthCheck(opts);
933
+ const observe = opts.observe ?? ((event) => {
934
+ fastify.log?.info?.({ scim: event }, "scim.request");
935
+ });
936
+ ensureScimContentTypeParser(fastify);
937
+ mountResourceRoutes(fastify, "Users", {
938
+ binding: opts.users,
939
+ mapping: mergeMapping(DEFAULT_USER_MAPPING, opts.users.mapping),
940
+ basePath: prefix
941
+ }, authCheck, maxResults, observe);
942
+ let hasGroups = false;
943
+ if (opts.groups) {
944
+ hasGroups = true;
945
+ mountResourceRoutes(fastify, "Groups", {
946
+ binding: opts.groups,
947
+ mapping: mergeMapping(DEFAULT_GROUP_MAPPING, opts.groups.mapping),
948
+ basePath: prefix
949
+ }, authCheck, maxResults, observe);
950
+ }
951
+ mountDiscoveryRoutes(fastify, prefix, hasGroups, authCheck, maxResults, observe);
952
+ fastify.log?.debug?.({
953
+ prefix,
954
+ hasGroups,
955
+ auth: opts.bearer ? "bearer" : "verify"
956
+ }, "SCIM 2.0 plugin mounted");
957
+ };
958
+ var scim_default = fp(scimPlugin, {
959
+ name: "arc-scim",
960
+ fastify: "5.x"
961
+ });
962
+ //#endregion
963
+ export { DEFAULT_GROUP_MAPPING, DEFAULT_USER_MAPPING, IDENTITY_MAP, SCIM_ENTERPRISE_USER_SCHEMA, SCIM_GROUP_SCHEMA, SCIM_USER_SCHEMA, ScimError, scim_default as default, parseScimFilter, parseScimPatch, resourceToScim, scimPlugin, scimToResource, scimUpdateToFlatPatch };