@maroonedsoftware/scim 0.2.23 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -83,7 +83,7 @@ contract is inseparable from HTTP.
83
83
  | Export | Kind | Shape | Notes |
84
84
  | ----------------- | ---------- | ----------------------------------------------------------- | -------------------------------------------- |
85
85
  | `ScimError` | class | `extends HttpError` | So `errorMiddleware` already understands it. |
86
- | `scimError` | function | `(status, scimType?, detail?) => ScimError` | The factory to use. |
86
+ | `scimError` | function | `(status, scimType?, statusText?) => ScimError` | The factory to use. The third argument is the HTTP status text; put the operator-facing reason on `.withDetails({ message })`, which `toScimBody` renders as `detail`. |
87
87
  | `IsScimError` | type guard | — | — |
88
88
  | `ScimErrorType` | type | `'invalidFilter'`, `'insufficientScope'`, `'mutability'`, … | RFC 7644 §3.12 `scimType` values. |
89
89
  | `ScimErrorSchema` | constant | The error envelope URN | — |
@@ -95,6 +95,8 @@ contract is inseparable from HTTP.
95
95
  | `ScimUserRepository` | abstract class | Implement over your datastore. |
96
96
  | `ScimGroupRepository` | abstract class | Implement over your datastore. |
97
97
  | `ScimListQuery` | interface | `{ filter?: ScimFilterNode; startIndex; count; sortBy?; sortOrder?; attributes?; excludedAttributes? }` — **the parsed AST**, never the raw string. |
98
+ | `projectScimResource` | function | `(resource, schemas: ScimSchema[], projection?) => resource` — applies `attributes` / `excludedAttributes` and strips `returned: 'never'`. |
99
+ | `ScimProjection` | interface | `{ attributes?: string[]; excludedAttributes?: string[] }` |
98
100
  | `ScimListResult<TResource>` | interface | `{ resources; totalResults }` — `totalResults` is the **filter-matching total**, not the page size. |
99
101
  | `ScimSortOrder` | type | `'ascending' \| 'descending'` |
100
102
  | `ScimUserService` / `ScimGroupService` / `ScimServiceProviderService` | class | Sit between the router and the repositories. |
@@ -108,7 +110,7 @@ contract is inseparable from HTTP.
108
110
  | `SCIM_MEDIA_TYPE` | constant | `'application/scim+json'` | — |
109
111
  | `requireScimScope` | function | `(scope: string) => ServerKitRouterMiddleware` | Reads `ctx.authenticationSession.claims.scimScopes`. `*` grants everything. |
110
112
  | `createScimRouter` | function | `(options: CreateScimRouterOptions) => Router<unknown, ServerKitContext>` | Mounts every endpoint below. |
111
- | `CreateScimRouterOptions` | interface | `{ userService, groupService, serviceProviderService, routeGuards?, maxResults? }` | `maxResults` defaults to the service-provider config's `filter.maxResults`, then 200. |
113
+ | `CreateScimRouterOptions` | interface | `{ userService, groupService, serviceProviderService, routeGuards?, maxResults?, baseUrl? }` | `maxResults` defaults to the service-provider config's `filter.maxResults`, then 200. Set `baseUrl` whenever the router is mounted under a prefix. |
112
114
 
113
115
  Endpoints mounted: `GET|POST /Users`, `GET|PUT|PATCH|DELETE /Users/:id`, `POST /Users/.search`, the
114
116
  same six plus `.search` for `/Groups`, and `GET /Schemas`, `/Schemas/:id`, `/ResourceTypes`,
@@ -145,6 +147,7 @@ const router = createScimRouter({
145
147
  groupService: new ScimGroupService(groupRepository, logger),
146
148
  serviceProviderService: new ScimServiceProviderService(config),
147
149
  routeGuards: [requireScimScope('scim:write')],
150
+ baseUrl: 'https://api.example.com/scim/v2',
148
151
  });
149
152
 
150
153
  // SCIM mountpoint — note scimErrorMiddleware, not errorMiddleware
@@ -166,12 +169,18 @@ app.use(router.routes()).use(router.allowedMethods());
166
169
  Provisioning clients paginate on it, and a wrong value makes Okta or Entra loop or truncate.
167
170
  - Populate `claims.scimScopes` (a string array) when minting the bearer session, or every request
168
171
  gets a 403.
172
+ - Honour `count: 0` by returning an empty `Resources` array with the real `totalResults`, per RFC
173
+ 7644 §3.4.2.4. The router already parses it that way from both the query string and a `.search`
174
+ body; a repository that treats `0` as "unset" breaks the count probe Okta and Entra make on
175
+ connection setup.
169
176
  - Honour `startIndex` as **1-based**, per RFC 7644 §3.4.2.4. Off-by-one here silently skips or
170
177
  repeats the first record.
171
178
  - Apply PATCH with `applyScimPatch` rather than hand-rolling op semantics — value-path filters
