@xcitedbs/client 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +70 -1
- package/dist/client.js +114 -11
- package/dist/client.test.js +74 -0
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +85 -3
- package/dist/user-isolation.test.js +88 -0
- package/llms-full.txt +93 -4
- package/package.json +1 -1
- package/unquery-ai-guide.md +29 -1
- package/unquery-grammar.md +6 -4
package/dist/client.d.ts
CHANGED
|
@@ -82,8 +82,25 @@ export declare class XCiteDBClient {
|
|
|
82
82
|
private getAppUserId;
|
|
83
83
|
private normalizeIsolationNamespaceTemplate;
|
|
84
84
|
private canonicalId;
|
|
85
|
-
/**
|
|
85
|
+
/** Resolve the configured namespace template for an explicit user id. Returns null if isolation is off, no template, or `userId` is empty. */
|
|
86
|
+
private userIsolationNamespaceFor;
|
|
87
|
+
/** Resolved namespace root for the calling user, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
|
|
86
88
|
private userIsolationNamespace;
|
|
89
|
+
/**
|
|
90
|
+
* Namespace for the current request's identity context.
|
|
91
|
+
*
|
|
92
|
+
* On a user-workspace branch (`_uw/<owner>/<slug>`) where the calling user is
|
|
93
|
+
* a member but not the owner, returns the *owner's* namespace — docs in the
|
|
94
|
+
* workspace are stored at `<owner_ns>/<rel>`, not at the caller's
|
|
95
|
+
* `<self_ns>/<rel>`. For the owner, non-`_uw/` branches, malformed
|
|
96
|
+
* branches, or when isolation is off, falls back to the calling user's
|
|
97
|
+
* namespace.
|
|
98
|
+
*
|
|
99
|
+
* Mirrors the server-side branch-aware behavior in
|
|
100
|
+
* `UserIsolationService::prefixIdentifier` so the SDK's pre-prefixing
|
|
101
|
+
* lands on the right namespace before the request leaves.
|
|
102
|
+
*/
|
|
103
|
+
private userIsolationContextNamespace;
|
|
87
104
|
private allSharedPassthroughPrefixes;
|
|
88
105
|
private pathMatchesSharedPassthrough;
|
|
89
106
|
private isoPrefixId;
|
|
@@ -108,6 +125,58 @@ export declare class XCiteDBClient {
|
|
|
108
125
|
destroyTestSessionByToken(token: string): Promise<{
|
|
109
126
|
message: string;
|
|
110
127
|
}>;
|
|
128
|
+
/**
|
|
129
|
+
* Create a long-lived developer sandbox via `POST /api/v1/sandboxes`. Returns the server's
|
|
130
|
+
* `SandboxInfo`. To start using the sandbox immediately, follow up with `client.useSandbox(name)`,
|
|
131
|
+
* or mint a sandbox-bound API key with `mintSandboxApiKey` and route subsequent requests through
|
|
132
|
+
* a fresh client constructed with that key (no header threading needed).
|
|
133
|
+
*/
|
|
134
|
+
createSandbox(opts: import('./types').CreateSandboxOptions): Promise<import('./types').SandboxInfo>;
|
|
135
|
+
/** List sandboxes for the current project (`GET /api/v1/sandboxes`). */
|
|
136
|
+
listSandboxes(): Promise<import('./types').SandboxInfo[]>;
|
|
137
|
+
/** Fetch a sandbox's detail by name (`GET /api/v1/sandboxes/{name}`). */
|
|
138
|
+
getSandbox(name: string): Promise<import('./types').SandboxInfo>;
|
|
139
|
+
/** Update mutable sandbox fields (`PATCH /api/v1/sandboxes/{name}`). */
|
|
140
|
+
updateSandbox(name: string, patch: Partial<Pick<import('./types').SandboxInfo, 'description' | 'pinned' | 'expires_at' | 'effects_policy'>>): Promise<import('./types').SandboxInfo>;
|
|
141
|
+
/** Drop overlay writes for a sandbox while preserving membership and bound keys. */
|
|
142
|
+
resetSandbox(name: string): Promise<{
|
|
143
|
+
message: string;
|
|
144
|
+
session_token: string;
|
|
145
|
+
}>;
|
|
146
|
+
/** Destroy a sandbox; revokes bound API keys and removes membership rows. Owner only. */
|
|
147
|
+
destroySandbox(name: string): Promise<{
|
|
148
|
+
message: string;
|
|
149
|
+
session_token: string;
|
|
150
|
+
}>;
|
|
151
|
+
listSandboxMembers(name: string): Promise<import('./types').SandboxMember[]>;
|
|
152
|
+
addSandboxMember(name: string, member_id: string, role: 'editor' | 'viewer'): Promise<{
|
|
153
|
+
message: string;
|
|
154
|
+
}>;
|
|
155
|
+
removeSandboxMember(name: string, member_id: string): Promise<{
|
|
156
|
+
message: string;
|
|
157
|
+
}>;
|
|
158
|
+
/**
|
|
159
|
+
* Mint an API key bound to the given sandbox. Requests presenting this key automatically route
|
|
160
|
+
* into the sandbox without an `X-Test-Session` header — paste it into your dev `.env` and
|
|
161
|
+
* existing client/curl/SDK calls just work. The `api_key` field is shown once; persist it now.
|
|
162
|
+
*/
|
|
163
|
+
mintSandboxApiKey(name: string, opts?: {
|
|
164
|
+
name?: string;
|
|
165
|
+
role?: 'admin' | 'editor' | 'viewer';
|
|
166
|
+
key_type?: 'secret' | 'public';
|
|
167
|
+
expires_at?: number;
|
|
168
|
+
}): Promise<import('./types').SandboxApiKeyMintResult>;
|
|
169
|
+
/**
|
|
170
|
+
* Pin this client to a named sandbox: subsequent requests carry `X-Test-Session: <token>`.
|
|
171
|
+
* Returns the sandbox info. Throws if the name does not resolve.
|
|
172
|
+
*
|
|
173
|
+
* If you instead want zero-header DX, mint a sandbox-bound API key with
|
|
174
|
+
* `mintSandboxApiKey(name)` and construct a fresh client with that `apiKey` — the server
|
|
175
|
+
* pre-filter resolves the binding without touching the test-session header.
|
|
176
|
+
*/
|
|
177
|
+
useSandbox(name: string): Promise<import('./types').SandboxInfo>;
|
|
178
|
+
/** Clear any pinned test/sandbox session token from this client (subsequent requests hit production). */
|
|
179
|
+
clearSandbox(): void;
|
|
111
180
|
/** True if this client would send API key or Bearer credentials on a normal request. */
|
|
112
181
|
private sentAuthCredentials;
|
|
113
182
|
/** 401 on these paths is an expected auth flow outcome, not a dead session. */
|
package/dist/client.js
CHANGED
|
@@ -185,6 +185,8 @@ class XCiteDBClient {
|
|
|
185
185
|
const sessionBody = {};
|
|
186
186
|
if (opts.overlay === true)
|
|
187
187
|
sessionBody.overlay = true;
|
|
188
|
+
if (opts.additionalKeys !== undefined)
|
|
189
|
+
sessionBody.additional_keys = opts.additionalKeys;
|
|
188
190
|
if (opts.bootstrap !== undefined)
|
|
189
191
|
sessionBody.bootstrap = opts.bootstrap;
|
|
190
192
|
const data = await temp.request('POST', '/api/v1/test/sessions', Object.keys(sessionBody).length ? sessionBody : undefined, undefined, { no401Retry: true });
|
|
@@ -521,13 +523,12 @@ class XCiteDBClient {
|
|
|
521
523
|
}
|
|
522
524
|
return t;
|
|
523
525
|
}
|
|
524
|
-
/**
|
|
525
|
-
|
|
526
|
+
/** Resolve the configured namespace template for an explicit user id. Returns null if isolation is off, no template, or `userId` is empty. */
|
|
527
|
+
userIsolationNamespaceFor(userId) {
|
|
526
528
|
if (!this.userIsolation?.enabled) {
|
|
527
529
|
return null;
|
|
528
530
|
}
|
|
529
|
-
|
|
530
|
-
if (!uid) {
|
|
531
|
+
if (!userId) {
|
|
531
532
|
return null;
|
|
532
533
|
}
|
|
533
534
|
const rawTpl = this.normalizeIsolationNamespaceTemplate(this.userIsolation.namespace ?? '/users/{userId}');
|
|
@@ -535,7 +536,42 @@ class XCiteDBClient {
|
|
|
535
536
|
if (!trimmed) {
|
|
536
537
|
return null;
|
|
537
538
|
}
|
|
538
|
-
return trimmed.replace(/{userId}/g,
|
|
539
|
+
return trimmed.replace(/{userId}/g, userId).replace(/{user_id}/g, userId);
|
|
540
|
+
}
|
|
541
|
+
/** Resolved namespace root for the calling user, e.g. `/users/abc`, or `null` if isolation is off or no app user id. */
|
|
542
|
+
userIsolationNamespace() {
|
|
543
|
+
return this.userIsolationNamespaceFor(this.getAppUserId());
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Namespace for the current request's identity context.
|
|
547
|
+
*
|
|
548
|
+
* On a user-workspace branch (`_uw/<owner>/<slug>`) where the calling user is
|
|
549
|
+
* a member but not the owner, returns the *owner's* namespace — docs in the
|
|
550
|
+
* workspace are stored at `<owner_ns>/<rel>`, not at the caller's
|
|
551
|
+
* `<self_ns>/<rel>`. For the owner, non-`_uw/` branches, malformed
|
|
552
|
+
* branches, or when isolation is off, falls back to the calling user's
|
|
553
|
+
* namespace.
|
|
554
|
+
*
|
|
555
|
+
* Mirrors the server-side branch-aware behavior in
|
|
556
|
+
* `UserIsolationService::prefixIdentifier` so the SDK's pre-prefixing
|
|
557
|
+
* lands on the right namespace before the request leaves.
|
|
558
|
+
*/
|
|
559
|
+
userIsolationContextNamespace() {
|
|
560
|
+
const selfId = this.getAppUserId();
|
|
561
|
+
const branch = this.defaultContext.workspace ?? this.defaultContext.branch;
|
|
562
|
+
if (branch && branch.startsWith('_uw/')) {
|
|
563
|
+
const m = branch.match(/^_uw\/([^/]+)\/[^/]+$/);
|
|
564
|
+
if (m) {
|
|
565
|
+
const ownerId = m[1];
|
|
566
|
+
if (ownerId && ownerId !== selfId) {
|
|
567
|
+
const ns = this.userIsolationNamespaceFor(ownerId);
|
|
568
|
+
if (ns) {
|
|
569
|
+
return ns;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return this.userIsolationNamespaceFor(selfId);
|
|
539
575
|
}
|
|
540
576
|
allSharedPassthroughPrefixes() {
|
|
541
577
|
const o = this.userIsolation;
|
|
@@ -564,7 +600,7 @@ class XCiteDBClient {
|
|
|
564
600
|
return false;
|
|
565
601
|
}
|
|
566
602
|
isoPrefixId(id) {
|
|
567
|
-
const ns = this.
|
|
603
|
+
const ns = this.userIsolationContextNamespace();
|
|
568
604
|
if (!ns) {
|
|
569
605
|
return id;
|
|
570
606
|
}
|
|
@@ -589,7 +625,7 @@ class XCiteDBClient {
|
|
|
589
625
|
return finalId;
|
|
590
626
|
}
|
|
591
627
|
isoUnprefixId(id) {
|
|
592
|
-
const ns = this.
|
|
628
|
+
const ns = this.userIsolationContextNamespace();
|
|
593
629
|
if (!ns) {
|
|
594
630
|
return id;
|
|
595
631
|
}
|
|
@@ -606,7 +642,7 @@ class XCiteDBClient {
|
|
|
606
642
|
return canonical;
|
|
607
643
|
}
|
|
608
644
|
isoPrefixQuery(q) {
|
|
609
|
-
if (!this.
|
|
645
|
+
if (!this.userIsolationContextNamespace()) {
|
|
610
646
|
return q;
|
|
611
647
|
}
|
|
612
648
|
const out = { ...q };
|
|
@@ -622,7 +658,7 @@ class XCiteDBClient {
|
|
|
622
658
|
return out;
|
|
623
659
|
}
|
|
624
660
|
isoApplyXmlDbIdentifier(xml) {
|
|
625
|
-
if (!this.
|
|
661
|
+
if (!this.userIsolationContextNamespace()) {
|
|
626
662
|
return xml;
|
|
627
663
|
}
|
|
628
664
|
return xml.replace(/(db:identifier\s*=\s*")([^"]*)(")/, (_m, p1, mid, p3) => {
|
|
@@ -663,6 +699,73 @@ class XCiteDBClient {
|
|
|
663
699
|
const enc = encodeURIComponent(token);
|
|
664
700
|
return this.request('DELETE', `/api/v1/test/sessions/${enc}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
665
701
|
}
|
|
702
|
+
/**
|
|
703
|
+
* Create a long-lived developer sandbox via `POST /api/v1/sandboxes`. Returns the server's
|
|
704
|
+
* `SandboxInfo`. To start using the sandbox immediately, follow up with `client.useSandbox(name)`,
|
|
705
|
+
* or mint a sandbox-bound API key with `mintSandboxApiKey` and route subsequent requests through
|
|
706
|
+
* a fresh client constructed with that key (no header threading needed).
|
|
707
|
+
*/
|
|
708
|
+
async createSandbox(opts) {
|
|
709
|
+
return this.request('POST', '/api/v1/sandboxes', opts, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
710
|
+
}
|
|
711
|
+
/** List sandboxes for the current project (`GET /api/v1/sandboxes`). */
|
|
712
|
+
async listSandboxes() {
|
|
713
|
+
const r = await this.request('GET', '/api/v1/sandboxes', undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
714
|
+
return r.sandboxes ?? [];
|
|
715
|
+
}
|
|
716
|
+
/** Fetch a sandbox's detail by name (`GET /api/v1/sandboxes/{name}`). */
|
|
717
|
+
async getSandbox(name) {
|
|
718
|
+
return this.request('GET', `/api/v1/sandboxes/${encodeURIComponent(name)}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
719
|
+
}
|
|
720
|
+
/** Update mutable sandbox fields (`PATCH /api/v1/sandboxes/{name}`). */
|
|
721
|
+
async updateSandbox(name, patch) {
|
|
722
|
+
return this.request('PATCH', `/api/v1/sandboxes/${encodeURIComponent(name)}`, patch, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
723
|
+
}
|
|
724
|
+
/** Drop overlay writes for a sandbox while preserving membership and bound keys. */
|
|
725
|
+
async resetSandbox(name) {
|
|
726
|
+
return this.request('POST', `/api/v1/sandboxes/${encodeURIComponent(name)}/reset`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
727
|
+
}
|
|
728
|
+
/** Destroy a sandbox; revokes bound API keys and removes membership rows. Owner only. */
|
|
729
|
+
async destroySandbox(name) {
|
|
730
|
+
return this.request('DELETE', `/api/v1/sandboxes/${encodeURIComponent(name)}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
731
|
+
}
|
|
732
|
+
async listSandboxMembers(name) {
|
|
733
|
+
const r = await this.request('GET', `/api/v1/sandboxes/${encodeURIComponent(name)}/members`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
734
|
+
return r.members ?? [];
|
|
735
|
+
}
|
|
736
|
+
async addSandboxMember(name, member_id, role) {
|
|
737
|
+
return this.request('POST', `/api/v1/sandboxes/${encodeURIComponent(name)}/members`, { member_id, role }, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
738
|
+
}
|
|
739
|
+
async removeSandboxMember(name, member_id) {
|
|
740
|
+
return this.request('DELETE', `/api/v1/sandboxes/${encodeURIComponent(name)}/members/${encodeURIComponent(member_id)}`, undefined, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Mint an API key bound to the given sandbox. Requests presenting this key automatically route
|
|
744
|
+
* into the sandbox without an `X-Test-Session` header — paste it into your dev `.env` and
|
|
745
|
+
* existing client/curl/SDK calls just work. The `api_key` field is shown once; persist it now.
|
|
746
|
+
*/
|
|
747
|
+
async mintSandboxApiKey(name, opts = {}) {
|
|
748
|
+
return this.request('POST', `/api/v1/sandboxes/${encodeURIComponent(name)}/api-keys`, opts, undefined, { suppressTestSessionHeader: true, no401Retry: true });
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Pin this client to a named sandbox: subsequent requests carry `X-Test-Session: <token>`.
|
|
752
|
+
* Returns the sandbox info. Throws if the name does not resolve.
|
|
753
|
+
*
|
|
754
|
+
* If you instead want zero-header DX, mint a sandbox-bound API key with
|
|
755
|
+
* `mintSandboxApiKey(name)` and construct a fresh client with that `apiKey` — the server
|
|
756
|
+
* pre-filter resolves the binding without touching the test-session header.
|
|
757
|
+
*/
|
|
758
|
+
async useSandbox(name) {
|
|
759
|
+
const info = await this.getSandbox(name);
|
|
760
|
+
if (!info.session_token)
|
|
761
|
+
throw new Error(`useSandbox: server returned no session_token for "${name}"`);
|
|
762
|
+
this.testSessionToken = info.session_token;
|
|
763
|
+
return info;
|
|
764
|
+
}
|
|
765
|
+
/** Clear any pinned test/sandbox session token from this client (subsequent requests hit production). */
|
|
766
|
+
clearSandbox() {
|
|
767
|
+
this.testSessionToken = undefined;
|
|
768
|
+
}
|
|
666
769
|
/** True if this client would send API key or Bearer credentials on a normal request. */
|
|
667
770
|
sentAuthCredentials() {
|
|
668
771
|
return !!(this.apiKey || this.accessToken || this.appUserAccessToken);
|
|
@@ -2461,8 +2564,8 @@ class XCiteDBClient {
|
|
|
2461
2564
|
}
|
|
2462
2565
|
const ct = opts.contentType.trim() || 'application/octet-stream';
|
|
2463
2566
|
const qParts = [];
|
|
2464
|
-
if (opts.scope === '
|
|
2465
|
-
qParts.push('scope=
|
|
2567
|
+
if (opts.scope === 'shared') {
|
|
2568
|
+
qParts.push('scope=shared');
|
|
2466
2569
|
}
|
|
2467
2570
|
if (opts.identifier !== undefined && opts.identifier !== '') {
|
|
2468
2571
|
qParts.push(`identifier=${encodeURIComponent(opts.identifier)}`);
|
package/dist/client.test.js
CHANGED
|
@@ -32,6 +32,80 @@ const types_js_1 = require("./types.js");
|
|
|
32
32
|
globalThis.fetch = orig;
|
|
33
33
|
}
|
|
34
34
|
});
|
|
35
|
+
(0, node_test_1.it)('user-workspace branch (`_uw/<owner>/<slug>`) prefixes paths with owner namespace, not caller', async () => {
|
|
36
|
+
// Issue B repro at the unit level: an invitee on someone else's user
|
|
37
|
+
// workspace must namespace identifiers to the owner's user_id parsed
|
|
38
|
+
// from the branch, not their own. We assert the outbound URL — SDK
|
|
39
|
+
// routes prefixing through userIsolationContextNamespace() which
|
|
40
|
+
// checks `_uw/<owner>/<slug>` and substitutes the owner.
|
|
41
|
+
const seen = [];
|
|
42
|
+
const orig = globalThis.fetch;
|
|
43
|
+
globalThis.fetch = node_test_1.mock.fn(async (input) => {
|
|
44
|
+
seen.push(String(input));
|
|
45
|
+
return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
const c = new client_js_1.XCiteDBClient({
|
|
49
|
+
baseUrl: 'http://127.0.0.1:9',
|
|
50
|
+
apiKey: 'test-key',
|
|
51
|
+
userIsolation: { enabled: true, namespace: '/users/{userId}' },
|
|
52
|
+
});
|
|
53
|
+
// sub claim = "user-B"
|
|
54
|
+
c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUIifQ.sig');
|
|
55
|
+
// Owner of the workspace is "user-A"; we are "user-B".
|
|
56
|
+
c.setContext({ branch: '_uw/user-A/shared', project_id: 'p1' });
|
|
57
|
+
await c.readJsonDocument('/drafts/x');
|
|
58
|
+
strict_1.default.equal(seen.length, 1);
|
|
59
|
+
strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-A%2Fdrafts%2Fx/, `expected identifier prefixed with owner ns, got: ${seen[0]}`);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
globalThis.fetch = orig;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
(0, node_test_1.it)('non-`_uw` branch keeps caller-namespace prefixing (regression guard)', async () => {
|
|
66
|
+
const seen = [];
|
|
67
|
+
const orig = globalThis.fetch;
|
|
68
|
+
globalThis.fetch = node_test_1.mock.fn(async (input) => {
|
|
69
|
+
seen.push(String(input));
|
|
70
|
+
return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const c = new client_js_1.XCiteDBClient({
|
|
74
|
+
baseUrl: 'http://127.0.0.1:9',
|
|
75
|
+
apiKey: 'test-key',
|
|
76
|
+
userIsolation: { enabled: true, namespace: '/users/{userId}' },
|
|
77
|
+
});
|
|
78
|
+
c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUIifQ.sig');
|
|
79
|
+
c.setContext({ branch: 'main', project_id: 'p1' });
|
|
80
|
+
await c.readJsonDocument('/drafts/x');
|
|
81
|
+
strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-B%2Fdrafts%2Fx/, `expected identifier prefixed with caller ns, got: ${seen[0]}`);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
globalThis.fetch = orig;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
(0, node_test_1.it)('owner of `_uw/<self>/<slug>` workspace uses own namespace (no-op for owner)', async () => {
|
|
88
|
+
const seen = [];
|
|
89
|
+
const orig = globalThis.fetch;
|
|
90
|
+
globalThis.fetch = node_test_1.mock.fn(async (input) => {
|
|
91
|
+
seen.push(String(input));
|
|
92
|
+
return new Response(JSON.stringify({ ok: true, _xcite_json_doc: true }), { status: 200 });
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
const c = new client_js_1.XCiteDBClient({
|
|
96
|
+
baseUrl: 'http://127.0.0.1:9',
|
|
97
|
+
apiKey: 'test-key',
|
|
98
|
+
userIsolation: { enabled: true, namespace: '/users/{userId}' },
|
|
99
|
+
});
|
|
100
|
+
c.setAppUserTokens('header.eyJzdWIiOiJ1c2VyLUEifQ.sig'); // user-A
|
|
101
|
+
c.setContext({ branch: '_uw/user-A/shared', project_id: 'p1' });
|
|
102
|
+
await c.readJsonDocument('/drafts/x');
|
|
103
|
+
strict_1.default.match(seen[0], /identifier=%2Fusers%2Fuser-A%2Fdrafts%2Fx/, `expected owner to keep own ns, got: ${seen[0]}`);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
globalThis.fetch = orig;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
35
109
|
(0, node_test_1.it)('queryByIdentifier / queryDocuments return XML bodies unmodified under userIsolation', async () => {
|
|
36
110
|
const xml = '<?xml version="1.0"?><doc xmlns:db="http://www.xcitedb.com/schema"><a href="https://example.com//path">x</a></doc>';
|
|
37
111
|
const orig = globalThis.fetch;
|
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, 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, TriggerEvent, TriggerEventsResponse, TxnOperation, TxnOperationResult, TxnPrecondition, TxnPreconditionResult, TxnRequest, TxnResponse, JwksKey, JwksResponse, VerifyAppUserTokenOptions, 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, SandboxApiKeyMintResult, SandboxInfo, SandboxMember, SearchIndexingProgress, SecurityConfig, SecurityPolicy, StoredPolicyResponse, StoredTriggerResponse, SubscriptionOptions, TagRecord, TextSearchHit, TextSearchQuery, TextSearchResult, TriggerDefinition, TriggerEvent, TriggerEventsResponse, TxnOperation, TxnOperationResult, TxnPrecondition, TxnPreconditionResult, TxnRequest, TxnResponse, JwksKey, JwksResponse, VerifyAppUserTokenOptions, TokenPair, UserInfo, UserIsolationConfig, UserIsolationCreateShareParams, UserIsolationOptions, UserIsolationShareMode, UserIsolationShareResult, WorkspaceInfo, WriteDocumentOptions, XmlDocumentBatchItem, CreateSandboxOptions, 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
|
@@ -192,8 +192,10 @@ export interface UploadAssetOptions {
|
|
|
192
192
|
contentType: string;
|
|
193
193
|
/** When set (with default scope), uploaded to this path after user-isolation prefixing rules on the server. */
|
|
194
194
|
identifier?: string;
|
|
195
|
-
/** `
|
|
196
|
-
|
|
195
|
+
/** `shared` writes under `/shared/assets/…` (tenant-common, readable by all logged-in app users).
|
|
196
|
+
* Default project/user scope when omitted. To publish anonymously, upload normally and use the
|
|
197
|
+
* share API with `target_user_id: "#anonymous"`. */
|
|
198
|
+
scope?: 'project' | 'shared';
|
|
197
199
|
}
|
|
198
200
|
/** Result of {@link XCiteDBClient.headAsset}. */
|
|
199
201
|
export interface AssetHeadResult {
|
|
@@ -761,6 +763,18 @@ export interface CreateTestSessionOptions {
|
|
|
761
763
|
* When true, creates an overlay test session: writable ephemeral LMDB with production project data as read-only base.
|
|
762
764
|
*/
|
|
763
765
|
overlay?: boolean;
|
|
766
|
+
/**
|
|
767
|
+
* API keys to snapshot into the new session's config DB so requests carrying any of these keys
|
|
768
|
+
* (alongside `X-Test-Session: <token>`) authenticate against the session, not production.
|
|
769
|
+
*
|
|
770
|
+
* Typical use: a backend-for-frontend (BFF) that performs admin-scoped writes on behalf of the
|
|
771
|
+
* SPA. Without registering the BFF's admin key here, the BFF would receive `401
|
|
772
|
+
* test_session_unknown_api_key` when forwarding `X-Test-Session` to XCiteDB.
|
|
773
|
+
*
|
|
774
|
+
* Each entry must be a valid production API key on the project hosting the session — otherwise
|
|
775
|
+
* the server returns `400 additional_key_invalid` (with the offending index) and aborts.
|
|
776
|
+
*/
|
|
777
|
+
additionalKeys?: string[];
|
|
764
778
|
/** Optional server-side bootstrap (user isolation, developer_bypass, policies, triggers). */
|
|
765
779
|
bootstrap?: TestSessionBootstrap;
|
|
766
780
|
/**
|
|
@@ -788,6 +802,53 @@ export interface CreateTestSessionOptions {
|
|
|
788
802
|
userIsolation?: UserIsolationOptions;
|
|
789
803
|
requestTimeoutMs?: number;
|
|
790
804
|
}
|
|
805
|
+
/** A long-lived developer sandbox: named overlay on a project + branch. */
|
|
806
|
+
export interface SandboxInfo {
|
|
807
|
+
session_token: string;
|
|
808
|
+
name: string;
|
|
809
|
+
description: string;
|
|
810
|
+
org_id: string;
|
|
811
|
+
project_id: string;
|
|
812
|
+
base_branch: string;
|
|
813
|
+
pinned: boolean;
|
|
814
|
+
effects_policy: 'real' | 'log' | 'mock';
|
|
815
|
+
created_at: number;
|
|
816
|
+
last_used: number;
|
|
817
|
+
expires_at: number;
|
|
818
|
+
reset_generation: number;
|
|
819
|
+
owner_member_id: string;
|
|
820
|
+
/** Present in list responses: the requesting member's role in this sandbox, or "" if not a member. */
|
|
821
|
+
my_role?: 'owner' | 'editor' | 'viewer' | '';
|
|
822
|
+
}
|
|
823
|
+
/** Options for {@link XCiteDBClient.createSandbox}. */
|
|
824
|
+
export interface CreateSandboxOptions {
|
|
825
|
+
/** Required. Kebab-case, 1-64 chars, must start with a-z, no consecutive hyphens. */
|
|
826
|
+
name: string;
|
|
827
|
+
description?: string;
|
|
828
|
+
/** Pinned sandboxes are not garbage-collected by idle TTL. */
|
|
829
|
+
pinned?: boolean;
|
|
830
|
+
/** Unix seconds; 0 = no explicit expiry. */
|
|
831
|
+
expires_at?: number;
|
|
832
|
+
base_branch?: string;
|
|
833
|
+
/** Defaults to "log" — webhooks/auth-emails get captured instead of firing. */
|
|
834
|
+
effects_policy?: 'real' | 'log' | 'mock';
|
|
835
|
+
}
|
|
836
|
+
/** Response from {@link XCiteDBClient.mintSandboxApiKey}. The `api_key` value is shown once. */
|
|
837
|
+
export interface SandboxApiKeyMintResult {
|
|
838
|
+
api_key: string;
|
|
839
|
+
key_id: string;
|
|
840
|
+
prefix: string;
|
|
841
|
+
key_type: 'secret' | 'public';
|
|
842
|
+
bound_sandbox_id: string;
|
|
843
|
+
sandbox_name: string;
|
|
844
|
+
message: string;
|
|
845
|
+
}
|
|
846
|
+
export interface SandboxMember {
|
|
847
|
+
member_id: string;
|
|
848
|
+
role: 'owner' | 'editor' | 'viewer';
|
|
849
|
+
added_at: number;
|
|
850
|
+
added_by: string;
|
|
851
|
+
}
|
|
791
852
|
/** Application user (tenant-scoped), distinct from developer users. */
|
|
792
853
|
export interface AppUser {
|
|
793
854
|
user_id: string;
|
|
@@ -966,7 +1027,17 @@ export interface PolicyUpdateResponse {
|
|
|
966
1027
|
export interface TriggerMatch {
|
|
967
1028
|
/** Non-empty array of identifier patterns (same shape as policy `resources.identifiers`). */
|
|
968
1029
|
identifiers: PolicyIdentifierPattern[];
|
|
1030
|
+
/**
|
|
1031
|
+
* Single meta path pattern (exact, or `prefix*`). Mutually exclusive with `match_meta_paths`;
|
|
1032
|
+
* the server rejects writes that set both fields.
|
|
1033
|
+
*/
|
|
969
1034
|
match_meta_path?: string;
|
|
1035
|
+
/**
|
|
1036
|
+
* Array of meta path patterns (anyOf). Each entry is exact or `prefix*`. Useful for one
|
|
1037
|
+
* trigger gating multiple fields of the same shape (e.g. role-restricted profile fields).
|
|
1038
|
+
* Empty array means no constraint. Mutually exclusive with `match_meta_path`.
|
|
1039
|
+
*/
|
|
1040
|
+
match_meta_paths?: string[];
|
|
970
1041
|
match_operation?: 'set' | 'append' | 'delete';
|
|
971
1042
|
}
|
|
972
1043
|
/** `action` block: run unquery and write result to target meta. */
|
|
@@ -980,10 +1051,21 @@ export interface TriggerAction {
|
|
|
980
1051
|
/** Stored trigger document under /_xcitedb/triggers. */
|
|
981
1052
|
export interface TriggerDefinition {
|
|
982
1053
|
enabled?: boolean;
|
|
1054
|
+
/**
|
|
1055
|
+
* `"after"` (default): run after the write commits, may run actions. `"before"`:
|
|
1056
|
+
* predicate-only — runs before the write applies and rejects with HTTP 422 when matched.
|
|
1057
|
+
* BEFORE triggers must not have `action` or `actions`.
|
|
1058
|
+
*/
|
|
1059
|
+
phase?: 'before' | 'after';
|
|
983
1060
|
event: 'meta_changed' | 'document_written' | 'document_deleted';
|
|
984
1061
|
match: TriggerMatch;
|
|
985
1062
|
conditions?: PolicyConditions;
|
|
986
|
-
action
|
|
1063
|
+
/** AFTER-trigger single action (legacy). Mutually exclusive with `actions`. Forbidden on BEFORE triggers. */
|
|
1064
|
+
action?: TriggerAction;
|
|
1065
|
+
/** AFTER-trigger multi-action variant. Mutually exclusive with `action`. Forbidden on BEFORE triggers. */
|
|
1066
|
+
actions?: TriggerAction[];
|
|
1067
|
+
/** BEFORE-trigger reject message surfaced to clients on HTTP 422. */
|
|
1068
|
+
reject_reason?: string;
|
|
987
1069
|
}
|
|
988
1070
|
export interface StoredTriggerResponse {
|
|
989
1071
|
trigger_id: string;
|
|
@@ -172,4 +172,92 @@ wd('user isolation (wet)', () => {
|
|
|
172
172
|
await admin.disableUserIsolation().catch(() => { });
|
|
173
173
|
}
|
|
174
174
|
});
|
|
175
|
+
(0, node_test_1.it)('user-workspace branch: invitee reads owner-namespaced docs through self-namespace API', async () => {
|
|
176
|
+
// Issue B repro: when user B is granted readwrite on user A's user
|
|
177
|
+
// workspace and reads a doc on the workspace branch, the SDK must
|
|
178
|
+
// namespace the path with A's user_id (parsed from the `_uw/<owner>/<slug>`
|
|
179
|
+
// branch), not B's. Server-side prefixIdentifier already passes the
|
|
180
|
+
// identifier through unchanged for workspace members; the SDK must put
|
|
181
|
+
// the owner's namespace on the path before sending.
|
|
182
|
+
const e = wetEnv();
|
|
183
|
+
if (!e)
|
|
184
|
+
throw new Error('missing env');
|
|
185
|
+
const admin = adminClient(e);
|
|
186
|
+
const suffix = (0, node_crypto_1.randomUUID)().slice(0, 8);
|
|
187
|
+
const emailA = `js_uwA_${suffix}@apitest.invalid`;
|
|
188
|
+
const emailB = `js_uwB_${suffix}@apitest.invalid`;
|
|
189
|
+
const password = `Js_${suffix}!aA1`;
|
|
190
|
+
const slug = `js-uw-doc-${suffix}`;
|
|
191
|
+
const wsName = `shared-${suffix}`;
|
|
192
|
+
let userA;
|
|
193
|
+
let userB;
|
|
194
|
+
let workspaceId;
|
|
195
|
+
let workspaceBranch;
|
|
196
|
+
try {
|
|
197
|
+
await admin.setUserIsolationConfig({
|
|
198
|
+
enabled: true,
|
|
199
|
+
namespace_pattern: '/users/${user.id}',
|
|
200
|
+
});
|
|
201
|
+
userA = await admin.createAppUser(emailA, password, undefined, [
|
|
202
|
+
client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
|
|
203
|
+
]);
|
|
204
|
+
userB = await admin.createAppUser(emailB, password, undefined, [
|
|
205
|
+
client_js_1.XCiteDBClient.buildProjectGroup(e.tenantId, 'editor'),
|
|
206
|
+
]);
|
|
207
|
+
const appA = new client_js_1.XCiteDBClient({
|
|
208
|
+
baseUrl: e.baseUrl,
|
|
209
|
+
context: { branch: 'main', project_id: e.tenantId },
|
|
210
|
+
userIsolation: { enabled: true },
|
|
211
|
+
});
|
|
212
|
+
await appA.loginAppUser(emailA, password);
|
|
213
|
+
// A creates the workspace, captures the branch from the response,
|
|
214
|
+
// grants B readwrite, and writes a doc on the workspace branch.
|
|
215
|
+
const ws = (await appA.createUserWorkspace(wsName));
|
|
216
|
+
strict_1.default.ok(ws.id, 'workspace id missing');
|
|
217
|
+
strict_1.default.ok(ws.branch && ws.branch.startsWith('_uw/'), 'workspace branch missing');
|
|
218
|
+
workspaceId = ws.id;
|
|
219
|
+
workspaceBranch = ws.branch;
|
|
220
|
+
strict_1.default.equal(workspaceBranch, `_uw/${userA.user_id}/${slugifyForAssert(wsName)}`, 'branch should be _uw/<ownerId>/<slug>');
|
|
221
|
+
await appA.addUserWorkspaceGrant(workspaceId, { user_id: userB.user_id, mode: 'rw' });
|
|
222
|
+
appA.setContext({ branch: workspaceBranch });
|
|
223
|
+
await appA.writeJsonDocument(`/${slug}`, { _xcite_json_doc: true, who: 'A', v: 1 });
|
|
224
|
+
// B opens the workspace branch and reads the doc using the relative
|
|
225
|
+
// identifier — must resolve to A's namespace, not B's.
|
|
226
|
+
const appB = new client_js_1.XCiteDBClient({
|
|
227
|
+
baseUrl: e.baseUrl,
|
|
228
|
+
context: { branch: workspaceBranch, project_id: e.tenantId },
|
|
229
|
+
userIsolation: { enabled: true },
|
|
230
|
+
});
|
|
231
|
+
await appB.loginAppUser(emailB, password);
|
|
232
|
+
const doc = await appB.readJsonDocument(`/${slug}`);
|
|
233
|
+
strict_1.default.equal(doc.who, 'A');
|
|
234
|
+
strict_1.default.equal(doc.v, 1);
|
|
235
|
+
// List on the workspace branch — identifiers must come back stripped
|
|
236
|
+
// of the owner's namespace (isoUnprefixId symmetry).
|
|
237
|
+
const list = await appB.listJsonDocuments(`/${slug.split('-')[0]}`);
|
|
238
|
+
const ids = (list.identifiers ?? []);
|
|
239
|
+
strict_1.default.ok(ids.includes(`/${slug}`), `expected response to include /${slug}, got ${JSON.stringify(ids)}`);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
if (workspaceId) {
|
|
243
|
+
await admin
|
|
244
|
+
.deleteJsonDocument(`/users/${userA?.user_id ?? ''}/${slug}`)
|
|
245
|
+
.catch(() => { });
|
|
246
|
+
await admin.deleteUserWorkspace(workspaceId).catch(() => { });
|
|
247
|
+
}
|
|
248
|
+
if (userB)
|
|
249
|
+
await admin.deleteAppUser(userB.user_id).catch(() => { });
|
|
250
|
+
if (userA)
|
|
251
|
+
await admin.deleteAppUser(userA.user_id).catch(() => { });
|
|
252
|
+
await admin.disableUserIsolation().catch(() => { });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
175
255
|
});
|
|
256
|
+
// Mirrors UserWorkspaceService::slugify (lowercase, runs of non-alphanumerics → '-', trim '-').
|
|
257
|
+
function slugifyForAssert(name) {
|
|
258
|
+
return name
|
|
259
|
+
.toLowerCase()
|
|
260
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
261
|
+
.replace(/^-+|-+$/g, '')
|
|
262
|
+
.slice(0, 64);
|
|
263
|
+
}
|
package/llms-full.txt
CHANGED
|
@@ -377,6 +377,58 @@ TEST_CASE("XciteDB XML document round-trip") {
|
|
|
377
377
|
}
|
|
378
378
|
```
|
|
379
379
|
|
|
380
|
+
## Overlay sessions with a backend-for-frontend (BFF)
|
|
381
|
+
|
|
382
|
+
When a single-page app is in an overlay test session and an admin-scoped action is delegated to a server-side BFF (e.g. group grants, project-editor enrolment, anything the SPA cannot do with its own credentials), the BFF must route through the **same overlay** — otherwise admin writes land in production while the SPA still reads from the overlay, and the SPA sees stale state.
|
|
383
|
+
|
|
384
|
+
The mechanism is the same `X-Test-Session` header used for SPA requests, plus pre-registering the BFF's admin key on the session at create time so the server accepts it.
|
|
385
|
+
|
|
386
|
+
1. **Pre-register the BFF admin key** when the SPA creates the session (SDK option `additionalKeys`, wire field `additional_keys`):
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
const client = await XCiteDBClient.createTestSession({
|
|
390
|
+
baseUrl, apiKey: SPA_PUBLIC_KEY,
|
|
391
|
+
overlay: true,
|
|
392
|
+
additionalKeys: [BFF_ADMIN_KEY], // snapshotted into the session config
|
|
393
|
+
bootstrap: { user_isolation: { /* ... */ } },
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Each entry is a production API key the BFF will use. The server snapshots each into the session's config DB so requests carrying that key alongside `X-Test-Session: <token>` authenticate against the session, not production. Without this, the BFF gets `401 test_session_unknown_api_key`. An invalid key (one not present in production for the session's project) returns `400 additional_key_invalid` with the offending array index, and the session is rolled back.
|
|
398
|
+
|
|
399
|
+
2. **SPA forwards `X-Test-Session` to the BFF** as a normal request header:
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
const r = await fetch('/api/app/grant-editor', {
|
|
403
|
+
method: 'POST',
|
|
404
|
+
headers: { 'X-Test-Session': client.testSessionToken ?? '' },
|
|
405
|
+
body: JSON.stringify({ user_id: u.user_id }),
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
3. **BFF reads the inbound header and propagates it on its outbound XCiteDB call.** With the JS SDK, instantiate a per-request admin client carrying the test-session token — don't reuse a long-lived admin client, since the same BFF process serves both real and test traffic:
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
app.post('/api/app/grant-editor', async (req, res) => {
|
|
413
|
+
const testSession = req.header('X-Test-Session') || undefined;
|
|
414
|
+
const admin = new XCiteDBClient({
|
|
415
|
+
baseUrl: process.env.XCITEDB_URL!,
|
|
416
|
+
apiKey: process.env.XCITEDB_BFF_KEY!, // == BFF_ADMIN_KEY
|
|
417
|
+
testSessionToken: testSession, // undefined ⇒ hits production as today
|
|
418
|
+
});
|
|
419
|
+
await admin.updateAppUserGroups(req.body.user_id, [
|
|
420
|
+
XCiteDBClient.buildProjectGroup(process.env.XCITEDB_PROJECT_ID!, 'editor'),
|
|
421
|
+
]);
|
|
422
|
+
res.status(204).end();
|
|
423
|
+
});
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The same shape works in any HTTP framework — the only requirement is propagating `X-Test-Session` from inbound to outbound. Production traffic doesn't carry the header, so `testSessionToken` falls back to `undefined` and the admin client behaves as before.
|
|
427
|
+
|
|
428
|
+
**Verification.** With this wired up, an end-to-end test that registers an app user, calls the BFF endpoint, then reads the user's groups through the SPA's overlay client should see the freshly-granted group on the next read. Without the propagation, the read returns the user's groups as they were at session creation (typically empty) and admin-gated routes return `403`.
|
|
429
|
+
|
|
430
|
+
The behavior is bolted to two server pieces: `additional_keys` snapshotting in `TestController::create` (`src/controllers/TestController.cpp`) and the test-session header dispatch in `AuthFilterShared` (`src/filters/AuthFilterShared.cpp`) — there's no separate "attach admin client to existing session" API; the snapshot + header pair is sufficient.
|
|
431
|
+
|
|
380
432
|
---
|
|
381
433
|
|
|
382
434
|
# Health, version & discovery
|
|
@@ -1105,18 +1157,55 @@ Definitions are stored as JSON under **`/_xcitedb/triggers`**. After a matching
|
|
|
1105
1157
|
| Field | Required | Description |
|
|
1106
1158
|
|--------|----------|-------------|
|
|
1107
1159
|
| `enabled` | No (default true) | If false, trigger is skipped. |
|
|
1160
|
+
| `phase` | No (default `"after"`) | `"after"` runs post-write actions; `"before"` is predicate-only and rejects the write with HTTP 422 when matched. BEFORE triggers must not have `action` or `actions`. |
|
|
1108
1161
|
| `event` | Yes | `meta_changed`, `document_written`, or `document_deleted`. |
|
|
1109
|
-
| `match` | Yes | Must include **`identifiers`**: non-empty array of identifier patterns (`exact`, `match_start`, `match_end`, `contains`, `regex`). Optional **`match_meta_path
|
|
1162
|
+
| `match` | Yes | Must include **`identifiers`**: non-empty array of identifier patterns (`exact`, `match_start`, `match_end`, `contains`, `regex`). Optional **`match_meta_path`** (single exact path or `prefix*`) **or** **`match_meta_paths`** (array of patterns, anyOf — useful for one trigger gating multiple fields; mutually exclusive with `match_meta_path`). Optional **`match_operation`**: `set`, `append`, or `delete`. |
|
|
1110
1163
|
| `conditions` | No | Optional **`branches`** (same as policies) and **`expression`** (see below). |
|
|
1111
|
-
| `action` |
|
|
1164
|
+
| `action` | AFTER only | **`query`** (`XCiteQuery`), **`unquery`** (Unquery DSL template), **`target_identifier`** (literal or `"$trigger_identifier"`), **`meta_path`**, optional **`mode`**: `set` (default), `append` (strict — unquery output must be a JSON array, stored target must be an array; trigger logs and skips on misuse), or `merge_append` (deep — recurses into objects to extend nested arrays). Mutually exclusive with `actions`. |
|
|
1165
|
+
| `actions` | AFTER only | Array of action objects for multi-op fan-out. Each `kind` is one of `WriteMeta`, `AppendMeta`, `WriteJson`, `DeleteIdentifier`, `AddIdentifier`, `ClearMetaPath`. Mutually exclusive with `action`. |
|
|
1166
|
+
| `reject_reason` | BEFORE only | Message returned to clients on HTTP 422 when the trigger matches. |
|
|
1112
1167
|
|
|
1113
|
-
###
|
|
1168
|
+
### BEFORE triggers (predicate-only)
|
|
1169
|
+
|
|
1170
|
+
`phase: "before"` triggers run *before* the write applies and reject it with **HTTP 422** when the match and conditions both hold. They are predicate-only — no `action` / `actions`. Fired only on writes routed through **`/api/v1/txn`** (regular `/api/v1/meta` and `/api/v1/json-documents` writes do not invoke BEFORE triggers).
|
|
1171
|
+
|
|
1172
|
+
The expression sees **`prior`** alongside `value`:
|
|
1114
1173
|
|
|
1115
|
-
|
|
1174
|
+
- **`prior`** (BEFORE + `meta_changed` only) — value at the same `meta_path` before the write. Bound only when at least one BEFORE trigger's expression references `prior`. JSON `null` when the path is currently absent.
|
|
1175
|
+
|
|
1176
|
+
Common patterns (the expression grammar uses `=` / `!=` / `<` / `>`, and single `&` / `|` for boolean):
|
|
1177
|
+
|
|
1178
|
+
- **Immutability after first set**: `prior is_literal & value != prior` — rejects only when a literal value already exists and differs.
|
|
1179
|
+
- **Monotonic non-decreasing**: `prior is_literal & value < prior`.
|
|
1180
|
+
- **Role-gated field**: `subject.role != 'system'` (no `prior` needed).
|
|
1181
|
+
|
|
1182
|
+
Example (`song.id` immutable once set):
|
|
1183
|
+
|
|
1184
|
+
```json
|
|
1185
|
+
{
|
|
1186
|
+
"trigger_id": "song_id_immutable",
|
|
1187
|
+
"trigger": {
|
|
1188
|
+
"phase": "before",
|
|
1189
|
+
"event": "meta_changed",
|
|
1190
|
+
"match": {
|
|
1191
|
+
"identifiers": [{ "match_start": "/spaces/" }],
|
|
1192
|
+
"match_meta_path": "id"
|
|
1193
|
+
},
|
|
1194
|
+
"conditions": {
|
|
1195
|
+
"expression": "prior is_literal & value != prior"
|
|
1196
|
+
},
|
|
1197
|
+
"reject_reason": "song id is immutable once set"
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
### Expression context for **triggers** (`conditions.expression`)
|
|
1116
1203
|
|
|
1117
1204
|
- **`trigger`**: `{ "event", "meta_path", "operation" }` (`operation`: `set` / `append` / `delete`)
|
|
1118
1205
|
- **`value`**: JSON written at `meta_path` for `meta_changed`, or null
|
|
1206
|
+
- **`prior`** (BEFORE + `meta_changed` only): value at `meta_path` before the write, or `null` if absent
|
|
1119
1207
|
- **`resource`**: `{ "identifier", "path" }` for the firing identifier
|
|
1208
|
+
- **`subject`**: `{ "id", "user_id", "email", "type", "role", "groups", "attr" }` (BEFORE only — AFTER triggers historically have no subject)
|
|
1120
1209
|
- **`env`**: `{ "branch" }`
|
|
1121
1210
|
|
|
1122
1211
|
### Unquery variables injected for triggers
|
package/package.json
CHANGED
package/unquery-ai-guide.md
CHANGED
|
@@ -85,7 +85,7 @@ A path selects a value from the current context.
|
|
|
85
85
|
| `[n]` | Array index (0-based) | `"Dependants[0].FirstName"` |
|
|
86
86
|
| `[expr]` | Computed index | `"Field1[$index+1]"` |
|
|
87
87
|
| `[]` | Whole array (projection over all elements) | `"Array1[].Field1"` |
|
|
88
|
-
| `[a:b]` | Array **slice** in a context modifier (half-open; negative counts from end) | `"scores:[0:3]"`, `"scores
|
|
88
|
+
| `[a:b]` | Array **slice** in a context modifier or path expression (half-open; negative counts from end) | `"scores:[0:3]"`, `"scores[2:5]"`, `"$size(scores[:3])"` |
|
|
89
89
|
| `/Field` | Absolute from document root | `"/employees.$(.)"` |
|
|
90
90
|
| `../Field` | Up one path level (skips array indices) | `"../DBInstanceIdentifier"` |
|
|
91
91
|
| `<<Field` | Read same field in *previous* document context | `"<<Field1"` after a `->$file(...)` |
|
|
@@ -710,6 +710,34 @@ Allowed only against literals (see §0.8 and §4.8):
|
|
|
710
710
|
|
|
711
711
|
Each recipe shows a small input-shape sketch, the Unquery template, and (where useful) the equivalent jq one-liner for cross-reference.
|
|
712
712
|
|
|
713
|
+
### 10.0 Materializing many rows from a prefix walk — pick a multi-row shape
|
|
714
|
+
|
|
715
|
+
Common gotcha for prefix-walk queries (`{ match_start: '/index/users/' }` and similar) feeding a search dropdown or candidate list: if the template root is a **single object**, every document iteration *overwrites* the same fields and you keep only the **last** match's values.
|
|
716
|
+
|
|
717
|
+
`{ "name": "name", "email": "email" }` — *wrong* for multi-row output. Returns one document's fields, not a list. (See §1's "evaluate once vs. per pass".)
|
|
718
|
+
|
|
719
|
+
Three correct shapes for "give me one row per matching document":
|
|
720
|
+
|
|
721
|
+
**Array of objects** (most common — preserves order, easy to iterate client-side):
|
|
722
|
+
|
|
723
|
+
```json
|
|
724
|
+
[{ "id": "$identifier", "name": "name", "email": "email" }]
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
**Map keyed by identifier** (constant-time lookup by id, no duplicates):
|
|
728
|
+
|
|
729
|
+
```json
|
|
730
|
+
{ "$($identifier)": { "name": "name", "email": "email" } }
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
**Array of identifiers only** (just need the keys, e.g. for a count or follow-up reads):
|
|
734
|
+
|
|
735
|
+
```json
|
|
736
|
+
["$identifier"]
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
The bare-object form is correct *only* when the prefix narrows to exactly one document or you genuinely want "last match wins" (e.g. picking a single config row). For dropdowns, search results, member lists, or any "show me everything matching" use case, default to the array-of-objects shape.
|
|
740
|
+
|
|
713
741
|
### 10.1 Pick one field per document → array
|
|
714
742
|
|
|
715
743
|
```json
|
package/unquery-grammar.md
CHANGED
|
@@ -128,7 +128,7 @@ Climbing parser levels:
|
|
|
128
128
|
|--------|-----------|------------|
|
|
129
129
|
| 0 | `+`, `-` | Left via loop: each new rhs parsed with `expression(1)`. |
|
|
130
130
|
| 1 | `*`, `/`, `mod` | Left; rhs parsed with `expression(2)`. |
|
|
131
|
-
| 3 | Postfix `.token`
|
|
131
|
+
| 3 | Postfix `.token`, `[ expr ]`, `[ slice ]` | Postfix chain on current `res`. |
|
|
132
132
|
|
|
133
133
|
Parentheses: `(` `expression(0)` `)` as primary.
|
|
134
134
|
|
|
@@ -138,12 +138,13 @@ Parentheses: `(` `expression(0)` `)` as primary.
|
|
|
138
138
|
expression ::= '(' expression ')'
|
|
139
139
|
| baseExpression ( ( '+' | '-' ) expression(1)
|
|
140
140
|
| ( '*' | '/' | 'mod' ) expression(2) )*
|
|
141
|
-
| baseExpression ( '.' fieldSegment | '[' expression ']' )*
|
|
141
|
+
| baseExpression ( '.' fieldSegment | '[' expression ']' | '[' slice ']' )*
|
|
142
142
|
|
|
143
|
+
slice ::= INT? ':' INT? (* INT may be `-` INT for negative-from-end *)
|
|
143
144
|
fieldSegment ::= IDENT | BACKTICK_STR | '$' IDENT ... (* lexer token after '.'; special case: if token starts with '$', subfield parse backs up — see pathWithBrackets *)
|
|
144
145
|
```
|
|
145
146
|
|
|
146
|
-
**Postfix** (prec ≤ 3): after `consume()` of `.` or `[`, if `[` then parse `expression(0)`
|
|
147
|
+
**Postfix** (prec ≤ 3): after `consume()` of `.` or `[`, if `[` then either (a) parse a slice `INT? ':' INT?` followed by `]` → `TExprSlice(res, …)`, or (b) parse `expression(0)` followed by `]` → `TExprSubfield(res, expr_or_empty, is_index)`. Empty `[]` builds `TExprSubfield` with no expression. Slice detection uses backtracking: a single integer with no `:` falls through to the expression form.
|
|
147
148
|
|
|
148
149
|
### 4.3 `baseExpression` — alternatives (first token disambiguation)
|
|
149
150
|
|
|
@@ -151,7 +152,8 @@ Roughly in parse order:
|
|
|
151
152
|
|
|
152
153
|
| Prefix / form | AST | Notes |
|
|
153
154
|
|-----------------|-----|--------|
|
|
154
|
-
| `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index.
|
|
155
|
+
| `[` optional `]` | `TExprSubfield(TExprField("."), expr?, is_index)` | `[]` = whole array on current `.`; `[e]` = index. |
|
|
156
|
+
| `[` (INT)? `:` (INT)? `]` | `TExprSlice(TExprField("."), …)` | Half-open array slice on `.`. Negative ints count from end. Bounds must be integer literals (with optional `-`). Yields a JSON array. |
|
|
155
157
|
| `$` `(` `expression` `)` | `TExprField(expr)` | Evaluate / path from dynamic name (`$()`). |
|
|
156
158
|
| `$if` `(` `condition` `,` `expression` `,` `expression` `)` | `TExprITE` | |
|
|
157
159
|
| `$call` `(` IDENT `)` | `TExprCall(name)` | Zero-arg user function by name. |
|