@xcitedbs/client 0.3.3 → 0.3.5

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
@@ -82,8 +82,25 @@ export declare class XCiteDBClient {
82
82
  private getAppUserId;
83
83
  private normalizeIsolationNamespaceTemplate;
84
84
  private canonicalId;
85
- /** Resolved namespace root, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
85
+ /** Resolve the configured namespace template for an explicit user id. Returns null if isolation is off, no template, or `userId` is empty. */
86
+ private userIsolationNamespaceFor;
87
+ /** Resolved namespace root for the calling user, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
86
88
  private userIsolationNamespace;
89
+ /**
90
+ * Namespace for the current request's identity context.
91
+ *
92
+ * On a user-workspace branch (`_uw/<owner>/<slug>`) where the calling user is
93
+ * a member but not the owner, returns the *owner's* namespace — docs in the
94
+ * workspace are stored at `<owner_ns>/<rel>`, not at the caller's
95
+ * `<self_ns>/<rel>`. For the owner, non-`_uw/` branches, malformed
96
+ * branches, or when isolation is off, falls back to the calling user's
97
+ * namespace.
98
+ *
99
+ * Mirrors the server-side branch-aware behavior in
100
+ * `UserIsolationService::prefixIdentifier` so the SDK's pre-prefixing
101
+ * lands on the right namespace before the request leaves.
102
+ */
103
+ private userIsolationContextNamespace;
87
104
  private allSharedPassthroughPrefixes;
88
105
  private pathMatchesSharedPassthrough;
89
106
  private isoPrefixId;
package/dist/client.js CHANGED
@@ -185,6 +185,8 @@ class XCiteDBClient {
185
185
  const sessionBody = {};
186
186
  if (opts.overlay === true)
187
187
  sessionBody.overlay = true;
188
+ if (opts.additionalKeys !== undefined)
189
+ sessionBody.additional_keys = opts.additionalKeys;
188
190
  if (opts.bootstrap !== undefined)
189
191
  sessionBody.bootstrap = opts.bootstrap;
190
192
  const data = await temp.request('POST', '/api/v1/test/sessions', Object.keys(sessionBody).length ? sessionBody : undefined, undefined, { no401Retry: true });
@@ -521,13 +523,12 @@ class XCiteDBClient {
521
523
  }
522
524
  return t;
523
525
  }
524
- /** Resolved namespace root, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
525
- userIsolationNamespace() {
526
+ /** Resolve the configured namespace template for an explicit user id. Returns null if isolation is off, no template, or `userId` is empty. */
527
+ userIsolationNamespaceFor(userId) {
526
528
  if (!this.userIsolation?.enabled) {
527
529
  return null;
528
530
  }
529
- const uid = this.getAppUserId();
530
- if (!uid) {
531
+ if (!userId) {
531
532
  return null;
532
533
  }
533
534
  const rawTpl = this.normalizeIsolationNamespaceTemplate(this.userIsolation.namespace ?? '/users/{userId}');
@@ -535,7 +536,42 @@ class XCiteDBClient {
535
536
  if (!trimmed) {
536
537
  return null;
537
538
  }
538
- return trimmed.replace(/{userId}/g, uid).replace(/{user_id}/g, uid);
539
+ return trimmed.replace(/{userId}/g, userId).replace(/{user_id}/g, userId);
540
+ }
541
+ /** Resolved namespace root for the calling user, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
542
+ userIsolationNamespace() {
543
+ return this.userIsolationNamespaceFor(this.getAppUserId());
544
+ }
545
+ /**
546
+ * Namespace for the current request's identity context.
547
+ *
548
+ * On a user-workspace branch (`_uw/<owner>/<slug>`) where the calling user is
549
+ * a member but not the owner, returns the *owner's* namespace — docs in the
550
+ * workspace are stored at `<owner_ns>/<rel>`, not at the caller's
551
+ * `<self_ns>/<rel>`. For the owner, non-`_uw/` branches, malformed
552
+ * branches, or when isolation is off, falls back to the calling user's
553
+ * namespace.
554
+ *
555
+ * Mirrors the server-side branch-aware behavior in
556
+ * `UserIsolationService::prefixIdentifier` so the SDK's pre-prefixing
557
+ * lands on the right namespace before the request leaves.
558
+ */
559
+ userIsolationContextNamespace() {
560
+ const selfId = this.getAppUserId();
561
+ const branch = this.defaultContext.workspace ?? this.defaultContext.branch;
562
+ if (branch && branch.startsWith('_uw/')) {
563
+ const m = branch.match(/^_uw\/([^/]+)\/[^/]+$/);
564
+ if (m) {
565
+ const ownerId = m[1];
566
+ if (ownerId && ownerId !== selfId) {
567
+ const ns = this.userIsolationNamespaceFor(ownerId);
568
+ if (ns) {
569
+ return ns;
570
+ }
571
+ }
572
+ }
573
+ }
574
+ return this.userIsolationNamespaceFor(selfId);
539
575
  }
540
576
  allSharedPassthroughPrefixes() {
541
577
  const o = this.userIsolation;
@@ -564,7 +600,7 @@ class XCiteDBClient {
564
600
  return false;
565
601
  }
566
602
  isoPrefixId(id) {
567
- const ns = this.userIsolationNamespace();
603
+ const ns = this.userIsolationContextNamespace();
568
604
  if (!ns) {
569
605
  return id;
570
606
  }
@@ -589,7 +625,7 @@ class XCiteDBClient {
589
625
  return finalId;
590
626
  }
591
627
  isoUnprefixId(id) {
592
- const ns = this.userIsolationNamespace();
628
+ const ns = this.userIsolationContextNamespace();
593
629
  if (!ns) {
594
630
  return id;
595
631
  }
@@ -606,7 +642,7 @@ class XCiteDBClient {
606
642
  return canonical;
607
643
  }
608
644
  isoPrefixQuery(q) {
609
- if (!this.userIsolationNamespace()) {
645
+ if (!this.userIsolationContextNamespace()) {
610
646
  return q;
611
647
  }
612
648
  const out = { ...q };
@@ -622,7 +658,7 @@ class XCiteDBClient {
622
658
  return out;
623
659
  }
624
660
  isoApplyXmlDbIdentifier(xml) {
625
- if (!this.userIsolationNamespace()) {
661
+ if (!this.userIsolationContextNamespace()) {
626
662
  return xml;
627
663
  }
628
664
  return xml.replace(/(db:identifier\s*=\s*")([^"]*)(")/, (_m, p1, mid, p3) => {
@@ -32,6 +32,80 @@ const types_js_1 = require("./types.js");
32
32
  globalThis.fetch = orig;
33
33
  }
34
34
  });
35
+ (0, node_test_1.it)('user-workspace branch (`_uw/<owner>/<slug>`) prefixes paths with owner namespace, not caller', async () => {
36
+ // Issue B repro at the unit level: an invitee on someone else's user
37
+ // workspace must namespace identifiers to the owner's user_id parsed
38
+ // from the branch, not their own. We assert the outbound URL — SDK
39
+ // routes prefixing through userIsolationContextNamespace() which
40
+ // checks `_uw/<owner>/<slug>` and substitutes the owner.
41
+ const seen = [];
42
+ const orig = globalThis.fetch;
43
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
44
+ seen.push(String(input));
45
+ return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
46
+ });
47
+ try {
48
+ const c = new client_js_1.XCiteDBClient({
49
+ baseUrl: 'http://127.0.0.1:9',
50
+ apiKey: 'test-key',
51
+ userIsolation: { enabled: true, namespace: '/users/{userId}' },
52
+ });
53
+ // sub claim = "user-B"
54
+ c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUIifQ.sig');
55
+ // Owner of the workspace is "user-A"; we are "user-B".
56
+ c.setContext({ branch: '_uw/user-A/shared', project_id: 'p1' });
57
+ await c.readJsonDocument('/drafts/x');
58
+ strict_1.default.equal(seen.length, 1);
59
+ strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-A%2Fdrafts%2Fx/, `expected identifier prefixed with owner ns, got: ${seen[0]}`);
60
+ }
61
+ finally {
62
+ globalThis.fetch = orig;
63
+ }
64
+ });
65
+ (0, node_test_1.it)('non-`_uw` branch keeps caller-namespace prefixing (regression guard)', async () => {
66
+ const seen = [];
67
+ const orig = globalThis.fetch;
68
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
69
+ seen.push(String(input));
70
+ return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
71
+ });
72
+ try {
73
+ const c = new client_js_1.XCiteDBClient({
74
+ baseUrl: 'http://127.0.0.1:9',
75
+ apiKey: 'test-key',
76
+ userIsolation: { enabled: true, namespace: '/users/{userId}' },
77
+ });
78
+ c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUIifQ.sig');
79
+ c.setContext({ branch: 'main', project_id: 'p1' });
80
+ await c.readJsonDocument('/drafts/x');
81
+ strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-B%2Fdrafts%2Fx/, `expected identifier prefixed with caller ns, got: ${seen[0]}`);
82
+ }
83
+ finally {
84
+ globalThis.fetch = orig;
85
+ }
86
+ });
87
+ (0, node_test_1.it)('owner of `_uw/<self>/<slug>` workspace uses own namespace (no-op for owner)', async () => {
88
+ const seen = [];
89
+ const orig = globalThis.fetch;
90
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
91
+ seen.push(String(input));
92
+ return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
93
+ });
94
+ try {
95
+ const c = new client_js_1.XCiteDBClient({
96
+ baseUrl: 'http://127.0.0.1:9',
97
+ apiKey: 'test-key',
98
+ userIsolation: { enabled: true, namespace: '/users/{userId}' },
99
+ });
100
+ c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUEifQ.sig'); // user-A
101
+ c.setContext({ branch: '_uw/user-A/shared', project_id: 'p1' });
102
+ await c.readJsonDocument('/drafts/x');
103
+ strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-A%2Fdrafts%2Fx/, `expected owner to keep own ns, got: ${seen[0]}`);
104
+ }
105
+ finally {
106
+ globalThis.fetch = orig;
107
+ }
108
+ });
35
109
  (0, node_test_1.it)('queryByIdentifier / queryDocuments return XML bodies unmodified under userIsolation', async () => {
36
110
  const xml = '<?xml version="1.0"?><doc xmlns:db="http://www.xcitedb.com/schema"><a href="https://example.com//path">x</a></doc>';
37
111
  const orig = globalThis.fetch;
@@ -315,6 +389,51 @@ const types_js_1 = require("./types.js");
315
389
  }));
