@xcitedbs/client 0.3.0 → 0.3.2

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * Wet-test reproduction of the client developer's "DB-reset Forbidden" bug, end-to-end.
8
+ *
9
+ * Set XCITEDB_BASE_URL, XCITEDB_ADMIN_TOKEN, XCITEDB_TENANT_ID to run; otherwise skipped.
10
+ *
11
+ * Each `it` exercises one of the four stacked failures from the original incident report:
12
+ * 1. `default_groups: []` + `registration_enabled: true` produces ghost users → server now
13
+ * surfaces `warnings: ["default_groups_empty"]` and the BFF can act on it before users
14
+ * register.
15
+ * 2. SPA tries `PUT /api/v1/app/users/:id/groups` with a public API key → server now returns
16
+ * `403 reason: "role_forbidden_public_key"` (previously bare 403 with no reason).
17
+ * 3. `loginAppUser` sends a stale `Authorization: Bearer …` from a previous session → SDK
18
+ * now clears tokens before the request; if a hand-rolled client skips that, server returns
19
+ * `403 reason: "already_authenticated"` so the failure is diagnosable.
20
+ * 4. The `X-Test-Auth: preserve` mode lets us reproduce #2 in a wet test (under legacy bypass
21
+ * the public-key context is erased, so the bug couldn't be reproduced in tests at all).
22
+ */
23
+ const node_test_1 = require("node:test");
24
+ const strict_1 = __importDefault(require("node:assert/strict"));
25
+ const node_crypto_1 = require("node:crypto");
26
+ const client_js_1 = require("./client.js");
27
+ const types_js_1 = require("./types.js");
28
+ function wetEnv() {
29
+ const baseUrl = process.env.XCITEDB_BASE_URL?.trim();
30
+ const accessToken = process.env.XCITEDB_ADMIN_TOKEN?.trim();
31
+ const tenantId = process.env.XCITEDB_TENANT_ID?.trim();
32
+ if (!baseUrl || !accessToken || !tenantId)
33
+ return null;
34
+ return { baseUrl, accessToken, tenantId };
35
+ }
36
+ const w = wetEnv();
37
+ const wd = w ? node_test_1.describe : node_test_1.describe.skip;
38
+ wd('bootstrap reproduction (wet)', () => {
39
+ (0, node_test_1.it)('default_groups_empty warning surfaces, updateAppAuthConfig clears it', async () => {
40
+ const e = wetEnv();
41
+ if (!e)
42
+ throw new Error('missing env');
43
+ // Use preserve mode so admin auth is faithful; we still need real admin credentials to PUT
44
+ // /api/v1/app/auth/config. The test session is just isolation, not auth bypass.
45
+ const admin = await client_js_1.XCiteDBClient.createTestSession({
46
+ baseUrl: e.baseUrl,
47
+ accessToken: e.accessToken,
48
+ platformConsole: true,
49
+ projectId: e.tenantId,
50
+ context: { branch: 'main', project_id: e.tenantId },
51
+ testAuth: 'preserve',
52
+ });
53
+ try {
54
+ // First: clear default_groups so the warning condition holds.
55
+ await admin.updateAppAuthConfig({ registration_enabled: true, default_groups: [] });
56
+ const before = await admin.getAppAuthConfig();
57
+ strict_1.default.deepEqual(before.default_groups, [], 'precondition: default_groups must be empty');
58
+ strict_1.default.ok(Array.isArray(before.warnings) && before.warnings.includes('default_groups_empty'), `getAppAuthConfig must surface "default_groups_empty" — got warnings=${JSON.stringify(before.warnings)}`);
59
+ // Now patch it.
60
+ const after = await admin.updateAppAuthConfig({ default_groups: ['editor'] });
61
+ strict_1.default.deepEqual(after.default_groups, ['editor']);
62
+ strict_1.default.ok(!after.warnings || !after.warnings.includes('default_groups_empty'), 'warning must clear once default_groups is non-empty');
63
+ }
64
+ finally {
65
+ await admin.destroyTestSession().catch(() => { });
66
+ }
67
+ });
68
+ (0, node_test_1.it)('register + login on the same client succeeds (token auto-cleared)', async () => {
69
+ const e = wetEnv();
70
+ if (!e)
71
+ throw new Error('missing env');
72
+ const admin = await client_js_1.XCiteDBClient.createTestSession({
73
+ baseUrl: e.baseUrl,
74
+ accessToken: e.accessToken,
75
+ platformConsole: true,
76
+ projectId: e.tenantId,
77
+ context: { branch: 'main', project_id: e.tenantId },
78
+ testAuth: 'preserve',
79
+ });
80
+ try {
81
+ // Bootstrap: registration on, default_groups set so the user can do things.
82
+ await admin.updateAppAuthConfig({
83
+ registration_enabled: true,
84
+ default_groups: [client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor')],
85
+ });
86
+ // Anonymous app client (no apiKey, no accessToken). Reuses the test session via header
87
+ // propagation through the testSessionToken on the parent client; spawn a sibling
88
+ // anon client that shares the test session.
89
+ const suffix = (0, node_crypto_1.randomUUID)().slice(0, 8);
90
+ const email = `js_boot_${suffix}@apitest.invalid`;
91
+ const password = `Js_${suffix}!aA1`;
92
+ const anon = new client_js_1.XCiteDBClient({
93
+ baseUrl: e.baseUrl,
94
+ context: { branch: 'main', project_id: e.tenantId },
95
+ // Same test session so the user lives in the ephemeral DB.
96
+ testSessionToken: admin.testSessionToken,
97
+ // Required so the app-auth endpoints see anonymous_app_client (project_id propagates anonymously).
98
+ testAuth: 'preserve',
99
+ });
100
+ // First flow: register, then login on the same client. Without the SDK's clear-before-login
101
+ // fix this would 403 with reason="already_authenticated" because the register response would
102
+ // (in some flows) have set a token, and the next login would reuse it.
103
+ await anon.registerAppUser(email, password);
104
+ const pair = await anon.loginAppUser(email, password);
105
+ strict_1.default.ok(pair.access_token.length > 0, 'login returned an access token');
106
+ }
107
+ finally {
108
+ await admin.destroyTestSession().catch(() => { });
109
+ }
110
+ });
111
+ (0, node_test_1.it)('hand-rolled stale-bearer login returns 403 reason="already_authenticated"', async () => {
112
+ const e = wetEnv();
113
+ if (!e)
114
+ throw new Error('missing env');
115
+ const admin = await client_js_1.XCiteDBClient.createTestSession({
116
+ baseUrl: e.baseUrl,
117
+ accessToken: e.accessToken,
118
+ platformConsole: true,
119
+ projectId: e.tenantId,
120
+ context: { branch: 'main', project_id: e.tenantId },
121
+ testAuth: 'preserve',
122
+ });
123
+ try {
124
+ await admin.updateAppAuthConfig({
125
+ registration_enabled: true,
126
+ default_groups: [client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor')],
127
+ });
128
+ const sessionToken = admin.testSessionToken;
129
+ const suffix = (0, node_crypto_1.randomUUID)().slice(0, 8);
130
+ const email = `js_stale_${suffix}@apitest.invalid`;
131
+ const password = `Js_${suffix}!aA1`;
132
+ // Step 1: register and login normally to get a real app-user JWT.
133
+ const anon = new client_js_1.XCiteDBClient({
134
+ baseUrl: e.baseUrl,
135
+ context: { branch: 'main', project_id: e.tenantId },
136
+ testSessionToken: sessionToken,
137
+ testAuth: 'preserve',
138
+ });
139
+ await anon.registerAppUser(email, password);
140
+ const pair = await anon.loginAppUser(email, password);
141
+ strict_1.default.ok(pair.access_token);
142
+ // Step 2: simulate a buggy client that re-issues login while still carrying the stale Bearer.
143
+ // Bypass the SDK (which would clear-before-login) by sending raw fetch.
144
+ const url = `${e.baseUrl.replace(/\/+$/, '')}/api/v1/app/auth/login`;
145
+ const r = await fetch(url, {
146
+ method: 'POST',
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ Authorization: `Bearer ${pair.access_token}`,
150
+ 'X-Test-Session': sessionToken,
151
+ 'X-Test-Auth': 'preserve',
152
+ },
153
+ body: JSON.stringify({ email, password, tenant_id: e.tenantId }),
154
+ });
155
+ strict_1.default.equal(r.status, 403, 'stale-bearer login must 403');
156
+ const body = (await r.json());
157
+ strict_1.default.equal(body.reason, 'already_authenticated', `body.reason must be "already_authenticated"; got ${JSON.stringify(body)}`);
158
+ strict_1.default.ok(body.hint && body.hint.length > 0, 'response must carry a hint');
159
+ }
160
+ finally {
161
+ await admin.destroyTestSession().catch(() => { });
162
+ }
163
+ });
164
+ (0, node_test_1.it)('public API key cannot PUT /api/v1/app/users/:id/groups; 403 reason="role_forbidden_public_key"', async () => {
165
+ const e = wetEnv();
166
+ if (!e)
167
+ throw new Error('missing env');
168
+ // We need a real public API key to exercise this path. The test session is a separate concern
169
+ // — the public-key denial is enforced by the role check before any test-session logic. Use
170
+ // the PROJECT public API key from env if available; otherwise skip with a clear message.
171
+ const publicKey = process.env.XCITEDB_PUBLIC_API_KEY?.trim();
172
+ if (!publicKey) {
173
+ // Run against the real tenant with a public key; not via test session because public keys
174
+ // can't always provision sessions and the failure mode is the role gate, not the storage.
175
+ console.log(' ⏭ skipped — set XCITEDB_PUBLIC_API_KEY (a public project key) to verify role_forbidden_public_key');
176
+ return;
177
+ }
178
+ const admin = new client_js_1.XCiteDBClient({
179
+ baseUrl: e.baseUrl,
180
+ accessToken: e.accessToken,
181
+ platformConsole: true,
182
+ projectId: e.tenantId,
183
+ context: { branch: 'main', project_id: e.tenantId },
184
+ });
185
+ // Create a real app user to target. Don't use a test session — the developer's bug was on
186
+ // the production tenant, and the role gate fires the same way regardless.
187
+ const suffix = (0, node_crypto_1.randomUUID)().slice(0, 8);
188
+ const email = `js_pk_${suffix}@apitest.invalid`;
189
+ const password = `Js_${suffix}!aA1`;
190
+ let userId = null;
191
+ try {
192
+ const u = await admin.createAppUser(email, password);
193
+ userId = u.user_id;
194
+ const url = `${e.baseUrl.replace(/\/+$/, '')}/api/v1/app/users/${encodeURIComponent(u.user_id)}/groups`;
195
+ const r = await fetch(url, {
196
+ method: 'PUT',
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ 'X-API-Key': publicKey,
200
+ 'X-Project-Id': e.tenantId,
201
+ },
202
+ body: JSON.stringify({ groups: [client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor')] }),
203
+ });
204
+ strict_1.default.equal(r.status, 403, 'public key on admin-write endpoint must 403');
205
+ const body = (await r.json());
206
+ strict_1.default.equal(body.reason, 'role_forbidden_public_key', `body.reason must be "role_forbidden_public_key"; got ${JSON.stringify(body)} — this is the bug the developer hit`);
207
+ }
208
+ finally {
209
+ if (userId)
210
+ await admin.deleteAppUser(userId).catch(() => { });
211
+ }
212
+ });
213
+ (0, node_test_1.it)('preserve mode reproduces public-key denials that bypass mode would mask', async () => {
214
+ // Sanity check that the new preserve mode actually evaluates auth normally inside a test
215
+ // session — the whole point of E in the plan. We don't need a public API key to verify
216
+ // this; we just need to confirm that an unauthenticated request to an admin endpoint
217
+ // returns 403 (with reason) rather than being silently admined-through.
218
+ const e = wetEnv();
219
+ if (!e)
220
+ throw new Error('missing env');
221
+ const admin = await client_js_1.XCiteDBClient.createTestSession({
222
+ baseUrl: e.baseUrl,
223
+ accessToken: e.accessToken,
224
+ platformConsole: true,
225
+ projectId: e.tenantId,
226
+ context: { branch: 'main', project_id: e.tenantId },
227
+ testAuth: 'preserve',
228
+ });
229
+ try {
230
+ const sessionToken = admin.testSessionToken;
231
+ // Send an admin GET (/api/v1/app/auth/config) with no credentials at all under preserve mode.
232
+ // Under bypass mode this would be silently approved (synthesized admin). Under preserve, the
233
+ // AuthFilter sees no credentials and rejects.
234
+ const url = `${e.baseUrl.replace(/\/+$/, '')}/api/v1/app/auth/config`;
235
+ const r = await fetch(url, {
236
+ method: 'GET',
237
+ headers: { 'X-Test-Session': sessionToken, 'X-Test-Auth': 'preserve' },
238
+ });
239
+ strict_1.default.ok(r.status === 401 || r.status === 403, `preserve mode must enforce auth; got status ${r.status} — likely regressed back to bypass`);
240
+ if (r.status === 403) {
241
+ const body = (await r.json());
242
+ strict_1.default.ok(body.reason && body.reason.length > 0, '403 must carry a reason');
243
+ }
244
+ }
245
+ finally {
246
+ await admin.destroyTestSession().catch(() => { });
247
+ }
248
+ });
249
+ });
250
+ // Type-level sanity check: confirm the typed XCiteDBForbiddenReason union covers the reasons we
251
+ // assert above. This costs nothing at runtime; if the reasons get renamed server-side without an
252
+ // SDK update, this block will fail to typecheck.
253
+ //
254
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
255
+ function _typeFenceForReasons() {
256
+ const _e = new types_js_1.XCiteDBForbiddenError('Forbidden', 403);
257
+ // Each branch must compile.
258
+ const r = 'already_authenticated';
259
+ const r2 = 'role_forbidden_public_key';
260
+ const r3 = 'auth_admin_required';
261
+ void r;
262
+ void r2;
263
+ void r3;
264
+ }
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AccessCheckResult, AppAuthConfig, AppEmailConfig, AppEmailTemplates, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BranchInfo, BookmarkRecord, CheckpointRecord, CommitRecord, CompareRef, CompareResult, DatabaseContext, DiffRef, DiffResult, DocumentBatchResponse, DocumentExportFormat, ExportDocumentResult, Flags, JsonDocumentBatchItem, ImportDocumentOptions, ImportDocumentResult, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LogEntry, MergeResult, PublishResult, RebaseUserWorkspaceResult, WorkspaceInfo, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, UnqueryTemplate, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, StoredPolicyResponse, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkResult, AssetShareListResponse, AssetShareRequest, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, VectorIndexEstimate, RagQueryOptions, RagQueryResult, RagStreamEvent, OAuthProvidersResponse, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspacesResponse, TokenPair, UserInfo, ApiKeyInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, XCiteDBClientOptions, XCiteDBJwtClaims, TestSessionBootstrapSummary, TestSessionInfo, XCiteQuery, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationShareResult } from './types';
1
+ import { AccessCheckResult, AppAuthConfig, AppEmailConfig, AppEmailTemplates, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BranchInfo, BookmarkRecord, CheckpointRecord, CommitRecord, CompareRef, CompareResult, DatabaseContext, DiffRef, DiffResult, SmartDiffRef, SmartDiffResult, DocumentBatchResponse, DocumentExportFormat, ExportDocumentResult, Flags, JsonDocumentBatchItem, ImportDocumentOptions, ImportDocumentResult, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LogEntry, MergeResult, PublishResult, RebaseUserWorkspaceResult, WorkspaceInfo, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, UnqueryTemplate, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, StoredPolicyResponse, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkResult, AssetShareListResponse, AssetShareRequest, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, VectorIndexEstimate, RagQueryOptions, RagQueryResult, RagStreamEvent, OAuthProvidersResponse, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspacesResponse, TokenPair, UserInfo, ApiKeyInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, XCiteDBClientOptions, XCiteDBJwtClaims, TestSessionBootstrapSummary, TestSessionInfo, XCiteQuery, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationShareResult } from './types';
2
2
  import { WebSocketSubscription } from './websocket';
3
3
  export declare class XCiteDBClient {
4
4
  private baseUrl;
@@ -502,6 +502,26 @@ export declare class XCiteDBClient {
502
502
  includeContent?: boolean;
503
503
  matchStart?: string;
504
504
  }): Promise<DiffResult>;
505
+ /**
506
+ * Compare two XML provision trees structurally + textually and write the resulting
507
+ * diff document — annotated with `<ins>`, `<del>`, `<moved-to>`, `<moved-from>` and
508
+ * `diff:<key>` metadata attributes — to the target location.
509
+ *
510
+ * The output document carries `diff:document="true"` on its root. If the target
511
+ * identifier already holds a regular (non-diff) document, the call returns 409
512
+ * `target_not_smart_diff`; existing smart-diff documents are silently overwritten.
513
+ */
514
+ smartDiff(from: SmartDiffRef, to: SmartDiffRef, target: SmartDiffRef, metadata?: Record<string, string>, options?: {
515
+ diffText?: boolean;
516
+ excludeTags?: string[];
517
+ maxTextDiffBytes?: number;
518
+ /**
519
+ * Hard cap (bytes) on the combined approximate size of source A + source B.
520
+ * The server returns 413 `smart_diff_sources_too_large` when exceeded.
521
+ * Defaults to 8 MiB server-side.
522
+ */
523
+ maxSourceBytes?: number;
524
+ }): Promise<SmartDiffResult>;
505
525
  publishWorkspace(targetWorkspace: string, sourceWorkspace: string, options?: {
506
526
  message?: string;
507
527
  autoResolve?: 'none' | 'source' | 'target';
package/dist/client.js CHANGED
@@ -1566,6 +1566,30 @@ class XCiteDBClient {
1566
1566
  }
1567
1567
  return this.compare(from, to, third);
1568
1568
  }
1569
+ /**
1570
+ * Compare two XML provision trees structurally + textually and write the resulting
1571
+ * diff document — annotated with `<ins>`, `<del>`, `<moved-to>`, `<moved-from>` and
1572
+ * `diff:<key>` metadata attributes — to the target location.
1573
+ *
1574
+ * The output document carries `diff:document="true"` on its root. If the target
1575
+ * identifier already holds a regular (non-diff) document, the call returns 409
1576
+ * `target_not_smart_diff`; existing smart-diff documents are silently overwritten.
1577
+ */
1578
+ async smartDiff(from, to, target, metadata = {}, options = {}) {
1579
+ const body = {
1580
+ from,
1581
+ to,
1582
+ target,
1583
+ metadata,
1584
+ options: {
1585
+ diff_text: options.diffText,
1586
+ exclude_tags: options.excludeTags,
1587
+ max_text_diff_bytes: options.maxTextDiffBytes,
1588
+ max_source_bytes: options.maxSourceBytes,
1589
+ },
1590
+ };
1591
+ return this.request('POST', '/api/v1/smart-diff', body);
1592
+ }
1569
1593
  async publishWorkspace(targetWorkspace, sourceWorkspace, options) {
1570
1594
  const body = {
1571
1595
  source_workspace: sourceWorkspace,
@@ -89,3 +89,299 @@ const types_js_1 = require("./types.js");
89
89
  }
90
90
  });
91
91
  });
