@adukilabs/sdk 0.1.1 → 0.1.2

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.
@@ -224,3 +224,253 @@ export interface Profile {
224
224
  editable: boolean;
225
225
  [field: string]: unknown;
226
226
  }
227
+ /** The workspace's profile (`GET /v1/tenant`). */
228
+ export interface TenantProfile {
229
+ hex: string;
230
+ name?: string;
231
+ slug?: string;
232
+ [field: string]: unknown;
233
+ }
234
+ /** A member row (`GET /v1/tenant/members`). */
235
+ export interface MemberRow {
236
+ hex: string;
237
+ email: string;
238
+ name: string;
239
+ avatar?: string | null;
240
+ owner: boolean;
241
+ state: string;
242
+ last?: string | null;
243
+ created: string;
244
+ tenant: unknown;
245
+ role: unknown;
246
+ [field: string]: unknown;
247
+ }
248
+ /** An audit row. */
249
+ export interface AuditRow {
250
+ hex: string;
251
+ action: string;
252
+ success: boolean;
253
+ reason?: string | null;
254
+ ip?: string | null;
255
+ agent?: string | null;
256
+ device?: unknown;
257
+ created: string;
258
+ actor?: unknown;
259
+ [field: string]: unknown;
260
+ }
261
+ /** An audit record with its `meta` (`GET /v1/tenant/view/audit/{hex}`). */
262
+ export interface AuditDetail extends AuditRow {
263
+ meta?: unknown;
264
+ }
265
+ /** A usage row: `value` of `metric` in the billing `window`. */
266
+ export interface UsageRow {
267
+ tenant: string;
268
+ metric: string;
269
+ window: string;
270
+ value: number;
271
+ ceiling: number;
272
+ [field: string]: unknown;
273
+ }
274
+ /** A quota in full. */
275
+ export interface Quota {
276
+ tenant: string;
277
+ metric: string;
278
+ ceiling: number;
279
+ reason?: string | null;
280
+ granted?: string | null;
281
+ expires?: string | null;
282
+ created: string;
283
+ updated: string;
284
+ [field: string]: unknown;
285
+ }
286
+ /** A quota as listed. */
287
+ export interface QuotaRow {
288
+ tenant: string;
289
+ metric: string;
290
+ ceiling: number;
291
+ expires?: string | null;
292
+ created: string;
293
+ [field: string]: unknown;
294
+ }
295
+ /** A domain to register. */
296
+ export interface DomainCreate {
297
+ name: string;
298
+ /** `primary`, `sending`, `receiving` or `alias` */
299
+ kind?: string;
300
+ /** the DKIM selector */
301
+ selector?: string;
302
+ meta?: Record<string, unknown>;
303
+ }
304
+ /** A DNS record of a DKIM key: publish `value` as TXT at `name`. */
305
+ export interface DkimRecord {
306
+ selector?: string;
307
+ name: string;
308
+ value: string;
309
+ [field: string]: unknown;
310
+ }
311
+ /** A domain in full: its status and the DNS records and snapshots. */
312
+ export interface Domain {
313
+ hex: string;
314
+ tenant: string;
315
+ name: string;
316
+ kind: string;
317
+ /** `pending`, `verified`, `active`, `suspended` or `failed` */
318
+ status: string;
319
+ selector: string;
320
+ /** the active key's record (never the private key) */
321
+ dkim?: DkimRecord | null;
322
+ dkim_twin?: DkimRecord | null;
323
+ dkim_rotation?: {
324
+ pending?: DkimRecord | null;
325
+ retired?: unknown;
326
+ } | null;
327
+ spf: unknown;
328
+ dmarc: unknown;
329
+ mta_sts: unknown;
330
+ bimi: unknown;
331
+ meta: unknown;
332
+ verified?: string | null;
333
+ created: string;
334
+ updated: string;
335
+ [field: string]: unknown;
336
+ }
337
+ /** A domain as listed. */
338
+ export interface DomainRow {
339
+ hex: string;
340
+ name: string;
341
+ kind: string;
342
+ status: string;
343
+ verified?: string | null;
344
+ created: string;
345
+ tenant: unknown;
346
+ [field: string]: unknown;
347
+ }
348
+ /** The state of a DKIM rotation. */
349
+ export interface DkimRotation {
350
+ active?: DkimRecord | null;
351
+ /** the staged key: publish it, then confirm */
352
+ pending?: DkimRecord | null;
353
+ /** the previous record, safe to remove after `remove_after` */
354
+ retired?: {
355
+ selector: string;
356
+ name: string;
357
+ remove_after: string;
358
+ } | null;
359
+ [field: string]: unknown;
360
+ }
361
+ /** An alias of a member's mailbox. */
362
+ export interface Alias {
363
+ address: string;
364
+ user: string;
365
+ created?: string;
366
+ [field: string]: unknown;
367
+ }
368
+ /** A spam rule to create. */
369
+ export interface RuleCreate {
370
+ name: string;
371
+ /** `header.from`, `header.to`, `header.subject`, `header.received`, `envelope.from`, `envelope.to`, `body.text`, `body.html`, `body.url`, `attachment.name`, `attachment.mime` */
372
+ target: string;
373
+ pattern: string;
374
+ regex?: boolean;
375
+ /** -100 to 100: positive towards spam, negative towards ham */
376
+ score: number;
377
+ active?: boolean;
378
+ meta?: Record<string, unknown>;
379
+ }
380
+ /** A spam rule. */
381
+ export interface Rule {
382
+ hex: string;
383
+ tenant?: unknown;
384
+ name: string;
385
+ target: string;
386
+ pattern: string;
387
+ regex: boolean;
388
+ score: number;
389
+ active: boolean;
390
+ meta?: unknown;
391
+ created: string;
392
+ updated?: string;
393
+ [field: string]: unknown;
394
+ }
395
+ /** A spam rule as listed. */
396
+ export type RuleRow = Omit<Rule, 'meta' | 'updated'>;
397
+ /** A webhook to create. */
398
+ export interface WebhookCreate {
399
+ /** `https`, a public host, at most 500 characters */
400
+ url: string;
401
+ /** 16 to 128 characters; omitted, the server makes one */
402
+ secret?: string;
403
+ /** `mail.delivered`, `mail.bounced`, `mail.deferred`, `mail.complained` */
404
+ events?: string[];
405
+ domains?: string[];
406
+ active?: boolean;
407
+ meta?: Record<string, unknown>;
408
+ }
409
+ /** A webhook as listed. */
410
+ export interface WebhookRow {
411
+ hex: string;
412
+ url: string;
413
+ active: boolean;
414
+ created: string;
415
+ tenant: unknown;
416
+ [field: string]: unknown;
417
+ }
418
+ /** A webhook (never its secret). */
419
+ export interface Webhook extends WebhookRow {
420
+ events: unknown;
421
+ domains?: unknown;
422
+ meta?: unknown;
423
+ updated?: string;
424
+ }
425
+ /** `GET /v1/tenant/security`. */
426
+ export interface SecurityStatus {
427
+ mtasts: unknown[];
428
+ tlsa: unknown[];
429
+ bimi: unknown[];
430
+ reports: unknown[];
431
+ [field: string]: unknown;
432
+ }
433
+ /** A mail service and whether this workspace may use it. */
434
+ export interface MailService {
435
+ id: string;
436
+ name: string;
437
+ status: string;
438
+ description: string;
439
+ requirements: string[];
440
+ limits: string;
441
+ entitled: boolean;
442
+ reason?: string | null;
443
+ [field: string]: unknown;
444
+ }
445
+ /** An effective holiday of the workspace. */
446
+ export interface Holiday {
447
+ date: string;
448
+ name: string;
449
+ kind: string;
450
+ source: string;
451
+ region?: string | null;
452
+ hex?: string;
453
+ verified?: string | null;
454
+ [field: string]: unknown;
455
+ }
456
+ /** A schedule template. */
457
+ export interface ScheduleTemplate {
458
+ hex: string;
459
+ name: string;
460
+ zone: string;
461
+ rules: unknown[];
462
+ followers: number;
463
+ [field: string]: unknown;
464
+ }
465
+ /** A room or piece of equipment. */
466
+ export interface ScheduleResource {
467
+ hex: string;
468
+ name: string;
469
+ kind: string;
470
+ capacity?: number | null;
471
+ schedule?: string | null;
472
+ retired: boolean | string | null;
473
+ created: string;
474
+ updated: string;
475
+ [field: string]: unknown;
476
+ }
@@ -0,0 +1,147 @@
1
+ import type { Activity, Checkin, Found, Member, Message, Page, Reply, Settings, SupportTicket, Task, Team } from '../space/model.js';
2
+ import type { AudienceFetch } from './store.js';
3
+ /** The Space REST root. */
4
+ export declare const SPACE = "https://space.aduki.pro/v1";
5
+ export interface SpaceApiOptions {
6
+ /** the REST root with `/v1` (default `https://space.aduki.pro/v1`) */
7
+ root?: string | undefined;
8
+ }
9
+ /** A query: undefined, null and false values are left out. */
10
+ export type SpaceQuery = Record<string, string | number | boolean | null | undefined>;
11
+ /** One page: `limit` 1 to 100, `after` the `next` of the previous page. */
12
+ export interface SpacePage {
13
+ limit?: number;
14
+ after?: string;
15
+ }
16
+ /** A request of `call`. */
17
+ export interface SpaceCall {
18
+ query?: SpaceQuery;
19
+ body?: unknown;
20
+ /** `Idempotency-Key` (a UUID) */
21
+ key?: string;
22
+ /** `If-Match`: the `etag` of the read the change is based on (stale: 409 `entity.conflict`) */
23
+ version?: string;
24
+ signal?: AbortSignal;
25
+ }
26
+ /** The filters of `tasks`. */
27
+ export interface SpaceTaskFilters extends SpacePage {
28
+ status?: string;
29
+ client?: string;
30
+ team?: string;
31
+ orphaned?: boolean;
32
+ unbilled?: boolean;
33
+ q?: string;
34
+ archived?: boolean;
35
+ }
36
+ /** The fields of a new task (`title` required). */
37
+ export interface SpaceTaskInput {
38
+ title: string;
39
+ description?: string;
40
+ priority?: string;
41
+ status?: string;
42
+ due?: string;
43
+ client?: string;
44
+ service?: string;
45
+ team?: string;
46
+ [field: string]: unknown;
47
+ }
48
+ /** The filters of `tickets`. */
49
+ export interface SpaceTicketFilters extends SpacePage {
50
+ status?: string;
51
+ client?: string;
52
+ assignee?: string;
53
+ unassigned?: boolean;
54
+ breached?: boolean;
55
+ q?: string;
56
+ }
57
+ /** The fields of a new ticket (`client` and `title` required). */
58
+ export interface SpaceTicketInput {
59
+ client: string;
60
+ title: string;
61
+ contact?: string;
62
+ body?: string;
63
+ priority?: string;
64
+ assignee?: string;
65
+ service?: string;
66
+ node?: string;
67
+ [field: string]: unknown;
68
+ }
69
+ /** The filters of `activity`. */
70
+ export interface SpaceActivityFilters extends SpacePage {
71
+ entity?: string;
72
+ action?: string;
73
+ task?: string;
74
+ }
75
+ /** Aduki Space for a program (see the module notes for what it may not do). */
76
+ export declare class SpaceApi {
77
+ #private;
78
+ readonly root: string;
79
+ constructor(session: AudienceFetch, options?: SpaceApiOptions);
80
+ /**
81
+ * One request to `path` (under `/v1`, segments already encoded): the
82
+ * envelope's `data`, the status and the `ETag`. A refusal throws the typed
83
+ * error of `errors.refusal`: `ClientRefusedError` on a strict or money
84
+ * route, `NetworkError 403 access.denied` for a bit the client lacks,
85
+ * `403 app.disabled` for a read-only workspace, `404` for a row it may not
86
+ * see, `RateLimitedError` with `retryAfter`.
87
+ */
88
+ call<T = any>(method: string, path: string, options?: SpaceCall): Promise<Reply<T>>;
89
+ /** One page of a list route (`items`, `next`). */
90
+ list<T = Record<string, unknown>>(path: string, query?: SpaceQuery, page?: SpacePage): Promise<Page<T>>;
91
+ /** Every item of a list route, page by page (`size` per page). */
92
+ all<T = Record<string, unknown>>(path: string, query?: SpaceQuery, size?: number): AsyncGenerator<T>;
93
+ /** The workspace settings, read only (`GET /v1/settings`); changing them is strict and refused to programs. */
94
+ settings(): Promise<Reply<Settings>>;
95
+ /** The workspace's teams (`GET /v1/teams`). */
96
+ teams(page?: SpacePage): Promise<Page<Team>>;
97
+ /** The members of the workspace: name, handle and teams (`GET /v1/staff/directory`). */
98
+ directory(page?: SpacePage): Promise<Page<Member>>;
99
+ /** The activity feed (`GET /v1/activity`); entries a program made carry `kind: "client"`. */
100
+ activity(filters?: SpaceActivityFilters): Promise<Page<Activity>>;
101
+ /** Tasks the client may see (`GET /v1/tasks`), filtered. */
102
+ tasks(filters?: SpaceTaskFilters): Promise<Page<Task>>;
103
+ /** A task and its version (`GET /v1/tasks/{id}`). */
104
+ task(task: string): Promise<Reply<Task>>;
105
+ /** Creates a task (`POST /v1/tasks`, `tasks.write`); a new Idempotency-Key unless `key` is given. */
106
+ createTask(input: SpaceTaskInput, options?: {
107
+ key?: string;
108
+ }): Promise<Reply<Task>>;
109
+ /** Changes a task (`PATCH /v1/tasks/{id}`); `version` guards against a lost update. */
110
+ updateTask(task: string, changes: Record<string, unknown>, options?: {
111
+ version?: string;
112
+ }): Promise<Reply<Task>>;
113
+ /** The check-ins of a task (`GET /v1/tasks/{id}/checkins`). */
114
+ checkins(task: string, page?: SpacePage): Promise<Page<Checkin>>;
115
+ /** Reports work on a task (`POST /v1/tasks/{id}/checkins`, `tasks.work`): `minutes` and an optional `proof` link. */
116
+ checkin(task: string, input: {
117
+ body: string;
118
+ minutes?: number;
119
+ proof?: string;
120
+ }, options?: {
121
+ key?: string;
122
+ }): Promise<Reply<Checkin>>;
123
+ /** Tickets the client may see (`GET /v1/tickets`, `tickets.read`), filtered. */
124
+ tickets(filters?: SpaceTicketFilters): Promise<Page<SupportTicket>>;
125
+ /** A ticket and its version (`GET /v1/tickets/{id}`). */
126
+ ticket(ticket: string): Promise<Reply<SupportTicket>>;
127
+ /** Opens a ticket (`POST /v1/tickets`, `tickets.write`). */
128
+ createTicket(input: SpaceTicketInput, options?: {
129
+ key?: string;
130
+ }): Promise<Reply<SupportTicket>>;
131
+ /** Changes a ticket (`PATCH /v1/tickets/{id}`): title, priority, status, assignee, service, node. */
132
+ updateTicket(ticket: string, changes: Record<string, unknown>, options?: {
133
+ version?: string;
134
+ }): Promise<Reply<SupportTicket>>;
135
+ /** The thread of a ticket, oldest first (`GET /v1/tickets/{id}/messages`). */
136
+ messages(ticket: string, page?: SpacePage): Promise<Page<Message>>;
137
+ /** Replies on a ticket (`POST /v1/tickets/{id}/messages`); `internal` makes it a staff note the client never sees. */
138
+ reply(ticket: string, body: string, options?: {
139
+ internal?: boolean;
140
+ key?: string;
141
+ }): Promise<Reply<Message>>;
142
+ /** Ids by query (`GET /v1/search`): `q` 1 to 200 characters, `entity` one collection, `limit` 1 to 50. */
143
+ search(q: string, options?: {
144
+ entity?: string;
145
+ limit?: number;
146
+ }): Promise<Found>;
147
+ }
@@ -0,0 +1,218 @@
1
+ // Aduki Space for a program of the workspace (machine access), with the
2
+ // DPoP-bound service-client token of `IdClient` (audience `space`). The same
3
+ // transport rules as the web Space module: the envelope, keyset lists with an
4
+ // opaque cursor, `Idempotency-Key` on creation, `ETag` / `If-Match` on edits.
5
+ //
6
+ // What Space gives a service client (its docs, "Programs of the workspace"):
7
+ // every route that is neither strict nor money, within the bits its grant
8
+ // gives (`space.use` and the route's bit, for example `tasks.write`,
9
+ // `tickets.write`), seeing the rows a person with those bits and no team
10
+ // would see. Refused with `403 access.denied`, reason `client`
11
+ // (`ClientRefusedError`): refunds, charges, payouts, rails and gateways,
12
+ // staff management and payroll, node management, settings changes, hooks,
13
+ // endpoints and exports. The activity feed marks what a program did with
14
+ // `kind: "client"`.
15
+ import { parse, rejection } from '../envelope.js';
16
+ import { ProtocolError } from '../errors.js';
17
+ import { url } from '../http.js';
18
+ /** The Space REST root. */
19
+ export const SPACE = 'https://space.aduki.pro/v1';
20
+ const HEX = /^[0-9a-f]{16}$/;
21
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ /** Aduki Space for a program (see the module notes for what it may not do). */
23
+ export class SpaceApi {
24
+ root;
25
+ #fetch;
26
+ constructor(session, options = {}) {
27
+ this.#fetch = (input, init) => session.fetch('space', input, init);
28
+ this.root = (options.root ?? SPACE).replace(/\/+$/, '');
29
+ }
30
+ // ---- transport -------------------------------------------------------
31
+ /**
32
+ * One request to `path` (under `/v1`, segments already encoded): the
33
+ * envelope's `data`, the status and the `ETag`. A refusal throws the typed
34
+ * error of `errors.refusal`: `ClientRefusedError` on a strict or money
35
+ * route, `NetworkError 403 access.denied` for a bit the client lacks,
36
+ * `403 app.disabled` for a read-only workspace, `404` for a row it may not
37
+ * see, `RateLimitedError` with `retryAfter`.
38
+ */
39
+ async call(method, path, options = {}) {
40
+ const params = new URLSearchParams();
41
+ for (const [k, v] of Object.entries(options.query ?? {}))
42
+ if (v !== undefined && v !== null && v !== false)
43
+ params.set(k, String(v));
44
+ const q = params.toString();
45
+ const clean = path.replace(/^\/+/, '');
46
+ const headers = { Accept: 'application/json' };
47
+ const init = { method, headers };
48
+ if (options.body !== undefined) {
49
+ headers['Content-Type'] = 'application/json';
50
+ init.body = JSON.stringify(options.body);
51
+ }
52
+ if (options.key !== undefined) {
53
+ if (!UUID.test(options.key))
54
+ throw new TypeError('An Idempotency-Key is a UUID');
55
+ headers['Idempotency-Key'] = options.key;
56
+ }
57
+ if (options.version !== undefined)
58
+ headers['If-Match'] = options.version === '*' || options.version.startsWith('"') ? options.version : `"${options.version}"`;
59
+ if (options.signal)
60
+ init.signal = options.signal;
61
+ const response = await this.#fetch(`${url(this.root, clean)}${q ? `?${q}` : ''}`, init);
62
+ const text = await response.text();
63
+ const what = `${method} /v1/${clean.split('?')[0]}`;
64
+ if (!response.ok)
65
+ throw rejection(response, text, what);
66
+ const envelope = parse(text);
67
+ if (!envelope || envelope.success !== true)
68
+ throw new ProtocolError(`${what}: the answer is not a success envelope`);
69
+ const tag = response.headers.get('ETag');
70
+ return { data: (envelope.data ?? null), status: response.status, etag: tag ? tag.replace(/^W\//, '').replace(/^"|"$/g, '') : null };
71
+ }
72
+ /** One page of a list route (`items`, `next`). */
73
+ async list(path, query = {}, page = {}) {
74
+ if (page.limit !== undefined && !(Number.isInteger(page.limit) && page.limit >= 1 && page.limit <= 100))
75
+ throw new TypeError('limit is 1 to 100');
76
+ const { data } = await this.call('GET', path, { query: { ...query, limit: page.limit, after: page.after } });
77
+ if (!data || !Array.isArray(data.items))
78
+ throw new ProtocolError(`${path}: a list without items`);
79
+ return { items: data.items, next: typeof data.next === 'string' && data.next ? data.next : null };
80
+ }
81
+ /** Every item of a list route, page by page (`size` per page). */
82
+ async *all(path, query = {}, size = 100) {
83
+ let after;
84
+ const seen = new Set();
85
+ for (;;) {
86
+ const page = await this.list(path, query, after ? { limit: size, after } : { limit: size });
87
+ yield* page.items;
88
+ if (!page.next)
89
+ return;
90
+ if (seen.has(page.next))
91
+ throw new ProtocolError(`${path}: cursor repeated`);
92
+ seen.add(page.next);
93
+ after = page.next;
94
+ }
95
+ }
96
+ // ---- workspace -------------------------------------------------------
97
+ /** The workspace settings, read only (`GET /v1/settings`); changing them is strict and refused to programs. */
98
+ settings() {
99
+ return this.call('GET', 'settings');
100
+ }
101
+ /** The workspace's teams (`GET /v1/teams`). */
102
+ teams(page = {}) {
103
+ return this.list('teams', {}, page);
104
+ }
105
+ /** The members of the workspace: name, handle and teams (`GET /v1/staff/directory`). */
106
+ directory(page = {}) {
107
+ return this.list('staff/directory', {}, page);
108
+ }
109
+ /** The activity feed (`GET /v1/activity`); entries a program made carry `kind: "client"`. */
110
+ activity(filters = {}) {
111
+ const { limit, after, ...query } = filters;
112
+ if (query.task)
113
+ hex(query.task, 'task');
114
+ return this.list('activity', query, paging(limit, after));
115
+ }
116
+ // ---- tasks and check-ins -----------------------------------------
117
+ /** Tasks the client may see (`GET /v1/tasks`), filtered. */
118
+ tasks(filters = {}) {
119
+ const { limit, after, ...query } = filters;
120
+ if (query.client)
121
+ hex(query.client, 'client');
122
+ if (query.team)
123
+ hex(query.team, 'team');
124
+ if (query.q === '')
125
+ delete query.q;
126
+ return this.list('tasks', query, paging(limit, after));
127
+ }
128
+ /** A task and its version (`GET /v1/tasks/{id}`). */
129
+ task(task) {
130
+ return this.call('GET', `tasks/${hex(task, 'task')}`);
131
+ }
132
+ /** Creates a task (`POST /v1/tasks`, `tasks.write`); a new Idempotency-Key unless `key` is given. */
133
+ createTask(input, options = {}) {
134
+ return this.call('POST', 'tasks', { body: input, key: options.key ?? crypto.randomUUID() });
135
+ }
136
+ /** Changes a task (`PATCH /v1/tasks/{id}`); `version` guards against a lost update. */
137
+ updateTask(task, changes, options = {}) {
138
+ return this.call('PATCH', `tasks/${hex(task, 'task')}`, versioned({ body: changes }, options.version));
139
+ }
140
+ /** The check-ins of a task (`GET /v1/tasks/{id}/checkins`). */
141
+ checkins(task, page = {}) {
142
+ return this.list(`tasks/${hex(task, 'task')}/checkins`, {}, page);
143
+ }
144
+ /** Reports work on a task (`POST /v1/tasks/{id}/checkins`, `tasks.work`): `minutes` and an optional `proof` link. */
145
+ checkin(task, input, options = {}) {
146
+ const minutes = input.minutes ?? 0;
147
+ if (!Number.isInteger(minutes) || minutes < 0)
148
+ throw new TypeError('minutes is a whole number, not negative');
149
+ const body = { body: input.body, minutes };
150
+ if (input.proof !== undefined)
151
+ body.proof = input.proof;
152
+ return this.call('POST', `tasks/${hex(task, 'task')}/checkins`, { body, key: options.key ?? crypto.randomUUID() });
153
+ }
154
+ // ---- tickets -------------------------------------------------------
155
+ /** Tickets the client may see (`GET /v1/tickets`, `tickets.read`), filtered. */
156
+ tickets(filters = {}) {
157
+ const { limit, after, ...query } = filters;
158
+ if (query.client)
159
+ hex(query.client, 'client');
160
+ if (query.assignee)
161
+ hex(query.assignee, 'assignee');
162
+ if (query.q === '')
163
+ delete query.q;
164
+ return this.list('tickets', query, paging(limit, after));
165
+ }
166
+ /** A ticket and its version (`GET /v1/tickets/{id}`). */
167
+ ticket(ticket) {
168
+ return this.call('GET', `tickets/${hex(ticket, 'ticket')}`);
169
+ }
170
+ /** Opens a ticket (`POST /v1/tickets`, `tickets.write`). */
171
+ createTicket(input, options = {}) {
172
+ return this.call('POST', 'tickets', { body: input, key: options.key ?? crypto.randomUUID() });
173
+ }
174
+ /** Changes a ticket (`PATCH /v1/tickets/{id}`): title, priority, status, assignee, service, node. */
175
+ updateTicket(ticket, changes, options = {}) {
176
+ return this.call('PATCH', `tickets/${hex(ticket, 'ticket')}`, versioned({ body: changes }, options.version));
177
+ }
178
+ /** The thread of a ticket, oldest first (`GET /v1/tickets/{id}/messages`). */
179
+ messages(ticket, page = {}) {
180
+ return this.list(`tickets/${hex(ticket, 'ticket')}/messages`, {}, page);
181
+ }
182
+ /** Replies on a ticket (`POST /v1/tickets/{id}/messages`); `internal` makes it a staff note the client never sees. */
183
+ reply(ticket, body, options = {}) {
184
+ return this.call('POST', `tickets/${hex(ticket, 'ticket')}/messages`, {
185
+ body: { body, internal: options.internal ?? false },
186
+ key: options.key ?? crypto.randomUUID(),
187
+ });
188
+ }
189
+ // ---- search ----------------------------------------------------------
190
+ /** Ids by query (`GET /v1/search`): `q` 1 to 200 characters, `entity` one collection, `limit` 1 to 50. */
191
+ async search(q, options = {}) {
192
+ const text = q.trim();
193
+ if (text.length < 1 || text.length > 200)
194
+ throw new TypeError('q is 1 to 200 characters');
195
+ if (options.limit !== undefined && !(Number.isInteger(options.limit) && options.limit >= 1 && options.limit <= 50))
196
+ throw new TypeError('limit is 1 to 50');
197
+ const { data } = await this.call('GET', 'search', { query: { q: text, entity: options.entity, limit: options.limit } });
198
+ return { items: data?.items ?? [], source: String(data?.source ?? ''), stale: Boolean(data?.stale) };
199
+ }
200
+ }
201
+ function hex(value, what) {
202
+ if (!HEX.test(value))
203
+ throw new TypeError(`${what} is not an id (16 lowercase hex digits)`);
204
+ return value;
205
+ }
206
+ function paging(limit, after) {
207
+ const out = {};
208
+ if (limit !== undefined)
209
+ out.limit = limit;
210
+ if (after !== undefined)
211
+ out.after = after;
212
+ return out;
213
+ }
214
+ function versioned(options, version) {
215
+ if (version !== undefined)
216
+ options.version = version;
217
+ return options;
218
+ }