@xcitedbs/client 0.3.0 → 0.3.1

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.1",
4
4
  "description": "XCiteDB BaaS client SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",