@xcitedbs/client 0.3.1 → 0.3.3

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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AccessCheckResult, AppAuthConfig, AppEmailConfig, AppEmailTemplates, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BranchInfo, BookmarkRecord, CheckpointRecord, CommitRecord, CompareRef, CompareResult, DatabaseContext, DiffRef, DiffResult, SmartDiffRef, SmartDiffResult, DocumentBatchResponse, DocumentExportFormat, ExportDocumentResult, Flags, JsonDocumentBatchItem, ImportDocumentOptions, ImportDocumentResult, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LogEntry, MergeResult, PublishResult, RebaseUserWorkspaceResult, WorkspaceInfo, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, UnqueryTemplate, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, StoredPolicyResponse, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkResult, AssetShareListResponse, AssetShareRequest, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, VectorIndexEstimate, RagQueryOptions, RagQueryResult, RagStreamEvent, OAuthProvidersResponse, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspacesResponse, TokenPair, UserInfo, ApiKeyInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, XCiteDBClientOptions, XCiteDBJwtClaims, TestSessionBootstrapSummary, TestSessionInfo, XCiteQuery, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationShareResult } from './types';
1
+ import { AccessCheckResult, AppAuthConfig, AppEmailConfig, AppEmailTemplates, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BranchInfo, BookmarkRecord, CheckpointRecord, CommitRecord, CompareRef, CompareResult, DatabaseContext, DiffRef, DiffResult, SmartDiffRef, SmartDiffResult, DocumentBatchResponse, DocumentExportFormat, ExportDocumentResult, Flags, JsonDocumentBatchItem, ImportDocumentOptions, ImportDocumentResult, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LogEntry, MergeResult, PublishResult, RebaseUserWorkspaceResult, WorkspaceInfo, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, UnqueryTemplate, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, TriggerEventsResponse, StoredPolicyResponse, TxnRequest, TxnResponse, VerifyAppUserTokenOptions, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkResult, AssetShareListResponse, AssetShareRequest, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, VectorIndexEstimate, RagQueryOptions, RagQueryResult, RagStreamEvent, OAuthProvidersResponse, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspacesResponse, TokenPair, UserInfo, ApiKeyInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, XCiteDBClientOptions, XCiteDBJwtClaims, TestSessionBootstrapSummary, TestSessionInfo, XCiteQuery, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationShareResult } from './types';
2
2
  import { WebSocketSubscription } from './websocket';