172
179
  (`emails[type eq "work"].value`) are easy to get subtly wrong.
173
- - Throw `scimError(status, scimType, detail)` with the right RFC 7644 §3.12 `scimType`. Clients
174
- branch on it.
180
+ - Throw `scimError(status, scimType, statusText).withDetails({ message })` with the right RFC 7644
181
+ §3.12 `scimType`. Clients branch on `scimType`, and `toScimBody` renders `details.message` as the
182
+ envelope's `detail` (falling back to the status text), which is what an operator reads in Okta or
183
+ Entra when provisioning fails.
175
184
  - Serve `/Schemas`, `/ResourceTypes`, and `/ServiceProviderConfig`. Provisioning clients call them
176
185
  during setup and fail the connection without them.
177
186
 
@@ -201,6 +210,15 @@ app.use(router.routes()).use(router.allowedMethods());
201
210
  `scimContentTypeMiddleware` is what actually enforces the media type if you want strictness.
202
211
  - **`schemas` on a resource is a required array of URNs**, not decoration. Omitting the enterprise
203
212
  URN on a user with enterprise attributes makes clients ignore them.
213
+ - **`meta.location` needs `baseUrl` to be right.** The services assign a root-relative
214
+ `/Users/{id}`, because they cannot know where the router is mounted. `createScimRouter` rewrites
215
+ it (and the `Location` header) against `options.baseUrl`; without that option a deployment under
216
+ `/scim/v2` emits URIs that provisioning clients then follow to a 404.
217
+ - **Responses are projected by the router, not the repository.** `createScimRouter` runs every user
218
+ and group response through `projectScimResource`, which applies `attributes` /
219
+ `excludedAttributes` and strips attributes the schema declares `returned: 'never'`. A repository
220
+ that stores and returns `password` verbatim is therefore safe on the wire — but do not rely on
221
+ that if you serve resources through your own routes, and prefer not to store it at all.
204
222
 
205
223
  ## Working inside this package
206
224
 
@@ -227,7 +245,7 @@ Invariants a change must not break:
227
245
  - Repositories receive the **parsed AST**, never the raw filter string. That is what keeps backend
228
246
  translation the only concern a consumer has.
229
247
  - `totalResults` is the filter-matching total. Provisioning clients' pagination depends on it.
230
- - `startIndex` is 1-based throughout.
248
+ - `startIndex` is 1-based throughout, and `count: 0` means "none, but tell me the total".
231
249
  - The SCIM error envelope (`ScimErrorSchema`, `scimType`, `detail`) is a wire contract with Okta,
232
250
  Entra ID, and every other provisioning client. It is not ServerKit's error shape.
233
251
  - The filter grammar and the PATCH value-path semantics follow RFC 7644. Divergence shows up as an
package/README.md CHANGED
@@ -9,7 +9,8 @@ This package provides the protocol layer — schemas, filter parser, PATCH appli
9
9
  - **Resource schemas** — `User`, `Group`, and the `EnterpriseUser` extension (RFC 7643).
10
10
  - **Filter parser** — full SCIM filter grammar (RFC 7644 §3.4.2.2) returning a typed AST.
11
11
  - **PATCH applier** — `add` / `replace` / `remove` ops with the path mini-language (RFC 7644 §3.5.2).
12
- - **Error envelope** — `scimError(status, scimType?)` builder producing the SCIM error JSON.
12
+ - **Attribute projection** — `projectScimResource(resource, schemas, projection)` applies `attributes` / `excludedAttributes` (RFC 7644 §3.9) and always strips `returned: 'never'` attributes such as `password`. The router applies it to every user and group response.
13
+ - **Error envelope** — `scimError(status, scimType?, statusText?)` builder producing the SCIM error JSON; the operator-facing reason goes on `.withDetails({ message })` and is rendered as the envelope's `detail`.
13
14
  - **Abstract repositories** — `ScimUserRepository`, `ScimGroupRepository`. The consumer implements these against their datastore.
14
15
  - **Services** — `ScimUserService`, `ScimGroupService`, `ScimServiceProviderService`.
15
16
  - **Koa middleware** — `scimErrorMiddleware()`, `scimContentTypeMiddleware()`, `requireScimScope(scope)`.