92
+ (0, node_test_1.describe)('app-user auth flows clear tokens before request', () => {
93
+ // The bug: a previously-logged-in client sent Authorization: Bearer <stale-token> on
94
+ // /app/auth/login, and the server (correctly) 403'd it as already_authenticated.
95
+ // SDK fix: each of login/register/oauth-exchange/custom-token clears tokens FIRST so the
96
+ // request goes out unauthenticated. These tests freeze that contract.
97
+ async function runWithMockedFetch(cb, response) {
98
+ const requests = [];
99
+ const orig = globalThis.fetch;
100
+ globalThis.fetch = node_test_1.mock.fn(async (input, init) => {
101
+ requests.push({ url: String(input), headers: new Headers(init?.headers) });
102
+ return response();
103
+ });
104
+ try {
105
+ await cb(() => null);
106
+ }
107
+ finally {
108
+ globalThis.fetch = orig;
109
+ }
110
+ return { requests };
111
+ }
112
+ (0, node_test_1.it)('loginAppUser drops a stale Authorization: Bearer before the request', async () => {
113
+ const { requests } = await runWithMockedFetch(async () => {
114
+ const c = new client_js_1.XCiteDBClient({
115
+ baseUrl: 'http://127.0.0.1:9',
116
+ // No apiKey/accessToken — the only credential is the stale app-user token below.
117
+ appUserAccessToken: 'stale-jwt-from-previous-login',
118
+ context: { project_id: 't1' },
119
+ });
120
+ await c.loginAppUser('alice@example.com', 'pw');
121
+ }, () => new Response(JSON.stringify({ access_token: 'new-jwt', refresh_token: 'new-refresh', expires_in: 3600 }), { status: 200 }));
122
+ strict_1.default.equal(requests.length, 1);
123
+ strict_1.default.equal(requests[0].headers.get('Authorization'), null, 'login request must not carry the stale Bearer; otherwise server rejects with already_authenticated');
124
+ strict_1.default.equal(requests[0].headers.get('X-App-User-Token'), null);
125
+ });
126
+ (0, node_test_1.it)('registerAppUser drops stale tokens before the request', async () => {
127
+ const { requests } = await runWithMockedFetch(async () => {
128
+ const c = new client_js_1.XCiteDBClient({
129
+ baseUrl: 'http://127.0.0.1:9',
130
+ appUserAccessToken: 'stale-jwt',
131
+ appUserRefreshToken: 'stale-refresh',
132
+ context: { project_id: 't1' },
133
+ });
134
+ await c.registerAppUser('bob@example.com', 'pw');
135
+ }, () => new Response(JSON.stringify({ user_id: 'u-bob', email: 'bob@example.com' }), { status: 201 }));
136
+ strict_1.default.equal(requests[0].headers.get('Authorization'), null);
137
+ });
138
+ (0, node_test_1.it)('exchangeOAuthCode drops stale tokens before the request', async () => {
139
+ const { requests } = await runWithMockedFetch(async () => {
140
+ const c = new client_js_1.XCiteDBClient({
141
+ baseUrl: 'http://127.0.0.1:9',
142
+ appUserAccessToken: 'stale-jwt',
143
+ context: { project_id: 't1' },
144
+ });
145
+ await c.exchangeOAuthCode('one-time-code');
146
+ }, () => new Response(JSON.stringify({ access_token: 'new', refresh_token: 'r', expires_in: 3600 }), { status: 200 }));
147
+ strict_1.default.equal(requests[0].headers.get('Authorization'), null);
148
+ });
149
+ (0, node_test_1.it)('exchangeCustomToken drops stale tokens before the request', async () => {
150
+ const { requests } = await runWithMockedFetch(async () => {
151
+ const c = new client_js_1.XCiteDBClient({
152
+ baseUrl: 'http://127.0.0.1:9',
153
+ appUserAccessToken: 'stale-jwt',
154
+ // Mixed with an apiKey so the request would otherwise carry both.
155
+ apiKey: 'pub-key',
156
+ });
157
+ await c.exchangeCustomToken('outside-jwt');
158
+ }, () => new Response(JSON.stringify({ access_token: 'new', refresh_token: 'r', expires_in: 3600 }), { status: 200 }));
159
+ // X-API-Key is still allowed; X-App-User-Token must be gone.
160
+ strict_1.default.equal(requests[0].headers.get('X-API-Key'), 'pub-key');
161
+ strict_1.default.equal(requests[0].headers.get('X-App-User-Token'), null);
162
+ });
163
+ (0, node_test_1.it)('loginAppUser stores the new tokens on success', async () => {
164
+ const orig = globalThis.fetch;
165
+ globalThis.fetch = node_test_1.mock.fn(async () => new Response(JSON.stringify({ access_token: 'new-jwt', refresh_token: 'new-refresh', expires_in: 3600 }), { status: 200 }));
166
+ try {
167
+ const c = new client_js_1.XCiteDBClient({
168
+ baseUrl: 'http://127.0.0.1:9',
169
+ context: { project_id: 't1' },
170
+ });
171
+ const pair = await c.loginAppUser('alice', 'pw');
172
+ strict_1.default.equal(pair.access_token, 'new-jwt');
173
+ // Side effect: token is cached for subsequent requests.
174
+ let captured = null;
175
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
176
+ captured = new Headers(init?.headers);
177
+ return new Response(JSON.stringify({}), { status: 200 });
178
+ });
179
+ await c.appUserMe();
180
+ strict_1.default.equal(captured.get('Authorization'), 'Bearer new-jwt');
181
+ }
182
+ finally {
183
+ globalThis.fetch = orig;
184
+ }
185
+ });
186
+ });
187
+ (0, node_test_1.describe)('updateAppAuthConfig', () => {
188
+ (0, node_test_1.it)('PUTs the patch and returns the effective config', async () => {
189
+ let capturedMethod = '';
190
+ let capturedBody = '';
191
+ const orig = globalThis.fetch;
192
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
193
+ capturedMethod = String(init?.method ?? 'GET');
194
+ capturedBody = String(init?.body ?? '');
195
+ return new Response(JSON.stringify({
196
+ enabled: true,
197
+ registration_enabled: true,
198
+ default_groups: ['editor'],
199
+ warnings: [],
200
+ }), { status: 200 });
201
+ });
202
+ try {
203
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'admin-key' });
204
+ const cfg = await c.updateAppAuthConfig({ default_groups: ['editor'] });
205
+ strict_1.default.equal(capturedMethod, 'PUT');
206
+ strict_1.default.match(capturedBody, /"default_groups":\["editor"\]/);
207
+ strict_1.default.deepEqual(cfg.default_groups, ['editor']);
208
+ strict_1.default.deepEqual(cfg.warnings, []);
209
+ }
210
+ finally {
211
+ globalThis.fetch = orig;
212
+ }
213
+ });
214
+ (0, node_test_1.it)('getAppAuthConfig surfaces default_groups_empty warning when present', async () => {
215
+ const orig = globalThis.fetch;
216
+ globalThis.fetch = node_test_1.mock.fn(async () => new Response(JSON.stringify({
217
+ enabled: true,
218
+ registration_enabled: true,
219
+ default_groups: [],
220
+ warnings: ['default_groups_empty'],
221
+ }), { status: 200 }));
222
+ try {
223
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'admin-key' });
224
+ const cfg = await c.getAppAuthConfig();
225
+ strict_1.default.deepEqual(cfg.warnings, ['default_groups_empty']);
226
+ }
227
+ finally {
228
+ globalThis.fetch = orig;
229
+ }
230
+ });
231
+ });
232
+ (0, node_test_1.describe)('createTestSession testAuth modes', () => {
233
+ // The legacy bypass mode lets tests pass while the matching production code path can't
234
+ // possibly succeed (public-key context erased). The SDK now defaults to 'required' so
235
+ // wet tests are production-faithful by default. These tests freeze the resolution rules.
236
+ function mockSessionCreate() {
237
+ return node_test_1.mock.fn(async () => new Response(JSON.stringify({
238
+ session_token: 'tok-123',
239
+ expires_at: Date.now() + 60000,
240
+ session_ttl_seconds: 60,
241
+ }), { status: 201 }));
242
+ }
243
+ async function captureHeadersFromFollowupRequest(create) {
244
+ const orig = globalThis.fetch;
245
+ globalThis.fetch = mockSessionCreate();
246
+ let client;
247
+ try {
248
+ client = await create();
249
+ }
250
+ finally {
251
+ globalThis.fetch = orig;
252
+ }
253
+ let captured = null;
254
+ const orig2 = globalThis.fetch;
255
+ globalThis.fetch = node_test_1.mock.fn(async (_i, init) => {
256
+ captured = new Headers(init?.headers);
257
+ return new Response(JSON.stringify({}), { status: 200 });
258
+ });
259
+ try {
260
+ await client.health();
261
+ }
262
+ finally {
263
+ globalThis.fetch = orig2;
264
+ }
265
+ return captured;
266
+ }
267
+ (0, node_test_1.it)('default sends X-Test-Auth: required and keeps credentials', async () => {
268
+ const h = await captureHeadersFromFollowupRequest(() => client_js_1.XCiteDBClient.createTestSession({ baseUrl: 'http://127.0.0.1:9', apiKey: 'k' }));
269
+ strict_1.default.equal(h.get('X-Test-Auth'), 'required');
270
+ strict_1.default.equal(h.get('X-Test-Session'), 'tok-123');
271
+ strict_1.default.equal(h.get('X-API-Key'), 'k', 'creds preserved in required mode');
272
+ });
273
+ (0, node_test_1.it)("testAuth: 'preserve' sends X-Test-Auth: preserve and keeps credentials", async () => {
274
+ const h = await captureHeadersFromFollowupRequest(() => client_js_1.XCiteDBClient.createTestSession({
275
+ baseUrl: 'http://127.0.0.1:9',
276
+ apiKey: 'k',
277
+ testAuth: 'preserve',
278
+ }));
279
+ strict_1.default.equal(h.get('X-Test-Auth'), 'preserve');
280
+ strict_1.default.equal(h.get('X-API-Key'), 'k');
281
+ });
282
+ (0, node_test_1.it)("testAuth: 'bypass' omits X-Test-Auth and drops credentials", async () => {
283
+ const h = await captureHeadersFromFollowupRequest(() => client_js_1.XCiteDBClient.createTestSession({
284
+ baseUrl: 'http://127.0.0.1:9',
285
+ apiKey: 'k',
286
+ testAuth: 'bypass',
287
+ }));
288
+ strict_1.default.equal(h.get('X-Test-Auth'), null);
289
+ strict_1.default.equal(h.get('X-API-Key'), null, 'bypass drops creds — server synthesizes admin');
290
+ });
291
+ (0, node_test_1.it)('legacy testRequireAuth: false maps to bypass', async () => {
292
+ const h = await captureHeadersFromFollowupRequest(() => client_js_1.XCiteDBClient.createTestSession({
293
+ baseUrl: 'http://127.0.0.1:9',
294
+ apiKey: 'k',
295
+ testRequireAuth: false,
296
+ }));
297
+ strict_1.default.equal(h.get('X-Test-Auth'), null);
298
+ strict_1.default.equal(h.get('X-API-Key'), null);
299
+ });
300
+ (0, node_test_1.it)('legacy testRequireAuth: true maps to required', async () => {
301
+ const h = await captureHeadersFromFollowupRequest(() => client_js_1.XCiteDBClient.createTestSession({
302
+ baseUrl: 'http://127.0.0.1:9',
303
+ apiKey: 'k',
304
+ testRequireAuth: true,
305
+ }));
306
+ strict_1.default.equal(h.get('X-Test-Auth'), 'required');
307
+ strict_1.default.equal(h.get('X-API-Key'), 'k');
308
+ });
309
+ (0, node_test_1.it)('explicit testAuth wins over legacy testRequireAuth', async () => {
310
+ const h = await captureHeadersFromFollowupRequest(() => client_js_1.XCiteDBClient.createTestSession({
311
+ baseUrl: 'http://127.0.0.1:9',
312
+ apiKey: 'k',
313
+ testAuth: 'preserve',
314
+ testRequireAuth: false,
315
+ }));
316
+ strict_1.default.equal(h.get('X-Test-Auth'), 'preserve');
317
+ });
318
+ });
319
+ (0, node_test_1.describe)('enableUserIsolation', () => {
320
+ (0, node_test_1.it)('with explicit config, configures prefixing without any HTTP call', async () => {
321
+ let calls = 0;
322
+ const orig = globalThis.fetch;
323
+ globalThis.fetch = node_test_1.mock.fn(async () => {
324
+ calls += 1;
325
+ return new Response('{}', { status: 200 });
326
+ });
327
+ try {
328
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9' });
329
+ const cfg = await c.enableUserIsolation({ namespace: '/users/{userId}' });
330
+ strict_1.default.equal(calls, 0, 'explicit config skips the network round-trip');
331
+ strict_1.default.equal(cfg.enabled, true);
332
+ strict_1.default.equal(cfg.namespace_pattern, '/users/{userId}');
333
+ }
334
+ finally {
335
+ globalThis.fetch = orig;
336
+ }
337
+ });
338
+ (0, node_test_1.it)('no-arg form prefers the public endpoint', async () => {
339
+ let lastPath = '';
340
+ const orig = globalThis.fetch;
341
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
342
+ lastPath = String(input);
343
+ return new Response(JSON.stringify({
344
+ enabled: true,
345
+ namespace_pattern: '/users/${user.id}',
346
+ shared_read_paths: [],
347
+ shared_write_paths: [],
348
+ }), { status: 200 });
349
+ });
350
+ try {
351
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'public-key' });
352
+ await c.enableUserIsolation();
353
+ strict_1.default.match(lastPath, /\/api\/v1\/security\/user-isolation\/public$/);
354
+ }
355
+ finally {
356
+ globalThis.fetch = orig;
357
+ }
358
+ });
359
+ (0, node_test_1.it)('falls back to admin endpoint when public 404s (older servers)', async () => {
360
+ const calls = [];
361
+ const orig = globalThis.fetch;
362
+ globalThis.fetch = node_test_1.mock.fn(async (input) => {
363
+ const url = String(input);
364
+ calls.push(url);
365
+ if (url.endsWith('/public')) {
366
+ return new Response(JSON.stringify({ message: 'Not found' }), { status: 404 });
367
+ }
368
+ return new Response(JSON.stringify({
369
+ enabled: true,
370
+ namespace_pattern: '/spaces/${user.id}',
371
+ shared_read_paths: ['/public'],
372
+ shared_write_paths: [],
373
+ }), { status: 200 });
374
+ });
375
+ try {
376
+ const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'admin-key' });
377
+ const cfg = await c.enableUserIsolation();
378
+ strict_1.default.equal(calls.length, 2);
379
+ strict_1.default.match(calls[0], /\/public$/);
380
+ strict_1.default.ok(!calls[1].endsWith('/public'));
381
+ strict_1.default.deepEqual(cfg.shared_read_paths, ['/public']);
382
+ }
383
+ finally {
384
+ globalThis.fetch = orig;
385
+ }
386
+ });
387
+ });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { XCiteDBClient } from './client';
2
2
  export { parseAssetUri, formatAssetUri, collectIdentifiersFromText, ASSET_URI_PREFIX } from './assetUri';
