@vxil/sdk 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,6 +2,13 @@
2
2
  // Every non-2xx response throws VxilError carrying the structured envelope
3
3
  // (code/message/hint/fixUrl + request id) so callers — humans or agents —
4
4
  // can self-correct.
5
+ /** The released API majors, as a CLOSED union — one per released major (docs/
6
+ * feature-versioning.md §3). `'v1'` is the only released major, so pinning
7
+ * anything else (`apiVersion: 'v2'`) is a COMPILE error until a v2 GAs and
8
+ * widens this union. Every path the SDK builds is major-versioned; the default
9
+ * is the compile-time constant `'v1'` (never a floating `latest` alias). */
10
+ import { qs } from './qs.js';
11
+ import { createTransport, parseRetryAfter } from './retry.js';
5
12
  /** Rewrite a built `/v1/<ns>/…` path onto the version configured for its
6
13
  * namespace: the per-namespace override wins, else the global default. PURE and
7
14
  * exported so it is unit-testable with a hypothetical future major (the runtime
@@ -15,19 +22,28 @@ export function versionedPath(path, globalDefault, overrides) {
15
22
  const version = overrides[ns] ?? globalDefault;
16
23
  return `/${version}/${ns}${m[2]}`;
17
24
  }
25
+ /** Every non-2xx answer is thrown as this class, carrying the structured error
26
+ * envelope. `status` is the HTTP status — or `0` when no response arrived
27
+ * (a `timeoutMs` timeout, `code: 'request_timeout'`). */
18
28
  export class VxilError extends Error {
19
29
  status;
20
30
  code;
21
31
  hint;
22
32
  fixUrl;
23
33
  requestId;
24
- constructor(status, code, message, hint, fixUrl, requestId) {
34
+ retryAfter;
35
+ constructor(status, code, message, hint, fixUrl, requestId,
36
+ /** Seconds the server asked the caller to wait — parsed from `Retry-After`
37
+ * (delta-seconds or an HTTP-date) when the answer carried it (a rate
38
+ * limit, a busy upstream); otherwise undefined. */
39
+ retryAfter) {
25
40
  super(message);
26
41
  this.status = status;
27
42
  this.code = code;
28
43
  this.hint = hint;
29
44
  this.fixUrl = fixUrl;
30
45
  this.requestId = requestId;
46
+ this.retryAfter = retryAfter;
31
47
  this.name = 'VxilError';
32
48
  }
33
49
  }
@@ -54,17 +70,13 @@ function resolveBase(explicit) {
54
70
  export function cmsPublicUrl(tenantId, collection, query, opts) {
55
71
  const base = resolveBase(opts?.baseUrl);
56
72
  const path = `/v1/cms/public/${encodeURIComponent(tenantId)}/${encodeURIComponent(collection)}`;
57
- const qs = new URLSearchParams();
58
- if (query?.filter)
59
- qs.set('filter', JSON.stringify(query.filter));
60
- if (query?.sort)
61
- qs.set('sort', query.sort);
62
- if (query?.limit !== undefined)
63
- qs.set('limit', String(query.limit));
64
- if (query?.cursor)
65
- qs.set('cursor', query.cursor);
66
- const s = qs.toString();
67
- return `${base}${path}${s ? `?${s}` : ''}`;
73
+ const s = qs({
74
+ filter: query?.filter ? JSON.stringify(query.filter) : undefined,
75
+ sort: query?.sort || undefined,
76
+ limit: query?.limit,
77
+ cursor: query?.cursor || undefined,
78
+ });
79
+ return `${base}${path}${s}`;
68
80
  }
69
81
  /** Fetch a page of a collection's PUBLISHED items over the KEYLESS public lane
70
82
  * (roadmap §4.4) — NO api key, no `Vxil` client, no auth of any kind. This is the
@@ -85,12 +97,12 @@ export async function listCmsPublic(tenantId, collection, query, opts) {
85
97
  parsed = JSON.parse(text);
86
98
  }
87
99
  catch {
88
- throw new VxilError(res.status, `http_${res.status}`, text.slice(0, 200));
100
+ throw new VxilError(res.status, `http_${res.status}`, text.slice(0, 200), undefined, undefined, undefined, parseRetryAfter(res.headers.get('retry-after')));
89
101
  }
90
102
  }
91
103
  if (!res.ok || parsed.error) {
92
104
  const e = parsed.error ?? { code: `http_${res.status}`, message: text.slice(0, 200) };
93
- throw new VxilError(res.status, e.code, e.message, e.hint, e.fixUrl, parsed.meta?.request_id);
105
+ throw new VxilError(res.status, e.code, e.message, e.hint, e.fixUrl, parsed.meta?.request_id, parseRetryAfter(res.headers.get('retry-after')));
94
106
  }
95
107
  return parsed.data ?? { items: [], next_cursor: null };
96
108
  }
@@ -103,6 +115,12 @@ export class Vxil {
103
115
  /** The end-user session token threaded as `X-Vxil-End-User` when set (end-user
104
116
  * mode). Undefined ⇒ no header ⇒ server-caller mode (unchanged). */
105
117
  endUserToken;
118
+ /** The ONE path every request takes: retry + timeout + hooks around
119
+ * `fetchImpl`. With none of the three configured it is a single fetch. */
120
+ transport;
121
+ retryOpts;
122
+ timeoutMs;
123
+ hooks;
106
124
  constructor(opts) {
107
125
  this.key = opts.apiKey;
108
126
  this.base = resolveBase(opts.baseUrl);
@@ -110,6 +128,16 @@ export class Vxil {
110
128
  this.apiVersion = opts.apiVersion ?? 'v1';
111
129
  this.apiVersions = opts.apiVersions ?? {};
112
130
  this.endUserToken = opts.endUserToken;
131
+ this.retryOpts = opts.retry;
132
+ this.timeoutMs = opts.timeoutMs;
133
+ this.hooks = opts.hooks;
134
+ this.transport = createTransport({
135
+ fetch: this.fetchImpl,
136
+ ...(opts.retry ? { retry: opts.retry } : {}),
137
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
138
+ ...(opts.hooks ? { hooks: opts.hooks } : {}),
139
+ timeoutError: (ms) => new VxilError(0, 'request_timeout', `request timed out after ${ms}ms`),
140
+ });
113
141
  }
114
142
  /** The header bag every request layers on top of `authorization`: the
115
143
  * `X-Vxil-End-User` session token in end-user mode, nothing in server mode.
@@ -120,9 +148,9 @@ export class Vxil {
120
148
  /** Return a client that sends `X-Vxil-End-User: <token>` on every request —
121
149
  * a per-call/scoped override of end-user mode over an otherwise server-mode
122
150
  * client, mirroring how the edge threads the verified principal. The base
123
- * URL, api key, fetch impl and version pins are inherited unchanged; only the
124
- * end-user token is (re)set. Pass a falsy token to get back a server-mode
125
- * client (drops the header). See docs/end-user-principals-design.md §4.1. */
151
+ * URL, api key, fetch impl, version pins and the retry/timeout/hooks options
152
+ * are inherited unchanged; only the end-user token is (re)set. Pass a falsy token to get back a server-mode
153
+ * client (drops the header). See https://vxil.com/docs/guide/09-security-and-multitenancy. */
126
154
  asEndUser(endUserToken) {
127
155
  return new Vxil({
128
156
  apiKey: this.key,
@@ -130,6 +158,9 @@ export class Vxil {
130
158
  fetch: this.fetchImpl,
131
159
  apiVersion: this.apiVersion,
132
160
  apiVersions: this.apiVersions,
161
+ ...(this.retryOpts ? { retry: this.retryOpts } : {}),
162
+ ...(this.timeoutMs !== undefined ? { timeoutMs: this.timeoutMs } : {}),
163
+ ...(this.hooks ? { hooks: this.hooks } : {}),
133
164
  ...(endUserToken ? { endUserToken } : {}),
134
165
  });
135
166
  }
@@ -188,26 +219,25 @@ export class Vxil {
188
219
  // envelope), so bypass call()'s unwrap — read the body directly, like
189
220
  // audit.export. Errors still surface as VxilError.
190
221
  return async (payload) => {
191
- const res = await this.fetchImpl(`${this.base}${this.path(`/v1/fn/${encodeURIComponent(prop)}`)}`, {
222
+ const { response: res, text } = await this.transport.send(`${this.base}${this.path(`/v1/fn/${encodeURIComponent(prop)}`)}`, {
192
223
  method: 'POST',
193
224
  headers: { authorization: `Bearer ${this.key}`, ...this.authHeaders(), 'content-type': 'application/json' },
194
225
  body: JSON.stringify(payload ?? {}),
195
226
  });
196
- const text = await res.text();
197
227
  if (!res.ok) {
198
228
  let e = {};
199
229
  try {
200
230
  e = JSON.parse(text).error ?? {};
201
231
  }
202
232
  catch { /* non-JSON error body */ }
203
- throw new VxilError(res.status, e.code ?? `http_${res.status}`, e.message ?? text.slice(0, 200));
233
+ throw new VxilError(res.status, e.code ?? `http_${res.status}`, e.message ?? text.slice(0, 200), undefined, undefined, undefined, parseRetryAfter(res.headers.get('retry-after')));
204
234
  }
205
235
  return text.length ? JSON.parse(text) : undefined;
206
236
  };
207
237
  },
208
238
  });
209
239
  async call(method, path, body, headers = {}) {
210
- const res = await this.fetchImpl(`${this.base}${this.path(path)}`, {
240
+ const { response: res, text } = await this.transport.send(`${this.base}${this.path(path)}`, {
211
241
  method,
212
242
  headers: {
213
243
  authorization: `Bearer ${this.key}`,
@@ -217,7 +247,6 @@ export class Vxil {
217
247
  },
218
248
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
219
249
  });
220
- const text = await res.text();
221
250
  // An empty body on a 2xx is a valid "no content" success (e.g. 204 from
222
251
  // DELETE/markRead) — never an error. Only parse when there are bytes; a
223
252
  // void-returning caller ignores `data` anyway. (audit #115)
@@ -231,16 +260,16 @@ export class Vxil {
231
260
  // only surface the parse failure as an error on a non-2xx.
232
261
  if (res.ok)
233
262
  return { data: undefined, meta: { request_id: '' }, response: res };
234
- throw new VxilError(res.status, `http_${res.status}`, text.slice(0, 200));
263
+ throw new VxilError(res.status, `http_${res.status}`, text.slice(0, 200), undefined, undefined, undefined, parseRetryAfter(res.headers.get('retry-after')));
235
264
  }
236
265
  }
237
266
  else if (!res.ok) {
238
267
  // Empty-body non-2xx: no envelope to surface, fall back to the status.
239
- throw new VxilError(res.status, `http_${res.status}`, `request failed (${res.status})`);
268
+ throw new VxilError(res.status, `http_${res.status}`, `request failed (${res.status})`, undefined, undefined, undefined, parseRetryAfter(res.headers.get('retry-after')));
240
269
  }
241
270
  if (!res.ok || parsed.error) {
242
271
  const e = parsed.error ?? { code: `http_${res.status}`, message: text.slice(0, 200) };
243
- throw new VxilError(res.status, e.code, e.message, e.hint, e.fixUrl, parsed.meta?.request_id);
272
+ throw new VxilError(res.status, e.code, e.message, e.hint, e.fixUrl, parsed.meta?.request_id, parseRetryAfter(res.headers.get('retry-after')));
244
273
  }
245
274
  return { data: parsed.data, meta: parsed.meta ?? { request_id: '' }, response: res };
246
275
  }
@@ -262,31 +291,36 @@ export class Vxil {
262
291
  await this.call('DELETE', `/v1/users/${encodeURIComponent(id)}${opts?.erase ? '?erase=true' : ''}`);
263
292
  },
264
293
  list: async (q) => {
265
- const qs = new URLSearchParams();
266
- if (q?.email)
267
- qs.set('email', q.email);
268
- if (q?.q)
269
- qs.set('q', q.q);
270
- if (q?.cursor)
271
- qs.set('cursor', q.cursor);
272
- if (q?.limit)
273
- qs.set('limit', String(q.limit));
274
- const s = qs.toString();
275
- return (await this.call('GET', `/v1/users${s ? `?${s}` : ''}`)).data;
294
+ const s = qs({
295
+ email: q?.email || undefined,
296
+ q: q?.q || undefined,
297
+ cursor: q?.cursor || undefined,
298
+ limit: q?.limit || undefined,
299
+ });
300
+ return (await this.call('GET', `/v1/users${s}`)).data;
276
301
  },
277
302
  };
278
303
  notifications = {
279
304
  send: async (input, opts) => (await this.call('POST', '/v1/notifications/send', input, opts?.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {})).data,
305
+ /** Up to 100 sends in one call. Each item is INDEPENDENTLY atomic (recorded
306
+ * + enqueued, or neither) and `results` is index-aligned with `items`. */
307
+ sendBatch: async (items) => (await this.call('POST', '/v1/notifications/send/batch', { items })).data,
308
+ /** Render a template exactly as a send would, WITHOUT sending. `missing`
309
+ * lists the required data keys you left out. Needs `notifications:read`. */
310
+ preview: async (templateId, input) => (await this.call('POST', `/v1/notifications/templates/${encodeURIComponent(templateId)}/preview`, input ?? {})).data,
311
+ /** Read-only deliverability probe: is `config.fromEmail`'s domain verified
312
+ * at the provider? FAIL-SOFT — `verified: 'unknown'` whenever it cannot be
313
+ * determined (mock provider, no key, provider unreachable). */
314
+ senderDomain: async () => (await this.call('GET', '/v1/notifications/sender-domain')).data,
280
315
  inbox: {
281
316
  list: async (q) => {
282
- const qs = new URLSearchParams({ user_id: q.user_id });
283
- if (q.unread_only)
284
- qs.set('unread_only', 'true');
285
- if (q.cursor)
286
- qs.set('cursor', q.cursor);
287
- if (q.limit)
288
- qs.set('limit', String(q.limit));
289
- return (await this.call('GET', `/v1/notifications/inbox?${qs.toString()}`)).data;
317
+ const s = qs({
318
+ user_id: q.user_id,
319
+ unread_only: q.unread_only || undefined,
320
+ cursor: q.cursor || undefined,
321
+ limit: q.limit || undefined,
322
+ });
323
+ return (await this.call('GET', `/v1/notifications/inbox${s}`)).data;
290
324
  },
291
325
  markRead: async (msgId, userId) => {
292
326
  await this.call('POST', `/v1/notifications/inbox/${encodeURIComponent(msgId)}/read`, { user_id: userId });
@@ -294,15 +328,13 @@ export class Vxil {
294
328
  markAllRead: async (userId) => (await this.call('POST', '/v1/notifications/inbox/read-all', { user_id: userId })).data.marked_read,
295
329
  },
296
330
  deliveries: async (q) => {
297
- const qs = new URLSearchParams();
298
- if (q?.user_id)
299
- qs.set('user_id', q.user_id);
300
- if (q?.status)
301
- qs.set('status', q.status);
302
- if (q?.limit)
303
- qs.set('limit', String(q.limit));
304
- const s = qs.toString();
305
- return (await this.call('GET', `/v1/notifications/deliveries${s ? `?${s}` : ''}`)).data.deliveries;
331
+ const s = qs({
332
+ user_id: q?.user_id || undefined,
333
+ status: q?.status || undefined,
334
+ engagement: q?.engagement || undefined,
335
+ limit: q?.limit || undefined,
336
+ });
337
+ return (await this.call('GET', `/v1/notifications/deliveries${s}`)).data.deliveries;
306
338
  },
307
339
  suppressions: {
308
340
  list: async () => (await this.call('GET', '/v1/notifications/suppressions')).data.suppressions,
@@ -329,6 +361,19 @@ export class Vxil {
329
361
  campaigns: {
330
362
  create: async (input) => (await this.call('POST', '/v1/notifications/campaigns', input)).data,
331
363
  list: async () => (await this.call('GET', '/v1/notifications/campaigns')).data.campaigns,
364
+ /** Stop a scheduled campaign from firing (the jobs cron is torn down). */
365
+ pause: async (campaignId) => (await this.call('POST', `/v1/notifications/campaigns/${encodeURIComponent(campaignId)}/pause`)).data,
366
+ /** Re-register the cron (back to `scheduled`, or `draft` with no cron). */
367
+ resume: async (campaignId) => (await this.call('POST', `/v1/notifications/campaigns/${encodeURIComponent(campaignId)}/resume`)).data,
368
+ /** Terminal stop — the cron is removed and the campaign cannot resume. */
369
+ cancel: async (campaignId) => (await this.call('POST', `/v1/notifications/campaigns/${encodeURIComponent(campaignId)}/cancel`)).data,
370
+ /** Soft-delete: hidden from the list, cron removed; the dedupe ledger
371
+ * survives so a re-created campaign cannot re-send to a served user. */
372
+ remove: async (campaignId) => (await this.call('DELETE', `/v1/notifications/campaigns/${encodeURIComponent(campaignId)}`)).data,
373
+ /** Push ONE audience page (≤ 1000 rows) into a campaign run — the shape a
374
+ * tenant audience callback uses. Omit `audience` for a campaign whose
375
+ * `audience_ref` is the built-in `users:all` selector. */
376
+ run: async (campaignId, audience) => (await this.call('POST', `/v1/notifications/campaigns/${encodeURIComponent(campaignId)}/run`, audience ? { audience } : {})).data,
332
377
  },
333
378
  };
334
379
  config = {
@@ -357,7 +402,7 @@ export class Vxil {
357
402
  * ADVISORY vxil plan — which features to enable, a materialized `config_draft`,
358
403
  * and which truly-unique logic needs a tenant function. Deterministic + pure;
359
404
  * it PROPOSES a plan, it never applies anything (review it, then push the
360
- * config). Needs `features:read`. (POST /v1/plan; docs/planner-feature-design.md) */
405
+ * config). Needs `features:read`. (POST /v1/plan; https://vxil.com/docs/guide/10-agents-and-mcp) */
361
406
  planner = {
362
407
  create: async (description, name) => (await this.call('POST', '/v1/plan', {
363
408
  description,
@@ -366,35 +411,28 @@ export class Vxil {
366
411
  };
367
412
  audit = {
368
413
  list: async (q) => {
369
- const qs = new URLSearchParams();
370
- if (q?.since)
371
- qs.set('since', q.since);
372
- if (q?.cursor)
373
- qs.set('cursor', q.cursor);
374
- if (q?.limit)
375
- qs.set('limit', String(q.limit));
376
- const s = qs.toString();
377
- return (await this.call('GET', `/v1/audit${s ? `?${s}` : ''}`)).data.events;
414
+ const s = qs({
415
+ since: q?.since || undefined,
416
+ cursor: q?.cursor || undefined,
417
+ limit: q?.limit || undefined,
418
+ });
419
+ return (await this.call('GET', `/v1/audit${s}`)).data.events;
378
420
  },
379
421
  /**
380
422
  * NDJSON export (≤10k rows/call, id-ascending). Returns parsed events +
381
423
  * next_after_id for resumption (null = done).
382
424
  */
383
425
  export: async (q) => {
384
- const qs = new URLSearchParams();
385
- if (q?.since)
386
- qs.set('since', q.since);
387
- if (q?.until)
388
- qs.set('until', q.until);
389
- if (q?.after_id)
390
- qs.set('after_id', q.after_id);
391
- if (q?.limit)
392
- qs.set('limit', String(q.limit));
393
- const s = qs.toString();
394
- const res = await this.fetchImpl(`${this.base}${this.path(`/v1/audit/export${s ? `?${s}` : ''}`)}`, {
426
+ const s = qs({
427
+ since: q?.since || undefined,
428
+ until: q?.until || undefined,
429
+ after_id: q?.after_id || undefined,
430
+ limit: q?.limit || undefined,
431
+ });
432
+ const { response: res, text } = await this.transport.send(`${this.base}${this.path(`/v1/audit/export${s}`)}`, {
433
+ method: 'GET',
395
434
  headers: { authorization: `Bearer ${this.key}` },
396
435
  });
397
- const text = await res.text();
398
436
  if (!res.ok) {
399
437
  // Preserve the structured error envelope on failure — the export path
400
438
  // is hand-rolled (no this.call), so without this a 403/429 would lose
@@ -405,7 +443,7 @@ export class Vxil {
405
443
  }
406
444
  catch { /* non-JSON error body — fall through to the status default */ }
407
445
  const e = env.error ?? { code: `http_${res.status}`, message: text.slice(0, 200) };
408
- throw new VxilError(res.status, e.code, e.message, e.hint, e.fixUrl, env.meta?.request_id);
446
+ throw new VxilError(res.status, e.code, e.message, e.hint, e.fixUrl, env.meta?.request_id, parseRetryAfter(res.headers.get('retry-after')));
409
447
  }
410
448
  // Skip blank lines AND tolerate a single malformed NDJSON line rather
411
449
  // than aborting the whole batch (e.g. a truncated final line on a large
@@ -459,17 +497,13 @@ export class Vxil {
459
497
  * outstanding-holds ceiling → 429. */
460
498
  generation: async (input) => (await this.call('POST', '/v1/jobs/generation', input)).data,
461
499
  runs: async (q) => {
462
- const qs = new URLSearchParams();
463
- if (q?.job_name)
464
- qs.set('job_name', q.job_name);
465
- if (q?.state)
466
- qs.set('state', q.state);
467
- if (q?.ids?.length)
468
- qs.set('ids', q.ids.join(','));
469
- if (q?.limit)
470
- qs.set('limit', String(q.limit));
471
- const s = qs.toString();
472
- return (await this.call('GET', `/v1/jobs/runs${s ? `?${s}` : ''}`)).data.runs;
500
+ const s = qs({
501
+ job_name: q?.job_name || undefined,
502
+ state: q?.state || undefined,
503
+ ids: q?.ids,
504
+ limit: q?.limit || undefined,
505
+ });
506
+ return (await this.call('GET', `/v1/jobs/runs${s}`)).data.runs;
473
507
  },
474
508
  run: async (runId) => (await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}`)).data,
475
509
  cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
@@ -510,6 +544,16 @@ export class Vxil {
510
544
  },
511
545
  },
512
546
  };
547
+ /** vxil-auth. SCOPES (vxil.com/docs/guide/09-security-and-multitenancy): every flow here that
548
+ * obtains, renews, verifies or ends the caller's OWN session (signUp/signIn,
549
+ * magicLink, otp, anonymous, stepUp, oauth, sessions.refresh/revoke/verify,
550
+ * password reset) accepts the narrow `auth:signin` — the ONLY auth scope a
551
+ * key baked into a browser/mobile bundle should carry. `sessions.list` needs
552
+ * `auth:read`; `sessions.revokeById` and `users.erase` need `auth:write`
553
+ * (administrative — server keys only). In end-user mode (`endUserToken` /
554
+ * `asEndUser`) the administrative calls bind to the signed-in user: own
555
+ * sessions only, own devices only, erase self only. `auth:write` still
556
+ * satisfies every call. */
513
557
  auth = {
514
558
  signUp: async (input) => (await this.call('POST', '/v1/auth/sign-up', input)).data,
515
559
  signIn: async (input) => (await this.call('POST', '/v1/auth/sign-in', input)).data,
@@ -523,9 +567,11 @@ export class Vxil {
523
567
  * Server-side guessing budget (config otp.maxAttempts), single active code,
524
568
  * resend cooldown (429 otp_rate_limited). */
525
569
  otp: {
526
- request: async (input) => {
527
- await this.call('POST', '/v1/auth/otp/request', input);
528
- },
570
+ /** `test_code` is present ONLY when the address matches auth config
571
+ * `otp.testRecipients` (store-review / CI accounts): no mail is sent and
572
+ * the code comes back instead. `captcha_token` is required when the
573
+ * tenant configured `security.captchaSecretRef`. */
574
+ request: async (input) => (await this.call('POST', '/v1/auth/otp/request', input)).data,
529
575
  verify: async (input) => (await this.call('POST', '/v1/auth/otp/verify', input)).data,
530
576
  },
531
577
  /** Anonymous (guest) sessions: instant end-user + session (JWT carries
@@ -533,9 +579,12 @@ export class Vxil {
533
579
  anonymous: {
534
580
  signIn: async () => (await this.call('POST', '/v1/auth/anonymous/sign-in', {})).data,
535
581
  link: {
536
- request: async (input) => {
537
- await this.call('POST', '/v1/auth/anonymous/link/request', input);
538
- },
582
+ request: async (input) => (await this.call('POST', '/v1/auth/anonymous/link/request', input)).data,
583
+ /** On success the guest keeps its user id (`user_id` unchanged).
584
+ * If the claimed email ALREADY has an account, the guest is MERGED
585
+ * into it: `user_id` is the existing account, `merged: true`, and a
586
+ * fresh `session.token` (same session, re-signed for the merged
587
+ * identity — swap it client-side; other guest sessions are revoked). */
539
588
  verify: async (input) => (await this.call('POST', '/v1/auth/anonymous/link/verify', input)).data,
540
589
  },
541
590
  },
@@ -543,9 +592,7 @@ export class Vxil {
543
592
  * bearer token ROTATES (the returned session.token replaces the old one —
544
593
  * swap it client-side) and the JWT gains an `elv` claim. */
545
594
  stepUp: {
546
- request: async (input) => {
547
- await this.call('POST', '/v1/auth/step-up/request', input);
548
- },
595
+ request: async (input) => (await this.call('POST', '/v1/auth/step-up/request', input)).data,
549
596
  verify: async (input) => (await this.call('POST', '/v1/auth/step-up/verify', input)).data,
550
597
  },
551
598
  /** End-user administration (server/function surface, auth:write). */
@@ -554,8 +601,16 @@ export class Vxil {
554
601
  * (email → tombstone, password/name/avatar cleared) and revoke every
555
602
  * live session. Idempotent — a second call on an already-erased user is a
556
603
  * no-op. Meant to be called from a "delete my account" server route or
557
- * vxil function (which declares `auth:write`). */
604
+ * vxil function (which declares `auth:write`). In END-USER mode the call
605
+ * is SELF-only: `userId` must be the signed-in user, any other id throws
606
+ * 403 `server_only` (a thin client can never erase someone else). */
558
607
  erase: async (userId) => (await this.call('POST', `/v1/auth/users/${encodeURIComponent(userId)}/erase`, {})).data,
608
+ /** END-USER MODE ONLY (`endUserToken` / `asEndUser`): the signed-in user
609
+ * deep-merges a bounded `attributes` object into its OWN shared identity
610
+ * row (null deletes a key; ≤8 KB, ≤64 top-level keys, depth ≤8). Profile
611
+ * fields are not patchable here. In server mode the call throws 422
612
+ * `end_user_mode_required` — use `users.patch(id, …)` instead. */
613
+ patchMe: async (attributes) => (await this.call('PATCH', '/v1/auth/users/me', { attributes })).data,
559
614
  },
560
615
  sessions: {
561
616
  verify: async (token) => (await this.call('POST', '/v1/auth/sessions/verify', { token })).data,
@@ -564,13 +619,24 @@ export class Vxil {
564
619
  revoke: async (token) => {
565
620
  await this.call('POST', '/v1/auth/sessions/revoke', { token });
566
621
  },
622
+ /** "Sign out everywhere": revoke EVERY live session of a user in one call
623
+ * (one statement, one batched edge-cache write; each session's
624
+ * revoked_reason = 'revoke_all'). SERVER mode names the user (`userId`,
625
+ * scope `auth:write`); in END-USER mode the signed-in user's own sessions
626
+ * go and `userId` is ignored (scope `auth:signin` suffices). Idempotent:
627
+ * a user with no live sessions returns `revoked: 0`. */
628
+ revokeAll: async (userId) => (await this.call('POST', '/v1/auth/sessions/revoke-all', userId !== undefined ? { user_id: userId } : {})).data,
567
629
  /** Force-revoke a SPECIFIC session by its `session_id` (from `list`) — the
568
630
  * server-forced device-lockout path beyond the client-cooperative
569
- * by-token `revoke`. Throws 404 when the id is unknown or already revoked. */
631
+ * by-token `revoke`. Throws 404 when the id is unknown or already revoked
632
+ * — or, in end-user mode, when it belongs to another user (a signed-in
633
+ * user can kick only their OWN devices). Scope `auth:write`. */
570
634
  revokeById: async (sessionId) => {
571
635
  await this.call('POST', `/v1/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, {});
572
636
  },
573
- /** List a user's active sessions (the account "signed-in devices" surface). */
637
+ /** List a user's active sessions (the account "signed-in devices" surface).
638
+ * Scope `auth:read`. In end-user mode the list is bound to the signed-in
639
+ * user regardless of `userId` (the verified principal wins). */
574
640
  list: async (userId) => (await this.call('GET', `/v1/auth/sessions?user_id=${encodeURIComponent(userId)}`)).data.sessions,
575
641
  },
576
642
  password: {
@@ -582,13 +648,36 @@ export class Vxil {
582
648
  await this.call('POST', '/v1/auth/password/reset/confirm', input);
583
649
  },
584
650
  },
651
+ /** Email verification (the flow behind auth config `emailVerification.required`,
652
+ * which makes password sign-in answer 403 `email_not_verified` until the
653
+ * address is confirmed). `request` always resolves (anti-enumeration —
654
+ * a link is mailed only to an existing, unverified user; `redirect_url`
655
+ * is your page that reads `?token=` and calls `confirm`). `confirm` is
656
+ * single-use and expires after 24h. */
657
+ emailVerification: {
658
+ request: async (input) => {
659
+ await this.call('POST', '/v1/auth/email/verify/request', input);
660
+ },
661
+ confirm: async (token) => (await this.call('POST', '/v1/auth/email/verify/confirm', { token })).data,
662
+ },
585
663
  /** Social sign-in. The web `start`/`callback` flows are browser redirects
586
- * (not JSON calls); `native` is the mobile token-exchange the SDK wraps. */
664
+ * (not JSON calls): send the browser to
665
+ * `GET /v1/auth/oauth/{provider}/start?redirect_uri=…` (server-side, with
666
+ * your key) and hand the returned `code`+`state` to `…/callback`; `native`
667
+ * is the mobile / broker token-exchange the SDK wraps. `oidc` is the
668
+ * tenant's generic OIDC / SSO issuer (auth config `providers.oidc` —
669
+ * Okta / Entra / Auth0 / any OpenID Connect IdP, or a SAML broker that
670
+ * speaks OIDC); it rides the same three routes. */
587
671
  oauth: {
588
672
  /** Native social sign-in: exchange a provider `id_token`/`access_token`
589
673
  * for a vxil session (`linked` marks whether the user was created or
590
674
  * matched to an existing identity). */
591
- native: async (provider, input) => (await this.call('POST', `/v1/auth/oauth/${encodeURIComponent(provider)}/native`, input)).data,
675
+ native: async (provider,
676
+ /** `anonymous_token`: the caller's CURRENT guest session bearer — the
677
+ * guest is PROMOTED to this identity (same user id, `linked: 'promoted'`)
678
+ * or, if the identity already has an account, MERGED into it
679
+ * (`linked: 'merged'`, `user_id` = that account). */
680
+ input) => (await this.call('POST', `/v1/auth/oauth/${encodeURIComponent(provider)}/native`, input)).data,
592
681
  },
593
682
  };
594
683
  rateLimits = {
@@ -606,10 +695,8 @@ export class Vxil {
606
695
  /** Per-policy usage analytics: hourly allowed/blocked buckets + top keys
607
696
  * (last `hours`, clamped 1..48, default 24). */
608
697
  analytics: async (input) => {
609
- const qs = new URLSearchParams({ policy_id: input.policy_id });
610
- if (input.hours !== undefined)
611
- qs.set('hours', String(input.hours));
612
- return (await this.call('GET', `/v1/rate-limits/analytics?${qs.toString()}`)).data;
698
+ const s = qs({ policy_id: input.policy_id, hours: input.hours });
699
+ return (await this.call('GET', `/v1/rate-limits/analytics${s}`)).data;
613
700
  },
614
701
  /**
615
702
  * Consume budget. With behavior 'block' an exceeded check throws
@@ -638,6 +725,16 @@ export class Vxil {
638
725
  addField: async (collection, field) => {
639
726
  await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, field);
640
727
  },
728
+ /** Set (or clear, with `null`/`[]`) a field's READ-ROLE gate (cms.md §18) —
729
+ * the same-type in-place alter on the fields route. Re-sends the field's
730
+ * `type` (required by the alter path); every OTHER attribute the field
731
+ * carries is re-sent from `rest`, because the alter overwrites the whole
732
+ * definition. In verified end-user mode a gated field is omitted from every
733
+ * read unless the session's verified roles intersect `roles`; server-caller
734
+ * reads and ALL writes are unaffected. */
735
+ setFieldReadRoles: async (collection, field, type, roles, rest = {}) => {
736
+ await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { ...rest, field, type, read_roles: roles ?? [] });
737
+ },
641
738
  /** Set (or clear, with `null`) the collection's end-user owner-scope flag
642
739
  * (design §5.1). Names an existing `string` field that holds the owner id. */
643
740
  setOwnerField: async (collection, ownerField) => {
@@ -651,6 +748,13 @@ export class Vxil {
651
748
  setPublic: async (collection, isPublic) => {
652
749
  await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { public: isPublic });
653
750
  },
751
+ /** Replace the collection's per-record ACTION list (cms.md §17) — the
752
+ * `{ actions }` fields-route meta-op; `[]` clears. Each action is ONE
753
+ * human-initiated step: the dashboard renders it as a button per record,
754
+ * and `items.runAction` invokes its deployed function. */
755
+ setActions: async (collection, actions) => {
756
+ await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { actions });
757
+ },
654
758
  },
655
759
  items: {
656
760
  /** `lock` serializes same-key writers (per-tenant advisory lock); `guard`
@@ -668,17 +772,13 @@ export class Vxil {
668
772
  * per-field `Filterable` unions on `vx.from(...).query` exclude.
669
773
  */
670
774
  query: async (collection, q) => {
671
- const qs = new URLSearchParams();
672
- if (q?.filter)
673
- qs.set('filter', JSON.stringify(q.filter));
674
- if (q?.sort)
675
- qs.set('sort', q.sort);
676
- if (q?.limit)
677
- qs.set('limit', String(q.limit));
678
- if (q?.cursor)
679
- qs.set('cursor', q.cursor);
680
- const s = qs.toString();
681
- return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}${s ? `?${s}` : ''}`)).data;
775
+ const s = qs({
776
+ filter: q?.filter ? JSON.stringify(q.filter) : undefined,
777
+ sort: q?.sort || undefined,
778
+ limit: q?.limit || undefined,
779
+ cursor: q?.cursor || undefined,
780
+ });
781
+ return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}${s}`)).data;
682
782
  },
683
783
  /** Merge-patch data keys; null clears a key. Concurrency opts (cms.md §9):
684
784
  * `ifVersion` → If-Match CAS; `if` → bounded field precondition against
@@ -698,15 +798,29 @@ export class Vxil {
698
798
  /** `{ count }` under the same bounded filter grammar (cms.md §9.4). 422
699
799
  * count_unavailable_with_read_hooks on beforeRead-hooked collections. */
700
800
  count: async (collection, filter) => {
701
- const qs = new URLSearchParams({ count: 'true' });
702
- if (filter)
703
- qs.set('filter', JSON.stringify(filter));
704
- return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}?${qs.toString()}`)).data.count;
801
+ const s = qs({ count: 'true', filter: filter ? JSON.stringify(filter) : undefined });
802
+ return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}${s}`)).data.count;
705
803
  },
706
804
  /** Returns the cascade tally (cms.md §11); `ifVersion` rides If-Match and
707
805
  * a conflict aborts BEFORE any cascade side-effect. */
708
806
  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,
709
807
  publish: async (collection, itemId) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}/publish`)).data,
808
+ /** P1-12 (cms.md §11.1): the BOUNDED reverse read — which live items
809
+ * reference this one, through which relation field. Owner-scoped in
810
+ * end-user mode like `get`. `count` is the page returned (≤ limit, max
811
+ * 100), never a total; `has_more` says the cap was hit. A DELETE refused
812
+ * with 409 `referenced` (an `on_delete: 'restrict'` edge) names the same
813
+ * refs in its `referencing` field. */
814
+ backlinks: async (collection, itemId, opts) => {
815
+ const qs = opts?.limit !== undefined ? `?limit=${encodeURIComponent(String(opts.limit))}` : '';
816
+ return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}/backlinks${qs}`)).data;
817
+ },
818
+ /** P1-7 (cms.md §17): run ONE declared per-record action — invokes the
819
+ * action's deployed function with `{ collection, item_id, action, actor,
820
+ * item }` and returns its result. 404 when the key is not declared;
821
+ * 502 `action_failed` (with `upstream.code` = the function's error
822
+ * class) when the function fails. Requires cms:write. */
823
+ runAction: async (collection, itemId, key) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}/actions/${encodeURIComponent(key)}`)).data,
710
824
  /** cms-rel B2 (cms.md §12): bounded group-by aggregate. fns count|sum|
711
825
  * min|max|avg over index-slot-bound fields; groupBy ≤2 slot-bound
712
826
  * fields; filter = the full query DSL incl. ONE-hop dotted join terms
@@ -735,12 +849,8 @@ export class Vxil {
735
849
  comments = {
736
850
  create: async (input) => (await this.call('POST', '/v1/comments', input)).data,
737
851
  list: async (q) => {
738
- const qs = new URLSearchParams({ topic: q.topic });
739
- if (q.cursor)
740
- qs.set('cursor', q.cursor);
741
- if (q.limit)
742
- qs.set('limit', String(q.limit));
743
- return (await this.call('GET', `/v1/comments?${qs.toString()}`)).data;
852
+ const s = qs({ topic: q.topic, cursor: q.cursor || undefined, limit: q.limit || undefined });
853
+ return (await this.call('GET', `/v1/comments${s}`)).data;
744
854
  },
745
855
  /** Author edit, allowed within the tenant's editWindowMinutes. */
746
856
  edit: async (commentId, input) => {
@@ -755,17 +865,13 @@ export class Vxil {
755
865
  /** Cross-topic recent-comments feed (tenant-wide, newest-first) — the
756
866
  * dashboard "recent activity" surface. Keyset cursor + optional filters. */
757
867
  recent: async (q) => {
758
- const qs = new URLSearchParams();
759
- if (q?.topic_prefix)
760
- qs.set('topic_prefix', q.topic_prefix);
761
- if (q?.author_id)
762
- qs.set('author_id', q.author_id);
763
- if (q?.cursor)
764
- qs.set('cursor', q.cursor);
765
- if (q?.limit !== undefined)
766
- qs.set('limit', String(q.limit));
767
- const s = qs.toString();
768
- return (await this.call('GET', `/v1/comments/recent${s ? `?${s}` : ''}`)).data;
868
+ const s = qs({
869
+ topic_prefix: q?.topic_prefix || undefined,
870
+ author_id: q?.author_id || undefined,
871
+ cursor: q?.cursor || undefined,
872
+ limit: q?.limit,
873
+ });
874
+ return (await this.call('GET', `/v1/comments/recent${s}`)).data;
769
875
  },
770
876
  };
771
877
  /**
@@ -774,7 +880,7 @@ export class Vxil {
774
880
  * only the envelope: membership, read-cursors, mute, and a directed block
775
881
  * list; the messages themselves are `comments` rows on the
776
882
  * `dm:<conversation_id>` topic. Gated by its own `dm` config, independent of
777
- * comments. See docs/features/dm.md.
883
+ * comments. See vxil.com/docs/guide/06-feature-catalog.
778
884
  */
779
885
  dm = {
780
886
  /** Read the resolved DM config (defaults merged with the stored partial). */
@@ -793,12 +899,8 @@ export class Vxil {
793
899
  /** Read a conversation's messages (an authz'd participant only; messages
794
900
  * from authors the viewer has blocked are filtered out). Keyset cursor. */
795
901
  messages: async (conversationId, q) => {
796
- const qs = new URLSearchParams({ user_id: q.user_id });
797
- if (q.cursor)
798
- qs.set('cursor', q.cursor);
799
- if (q.limit !== undefined)
800
- qs.set('limit', String(q.limit));
801
- return (await this.call('GET', `/v1/dm/conversations/${encodeURIComponent(conversationId)}/messages?${qs.toString()}`)).data;
902
+ const s = qs({ user_id: q.user_id, cursor: q.cursor || undefined, limit: q.limit });
903
+ return (await this.call('GET', `/v1/dm/conversations/${encodeURIComponent(conversationId)}/messages${s}`)).data;
802
904
  },
803
905
  },
804
906
  /** Send a message (an authz'd participant). Stored as a comment on the
@@ -841,21 +943,15 @@ export class Vxil {
841
943
  * feed; `notificationState` filters to unseen | unread | all.
842
944
  */
843
945
  read: async (group, feedId, q) => {
844
- const qs = new URLSearchParams();
845
- if (q?.limit !== undefined)
846
- qs.set('limit', String(q.limit));
847
- if (q?.id_lt)
848
- qs.set('id_lt', q.id_lt);
849
- if (q?.id_gt)
850
- qs.set('id_gt', q.id_gt);
851
- if (q?.notificationState)
852
- qs.set('notification_state', q.notificationState);
853
- if (q?.markSeen)
854
- qs.set('mark_seen', 'true');
855
- if (q?.markRead)
856
- qs.set('mark_read', 'true');
857
- const s = qs.toString();
858
- return (await this.call('GET', `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}${s ? `?${s}` : ''}`)).data;
946
+ const s = qs({
947
+ limit: q?.limit,
948
+ id_lt: q?.id_lt || undefined,
949
+ id_gt: q?.id_gt || undefined,
950
+ notification_state: q?.notificationState || undefined,
951
+ mark_seen: q?.markSeen || undefined,
952
+ mark_read: q?.markRead || undefined,
953
+ });
954
+ return (await this.call('GET', `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}${s}`)).data;
859
955
  },
860
956
  /** Remove an activity by id (or by foreign_id); tombstones + un-fans-out. */
861
957
  removeActivity: async (group, feedId, ref) => {
@@ -886,13 +982,8 @@ export class Vxil {
886
982
  /** Who a feed FOLLOWS (`following[]` + `following_count`) plus the true
887
983
  * `followers_count` (who follows this feed). Keyset cursor via `after`. */
888
984
  listFollows: async (group, feedId, q) => {
889
- const qs = new URLSearchParams();
890
- if (q?.limit !== undefined)
891
- qs.set('limit', String(q.limit));
892
- if (q?.after)
893
- qs.set('after', q.after);
894
- const s = qs.toString();
895
- return (await this.call('GET', `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}/follows${s ? `?${s}` : ''}`)).data;
985
+ const s = qs({ limit: q?.limit, after: q?.after || undefined });
986
+ return (await this.call('GET', `/v1/feeds/${encodeURIComponent(group)}/${encodeURIComponent(feedId)}/follows${s}`)).data;
896
987
  },
897
988
  /**
898
989
  * Block (two-way fan-out suppression, removes existing rows) or mute (one-way
@@ -968,17 +1059,13 @@ export class Vxil {
968
1059
  },
969
1060
  events: {
970
1061
  list: async (q) => {
971
- const qs = new URLSearchParams();
972
- if (q?.source_id)
973
- qs.set('source_id', q.source_id);
974
- if (q?.status)
975
- qs.set('status', q.status);
976
- if (q?.cursor)
977
- qs.set('cursor', q.cursor);
978
- if (q?.limit)
979
- qs.set('limit', String(q.limit));
980
- const s = qs.toString();
981
- return (await this.call('GET', `/v1/webhooks/events${s ? `?${s}` : ''}`)).data;
1062
+ const s = qs({
1063
+ source_id: q?.source_id || undefined,
1064
+ status: q?.status || undefined,
1065
+ cursor: q?.cursor || undefined,
1066
+ limit: q?.limit || undefined,
1067
+ });
1068
+ return (await this.call('GET', `/v1/webhooks/events${s}`)).data;
982
1069
  },
983
1070
  replay: async (eventId) => {
984
1071
  await this.call('POST', `/v1/webhooks/events/${encodeURIComponent(eventId)}/replay`);
@@ -987,6 +1074,12 @@ export class Vxil {
987
1074
  * every jobs run this event's forwards created. */
988
1075
  runs: async (eventId) => (await this.call('GET', `/v1/webhooks/events/${encodeURIComponent(eventId)}/runs`)).data,
989
1076
  },
1077
+ /** Every audit event the platform can emit, with the subscribable prefixes
1078
+ * and their counts — the discovery surface for `subscribe({ event_prefixes })`.
1079
+ * Static reference data (CI-generated from the emitters), identical for every
1080
+ * tenant. `level` is the event CLASS ('lifecycle' | 'failure'), not the
1081
+ * payload's 'info'|'warn'|'error' severity key. */
1082
+ eventCatalog: async () => (await this.call('GET', '/v1/webhooks/events/catalog')).data,
990
1083
  };
991
1084
  orgs = {
992
1085
  create: async (input) => (await this.call('POST', '/v1/orgs', input)).data,
@@ -1002,10 +1095,8 @@ export class Vxil {
1002
1095
  * `opts.resource` to additionally consult per-resource ACL grants; the
1003
1096
  * response `source` marks whether a grant came from the role lattice or an ACL. */
1004
1097
  check: async (orgId, userId, permission, opts) => {
1005
- const qs = new URLSearchParams({ user_id: userId, permission });
1006
- if (opts?.resource)
1007
- qs.set('resource', opts.resource);
1008
- return (await this.call('GET', `/v1/orgs/${encodeURIComponent(orgId)}/check?${qs.toString()}`)).data;
1098
+ const s = qs({ user_id: userId, permission, resource: opts?.resource || undefined });
1099
+ return (await this.call('GET', `/v1/orgs/${encodeURIComponent(orgId)}/check${s}`)).data;
1009
1100
  },
1010
1101
  /** The active-org snapshot auth embeds in session JWTs when the tenant
1011
1102
  * enables auth config `orgClaims` (most-recent membership by joined_at).
@@ -1100,15 +1191,12 @@ export class Vxil {
1100
1191
  complete: async (objectId) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/complete`)).data,