3
3
  export declare class XCiteDBClient {
4
4
  private baseUrl;
@@ -24,6 +24,9 @@ export declare class XCiteDBClient {
24
24
  private userIsolation?;
25
25
  private cachedAppUserId?;
26
26
  private readonly requestTimeoutMs?;
27
+ private jwksCache?;
28
+ /** TTL for the JWKS cache; rotations land via `kid` mismatch + force refresh. */
29
+ private static readonly JWKS_TTL_MS;
27
30
  /** Set by {@link XCiteDBClient.createTestSession} when the server returns a `bootstrap` summary. */
28
31
  lastTestSessionBootstrap?: TestSessionBootstrapSummary;
29
32
  constructor(options: XCiteDBClientOptions);
@@ -47,11 +50,33 @@ export declare class XCiteDBClient {
47
50
  */
48
51
  static buildProjectGroup(projectId: string, role: 'admin' | 'editor' | 'viewer'): string;
49
52
  private static decodeJwtPayloadJson;
53
+ /** Build {@link XCiteDBJwtClaims} from a decoded JWT payload, or `null` if shape is wrong. */
54
+ private static claimsFromPayload;
50
55
  /**
51
56
  * Decode `appUserAccessToken`, or `accessToken` if no app token (platform JWT), without verifying the signature.
52
57
  * Use for debugging ABAC (compare `tenant_id` and `groups` to `project:<…>:role` in policies).
53
58
  */
54
59
  getTokenClaims(): XCiteDBJwtClaims | null;
60
+ private static decodeJwtHeaderJson;
61
+ private static base64UrlToBytes;
62
+ /** Fetch JWKS, honoring the in-memory TTL unless `force` is set. */
63
+ private fetchJwks;
64
+ /**
65
+ * Verify an app-user JWT and return its claims. Validates signature, expiry, and
66
+ * (unless overridden in {@link VerifyAppUserTokenOptions.expectedTenantId}) tenant scope.
67
+ *
68
+ * The verification path depends on the server's `auth.app_users.jwt_algorithm`:
69
+ * * **`RS256`** — verifies locally against `/.well-known/jwks.json` (cached) using
70
+ * `crypto.subtle`. No round-trip per call after the JWKS is cached.
71
+ * * **`HS256`** — there's no public key the BFF can hold, so the SDK falls back to
72
+ * `POST /api/v1/app/auth/verify` (one round-trip). The shape is the same.
73
+ *
74
+ * Throws {@link XCiteDBAuthError} on bad signature, expired token, tenant mismatch, or
75
+ * malformed payload.
76
+ */
77
+ verifyAppUserToken(token: string, options?: VerifyAppUserTokenOptions): Promise<XCiteDBJwtClaims>;
78
+ private verifyRs256Locally;
79
+ private verifyTokenViaServer;
55
80
  private cacheAppUserIdFromPair;
56
81
  private clearAppUserIdCache;
57
82
  private getAppUserId;
@@ -324,6 +349,13 @@ export declare class XCiteDBClient {
324
349
  listTriggers(): Promise<Record<string, TriggerDefinition>>;
325
350
  getTrigger(name: string): Promise<StoredTriggerResponse>;
326
351
  deleteTrigger(name: string): Promise<void>;
352
+ /**
353
+ * Recent trigger firings for the current project (`GET /api/v1/_xcitedb/trigger-events`).
354
+ *
355
+ * Newest first. `limit` is clamped server-side to `[1, 500]` (default 100). Only
356
+ * `admin`/`editor` roles can read this; public-key requests are rejected.
357
+ */
358
+ listTriggerEvents(limit?: number): Promise<TriggerEventsResponse>;
327
359
  /**
328
360
  * Dry-run access check (`POST /api/v1/security/check`). Returns `effect` and optional `matched_policy_id`.
329
361
  * Useful for debugging policies. Actions: `read`, `write`, `delete`, `list`, `unquery`.
@@ -838,6 +870,33 @@ export declare class XCiteDBClient {
838
870
  * Best-effort batch JSON document writes (`POST /api/v1/json-documents/batch`). Each item is independent; check `results[].ok`.
839
871
  */
840
872
  writeJsonDocumentsBatch(items: JsonDocumentBatchItem[]): Promise<DocumentBatchResponse>;
873
+ /**
874
+ * Atomic multi-operation transaction (`POST /api/v1/txn`). All `operations` and `preconditions`
875
+ * apply or none do — any failure aborts the whole txn and unexecuted ops carry
876
+ * `code: 0` with `error: "not executed (atomic txn aborted)"`.
877
+ *
878
+ * Operation kinds: `WriteJson`, `WriteXml`, `WriteMeta`, `ClearMetaPath`, `DeleteIdentifier`,
879
+ * `AddIdentifier`. Precondition kinds: `MetaEquals`, `MetaExists`, `MetaAbsent`,
880
+ * `DocumentExists`, `DocumentAbsent`. See {@link TxnOperation} / {@link TxnPrecondition} for
881
+ * field-by-field shapes.
882
+ *
883
+ * Pass `idempotency_key` to make safe retries — the server caches the response body for ~24h
884
+ * so a repeated call returns the original 200 result.
885
+ *
886
+ * @example
887
+ * ```ts
888
+ * const r = await client.txn({
889
+ * preconditions: [{ kind: 'MetaEquals', identifier: '/songs/42', path: 'rating', value: 4 }],
890
+ * operations: [
891
+ * { kind: 'WriteMeta', identifier: '/songs/42', path: 'rating', value: 5 },
892
+ * { kind: 'WriteJson', identifier: '/users/u1/votes/42', data: { score: 5 } },
893
+ * ],
894
+ * idempotency_key: 'vote-u1-42-v5',
895
+ * });
896
+ * if (!r.committed) throw new Error(r.error);
897
+ * ```
898
+ */
899
+ txn(req: TxnRequest): Promise<TxnResponse>;
841
900
  readJsonDocument<T = unknown>(identifier: string): Promise<T>;
842
901
  deleteJsonDocument(identifier: string): Promise<void>;
843
902
  listJsonDocuments(match?: string, limit?: number, offset?: number): Promise<ListIdentifiersResult>;
package/dist/client.js CHANGED
@@ -9,6 +9,20 @@ function joinUrl(base, path) {
9
9
  const p = path.startsWith('/') ? path : `/${path}`;
10
10
  return `${b}${p}`;
11
11
  }
12
+ function pickJwk(keys, kid) {
13
+ if (!Array.isArray(keys) || keys.length === 0)
14
+ return undefined;
15
+ if (kid) {
16
+ const byKid = keys.find((k) => k.kid === kid);
17
+ if (byKid)
18
+ return byKid;
19
+ }
20
+ // No matching kid: only accept a sole RSA signing key — never silently pick from a multi-key set.
21
+ const rsa = keys.filter((k) => k.kty === 'RSA' && (k.use === 'sig' || k.use === undefined));
22
+ if (rsa.length === 1)
23
+ return rsa[0];
24
+ return undefined;
25
+ }
12
26
  function newClientRequestId() {
13
27
  const c = globalThis.crypto?.randomUUID?.();
14
28
  if (c)
@@ -229,17 +243,8 @@ class XCiteDBClient {
229
243
  return null;
230
244
  }
231
245
  }
232
- /**
233
- * Decode `appUserAccessToken`, or `accessToken` if no app token (platform JWT), without verifying the signature.
234
- * Use for debugging ABAC (compare `tenant_id` and `groups` to `project:<…>:role` in policies).
235
- */
236
- getTokenClaims() {
237
- const raw = this.appUserAccessToken ?? this.accessToken;
238
- if (!raw)
239
- return null;
240
- const p = XCiteDBClient.decodeJwtPayloadJson(raw);
241
- if (!p)
242
- return null;
246
+ /** Build {@link XCiteDBJwtClaims} from a decoded JWT payload, or `null` if shape is wrong. */
247
+ static claimsFromPayload(p) {
243
248
  let groups = [];
244
249
  const g = p['groups'];
245
250
  if (Array.isArray(g)) {
@@ -273,6 +278,209 @@ class XCiteDBClient {
273
278
  jti: typeof jti === 'string' ? jti : undefined,
274
279
  };
275
280
  }
281
+ /**
282
+ * Decode `appUserAccessToken`, or `accessToken` if no app token (platform JWT), without verifying the signature.
283
+ * Use for debugging ABAC (compare `tenant_id` and `groups` to `project:<…>:role` in policies).
284
+ */
285
+ getTokenClaims() {
286
+ const raw = this.appUserAccessToken ?? this.accessToken;
287
+ if (!raw)
288
+ return null;
289
+ const p = XCiteDBClient.decodeJwtPayloadJson(raw);
290
+ if (!p)
291
+ return null;
292
+ return XCiteDBClient.claimsFromPayload(p);
293
+ }
294
+ static decodeJwtHeaderJson(token) {
295
+ const parts = token.split('.');
296
+ if (parts.length < 2)
297
+ return null;
298
+ try {
299
+ let b64 = parts[0].replace(/-/g, '+').replace(/_/g, '/');
300
+ const pad = (4 - (b64.length % 4)) % 4;
301
+ b64 += '='.repeat(pad);
302
+ const g = globalThis;
303
+ let json = null;
304
+ if (g.Buffer) {
305
+ json = g.Buffer.from(b64, 'base64').toString('utf8');
306
+ }
307
+ else if (typeof atob === 'function') {
308
+ json = atob(b64);
309
+ }
310
+ if (json === null)
311
+ return null;
312
+ return JSON.parse(json);
313
+ }
314
+ catch {
315
+ return null;
316
+ }
317
+ }
318
+ static base64UrlToBytes(b64url) {
319
+ let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
320
+ const pad = (4 - (b64.length % 4)) % 4;
321
+ b64 += '='.repeat(pad);
322
+ const g = globalThis;
323
+ if (g.Buffer) {
324
+ const buf = g.Buffer.from(b64, 'base64');
325
+ // Buffer extends Uint8Array; copy to a fresh view to detach from the underlying pool.
326
+ return new Uint8Array(buf);
327
+ }
328
+ if (typeof atob !== 'function') {
329
+ throw new types_1.XCiteDBAuthError('No base64 decoder available in this environment', 0, null);
330
+ }
331
+ const bin = atob(b64);
332
+ const out = new Uint8Array(bin.length);
333
+ for (let i = 0; i < bin.length; i++)
334
+ out[i] = bin.charCodeAt(i);
335
+ return out;
336
+ }
337
+ /** Fetch JWKS, honoring the in-memory TTL unless `force` is set. */
338
+ async fetchJwks(force) {
339
+ const now = Date.now();
340
+ if (!force && this.jwksCache && now - this.jwksCache.fetchedAt < XCiteDBClient.JWKS_TTL_MS) {
341
+ return this.jwksCache.keys;
342
+ }
343
+ const url = joinUrl(this.baseUrl, '/.well-known/jwks.json');
344
+ const sig = requestTimeoutSignal(this.requestTimeoutMs);
345
+ const init = { method: 'GET', headers: { Accept: 'application/json' } };
346
+ if (sig)
347
+ init.signal = sig;
348
+ let res;
349
+ try {
350
+ res = await fetch(url, init);
351
+ }
352
+ catch (e) {
353
+ throw new types_1.XCiteDBAuthError(`JWKS fetch failed: ${e instanceof Error ? e.message : String(e)}`, 0, null);
354
+ }
355
+ if (!res.ok) {
356
+ throw new types_1.XCiteDBAuthError(`JWKS fetch returned HTTP ${res.status}`, res.status, null);
357
+ }
358
+ let body;
359
+ try {
360
+ body = (await res.json());
361
+ }
362
+ catch {
363
+ throw new types_1.XCiteDBAuthError('JWKS response was not JSON', 0, null);
364
+ }
365
+ const keys = Array.isArray(body?.keys) ? body.keys : [];
366
+ this.jwksCache = { fetchedAt: now, keys };
367
+ return keys;
368
+ }
369
+ /**
370
+ * Verify an app-user JWT and return its claims. Validates signature, expiry, and
371
+ * (unless overridden in {@link VerifyAppUserTokenOptions.expectedTenantId}) tenant scope.
372
+ *
373
+ * The verification path depends on the server's `auth.app_users.jwt_algorithm`:
374
+ * * **`RS256`** — verifies locally against `/.well-known/jwks.json` (cached) using
375
+ * `crypto.subtle`. No round-trip per call after the JWKS is cached.
376
+ * * **`HS256`** — there's no public key the BFF can hold, so the SDK falls back to
377
+ * `POST /api/v1/app/auth/verify` (one round-trip). The shape is the same.
378
+ *
379
+ * Throws {@link XCiteDBAuthError} on bad signature, expired token, tenant mismatch, or
380
+ * malformed payload.
381
+ */
382
+ async verifyAppUserToken(token, options = {}) {
383
+ if (typeof token !== 'string' || token.length === 0) {
384
+ throw new types_1.XCiteDBAuthError('verifyAppUserToken: token must be a non-empty string', 0, null);
385
+ }
386
+ const parts = token.split('.');
387
+ if (parts.length !== 3) {
388
+ throw new types_1.XCiteDBAuthError('verifyAppUserToken: token is not a compact JWS', 0, null);
389
+ }
390
+ const header = XCiteDBClient.decodeJwtHeaderJson(token);
391
+ const alg = header && typeof header['alg'] === 'string' ? header['alg'] : '';
392
+ const kid = header && typeof header['kid'] === 'string' ? header['kid'] : '';
393
+ let claims;
394
+ if (alg === 'RS256') {
395
+ claims = await this.verifyRs256Locally(token, kid, options.forceRefreshJwks === true);
396
+ }
397
+ else {
398
+ // HS256 (the server default), or any unknown alg — round-trip to the server.
399
+ claims = await this.verifyTokenViaServer(token);
400
+ }
401
+ const skew = options.clockToleranceSeconds ?? 30;
402
+ if (typeof claims.exp === 'number') {
403
+ const nowSec = Math.floor(Date.now() / 1000);
404
+ if (claims.exp + skew < nowSec) {
405
+ throw new types_1.XCiteDBAuthError('Token expired', 401, null);
406
+ }
407
+ }
408
+ const expected = options.expectedTenantId !== undefined
409
+ ? options.expectedTenantId
410
+ : this.defaultContext.project_id ?? this.defaultContext.tenant_id ?? this.projectId ?? '';
411
+ if (expected && claims.tenant_id !== expected) {
412
+ throw new types_1.XCiteDBAuthError(`Token tenant_id "${claims.tenant_id}" does not match expected "${expected}"`, 401, null);
413
+ }
414
+ return claims;
415
+ }
416
+ async verifyRs256Locally(token, kid, forceRefresh) {
417
+ const subtle = globalThis.crypto?.subtle;
418
+ if (!subtle) {
419
+ throw new types_1.XCiteDBAuthError('verifyAppUserToken: crypto.subtle is unavailable in this environment', 0, null);
420
+ }
421
+ let keys = await this.fetchJwks(forceRefresh);
422
+ let jwk = pickJwk(keys, kid);
423
+ if (!jwk) {
424
+ // Possibly a key rotation since we last cached. Force-refresh once and retry.
425
+ keys = await this.fetchJwks(true);
426
+ jwk = pickJwk(keys, kid);
427
+ }
428
+ if (!jwk) {
429
+ // JWKS empty → server likely on HS256 (or RS256 not yet provisioned). Fall back.
430
+ return this.verifyTokenViaServer(token);
431
+ }
432
+ let cryptoKey;
433
+ try {
434
+ cryptoKey = await subtle.importKey('jwk', jwk, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']);
435
+ }
436
+ catch (e) {
437
+ throw new types_1.XCiteDBAuthError(`JWKS import failed: ${e instanceof Error ? e.message : String(e)}`, 0, null);
438
+ }
439
+ const parts = token.split('.');
440
+ const signed = `${parts[0]}.${parts[1]}`;
441
+ const sigBytes = XCiteDBClient.base64UrlToBytes(parts[2]);
442
+ const dataBytes = new TextEncoder().encode(signed);
443
+ // Copy into fresh ArrayBuffer-backed views — strict TS lib types disallow ArrayBufferLike (SAB).
444
+ const sigBuf = new ArrayBuffer(sigBytes.byteLength);
445
+ new Uint8Array(sigBuf).set(sigBytes);
446
+ const dataBuf = new ArrayBuffer(dataBytes.byteLength);
447
+ new Uint8Array(dataBuf).set(dataBytes);
448
+ const ok = await subtle.verify({ name: 'RSASSA-PKCS1-v1_5' }, cryptoKey, sigBuf, dataBuf);
449
+ if (!ok) {
450
+ throw new types_1.XCiteDBAuthError('Token signature is invalid', 401, null);
451
+ }
452
+ const payload = XCiteDBClient.decodeJwtPayloadJson(token);
453
+ if (!payload) {
454
+ throw new types_1.XCiteDBAuthError('Token payload is malformed', 401, null);
455
+ }
456
+ const claims = XCiteDBClient.claimsFromPayload(payload);
457
+ if (!claims) {
458
+ throw new types_1.XCiteDBAuthError('Token payload is missing required claims', 401, null);
459
+ }
460
+ return claims;
461
+ }
462
+ async verifyTokenViaServer(token) {
463
+ try {
464
+ const r = await this.request('POST', '/api/v1/app/auth/verify', this.mergeAppTenant({ token }));
465
+ const claims = r?.claims;
466
+ if (!claims || typeof claims.sub !== 'string' || typeof claims.tenant_id !== 'string') {
467
+ throw new types_1.XCiteDBAuthError('Server verify returned a malformed claims object', 0, r);
468
+ }
469
+ return claims;
470
+ }
471
+ catch (e) {
472
+ if (e instanceof types_1.XCiteDBAuthError)
473
+ throw e;
474
+ if (e instanceof types_1.XCiteDBError) {
475
+ throw new types_1.XCiteDBAuthError(e.message, e.status, e.body, {
476
+ reason: e.reason,
477
+ serverRequestId: e.serverRequestId,
478
+ clientRequestId: e.clientRequestId,
479
+ });
480
+ }
481
+ throw e;
482
+ }
483
+ }
276
484
  cacheAppUserIdFromPair(pair) {
277
485
  if (pair?.user?.user_id) {
278
486
  this.cachedAppUserId = pair.user.user_id;
@@ -1201,6 +1409,20 @@ class XCiteDBClient {
1201
1409
  const q = buildQuery({ name });
1202
1410
  await this.request('DELETE', `/api/v1/triggers${q}`);
1203
1411
  }
1412
+ /**
1413
+ * Recent trigger firings for the current project (`GET /api/v1/_xcitedb/trigger-events`).
1414
+ *
1415
+ * Newest first. `limit` is clamped server-side to `[1, 500]` (default 100). Only
1416
+ * `admin`/`editor` roles can read this; public-key requests are rejected.
1417
+ */
1418
+ async listTriggerEvents(limit) {
1419
+ const q = buildQuery({ limit });
1420
+ const r = await this.request('GET', `/api/v1/_xcitedb/trigger-events${q}`);
1421
+ return {
1422
+ events: Array.isArray(r?.events) ? r.events : [],
1423
+ limit: typeof r?.limit === 'number' ? r.limit : 0,
1424
+ };
1425
+ }
1204
1426
  /**
1205
1427
  * Dry-run access check (`POST /api/v1/security/check`). Returns `effect` and optional `matched_policy_id`.
1206
1428
  * Useful for debugging policies. Actions: `read`, `write`, `delete`, `list`, `unquery`.
@@ -2127,6 +2349,8 @@ class XCiteDBClient {
2127
2349
  body.date_from = q.date_from;
2128
2350
  if (q.date_to !== undefined && q.date_to !== '')
2129
2351
  body.date_to = q.date_to;
2352
+ if (q.identifiers && q.identifiers.length > 0)
2353
+ body.identifiers = q.identifiers;
2130
2354
  const data = await this.request('POST', '/api/v1/search', body);
2131
2355
  const hits = [];
2132
2356
  if (Array.isArray(data.hits)) {
@@ -2504,6 +2728,8 @@ class XCiteDBClient {
2504
2728
  body.max_tokens = options.max_tokens;
2505
2729
  if (options.messages?.length)
2506
2730
  body.messages = options.messages;
2731
+ if (options.identifiers && options.identifiers.length > 0)
2732
+ body.identifiers = options.identifiers;
2507
2733
  const data = await this.request('POST', '/api/v1/rag/query', body);
2508
2734
  if (!data || typeof data !== 'object') {
2509
2735
  throw new types_1.XCiteDBError('Invalid RAG response', 500, data);
@@ -2530,6 +2756,9 @@ class XCiteDBClient {
2530
2756
  ...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
2531
2757
  ...(options.max_tokens !== undefined ? { max_tokens: options.max_tokens } : {}),
2532
2758
  ...(options.messages?.length ? { messages: options.messages } : {}),
2759
+ ...(options.identifiers && options.identifiers.length > 0
2760
+ ? { identifiers: options.identifiers }
2761
+ : {}),
2533
2762
  });
2534
2763
  for (let attempt = 0; attempt < 2; attempt++) {
2535
2764
  const url = joinUrl(this.baseUrl, path);
@@ -2638,6 +2867,109 @@ class XCiteDBClient {
2638
2867
  }
2639
2868
  return r;
2640
2869
  }
2870
+ /**
2871
+ * Atomic multi-operation transaction (`POST /api/v1/txn`). All `operations` and `preconditions`
2872
+ * apply or none do — any failure aborts the whole txn and unexecuted ops carry
2873
+ * `code: 0` with `error: "not executed (atomic txn aborted)"`.
2874
+ *
2875
+ * Operation kinds: `WriteJson`, `WriteXml`, `WriteMeta`, `ClearMetaPath`, `DeleteIdentifier`,
2876
+ * `AddIdentifier`. Precondition kinds: `MetaEquals`, `MetaExists`, `MetaAbsent`,
2877
+ * `DocumentExists`, `DocumentAbsent`. See {@link TxnOperation} / {@link TxnPrecondition} for
2878
+ * field-by-field shapes.
2879
+ *
2880
+ * Pass `idempotency_key` to make safe retries — the server caches the response body for ~24h
2881
+ * so a repeated call returns the original 200 result.
2882
+ *
2883
+ * @example
2884
+ * ```ts
2885
+ * const r = await client.txn({
2886
+ * preconditions: [{ kind: 'MetaEquals', identifier: '/songs/42', path: 'rating', value: 4 }],
2887
+ * operations: [
2888
+ * { kind: 'WriteMeta', identifier: '/songs/42', path: 'rating', value: 5 },
2889
+ * { kind: 'WriteJson', identifier: '/users/u1/votes/42', data: { score: 5 } },
2890
+ * ],
2891
+ * idempotency_key: 'vote-u1-42-v5',
2892
+ * });
2893
+ * if (!r.committed) throw new Error(r.error);
2894
+ * ```
2895
+ */
2896
+ async txn(req) {
2897
+ if (!req || !Array.isArray(req.operations) || req.operations.length === 0) {
2898
+ throw new types_1.XCiteDBError('txn: operations must be a non-empty array', 400, null);
2899
+ }
2900
+ const operations = req.operations.map((op) => {
2901
+ const out = { kind: op.kind, identifier: this.isoPrefixId(op.identifier) };
2902
+ switch (op.kind) {
2903
+ case 'WriteJson':
2904
+ out.data = op.data;
2905
+ if (op.overwrite)
2906
+ out.overwrite = true;
2907
+ break;
2908
+ case 'WriteXml':
2909
+ out.xml = this.isoApplyXmlDbIdentifier(op.xml);
2910
+ if (op.is_top)
2911
+ out.is_top = true;
2912
+ if (op.compare_attributes)
2913
+ out.compare_attributes = true;
2914
+ break;
2915
+ case 'WriteMeta':
2916
+ out.value = op.value;
2917
+ if (op.path !== undefined)
2918
+ out.path = op.path;
2919
+ if (op.mode !== undefined)
2920
+ out.mode = op.mode;
2921
+ if (op.overwrite)
2922
+ out.overwrite = true;
2923
+ break;
2924
+ case 'ClearMetaPath':
2925
+ out.path = op.path;
2926
+ break;
2927
+ case 'DeleteIdentifier':
2928
+ case 'AddIdentifier':
2929
+ break;
2930
+ }
2931
+ return out;
2932
+ });
2933
+ const body = { operations };
2934
+ if (req.preconditions && req.preconditions.length > 0) {
2935
+ body.preconditions = req.preconditions.map((p) => {
2936
+ const out = { kind: p.kind, identifier: this.isoPrefixId(p.identifier) };
2937
+ switch (p.kind) {
2938
+ case 'MetaEquals':
2939
+ out.value = p.value;
2940
+ if (p.path !== undefined)
2941
+ out.path = p.path;
2942
+ break;
2943
+ case 'MetaExists':
2944
+ case 'MetaAbsent':
2945
+ if (p.path !== undefined)
2946
+ out.path = p.path;
2947
+ break;
2948
+ case 'DocumentExists':
2949
+ case 'DocumentAbsent':
2950
+ break;
2951
+ }
2952
+ return out;
2953
+ });
2954
+ }
2955
+ if (req.idempotency_key !== undefined)
2956
+ body.idempotency_key = req.idempotency_key;
2957
+ const r = await this.request('POST', '/api/v1/txn', body);
2958
+ // Un-prefix identifiers in the response so callers see what they passed in.
2959
+ if (Array.isArray(r?.results)) {
2960
+ for (const row of r.results) {
2961
+ if (row?.identifier)
2962
+ row.identifier = this.isoUnprefixId(String(row.identifier));
2963
+ }
2964
+ }
2965
+ if (Array.isArray(r?.preconditions)) {
2966
+ for (const row of r.preconditions) {
2967
+ if (row?.identifier)
2968
+ row.identifier = this.isoUnprefixId(String(row.identifier));
2969
+ }
2970
+ }
2971
+ return r;
2972
+ }
2641
2973
  async readJsonDocument(identifier) {
2642
2974
  return this.request('GET', `/api/v1/json-documents${buildQuery({ identifier: this.isoPrefixId(identifier) })}`);
2643
2975
  }
@@ -2710,3 +3042,5 @@ class XCiteDBClient {
2710
3042
  }
2711
3043
  }
2712
3044
  exports.XCiteDBClient = XCiteDBClient;
3045
+ /** TTL for the JWKS cache; rotations land via `kid` mismatch + force refresh. */
3046
+ XCiteDBClient.JWKS_TTL_MS = 10 * 60 * 1000;
@@ -385,3 +385,134 @@ const types_js_1 = require("./types.js");
385
385
  }
386
386
  });
387
387
  });
388
+ (0, node_test_1.describe)('txn wrapper', () => {
389
+ (0, node_test_1.it)('rejects empty operations array client-side', async () => {
390
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'k' });
391
+ await strict_1.default.rejects(c.txn({ operations: [] }), /non-empty/);
392
+ });
393
+ (0, node_test_1.it)('serializes mixed ops + preconditions and unprefixes response identifiers', async () => {
394
+ const captured = { value: null };
395
+ const orig = globalThis.fetch;
396
+ globalThis.fetch = node_test_1.mock.fn(async (input, init) => {
397
+ captured.value = {
398
+ url: String(input),
399
+ body: JSON.parse(String(init?.body ?? '{}')),
400
+ };
401
+ return new Response(JSON.stringify({
402
+ committed: true,
403
+ preconditions: [
404
+ { index: 0, identifier: '/users/u1/songs/42', kind: 'MetaEquals', ok: true },
405
+ ],
406
+ results: [
407
+ { index: 0, identifier: '/users/u1/songs/42', kind: 'WriteMeta', ok: true, code: 200 },
408
+ { index: 1, identifier: '/users/u1/votes/42', kind: 'WriteJson', ok: true, code: 200 },
409
+ ],
410
+ }), { status: 200 });
411
+ });
412
+ try {
413
+ const c = new client_js_1.XCiteDBClient({
414
+ baseUrl: 'http://127.0.0.1:9',
415
+ apiKey: 'k',
416
+ userIsolation: { enabled: true, namespace: '/users/{userId}' },
417
+ });
418
+ c.setAppUserTokens('h.eyJzdWIiOiJ1MSJ9.s');
419
+ const r = await c.txn({
420
+ preconditions: [{ kind: 'MetaEquals', identifier: '/songs/42', path: 'rating', value: 4 }],
421
+ operations: [
422
+ { kind: 'WriteMeta', identifier: '/songs/42', path: 'rating', value: 5 },
423
+ { kind: 'WriteJson', identifier: '/votes/42', data: { score: 5 } },
424
+ ],
425
+ idempotency_key: 'abc',
426
+ });
427
+ strict_1.default.equal(r.committed, true);
428
+ // identifiers come back un-prefixed back to caller's namespace.
429
+ strict_1.default.equal(r.results[0].identifier, '/songs/42');
430
+ strict_1.default.equal(r.results[1].identifier, '/votes/42');
431
+ strict_1.default.equal(r.preconditions[0].identifier, '/songs/42');
432
+ strict_1.default.ok(captured.value);
433
+ strict_1.default.match(captured.value.url, /\/api\/v1\/txn$/);
434
+ const body = captured.value.body;
435
+ strict_1.default.equal(body.idempotency_key, 'abc');
436
+ const ops = body.operations;
437
+ strict_1.default.equal(ops[0].identifier, '/users/u1/songs/42');
438
+ strict_1.default.equal(ops[0].kind, 'WriteMeta');
439
+ strict_1.default.equal(ops[0].value, 5);
440
+ strict_1.default.equal(ops[1].identifier, '/users/u1/votes/42');
441
+ const pre = body.preconditions;
442
+ strict_1.default.equal(pre[0].identifier, '/users/u1/songs/42');
443
+ strict_1.default.equal(pre[0].value, 4);
444
+ }
445
+ finally {
446
+ globalThis.fetch = orig;
447
+ }
448
+ });
449
+ });
450
+ (0, node_test_1.describe)('verifyAppUserToken', () => {
451
+ (0, node_test_1.it)('rejects malformed tokens client-side (not a compact JWS)', async () => {
452
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'k' });
453
+ await strict_1.default.rejects(c.verifyAppUserToken('only.two-segments'), /compact JWS/);
454
+ await strict_1.default.rejects(c.verifyAppUserToken(''), /non-empty/);
455
+ });
456
+ (0, node_test_1.it)('falls back to /api/v1/app/auth/verify for HS256 and enforces tenant', async () => {
457
+ // Token with header alg=HS256 — SDK should not try JWKS, it should POST /verify.
458
+ // header { alg: HS256 } base64url, payload arbitrary, sig dummy.
459
+ const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
460
+ const payload = Buffer.from(JSON.stringify({ sub: 'u1', tenant_id: 't1' })).toString('base64url');
461
+ const token = `${header}.${payload}.sig`;
462
+ const orig = globalThis.fetch;
463
+ let path = '';
464
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
465
+ path = String(input);
466
+ return new Response(JSON.stringify({
467
+ claims: {
468
+ sub: 'u1',
469
+ tenant_id: 't1',
470
+ groups: ['g'],
471
+ email: 'a@b',
472
+ exp: Math.floor(Date.now() / 1000) + 3600,
473
+ },
474
+ }), { status: 200 });
475
+ });
476
+ try {
477
+ const c = new client_js_1.XCiteDBClient({
478
+ baseUrl: 'http://127.0.0.1:9',
479
+ apiKey: 'k',
480
+ projectId: 't1',
481
+ context: { project_id: 't1' },
482
+ });
483
+ const claims = await c.verifyAppUserToken(token);
484
+ strict_1.default.match(path, /\/api\/v1\/app\/auth\/verify$/);
485
+ strict_1.default.equal(claims.sub, 'u1');
486
+ strict_1.default.equal(claims.tenant_id, 't1');
487
+ // Tenant mismatch must throw.
488
+ await strict_1.default.rejects(c.verifyAppUserToken(token, { expectedTenantId: 'other' }), /tenant_id/);
489
+ }
490
+ finally {
491
+ globalThis.fetch = orig;
492
+ }
493
+ });
494
+ });
495
+ (0, node_test_1.describe)('listTriggerEvents', () => {
496
+ (0, node_test_1.it)('passes limit and parses events', async () => {
497
+ let url = '';
498
+ const orig = globalThis.fetch;
499
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
500
+ url = String(input);
501
+ return new Response(JSON.stringify({
502
+ events: [{ id: '1', trigger: 't', identifier: '/x', at: '...', status: 'ok' }],
503
+ limit: 50,
504
+ }), { status: 200 });
505
+ });
506
+ try {
507
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'k' });
508
+ const r = await c.listTriggerEvents(50);
509
+ strict_1.default.match(url, /\/api\/v1\/_xcitedb\/trigger-events\?limit=50$/);
510
+ strict_1.default.equal(r.limit, 50);
511
+ strict_1.default.equal(r.events.length, 1);
512
+ strict_1.default.equal(r.events[0].trigger, 't');
513
+ }
514
+ finally {
515
+ globalThis.fetch = orig;
516
+ }
517
+ });
518
+ });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { XCiteDBClient } from './client';
2
2
  export { parseAssetUri, formatAssetUri, collectIdentifiersFromText, ASSET_URI_PREFIX } from './assetUri';
