@_mustachio/openauth 0.12.0 → 0.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 (98) hide show
  1. package/dist/esm/domain/scim/discovery.js +206 -0
  2. package/dist/esm/domain/scim/filter.js +177 -0
  3. package/dist/esm/domain/scim/handle.js +338 -0
  4. package/dist/esm/domain/scim/patch.js +568 -0
  5. package/dist/esm/domain/scim/resource.js +256 -0
  6. package/dist/esm/http/handlers/scim.js +59 -0
  7. package/dist/esm/http/middleware/tenant.js +25 -0
  8. package/dist/esm/http/router.js +3 -0
  9. package/dist/esm/index.js +1 -0
  10. package/dist/esm/methods/saml-sp/acs.js +44 -20
  11. package/dist/esm/methods/saml-sp/attributes.js +2 -0
  12. package/dist/esm/methods/saml-sp/authnrequest.js +2 -2
  13. package/dist/esm/methods/saml-sp/factory.js +12 -1
  14. package/dist/esm/methods/saml-sp/metadata.js +5 -3
  15. package/dist/esm/methods/saml-sp/saml-instance.js +13 -3
  16. package/dist/esm/methods/saml-sp/slo-initiate.js +2 -2
  17. package/dist/esm/methods/saml-sp/sls.js +12 -4
  18. package/dist/esm/ports/scim-directory.js +0 -0
  19. package/dist/esm/types/error.js +5 -0
  20. package/dist/esm/types/scim.js +0 -0
  21. package/dist/types/domain/scim/discovery.d.ts +11 -0
  22. package/dist/types/domain/scim/discovery.d.ts.map +1 -0
  23. package/dist/types/domain/scim/filter.d.ts +51 -0
  24. package/dist/types/domain/scim/filter.d.ts.map +1 -0
  25. package/dist/types/domain/scim/handle.d.ts +35 -0
  26. package/dist/types/domain/scim/handle.d.ts.map +1 -0
  27. package/dist/types/domain/scim/patch.d.ts +73 -0
  28. package/dist/types/domain/scim/patch.d.ts.map +1 -0
  29. package/dist/types/domain/scim/resource.d.ts +56 -0
  30. package/dist/types/domain/scim/resource.d.ts.map +1 -0
  31. package/dist/types/http/context.d.ts +3 -0
  32. package/dist/types/http/context.d.ts.map +1 -1
  33. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  34. package/dist/types/http/handlers/scim.d.ts +3 -0
  35. package/dist/types/http/handlers/scim.d.ts.map +1 -0
  36. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  37. package/dist/types/http/router.d.ts.map +1 -1
  38. package/dist/types/index.d.ts +2 -0
  39. package/dist/types/index.d.ts.map +1 -1
  40. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -1
  41. package/dist/types/methods/saml-sp/attributes.d.ts +5 -0
  42. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -1
  43. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -1
  44. package/dist/types/methods/saml-sp/factory.d.ts +217 -1
  45. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -1
  46. package/dist/types/methods/saml-sp/metadata.d.ts +8 -0
  47. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
  48. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -1
  49. package/dist/types/methods/saml-sp/saml-instance.d.ts +12 -0
  50. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -1
  51. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -1
  52. package/dist/types/methods/saml-sp/sls.d.ts +20 -0
  53. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -1
  54. package/dist/types/methods/saml-sp/types.d.ts +95 -0
  55. package/dist/types/methods/saml-sp/types.d.ts.map +1 -1
  56. package/dist/types/ports/scim-directory.d.ts +104 -0
  57. package/dist/types/ports/scim-directory.d.ts.map +1 -0
  58. package/dist/types/ports/session-store.d.ts.map +1 -1
  59. package/dist/types/types/error.d.ts +5 -0
  60. package/dist/types/types/error.d.ts.map +1 -1
  61. package/dist/types/types/idp.d.ts +12 -0
  62. package/dist/types/types/idp.d.ts.map +1 -1
  63. package/dist/types/types/result.d.ts.map +1 -1
  64. package/dist/types/types/scim.d.ts +269 -0
  65. package/dist/types/types/scim.d.ts.map +1 -0
  66. package/dist/types/types/tenant.d.ts +10 -0
  67. package/dist/types/types/tenant.d.ts.map +1 -1
  68. package/package.json +1 -1
  69. package/src/domain/scim/discovery.ts +262 -0
  70. package/src/domain/scim/filter.ts +295 -0
  71. package/src/domain/scim/handle.ts +597 -0
  72. package/src/domain/scim/patch.ts +807 -0
  73. package/src/domain/scim/resource.ts +358 -0
  74. package/src/http/context.ts +3 -0
  75. package/src/http/handlers/authorize.ts +0 -1
  76. package/src/http/handlers/method-route.ts +1 -4
  77. package/src/http/handlers/scim.ts +107 -0
  78. package/src/http/middleware/tenant.ts +47 -1
  79. package/src/http/router.ts +9 -0
  80. package/src/index.ts +20 -0
  81. package/src/methods/saml-sp/acs.ts +98 -49
  82. package/src/methods/saml-sp/attributes.ts +12 -3
  83. package/src/methods/saml-sp/authnrequest.ts +3 -2
  84. package/src/methods/saml-sp/factory.ts +24 -3
  85. package/src/methods/saml-sp/metadata.ts +19 -8
  86. package/src/methods/saml-sp/parse-idp-metadata.ts +3 -9
  87. package/src/methods/saml-sp/saml-instance.ts +42 -5
  88. package/src/methods/saml-sp/slo-initiate.ts +6 -3
  89. package/src/methods/saml-sp/sls.ts +35 -5
  90. package/src/methods/saml-sp/types.ts +97 -6
  91. package/src/ports/CONSISTENCY.md +28 -25
  92. package/src/ports/scim-directory.ts +166 -0
  93. package/src/ports/session-store.ts +1 -5
  94. package/src/types/error.ts +11 -0
  95. package/src/types/idp.ts +13 -3
  96. package/src/types/result.ts +1 -2
  97. package/src/types/scim.ts +280 -0
  98. package/src/types/tenant.ts +12 -10