1101
1192
  downloadUrl: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/download-url`)).data.download_url,
1102
1193
  list: async (q) => {
1103
- const qs = new URLSearchParams();
1104
- if (q?.user_id)
1105
- qs.set('user_id', q.user_id);
1106
- if (q?.cursor)
1107
- qs.set('cursor', q.cursor);
1108
- if (q?.limit)
1109
- qs.set('limit', String(q.limit));
1110
- const s = qs.toString();
1111
- return (await this.call('GET', `/v1/files${s ? `?${s}` : ''}`)).data;
1194
+ const s = qs({
1195
+ user_id: q?.user_id || undefined,
1196
+ cursor: q?.cursor || undefined,
1197
+ limit: q?.limit || undefined,
1198
+ });
1199
+ return (await this.call('GET', `/v1/files${s}`)).data;
1112
1200
  },
1113
1201
  /** Soft delete; bytes are hard-deleted 30 days later. */
1114
1202
  delete: async (objectId) => {
@@ -1126,9 +1214,12 @@ export class Vxil {
1126
1214
  * (else a future auto-delete after the given seconds; minimum 60). */
1127
1215
  setTtl: async (objectId, expiresInSeconds) => (await this.call('PUT', `/v1/files/${encodeURIComponent(objectId)}/ttl`, { expiresInSeconds })).data,
1128
1216
  sharedLinks: {
1129
- /** Public (unauthenticated) URL for the object; revocable. */
1217
+ /** Public (unauthenticated) URL for the object; revocable. Optional
1218
+ * `max_downloads` (1 = one-time link) and an absolute `expires_at`; the
1219
+ * public resolver answers 410 `link_exhausted` / `link_expired` past
1220
+ * either bound. */
1130
1221
  create: async (objectId, opts) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/shared-links`, opts ?? {})).data,
