@_mustachio/openauth 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/dist/esm/adapters/d1/index.js +18 -9
  2. package/dist/esm/adapters/d1/migrations.js +7 -2
  3. package/dist/esm/adapters/d1/session-store.js +39 -0
  4. package/dist/esm/adapters/d1/session.js +2 -2
  5. package/dist/esm/adapters/durable-object/index.js +4 -0
  6. package/dist/esm/adapters/durable-object/session-store.js +42 -0
  7. package/dist/esm/adapters/dynamo/index.js +24 -7
  8. package/dist/esm/adapters/dynamo/session-store.js +49 -0
  9. package/dist/esm/adapters/kms/index.js +6 -2
  10. package/dist/esm/adapters/kv/index.js +6 -2
  11. package/dist/esm/adapters/memory/index.js +24 -7
  12. package/dist/esm/adapters/memory/session-store.js +23 -0
  13. package/dist/esm/adapters/postgres/executor.js +2 -2
  14. package/dist/esm/adapters/postgres/index.js +33 -10
  15. package/dist/esm/adapters/postgres/migrations.js +8 -2
  16. package/dist/esm/adapters/postgres/session-store.js +42 -0
  17. package/dist/esm/client.js +27 -27
  18. package/dist/esm/domain/authorize.js +2 -2
  19. package/dist/esm/domain/callback.js +121 -7
  20. package/dist/esm/domain/client-auth.js +2 -2
  21. package/dist/esm/domain/crypto.js +14 -14
  22. package/dist/esm/domain/discovery.js +2 -2
  23. package/dist/esm/domain/dpop.js +4 -4
  24. package/dist/esm/domain/id-token.js +5 -5
  25. package/dist/esm/domain/jwt.js +4 -4
  26. package/dist/esm/domain/method-dispatch.js +27 -1
  27. package/dist/esm/domain/method-route.js +80 -1
  28. package/dist/esm/domain/par.js +2 -2
  29. package/dist/esm/domain/pkce.js +2 -2
  30. package/dist/esm/domain/revoke.js +2 -2
  31. package/dist/esm/domain/scim/discovery.js +206 -0
  32. package/dist/esm/domain/scim/filter.js +177 -0
  33. package/dist/esm/domain/scim/handle.js +338 -0
  34. package/dist/esm/domain/scim/patch.js +568 -0
  35. package/dist/esm/domain/scim/resource.js +256 -0
  36. package/dist/esm/domain/state-envelope.js +2 -2
  37. package/dist/esm/domain/token.js +5 -5
  38. package/dist/esm/error.js +8 -8
  39. package/dist/esm/http/cookies.js +19 -2
  40. package/dist/esm/http/errors.js +4 -4
  41. package/dist/esm/http/handlers/authorize.js +3 -19
  42. package/dist/esm/http/handlers/callback.js +7 -1
  43. package/dist/esm/http/handlers/metadata.js +2 -2
  44. package/dist/esm/http/handlers/method-route.js +47 -2
  45. package/dist/esm/http/handlers/revocation.js +2 -2
  46. package/dist/esm/http/handlers/scim.js +59 -0
  47. package/dist/esm/http/middleware/tenant.js +31 -6
  48. package/dist/esm/http/router.js +3 -0
  49. package/dist/esm/http/schemas/authorize.js +2 -2
  50. package/dist/esm/http/schemas/revocation.js +2 -2
  51. package/dist/esm/http/schemas/token.js +7 -7
  52. package/dist/esm/index.js +34 -32
  53. package/dist/esm/methods/oauth2-factory.js +2 -2
  54. package/dist/esm/methods/password-hash.js +2 -2
  55. package/dist/esm/methods/providers/index.js +30 -14
  56. package/dist/esm/methods/saml-sp/acs.js +232 -0
  57. package/dist/esm/methods/saml-sp/attributes.js +77 -0
  58. package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
  59. package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
  60. package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
  61. package/dist/esm/methods/saml-sp/factory.js +81 -0
  62. package/dist/esm/methods/saml-sp/index.js +7 -0
  63. package/dist/esm/methods/saml-sp/metadata.js +71 -0
  64. package/dist/esm/methods/saml-sp/method.js +29 -0
  65. package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
  66. package/dist/esm/methods/saml-sp/saml-instance.js +59 -0
  67. package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
  68. package/dist/esm/methods/saml-sp/sls.js +154 -0
  69. package/dist/esm/methods/saml-sp/types.js +0 -0
  70. package/dist/esm/ports/scim-directory.js +0 -0
  71. package/dist/esm/types/error.js +5 -0
  72. package/dist/esm/types/result.js +3 -3
  73. package/dist/esm/types/scim.js +0 -0
  74. package/dist/esm/ui/forms.js +3 -3
  75. package/dist/esm/ui/index.js +8 -5
  76. package/dist/esm/ui/picker.js +2 -2
  77. package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
  78. package/dist/types/adapters/d1/session-store.d.ts +3 -0
  79. package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
  80. package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
  81. package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
  82. package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
  83. package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
  84. package/dist/types/adapters/memory/session-store.d.ts +3 -0
  85. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  86. package/dist/types/adapters/postgres/migrations.d.ts +1 -1
  87. package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
  88. package/dist/types/adapters/postgres/session-store.d.ts +3 -0
  89. package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
  90. package/dist/types/domain/callback.d.ts +16 -1
  91. package/dist/types/domain/callback.d.ts.map +1 -1
  92. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  93. package/dist/types/domain/method-route.d.ts +57 -1
  94. package/dist/types/domain/method-route.d.ts.map +1 -1
  95. package/dist/types/domain/scim/discovery.d.ts +11 -0
  96. package/dist/types/domain/scim/discovery.d.ts.map +1 -0
  97. package/dist/types/domain/scim/filter.d.ts +51 -0
  98. package/dist/types/domain/scim/filter.d.ts.map +1 -0
  99. package/dist/types/domain/scim/handle.d.ts +35 -0
  100. package/dist/types/domain/scim/handle.d.ts.map +1 -0
  101. package/dist/types/domain/scim/patch.d.ts +73 -0
  102. package/dist/types/domain/scim/patch.d.ts.map +1 -0
  103. package/dist/types/domain/scim/resource.d.ts +56 -0
  104. package/dist/types/domain/scim/resource.d.ts.map +1 -0
  105. package/dist/types/http/context.d.ts +6 -1
  106. package/dist/types/http/context.d.ts.map +1 -1
  107. package/dist/types/http/cookies.d.ts +9 -1
  108. package/dist/types/http/cookies.d.ts.map +1 -1
  109. package/dist/types/http/handlers/callback.d.ts.map +1 -1
  110. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  111. package/dist/types/http/handlers/scim.d.ts +3 -0
  112. package/dist/types/http/handlers/scim.d.ts.map +1 -0
  113. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  114. package/dist/types/http/router.d.ts.map +1 -1
  115. package/dist/types/index.d.ts +3 -1
  116. package/dist/types/index.d.ts.map +1 -1
  117. package/dist/types/methods/saml-sp/acs.d.ts +4 -0
  118. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
  119. package/dist/types/methods/saml-sp/attributes.d.ts +45 -0
  120. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
  121. package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
  122. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
  123. package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
  124. package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
  125. package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
  126. package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
  127. package/dist/types/methods/saml-sp/factory.d.ts +920 -0
  128. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
  129. package/dist/types/methods/saml-sp/index.d.ts +24 -0
  130. package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
  131. package/dist/types/methods/saml-sp/metadata.d.ts +50 -0
  132. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
  133. package/dist/types/methods/saml-sp/method.d.ts +17 -0
  134. package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
  135. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
  136. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
  137. package/dist/types/methods/saml-sp/saml-instance.d.ts +93 -0
  138. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
  139. package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
  140. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
  141. package/dist/types/methods/saml-sp/sls.d.ts +4 -0
  142. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
  143. package/dist/types/methods/saml-sp/types.d.ts +299 -0
  144. package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
  145. package/dist/types/ports/audit-log.d.ts +18 -5
  146. package/dist/types/ports/audit-log.d.ts.map +1 -1
  147. package/dist/types/ports/scim-directory.d.ts +104 -0
  148. package/dist/types/ports/scim-directory.d.ts.map +1 -0
  149. package/dist/types/ports/session-store.d.ts +23 -0
  150. package/dist/types/ports/session-store.d.ts.map +1 -1
  151. package/dist/types/types/error.d.ts +5 -0
  152. package/dist/types/types/error.d.ts.map +1 -1
  153. package/dist/types/types/idp.d.ts +83 -0
  154. package/dist/types/types/idp.d.ts.map +1 -1
  155. package/dist/types/types/method.d.ts +109 -0
  156. package/dist/types/types/method.d.ts.map +1 -1
  157. package/dist/types/types/scim.d.ts +269 -0
  158. package/dist/types/types/scim.d.ts.map +1 -0
  159. package/dist/types/types/tenant.d.ts +10 -0
  160. package/dist/types/types/tenant.d.ts.map +1 -1
  161. package/package.json +9 -1
  162. package/src/adapters/d1/migrations.ts +5 -0
  163. package/src/adapters/d1/session-store.ts +68 -0
  164. package/src/adapters/durable-object/session-store.ts +61 -0
  165. package/src/adapters/dynamo/session-store.ts +63 -0
  166. package/src/adapters/memory/session-store.ts +39 -0
  167. package/src/adapters/postgres/migrations.ts +6 -0
  168. package/src/adapters/postgres/session-store.ts +69 -0
  169. package/src/domain/callback.ts +215 -7
  170. package/src/domain/method-dispatch.ts +53 -1
  171. package/src/domain/method-route.ts +190 -1
  172. package/src/domain/scim/discovery.ts +262 -0
  173. package/src/domain/scim/filter.ts +295 -0
  174. package/src/domain/scim/handle.ts +597 -0
  175. package/src/domain/scim/patch.ts +807 -0
  176. package/src/domain/scim/resource.ts +358 -0
  177. package/src/http/context.ts +9 -0
  178. package/src/http/cookies.ts +19 -1
  179. package/src/http/handlers/authorize.ts +2 -22
  180. package/src/http/handlers/callback.ts +9 -1
  181. package/src/http/handlers/method-route.ts +72 -2
  182. package/src/http/handlers/scim.ts +107 -0
  183. package/src/http/middleware/tenant.ts +47 -1
  184. package/src/http/router.ts +9 -0
  185. package/src/index.ts +23 -0
  186. package/src/methods/saml-sp/acs.ts +449 -0
  187. package/src/methods/saml-sp/attributes.ts +146 -0
  188. package/src/methods/saml-sp/authnrequest.ts +129 -0
  189. package/src/methods/saml-sp/cache-provider.ts +76 -0
  190. package/src/methods/saml-sp/cert-rotation.ts +34 -0
  191. package/src/methods/saml-sp/factory.ts +139 -0
  192. package/src/methods/saml-sp/index.ts +34 -0
  193. package/src/methods/saml-sp/metadata.ts +215 -0
  194. package/src/methods/saml-sp/method.ts +65 -0
  195. package/src/methods/saml-sp/parse-idp-metadata.ts +158 -0
  196. package/src/methods/saml-sp/saml-instance.ts +210 -0
  197. package/src/methods/saml-sp/slo-initiate.ts +180 -0
  198. package/src/methods/saml-sp/sls.ts +307 -0
  199. package/src/methods/saml-sp/types.ts +298 -0
  200. package/src/ports/CONSISTENCY.md +52 -24
  201. package/src/ports/audit-log.ts +18 -5
  202. package/src/ports/scim-directory.ts +166 -0
  203. package/src/ports/session-store.ts +24 -0
  204. package/src/types/error.ts +11 -0
  205. package/src/types/idp.ts +86 -0
  206. package/src/types/method.ts +110 -0
  207. package/src/types/scim.ts +280 -0
  208. package/src/types/tenant.ts +10 -0
