@vxil/sdk 0.2.0 → 0.4.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 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,8 +148,8 @@ 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
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
125
153
  * client (drops the header). See docs/end-user-principals-design.md §4.1. */
126
154
  asEndUser(endUserToken) {
127
155
  return new Vxil({
@@ -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 = {
@@ -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
@@ -429,6 +467,13 @@ export class Vxil {
429
467
  };
430
468
  },
431
469
  };
470
+ /** Current-month usage: requests used vs the plan's included quota, plus
471
+ * month-to-date per-feature metered detail. Read-only (needs `usage:read`
472
+ * or `features:read`). Agents: call this before a bulk run to self-check
473
+ * remaining quota. */
474
+ usage = {
475
+ current: async () => (await this.call('GET', '/v1/usage')).data,
476
+ };
432
477
  jobs = {
433
478
  /** Enqueue a one-off job; Vxil POSTs a signed callback to target_url with
434
479
  * retries. deliver_after (ISO) / delay_seconds (≤ 30 d, at most one of the
@@ -452,17 +497,13 @@ export class Vxil {
452
497
  * outstanding-holds ceiling → 429. */
453
498
  generation: async (input) => (await this.call('POST', '/v1/jobs/generation', input)).data,
454
499
  runs: async (q) => {
455
- const qs = new URLSearchParams();
456
- if (q?.job_name)
457
- qs.set('job_name', q.job_name);
458
- if (q?.state)
459
- qs.set('state', q.state);
460
- if (q?.ids?.length)
461
- qs.set('ids', q.ids.join(','));
462
- if (q?.limit)
463
- qs.set('limit', String(q.limit));
464
- const s = qs.toString();
465
- 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;
466
507
  },
467
508
  run: async (runId) => (await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}`)).data,
468
509
  cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
@@ -503,6 +544,16 @@ export class Vxil {
503
544
  },
504
545
  },
505
546
  };
547
+ /** vxil-auth. SCOPES (docs/features/auth.md §7.6): 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. */
506
557
  auth = {
507
558
  signUp: async (input) => (await this.call('POST', '/v1/auth/sign-up', input)).data,
508
559
  signIn: async (input) => (await this.call('POST', '/v1/auth/sign-in', input)).data,
@@ -516,9 +567,11 @@ export class Vxil {
516
567
  * Server-side guessing budget (config otp.maxAttempts), single active code,
517
568
  * resend cooldown (429 otp_rate_limited). */
518
569
  otp: {
519
- request: async (input) => {
520
- await this.call('POST', '/v1/auth/otp/request', input);
521
- },
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,
522
575
  verify: async (input) => (await this.call('POST', '/v1/auth/otp/verify', input)).data,
523
576
  },
524
577
  /** Anonymous (guest) sessions: instant end-user + session (JWT carries
@@ -526,9 +579,12 @@ export class Vxil {
526
579
  anonymous: {
527
580
  signIn: async () => (await this.call('POST', '/v1/auth/anonymous/sign-in', {})).data,
528
581
  link: {
529
- request: async (input) => {
530
- await this.call('POST', '/v1/auth/anonymous/link/request', input);
531
- },
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). */
532
588
  verify: async (input) => (await this.call('POST', '/v1/auth/anonymous/link/verify', input)).data,
533
589
  },
534
590
  },
@@ -536,9 +592,7 @@ export class Vxil {
536
592
  * bearer token ROTATES (the returned session.token replaces the old one —
537
593
  * swap it client-side) and the JWT gains an `elv` claim. */
538
594
  stepUp: {
539
- request: async (input) => {
540
- await this.call('POST', '/v1/auth/step-up/request', input);
541
- },
595
+ request: async (input) => (await this.call('POST', '/v1/auth/step-up/request', input)).data,
542
596
  verify: async (input) => (await this.call('POST', '/v1/auth/step-up/verify', input)).data,
543
597
  },
544
598
  /** End-user administration (server/function surface, auth:write). */
@@ -547,8 +601,16 @@ export class Vxil {
547
601
  * (email → tombstone, password/name/avatar cleared) and revoke every
548
602
  * live session. Idempotent — a second call on an already-erased user is a
549
603
  * no-op. Meant to be called from a "delete my account" server route or
550
- * 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). */
551
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,
552
614
  },
553
615
  sessions: {
554
616
  verify: async (token) => (await this.call('POST', '/v1/auth/sessions/verify', { token })).data,
@@ -557,13 +619,24 @@ export class Vxil {
557
619
  revoke: async (token) => {
558
620
  await this.call('POST', '/v1/auth/sessions/revoke', { token });
559
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,
560
629
  /** Force-revoke a SPECIFIC session by its `session_id` (from `list`) — the
561
630
  * server-forced device-lockout path beyond the client-cooperative
562
- * 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`. */
563
634
  revokeById: async (sessionId) => {
564
635
  await this.call('POST', `/v1/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, {});
565
636
  },
566
- /** 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). */
567
640
  list: async (userId) => (await this.call('GET', `/v1/auth/sessions?user_id=${encodeURIComponent(userId)}`)).data.sessions,
568
641
  },
569
642
  password: {
@@ -575,13 +648,36 @@ export class Vxil {
575
648
  await this.call('POST', '/v1/auth/password/reset/confirm', input);
576
649
  },
577
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
+ },
578
663
  /** Social sign-in. The web `start`/`callback` flows are browser redirects
579
- * (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. */
580
671
  oauth: {
581
672
  /** Native social sign-in: exchange a provider `id_token`/`access_token`
582
673
  * for a vxil session (`linked` marks whether the user was created or
583
674
  * matched to an existing identity). */
584
- 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,
585
681
  },
586
682
  };
587
683
  rateLimits = {
@@ -599,10 +695,8 @@ export class Vxil {
599
695
  /** Per-policy usage analytics: hourly allowed/blocked buckets + top keys
600
696
  * (last `hours`, clamped 1..48, default 24). */
601
697
  analytics: async (input) => {
602
- const qs = new URLSearchParams({ policy_id: input.policy_id });
603
- if (input.hours !== undefined)
604
- qs.set('hours', String(input.hours));
605
- 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;
606
700
  },
607
701
  /**
608
702
  * Consume budget. With behavior 'block' an exceeded check throws
@@ -631,6 +725,16 @@ export class Vxil {
631
725
  addField: async (collection, field) => {
632
726
  await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, field);
633
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
+ },
634
738
  /** Set (or clear, with `null`) the collection's end-user owner-scope flag
635
739
  * (design §5.1). Names an existing `string` field that holds the owner id. */
636
740
  setOwnerField: async (collection, ownerField) => {
@@ -644,6 +748,13 @@ export class Vxil {
644
748
  setPublic: async (collection, isPublic) => {
645
749
  await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { public: isPublic });
646
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
+ },
647
758
  },
648
759
  items: {
649
760
  /** `lock` serializes same-key writers (per-tenant advisory lock); `guard`
@@ -661,17 +772,13 @@ export class Vxil {
661
772
  * per-field `Filterable` unions on `vx.from(...).query` exclude.
662
773
  */
663
774
  query: async (collection, q) => {
664
- const qs = new URLSearchParams();
665
- if (q?.filter)
666
- qs.set('filter', JSON.stringify(q.filter));
667
- if (q?.sort)
668
- qs.set('sort', q.sort);
669
- if (q?.limit)
670
- qs.set('limit', String(q.limit));
671
- if (q?.cursor)
672
- qs.set('cursor', q.cursor);
673
- const s = qs.toString();
674
- 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;
675
782
  },
676
783
  /** Merge-patch data keys; null clears a key. Concurrency opts (cms.md §9):
677
784
  * `ifVersion` → If-Match CAS; `if` → bounded field precondition against
@@ -691,15 +798,29 @@ export class Vxil {
691
798
  /** `{ count }` under the same bounded filter grammar (cms.md §9.4). 422
692
799
  * count_unavailable_with_read_hooks on beforeRead-hooked collections. */
693
800
  count: async (collection, filter) => {
694
- const qs = new URLSearchParams({ count: 'true' });
695
- if (filter)
696
- qs.set('filter', JSON.stringify(filter));
697
- 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;
698
803
  },
699
804
  /** Returns the cascade tally (cms.md §11); `ifVersion` rides If-Match and
700
805
  * a conflict aborts BEFORE any cascade side-effect. */
701
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,
702
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,
703
824
  /** cms-rel B2 (cms.md §12): bounded group-by aggregate. fns count|sum|
704
825
  * min|max|avg over index-slot-bound fields; groupBy ≤2 slot-bound
705
826
  * fields; filter = the full query DSL incl. ONE-hop dotted join terms
@@ -728,12 +849,8 @@ export class Vxil {
728
849
  comments = {
729
850
  create: async (input) => (await this.call('POST', '/v1/comments', input)).data,
730
851
  list: async (q) => {
731
- const qs = new URLSearchParams({ topic: q.topic });
732
- if (q.cursor)
733
- qs.set('cursor', q.cursor);
734
- if (q.limit)
735
- qs.set('limit', String(q.limit));
736
- 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;
737
854
  },
738
855
  /** Author edit, allowed within the tenant's editWindowMinutes. */
739
856
  edit: async (commentId, input) => {
@@ -748,17 +865,13 @@ export class Vxil {
748
865
  /** Cross-topic recent-comments feed (tenant-wide, newest-first) — the
749
866
  * dashboard "recent activity" surface. Keyset cursor + optional filters. */
750
867
  recent: async (q) => {
751
- const qs = new URLSearchParams();
752
- if (q?.topic_prefix)
753
- qs.set('topic_prefix', q.topic_prefix);
754
- if (q?.author_id)
755
- qs.set('author_id', q.author_id);
756
- if (q?.cursor)
757
- qs.set('cursor', q.cursor);
758
- if (q?.limit !== undefined)
759
- qs.set('limit', String(q.limit));
760
- const s = qs.toString();
761
- 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;
762
875
  },
763
876
  };
764
877
  /**
@@ -786,12 +899,8 @@ export class Vxil {
786
899
  /** Read a conversation's messages (an authz'd participant only; messages
787
900
  * from authors the viewer has blocked are filtered out). Keyset cursor. */
788
901
  messages: async (conversationId, q) => {
789
- const qs = new URLSearchParams({ user_id: q.user_id });
790
- if (q.cursor)
791
- qs.set('cursor', q.cursor);
792
- if (q.limit !== undefined)
793
- qs.set('limit', String(q.limit));
794
- 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;
795
904
  },
796
905
  },
797
906
  /** Send a message (an authz'd participant). Stored as a comment on the
@@ -834,21 +943,15 @@ export class Vxil {
834
943
  * feed; `notificationState` filters to unseen | unread | all.
835
944
  */
836
945
  read: async (group, feedId, q) => {
837
- const qs = new URLSearchParams();
838
- if (q?.limit !== undefined)
839
- qs.set('limit', String(q.limit));
840
- if (q?.id_lt)
841
- qs.set('id_lt', q.id_lt);
842
- if (q?.id_gt)
843
- qs.set('id_gt', q.id_gt);
844
- if (q?.notificationState)
845
- qs.set('notification_state', q.notificationState);
846
- if (q?.markSeen)
847
- qs.set('mark_seen', 'true');
848
- if (q?.markRead)
849
- qs.set('mark_read', 'true');
850
- const s = qs.toString();
851
- 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;
852
955
  },
853
956
  /** Remove an activity by id (or by foreign_id); tombstones + un-fans-out. */
854
957
  removeActivity: async (group, feedId, ref) => {
@@ -879,13 +982,8 @@ export class Vxil {
879
982
  /** Who a feed FOLLOWS (`following[]` + `following_count`) plus the true
880
983
  * `followers_count` (who follows this feed). Keyset cursor via `after`. */
881
984
  listFollows: async (group, feedId, q) => {
882
- const qs = new URLSearchParams();
883
- if (q?.limit !== undefined)
884
- qs.set('limit', String(q.limit));
885
- if (q?.after)
886
- qs.set('after', q.after);
887
- const s = qs.toString();
888
- 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;
889
987
  },
890
988
  /**
891
989
  * Block (two-way fan-out suppression, removes existing rows) or mute (one-way
@@ -961,17 +1059,13 @@ export class Vxil {
961
1059
  },
962
1060
  events: {
963
1061
  list: async (q) => {
964
- const qs = new URLSearchParams();
965
- if (q?.source_id)
966
- qs.set('source_id', q.source_id);
967
- if (q?.status)
968
- qs.set('status', q.status);
969
- if (q?.cursor)
970
- qs.set('cursor', q.cursor);
971
- if (q?.limit)
972
- qs.set('limit', String(q.limit));
973
- const s = qs.toString();
974
- 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;
975
1069
  },
976
1070
  replay: async (eventId) => {
977
1071
  await this.call('POST', `/v1/webhooks/events/${encodeURIComponent(eventId)}/replay`);
@@ -980,6 +1074,12 @@ export class Vxil {
980
1074
  * every jobs run this event's forwards created. */
981
1075
  runs: async (eventId) => (await this.call('GET', `/v1/webhooks/events/${encodeURIComponent(eventId)}/runs`)).data,
982
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,
983
1083
  };
984
1084
  orgs = {
985
1085
  create: async (input) => (await this.call('POST', '/v1/orgs', input)).data,
@@ -995,10 +1095,8 @@ export class Vxil {
995
1095
  * `opts.resource` to additionally consult per-resource ACL grants; the
996
1096
  * response `source` marks whether a grant came from the role lattice or an ACL. */
997
1097
  check: async (orgId, userId, permission, opts) => {
998
- const qs = new URLSearchParams({ user_id: userId, permission });
999
- if (opts?.resource)
1000
- qs.set('resource', opts.resource);
1001
- 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;
1002
1100
  },
1003
1101
  /** The active-org snapshot auth embeds in session JWTs when the tenant
1004
1102
  * enables auth config `orgClaims` (most-recent membership by joined_at).
@@ -1093,15 +1191,12 @@ export class Vxil {
1093
1191
  complete: async (objectId) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/complete`)).data,
1094
1192
  downloadUrl: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/download-url`)).data.download_url,
1095
1193
  list: async (q) => {
1096
- const qs = new URLSearchParams();
1097
- if (q?.user_id)
1098
- qs.set('user_id', q.user_id);
1099
- if (q?.cursor)
1100
- qs.set('cursor', q.cursor);
1101
- if (q?.limit)
1102
- qs.set('limit', String(q.limit));
1103
- const s = qs.toString();
1104
- 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;
1105
1200
  },
1106
1201
  /** Soft delete; bytes are hard-deleted 30 days later. */
1107
1202
  delete: async (objectId) => {
@@ -1119,9 +1214,12 @@ export class Vxil {
1119
1214
  * (else a future auto-delete after the given seconds; minimum 60). */
1120
1215
  setTtl: async (objectId, expiresInSeconds) => (await this.call('PUT', `/v1/files/${encodeURIComponent(objectId)}/ttl`, { expiresInSeconds })).data,
1121
1216
  sharedLinks: {
1122
- /** 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. */
1123
1221
  create: async (objectId, opts) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/shared-links`, opts ?? {})).data,
1124
- /** List an object's active (unrevoked, unexpired) shared links. */
1222
+ /** List an object's active (unrevoked, unexpired, unexhausted) shared links. */
1125
1223
  list: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/shared-links`)).data.links,
1126
1224
  revoke: async (linkId) => {
1127
1225
  await this.call('DELETE', `/v1/files/shared-links/${encodeURIComponent(linkId)}`);
@@ -1145,15 +1243,12 @@ export class Vxil {
1145
1243
  ingest: async (collection, input) => (await this.call('POST', `/v1/search/${encodeURIComponent(collection)}/documents`, input)).data,
1146
1244
  /** List indexed documents (keyset cursor, optional `user_id` filter). */
1147
1245
  listDocuments: async (collection, q) => {
1148
- const qs = new URLSearchParams();
1149
- if (q?.user_id)
1150
- qs.set('user_id', q.user_id);
1151
- if (q?.cursor)
1152
- qs.set('cursor', q.cursor);
1153
- if (q?.limit)
1154
- qs.set('limit', String(q.limit));
1155
- const s = qs.toString();
1156
- 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;
1157
1252
  },
1158
1253
  /** De-index a document (+ invalidate the query cache). */
1159
1254
  deleteDocument: async (collection, docId) => {
@@ -1190,7 +1285,10 @@ export class Vxil {
1190
1285
  ai = {
1191
1286
  templates: {
1192
1287
  /** Store a tenant-authored prompt template; versions are monotonic per name.
1193
- * `{{var}}` placeholders are filled from `generate`'s `input`. */
1288
+ * `{{var}}` placeholders are filled from `generate`'s `input`.
1289
+ * `schema` (a JSON Schema) is now ENFORCED at generate time on sync/job
1290
+ * requests — the output is validated (with one repair pass) against it;
1291
+ * a per-request `response_schema` overrides it. Ignored on streams. */
1194
1292
  put: async (input) => (await this.call('POST', '/v1/ai/templates', input)).data,
1195
1293
  /** List stored templates (each name + its latest version). */
1196
1294
  list: async () => (await this.call('GET', '/v1/ai/templates')).data.templates,
@@ -1220,19 +1318,34 @@ export class Vxil {
1220
1318
  const s = q?.since != null ? `?since=${q.since}` : '';
1221
1319
  return (await this.call('GET', `/v1/ai/generations/${encodeURIComponent(generationId)}/stream${s}`)).data;
1222
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,
1223
1339
  /** Embed a batch of strings (1–256). */
1224
1340
  embed: async (input) => (await this.call('POST', '/v1/ai/embed', input)).data,
1225
1341
  /** Per-user token rollups (optionally scoped by `since`/`user_id`). */
1226
1342
  usage: async (q) => {
1227
- const qs = new URLSearchParams();
1228
- if (q?.since)
1229
- qs.set('since', q.since);
1230
- if (q?.user_id)
1231
- qs.set('user_id', q.user_id);
1232
- if (q?.limit)
1233
- qs.set('limit', String(q.limit));
1234
- const s = qs.toString();
1235
- 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;
1236
1349
  },
1237
1350
  };
1238
1351
  /**
@@ -1283,12 +1396,7 @@ export class Vxil {
1283
1396
  /** List an agent's conversations, newest-updated first (optionally scoped
1284
1397
  * to one end user). */
1285
1398
  list: async (agentId, q) => {
1286
- const qs = new URLSearchParams();
1287
- if (q?.user_id)
1288
- qs.set('user_id', q.user_id);
1289
- if (q?.limit)
1290
- qs.set('limit', String(q.limit));
1291
- const s = qs.size ? `?${qs.toString()}` : '';
1399
+ const s = qs({ user_id: q?.user_id || undefined, limit: q?.limit || undefined });
1292
1400
  return (await this.call('GET', `/v1/copilot/${encodeURIComponent(agentId)}/conversations${s}`)).data;
1293
1401
  },
1294
1402
  /** A conversation row + the last N transcript messages (oldest-first). */
@@ -1306,11 +1414,8 @@ export class Vxil {
1306
1414
  * frame with seq > `since` plus `done` — replays without re-running (or
1307
1415
  * re-billing) the turn. Defaults to the conversation's latest turn. */
1308
1416
  resume: async (conversationId, q) => {
1309
- const qs = new URLSearchParams();
1310
- qs.set('since', String(q?.since ?? 0));
1311
- if (q?.message_id)
1312
- qs.set('message_id', q.message_id);
1313
- 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;
1314
1419
  },
1315
1420
  /** Confirm a proposed action — the ONLY way a write executes. The client
1316
1421
  * sends just the path ids; everything that runs comes from the persisted
@@ -1320,14 +1425,11 @@ export class Vxil {
1320
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,
1321
1426
  /** Per-user token rollup across assistant turns. */
1322
1427
  usage: async (q) => {
1323
- const qs = new URLSearchParams();
1324
- if (q?.since)
1325
- qs.set('since', q.since);
1326
- if (q?.user_id)
1327
- qs.set('user_id', q.user_id);
1328
- if (q?.limit)
1329
- qs.set('limit', String(q.limit));
1330
- 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
+ });
1331
1433
  return (await this.call('GET', `/v1/copilot/usage${s}`)).data;
1332
1434
  },
1333
1435
  };
@@ -1341,13 +1443,30 @@ export class Vxil {
1341
1443
  */
1342
1444
  payments = {
1343
1445
  /** Provider + ledger capability surface (+ the `missing` set an agent can act
1344
- * 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. */
1345
1449
  capabilities: async () => (await this.call('GET', '/v1/payments/capabilities')).data,
1346
1450
  /** The user's effective entitlement snapshot (the multi-sub tier fold):
1347
- * tier + boolean entitlements + numeric quotas. */
1348
- 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
+ },
1349
1467
  /** Boolean gate: does the user hold `entitlement`? Returns granted + its
1350
- * 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. */
1351
1470
  hasEntitlement: async (userId, entitlement) => (await this.call('GET', `/v1/payments/entitlements/check?user_id=${encodeURIComponent(userId)}&entitlement=${encodeURIComponent(entitlement)}`)).data,
1352
1471
  /** A single numeric quota for the user (e.g. `seats`, `api_calls`); null when
1353
1472
  * the resolved tier declares no such quota. Convenience over getEntitlements. */
@@ -1376,12 +1495,12 @@ export class Vxil {
1376
1495
  /** Recent ledger rows for the user (the audit/trail surface), newest first;
1377
1496
  * optionally scoped to one credit type. */
1378
1497
  usage: async (q) => {
1379
- const qs = new URLSearchParams({ user_id: q.user_id });
1380
- if (q.credit_type)
1381
- qs.set('credit_type', q.credit_type);
1382
- if (q.limit)
1383
- qs.set('limit', String(q.limit));
1384
- 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;
1385
1504
  },
1386
1505
  /** Start a subscription (mock = instant checkout; real providers redirect via
1387
1506
  * their own flow). `tier` keys the tierMap fold; `price_ref` is your catalog
@@ -1393,13 +1512,8 @@ export class Vxil {
1393
1512
  /** List subscriptions (the cancel enabler — discover the subscription_id);
1394
1513
  * optionally scoped to one user / status. */
1395
1514
  listSubscriptions: async (q) => {
1396
- const qs = new URLSearchParams();
1397
- if (q?.user_id)
1398
- qs.set('user_id', q.user_id);
1399
- if (q?.status)
1400
- qs.set('status', q.status);
1401
- const s = qs.toString();
1402
- 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;
1403
1517
  },
1404
1518
  /**
1405
1519
  * Create a hosted-checkout session (payments.md §3). Redirect the buyer to
@@ -1415,20 +1529,56 @@ export class Vxil {
1415
1529
  * NEVER exceed the charge (422 refund_exceeds_charge).
1416
1530
  */
1417
1531
  createRefund: async (input, opts) => (await this.call('POST', '/v1/payments/refunds', input, { 'idempotency-key': opts.idempotencyKey })).data,
1418
- /** The provider-hosted billing-management portal URL for an end user (Stripe
1419
- * 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). */
1420
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,
1421
1574
  /** List charges newest-first (the refund enabler — discover the charge_id).
1422
1575
  * Filters: user_id, status, limit (clamped 1..100, default 50). */
1423
1576
  listCharges: async (q) => {
1424
- const qs = new URLSearchParams();
1425
- if (q?.user_id)
1426
- qs.set('user_id', q.user_id);
1427
- if (q?.status)
1428
- qs.set('status', q.status);
1429
- if (q?.limit)
1430
- qs.set('limit', String(q.limit));
1431
- 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
+ });
1432
1582
  return (await this.call('GET', `/v1/payments/charges${suffix}`)).data;
1433
1583
  },
1434
1584
  /** Provider webhook event log (payments.md §7 "Event log & replay"):
@@ -1439,20 +1589,15 @@ export class Vxil {
1439
1589
  /** List deliveries newest-first (keyset-paginated; pass `cursor` from a
1440
1590
  * prior page's next_cursor). List rows omit payload/raw_body. */
1441
1591
  list: async (q) => {
1442
- const qs = new URLSearchParams();
1443
- if (q?.provider)
1444
- qs.set('provider', q.provider);
1445
- if (q?.event_type)
1446
- qs.set('event_type', q.event_type);
1447
- if (q?.outcome)
1448
- qs.set('outcome', q.outcome);
1449
- if (q?.since)
1450
- qs.set('since', q.since);
1451
- if (q?.cursor)
1452
- qs.set('cursor', q.cursor);
1453
- if (q?.limit)
1454
- qs.set('limit', String(q.limit));
1455
- 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
+ });
1456
1601
  return (await this.call('GET', `/v1/payments/webhook-events${suffix}`)).data;
1457
1602
  },
1458
1603
  /** Full delivery detail incl. the verified payload and (for failed