1131
- /** List an object's active (unrevoked, unexpired) shared links. */
1222
+ /** List an object's active (unrevoked, unexpired, unexhausted) shared links. */
1132
1223
  list: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/shared-links`)).data.links,
1133
1224
  revoke: async (linkId) => {
1134
1225
  await this.call('DELETE', `/v1/files/shared-links/${encodeURIComponent(linkId)}`);
@@ -1152,15 +1243,12 @@ export class Vxil {
1152
1243
  ingest: async (collection, input) => (await this.call('POST', `/v1/search/${encodeURIComponent(collection)}/documents`, input)).data,
1153
1244
  /** List indexed documents (keyset cursor, optional `user_id` filter). */
1154
1245
  listDocuments: async (collection, q) => {
1155
- const qs = new URLSearchParams();
1156
- if (q?.user_id)
1157
- qs.set('user_id', q.user_id);
1158
- if (q?.cursor)
1159
- qs.set('cursor', q.cursor);
1160
- if (q?.limit)
1161
- qs.set('limit', String(q.limit));
1162
- const s = qs.toString();
1163
- return (await this.call('GET', `/v1/search/${encodeURIComponent(collection)}/documents${s ? `?${s}` : ''}`)).data;
1246
+ const s = qs({
1247
+ user_id: q?.user_id || undefined,
1248
+ cursor: q?.cursor || undefined,
1249
+ limit: q?.limit || undefined,
1250
+ });
1251
+ return (await this.call('GET', `/v1/search/${encodeURIComponent(collection)}/documents${s}`)).data;
1164
1252
  },
1165
1253
  /** De-index a document (+ invalidate the query cache). */
1166
1254
  deleteDocument: async (collection, docId) => {
@@ -1230,19 +1318,34 @@ export class Vxil {
1230
1318
  const s = q?.since != null ? `?since=${q.since}` : '';
1231
1319
  return (await this.call('GET', `/v1/ai/generations/${encodeURIComponent(generationId)}/stream${s}`)).data;
1232
1320
  },
1321
+ /** Classify an input into one of YOUR labels (or, with `multi:true`, every
1322
+ * label that applies) — a schema-forced verdict over the generate path:
1323
+ * the allowed labels ride the structured-output schema as an enum, so the
1324
+ * model cannot answer off-list; the reply is `{ label | labels,
1325
+ * confidence, rationale }`. Same metering/cache/provider rules as
1326
+ * `generate` (a still-invalid verdict is a billed 422
1327
+ * output_schema_mismatch). Caps: 2–64 labels (≤64 chars, unique), input
1328
+ * ≤32k chars (a string or ordered `{ text }` parts joined into ONE
1329
+ * input), rubric ≤4k, ≤16 examples. */
1330
+ classify: async (input) => (await this.call('POST', '/v1/ai/classify', input)).data,
1331
+ /** Grade a candidate response (or rank up to 8) against YOUR criteria —
1332
+ * a schema-forced integer score inside `scale` (default 0..10) plus
1333
+ * `pass|fail` for one candidate, or `scores[]` + `best` (+ `verdict:'tie'`)
1334
+ * for several — `candidates` always yields `scores`/`best`, even at
1335
+ * length 1. Same metering/cache/provider rules as `generate`. Caps:
1336
+ * input ≤16k chars, candidates ≤8 × 4k chars, criteria ≤16 items / 4k
1337
+ * chars, scale span ≤100. */
1338
+ judge: async (input) => (await this.call('POST', '/v1/ai/judge', input)).data,
1233
1339
  /** Embed a batch of strings (1–256). */
1234
1340
  embed: async (input) => (await this.call('POST', '/v1/ai/embed', input)).data,
1235
1341
  /** Per-user token rollups (optionally scoped by `since`/`user_id`). */
1236
1342
  usage: async (q) => {
1237
- const qs = new URLSearchParams();
1238
- if (q?.since)
1239
- qs.set('since', q.since);
1240
- if (q?.user_id)
1241
- qs.set('user_id', q.user_id);
1242
- if (q?.limit)
1243
- qs.set('limit', String(q.limit));
1244
- const s = qs.toString();
1245
- return (await this.call('GET', `/v1/ai/usage${s ? `?${s}` : ''}`)).data;
1343
+ const s = qs({
1344
+ since: q?.since || undefined,
1345
+ user_id: q?.user_id || undefined,
1346
+ limit: q?.limit || undefined,
1347
+ });
1348
+ return (await this.call('GET', `/v1/ai/usage${s}`)).data;
1246
1349
  },
1247
1350
  };
1248
1351
  /**
@@ -1293,12 +1396,7 @@ export class Vxil {
1293
1396
  /** List an agent's conversations, newest-updated first (optionally scoped
1294
1397
  * to one end user). */
1295
1398
  list: async (agentId, q) => {
1296
- const qs = new URLSearchParams();
1297
- if (q?.user_id)
1298
- qs.set('user_id', q.user_id);
1299
- if (q?.limit)
1300
- qs.set('limit', String(q.limit));
1301
- const s = qs.size ? `?${qs.toString()}` : '';
1399
+ const s = qs({ user_id: q?.user_id || undefined, limit: q?.limit || undefined });
1302
1400
  return (await this.call('GET', `/v1/copilot/${encodeURIComponent(agentId)}/conversations${s}`)).data;
1303
1401
  },
1304
1402
  /** A conversation row + the last N transcript messages (oldest-first). */
@@ -1316,11 +1414,8 @@ export class Vxil {
1316
1414
  * frame with seq > `since` plus `done` — replays without re-running (or
1317
1415
  * re-billing) the turn. Defaults to the conversation's latest turn. */
1318
1416
  resume: async (conversationId, q) => {
1319
- const qs = new URLSearchParams();
1320
- qs.set('since', String(q?.since ?? 0));
1321
- if (q?.message_id)
1322
- qs.set('message_id', q.message_id);
1323
- return (await this.call('GET', `/v1/copilot/conversations/${encodeURIComponent(conversationId)}/stream?${qs.toString()}`)).data;
1417
+ const s = qs({ since: q?.since ?? 0, message_id: q?.message_id || undefined });
1418
+ return (await this.call('GET', `/v1/copilot/conversations/${encodeURIComponent(conversationId)}/stream${s}`)).data;
1324
1419
  },
1325
1420
  /** Confirm a proposed action — the ONLY way a write executes. The client
1326
1421
  * sends just the path ids; everything that runs comes from the persisted
@@ -1330,14 +1425,11 @@ export class Vxil {
1330
1425
  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,
1331
1426
  /** Per-user token rollup across assistant turns. */
1332
1427
  usage: async (q) => {
1333
- const qs = new URLSearchParams();
1334
- if (q?.since)
1335
- qs.set('since', q.since);
1336
- if (q?.user_id)
1337
- qs.set('user_id', q.user_id);
1338
- if (q?.limit)
1339
- qs.set('limit', String(q.limit));
1340
- const s = qs.size ? `?${qs.toString()}` : '';
1428
+ const s = qs({
1429
+ since: q?.since || undefined,
1430
+ user_id: q?.user_id || undefined,
1431
+ limit: q?.limit || undefined,
1432
+ });
1341
1433
  return (await this.call('GET', `/v1/copilot/usage${s}`)).data;
1342
1434
  },
1343
1435
  };
@@ -1351,13 +1443,30 @@ export class Vxil {
1351
1443
  */
1352
1444
  payments = {
1353
1445
  /** Provider + ledger capability surface (+ the `missing` set an agent can act
1354
- * on to recommend a provider). */
1446
+ * on to recommend a provider). `client` is the RevenueCat tenant's PUBLIC
1447
+ * SDK handout (project id + public SDK key — never the secret key); null
1448
+ * for every other provider. */
1355
1449
  capabilities: async () => (await this.call('GET', '/v1/payments/capabilities')).data,
1356
1450
  /** The user's effective entitlement snapshot (the multi-sub tier fold):
1357
- * tier + boolean entitlements + numeric quotas. */
1358
- getEntitlements: async (userId) => (await this.call('GET', `/v1/payments/entitlements?user_id=${encodeURIComponent(userId)}`)).data,
1451
+ * tier + boolean entitlements + numeric quotas — plus the access window
1452
+ * (`since` / `until` = the winning subscription's period end, or end +
1453
+ * grace when it is past_due), `as_of` (server time), the `environment`
1454
+ * the winning subscription was written from, and OPTIONAL inline reads:
1455
+ * `quota` inlines one quota, `creditType` inlines the same owner-bound
1456
+ * balance `getBalance` returns — ONE call for a thin client's paywall.
1457
+ * A non-2xx answer means UNKNOWN: render the last cached answer, never
1458
+ * free (payments.md §3a). */
1459
+ getEntitlements: async (userId, opts) => {
1460
+ const s = qs({
1461
+ user_id: userId,
1462
+ quota: opts?.quota || undefined,
1463
+ credit_type: opts?.creditType || undefined,
1464
+ });
1465
+ return (await this.call('GET', `/v1/payments/entitlements${s}`)).data;
1466
+ },
1359
1467
  /** Boolean gate: does the user hold `entitlement`? Returns granted + its
1360
- * source (subscription/tier) + the resolved tier. */
1468
+ * source (subscription/tier) + the resolved tier, plus `until` (how long
1469
+ * the answer is good for) and `as_of`. Non-2xx = unknown, never free. */
1361
1470
  hasEntitlement: async (userId, entitlement) => (await this.call('GET', `/v1/payments/entitlements/check?user_id=${encodeURIComponent(userId)}&entitlement=${encodeURIComponent(entitlement)}`)).data,
1362
1471
  /** A single numeric quota for the user (e.g. `seats`, `api_calls`); null when
1363
1472
  * the resolved tier declares no such quota. Convenience over getEntitlements. */
@@ -1386,12 +1495,12 @@ export class Vxil {
1386
1495
  /** Recent ledger rows for the user (the audit/trail surface), newest first;
1387
1496
  * optionally scoped to one credit type. */
1388
1497
  usage: async (q) => {
1389
- const qs = new URLSearchParams({ user_id: q.user_id });
1390
- if (q.credit_type)
1391
- qs.set('credit_type', q.credit_type);
1392
- if (q.limit)
1393
- qs.set('limit', String(q.limit));
1394
- return (await this.call('GET', `/v1/payments/usage?${qs.toString()}`)).data;
1498
+ const s = qs({
1499
+ user_id: q.user_id,
1500
+ credit_type: q.credit_type || undefined,
1501
+ limit: q.limit || undefined,
1502
+ });
1503
+ return (await this.call('GET', `/v1/payments/usage${s}`)).data;
1395
1504
  },
1396
1505
  /** Start a subscription (mock = instant checkout; real providers redirect via
1397
1506
  * their own flow). `tier` keys the tierMap fold; `price_ref` is your catalog
@@ -1403,13 +1512,8 @@ export class Vxil {
1403
1512
  /** List subscriptions (the cancel enabler — discover the subscription_id);
1404
1513
  * optionally scoped to one user / status. */
1405
1514
  listSubscriptions: async (q) => {
1406
- const qs = new URLSearchParams();
1407
- if (q?.user_id)
1408
- qs.set('user_id', q.user_id);
1409
- if (q?.status)
1410
- qs.set('status', q.status);
1411
- const s = qs.toString();
1412
- return (await this.call('GET', `/v1/payments/subscriptions${s ? `?${s}` : ''}`)).data.subscriptions;
1515
+ const s = qs({ user_id: q?.user_id || undefined, status: q?.status || undefined });
1516
+ return (await this.call('GET', `/v1/payments/subscriptions${s}`)).data.subscriptions;
1413
1517
  },
1414
1518
  /**
1415
1519
  * Create a hosted-checkout session (payments.md §3). Redirect the buyer to
@@ -1425,20 +1529,56 @@ export class Vxil {
1425
1529
  * NEVER exceed the charge (422 refund_exceeds_charge).
1426
1530
  */
1427
1531
  createRefund: async (input, opts) => (await this.call('POST', '/v1/payments/refunds', input, { 'idempotency-key': opts.idempotencyKey })).data,
1428
- /** The provider-hosted billing-management portal URL for an end user (Stripe
1429
- * billing portal; mock is deterministic; RC/Paddle/PayPal → 501). */
1532
+ /** Where the user MANAGES their subscription, resolved by FUNDING SOURCE
1533
+ * (the provider of their live subscription, not the tenant's primary):
1534
+ * `kind: 'portal'` = a provider-minted portal session URL (Stripe, Paddle
1535
+ * via the captured customer id, mock); `'store'` = the App Store / Play
1536
+ * Store manage-subscriptions page for a RevenueCat row that recorded its
1537
+ * store; `'none'` (url null) = nothing to link (PayPal, a manual grant, no
1538
+ * live subscription on a portal-less primary). */
1430
1539
  getCustomerPortalUrl: async (userId) => (await this.call('GET', `/v1/payments/customer-portal-url?user_id=${encodeURIComponent(userId)}`)).data,
1540
+ /** SERVER-ONLY. A support / promotional / migration grant: a `manual`
1541
+ * subscription row (no provider behind it) folded like a webhook —
1542
+ * entitlements + the tier's recurring credits apply; `until` null =
1543
+ * open-ended, else access ends at `until`. Idempotent on
1544
+ * `idempotency_key` (a replay returns the first grant, `replayed: true`).
1545
+ * 422 unknown_tier when the tier is not in ledger.tierMap. */
1546
+ grantSubscription: async (input) => (await this.call('POST', '/v1/payments/subscriptions/grant', input)).data,
1547
+ /** SERVER-ONLY. Revoke a MANUAL grant now (409 not_manual for a provider
1548
+ * subscription — use `cancel`). Never touches a provider. */
1549
+ revokeSubscription: async (subscriptionId) => (await this.call('POST', `/v1/payments/subscriptions/${encodeURIComponent(subscriptionId)}/revoke`)).data,
1550
+ /** SERVER-ONLY. Re-read a customer's subscriptions FROM the provider and
1551
+ * fold them through the same conditional upsert a webhook uses (never a
1552
+ * second write path). `dry_run` diffs without writing. Capped 30/min per
1553
+ * project (429 rate_limited). The migration backfill (`vxil migrate
1554
+ * payments --from-provider …`) drives this per customer. */
1555
+ syncSubscriptions: async (input) => (await this.call('POST', '/v1/payments/subscriptions/sync', input)).data,
1556
+ /** The server leg of "Restore Purchases": in end-user mode the verified
1557
+ * principal's OWN provider state is re-read (no user id needed); in server
1558
+ * mode pass `user_id`. 3 per hour per user (429). Returns the sync outcome
1559
+ * plus the fresh entitlement view in one answer. */
1560
+ restoreSubscriptions: async (input) => (await this.call('POST', '/v1/payments/subscriptions/restore', input ?? {})).data,
1561
+ /** SERVER-ONLY. Account merge: move EVERY provider's subscriptions,
1562
+ * customer links and available credit balances of `from_user_id` onto
1563
+ * `into_user_id` — at-most-once per pair. The consumer of the
1564
+ * `auth.user.merged { from, into }` audit event. */
1565
+ reKeySubscriptions: async (input) => (await this.call('POST', '/v1/payments/subscriptions/re-key', input)).data,
1566
+ /** SERVER-ONLY, mock/dev tenants ONLY (403 simulation_not_allowed
1567
+ * elsewhere). Drive a scripted lifecycle (renewal, expiry, refund-pair,
1568
+ * past-due-grace, cross-platform-unlock, transfer) through the mock
1569
+ * webhook path and judge it with the conformance oracle. */
1570
+ simulate: async (input) => (await this.call('POST', '/v1/payments/simulate', input)).data,
1571
+ /** The last report-only reconciliation sweep result for this project
1572
+ * (`run: null` before the first daily tick). */
1573
+ reconcile: async () => (await this.call('GET', '/v1/payments/reconcile')).data,
1431
1574
  /** List charges newest-first (the refund enabler — discover the charge_id).
1432
1575
  * Filters: user_id, status, limit (clamped 1..100, default 50). */
1433
1576
  listCharges: async (q) => {
1434
- const qs = new URLSearchParams();
1435
- if (q?.user_id)
1436
- qs.set('user_id', q.user_id);
1437
- if (q?.status)
1438
- qs.set('status', q.status);
1439
- if (q?.limit)
1440
- qs.set('limit', String(q.limit));
1441
- const suffix = qs.size ? `?${qs.toString()}` : '';
1577
+ const suffix = qs({
1578
+ user_id: q?.user_id || undefined,
1579
+ status: q?.status || undefined,
1580
+ limit: q?.limit || undefined,
1581
+ });
1442
1582
  return (await this.call('GET', `/v1/payments/charges${suffix}`)).data;
1443
1583
  },
1444
1584
  /** Provider webhook event log (payments.md §7 "Event log & replay"):
@@ -1449,20 +1589,15 @@ export class Vxil {
1449
1589
  /** List deliveries newest-first (keyset-paginated; pass `cursor` from a
1450
1590
  * prior page's next_cursor). List rows omit payload/raw_body. */
1451
1591
  list: async (q) => {
1452
- const qs = new URLSearchParams();
1453
- if (q?.provider)
1454
- qs.set('provider', q.provider);
1455
- if (q?.event_type)
1456
- qs.set('event_type', q.event_type);
1457
- if (q?.outcome)
1458
- qs.set('outcome', q.outcome);
1459
- if (q?.since)
1460
- qs.set('since', q.since);
1461
- if (q?.cursor)
1462
- qs.set('cursor', q.cursor);
1463
- if (q?.limit)
1464
- qs.set('limit', String(q.limit));
1465
- const suffix = qs.size ? `?${qs.toString()}` : '';
1592
+ const suffix = qs({
1593
+ provider: q?.provider || undefined,
1594
+ event_type: q?.event_type || undefined,
1595
+ outcome: q?.outcome || undefined,
1596
+ environment: q?.environment || undefined,
1597
+ since: q?.since || undefined,
1598
+ cursor: q?.cursor || undefined,
1599
+ limit: q?.limit || undefined,
1600
+ });
1466
1601
  return (await this.call('GET', `/v1/payments/webhook-events${suffix}`)).data;
1467
1602
  },
1468
1603
  /** Full delivery detail incl. the verified payload and (for failed