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