@adukilabs/sdk 0.0.0-stage → 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.
@@ -0,0 +1,352 @@
1
+ // The workspace drive on Aduki Store for a program (machine access): the
2
+ // metadata API on `store.aduki.pro/v1` and the content API on
3
+ // `files.aduki.pro/v1`, with the DPoP-bound service-client token of
4
+ // `IdClient` (audience `store`).
5
+ //
6
+ // What Store gives a service client (its docs, "Programs of a workspace"):
7
+ // the workspace drive only, outside the Space folder, at the level of the
8
+ // client's bits: `store.read` reads, `store.write` also creates, renames,
9
+ // moves, trashes, restores and uploads. Never a drive of its own, never a
10
+ // member's or a personal drive (their ids answer `404`). Shares, public
11
+ // links, devices, reports, search, tickets and the change feed are for people:
12
+ // `403 access.denied` with reason `client` (`ClientRefusedError`), so this
13
+ // class has no calls for them.
14
+ //
15
+ // Uploads use the same chunking and hashing as the web Store module
16
+ // (FastCDC 2020 cut points, BLAKE3 chunk and manifest hashes): begin a version
17
+ // with the manifest, PUT the chunks the server lacks, commit; the same `key`
18
+ // resumes an interrupted upload.
19
+ import { failure, parse, rejection } from '../envelope.js';
20
+ import { NetworkError, ProtocolError } from '../errors.js';
21
+ import { url } from '../http.js';
22
+ import { blob, manifest } from '../store/chunks.js';
23
+ import * as model from '../store/model.js';
24
+ /** Store's metadata API. */
25
+ export const STORE = 'https://store.aduki.pro/v1';
26
+ /** Store's content API (chunks, downloads). */
27
+ export const FILES = 'https://files.aduki.pro/v1';
28
+ const HEX = /^[0-9a-f]{16}$/;
29
+ /** The workspace drive (see the module notes for what a program may not do). */
30
+ export class StoreApi {
31
+ root;
32
+ files;
33
+ #fetch;
34
+ #sleep;
35
+ constructor(session, options = {}) {
36
+ this.#fetch = (input, init) => session.fetch('store', input, init);
37
+ this.root = (options.root ?? STORE).replace(/\/+$/, '');
38
+ this.files = (options.files ?? FILES).replace(/\/+$/, '');
39
+ this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
40
+ }
41
+ /** The workspace drive's numbers: `owner` (the workspace), `cap`, `per`, `used` (`GET /v1/quota`). */
42
+ async quota() {
43
+ return (await this.call('GET', 'quota'));
44
+ }
45
+ /** The live children of folder `parent`, or of the drive root; with `trashed`, the trash (`GET /v1/folders`). */
46
+ async list(options = {}) {
47
+ const query = { parent: options.parent && id(options.parent, 'parent'), trashed: options.trashed ? true : undefined };
48
+ return model.listing(await this.call('GET', 'folders', { query }));
49
+ }
50
+ /** Folder `folder` (`GET /v1/folders/{id}`); one outside the workspace drive is `404`. */
51
+ async folder(folder) {
52
+ return model.folder(await this.call('GET', `folders/${id(folder, 'folder')}`));
53
+ }
54
+ /** File `file` (`GET /v1/files/{id}`). */
55
+ async file(file) {
56
+ return model.file(await this.call('GET', `files/${id(file, 'file')}`));
57
+ }
58
+ /** Creates a folder in `parent` (default the drive root) (`POST /v1/folders`, `store.write`); a name in use is 409. */
59
+ async createFolder(name, options = {}) {
60
+ const body = { name };
61
+ if (options.parent)
62
+ body.parent = id(options.parent, 'parent');
63
+ return model.folder(await this.call('POST', 'folders', { body }));
64
+ }
65
+ /** Creates an empty file to upload a version into (`POST /v1/files`). `upload` does this for you. */
66
+ async createFile(name, options = {}) {
67
+ const body = { name };
68
+ if (options.folder)
69
+ body.folder = id(options.folder, 'folder');
70
+ if (options.mime)
71
+ body.mime = options.mime;
72
+ return model.file(await this.call('POST', 'files', { body }));
73
+ }
74
+ /** Renames, moves (`parent: null` is the drive root), trashes or restores a folder (`PATCH /v1/folders/{id}`). */
75
+ async updateFolder(folder, change) {
76
+ const body = {};
77
+ if (change.name !== undefined)
78
+ body.name = change.name;
79
+ if (change.parent !== undefined)
80
+ body.parent = change.parent === null ? null : id(change.parent, 'parent');
81
+ if (change.trashed !== undefined)
82
+ body.trashed = change.trashed;
83
+ return model.folder(await this.call('PATCH', `folders/${id(folder, 'folder')}`, { body }));
84
+ }
85
+ /** Renames, moves (`folder: null` is the drive root), trashes or restores a file (`PATCH /v1/files/{id}`). */
86
+ async updateFile(file, change) {
87
+ const body = {};
88
+ if (change.name !== undefined)
89
+ body.name = change.name;
90
+ if (change.folder !== undefined)
91
+ body.folder = change.folder === null ? null : id(change.folder, 'folder');
92
+ if (change.trashed !== undefined)
93
+ body.trashed = change.trashed;
94
+ return model.file(await this.call('PATCH', `files/${id(file, 'file')}`, { body }));
95
+ }
96
+ /** Moves a folder and everything in it to the trash (`DELETE /v1/folders/{id}`). */
97
+ async trashFolder(folder) {
98
+ return model.folder(await this.call('DELETE', `folders/${id(folder, 'folder')}`));
99
+ }
100
+ /** Moves a file to the trash (`DELETE /v1/files/{id}`). */
101
+ async trashFile(file) {
102
+ return model.file(await this.call('DELETE', `files/${id(file, 'file')}`));
103
+ }
104
+ /** Takes a folder out of the trash. */
105
+ restoreFolder(folder) {
106
+ return this.updateFolder(folder, { trashed: false });
107
+ }
108
+ /** Takes a file out of the trash. */
109
+ restoreFile(file) {
110
+ return this.updateFile(file, { trashed: false });
111
+ }
112
+ // ---- versions and uploads ------------------------------------------
113
+ /** The versions of a file, newest first (`GET /v1/files/{id}/versions`). */
114
+ async versions(file) {
115
+ const list = await this.call('GET', `files/${id(file, 'file')}/versions`);
116
+ return (Array.isArray(list) ? list : []).map(model.version);
117
+ }
118
+ /** A version and the chunk positions still missing (`GET /v1/versions/{id}`). */
119
+ async version(version) {
120
+ return model.status(await this.call('GET', `versions/${id(version, 'version')}`));
121
+ }
122
+ /** Begins a version of `file` with its manifest (`POST /v1/files/{id}/versions`); `key` (a UUID) makes it idempotent. */
123
+ async begin(file, plan, options = {}) {
124
+ const fields = {
125
+ size: plan.size,
126
+ hash: plan.hash,
127
+ chunks: plan.chunks.map((c) => ({ hash: c.hash, size: c.size })),
128
+ };
129
+ if (options.base)
130
+ fields.base = id(options.base, 'base');
131
+ if (options.key)
132
+ fields.idempotency = options.key;
133
+ let body = JSON.stringify(fields);
134
+ if (options.attrs) {
135
+ // mtime_ns passes 2^53: written as an exact integer, not through a double
136
+ const ms = options.attrs.mtime instanceof Date ? options.attrs.mtime.getTime() : options.attrs.mtime;
137
+ if (!Number.isFinite(ms))
138
+ throw new TypeError('attrs.mtime is not a time');
139
+ const ns = (BigInt(Math.round(ms)) * 1000000n).toString();
140
+ body = `${body.slice(0, -1)},"attrs":{"mtime_ns":${ns},"exec":${options.attrs.exec ? 'true' : 'false'}}}`;
141
+ }
142
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
143
+ if (options.key)
144
+ headers['Idempotency-Key'] = options.key;
145
+ const data = await this.#raw(url(this.root, `files/${id(file, 'file')}/versions`), { method: 'POST', headers, body }, 'Begin');
146
+ return { version: data.version, state: data.state, missing: data.missing ?? [], expires: data.expires ?? null, reserved: data.reserved ?? 0 };
147
+ }
148
+ /**
149
+ * Sends one chunk of an open version (`PUT /v1/chunks/{owner}/{hash}` on
150
+ * the content host): true when stored, false when the drive already held
151
+ * it. A chunk being reclaimed (`409 chunk.busy`) is retried after the
152
+ * server's wait, up to three times.
153
+ */
154
+ async putChunk(owner, version, hash, bytes, signal) {
155
+ const target = url(this.files, `chunks/${id(owner, 'owner')}/${digest(hash)}`);
156
+ for (let attempt = 0;; attempt++) {
157
+ const init = {
158
+ method: 'PUT',
159
+ headers: { 'X-Version': id(version, 'version'), 'Content-Type': 'application/octet-stream' },
160
+ body: bytes instanceof Uint8Array ? new Uint8Array(bytes) : bytes,
161
+ };
162
+ if (signal)
163
+ init.signal = signal;
164
+ const response = await this.#fetch(target, init);
165
+ if (response.status === 201 || response.status === 204) {
166
+ await response.arrayBuffer().catch(() => { });
167
+ return response.status === 201;
168
+ }
169
+ const text = await response.text();
170
+ const error = failure(text);
171
+ if (response.status === 409 && error?.kind === 'chunk.busy' && attempt < 3) {
172
+ const wait = Number(error.details?.retry) || 5;
173
+ await this.#sleep(wait * 1000);
174
+ continue;
175
+ }
176
+ throw rejection(response, text, 'Chunk');
177
+ }
178
+ }
179
+ /** Commits a version (`POST /v1/files/{id}/commits`); chunks still missing are `409` with `details.missing`. */
180
+ async commit(file, version) {
181
+ return (await this.call('POST', `files/${id(file, 'file')}/commits`, { body: { version: id(version, 'version') } }));
182
+ }
183
+ /** Aborts an uncommitted version and frees its reservation (`DELETE /v1/files/{id}/versions/{version}`). */
184
+ async abort(file, version) {
185
+ return (await this.call('DELETE', `files/${id(file, 'file')}/versions/${id(version, 'version')}`));
186
+ }
187
+ /**
188
+ * Uploads `content` as a new file (`name`, `folder`) or as a new version of
189
+ * `file`: hashes it, begins the version, sends the missing chunks and
190
+ * commits. Run again with the same `key` to resume: only the chunks the
191
+ * server still lacks are sent. The version is `pending` until scanned.
192
+ */
193
+ async upload(content, options = {}) {
194
+ const source = blob(content);
195
+ let file;
196
+ let base = options.base;
197
+ if (options.file) {
198
+ file = await this.file(options.file);
199
+ base ??= file.version ?? undefined;
200
+ }
201
+ else {
202
+ const named = source.name;
203
+ const name = options.name ?? (typeof named === 'string' ? named : '');
204
+ if (!name)
205
+ throw new TypeError('upload needs a name or a file');
206
+ const place = { folder: options.folder ?? null };
207
+ const mime = options.mime ?? source.type;
208
+ if (mime)
209
+ place.mime = mime;
210
+ file = await this.createFile(name, place);
211
+ }
212
+ const owner = file.owner;
213
+ if (!owner)
214
+ throw new ProtocolError('The file has no owner');
215
+ const plan = options.manifest ?? (await manifest(source, (done) => options.progress?.({ phase: 'hash', done, total: source.size })));
216
+ const begin = { key: options.key ?? crypto.randomUUID() };
217
+ if (base)
218
+ begin.base = base;
219
+ if (options.attrs)
220
+ begin.attrs = options.attrs;
221
+ const begun = await this.begin(file.id, plan, begin);
222
+ if (begun.state === 'fast')
223
+ return { file, version: begun.version, state: 'ready', sent: 0 };
224
+ let sent = 0;
225
+ const total = begun.missing.reduce((n, i) => n + (plan.chunks[i]?.size ?? 0), 0);
226
+ const push = async (missing) => {
227
+ for (const index of missing) {
228
+ const chunk = plan.chunks[index];
229
+ if (!chunk)
230
+ throw new ProtocolError(`The server asked for chunk ${index}, which the manifest does not have`);
231
+ options.signal?.throwIfAborted();
232
+ await this.putChunk(owner, begun.version, chunk.hash, source.slice(chunk.offset, chunk.offset + chunk.size), options.signal);
233
+ sent += chunk.size;
234
+ options.progress?.({ phase: 'send', done: sent, total: Math.max(total, sent) });
235
+ }
236
+ };
237
+ await push(begun.missing);
238
+ for (let round = 0;; round++) {
239
+ try {
240
+ const done = await this.commit(file.id, begun.version);
241
+ return { file, version: done.version, state: done.state, sent };
242
+ }
243
+ catch (error) {
244
+ const missing = error?.details;
245
+ if (round < 2 && error instanceof NetworkError && error.status === 409 && Array.isArray(missing?.missing)) {
246
+ await push(missing.missing);
247
+ continue;
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+ }
253
+ // ---- downloads and renditions -------------------------------------
254
+ /**
255
+ * The content of a file as a Response (`GET /v1/files/{id}/content` on the
256
+ * content host): `200`, `206` for a range, `304` for an unchanged `etag`;
257
+ * `ETag`, `Content-Type`, `Content-Range` headers. A failure throws (`416`
258
+ * for a range past the end).
259
+ */
260
+ async content(file, options = {}) {
261
+ const version = options.version ? `?version=${id(options.version, 'version')}` : '';
262
+ const headers = {};
263
+ if (options.range)
264
+ headers.Range = range(options.range);
265
+ if (options.etag)
266
+ headers['If-None-Match'] = options.etag.startsWith('"') ? options.etag : `"${options.etag}"`;
267
+ const init = { headers };
268
+ if (options.signal)
269
+ init.signal = options.signal;
270
+ const response = await this.#fetch(`${url(this.files, `files/${id(file, 'file')}/content`)}${version}`, init);
271
+ if (response.ok || response.status === 304)
272
+ return response;
273
+ throw rejection(response, await response.text(), 'Download');
274
+ }
275
+ /** The content of a file as a Blob (the whole file, or `range`). */
276
+ async download(file, options = {}) {
277
+ const response = await this.content(file, options);
278
+ if (response.status === 304)
279
+ throw new ProtocolError('Download answered 304: pass no etag to download');
280
+ return response.blob();
281
+ }
282
+ /** The renditions (thumbnails, posters, previews) of a version (`GET /v1/versions/{id}/media`). */
283
+ async media(version) {
284
+ const list = await this.call('GET', `versions/${id(version, 'version')}/media`);
285
+ return Array.isArray(list) ? list : [];
286
+ }
287
+ /** The bytes of a ready rendition (`GET /v1/derivatives/{id}/content` on the content host), or a `range` of them. */
288
+ async derivative(derivative, options = {}) {
289
+ const headers = {};
290
+ if (options.range)
291
+ headers.Range = range(options.range);
292
+ const init = { headers };
293
+ if (options.signal)
294
+ init.signal = options.signal;
295
+ const response = await this.#fetch(url(this.files, `derivatives/${id(derivative, 'derivative')}/content`), init);
296
+ if (response.ok)
297
+ return response;
298
+ throw rejection(response, await response.text(), 'Derivative');
299
+ }
300
+ // ---- any other route ----------------------------------------------
301
+ /**
302
+ * Any metadata route under `/v1` (`path` without it): returns the
303
+ * envelope's `data`; a refusal throws the typed error of `errors.refusal`
304
+ * (`ClientRefusedError` for a route that is not for programs).
305
+ */
306
+ async call(method, path, options = {}) {
307
+ const params = new URLSearchParams();
308
+ for (const [k, v] of Object.entries(options.query ?? {}))
309
+ if (v !== undefined && v !== '')
310
+ params.set(k, String(v));
311
+ const q = params.toString();
312
+ const clean = path.replace(/^\/+/, '');
313
+ const headers = { Accept: 'application/json' };
314
+ const init = { method, headers };
315
+ if (options.body !== undefined) {
316
+ headers['Content-Type'] = 'application/json';
317
+ init.body = JSON.stringify(options.body);
318
+ }
319
+ if (options.signal)
320
+ init.signal = options.signal;
321
+ return this.#raw(`${url(this.root, clean)}${q ? `?${q}` : ''}`, init, `${method} /v1/${clean.split('?')[0]}`);
322
+ }
323
+ async #raw(target, init, what) {
324
+ const response = await this.#fetch(target, init);
325
+ const text = await response.text();
326
+ if (!response.ok)
327
+ throw rejection(response, text, what);
328
+ const envelope = parse(text);
329
+ if (!envelope || envelope.success !== true)
330
+ throw new ProtocolError(`${what}: the answer is not a success envelope`);
331
+ if (envelope.data === undefined)
332
+ throw new NetworkError(`Empty response from ${what}`, { status: response.status });
333
+ return envelope.data;
334
+ }
335
+ }
336
+ /** A public id (16 lowercase hex digits), or TypeError. */
337
+ function id(value, what) {
338
+ if (!HEX.test(value))
339
+ throw new TypeError(`${what} is not an id (16 lowercase hex digits)`);
340
+ return value;
341
+ }
342
+ function digest(value) {
343
+ if (!/^[0-9a-f]{64}$/.test(value))
344
+ throw new TypeError('A chunk hash is 64 lowercase hex digits');
345
+ return value;
346
+ }
347
+ function range(r) {
348
+ if (!(Number.isInteger(r.start) && r.start >= 0) || (r.end !== undefined && !(Number.isInteger(r.end) && r.end >= r.start))) {
349
+ throw new TypeError('A range is whole byte offsets, start <= end');
350
+ }
351
+ return `bytes=${r.start}-${r.end ?? ''}`;
352
+ }
@@ -0,0 +1,319 @@
1
+ import type { Api } from './api.js';
2
+ import type * as model from './model.js';
3
+ import type { Page, PageQuery } from './model.js';
4
+ /** The workspace: profile, members, audits, usage and the mail admin routes. */
5
+ export declare class Tenant {
6
+ #private;
7
+ readonly members: TenantMembers;
8
+ readonly audits: TenantAudits;
9
+ readonly usage: TenantUsage;
10
+ readonly domains: TenantDomains;
11
+ readonly aliases: TenantAliases;
12
+ readonly quotas: TenantQuotas;
13
+ readonly rules: TenantRules;
14
+ readonly webhooks: TenantWebhooks;
15
+ readonly holidays: TenantHolidays;
16
+ readonly schedule: TenantSchedule;
17
+ constructor(api: Api);
18
+ /** `GET /v1/tenant`: the workspace's profile (scope `tenants:read`). */
19
+ get(): Promise<model.TenantProfile>;
20
+ /** `GET /v1/tenant/view/slug/{slug}` (`tenants:read`; another workspace's slug is `403`). */
21
+ bySlug(slug: string): Promise<model.TenantProfile>;
22
+ /** `GET /v1/tenant/security`: MTA-STS, TLSA, BIMI and the DMARC/TLS reports (`security:read`). */
23
+ security(): Promise<model.SecurityStatus>;
24
+ /** `GET /v1/tenant/mail/services`: the mail services and whether this workspace may use each (`mailservices:read`). */
25
+ services(): Promise<{
26
+ services: model.MailService[];
27
+ }>;
28
+ }
29
+ /**
30
+ * The members as Aduki Mail sees them. Listing all needs `tenants:read`, the
31
+ * filtered lists `members:read`; removing `tenants:write`. Inviting, roles
32
+ * and closing an account are Aduki ID's and need a signed-in person.
33
+ */
34
+ export declare class TenantMembers {
35
+ #private;
36
+ constructor(api: Api);
37
+ /** `GET /v1/tenant/members`, `.../active` or `.../owners`. */
38
+ list(query?: PageQuery & {
39
+ state?: 'active' | 'owners';
40
+ }): Promise<Page<model.MemberRow>>;
41
+ /** `GET /v1/tenant/members/search/{q}`. */
42
+ search(text: string, query?: PageQuery): Promise<Page<model.MemberRow>>;
43
+ /** `DELETE /v1/tenant/members/{user}`: removes the member's mail account from the workspace (`404` for a non-member). */
44
+ remove(user: string): Promise<void>;
45
+ }
46
+ /** The workspace's audit trail (`audits:read`; one record by hex: `tenants:read`). */
47
+ export declare class TenantAudits {
48
+ #private;
49
+ constructor(api: Api);
50
+ /**
51
+ * `GET /v1/tenant/audits[/user/{user}][/action/{action} | /failed |
52
+ * /successful | /ip/{ip}]`: one filter besides `user` at a time (the
53
+ * server has no route for two).
54
+ */
55
+ list(query?: PageQuery & {
56
+ user?: string;
57
+ action?: string;
58
+ outcome?: 'failed' | 'successful';
59
+ ip?: string;
60
+ }): Promise<Page<model.AuditRow>>;
61
+ /** `GET /v1/tenant/view/audit/{hex}`: one record with its `meta` (`tenants:read`). */
62
+ get(hex: string): Promise<model.AuditDetail>;
63
+ }
64
+ /** Metered usage (`usage:read`). */
65
+ export declare class TenantUsage {
66
+ #private;
67
+ constructor(api: Api);
68
+ /** `GET /v1/tenant/usage`. */
69
+ list(query?: PageQuery): Promise<Page<model.UsageRow>>;
70
+ /** `GET /v1/tenant/usage/summary`: one row per metric, no paging. */
71
+ summary(): Promise<model.UsageRow[]>;
72
+ /** `GET /v1/tenant/usage/metric/{metric}`. */
73
+ metric(metric: string, query?: PageQuery): Promise<Page<model.UsageRow>>;
74
+ }
75
+ /**
76
+ * Mail domains: `domains:read`, `domains:write` to change. The DNS records
77
+ * to publish are on the domain (`dkim`, and `pending` of a DKIM rotation).
78
+ */
79
+ export declare class TenantDomains {
80
+ #private;
81
+ readonly dkim: TenantDkim;
82
+ constructor(api: Api);
83
+ /** `POST /v1/tenant/domains`: registers a domain (status `pending`); returns its hex. */
84
+ add(input: model.DomainCreate): Promise<string>;
85
+ /** `GET /v1/tenant/domains`, `.../active` or `.../pending`. */
86
+ list(query?: PageQuery & {
87
+ state?: 'active' | 'pending';
88
+ }): Promise<Page<model.DomainRow>>;
89
+ /** `GET /v1/tenant/domains/{hex}`: the full record with its DNS snapshots. */
90
+ get(hex: string): Promise<model.Domain>;
91
+ /** `GET /v1/tenant/domains/lookup/name?name=`: a domain by name (`404` when none). */
92
+ byName(name: string): Promise<model.Domain>;
93
+ /** `PATCH /v1/tenant/domains/{hex}/name`. */
94
+ rename(hex: string, name: string): Promise<void>;
95
+ /** `PATCH /v1/tenant/domains/{hex}/kind`: `primary`, `sending`, `receiving` or `alias`. */
96
+ kind(hex: string, kind: string): Promise<void>;
97
+ /** `PATCH /v1/tenant/domains/{hex}/selector`: the DKIM selector, before the domain has a key (`409` after). */
98
+ selector(hex: string, selector: string): Promise<void>;
99
+ /** `PATCH /v1/tenant/domains/{hex}/auth`: the SPF, DMARC, MTA-STS and BIMI settings, all four. */
100
+ auth(hex: string, input: {
101
+ spf: unknown;
102
+ dmarc: unknown;
103
+ mta_sts: unknown;
104
+ bimi: unknown;
105
+ }): Promise<void>;
106
+ /** `PATCH /v1/tenant/domains/{hex}/meta`. */
107
+ meta(hex: string, meta: Record<string, unknown>): Promise<void>;
108
+ /** `DELETE /v1/tenant/domains/{hex}`. */
109
+ delete(hex: string): Promise<void>;
110
+ }
111
+ /**
112
+ * DKIM key rotation without a gap: stage a key the server makes, publish its
113
+ * TXT record, check, confirm. No private key is sent or returned.
114
+ */
115
+ export declare class TenantDkim {
116
+ #private;
117
+ constructor(api: Api);
118
+ /** `GET /v1/tenant/domains/{hex}/dkim/rotation`: the active, staged and retired records (`domains:read`). */
119
+ state(hex: string): Promise<model.DkimRotation>;
120
+ /** `POST .../dkim/rotation`: stages the next key (a retry returns the same one); publish `pending.value` at `pending.name`. */
121
+ stage(hex: string, input?: {
122
+ selector?: string;
123
+ }): Promise<model.DkimRotation>;
124
+ /** `POST .../dkim/rotation/check`: whether DNS carries the staged record; changes nothing (`domains:read`). */
125
+ check(hex: string): Promise<{
126
+ ready: boolean;
127
+ reason?: string;
128
+ pending?: unknown;
129
+ [field: string]: unknown;
130
+ }>;
131
+ /** `POST .../dkim/rotation/confirm`: switches signing to the staged key (`409` while DNS does not carry it). */
132
+ confirm(hex: string): Promise<model.DkimRotation>;
133
+ /** `DELETE .../dkim/rotation`: drops the staged key (`404` when none). */
134
+ cancel(hex: string): Promise<void>;
135
+ }
136
+ /** Members' extra addresses on the workspace's verified domains: `aliases:read`, `aliases:write`. */
137
+ export declare class TenantAliases {
138
+ #private;
139
+ constructor(api: Api);
140
+ /** `POST /v1/tenant/aliases`: `created` is false when the member already had it. */
141
+ add(user: string, address: string): Promise<model.Alias & {
142
+ created: boolean;
143
+ }>;
144
+ /** `GET /v1/tenant/aliases[?user=]`: at most 1000, by address. */
145
+ list(query?: {
146
+ user?: string;
147
+ }): Promise<{
148
+ items: model.Alias[];
149
+ }>;
150
+ /** `DELETE /v1/tenant/aliases/{address}`. */
151
+ remove(address: string): Promise<void>;
152
+ }
153
+ /** The workspace's own usage ceilings per metric: `quotas:read`, `quotas:write`. */
154
+ export declare class TenantQuotas {
155
+ #private;
156
+ constructor(api: Api);
157
+ /** `POST /v1/tenant/quotas`. */
158
+ add(input: {
159
+ metric: string;
160
+ ceiling: number;
161
+ reason?: string;
162
+ expires?: string;
163
+ }): Promise<model.Quota>;
164
+ /** `GET /v1/tenant/quotas`. */
165
+ list(query?: PageQuery): Promise<Page<model.QuotaRow>>;
166
+ /** `GET /v1/tenant/quotas/{metric}`. */
167
+ get(metric: string): Promise<model.Quota>;
168
+ /** `PATCH /v1/tenant/quotas/{metric}/ceiling`, `/expires`, `/reason`: the fields given, one call each. */
169
+ edit(metric: string, change: {
170
+ ceiling?: number;
171
+ expires?: string;
172
+ reason?: string;
173
+ }): Promise<void>;
174
+ /** `DELETE /v1/tenant/quotas/{metric}`. */
175
+ delete(metric: string): Promise<void>;
176
+ }
177
+ /** Spam rules of the workspace: `rules:read`, `rules:write`. */
178
+ export declare class TenantRules {
179
+ #private;
180
+ constructor(api: Api);
181
+ /** `POST /v1/tenant/rules`. */
182
+ add(input: model.RuleCreate): Promise<model.Rule>;
183
+ /** `GET /v1/tenant/rules`, `.../active`, or `.../target/{target}`. */
184
+ list(query?: PageQuery & {
185
+ active?: boolean;
186
+ target?: string;
187
+ }): Promise<Page<model.RuleRow>>;
188
+ /** `GET /v1/tenant/rules/{hex}/detail`: the rule with its `meta`. */
189
+ get(hex: string): Promise<model.Rule>;
190
+ /** `GET /v1/tenant/rules/name/{name}`. */
191
+ byName(name: string): Promise<model.Rule>;
192
+ /**
193
+ * `PATCH /v1/tenant/rules/{hex}/{field}` for each field given (`regex` is
194
+ * sent after `pattern`, so a new pattern and `regex: true` go together).
195
+ */
196
+ edit(hex: string, change: {
197
+ name?: string;
198
+ target?: string;
199
+ pattern?: string;
200
+ regex?: boolean;
201
+ score?: number;
202
+ active?: boolean;
203
+ }): Promise<void>;
204
+ /** `DELETE /v1/tenant/rules/{hex}`. */
205
+ delete(hex: string): Promise<void>;
206
+ }
207
+ /** Delivery-event webhooks: `webhooks:read`, `webhooks:write`. */
208
+ export declare class TenantWebhooks {
209
+ #private;
210
+ constructor(api: Api);
211
+ /** `POST /v1/tenant/webhooks`: returns the hex and the signing secret, shown this once. */
212
+ add(input: model.WebhookCreate): Promise<{
213
+ hex: string;
214
+ secret: string;
215
+ }>;
216
+ /** `GET /v1/tenant/webhooks`, `.../active`, or `.../subscribers/{event}`. */
217
+ list(query?: PageQuery & {
218
+ active?: boolean;
219
+ event?: string;
220
+ }): Promise<Page<model.WebhookRow>>;
221
+ /** `GET /v1/tenant/webhooks/{hex}`: the record, never the secret. */
222
+ get(hex: string): Promise<model.Webhook>;
223
+ /** `PATCH /v1/tenant/webhooks/{hex}/{field}` for each field given (`url` is checked again: `https`, a public host). */
224
+ edit(hex: string, change: {
225
+ url?: string;
226
+ events?: string[];
227
+ domains?: string[];
228
+ active?: boolean;
229
+ secret?: string;
230
+ }): Promise<void>;
231
+ /** `POST /v1/tenant/webhooks/{hex}/secret/rotate`: a new secret made by the server, returned once. */
232
+ rotate(hex: string): Promise<string>;
233
+ /** `DELETE /v1/tenant/webhooks/{hex}`. */
234
+ delete(hex: string): Promise<void>;
235
+ }
236
+ /** The workspace's holidays and closures: reading `tenant:read`, writing `tenants:write`. */
237
+ export declare class TenantHolidays {
238
+ #private;
239
+ constructor(api: Api);
240
+ /** `GET /v1/tenant/holidays?region&year`: the effective days (`region` defaults to the workspace's). */
241
+ list(query?: {
242
+ region?: string;
243
+ year?: number;
244
+ }): Promise<model.Holiday[]>;
245
+ /**
246
+ * `POST /v1/tenant/holidays`: a day or range of the workspace's own
247
+ * (`kind` `closure` by default, or `observance`), or `{exclude: true,
248
+ * region, date}` to drop the platform's day on that date.
249
+ */
250
+ add(input: {
251
+ date: string;
252
+ until?: string;
253
+ name: string;
254
+ kind?: 'closure' | 'observance';
255
+ region?: string;
256
+ } | {
257
+ exclude: true;
258
+ region: string;
259
+ date: string;
260
+ }): Promise<model.Holiday>;
261
+ /** `DELETE /v1/tenant/holidays/{hex}`: one of the workspace's own rows. */
262
+ remove(hex: string): Promise<void>;
263
+ /** `GET /v1/tenant/holidays/region`: the default region, or null. */
264
+ region(): Promise<{
265
+ region: string;
266
+ subdivision?: string | null;
267
+ updated: string;
268
+ } | null>;
269
+ /** `PUT /v1/tenant/holidays/region` (`422` for a region with no data); `null` clears it (`DELETE`). */
270
+ setRegion(region: {
271
+ region: string;
272
+ subdivision?: string;
273
+ } | null): Promise<void>;
274
+ }
275
+ /** Scheduling templates and resources: reading `tenant:read`, writing `tenants:write`. */
276
+ export declare class TenantSchedule {
277
+ #private;
278
+ constructor(api: Api);
279
+ /** `GET /v1/tenant/schedule/templates`. */
280
+ templates(): Promise<model.ScheduleTemplate[]>;
281
+ /** `GET /v1/tenant/schedule/templates/{hex}`. */
282
+ template(hex: string): Promise<model.ScheduleTemplate>;
283
+ /** `POST /v1/tenant/schedule/templates` (rules default to weekdays 09:00-17:00, zone to UTC). */
284
+ addTemplate(input: {
285
+ name: string;
286
+ zone?: string;
287
+ rules?: unknown[];
288
+ }): Promise<model.ScheduleTemplate>;
289
+ /** `PATCH /v1/tenant/schedule/templates/{hex}`: name and zone. */
290
+ editTemplate(hex: string, change: {
291
+ name?: string;
292
+ zone?: string;
293
+ }): Promise<model.ScheduleTemplate>;
294
+ /** `PUT /v1/tenant/schedule/templates/{hex}/rules`: replaces the weekly rules. */
295
+ setRules(hex: string, rules: unknown[]): Promise<model.ScheduleTemplate>;
296
+ /** `DELETE /v1/tenant/schedule/templates/{hex}` (`409` while followed or used). */
297
+ deleteTemplate(hex: string): Promise<void>;
298
+ /** `GET /v1/tenant/schedule/resources`: rooms and equipment, live ones first. */
299
+ resources(): Promise<model.ScheduleResource[]>;
300
+ /** `GET /v1/tenant/schedule/resources/{hex}`. */
301
+ resource(hex: string): Promise<model.ScheduleResource>;
302
+ /** `POST /v1/tenant/schedule/resources` (`409` for a name in use). */
303
+ addResource(input: {
304
+ name: string;
305
+ kind: 'room' | 'equipment';
306
+ capacity?: number;
307
+ schedule?: string;
308
+ }): Promise<model.ScheduleResource>;
309
+ /** `PATCH /v1/tenant/schedule/resources/{hex}`: `null` clears `capacity` and `schedule`; `retired` stops offering it. */
310
+ editResource(hex: string, change: {
311
+ name?: string;
312
+ kind?: 'room' | 'equipment';
313
+ capacity?: number | null;
314
+ schedule?: string | null;
315
+ retired?: boolean;
316
+ }): Promise<model.ScheduleResource>;
317
+ /** `DELETE /v1/tenant/schedule/resources/{hex}` (`409` once booked: retire it instead). */
318
+ deleteResource(hex: string): Promise<void>;
319
+ }