@@ -20,7 +21,16 @@ This package provides the protocol layer — schemas, filter parser, PATCH appli
20
21
  ```ts
21
22
  import Koa from 'koa';
22
23
  import { ServerKitContext, serverKitContextMiddleware, authenticationMiddleware } from '@maroonedsoftware/koa';
23
- import { createScimRouter, ScimUserRepository, ScimGroupRepository, scimErrorMiddleware } from '@maroonedsoftware/scim';
24
+ import {
25
+ createScimRouter,
26
+ requireScimScope,
27
+ ScimGroupRepository,
28
+ ScimGroupService,
29
+ ScimServiceProviderService,
30
+ ScimUserRepository,
31
+ ScimUserService,
32
+ scimErrorMiddleware,
33
+ } from '@maroonedsoftware/scim';
24
34
 
25
35
  class MyScimUserRepository extends ScimUserRepository {
26
36
  // implement against your datastore
@@ -34,23 +44,28 @@ const app = new Koa();
34
44
  app.use(serverKitContextMiddleware(container));
35
45
  app.use(authenticationMiddleware());
36
46
 
47
+ // The router takes services, not repositories: the service owns id and meta
48
+ // assignment, uniqueness, and PATCH application.
49
+ const serviceProviderService = new ScimServiceProviderService({
50
+ documentationUri: 'https://example.com/scim/docs',
51
+ patch: { supported: true },
52
+ filter: { supported: true, maxResults: 200 },
53
+ sort: { supported: true },
54
+ });
55
+
37
56
  const scimRouter = createScimRouter({
38
- userRepository: new MyScimUserRepository(),
39
- groupRepository: new MyScimGroupRepository(),
40
- basePath: '/scim/v2',
41
- serviceProviderConfig: {
42
- documentationUri: 'https://example.com/scim/docs',
43
- patch: { supported: true },
44
- bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
45
- filter: { supported: true, maxResults: 200 },
46
- changePassword: { supported: false },
47
- sort: { supported: true },
48
- etag: { supported: false },
49
- },
57
+ userService: new ScimUserService(new MyScimUserRepository(), logger),
58
+ groupService: new ScimGroupService(new MyScimGroupRepository(), logger),
59
+ serviceProviderService,
60
+ routeGuards: [requireScimScope('scim')],
61
+ // Where this router is reachable. Set it whenever you mount under a prefix,
62
+ // or `meta.location` and the `Location` header will be wrong.
63
+ baseUrl: 'https://api.example.com/scim/v2',
50
64
  });
51
65
 
52
66
  app.use(scimErrorMiddleware());
53
67
  app.use(scimRouter.routes());
68
+ app.use(scimRouter.allowedMethods());
54
69
  ```
55
70
 
56
71
  ## Authentication