3
3
  export { WebSocketSubscription } from './websocket';
4
- export type { AccessCheckResult, ApiKeyInfo, AppAuthConfig, AppEmailConfig, AppEmailSmtpConfig, AppEmailTemplateEntry, AppEmailTemplates, AppEmailWebhookConfig, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BookmarkRecord, BranchInfo, BranchListItem, CheckpointRecord, CommitRecord, CompareEntry, CompareRef, CompareResult, DatabaseContext, DiffEntry, DiffRef, DiffResult, SmartDiffRef, SmartDiffResult, SmartDiffStats, DocumentBatchResponse, DocumentBatchResultRow, DocumentExportFormat, DocumentImportFormat, ExportDocumentResult, Flags, ImportDocumentOptions, ImportDocumentResult, JsonDocumentData, JsonDocumentBatchItem, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LockConflictBody, LockExpiredBody, LockUnknownBody, MergeConflict, MergeResult, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetListItem, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkRecord, AssetMagicLinkResult, AssetShareListEntry, AssetShareListResponse, AssetShareRequest, AssetStorageImport, AssetStorageMount, AssetStorageTarget, AssetStorageTargetType, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PublishConflict, PublishResult, RebaseUserWorkspaceResult, PolicyConditions, PolicyIdentifierPattern, PolicyResources, PolicySubjectInput, PolicySubjects, RagQueryOptions, RagQueryResult, RagStreamEvent, RealtimeEvent, SearchIndexingProgress, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TagRecord, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TokenPair, UserInfo, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationOptions, UserIsolationShareMode, UserIsolationShareResult, WorkspaceInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, TestSessionBootstrap, TestSessionBootstrapSummary, TestSessionInfo, XCiteDBClientOptions, XCiteDBErrorExtras, XCiteDBJwtClaims, UnqueryResult, UnqueryTemplate, XCiteQuery, } from './types';
4
+ export type { AccessCheckResult, ApiKeyInfo, AppAuthConfig, AppEmailConfig, AppEmailSmtpConfig, AppEmailTemplateEntry, AppEmailTemplates, AppEmailWebhookConfig, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BookmarkRecord, BranchInfo, BranchListItem, CheckpointRecord, CommitRecord, CompareEntry, CompareRef, CompareResult, DatabaseContext, DiffEntry, DiffRef, DiffResult, SmartDiffRef, SmartDiffResult, SmartDiffStats, DocumentBatchResponse, DocumentBatchResultRow, DocumentExportFormat, DocumentImportFormat, ExportDocumentResult, Flags, ImportDocumentOptions, ImportDocumentResult, JsonDocumentData, JsonDocumentBatchItem, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LockConflictBody, LockExpiredBody, LockUnknownBody, MergeConflict, MergeResult, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetListItem, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkRecord, AssetMagicLinkResult, AssetShareListEntry, AssetShareListResponse, AssetShareRequest, AssetStorageImport, AssetStorageMount, AssetStorageTarget, AssetStorageTargetType, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PublishConflict, PublishResult, RebaseUserWorkspaceResult, PolicyConditions, PolicyIdentifierPattern, PolicyResources, PolicySubjectInput, PolicySubjects, RagQueryOptions, RagQueryResult, RagStreamEvent, RealtimeEvent, SearchIndexingProgress, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TagRecord, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TriggerEvent, TriggerEventsResponse, TxnOperation, TxnOperationResult, TxnPrecondition, TxnPreconditionResult, TxnRequest, TxnResponse, JwksKey, JwksResponse, VerifyAppUserTokenOptions, TokenPair, UserInfo, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationOptions, UserIsolationShareMode, UserIsolationShareResult, WorkspaceInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, TestSessionBootstrap, TestSessionBootstrapSummary, TestSessionInfo, XCiteDBClientOptions, XCiteDBErrorExtras, XCiteDBJwtClaims, UnqueryResult, UnqueryTemplate, XCiteQuery, } from './types';
5
5
  export { XCiteDBError, XCiteDBForbiddenError, XCiteDBNotFoundError, XCiteDBAuthError, XCiteDBLockConflictError, } from './types';
