@xcitedbs/client 0.2.8 → 0.2.10

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/dist/client.js CHANGED
@@ -33,6 +33,7 @@ class XCiteDBClient {
33
33
  this.onSessionInvalid = options.onSessionInvalid;
34
34
  this.testSessionToken = options.testSessionToken;
35
35
  this.testRequireAuth = options.testRequireAuth === true;
36
+ this.userIsolation = options.userIsolation;
36
37
  }
37
38
  /**
38
39
  * Create an ephemeral isolated database: calls `POST /api/v1/test/sessions` with your API key or Bearer,
@@ -51,6 +52,7 @@ class XCiteDBClient {
51
52
  onSessionTokensUpdated: opts.onSessionTokensUpdated,
52
53
  onAppUserTokensUpdated: opts.onAppUserTokensUpdated,
53
54
  onSessionInvalid: opts.onSessionInvalid,
55
+ userIsolation: opts.userIsolation,
54
56
  });
55
57
  const data = await temp.request('POST', '/api/v1/test/sessions', undefined, undefined, { no401Retry: true });
56
58
  return new XCiteDBClient({
@@ -67,6 +69,230 @@ class XCiteDBClient {
67
69
  onSessionInvalid: opts.onSessionInvalid,
68
70
  testSessionToken: data.session_token,
69
71
  testRequireAuth: opts.testRequireAuth,
72
+ userIsolation: opts.userIsolation,
73
+ });
74
+ }
75
+ /**
76
+ * Canonical `project:<tenant_id>:<role>` group string. The middle segment must match the app JWT `tenant_id`
77
+ * (internal project id), not the human-readable project name.
78
+ */
79
+ static buildProjectGroup(projectId, role) {
80
+ return `project:${projectId}:${role}`;
81
+ }
82
+ static decodeJwtPayloadJson(token) {
83
+ const parts = token.split('.');
84
+ if (parts.length < 2)
85
+ return null;
86
+ try {
87
+ let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
88
+ const pad = (4 - (b64.length % 4)) % 4;
89
+ b64 += '='.repeat(pad);
90
+ const g = globalThis;
91
+ let json = null;
92
+ if (g.Buffer) {
93
+ json = g.Buffer.from(b64, 'base64').toString('utf8');
94
+ }
95
+ else if (typeof atob === 'function') {
96
+ json = atob(b64);
97
+ }
98
+ if (json === null)
99
+ return null;
100
+ return JSON.parse(json);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ /**
107
+ * Decode `appUserAccessToken`, or `accessToken` if no app token (platform JWT), without verifying the signature.
108
+ * Use for debugging ABAC (compare `tenant_id` and `groups` to `project:<…>:role` in policies).
109
+ */
110
+ getTokenClaims() {
111
+ const raw = this.appUserAccessToken ?? this.accessToken;
112
+ if (!raw)
113
+ return null;
114
+ const p = XCiteDBClient.decodeJwtPayloadJson(raw);
115
+ if (!p)
116
+ return null;
117
+ let groups = [];
118
+ const g = p['groups'];
119
+ if (Array.isArray(g)) {
120
+ groups = g.filter((x) => typeof x === 'string');
121
+ }
122
+ else if (g && typeof g === 'object' && !Array.isArray(g)) {
123
+ groups = Object.keys(g);
124
+ }
125
+ else if (typeof g === 'string') {
126
+ groups = g.split(',').map((s) => s.trim()).filter(Boolean);
127
+ }
128
+ const sub = p['sub'];
129
+ const tenant_id = p['tenant_id'];
130
+ if (typeof sub !== 'string' || typeof tenant_id !== 'string')
131
+ return null;
132
+ const email = p['email'];
133
+ const type = p['type'];
134
+ const exp = p['exp'];
135
+ const iat = p['iat'];
136
+ const iss = p['iss'];
137
+ const jti = p['jti'];
138
+ return {
139
+ sub,
140
+ tenant_id,
141
+ groups,
142
+ email: typeof email === 'string' ? email : undefined,
143
+ type: typeof type === 'string' ? type : undefined,
144
+ exp: typeof exp === 'number' ? exp : undefined,
145
+ iat: typeof iat === 'number' ? iat : undefined,
146
+ iss: typeof iss === 'string' ? iss : undefined,
147
+ jti: typeof jti === 'string' ? jti : undefined,
148
+ };
149
+ }
150
+ cacheAppUserIdFromPair(pair) {
151
+ if (pair?.user?.user_id) {
152
+ this.cachedAppUserId = pair.user.user_id;
153
+ }
154
+ }
155
+ clearAppUserIdCache() {
156
+ this.cachedAppUserId = undefined;
157
+ }
158
+ getAppUserId() {
159
+ if (this.cachedAppUserId) {
160
+ return this.cachedAppUserId;
161
+ }
162
+ const raw = this.appUserAccessToken;
163
+ if (!raw) {
164
+ return undefined;
165
+ }
166
+ const p = XCiteDBClient.decodeJwtPayloadJson(raw);
167
+ if (!p) {
168
+ return undefined;
169
+ }
170
+ const sub = p['sub'];
171
+ if (typeof sub === 'string' && sub.length > 0) {
172
+ return sub;
173
+ }
174
+ const uid = p['user_id'];
175
+ if (typeof uid === 'string' && uid.length > 0) {
176
+ return uid;
177
+ }
178
+ return undefined;
179
+ }
180
+ normalizeIsolationNamespaceTemplate(tpl) {
181
+ return tpl.replace(/\$\{user\.id\}/g, '{userId}');
182
+ }
183
+ canonicalId(s) {
184
+ let t = s.trim().replace(/\/+/g, '/');
185
+ if (!t.startsWith('/')) {
186
+ t = `/${t}`;
187
+ }
188
+ return t;
189
+ }
190
+ /** Resolved namespace root, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
191
+ userIsolationNamespace() {
192
+ if (!this.userIsolation?.enabled) {
193
+ return null;
194
+ }
195
+ const uid = this.getAppUserId();
196
+ if (!uid) {
197
+ return null;
198
+ }
199
+ const rawTpl = this.normalizeIsolationNamespaceTemplate(this.userIsolation.namespace ?? '/users/{userId}');
200
+ const trimmed = rawTpl.replace(/\/+$/, '') || '';
201
+ if (!trimmed) {
202
+ return null;
203
+ }
204
+ return trimmed.replace(/{userId}/g, uid).replace(/{user_id}/g, uid);
205
+ }
206
+ allSharedPassthroughPrefixes() {
207
+ const o = this.userIsolation;
208
+ if (!o) {
209
+ return [];
210
+ }
211
+ return [...(o.shared_read_paths ?? []), ...(o.shared_write_paths ?? [])];
212
+ }
213
+ pathMatchesSharedPassthrough(path) {
214
+ const canon = this.canonicalId(path);
215
+ for (const raw of this.allSharedPassthroughPrefixes()) {
216
+ if (!raw) {
217
+ continue;
218
+ }
219
+ const p = this.canonicalId(raw);
220
+ if (canon === p) {
221
+ return true;
222
+ }
223
+ if (p.endsWith('/') && canon.startsWith(p)) {
224
+ return true;
225
+ }
226
+ if (!p.endsWith('/') && (canon === p || canon.startsWith(`${p}/`))) {
227
+ return true;
228
+ }
229
+ }
230
+ return false;
231
+ }
232
+ isoPrefixId(id) {
233
+ const ns = this.userIsolationNamespace();
234
+ if (!ns) {
235
+ return id;
236
+ }
237
+ if (id.includes('..')) {
238
+ throw new types_1.XCiteDBError('Invalid identifier: path traversal not allowed', 400, null);
239
+ }
240
+ const canonical = this.canonicalId(id);
241
+ if (canonical.startsWith(`${ns}/`) || canonical === ns) {
242
+ return canonical;
243
+ }
244
+ if (this.pathMatchesSharedPassthrough(canonical)) {
245
+ return canonical;
246
+ }
247
+ if (canonical.startsWith('/users/') && !canonical.startsWith(`${ns}/`) && canonical !== ns) {
248
+ return canonical;
249
+ }
250
+ const combined = `${ns}${canonical === '/' ? '/' : canonical}`;
251
+ const finalId = combined.replace(/\/+/g, '/');
252
+ if (!finalId.startsWith(ns) || (finalId.length > ns.length && finalId.charAt(ns.length) !== '/')) {
253
+ throw new types_1.XCiteDBError('Identifier escapes user namespace', 400, null);
254
+ }
255
+ return finalId;
256
+ }
257
+ isoUnprefixId(id) {
258
+ const ns = this.userIsolationNamespace();
259
+ if (!ns) {
260
+ return id;
261
+ }
262
+ const canonical = this.canonicalId(id);
263
+ if (this.pathMatchesSharedPassthrough(canonical)) {
264
+ return canonical;
265
+ }
266
+ if (canonical.startsWith(`${ns}/`)) {
267
+ return canonical.slice(ns.length);
268
+ }
269
+ if (canonical === ns) {
270
+ return '/';
271
+ }
272
+ return canonical;
273
+ }
274
+ isoPrefixQuery(q) {
275
+ if (!this.userIsolationNamespace()) {
276
+ return q;
277
+ }
278
+ const out = { ...q };
279
+ if (out.match) {
280
+ out.match = this.isoPrefixId(out.match);
281
+ }
282
+ if (out.match_start) {
283
+ out.match_start = this.isoPrefixId(out.match_start);
284
+ }
285
+ if (out.match_end) {
286
+ out.match_end = this.isoPrefixId(out.match_end);
287
+ }
288
+ return out;
289
+ }
290
+ isoApplyXmlDbIdentifier(xml) {
291
+ if (!this.userIsolationNamespace()) {
292
+ return xml;
293
+ }
294
+ return xml.replace(/(db:identifier\s*=\s*")([^"]*)(")/, (_m, p1, mid, p3) => {
295
+ return `${p1}${this.isoPrefixId(String(mid))}${p3}`;
70
296
  });
71
297
  }
72
298
  /** Destroy this test session on the server (`DELETE /api/v1/test/sessions/current`). */
@@ -131,10 +357,12 @@ class XCiteDBClient {
131
357
  this.appUserAccessToken = access;
132
358
  if (refresh !== undefined)
133
359
  this.appUserRefreshToken = refresh;
360
+ this.clearAppUserIdCache();
134
361
  }
135
362
  clearAppUserTokens() {
136
363
  this.appUserAccessToken = undefined;
137
364
  this.appUserRefreshToken = undefined;
365
+ this.clearAppUserIdCache();
138
366
  }
139
367
  contextHeaders() {
140
368
  const h = {};
@@ -186,14 +414,19 @@ class XCiteDBClient {
186
414
  }
187
415
  return h;
188
416
  }
417
+ requestHeaders() {
418
+ return {
419
+ ...this.authHeaders(),
420
+ ...this.contextHeaders(),
421
+ ...this.testHeaders(),
422
+ };
423
+ }
189
424
  async request(method, path, body, extraHeaders, opts) {
190
425
  const no401Retry = opts?.no401Retry === true;
191
426
  for (let attempt = 0; attempt < 2; attempt++) {
192
427
  const url = joinUrl(this.baseUrl, path);
193
428
  const headers = {
194
- ...this.authHeaders(),
195
- ...this.contextHeaders(),
196
- ...this.testHeaders(),
429
+ ...this.requestHeaders(),
197
430
  ...extraHeaders,
198
431
  };
199
432
  let init = { method, headers };
@@ -261,6 +494,7 @@ class XCiteDBClient {
261
494
  const pair = await this.request('POST', '/api/v1/app/auth/refresh', this.mergeAppTenant({ refresh_token: this.appUserRefreshToken }), undefined, { no401Retry: true });
262
495
  this.appUserAccessToken = pair.access_token;
263
496
  this.appUserRefreshToken = pair.refresh_token;
497
+ this.cacheAppUserIdFromPair(pair);
264
498
  this.onAppUserTokensUpdated?.(pair);
265
499
  return pair;
266
500
  }
@@ -399,12 +633,14 @@ class XCiteDBClient {
399
633
  const pair = await this.request('POST', '/api/v1/app/auth/oauth/exchange', this.mergeAppTenant({ code }));
400
634
  this.appUserAccessToken = pair.access_token;
401
635
  this.appUserRefreshToken = pair.refresh_token;
636
+ this.cacheAppUserIdFromPair(pair);
402
637
  return pair;
403
638
  }
404
639
  async loginAppUser(email, password) {
405
640
  const pair = await this.request('POST', '/api/v1/app/auth/login', this.mergeAppTenant({ email, password }));
406
641
  this.appUserAccessToken = pair.access_token;
407
642
  this.appUserRefreshToken = pair.refresh_token;
643
+ this.cacheAppUserIdFromPair(pair);
408
644
  return pair;
409
645
  }
410
646
  async refreshAppUser() {
@@ -414,6 +650,7 @@ class XCiteDBClient {
414
650
  await this.request('POST', '/api/v1/app/auth/logout', this.mergeAppTenant({ refresh_token: this.appUserRefreshToken }));
415
651
  this.appUserAccessToken = undefined;
416
652
  this.appUserRefreshToken = undefined;
653
+ this.clearAppUserIdCache();
417
654
  }
418
655
  async appUserMe() {
419
656
  return this.request('GET', '/api/v1/app/auth/me');
@@ -430,6 +667,7 @@ class XCiteDBClient {
430
667
  const pair = await this.request('POST', '/api/v1/app/auth/custom-token', { token });
431
668
  this.appUserAccessToken = pair.access_token;
432
669
  this.appUserRefreshToken = pair.refresh_token;
670
+ this.cacheAppUserIdFromPair(pair);
433
671
  return pair;
434
672
  }
435
673
  /** Change app-user password (requires valid app-user access token). */
@@ -646,7 +884,7 @@ class XCiteDBClient {
646
884
  * ```
647
885
  */
648
886
  async checkAccess(subject, identifier, action, metaPath, branch) {
649
- const body = { subject, identifier, action };
887
+ const body = { subject, identifier: this.isoPrefixId(identifier), action };
650
888
  if (metaPath !== undefined)
651
889
  body.meta_path = metaPath;
652
890
  if (branch !== undefined)
@@ -672,6 +910,37 @@ class XCiteDBClient {
672
910
  async updateSecurityConfig(config) {
673
911
  await this.request('PUT', '/api/v1/security/config', config);
674
912
  }
913
+ /** Per-tenant user data spaces (`GET /api/v1/security/user-isolation`). Requires security admin. */
914
+ async getUserIsolationConfig() {
915
+ return this.request('GET', '/api/v1/security/user-isolation');
916
+ }
917
+ /** Enable or reconfigure user isolation (`PUT /api/v1/security/user-isolation`). */
918
+ async setUserIsolationConfig(config) {
919
+ return this.request('PUT', '/api/v1/security/user-isolation', config);
920
+ }
921
+ /** Disable user isolation and remove generated policies (`DELETE /api/v1/security/user-isolation`). */
922
+ async disableUserIsolation() {
923
+ await this.request('DELETE', '/api/v1/security/user-isolation');
924
+ }
925
+ /**
926
+ * Loads server isolation config; when enabled, configures client-side identifier prefixing to match
927
+ * the server (namespace + shared paths). Does not send `X-Prefix`; identifiers in requests are rewritten.
928
+ */
929
+ async enableUserIsolation() {
930
+ const cfg = await this.getUserIsolationConfig();
931
+ if (cfg.enabled) {
932
+ this.userIsolation = {
933
+ enabled: true,
934
+ namespace: cfg.namespace_pattern,
935
+ shared_read_paths: cfg.shared_read_paths,
936
+ shared_write_paths: cfg.shared_write_paths,
937
+ };
938
+ }
939
+ else {
940
+ this.userIsolation = { enabled: false };
941
+ }
942
+ return cfg;
943
+ }
675
944
  async createBranch(name, fromBranch, fromDate) {
676
945
  const body = { name };
677
946
  if (fromBranch)
@@ -789,7 +1058,8 @@ class XCiteDBClient {
789
1058
  }
790
1059
  /** Send raw XML body (`Content-Type: application/xml`). For JSON wrapper + options use `writeXmlDocument`. */
791
1060
  async writeXML(xml, _options) {
792
- await this.request('POST', '/api/v1/documents', xml, {
1061
+ const payload = this.isoApplyXmlDbIdentifier(xml);
1062
+ await this.request('POST', '/api/v1/documents', payload, {
793
1063
  'Content-Type': 'application/xml',
794
1064
  });
795
1065
  }
@@ -799,7 +1069,7 @@ class XCiteDBClient {
799
1069
  */
800
1070
  async writeXmlDocument(xml, options) {
801
1071
  await this.request('POST', '/api/v1/documents', {
802
- xml,
1072
+ xml: this.isoApplyXmlDbIdentifier(xml),
803
1073
  is_top: options?.is_top ?? true,
804
1074
  compare_attributes: options?.compare_attributes ?? false,
805
1075
  });
@@ -812,92 +1082,97 @@ class XCiteDBClient {
812
1082
  }
813
1083
  async queryByIdentifier(identifier, flags, filter, pathFilter) {
814
1084
  const q = buildQuery({
815
- identifier,
1085
+ identifier: this.isoPrefixId(identifier),
816
1086
  flags: flags,
817
1087
  filter,
818
1088
  path_filter: pathFilter,
819
1089
  });
820
- return this.request('GET', `/api/v1/documents/by-id${q}`);
1090
+ const rows = await this.request('GET', `/api/v1/documents/by-id${q}`);
1091
+ return Array.isArray(rows) ? rows.map((x) => this.isoUnprefixId(String(x))) : rows;
821
1092
  }
822
1093
  async queryDocuments(query, flags, filter, pathFilter) {
1094
+ const pq = this.isoPrefixQuery(query);
823
1095
  const params = {
824
- match: query.match,
825
- match_start: query.match_start,
826
- match_end: query.match_end,
827
- regex: query.regex,
1096
+ match: pq.match,
1097
+ match_start: pq.match_start,
1098
+ match_end: pq.match_end,
1099
+ regex: pq.regex,
828
1100
  flags: flags,
829
1101
  filter,
830
1102
  path_filter: pathFilter,
831
1103
  };
832
- if (query.contains !== undefined) {
833
- const c = Array.isArray(query.contains) ? query.contains.join(',') : query.contains;
1104
+ if (pq.contains !== undefined) {
1105
+ const c = Array.isArray(pq.contains) ? pq.contains.join(',') : pq.contains;
834
1106
  params.contains = c;
835
1107
  }
836
- if (query.required_meta_paths !== undefined && query.required_meta_paths.length > 0) {
837
- params.required_meta_paths = query.required_meta_paths.join(',');
1108
+ if (pq.required_meta_paths !== undefined && pq.required_meta_paths.length > 0) {
1109
+ params.required_meta_paths = pq.required_meta_paths.join(',');
838
1110
  }
839
- if (query.filter_any_meta === true) {
1111
+ if (pq.filter_any_meta === true) {
840
1112
  params.any_meta = '1';
841
1113
  }
842
- return this.request('GET', `/api/v1/documents${buildQuery(params)}`);
1114
+ const rows = await this.request('GET', `/api/v1/documents${buildQuery(params)}`);
1115
+ return Array.isArray(rows) ? rows.map((x) => this.isoUnprefixId(String(x))) : rows;
843
1116
  }
844
1117
  async deleteDocument(identifier) {
845
- await this.request('DELETE', `/api/v1/documents/by-id${buildQuery({ identifier })}`);
1118
+ await this.request('DELETE', `/api/v1/documents/by-id${buildQuery({ identifier: this.isoPrefixId(identifier) })}`);
846
1119
  }
847
1120
  async addIdentifier(identifier) {
848
1121
  const r = await this.request('POST', '/api/v1/documents/identifiers', {
849
- identifier,
1122
+ identifier: this.isoPrefixId(identifier),
850
1123
  });
851
1124
  return r?.ok !== false;
852
1125
  }
853
1126
  async addAlias(original, alias) {
854
- const r = await this.request('POST', '/api/v1/documents/identifiers/alias', { original, alias });
1127
+ const r = await this.request('POST', '/api/v1/documents/identifiers/alias', { original: this.isoPrefixId(original), alias: this.isoPrefixId(alias) });
855
1128
  return r?.ok !== false;
856
1129
  }
857
1130
  async queryChangeDate(identifier) {
858
- const r = await this.request('GET', `/api/v1/documents/change-date${buildQuery({ identifier })}`);
1131
+ const r = await this.request('GET', `/api/v1/documents/change-date${buildQuery({ identifier: this.isoPrefixId(identifier) })}`);
859
1132
  return r?.change_date ?? r?.date ?? '';
860
1133
  }
861
1134
  async getXcitepath(identifier) {
862
- const r = await this.request('GET', `/api/v1/documents/xcitepath${buildQuery({ identifier })}`);
1135
+ const r = await this.request('GET', `/api/v1/documents/xcitepath${buildQuery({ identifier: this.isoPrefixId(identifier) })}`);
863
1136
  return r?.xcitepath ?? '';
864
1137
  }
865
1138
  async changedIdentifiers(branch, fromDate, toDate) {
866
- return this.request('GET', `/api/v1/documents/changed${buildQuery({ branch, from_date: fromDate, to_date: toDate })}`);
1139
+ const ids = await this.request('GET', `/api/v1/documents/changed${buildQuery({ branch, from_date: fromDate, to_date: toDate })}`);
1140
+ return Array.isArray(ids) ? ids.map((x) => this.isoUnprefixId(String(x))) : ids;
867
1141
  }
868
1142
  async listIdentifiers(query) {
1143
+ const pq = this.isoPrefixQuery(query);
869
1144
  const params = {
870
- match: query.match,
871
- match_start: query.match_start,
872
- match_end: query.match_end,
873
- regex: query.regex,
874
- limit: query.limit,
875
- offset: query.offset,
1145
+ match: pq.match,
1146
+ match_start: pq.match_start,
1147
+ match_end: pq.match_end,
1148
+ regex: pq.regex,
1149
+ limit: pq.limit,
1150
+ offset: pq.offset,
876
1151
  };
877
- if (query.contains !== undefined) {
878
- params.contains = Array.isArray(query.contains)
879
- ? query.contains.join(',')
880
- : query.contains;
1152
+ if (pq.contains !== undefined) {
1153
+ params.contains = Array.isArray(pq.contains) ? pq.contains.join(',') : pq.contains;
881
1154
  }
882
- if (query.required_meta_paths !== undefined && query.required_meta_paths.length > 0) {
883
- params.required_meta_paths = query.required_meta_paths.join(',');
1155
+ if (pq.required_meta_paths !== undefined && pq.required_meta_paths.length > 0) {
1156
+ params.required_meta_paths = pq.required_meta_paths.join(',');
884
1157
  }
885
- if (query.filter_any_meta === true) {
1158
+ if (pq.filter_any_meta === true) {
886
1159
  params.any_meta = '1';
887
1160
  }
888
1161
  const data = await this.request('GET', `/api/v1/documents/identifiers${buildQuery(params)}`);
1162
+ const un = (ids) => ids.map((id) => this.isoUnprefixId(id));
889
1163
  // Older servers returned a bare string[]; paginated API returns { identifiers, total, offset, limit }.
890
1164
  if (Array.isArray(data)) {
1165
+ const ids = un(data);
891
1166
  return {
892
- identifiers: data,
893
- total: data.length,
1167
+ identifiers: ids,
1168
+ total: ids.length,
894
1169
  offset: 0,
895
- limit: data.length,
1170
+ limit: ids.length,
896
1171
  };
897
1172
  }
898
1173
  if (data !== null && typeof data === 'object') {
899
1174
  const o = data;
900
- const ids = Array.isArray(o.identifiers) ? o.identifiers : [];
1175
+ const ids = Array.isArray(o.identifiers) ? un(o.identifiers) : [];
901
1176
  return {
902
1177
  identifiers: ids,
903
1178
  total: typeof o.total === 'number' ? o.total : ids.length,
@@ -910,7 +1185,7 @@ class XCiteDBClient {
910
1185
  async listIdentifierChildren(parentPath) {
911
1186
  const params = {};
912
1187
  if (parentPath !== undefined && parentPath !== '') {
913
- params.parent_path = parentPath;
1188
+ params.parent_path = this.isoPrefixId(parentPath);
914
1189
  }
915
1190
  const data = await this.request('GET', `/api/v1/documents/identifier-children${buildQuery(params)}`);
916
1191
  if (data !== null && typeof data === 'object') {
@@ -923,7 +1198,7 @@ class XCiteDBClient {
923
1198
  const r = row;
924
1199
  children.push({
925
1200
  segment: typeof r.segment === 'string' ? r.segment : '',
926
- full_path: typeof r.full_path === 'string' ? r.full_path : '',
1201
+ full_path: typeof r.full_path === 'string' ? this.isoUnprefixId(r.full_path) : '',
927
1202
  is_identifier: r.is_identifier === true,
928
1203
  has_children: r.has_children === true,
929
1204
  });
@@ -931,7 +1206,7 @@ class XCiteDBClient {
931
1206
  }
932
1207
  }
933
1208
  return {
934
- parent_path: typeof o.parent_path === 'string' ? o.parent_path : '',
1209
+ parent_path: typeof o.parent_path === 'string' ? this.isoUnprefixId(o.parent_path) : '',
935
1210
  parent_is_identifier: o.parent_is_identifier === true,
936
1211
  hierarchy_index_available: o.hierarchy_index_available === true,
937
1212
  children,
@@ -945,16 +1220,17 @@ class XCiteDBClient {
945
1220
  };
946
1221
  }
947
1222
  async queryLog(query, fromDate, toDate) {
1223
+ const pq = this.isoPrefixQuery(query);
948
1224
  const params = {
949
- match_start: query.match_start,
950
- match: query.match,
1225
+ match_start: pq.match_start,
1226
+ match: pq.match,
951
1227
  from_date: fromDate,
952
1228
  to_date: toDate,
953
1229
  };
954
1230
  return this.request('GET', `/api/v1/documents/log${buildQuery(params)}`);
955
1231
  }
956
1232
  async addMeta(identifier, value, path = '', opts) {
957
- const body = { identifier, value, path };
1233
+ const body = { identifier: this.isoPrefixId(identifier), value, path };
958
1234
  if (opts?.mode === 'append')
959
1235
  body.mode = 'append';
960
1236
  const r = await this.request('POST', '/api/v1/meta', body);
@@ -962,7 +1238,7 @@ class XCiteDBClient {
962
1238
  }
963
1239
  async addMetaByQuery(query, value, path = '', firstMatch = false, opts) {
964
1240
  const body = {
965
- query,
1241
+ query: this.isoPrefixQuery(query),
966
1242
  value,
967
1243
  path,
968
1244
  first_match: firstMatch,
@@ -979,27 +1255,36 @@ class XCiteDBClient {
979
1255
  return this.addMetaByQuery(query, value, path, firstMatch, { mode: 'append' });
980
1256
  }
981
1257
  async queryMeta(identifier, path = '') {
982
- return this.request('GET', `/api/v1/meta${buildQuery({ identifier, path })}`);
1258
+ return this.request('GET', `/api/v1/meta${buildQuery({ identifier: this.isoPrefixId(identifier), path })}`);
983
1259
  }
984
1260
  async queryMetaByQuery(query, path = '') {
985
- return this.request('POST', '/api/v1/meta/query', { query, path });
1261
+ return this.request('POST', '/api/v1/meta/query', { query: this.isoPrefixQuery(query), path });
986
1262
  }
987
1263
  async clearMeta(query) {
988
- const r = await this.request('DELETE', '/api/v1/meta', { query });
1264
+ const r = await this.request('DELETE', '/api/v1/meta', {
1265
+ query: this.isoPrefixQuery(query),
1266
+ });
989
1267
  return r?.ok !== false;
990
1268
  }
991
1269
  async acquireLock(identifier, expires = 0) {
992
- return this.request('POST', '/api/v1/locks', { identifier, expires });
1270
+ const lock = await this.request('POST', '/api/v1/locks', {
1271
+ identifier: this.isoPrefixId(identifier),
1272
+ expires,
1273
+ });
1274
+ return { ...lock, identifier: this.isoUnprefixId(lock.identifier) };
993
1275
  }
994
1276
  async releaseLock(identifier, lockId) {
995
1277
  const r = await this.request('DELETE', '/api/v1/locks', {
996
- identifier,
1278
+ identifier: this.isoPrefixId(identifier),
997
1279
  lock_id: lockId,
998
1280
  });
999
1281
  return r?.ok !== false;
1000
1282
  }
1001
1283
  async findLocks(identifier) {
1002
- return this.request('GET', `/api/v1/locks${buildQuery({ identifier })}`);
1284
+ const locks = await this.request('GET', `/api/v1/locks${buildQuery({ identifier: this.isoPrefixId(identifier) })}`);
1285
+ return Array.isArray(locks)
1286
+ ? locks.map((L) => ({ ...L, identifier: this.isoUnprefixId(L.identifier) }))
1287
+ : locks;
1003
1288
  }
1004
1289
  /**
1005
1290
  * Run Unquery (`POST /api/v1/unquery`): declarative analytics over documents matching `query`.
@@ -1027,7 +1312,7 @@ class XCiteDBClient {
1027
1312
  * ```
1028
1313
  */
1029
1314
  async unquery(query, unquery) {
1030
- return this.request('POST', '/api/v1/unquery', { query, unquery });
1315
+ return this.request('POST', '/api/v1/unquery', { query: this.isoPrefixQuery(query), unquery });
1031
1316
  }
1032
1317
  async search(q) {
1033
1318
  const body = { query: q.query };
@@ -1039,6 +1324,12 @@ class XCiteDBClient {
1039
1324
  body.offset = q.offset;
1040
1325
  if (q.limit !== undefined)
1041
1326
  body.limit = q.limit;
1327
+ if (q.mode)
1328
+ body.mode = q.mode;
1329
+ if (q.min_score !== undefined)
1330
+ body.min_score = q.min_score;
1331
+ if (q.semantic_weight !== undefined)
1332
+ body.semantic_weight = q.semantic_weight;
1042
1333
  const data = await this.request('POST', '/api/v1/search', body);
1043
1334
  const hits = [];
1044
1335
  if (Array.isArray(data.hits)) {
@@ -1046,7 +1337,7 @@ class XCiteDBClient {
1046
1337
  if (h && typeof h === 'object') {
1047
1338
  const o = h;
1048
1339
  const hit = {
1049
- identifier: String(o.identifier ?? ''),
1340
+ identifier: this.isoUnprefixId(String(o.identifier ?? '')),
1050
1341
  path: String(o.path ?? ''),
1051
1342
  doc_type: o.doc_type === 'json' ? 'json' : 'xml',
1052
1343
  branch: String(o.branch ?? ''),
@@ -1056,6 +1347,9 @@ class XCiteDBClient {
1056
1347
  if (typeof o.xcitepath === 'string' && o.xcitepath.length > 0) {
1057
1348
  hit.xcitepath = o.xcitepath;
1058
1349
  }
1350
+ if (o.source === 'fts' || o.source === 'semantic' || o.source === 'both') {
1351
+ hit.source = o.source;
1352
+ }
1059
1353
  hits.push(hit);
1060
1354
  }
1061
1355
  }
@@ -1069,24 +1363,170 @@ class XCiteDBClient {
1069
1363
  async reindex() {
1070
1364
  return this.request('POST', '/api/v1/search/reindex', {});
1071
1365
  }
1366
+ async reembedVector() {
1367
+ return this.request('POST', '/api/v1/search/reembed-vector', {});
1368
+ }
1369
+ /** Project search / FTS / vector / LLM configuration (`GET /api/v1/project/settings/search`). */
1370
+ async getProjectSearchSettings() {
1371
+ return this.request('GET', '/api/v1/project/settings/search');
1372
+ }
1373
+ /** Update project search settings (`PUT /api/v1/project/settings/search`). Returns the same shape as GET. */
1374
+ async updateProjectSearchSettings(patch) {
1375
+ return this.request('PUT', '/api/v1/project/settings/search', patch);
1376
+ }
1377
+ /** Blocking full DB scan (admin; no calls to embedding API). Prefer {@link postVectorIndexEstimateSession} for UI. */
1378
+ async getVectorIndexEstimate() {
1379
+ return this.request('GET', '/api/v1/project/settings/search/vector-index-estimate', undefined);
1380
+ }
1381
+ /** Start background estimate (202); cancel prior session for this tenant. */
1382
+ async postVectorIndexEstimateSession() {
1383
+ return this.request('POST', '/api/v1/project/settings/search/vector-index-estimate', {});
1384
+ }
1385
+ async getVectorIndexEstimateSession(sessionId) {
1386
+ const q = buildQuery({ session: sessionId });
1387
+ return this.request('GET', `/api/v1/project/settings/search/vector-index-estimate${q}`, undefined);
1388
+ }
1389
+ async deleteVectorIndexEstimateSession(sessionId) {
1390
+ const q = buildQuery({ session: sessionId });
1391
+ return this.request('DELETE', `/api/v1/project/settings/search/vector-index-estimate${q}`, undefined);
1392
+ }
1393
+ /**
1394
+ * RAG over indexed documents (`POST /api/v1/rag/query` with JSON body).
1395
+ * Requires LLM completion; embedding required when retrieval uses semantic or hybrid.
1396
+ */
1397
+ async ragQuery(options) {
1398
+ const body = {
1399
+ question: options.question,
1400
+ stream: false,
1401
+ };
1402
+ if (options.branch !== undefined)
1403
+ body.branch = options.branch;
1404
+ if (options.max_context_docs !== undefined)
1405
+ body.max_context_docs = options.max_context_docs;
1406
+ if (options.doc_types?.length)
1407
+ body.doc_types = options.doc_types;
1408
+ if (options.search_mode !== undefined)
1409
+ body.search_mode = options.search_mode;
1410
+ if (options.min_score !== undefined)
1411
+ body.min_score = options.min_score;
1412
+ if (options.semantic_weight !== undefined)
1413
+ body.semantic_weight = options.semantic_weight;
1414
+ const data = await this.request('POST', '/api/v1/rag/query', body);
1415
+ if (!data || typeof data !== 'object') {
1416
+ throw new types_1.XCiteDBError('Invalid RAG response', 500, data);
1417
+ }
1418
+ const answer = typeof data.answer === 'string' ? data.answer : '';
1419
+ const sources = 'sources' in data ? data.sources : [];
1420
+ return { answer, sources };
1421
+ }
1422
+ /**
1423
+ * Streaming RAG (`POST /api/v1/rag/query` with `stream: true`). Parses SSE `data: {...}` lines.
1424
+ * The final event has `done: true` and may include `sources`.
1425
+ */
1426
+ async ragQueryStream(options, onEvent) {
1427
+ const path = '/api/v1/rag/query';
1428
+ const payload = JSON.stringify({
1429
+ question: options.question,
1430
+ stream: true,
1431
+ ...(options.branch !== undefined ? { branch: options.branch } : {}),
1432
+ ...(options.max_context_docs !== undefined ? { max_context_docs: options.max_context_docs } : {}),
1433
+ ...(options.doc_types?.length ? { doc_types: options.doc_types } : {}),
1434
+ ...(options.search_mode !== undefined ? { search_mode: options.search_mode } : {}),
1435
+ ...(options.min_score !== undefined ? { min_score: options.min_score } : {}),
1436
+ ...(options.semantic_weight !== undefined ? { semantic_weight: options.semantic_weight } : {}),
1437
+ });
1438
+ for (let attempt = 0; attempt < 2; attempt++) {
1439
+ const url = joinUrl(this.baseUrl, path);
1440
+ const headers = {
1441
+ ...this.requestHeaders(),
1442
+ 'Content-Type': 'application/json',
1443
+ };
1444
+ const res = await fetch(url, { method: 'POST', headers, body: payload });
1445
+ if (res.status === 401 &&
1446
+ attempt === 0 &&
1447
+ (await this.tryRefreshSessionAfter401())) {
1448
+ continue;
1449
+ }
1450
+ if (!res.ok) {
1451
+ const text = await res.text();
1452
+ let data;
1453
+ try {
1454
+ data = text ? JSON.parse(text) : null;
1455
+ }
1456
+ catch {
1457
+ data = text;
1458
+ }
1459
+ const msg = typeof data === 'object' && data !== null && 'message' in data
1460
+ ? String(data.message)
1461
+ : res.statusText;
1462
+ this.notifySessionInvalidIfNeeded(path, res.status);
1463
+ throw new types_1.XCiteDBError(msg || `HTTP ${res.status}`, res.status, data);
1464
+ }
1465
+ const streamBody = res.body;
1466
+ if (!streamBody) {
1467
+ const text = await res.text();
1468
+ throw new types_1.XCiteDBError('RAG stream: empty response body', res.status, text);
1469
+ }
1470
+ const reader = streamBody.getReader();
1471
+ const decoder = new TextDecoder();
1472
+ let buf = '';
1473
+ try {
1474
+ for (;;) {
1475
+ const { done, value } = await reader.read();
1476
+ if (value) {
1477
+ buf += decoder.decode(value, { stream: !done });
1478
+ }
1479
+ let sep;
1480
+ while ((sep = buf.indexOf('\n\n')) >= 0) {
1481
+ const block = buf.slice(0, sep);
1482
+ buf = buf.slice(sep + 2);
1483
+ const lines = block.split('\n');
1484
+ const dataLine = lines.find((l) => l.startsWith('data:'));
1485
+ if (!dataLine)
1486
+ continue;
1487
+ const jsonStr = dataLine.replace(/^data:\s*/, '').trim();
1488
+ if (!jsonStr)
1489
+ continue;
1490
+ let ev;
1491
+ try {
1492
+ ev = JSON.parse(jsonStr);
1493
+ }
1494
+ catch {
1495
+ continue;
1496
+ }
1497
+ onEvent(ev);
1498
+ }
1499
+ if (done)
1500
+ break;
1501
+ }
1502
+ }
1503
+ finally {
1504
+ reader.releaseLock();
1505
+ }
1506
+ return;
1507
+ }
1508
+ throw new types_1.XCiteDBError('RAG stream failed after retry', 401, null);
1509
+ }
1072
1510
  async writeJsonDocument(identifier, data) {
1073
- await this.request('POST', '/api/v1/json-documents', { identifier, data });
1511
+ await this.request('POST', '/api/v1/json-documents', { identifier: this.isoPrefixId(identifier), data });
1074
1512
  }
1075
1513
  async readJsonDocument(identifier) {
1076
- return this.request('GET', `/api/v1/json-documents${buildQuery({ identifier })}`);
1514
+ return this.request('GET', `/api/v1/json-documents${buildQuery({ identifier: this.isoPrefixId(identifier) })}`);
1077
1515
  }
1078
1516
  async deleteJsonDocument(identifier) {
1079
- await this.request('DELETE', `/api/v1/json-documents${buildQuery({ identifier })}`);
1517
+ await this.request('DELETE', `/api/v1/json-documents${buildQuery({ identifier: this.isoPrefixId(identifier) })}`);
1080
1518
  }
1081
1519
  async listJsonDocuments(match, limit, offset) {
1082
- const data = await this.request('GET', `/api/v1/json-documents/list${buildQuery({ match, limit, offset })}`);
1520
+ const m = match !== undefined && match !== '' ? this.isoPrefixId(match) : match;
1521
+ const data = await this.request('GET', `/api/v1/json-documents/list${buildQuery({ match: m, limit, offset })}`);
1522
+ const un = (ids) => ids.map((id) => this.isoUnprefixId(id));
1083
1523
  if (Array.isArray(data)) {
1084
- const identifiers = data;
1524
+ const identifiers = un(data);
1085
1525
  return { identifiers, total: identifiers.length, offset: 0, limit: identifiers.length };
1086
1526
  }
1087
1527
  if (data !== null && typeof data === 'object') {
1088
1528
  const o = data;
1089
- const ids = Array.isArray(o.identifiers) ? o.identifiers : [];
1529
+ const ids = Array.isArray(o.identifiers) ? un(o.identifiers) : [];
1090
1530
  return {
1091
1531
  identifiers: ids,
1092
1532
  total: typeof o.total === 'number' ? o.total : ids.length,