@xcitedbs/client 0.2.18 → 0.2.20
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/README.md +1 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.js +104 -7
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +69 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -1
- package/dist/types.d.ts +60 -1
- package/dist/types.js +39 -2
- package/llms-full.txt +129 -11
- package/llms.txt +40 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ Call **`POST /api/v1/test/sessions`** with your normal API key or Bearer (same p
|
|
|
65
65
|
- **Default:** isolated empty LMDB under the server’s `_test/<uuid>/` (writes never touch production).
|
|
66
66
|
- **Overlay:** pass **`overlay: true`** in **`createTestSession`** options (or **`POST`** body **`{"overlay":true}`**). The server layers a writable LMDB on top of the **current project’s on-disk data opened read-only** so you can debug against real data; changes still live only under `_test/<uuid>/`. Use project-scoped credentials or platform Bearer + **`X-Project-Id`** as when calling production APIs.
|
|
67
67
|
|
|
68
|
-
Tear down with **`destroyTestSession()`** (or **`DELETE /api/v1/test/sessions/current`** with the session header). See **`llms.txt`** / **`llms-full.txt`** in this package for full behavior, limits, and auth notes.
|
|
68
|
+
Tear down with **`destroyTestSession()`** (or **`DELETE /api/v1/test/sessions/current`** with the session header). With the **same** API key / Bearer and **no** `X-Test-Session`, use **`listTestSessions()`**, **`destroyAllTestSessions()`**, and **`destroyTestSessionByToken(token)`** to clear leaked sessions before large suites (avoids the default per-credential concurrent cap). See **`llms.txt`** / **`llms-full.txt`** in this package for full behavior, limits, and auth notes.
|
|
69
69
|
|
|
70
70
|
## Build
|
|
71
71
|
|
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, Flags, JsonDocumentBatchItem, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LogEntry, MergeResult, PublishResult, WorkspaceInfo, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, UnqueryTemplate, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, StoredPolicyResponse, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, PlatformDefaultDocConfResponse, VectorIndexEstimate, RagQueryOptions, RagQueryResult, RagStreamEvent, OAuthProvidersResponse, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspacesResponse, TokenPair, UserInfo, ApiKeyInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateTestSessionOptions, XCiteDBClientOptions, XCiteDBJwtClaims, 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, DocumentBatchResponse, Flags, JsonDocumentBatchItem, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LogEntry, MergeResult, PublishResult, WorkspaceInfo, MetaValue, PlatformRegisterResult, PolicySubjectInput, UnqueryResult, UnqueryTemplate, PolicyUpdateResponse, RealtimeEvent, SecurityConfig, SecurityPolicy, StoredTriggerResponse, TriggerDefinition, StoredPolicyResponse, SubscriptionOptions, TagRecord, TextSearchQuery, TextSearchResult, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, 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;
|
|
@@ -19,6 +19,8 @@ export declare class XCiteDBClient {
|
|
|
19
19
|
private userIsolation?;
|
|
20
20
|
private cachedAppUserId?;
|
|
21
21
|
private readonly requestTimeoutMs?;
|
|
22
|
+
/** Set by {@link XCiteDBClient.createTestSession} when the server returns a `bootstrap` summary. */
|
|
23
|
+
lastTestSessionBootstrap?: TestSessionBootstrapSummary;
|
|
22
24
|
constructor(options: XCiteDBClientOptions);
|
|
23
25
|
/**
|
|
24
26
|
* Create an ephemeral test database: calls `POST /api/v1/test/sessions` with your API key or Bearer,
|
|
@@ -55,6 +57,20 @@ export declare class XCiteDBClient {
|
|
|
55
57
|
destroyTestSession(): Promise<{
|
|
56
58
|
message: string;
|
|
57
59
|
}>;
|
|
60
|
+
/**
|
|
61
|
+
* List active test sessions for the current API key or Bearer (`GET /api/v1/test/sessions`).
|
|
62
|
+
* Does not send `X-Test-Session` (management route).
|
|
63
|
+
*/
|
|
64
|
+
listTestSessions(): Promise<TestSessionInfo[]>;
|
|
65
|
+
/** Destroy every test session owned by this credential (`DELETE /api/v1/test/sessions/all`). */
|
|
66
|
+
destroyAllTestSessions(): Promise<{
|
|
67
|
+
message: string;
|
|
68
|
+
destroyed: number;
|
|
69
|
+
}>;
|
|
70
|
+
/** Destroy a single owned session by token (`DELETE /api/v1/test/sessions/{token}`). */
|
|
71
|
+
destroyTestSessionByToken(token: string): Promise<{
|
|
72
|
+
message: string;
|
|
73
|
+
}>;
|
|
58
74
|
/** True if this client would send API key or Bearer credentials on a normal request. */
|
|
59
75
|
private sentAuthCredentials;
|
|
60
76
|
/** 401 on these paths is an expected auth flow outcome, not a dead session. */
|
|
@@ -269,6 +285,10 @@ export declare class XCiteDBClient {
|
|
|
269
285
|
* ```
|
|
270
286
|
*/
|
|
271
287
|
checkAccess(subject: PolicySubjectInput, identifier: string, action: string, branch?: string): Promise<AccessCheckResult>;
|
|
288
|
+
/**
|
|
289
|
+
* Dry-run access (`POST /api/v1/security/check`). Same as {@link checkAccess}; use for debugging 403s.
|
|
290
|
+
*/
|
|
291
|
+
diagnoseAccess(subject: PolicySubjectInput, identifier: string, action: string, branch?: string): Promise<AccessCheckResult>;
|
|
272
292
|
getSecurityConfig(): Promise<SecurityConfig>;
|
|
273
293
|
/**
|
|
274
294
|
* Update tenant security defaults (`PUT /api/v1/security/config`). When ABAC is enabled, pair
|
package/dist/client.js
CHANGED
|
@@ -8,6 +8,44 @@ function joinUrl(base, path) {
|
|
|
8
8
|
const p = path.startsWith('/') ? path : `/${path}`;
|
|
9
9
|
return `${b}${p}`;
|
|
10
10
|
}
|
|
11
|
+
function newClientRequestId() {
|
|
12
|
+
const c = globalThis.crypto?.randomUUID?.();
|
|
13
|
+
if (c)
|
|
14
|
+
return c;
|
|
15
|
+
const a = new Uint8Array(16);
|
|
16
|
+
if (globalThis.crypto?.getRandomValues)
|
|
17
|
+
globalThis.crypto.getRandomValues(a);
|
|
18
|
+
else
|
|
19
|
+
for (let i = 0; i < 16; i++)
|
|
20
|
+
a[i] = Math.floor(Math.random() * 256);
|
|
21
|
+
return Array.from(a, (x) => x.toString(16).padStart(2, '0')).join('');
|
|
22
|
+
}
|
|
23
|
+
function extrasFromErrorBody(data, serverRequestId, clientRequestId) {
|
|
24
|
+
const bodyObj = typeof data === 'object' && data !== null ? data : undefined;
|
|
25
|
+
return {
|
|
26
|
+
reason: bodyObj && typeof bodyObj.reason === 'string' ? bodyObj.reason : undefined,
|
|
27
|
+
policyId: bodyObj && typeof bodyObj.policy_id === 'string' ? bodyObj.policy_id : undefined,
|
|
28
|
+
hint: bodyObj && typeof bodyObj.hint === 'string' ? bodyObj.hint : undefined,
|
|
29
|
+
expectedRole: bodyObj && typeof bodyObj.expected_role === 'string' ? bodyObj.expected_role : undefined,
|
|
30
|
+
actualRole: bodyObj && typeof bodyObj.actual_role === 'string' ? bodyObj.actual_role : undefined,
|
|
31
|
+
serverRequestId,
|
|
32
|
+
clientRequestId,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** @internal */
|
|
36
|
+
function throwForFailedHttp(status, path, data, msg, serverRequestId, clientRequestId) {
|
|
37
|
+
const extras = extrasFromErrorBody(data, serverRequestId, clientRequestId);
|
|
38
|
+
const Ctor = status === 403
|
|
39
|
+
? types_1.XCiteDBForbiddenError
|
|
40
|
+
: status === 404
|
|
41
|
+
? types_1.XCiteDBNotFoundError
|
|
42
|
+
: status === 401
|
|
43
|
+
? types_1.XCiteDBAuthError
|
|
44
|
+
: status === 409 || status === 423
|
|
45
|
+
? types_1.XCiteDBLockConflictError
|
|
46
|
+
: types_1.XCiteDBError;
|
|
47
|
+
throw new Ctor(msg || `HTTP ${status}`, status, data, extras);
|
|
48
|
+
}
|
|
11
49
|
function buildQuery(params) {
|
|
12
50
|
const sp = new URLSearchParams();
|
|
13
51
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -83,8 +121,13 @@ class XCiteDBClient {
|
|
|
83
121
|
userIsolation: opts.userIsolation,
|
|
84
122
|
requestTimeoutMs: opts.requestTimeoutMs,
|
|
85
123
|
});
|
|
86
|
-
const
|
|
87
|
-
|
|
124
|
+
const sessionBody = {};
|
|
125
|
+
if (opts.overlay === true)
|
|
126
|
+
sessionBody.overlay = true;
|
|
127
|
+
if (opts.bootstrap !== undefined)
|
|
128
|
+
sessionBody.bootstrap = opts.bootstrap;
|
|
129
|
+
const data = await temp.request('POST', '/api/v1/test/sessions', Object.keys(sessionBody).length ? sessionBody : undefined, undefined, { no401Retry: true });
|
|
130
|
+
const child = new XCiteDBClient({
|
|
88
131
|
baseUrl: opts.baseUrl,
|
|
89
132
|
apiKey: opts.testRequireAuth ? opts.apiKey : undefined,
|
|
90
133
|
accessToken: opts.testRequireAuth ? opts.accessToken : undefined,
|
|
@@ -101,6 +144,10 @@ class XCiteDBClient {
|
|
|
101
144
|
userIsolation: opts.userIsolation,
|
|
102
145
|
requestTimeoutMs: opts.requestTimeoutMs,
|
|
103
146
|
});
|
|
147
|
+
if (data.bootstrap && typeof data.bootstrap === 'object') {
|
|
148
|
+
child.lastTestSessionBootstrap = data.bootstrap;
|
|
149
|
+
}
|
|
150
|
+
return child;
|
|
104
151
|
}
|
|
105
152
|
/**
|
|
106
153
|
* Canonical `project:<tenant_id>:<role>` group string. The middle segment must match the app JWT `tenant_id`
|
|
@@ -332,6 +379,33 @@ class XCiteDBClient {
|
|
|
332
379
|
}
|
|
333
380
|
return this.request('DELETE', '/api/v1/test/sessions/current', undefined, undefined, { no401Retry: true });
|
|
334
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* List active test sessions for the current API key or Bearer (`GET /api/v1/test/sessions`).
|
|
384
|
+
* Does not send `X-Test-Session` (management route).
|
|
385
|
+
*/
|
|
386
|
+
async listTestSessions() {
|
|
387
|
+
const r = await this.request('GET', '/api/v1/test/sessions', undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
388
|
+
const rows = r.sessions ?? [];
|
|
389
|
+
return rows.map((s) => ({
|
|
390
|
+
sessionToken: String(s.session_token ?? ''),
|
|
391
|
+
createdAt: Number(s.created_at ?? 0),
|
|
392
|
+
lastUsed: Number(s.last_used ?? 0),
|
|
393
|
+
...(s.overlay === true ? { overlay: true } : {}),
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
/** Destroy every test session owned by this credential (`DELETE /api/v1/test/sessions/all`). */
|
|
397
|
+
async destroyAllTestSessions() {
|
|
398
|
+
const r = await this.request('DELETE', '/api/v1/test/sessions/all', undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
399
|
+
return {
|
|
400
|
+
message: String(r.message ?? ''),
|
|
401
|
+
destroyed: Number(r.destroyed ?? 0),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/** Destroy a single owned session by token (`DELETE /api/v1/test/sessions/{token}`). */
|
|
405
|
+
async destroyTestSessionByToken(token) {
|
|
406
|
+
const enc = encodeURIComponent(token);
|
|
407
|
+
return this.request('DELETE', `/api/v1/test/sessions/${enc}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
408
|
+
}
|
|
335
409
|
/** True if this client would send API key or Bearer credentials on a normal request. */
|
|
336
410
|
sentAuthCredentials() {
|
|
337
411
|
return !!(this.apiKey || this.accessToken || this.appUserAccessToken);
|
|
@@ -458,12 +532,17 @@ class XCiteDBClient {
|
|
|
458
532
|
}
|
|
459
533
|
async request(method, path, body, extraHeaders, opts) {
|
|
460
534
|
const no401Retry = opts?.no401Retry === true;
|
|
535
|
+
const suppressTestSessionHeader = opts?.suppressTestSessionHeader === true;
|
|
461
536
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
462
537
|
const url = joinUrl(this.baseUrl, path);
|
|
463
538
|
const headers = {
|
|
464
|
-
...this.
|
|
539
|
+
...this.authHeaders(),
|
|
540
|
+
...this.contextHeaders(),
|
|
541
|
+
...(suppressTestSessionHeader ? {} : this.testHeaders()),
|
|
465
542
|
...extraHeaders,
|
|
466
543
|
};
|
|
544
|
+
const outgoingRequestId = newClientRequestId();
|
|
545
|
+
headers['X-Request-Id'] = outgoingRequestId;
|
|
467
546
|
let init = { method, headers };
|
|
468
547
|
if (body !== undefined) {
|
|
469
548
|
if (typeof body === 'string') {
|
|
@@ -477,7 +556,14 @@ class XCiteDBClient {
|
|
|
477
556
|
const sig = requestTimeoutSignal(this.requestTimeoutMs);
|
|
478
557
|
if (sig)
|
|
479
558
|
init.signal = sig;
|
|
480
|
-
|
|
559
|
+
let res;
|
|
560
|
+
try {
|
|
561
|
+
res = await fetch(url, init);
|
|
562
|
+
}
|
|
563
|
+
catch (e) {
|
|
564
|
+
const m = e instanceof Error ? e.message : 'Network error';
|
|
565
|
+
throw new types_1.XCiteDBError(m, 0, null, { clientRequestId: outgoingRequestId });
|
|
566
|
+
}
|
|
481
567
|
const text = await res.text();
|
|
482
568
|
let data;
|
|
483
569
|
try {
|
|
@@ -499,7 +585,7 @@ class XCiteDBClient {
|
|
|
499
585
|
? String(data.message)
|
|
500
586
|
: res.statusText;
|
|
501
587
|
this.notifySessionInvalidIfNeeded(path, res.status);
|
|
502
|
-
|
|
588
|
+
throwForFailedHttp(res.status, path, data, msg || `HTTP ${res.status}`, res.headers.get('X-Request-Id') ?? undefined, res.headers.get('X-Client-Request-Id') ?? undefined);
|
|
503
589
|
}
|
|
504
590
|
this.notifySessionInvalidIfNeeded(path, 401);
|
|
505
591
|
throw new types_1.XCiteDBError('Request failed after retry', 401, null);
|
|
@@ -926,6 +1012,12 @@ class XCiteDBClient {
|
|
|
926
1012
|
body.branch = branch;
|
|
927
1013
|
return this.request('POST', '/api/v1/security/check', body);
|
|
928
1014
|
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Dry-run access (`POST /api/v1/security/check`). Same as {@link checkAccess}; use for debugging 403s.
|
|
1017
|
+
*/
|
|
1018
|
+
async diagnoseAccess(subject, identifier, action, branch) {
|
|
1019
|
+
return this.checkAccess(subject, identifier, action, branch);
|
|
1020
|
+
}
|
|
929
1021
|
async getSecurityConfig() {
|
|
930
1022
|
return this.request('GET', '/api/v1/security/config');
|
|
931
1023
|
}
|
|
@@ -1680,9 +1772,11 @@ class XCiteDBClient {
|
|
|
1680
1772
|
});
|
|
1681
1773
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1682
1774
|
const url = joinUrl(this.baseUrl, path);
|
|
1775
|
+
const outgoingRequestId = newClientRequestId();
|
|
1683
1776
|
const headers = {
|
|
1684
1777
|
...this.requestHeaders(),
|
|
1685
1778
|
'Content-Type': 'application/json',
|
|
1779
|
+
'X-Request-Id': outgoingRequestId,
|
|
1686
1780
|
};
|
|
1687
1781
|
const res = await fetch(url, { method: 'POST', headers, body: payload });
|
|
1688
1782
|
if (res.status === 401 &&
|
|
@@ -1703,12 +1797,15 @@ class XCiteDBClient {
|
|
|
1703
1797
|
? String(data.message)
|
|
1704
1798
|
: res.statusText;
|
|
1705
1799
|
this.notifySessionInvalidIfNeeded(path, res.status);
|
|
1706
|
-
|
|
1800
|
+
throwForFailedHttp(res.status, path, data, msg || `HTTP ${res.status}`, res.headers.get('X-Request-Id') ?? undefined, res.headers.get('X-Client-Request-Id') ?? undefined);
|
|
1707
1801
|
}
|
|
1708
1802
|
const streamBody = res.body;
|
|
1709
1803
|
if (!streamBody) {
|
|
1710
1804
|
const text = await res.text();
|
|
1711
|
-
throw new types_1.XCiteDBError('RAG stream: empty response body', res.status, text
|
|
1805
|
+
throw new types_1.XCiteDBError('RAG stream: empty response body', res.status, text, {
|
|
1806
|
+
serverRequestId: res.headers.get('X-Request-Id') ?? undefined,
|
|
1807
|
+
clientRequestId: res.headers.get('X-Client-Request-Id') ?? undefined,
|
|
1808
|
+
});
|
|
1712
1809
|
}
|
|
1713
1810
|
const reader = streamBody.getReader();
|
|
1714
1811
|
const decoder = new TextDecoder();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
* Unit tests for HTTP error mapping (mocked fetch).
|
|
8
|
+
*/
|
|
9
|
+
const node_test_1 = require("node:test");
|
|
10
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
11
|
+
const client_js_1 = require("./client.js");
|
|
12
|
+
const types_js_1 = require("./types.js");
|
|
13
|
+
(0, node_test_1.describe)('XCiteDBClient error mapping', () => {
|
|
14
|
+
(0, node_test_1.it)('listTestSessions omits X-Test-Session even when client has testSessionToken', async () => {
|
|
15
|
+
const orig = globalThis.fetch;
|
|
16
|
+
globalThis.fetch = node_test_1.mock.fn(async (input, init) => {
|
|
17
|
+
const h = new Headers(init?.headers);
|
|
18
|
+
strict_1.default.equal(h.get('X-Test-Session'), null);
|
|
19
|
+
strict_1.default.equal(h.get('X-API-Key'), 'test-key');
|
|
20
|
+
return new Response(JSON.stringify({ sessions: [] }), { status: 200 });
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
const c = new client_js_1.XCiteDBClient({
|
|
24
|
+
baseUrl: 'http://127.0.0.1:9',
|
|
25
|
+
apiKey: 'test-key',
|
|
26
|
+
testSessionToken: '00000000-0000-0000-0000-000000000001',
|
|
27
|
+
});
|
|
28
|
+
const list = await c.listTestSessions();
|
|
29
|
+
strict_1.default.deepEqual(list, []);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
globalThis.fetch = orig;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
(0, node_test_1.it)('403 becomes XCiteDBForbiddenError with policy_id and request ids', async () => {
|
|
36
|
+
const orig = globalThis.fetch;
|
|
37
|
+
globalThis.fetch = node_test_1.mock.fn(async () => {
|
|
38
|
+
return new Response(JSON.stringify({
|
|
39
|
+
message: 'Forbidden',
|
|
40
|
+
reason: 'tenant_security_unconfigured',
|
|
41
|
+
policy_id: 'abac_denied',
|
|
42
|
+
hint: 'add policies',
|
|
43
|
+
}), {
|
|
44
|
+
status: 403,
|
|
45
|
+
headers: {
|
|
46
|
+
'X-Request-Id': 'srv-req-1',
|
|
47
|
+
'X-Client-Request-Id': 'cli-req-1',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
const c = new client_js_1.XCiteDBClient({ baseUrl: 'http://127.0.0.1:9', apiKey: 'test-key' });
|
|
53
|
+
await c.health();
|
|
54
|
+
strict_1.default.fail('expected error');
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
strict_1.default.ok(e instanceof types_js_1.XCiteDBForbiddenError);
|
|
58
|
+
const err = e;
|
|
59
|
+
strict_1.default.equal(err.policyId, 'abac_denied');
|
|
60
|
+
strict_1.default.equal(err.reason, 'tenant_security_unconfigured');
|
|
61
|
+
strict_1.default.equal(err.hint, 'add policies');
|
|
62
|
+
strict_1.default.equal(err.serverRequestId, 'srv-req-1');
|
|
63
|
+
strict_1.default.equal(err.clientRequestId, 'cli-req-1');
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
globalThis.fetch = orig;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { XCiteDBClient } from './client';
|
|
2
2
|
export { WebSocketSubscription } from './websocket';
|
|
3
|
-
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, Flags, JsonDocumentData, JsonDocumentBatchItem, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LockConflictBody, LockExpiredBody, LockUnknownBody, MergeConflict, MergeResult, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, PlatformDefaultDocConfResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PublishConflict, PublishResult, 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, XCiteDBClientOptions, XCiteDBJwtClaims, UnqueryResult, UnqueryTemplate, XCiteQuery, } from './types';
|
|
4
|
-
export { XCiteDBError } from './types';
|
|
3
|
+
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, Flags, JsonDocumentData, JsonDocumentBatchItem, IdentifierChildNode, ListIdentifierChildrenResult, ListIdentifiersResult, LockInfo, AcquireLockOptions, LockConflictBody, LockExpiredBody, LockUnknownBody, MergeConflict, MergeResult, OAuthProviderInfo, OAuthProvidersResponse, OwnedTenantInfo, ProjectInfo, PlatformRegistrationConfig, PlatformWorkspaceOrg, PlatformWorkspacesResponse, ProjectSearchSettings, ProjectSearchSettingsUpdate, ProjectDocConfResponse, PlatformDefaultDocConfResponse, LogEntry, MetaValue, PlatformRegisterResult, PolicyUpdateResponse, PublishConflict, PublishResult, 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 { XCiteDBError, XCiteDBForbiddenError, XCiteDBNotFoundError, XCiteDBAuthError, XCiteDBLockConflictError, } from './types';
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.XCiteDBError = exports.WebSocketSubscription = exports.XCiteDBClient = void 0;
|
|
3
|
+
exports.XCiteDBLockConflictError = exports.XCiteDBAuthError = exports.XCiteDBNotFoundError = exports.XCiteDBForbiddenError = exports.XCiteDBError = exports.WebSocketSubscription = exports.XCiteDBClient = void 0;
|
|
4
4
|
var client_1 = require("./client");
|
|
5
5
|
Object.defineProperty(exports, "XCiteDBClient", { enumerable: true, get: function () { return client_1.XCiteDBClient; } });
|
|
6
6
|
var websocket_1 = require("./websocket");
|
|
7
7
|
Object.defineProperty(exports, "WebSocketSubscription", { enumerable: true, get: function () { return websocket_1.WebSocketSubscription; } });
|
|
8
8
|
var types_1 = require("./types");
|
|
9
9
|
Object.defineProperty(exports, "XCiteDBError", { enumerable: true, get: function () { return types_1.XCiteDBError; } });
|
|
10
|
+
Object.defineProperty(exports, "XCiteDBForbiddenError", { enumerable: true, get: function () { return types_1.XCiteDBForbiddenError; } });
|
|
11
|
+
Object.defineProperty(exports, "XCiteDBNotFoundError", { enumerable: true, get: function () { return types_1.XCiteDBNotFoundError; } });
|
|
12
|
+
Object.defineProperty(exports, "XCiteDBAuthError", { enumerable: true, get: function () { return types_1.XCiteDBAuthError; } });
|
|
13
|
+
Object.defineProperty(exports, "XCiteDBLockConflictError", { enumerable: true, get: function () { return types_1.XCiteDBLockConflictError; } });
|
package/dist/types.d.ts
CHANGED
|
@@ -504,6 +504,34 @@ export interface XCiteDBClientOptions {
|
|
|
504
504
|
*/
|
|
505
505
|
requestTimeoutMs?: number;
|
|
506
506
|
}
|
|
507
|
+
/** `bootstrap` payload for `POST /api/v1/test/sessions` (see server docs / `llms.txt`). */
|
|
508
|
+
export interface TestSessionBootstrap {
|
|
509
|
+
user_isolation?: {
|
|
510
|
+
enabled: boolean;
|
|
511
|
+
namespace_pattern: string;
|
|
512
|
+
shared_read_paths?: string[];
|
|
513
|
+
shared_write_paths?: string[];
|
|
514
|
+
};
|
|
515
|
+
developer_bypass?: boolean;
|
|
516
|
+
policies?: Array<{
|
|
517
|
+
policy_id: string;
|
|
518
|
+
policy: SecurityPolicy;
|
|
519
|
+
}>;
|
|
520
|
+
}
|
|
521
|
+
/** `bootstrap` summary returned by the server after a bootstrapped test session is created. */
|
|
522
|
+
export interface TestSessionBootstrapSummary {
|
|
523
|
+
user_isolation_applied?: boolean;
|
|
524
|
+
developer_bypass_applied?: boolean;
|
|
525
|
+
policies_created?: string[];
|
|
526
|
+
}
|
|
527
|
+
/** One row from `GET /api/v1/test/sessions` (management; no `X-Test-Session` on that route). */
|
|
528
|
+
export interface TestSessionInfo {
|
|
529
|
+
sessionToken: string;
|
|
530
|
+
createdAt: number;
|
|
531
|
+
lastUsed: number;
|
|
532
|
+
/** Present when the session was created with overlay mode. */
|
|
533
|
+
overlay?: true;
|
|
534
|
+
}
|
|
507
535
|
/** Options for {@link XCiteDBClient.createTestSession} (provisions via API key or Bearer). */
|
|
508
536
|
export interface CreateTestSessionOptions {
|
|
509
537
|
baseUrl: string;
|
|
@@ -518,6 +546,8 @@ export interface CreateTestSessionOptions {
|
|
|
518
546
|
* When true, creates an overlay test session: writable ephemeral LMDB with production project data as read-only base.
|
|
519
547
|
*/
|
|
520
548
|
overlay?: boolean;
|
|
549
|
+
/** Optional server-side bootstrap (user isolation, developer_bypass, policies). */
|
|
550
|
+
bootstrap?: TestSessionBootstrap;
|
|
521
551
|
/** Keep `apiKey` / `accessToken` on the client and send `X-Test-Auth: required` on each request. */
|
|
522
552
|
testRequireAuth?: boolean;
|
|
523
553
|
onSessionTokensUpdated?: (pair: TokenPair) => void;
|
|
@@ -677,6 +707,7 @@ export interface SecurityConfig {
|
|
|
677
707
|
export interface AccessCheckResult {
|
|
678
708
|
effect: 'allow' | 'deny';
|
|
679
709
|
matched_policy_id?: string;
|
|
710
|
+
reason?: string;
|
|
680
711
|
}
|
|
681
712
|
export interface StoredPolicyResponse {
|
|
682
713
|
policy_id: string;
|
|
@@ -826,8 +857,36 @@ export interface PublishResult {
|
|
|
826
857
|
}
|
|
827
858
|
/** @deprecated Use {@link PublishResult}. */
|
|
828
859
|
export type MergeResult = PublishResult;
|
|
860
|
+
export type XCiteDBErrorExtras = {
|
|
861
|
+
reason?: string;
|
|
862
|
+
policyId?: string;
|
|
863
|
+
hint?: string;
|
|
864
|
+
expectedRole?: string;
|
|
865
|
+
actualRole?: string;
|
|
866
|
+
serverRequestId?: string;
|
|
867
|
+
clientRequestId?: string;
|
|
868
|
+
};
|
|
829
869
|
export declare class XCiteDBError extends Error {
|
|
830
870
|
readonly status: number;
|
|
831
871
|
readonly body?: unknown | undefined;
|
|
832
|
-
|
|
872
|
+
reason?: string;
|
|
873
|
+
policyId?: string;
|
|
874
|
+
hint?: string;
|
|
875
|
+
expectedRole?: string;
|
|
876
|
+
actualRole?: string;
|
|
877
|
+
serverRequestId?: string;
|
|
878
|
+
clientRequestId?: string;
|
|
879
|
+
constructor(message: string, status: number, body?: unknown | undefined, extras?: XCiteDBErrorExtras);
|
|
880
|
+
}
|
|
881
|
+
export declare class XCiteDBForbiddenError extends XCiteDBError {
|
|
882
|
+
constructor(message: string, status: number, body?: unknown, extras?: XCiteDBErrorExtras);
|
|
883
|
+
}
|
|
884
|
+
export declare class XCiteDBNotFoundError extends XCiteDBError {
|
|
885
|
+
constructor(message: string, status: number, body?: unknown, extras?: XCiteDBErrorExtras);
|
|
886
|
+
}
|
|
887
|
+
export declare class XCiteDBAuthError extends XCiteDBError {
|
|
888
|
+
constructor(message: string, status: number, body?: unknown, extras?: XCiteDBErrorExtras);
|
|
889
|
+
}
|
|
890
|
+
export declare class XCiteDBLockConflictError extends XCiteDBError {
|
|
891
|
+
constructor(message: string, status: number, body?: unknown, extras?: XCiteDBErrorExtras);
|
|
833
892
|
}
|
package/dist/types.js
CHANGED
|
@@ -1,12 +1,49 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.XCiteDBError = void 0;
|
|
3
|
+
exports.XCiteDBLockConflictError = exports.XCiteDBAuthError = exports.XCiteDBNotFoundError = exports.XCiteDBForbiddenError = exports.XCiteDBError = void 0;
|
|
4
4
|
class XCiteDBError extends Error {
|
|
5
|
-
constructor(message, status, body) {
|
|
5
|
+
constructor(message, status, body, extras) {
|
|
6
6
|
super(message);
|
|
7
7
|
this.status = status;
|
|
8
8
|
this.body = body;
|
|
9
9
|
this.name = 'XCiteDBError';
|
|
10
|
+
if (extras) {
|
|
11
|
+
this.reason = extras.reason;
|
|
12
|
+
this.policyId = extras.policyId;
|
|
13
|
+
this.hint = extras.hint;
|
|
14
|
+
this.expectedRole = extras.expectedRole;
|
|
15
|
+
this.actualRole = extras.actualRole;
|
|
16
|
+
this.serverRequestId = extras.serverRequestId;
|
|
17
|
+
this.clientRequestId = extras.clientRequestId;
|
|
18
|
+
}
|
|
10
19
|
}
|
|
11
20
|
}
|
|
12
21
|
exports.XCiteDBError = XCiteDBError;
|
|
22
|
+
class XCiteDBForbiddenError extends XCiteDBError {
|
|
23
|
+
constructor(message, status, body, extras) {
|
|
24
|
+
super(message, status, body, extras);
|
|
25
|
+
this.name = 'XCiteDBForbiddenError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.XCiteDBForbiddenError = XCiteDBForbiddenError;
|
|
29
|
+
class XCiteDBNotFoundError extends XCiteDBError {
|
|
30
|
+
constructor(message, status, body, extras) {
|
|
31
|
+
super(message, status, body, extras);
|
|
32
|
+
this.name = 'XCiteDBNotFoundError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
exports.XCiteDBNotFoundError = XCiteDBNotFoundError;
|
|
36
|
+
class XCiteDBAuthError extends XCiteDBError {
|
|
37
|
+
constructor(message, status, body, extras) {
|
|
38
|
+
super(message, status, body, extras);
|
|
39
|
+
this.name = 'XCiteDBAuthError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
exports.XCiteDBAuthError = XCiteDBAuthError;
|
|
43
|
+
class XCiteDBLockConflictError extends XCiteDBError {
|
|
44
|
+
constructor(message, status, body, extras) {
|
|
45
|
+
super(message, status, body, extras);
|
|
46
|
+
this.name = 'XCiteDBLockConflictError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.XCiteDBLockConflictError = XCiteDBLockConflictError;
|
package/llms-full.txt
CHANGED
|
@@ -199,6 +199,19 @@ Browser and mobile apps can use OAuth2-style flows against `/api/v1/app/auth/oau
|
|
|
199
199
|
|
|
200
200
|
---
|
|
201
201
|
|
|
202
|
+
## When a request returns 403
|
|
203
|
+
|
|
204
|
+
XCiteDB returns one of two 403 shapes; both include `reason`:
|
|
205
|
+
|
|
206
|
+
- Generic role/credential 403: `{ "error": "Error", "message": "Forbidden", "code": 403, "reason": "role_forbidden_*"|"auth_*" }`. The credential failed before ABAC ran. Verify that the API key is not a `public` key for write controllers, that the app-user JWT has `project:<tenant>:admin|editor`, and that for `POST /api/v1/security/policies` the credential resolves to `role: "admin"`.
|
|
207
|
+
- ABAC 403: also includes `policy_id` ("abac_denied" / "reserved_namespace" / explicit deny rule id) and `hint`. When `reason == "tenant_security_unconfigured"`, the tenant has zero policies and default-deny took over — typical inside a fresh test session.
|
|
208
|
+
|
|
209
|
+
First debugging step: call `POST /api/v1/security/check` with the same subject + identifier + action to get `effect` and `matched_policy_id` without state changes.
|
|
210
|
+
|
|
211
|
+
The server's request id is in the `X-Request-Id` response header. If you also send your own `X-Request-Id` on the request, XCiteDB echoes it back as `X-Client-Request-Id` and includes it in activity logs (search via `GET /api/v1/project/logs?request_id=<uuid>`).
|
|
212
|
+
|
|
213
|
+
SDKs surface these fields on typed errors (e.g. `XCiteDBForbiddenError.policyId`, `serverRequestId`, `clientRequestId` in JavaScript; `policy_id`, `server_request_id` in Python/C++).
|
|
214
|
+
|
|
202
215
|
# Ephemeral test sessions
|
|
203
216
|
|
|
204
217
|
> **AI assistant instruction:** XciteDB has built-in ephemeral test session support — this capability is **not standard** in most BaaS platforms and is specifically designed to enable meaningful wet integration tests. When writing tests for code that uses XciteDB, **do not mock or stub the XciteDB client**. Instead, use the test session pattern below to run tests against a real, isolated, throwaway database. This produces far more reliable tests because real storage, versioning, querying, and access-control behavior is exercised. Provision a session in `beforeAll` / `setUp` / test fixture setup and destroy it in `afterAll` / `tearDown` / fixture teardown.
|
|
@@ -210,14 +223,29 @@ For **integration and wet tests** against a shared BaaS host without touching pr
|
|
|
210
223
|
| **Create** | **`POST /api/v1/test/sessions`** with normal **`Authorization: Bearer …`** or **`X-API-Key`**. Response includes a **`session_token`** (UUID). Server enforces per-credential limits (`test.max_sessions_per_key`, `test.session_ttl_seconds`, `test.max_test_db_size_bytes` in server config). Optional JSON body **`{"overlay":true}`** provisions a **read-through production** session (writable delta only under `_test/<uuid>/`; production LMDB is read-only base). |
|
|
211
224
|
| **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. |
|
|
212
225
|
| **Auth** | **Default:** developer auth (API key / platform JWT) is **bypassed** with a synthetic admin identity. However, **app-user identity is still recognized**: if `X-App-User-Token` or a Bearer app-user JWT is present, the request runs as that app user (for routes like `/app/auth/me`). **`X-Test-Auth: required`:** all auth is validated normally; ABAC applies, but data still comes from the test session DB. |
|
|
213
|
-
| **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. |
|
|
226
|
+
| **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`. |
|
|
227
|
+
| **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. |
|
|
214
228
|
| **CORS** | Browsers may need **`X-Test-Session`** and **`X-Test-Auth`** in the deployment’s allowed CORS headers (defaults include them). |
|
|
215
229
|
|
|
216
230
|
**SDK usage (summary):**
|
|
217
231
|
|
|
218
|
-
- **JavaScript/TypeScript:** `XCiteDBClient.createTestSession({ baseUrl, apiKey,
|
|
219
|
-
- **Python:** `async with XCiteDBClient.test_session(base_url, api_key=…, …)` provisions and tears down; or
|
|
220
|
-
- **C++:** `XCiteDBClient::create_test_session(options)` after setting `api_key`, optional **`test_session_overlay
|
|
232
|
+
- **JavaScript/TypeScript:** `XCiteDBClient.createTestSession({ baseUrl, apiKey, …, bootstrap })` returns a client configured with `testSessionToken`; optional **`overlay: true`** for overlay mode; optional `testRequireAuth: true` maps to `X-Test-Auth: required`; optional **`bootstrap`** (`user_isolation`, `developer_bypass`, `policies`); read **`lastTestSessionBootstrap`** on the returned client when the server included a summary. `destroyTestSession()` calls `DELETE …/test/sessions/current`. On a **normal** client (same `apiKey` / Bearer, **no** `testSessionToken`), **`listTestSessions()`**, **`destroyAllTestSessions()`**, **`destroyTestSessionByToken(token)`** wrap the management routes (they never send `X-Test-Session`).
|
|
233
|
+
- **Python:** `async with XCiteDBClient.test_session(base_url, api_key=…, …, bootstrap={…})` provisions and tears down; or **`POST /api/v1/test/sessions`** with JSON **`{"overlay":true}`**, **`bootstrap`**, or both, then construct the client with the returned token; **`client.last_test_session_bootstrap`** mirrors the server summary. Management: **`await client.list_test_sessions()`**, **`await client.destroy_all_test_sessions()`**, **`await client.destroy_test_session_by_token(token)`** with `suppress_test_session` behavior (omit `X-Test-Session` on those paths).
|
|
234
|
+
- **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.
|
|
235
|
+
|
|
236
|
+
**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:
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"bootstrap": {
|
|
241
|
+
"user_isolation": { "enabled": true, "namespace_pattern": "/spaces/${user.id}" },
|
|
242
|
+
"developer_bypass": true,
|
|
243
|
+
"policies": []
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Skipping bootstrap yields 403 with `reason: "tenant_security_unconfigured"` for app-user reads/writes under `/spaces/<userId>/...`.
|
|
221
249
|
|
|
222
250
|
**JavaScript/TypeScript — complete test scaffold (Vitest / Jest):**
|
|
223
251
|
|
|
@@ -229,11 +257,14 @@ describe('XciteDB integration', () => {
|
|
|
229
257
|
let client: XCiteDBClient;
|
|
230
258
|
|
|
231
259
|
beforeAll(async () => {
|
|
232
|
-
|
|
233
|
-
client = await XCiteDBClient.createTestSession({
|
|
260
|
+
const base = {
|
|
234
261
|
baseUrl: process.env.XCITEDB_URL ?? 'http://localhost:8080',
|
|
235
262
|
apiKey: process.env.XCITEDB_API_KEY!,
|
|
236
|
-
}
|
|
263
|
+
};
|
|
264
|
+
// Optional: clear sessions leaked from killed CI runs (same API key; default cap is 5).
|
|
265
|
+
const janitor = new XCiteDBClient(base);
|
|
266
|
+
await janitor.destroyAllTestSessions();
|
|
267
|
+
client = await XCiteDBClient.createTestSession(base);
|
|
237
268
|
});
|
|
238
269
|
|
|
239
270
|
afterAll(async () => {
|
|
@@ -517,8 +548,8 @@ Attach structured **JSON metadata** to documents or nodes.
|
|
|
517
548
|
| `identifier` **or** `query` | one required | Target document id or document query |
|
|
518
549
|
| `value` | yes | JSON to write (omit only for string-specific query batch paths handled by the server) |
|
|
519
550
|
| `path` | no (default `""`) | Meta path (dot-separated keys; `[i]` for array indices) |
|
|
520
|
-
| `mode` | no (default `"set"`) | `"set"` — write/replace at `path`. For **arrays** at `path`, indices `0..n-1` are written and any previous tail beyond the new length is cleared. `"append"` —
|
|
521
|
-
| `overwrite` | no (default `false`) | When `true`, delete existing metadata under `path` before applying `value`. |
|
|
551
|
+
| `mode` | no (default `"set"`) | `"set"` — write/replace at `path`. For **arrays** at `path`, indices `0..n-1` are written and any previous tail beyond the new length is cleared. `"append"` — array-extend behavior applies only when the **payload** at `path` is a JSON **array**; new elements are written after the stored length from the array marker `[n]` at `path`. If nothing array-shaped is stored there, the effective prior length is `0` (indices start at `0`). Scalars or a non-array object at `path` are not merged via array-append (object payloads recurse; nested array fields follow the same rules under their own paths). See **Append semantics** below. |
|
|
552
|
+
| `overwrite` | no (default `false`) | When `true`, delete existing metadata under `path` **before** applying `value`. That runs before array logic: with `mode: "append"` it clears the stored `[n]` marker, so the subsequent write always starts at index `0` (you do not keep prior elements). To extend an existing array, use **`overwrite: false`**. For “clear then replace the whole array”, use **`overwrite: true`** with **`mode: "set"`** (or `append` after clear, which is equivalent to writing from `0`). |
|
|
522
553
|
| `first_match` | no | With `query`, only the first matching identifier is updated when `true`. |
|
|
523
554
|
|
|
524
555
|
```json
|
|
@@ -531,7 +562,35 @@ Attach structured **JSON metadata** to documents or nodes.
|
|
|
531
562
|
}
|
|
532
563
|
```
|
|
533
564
|
|
|
534
|
-
|
|
565
|
+
### Append semantics
|
|
566
|
+
|
|
567
|
+
- **`mode: "append"`** only changes array behavior when the **JSON at `path` in this request’s `value`** is an **array** (or when traversing an object payload, each **nested** field whose value is an array, under the same `append` flag). A **scalar** or **non-array object** at `path` does not run “append after `[n]`” logic for that node.
|
|
568
|
+
- To **extend** an existing list in storage, the **stored** value at `path` should already be an array (the server keeps a `[length]` marker). New payload elements are written at indices `length`, `length+1`, … If there is **no** valid array marker at `path` in storage, append still succeeds but behaves like a **first write** from index `0`.
|
|
569
|
+
- **`overwrite: true` + `append`:** do not use this expecting “delete old tail then append onto what was left” — overwrite **removes everything under `path` first**, so append always indexes from **`0`**. Prefer **`overwrite: false`** + **`append`** to grow a list; use **`overwrite: true`** + **`set`** when replacing the whole subtree/array.
|
|
570
|
+
|
|
571
|
+
**Example — extend `tags` without overwrite** (stored `tags` is `["a","b"]`; result `["a","b","c","d"]`):
|
|
572
|
+
|
|
573
|
+
```json
|
|
574
|
+
{
|
|
575
|
+
"identifier": "/book/ch1",
|
|
576
|
+
"path": "tags",
|
|
577
|
+
"mode": "append",
|
|
578
|
+
"overwrite": false,
|
|
579
|
+
"value": ["c", "d"]
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
**Example — same payload with `overwrite: true`** (clears `tags` first; result only `["c","d"]`):
|
|
584
|
+
|
|
585
|
+
```json
|
|
586
|
+
{
|
|
587
|
+
"identifier": "/book/ch1",
|
|
588
|
+
"path": "tags",
|
|
589
|
+
"mode": "append",
|
|
590
|
+
"overwrite": true,
|
|
591
|
+
"value": ["c", "d"]
|
|
592
|
+
}
|
|
593
|
+
```
|
|
535
594
|
|
|
536
595
|
Can also use `"query"` instead of `"identifier"` to target multiple documents by query filter.
|
|
537
596
|
|
|
@@ -681,6 +740,15 @@ Operators: `=`, `!=`, `<`, `>`, `<=`, `>=`, `in`, `not_in`, `contains`, `starts_
|
|
|
681
740
|
|
|
682
741
|
SDK: `UnqueryTemplate` in `@xcitedbs/client` types; method `unquery(query, unquery)`.
|
|
683
742
|
|
|
743
|
+
### `reason` / `policy_id` markers (403 debugging)
|
|
744
|
+
|
|
745
|
+
| reason | policy_id | Meaning |
|
|
746
|
+
|--------|------------|---------|
|
|
747
|
+
| `abac_explicit_deny` | <your-rule-id> | An explicit deny rule matched. |
|
|
748
|
+
| `abac_default_deny` | `abac_denied` | No allow policy matched; tenant `default_effect` (or `app_user_default_effect`) deny took over. |
|
|
749
|
+
| `tenant_security_unconfigured` | `abac_denied` | Same as default deny but the tenant has **zero** policies — usual for a fresh test session without bootstrap. |
|
|
750
|
+
| `abac_reserved_namespace` | `reserved_namespace` | App-user or public-key request touched `_xcitedb*` reserved identifiers. |
|
|
751
|
+
|
|
684
752
|
## ABAC policy expression language
|
|
685
753
|
|
|
686
754
|
Policies may set **`conditions.expression`** (optional) and **`conditions.branches`** (optional array). If `branches` is non-empty, the request branch must match an entry (`"*"`, exact name, or `prefix*`).
|
|
@@ -1314,12 +1382,34 @@ For logged-in users (not only platform admins):
|
|
|
1314
1382
|
|
|
1315
1383
|
---
|
|
1316
1384
|
|
|
1385
|
+
## Wrapping XCiteDB in a higher-level service
|
|
1386
|
+
|
|
1387
|
+
When you build a backend that calls XCiteDB on behalf of users:
|
|
1388
|
+
|
|
1389
|
+
- Never collapse upstream errors to a generic `xcitedb_upstream` code. Pass `body.policy_id`, `body.hint`, `body.reason`, `body.expected_role`, `body.actual_role` through your error wrapper. They are stable, low-cardinality, and safe to surface to test fixtures.
|
|
1390
|
+
- Capture `X-Request-Id` from XCiteDB's response and log it next to your own request id. Send your service's own request id as `X-Request-Id` on outgoing calls; XCiteDB will echo it as `X-Client-Request-Id`.
|
|
1391
|
+
- Distinguish service-key calls vs end-user-token calls in your error trace; tag the call site so a 403 from a user-token spine read and a 403 from an admin `createPolicy` are distinguishable.
|
|
1392
|
+
- Do not assume an end-user implicitly has read access to documents under `/spaces/<their_user_id>/....` That is only true when user isolation is enabled on the tenant with the matching `namespace_pattern`. In production tenants this is one-time setup; in test sessions use the bootstrap option on `POST /api/v1/test/sessions`.
|
|
1393
|
+
|
|
1317
1394
|
# Part 3: SDK Reference
|
|
1318
1395
|
|
|
1319
1396
|
# JavaScript/TypeScript SDK (`@xcitedbs/client`)
|
|
1320
1397
|
|
|
1321
1398
|
Install: `npm install @xcitedbs/client`
|
|
1322
1399
|
|
|
1400
|
+
### Typed 403 handling
|
|
1401
|
+
|
|
1402
|
+
```typescript
|
|
1403
|
+
import { XCiteDBClient, XCiteDBForbiddenError } from '@xcitedbs/client';
|
|
1404
|
+
try {
|
|
1405
|
+
await client.readJsonDocument('/spaces/u1/docs/d/spine.json');
|
|
1406
|
+
} catch (e) {
|
|
1407
|
+
if (e instanceof XCiteDBForbiddenError) {
|
|
1408
|
+
console.error({ reason: e.reason, policyId: e.policyId, hint: e.hint, requestId: e.serverRequestId });
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1323
1413
|
## Quick Start
|
|
1324
1414
|
|
|
1325
1415
|
```typescript
|
|
@@ -1592,6 +1682,19 @@ class XCiteDBError extends Error {
|
|
|
1592
1682
|
|
|
1593
1683
|
Install: `pip install xcitedb`
|
|
1594
1684
|
|
|
1685
|
+
### Typed 403 handling
|
|
1686
|
+
|
|
1687
|
+
```python
|
|
1688
|
+
from xcitedb import XCiteDBClient, XCiteDBForbiddenError
|
|
1689
|
+
|
|
1690
|
+
async def main():
|
|
1691
|
+
client = XCiteDBClient("http://localhost:8080", api_key="your-key")
|
|
1692
|
+
try:
|
|
1693
|
+
await client.read_json_document("/spaces/u1/docs/d/spine.json")
|
|
1694
|
+
except XCiteDBForbiddenError as e:
|
|
1695
|
+
print(e.reason, e.policy_id, e.hint, e.server_request_id)
|
|
1696
|
+
```
|
|
1697
|
+
|
|
1595
1698
|
```python
|
|
1596
1699
|
import asyncio
|
|
1597
1700
|
from xcitedb import (
|
|
@@ -1644,6 +1747,21 @@ q.match_start = "/manual/";
|
|
|
1644
1747
|
auto ids = client.query_documents(q);
|
|
1645
1748
|
```
|
|
1646
1749
|
|
|
1647
|
-
Synchronous (blocking) HTTP client. Methods mirror the JavaScript SDK with C++ naming (`write_xml_document`, deprecated `write_document_json`). Errors throw `xcitedb::XCiteDBError` with `.status()
|
|
1750
|
+
Synchronous (blocking) HTTP client. Methods mirror the JavaScript SDK with C++ naming (`write_xml_document`, deprecated `write_document_json`). Errors throw `xcitedb::XCiteDBError` (or subclasses such as `xcitedb::XCiteDBForbiddenError`) with `.status()`, `.body()`, `.reason()`, `.policy_id()`, `.hint()`, `.server_request_id()`, and `.client_request_id()`.
|
|
1751
|
+
|
|
1752
|
+
### Typed 403 handling
|
|
1753
|
+
|
|
1754
|
+
```cpp
|
|
1755
|
+
#include <xcitedb/xcitedb.hpp>
|
|
1756
|
+
#include <iostream>
|
|
1757
|
+
|
|
1758
|
+
void example(xcitedb::XCiteDBClient& client) {
|
|
1759
|
+
try {
|
|
1760
|
+
client.read_json_document("/spaces/u1/docs/d/spine.json");
|
|
1761
|
+
} catch (const xcitedb::XCiteDBForbiddenError& e) {
|
|
1762
|
+
std::cerr << e.reason() << " " << e.policy_id() << " " << e.hint() << " " << e.server_request_id() << "\n";
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
```
|
|
1648
1766
|
|
|
1649
1767
|
Includes optional `xcitevcs` CLI for command-line operations (legacy branch/commit vocabulary on the wire, documents, search, import/export).
|
package/llms.txt
CHANGED
|
@@ -101,6 +101,19 @@ await app.writeJsonDocument('userdata/alice/profile', { ok: true });
|
|
|
101
101
|
|
|
102
102
|
**Python:** `XCiteDBClient.build_project_group(project_id, "editor")` or module `build_project_group`. **C++:** `XCiteDBClient::build_project_group(project_id, "editor")`.
|
|
103
103
|
|
|
104
|
+
## When a request returns 403
|
|
105
|
+
|
|
106
|
+
XCiteDB returns one of two 403 shapes; both include `reason`:
|
|
107
|
+
|
|
108
|
+
- Generic role/credential 403: `{ "error": "Error", "message": "Forbidden", "code": 403, "reason": "role_forbidden_*"|"auth_*" }`. The credential failed before ABAC ran. Verify that the API key is not a `public` key for write controllers, that the app-user JWT has `project:<tenant>:admin|editor`, and that for `POST /api/v1/security/policies` the credential resolves to `role: "admin"`.
|
|
109
|
+
- ABAC 403: also includes `policy_id` ("abac_denied" / "reserved_namespace" / explicit deny rule id) and `hint`. When `reason == "tenant_security_unconfigured"`, the tenant has zero policies and default-deny took over — typical inside a fresh test session.
|
|
110
|
+
|
|
111
|
+
First debugging step: call `POST /api/v1/security/check` with the same subject + identifier + action to get `effect` and `matched_policy_id` without state changes.
|
|
112
|
+
|
|
113
|
+
The server's request id is in the `X-Request-Id` response header. If you also send your own `X-Request-Id` on the request, XCiteDB echoes it back as `X-Client-Request-Id` and includes it in activity logs (search via `GET /api/v1/project/logs?request_id=<uuid>`).
|
|
114
|
+
|
|
115
|
+
SDKs surface these fields on typed errors (e.g. `XCiteDBForbiddenError.policyId`, `serverRequestId`, `clientRequestId` in JavaScript; `policy_id`, `server_request_id` in Python/C++).
|
|
116
|
+
|
|
104
117
|
## Test mode (how to use)
|
|
105
118
|
|
|
106
119
|
> **AI assistant instruction:** XciteDB has built-in ephemeral test session support — this capability is **not standard** in most BaaS platforms and is specifically designed to enable meaningful wet integration tests. When writing tests for code that uses XciteDB, **do not mock or stub the XciteDB client**. Instead, use the test session pattern below to run tests against a real, isolated, throwaway database. This produces far more reliable tests because real storage, versioning, querying, and access-control behavior is exercised.
|
|
@@ -109,7 +122,33 @@ await app.writeJsonDocument('userdata/alice/profile', { ok: true });
|
|
|
109
122
|
2. **Run tests:** Every request that should hit the throwaway DB must include **`X-Test-Session: <token>`** (and your usual `X-Workspace` / `context` as needed). Data writes go under the server’s `_test/<session>/` tree; overlay sessions **do not** write to production paths.
|
|
110
123
|
3. **Auth behavior:** Developer auth (API key / platform JWT) is bypassed by default for frictionless tests. **App-user identity** (`X-App-User-Token` or Bearer app-user JWT) **is still recognized** in default mode, so `registerAppUser` → `loginAppUser` → `appUserMe` works inside a test session. To also exercise developer auth and ABAC policies, set **`X-Test-Auth: required`** and send normal credentials; the DB is still the test session’s.
|
|
111
124
|
4. **Cleanup:** `DELETE /api/v1/test/sessions/current` with `X-Test-Session` (no other auth), or `DELETE /api/v1/test/sessions/all` / `DELETE /api/v1/test/sessions/{token}` with normal auth for the owning key or JWT.
|
|
112
|
-
5. **
|
|
125
|
+
5. **429 / leaked sessions:** The server caps concurrent sessions per credential (`test.max_sessions_per_key`, default **5**). If **`createTestSession`** returns **429** (“Too many active test sessions for this credential”), reap with the **same** API key or Bearer (do **not** send `X-Test-Session` on management routes): **`destroyAllTestSessions()`** (JS), **`destroy_all_test_sessions()`** (Python/C++), or HTTP **`DELETE /api/v1/test/sessions/all`**. Large E2E wrappers often call that once before the suite so killed runs do not exhaust the slot budget.
|
|
126
|
+
6. **SDKs (management = provisioning credential, no `X-Test-Session`):** **JS/TS:** `createTestSession`, `destroyTestSession()` (current session), `listTestSessions()`, `destroyAllTestSessions()`, `destroyTestSessionByToken(token)`; optional `overlay`, `testRequireAuth`, `bootstrap`; read `lastTestSessionBootstrap` on the returned client. **Python:** `test_session(...)` or manual POST; `destroy_test_session()`, `list_test_sessions()`, `destroy_all_test_sessions()`, `destroy_test_session_by_token(token)`; `last_test_session_bootstrap`. **C++:** `create_test_session`, `destroy_test_session()`, `list_test_sessions()`, `destroy_all_test_sessions()`, `destroy_test_session_by_token(token)`; `last_test_session_bootstrap`. **MCP:** tools `list_test_sessions`, `destroy_all_test_sessions`, `destroy_test_session_by_token`.
|
|
127
|
+
|
|
128
|
+
**A fresh test session is an empty tenant with zero policies.** With `X-Test-Auth: required` the AuthFilter runs normally and ABAC is enforced against the test tenant — default-deny rejects every app-user read/write until you either enable user isolation or add explicit allow policies. The standard recipe is to use the bootstrap option on `POST /api/v1/test/sessions`:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"bootstrap": {
|
|
133
|
+
"user_isolation": { "enabled": true, "namespace_pattern": "/spaces/${user.id}" },
|
|
134
|
+
"developer_bypass": true,
|
|
135
|
+
"policies": []
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
If you skip the bootstrap, an app-user JWT cannot read or write `/spaces/<userId>/...` — even paths the user "owns" — and you will see 403 with reason `tenant_security_unconfigured`.
|
|
141
|
+
|
|
142
|
+
**SDK one-liners for bootstrap:** **JS:** `XCiteDBClient.createTestSession({ baseUrl, apiKey, testRequireAuth: true, bootstrap: { user_isolation: { enabled: true, namespace_pattern: '/spaces/${user.id}' }, developer_bypass: true } })`. **Python:** `async with XCiteDBClient.test_session(base_url, api_key=sk, test_require_auth=True, bootstrap={...}) as c:`. **C++:** set `options.test_session_bootstrap = nlohmann::json::parse(R"(...)");` then `XCiteDBClient::create_test_session(options)`.
|
|
143
|
+
|
|
144
|
+
## Wrapping XCiteDB in a higher-level service
|
|
145
|
+
|
|
146
|
+
When you build a backend that calls XCiteDB on behalf of users:
|
|
147
|
+
|
|
148
|
+
- Never collapse upstream errors to a generic `xcitedb_upstream` code. Pass `body.policy_id`, `body.hint`, `body.reason`, `body.expected_role`, `body.actual_role` through your error wrapper. They are stable, low-cardinality, and safe to surface to test fixtures.
|
|
149
|
+
- Capture `X-Request-Id` from XCiteDB's response and log it next to your own request id. Send your service's own request id as `X-Request-Id` on outgoing calls; XCiteDB will echo it as `X-Client-Request-Id`.
|
|
150
|
+
- Distinguish service-key calls vs end-user-token calls in your error trace; tag the call site so a 403 from a user-token spine read and a 403 from an admin `createPolicy` are distinguishable.
|
|
151
|
+
- Do not assume an end-user implicitly has read access to documents under `/spaces/<their_user_id>/....` That is only true when user isolation is enabled on the tenant with the matching `namespace_pattern`. In production tenants this is one-time setup; in test sessions use the bootstrap option.
|
|
113
152
|
|
|
114
153
|
## JSON documents and metadata (merge, overwrite, efficiency)
|
|
115
154
|
|