316
390
  strict_1.default.equal(h.get('X-Test-Auth'), 'preserve');
317
391
  });
392
+ (0, node_test_1.it)('forwards bootstrap.triggers and surfaces triggers_created on the client', async () => {
393
+ const captured = { value: null };
394
+ const orig = globalThis.fetch;
395
+ globalThis.fetch = node_test_1.mock.fn(async (_input, init) => {
396
+ captured.value = { body: JSON.parse(String(init?.body ?? '{}')) };
397
+ return new Response(JSON.stringify({
398
+ session_token: 'tok-trig',
399
+ expires_at: Date.now() + 60000,
400
+ session_ttl_seconds: 60,
401
+ bootstrap: {
402
+ user_isolation_applied: false,
403
+ developer_bypass_applied: false,
404
+ policies_created: [],
405
+ triggers_created: ['t-write-meta'],
406
+ },
407
+ }), { status: 201 });
408
+ });
409
+ try {
410
+ const client = await client_js_1.XCiteDBClient.createTestSession({
411
+ baseUrl: 'http://127.0.0.1:9',
412
+ apiKey: 'k',
413
+ bootstrap: {
414
+ triggers: [
415
+ {
416
+ trigger_id: 't-write-meta',
417
+ trigger: {
418
+ event: 'meta_changed',
419
+ match: { identifiers: [{ match_start: '/' }] },
420
+ action: { unquery: { x: '$count' }, target_identifier: '/_audit', meta_path: 'n', mode: 'set' },
421
+ },
422
+ },
423
+ ],
424
+ },
425
+ });
426
+ const body = captured.value.body;
427
+ const boot = body.bootstrap;
428
+ const triggers = boot.triggers;
429
+ strict_1.default.equal(triggers.length, 1);
430
+ strict_1.default.equal(triggers[0].trigger_id, 't-write-meta');
431
+ strict_1.default.deepEqual(client.lastTestSessionBootstrap?.triggers_created, ['t-write-meta']);
432
+ }
433
+ finally {
434
+ globalThis.fetch = orig;
435
+ }
436
+ });
318
437
  });