@@ -0,0 +1,262 @@
1
+ /**
2
+ * SCIM discovery documents — `/ServiceProviderConfig`, `/ResourceTypes`,
3
+ * `/Schemas`.
4
+ *
5
+ * Same advertise-only-what-we-serve invariant the SAML SP metadata
6
+ * endpoint holds: a capability we announce but do not implement is worse
7
+ * than one we never claimed, because the client will use it. So `bulk`,
8
+ * `sort`, `etag` and `changePassword` are all declared `false` — they
9
+ * are optional in RFC 7644 and deliberately out of scope
10
+ * (`SCIM-AD1` non-goals).
11
+ *
12
+ * `filter.supported` is `true` with a `maxResults`, which is honest: we
13
+ * do support filtering, on the documented subset (`SCIM-AD3`). SCIM has
14
+ * no way to advertise a partial filter grammar, so the narrowing is
15
+ * communicated where a client can actually act on it — the `400
16
+ * invalidFilter` response names exactly what works.
17
+ *
18
+ * Pure: no I/O.
19
+ */
20
+ import {
21
+ SCIM_ENTERPRISE_SCHEMA,
22
+ SCIM_GROUP_SCHEMA,
23
+ SCIM_LIST_SCHEMA,
24
+ SCIM_USER_SCHEMA,
25
+ } from "./resource"
26
+
27
+ export function serviceProviderConfig(
28
+ baseUrl: string,
29
+ maxResults: number,
30
+ ): Record<string, unknown> {
31
+ return {
32
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
33
+ documentationUri: "https://datatracker.ietf.org/doc/html/rfc7644",
34
+ patch: { supported: true },
35
+ bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
36
+ filter: { supported: true, maxResults },
37
+ changePassword: { supported: false },
38
+ sort: { supported: false },
39
+ etag: { supported: false },
40
+ authenticationSchemes: [
41
+ {
42
+ type: "oauthbearertoken",
43
+ name: "OAuth Bearer Token",
44
+ description:
45
+ "Authentication via the bearer token issued for this SCIM connection",
46
+ specUri: "https://datatracker.ietf.org/doc/html/rfc6750",
47
+ primary: true,
48
+ },
49
+ ],
50
+ meta: {
51
+ resourceType: "ServiceProviderConfig",
52
+ location: `${baseUrl}/ServiceProviderConfig`,
53
+ },
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `groups` reflects whether the host actually implemented the Group
59
+ * half of `ScimDirectory`. Advertising a resource type we answer 501 for
60
+ * would send a client straight into a failure it was told to expect to
61
+ * work — the same advertise-only-what-we-serve rule the SAML SP metadata
62
+ * endpoint follows.
63
+ */
64
+ export function resourceTypes(
65
+ baseUrl: string,
66
+ groups: boolean,
67
+ ): Record<string, unknown> {
68
+ const user = {
69
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
70
+ id: "User",
71
+ name: "User",
72
+ endpoint: "/Users",
73
+ description: "User Account",
74
+ schema: SCIM_USER_SCHEMA,
75
+ schemaExtensions: [{ schema: SCIM_ENTERPRISE_SCHEMA, required: false }],
76
+ meta: {
77
+ resourceType: "ResourceType",
78
+ location: `${baseUrl}/ResourceTypes/User`,
79
+ },
80
+ }
81
+ const group = {
82
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
83
+ id: "Group",
84
+ name: "Group",
85
+ endpoint: "/Groups",
86
+ description: "Group",
87
+ schema: SCIM_GROUP_SCHEMA,
88
+ meta: {
89
+ resourceType: "ResourceType",
90
+ location: `${baseUrl}/ResourceTypes/Group`,
91
+ },
92
+ }
93
+
94
+ const resources = groups ? [user, group] : [user]
95
+ return {
96
+ schemas: [SCIM_LIST_SCHEMA],
97
+ totalResults: resources.length,
98
+ startIndex: 1,
99
+ itemsPerPage: resources.length,
100
+ Resources: resources,
101
+ }
102
+ }
103
+
104
+ type AttributeSpec = {
105
+ name: string
106
+ type: string
107
+ multiValued: boolean
108
+ required: boolean
109
+ caseExact?: boolean
110
+ mutability?: string
111
+ returned?: string
112
+ uniqueness?: string
113
+ subAttributes?: AttributeSpec[]
114
+ }
115
+
116
+ const str = (
117
+ name: string,
118
+ extra: Partial<AttributeSpec> = {},
119
+ ): AttributeSpec => ({
120
+ name,
121
+ type: "string",
122
+ multiValued: false,
123
+ required: false,
124
+ caseExact: false,
125
+ mutability: "readWrite",
126
+ returned: "default",
127
+ uniqueness: "none",
128
+ ...extra,
129
+ })
130
+
131
+ const multiValueSub: AttributeSpec[] = [
132
+ str("value"),
133
+ str("type"),
134
+ {
135
+ name: "primary",
136
+ type: "boolean",
137
+ multiValued: false,
138
+ required: false,
139
+ mutability: "readWrite",
140
+ returned: "default",
141
+ },
142
+ ]
143
+
144
+ export function schemas(
145
+ baseUrl: string,
146
+ groups: boolean,
147
+ ): Record<string, unknown> {
148
+ const userSchema = {
149
+ id: SCIM_USER_SCHEMA,
150
+ name: "User",
151
+ description: "User Account",
152
+ attributes: [
153
+ str("userName", { required: true, uniqueness: "server" }),
154
+ {
155
+ name: "name",
156
+ type: "complex",
157
+ multiValued: false,
158
+ required: false,
159
+ mutability: "readWrite",
160
+ returned: "default",
161
+ subAttributes: [
162
+ str("formatted"),
163
+ str("familyName"),
164
+ str("givenName"),
165
+ str("middleName"),
166
+ str("honorificPrefix"),
167
+ str("honorificSuffix"),
168
+ ],
169
+ },
170
+ str("displayName"),
171
+ {
172
+ name: "active",
173
+ type: "boolean",
174
+ multiValued: false,
175
+ required: false,
176
+ mutability: "readWrite",
177
+ returned: "default",
178
+ },
179
+ {
180
+ name: "emails",
181
+ type: "complex",
182
+ multiValued: true,
183
+ required: false,
184
+ mutability: "readWrite",
185
+ returned: "default",
186
+ subAttributes: multiValueSub,
187
+ },
188
+ {
189
+ name: "phoneNumbers",
190
+ type: "complex",
191
+ multiValued: true,
192
+ required: false,
193
+ mutability: "readWrite",
194
+ returned: "default",
195
+ subAttributes: multiValueSub,
196
+ },
197
+ ],
198
+ meta: {
199
+ resourceType: "Schema",
200
+ location: `${baseUrl}/Schemas/${SCIM_USER_SCHEMA}`,
201
+ },
202
+ }
203
+
204
+ const enterpriseSchema = {
205
+ id: SCIM_ENTERPRISE_SCHEMA,
206
+ name: "EnterpriseUser",
207
+ description: "Enterprise User Extension",
208
+ attributes: [
209
+ str("employeeNumber"),
210
+ str("costCenter"),
211
+ str("organization"),
212
+ str("division"),
213
+ str("department"),
214
+ {
215
+ name: "manager",
216
+ type: "complex",
217
+ multiValued: false,
218
+ required: false,
219
+ mutability: "readWrite",
220
+ returned: "default",
221
+ subAttributes: [str("value"), str("displayName")],
222
+ },
223
+ ],
224
+ meta: {
225
+ resourceType: "Schema",
226
+ location: `${baseUrl}/Schemas/${SCIM_ENTERPRISE_SCHEMA}`,
227
+ },
228
+ }
229
+
230
+ const groupSchema = {
231
+ id: SCIM_GROUP_SCHEMA,
232
+ name: "Group",
233
+ description: "Group",
234
+ attributes: [
235
+ str("displayName", { required: true }),
236
+ {
237
+ name: "members",
238
+ type: "complex",
239
+ multiValued: true,
240
+ required: false,
241
+ mutability: "readWrite",
242
+ returned: "default",
243
+ subAttributes: [str("value"), str("display"), str("type")],
244
+ },
245
+ ],
246
+ meta: {
247
+ resourceType: "Schema",
248
+ location: `${baseUrl}/Schemas/${SCIM_GROUP_SCHEMA}`,
249
+ },
250
+ }
251
+
252
+ const resources = groups
253
+ ? [userSchema, enterpriseSchema, groupSchema]
254
+ : [userSchema, enterpriseSchema]
255
+ return {
256
+ schemas: [SCIM_LIST_SCHEMA],
257
+ totalResults: resources.length,
258
+ startIndex: 1,
259
+ itemsPerPage: resources.length,
260
+ Resources: resources,
261
+ }
262
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * SCIM filter parsing — the deliberately narrow subset (`SCIM-AD3`).
3
+ *
4
+ * RFC 7644 §3.4.2.2 defines a full expression language: grouping,
5
+ * precedence, `co`/`sw`/`ew`/`pr`/`gt`/`ge`/`lt`/`le`, complex attribute
6
+ * paths, `not`, and arbitrary nesting. Okta and Entra emit a sliver of
7
+ * it — overwhelmingly `userName eq "…"` for the existence check before a
8
+ * create.
9
+ *
10
+ * Implementing the rest would mean either a large parser plus a query
11
+ * interface no host could reasonably satisfy, or quietly returning wrong
12
+ * results. We support the observed subset and reject everything else
13
+ * with a message naming what works, so a gap is visible on day one
14
+ * rather than becoming silent provisioning drift.
15
+ *
16
+ * Supported:
17
+ *
18
+ * userName eq "alice@corp.example"
19
+ * externalId eq "00u1abc"
20
+ * id eq "usr_123"
21
+ * active eq true
22
+ * emails[type eq "work"].value eq "a@b.com" (Entra's shape)
23
+ * emails.value eq "a@b.com"
24
+ * <term> and <term> (two terms, no nesting)
25
+ *
26
+ * Pure: no I/O, no port access. Returns a `Result` so the caller renders
27
+ * the SCIM error envelope.
28
+ */
29
+ import type { ScimFilter, ScimFilterAttribute } from "../../types/scim"
30
+ import { err, ok, type Result } from "../../types/result"
31
+
32
+ /** What we tell a client when their filter is outside the subset. */
33
+ export const SUPPORTED_FILTER_HELP =
34
+ 'supported filters are: userName eq "…", externalId eq "…", id eq "…", ' +
35
+ 'active eq true|false, emails[type eq "…"].value eq "…", and two of ' +
36
+ 'those joined by "and"'
37
+
38
+ export type ScimFilterError = { detail: string }
39
+
40
+ const ATTRIBUTES: Record<string, ScimFilterAttribute> = {
41
+ id: "id",
42
+ username: "userName",
43
+ externalid: "externalId",
44
+ active: "active",
45
+ "emails.value": "emails.value",
46
+ displayname: "displayName",
47
+ }
48
+
49
+ /**
50
+ * Which attributes each resource type may be filtered on. Filtering a
51
+ * Group by `userName` is nonsense, and answering it with an empty list
52
+ * rather than a 400 would look like "no such group" — a wrong answer
53
+ * dressed as a valid one.
54
+ */
55
+ export const USER_FILTER_ATTRIBUTES: ReadonlySet<ScimFilterAttribute> = new Set(
56
+ ["id", "userName", "externalId", "active", "emails.value"],
57
+ )
58
+
59
+ export const GROUP_FILTER_ATTRIBUTES: ReadonlySet<ScimFilterAttribute> =
60
+ new Set(["id", "displayName", "externalId"])
61
+
62
+ /**
63
+ * Normalize the complex multi-valued path Entra emits.
64
+ *
65
+ * `emails[type eq "work"].value` means "the value of the work email".
66
+ * We collapse the qualifier: the host matches on any email value. That
67
+ * is a deliberate simplification — a user with the same address under
68
+ * two `type`s is the same person, and hosts that model emails as a flat
69
+ * list (most of them) cannot honour the qualifier anyway.
70
+ */
71
+ function normalizeComplexPath(raw: string): string {
72
+ const m = /^([A-Za-z]+)\[[^\]]*\]\.([A-Za-z]+)$/.exec(raw.trim())
73
+ if (!m) return raw.trim()
74
+ return `${m[1]}.${m[2]}`
75
+ }
76
+
77
+ /**
78
+ * Locate the comparison operator at bracket depth 0, outside quotes.
79
+ *
80
+ * A naive regex splits `emails[type eq "work"].value eq "a@b.c"` on the
81
+ * *inner* `eq` and mis-reads the attribute as `emails[type`. The
82
+ * qualifier of a complex attribute contains its own operator, so the
83
+ * scan has to respect brackets.
84
+ */
85
+ function findOperator(
86
+ term: string,
87
+ ): { attr: string; op: string; value: string } | null {
88
+ let depth = 0
89
+ let inQuotes = false
90
+ for (let i = 0; i < term.length; i++) {
91
+ const ch = term[i]
92
+ if (ch === '"' && term[i - 1] !== "\\") {
93
+ inQuotes = !inQuotes
94
+ continue
95
+ }
96
+ if (inQuotes) continue
97
+ if (ch === "[") depth++
98
+ else if (ch === "]") depth--
99
+ else if (depth === 0 && ch === " ") {
100
+ const m = /^\s+([A-Za-z]{2,3})(?:\s+|$)/.exec(term.slice(i))
101
+ if (m) {
102
+ return {
103
+ attr: term.slice(0, i),
104
+ op: m[1] as string,
105
+ value: term.slice(i + (m[0] as string).length),
106
+ }
107
+ }
108
+ }
109
+ }
110
+ return null
111
+ }
112
+
113
+ /** Parse one `<attr> eq <value>` term. */
114
+ function parseTerm(
115
+ raw: string,
116
+ allowed: ReadonlySet<ScimFilterAttribute>,
117
+ ): Result<ScimFilter, ScimFilterError> {
118
+ const term = raw.trim()
119
+
120
+ const found = findOperator(term)
121
+ if (!found) {
122
+ return err({
123
+ detail: `could not parse filter term "${term}". ${SUPPORTED_FILTER_HELP}`,
124
+ })
125
+ }
126
+ const { attr: rawAttr, op: rawOp, value: rawValue } = found
127
+
128
+ if (rawOp.toLowerCase() !== "eq") {
129
+ return err({
130
+ detail:
131
+ `operator "${rawOp}" is not supported; only "eq" is. ` +
132
+ SUPPORTED_FILTER_HELP,
133
+ })
134
+ }
135
+
136
+ const attrKey = normalizeComplexPath(rawAttr).toLowerCase()
137
+ const attribute = ATTRIBUTES[attrKey]
138
+ if (!attribute || !allowed.has(attribute)) {
139
+ return err({
140
+ detail:
141
+ `attribute "${rawAttr.trim()}" is not filterable. ` +
142
+ SUPPORTED_FILTER_HELP,
143
+ })
144
+ }
145
+
146
+ const value = rawValue.trim()
147
+
148
+ if (attribute === "active") {
149
+ const lowered = value.toLowerCase()
150
+ if (lowered !== "true" && lowered !== "false") {
151
+ return err({
152
+ detail: `"active" must be compared to true or false, got ${value}`,
153
+ })
154
+ }
155
+ return ok({ op: "eq", attribute, value: lowered === "true" })
156
+ }
157
+
158
+ // Every other supported attribute is a quoted string.
159
+ if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) {
160
+ return err({
161
+ detail: `value for "${attribute}" must be a quoted string, got ${value}`,
162
+ })
163
+ }
164
+ const unquoted = value
165
+ .slice(1, -1)
166
+ // RFC 7644 uses JSON string escaping; only \" and \\ are plausible here.
167
+ .replace(/\\"/g, '"')
168
+ .replace(/\\\\/g, "\\")
169
+
170
+ if (unquoted.length === 0) {
171
+ return err({ detail: `value for "${attribute}" must not be empty` })
172
+ }
173
+ return ok({ op: "eq", attribute, value: unquoted })
174
+ }
175
+
176
+ /**
177
+ * Parse a SCIM `filter` query parameter.
178
+ *
179
+ * Returns `ok(undefined)` for an absent or blank filter — an unfiltered
180
+ * list is legal and is what Okta sends when importing all users.
181
+ */
182
+ export function parseScimFilter(
183
+ raw: string | null | undefined,
184
+ allowed: ReadonlySet<ScimFilterAttribute> = USER_FILTER_ATTRIBUTES,
185
+ ): Result<ScimFilter | undefined, ScimFilterError> {
186
+ if (raw === null || raw === undefined || raw.trim().length === 0) {
187
+ return ok(undefined)
188
+ }
189
+ const input = raw.trim()
190
+
191
+ // Every structural check below runs against a *masked* copy in which
192
+ // the contents of quoted literals are blanked out. Testing the raw
193
+ // string rejected perfectly ordinary values — a group called
194
+ // "Sales (EMEA)" looked like a grouped expression, and a user called
195
+ // "jack or jill" looked like a disjunction. Both are common, and the
196
+ // damage is worse than a bad error message: Okta reacts to a failed
197
+ // existence lookup by creating a duplicate.
198
+ const masked = maskQuoted(input)
199
+
200
+ if (masked.includes("(") || masked.includes(")")) {
201
+ return err({
202
+ detail: `grouped expressions are not supported. ${SUPPORTED_FILTER_HELP}`,
203
+ })
204
+ }
205
+
206
+ // Split on a top-level ` and `, ignoring one inside a quoted value or
207
+ // inside a complex-attribute qualifier (`emails[type eq "work"]`).
208
+ const parts = splitTopLevelAnd(input)
209
+
210
+ if (parts.length > 2) {
211
+ return err({
212
+ detail:
213
+ `at most two terms joined by "and" are supported. ` +
214
+ SUPPORTED_FILTER_HELP,
215
+ })
216
+ }
217
+ if (/\sor\s/i.test(masked)) {
218
+ return err({
219
+ detail: `"or" is not supported. ${SUPPORTED_FILTER_HELP}`,
220
+ })
221
+ }
222
+ if (/(^|\s)not\s/i.test(masked)) {
223
+ return err({
224
+ detail: `"not" is not supported. ${SUPPORTED_FILTER_HELP}`,
225
+ })
226
+ }
227
+
228
+ const first = parseTerm(parts[0] as string, allowed)
229
+ if (!first.ok) return first
230
+ if (parts.length === 1) return ok(first.value)
231
+
232
+ const second = parseTerm(parts[1] as string, allowed)
233
+ if (!second.ok) return second
234
+ return ok({ op: "and", left: first.value, right: second.value })
235
+ }
236
+
237
+ /**
238
+ * Replace the contents of every quoted literal with spaces, preserving
239
+ * length and the quote characters themselves. Lets the structural checks
240
+ * reason about filter *syntax* without tripping over filter *values*.
241
+ */
242
+ function maskQuoted(input: string): string {
243
+ let out = ""
244
+ let inQuotes = false
245
+ for (let i = 0; i < input.length; i++) {
246
+ const ch = input[i] as string
247
+ if (ch === '"' && input[i - 1] !== "\\") {
248
+ inQuotes = !inQuotes
249
+ out += ch
250
+ continue
251
+ }
252
+ out += inQuotes ? " " : ch
253
+ }
254
+ return out
255
+ }
256
+
257
+ /**
258
+ * Split on ` and ` at the top level only. Quoted strings and the
259
+ * `[...]` qualifier of a complex attribute may both legitimately
260
+ * contain the word, so a naive split corrupts them.
261
+ */
262
+ function splitTopLevelAnd(input: string): string[] {
263
+ const parts: string[] = []
264
+ let depth = 0
265
+ let inQuotes = false
266
+ let start = 0
267
+
268
+ for (let i = 0; i < input.length; i++) {
269
+ const ch = input[i]
270
+ if (ch === '"' && input[i - 1] !== "\\") {
271
+ inQuotes = !inQuotes
272
+ continue
273
+ }
274
+ if (inQuotes) continue
275
+ if (ch === "[") depth++
276
+ else if (ch === "]") depth--
277
+ else if (depth === 0 && (ch === "a" || ch === "A")) {
278
+ // Match a standalone " and " (case-insensitive) at this position.
279
+ const window = input.slice(i, i + 3)
280
+ const before = input[i - 1]
281
+ const after = input[i + 3]
282
+ if (
283
+ window.toLowerCase() === "and" &&
284
+ (before === " " || before === undefined) &&
285
+ (after === " " || after === undefined)
286
+ ) {
287
+ parts.push(input.slice(start, i))
288
+ start = i + 3
289
+ i += 2
290
+ }
291
+ }
292
+ }
293
+ parts.push(input.slice(start))
294
+ return parts.map((p) => p.trim()).filter((p) => p.length > 0)
295
+ }