3
3
  export { WebSocketSubscription } from './websocket';
4
- export type { AccessCheckResult, ApiKeyInfo, AppAuthConfig, AppEmailConfig, AppEmailSmtpConfig, AppEmailTemplateEntry, AppEmailTemplates, AppEmailWebhookConfig, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BookmarkRecord, BranchInfo, BranchListItem, CheckpointRecord, CommitRecord, CompareEntry, CompareRef, CompareResult, DatabaseContext, DiffEntry, DiffRef, DiffResult, DocumentBatchResponse, DocumentBatchResultRow, DocumentExportFormat, DocumentImportFormat, ExportDocumentResult, Flags, ImportDocumentOptions, ImportDocumentResult, JsonDocumentData, JsonDocumentBatchItem, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LockConflictBody, LockExpiredBody, LockUnknownBody, MergeConflict, MergeResult, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetListItem, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkRecord, AssetMagicLinkResult, AssetShareListEntry, AssetShareListResponse, AssetShareRequest, AssetStorageImport, AssetStorageMount, AssetStorageTarget, AssetStorageTargetType, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PublishConflict, PublishResult, RebaseUserWorkspaceResult, PolicyConditions, PolicyIdentifierPattern, PolicyResources, PolicySubjectInput, PolicySubjects, RagQueryOptions, RagQueryResult, RagStreamEvent, RealtimeEvent, SearchIndexingProgress, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TagRecord, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TokenPair, UserInfo, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationOptions, UserIsolationShareMode, UserIsolationShareResult, WorkspaceInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, TestSessionBootstrap, TestSessionBootstrapSummary, TestSessionInfo, XCiteDBClientOptions, XCiteDBErrorExtras, XCiteDBJwtClaims, UnqueryResult, UnqueryTemplate, XCiteQuery, } from './types';
4
+ export type { AccessCheckResult, ApiKeyInfo, AppAuthConfig, AppEmailConfig, AppEmailSmtpConfig, AppEmailTemplateEntry, AppEmailTemplates, AppEmailWebhookConfig, AppUser, AppUserTokenPair, EmailTestResponse, ForgotPasswordResponse, SendVerificationResponse, BookmarkRecord, BranchInfo, BranchListItem, CheckpointRecord, CommitRecord, CompareEntry, CompareRef, CompareResult, DatabaseContext, DiffEntry, DiffRef, DiffResult, SmartDiffRef, SmartDiffResult, SmartDiffStats, DocumentBatchResponse, DocumentBatchResultRow, DocumentExportFormat, DocumentImportFormat, ExportDocumentResult, Flags, ImportDocumentOptions, ImportDocumentResult, JsonDocumentData, JsonDocumentBatchItem, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LockConflictBody, LockExpiredBody, LockUnknownBody, MergeConflict, MergeResult, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, AssetGcDryRunResult, AssetHeadResult, AssetListItem, AssetListResponse, AssetMagicLinkListResponse, AssetMagicLinkRecord, AssetMagicLinkResult, AssetShareListEntry, AssetShareListResponse, AssetShareRequest, AssetStorageImport, AssetStorageMount, AssetStorageTarget, AssetStorageTargetType, AssetUnshareRequest, AssetUploadResult, CreateAssetMagicLinkRequest, ListAssetsOptions, ProjectAssetStorageConfig, UploadAssetOptions, PlatformDefaultDocConfResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PublishConflict, PublishResult, RebaseUserWorkspaceResult, PolicyConditions, PolicyIdentifierPattern, PolicyResources, PolicySubjectInput, PolicySubjects, RagQueryOptions, RagQueryResult, RagStreamEvent, RealtimeEvent, SearchIndexingProgress, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TagRecord, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TokenPair, UserInfo, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationOptions, UserIsolationShareMode, UserIsolationShareResult, WorkspaceInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, TestSessionBootstrap, TestSessionBootstrapSummary, TestSessionInfo, XCiteDBClientOptions, XCiteDBErrorExtras, XCiteDBJwtClaims, UnqueryResult, UnqueryTemplate, XCiteQuery, } from './types';
5
5
  export { XCiteDBError, XCiteDBForbiddenError, XCiteDBNotFoundError, XCiteDBAuthError, XCiteDBLockConflictError, } from './types';