package/dist/types.d.ts CHANGED
@@ -55,6 +55,13 @@ export interface TextSearchQuery {
55
55
  /** Range FTS filter: interval overlap with [date_from, date_to) (ISO dates). */
56
56
  date_from?: string;
57
57
  date_to?: string;
58
+ /**
59
+ * Restrict hits to documents whose identifier matches one of these patterns.
60
+ * Same shape as `policies.resources.identifiers` and `triggers.resources.identifiers`:
61
+ * within one entry the recognized keys AND together (and `contains` arrays AND across substrings);
62
+ * across the array entries OR. Empty array = no filtering.
63
+ */
64
+ identifiers?: PolicyIdentifierPattern[];
58
65
  }
59
66
  export interface TextSearchHit {
60
67
  /** JSON: document id. XML: first non-xcitepath id from db:identifier, else xcitepath (xcitepath also in `xcitepath`). */
@@ -344,6 +351,11 @@ export interface RagQueryOptions {
344
351
  role: 'system' | 'user' | 'assistant';
345
352
  content: string;
346
353
  }[];
354
+ /**
355
+ * Restrict the documents pulled into RAG context by identifier pattern.
356
+ * Same shape as {@link TextSearchQuery.identifiers}.
357
+ */
358
+ identifiers?: PolicyIdentifierPattern[];
347
359
  }
