1
19import { ScimError } from "./scim-errors.js";
20
21export type ScimAttr = "userName" | "active" | "id" | "displayName";
22export type ScimValue = string | boolean | number;
23
24export interface AttrTest {
25 op: "eq" | "pr";
26 attr: ScimAttr;
27 value?: ScimValue;
28}
29
30export type ScimFilter =
31 | { kind: "test"; test: AttrTest }
32 | { kind: "and"; left: ScimFilter; right: ScimFilter }
33 | { kind: "or"; left: ScimFilter; right: ScimFilter };
34
35const ATTRS: ScimAttr[] = ["userName", "active", "id", "displayName"];
36
37class Tokenizer {
38 pos = 0;
39 constructor(private readonly src: string) {}
40
41 private err(where: string): never {
42 throw ScimError.invalidFilter(`Unsupported SCIM filter near "${where}": ${this.src.slice(0, 80)}`);
43 }
44
45 peek(): string | null {
46 if (this.pos >= this.src.length) return null;
47 return this.src[this.pos]!;
48 }
49
50 skipWs(): void {
51 while (this.pos < this.src.length && /\s/.test(this.src[this.pos]!)) this.pos += 1;
52 }
53
54
55 token(until: RegExp): string {
56 this.skipWs();
57 const start = this.pos;
58 if (this.peek() === '"') {
59 this.pos += 1;
60 let out = "";
61 while (this.pos < this.src.length) {
62 const c = this.src[this.pos]!;
63 if (c === '"') {
64 this.pos += 1;
65 return out;
66 }
67 if (c === "\\" && this.pos + 1 < this.src.length) {
68 out += this.src[this.pos + 1]!;
69 this.pos += 2;
70 continue;
71 }
72 out += c;
73 this.pos += 1;
74 }
75 this.err(`unterminated string at ${start}`);
76 }
77 while (this.pos < this.src.length && !until.test(this.src[this.pos]!)) this.pos += 1;
78 const raw = this.src.slice(start, this.pos);
79 if (!raw.trim()) this.err(`expected value at ${start}`);
80 return raw;
81 }
82
83
84 consumeWord(word: string): boolean {
85 this.skipWs();
86 const rest = this.src.slice(this.pos);
87 if (
88 rest.toLowerCase().startsWith(word) &&
89 (this.src[this.pos + word.length] === undefined || /\s|"/.test(this.src[this.pos + word.length]!))
90 ) {
91 this.pos += word.length;
92 return true;
93 }
94 return false;
95 }
96
97 done(): boolean {
98 this.skipWs();
99 return this.pos >= this.src.length;
100 }
101}
102
103function parseValue(t: Tokenizer): ScimValue {
104
105
106 const raw = t.token(/\s/);
107 const lower = raw.toLowerCase();
108 if (lower === "true") return true;
109 if (lower === "false") return false;
110 if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
111 return raw;
112}
113
114function parseAttr(t: Tokenizer): ScimAttr {
115 const raw = t.token(/[\s"]/);
116
117
118 const match = ATTRS.find((a) => a.toLowerCase() === raw.toLowerCase());
119 if (!match) {
120 throw ScimError.invalidFilter(`Unsupported filter attribute "${raw}" (allowed: ${ATTRS.join(", ")})`);
121 }
122 return match;
123}
124
125function parseTest(t: Tokenizer): AttrTest {
126 const attr = parseAttr(t);
127 if (t.consumeWord("eq")) {
128 const value = parseValue(t);
129 if (attr === "active" && typeof value !== "boolean") {
130 throw ScimError.invalidFilter(`"active" takes a boolean (true/false), got ${JSON.stringify(value)}`);
131 }
132 return { op: "eq", attr, value };
133 }
134 if (t.consumeWord("pr")) {
135 return { op: "pr", attr };
136 }
137 throw ScimError.invalidFilter(`Expected "eq" or "pr" after "${attr}"`);
138}
139
140function parseTestOrCombined(t: Tokenizer, depth: number): ScimFilter {
141 if (depth > 8) throw ScimError.invalidFilter("Filter expression too deep.");
142 let left: ScimFilter = { kind: "test", test: parseTest(t) };
143 for (;;) {
144 if (t.consumeWord("and")) {
145 left = { kind: "and", left, right: { kind: "test", test: parseTest(t) } };
146 } else if (t.consumeWord("or")) {
147 left = { kind: "or", left, right: { kind: "test", test: parseTest(t) } };
148 } else {
149 return left;
150 }
151 }
152}
153
154
158export function parseScimFilter(input: string): ScimFilter {
159 const src = input.trim();
160 if (!src) throw ScimError.invalidFilter("Empty filter.");
161 const t = new Tokenizer(src);
162 const filter = parseTestOrCombined(t, 0);
163 if (!t.done()) {
164 throw ScimError.invalidFilter(`Trailing characters in filter: "${src.slice(t.pos).trim()}"`);
165 }
166 return filter;
167}
168
169
179export function filterToWhere(filter: ScimFilter): Record<string, unknown> {
180 const testWhere = (t: AttrTest): Record<string, unknown> => {
181 if (t.op === "pr") {
182 switch (t.attr) {
183 case "id":
184 return { id: { not: null } };
185 case "active":
186 return {};
187 case "userName":
188 return { email: { not: null } };
189 case "displayName":
190 return { displayName: { not: null } };
191 }
192 }
193 switch (t.attr) {
194 case "userName":
195 return { email: { equals: String(t.value), mode: "insensitive" } };
196 case "active":
197 return t.value === true ? { status: { in: ["ACTIVE", "INVITED"] } } : { status: "SUSPENDED" };
198 case "id":
199 return { id: { equals: String(t.value) } };
200 case "displayName":
201 return { displayName: { equals: String(t.value), mode: "insensitive" } };
202 }
203 };
204 const walk = (f: ScimFilter): Record<string, unknown> => {
205 if (f.kind === "test") return testWhere(f.test);
206 if (f.kind === "and") return { AND: [walk(f.left), walk(f.right)] };
207 return { OR: [walk(f.left), walk(f.right)] };
208 };
209 return walk(filter);
210}
211