@@ -18,7 +18,16 @@ export declare class ScimError extends HttpError {
18
18
  /** SCIM-specific error subtype, see RFC 7644 §3.12. */
19
19
  readonly scimType?: ScimErrorType;
20
20
  constructor(statusCode: HttpStatusCodes, scimType?: ScimErrorType, message?: HttpStatusMessage<HttpStatusCodes>);
21
- /** Build the SCIM error JSON body for this error. */
21
+ /**
22
+ * Build the SCIM error JSON body for this error.
23
+ *
24
+ * `detail` prefers a string `message` from {@link ServerkitError.details}, falling
25
+ * back to the error's own message. The package raises errors as
26
+ * `scimError(409, 'uniqueness', 'Conflict').withDetails({ message: '...' })`, where
27
+ * the constructor's third argument is only the HTTP status text; without this
28
+ * preference every provisioning client would see `"detail": "Conflict"` and never
29
+ * the reason.
30
+ */
22
31
  toScimBody(): {
23
32
  schemas: [typeof ScimErrorSchema];
24
33
  status: string;
@@ -1 +1 @@
1
- {"version":3,"file":"scim.error.d.ts","sourceRoot":"","sources":["../../src/errors/scim.error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,6CAA6C;AAC7C,eAAO,MAAM,eAAe,gDAAgD,CAAC;AAE7E;;;GAGG;AACH,MAAM,MAAM,aAAa,GACrB,eAAe,GACf,SAAS,GACT,YAAY,GACZ,YAAY,GACZ,eAAe,GACf,aAAa,GACb,UAAU,GACV,cAAc,GACd,aAAa,GACb,WAAW,GACX,mBAAmB,GACnB,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;;;;;GAOG;AACH,qBAAa,SAAU,SAAQ,SAAS;IACtC,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC;gBAEtB,UAAU,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE,iBAAiB,CAAC,eAAe,CAAC;IAQ/G,qDAAqD;IACrD,UAAU,IAAI;QAAE,OAAO,EAAE,CAAC,OAAO,eAAe,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,aAAa,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;CAQ/G;AAED,wCAAwC;AACxC,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,SAAuC,CAAC;AAE9F;;;;;;;;;GASG;AACH,eAAO,MAAM,SAAS,GAAI,MAAM,SAAS,eAAe,EACtD,YAAY,MAAM,EAClB,WAAW,aAAa,EACxB,UAAU,iBAAiB,CAAC,MAAM,CAAC,KAClC,SAAyD,CAAC"}
1
+ {"version":3,"file":"scim.error.d.ts","sourceRoot":"","sources":["../../src/errors/scim.error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,6CAA6C;AAC7C,eAAO,MAAM,eAAe,gDAAgD,CAAC;AAE7E;;;GAGG;AACH,MAAM,MAAM,aAAa,GACrB,eAAe,GACf,SAAS,GACT,YAAY,GACZ,YAAY,GACZ,eAAe,GACf,aAAa,GACb,UAAU,GACV,cAAc,GACd,aAAa,GACb,WAAW,GACX,mBAAmB,GACnB,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;;;;;GAOG;AACH,qBAAa,SAAU,SAAQ,SAAS;IACtC,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC;gBAEtB,UAAU,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE,iBAAiB,CAAC,eAAe,CAAC;IAQ/G;;;;;;;;;OASG;IACH,UAAU,IAAI;QAAE,OAAO,EAAE,CAAC,OAAO,eAAe,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,aAAa,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;CAS/G;AAED,wCAAwC;AACxC,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,SAAuC,CAAC;AAE9F;;;;;;;;;GASG;AACH,eAAO,MAAM,SAAS,GAAI,MAAM,SAAS,eAAe,EACtD,YAAY,MAAM,EAClB,WAAW,aAAa,EACxB,UAAU,iBAAiB,CAAC,MAAM,CAAC,KAClC,SAAyD,CAAC"}
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './types/patch.op.js';
6
6
  export * from './schemas/index.js';
7
7
  export * from './filter/index.js';
8
8
  export * from './patch/index.js';
9
+ export * from './projection/index.js';
9
10
  export * from './errors/scim.error.js';
10
11
  export * from './repositories/index.js';
11
12
  export * from './services/index.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,oBAAoB,CAAC;AAGnC,cAAc,mBAAmB,CAAC;AAGlC,cAAc,kBAAkB,CAAC;AAGjC,cAAc,wBAAwB,CAAC;AAGvC,cAAc,yBAAyB,CAAC;AAGxC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,oBAAoB,CAAC;AAGnC,cAAc,mBAAmB,CAAC;AAGlC,cAAc,kBAAkB,CAAC;AAGjC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,wBAAwB,CAAC;AAGvC,cAAc,yBAAyB,CAAC;AAGxC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -662,8 +662,18 @@ var ScimError = class extends HttpError {
662
662
  super(statusCode, message);
663
663
  this.scimType = scimType;
664
664
  }
665
- /** Build the SCIM error JSON body for this error. */
665
+ /**
666
+ * Build the SCIM error JSON body for this error.
667
+ *
668
+ * `detail` prefers a string `message` from {@link ServerkitError.details}, falling
669
+ * back to the error's own message. The package raises errors as
670
+ * `scimError(409, 'uniqueness', 'Conflict').withDetails({ message: '...' })`, where
671
+ * the constructor's third argument is only the HTTP status text; without this
672
+ * preference every provisioning client would see `"detail": "Conflict"` and never
673
+ * the reason.
674
+ */
666
675
  toScimBody() {
676
+ const detailFromDetails = this.details?.message;
667
677
  return {
668
678
  schemas: [
669
679
  ScimErrorSchema
@@ -672,7 +682,7 @@ var ScimError = class extends HttpError {
672
682
  ...this.scimType ? {
673
683
  scimType: this.scimType
674
684
  } : {},
675
- detail: this.message
685
+ detail: typeof detailFromDetails === "string" ? detailFromDetails : this.message
676
686
  };
677
687
  }
678
688
  };
@@ -1320,18 +1330,26 @@ var mergeObject = /* @__PURE__ */ __name((target, source, kind) => {
1320
1330
  for (const [key, val] of Object.entries(source)) {
1321
1331
  const existing = out[key];
1322
1332
  if (Array.isArray(existing) && Array.isArray(val)) {
1323
- out[key] = [
1333
+ defineOwn(out, key, [
1324
1334
  ...existing,
1325
1335
  ...val
1326
- ];
1336
+ ]);
1327
1337
  } else if (isPlainObject(existing) && isPlainObject(val)) {
1328
- out[key] = mergeObject(existing, val, kind);
1338
+ defineOwn(out, key, mergeObject(existing, val, kind));
1329
1339
  } else {
1330
- out[key] = val;
1340
+ defineOwn(out, key, val);
1331
1341
  }
1332
1342
  }
1333
1343
  return out;
1334
1344
  }, "mergeObject");
1345
+ var defineOwn = /* @__PURE__ */ __name((target, key, value) => {
1346
+ Object.defineProperty(target, key, {
1347
+ value,
1348
+ writable: true,
1349
+ enumerable: true,
1350
+ configurable: true
1351
+ });
1352
+ }, "defineOwn");
1335
1353
  var evaluateFilter = /* @__PURE__ */ __name((item, filter) => {
1336
1354
  switch (filter.kind) {
1337
1355
  case "comparison":
@@ -1391,6 +1409,98 @@ var isPlainObject = /* @__PURE__ */ __name((value) => {
1391
1409
  return typeof value === "object" && value !== null && !Array.isArray(value);
1392
1410
  }, "isPlainObject");
1393
1411
 
1412
+ // src/projection/attribute.projection.ts
1413
+ var ALWAYS_RETURNED = /* @__PURE__ */ new Set([
1414
+ "id",
1415
+ "schemas",
1416
+ "meta"
1417
+ ]);
1418
+ var isPlainObject2 = /* @__PURE__ */ __name((value) => typeof value === "object" && value !== null && !Array.isArray(value), "isPlainObject");
1419
+ var splitPath = /* @__PURE__ */ __name((path) => {
1420
+ const urnBoundary = path.lastIndexOf(":");
1421
+ if (urnBoundary === -1) return path.split(".");
1422
+ return [
1423
+ path.slice(0, urnBoundary),
1424
+ ...path.slice(urnBoundary + 1).split(".")
1425
+ ];
1426
+ }, "splitPath");
1427
+ var PathSet = class PathSet2 {
1428
+ static {
1429
+ __name(this, "PathSet");
1430
+ }
1431
+ paths;
1432
+ constructor(paths) {
1433
+ this.paths = paths.map((p) => splitPath(p).map((s) => s.toLowerCase()));
1434
+ }
1435
+ get empty() {
1436
+ return this.paths.length === 0;
1437
+ }
1438
+ /** Whether `segments` is at or above a requested path — `name` matches a request for `name.givenName`. */
1439
+ coversOrLeadsTo(segments) {
1440
+ const target = segments.map((s) => s.toLowerCase());
1441
+ return this.paths.some((path) => {
1442
+ const shared = Math.min(path.length, target.length);
1443
+ for (let i = 0; i < shared; i++) {
1444
+ if (path[i] !== target[i]) return false;
1445
+ }
1446
+ return true;
1447
+ });
1448
+ }
1449
+ /** Whether `segments` is at or below a requested path — `name.givenName` matches a request for `name`. */
1450
+ isAtOrBelow(segments) {
1451
+ const target = segments.map((s) => s.toLowerCase());
1452
+ return this.paths.some((path) => {
1453
+ if (path.length > target.length) return false;
1454
+ return path.every((segment, i) => segment === target[i]);
1455
+ });
1456
+ }
1457
+ };
1458
+ var findDefinition = /* @__PURE__ */ __name((definitions, name) => definitions.find((d) => d.name.toLowerCase() === name.toLowerCase()), "findDefinition");
1459
+ var projectScimResource = /* @__PURE__ */ __name((resource, schemas, projection = {}) => {
1460
+ const requested = new PathSet(projection.attributes ?? []);
1461
+ const excluded = new PathSet(projection.excludedAttributes ?? []);
1462
+ const mode = !requested.empty ? "include" : !excluded.empty ? "exclude" : "default";
1463
+ const [core, ...extensions] = schemas;
1464
+ const extensionsById = new Map(extensions.map((schema) => [
1465
+ schema.id.toLowerCase(),
1466
+ schema
1467
+ ]));
1468
+ return projectObject(resource, core?.attributes ?? [], extensionsById, [], mode, requested, excluded);
1469
+ }, "projectScimResource");
1470
+ var projectObject = /* @__PURE__ */ __name((source, definitions, extensionsById, prefix, mode, requested, excluded) => {
1471
+ const out = {};
1472
+ for (const [key, value] of Object.entries(source)) {
1473
+ const segments = [
1474
+ ...prefix,
1475
+ key
1476
+ ];
1477
+ const extension = prefix.length === 0 ? extensionsById.get(key.toLowerCase()) : void 0;
1478
+ const definition = findDefinition(definitions, key);
1479
+ const alwaysReturned = definition?.returned === "always" || prefix.length === 0 && ALWAYS_RETURNED.has(key.toLowerCase());
1480
+ if (definition?.returned === "never") continue;
1481
+ if (!alwaysReturned) {
1482
+ if (mode === "include" && !requested.coversOrLeadsTo(segments)) continue;
1483
+ if (mode === "exclude" && excluded.isAtOrBelow(segments)) continue;
1484
+ if (mode !== "include" && definition?.returned === "request") continue;
1485
+ }
1486
+ if (extension && isPlainObject2(value)) {
1487
+ out[key] = projectObject(value, extension.attributes, extensionsById, segments, mode, requested, excluded);
1488
+ continue;
1489
+ }
1490
+ const subDefinitions = definition?.subAttributes;
1491
+ if (subDefinitions && Array.isArray(value)) {
1492
+ out[key] = value.map((entry) => isPlainObject2(entry) ? projectObject(entry, subDefinitions, extensionsById, segments, mode, requested, excluded) : entry);
1493
+ continue;
1494
+ }
1495
+ if (subDefinitions && isPlainObject2(value)) {
1496
+ out[key] = projectObject(value, subDefinitions, extensionsById, segments, mode, requested, excluded);
1497
+ continue;
1498
+ }
1499
+ out[key] = value;
1500
+ }
1501
+ return out;
1502
+ }, "projectObject");
1503
+
1394
1504
  // src/repositories/scim.user.repository.ts
1395
1505
  var ScimUserRepository = class {
1396
1506
  static {
@@ -1455,6 +1565,9 @@ var ScimUserService = class {
1455
1565
  * and `meta.lastModified`, and ensures the resource's `schemas` includes the
1456
1566
  * core User URN (and the EnterpriseUser URN when the extension is present).
1457
1567
  *
1568
+ * An `id` on the payload is ignored: RFC 7643 §3.1 makes it readOnly and
1569
+ * server-assigned.
1570
+ *
1458
1571
  * @throws {ScimError} 400 `invalidValue` when `userName` is missing.
1459
1572
  * @throws {ScimError} 409 `uniqueness` when `userName` already exists.
1460
1573
  */
@@ -1471,7 +1584,7 @@ var ScimUserService = class {
1471
1584
  });
1472
1585
  }
1473
1586
  const now = DateTime.utc().toISO();
1474
- const id = payload.id ?? randomUUID();
1587
+ const id = randomUUID();
1475
1588
  const user = {
1476
1589
  ...payload,
1477
1590
  id,
@@ -1645,7 +1758,8 @@ var ScimGroupService = class {
1645
1758
  }
1646
1759
  /**
1647
1760
  * Create a new group. Assigns a server-generated `id` and fills `meta`
1648
- * timestamps.
1761
+ * timestamps. An `id` on the payload is ignored: RFC 7643 §3.1 makes it
1762
+ * readOnly and server-assigned.
1649
1763
  *
1650
1764
  * @throws {ScimError} 400 `invalidValue` when `displayName` is missing.
1651
1765
  * @throws {ScimError} 409 `uniqueness` when `displayName` already exists.
@@ -1663,7 +1777,7 @@ var ScimGroupService = class {
1663
1777
  });
1664
1778
  }
1665
1779
  const now = DateTime2.utc().toISO();
1666
- const id = payload.id ?? randomUUID2();
1780
+ const id = randomUUID2();
1667
1781
  const group = {
1668
1782
  ...payload,
1669
1783
  id,
@@ -1947,6 +2061,14 @@ var createScimRouter = /* @__PURE__ */ __name((options) => {
1947
2061
  "application/json"
1948
2062
  ]);
1949
2063
  const maxResults = options.maxResults ?? options.serviceProviderService.getServiceProviderConfig().filter.maxResults ?? 200;
2064
+ const baseUrl = options.baseUrl?.replace(/\/+$/, "");
2065
+ const withLocation = /* @__PURE__ */ __name((resource, endpoint) => baseUrl ? {
2066
+ ...resource,
2067
+ meta: {
2068
+ ...resource.meta,
2069
+ location: `${baseUrl}/${endpoint}/${resource.id}`
2070
+ }
2071
+ } : resource, "withLocation");
1950
2072
  router.get("/ServiceProviderConfig", ...guards, async (ctx) => {
1951
2073
  ctx.body = options.serviceProviderService.getServiceProviderConfig();
1952
2074
  ctx.type = SCIM_MEDIA_TYPE;
@@ -1970,37 +2092,38 @@ var createScimRouter = /* @__PURE__ */ __name((options) => {
1970
2092
  router.get("/Users", ...guards, async (ctx) => {
1971
2093
  const query = parseListQueryFromUrl(ctx.query, maxResults);
1972
2094
  const result = await options.userService.list(query);
1973
- ctx.body = listEnvelope(result.resources, query, result.totalResults);
2095
+ ctx.body = listEnvelope(result.resources.map((user) => projectUser(withLocation(user, "Users"), query)), query, result.totalResults);
1974
2096
  ctx.type = SCIM_MEDIA_TYPE;
1975
2097
  });
1976
2098
  router.post("/Users/.search", ...guards, json, async (ctx) => {
1977
2099
  const requestBody = takeRequestBody(ctx);
1978
2100
  const query = parseListQueryFromBody(requestBody, maxResults);
1979
2101
  const result = await options.userService.list(query);
1980
- ctx.body = listEnvelope(result.resources, query, result.totalResults);
2102
+ ctx.body = listEnvelope(result.resources.map((user) => projectUser(withLocation(user, "Users"), query)), query, result.totalResults);
1981
2103
  ctx.type = SCIM_MEDIA_TYPE;
1982
2104
  });
1983
2105
  router.post("/Users", ...guards, json, async (ctx) => {
1984
2106
  const payload = takeRequestBody(ctx);
1985
2107
  const created = await options.userService.create(payload);
1986
2108
  ctx.status = 201;
1987
- ctx.body = created;
2109
+ const located = withLocation(created, "Users");
2110
+ ctx.body = projectUser(located, parseProjectionFromUrl(ctx.query));
1988
2111
  ctx.type = SCIM_MEDIA_TYPE;
1989
- if (created.meta.location) ctx.set("Location", created.meta.location);
2112
+ if (located.meta.location) ctx.set("Location", located.meta.location);
1990
2113
  });
1991
2114
  router.get("/Users/:id", ...guards, async (ctx) => {
1992
- ctx.body = await options.userService.get(ctx.params.id);
2115
+ ctx.body = projectUser(withLocation(await options.userService.get(ctx.params.id), "Users"), parseProjectionFromUrl(ctx.query));
1993
2116
  ctx.type = SCIM_MEDIA_TYPE;
1994
2117
  });
1995
2118
  router.put("/Users/:id", ...guards, json, async (ctx) => {
1996
2119
  const payload = takeRequestBody(ctx);
1997
- ctx.body = await options.userService.replace(ctx.params.id, payload);
2120
+ ctx.body = projectUser(withLocation(await options.userService.replace(ctx.params.id, payload), "Users"), parseProjectionFromUrl(ctx.query));
1998
2121
  ctx.type = SCIM_MEDIA_TYPE;
1999
2122
  });
2000
2123
  router.patch("/Users/:id", ...guards, json, async (ctx) => {
2001
2124
  const requestBody = takeRequestBody(ctx);
2002
2125
  const ops = validatePatchRequest(requestBody);
2003
- ctx.body = await options.userService.patch(ctx.params.id, ops);
2126
+ ctx.body = projectUser(withLocation(await options.userService.patch(ctx.params.id, ops), "Users"), parseProjectionFromUrl(ctx.query));
2004
2127
  ctx.type = SCIM_MEDIA_TYPE;
2005
2128
  });
2006
2129
  router.delete("/Users/:id", ...guards, async (ctx) => {
@@ -2010,37 +2133,38 @@ var createScimRouter = /* @__PURE__ */ __name((options) => {
2010
2133
  router.get("/Groups", ...guards, async (ctx) => {
2011
2134
  const query = parseListQueryFromUrl(ctx.query, maxResults);
2012
2135
  const result = await options.groupService.list(query);
2013
- ctx.body = listEnvelope(result.resources, query, result.totalResults);
2136
+ ctx.body = listEnvelope(result.resources.map((group) => projectGroup(withLocation(group, "Groups"), query)), query, result.totalResults);
2014
2137
  ctx.type = SCIM_MEDIA_TYPE;
2015
2138
  });
2016
2139
  router.post("/Groups/.search", ...guards, json, async (ctx) => {
2017
2140
  const requestBody = takeRequestBody(ctx);
2018
2141
  const query = parseListQueryFromBody(requestBody, maxResults);
2019
2142
  const result = await options.groupService.list(query);
2020
- ctx.body = listEnvelope(result.resources, query, result.totalResults);
2143
+ ctx.body = listEnvelope(result.resources.map((group) => projectGroup(withLocation(group, "Groups"), query)), query, result.totalResults);
2021
2144
  ctx.type = SCIM_MEDIA_TYPE;
2022
2145
  });
2023
2146
  router.post("/Groups", ...guards, json, async (ctx) => {
2024
2147
  const payload = takeRequestBody(ctx);
2025
2148
  const created = await options.groupService.create(payload);
2026
2149
  ctx.status = 201;
2027
- ctx.body = created;
2150
+ const located = withLocation(created, "Groups");
2151
+ ctx.body = projectGroup(located, parseProjectionFromUrl(ctx.query));
2028
2152
  ctx.type = SCIM_MEDIA_TYPE;
2029
- if (created.meta.location) ctx.set("Location", created.meta.location);
2153
+ if (located.meta.location) ctx.set("Location", located.meta.location);
2030
2154
  });
2031
2155
  router.get("/Groups/:id", ...guards, async (ctx) => {
2032
- ctx.body = await options.groupService.get(ctx.params.id);
2156
+ ctx.body = projectGroup(withLocation(await options.groupService.get(ctx.params.id), "Groups"), parseProjectionFromUrl(ctx.query));
2033
2157
  ctx.type = SCIM_MEDIA_TYPE;
2034
2158
  });
2035
2159
  router.put("/Groups/:id", ...guards, json, async (ctx) => {
2036
2160
  const payload = takeRequestBody(ctx);
2037
- ctx.body = await options.groupService.replace(ctx.params.id, payload);
2161
+ ctx.body = projectGroup(withLocation(await options.groupService.replace(ctx.params.id, payload), "Groups"), parseProjectionFromUrl(ctx.query));
2038
2162
  ctx.type = SCIM_MEDIA_TYPE;
2039
2163
  });
2040
2164
  router.patch("/Groups/:id", ...guards, json, async (ctx) => {
2041
2165
  const requestBody = takeRequestBody(ctx);
2042
2166
  const ops = validatePatchRequest(requestBody);
2043
- ctx.body = await options.groupService.patch(ctx.params.id, ops);
2167
+ ctx.body = projectGroup(withLocation(await options.groupService.patch(ctx.params.id, ops), "Groups"), parseProjectionFromUrl(ctx.query));
2044
2168
  ctx.type = SCIM_MEDIA_TYPE;
2045
2169
  });
2046
2170
  router.delete("/Groups/:id", ...guards, async (ctx) => {
@@ -2049,6 +2173,18 @@ var createScimRouter = /* @__PURE__ */ __name((options) => {
2049
2173
  });
2050
2174
  return router;
2051
2175
  }, "createScimRouter");
2176
+ var userSchemas = [
2177
+ userSchema,
2178
+ enterpriseUserSchema
2179
+ ];
2180
+ var projectUser = /* @__PURE__ */ __name((user, projection) => projectScimResource(user, userSchemas, projection), "projectUser");
2181
+ var projectGroup = /* @__PURE__ */ __name((group, projection) => projectScimResource(group, [
2182
+ groupSchema
2183
+ ], projection), "projectGroup");
2184
+ var parseProjectionFromUrl = /* @__PURE__ */ __name((query) => ({
2185
+ attributes: parseCsvParam(pickStringParam(query, "attributes")),
2186
+ excludedAttributes: parseCsvParam(pickStringParam(query, "excludedAttributes"))
2187
+ }), "parseProjectionFromUrl");
2052
2188
  var takeRequestBody = /* @__PURE__ */ __name((ctx) => {
2053
2189
  return ctx.parsedBody;
2054
2190
  }, "takeRequestBody");
@@ -2057,7 +2193,7 @@ var parseListQueryFromUrl = /* @__PURE__ */ __name((query, maxResults) => {
2057
2193
  return {
2058
2194
  filter: filterRaw ? parseScimFilter(filterRaw) : void 0,
2059
2195
  startIndex: parsePositiveInt(pickStringParam(query, "startIndex"), 1),
2060
- count: clamp(parsePositiveInt(pickStringParam(query, "count"), maxResults), 0, maxResults),
2196
+ count: clamp(parseCount(pickStringParam(query, "count"), maxResults), 0, maxResults),
2061
2197
  sortBy: pickStringParam(query, "sortBy"),
2062
2198
  sortOrder: parseSortOrder(pickStringParam(query, "sortOrder")),
2063
2199
  attributes: parseCsvParam(pickStringParam(query, "attributes")),
@@ -2065,7 +2201,7 @@ var parseListQueryFromUrl = /* @__PURE__ */ __name((query, maxResults) => {
2065
2201
  };
2066
2202
  }, "parseListQueryFromUrl");
2067
2203
  var parseListQueryFromBody = /* @__PURE__ */ __name((body, maxResults) => {
2068
- if (!isPlainObject2(body)) {
2204
+ if (!isPlainObject3(body)) {
2069
2205
  throw scimError(400, "invalidSyntax", "Bad Request").withDetails({
2070
2206
  message: "Search body must be a JSON object"
2071
2207
  });
@@ -2085,6 +2221,12 @@ var parseSortOrder = /* @__PURE__ */ __name((raw) => {
2085
2221
  if (raw === "ascending" || raw === "descending") return raw;
2086
2222
  return void 0;
2087
2223
  }, "parseSortOrder");
2224
+ var parseCount = /* @__PURE__ */ __name((raw, fallback) => {
2225
+ if (raw === void 0) return fallback;
2226
+ const n = Number(raw);
2227
+ if (!Number.isFinite(n)) return fallback;
2228
+ return n < 1 ? 0 : Math.floor(n);
2229
+ }, "parseCount");
2088
2230
  var parsePositiveInt = /* @__PURE__ */ __name((raw, fallback) => {
2089
2231
  if (raw === void 0) return fallback;
2090
2232
  const n = Number(raw);
@@ -2104,7 +2246,7 @@ var pickStringParam = /* @__PURE__ */ __name((query, key) => {
2104
2246
  return void 0;
2105
2247
  }, "pickStringParam");
2106
2248
  var validatePatchRequest = /* @__PURE__ */ __name((body) => {
2107
- if (!isPlainObject2(body)) {
2249
+ if (!isPlainObject3(body)) {
2108
2250
  throw scimError(400, "invalidSyntax", "Bad Request").withDetails({
2109
2251
  message: "PATCH request must be a JSON object"
2110
2252
  });
@@ -2130,7 +2272,7 @@ var listEnvelope = /* @__PURE__ */ __name((resources, query, total) => ({
2130
2272
  itemsPerPage: resources.length,
2131
2273
  Resources: resources
2132
2274
  }), "listEnvelope");
2133
- var isPlainObject2 = /* @__PURE__ */ __name((value) => {
2275
+ var isPlainObject3 = /* @__PURE__ */ __name((value) => {
2134
2276
  return typeof value === "object" && value !== null && !Array.isArray(value);
2135
2277
  }, "isPlainObject");
2136
2278
  export {
@@ -2158,6 +2300,7 @@ export {
2158
2300
  groupResourceType,
2159
2301
  groupSchema,
2160
2302
  parseScimFilter,
2303
+ projectScimResource,
2161
2304
  requireScimScope,
2162
2305
  scimContentTypeMiddleware,
2163
2306
  scimError,