@@ -0,0 +1,568 @@
1
+ // src/domain/scim/patch.ts
2
+ import { err, ok } from "../../types/result";
3
+ import {
4
+ parseMembers,
5
+ SCIM_ENTERPRISE_SCHEMA,
6
+ SCIM_PATCH_SCHEMA
7
+ } from "./resource";
8
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
9
+ function coerceBoolean(v) {
10
+ if (typeof v === "boolean")
11
+ return v;
12
+ if (typeof v === "string") {
13
+ const lowered = v.toLowerCase();
14
+ if (lowered === "true")
15
+ return true;
16
+ if (lowered === "false")
17
+ return false;
18
+ }
19
+ return;
20
+ }
21
+ var asString = (v) => typeof v === "string" ? v : undefined;
22
+ function stripEnterprisePrefix(path) {
23
+ if (path.toLowerCase() === SCIM_ENTERPRISE_SCHEMA.toLowerCase()) {
24
+ return { path: "", enterprise: true };
25
+ }
26
+ const prefix = `${SCIM_ENTERPRISE_SCHEMA}:`;
27
+ if (path.toLowerCase().startsWith(prefix.toLowerCase())) {
28
+ return { path: path.slice(prefix.length), enterprise: true };
29
+ }
30
+ const corePrefix = "urn:ietf:params:scim:schemas:core:2.0:user:";
31
+ if (path.toLowerCase().startsWith(corePrefix)) {
32
+ return { path: path.slice(corePrefix.length), enterprise: false };
33
+ }
34
+ return { path, enterprise: false };
35
+ }
36
+ function parseMultiValuedPath(path) {
37
+ const m = /^([A-Za-z]+)\[\s*type\s+eq\s+"([^"]+)"\s*\]\.([A-Za-z]+)$/i.exec(path.trim());
38
+ if (!m)
39
+ return null;
40
+ return {
41
+ attribute: m[1].toLowerCase(),
42
+ type: m[2],
43
+ sub: m[3].toLowerCase()
44
+ };
45
+ }
46
+ function upsertByType(current, type, value) {
47
+ const list = [...current ?? []];
48
+ const byType = list.findIndex((e) => (e.type ?? "").toLowerCase() === type.toLowerCase());
49
+ if (byType >= 0) {
50
+ list[byType] = { ...list[byType], value };
51
+ return list;
52
+ }
53
+ const untyped = list.filter((e) => e.type === undefined);
54
+ if (untyped.length === 1) {
55
+ const idx = list.indexOf(untyped[0]);
56
+ list[idx] = { ...list[idx], value, type };
57
+ return list;
58
+ }
59
+ list.push({ value, type });
60
+ return list;
61
+ }
62
+ var NAME_SUBS = new Set([
63
+ "formatted",
64
+ "familyname",
65
+ "givenname",
66
+ "middlename",
67
+ "honorificprefix",
68
+ "honorificsuffix"
69
+ ]);
70
+ var NAME_CANONICAL = {
71
+ formatted: "formatted",
72
+ familyname: "familyName",
73
+ givenname: "givenName",
74
+ middlename: "middleName",
75
+ honorificprefix: "honorificPrefix",
76
+ honorificsuffix: "honorificSuffix"
77
+ };
78
+ var ENTERPRISE_SUBS = {
79
+ employeenumber: "employeeNumber",
80
+ costcenter: "costCenter",
81
+ organization: "organization",
82
+ division: "division",
83
+ department: "department"
84
+ };
85
+ function invalidPath(detail) {
86
+ return { status: 400, scimType: "invalidPath", detail };
87
+ }
88
+ var SKIP = null;
89
+ function applyOperation(draft, op, rawPath, value) {
90
+ const operation = op.toLowerCase();
91
+ if (operation !== "add" && operation !== "replace" && operation !== "remove") {
92
+ return invalidPath(`unsupported PATCH op "${op}"`);
93
+ }
94
+ if (rawPath === undefined || rawPath.trim().length === 0) {
95
+ if (operation === "remove") {
96
+ return invalidPath('"remove" requires a path');
97
+ }
98
+ if (!isRecord(value)) {
99
+ return invalidPath("a PATCH operation without a path must carry an object value");
100
+ }
101
+ for (const [k, v] of Object.entries(value)) {
102
+ const nested = applyOperation(draft, operation, k, v);
103
+ if (nested)
104
+ return nested;
105
+ }
106
+ return null;
107
+ }
108
+ const { path: stripped, enterprise } = stripEnterprisePrefix(rawPath.trim());
109
+ const removing = operation === "remove";
110
+ const mv = parseMultiValuedPath(stripped);
111
+ if (mv) {
112
+ if (mv.attribute !== "emails" && mv.attribute !== "phonenumbers") {
113
+ return SKIP;
114
+ }
115
+ if (mv.sub !== "value") {
116
+ return invalidPath(`only the ".value" sub-attribute of a multi-valued path is ` + `supported, got ".${mv.sub}"`);
117
+ }
118
+ const key = mv.attribute === "emails" ? "emails" : "phoneNumbers";
119
+ const current = mv.attribute === "emails" ? draft.emails : draft.phoneNumbers;
120
+ if (removing) {
121
+ const filtered = (current ?? []).filter((e) => (e.type ?? "").toLowerCase() !== mv.type.toLowerCase());
122
+ if (key === "emails")
123
+ draft.emails = filtered;
124
+ else
125
+ draft.phoneNumbers = filtered;
126
+ draft.patch[key] = filtered.length > 0 ? filtered : null;
127
+ return null;
128
+ }
129
+ const v = asString(value);
130
+ if (v === undefined) {
131
+ return {
132
+ status: 400,
133
+ scimType: "invalidValue",
134
+ detail: `value for "${rawPath}" must be a string`
135
+ };
136
+ }
137
+ const next = upsertByType(current, mv.type, v);
138
+ if (key === "emails")
139
+ draft.emails = next;
140
+ else
141
+ draft.phoneNumbers = next;
142
+ draft.patch[key] = next;
143
+ return null;
144
+ }
145
+ if (stripped.includes("[")) {
146
+ return invalidPath(`unsupported multi-valued path "${rawPath}"; only ` + `<attr>[type eq "…"].value is supported`);
147
+ }
148
+ const segments = stripped.split(".");
149
+ const head = (segments[0] ?? "").toLowerCase();
150
+ const sub = segments[1]?.toLowerCase();
151
+ if (segments.length > 2) {
152
+ return invalidPath(`path "${rawPath}" is too deeply nested`);
153
+ }
154
+ if (enterprise) {
155
+ if (stripped === "") {
156
+ if (removing) {
157
+ draft.enterprise = undefined;
158
+ draft.patch.enterprise = null;
159
+ return null;
160
+ }
161
+ if (!isRecord(value)) {
162
+ return {
163
+ status: 400,
164
+ scimType: "invalidValue",
165
+ detail: "the enterprise extension requires an object value"
166
+ };
167
+ }
168
+ for (const [k, v] of Object.entries(value)) {
169
+ const nested = applyOperation(draft, operation, `${SCIM_ENTERPRISE_SCHEMA}:${k}`, v);
170
+ if (nested)
171
+ return nested;
172
+ }
173
+ return null;
174
+ }
175
+ const canonical = ENTERPRISE_SUBS[head];
176
+ if (head === "manager") {
177
+ const current = { ...draft.enterprise ?? {} };
178
+ if (removing)
179
+ delete current["manager"];
180
+ else {
181
+ const v = isRecord(value) ? {
182
+ value: asString(value["value"]),
183
+ displayName: asString(value["displayName"])
184
+ } : { value: asString(value) };
185
+ current["manager"] = v;
186
+ }
187
+ draft.enterprise = current;
188
+ draft.patch.enterprise = current;
189
+ return null;
190
+ }
191
+ if (!canonical) {
192
+ return SKIP;
193
+ }
194
+ const current = { ...draft.enterprise ?? {} };
195
+ if (removing)
196
+ delete current[canonical];
197
+ else {
198
+ const v = asString(value);
199
+ if (v === undefined) {
200
+ return {
201
+ status: 400,
202
+ scimType: "invalidValue",
203
+ detail: `value for "${rawPath}" must be a string`
204
+ };
205
+ }
206
+ current[canonical] = v;
207
+ }
208
+ draft.enterprise = current;
209
+ draft.patch.enterprise = current;
210
+ return null;
211
+ }
212
+ if (head === "name") {
213
+ if (sub === undefined) {
214
+ if (removing) {
215
+ draft.name = undefined;
216
+ draft.patch.name = null;
217
+ return null;
218
+ }
219
+ if (!isRecord(value)) {
220
+ return invalidPath('"name" requires an object value');
221
+ }
222
+ const merged = operation === "add" ? { ...draft.name ?? {} } : {};
223
+ for (const [k, v] of Object.entries(value)) {
224
+ const canonical = NAME_CANONICAL[k.toLowerCase()];
225
+ const s = asString(v);
226
+ if (canonical && s !== undefined)
227
+ merged[canonical] = s;
228
+ }
229
+ draft.name = merged;
230
+ draft.patch.name = Object.keys(merged).length > 0 ? merged : null;
231
+ return null;
232
+ }
233
+ if (!NAME_SUBS.has(sub)) {
234
+ return invalidPath(`name sub-attribute "${sub}" is not supported`);
235
+ }
236
+ const canonical = NAME_CANONICAL[sub];
237
+ const merged = { ...draft.name ?? {} };
238
+ if (removing)
239
+ delete merged[canonical];
240
+ else {
241
+ const s = asString(value);
242
+ if (s === undefined) {
243
+ return {
244
+ status: 400,
245
+ scimType: "invalidValue",
246
+ detail: `value for "${rawPath}" must be a string`
247
+ };
248
+ }
249
+ merged[canonical] = s;
250
+ }
251
+ draft.name = merged;
252
+ draft.patch.name = Object.keys(merged).length > 0 ? merged : null;
253
+ return null;
254
+ }
255
+ if (sub !== undefined) {
256
+ return SKIP;
257
+ }
258
+ switch (head) {
259
+ case "active": {
260
+ if (removing) {
261
+ return invalidPath('"active" cannot be removed; set it to false');
262
+ }
263
+ const b = coerceBoolean(value);
264
+ if (b === undefined) {
265
+ return {
266
+ status: 400,
267
+ scimType: "invalidValue",
268
+ detail: `"active" must be a boolean, got ${JSON.stringify(value)}`
269
+ };
270
+ }
271
+ draft.patch.active = b;
272
+ return null;
273
+ }
274
+ case "username": {
275
+ if (removing) {
276
+ return invalidPath('"userName" cannot be removed');
277
+ }
278
+ const s = asString(value);
279
+ if (s === undefined || s.length === 0) {
280
+ return {
281
+ status: 400,
282
+ scimType: "invalidValue",
283
+ detail: '"userName" must be a non-empty string'
284
+ };
285
+ }
286
+ draft.patch.userName = s;
287
+ return null;
288
+ }
289
+ case "externalid": {
290
+ draft.patch.externalId = removing ? null : asString(value) ?? null;
291
+ return null;
292
+ }
293
+ case "displayname": {
294
+ draft.patch.displayName = removing ? null : asString(value) ?? null;
295
+ return null;
296
+ }
297
+ case "emails":
298
+ case "phonenumbers": {
299
+ const key = head === "emails" ? "emails" : "phoneNumbers";
300
+ if (removing) {
301
+ if (key === "emails")
302
+ draft.emails = undefined;
303
+ else
304
+ draft.phoneNumbers = undefined;
305
+ draft.patch[key] = null;
306
+ return null;
307
+ }
308
+ if (!Array.isArray(value)) {
309
+ return {
310
+ status: 400,
311
+ scimType: "invalidValue",
312
+ detail: `"${head}" must be an array`
313
+ };
314
+ }
315
+ const list = [];
316
+ for (const entry of value) {
317
+ if (!isRecord(entry))
318
+ continue;
319
+ const v = asString(entry["value"]);
320
+ if (v === undefined)
321
+ continue;
322
+ const type = asString(entry["type"]);
323
+ const primary = entry["primary"];
324
+ list.push({
325
+ value: v,
326
+ ...type !== undefined ? { type } : {},
327
+ ...typeof primary === "boolean" ? { primary } : {}
328
+ });
329
+ }
330
+ if (key === "emails")
331
+ draft.emails = list;
332
+ else
333
+ draft.phoneNumbers = list;
334
+ draft.patch[key] = list.length > 0 ? list : null;
335
+ return null;
336
+ }
337
+ default:
338
+ return SKIP;
339
+ }
340
+ }
341
+ function normalizePatch(body, current) {
342
+ if (!isRecord(body)) {
343
+ return err({
344
+ status: 400,
345
+ scimType: "invalidSyntax",
346
+ detail: "request body must be a JSON object"
347
+ });
348
+ }
349
+ const schemas = body["schemas"];
350
+ if (Array.isArray(schemas) && !schemas.some((s) => typeof s === "string" && s.toLowerCase() === SCIM_PATCH_SCHEMA.toLowerCase())) {
351
+ return err({
352
+ status: 400,
353
+ scimType: "invalidSyntax",
354
+ detail: `PATCH body must declare the ${SCIM_PATCH_SCHEMA} schema`
355
+ });
356
+ }
357
+ const rawOps = body["Operations"] ?? body["operations"];
358
+ if (!Array.isArray(rawOps) || rawOps.length === 0) {
359
+ return err({
360
+ status: 400,
361
+ scimType: "invalidSyntax",
362
+ detail: "PATCH body must carry a non-empty Operations array"
363
+ });
364
+ }
365
+ const draft = {
366
+ patch: {},
367
+ emails: current.emails ? [...current.emails] : undefined,
368
+ phoneNumbers: current.phoneNumbers ? [...current.phoneNumbers] : undefined,
369
+ name: current.name ? { ...current.name } : undefined,
370
+ enterprise: current.enterprise ? { ...current.enterprise } : undefined
371
+ };
372
+ for (const raw of rawOps) {
373
+ if (!isRecord(raw)) {
374
+ return err({
375
+ status: 400,
376
+ scimType: "invalidSyntax",
377
+ detail: "each PATCH operation must be an object"
378
+ });
379
+ }
380
+ const op = asString(raw["op"]);
381
+ if (op === undefined) {
382
+ return err({
383
+ status: 400,
384
+ scimType: "invalidSyntax",
385
+ detail: "each PATCH operation must carry an op"
386
+ });
387
+ }
388
+ const failure = applyOperation(draft, op, asString(raw["path"]), raw["value"]);
389
+ if (failure)
390
+ return err(failure);
391
+ }
392
+ if (Object.keys(draft.patch).length === 0) {
393
+ return err({
394
+ status: 400,
395
+ scimType: "invalidValue",
396
+ detail: "PATCH resolved to no changes"
397
+ });
398
+ }
399
+ return ok(draft.patch);
400
+ }
401
+ function parseMemberFilterPath(path) {
402
+ const m = /^members\[\s*value\s+eq\s+"([^"]+)"\s*\]$/i.exec(path.trim());
403
+ return m ? m[1] : null;
404
+ }
405
+ function normalizeGroupPatch(body) {
406
+ if (!isRecord(body)) {
407
+ return err({
408
+ status: 400,
409
+ scimType: "invalidSyntax",
410
+ detail: "request body must be a JSON object"
411
+ });
412
+ }
413
+ const schemas = body["schemas"];
414
+ if (Array.isArray(schemas) && !schemas.some((s) => typeof s === "string" && s.toLowerCase() === SCIM_PATCH_SCHEMA.toLowerCase())) {
415
+ return err({
416
+ status: 400,
417
+ scimType: "invalidSyntax",
418
+ detail: `PATCH body must declare the ${SCIM_PATCH_SCHEMA} schema`
419
+ });
420
+ }
421
+ const rawOps = body["Operations"] ?? body["operations"];
422
+ if (!Array.isArray(rawOps) || rawOps.length === 0) {
423
+ return err({
424
+ status: 400,
425
+ scimType: "invalidSyntax",
426
+ detail: "PATCH body must carry a non-empty Operations array"
427
+ });
428
+ }
429
+ const patch = {};
430
+ const added = [];
431
+ const removed = [];
432
+ let replacement = null;
433
+ const applyAdd = (members) => {
434
+ if (replacement !== null) {
435
+ for (const m of members) {
436
+ if (!replacement.some((e) => e.value === m.value))
437
+ replacement.push(m);
438
+ }
439
+ return;
440
+ }
441
+ for (const m of members) {
442
+ if (!added.some((e) => e.value === m.value))
443
+ added.push(m);
444
+ }
445
+ };
446
+ const applyRemove = (ids) => {
447
+ if (replacement !== null) {
448
+ replacement = replacement.filter((e) => !ids.includes(e.value));
449
+ return;
450
+ }
451
+ for (const id of ids)
452
+ if (!removed.includes(id))
453
+ removed.push(id);
454
+ };
455
+ const handle = (op, rawPath, value) => {
456
+ const operation = op.toLowerCase();
457
+ if (operation !== "add" && operation !== "replace" && operation !== "remove") {
458
+ return invalidPath(`unsupported PATCH op "${op}"`);
459
+ }
460
+ if (rawPath === undefined || rawPath.trim().length === 0) {
461
+ if (!isRecord(value)) {
462
+ return invalidPath("a PATCH operation without a path must carry an object value");
463
+ }
464
+ for (const [k, v] of Object.entries(value)) {
465
+ const nested = handle(operation, k, v);
466
+ if (nested)
467
+ return nested;
468
+ }
469
+ return null;
470
+ }
471
+ const path = rawPath.trim();
472
+ const targeted = parseMemberFilterPath(path);
473
+ if (targeted !== null) {
474
+ if (operation !== "remove") {
475
+ return invalidPath(`only "remove" is supported with a members[value eq …] path`);
476
+ }
477
+ applyRemove([targeted]);
478
+ return null;
479
+ }
480
+ if (path.toLowerCase() === "members") {
481
+ if (operation === "remove" && value === undefined) {
482
+ replacement = [];
483
+ return null;
484
+ }
485
+ const members = parseMembers(value);
486
+ if (members === undefined) {
487
+ return {
488
+ status: 400,
489
+ scimType: "invalidValue",
490
+ detail: '"members" must be an array of {value} entries'
491
+ };
492
+ }
493
+ if (operation === "add")
494
+ applyAdd(members);
495
+ else if (operation === "remove")
496
+ applyRemove(members.map((m) => m.value));
497
+ else
498
+ replacement = [...members];
499
+ return null;
500
+ }
501
+ if (path.includes("[")) {
502
+ return invalidPath(`unsupported path "${rawPath}"; only members[value eq "…"] is ` + `supported for targeted membership changes`);
503
+ }
504
+ switch (path.toLowerCase()) {
505
+ case "displayname": {
506
+ if (operation === "remove") {
507
+ return invalidPath('"displayName" cannot be removed');
508
+ }
509
+ const s = asString(value);
510
+ if (s === undefined || s.length === 0) {
511
+ return {
512
+ status: 400,
513
+ scimType: "invalidValue",
514
+ detail: '"displayName" must be a non-empty string'
515
+ };
516
+ }
517
+ patch.displayName = s;
518
+ return null;
519
+ }
520
+ case "externalid": {
521
+ patch.externalId = operation === "remove" ? null : asString(value) ?? null;
522
+ return null;
523
+ }
524
+ default:
525
+ return invalidPath(`attribute "${rawPath}" is not patchable on a Group`);
526
+ }
527
+ };
528
+ for (const raw of rawOps) {
529
+ if (!isRecord(raw)) {
530
+ return err({
531
+ status: 400,
532
+ scimType: "invalidSyntax",
533
+ detail: "each PATCH operation must be an object"
534
+ });
535
+ }
536
+ const op = asString(raw["op"]);
537
+ if (op === undefined) {
538
+ return err({
539
+ status: 400,
540
+ scimType: "invalidSyntax",
541
+ detail: "each PATCH operation must carry an op"
542
+ });
543
+ }
544
+ const failure = handle(op, asString(raw["path"]), raw["value"]);
545
+ if (failure)
546
+ return err(failure);
547
+ }
548
+ if (replacement !== null)
549
+ patch.members = replacement;
550
+ else {
551
+ if (added.length > 0)
552
+ patch.addMembers = added;
553
+ if (removed.length > 0)
554
+ patch.removeMembers = removed;
555
+ }
556
+ if (Object.keys(patch).length === 0) {
557
+ return err({
558
+ status: 400,
559
+ scimType: "invalidValue",
560
+ detail: "PATCH resolved to no changes"
561
+ });
562
+ }
563
+ return ok(patch);
564
+ }
565
+ export {
566
+ normalizeGroupPatch,
567
+ normalizePatch
568
+ };