319
438
  (0, node_test_1.describe)('enableUserIsolation', () => {
320
439
  (0, node_test_1.it)('with explicit config, configures prefixing without any HTTP call', async () => {
package/dist/types.d.ts CHANGED
@@ -720,12 +720,24 @@ export interface TestSessionBootstrap {
720
720
  policy_id: string;
721
721
  policy: SecurityPolicy;
722
722
  }>;
723
+ /**
724
+ * Per-test trigger set. Installed into the synthetic test tenant only — the project's
725
+ * global trigger registry is untouched. Each entry is `{ trigger_id, trigger }`; the
726
+ * `trigger` shape matches `upsertTrigger`. Validation errors abort the bootstrap with
727
+ * `400 bootstrap_trigger_invalid`; storage failures abort with `500 bootstrap_trigger_failed`.
728
+ */
729
+ triggers?: Array<{
730
+ trigger_id: string;
731
+ trigger: TriggerDefinition;
732
+ }>;
723
733
  }
724
734
  /** `bootstrap` summary returned by the server after a bootstrapped test session is created. */
725
735
  export interface TestSessionBootstrapSummary {
726
736
  user_isolation_applied?: boolean;
727
737
  developer_bypass_applied?: boolean;
728
738
  policies_created?: string[];
739
+ /** IDs of triggers installed by the bootstrap (in input order). */
740
+ triggers_created?: string[];
729
741
  }
730
742
  /** One row from `GET /api/v1/test/sessions` (management; no `X-Test-Session` on that route). */
731
743
  export interface TestSessionInfo {
@@ -749,7 +761,19 @@ export interface CreateTestSessionOptions {
749
761
  * When true, creates an overlay test session: writable ephemeral LMDB with production project data as read-only base.
750
762
  */
751
763
  overlay?: boolean;
752
- /** Optional server-side bootstrap (user isolation, developer_bypass, policies). */
764
+ /**
765
+ * API keys to snapshot into the new session's config DB so requests carrying any of these keys
766
+ * (alongside `X-Test-Session: <token>`) authenticate against the session, not production.
767
+ *
768
+ * Typical use: a backend-for-frontend (BFF) that performs admin-scoped writes on behalf of the
769
+ * SPA. Without registering the BFF's admin key here, the BFF would receive `401
770
+ * test_session_unknown_api_key` when forwarding `X-Test-Session` to XCiteDB.
771
+ *
772
+ * Each entry must be a valid production API key on the project hosting the session — otherwise
773
+ * the server returns `400 additional_key_invalid` (with the offending index) and aborts.
774
+ */
775
+ additionalKeys?: string[];
776
+ /** Optional server-side bootstrap (user isolation, developer_bypass, policies, triggers). */
753
777
  bootstrap?: TestSessionBootstrap;
754
778
  /**
755
779
  * Test-session auth fidelity mode (`X-Test-Auth` header on the returned client).
@@ -172,4 +172,92 @@ wd('user isolation (wet)', () => {
172
172
  await admin.disableUserIsolation().catch(() => { });
173
173
  }
174
174
  });
175
+ (0, node_test_1.it)('user-workspace branch: invitee reads owner-namespaced docs through self-namespace API', async () => {
176
+ // Issue B repro: when user B is granted readwrite on user A's user
177
+ // workspace and reads a doc on the workspace branch, the SDK must
178
+ // namespace the path with A's user_id (parsed from the `_uw/<owner>/<slug>`
179
+ // branch), not B's. Server-side prefixIdentifier already passes the
180
+ // identifier through unchanged for workspace members; the SDK must put
181
+ // the owner's namespace on the path before sending.
182
+ const e = wetEnv();
183
+ if (!e)
184
+ throw new Error('missing env');
185
+ const admin = adminClient(e);
186
+ const suffix = (0, node_crypto_1.randomUUID)().slice(0, 8);
187
+ const emailA = `js_uwA_${suffix}@apitest.invalid`;
188
+ const emailB = `js_uwB_${suffix}@apitest.invalid`;
189
+ const password = `Js_${suffix}!aA1`;
190
+ const slug = `js-uw-doc-${suffix}`;
191
+ const wsName = `shared-${suffix}`;
192
+ let userA;
193
+ let userB;
194
+ let workspaceId;
195
+ let workspaceBranch;
196
+ try {
197
+ await admin.setUserIsolationConfig({
198
+ enabled: true,
199
+ namespace_pattern: '/users/${user.id}',
200
+ });
201
+ userA = await admin.createAppUser(emailA, password, undefined, [
202
+ client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
203
+ ]);
204
+ userB = await admin.createAppUser(emailB, password, undefined, [
205
+ client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
206
+ ]);
207
+ const appA = new client_js_1.XCiteDBClient({
208
+ baseUrl: e.baseUrl,
209
+ context: { branch: 'main', project_id: e.tenantId },
210
+ userIsolation: { enabled: true },
211
+ });
212
+ await appA.loginAppUser(emailA, password);
213
+ // A creates the workspace, captures the branch from the response,
214
+ // grants B readwrite, and writes a doc on the workspace branch.
215
+ const ws = (await appA.createUserWorkspace(wsName));
216
+ strict_1.default.ok(ws.id, 'workspace id missing');
217
+ strict_1.default.ok(ws.branch && ws.branch.startsWith('_uw/'), 'workspace branch missing');
218
+ workspaceId = ws.id;
219
+ workspaceBranch = ws.branch;
220
+ strict_1.default.equal(workspaceBranch, `_uw/${userA.user_id}/${slugifyForAssert(wsName)}`, 'branch should be _uw/<ownerId>/<slug>');
221
+ await appA.addUserWorkspaceGrant(workspaceId, { user_id: userB.user_id, mode: 'rw' });
222
+ appA.setContext({ branch: workspaceBranch });
223
+ await appA.writeJsonDocument(`/${slug}`, { _xcite_json_doc: true, who: 'A', v: 1 });
224
+ // B opens the workspace branch and reads the doc using the relative
225
+ // identifier — must resolve to A's namespace, not B's.
226
+ const appB = new client_js_1.XCiteDBClient({
227
+ baseUrl: e.baseUrl,
228
+ context: { branch: workspaceBranch, project_id: e.tenantId },
229
+ userIsolation: { enabled: true },
230
+ });
231
+ await appB.loginAppUser(emailB, password);
232
+ const doc = await appB.readJsonDocument(`/${slug}`);
233
+ strict_1.default.equal(doc.who, 'A');
234
+ strict_1.default.equal(doc.v, 1);
235
+ // List on the workspace branch — identifiers must come back stripped
236
+ // of the owner's namespace (isoUnprefixId symmetry).
237
+ const list = await appB.listJsonDocuments(`/${slug.split('-')[0]}`);
238
+ const ids = (list.identifiers ?? []);
239
+ strict_1.default.ok(ids.includes(`/${slug}`), `expected response to include /${slug}, got ${JSON.stringify(ids)}`);
240
+ }
241
+ finally {
242
+ if (workspaceId) {
243
+ await admin
244
+ .deleteJsonDocument(`/users/${userA?.user_id ?? ''}/${slug}`)
245
+ .catch(() => { });
246
+ await admin.deleteUserWorkspace(workspaceId).catch(() => { });
247
+ }
248
+ if (userB)
249
+ await admin.deleteAppUser(userB.user_id).catch(() => { });
250
+ if (userA)
251
+ await admin.deleteAppUser(userA.user_id).catch(() => { });
252
+ await admin.disableUserIsolation().catch(() => { });
253
+ }
254
+ });
175
255
  });
256
+ // Mirrors UserWorkspaceService::slugify (lowercase, runs of non-alphanumerics → '-', trim '-').
257
+ function slugifyForAssert(name) {
258
+ return name
259
+ .toLowerCase()
260
+ .replace(/[^a-z0-9]+/g, '-')
261
+ .replace(/^-+|-+$/g, '')
262
+ .slice(0, 64);
263
+ }
package/llms-full.txt CHANGED
@@ -377,6 +377,58 @@ TEST_CASE("XciteDB XML document round-trip") {
377
377
  }
378
378
  ```
379
379
 
380
+ ## Overlay sessions with a backend-for-frontend (BFF)
381
+
382
+ When a single-page app is in an overlay test session and an admin-scoped action is delegated to a server-side BFF (e.g. group grants, project-editor enrolment, anything the SPA cannot do with its own credentials), the BFF must route through the **same overlay** — otherwise admin writes land in production while the SPA still reads from the overlay, and the SPA sees stale state.
383
+
384
+ The mechanism is the same `X-Test-Session` header used for SPA requests, plus pre-registering the BFF's admin key on the session at create time so the server accepts it.
385
+
386
+ 1. **Pre-register the BFF admin key** when the SPA creates the session (SDK option `additionalKeys`, wire field `additional_keys`):
387
+
388
+ ```typescript
389
+ const client = await XCiteDBClient.createTestSession({
390
+ baseUrl, apiKey: SPA_PUBLIC_KEY,
391
+ overlay: true,
392
+ additionalKeys: [BFF_ADMIN_KEY], // snapshotted into the session config
393
+ bootstrap: { user_isolation: { /* ... */ } },
394
+ });
395
+ ```
396
+
397
+ Each entry is a production API key the BFF will use. The server snapshots each into the session's config DB so requests carrying that key alongside `X-Test-Session: <token>` authenticate against the session, not production. Without this, the BFF gets `401 test_session_unknown_api_key`. An invalid key (one not present in production for the session's project) returns `400 additional_key_invalid` with the offending array index, and the session is rolled back.
398
+
399
+ 2. **SPA forwards `X-Test-Session` to the BFF** as a normal request header:
400
+
401
+ ```typescript
402
+ const r = await fetch('/api/app/grant-editor', {
403
+ method: 'POST',
404
+ headers: { 'X-Test-Session': client.testSessionToken ?? '' },
405
+ body: JSON.stringify({ user_id: u.user_id }),
406
+ });
407
+ ```
408
+
409
+ 3. **BFF reads the inbound header and propagates it on its outbound XCiteDB call.** With the JS SDK, instantiate a per-request admin client carrying the test-session token — don't reuse a long-lived admin client, since the same BFF process serves both real and test traffic:
410
+
411
+ ```typescript
412
+ app.post('/api/app/grant-editor', async (req, res) => {
413
+ const testSession = req.header('X-Test-Session') || undefined;
414
+ const admin = new XCiteDBClient({
415
+ baseUrl: process.env.XCITEDB_URL!,
416
+ apiKey: process.env.XCITEDB_BFF_KEY!, // == BFF_ADMIN_KEY
417
+ testSessionToken: testSession, // undefined ⇒ hits production as today
418
+ });
419
+ await admin.updateAppUserGroups(req.body.user_id, [
420
+ XCiteDBClient.buildProjectGroup(process.env.XCITEDB_PROJECT_ID!, 'editor'),
421
+ ]);
422
+ res.status(204).end();
423
+ });
424
+ ```
425
+
426
+ The same shape works in any HTTP framework — the only requirement is propagating `X-Test-Session` from inbound to outbound. Production traffic doesn't carry the header, so `testSessionToken` falls back to `undefined` and the admin client behaves as before.
427
+
428
+ **Verification.** With this wired up, an end-to-end test that registers an app user, calls the BFF endpoint, then reads the user's groups through the SPA's overlay client should see the freshly-granted group on the next read. Without the propagation, the read returns the user's groups as they were at session creation (typically empty) and admin-gated routes return `403`.
429
+
430
+ The behavior is bolted to two server pieces: `additional_keys` snapshotting in `TestController::create` (`src/controllers/TestController.cpp`) and the test-session header dispatch in `AuthFilterShared` (`src/filters/AuthFilterShared.cpp`) — there's no separate "attach admin client to existing session" API; the snapshot + header pair is sufficient.
431
+
380
432
  ---
381
433
 
382
434
  # Health, version & discovery
package/llms.txt CHANGED
@@ -138,11 +138,23 @@ SDKs surface these fields on typed errors (e.g. `XCiteDBForbiddenError.policyId`
138
138
  "bootstrap": {
139
139
  "user_isolation": { "enabled": true, "namespace_pattern": "/spaces/${user.id}" },
140
140
  "developer_bypass": true,
141
- "policies": []
141
+ "policies": [],
142
+ "triggers": [
143
+ {
144
+ "trigger_id": "audit-writes",
145
+ "trigger": {
146
+ "event": "meta_changed",
147
+ "match": { "identifiers": [{ "match_start": "/" }] },
148
+ "action": { "unquery": { "n": "$count" }, "target_identifier": "/_audit", "meta_path": "n", "mode": "set" }
149
+ }
150
+ }
151
+ ]
142
152
  }
143
153
  }
144
154
  ```
145
155
 
156
+ `bootstrap.triggers` installs per-test triggers into the synthetic test tenant only — the project's global trigger registry (`/_xcitedb/triggers`) is never touched. Each entry is `{ trigger_id, trigger }` (same shape as `upsertTrigger`); validation errors abort with `400 bootstrap_trigger_invalid`. The summary echoes back `triggers_created: string[]`.
157
+
146
158
  If you skip the bootstrap, an app-user JWT cannot read or write `/spaces/<userId>/...` — even paths the user "owns" — and you will see 403 with reason `tenant_security_unconfigured`.
147
159
 
148
160
  **SDK one-liners for bootstrap:** **JS:** `XCiteDBClient.createTestSession({ baseUrl, apiKey, testRequireAuth: true, bootstrap: { user_isolation: { enabled: true, namespace_pattern: '/spaces/${user.id}' }, developer_bypass: true } })`. **Python:** `async with XCiteDBClient.test_session(base_url, api_key=sk, test_require_auth=True, bootstrap={...}) as c:`. **C++:** set `options.test_session_bootstrap = nlohmann::json::parse(R"(...)");` then `XCiteDBClient::create_test_session(options)`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcitedbs/client",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -85,7 +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
+ | `[a:b]` | Array **slice** in a context modifier or path expression (half-open; negative counts from end) | `"scores:[0:3]"`, `"scores[2:5]"`, `"$size(scores[:3])"` |
89
89
  | `/Field` | Absolute from document root | `"/employees.$(.)"` |
90
90
  | `../Field` | Up one path level (skips array indices) | `"../DBInstanceIdentifier"` |
91
91
  | `<<Field` | Read same field in *previous* document context | `"<<Field1"` after a `->$file(...)` |
@@ -710,6 +710,34 @@ Allowed only against literals (see §0.8 and §4.8):
710
710
 
711
711
  Each recipe shows a small input-shape sketch, the Unquery template, and (where useful) the equivalent jq one-liner for cross-reference.
712
712
 
713
+ ### 10.0 Materializing many rows from a prefix walk — pick a multi-row shape
714
+
715
+ Common gotcha for prefix-walk queries (`{ match_start: '/index/users/' }` and similar) feeding a search dropdown or candidate list: if the template root is a **single object**, every document iteration *overwrites* the same fields and you keep only the **last** match's values.
716
+
717
+ `{ "name": "name", "email": "email" }` — *wrong* for multi-row output. Returns one document's fields, not a list. (See §1's "evaluate once vs. per pass".)
718
+
719
+ Three correct shapes for "give me one row per matching document":
720
+
721
+ **Array of objects** (most common — preserves order, easy to iterate client-side):
722
+
723
+ ```json
724
+ [{ "id": "$identifier", "name": "name", "email": "email" }]
725
+ ```
726
+
727
+ **Map keyed by identifier** (constant-time lookup by id, no duplicates):
728
+
729
+ ```json
730
+ { "$($identifier)": { "name": "name", "email": "email" } }
731
+ ```
732
+
733
+ **Array of identifiers only** (just need the keys, e.g. for a count or follow-up reads):
734
+
735
+ ```json
736
+ ["$identifier"]
737
+ ```
738
+
739
+ The bare-object form is correct *only* when the prefix narrows to exactly one document or you genuinely want "last match wins" (e.g. picking a single config row). For dropdowns, search results, member lists, or any "show me everything matching" use case, default to the array-of-objects shape.
740
+
713
741
  ### 10.1 Pick one field per document → array
