@xcitedbs/client 0.2.26 → 0.3.0

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
@@ -15,7 +15,12 @@ export declare class XCiteDBClient {
15
15
  private onAppUserTokensUpdated?;
16
16
  private onSessionInvalid?;
17
17
  private testSessionToken?;
18
- private testRequireAuth?;
18
+ /**
19
+ * Test-session auth fidelity. `'bypass'` is the legacy mode that synthesizes a developer-admin
20
+ * identity server-side; `'required'` and `'preserve'` send `X-Test-Auth: required|preserve`.
21
+ * `createTestSession` defaults to `'required'`.
22
+ */
23
+ private testAuthMode?;
19
24
  private userIsolation?;
20
25
  private cachedAppUserId?;
21
26
  private readonly requestTimeoutMs?;
@@ -24,7 +29,14 @@ export declare class XCiteDBClient {
24
29
  constructor(options: XCiteDBClientOptions);
25
30
  /**
26
31
  * Create an ephemeral test database: calls `POST /api/v1/test/sessions` with your API key or Bearer,
27
- * then returns a client that sends `X-Test-Session` (auth-free by default).
32
+ * then returns a client that sends `X-Test-Session` on every subsequent request.
33
+ *
34
+ * **Auth fidelity (default = `'required'`):** the SDK now defaults to faithful auth so wet tests
35
+ * exercise the same role/credential gates as production. Pass `testAuth: 'preserve'` to keep
36
+ * faithful auth but skip ABAC bootstrapping (real auth, lenient policies). The legacy
37
+ * `'bypass'` mode (server synthesizes a developer-admin identity) is available but not the
38
+ * default — it erases the public-key context and produces tests that pass when production fails.
39
+ *
28
40
  * With `opts.overlay === true`, the server stores overlay mode: reads merge the empty `_test/...` LMDB
29
41
  * over the current project's production data (read-only base); writes stay under `_test/...` only.
30
42
  */
@@ -147,17 +159,43 @@ export declare class XCiteDBClient {
147
159
  createApiKey(name: string, expiresAt?: number, keyType?: 'secret' | 'public'): Promise<unknown>;
148
160
  changePassword(currentPassword: string, newPassword: string): Promise<void>;
149
161
  revokeApiKey(keyId: string): Promise<void>;
162
+ /**
163
+ * Self-register an app user. Public endpoint — set `context.project_id` on the client first.
164
+ *
165
+ * **Side effect:** clears any cached `appUserAccessToken` / `appUserRefreshToken` before the
166
+ * request. Without this, a previously logged-in client would send `Authorization: Bearer
167
+ * <stale-token>`, which the server (correctly) rejects with `reason: "already_authenticated"`.
168
+ * The clear runs before the request fires, so even a 4xx response leaves the client in a clean
169
+ * unauthenticated state — a fresh `loginAppUser` call works without manual `clearAppUserTokens`.
170
+ */
150
171
  registerAppUser(email: string, password: string, displayName?: string, attributes?: Record<string, unknown>): Promise<AppUser>;
151
172
  getOAuthProviders(): Promise<OAuthProvidersResponse>;
152
173
  /** Relative path + query for browser navigation to start OAuth (append to API base URL). */
153
174
  oauthAuthorizePath(provider: string): string;
154
- /** Exchange one-time session code from OAuth browser redirect (public + tenant_id). */
175
+ /**
176
+ * Exchange one-time session code from OAuth browser redirect (public + tenant_id).
177
+ * Clears any cached app-user tokens before the request — see `loginAppUser` for rationale.
178
+ */
155
179
  exchangeOAuthCode(code: string): Promise<AppUserTokenPair>;
180
+ /**
181
+ * Sign in as an app user and **store** the issued access/refresh tokens on this client. Subsequent
182
+ * requests automatically send `Authorization: Bearer <access>` (or `X-App-User-Token` when paired
183
+ * with a developer key/JWT). Call `clearAppUserTokens()` (or `logoutAppUser()`) to drop them.
184
+ *
185
+ * **Side effect:** clears any cached `appUserAccessToken` / `appUserRefreshToken` before the
186
+ * request. Without this, a previously logged-in client would send `Authorization: Bearer
187
+ * <stale-token>` and the server would reject with `reason: "already_authenticated"`. The clear
188
+ * runs before the request fires, so re-logging in as a different user just works.
189
+ */
156
190
  loginAppUser(email: string, password: string): Promise<AppUserTokenPair>;
157
191
  refreshAppUser(): Promise<AppUserTokenPair>;
158
192
  logoutAppUser(): Promise<void>;
159
193
  appUserMe(): Promise<AppUser>;
160
194
  updateAppUserProfile(displayName?: string, attributes?: Record<string, unknown>): Promise<AppUser>;
195
+ /**
196
+ * Exchange a custom JWT for an app-user session.
197
+ * Clears any cached app-user tokens before the request — see `loginAppUser` for rationale.
198
+ */
161
199
  exchangeCustomToken(token: string): Promise<AppUserTokenPair>;
162
200
  /** Change app-user password (requires valid app-user access token). */
163
201
  changeAppUserPassword(currentPassword: string, newPassword: string): Promise<void>;
@@ -168,6 +206,24 @@ export declare class XCiteDBClient {
168
206
  /** Issue email verification token (developer-authenticated). Token omitted when delivery is smtp/webhook success. */
169
207
  sendAppUserVerification(userId: string): Promise<SendVerificationResponse>;
170
208
  getAppAuthConfig(): Promise<AppAuthConfig>;
209
+ /**
210
+ * Patch `auth.app_users.*` (PUT /api/v1/app/auth/config). Requires admin developer auth.
211
+ *
212
+ * Whitelisted keys: `enabled`, `registration_enabled`, `default_groups`,
213
+ * `require_email_verification`, `min_password_length`, `max_login_attempts`,
214
+ * `lockout_duration_seconds`, `access_token_expiry_seconds`, `refresh_token_expiry_seconds`,
215
+ * `reset_token_expiry_seconds`, `verification_token_expiry_seconds`, `public_base_url`.
216
+ *
217
+ * Secret-bearing fields (`jwt_secret`, `signing_key_path`, `custom_token_secret`,
218
+ * `oauth_providers`) are intentionally not patchable here — set them in server config and reload.
219
+ * Returns the full effective config including any `warnings`.
220
+ *
221
+ * @example Bootstrap a fresh project so registered users land in the editor group:
222
+ * ```ts
223
+ * await client.updateAppAuthConfig({ default_groups: ['editor'] });
224
+ * ```
225
+ */
226
+ updateAppAuthConfig(patch: Partial<AppAuthConfig>): Promise<AppAuthConfig>;
171
227
  getEmailConfig(): Promise<AppEmailConfig>;
172
228
  updateEmailConfig(config: AppEmailConfig): Promise<AppEmailConfig>;
173
229
  getEmailTemplates(): Promise<AppEmailTemplates>;
@@ -312,15 +368,31 @@ export declare class XCiteDBClient {
312
368
  updateSecurityConfig(config: Partial<SecurityConfig>): Promise<void>;
313
369
  /** Per-tenant user data spaces (`GET /api/v1/security/user-isolation`). Requires security admin. */
314
370
  getUserIsolationConfig(): Promise<UserIsolationConfig>;
371
+ /**
372
+ * Public-read sibling (`GET /api/v1/security/user-isolation/public`). Returns only the
373
+ * client-prefix-relevant fields (enabled, namespace_pattern, shared_*_paths). Lets SPAs
374
+ * configure isolation without admin auth.
375
+ */
376
+ getUserIsolationConfigPublic(): Promise<UserIsolationConfig>;
315
377
  /** Enable or reconfigure user isolation (`PUT /api/v1/security/user-isolation`). */
316
378
  setUserIsolationConfig(config: Partial<UserIsolationConfig>): Promise<UserIsolationConfig>;
317
379
  /** Disable user isolation and remove generated policies (`DELETE /api/v1/security/user-isolation`). */
318
380
  disableUserIsolation(): Promise<void>;
319
381
  /**
320
- * Loads server isolation config; when enabled, configures client-side identifier prefixing to match
321
- * the server (namespace + shared paths). Does not send `X-Prefix`; identifiers in requests are rewritten.
322
- */
323
- enableUserIsolation(): Promise<UserIsolationConfig>;
382
+ * Configure client-side identifier prefixing to match server-side user isolation. Does not send
383
+ * `X-Prefix`; identifiers in requests are rewritten by the SDK.
384
+ *
385
+ * Two forms:
386
+ * 1. **No-arg**: fetches the public read-only endpoint (`/api/v1/security/user-isolation/public`),
387
+ * falling back to the admin endpoint on 404 (older servers). No admin auth needed in SPAs.
388
+ * 2. **Explicit config**: pass `{ namespace, shared_read_paths?, shared_write_paths? }` to skip
389
+ * the network round-trip entirely. Useful when the BFF already knows the configuration.
390
+ */
391
+ enableUserIsolation(config?: {
392
+ namespace?: string;
393
+ shared_read_paths?: string[];
394
+ shared_write_paths?: string[];
395
+ }): Promise<UserIsolationConfig>;
324
396
  /**
325
397
  * Share a document from the caller’s user namespace with another app user (`POST …/user-isolation/shares`).
326
398
  * Requires an **app user** session (`appUserAccessToken` / `loginAppUser`) and tenant isolation enabled.
@@ -443,7 +515,13 @@ export declare class XCiteDBClient {
443
515
  listUserWorkspaces(): Promise<{
444
516
  user_workspaces: Record<string, unknown>[];
445
517
  }>;
446
- /** Create a user workspace (`POST /api/v1/user-workspaces`). Returns the workspace record (top-level JSON). */
518
+ /**
519
+ * Create a user workspace (`POST /api/v1/user-workspaces`). Returns the workspace record (top-level JSON).
520
+ *
521
+ * `sourceBranch` defaults to the root timeline (`""`) — works on a fresh project with no other
522
+ * workspaces. `"main"` is normalized to `""` server-side; the persisted record stores `""`. If
523
+ * you pass an explicit non-existent `sourceBranch`, the server returns 400 with the offending name.
524
+ */
447
525
  createUserWorkspace(name: string, options?: {
448
526
  sourceBranch?: string;
449
527
  sourceDate?: string;
@@ -566,17 +644,38 @@ export declare class XCiteDBClient {
566
644
  listIdentifierChildren(parentPath?: string): Promise<ListIdentifierChildrenResult>;
567
645
  queryLog(query: XCiteQuery, fromDate: string, toDate: string): Promise<LogEntry[]>;
568
646
  addMeta(identifier: string, value: unknown, path?: string, opts?: {
569
- mode?: 'set' | 'append';
647
+ mode?: 'set' | 'append' | 'merge_append';
570
648
  overwrite?: boolean;
571
649
  }): Promise<boolean>;
572
650
  addMetaByQuery(query: XCiteQuery, value: unknown, path?: string, firstMatch?: boolean, opts?: {
573
- mode?: 'set' | 'append';
651
+ mode?: 'set' | 'append' | 'merge_append';
652
+ overwrite?: boolean;
653
+ }): Promise<boolean>;
654
+ /**
655
+ * Strict array-extend: `value` must be an array; the stored target at `path` must be an array
656
+ * (or absent). Server returns 400 `append_payload_not_array` or 409 `append_target_not_array`
657
+ * otherwise. To merge an object payload (recursing into nested arrays), use {@link mergeAppendMeta}.
658
+ * To push a single item, use {@link appendItem}.
659
+ */
660
+ appendMeta(identifier: string, value: unknown[], path?: string, opts?: {
661
+ overwrite?: boolean;
662
+ }): Promise<boolean>;
663
+ appendMetaByQuery(query: XCiteQuery, value: unknown[], path?: string, firstMatch?: boolean, opts?: {
664
+ overwrite?: boolean;
665
+ }): Promise<boolean>;
666
+ /**
667
+ * Deep-extend: walks the payload and extends any nested arrays found in storage. Scalars and
668
+ * non-array objects in the payload are written as `set` at their respective paths. Use this
669
+ * when you intentionally want recursive array-extend; otherwise prefer {@link appendMeta}.
670
+ */
671
+ mergeAppendMeta(identifier: string, value: unknown, path?: string, opts?: {
574
672
  overwrite?: boolean;
575
673
  }): Promise<boolean>;
576
- appendMeta(identifier: string, value: unknown, path?: string, opts?: {
674
+ mergeAppendMetaByQuery(query: XCiteQuery, value: unknown, path?: string, firstMatch?: boolean, opts?: {
577
675
  overwrite?: boolean;
578
676
  }): Promise<boolean>;
579
- appendMetaByQuery(query: XCiteQuery, value: unknown, path?: string, firstMatch?: boolean, opts?: {
677
+ /** Convenience: push a single item. Wraps `item` in `[item]` and calls {@link appendMeta}. */
678
+ appendItem(identifier: string, item: unknown, path?: string, opts?: {
580
679
  overwrite?: boolean;
581
680
  }): Promise<boolean>;
582
681
  queryMeta<T = MetaValue>(identifier: string, path?: string): Promise<T>;
package/dist/client.js CHANGED
@@ -90,6 +90,27 @@ function warnIfHttpOnTlsPort(baseUrl) {
90
90
  /* ignore invalid baseUrl */
91
91
  }
92
92
  }
93
+ /**
94
+ * Resolve the test-session auth mode from the new `testAuth` option and the deprecated
95
+ * `testRequireAuth` boolean. Used by both the {@link XCiteDBClient} constructor and
96
+ * {@link XCiteDBClient.createTestSession}. Returns `undefined` when the client is not in a
97
+ * test session at all (no `testSessionToken`).
98
+ *
99
+ * Precedence:
100
+ * - If `testAuth` is set, use it directly.
101
+ * - Else if `testRequireAuth === true`, use `'required'`.
102
+ * - Else if `testRequireAuth === false`, use `'bypass'` (legacy explicit opt-out).
103
+ * - Else `undefined` — caller decides default. `createTestSession` defaults to `'required'`.
104
+ */
105
+ function resolveTestAuthMode(testAuth, testRequireAuth) {
106
+ if (testAuth !== undefined)
107
+ return testAuth;
108
+ if (testRequireAuth === true)
109
+ return 'required';
110
+ if (testRequireAuth === false)
111
+ return 'bypass';
112
+ return undefined;
113
+ }
93
114
  /** Uses `AbortSignal.timeout` when the runtime supports it. */
94
115
  function requestTimeoutSignal(ms) {
95
116
  if (ms === undefined || ms <= 0)
@@ -113,14 +134,21 @@ class XCiteDBClient {
113
134
  this.onAppUserTokensUpdated = options.onAppUserTokensUpdated;
114
135
  this.onSessionInvalid = options.onSessionInvalid;
115
136
  this.testSessionToken = options.testSessionToken;
116
- this.testRequireAuth = options.testRequireAuth === true;
137
+ this.testAuthMode = resolveTestAuthMode(options.testAuth, options.testRequireAuth);
117
138
  this.userIsolation = options.userIsolation;
118
139
  this.requestTimeoutMs = options.requestTimeoutMs;
119
140
  warnIfHttpOnTlsPort(this.baseUrl);
120
141
  }
121
142
  /**
122
143
  * Create an ephemeral test database: calls `POST /api/v1/test/sessions` with your API key or Bearer,
123
- * then returns a client that sends `X-Test-Session` (auth-free by default).
144
+ * then returns a client that sends `X-Test-Session` on every subsequent request.
145
+ *
146
+ * **Auth fidelity (default = `'required'`):** the SDK now defaults to faithful auth so wet tests
147
+ * exercise the same role/credential gates as production. Pass `testAuth: 'preserve'` to keep
148
+ * faithful auth but skip ABAC bootstrapping (real auth, lenient policies). The legacy
149
+ * `'bypass'` mode (server synthesizes a developer-admin identity) is available but not the
150
+ * default — it erases the public-key context and produces tests that pass when production fails.
151
+ *
124
152
  * With `opts.overlay === true`, the server stores overlay mode: reads merge the empty `_test/...` LMDB
125
153
  * over the current project's production data (read-only base); writes stay under `_test/...` only.
126
154
  */
@@ -146,12 +174,14 @@ class XCiteDBClient {
146
174
  if (opts.bootstrap !== undefined)
147
175
  sessionBody.bootstrap = opts.bootstrap;
148
176
  const data = await temp.request('POST', '/api/v1/test/sessions', Object.keys(sessionBody).length ? sessionBody : undefined, undefined, { no401Retry: true });
177
+ const mode = resolveTestAuthMode(opts.testAuth, opts.testRequireAuth) ?? 'required';
178
+ const keepCreds = mode !== 'bypass';
149
179
  const child = new XCiteDBClient({
150
180
  baseUrl: opts.baseUrl,
151
- apiKey: opts.testRequireAuth ? opts.apiKey : undefined,
152
- accessToken: opts.testRequireAuth ? opts.accessToken : undefined,
153
- appUserAccessToken: opts.testRequireAuth ? opts.appUserAccessToken : undefined,
154
- appUserRefreshToken: opts.testRequireAuth ? opts.appUserRefreshToken : undefined,
181
+ apiKey: keepCreds ? opts.apiKey : undefined,
182
+ accessToken: keepCreds ? opts.accessToken : undefined,
183
+ appUserAccessToken: keepCreds ? opts.appUserAccessToken : undefined,
184
+ appUserRefreshToken: keepCreds ? opts.appUserRefreshToken : undefined,
155
185
  context: opts.context,
156
186
  platformConsole: opts.platformConsole,
157
187
  projectId: opts.projectId,
@@ -159,7 +189,7 @@ class XCiteDBClient {
159
189
  onAppUserTokensUpdated: opts.onAppUserTokensUpdated,
160
190
  onSessionInvalid: opts.onSessionInvalid,
161
191
  testSessionToken: data.session_token,
162
- testRequireAuth: opts.testRequireAuth,
192
+ testAuth: mode,
163
193
  userIsolation: opts.userIsolation,
164
194
  requestTimeoutMs: opts.requestTimeoutMs,
165
195
  });
@@ -536,8 +566,10 @@ class XCiteDBClient {
536
566
  const h = {};
537
567
  if (this.testSessionToken) {
538
568
  h['X-Test-Session'] = this.testSessionToken;
539
- if (this.testRequireAuth) {
540
- h['X-Test-Auth'] = 'required';
569
+ // 'required' / 'preserve' send the header; 'bypass' (or undefined) omits it so the server
570
+ // applies the legacy synthesized-admin behavior.
571
+ if (this.testAuthMode === 'required' || this.testAuthMode === 'preserve') {
572
+ h['X-Test-Auth'] = this.testAuthMode;
541
573
  }
542
574
  }
543
575
  return h;
@@ -868,7 +900,17 @@ class XCiteDBClient {
868
900
  await this.request('DELETE', `/api/v1/project/keys/${encodeURIComponent(keyId)}`);
869
901
  }
870
902
  // --- App user auth (requires developer API key or JWT on the same tenant) ---
903
+ /**
904
+ * Self-register an app user. Public endpoint — set `context.project_id` on the client first.
905
+ *
906
+ * **Side effect:** clears any cached `appUserAccessToken` / `appUserRefreshToken` before the
907
+ * request. Without this, a previously logged-in client would send `Authorization: Bearer
908
+ * <stale-token>`, which the server (correctly) rejects with `reason: "already_authenticated"`.
909
+ * The clear runs before the request fires, so even a 4xx response leaves the client in a clean
910
+ * unauthenticated state — a fresh `loginAppUser` call works without manual `clearAppUserTokens`.
911
+ */
871
912
  async registerAppUser(email, password, displayName, attributes) {
913
+ this.clearAppUserTokens();
872
914
  const body = { email, password };
873
915
  if (displayName !== undefined)
874
916
  body.display_name = displayName;
@@ -887,15 +929,30 @@ class XCiteDBClient {
887
929
  const tid = raw && String(raw).length > 0 ? String(raw) : 'default';
888
930
  return `/api/v1/app/auth/oauth/${encodeURIComponent(provider)}/authorize${buildQuery({ tenant_id: tid })}`;
889
931
  }
890
- /** Exchange one-time session code from OAuth browser redirect (public + tenant_id). */
932
+ /**
933
+ * Exchange one-time session code from OAuth browser redirect (public + tenant_id).
934
+ * Clears any cached app-user tokens before the request — see `loginAppUser` for rationale.
935
+ */
891
936
  async exchangeOAuthCode(code) {
937
+ this.clearAppUserTokens();
892
938
  const pair = await this.request('POST', '/api/v1/app/auth/oauth/exchange', this.mergeAppTenant({ code }));
893
939
  this.appUserAccessToken = pair.access_token;
894
940
  this.appUserRefreshToken = pair.refresh_token;
895
941
  this.cacheAppUserIdFromPair(pair);
896
942
  return pair;
897
943
  }
944
+ /**
945
+ * Sign in as an app user and **store** the issued access/refresh tokens on this client. Subsequent
946
+ * requests automatically send `Authorization: Bearer <access>` (or `X-App-User-Token` when paired
947
+ * with a developer key/JWT). Call `clearAppUserTokens()` (or `logoutAppUser()`) to drop them.
948
+ *
949
+ * **Side effect:** clears any cached `appUserAccessToken` / `appUserRefreshToken` before the
950
+ * request. Without this, a previously logged-in client would send `Authorization: Bearer
951
+ * <stale-token>` and the server would reject with `reason: "already_authenticated"`. The clear
952
+ * runs before the request fires, so re-logging in as a different user just works.
953
+ */
898
954
  async loginAppUser(email, password) {
955
+ this.clearAppUserTokens();
899
956
  const pair = await this.request('POST', '/api/v1/app/auth/login', this.mergeAppTenant({ email, password }));
900
957
  this.appUserAccessToken = pair.access_token;
901
958
  this.appUserRefreshToken = pair.refresh_token;
@@ -922,7 +979,12 @@ class XCiteDBClient {
922
979
  body.attributes = attributes;
923
980
  return this.request('PUT', '/api/v1/app/auth/me', body);
924
981
  }
982
+ /**
983
+ * Exchange a custom JWT for an app-user session.
984
+ * Clears any cached app-user tokens before the request — see `loginAppUser` for rationale.
985
+ */
925
986
  async exchangeCustomToken(token) {
987
+ this.clearAppUserTokens();
926
988
  const pair = await this.request('POST', '/api/v1/app/auth/custom-token', { token });
927
989
  this.appUserAccessToken = pair.access_token;
928
990
  this.appUserRefreshToken = pair.refresh_token;
@@ -953,6 +1015,26 @@ class XCiteDBClient {
953
1015
  async getAppAuthConfig() {
954
1016
  return this.request('GET', '/api/v1/app/auth/config');
955
1017
  }
1018
+ /**
1019
+ * Patch `auth.app_users.*` (PUT /api/v1/app/auth/config). Requires admin developer auth.
1020
+ *
1021
+ * Whitelisted keys: `enabled`, `registration_enabled`, `default_groups`,
1022
+ * `require_email_verification`, `min_password_length`, `max_login_attempts`,
1023
+ * `lockout_duration_seconds`, `access_token_expiry_seconds`, `refresh_token_expiry_seconds`,
1024
+ * `reset_token_expiry_seconds`, `verification_token_expiry_seconds`, `public_base_url`.
1025
+ *
1026
+ * Secret-bearing fields (`jwt_secret`, `signing_key_path`, `custom_token_secret`,
1027
+ * `oauth_providers`) are intentionally not patchable here — set them in server config and reload.
1028
+ * Returns the full effective config including any `warnings`.
1029
+ *
1030
+ * @example Bootstrap a fresh project so registered users land in the editor group:
1031
+ * ```ts
1032
+ * await client.updateAppAuthConfig({ default_groups: ['editor'] });
1033
+ * ```
1034
+ */
1035
+ async updateAppAuthConfig(patch) {
1036
+ return this.request('PUT', '/api/v1/app/auth/config', patch);
1037
+ }
956
1038
  async getEmailConfig() {
957
1039
  return this.request('GET', '/api/v1/app/email/config');
958
1040
  }
@@ -1176,6 +1258,14 @@ class XCiteDBClient {
1176
1258
  async getUserIsolationConfig() {
1177
1259
  return this.request('GET', '/api/v1/security/user-isolation');
1178
1260
  }
1261
+ /**
1262
+ * Public-read sibling (`GET /api/v1/security/user-isolation/public`). Returns only the
1263
+ * client-prefix-relevant fields (enabled, namespace_pattern, shared_*_paths). Lets SPAs
1264
+ * configure isolation without admin auth.
1265
+ */
1266
+ async getUserIsolationConfigPublic() {
1267
+ return this.request('GET', '/api/v1/security/user-isolation/public');
1268
+ }
1179
1269
  /** Enable or reconfigure user isolation (`PUT /api/v1/security/user-isolation`). */
1180
1270
  async setUserIsolationConfig(config) {
1181
1271
  return this.request('PUT', '/api/v1/security/user-isolation', config);
@@ -1185,11 +1275,43 @@ class XCiteDBClient {
1185
1275
  await this.request('DELETE', '/api/v1/security/user-isolation');
1186
1276
  }
1187
1277
  /**
1188
- * Loads server isolation config; when enabled, configures client-side identifier prefixing to match
1189
- * the server (namespace + shared paths). Does not send `X-Prefix`; identifiers in requests are rewritten.
1278
+ * Configure client-side identifier prefixing to match server-side user isolation. Does not send
1279
+ * `X-Prefix`; identifiers in requests are rewritten by the SDK.
1280
+ *
1281
+ * Two forms:
1282
+ * 1. **No-arg**: fetches the public read-only endpoint (`/api/v1/security/user-isolation/public`),
1283
+ * falling back to the admin endpoint on 404 (older servers). No admin auth needed in SPAs.
1284
+ * 2. **Explicit config**: pass `{ namespace, shared_read_paths?, shared_write_paths? }` to skip
1285
+ * the network round-trip entirely. Useful when the BFF already knows the configuration.
1190
1286
  */
1191
- async enableUserIsolation() {
1192
- const cfg = await this.getUserIsolationConfig();
1287
+ async enableUserIsolation(config) {
1288
+ if (config && config.namespace) {
1289
+ this.userIsolation = {
1290
+ enabled: true,
1291
+ namespace: config.namespace,
1292
+ shared_read_paths: config.shared_read_paths,
1293
+ shared_write_paths: config.shared_write_paths,
1294
+ };
1295
+ return {
1296
+ enabled: true,
1297
+ namespace_pattern: config.namespace,
1298
+ shared_read_paths: config.shared_read_paths ?? [],
1299
+ shared_write_paths: config.shared_write_paths ?? [],
1300
+ };
1301
+ }
1302
+ let cfg;
1303
+ try {
1304
+ cfg = await this.getUserIsolationConfigPublic();
1305
+ }
1306
+ catch (err) {
1307
+ // Older servers don't expose the public endpoint — try the admin one.
1308
+ if (err instanceof types_1.XCiteDBError && err.status === 404) {
1309
+ cfg = await this.getUserIsolationConfig();
1310
+ }
1311
+ else {
1312
+ throw err;
1313
+ }
1314
+ }
1193
1315
  if (cfg.enabled) {
1194
1316
  this.userIsolation = {
1195
1317
  enabled: true,
@@ -1462,7 +1584,13 @@ class XCiteDBClient {
1462
1584
  async listUserWorkspaces() {
1463
1585
  return this.request('GET', '/api/v1/user-workspaces');
1464
1586
  }
1465
- /** Create a user workspace (`POST /api/v1/user-workspaces`). Returns the workspace record (top-level JSON). */
1587
+ /**
1588
+ * Create a user workspace (`POST /api/v1/user-workspaces`). Returns the workspace record (top-level JSON).
1589
+ *
1590
+ * `sourceBranch` defaults to the root timeline (`""`) — works on a fresh project with no other
1591
+ * workspaces. `"main"` is normalized to `""` server-side; the persisted record stores `""`. If
1592
+ * you pass an explicit non-existent `sourceBranch`, the server returns 400 with the offending name.
1593
+ */
1466
1594
  async createUserWorkspace(name, options) {
1467
1595
  const body = { name };
1468
1596
  if (options?.sourceBranch)
@@ -1800,8 +1928,8 @@ class XCiteDBClient {
1800
1928
  }
1801
1929
  async addMeta(identifier, value, path = '', opts) {
1802
1930
  const body = { identifier: this.isoPrefixId(identifier), value, path };
1803
- if (opts?.mode === 'append')
1804
- body.mode = 'append';
1931
+ if (opts?.mode === 'append' || opts?.mode === 'merge_append')
1932
+ body.mode = opts.mode;
1805
1933
  if (opts?.overwrite)
1806
1934
  body.overwrite = true;
1807
1935
  const r = await this.request('POST', '/api/v1/meta', body);
@@ -1814,19 +1942,40 @@ class XCiteDBClient {
1814
1942
  path,
1815
1943
  first_match: firstMatch,
1816
1944
  };
1817
- if (opts?.mode === 'append')
1818
- body.mode = 'append';
1945
+ if (opts?.mode === 'append' || opts?.mode === 'merge_append')
1946
+ body.mode = opts.mode;
1819
1947
  if (opts?.overwrite)
1820
1948
  body.overwrite = true;
1821
1949
  const r = await this.request('POST', '/api/v1/meta', body);
1822
1950
  return r?.ok !== false;
1823
1951
  }
1952
+ /**
1953
+ * Strict array-extend: `value` must be an array; the stored target at `path` must be an array
1954
+ * (or absent). Server returns 400 `append_payload_not_array` or 409 `append_target_not_array`
1955
+ * otherwise. To merge an object payload (recursing into nested arrays), use {@link mergeAppendMeta}.
1956
+ * To push a single item, use {@link appendItem}.
1957
+ */
1824
1958
  async appendMeta(identifier, value, path = '', opts) {
1825
1959
  return this.addMeta(identifier, value, path, { mode: 'append', ...opts });
1826
1960
  }
1827
1961
  async appendMetaByQuery(query, value, path = '', firstMatch = false, opts) {
1828
1962
  return this.addMetaByQuery(query, value, path, firstMatch, { mode: 'append', ...opts });
1829
1963
  }
1964
+ /**
1965
+ * Deep-extend: walks the payload and extends any nested arrays found in storage. Scalars and
1966
+ * non-array objects in the payload are written as `set` at their respective paths. Use this
1967
+ * when you intentionally want recursive array-extend; otherwise prefer {@link appendMeta}.
1968
+ */
1969
+ async mergeAppendMeta(identifier, value, path = '', opts) {
1970
+ return this.addMeta(identifier, value, path, { mode: 'merge_append', ...opts });
1971
+ }
1972
+ async mergeAppendMetaByQuery(query, value, path = '', firstMatch = false, opts) {
1973
+ return this.addMetaByQuery(query, value, path, firstMatch, { mode: 'merge_append', ...opts });
1974
+ }
1975
+ /** Convenience: push a single item. Wraps `item` in `[item]` and calls {@link appendMeta}. */
1976
+ async appendItem(identifier, item, path = '', opts) {
1977
+ return this.appendMeta(identifier, [item], path, opts);
1978
+ }
1830
1979
  async queryMeta(identifier, path = '') {
1831
1980
  return this.request('GET', `/api/v1/meta${buildQuery({ identifier: this.isoPrefixId(identifier), path })}`);
1832
1981
  }
package/dist/types.d.ts CHANGED
@@ -676,7 +676,16 @@ export interface XCiteDBClientOptions {
676
676
  * Sends `X-Test-Session`; use with {@link XCiteDBClient.createTestSession}.
677
677
  */
678
678
  testSessionToken?: string;
679
- /** When true with `testSessionToken`, sends `X-Test-Auth: required` so real credentials are validated. */
679
+ /**
680
+ * Test-session auth fidelity. `'required'` / `'preserve'` send `X-Test-Auth: <mode>`;
681
+ * `'bypass'` omits the header (server synthesizes a developer-admin identity). See
682
+ * {@link CreateTestSessionOptions.testAuth} for guidance on which to pick.
683
+ */
684
+ testAuth?: 'required' | 'preserve' | 'bypass';
685
+ /**
686
+ * @deprecated Use `testAuth`.
687
+ * `true` ↔ `testAuth: 'required'`; `false` ↔ `testAuth: 'bypass'`.
688
+ */
680
689
  testRequireAuth?: boolean;
681
690
  /** Auto-prefix identifiers for app-user sessions (see {@link UserIsolationOptions}). */
682
691
  userIsolation?: UserIsolationOptions;
@@ -730,7 +739,24 @@ export interface CreateTestSessionOptions {
730
739
  overlay?: boolean;
731
740
  /** Optional server-side bootstrap (user isolation, developer_bypass, policies). */
732
741
  bootstrap?: TestSessionBootstrap;
733
- /** Keep `apiKey` / `accessToken` on the client and send `X-Test-Auth: required` on each request. */
742
+ /**
743
+ * Test-session auth fidelity mode (`X-Test-Auth` header on the returned client).
744
+ *
745
+ * - `'required'` (**SDK default**) — auth runs normally; ABAC default-deny applies until you bootstrap policies.
746
+ * Tests that exercise role/credential checks (public-key denials, etc.) work as in production.
747
+ * - `'preserve'` — auth runs normally, but ABAC defaults to allow inside the test tenant when no
748
+ * policies are configured. Use when you want production-faithful auth without writing ABAC policies.
749
+ * - `'bypass'` — server synthesizes a developer-admin identity. Erases the public-key context entirely;
750
+ * tests that depend on auth/role failures will pass under bypass and fail in production. Avoid.
751
+ *
752
+ * @default 'required'
753
+ */
754
+ testAuth?: 'required' | 'preserve' | 'bypass';
755
+ /**
756
+ * @deprecated Use `testAuth: 'required'` (or omit for the SDK default).
757
+ * Setting `testRequireAuth: false` explicitly opts into legacy bypass mode (`testAuth: 'bypass'`).
758
+ * Setting `true` is equivalent to `testAuth: 'required'`.
759
+ */
734
760
  testRequireAuth?: boolean;
735
761
  onSessionTokensUpdated?: (pair: TokenPair) => void;
736
762
  onAppUserTokensUpdated?: (pair: AppUserTokenPair) => void;
@@ -763,7 +789,10 @@ export interface OAuthProviderInfo {
763
789
  export interface OAuthProvidersResponse {
764
790
  providers: OAuthProviderInfo[];
765
791
  }
766
- /** Read-only effective `auth.app_users` (GET /api/v1/app/auth/config). */
792
+ /**
793
+ * Effective `auth.app_users` (GET /api/v1/app/auth/config). Mutable subset is patchable
794
+ * via `updateAppAuthConfig` — see that method's JSDoc for which keys are accepted.
795
+ */
767
796
  export interface AppAuthConfig {
768
797
  enabled: boolean;
769
798
  registration_enabled: boolean;
@@ -778,6 +807,12 @@ export interface AppAuthConfig {
778
807
  verification_token_expiry_seconds: number;
779
808
  jwt_algorithm: string;
780
809
  public_base_url: string;
810
+ /**
811
+ * Bootstrap warnings the operator should act on. Today: `"default_groups_empty"` when
812
+ * `registration_enabled: true` is paired with an empty `default_groups` (self-registered users
813
+ * authenticate but cannot perform writes). The BFF / app shell should surface these at startup.
814
+ */
815
+ warnings?: string[];
781
816
  }
782
817
  export interface AppEmailSmtpConfig {
783
818
  host: string;
@@ -1051,8 +1086,16 @@ export interface RebaseUserWorkspaceResult {
1051
1086
  auto_mergeable?: string[];
1052
1087
  would_expose?: string[];
1053
1088
  }
1089
+ /**
1090
+ * Canonical 403 `reason` codes the server can emit. Use as a discriminator on
1091
+ * `XCiteDBForbiddenError.reason` in catch sites — TypeScript will warn if you forget a case.
1092
+ *
1093
+ * The trailing `(string & {})` keeps the type open to forward-compatible reasons added by newer
1094
+ * servers without forcing a SDK bump on every new code.
1095
+ */
1096
+ export type XCiteDBForbiddenReason = 'role_forbidden_public_key' | 'role_forbidden_not_admin' | 'role_forbidden_not_admin_or_editor' | 'role_forbidden_app_user_no_admin' | 'role_forbidden_viewer' | 'role_forbidden' | 'auth_admin_required' | 'auth_developer_required' | 'auth_app_user_required' | 'auth_app_user_or_developer_required' | 'auth_admin_or_app_user_required' | 'auth_developer_or_anonymous_required' | 'auth_developer_or_app_user_required' | 'auth_site_admin_required' | 'auth_platform_required' | 'auth_platform_console_required' | 'auth_project_member_required' | 'auth_project_admin_required' | 'auth_org_admin_required' | 'auth_org_member_required' | 'auth_missing' | 'already_authenticated' | 'registration_disabled' | 'system_tenant_register_forbidden' | 'system_tenant_login_forbidden' | 'system_tenant_refresh_forbidden' | 'forgot_password_self_forbidden' | 'account_pending_approval' | 'email_verification_required' | 'abac_default_deny' | 'abac_explicit_deny' | 'abac_reserved_namespace' | 'tenant_security_unconfigured' | 'user_workspace_forbidden' | 'user_workspace_requires_app_user' | 'user_workspaces_app_user_only' | 'user_workspaces_create_app_user_only' | 'share_invalid_user_id' | 'share_invalid_identifier' | 'share_outside_namespace' | 'share_group_not_owner' | 'share_group_admin_only' | 'magic_link_not_authorized' | 'magic_link_not_owner' | 'magic_link_session_forbidden' | 'magic_link_forbidden' | 'reserved_namespace' | 'xml_path_policy_redacted_all' | 'test_session_not_owned' | 'ip_access_denied' | 'cluster_secret_invalid' | 'tenant_not_active' | 'org_not_active' | 'org_create_not_allowed' | 'org_project_limit_reached' | (string & {});
1054
1097
  export type XCiteDBErrorExtras = {
1055
- reason?: string;
1098
+ reason?: XCiteDBForbiddenReason;
1056
1099
  policyId?: string;
1057
1100
  hint?: string;
1058
1101
  expectedRole?: string;
@@ -1063,7 +1106,7 @@ export type XCiteDBErrorExtras = {
1063
1106
  export declare class XCiteDBError extends Error {
1064
1107
  readonly status: number;
1065
1108
  readonly body?: unknown | undefined;
1066
- reason?: string;
1109
+ reason?: XCiteDBForbiddenReason;
1067
1110
  policyId?: string;
1068
1111
  hint?: string;
1069
1112
  expectedRole?: string;
package/llms-full.txt CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Before reading the full reference, note these critical differences from typical databases:
10
10
 
11
- 1. **The default workspace is the empty string `""`, not `"main"`.** When no `X-Workspace` header is sent (or `context.workspace` / `context.branch` is omitted/empty), the server operates on the root timeline. `X-Branch` is a supported alias of `X-Workspace`. A workspace named `"main"` may exist as user-created metadata, but it is not required or special.
11
+ 1. **The default workspace is the root timeline (`""`); `"main"` is its alias.** When no `X-Workspace` header is sent (or `context.workspace` / `context.branch` is omitted/empty), the server operates on the root timeline. `"main"` passed in any header, body field (`source_branch`, `from_branch`, `target_branch`, `branch`, `source_workspace`, `target_workspace`), or query param is normalized server-side to `""`. **Wrinkle to know:** the canonical stored form is `""`. If you pass `source_branch: "main"` to `createUserWorkspace`, the persisted record reads back as `source_branch: ""` — `""` is the source of truth; `"main"` is just a convenience input. `X-Branch` is a supported alias of `X-Workspace`. Attempting to **create** a branch literally named `"main"` (e.g. `POST /api/v1/workspaces {"name": "main"}`) returns 400 the name is reserved.
12
12
 
13
13
  2. **Identifiers are hierarchical, path-like strings** — e.g. `/us/bills/hr1`, `/manual/v2/chapter3`. They are NOT auto-generated UUIDs. The leading `/` is part of the identifier. Parent/child relationships are derived from the path structure.
14
14
 
@@ -61,7 +61,7 @@ Legacy REST paths under `/api/v1/branches`, `/commits`, `/tags`, `/diff` remain
61
61
 
62
62
  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.
63
63
 
64
- 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()`.
64
+ 6. **Self-registration uses server-configured default groups — set them before opening registration.** `registerAppUser()` assigns groups from the server's `auth.app_users.default_groups` config, not from the client request. With `default_groups: []` paired with `registration_enabled: true`, registered users authenticate but have no role groups and **cannot perform writes** (the failure is silent — every write returns 403 `role_forbidden_app_user_no_admin`). Set via `updateAppAuthConfig({ default_groups: ["editor"] })` (`PUT /api/v1/app/auth/config`, admin-gated) or via the platform console. `getAppAuthConfig()` includes `warnings: ["default_groups_empty"]` when this combination is detected; surface it at BFF startup so DB-reset bootstrap doesn't silently no-op. To assign specific groups outside the default, use the admin endpoint `createAppUser()` or `updateAppUserGroups()` after registration.
65
65
 
66
66
  7. **Do not mock XciteDB in tests — use ephemeral test sessions instead.** Unlike most BaaS platforms, XciteDB has built-in support for isolated, throwaway database sessions specifically designed for wet integration tests. Mocking the client skips the actual storage, versioning, querying, and ABAC behavior, producing tests that don't catch real integration issues. Use `createTestSession()` / `test_session()` / `create_test_session()` (SDK helpers) or `POST /api/v1/test/sessions` directly to get a real LMDB under `_test/<uuid>/` (empty by default, or **overlay** on read-only production with **`{"overlay":true}`** / **`overlay: true`** / **`test_session_overlay`**). See "Ephemeral test sessions" below.
67
67
 
@@ -225,18 +225,20 @@ For **integration and wet tests** against a shared BaaS host without touching pr
225
225
 
226
226
  | Step | What to do |
227
227
  |------|------------|
228
- | **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). Optional JSON body **`{"overlay":true}`** provisions a **read-through production** session (writable delta only under `_test/<uuid>/`; production LMDB is read-only base). |
228
+ | **Create** | **`POST /api/v1/test/sessions`** with normal **`Authorization: Bearer …`** or **`X-API-Key`**. Response includes **`session_token`** (UUID), **`tenant_id`** (the session's logical tenant id, formatted as `xcitedbtest-<session_token>` — useful for ABAC group strings like `project:<tenant_id>:editor`), `expires_at`, and `session_ttl_seconds`. Server enforces per-credential limits (`test.max_sessions_per_key`, `test.session_ttl_seconds`, `test.max_test_db_size_bytes` in server config). Optional JSON body **`{"overlay":true}`** provisions a **read-through production** session (writable delta only under `_test/<uuid>/`; production LMDB is read-only base). |
229
229
  | **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. |
230
- | **Auth** | **Default:** developer auth (API key / platform JWT) is **bypassed** with a synthetic admin identity. However, **app-user identity is still recognized**: if `X-App-User-Token` or a Bearer app-user JWT is present, the request runs as that app user (for routes like `/app/auth/me`). **`X-Test-Auth: required`:** all auth is validated normally; ABAC applies, but data still comes from the test session DB. |
230
+ | **Auth** | Three modes via the **`X-Test-Auth`** header. **`required`** (SDK default as of this release): all auth is validated normally; ABAC applies, so role/credential checks (e.g. public-key on admin-write controllers → 403 `role_forbidden_public_key`) fire as in production. **`preserve`**: auth runs normally, but ABAC defaults to **allow** when the test tenant has no policies — production-faithful auth without forcing a policy bootstrap. **Header omitted** (legacy bypass): server synthesizes a developer-admin identity (`auth_user_type=member, role=admin`) and **erases the public-key context entirely** — tests that exercise auth/role failures will pass under bypass and fail in production. **App-user identity is still recognized in every mode**: if `X-App-User-Token` or a Bearer app-user JWT is present, the request runs as that app user (for routes like `/app/auth/me`). |
231
231
  | **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. **SDKs:** JS `listTestSessions` / `destroyAllTestSessions` / `destroyTestSessionByToken`; Python `list_test_sessions` / `destroy_all_test_sessions` / `destroy_test_session_by_token`; C++ same snake_case; MCP tools `list_test_sessions`, `destroy_all_test_sessions`, `destroy_test_session_by_token`. |
232
232
  | **429 / suites** | Per-credential concurrent cap (default **5**, `test.max_sessions_per_key`). If **`createTestSession`** returns **429**, call **`destroyAllTestSessions`** (same API key / Bearer, no `X-Test-Session`) before retrying — typical **once in `beforeAll`** for large wet suites. |
233
233
  | **CORS** | Browsers may need **`X-Test-Session`** and **`X-Test-Auth`** in the deployment’s allowed CORS headers (defaults include them). |
234
234
 
235
235
  **SDK usage (summary):**
236
236
 
237
- - **JavaScript/TypeScript:** `XCiteDBClient.createTestSession({ baseUrl, apiKey, …, bootstrap })` returns a client configured with `testSessionToken`; optional **`overlay: true`** for overlay mode; optional `testRequireAuth: true` maps to `X-Test-Auth: required`; optional **`bootstrap`** (`user_isolation`, `developer_bypass`, `policies`); read **`lastTestSessionBootstrap`** on the returned client when the server included a summary. `destroyTestSession()` calls `DELETE …/test/sessions/current`. On a **normal** client (same `apiKey` / Bearer, **no** `testSessionToken`), **`listTestSessions()`**, **`destroyAllTestSessions()`**, **`destroyTestSessionByToken(token)`** wrap the management routes (they never send `X-Test-Session`).
238
- - **Python:** `async with XCiteDBClient.test_session(base_url, api_key=…, …, bootstrap={…})` provisions and tears down; or **`POST /api/v1/test/sessions`** with JSON **`{"overlay":true}`**, **`bootstrap`**, or both, then construct the client with the returned token; **`client.last_test_session_bootstrap`** mirrors the server summary. Management: **`await client.list_test_sessions()`**, **`await client.destroy_all_test_sessions()`**, **`await client.destroy_test_session_by_token(token)`** with `suppress_test_session` behavior (omit `X-Test-Session` on those paths).
239
- - **C++:** `XCiteDBClient::create_test_session(options)` after setting `api_key`, optional **`test_session_overlay`**, optional **`test_session_bootstrap`** on `XCiteDBClientOptions`, optional `test_require_auth`; **`last_test_session_bootstrap()`** on the returned client; `destroy_test_session()`. Management: **`list_test_sessions()`**, **`destroy_all_test_sessions()`**, **`destroy_test_session_by_token(token)`** on a client carrying the provisioning credential only.
237
+ - **JavaScript/TypeScript:** `XCiteDBClient.createTestSession({ baseUrl, apiKey, …, bootstrap })` returns a client configured with `testSessionToken`; optional **`overlay: true`** for overlay mode; **`testAuth: 'required' | 'preserve' | 'bypass'`** (default **`'required'`**); legacy **`testRequireAuth`** still accepted (`true` `'required'`, `false` `'bypass'`); optional **`bootstrap`** (`user_isolation`, `developer_bypass`, `policies`); read **`lastTestSessionBootstrap`** on the returned client when the server included a summary. `destroyTestSession()` calls `DELETE …/test/sessions/current`. On a **normal** client (same `apiKey` / Bearer, **no** `testSessionToken`), **`listTestSessions()`**, **`destroyAllTestSessions()`**, **`destroyTestSessionByToken(token)`** wrap the management routes (they never send `X-Test-Session`).
238
+ - **Python:** `async with XCiteDBClient.test_session(base_url, api_key=…, …, bootstrap={…})` provisions and tears down; default `test_auth="required"`; pass **`test_auth="preserve"`** for real auth + lenient ABAC, **`test_auth="bypass"`** for legacy synthesized-admin. Or **`POST /api/v1/test/sessions`** with JSON **`{"overlay":true}`**, **`bootstrap`**, or both, then construct the client with the returned token; **`client.last_test_session_bootstrap`** mirrors the server summary. Management: **`await client.list_test_sessions()`**, **`await client.destroy_all_test_sessions()`**, **`await client.destroy_test_session_by_token(token)`** with `suppress_test_session` behavior (omit `X-Test-Session` on those paths).
239
+ - **C++:** `XCiteDBClient::create_test_session(options)` after setting `api_key`, optional **`test_session_overlay`**, optional **`test_session_bootstrap`** on `XCiteDBClientOptions`, optional **`test_auth`** (`"required"`/`"preserve"`/`"bypass"`; default `"required"`) or legacy `test_require_auth`; **`last_test_session_bootstrap()`** on the returned client; `destroy_test_session()`. Management: **`list_test_sessions()`**, **`destroy_all_test_sessions()`**, **`destroy_test_session_by_token(token)`** on a client carrying the provisioning credential only.
240
+
241
+ > **Test fidelity warning.** The legacy bypass mode (omit `X-Test-Auth`) erases public-key context entirely — `isPublicKeyContext()` returns false for every request, so role gates that reject public keys silently pass in tests and fail in production. The SDK helpers default to `'required'` as of this release; explicitly opt into `'bypass'` only when you've verified the test does not depend on auth/role behavior. Use `'preserve'` to get production-faithful auth without writing ABAC policies for the test tenant.
240
242
 
241
243
  **A fresh test session is an empty tenant with zero policies.** With `X-Test-Auth: required`, ABAC default-deny applies until you bootstrap user isolation and/or policies. Typical `POST /api/v1/test/sessions` body:
242
244
 
@@ -606,8 +608,8 @@ Attach structured **JSON metadata** to documents or nodes.
606
608
  | `identifier` **or** `query` | one required | Target document id or document query |
607
609
  | `value` | yes | JSON to write (omit only for string-specific query batch paths handled by the server) |
608
610
  | `path` | no (default `""`) | Meta path (dot-separated keys; `[i]` for array indices) |
609
- | `mode` | no (default `"set"`) | `"set"` — write/replace at `path`. For **arrays** at `path`, indices `0..n-1` are written and any previous tail beyond the new length is cleared. `"append"` — array-extend behavior applies only when the **payload** at `path` is a JSON **array**; new elements are written after the stored length from the array marker `[n]` at `path`. If nothing array-shaped is stored there, the effective prior length is `0` (indices start at `0`). Scalars or a non-array object at `path` are not merged via array-append (object payloads recurse; nested array fields follow the same rules under their own paths). See **Append semantics** below. |
610
- | `overwrite` | no (default `false`) | When `true`, delete existing metadata under `path` **before** applying `value`. That runs before array logic: with `mode: "append"` it clears the stored `[n]` marker, so the subsequent write always starts at index `0` (you do not keep prior elements). To extend an existing array, use **`overwrite: false`**. For “clear then replace the whole array”, use **`overwrite: true`** with **`mode: "set"`** (or `append` after clear, which is equivalent to writing from `0`). |
611
+ | `mode` | no (default `"set"`) | `"set"` — write/replace at `path`. For **arrays** at `path`, indices `0..n-1` are written and any previous tail beyond the new length is cleared. `"append"` — **strict** array-extend: `value` **must** be a JSON array, and the stored value at `path` must be an array (or absent). The new elements are written after the stored length. `"merge_append"` **deep** array-extend: walks the payload and extends any nested arrays in storage; scalars and non-array objects in the payload are written as `set` at their own paths. Use `merge_append` only when you intentionally want recursion. See **Append semantics** below. |
612
+ | `overwrite` | no (default `false`) | When `true`, delete existing metadata under `path` **before** applying `value`. That runs before array logic: with `mode: "append"` or `"merge_append"` it clears the stored `[n]` marker, so the subsequent write always starts at index `0` (you do not keep prior elements). To extend an existing array, use **`overwrite: false`**. For “clear then replace the whole array”, use **`overwrite: true`** with **`mode: "set"`**. |
611
613
  | `first_match` | no | With `query`, only the first matching identifier is updated when `true`. |
612
614
 
613
615
  ```json
@@ -622,23 +624,47 @@ Attach structured **JSON metadata** to documents or nodes.
622
624
 
623
625
  ### Append semantics
624
626
 
625
- - **`mode: "append"`** only changes array behavior when the **JSON at `path` in this request’s `value`** is an **array** (or when traversing an object payload, each **nested** field whose value is an array, under the same `append` flag). A **scalar** or **non-array object** at `path` does not run “append after `[n]`” logic for that node.
626
- - To **extend** an existing list in storage, the **stored** value at `path` should already be an array (the server keeps a `[length]` marker). New payload elements are written at indices `length`, `length+1`, If there is **no** valid array marker at `path` in storage, append still succeeds but behaves like a **first write** from index `0`.
627
- - **`overwrite: true` + `append`:** do not use this expecting “delete old tail then append onto what was left” — overwrite **removes everything under `path` first**, so append always indexes from **`0`**. Prefer **`overwrite: false`** + **`append`** to grow a list; use **`overwrite: true`** + **`set`** when replacing the whole subtree/array.
627
+ - **`mode: "append"` (strict).** The `value` **must** be a JSON array. Non-array payloads (objects, scalars, strings) are rejected with **`400 append_payload_not_array`**. The stored value at `path` must be an array (or absent). If a non-array value is already stored at `path`, the request is rejected with **`409 append_target_not_array`** to avoid silently overwriting it. Empty array `[]` is a no-op success. Missing path is OK — a fresh array is created.
628
+ - **`mode: "merge_append"` (deep).** Walks the payload as if it were a tree of writes. Object payloads recurse into their fields; at each leaf where the stored value is an array and the payload is an array, elements are extended after the stored length. Scalars and non-array objects at leaves are written as `set` at their own paths. Use this only when you intentionally want a single request to extend several nested arrays at once. There is **no** strict precheck — the recursive semantics is the contract.
629
+ - **`overwrite: true` + append modes:** overwrite removes everything under `path` first, so append always indexes from **`0`**. Prefer **`overwrite: false`** + **`append`** to grow a list; use **`overwrite: true`** + **`set`** when replacing the whole subtree/array.
628
630
 
629
- **Example — extend `tags` without overwrite** (stored `tags` is `["a","b"]`; result `["a","b","c","d"]`):
631
+ **Example — strict append: extend `tags`** (stored `tags` is `["a","b"]`; result `["a","b","c","d"]`):
630
632
 
631
633
  ```json
632
634
  {
633
635
  "identifier": "/book/ch1",
634
636
  "path": "tags",
635
637
  "mode": "append",
636
- "overwrite": false,
637
638
  "value": ["c", "d"]
638
639
  }
639
640
  ```
640
641
 
641
- **Example — same payload with `overwrite: true`** (clears `tags` first; result only `["c","d"]`):
642
+ **Example — push a single item.** Strict append requires an array; wrap a single item:
643
+
644
+ ```json
645
+ {
646
+ "identifier": "/book/ch1",
647
+ "path": "comments",
648
+ "mode": "append",
649
+ "value": [{"author": "alice", "text": "lgtm"}]
650
+ }
651
+ ```
652
+
653
+ The JS SDK exposes `appendItem(id, item, path)` as sugar for this; Python has `append_item`; C++ has `append_item`.
654
+
655
+ **Example — merge_append: extend several arrays in one shot** (stored `tags` is `["a"]`, `reviewers` is `["alice"]`):
656
+
657
+ ```json
658
+ {
659
+ "identifier": "/book/ch1",
660
+ "mode": "merge_append",
661
+ "value": {"tags": ["b","c"], "reviewers": ["bob"]}
662
+ }
663
+ ```
664
+
665
+ Result: `tags` becomes `["a","b","c"]`; `reviewers` becomes `["alice","bob"]`.
666
+
667
+ **Example — overwrite + append** (clears `tags` first; result only `["c","d"]`):
642
668
 
643
669
  ```json
644
670
  {
@@ -650,7 +676,7 @@ Attach structured **JSON metadata** to documents or nodes.
650
676
  }
651
677
  ```
652
678
 
653
- Can also use `"query"` instead of `"identifier"` to target multiple documents by query filter.
679
+ Can also use `"query"` instead of `"identifier"` to target multiple documents by query filter. With strict `mode: "append"`, the target precheck runs per matched identifier; on the first one whose stored value at `path` is non-array, the response is `409 append_target_not_array` with `detail.identifier` naming the offender.
654
680
 
655
681
  ## Query metadata (GET)
656
682
 
@@ -863,7 +889,7 @@ Dry-run: **`POST /api/v1/security/check`** with `subject`, `identifier`, `action
863
889
  **Preferred base path:** `/api/v1/workspaces`
864
890
  **Deprecated alias:** `/api/v1/branches` (same handlers)
865
891
 
866
- **IMPORTANT: The default workspace is the empty string `""`.** When no workspace is specified, the server uses the root timeline.
892
+ **IMPORTANT: The default workspace is the empty string `""`.** When no workspace is specified, the server uses the root timeline. `"main"` is normalized to `""` server-side wherever a workspace name appears (headers, body fields, query params); the canonical stored form is always `""`. Attempting to create a branch literally named `"main"` returns 400 (reserved name).
867
893
 
868
894
  ## List workspaces
869
895
 
@@ -1082,7 +1108,7 @@ Definitions are stored as JSON under **`/_xcitedb/triggers`**. After a matching
1082
1108
  | `event` | Yes | `meta_changed`, `document_written`, or `document_deleted`. |
1083
1109
  | `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). |
1084
1110
  | `conditions` | No | Optional **`branches`** (same as policies) and **`expression`** (see below). |
1085
- | `action` | Yes | **`query`** (`XCiteQuery`), **`unquery`** (Unquery DSL template), **`target_identifier`** (literal or `"$trigger_identifier"`), **`meta_path`**, optional **`mode`**: `set` (default) or `append`. |
1111
+ | `action` | Yes | **`query`** (`XCiteQuery`), **`unquery`** (Unquery DSL template), **`target_identifier`** (literal or `"$trigger_identifier"`), **`meta_path`**, optional **`mode`**: `set` (default), `append` (strict — unquery output must be a JSON array, stored target must be an array; trigger logs and skips on misuse), or `merge_append` (deep — recurses into objects to extend nested arrays). |
1086
1112
 
1087
1113
  ### Expression context for **triggers** (`conditions.expression`)
1088
1114
 
@@ -1608,10 +1634,13 @@ interface DatabaseContext {
1608
1634
  - `put(identifier, data, opts?)` / `get<T>(identifier)` / `remove(identifier)` / `list(match?, limit?, offset?)` — JSON CRUD aliases (`opts` same as `writeJsonDocument`)
1609
1635
 
1610
1636
  ### Metadata
1611
- - `addMeta(identifier, value, path?, opts?)` → `boolean` — `opts`: `mode?: 'set'|'append'`, `overwrite?: boolean`
1637
+ - `addMeta(identifier, value, path?, opts?)` → `boolean` — `opts`: `mode?: 'set'|'append'|'merge_append'`, `overwrite?: boolean`
1612
1638
  - `addMetaByQuery(query, value, path?, firstMatch?, opts?)` → `boolean`
1613
- - `appendMeta(identifier, value, path?, opts?)` → `boolean` — same as `addMeta` with `mode: 'append'`
1614
- - `appendMetaByQuery(query, value, path?, firstMatch?, opts?)` → `boolean`
1639
+ - `appendMeta(identifier, value: unknown[], path?, opts?)` → `boolean` — **strict** array-extend; `value` typed as array. Server returns `400 append_payload_not_array` / `409 append_target_not_array` on misuse.
1640
+ - `appendMetaByQuery(query, value: unknown[], path?, firstMatch?, opts?)` → `boolean` — strict.
1641
+ - `mergeAppendMeta(identifier, value, path?, opts?)` → `boolean` — **deep** array-extend (recurses into objects, extends nested arrays). Use only when you intentionally want recursion.
1642
+ - `mergeAppendMetaByQuery(query, value, path?, firstMatch?, opts?)` → `boolean`
1643
+ - `appendItem(identifier, item, path?, opts?)` → `boolean` — sugar for `appendMeta(id, [item], path, opts)`.
1615
1644
  - `queryMeta<T>(identifier, path?)` → `T`
1616
1645
  - `queryMetaByQuery<T>(query, path?)` → `T`
1617
1646
  - `clearMeta(query)` → `boolean`
package/llms.txt CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  These are the most common sources of confusion for developers and AI assistants:
8
8
 
9
- 1. **The default workspace is the empty string `""`, not `"main"`.** When no `X-Workspace` header is sent (or `context.workspace` / `context.branch` is omitted/empty in the SDK), the server operates on the root timeline. `X-Branch` is a supported alias of `X-Workspace`. A workspace named `"main"` may exist as user-created metadata, but it is not required or special.
9
+ 1. **The default workspace is the root timeline (`""`); `"main"` is its alias.** When no `X-Workspace` header is sent (or `context.workspace` / `context.branch` is omitted/empty in the SDK), the server operates on the root timeline. `"main"` passed in any header, body field (`source_branch`, `from_branch`, `target_branch`, `branch`, `source_workspace`, `target_workspace`), or query param is normalized server-side to `""`. **Wrinkle to know:** the canonical stored form is `""`. If you pass `source_branch: "main"` to `createUserWorkspace`, the persisted record reads back as `source_branch: ""` — `""` is the source of truth; `"main"` is just a convenience input. `X-Branch` is a supported alias of `X-Workspace`. Attempting to **create** a branch literally named `"main"` (e.g. `POST /api/v1/workspaces {"name": "main"}`) returns 400 the name is reserved.
10
10
 
11
11
  2. **Identifiers are hierarchical, path-like strings** — e.g. `/us/bills/hr1`, `/manual/v2/chapter3`. They are NOT auto-generated UUIDs. The leading `/` is part of the identifier. Parent/child relationships are derived from the path structure (like a filesystem). The server indexes this hierarchy natively.
12
12
 
@@ -159,7 +159,7 @@ When you build a backend that calls XCiteDB on behalf of users:
159
159
  ## JSON documents and metadata (merge, overwrite, efficiency)
160
160
 
161
161
  - **JSON documents merge by default.** `POST /api/v1/json-documents` merges the posted `data` into the existing document (object fields combined per XCiteDB meta merge rules). Send **`overwrite: true`** to clear all stored JSON under that document root first, then write only the new payload. SDKs accept the same flag (e.g. `writeJsonDocument(id, data, { overwrite: true })` in JavaScript).
162
- - **Metadata `mode` and arrays.** **`POST /api/v1/meta`** uses **`mode`**: default **`set`** writes or replaces at `path` (arrays are replaced in range; excess old indices cleared); **`append`** appends array elements after existing ones at `path`. Optional **`overwrite: true`** clears metadata under `path` before writing. JavaScript: **`appendMeta`** or **`addMeta(..., { mode: 'append' })`**; Python/C++: **`append_meta`** or **`add_meta`** with **`mode`** / **`overwrite`** (see SDK sections below).
162
+ - **Metadata `mode` and arrays.** **`POST /api/v1/meta`** uses **`mode`**: default **`set`** writes or replaces at `path`; **`append`** is **strict** array-extend — `value` must be a JSON array AND the stored target at `path` must already be an array (or absent). Otherwise the server returns **`400 append_payload_not_array`** or **`409 append_target_not_array`**. **`merge_append`** is the legacy "deep" behavior — recurses into object payloads and extends nested arrays in storage; use only when you intentionally want recursion. Optional **`overwrite: true`** clears metadata under `path` before writing. JavaScript: **`appendMeta(id, [items], path)`** (strict, types `value` as array), **`mergeAppendMeta(id, value, path)`** (deep), **`appendItem(id, item, path)`** (sugar for a single item). Python/C++: **`append_meta` / `merge_append_meta` / `append_item`**.
163
163
  - **Prefer dictionary-style objects.** **Shredded** JSON metadata is stored in **fragment-level** keys (e.g. per field name). **Object maps** get per-field storage; when an object accumulates enough distinct field names (server default threshold **32**), XCiteDB switches automatically to **dictionary storage** (`{*}` plus per-field keys) for efficient indexed access. For lookup-heavy or wide records, use **`{ "key": value, ... }`** shapes (or one document per logical row) rather than opaque arrays when you need keyed reads.
164
164
 
165
165
  ## XML subtree writes (shredded model)
@@ -349,8 +349,10 @@ interface XCiteDBClientOptions {
349
349
  - **Quick JSON aliases:** `put`, `get`, `remove`, `list` — same as the four methods above
350
350
 
351
351
  **Metadata (JSON on XML):**
352
- - `addMeta(identifier, value, path?, opts?)` — Set or append metadata (`opts?.mode`: `set`|`append`; `opts?.overwrite`)
353
- - `appendMeta(identifier, value, path?, opts?)` — Append at `path` (same as `addMeta` with `mode: 'append'`)
352
+ - `addMeta(identifier, value, path?, opts?)` — Set or append metadata (`opts?.mode`: `set`|`append`|`merge_append`; `opts?.overwrite`)
353
+ - `appendMeta(identifier, value: unknown[], path?, opts?)` — **Strict** array-extend at `path`; `value` typed as array. Server returns `400 append_payload_not_array` / `409 append_target_not_array` on misuse.
354
+ - `mergeAppendMeta(identifier, value, path?, opts?)` — **Deep** array-extend (recurses into objects, extends nested arrays). Use only when you intentionally want recursion.
355
+ - `appendItem(identifier, item, path?, opts?)` — Convenience: wraps `item` in `[item]` and calls `appendMeta`.
354
356
  - `queryMeta(identifier, path?)` — Read metadata
355
357
  - `clearMeta(query)` — Remove metadata
356
358
 
@@ -402,7 +404,7 @@ interface XCiteDBClientOptions {
402
404
 
403
405
  - **Events:** `meta_changed`, `document_written`, `document_deleted`.
404
406
  - **`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`.
405
- - **`action`:** **`query`** (document query), **`unquery`** (Unquery template), **`target_identifier`** (or `"$trigger_identifier"`), **`meta_path`**, **`mode`:** `set` | `append`.
407
+ - **`action`:** **`query`** (document query), **`unquery`** (Unquery template), **`target_identifier`** (or `"$trigger_identifier"`), **`meta_path`**, **`mode`:** `set` | `append` (strict — unquery output must be a JSON array) | `merge_append` (deep — recurses into objects to extend nested arrays).
406
408
  - **Unquery vars:** `$trigger_identifier`, `$trigger_meta_path`, `$trigger_operation`, `$trigger_value`. **Trigger `conditions.expression` context:** `trigger.{event,meta_path,operation}`, `value`, `resource`, `env.branch` (workspace; no `subject`).
407
409
 
408
410
  ### Advanced: Unquery DSL (`unquery(query, unqueryDoc)`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcitedbs/client",
3
- "version": "0.2.26",
3
+ "version": "0.3.0",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",