package/dist/types.d.ts CHANGED
@@ -1057,6 +1057,35 @@ export interface CompareResult {
1057
1057
  }
1058
1058
  /** @deprecated Use {@link CompareResult}. */
1059
1059
  export type DiffResult = CompareResult;
1060
+ /**
1061
+ * Reference to a single document version in a workspace/branch — used by `smartDiff()`
1062
+ * to address Source A, Source B, and the Target. Supports `branch`+`date` (or `date_key`)
1063
+ * or a `checkpoint_id`.
1064
+ */
1065
+ export interface SmartDiffRef {
1066
+ /** Identifier of the document or subtree. Required. */
1067
+ identifier: string;
1068
+ branch?: string;
1069
+ date?: string;
1070
+ date_key?: string;
1071
+ checkpoint_id?: string;
1072
+ }
1073
+ export interface SmartDiffStats {
1074
+ matched: number;
1075
+ ins: number;
1076
+ del: number;
1077
+ moved_parent: number;
1078
+ moved_order: number;
1079
+ }
1080
+ export interface SmartDiffResult {
1081
+ status: 'ok';
1082
+ target: {
1083
+ identifier: string;
1084
+ branch: string;
1085
+ date: string;
1086
+ };
1087
+ stats: SmartDiffStats;
1088
+ }
1060
1089
  export interface PublishConflict {
1061
1090
  identifier: string;
1062
1091
  source_action: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcitedbs/client",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -85,6 +85,7 @@ A path selects a value from the current context.
85
85
  | `[n]` | Array index (0-based) | `"Dependants[0].FirstName"` |
86
86
  | `[expr]` | Computed index | `"Field1[$index+1]"` |
87
87
  | `[]` | Whole array (projection over all elements) | `"Array1[].Field1"` |
88
+ | `[a:b]` | Array **slice** in a context modifier (half-open; negative counts from end) | `"scores:[0:3]"`, `"scores:[-2:]"` |
88
89
  | `/Field` | Absolute from document root | `"/employees.$(.)"` |
89
90
  | `../Field` | Up one path level (skips array indices) | `"../DBInstanceIdentifier"` |
90
91
  | `<<Field` | Read same field in *previous* document context | `"<<Field1"` after a `->$file(...)` |
@@ -259,18 +260,21 @@ Equivalent to SQL `GROUP BY bin`. Combine with aggregate values for grouped stat
259
260
  { "$(Title)": "$avg(Salary)" }
260
261
  ```
261
262
 
262
- ### 5.4 Copy-all keys — `{}` and `{'regex'}`
263
+ ### 5.4 Copy-all keys — `{}`, `{'regex'}`, and slice `{a:b}`
263
264
 
264
- `{}` evaluates to *every* key in the queried object. `{'regex'}` evaluates to keys matching the regex.
265
+ `{}` evaluates to *every* key in the queried object. `{'regex'}` evaluates to keys matching the regex. `{a:b}` is a positional **slice** of the source-key sequence (half-open, negative values count from the end) — keys are visited in their stored sort order, so this naturally gives "first K", "last K", or a middle range.
265
266
 
266
267
  ```json
267
268
  { "{}": "value" } // every key gets the constant 'value' (rarely useful)
268
269
  { "{}:": "." } // copy every key → its value (very useful)
269
270
  { "{'A.*B'}:": "." } // copy only keys matching the regex
270
271
  { "{}:": ".?$key!='ID'" } // copy all except ID (predicate filter)
272
+ { "{:3}:": "." } // first 3 keys (alphabetical)
273
+ { "{-2:}:": "." } // last 2 keys
274
+ { "{2:5}:": "." } // keys at positions 2..4 inclusive
271
275
  ```
272
276
 
273
- The trailing `:` after `{}` is a context modifier ("for each key, switch context to that key"). Without `:`, `{}` is just the empty-key spec.
277
+ The trailing `:` after `{}` is a context modifier ("for each key, switch context to that key"). Without `:`, `{}` is just the empty-key spec. The slice form `{a:b}` mirrors the array slice `[a:b]` (see §4 path expressions and §6 context modifiers): `{:K}` is the first K, `{-K:}` the last K, `{a:b}` the half-open range.
274
278
 
275
279
  ### 5.5 Key utility functions
276
280
 
@@ -298,7 +302,9 @@ A modifier may be followed by `?cond` (predicate). Modifiers chain freely.
298
302
  | `:` (nothing after) | Repeat the key name as the path: `"Field1:"` ≡ `"Field1:Field1"` |
299
303
  | `:[]` | Iterate over each array element |
300
304
  | `:[n]` | Switch to array index `n` |
305
+ | `:[a:b]` | Iterate over the array **slice** at indices `a..b` (half-open; negative counts from end) |
301
306
  | `:{}` | Iterate over each object field |
307
+ | `:{a:b}` | Iterate over the object-key **slice** at positions `a..b` (half-open; negative counts from end) |
302
308
  | `:{'regex'}` | Iterate over fields matching regex |
303
309
  | `:**` | Recursive descent (every path under current) |
304
310
  | `:.` | Stay at current path (rarely needed) |
@@ -308,8 +314,18 @@ A modifier may be followed by `?cond` (predicate). Modifiers chain freely.
308
314
  { "result:Customers[]?Balance>100000:Accounts[]": ["accountNumber"] }
309
315
  { "result:{}": ["."] }
310
316
  { "#return:**": ["$key@unique_ascending"] }
317
+ { "first:scores[:3]": ["."] } // first 3 elements of `scores`
318
+ { "tail:scores[-2:]": ["."] } // last 2 elements
319
+ { "middle:scores[2:5]": ["."] } // elements at positions 2..4
311
320
  ```
312
321
 
322
+ **Slice semantics** (same shape for arrays and object-key iteration):
323
+ - `[:K]` / `{:K}` — first K (start defaults to 0).
324
+ - `[K:]` / `{K:}` — from index K to end.
325
+ - `[a:b]` / `{a:b}` — half-open range `[a, b)`.
326
+ - `[-K:]` / `{-K:}` — last K (negative values count from the end).
327
+ - Out-of-range bounds are clamped; `b ≤ a` yields an empty result.
328
+
313
329
  ### 6.2 The `:Field` shorthand (very common)
314
330
 
315
331
  `"FirstName:"` is identical to `"FirstName:FirstName"`. Use it whenever the result key matches the source field:
@@ -151,7 +151,7 @@ Roughly in parse order:
151
151
 
152
152
  | Prefix / form | AST | Notes |
153
153
  |-----------------|-----|--------|
154
- | `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index. |
154
+ | `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index. (Slice `[a:b]` form is currently parsed only on context modifiers, not in path expressions.) |
155
155
  | `$` `(` `expression` `)` | `TExprField(expr)` | Evaluate / path from dynamic name (`$()`). |
156
156
  | `$if` `(` `condition` `,` `expression` `,` `expression` `)` | `TExprITE` | |
157
157
  | `$call` `(` IDENT `)` | `TExprCall(name)` | Zero-arg user function by name. |
@@ -294,9 +294,12 @@ key ::= '$' '(' expression ')' (* dynamic key → TQParamKey *)
294
294
  | '$' IDENT ... | '%' IDENT ... (* whole thing parsed as expression() → TQParamKey *)
295
295
  | '{' '}' (* TQRegexKey empty regex *)
296
296
  | '{' QUOTED '}' (* TQRegexKey *)
297
+ | '{' slice '}' (* TQRegexKey + slice over source-key sequence *)
297
298
  | '#' directive
298
299
  | pathId (* TQSimpleKey, name may include quoted segments from pathId *)
299
300
 
301
+ slice ::= INT? ':' INT? (* INT may be `-` INT for negative-from-end *)
302
+
300
303
  directive ::= 'if'
301
304
  | 'func' IDENT ( '(' IDENT ( ',' IDENT )* ')' )? (* register arity; TQFuncDefinition *)
302
305
  | 'var' IDENT
@@ -336,8 +339,10 @@ Parsed **after** `key()` from the **same** JSON key string. Mode parameter `Cont
336
339
  | `->` … | `Arrow` | See §8.2 (`identifierExpression`, builtins, legacy JSON-field lookup, `->$each`); often `new_frame = true` |
337
340
  | `[` `]` | `Array` | traversal |
338
341
  | `[` INT `]` | *(string context)* | literal index path segment |
342
+ | `[` (INT)? `:` (INT)? `]` | `Array` + slice | half-open slice; sets `array_slice_set` and the start/end fields on the `TQContextMod`. Negative ints count from end. (`-` token + INT recognized.) |
339
343
  | `.` | *(path)* | context string `"."` |
340
344
  | IDENT… `[` `]`? | `Array` if trailing `[]`, else path | Empty path + no `[` → **`Reskey`** mode |
345
+ | IDENT… `[` slice `]` | `Array` + slice | `Field[a:b]` after a path |
341
346
  | Optional `?` `condition` after each atom | — | Wraps inner mod chain in `TQValueWithCond` |
342
347
 
343
348
  Recursion: `context_mod(...)` parses the **rest** of the chain; result wrapped in `TQContextMod` (expr or string form).