714
742
 
715
743
  ```json
@@ -128,7 +128,7 @@ Climbing parser levels:
128
128
  |--------|-----------|------------|
129
129
  | 0 | `+`, `-` | Left via loop: each new rhs parsed with `expression(1)`. |
130
130
  | 1 | `*`, `/`, `mod` | Left; rhs parsed with `expression(2)`. |
131
- | 3 | Postfix `.token` and `[ expr ]` | Postfix chain on current `res`. |
131
+ | 3 | Postfix `.token`, `[ expr ]`, `[ slice ]` | Postfix chain on current `res`. |
132
132
 
133
133
  Parentheses: `(` `expression(0)` `)` as primary.
134
134
 
@@ -138,12 +138,13 @@ Parentheses: `(` `expression(0)` `)` as primary.
138
138
  expression ::= '(' expression ')'
139
139
  | baseExpression ( ( '+' | '-' ) expression(1)
140
140
  | ( '*' | '/' | 'mod' ) expression(2) )*
141
- | baseExpression ( '.' fieldSegment | '[' expression ']' )*
141
+ | baseExpression ( '.' fieldSegment | '[' expression ']' | '[' slice ']' )*
142
142
 
143
+ slice ::= INT? ':' INT? (* INT may be `-` INT for negative-from-end *)
143
144
  fieldSegment ::= IDENT | BACKTICK_STR | '$' IDENT ... (* lexer token after '.'; special case: if token starts with '$', subfield parse backs up — see pathWithBrackets *)
144
145
  ```