348
360
  export interface RagQueryResult {
349
361
  answer: string;
@@ -1115,6 +1127,146 @@ export interface RebaseUserWorkspaceResult {
1115
1127
  auto_mergeable?: string[];
1116
1128
  would_expose?: string[];
1117
1129
  }
1130
+ /** One mutation within a {@link XCiteDBClient.txn} call. */
1131
+ export type TxnOperation = {
1132
+ kind: 'WriteJson';
1133
+ identifier: string;
1134
+ data: unknown;
1135
+ overwrite?: boolean;
1136
+ } | {
1137
+ kind: 'WriteXml';
1138
+ identifier: string;
1139
+ xml: string;
1140
+ is_top?: boolean;
1141
+ compare_attributes?: boolean;
1142
+ } | {
1143
+ kind: 'WriteMeta';
1144
+ identifier: string;
1145
+ value: unknown;
1146
+ path?: string;
1147
+ mode?: 'set' | 'append' | 'merge_append';
1148
+ overwrite?: boolean;
1149
+ } | {
1150
+ kind: 'ClearMetaPath';
1151
+ identifier: string;
1152
+ path: string;
1153
+ } | {
1154
+ kind: 'DeleteIdentifier';
1155
+ identifier: string;
1156
+ } | {
1157
+ kind: 'AddIdentifier';
1158
+ identifier: string;
1159
+ };
1160
+ /** One precondition checked under the same transaction (must pass for the writes to apply). */
1161
+ export type TxnPrecondition = {
1162
+ kind: 'MetaEquals';
1163
+ identifier: string;
1164
+ path?: string;
1165
+ value: unknown;
1166
+ } | {
1167
+ kind: 'MetaExists';
1168
+ identifier: string;
1169
+ path?: string;
1170
+ } | {
1171
+ kind: 'MetaAbsent';
1172
+ identifier: string;
1173
+ path?: string;
1174
+ } | {
1175
+ kind: 'DocumentExists';
1176
+ identifier: string;
1177
+ } | {
1178
+ kind: 'DocumentAbsent';
1179
+ identifier: string;
1180
+ };
1181
+ export interface TxnRequest {
1182
+ operations: TxnOperation[];
1183
+ preconditions?: TxnPrecondition[];
1184
+ /**
1185
+ * Optional idempotency key. The server caches the response body for ~24h; replays return the
1186
+ * original 200 with `committed:true` and an additional `idempotency_hit` discriminator can be
1187
+ * inferred from a successful repeat call.
1188
+ */
1189
+ idempotency_key?: string;
1190
+ }
1191
+ export interface TxnPreconditionResult {
1192
+ index: number;
1193
+ identifier: string;
1194
+ kind: TxnPrecondition['kind'];
1195
+ ok: boolean;
1196
+ error?: string;
1197
+ }
1198
+ export interface TxnOperationResult {
1199
+ index: number;
1200
+ identifier: string;
1201
+ kind: TxnOperation['kind'];
1202
+ ok: boolean;
1203
+ /** HTTP-style code per op; 0 means "not executed because the txn aborted". */
1204
+ code: number;
1205
+ error?: string;
1206
+ }
1207
+ export interface TxnResponse {
1208
+ committed: boolean;
1209
+ /** Index of the failing precondition when the txn aborted on a precondition. */
1210
+ first_failed_precond?: number;
1211
+ /** Index of the failing operation when the txn aborted on an op. */
1212
+ first_failure_index?: number;
1213
+ code?: number;
1214
+ error?: string;
1215
+ preconditions: TxnPreconditionResult[];
1216
+ results: TxnOperationResult[];
1217
+ }
1218
+ /** A persisted trigger firing record. */
1219
+ export interface TriggerEvent {
1220
+ /** Unique id for the firing (sortable; newer first when listing). */
1221
+ id?: string;
1222
+ /** Trigger name that fired. */
1223
+ trigger?: string;
1224
+ /** Identifier whose write caused the firing. */
1225
+ identifier?: string;
1226
+ /** Server-assigned ISO timestamp. */
1227
+ at?: string;
1228
+ /** Outcome — `ok` or `error` for synchronous triggers. */
1229
+ status?: string;
1230
+ /** Free-form details (matched value, error message). */
1231
+ detail?: unknown;
1232
+ [key: string]: unknown;
1233
+ }
1234
+ export interface TriggerEventsResponse {
1235
+ events: TriggerEvent[];
1236
+ limit: number;
1237
+ }
1238
+ /** Single key entry from `GET /.well-known/jwks.json` (RFC 7517). */
1239
+ export interface JwksKey {
1240
+ kty: string;
1241
+ use?: string;
1242
+ alg?: string;
1243
+ kid?: string;
1244
+ /** RSA modulus (base64url) — present for `kty: "RSA"`. */
1245
+ n?: string;
1246
+ /** RSA public exponent (base64url) — present for `kty: "RSA"`. */
1247
+ e?: string;
1248
+ [key: string]: unknown;
1249
+ }
1250
+ export interface JwksResponse {
1251
+ keys: JwksKey[];
1252
+ }
1253
+ /** Options for {@link XCiteDBClient.verifyAppUserToken}. */
1254
+ export interface VerifyAppUserTokenOptions {
1255
+ /**
1256
+ * Override the expected `tenant_id` claim. When omitted, the client's configured
1257
+ * `tenant_id` / `project_id` is used. Pass an empty string to skip the check.
1258
+ */
1259
+ expectedTenantId?: string;
1260
+ /**
1261
+ * Clock-skew tolerance for `exp` in seconds (default 30). Set to 0 for strict comparison.
1262
+ */
1263
+ clockToleranceSeconds?: number;
1264
+ /**
1265
+ * Force a re-fetch of the JWKS even if a cached copy is fresh. Useful right after a key
1266
+ * rotation — normally the cache TTL handles this on its own.
1267
+ */
1268
+ forceRefreshJwks?: boolean;
1269
+ }
1118
1270
  /**
1119
1271
  * Canonical 403 `reason` codes the server can emit. Use as a discriminator on
1120
1272
  * `XCiteDBForbiddenError.reason` in catch sites — TypeScript will warn if you forget a case.
package/llms.txt CHANGED
@@ -323,6 +323,7 @@ interface XCiteDBClientOptions {
323
323
  - `loginAppUser(email, password)` — App end-user sign-in
324
324
  - `XCiteDBClient.buildProjectGroup(projectId, 'admin'|'editor'|'viewer')` — Static helper: canonical `project:<tenant_id>:<role>` string
325
325
  - `getTokenClaims()` — Decode current `appUserAccessToken` or `accessToken` payload (no signature verification); use for ABAC debugging
326
+ - `verifyAppUserToken(token, options?)` — **BFF token verifier.** Validates signature, expiry, and (by default) the `tenant_id` claim against the client's configured project. RS256 deployments verify locally against `/.well-known/jwks.json` (cached; key rotations land via `kid` mismatch + auto refresh). HS256 deployments fall back to `POST /api/v1/app/auth/verify` (one round-trip; the same shape comes back). Throws `XCiteDBAuthError` on bad signature, expired, or tenant mismatch. Options: `expectedTenantId` (override; pass `''` to skip), `clockToleranceSeconds` (default 30), `forceRefreshJwks`. Returns `XCiteDBJwtClaims` (sub/tenant_id/groups/email/exp/iat/iss/jti/type).
326
327
  - `setContext(ctx)` — Update workspace/date context
327
328
  - `setProjectId(id)` — Switch active project (platform console)
328
329
 
@@ -381,9 +382,9 @@ interface XCiteDBClientOptions {
381
382
  - ABAC actions: `lock`, `unlock`, `force_unlock`; policies with `write` only still authorize all three (compat)
382
383
 
383
384
  **Search & Analytics:**
384
- - `search(query)` — Full-text search (embedded FTS). Optional **`at_date`**, **`date_from`**, **`date_to`** on the query for temporal keyword search (use **`mode: 'fts'`**). Hits may include **`valid_from`** / **`valid_to`** (7-char internal keys).
385
+ - `search(query)` — Full-text search (embedded FTS). Optional **`at_date`**, **`date_from`**, **`date_to`** on the query for temporal keyword search (use **`mode: 'fts'`**). Hits may include **`valid_from`** / **`valid_to`** (7-char internal keys). Optional **`identifiers`** — array of pattern objects (`exact` / `match_start` / `match_end` / `contains` / `regex`, same shape as policy `resources.identifiers`) restricts hits to matching documents (entries OR; keys within an entry AND).
385
386
  - `reindex()` — Rebuild search index
386
- - `unquery(query, unqueryDoc)` — Execute Unquery DSL
387
+ - `unquery(query, unqueryDoc)` — Execute Unquery DSL. Postfix slicing supported: `field[start:end]` with optional negative indices on a JSON array yields a sliced array.
387
388
 
388
389
  **Security & Policies:**
389
390
  - `createPolicy(id, policy)` / `listPolicies()` / `updatePolicy()` / `deletePolicy()` — ABAC policies
@@ -392,6 +393,13 @@ interface XCiteDBClientOptions {
392
393
 
393
394
  **Triggers:**
394
395
  - `upsertTrigger(id, trigger)` / `listTriggers()` / `deleteTrigger(name)` — Automation triggers
396
+ - `listTriggerEvents(limit?)` — `GET /api/v1/_xcitedb/trigger-events`; recent firings (newest first; clamp 1..500, default 100). Admin/editor only; public-key contexts rejected.
397
+
398
+ **Atomic transactions:**
399
+ - `txn({ operations, preconditions?, idempotency_key? })` — `POST /api/v1/txn`. All-or-nothing across mixed kinds (`WriteJson`, `WriteXml`, `WriteMeta`, `ClearMetaPath`, `DeleteIdentifier`, `AddIdentifier`) with optional preconditions (`MetaEquals`, `MetaExists`, `MetaAbsent`, `DocumentExists`, `DocumentAbsent`). Pass `idempotency_key` to make safe retries — server caches the response body for ~24h so a repeated call replays the original 200. Response: `{ committed, first_failed_precond?, first_failure_index?, code?, error?, preconditions[], results[] }`. Unexecuted ops on abort carry `code: 0` with `error: "not executed (atomic txn aborted)"`.
400
+
401
+ **RAG:**
402
+ - `ragQuery(options)` / `ragQueryStream(options, onEvent)` — Retrieval-augmented Q&A. Optional **`identifiers`** restricts the documents pulled into context (same shape as `search` filter).
395
403
 
396
404
  ### Advanced: policy expressions (ABAC)
397
405
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcitedbs/client",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -85,6 +85,7 @@ A path selects a value from the current context.
85
85
  | `[n]` | Array index (0-based) | `"Dependants[0].FirstName"` |
86
86
  | `[expr]` | Computed index | `"Field1[$index+1]"` |
87
87
  | `[]` | Whole array (projection over all elements) | `"Array1[].Field1"` |
88
+ | `[a:b]` | Array **slice** in a context modifier (half-open; negative counts from end) | `"scores:[0:3]"`, `"scores:[-2:]"` |
88
89
  | `/Field` | Absolute from document root | `"/employees.$(.)"` |
89
90
  | `../Field` | Up one path level (skips array indices) | `"../DBInstanceIdentifier"` |
90
91
  | `<<Field` | Read same field in *previous* document context | `"<<Field1"` after a `->$file(...)` |
@@ -259,18 +260,21 @@ Equivalent to SQL `GROUP BY bin`. Combine with aggregate values for grouped stat
259
260
  { "$(Title)": "$avg(Salary)" }
260
261
  ```
261
262
 
262
- ### 5.4 Copy-all keys — `{}` and `{'regex'}`
263
+ ### 5.4 Copy-all keys — `{}`, `{'regex'}`, and slice `{a:b}`
263
264
 
264
- `{}` evaluates to *every* key in the queried object. `{'regex'}` evaluates to keys matching the regex.
265
+ `{}` evaluates to *every* key in the queried object. `{'regex'}` evaluates to keys matching the regex. `{a:b}` is a positional **slice** of the source-key sequence (half-open, negative values count from the end) — keys are visited in their stored sort order, so this naturally gives "first K", "last K", or a middle range.
265
266
 
266
267
  ```json
267
268
  { "{}": "value" } // every key gets the constant 'value' (rarely useful)
268
269
  { "{}:": "." } // copy every key → its value (very useful)
269
270
  { "{'A.*B'}:": "." } // copy only keys matching the regex
270
271
  { "{}:": ".?$key!='ID'" } // copy all except ID (predicate filter)
272
+ { "{:3}:": "." } // first 3 keys (alphabetical)
273
+ { "{-2:}:": "." } // last 2 keys
274
+ { "{2:5}:": "." } // keys at positions 2..4 inclusive
271
275
  ```
272
276
 
273
- The trailing `:` after `{}` is a context modifier ("for each key, switch context to that key"). Without `:`, `{}` is just the empty-key spec.
277
+ The trailing `:` after `{}` is a context modifier ("for each key, switch context to that key"). Without `:`, `{}` is just the empty-key spec. The slice form `{a:b}` mirrors the array slice `[a:b]` (see §4 path expressions and §6 context modifiers): `{:K}` is the first K, `{-K:}` the last K, `{a:b}` the half-open range.
274
278
 
275
279
  ### 5.5 Key utility functions
276
280
 
@@ -298,7 +302,9 @@ A modifier may be followed by `?cond` (predicate). Modifiers chain freely.
298
302
  | `:` (nothing after) | Repeat the key name as the path: `"Field1:"` ≡ `"Field1:Field1"` |
299
303
  | `:[]` | Iterate over each array element |
300
304
  | `:[n]` | Switch to array index `n` |
305
+ | `:[a:b]` | Iterate over the array **slice** at indices `a..b` (half-open; negative counts from end) |
301
306
  | `:{}` | Iterate over each object field |
307
+ | `:{a:b}` | Iterate over the object-key **slice** at positions `a..b` (half-open; negative counts from end) |
302
308
  | `:{'regex'}` | Iterate over fields matching regex |
303
309
  | `:**` | Recursive descent (every path under current) |
304
310
  | `:.` | Stay at current path (rarely needed) |
@@ -308,8 +314,18 @@ A modifier may be followed by `?cond` (predicate). Modifiers chain freely.
308
314
  { "result:Customers[]?Balance>100000:Accounts[]": ["accountNumber"] }
309
315
  { "result:{}": ["."] }
310
316
  { "#return:**": ["$key@unique_ascending"] }
317
+ { "first:scores[:3]": ["."] } // first 3 elements of `scores`
318
+ { "tail:scores[-2:]": ["."] } // last 2 elements
319
+ { "middle:scores[2:5]": ["."] } // elements at positions 2..4
311
320
  ```
312
321
 
322
+ **Slice semantics** (same shape for arrays and object-key iteration):
323
+ - `[:K]` / `{:K}` — first K (start defaults to 0).
324
+ - `[K:]` / `{K:}` — from index K to end.
325
+ - `[a:b]` / `{a:b}` — half-open range `[a, b)`.
326
+ - `[-K:]` / `{-K:}` — last K (negative values count from the end).
327
+ - Out-of-range bounds are clamped; `b ≤ a` yields an empty result.
328
+
313
329
  ### 6.2 The `:Field` shorthand (very common)
314
330
 
315
331
  `"FirstName:"` is identical to `"FirstName:FirstName"`. Use it whenever the result key matches the source field:
@@ -151,7 +151,7 @@ Roughly in parse order:
151
151
 
152
152
  | Prefix / form | AST | Notes |
153
153
  |-----------------|-----|--------|
154
- | `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index. |
154
+ | `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index. (Slice `[a:b]` form is currently parsed only on context modifiers, not in path expressions.) |
155
155
  | `$` `(` `expression` `)` | `TExprField(expr)` | Evaluate / path from dynamic name (`$()`). |
156
156
  | `$if` `(` `condition` `,` `expression` `,` `expression` `)` | `TExprITE` | |
157
157
  | `$call` `(` IDENT `)` | `TExprCall(name)` | Zero-arg user function by name. |
@@ -294,9 +294,12 @@ key ::= '$' '(' expression ')' (* dynamic key → TQParamKey *)
294
294
  | '$' IDENT ... | '%' IDENT ... (* whole thing parsed as expression() → TQParamKey *)
295
295
  | '{' '}' (* TQRegexKey empty regex *)
296
296
  | '{' QUOTED '}' (* TQRegexKey *)
297
+ | '{' slice '}' (* TQRegexKey + slice over source-key sequence *)
297
298
  | '#' directive
298
299
  | pathId (* TQSimpleKey, name may include quoted segments from pathId *)
299
300
 
301
+ slice ::= INT? ':' INT? (* INT may be `-` INT for negative-from-end *)
302
+
300
303
  directive ::= 'if'
301
304
  | 'func' IDENT ( '(' IDENT ( ',' IDENT )* ')' )? (* register arity; TQFuncDefinition *)
302
305
  | 'var' IDENT
@@ -336,8 +339,10 @@ Parsed **after** `key()` from the **same** JSON key string. Mode parameter `Cont
336
339
  | `->` … | `Arrow` | See §8.2 (`identifierExpression`, builtins, legacy JSON-field lookup, `->$each`); often `new_frame = true` |
337
340
  | `[` `]` | `Array` | traversal |
338
341
  | `[` INT `]` | *(string context)* | literal index path segment |
342
+ | `[` (INT)? `:` (INT)? `]` | `Array` + slice | half-open slice; sets `array_slice_set` and the start/end fields on the `TQContextMod`. Negative ints count from end. (`-` token + INT recognized.) |
339
343
  | `.` | *(path)* | context string `"."` |
340
344
  | IDENT… `[` `]`? | `Array` if trailing `[]`, else path | Empty path + no `[` → **`Reskey`** mode |
345
+ | IDENT… `[` slice `]` | `Array` + slice | `Field[a:b]` after a path |
341
346
  | Optional `?` `condition` after each atom | — | Wraps inner mod chain in `TQValueWithCond` |
342
347
 
343
348
  Recursion: `context_mod(...)` parses the **rest** of the chain; result wrapped in `TQContextMod` (expr or string form).