@vxil/sdk 0.1.0

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/index.js ADDED
@@ -0,0 +1,1396 @@
1
+ // @vxil/sdk — typed client over the public REST API (api.vxil.com).
2
+ // Every non-2xx response throws VxilError carrying the structured envelope
3
+ // (code/message/hint/fixUrl + request id) so callers — humans or agents —
4
+ // can self-correct.
5
+ /** Rewrite a built `/v1/<ns>/…` path onto the version configured for its
6
+ * namespace: the per-namespace override wins, else the global default. PURE and
7
+ * exported so it is unit-testable with a hypothetical future major (the runtime
8
+ * accepts any version string; the public options constrain to the `ApiVersion`
9
+ * union). A path whose first segment is not `/v1` is returned untouched. */
10
+ export function versionedPath(path, globalDefault, overrides) {
11
+ const m = /^\/v1\/([^/?#]+)(.*)$/.exec(path);
12
+ if (!m)
13
+ return path;
14
+ const ns = m[1];
15
+ const version = overrides[ns] ?? globalDefault;
16
+ return `/${version}/${ns}${m[2]}`;
17
+ }
18
+ export class VxilError extends Error {
19
+ status;
20
+ code;
21
+ hint;
22
+ fixUrl;
23
+ requestId;
24
+ constructor(status, code, message, hint, fixUrl, requestId) {
25
+ super(message);
26
+ this.status = status;
27
+ this.code = code;
28
+ this.hint = hint;
29
+ this.fixUrl = fixUrl;
30
+ this.requestId = requestId;
31
+ this.name = 'VxilError';
32
+ }
33
+ }
34
+ const DEFAULT_BASE = 'https://api.vxil.com';
35
+ /** Resolve the API base. Precedence: explicit `baseUrl` option > the
36
+ * `VXIL_BASE_URL` env var (Node/server only — guarded via `globalThis`, so it's
37
+ * inert in browsers & Workers) > the production default. This lets an internal /
38
+ * staging caller point at a non-prod edge with one env var and no code change,
39
+ * while an external caller needs nothing — the environment is runtime config,
40
+ * never a separate build or package. */
41
+ function resolveBase(explicit) {
42
+ const g = globalThis;
43
+ const envBase = g.process?.env?.VXIL_BASE_URL;
44
+ return (explicit ?? envBase ?? DEFAULT_BASE).replace(/\/$/, '');
45
+ }
46
+ export class Vxil {
47
+ base;
48
+ key;
49
+ fetchImpl;
50
+ apiVersion;
51
+ apiVersions;
52
+ /** The end-user session token threaded as `X-Vxil-End-User` when set (end-user
53
+ * mode). Undefined ⇒ no header ⇒ server-caller mode (unchanged). */
54
+ endUserToken;
55
+ constructor(opts) {
56
+ this.key = opts.apiKey;
57
+ this.base = resolveBase(opts.baseUrl);
58
+ this.fetchImpl = opts.fetch ?? fetch;
59
+ this.apiVersion = opts.apiVersion ?? 'v1';
60
+ this.apiVersions = opts.apiVersions ?? {};
61
+ this.endUserToken = opts.endUserToken;
62
+ }
63
+ /** The header bag every request layers on top of `authorization`: the
64
+ * `X-Vxil-End-User` session token in end-user mode, nothing in server mode.
65
+ * One place so `call()` and the `fn` proxy stay in lockstep. */
66
+ authHeaders() {
67
+ return this.endUserToken ? { 'x-vxil-end-user': this.endUserToken } : {};
68
+ }
69
+ /** Return a client that sends `X-Vxil-End-User: <token>` on every request —
70
+ * a per-call/scoped override of end-user mode over an otherwise server-mode
71
+ * client, mirroring how the edge threads the verified principal. The base
72
+ * URL, api key, fetch impl and version pins are inherited unchanged; only the
73
+ * end-user token is (re)set. Pass a falsy token to get back a server-mode
74
+ * client (drops the header). See docs/end-user-principals-design.md §4.1. */
75
+ asEndUser(endUserToken) {
76
+ return new Vxil({
77
+ apiKey: this.key,
78
+ baseUrl: this.base,
79
+ fetch: this.fetchImpl,
80
+ apiVersion: this.apiVersion,
81
+ apiVersions: this.apiVersions,
82
+ ...(endUserToken ? { endUserToken } : {}),
83
+ });
84
+ }
85
+ /** Build the versioned request path — the ONE place a wire path is finalized.
86
+ * Every request (call(), audit.export, the fn proxy) routes through here, so
87
+ * the per-feature/global major pin applies uniformly. Default → unchanged
88
+ * `/v1/…`. */
89
+ path(p) {
90
+ return versionedPath(p, this.apiVersion, this.apiVersions);
91
+ }
92
+ /** Construct a FEATURE-NARROWED client: feature namespaces the tenant hasn't
93
+ * enabled (per the generated `VxilSchema['features']`) become compile errors —
94
+ * `Vxil.connect<VxilSchema>({ apiKey }).rag` won't type-check if `rag` is off.
95
+ * Runtime is identical to `new Vxil`; this only adds the compile-time gate. */
96
+ static connect(opts) {
97
+ return new Vxil(opts);
98
+ }
99
+ /** Typed per-collection CMS handle (design §4.8). A thin wrapper over the
100
+ * generic `cms.items.*` methods — the wire calls are identical; the generated
101
+ * `VxilSchema` supplies the field types. `vx.cms.items.*` stays as the
102
+ * always-available un-generic fallback. */
103
+ from(collection) {
104
+ const c = collection;
105
+ // CMS responses are envelopes { item_id, status, data: {…fields}, … }; the
106
+ // typed Row IS the field bag, so unwrap `.data` for get/query/patch/publish.
107
+ const unwrap = (item) => item.data;
108
+ return {
109
+ create: (data, opts) => this.cms.items.create(c, {
110
+ data: data,
111
+ ...(opts?.status ? { status: opts.status } : {}),
112
+ ...(opts?.lock ? { lock: opts.lock } : {}),
113
+ ...(opts?.guard ? { guard: opts.guard } : {}),
114
+ ...(opts?.guards ? { guards: opts.guards } : {}),
115
+ }),
116
+ get: (itemId) => this.cms.items.get(c, itemId).then(unwrap),
117
+ query: (q) => this.cms.items.query(c, q)
118
+ .then((r) => ({ items: r.items.map(unwrap), next_cursor: r.next_cursor })),
119
+ count: (filter) => this.cms.items.count(c, filter),
120
+ patch: (itemId, data, opts) => this.cms.items.patch(c, itemId, data, opts).then(unwrap),
121
+ inc: (itemId, incs, opts) => this.cms.items.inc(c, itemId, incs, opts).then(unwrap),
122
+ delete: (itemId, opts) => this.cms.items.delete(c, itemId, opts),
123
+ publish: (itemId) => this.cms.items.publish(c, itemId).then(unwrap),
124
+ aggregate: (q) => this.cms.items.aggregate(c, q),
125
+ rank: (q) => this.cms.items.rank(c, q),
126
+ };
127
+ }
128
+ /** Typed function invoke (design §4.5). `vx.fn.<name>(payload)` POSTs to
129
+ * /v1/fn/<name>; the generated `VxilSchema` types Input/Output (Level-0
130
+ * opaque until a function declares a signature). A Proxy gives the
131
+ * `vx.fn.<name>` accessor shape without enumerating names at runtime. */
132
+ fn = new Proxy({}, {
133
+ get: (_t, prop) => {
134
+ if (typeof prop !== 'string')
135
+ return undefined;
136
+ // /v1/fn/<name> returns the function's RAW Response (not a {data,meta}
137
+ // envelope), so bypass call()'s unwrap — read the body directly, like
138
+ // audit.export. Errors still surface as VxilError.
139
+ return async (payload) => {
140
+ const res = await this.fetchImpl(`${this.base}${this.path(`/v1/fn/${encodeURIComponent(prop)}`)}`, {
141
+ method: 'POST',
142
+ headers: { authorization: `Bearer ${this.key}`, ...this.authHeaders(), 'content-type': 'application/json' },
143
+ body: JSON.stringify(payload ?? {}),
144
+ });
145
+ const text = await res.text();
146
+ if (!res.ok) {
147
+ let e = {};
148
+ try {
149
+ e = JSON.parse(text).error ?? {};
150
+ }
151
+ catch { /* non-JSON error body */ }
152
+ throw new VxilError(res.status, e.code ?? `http_${res.status}`, e.message ?? text.slice(0, 200));
153
+ }
154
+ return text.length ? JSON.parse(text) : undefined;
155
+ };
156
+ },
157
+ });
158
+ async call(method, path, body, headers = {}) {
159
+ const res = await this.fetchImpl(`${this.base}${this.path(path)}`, {
160
+ method,
161
+ headers: {
162
+ authorization: `Bearer ${this.key}`,
163
+ ...this.authHeaders(),
164
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
165
+ ...headers,
166
+ },
167
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
168
+ });
169
+ const text = await res.text();
170
+ // An empty body on a 2xx is a valid "no content" success (e.g. 204 from
171
+ // DELETE/markRead) — never an error. Only parse when there are bytes; a
172
+ // void-returning caller ignores `data` anyway. (audit #115)
173
+ let parsed = {};
174
+ if (text.length > 0) {
175
+ try {
176
+ parsed = JSON.parse(text);
177
+ }
178
+ catch {
179
+ // A non-JSON *success* body is still a usable response for void calls;
180
+ // only surface the parse failure as an error on a non-2xx.
181
+ if (res.ok)
182
+ return { data: undefined, meta: { request_id: '' }, response: res };
183
+ throw new VxilError(res.status, `http_${res.status}`, text.slice(0, 200));
184
+ }
185
+ }
186
+ else if (!res.ok) {
187
+ // Empty-body non-2xx: no envelope to surface, fall back to the status.
188
+ throw new VxilError(res.status, `http_${res.status}`, `request failed (${res.status})`);
189
+ }
190
+ if (!res.ok || parsed.error) {
191
+ const e = parsed.error ?? { code: `http_${res.status}`, message: text.slice(0, 200) };
192
+ throw new VxilError(res.status, e.code, e.message, e.hint, e.fixUrl, parsed.meta?.request_id);
193
+ }
194
+ return { data: parsed.data, meta: parsed.meta ?? { request_id: '' }, response: res };
195
+ }
196
+ users = {
197
+ upsert: async (user) => (await this.call('POST', '/v1/users', user)).data,
198
+ bulk: async (users) => (await this.call('POST', '/v1/users/bulk', { users })).data.upserted,
199
+ get: async (id, opts) => (await this.call('GET', `/v1/users/${encodeURIComponent(id)}${opts?.includeDeleted ? '?include_deleted=true' : ''}`)).data,
200
+ patch: async (id, patch) => (await this.call('PATCH', `/v1/users/${encodeURIComponent(id)}`, patch)).data,
201
+ /**
202
+ * Delete a user. By default this is a SOFT delete (sets deleted_at; the PII
203
+ * persists for the 30-day retention window). Pass { erase: true } for GDPR
204
+ * Article 17 erasure: the row is soft-deleted AND its PII
205
+ * (email/display_name/avatar_url/attributes) is scrubbed, while the id is
206
+ * kept so cross-feature references stay intact. NOTE: a full account-delete
207
+ * flow also calls the auth half — POST /v1/auth/users/:id/erase — to erase
208
+ * the credential/session identity (see features/tenant-users.md §3).
209
+ */
210
+ delete: async (id, opts) => {
211
+ await this.call('DELETE', `/v1/users/${encodeURIComponent(id)}${opts?.erase ? '?erase=true' : ''}`);
212
+ },
213
+ list: async (q) => {
214
+ const qs = new URLSearchParams();
215
+ if (q?.email)
216
+ qs.set('email', q.email);
217
+ if (q?.q)
218
+ qs.set('q', q.q);
219
+ if (q?.cursor)
220
+ qs.set('cursor', q.cursor);
221
+ if (q?.limit)
222
+ qs.set('limit', String(q.limit));
223
+ const s = qs.toString();
224
+ return (await this.call('GET', `/v1/users${s ? `?${s}` : ''}`)).data;
225
+ },
226
+ };
227
+ notifications = {
228
+ send: async (input, opts) => (await this.call('POST', '/v1/notifications/send', input, opts?.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {})).data,
229
+ inbox: {
230
+ list: async (q) => {
231
+ const qs = new URLSearchParams({ user_id: q.user_id });
232
+ if (q.unread_only)
233
+ qs.set('unread_only', 'true');
234
+ if (q.cursor)
235
+ qs.set('cursor', q.cursor);
236
+ if (q.limit)
237
+ qs.set('limit', String(q.limit));
238
+ return (await this.call('GET', `/v1/notifications/inbox?${qs.toString()}`)).data;
239
+ },
240
+ markRead: async (msgId, userId) => {
241
+ await this.call('POST', `/v1/notifications/inbox/${encodeURIComponent(msgId)}/read`, { user_id: userId });
242
+ },
243
+ markAllRead: async (userId) => (await this.call('POST', '/v1/notifications/inbox/read-all', { user_id: userId })).data.marked_read,
244
+ },
245
+ deliveries: async (q) => {
246
+ const qs = new URLSearchParams();
247
+ if (q?.user_id)
248
+ qs.set('user_id', q.user_id);
249
+ if (q?.status)
250
+ qs.set('status', q.status);
251
+ if (q?.limit)
252
+ qs.set('limit', String(q.limit));
253
+ const s = qs.toString();
254
+ return (await this.call('GET', `/v1/notifications/deliveries${s ? `?${s}` : ''}`)).data.deliveries;
255
+ },
256
+ suppressions: {
257
+ list: async () => (await this.call('GET', '/v1/notifications/suppressions')).data.suppressions,
258
+ add: async (email) => {
259
+ await this.call('POST', '/v1/notifications/suppressions', { email });
260
+ },
261
+ remove: async (email) => {
262
+ await this.call('DELETE', `/v1/notifications/suppressions/${encodeURIComponent(email)}`);
263
+ },
264
+ },
265
+ /** Deliveries the retry budget could not save (the dashboard "Resend" surface). */
266
+ deadLetters: {
267
+ list: async () => (await this.call('GET', '/v1/notifications/dead-letters')).data.dead_letters,
268
+ /** Reset the row and re-enqueue the original message. */
269
+ replay: async (deliveryId) => {
270
+ await this.call('POST', `/v1/notifications/dead-letters/${encodeURIComponent(deliveryId)}/replay`);
271
+ },
272
+ },
273
+ /** A single delivery by id (the list is `deliveries()`). */
274
+ delivery: async (deliveryId) => (await this.call('GET', `/v1/notifications/deliveries/${encodeURIComponent(deliveryId)}`)).data,
275
+ /** Email broadcast campaigns (notifications.md §11b): audience-ref fan-out
276
+ * with quiet-hours + frequency-cap policy. `schedule_cron` sets a recurring
277
+ * send (status `scheduled`); omit it for a `draft`. */
278
+ campaigns: {
279
+ create: async (input) => (await this.call('POST', '/v1/notifications/campaigns', input)).data,
280
+ list: async () => (await this.call('GET', '/v1/notifications/campaigns')).data.campaigns,
281
+ },
282
+ };
283
+ config = {
284
+ get: async (feature) => (await this.call('GET', `/v1/config/${feature}`)).data,
285
+ /** Optimistic-concurrency write: pass the version you read (etag). */
286
+ set: async (feature, manifest, opts) => (await this.call('PUT', `/v1/config/${feature}`, manifest, {
287
+ ...(opts?.ifMatch !== undefined ? { 'if-match': String(opts.ifMatch) } : {}),
288
+ ...(opts?.surface ? { 'x-vxil-surface': opts.surface } : {}),
289
+ })).data,
290
+ };
291
+ features = {
292
+ /** The tenant's enabled-feature summary (GET /v1/features). Privilege-
293
+ * independent: any valid key may read it — it leaks no secrets, only which
294
+ * features the tenant has turned on. Mirrors what the MCP tool-list
295
+ * aggregation sees. (audit #113) */
296
+ list: async () => (await this.call('GET', '/v1/features')).data.features,
297
+ /** The FULL enabled-feature summary: `features` + `api_versions` (released
298
+ * API majors per feature) + the additive `key` block — the CALLING key's
299
+ * identity and per-tool MCP permissions (allowed_tools/denied_tools
300
+ * patterns, api_keys 0057; mcp.md §6.7). `key` is absent for non-key
301
+ * callers and for keys without explicit tool perms. `list()` stays the
302
+ * stable flat-array shorthand. */
303
+ summary: async () => (await this.call('GET', '/v1/features')).data,
304
+ };
305
+ audit = {
306
+ list: async (q) => {
307
+ const qs = new URLSearchParams();
308
+ if (q?.since)
309
+ qs.set('since', q.since);
310
+ if (q?.cursor)
311
+ qs.set('cursor', q.cursor);
312
+ if (q?.limit)
313
+ qs.set('limit', String(q.limit));
314
+ const s = qs.toString();
315
+ return (await this.call('GET', `/v1/audit${s ? `?${s}` : ''}`)).data.events;
316
+ },
317
+ /**
318
+ * NDJSON export (≤10k rows/call, id-ascending). Returns parsed events +
319
+ * next_after_id for resumption (null = done).
320
+ */
321
+ export: async (q) => {
322
+ const qs = new URLSearchParams();
323
+ if (q?.since)
324
+ qs.set('since', q.since);
325
+ if (q?.until)
326
+ qs.set('until', q.until);
327
+ if (q?.after_id)
328
+ qs.set('after_id', q.after_id);
329
+ if (q?.limit)
330
+ qs.set('limit', String(q.limit));
331
+ const s = qs.toString();
332
+ const res = await this.fetchImpl(`${this.base}${this.path(`/v1/audit/export${s ? `?${s}` : ''}`)}`, {
333
+ headers: { authorization: `Bearer ${this.key}` },
334
+ });
335
+ const text = await res.text();
336
+ if (!res.ok) {
337
+ // Preserve the structured error envelope on failure — the export path
338
+ // is hand-rolled (no this.call), so without this a 403/429 would lose
339
+ // its code/hint/requestId. (audit #119)
340
+ let env = {};
341
+ try {
342
+ env = JSON.parse(text);
343
+ }
344
+ catch { /* non-JSON error body — fall through to the status default */ }
345
+ const e = env.error ?? { code: `http_${res.status}`, message: text.slice(0, 200) };
346
+ throw new VxilError(res.status, e.code, e.message, e.hint, e.fixUrl, env.meta?.request_id);
347
+ }
348
+ // Skip blank lines AND tolerate a single malformed NDJSON line rather
349
+ // than aborting the whole batch (e.g. a truncated final line on a large
350
+ // page) — collect parse failures so the caller can decide. (audit #116)
351
+ const events = [];
352
+ const parseErrors = [];
353
+ text.split('\n').forEach((l, i) => {
354
+ if (!l)
355
+ return;
356
+ try {
357
+ events.push(JSON.parse(l));
358
+ }
359
+ catch {
360
+ parseErrors.push({ line: i, raw: l.slice(0, 200) });
361
+ }
362
+ });
363
+ return {
364
+ events,
365
+ next_after_id: res.headers.get('x-vxil-next-after-id'),
366
+ ...(parseErrors.length ? { parse_errors: parseErrors } : {}),
367
+ };
368
+ },
369
+ };
370
+ jobs = {
371
+ /** Enqueue a one-off job; Vxil POSTs a signed callback to target_url with
372
+ * retries. deliver_after (ISO) / delay_seconds (≤ 30 d, at most one of the
373
+ * two) defer the first delivery; > 12 h returns state 'delayed'. */
374
+ enqueue: async (input) => (await this.call('POST', '/v1/jobs/enqueue', input)).data,
375
+ /** Atomic multi-enqueue (≤100 items; any invalid item rejects the whole
376
+ * batch). Each item = the enqueue input, incl. per-item idempotency_key
377
+ * and deliver_after/delay_seconds. Results align with the input order. */
378
+ enqueueBatch: async (items) => (await this.call('POST', '/v1/jobs/enqueue-batch', { jobs: items })).data,
379
+ /** Enqueue a long-running EXTERNAL generation run (jobs.md §11): Vxil calls
380
+ * the provider (BYO key), tracks completion via poll/webhook, mirrors a typed
381
+ * generation_status onto a tenant record, enforces a built-in timeout, and
382
+ * (on failure) fires the payments credit-reversal.
383
+ *
384
+ * `reserve_credits` (§11.8) takes a PROVISIONAL held credit debit at enqueue
385
+ * (linked to the run), committed on `completed` and reversed on
386
+ * failed/timeout/DLQ. `amount` is positive-only and CLAMPED to the platform
387
+ * `config.generation.maxReserveCredits` cap; in end-user mode `user_id` is
388
+ * FORCED to the verified end-user (a mismatched user_id → 400). A 402 aborts
389
+ * the enqueue (insufficient balance); a runaway over the per-tenant
390
+ * outstanding-holds ceiling → 429. */
391
+ generation: async (input) => (await this.call('POST', '/v1/jobs/generation', input)).data,
392
+ runs: async (q) => {
393
+ const qs = new URLSearchParams();
394
+ if (q?.job_name)
395
+ qs.set('job_name', q.job_name);
396
+ if (q?.state)
397
+ qs.set('state', q.state);
398
+ if (q?.ids?.length)
399
+ qs.set('ids', q.ids.join(','));
400
+ if (q?.limit)
401
+ qs.set('limit', String(q.limit));
402
+ const s = qs.toString();
403
+ return (await this.call('GET', `/v1/jobs/runs${s ? `?${s}` : ''}`)).data.runs;
404
+ },
405
+ run: async (runId) => (await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}`)).data,
406
+ cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
407
+ /** Clone a terminal run into a fresh queued run. */
408
+ replay: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/replay`)).data,
409
+ /**
410
+ * Suspend the RUNNING run until an event (call from the executing
411
+ * handler, then return 200 — the suspension wins).
412
+ */
413
+ wait: async (runId, input) => {
414
+ await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/wait`, input);
415
+ },
416
+ /** Wake every run waiting on the event. */
417
+ emitEvent: async (event, payload) => (await this.call('POST', '/v1/jobs/events', { event, ...(payload ? { payload } : {}) })).data.woken,
418
+ /** Secret for verifying X-Vxil-Jobs-Signature on your callback endpoints. */
419
+ signingSecret: async () => (await this.call('GET', '/v1/jobs/signing-secret')).data.signing_secret,
420
+ schedules: {
421
+ /** Recurring (5-field cron, UTC) or one-shot (run_at). Exactly one of cron/run_at. */
422
+ create: async (input) => (await this.call('POST', '/v1/jobs/schedules', input)).data,
423
+ list: async () => (await this.call('GET', '/v1/jobs/schedules')).data.schedules,
424
+ delete: async (scheduleId) => {
425
+ await this.call('DELETE', `/v1/jobs/schedules/${encodeURIComponent(scheduleId)}`);
426
+ },
427
+ /** Stop an active schedule firing (the row + next_run_at survive). */
428
+ pause: async (scheduleId) => (await this.call('POST', `/v1/jobs/schedules/${encodeURIComponent(scheduleId)}/pause`)).data,
429
+ /** Resume a paused schedule; next_run_at recomputes (cron) or re-arms
430
+ * (one-shot; a lapsed run_at → 409 run_at_passed). */
431
+ resume: async (scheduleId) => (await this.call('POST', `/v1/jobs/schedules/${encodeURIComponent(scheduleId)}/resume`)).data,
432
+ },
433
+ /** Per-endpoint/job flow control: rate (per minute) + max parallelism,
434
+ * enforced by the executor. 'endpoint' matches the target_url https
435
+ * ORIGIN; 'job_name' matches exactly. */
436
+ flowRules: {
437
+ create: async (input) => (await this.call('POST', '/v1/jobs/flow-rules', input)).data,
438
+ list: async () => (await this.call('GET', '/v1/jobs/flow-rules')).data.rules,
439
+ delete: async (ruleId) => {
440
+ await this.call('DELETE', `/v1/jobs/flow-rules/${encodeURIComponent(ruleId)}`);
441
+ },
442
+ },
443
+ };
444
+ auth = {
445
+ signUp: async (input) => (await this.call('POST', '/v1/auth/sign-up', input)).data,
446
+ signIn: async (input) => (await this.call('POST', '/v1/auth/sign-in', input)).data,
447
+ magicLink: {
448
+ request: async (input) => {
449
+ await this.call('POST', '/v1/auth/magic-link/request', input);
450
+ },
451
+ verify: async (token) => (await this.call('POST', '/v1/auth/magic-link/verify', { token })).data,
452
+ },
453
+ /** Email OTP sign-in: a 6-digit single-use code (distinct from magic-link).
454
+ * Server-side guessing budget (config otp.maxAttempts), single active code,
455
+ * resend cooldown (429 otp_rate_limited). */
456
+ otp: {
457
+ request: async (input) => {
458
+ await this.call('POST', '/v1/auth/otp/request', input);
459
+ },
460
+ verify: async (input) => (await this.call('POST', '/v1/auth/otp/verify', input)).data,
461
+ },
462
+ /** Anonymous (guest) sessions: instant end-user + session (JWT carries
463
+ * `anon: true`); later convertible via the email-claim OTP link flow. */
464
+ anonymous: {
465
+ signIn: async () => (await this.call('POST', '/v1/auth/anonymous/sign-in', {})).data,
466
+ link: {
467
+ request: async (input) => {
468
+ await this.call('POST', '/v1/auth/anonymous/link/request', input);
469
+ },
470
+ verify: async (input) => (await this.call('POST', '/v1/auth/anonymous/link/verify', input)).data,
471
+ },
472
+ },
473
+ /** Step-up re-auth: an OTP challenge on the CURRENT session. On verify the
474
+ * bearer token ROTATES (the returned session.token replaces the old one —
475
+ * swap it client-side) and the JWT gains an `elv` claim. */
476
+ stepUp: {
477
+ request: async (input) => {
478
+ await this.call('POST', '/v1/auth/step-up/request', input);
479
+ },
480
+ verify: async (input) => (await this.call('POST', '/v1/auth/step-up/verify', input)).data,
481
+ },
482
+ /** End-user administration (server/function surface, auth:write). */
483
+ users: {
484
+ /** GDPR PII erasure: anonymize the end-user's auth + tenant_users record
485
+ * (email → tombstone, password/name/avatar cleared) and revoke every
486
+ * live session. Idempotent — a second call on an already-erased user is a
487
+ * no-op. Meant to be called from a "delete my account" server route or
488
+ * vxil function (which declares `auth:write`). */
489
+ erase: async (userId) => (await this.call('POST', `/v1/auth/users/${encodeURIComponent(userId)}/erase`, {})).data,
490
+ },
491
+ sessions: {
492
+ verify: async (token) => (await this.call('POST', '/v1/auth/sessions/verify', { token })).data,
493
+ /** Rotates the refresh token: store the returned pair, discard the old one. */
494
+ refresh: async (refreshToken) => (await this.call('POST', '/v1/auth/sessions/refresh', { refresh_token: refreshToken })).data,
495
+ revoke: async (token) => {
496
+ await this.call('POST', '/v1/auth/sessions/revoke', { token });
497
+ },
498
+ /** Force-revoke a SPECIFIC session by its `session_id` (from `list`) — the
499
+ * server-forced device-lockout path beyond the client-cooperative
500
+ * by-token `revoke`. Throws 404 when the id is unknown or already revoked. */
501
+ revokeById: async (sessionId) => {
502
+ await this.call('POST', `/v1/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, {});
503
+ },
504
+ /** List a user's active sessions (the account "signed-in devices" surface). */
505
+ list: async (userId) => (await this.call('GET', `/v1/auth/sessions?user_id=${encodeURIComponent(userId)}`)).data.sessions,
506
+ },
507
+ password: {
508
+ requestReset: async (input) => {
509
+ await this.call('POST', '/v1/auth/password/reset/request', input);
510
+ },
511
+ /** Confirming kills every existing session for the user. */
512
+ confirmReset: async (input) => {
513
+ await this.call('POST', '/v1/auth/password/reset/confirm', input);
514
+ },
515
+ },
516
+ /** Social sign-in. The web `start`/`callback` flows are browser redirects
517
+ * (not JSON calls); `native` is the mobile token-exchange the SDK wraps. */
518
+ oauth: {
519
+ /** Native social sign-in: exchange a provider `id_token`/`access_token`
520
+ * for a vxil session (`linked` marks whether the user was created or
521
+ * matched to an existing identity). */
522
+ native: async (provider, input) => (await this.call('POST', `/v1/auth/oauth/${encodeURIComponent(provider)}/native`, input)).data,
523
+ },
524
+ };
525
+ rateLimits = {
526
+ createPolicy: async (input) => (await this.call('POST', '/v1/rate-limits/policies', input)).data,
527
+ listPolicies: async () => (await this.call('GET', '/v1/rate-limits/policies')).data.policies,
528
+ deletePolicy: async (policyId) => {
529
+ await this.call('DELETE', `/v1/rate-limits/policies/${encodeURIComponent(policyId)}`);
530
+ },
531
+ /** Update a policy's mutable fields (name/limit/window/behavior/algorithm);
532
+ * `policy_id` + `key_template` are immutable. */
533
+ updatePolicy: async (policyId, patch) => (await this.call('PUT', `/v1/rate-limits/policies/${encodeURIComponent(policyId)}`, patch)).data,
534
+ /** Reset a single rendered counter back to a full budget (the policy_id +
535
+ * the key_values that render its key). */
536
+ resetCounter: async (input) => (await this.call('POST', '/v1/rate-limits/reset', input)).data,
537
+ /** Per-policy usage analytics: hourly allowed/blocked buckets + top keys
538
+ * (last `hours`, clamped 1..48, default 24). */
539
+ analytics: async (input) => {
540
+ const qs = new URLSearchParams({ policy_id: input.policy_id });
541
+ if (input.hours !== undefined)
542
+ qs.set('hours', String(input.hours));
543
+ return (await this.call('GET', `/v1/rate-limits/analytics?${qs.toString()}`)).data;
544
+ },
545
+ /**
546
+ * Consume budget. With behavior 'block' an exceeded check throws
547
+ * VxilError(429); 'shape' resolves with allowed=false instead.
548
+ * `override_id` is present when a per-identifier override was applied.
549
+ */
550
+ check: async (input) => (await this.call('POST', '/v1/rate-limits/check', input)).data,
551
+ /** Per-identifier overrides layered over a policy: `pattern` matches the
552
+ * RENDERED key (exact, or a `*`-glob where the longest literal prefix
553
+ * wins). Propagates to the check path within ≤30s (KV cacheTtl). */
554
+ overrides: {
555
+ list: async (policyId) => (await this.call('GET', `/v1/rate-limits/policies/${encodeURIComponent(policyId)}/overrides`)).data.overrides,
556
+ create: async (policyId, input) => (await this.call('POST', `/v1/rate-limits/policies/${encodeURIComponent(policyId)}/overrides`, input)).data,
557
+ /** `pattern` is immutable (like a policy's key_template). */
558
+ update: async (policyId, overrideId, patch) => (await this.call('PUT', `/v1/rate-limits/policies/${encodeURIComponent(policyId)}/overrides/${encodeURIComponent(overrideId)}`, patch)).data,
559
+ delete: async (policyId, overrideId) => {
560
+ await this.call('DELETE', `/v1/rate-limits/policies/${encodeURIComponent(policyId)}/overrides/${encodeURIComponent(overrideId)}`);
561
+ },
562
+ },
563
+ };
564
+ cms = {
565
+ collections: {
566
+ /** Define a content type. The model is data — not config. */
567
+ create: async (input) => (await this.call('POST', '/v1/cms/collections', input)).data,
568
+ list: async () => (await this.call('GET', '/v1/cms/collections')).data.collections,
569
+ addField: async (collection, field) => {
570
+ await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, field);
571
+ },
572
+ /** Set (or clear, with `null`) the collection's end-user owner-scope flag
573
+ * (design §5.1). Names an existing `string` field that holds the owner id. */
574
+ setOwnerField: async (collection, ownerField) => {
575
+ await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { owner_field: ownerField });
576
+ },
577
+ },
578
+ items: {
579
+ /** `lock` serializes same-key writers (per-tenant advisory lock); `guard`
580
+ * is the declarative capacity/overlap invariant (requires lock) — cms.md §10. */
581
+ create: async (collection, input) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}`, input)).data,
582
+ get: async (collection, itemId) => (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}`)).data,
583
+ /**
584
+ * The bounded query DSL. filter ops: $eq $ne $gt $gte $lt $lte $in
585
+ * $contains $startsWith (LIKE-escaped; trigram-indexed on s*-slotted
586
+ * fields) $arrayContains/$anyOf (json/relation array containment via the
587
+ * JSONB GIN); range/sort needs slot-indexed fields. This is the
588
+ * DELIBERATELY-UNTYPED escape hatch: it accepts everything the server
589
+ * admits, including unslotted range ops (served by unindexed JSONB
590
+ * scans, bounded only by the statement timeout) that the generated
591
+ * per-field `Filterable` unions on `vx.from(...).query` exclude.
592
+ */
593
+ query: async (collection, q) => {
594
+ const qs = new URLSearchParams();
595
+ if (q?.filter)
596
+ qs.set('filter', JSON.stringify(q.filter));
597
+ if (q?.sort)
598
+ qs.set('sort', q.sort);
599
+ if (q?.limit)
600
+ qs.set('limit', String(q.limit));
601
+ if (q?.cursor)
602
+ qs.set('cursor', q.cursor);
603
+ const s = qs.toString();
604
+ return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}${s ? `?${s}` : ''}`)).data;
605
+ },
606
+ /** Merge-patch data keys; null clears a key. Concurrency opts (cms.md §9):
607
+ * `ifVersion` → If-Match CAS; `if` → bounded field precondition against
608
+ * the locked row; `lock`/`guard` → the §10 serialization primitives. */
609
+ patch: async (collection, itemId, data, opts) => (await this.call('PATCH', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}`, {
610
+ data,
611
+ ...(opts?.if ? { if: opts.if } : {}),
612
+ ...(opts?.lock ? { lock: opts.lock } : {}),
613
+ ...(opts?.guard ? { guard: opts.guard } : {}),
614
+ ...(opts?.guards ? { guards: opts.guards } : {}),
615
+ }, opts?.ifVersion !== undefined ? { 'if-match': String(opts.ifVersion) } : {})).data,
616
+ /** Atomic in-database increment — PATCH `{ $inc: {field: delta} }`, ONE
617
+ * conditional UPDATE guarded by the field's validation min/max (the quota
618
+ * shape), the optional `if` precondition, and If-Match. 409
619
+ * inc_out_of_bounds when the guard refuses (cms.md §9.3). */
620
+ inc: async (collection, itemId, incs, opts) => (await this.call('PATCH', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}`, { $inc: incs, ...(opts?.if ? { if: opts.if } : {}) }, opts?.ifVersion !== undefined ? { 'if-match': String(opts.ifVersion) } : {})).data,
621
+ /** `{ count }` under the same bounded filter grammar (cms.md §9.4). 422
622
+ * count_unavailable_with_read_hooks on beforeRead-hooked collections. */
623
+ count: async (collection, filter) => {
624
+ const qs = new URLSearchParams({ count: 'true' });
625
+ if (filter)
626
+ qs.set('filter', JSON.stringify(filter));
627
+ return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}?${qs.toString()}`)).data.count;
628
+ },
629
+ /** Returns the cascade tally (cms.md §11); `ifVersion` rides If-Match and
630
+ * a conflict aborts BEFORE any cascade side-effect. */
631
+ delete: async (collection, itemId, opts) => (await this.call('DELETE', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}`, undefined, opts?.ifVersion !== undefined ? { 'if-match': String(opts.ifVersion) } : {})).data,
632
+ publish: async (collection, itemId) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}/publish`)).data,
633
+ /** cms-rel B2 (cms.md §12): bounded group-by aggregate. fns count|sum|
634
+ * min|max|avg over index-slot-bound fields; groupBy ≤2 slot-bound
635
+ * fields; filter = the full query DSL incl. ONE-hop dotted join terms
636
+ * ({"channel.visibility":"public"}); scan capped at 50k rows → 422
637
+ * window_too_large (narrow the window or materialize a read-model). */
638
+ aggregate: async (collection, body) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/aggregate`, body)).data,
639
+ /** cms-rel B3 (cms.md §12): window ranking over the aggregate — rank ∈
640
+ * row_number|rank|percent_rank, computed over ≤500 aggregated groups
641
+ * (never raw rows), optional partitionBy. Same scan cap as aggregate. */
642
+ rank: async (collection, body) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/rank`, body)).data,
643
+ },
644
+ /** cms-rel B4 (cms.md §13): atomic multi-collection transaction — 1–5
645
+ * steps over ≤3 collections, per-step `$where` CAS preconditions (the
646
+ * PATCH `if` grammar). All-or-nothing: any failed precondition/validation
647
+ * rolls the WHOLE transaction back (409 precondition_failed names the
648
+ * step). cms-internal only — no cross-feature effects inside the tx. */
649
+ transaction: async (steps) => (await this.call('POST', '/v1/cms/transactions', { steps })).data,
650
+ /** cms-rel B5 (cms.md §12.4): declared read-models (config `readModels`
651
+ * bag) — list with last-run status, and "run now" materialization into
652
+ * the rollup collection. */
653
+ readModels: {
654
+ list: async () => (await this.call('GET', '/v1/cms/read-models')).data.read_models,
655
+ materialize: async (name) => (await this.call('POST', `/v1/cms/read-models/${encodeURIComponent(name)}/materialize`)).data,
656
+ },
657
+ };
658
+ comments = {
659
+ create: async (input) => (await this.call('POST', '/v1/comments', input)).data,
660
+ list: async (q) => {
661
+ const qs = new URLSearchParams({ topic: q.topic });
662
+ if (q.cursor)
663
+ qs.set('cursor', q.cursor);
664
+ if (q.limit)
665
+ qs.set('limit', String(q.limit));
666
+ return (await this.call('GET', `/v1/comments?${qs.toString()}`)).data;
667
+ },
668
+ /** Author edit, allowed within the tenant's editWindowMinutes. */
669
+ edit: async (commentId, input) => {
670
+ await this.call('PATCH', `/v1/comments/${encodeURIComponent(commentId)}`, input);
671
+ },
672
+ /** Tombstone. Pass author_id for self-delete; omit for tenant moderation. */
673
+ delete: async (commentId, opts) => {
674
+ await this.call('DELETE', `/v1/comments/${encodeURIComponent(commentId)}`, opts ?? {});
675
+ },
676
+ /** Toggle an emoji reaction for an author. */
677
+ react: async (commentId, input) => (await this.call('POST', `/v1/comments/${encodeURIComponent(commentId)}/reactions`, input)).data,
678
+ /** Cross-topic recent-comments feed (tenant-wide, newest-first) — the
679
+ * dashboard "recent activity" surface. Keyset cursor + optional filters. */
680
+ recent: async (q) => {
681
+ const qs = new URLSearchParams();
682
+ if (q?.topic_prefix)
683
+ qs.set('topic_prefix', q.topic_prefix);
684
+ if (q?.author_id)
685
+ qs.set('author_id', q.author_id);
686
+ if (q?.cursor)
687
+ qs.set('cursor', q.cursor);
688
+ if (q?.limit !== undefined)
689
+ qs.set('limit', String(q.limit));
690
+ const s = qs.toString();
691
+ return (await this.call('GET', `/v1/comments/recent${s ? `?${s}` : ''}`)).data;
692
+ },
693
+ };
694
+ /**
695
+ * DM / inbox (the `dm` feature) — a conversation ENVELOPE composed over
696
+ * `comments` (message store) + `realtime` (delivery) + `presence`. DM owns
697
+ * only the envelope: membership, read-cursors, mute, and a directed block
698
+ * list; the messages themselves are `comments` rows on the
699
+ * `dm:<conversation_id>` topic. Gated by its own `dm` config, independent of
700
+ * comments. See docs/features/dm.md.
701
+ */
702
+ dm = {
703
+ /** Read the resolved DM config (defaults merged with the stored partial). */
704
+ getConfig: async () => (await this.call('GET', '/v1/dm/config')).data,
705
+ /** Write the DM master config — a partial merge (omitted leaves keep their
706
+ * current value), version-bumped and republished to the gate's KV key. */
707
+ setConfig: async (patch) => (await this.call('PUT', '/v1/dm/config', patch)).data,
708
+ conversations: {
709
+ /** Open (or, for a direct pair, return the existing) conversation. A
710
+ * `direct` conversation (exactly 2 people) dedupes on the unordered pair
711
+ * (reused:true); a block on either side rejects the open (403). */
712
+ open: async (input) => (await this.call('POST', '/v1/dm/conversations', input)).data,
713
+ /** "My inbox": a user's active conversations, newest-activity first, each
714
+ * with `muted` + `unread`. */
715
+ list: async (userId) => (await this.call('GET', `/v1/dm/conversations?user_id=${encodeURIComponent(userId)}`)).data.conversations,
716
+ /** Read a conversation's messages (an authz'd participant only; messages
717
+ * from authors the viewer has blocked are filtered out). Keyset cursor. */
718
+ messages: async (conversationId, q) => {
719
+ const qs = new URLSearchParams({ user_id: q.user_id });
720
+ if (q.cursor)
721
+ qs.set('cursor', q.cursor);
722
+ if (q.limit !== undefined)
723
+ qs.set('limit', String(q.limit));
724
+ return (await this.call('GET', `/v1/dm/conversations/${encodeURIComponent(conversationId)}/messages?${qs.toString()}`)).data;
725
+ },
726
+ },
727
+ /** Send a message (an authz'd participant). Stored as a comment on the
728
+ * conversation topic + fanned out over realtime to the unmuted, non-sender
729
+ * participants (best-effort; degrades to pull). */
730
+ sendMessage: async (conversationId, input) => (await this.call('POST', `/v1/dm/conversations/${encodeURIComponent(conversationId)}/messages`, input)).data,
731
+ /** Advance a participant's read-cursor to `message_id` (forward-only — a
732
+ * mark-read for an older message is a no-op; the cursor never rewinds). */
733
+ markRead: async (conversationId, input) => (await this.call('POST', `/v1/dm/conversations/${encodeURIComponent(conversationId)}/read`, input)).data,
734
+ /** Mute / unmute a participant — suppresses realtime delivery + unread
735
+ * badging WITHOUT removing them from the conversation. */
736
+ mute: async (conversationId, input) => (await this.call('POST', `/v1/dm/conversations/${encodeURIComponent(conversationId)}/mute`, input)).data,
737
+ /** Set / clear a directed user block edge (conversation-independent). Omit
738
+ * `blocked_state` (or pass true) to block; false to unblock. Requires the
739
+ * tenant's `blockingEnabled` DM config (else 403). */
740
+ block: async (input) => (await this.call('POST', '/v1/dm/blocks', input)).data,
741
+ };
742
+ /** The MCP aggregation surface (the `mcp` feature). */
743
+ mcp = {
744
+ /** Per-tenant secret for verifying the X-Vxil-Mcp-Signature header on custom
745
+ * MCP tool calls (mcp.md §6.5) — the mirror of jobs.signingSecret(). */
746
+ signingSecret: async () => (await this.call('GET', '/v1/mcp/signing-secret')).data.signing_secret,
747
+ };
748
+ /**
749
+ * Activity streams + in-app notification feeds (the `activity-feed` feature).
750
+ * Feed refs are addressed as ({group}, {feed_id}); a group's behaviour
751
+ * (flat / aggregated / notification) is the tenant's config, not an argument.
752
+ */
753
+ feeds = {
754
+ /**
755
+ * Add one activity (or a batch — all-or-nothing) to a flat feed. Idempotent
756
+ * upsert on (foreign_id, time); `to:[]` CCs into other feeds; triggers
757
+ * fan-out + a realtime new-activity event.
758
+ */
759
+ addActivity: async (group, feedId, activity) => (await this.call('POST', `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}/activities`, activity)).data.activities,
760
+ /**
761
+ * Read a feed with a keyset cursor. Flat feeds return `activities`; aggregated
762
+ * and notification feeds return `groups` (notification also carries the badge).
763
+ * Pass `markSeen`/`markRead` to acknowledge the returned page on a notification
764
+ * feed; `notificationState` filters to unseen | unread | all.
765
+ */
766
+ read: async (group, feedId, q) => {
767
+ const qs = new URLSearchParams();
768
+ if (q?.limit !== undefined)
769
+ qs.set('limit', String(q.limit));
770
+ if (q?.id_lt)
771
+ qs.set('id_lt', q.id_lt);
772
+ if (q?.id_gt)
773
+ qs.set('id_gt', q.id_gt);
774
+ if (q?.notificationState)
775
+ qs.set('notification_state', q.notificationState);
776
+ if (q?.markSeen)
777
+ qs.set('mark_seen', 'true');
778
+ if (q?.markRead)
779
+ qs.set('mark_read', 'true');
780
+ const s = qs.toString();
781
+ return (await this.call('GET', `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}${s ? `?${s}` : ''}`)).data;
782
+ },
783
+ /** Remove an activity by id (or by foreign_id); tombstones + un-fans-out. */
784
+ removeActivity: async (group, feedId, ref) => {
785
+ const base = `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}/activities`;
786
+ if (ref.foreign_id !== undefined) {
787
+ // The route requires a {ref} path segment even when deleting by foreign_id
788
+ // (the ?foreign_id= query param takes precedence on the server), so we send
789
+ // the foreign_id as the placeholder segment too.
790
+ await this.call('DELETE', `${base}/${encodeURIComponent(ref.foreign_id)}?foreign_id=${encodeURIComponent(ref.foreign_id)}`);
791
+ return;
792
+ }
793
+ await this.call('DELETE', `${base}/${encodeURIComponent(ref.id ?? '')}`);
794
+ },
795
+ /**
796
+ * Follow one target (or many in one txn — all-or-nothing under follow.maxFollowing).
797
+ * Backfills up to `copyLimit` recent activities into this timeline.
798
+ */
799
+ follow: async (group, feedId, target, opts) => {
800
+ const body = Array.isArray(target) ? { targets: target } : { target };
801
+ if (opts?.copyLimit !== undefined)
802
+ body.activity_copy_limit = opts.copyLimit;
803
+ return (await this.call('POST', `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}/follows`, body)).data;
804
+ },
805
+ /** Unfollow a target; un-fans the source out of this timeline (idempotent). */
806
+ unfollow: async (group, feedId, target) => {
807
+ await this.call('DELETE', `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}/follows/${encodeURIComponent(target)}`);
808
+ },
809
+ /** Who a feed FOLLOWS (`following[]` + `following_count`) plus the true
810
+ * `followers_count` (who follows this feed). Keyset cursor via `after`. */
811
+ listFollows: async (group, feedId, q) => {
812
+ const qs = new URLSearchParams();
813
+ if (q?.limit !== undefined)
814
+ qs.set('limit', String(q.limit));
815
+ if (q?.after)
816
+ qs.set('after', q.after);
817
+ const s = qs.toString();
818
+ return (await this.call('GET', `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}/follows${s ? `?${s}` : ''}`)).data;
819
+ },
820
+ /**
821
+ * Block (two-way fan-out suppression, removes existing rows) or mute (one-way
822
+ * read-side filter) the `blocked` feed for `owner`. Defaults to block.
823
+ */
824
+ block: async (input) => (await this.call('POST', '/v1/feeds/blocks', input)).data,
825
+ /** Remove a block/mute. */
826
+ unblock: async (owner, blocked) => {
827
+ await this.call('DELETE', `/v1/feeds/blocks?owner=${encodeURIComponent(owner)}&blocked=${encodeURIComponent(blocked)}`);
828
+ },
829
+ notification: {
830
+ /**
831
+ * Mark groups seen. Pass `ids` to scope, `before` for everything older than a
832
+ * timestamp, or neither to mark all. Recomputes + publishes the badge.
833
+ */
834
+ markSeen: async (userId, scope) => this.feedsMark(userId, 'seen', scope),
835
+ /** Mark groups read (read implies seen). Same scoping as markSeen. */
836
+ markRead: async (userId, scope) => this.feedsMark(userId, 'read', scope),
837
+ /** Mark every group for the user read (the "mark all read" affordance). */
838
+ markAll: async (userId) => this.feedsMark(userId, 'read'),
839
+ /** Archive/dismiss groups (hidden from inbox + dropped from the badge). */
840
+ archive: async (userId, scope) => this.feedsMark(userId, 'archive', scope),
841
+ /** The badge: { unseen, unread, total } (KV-cached over the authority). */
842
+ unreadCount: async (userId) => (await this.call('GET', `/v1/feeds/notification/${encodeURIComponent(userId)}/count`)).data,
843
+ },
844
+ /**
845
+ * Mint realtime connect token(s) for a user, one per requested feed channel.
846
+ * Open wss://api…{connect_path} per token in the browser.
847
+ */
848
+ realtimeToken: async (input) => (await this.call('POST', '/v1/feeds/realtime/token', input)).data,
849
+ /** Read a user's notification preference policy. */
850
+ getPreferences: async (userId) => (await this.call('GET', `/v1/feeds/preferences/${encodeURIComponent(userId)}`)).data,
851
+ /** Write a user's notification preference policy. */
852
+ setPreferences: async (userId, policy) => (await this.call('PUT', `/v1/feeds/preferences/${encodeURIComponent(userId)}`, policy)).data,
853
+ };
854
+ /** Alias for `feeds` — the `activity-feed` feature namespace. */
855
+ activityFeed = this.feeds;
856
+ /** Shared executor for the seen/read/archive notification-state endpoints. */
857
+ async feedsMark(userId, action, scope) {
858
+ const body = scope?.ids !== undefined ? { ids: scope.ids } : {};
859
+ const before = scope?.before ? `?before=${encodeURIComponent(scope.before)}` : '';
860
+ const { unseen, unread, total } = (await this.call('POST', `/v1/feeds/notification/${encodeURIComponent(userId)}/${action}${before}`, body)).data;
861
+ return { unseen, unread, total };
862
+ }
863
+ webhooks = {
864
+ /**
865
+ * Subscribe an https endpoint to audit-stream events (optionally
866
+ * filtered by event-name prefixes like 'user.'). Deliveries are jobs
867
+ * callbacks: verify X-Vxil-Jobs-Signature with jobs.signingSecret().
868
+ */
869
+ subscribe: async (input) => (await this.call('POST', '/v1/webhooks/subscriptions', input)).data,
870
+ list: async () => (await this.call('GET', '/v1/webhooks/subscriptions')).data.subscriptions,
871
+ unsubscribe: async (subId) => {
872
+ await this.call('DELETE', `/v1/webhooks/subscriptions/${encodeURIComponent(subId)}`);
873
+ },
874
+ /**
875
+ * Send a synthetic 'webhooks.test' event to the subscription's endpoint
876
+ * through the REAL signing+delivery pipeline and wait inline (≤ ~8 s).
877
+ * Terminal → { delivered, state, last_error_* }; still in flight → the
878
+ * 202 pending shape { sub_id, run_id, state } (poll jobs.run(run_id)).
879
+ */
880
+ test: async (subId) => (await this.call('POST', `/v1/webhooks/subscriptions/${encodeURIComponent(subId)}/test`)).data,
881
+ sources: {
882
+ /** Register an inbound source; the receiver URL is returned ONCE. */
883
+ create: async (input) => (await this.call('POST', '/v1/webhooks/sources', input)).data,
884
+ list: async () => (await this.call('GET', '/v1/webhooks/sources')).data.sources,
885
+ delete: async (sourceId) => {
886
+ await this.call('DELETE', `/v1/webhooks/sources/${encodeURIComponent(sourceId)}`);
887
+ },
888
+ /** Send a sample envelope to the source's forward_url through the real
889
+ * delivery path (inline result; 202 pending shape when still in flight). */
890
+ test: async (sourceId) => (await this.call('POST', `/v1/webhooks/sources/${encodeURIComponent(sourceId)}/test`)).data,
891
+ },
892
+ events: {
893
+ list: async (q) => {
894
+ const qs = new URLSearchParams();
895
+ if (q?.source_id)
896
+ qs.set('source_id', q.source_id);
897
+ if (q?.status)
898
+ qs.set('status', q.status);
899
+ if (q?.cursor)
900
+ qs.set('cursor', q.cursor);
901
+ if (q?.limit)
902
+ qs.set('limit', String(q.limit));
903
+ const s = qs.toString();
904
+ return (await this.call('GET', `/v1/webhooks/events${s ? `?${s}` : ''}`)).data;
905
+ },
906
+ replay: async (eventId) => {
907
+ await this.call('POST', `/v1/webhooks/events/${encodeURIComponent(eventId)}/replay`);
908
+ },
909
+ /** The Svix message-attempt view: delivery state/attempts/DLQ flag for
910
+ * every jobs run this event's forwards created. */
911
+ runs: async (eventId) => (await this.call('GET', `/v1/webhooks/events/${encodeURIComponent(eventId)}/runs`)).data,
912
+ },
913
+ };
914
+ orgs = {
915
+ create: async (input) => (await this.call('POST', '/v1/orgs', input)).data,
916
+ list: async (q) => {
917
+ const s = q?.user_id ? `?user_id=${encodeURIComponent(q.user_id)}` : '';
918
+ return (await this.call('GET', `/v1/orgs${s}`)).data.orgs;
919
+ },
920
+ delete: async (orgId) => {
921
+ await this.call('DELETE', `/v1/orgs/${encodeURIComponent(orgId)}`);
922
+ },
923
+ /** RBAC: org:read (viewer+), org:write (member+), members:manage (admin+),
924
+ * org:admin (owner) — plus any custom-role permission-set. Pass
925
+ * `opts.resource` to additionally consult per-resource ACL grants; the
926
+ * response `source` marks whether a grant came from the role lattice or an ACL. */
927
+ check: async (orgId, userId, permission, opts) => {
928
+ const qs = new URLSearchParams({ user_id: userId, permission });
929
+ if (opts?.resource)
930
+ qs.set('resource', opts.resource);
931
+ return (await this.call('GET', `/v1/orgs/${encodeURIComponent(orgId)}/check?${qs.toString()}`)).data;
932
+ },
933
+ /** The active-org snapshot auth embeds in session JWTs when the tenant
934
+ * enables auth config `orgClaims` (most-recent membership by joined_at).
935
+ * EVENTUAL-CONSISTENT for JWTs (recomputed at refresh); use `check` for
936
+ * revocation-grade authz. No membership → org_id null. */
937
+ sessionClaims: async (userId) => (await this.call('GET', `/v1/orgs/session-claims?user_id=${encodeURIComponent(userId)}`)).data,
938
+ /** Read one workspace (incl. `settings`). */
939
+ get: async (orgId) => (await this.call('GET', `/v1/orgs/${encodeURIComponent(orgId)}`)).data,
940
+ /** Update a workspace's name / `settings` jsonb. */
941
+ update: async (orgId, patch) => (await this.call('PATCH', `/v1/orgs/${encodeURIComponent(orgId)}`, patch)).data,
942
+ members: {
943
+ add: async (orgId, userId, role) => {
944
+ await this.call('POST', `/v1/orgs/${encodeURIComponent(orgId)}/members`, { user_id: userId, role });
945
+ },
946
+ list: async (orgId) => (await this.call('GET', `/v1/orgs/${encodeURIComponent(orgId)}/members`)).data.members,
947
+ remove: async (orgId, userId) => {
948
+ await this.call('DELETE', `/v1/orgs/${encodeURIComponent(orgId)}/members/${encodeURIComponent(userId)}`);
949
+ },
950
+ },
951
+ invitations: {
952
+ /** invite_token is returned ONCE — your app builds the accept URL. */
953
+ create: async (orgId, email, role) => (await this.call('POST', `/v1/orgs/${encodeURIComponent(orgId)}/invitations`, { email, role })).data,
954
+ accept: async (token, userId) => (await this.call('POST', '/v1/orgs/invitations/accept', { token, user_id: userId })).data,
955
+ revoke: async (inviteId) => {
956
+ await this.call('DELETE', `/v1/orgs/invitations/${encodeURIComponent(inviteId)}`);
957
+ },
958
+ /** List an org's pending invitations. */
959
+ list: async (orgId) => (await this.call('GET', `/v1/orgs/${encodeURIComponent(orgId)}/invitations`)).data.invitations,
960
+ },
961
+ /** Custom tenant roles (permission-sets; migration orgs/0019). A custom role
962
+ * is a named set of permissions assignable like any built-in; the four
963
+ * built-ins reproduce the fixed owner>admin>member>viewer lattice. */
964
+ roles: {
965
+ /** Define (or upsert) a custom role. `role_key` must not collide with a built-in. */
966
+ define: async (input) => (await this.call('POST', '/v1/orgs/roles', input)).data,
967
+ /** The tenant role catalog: `builtin` (the fixed lattice) + `custom`. */
968
+ list: async () => (await this.call('GET', '/v1/orgs/roles')).data,
969
+ /** Delete a custom role (built-in roles cannot be deleted). */
970
+ delete: async (roleKey) => {
971
+ await this.call('DELETE', `/v1/orgs/roles/${encodeURIComponent(roleKey)}`);
972
+ },
973
+ },
974
+ /** Per-org/user resource ACL grants (migration orgs/0019): grant a permission
975
+ * on a specific resource string; `check(...,{ resource })` consults them. */
976
+ acl: {
977
+ grant: async (orgId, input) => (await this.call('POST', `/v1/orgs/${encodeURIComponent(orgId)}/acl`, input)).data,
978
+ /** List an org's resource ACL grants (optionally scoped to one user). */
979
+ list: async (orgId, q) => {
980
+ const s = q?.user_id ? `?user_id=${encodeURIComponent(q.user_id)}` : '';
981
+ return (await this.call('GET', `/v1/orgs/${encodeURIComponent(orgId)}/acl${s}`)).data.grants;
982
+ },
983
+ /** Revoke a resource ACL grant. */
984
+ revoke: async (orgId, input) => {
985
+ await this.call('DELETE', `/v1/orgs/${encodeURIComponent(orgId)}/acl`, input);
986
+ },
987
+ },
988
+ };
989
+ realtime = {
990
+ /** Server-side: mint a connect token for an end-user; the browser opens
991
+ * wss://api…/v1/realtime/connect?token=… with it. */
992
+ mintToken: async (input) => (await this.call('POST', '/v1/realtime/tokens', input)).data,
993
+ publish: async (channel, event, data) => (await this.call('POST', `/v1/realtime/channels/${encodeURIComponent(channel)}/publish`, { event, data })).data.delivered,
994
+ /** Needs the presence feature enabled. */
995
+ presence: async (channel) => (await this.call('GET', `/v1/realtime/channels/${encodeURIComponent(channel)}/presence`)).data,
996
+ /** Glue for the `@vxil/realtime` reconnecting client: returns a function
997
+ * matching its TokenProvider type *structurally* (no import — a static
998
+ * import would break the single-file served sdk.mjs) that re-mints via
999
+ * POST /v1/realtime/tokens with `defaults` merged over the channel the
1000
+ * client asks for. Server-side (Node) use only — it needs the API key;
1001
+ * browsers must fetch tokens from YOUR backend instead. */
1002
+ tokenProvider: (defaults) => (ctx) => this.realtime.mintToken({ channel: ctx.channel, ...defaults }),
1003
+ /** One-shot: mint a token AND open the WebSocket. Uses the global
1004
+ * `WebSocket` (browser / Node ≥22). On older Node, pass a constructor —
1005
+ * `import WebSocket from 'ws'; await vxil.realtime.connect(opts, { WebSocket })`
1006
+ * — instead of failing with a silent `WebSocket is not defined`. For
1007
+ * auto-reconnect / token refresh / presence helpers use `@vxil/realtime`
1008
+ * with `vxil.realtime.tokenProvider(...)` instead. */
1009
+ connect: async (input, opts) => {
1010
+ const WS = (opts?.WebSocket ?? globalThis.WebSocket);
1011
+ if (!WS) {
1012
+ throw new VxilError(0, 'no_websocket', 'No WebSocket available. In the browser it is global; on Node <22 pass one: ' +
1013
+ "import WebSocket from 'ws'; vxil.realtime.connect(opts, { WebSocket }).");
1014
+ }
1015
+ const { connect_path } = await this.realtime.mintToken(input);
1016
+ const wsUrl = `${this.base.replace(/^http/, 'ws')}${connect_path}`;
1017
+ return new WS(wsUrl);
1018
+ },
1019
+ };
1020
+ files = {
1021
+ /** Mint a presigned PUT; upload the bytes yourself, then call complete(). */
1022
+ createUploadUrl: async (input) => (await this.call('POST', '/v1/files/upload-url', input)).data,
1023
+ complete: async (objectId) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/complete`)).data,
1024
+ downloadUrl: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/download-url`)).data.download_url,
1025
+ list: async (q) => {
1026
+ const qs = new URLSearchParams();
1027
+ if (q?.user_id)
1028
+ qs.set('user_id', q.user_id);
1029
+ if (q?.cursor)
1030
+ qs.set('cursor', q.cursor);
1031
+ if (q?.limit)
1032
+ qs.set('limit', String(q.limit));
1033
+ const s = qs.toString();
1034
+ return (await this.call('GET', `/v1/files${s ? `?${s}` : ''}`)).data;
1035
+ },
1036
+ /** Soft delete; bytes are hard-deleted 30 days later. */
1037
+ delete: async (objectId) => {
1038
+ await this.call('DELETE', `/v1/files/${encodeURIComponent(objectId)}`);
1039
+ },
1040
+ /** Aggregate storage usage vs quotas (the FilesManager Storage panel). */
1041
+ usage: async () => (await this.call('GET', '/v1/files/usage')).data,
1042
+ /** OCR / text extraction (files.md §1.1, BYO-key add-on). Small/mock inputs
1043
+ * extract inline (status `available`); large inputs (or `async:true`) return
1044
+ * 202 with a `job_id` — poll `getText()`. */
1045
+ extractText: async (objectId, opts) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/extract-text`, opts ?? {})).data,
1046
+ /** Fetch the cached extraction (status: not_extracted|pending|available|failed). */
1047
+ getText: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/text`)).data,
1048
+ /** Set / extend / clear an object's TTL. `expiresInSeconds: null` clears it
1049
+ * (else a future auto-delete after the given seconds; minimum 60). */
1050
+ setTtl: async (objectId, expiresInSeconds) => (await this.call('PUT', `/v1/files/${encodeURIComponent(objectId)}/ttl`, { expiresInSeconds })).data,
1051
+ sharedLinks: {
1052
+ /** Public (unauthenticated) URL for the object; revocable. */
1053
+ create: async (objectId, opts) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/shared-links`, opts ?? {})).data,
1054
+ /** List an object's active (unrevoked, unexpired) shared links. */
1055
+ list: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/shared-links`)).data.links,
1056
+ revoke: async (linkId) => {
1057
+ await this.call('DELETE', `/v1/files/shared-links/${encodeURIComponent(linkId)}`);
1058
+ },
1059
+ },
1060
+ };
1061
+ /**
1062
+ * Managed hybrid search (the `vector-search` feature): store documents → chunk →
1063
+ * embed → index, then retrieve top-k via hybrid (BM25 ∪ vector, RRF-fused),
1064
+ * vector, or keyword. The embedder is config: 'mock' (deterministic default),
1065
+ * 'byov' (you supply vectors), or a BYO-key provider (openai/cohere).
1066
+ */
1067
+ search = {
1068
+ /** Define a collection. `dimensions` (and an `embed` override) pin its vector
1069
+ * width — one of 256|384|512|768|1024|1536|3072 (3072 = halfvec, sized for
1070
+ * OpenAI text-embedding-3-large); immutable after creation. */
1071
+ createCollection: async (input) => (await this.call('POST', '/v1/search/collections', input)).data,
1072
+ /** List the tenant's collections (with per-collection document counts). */
1073
+ listCollections: async () => (await this.call('GET', '/v1/search/collections')).data.collections,
1074
+ /** Ingest a document: chunk → embed → index (202). BYOV skips embedding. */
1075
+ ingest: async (collection, input) => (await this.call('POST', `/v1/search/${encodeURIComponent(collection)}/documents`, input)).data,
1076
+ /** List indexed documents (keyset cursor, optional `user_id` filter). */
1077
+ listDocuments: async (collection, q) => {
1078
+ const qs = new URLSearchParams();
1079
+ if (q?.user_id)
1080
+ qs.set('user_id', q.user_id);
1081
+ if (q?.cursor)
1082
+ qs.set('cursor', q.cursor);
1083
+ if (q?.limit)
1084
+ qs.set('limit', String(q.limit));
1085
+ const s = qs.toString();
1086
+ return (await this.call('GET', `/v1/search/${encodeURIComponent(collection)}/documents${s ? `?${s}` : ''}`)).data;
1087
+ },
1088
+ /** De-index a document (+ invalidate the query cache). */
1089
+ deleteDocument: async (collection, docId) => {
1090
+ await this.call('DELETE', `/v1/search/${encodeURIComponent(collection)}/documents/${encodeURIComponent(docId)}`);
1091
+ },
1092
+ /** Retrieve top-k. `mode` defaults to the collection's hybrid config; pass
1093
+ * `vector` to run a BYOV query (skips the embedder). `rerank` opts a single
1094
+ * request in/out of the configured BYO reranker (config `rerank` block;
1095
+ * fail-open — a provider fault returns the RRF order with reranked:false). */
1096
+ query: async (collection, input) => (await this.call('POST', `/v1/search/${encodeURIComponent(collection)}/query`, input)).data,
1097
+ /** Embedding-token + query + rerank counts since a timestamp (default 30 days). */
1098
+ usage: async (q) => {
1099
+ const s = q?.since ? `?since=${encodeURIComponent(q.since)}` : '';
1100
+ return (await this.call('GET', `/v1/search/usage${s}`)).data;
1101
+ },
1102
+ /** List the configured cms auto-sync sources with their cursor state. */
1103
+ syncSources: async () => (await this.call('GET', '/v1/search/sync')).data.sources,
1104
+ /** Run one budgeted sync tick inline (409 when a tick is already running).
1105
+ * `sweep: true` runs the hard-delete reconcile leg instead. */
1106
+ syncRun: async (sourceKey, opts) => (await this.call('POST', `/v1/search/sync/${encodeURIComponent(sourceKey)}/run`, opts ?? {})).data,
1107
+ /** Reset a source to a fresh backfill; `purge: true` also hard-deletes every
1108
+ * synced document (chunks + cache invalidation). */
1109
+ syncReset: async (sourceKey, opts) => (await this.call('POST', `/v1/search/sync/${encodeURIComponent(sourceKey)}/reset`, opts ?? {})).data,
1110
+ };
1111
+ /**
1112
+ * The AI substrate (the `ai` feature): store prompt TEMPLATES (config-as-code),
1113
+ * then generate (sync or streamed) and embed across providers. 'mock' is the
1114
+ * deterministic default; the real providers (openai/anthropic/gemini/azure/
1115
+ * openrouter) route via BYO keys in tenant_secrets. `images` on the generate
1116
+ * inputs takes up to 8 vision refs: a public https:// URL, a
1117
+ * data:image/...;base64 URL, or file:<object_id> (a files-feature object) —
1118
+ * fetched images are capped at 4 MiB each / 16 MiB total.
1119
+ */
1120
+ ai = {
1121
+ templates: {
1122
+ /** Store a tenant-authored prompt template; versions are monotonic per name.
1123
+ * `{{var}}` placeholders are filled from `generate`'s `input`. */
1124
+ put: async (input) => (await this.call('POST', '/v1/ai/templates', input)).data,
1125
+ /** List stored templates (each name + its latest version). */
1126
+ list: async () => (await this.call('GET', '/v1/ai/templates')).data.templates,
1127
+ },
1128
+ /** Provider × capability matrix + the tenant's default provider (the set an
1129
+ * agent/dashboard reads to know which providers/features are configured). */
1130
+ capabilities: async () => (await this.call('GET', '/v1/ai/capabilities')).data,
1131
+ /** Synchronous generation: pass `template` (+ `input` vars) or a raw `prompt`. */
1132
+ generate: async (input) => (await this.call('POST', '/v1/ai/generate', { ...input, stream: false })).data,
1133
+ /** Streamed generation: returns the channel + connect token immediately; open
1134
+ * the realtime channel for AiStreamFrame frames (token/title, then a
1135
+ * terminal usage frame). `title:true` (or `title_template`) emits an early
1136
+ * seq'd title frame billed into the same generation. Long streams also get
1137
+ * live-only `token_expiring` events — see AiTokenExpiringEvent. */
1138
+ stream: async (input) => (await this.call('POST', '/v1/ai/generate', { ...input, stream: true })).data,
1139
+ /** Job-routed async generation (long/vision/batch): 202 + a jobs run drives
1140
+ * the provider call; the settled answer lands in the replay buffer at
1141
+ * `resume_path`. Reserve/settle + credit reversal-on-failure ride the run. */
1142
+ generateAsync: async (input) => (await this.call('POST', '/v1/ai/generate', { ...input, mode: 'job' })).data,
1143
+ /** Re-mint a fresh connect token for a LIVE stream (a generation that
1144
+ * outlives the ≤300s realtime token TTL); reconnect with `?since=<seq>`. */
1145
+ remintToken: async (generationId) => (await this.call('GET', `/v1/ai/generations/${encodeURIComponent(generationId)}/token`)).data,
1146
+ /** Resume a streamed generation after a dropped socket: every recorded frame
1147
+ * with seq > `since` plus `done` (the §2a replay buffer — plain JSON, not
1148
+ * an SSE stream). The rag-native mirror is `rag.resume()`. */
1149
+ resume: async (generationId, q) => {
1150
+ const s = q?.since != null ? `?since=${q.since}` : '';
1151
+ return (await this.call('GET', `/v1/ai/generations/${encodeURIComponent(generationId)}/stream${s}`)).data;
1152
+ },
1153
+ /** Embed a batch of strings (1–256). */
1154
+ embed: async (input) => (await this.call('POST', '/v1/ai/embed', input)).data,
1155
+ /** Per-user token rollups (optionally scoped by `since`/`user_id`). */
1156
+ usage: async (q) => {
1157
+ const qs = new URLSearchParams();
1158
+ if (q?.since)
1159
+ qs.set('since', q.since);
1160
+ if (q?.user_id)
1161
+ qs.set('user_id', q.user_id);
1162
+ if (q?.limit)
1163
+ qs.set('limit', String(q.limit));
1164
+ const s = qs.toString();
1165
+ return (await this.call('GET', `/v1/ai/usage${s ? `?${s}` : ''}`)).data;
1166
+ },
1167
+ };
1168
+ /**
1169
+ * Grounded composition (the `rag` feature): retrieve → budget → generate → cite, as
1170
+ * one endpoint. It composes `search` (retrieval) and `ai` (generation); it owns
1171
+ * no datastore. `collection`/`template` fall back to the rag config defaults.
1172
+ */
1173
+ rag = {
1174
+ /** Synchronous grounded answer (full text + citations + usage). */
1175
+ answer: async (input) => (await this.call('POST', '/v1/rag/answer', { ...input, stream: false })).data,
1176
+ /** Streamed grounded answer: citations + the channel handle up front, tokens
1177
+ * over the realtime channel. */
1178
+ stream: async (input) => (await this.call('POST', '/v1/rag/answer', { ...input, stream: true })).data,
1179
+ /** Retrieval-only grounding preview (rag.md §1c): the exact chunks `answer`
1180
+ * would ground on, with rerank + metadata boosts applied — no generation,
1181
+ * no token spend. `boosts`/`rerank`/`min_score` override the rag config. */
1182
+ search: async (input) => (await this.call('POST', '/v1/rag/search', input)).data,
1183
+ /** Ingest into the backing vector-search collection (chunk → embed → index). */
1184
+ ingest: async (collection, input) => (await this.call('POST', `/v1/rag/ingest/${encodeURIComponent(collection)}`, input)).data,
1185
+ /** Resume a streamed answer after a dropped socket: every recorded frame
1186
+ * with seq > `since` plus `done` — the RAG-native proxy of the ai replay
1187
+ * buffer, so a rag-scoped key suffices. */
1188
+ resume: async (generationId, q) => {
1189
+ const s = q?.since != null ? `?since=${q.since}` : '';
1190
+ return (await this.call('GET', `/v1/rag/answers/${encodeURIComponent(generationId)}/stream${s}`)).data;
1191
+ },
1192
+ /** Aggregated retrieval (vector-search) + generation (ai) usage. */
1193
+ usage: async (q) => {
1194
+ const s = q?.since ? `?since=${encodeURIComponent(q.since)}` : '';
1195
+ return (await this.call('GET', `/v1/rag/usage${s}`)).data;
1196
+ },
1197
+ };
1198
+ /**
1199
+ * Copilot — embeddable AI agents (the `copilot` feature). A thin COMPOSITION over
1200
+ * ai + vector-search + rag + mcp: each agent is CONFIG (persona + knowledge +
1201
+ * a curated action allowlist + guardrails). A turn retrieves, grounds, and
1202
+ * generates a cited answer; a WRITE action is PROPOSED, never auto-run — you
1203
+ * `confirm` it, and only then does the real internal route execute (with the
1204
+ * caller's own scopes, so nothing widens). The `mock` ai provider is the
1205
+ * zero-config default — the whole loop is exercisable without provider keys.
1206
+ */
1207
+ copilot = {
1208
+ /** Conversations: open / list / read a chat session pinned to one agent. */
1209
+ conversations: {
1210
+ /** Open a session for `agentId` (agents are config; an unknown id 404s).
1211
+ * Omit `user_id` for a guest conversation. */
1212
+ create: async (agentId, input) => (await this.call('POST', `/v1/copilot/${encodeURIComponent(agentId)}/conversations`, input ?? {})).data,
1213
+ /** List an agent's conversations, newest-updated first (optionally scoped
1214
+ * to one end user). */
1215
+ list: async (agentId, q) => {
1216
+ const qs = new URLSearchParams();
1217
+ if (q?.user_id)
1218
+ qs.set('user_id', q.user_id);
1219
+ if (q?.limit)
1220
+ qs.set('limit', String(q.limit));
1221
+ const s = qs.size ? `?${qs.toString()}` : '';
1222
+ return (await this.call('GET', `/v1/copilot/${encodeURIComponent(agentId)}/conversations${s}`)).data;
1223
+ },
1224
+ /** A conversation row + the last N transcript messages (oldest-first). */
1225
+ get: async (conversationId, q) => {
1226
+ const s = q?.limit ? `?limit=${q.limit}` : '';
1227
+ return (await this.call('GET', `/v1/copilot/conversations/${encodeURIComponent(conversationId)}${s}`)).data;
1228
+ },
1229
+ },
1230
+ /** Send a message — the agentic turn (retrieve → generate → tool_calls →
1231
+ * propose). Returns the synchronous `answer` + citations (+ a `proposal`
1232
+ * when the agent wants a write) AND the streaming handle. `conversation_id`
1233
+ * continues an existing session; omit it to open a new one. */
1234
+ send: async (agentId, input) => (await this.call('POST', `/v1/copilot/${encodeURIComponent(agentId)}/messages`, input)).data,
1235
+ /** Resume a turn's streamed frames after a dropped socket: every recorded
1236
+ * frame with seq > `since` plus `done` — replays without re-running (or
1237
+ * re-billing) the turn. Defaults to the conversation's latest turn. */
1238
+ resume: async (conversationId, q) => {
1239
+ const qs = new URLSearchParams();
1240
+ qs.set('since', String(q?.since ?? 0));
1241
+ if (q?.message_id)
1242
+ qs.set('message_id', q.message_id);
1243
+ return (await this.call('GET', `/v1/copilot/conversations/${encodeURIComponent(conversationId)}/stream?${qs.toString()}`)).data;
1244
+ },
1245
+ /** Confirm a proposed action — the ONLY way a write executes. The client
1246
+ * sends just the path ids; everything that runs comes from the persisted
1247
+ * proposal. Idempotent: pass an Idempotency-Key to make a racing/retried
1248
+ * double-confirm a downstream no-op (defaults to the proposal message_id
1249
+ * server-side). Replaying returns `already:true`. */
1250
+ confirm: async (conversationId, messageId, opts) => (await this.call('POST', `/v1/copilot/conversations/${encodeURIComponent(conversationId)}/actions/${encodeURIComponent(messageId)}/confirm`, undefined, opts?.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {})).data,
1251
+ /** Per-user token rollup across assistant turns. */
1252
+ usage: async (q) => {
1253
+ const qs = new URLSearchParams();
1254
+ if (q?.since)
1255
+ qs.set('since', q.since);
1256
+ if (q?.user_id)
1257
+ qs.set('user_id', q.user_id);
1258
+ if (q?.limit)
1259
+ qs.set('limit', String(q.limit));
1260
+ const s = qs.size ? `?${qs.toString()}` : '';
1261
+ return (await this.call('GET', `/v1/copilot/usage${s}`)).data;
1262
+ },
1263
+ };
1264
+ /**
1265
+ * Payments + the credits & entitlements LEDGER (the `payments` feature). vxil owns
1266
+ * the MECHANISM (a balance, a tier flag, an idempotent debit, a grant cron, a
1267
+ * product→credit translation, an at-most-once reversal); you own the POLICY
1268
+ * (pricing/packaging, what a "scan" costs, when to consume). The `mock` provider
1269
+ * is the zero-config default — the WHOLE ledger path (subscribe → entitlements →
1270
+ * consume → grant → reverse) is exercisable without real provider keys.
1271
+ */
1272
+ payments = {
1273
+ /** Provider + ledger capability surface (+ the `missing` set an agent can act
1274
+ * on to recommend a provider). */
1275
+ capabilities: async () => (await this.call('GET', '/v1/payments/capabilities')).data,
1276
+ /** The user's effective entitlement snapshot (the multi-sub tier fold):
1277
+ * tier + boolean entitlements + numeric quotas. */
1278
+ getEntitlements: async (userId) => (await this.call('GET', `/v1/payments/entitlements?user_id=${encodeURIComponent(userId)}`)).data,
1279
+ /** Boolean gate: does the user hold `entitlement`? Returns granted + its
1280
+ * source (subscription/tier) + the resolved tier. */
1281
+ hasEntitlement: async (userId, entitlement) => (await this.call('GET', `/v1/payments/entitlements/check?user_id=${encodeURIComponent(userId)}&entitlement=${encodeURIComponent(entitlement)}`)).data,
1282
+ /** A single numeric quota for the user (e.g. `seats`, `api_calls`); null when
1283
+ * the resolved tier declares no such quota. Convenience over getEntitlements. */
1284
+ getQuota: async (userId, quota) => {
1285
+ const snap = await this.payments.getEntitlements(userId);
1286
+ const v = snap.quotas[quota];
1287
+ return typeof v === 'number' ? v : null;
1288
+ },
1289
+ /** The credit balance for a credit type: balance/held/available (= balance −
1290
+ * held) + the current period_end. */
1291
+ getBalance: async (userId, creditType) => (await this.call('GET', `/v1/payments/credits/balance?user_id=${encodeURIComponent(userId)}&credit_type=${encodeURIComponent(creditType)}`)).data,
1292
+ /**
1293
+ * Debit credits — idempotent (Idempotency-Key REQUIRED) + balance-guarded
1294
+ * (no oversell). Pass `job_id` to make the debit PROVISIONAL (held, not yet
1295
+ * committed): a linked jobs run that terminally fails auto-refunds the hold,
1296
+ * a success settles it. Throws VxilError(402, 'INSUFFICIENT_CREDITS') when the
1297
+ * available balance can't cover `amount`.
1298
+ */
1299
+ consume: async (input, opts) => (await this.call('POST', '/v1/payments/credits/consume', input, { 'idempotency-key': opts.idempotencyKey })).data,
1300
+ /** Additive grant (recurring/top-up) — idempotent per Idempotency-Key. A grant
1301
+ * only ADDS; `amount` must be ≥ 1. */
1302
+ grant: async (input, opts) => (await this.call('POST', '/v1/payments/credits/grant', input, { 'idempotency-key': opts.idempotencyKey })).data,
1303
+ /** One-time grant idempotent on `grant_key` (at-most-once across ALL flows,
1304
+ * not just retries). A repeat with the same key → granted:false. */
1305
+ grantOnce: async (input) => (await this.call('POST', '/v1/payments/credits/grant-once', input)).data,
1306
+ /** Recent ledger rows for the user (the audit/trail surface), newest first;
1307
+ * optionally scoped to one credit type. */
1308
+ usage: async (q) => {
1309
+ const qs = new URLSearchParams({ user_id: q.user_id });
1310
+ if (q.credit_type)
1311
+ qs.set('credit_type', q.credit_type);
1312
+ if (q.limit)
1313
+ qs.set('limit', String(q.limit));
1314
+ return (await this.call('GET', `/v1/payments/usage?${qs.toString()}`)).data;
1315
+ },
1316
+ /** Start a subscription (mock = instant checkout; real providers redirect via
1317
+ * their own flow). `tier` keys the tierMap fold; `price_ref` is your catalog
1318
+ * ref. Returns the vxil subscription_id + provider sub id + status. */
1319
+ subscribe: async (input) => (await this.call('POST', '/v1/payments/subscriptions', input)).data,
1320
+ /** Cancel a subscription. `atPeriodEnd` true keeps entitlements until the
1321
+ * period closes; false revokes immediately and re-folds entitlements. */
1322
+ cancel: async (subscriptionId, opts) => (await this.call('DELETE', `/v1/payments/subscriptions/${encodeURIComponent(subscriptionId)}${opts?.atPeriodEnd ? '?at_period_end=true' : ''}`)).data,
1323
+ /** List subscriptions (the cancel enabler — discover the subscription_id);
1324
+ * optionally scoped to one user / status. */
1325
+ listSubscriptions: async (q) => {
1326
+ const qs = new URLSearchParams();
1327
+ if (q?.user_id)
1328
+ qs.set('user_id', q.user_id);
1329
+ if (q?.status)
1330
+ qs.set('status', q.status);
1331
+ const s = qs.toString();
1332
+ return (await this.call('GET', `/v1/payments/subscriptions${s ? `?${s}` : ''}`)).data.subscriptions;
1333
+ },
1334
+ /**
1335
+ * Create a hosted-checkout session (payments.md §3). Redirect the buyer to
1336
+ * the returned `url`; completion lands server-side via the provider webhook
1337
+ * (the matching session flips to completed, the charge/grant is folded).
1338
+ * Idempotency-Key REQUIRED — a retry replays the SAME session verbatim.
1339
+ */
1340
+ createCheckoutSession: async (input, opts) => (await this.call('POST', '/v1/payments/checkout-sessions', input, { 'idempotency-key': opts.idempotencyKey })).data,
1341
+ /**
1342
+ * Refund a charge at-most-once (payments.md §6a). Omit `amount_cents` to
1343
+ * refund the full un-refunded remainder. Idempotency-Key REQUIRED — a retry
1344
+ * replays the recorded refund (never a second provider refund); a refund can
1345
+ * NEVER exceed the charge (422 refund_exceeds_charge).
1346
+ */
1347
+ createRefund: async (input, opts) => (await this.call('POST', '/v1/payments/refunds', input, { 'idempotency-key': opts.idempotencyKey })).data,
1348
+ /** The provider-hosted billing-management portal URL for an end user (Stripe
1349
+ * billing portal; mock is deterministic; RC/Paddle/PayPal → 501). */
1350
+ getCustomerPortalUrl: async (userId) => (await this.call('GET', `/v1/payments/customer-portal-url?user_id=${encodeURIComponent(userId)}`)).data,
1351
+ /** List charges newest-first (the refund enabler — discover the charge_id).
1352
+ * Filters: user_id, status, limit (clamped 1..100, default 50). */
1353
+ listCharges: async (q) => {
1354
+ const qs = new URLSearchParams();
1355
+ if (q?.user_id)
1356
+ qs.set('user_id', q.user_id);
1357
+ if (q?.status)
1358
+ qs.set('status', q.status);
1359
+ if (q?.limit)
1360
+ qs.set('limit', String(q.limit));
1361
+ const suffix = qs.size ? `?${qs.toString()}` : '';
1362
+ return (await this.call('GET', `/v1/payments/charges${suffix}`)).data;
1363
+ },
1364
+ /** Provider webhook event log (payments.md §7 "Event log & replay"):
1365
+ * operator visibility over every delivery — incl. persisted signature
1366
+ * failures — plus an idempotent reprocess verb. Needs payments:read
1367
+ * (reprocess: payments:write). */
1368
+ webhookEvents: {
1369
+ /** List deliveries newest-first (keyset-paginated; pass `cursor` from a
1370
+ * prior page's next_cursor). List rows omit payload/raw_body. */
1371
+ list: async (q) => {
1372
+ const qs = new URLSearchParams();
1373
+ if (q?.provider)
1374
+ qs.set('provider', q.provider);
1375
+ if (q?.event_type)
1376
+ qs.set('event_type', q.event_type);
1377
+ if (q?.outcome)
1378
+ qs.set('outcome', q.outcome);
1379
+ if (q?.since)
1380
+ qs.set('since', q.since);
1381
+ if (q?.cursor)
1382
+ qs.set('cursor', q.cursor);
1383
+ if (q?.limit)
1384
+ qs.set('limit', String(q.limit));
1385
+ const suffix = qs.size ? `?${qs.toString()}` : '';
1386
+ return (await this.call('GET', `/v1/payments/webhook-events${suffix}`)).data;
1387
+ },
1388
+ /** Full delivery detail incl. the verified payload and (for failed
1389
+ * deliveries) the truncated raw body. */
1390
+ get: async (eventId) => (await this.call('GET', `/v1/payments/webhook-events/${encodeURIComponent(eventId)}`)).data,
1391
+ /** Re-run the fold from the STORED payload (idempotent — never
1392
+ * double-grants; 409 not_reprocessable for sig_failed/parse_failed). */
1393
+ reprocess: async (eventId) => (await this.call('POST', `/v1/payments/webhook-events/${encodeURIComponent(eventId)}/reprocess`)).data,
1394
+ },
1395
+ };
1396
+ }