145
146
 
146
- **Postfix** (prec ≤ 3): after `consume()` of `.` or `[`, if `[` then parse `expression(0)` unless next is `]`; require `]`; build `TExprSubfield(res, expr_or_empty, is_index)`.
147
+ **Postfix** (prec ≤ 3): after `consume()` of `.` or `[`, if `[` then either (a) parse a slice `INT? ':' INT?` followed by `]` → `TExprSlice(res, …)`, or (b) parse `expression(0)` followed by `]` `TExprSubfield(res, expr_or_empty, is_index)`. Empty `[]` builds `TExprSubfield` with no expression. Slice detection uses backtracking: a single integer with no `:` falls through to the expression form.
147
148
 
148
149
  ### 4.3 `baseExpression` — alternatives (first token disambiguation)
149
150
 
@@ -151,7 +152,8 @@ Roughly in parse order:
151
152
 
152
153
  | Prefix / form | AST | Notes |
153
154
  |-----------------|-----|--------|
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
+ | `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index. |
156
+ | `[` (INT)? `:` (INT)? `]` | `TExprSlice(TExprField("."), …)` | Half-open array slice on `.`. Negative ints count from end. Bounds must be integer literals (with optional `-`). Yields a JSON array. |
155
157
  | `$` `(` `expression` `)` | `TExprField(expr)` | Evaluate / path from dynamic name (`$()`). |
156
158
  | `$if` `(` `condition` `,` `expression` `,` `expression` `)` | `TExprITE` | |
157
159
  | `$call` `(` IDENT `)` | `TExprCall(name)` | Zero-arg user function by name. |