@xcitedbs/client 0.2.5 → 0.2.7

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, CommitRecord, DatabaseContext, DiffRef, DiffResult, Flags, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, LogEntry, MergeResult, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, StoredPolicyResponse, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, OAuthProvidersResponse, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspacesResponse, TokenPair, UserInfo, ApiKeyInfo, WriteDocumentOptions, XCiteDBClientOptions, XCiteQuery } from './types';
1
+ import { AccessCheckResult, AppAuthConfig, AppEmailConfig, AppEmailTemplates, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BranchInfo, CommitRecord, DatabaseContext, DiffRef, DiffResult, Flags, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, LogEntry, MergeResult, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, UnqueryTemplate, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, StoredPolicyResponse, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, OAuthProvidersResponse, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspacesResponse, TokenPair, UserInfo, ApiKeyInfo, WriteDocumentOptions, CreateTestSessionOptions, XCiteDBClientOptions, XCiteQuery } from './types';
2
2
  import { WebSocketSubscription } from './websocket';
3
3
  export declare class XCiteDBClient {
4
4
  private baseUrl;
@@ -14,7 +14,18 @@ export declare class XCiteDBClient {
14
14
  private onSessionTokensUpdated?;
15
15
  private onAppUserTokensUpdated?;
16
16
  private onSessionInvalid?;
17
+ private testSessionToken?;
18
+ private testRequireAuth?;
17
19
  constructor(options: XCiteDBClientOptions);
20
+ /**
21
+ * Create an ephemeral isolated database: calls `POST /api/v1/test/sessions` with your API key or Bearer,
22
+ * then returns a client that sends `X-Test-Session` (auth-free by default).
23
+ */
24
+ static createTestSession(opts: CreateTestSessionOptions): Promise<XCiteDBClient>;
25
+ /** Destroy this test session on the server (`DELETE /api/v1/test/sessions/current`). */
26
+ destroyTestSession(): Promise<{
27
+ message: string;
28
+ }>;
18
29
  /** True if this client would send API key or Bearer credentials on a normal request. */
19
30
  private sentAuthCredentials;
20
31
  /** 401 on these paths is an expected auth flow outcome, not a dead session. */
@@ -34,6 +45,7 @@ export declare class XCiteDBClient {
34
45
  /** Include `tenant_id` for public app-auth routes when using only app-user tokens (no developer key/JWT). */
35
46
  private mergeAppTenant;
36
47
  private authHeaders;
48
+ private testHeaders;
37
49
  private request;
38
50
  /** Developer Bearer refresh first, then app-user refresh (no API key refresh). */
39
51
  private tryRefreshSessionAfter401;
@@ -83,7 +95,7 @@ export declare class XCiteDBClient {
83
95
  createApiKey(name: string, expiresAt?: number, keyType?: 'secret' | 'public'): Promise<unknown>;
84
96
  changePassword(currentPassword: string, newPassword: string): Promise<void>;
85
97
  revokeApiKey(keyId: string): Promise<void>;
86
- registerAppUser(email: string, password: string, displayName?: string, groups?: string[], attributes?: Record<string, unknown>): Promise<AppUser>;
98
+ registerAppUser(email: string, password: string, displayName?: string, attributes?: Record<string, unknown>): Promise<AppUser>;
87
99
  getOAuthProviders(): Promise<OAuthProvidersResponse>;
88
100
  /** Relative path + query for browser navigation to start OAuth (append to API base URL). */
89
101
  oauthAuthorizePath(provider: string): string;
@@ -117,17 +129,131 @@ export declare class XCiteDBClient {
117
129
  deleteAppUser(userId: string): Promise<void>;
118
130
  updateAppUserGroups(userId: string, groups: string[]): Promise<void>;
119
131
  updateAppUserStatus(userId: string, status: 'active' | 'disabled' | 'pending_verification'): Promise<void>;
132
+ /**
133
+ * Create an ABAC policy (`POST /api/v1/security/policies`). Requires admin/editor role.
134
+ * Use `conditions.expression` for attribute-based rules (same predicate syntax as Unquery `?` conditions).
135
+ * Policy actions: `read`, `write`, `delete`, `list`, `meta:read`, `meta:write`, `unquery`.
136
+ *
137
+ * @example Tenant isolation: first path segment must equal app-user attribute `tenant_code`.
138
+ * ```ts
139
+ * await client.createPolicy('tenant_isolation', {
140
+ * effect: 'allow',
141
+ * priority: 100,
142
+ * subjects: { type: 'app_user' },
143
+ * actions: ['read'],
144
+ * resources: { identifiers: [{ match_start: '/' }] },
145
+ * conditions: { expression: 'resource.path[0] = subject.attr.tenant_code' },
146
+ * });
147
+ * ```
148
+ *
149
+ * @example Deny viewers write access under `/admin/`.
150
+ * ```ts
151
+ * await client.createPolicy('deny_viewers_admin', {
152
+ * effect: 'deny',
153
+ * priority: 10,
154
+ * subjects: { type: 'app_user', groups: ['viewers'] },
155
+ * resources: { identifiers: [{ match_start: '/admin/' }] },
156
+ * actions: ['write', 'delete', 'meta:write'],
157
+ * });
158
+ * ```
159
+ *
160
+ * @example Branch-only rule via `conditions.branches`.
161
+ * ```ts
162
+ * await client.createPolicy('staging_only', {
163
+ * effect: 'allow',
164
+ * priority: 50,
165
+ * subjects: { type: 'app_user', groups: ['qa'] },
166
+ * actions: ['read', 'write'],
167
+ * resources: { identifiers: [{ match_start: '/staging/' }] },
168
+ * conditions: { branches: ['stg', 'stg*'] },
169
+ * });
170
+ * ```
171
+ */
120
172
  createPolicy(policyId: string, policy: SecurityPolicy): Promise<StoredPolicyResponse>;
121
173
  listPolicies(): Promise<Record<string, SecurityPolicy>>;
122
174
  getPolicy(policyId: string): Promise<StoredPolicyResponse>;
123
175
  updatePolicy(policyId: string, policy: SecurityPolicy): Promise<PolicyUpdateResponse>;
124
176
  deletePolicy(policyId: string): Promise<void>;
177
+ /**
178
+ * Create or update a trigger (`POST /api/v1/triggers`). Definitions live under `/_xcitedb/triggers`.
179
+ * After matching events, the server runs `action.unquery` over `action.query` and writes JSON to
180
+ * `action.meta_path` on `action.target_identifier` (use `"$trigger_identifier"` for the firing doc).
181
+ * Unquery can use `$trigger_identifier`, `$trigger_meta_path`, `$trigger_operation`, `$trigger_value`.
182
+ *
183
+ * @example On meta change under `/projects/`, append a snapshot entry to an index document.
184
+ * ```ts
185
+ * await client.upsertTrigger('project_meta_index', {
186
+ * event: 'meta_changed',
187
+ * match: {
188
+ * identifiers: [{ match_start: '/projects/' }],
189
+ * match_meta_path: 'status',
190
+ * },
191
+ * action: {
192
+ * query: { match: '$trigger_identifier' },
193
+ * unquery: { lastId: '$trigger_identifier', path: '$trigger_meta_path' },
194
+ * target_identifier: '/indexes/project_meta',
195
+ * meta_path: 'entries',
196
+ * mode: 'append',
197
+ * },
198
+ * });
199
+ * ```
200
+ *
201
+ * @example On document write, write a computed summary onto the same identifier's meta.
202
+ * ```ts
203
+ * await client.upsertTrigger('doc_written_summary', {
204
+ * event: 'document_written',
205
+ * match: { identifiers: [{ match_start: '/articles/' }] },
206
+ * action: {
207
+ * query: { match: '$trigger_identifier' },
208
+ * unquery: { id: '$identifier', nodeCount: '$count' },
209
+ * target_identifier: '$trigger_identifier',
210
+ * meta_path: 'stats',
211
+ * },
212
+ * });
213
+ * ```
214
+ */
125
215
  upsertTrigger(triggerId: string, trigger: TriggerDefinition): Promise<StoredTriggerResponse>;
126
216
  listTriggers(): Promise<Record<string, TriggerDefinition>>;
127
217
  getTrigger(name: string): Promise<StoredTriggerResponse>;
128
218
  deleteTrigger(name: string): Promise<void>;
219
+ /**
220
+ * Dry-run access check (`POST /api/v1/security/check`). Returns `effect` and optional `matched_policy_id`.
221
+ * Useful for debugging policies. Actions: `read`, `write`, `delete`, `list`, `meta:read`, `meta:write`, `unquery`.
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * const r = await client.checkAccess(
226
+ * {
227
+ * type: 'app_user',
228
+ * user_id: 'u1',
229
+ * email: 'alice@example.com',
230
+ * groups: ['editors'],
231
+ * role: 'app_user',
232
+ * attributes: { tenant_code: 'acme', level: 5 },
233
+ * },
234
+ * '/acme/reports/q1',
235
+ * 'read',
236
+ * undefined,
237
+ * 'main'
238
+ * );
239
+ * console.log(r.effect, r.matched_policy_id);
240
+ * ```
241
+ */
129
242
  checkAccess(subject: PolicySubjectInput, identifier: string, action: string, metaPath?: string, branch?: string): Promise<AccessCheckResult>;
130
243
  getSecurityConfig(): Promise<SecurityConfig>;
244
+ /**
245
+ * Update tenant security defaults (`PUT /api/v1/security/config`). When ABAC is enabled, pair
246
+ * `app_user_default_effect: 'deny'` with explicit allow policies for least privilege.
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * await client.updateSecurityConfig({
251
+ * app_user_default_effect: 'deny',
252
+ * default_effect: 'allow',
253
+ * developer_bypass: true,
254
+ * });
255
+ * ```
256
+ */
131
257
  updateSecurityConfig(config: Partial<SecurityConfig>): Promise<void>;
132
258
  createBranch(name: string, fromBranch?: string, fromDate?: string): Promise<void>;
133
259
  deleteBranch(name: string): Promise<void>;
@@ -218,7 +344,32 @@ export declare class XCiteDBClient {
218
344
  acquireLock(identifier: string, expires?: number): Promise<LockInfo>;
219
345
  releaseLock(identifier: string, lockId: string): Promise<boolean>;
220
346
  findLocks(identifier: string): Promise<LockInfo[]>;
221
- unquery<T = UnqueryResult>(query: XCiteQuery, unquery: unknown): Promise<T>;
347
+ /**
348
+ * Run Unquery (`POST /api/v1/unquery`): declarative analytics over documents matching `query`.
349
+ * The `unquery` argument is a JSON template; keys are output fields, string values are expressions.
350
+ *
351
+ * @example Project a meta field into a result key.
352
+ * ```ts
353
+ * const out = await client.unquery<{ tag: string }>(
354
+ * { match_start: '/manual/' },
355
+ * { title: 'chapter_title' }
356
+ * );
357
+ * ```
358
+ *
359
+ * @example Count matching documents.
360
+ * ```ts
361
+ * const out = await client.unquery<{ total: number }>({ match_start: '/orders/' }, { total: '$count' });
362
+ * ```
363
+ *
364
+ * @example XPath from XML (string value must escape quotes for TS).
365
+ * ```ts
366
+ * const out = await client.unquery<{ heading: string }>(
367
+ * { match: '/book/ch1' },
368
+ * { heading: '$xpath("//h1")' }
369
+ * );
370
+ * ```
371
+ */
372
+ unquery<T = UnqueryResult>(query: XCiteQuery, unquery: UnqueryTemplate): Promise<T>;
222
373
  search(q: TextSearchQuery): Promise<TextSearchResult>;
223
374
  reindex(): Promise<{
224
375
  status: string;
package/dist/client.js CHANGED
@@ -31,6 +31,50 @@ class XCiteDBClient {
31
31
  this.onSessionTokensUpdated = options.onSessionTokensUpdated;
32
32
  this.onAppUserTokensUpdated = options.onAppUserTokensUpdated;
33
33
  this.onSessionInvalid = options.onSessionInvalid;
34
+ this.testSessionToken = options.testSessionToken;
35
+ this.testRequireAuth = options.testRequireAuth === true;
36
+ }
37
+ /**
38
+ * Create an ephemeral isolated database: calls `POST /api/v1/test/sessions` with your API key or Bearer,
39
+ * then returns a client that sends `X-Test-Session` (auth-free by default).
40
+ */
41
+ static async createTestSession(opts) {
42
+ const temp = new XCiteDBClient({
43
+ baseUrl: opts.baseUrl,
44
+ apiKey: opts.apiKey,
45
+ accessToken: opts.accessToken,
46
+ appUserAccessToken: opts.appUserAccessToken,
47
+ appUserRefreshToken: opts.appUserRefreshToken,
48
+ context: opts.context,
49
+ platformConsole: opts.platformConsole,
50
+ projectId: opts.projectId,
51
+ onSessionTokensUpdated: opts.onSessionTokensUpdated,
52
+ onAppUserTokensUpdated: opts.onAppUserTokensUpdated,
53
+ onSessionInvalid: opts.onSessionInvalid,
54
+ });
55
+ const data = await temp.request('POST', '/api/v1/test/sessions', undefined, undefined, { no401Retry: true });
56
+ return new XCiteDBClient({
57
+ baseUrl: opts.baseUrl,
58
+ apiKey: opts.testRequireAuth ? opts.apiKey : undefined,
59
+ accessToken: opts.testRequireAuth ? opts.accessToken : undefined,
60
+ appUserAccessToken: opts.testRequireAuth ? opts.appUserAccessToken : undefined,
61
+ appUserRefreshToken: opts.testRequireAuth ? opts.appUserRefreshToken : undefined,
62
+ context: opts.context,
63
+ platformConsole: opts.platformConsole,
64
+ projectId: opts.projectId,
65
+ onSessionTokensUpdated: opts.onSessionTokensUpdated,
66
+ onAppUserTokensUpdated: opts.onAppUserTokensUpdated,
67
+ onSessionInvalid: opts.onSessionInvalid,
68
+ testSessionToken: data.session_token,
69
+ testRequireAuth: opts.testRequireAuth,
70
+ });
71
+ }
72
+ /** Destroy this test session on the server (`DELETE /api/v1/test/sessions/current`). */
73
+ async destroyTestSession() {
74
+ if (!this.testSessionToken) {
75
+ throw new Error('destroyTestSession: client has no testSessionToken');
76
+ }
77
+ return this.request('DELETE', '/api/v1/test/sessions/current', undefined, undefined, { no401Retry: true });
34
78
  }
35
79
  /** True if this client would send API key or Bearer credentials on a normal request. */
36
80
  sentAuthCredentials() {
@@ -132,6 +176,16 @@ class XCiteDBClient {
132
176
  }
133
177
  return h;
134
178
  }
179
+ testHeaders() {
180
+ const h = {};
181
+ if (this.testSessionToken) {
182
+ h['X-Test-Session'] = this.testSessionToken;
183
+ if (this.testRequireAuth) {
184
+ h['X-Test-Auth'] = 'required';
185
+ }
186
+ }
187
+ return h;
188
+ }
135
189
  async request(method, path, body, extraHeaders, opts) {
136
190
  const no401Retry = opts?.no401Retry === true;
137
191
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -139,6 +193,7 @@ class XCiteDBClient {
139
193
  const headers = {
140
194
  ...this.authHeaders(),
141
195
  ...this.contextHeaders(),
196
+ ...this.testHeaders(),
142
197
  ...extraHeaders,
143
198
  };
144
199
  let init = { method, headers };
@@ -320,12 +375,10 @@ class XCiteDBClient {
320
375
  await this.request('DELETE', `/api/v1/project/keys/${encodeURIComponent(keyId)}`);
321
376
  }
322
377
  // --- App user auth (requires developer API key or JWT on the same tenant) ---
323
- async registerAppUser(email, password, displayName, groups, attributes) {
378
+ async registerAppUser(email, password, displayName, attributes) {
324
379
  const body = { email, password };
325
380
  if (displayName !== undefined)
326
381
  body.display_name = displayName;
327
- if (groups !== undefined)
328
- body.groups = groups;
329
382
  if (attributes !== undefined)
330
383
  body.attributes = attributes;
331
384
  return this.request('POST', '/api/v1/app/auth/register', this.mergeAppTenant(body));
@@ -449,6 +502,46 @@ class XCiteDBClient {
449
502
  await this.request('PUT', `/api/v1/app/users/${encodeURIComponent(userId)}/status`, { status });
450
503
  }
451
504
  // --- Security policies (developer admin/editor) ---
505
+ /**
506
+ * Create an ABAC policy (`POST /api/v1/security/policies`). Requires admin/editor role.
507
+ * Use `conditions.expression` for attribute-based rules (same predicate syntax as Unquery `?` conditions).
508
+ * Policy actions: `read`, `write`, `delete`, `list`, `meta:read`, `meta:write`, `unquery`.
509
+ *
510
+ * @example Tenant isolation: first path segment must equal app-user attribute `tenant_code`.
511
+ * ```ts
512
+ * await client.createPolicy('tenant_isolation', {
513
+ * effect: 'allow',
514
+ * priority: 100,
515
+ * subjects: { type: 'app_user' },
516
+ * actions: ['read'],
517
+ * resources: { identifiers: [{ match_start: '/' }] },
518
+ * conditions: { expression: 'resource.path[0] = subject.attr.tenant_code' },
519
+ * });
520
+ * ```
521
+ *
522
+ * @example Deny viewers write access under `/admin/`.
523
+ * ```ts
524
+ * await client.createPolicy('deny_viewers_admin', {
525
+ * effect: 'deny',
526
+ * priority: 10,
527
+ * subjects: { type: 'app_user', groups: ['viewers'] },
528
+ * resources: { identifiers: [{ match_start: '/admin/' }] },
529
+ * actions: ['write', 'delete', 'meta:write'],
530
+ * });
531
+ * ```
532
+ *
533
+ * @example Branch-only rule via `conditions.branches`.
534
+ * ```ts
535
+ * await client.createPolicy('staging_only', {
536
+ * effect: 'allow',
537
+ * priority: 50,
538
+ * subjects: { type: 'app_user', groups: ['qa'] },
539
+ * actions: ['read', 'write'],
540
+ * resources: { identifiers: [{ match_start: '/staging/' }] },
541
+ * conditions: { branches: ['stg', 'stg*'] },
542
+ * });
543
+ * ```
544
+ */
452
545
  async createPolicy(policyId, policy) {
453
546
  return this.request('POST', '/api/v1/security/policies', {
454
547
  policy_id: policyId,
@@ -471,6 +564,44 @@ class XCiteDBClient {
471
564
  await this.request('DELETE', `/api/v1/security/policies/${encodeURIComponent(policyId)}`);
472
565
  }
473
566
  // --- Triggers (developer admin/editor) ---
567
+ /**
568
+ * Create or update a trigger (`POST /api/v1/triggers`). Definitions live under `/_xcitedb/triggers`.
569
+ * After matching events, the server runs `action.unquery` over `action.query` and writes JSON to
570
+ * `action.meta_path` on `action.target_identifier` (use `"$trigger_identifier"` for the firing doc).
571
+ * Unquery can use `$trigger_identifier`, `$trigger_meta_path`, `$trigger_operation`, `$trigger_value`.
572
+ *
573
+ * @example On meta change under `/projects/`, append a snapshot entry to an index document.
574
+ * ```ts
575
+ * await client.upsertTrigger('project_meta_index', {
576
+ * event: 'meta_changed',
577
+ * match: {
578
+ * identifiers: [{ match_start: '/projects/' }],
579
+ * match_meta_path: 'status',
580
+ * },
581
+ * action: {
582
+ * query: { match: '$trigger_identifier' },
583
+ * unquery: { lastId: '$trigger_identifier', path: '$trigger_meta_path' },
584
+ * target_identifier: '/indexes/project_meta',
585
+ * meta_path: 'entries',
586
+ * mode: 'append',
587
+ * },
588
+ * });
589
+ * ```
590
+ *
591
+ * @example On document write, write a computed summary onto the same identifier's meta.
592
+ * ```ts
593
+ * await client.upsertTrigger('doc_written_summary', {
594
+ * event: 'document_written',
595
+ * match: { identifiers: [{ match_start: '/articles/' }] },
596
+ * action: {
597
+ * query: { match: '$trigger_identifier' },
598
+ * unquery: { id: '$identifier', nodeCount: '$count' },
599
+ * target_identifier: '$trigger_identifier',
600
+ * meta_path: 'stats',
601
+ * },
602
+ * });
603
+ * ```
604
+ */
474
605
  async upsertTrigger(triggerId, trigger) {
475
606
  return this.request('POST', '/api/v1/triggers', {
476
607
  trigger_id: triggerId,
@@ -491,6 +622,29 @@ class XCiteDBClient {
491
622
  const q = buildQuery({ name });
492
623
  await this.request('DELETE', `/api/v1/triggers${q}`);
493
624
  }
625
+ /**
626
+ * Dry-run access check (`POST /api/v1/security/check`). Returns `effect` and optional `matched_policy_id`.
627
+ * Useful for debugging policies. Actions: `read`, `write`, `delete`, `list`, `meta:read`, `meta:write`, `unquery`.
628
+ *
629
+ * @example
630
+ * ```ts
631
+ * const r = await client.checkAccess(
632
+ * {
633
+ * type: 'app_user',
634
+ * user_id: 'u1',
635
+ * email: 'alice@example.com',
636
+ * groups: ['editors'],
637
+ * role: 'app_user',
638
+ * attributes: { tenant_code: 'acme', level: 5 },
639
+ * },
640
+ * '/acme/reports/q1',
641
+ * 'read',
642
+ * undefined,
643
+ * 'main'
644
+ * );
645
+ * console.log(r.effect, r.matched_policy_id);
646
+ * ```
647
+ */
494
648
  async checkAccess(subject, identifier, action, metaPath, branch) {
495
649
  const body = { subject, identifier, action };
496
650
  if (metaPath !== undefined)
@@ -502,6 +656,19 @@ class XCiteDBClient {
502
656
  async getSecurityConfig() {
503
657
  return this.request('GET', '/api/v1/security/config');
504
658
  }
659
+ /**
660
+ * Update tenant security defaults (`PUT /api/v1/security/config`). When ABAC is enabled, pair
661
+ * `app_user_default_effect: 'deny'` with explicit allow policies for least privilege.
662
+ *
663
+ * @example
664
+ * ```ts
665
+ * await client.updateSecurityConfig({
666
+ * app_user_default_effect: 'deny',
667
+ * default_effect: 'allow',
668
+ * developer_bypass: true,
669
+ * });
670
+ * ```
671
+ */
505
672
  async updateSecurityConfig(config) {
506
673
  await this.request('PUT', '/api/v1/security/config', config);
507
674
  }
@@ -834,6 +1001,31 @@ class XCiteDBClient {
834
1001
  async findLocks(identifier) {
835
1002
  return this.request('GET', `/api/v1/locks${buildQuery({ identifier })}`);
836
1003
  }
1004
+ /**
1005
+ * Run Unquery (`POST /api/v1/unquery`): declarative analytics over documents matching `query`.
1006
+ * The `unquery` argument is a JSON template; keys are output fields, string values are expressions.
1007
+ *
1008
+ * @example Project a meta field into a result key.
1009
+ * ```ts
1010
+ * const out = await client.unquery<{ tag: string }>(
1011
+ * { match_start: '/manual/' },
1012
+ * { title: 'chapter_title' }
1013
+ * );
1014
+ * ```
1015
+ *
1016
+ * @example Count matching documents.
1017
+ * ```ts
1018
+ * const out = await client.unquery<{ total: number }>({ match_start: '/orders/' }, { total: '$count' });
1019
+ * ```
1020
+ *
1021
+ * @example XPath from XML (string value must escape quotes for TS).
1022
+ * ```ts
1023
+ * const out = await client.unquery<{ heading: string }>(
1024
+ * { match: '/book/ch1' },
1025
+ * { heading: '$xpath("//h1")' }
1026
+ * );
1027
+ * ```
1028
+ */
837
1029
  async unquery(query, unquery) {
838
1030
  return this.request('POST', '/api/v1/unquery', { query, unquery });
839
1031
  }
@@ -937,6 +1129,9 @@ class XCiteDBClient {
937
1129
  ...this.authHeaders(),
938
1130
  ...this.contextHeaders(),
939
1131
  };
1132
+ const tid = this.defaultContext.project_id ?? this.defaultContext.tenant_id;
1133
+ if (tid)
1134
+ headers['tenant_id'] = tid;
940
1135
  const sub = new websocket_1.WebSocketSubscription(wsBase, headers);
941
1136
  sub.onMessage(callback);
942
1137
  if (onError)
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { XCiteDBClient } from './client';
2
2
  export { WebSocketSubscription } from './websocket';
3
- export type { AccessCheckResult, ApiKeyInfo, AppAuthConfig, AppEmailConfig, AppEmailSmtpConfig, AppEmailTemplateEntry, AppEmailTemplates, AppEmailWebhookConfig, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, DatabaseContext, Flags, JsonDocumentData, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PolicyConditions, PolicyIdentifierPattern, PolicyResources, PolicySubjectInput, PolicySubjects, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TokenPair, UserInfo, WriteDocumentOptions, XCiteDBClientOptions, UnqueryResult, XCiteQuery, } from './types';
3
+ export type { AccessCheckResult, ApiKeyInfo, AppAuthConfig, AppEmailConfig, AppEmailSmtpConfig, AppEmailTemplateEntry, AppEmailTemplates, AppEmailWebhookConfig, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, DatabaseContext, Flags, JsonDocumentData, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PolicyConditions, PolicyIdentifierPattern, PolicyResources, PolicySubjectInput, PolicySubjects, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TokenPair, UserInfo, WriteDocumentOptions, CreateTestSessionOptions, XCiteDBClientOptions, UnqueryResult, UnqueryTemplate, XCiteQuery, } from './types';
4
4
  export { XCiteDBError } from './types';
package/dist/types.d.ts CHANGED
@@ -20,6 +20,13 @@ export type JsonDocumentData = Record<string, unknown>;
20
20
  export type MetaValue = unknown;
21
21
  /** Result of `unquery` (shape depends on the unquery definition). */
22
22
  export type UnqueryResult = unknown;
23
+ /**
24
+ * Unquery DSL template document. Object keys become output field names; string values are
25
+ * expressions (e.g. `"$count"`, `"$xpath(\"//title\")"`, or a meta field name). Supports nested
26
+ * objects, arrays, and `$`-prefixed builtins. See `llms-full.txt` shipped with this package for
27
+ * the full DSL reference.
28
+ */
29
+ export type UnqueryTemplate = Record<string, unknown>;
23
30
  /** Typical `POST /api/v1/platform/auth/register` response (fields vary by registration policy). */
24
31
  export interface PlatformRegisterResult {
25
32
  user_id?: string;
@@ -205,6 +212,29 @@ export interface XCiteDBClientOptions {
205
212
  * Use to clear stored credentials and redirect to sign-in. Not invoked for login/register-style paths.
206
213
  */
207
214
  onSessionInvalid?: () => void;
215
+ /**
216
+ * Ephemeral BaaS test session token from `POST /api/v1/test/sessions`.
217
+ * Sends `X-Test-Session`; use with {@link XCiteDBClient.createTestSession}.
218
+ */
219
+ testSessionToken?: string;
220
+ /** When true with `testSessionToken`, sends `X-Test-Auth: required` so real credentials are validated. */
221
+ testRequireAuth?: boolean;
222
+ }
223
+ /** Options for {@link XCiteDBClient.createTestSession} (provisions via API key or Bearer). */
224
+ export interface CreateTestSessionOptions {
225
+ baseUrl: string;
226
+ apiKey?: string;
227
+ accessToken?: string;
228
+ appUserAccessToken?: string;
229
+ appUserRefreshToken?: string;
230
+ context?: DatabaseContext;
231
+ platformConsole?: boolean;
232
+ projectId?: string;
233
+ /** Keep `apiKey` / `accessToken` on the client and send `X-Test-Auth: required` on each request. */
234
+ testRequireAuth?: boolean;
235
+ onSessionTokensUpdated?: (pair: TokenPair) => void;
236
+ onAppUserTokensUpdated?: (pair: AppUserTokenPair) => void;
237
+ onSessionInvalid?: () => void;
208
238
  }
209
239
  /** Application user (tenant-scoped), distinct from developer users. */
210
240
  export interface AppUser {
@@ -317,6 +347,12 @@ export interface PolicyResources {
317
347
  }
318
348
  export interface PolicyConditions {
319
349
  branches?: string[];
350
+ /**
351
+ * Unquery condition expression.
352
+ * Policies: evaluated against `{ subject, resource, env }`.
353
+ * Triggers: evaluated against `{ trigger, value, resource, env }` (see server docs).
354
+ */
355
+ expression?: string;
320
356
  }
321
357
  export interface SecurityPolicy {
322
358
  description?: string;
@@ -335,6 +371,8 @@ export interface PolicySubjectInput {
335
371
  groups?: string[];
336
372
  role?: string;
337
373
  username?: string;
374
+ /** Custom app-user attributes (mirrors stored user `attributes`); used by `conditions.expression`. */
375
+ attributes?: Record<string, unknown>;
338
376
  }
339
377
  export interface SecurityConfig {
340
378
  default_effect: 'allow' | 'deny';
@@ -363,8 +401,29 @@ export interface PolicyUpdateResponse {
363
401
  policy: SecurityPolicy;
364
402
  warnings?: string[];
365
403
  }
366
- /** Stored trigger document under /_xcitedb/triggers (server-defined shape). */
367
- export type TriggerDefinition = Record<string, unknown>;
404
+ /** `match` block for stored triggers (`/_xcitedb/triggers`). */
405
+ export interface TriggerMatch {
406
+ /** Non-empty array of identifier patterns (same shape as policy `resources.identifiers`). */
407
+ identifiers: PolicyIdentifierPattern[];
408
+ match_meta_path?: string;
409
+ match_operation?: 'set' | 'append' | 'delete';
410
+ }
411
+ /** `action` block: run unquery and write result to target meta. */
412
+ export interface TriggerAction {
413
+ query: XCiteQuery;
414
+ unquery: UnqueryTemplate;
415
+ target_identifier: string;
416
+ meta_path: string;
417
+ mode?: 'set' | 'append';
418
+ }
419
+ /** Stored trigger document under /_xcitedb/triggers. */
420
+ export interface TriggerDefinition {
421
+ enabled?: boolean;
422
+ event: 'meta_changed' | 'document_written' | 'document_deleted';
423
+ match: TriggerMatch;
424
+ conditions?: PolicyConditions;
425
+ action: TriggerAction;
426
+ }
368
427
  export interface StoredTriggerResponse {
369
428
  trigger_id: string;
370
429
  trigger: TriggerDefinition;
package/dist/websocket.js CHANGED
@@ -19,6 +19,11 @@ class WebSocketSubscription {
19
19
  const token = this.headerParams['Authorization']?.replace(/^Bearer\s+/i, '');
20
20
  if (token)
21
21
  u.searchParams.set('access_token', token);
22
+ if (!u.searchParams.has('access_token')) {
23
+ const appUser = this.headerParams['X-App-User-Token'];
24
+ if (appUser)
25
+ u.searchParams.set('access_token', appUser);
26
+ }
22
27
  const apiKey = this.headerParams['X-API-Key'];
23
28
  if (apiKey)
24
29
  u.searchParams.set('api_key', apiKey);
package/llms-full.txt CHANGED
@@ -26,6 +26,28 @@ Before reading the full reference, note these critical differences from typical
26
26
 
27
27
  9. **OpenAPI:** See repository `docs/openapi.yaml` for a machine-readable route map.
28
28
 
29
+ 10. **Ephemeral test sessions.** `POST /api/v1/test/sessions` (authenticated) returns a UUID **`session_token`**. Clients send **`X-Test-Session: <token>`** on API calls to use an isolated, TTL- and quota-limited LMDB instead of production project data. Unless **`X-Test-Auth: required`** is set, normal JWT/API-key checks are bypassed for those requests (synthetic admin for wet tests). Management routes under **`/api/v1/test/*`** must not include `X-Test-Session`. The test store starts empty (no cloned production project config).
30
+
31
+ ## Common Pitfalls
32
+
33
+ 1. **`baseUrl` must be origin-only (no `/api` or `/api/v1` path).** The SDK prepends `/api/v1/…` to every request. If `baseUrl` is `https://host/api/v1`, requests hit `/api/v1/api/v1/…` which typically returns **405 Not Allowed** from the reverse proxy. Use only the scheme + host + optional port: `https://host` or `http://localhost:8080`.
34
+
35
+ 2. **Browser WebSocket requires correct CORS and proxy config.** Browsers add an `Origin` header to WebSocket connections — make sure your project's CORS allowed-origins includes your app's origin. Reverse proxies (nginx) must forward WebSocket `Upgrade` requests to XCiteDB's `/api/v1/ws` endpoint (the default `docker/nginx.conf` already does this). For production deployments that need fine-grained server-side filtering or short-lived auth tickets, consider proxying the WebSocket through your own backend:
36
+
37
+ ```
38
+ Browser ──ws──▶ Your API server ──ws──▶ XCiteDB /api/v1/ws
39
+ (same origin, (server-to-server,
40
+ short ticket) secret API key + user JWT)
41
+ ```
42
+
43
+ 3. **Prefer `context.project_id` over `platformConsole` for project-scoped API keys.** `platformConsole` / `projectId` is designed for platform-operator keys that select a project dynamically via the `X-Project-Id` header. For project-scoped keys (which already identify their project), set **`context.project_id`** instead — it adds `tenant_id` to app-auth JSON bodies via `mergeAppTenant()` without sending `X-Project-Id`.
44
+
45
+ 4. **Prefer `https://` in `baseUrl` for remote/production hosts.** XciteDB's default nginx uses a method-preserving **308** redirect (POST stays POST), but third-party proxies or older configurations may use **301** which silently downgrades POST to GET. Using `https://` directly avoids the redirect entirely.
46
+
47
+ 5. **`context.project_id` (or `tenant_id`) is required for app-user self-registration.** `registerAppUser()` uses `mergeAppTenant(body)` to add `tenant_id` to the JSON body only when `context.project_id` or `context.tenant_id` is set. If both are omitted, the server cannot determine which project to register the user in. Always set `project_id` in the constructor `context` when calling `registerAppUser`, `loginAppUser`, and other public app-auth methods.
48
+
49
+ 6. **Self-registration uses server-configured default groups.** `registerAppUser()` assigns groups from the server's `auth.app_users.default_groups` config, not from the client request. To assign specific groups, use the admin endpoint `createAppUser()` instead, or update groups after registration via `updateAppUserGroups()`.
50
+
29
51
  ---
30
52
 
31
53
  # Part 1: Product Overview
@@ -162,6 +184,26 @@ Browser and mobile apps can use OAuth2-style flows against `/api/v1/app/auth/oau
162
184
 
163
185
  ---
164
186
 
187
+ # Ephemeral test sessions
188
+
189
+ For **integration and wet tests** against a shared BaaS host without touching production data:
190
+
191
+ | Step | What to do |
192
+ |------|------------|
193
+ | **Create** | **`POST /api/v1/test/sessions`** with normal **`Authorization: Bearer …`** or **`X-API-Key`**. Response includes a **`session_token`** (UUID). Server enforces per-credential limits (`test.max_sessions_per_key`, `test.session_ttl_seconds`, `test.max_test_db_size_bytes` in server config). |
194
+ | **Use** | Send **`X-Test-Session: <session_token>`** on document and other data API requests. The server routes to a dedicated LMDB under its data root (`_test/<id>/`), not the caller’s production tenant. **`tenant_id` / `X-Project-Id` semantics do not select production** while the test header is present—the synthetic test tenant is implied. |
195
+ | **Auth** | **Default:** test requests **skip** normal auth (convenient for automated tests). **`X-Test-Auth: required`:** require normal Bearer or API key; identity and ABAC apply, but data still comes from the test session DB. |
196
+ | **Manage** | **`GET /api/v1/test/sessions`** — list sessions for the current credential. **`DELETE /api/v1/test/sessions/current`** — destroy the session named by **`X-Test-Session`** (no other auth). **`DELETE /api/v1/test/sessions/all`** — destroy all sessions for the credential. **`DELETE /api/v1/test/sessions/{token}`** — destroy one session if owned by the credential. Do **not** send **`X-Test-Session`** on these `/api/v1/test/*` routes. |
197
+ | **CORS** | Browsers may need **`X-Test-Session`** and **`X-Test-Auth`** in the deployment’s allowed CORS headers (defaults include them). |
198
+
199
+ **SDK usage (summary):**
200
+
201
+ - **JavaScript/TypeScript:** `XCiteDBClient.createTestSession({ baseUrl, apiKey, … })` returns a client configured with `testSessionToken`; optional `testRequireAuth: true` maps to `X-Test-Auth: required`. `destroyTestSession()` calls `DELETE …/test/sessions/current`.
202
+ - **Python:** `async with XCiteDBClient.test_session(base_url, api_key=…, …)` provisions and tears down; or pass `test_session_token` / `test_require_auth` to the constructor.
203
+ - **C++:** `XCiteDBClient::create_test_session(options)` after setting `api_key` (and optional `test_require_auth`); `destroy_test_session()`.
204
+
205
+ ---
206
+
165
207
  # Health, version & discovery
166
208
 
167
209
  ## Health
@@ -393,6 +435,122 @@ Executes a structured query document (JSON DSL) for advanced analytics, bulk exp
393
435
 
394
436
  Unquery supports: hierarchical navigation (self, parents, ancestors, children, descendants), XPath queries on XML, JSON path navigation, counts, sums, string operations, type casting, and conditional branching. Results are always structured JSON.
395
437
 
438
+ ## Unquery DSL reference
439
+
440
+ The `unquery` body is a **JSON template**: each **object key** names an output field; each **value** is either a nested object/array (recursed) or a **string parsed as an expression**. Arrays produce JSON arrays in order.
441
+
442
+ ### Core expression builtins (string values)
443
+
444
+ - **Document / path:** `$identifier`, `$identifier(n)` (path segment by index), `$key`, `$index`, `$path`
445
+ - **XML:** `$xpath("expr")`, `$lxpath("expr")` (leaf / no-children variant), `$xml`, `$xml_no_children`, `$node`, `$attr("name")`, `$child("name")`, `$text(expr)`
446
+ - **Aggregates / size:** `$count`, `$sum(expr)`, `$avg(expr)`, `$min(expr)`, `$max(expr)`, `$size(expr)` (array length), `$length(expr)` (string length)
447
+ - **String ops:** `$lower(expr)`, `$upper(expr)`, `$substr(s,start,len?)`, `$replace(src,from,to,all?)`, `$split(s,delim)`, `$join(arr,delim?)`, `$find`, `$ifind`
448
+ - **Time / casts:** `$now`, `$to_time(expr,fmt?)`, `$time_to_str(expr,fmt?)`, `$string`, `$number`, `$int`, `$float`, `$bool(expr)`
449
+ - **Control:** `$if(cond, then, else)`, `$var(name)` or `%name` (template variables), `$call(name)` (user `#func`), `$prev(default)`
450
+ - **Other:** `$node_date`, `$data_date(expr?)`, `$in_filter("name")`, `$file`, `$csv`, `$env`, `$filename` (last three blocked in safety mode for standalone `evaluateCondition`)
451
+
452
+ ### Key-side syntax (JSON object member **names**, not values)
453
+
454
+ - Plain names, dotted paths, `[index]`, `$(expression)` / `$expr` for dynamic keys
455
+ - **`#if`**, **`#var`**, **`#assign`**, **`#func name (a,b,…)`**, **`#exists`**, **`#notexists`**, **`#return`**, **`#returnif`**
456
+ - Suffixes on keys: `?condition` (filter branch), `@ ascending` / `@ descending` / `@ unique_ascending` / `@ unique_descending` on values
457
+ - **Context navigation** after `->` (in key paths): `$self`, `$parent`, `$ancestors`, `$ancestors_and_self`, `$children`, `$descendants`, `$descendants_and_self`, `$date`, `$branch`, `$all`, `$var`, `$file`, `$csv`
458
+
459
+ ### Conditions (same grammar as policy `conditions.expression` and Unquery `?` on keys)
460
+
461
+ Operators: `=`, `!=`, `<`, `>`, `<=`, `>=`, `in`, `not_in`, `contains`, `starts_with`, `ends_with`, `matches`, `is_array`, `is_object`, `is_string`, `is_number`, `is_int`, `is_float`, `is_bool`, `is_literal`. Logic: `&` (AND), `|` (OR), `!` (NOT). Postfix `!` on an expression tests **field exists**.
462
+
463
+ ### Graded examples
464
+
465
+ **1. Project one meta field into an output key** (per matching document, then combined per engine rules):
466
+
467
+ ```json
468
+ {
469
+ "query": { "match_start": "/manual/" },
470
+ "unquery": { "title": "chapter_title" }
471
+ }
472
+ ```
473
+
474
+ **2. Count documents in a prefix:**
475
+
476
+ ```json
477
+ {
478
+ "query": { "match_start": "/orders/" },
479
+ "unquery": { "total": "$count" }
480
+ }
481
+ ```
482
+
483
+ **3. XPath from XML:**
484
+
485
+ ```json
486
+ {
487
+ "query": { "match": "/book/ch1" },
488
+ "unquery": { "heading": "$xpath(\"//h1\")" }
489
+ }
490
+ ```
491
+
492
+ **4. Nested object with conditional key** (illustrative; exact key syntax may use `#if` blocks for complex trees):
493
+
494
+ ```json
495
+ {
496
+ "query": { "match_start": "/items/" },
497
+ "unquery": {
498
+ "id": "$identifier",
499
+ "summary": {
500
+ "len": "$length(title_field)"
501
+ }
502
+ }
503
+ }
504
+ ```
505
+
506
+ SDK: `UnqueryTemplate` in `@xcitedbs/client` types; method `unquery(query, unquery)`.
507
+
508
+ ## ABAC policy expression language
509
+
510
+ Policies may set **`conditions.expression`** (optional) and **`conditions.branches`** (optional array). If `branches` is non-empty, the request branch must match an entry (`"*"`, exact name, or `prefix*`).
511
+
512
+ ### Evaluation context for **policies** (`conditions.expression`)
513
+
514
+ ```json
515
+ {
516
+ "subject": {
517
+ "id": "...",
518
+ "email": "...",
519
+ "type": "app_user|developer|...",
520
+ "role": "...",
521
+ "groups": ["..."],
522
+ "attr": { }
523
+ },
524
+ "resource": {
525
+ "identifier": "/full/path",
526
+ "path": ["segment", "segment"]
527
+ },
528
+ "env": { "branch": "..." }
529
+ }
530
+ ```
531
+
532
+ `subject.attr` mirrors app-user **custom attributes** from the user record. `resource.path` is the identifier split on `/` (non-empty segments only).
533
+
534
+ ### Operators (predicates)
535
+
536
+ `=`, `!=`, `>=`, `<=`, `>`, `<`, `in`, `contains`, `starts_with`, `ends_with`, `&`, `|`, `!`, `+` (string concat), postfix `!` (exists).
537
+
538
+ ### Policy action strings (in `actions` arrays)
539
+
540
+ Use: **`read`**, **`write`**, **`delete`**, **`list`**, **`meta:read`**, **`meta:write`**, **`unquery`**. (Do not use legacy `document:write`-style names in new policies.)
541
+
542
+ ### Copy-pasteable expression recipes (from integration tests)
543
+
544
+ | Scenario | `conditions.expression` |
545
+ |----------|-------------------------|
546
+ | Tenant isolation (first path segment = user attr) | `resource.path[0] = subject.attr.tenant_code` |
547
+ | Numeric level | `subject.attr.level >= 5` |
548
+ | Project membership (second segment in array attr) | `resource.path[1] in subject.attr.projects` |
549
+ | Boolean feature flag | `subject.attr.beta = true` |
550
+ | Compound + branch (also use `conditions.branches` when needed) | `subject.email ends_with '@company.com' \| (subject.email ends_with '@partner.com' & env.branch = 'stg')` |
551
+
552
+ Dry-run: **`POST /api/v1/security/check`** with `subject`, `identifier`, `action`, optional `meta_path`, `branch`.
553
+
396
554
  ---
397
555
 
398
556
  # Branches
@@ -527,27 +685,111 @@ Returns lock info. **`409`** if already locked.
527
685
 
528
686
  **Base path:** `/api/v1/triggers`
529
687
 
688
+ Definitions are stored as JSON under **`/_xcitedb/triggers`**. After a matching event, the server runs **`action.unquery`** over documents selected by **`action.query`**, then writes the resulting JSON to **`action.meta_path`** on **`action.target_identifier`** (elevated privileges inside the same transaction). Nested trigger evaluation is blocked (no infinite loops).
689
+
530
690
  ## Create or update trigger
531
691
 
532
692
  **`POST /api/v1/triggers`**
533
693
 
534
694
  ```json
535
695
  {
536
- "trigger_id": "on_status_review",
696
+ "trigger_id": "on_project_status_meta",
697
+ "trigger": {
698
+ "enabled": true,
699
+ "event": "meta_changed",
700
+ "match": {
701
+ "identifiers": [{ "match_start": "/projects/" }],
702
+ "match_meta_path": "status",
703
+ "match_operation": "set"
704
+ },
705
+ "conditions": {
706
+ "branches": ["*", "main"],
707
+ "expression": "trigger.meta_path = \"status\""
708
+ },
709
+ "action": {
710
+ "query": { "match": "$trigger_identifier" },
711
+ "unquery": { "id": "$identifier", "status": "status" },
712
+ "target_identifier": "/indexes/project_status",
713
+ "meta_path": "entries",
714
+ "mode": "append"
715
+ }
716
+ }
717
+ }
718
+ ```
719
+
720
+ ### Trigger fields
721
+
722
+ | Field | Required | Description |
723
+ |--------|----------|-------------|
724
+ | `enabled` | No (default true) | If false, trigger is skipped. |
725
+ | `event` | Yes | `meta_changed`, `document_written`, or `document_deleted`. |
726
+ | `match` | Yes | Must include **`identifiers`**: non-empty array of identifier patterns (`exact`, `match_start`, `match_end`, `contains`, `regex`). Optional **`match_meta_path`**: exact path or prefix ending with `*`. Optional **`match_operation`**: `set`, `append`, or `delete` (meta / delete ops). |
727
+ | `conditions` | No | Optional **`branches`** (same as policies) and **`expression`** (see below). |
728
+ | `action` | Yes | **`query`** (`XCiteQuery`), **`unquery`** (Unquery DSL template), **`target_identifier`** (literal or `"$trigger_identifier"`), **`meta_path`**, optional **`mode`**: `set` (default) or `append`. |
729
+
730
+ ### Expression context for **triggers** (`conditions.expression`)
731
+
732
+ Not the same as policies: there is **no `subject`**. Context object:
733
+
734
+ - **`trigger`**: `{ "event", "meta_path", "operation" }` (`operation`: `set` / `append` / `delete`)
735
+ - **`value`**: JSON written at `meta_path` for `meta_changed`, or null
736
+ - **`resource`**: `{ "identifier", "path" }` for the firing identifier
737
+ - **`env`**: `{ "branch" }`
738
+
739
+ ### Unquery variables injected for triggers
740
+
741
+ String template vars: **`$trigger_identifier`**, **`$trigger_meta_path`**, **`$trigger_operation`**. JSON var: **`$trigger_value`** (the written value when applicable).
742
+
743
+ ### Recipes
744
+
745
+ **Meta change → append row to an index document**
746
+
747
+ ```json
748
+ {
749
+ "trigger_id": "meta_index",
750
+ "trigger": {
751
+ "event": "meta_changed",
752
+ "match": {
753
+ "identifiers": [{ "match_start": "/docs/" }],
754
+ "match_meta_path": "reviewed"
755
+ },
756
+ "action": {
757
+ "query": { "match": "$trigger_identifier" },
758
+ "unquery": { "doc": "$trigger_identifier", "at": "$trigger_meta_path" },
759
+ "target_identifier": "/indexes/reviews",
760
+ "meta_path": "log",
761
+ "mode": "append"
762
+ }
763
+ }
764
+ }
765
+ ```
766
+
767
+ **Document written → write stats meta on the same identifier**
768
+
769
+ ```json
770
+ {
771
+ "trigger_id": "article_stats",
537
772
  "trigger": {
538
- "match": { "meta_key": "status", "meta_value": "review" },
539
- "action": { "type": "webhook", "url": "https://hooks.example.com/xcite" }
773
+ "event": "document_written",
774
+ "match": { "identifiers": [{ "match_start": "/articles/" }] },
775
+ "action": {
776
+ "query": { "match": "$trigger_identifier" },
777
+ "unquery": { "id": "$identifier", "nodes": "$count" },
778
+ "target_identifier": "$trigger_identifier",
779
+ "meta_path": "stats",
780
+ "mode": "set"
781
+ }
540
782
  }
541
783
  }
542
784
  ```
543
785
 
544
786
  ## List triggers
545
787
 
546
- **`GET /api/v1/triggers`** — Returns map of `{ trigger_id: trigger_definition }`.
788
+ **`GET /api/v1/triggers`** — Returns map of `{ trigger_id: trigger_definition }`. Optional **`?name=`** returns one trigger as `{ trigger_id, trigger }`.
547
789
 
548
790
  ## Delete trigger
549
791
 
550
- **`DELETE /api/v1/triggers?name=on_status_review`**
792
+ **`DELETE /api/v1/triggers?name=on_project_status_meta`**
551
793
 
552
794
  ---
553
795
 
@@ -567,7 +809,7 @@ Returns lock info. **`409`** if already locked.
567
809
  "priority": 10,
568
810
  "subjects": { "type": "app_user", "groups": ["viewers"] },
569
811
  "resources": { "identifiers": [{ "match_start": "/admin/" }] },
570
- "actions": ["document:write", "document:delete"],
812
+ "actions": ["write", "delete", "meta:write"],
571
813
  "conditions": {}
572
814
  }
573
815
  }
@@ -588,11 +830,11 @@ Returns lock info. **`409`** if already locked.
588
830
  {
589
831
  "subject": { "type": "app_user", "groups": ["editors"] },
590
832
  "identifier": "/admin/config",
591
- "action": "document:write"
833
+ "action": "write"
592
834
  }
593
835
  ```
594
836
 
595
- Returns `{ effect: "allow"|"deny", matched_policy_id? }`.
837
+ Returns `{ effect: "allow"|"deny", matched_policy_id?, expression_context? }`.
596
838
 
597
839
  ## Security config
598
840
 
@@ -1042,7 +1284,7 @@ interface DatabaseContext {
1042
1284
  - `deleteTrigger(name)` → `void`
1043
1285
 
1044
1286
  ### App User Auth
1045
- - `registerAppUser(email, password, displayName?, groups?, attributes?)` → `AppUser`
1287
+ - `registerAppUser(email, password, displayName?, attributes?)` → `AppUser` (groups assigned from server config)
1046
1288
  - `loginAppUser(email, password)` → `AppUserTokenPair`
1047
1289
  - `refreshAppUser()` → `AppUserTokenPair`
1048
1290
  - `logoutAppUser()` → `void`
@@ -1081,6 +1323,9 @@ interface DatabaseContext {
1081
1323
 
1082
1324
  ### WebSocket
1083
1325
  - `subscribe(options: SubscriptionOptions, callback, onError?)` → `WebSocketSubscription`
1326
+ - Browser WebSocket cannot send HTTP headers. The SDK maps credentials to query params:
1327
+ `Authorization: Bearer <jwt>` → `?access_token=<jwt>`, `X-App-User-Token` → `?access_token=<jwt>` (fallback),
1328
+ `X-API-Key` → `?api_key=<key>`. The SDK also propagates `context.project_id` as `?tenant_id=`.
1084
1329
 
1085
1330
  ## Key Types
1086
1331
 
package/llms.txt CHANGED
@@ -22,6 +22,30 @@ These are the most common sources of confusion for developers and AI assistants:
22
22
 
23
23
  8. **Project vs tenant id.** In the SDK, prefer `context.project_id` (and `listMyProjects` / `switchProject`). Many JSON bodies and JWT claims still use the field name `tenant_id` for the same value — the client sends that wire name automatically.
24
24
 
25
+ 9. **Ephemeral test sessions (wet tests).** Call **`POST /api/v1/test/sessions`** with a normal API key or Bearer token to get a `session_token` (UUID). Send **`X-Test-Session: <token>`** on subsequent document/API calls to use an isolated, short-lived LMDB instead of production data. By default those requests **skip** normal auth; send **`X-Test-Auth: required`** to exercise real JWT/API-key auth against the same test database. Do not send `X-Test-Session` on `/api/v1/test/*` management routes. Server limits apply (`test.session_ttl_seconds`, `test.max_sessions_per_key`, `test.max_test_db_size_bytes` in config). The test DB starts **empty** (no copy of production project config or keys).
26
+
27
+ ## Common Pitfalls
28
+
29
+ 1. **`baseUrl` must be origin-only (no `/api` or `/api/v1` path).** The SDK prepends `/api/v1/…` to every request. If `baseUrl` is `https://host/api/v1`, requests hit `/api/v1/api/v1/…` which typically returns **405 Not Allowed** from the reverse proxy. Use only the scheme + host + optional port: `https://host` or `http://localhost:8080`.
30
+
31
+ 2. **Browser WebSocket requires correct CORS and proxy config.** Browsers add an `Origin` header to WebSocket connections — make sure your project's CORS allowed-origins includes your app's origin. Reverse proxies (nginx) must forward WebSocket `Upgrade` requests to XCiteDB's `/api/v1/ws` endpoint (the default `docker/nginx.conf` already does this). For production deployments that need fine-grained server-side filtering or short-lived auth tickets, consider proxying the WebSocket through your own backend.
32
+
33
+ 3. **Prefer `context.project_id` over `platformConsole` for project-scoped API keys.** `platformConsole` / `projectId` is designed for platform-operator keys that select a project dynamically via the `X-Project-Id` header. For project-scoped keys (which already identify their project), set **`context.project_id`** instead — it adds `tenant_id` to app-auth JSON bodies via `mergeAppTenant()` without sending `X-Project-Id`.
34
+
35
+ 4. **Prefer `https://` in `baseUrl` for remote/production hosts.** XciteDB's default nginx uses a method-preserving **308** redirect (POST stays POST), but third-party proxies or older configurations may use **301** which silently downgrades POST to GET. Using `https://` directly avoids the redirect entirely.
36
+
37
+ 5. **`context.project_id` (or `tenant_id`) is required for app-user self-registration.** `registerAppUser()` uses `mergeAppTenant(body)` to add `tenant_id` to the JSON body only when `context.project_id` or `context.tenant_id` is set. If both are omitted, the server cannot determine which project to register the user in. Always set `project_id` in the constructor `context` when calling `registerAppUser`, `loginAppUser`, and other public app-auth methods.
38
+
39
+ 6. **Self-registration uses server-configured default groups.** `registerAppUser()` assigns groups from the server's `auth.app_users.default_groups` config, not from the client request. To assign specific groups, use the admin endpoint `createAppUser()` instead, or update groups after registration via `updateAppUserGroups()`.
40
+
41
+ ## Test mode (how to use)
42
+
43
+ 1. **Provision:** `POST /api/v1/test/sessions` with `Authorization: Bearer …` or `X-API-Key` (same as normal API access). Response JSON includes the session token.
44
+ 2. **Run tests:** Every request that should hit the throwaway DB must include **`X-Test-Session: <token>`** (and your usual `X-Branch` / `context` as needed). Data is stored under the server’s `_test/<session>/` area, not your production tenant.
45
+ 3. **Auth behavior:** Omit extra headers for frictionless tests. To test policies and real identities, set **`X-Test-Auth: required`** and send normal credentials; the DB is still the test session’s.
46
+ 4. **Cleanup:** `DELETE /api/v1/test/sessions/current` with `X-Test-Session` (no other auth), or `DELETE /api/v1/test/sessions/all` / `DELETE /api/v1/test/sessions/{token}` with normal auth for the owning key or JWT.
47
+ 5. **SDKs:** **JS/TS:** `XCiteDBClient.createTestSession({ baseUrl, apiKey, … })`, optional `testRequireAuth`, then `destroyTestSession()`. **Python:** `async with XCiteDBClient.test_session(...)` or manual token + `test_session_token` / `test_require_auth` constructor args. **C++:** `XCiteDBClient::create_test_session(options)`, `destroy_test_session()`, optional `test_require_auth` in options.
48
+
25
49
  ## JavaScript/TypeScript SDK (`@xcitedbs/client`)
26
50
 
27
51
  Install: `npm install @xcitedbs/client`
@@ -182,13 +206,36 @@ interface XCiteDBClientOptions {
182
206
  **Triggers:**
183
207
  - `upsertTrigger(id, trigger)` / `listTriggers()` / `deleteTrigger(name)` — Automation triggers
184
208
 
209
+ ### Advanced: policy expressions (ABAC)
210
+
211
+ - **Actions** (use in `policy.actions`): `read`, `write`, `delete`, `list`, `meta:read`, `meta:write`, `unquery`.
212
+ - **`conditions.expression`** uses the same predicate syntax as Unquery `?` filters. **Context:** `subject.id`, `subject.email`, `subject.role`, `subject.groups`, `subject.attr.*` (app-user JSON attributes), `resource.identifier`, `resource.path` (array of path segments), `env.branch`.
213
+ - **Operators:** `=`, `!=`, `>=`, `<=`, `>`, `<`, `in`, `contains`, `starts_with`, `ends_with`, `&`, `|`, `!`, `+` (string concat), postfix `!` (exists).
214
+ - **Examples:** `resource.path[0] = subject.attr.tenant_code` — tenant isolation; `subject.attr.level >= 5` — numeric attribute gate.
215
+
216
+ ### Advanced: triggers (stored under `/_xcitedb/triggers`)
217
+
218
+ - **Events:** `meta_changed`, `document_written`, `document_deleted`.
219
+ - **`match`:** required non-empty **`identifiers`** (same pattern objects as policy `resources.identifiers`); optional **`match_meta_path`** (exact or `prefix*`); optional **`match_operation`:** `set` | `append` | `delete`.
220
+ - **`action`:** **`query`** (document query), **`unquery`** (Unquery template), **`target_identifier`** (or `"$trigger_identifier"`), **`meta_path`**, **`mode`:** `set` | `append`.
221
+ - **Unquery vars:** `$trigger_identifier`, `$trigger_meta_path`, `$trigger_operation`, `$trigger_value`. **Trigger `conditions.expression` context:** `trigger.{event,meta_path,operation}`, `value`, `resource`, `env.branch` (no `subject`).
222
+
223
+ ### Advanced: Unquery DSL (`unquery(query, unqueryDoc)`)
224
+
225
+ - JSON **keys** = output fields; **string values** = expressions referencing meta/XML (e.g. field name `"title"` reads meta key `title`).
226
+ - **Common builtins:** `$count`, `$sum(expr)`, `$avg(expr)`, `$min(expr)`, `$max(expr)`, `$xpath("…")`, `$identifier`, `$size(expr)`, `$length(expr)`, `$if(cond, a, b)`, `$var(name)` / `%name`.
227
+ - **Example:** `{ "query": { "match_start": "/orders/" }, "unquery": { "n": "$count" } }` — count documents. Full grammar: **`llms-full.txt`** in this package.
228
+
185
229
  **App Users (admin):**
186
230
  - `listAppUsers()` / `createAppUser()` / `deleteAppUser()` — Manage end-user accounts
187
- - `registerAppUser()` — Self-registration
231
+ - `registerAppUser(email, password, displayName?, attributes?)` — Self-registration (groups assigned from server config)
188
232
  - `getAppAuthConfig()` — Auth configuration
189
233
 
190
234
  **WebSocket:**
191
235
  - `subscribe(options, callback, onError?)` — Real-time document change notifications
236
+ - Browser WebSocket cannot send HTTP headers. The SDK maps credentials to query params:
237
+ `Authorization: Bearer <jwt>` → `?access_token=<jwt>`, `X-App-User-Token` → `?access_token=<jwt>` (fallback),
238
+ `X-API-Key` → `?api_key=<key>`. The SDK also propagates `context.project_id` as `?tenant_id=`.
192
239
 
193
240
  ### Query Flags
194
241
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcitedbs/client",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",