@xcitedbs/client 0.2.27 → 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.
- package/dist/bootstrap-real.test.d.ts +1 -0
- package/dist/bootstrap-real.test.js +264 -0
- package/dist/client.d.ts +100 -8
- package/dist/client.js +160 -14
- package/dist/client.test.js +296 -0
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +77 -5
- package/llms-full.txt +7 -5
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -15,7 +15,12 @@ export declare class XCiteDBClient {
|
|
|
15
15
|
private onAppUserTokensUpdated?;
|
|
16
16
|
private onSessionInvalid?;
|
|
17
17
|
private testSessionToken?;
|
|
18
|
-
|
|
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`
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
321
|
-
*
|
|
322
|
-
|
|
323
|
-
|
|
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.
|
|
@@ -430,6 +502,26 @@ export declare class XCiteDBClient {
|
|
|
430
502
|
includeContent?: boolean;
|
|
431
503
|
matchStart?: string;
|
|
432
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>;
|
|
433
525
|
publishWorkspace(targetWorkspace: string, sourceWorkspace: string, options?: {
|
|
434
526
|
message?: string;
|
|
435
527
|
autoResolve?: 'none' | 'source' | 'target';
|
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.
|
|
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`
|
|
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:
|
|
152
|
-
accessToken:
|
|
153
|
-
appUserAccessToken:
|
|
154
|
-
appUserRefreshToken:
|
|
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
|
-
|
|
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
|
-
|
|
540
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
1189
|
-
*
|
|
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
|
-
|
|
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,
|
|
@@ -1444,6 +1566,30 @@ class XCiteDBClient {
|
|
|
1444
1566
|
}
|
|
1445
1567
|
return this.compare(from, to, third);
|
|
1446
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
|
+
}
|
|
1447
1593
|
async publishWorkspace(targetWorkspace, sourceWorkspace, options) {
|
|
1448
1594
|
const body = {
|
|
1449
1595
|
source_workspace: sourceWorkspace,
|
package/dist/client.test.js
CHANGED
|
@@ -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
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|
|
@@ -1022,6 +1057,35 @@ export interface CompareResult {
|
|
|
1022
1057
|
}
|
|
1023
1058
|
/** @deprecated Use {@link CompareResult}. */
|
|
1024
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
|
+
}
|
|
1025
1089
|
export interface PublishConflict {
|
|
1026
1090
|
identifier: string;
|
|
1027
1091
|
source_action: string;
|
|
@@ -1051,8 +1115,16 @@ export interface RebaseUserWorkspaceResult {
|
|
|
1051
1115
|
auto_mergeable?: string[];
|
|
1052
1116
|
would_expose?: string[];
|
|
1053
1117
|
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Canonical 403 `reason` codes the server can emit. Use as a discriminator on
|
|
1120
|
+
* `XCiteDBForbiddenError.reason` in catch sites — TypeScript will warn if you forget a case.
|
|
1121
|
+
*
|
|
1122
|
+
* The trailing `(string & {})` keeps the type open to forward-compatible reasons added by newer
|
|
1123
|
+
* servers without forcing a SDK bump on every new code.
|
|
1124
|
+
*/
|
|
1125
|
+
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
1126
|
export type XCiteDBErrorExtras = {
|
|
1055
|
-
reason?:
|
|
1127
|
+
reason?: XCiteDBForbiddenReason;
|
|
1056
1128
|
policyId?: string;
|
|
1057
1129
|
hint?: string;
|
|
1058
1130
|
expectedRole?: string;
|
|
@@ -1063,7 +1135,7 @@ export type XCiteDBErrorExtras = {
|
|
|
1063
1135
|
export declare class XCiteDBError extends Error {
|
|
1064
1136
|
readonly status: number;
|
|
1065
1137
|
readonly body?: unknown | undefined;
|
|
1066
|
-
reason?:
|
|
1138
|
+
reason?: XCiteDBForbiddenReason;
|
|
1067
1139
|
policyId?: string;
|
|
1068
1140
|
hint?: string;
|
|
1069
1141
|
expectedRole?: string;
|
package/llms-full.txt
CHANGED
|
@@ -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()`
|
|
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
|
|
|
@@ -227,16 +227,18 @@ For **integration and wet tests** against a shared BaaS host without touching pr
|
|
|
227
227
|
|------|------------|
|
|
228
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** |
|
|
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;
|
|
238
|
-
- **Python:** `async with XCiteDBClient.test_session(base_url, api_key=…, …, bootstrap={…})` provisions and tears down;
|
|
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
|
|