@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.
- package/LICENSE +201 -0
- package/README.md +184 -2
- package/dist/api/api.d.ts +226 -0
- package/dist/api/api.js +365 -0
- package/dist/api/id.d.ts +75 -0
- package/dist/api/id.js +184 -0
- package/dist/api/index.d.ts +23 -0
- package/dist/api/index.js +19 -0
- package/dist/api/key.d.ts +16 -0
- package/dist/api/key.js +16 -0
- package/dist/api/model.d.ts +476 -0
- package/dist/api/space.d.ts +147 -0
- package/dist/api/space.js +218 -0
- package/dist/api/store.d.ts +181 -0
- package/dist/api/store.js +352 -0
- package/dist/api/tenant.d.ts +319 -0
- package/dist/api/tenant.js +429 -0
- package/dist/base64.d.ts +11 -0
- package/dist/base64.js +40 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.js +22 -0
- package/dist/dpop.d.ts +82 -0
- package/dist/dpop.js +156 -0
- package/dist/envelope.d.ts +59 -0
- package/dist/envelope.js +114 -0
- package/dist/errors.d.ts +165 -0
- package/dist/errors.js +206 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.js +22 -0
- package/dist/jmap.d.ts +100 -0
- package/dist/jmap.js +180 -0
- package/dist/space/model.d.ts +147 -0
- package/dist/store/blake3.d.ts +4 -0
- package/dist/store/blake3.js +130 -0
- package/dist/store/cdc.d.ts +12 -0
- package/dist/store/cdc.js +127 -0
- package/dist/store/chunks.d.ts +23 -0
- package/dist/store/chunks.js +63 -0
- package/dist/store/model.d.ts +202 -0
- package/dist/store/model.js +38 -0
- package/package.json +45 -4
package/dist/api/api.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// The API-key client: programs (servers, scripts, workers) that call Aduki
|
|
2
|
+
// Mail with a tenant API key, `Authorization: Key <key>`. No sign-in, no
|
|
3
|
+
// browser storage, no DPoP: the key is the credential, its stored scopes
|
|
4
|
+
// (narrowed to what the workspace owner who made it may do) are its rights.
|
|
5
|
+
//
|
|
6
|
+
// Aduki Mail (`mail.aduki.pro`) is the one service that accepts API keys:
|
|
7
|
+
// its REST API under `/v1` (the `user` and `tenant` routes, see `tenant.ts`;
|
|
8
|
+
// never `platform`) and its JMAP endpoints. Aduki ID, Store and Space take
|
|
9
|
+
// only Aduki ID tokens (guide/api-keys.md).
|
|
10
|
+
import { rejection } from '../envelope.js';
|
|
11
|
+
import { ProtocolError } from '../errors.js';
|
|
12
|
+
import { json, send, url } from '../http.js';
|
|
13
|
+
import { Jmap } from '../jmap.js';
|
|
14
|
+
import { createKey } from './key.js';
|
|
15
|
+
import { Tenant } from './tenant.js';
|
|
16
|
+
/** The mail host (REST under `/v1`, JMAP at the root). */
|
|
17
|
+
export const ROOT = 'https://mail.aduki.pro';
|
|
18
|
+
export class Api {
|
|
19
|
+
/** the JMAP client on the same key (mail: `u.mail:read`, sending: `u.mail:send`) */
|
|
20
|
+
jmap;
|
|
21
|
+
user;
|
|
22
|
+
mail;
|
|
23
|
+
mailboxes;
|
|
24
|
+
contacts;
|
|
25
|
+
calendar;
|
|
26
|
+
keys;
|
|
27
|
+
/** the workspace: domains, aliases, members, rules, webhooks, quotas, audits, usage, holidays, scheduling (`tenant.*` scopes) */
|
|
28
|
+
tenant;
|
|
29
|
+
#key;
|
|
30
|
+
#root;
|
|
31
|
+
#fetch;
|
|
32
|
+
random;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
const key = options?.key?.trim();
|
|
35
|
+
if (!key)
|
|
36
|
+
throw new TypeError('Api needs a key');
|
|
37
|
+
if (/\s/.test(key))
|
|
38
|
+
throw new TypeError('An API key has no whitespace');
|
|
39
|
+
this.#key = key;
|
|
40
|
+
this.#root = (options.root ?? ROOT).replace(/\/+$/, '');
|
|
41
|
+
const transport = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
42
|
+
this.#fetch = (input, init) => send(transport, input, init);
|
|
43
|
+
this.random = options.random ?? (() => crypto.randomUUID());
|
|
44
|
+
const keyed = { fetch: (_audience, input, init) => this.#send(input, init) };
|
|
45
|
+
this.jmap = new Jmap({ session: keyed, root: this.#root });
|
|
46
|
+
this.user = new User(this);
|
|
47
|
+
this.mail = new MailApi(this);
|
|
48
|
+
this.mailboxes = new Mailboxes(this);
|
|
49
|
+
this.contacts = new ContactsApi(this);
|
|
50
|
+
this.calendar = new CalendarApi(this);
|
|
51
|
+
this.keys = new Keys(this);
|
|
52
|
+
this.tenant = new Tenant(this);
|
|
53
|
+
}
|
|
54
|
+
/** The request with the key added (an absolute URL, or a path on the mail host). */
|
|
55
|
+
#send(input, init) {
|
|
56
|
+
const target = typeof input === 'string' && !/^https?:\/\//.test(input) ? url(this.#root, input.replace(/^\/+/, '')) : input;
|
|
57
|
+
const request = new Request(target, init);
|
|
58
|
+
request.headers.set('Authorization', `Key ${this.#key}`);
|
|
59
|
+
return this.#fetch(request);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Sends a request to the mail host with the key (`path` relative to the
|
|
63
|
+
* host, as `v1/user`, or an absolute URL) and returns the raw response.
|
|
64
|
+
* A transport failure is a NetworkError; the answer is not checked.
|
|
65
|
+
*/
|
|
66
|
+
fetch(path, init) {
|
|
67
|
+
return this.#send(path, init);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Any REST route under `/v1` (`path` without it, as `user/profile`):
|
|
71
|
+
* returns the envelope's `data` (null for `204`); a refusal throws the
|
|
72
|
+
* typed error of `errors.refusal` (UnauthorizedError for a bad key,
|
|
73
|
+
* NetworkError with `403 access.denied` for a scope the key lacks,
|
|
74
|
+
* RateLimitedError with `retryAfter`, ...).
|
|
75
|
+
*/
|
|
76
|
+
async call(method, path, options = {}) {
|
|
77
|
+
let target = `v1/${path.replace(/^\/+/, '')}`;
|
|
78
|
+
const query = new URLSearchParams();
|
|
79
|
+
for (const [name, value] of Object.entries(options.query ?? {}))
|
|
80
|
+
if (value !== undefined)
|
|
81
|
+
query.set(name, String(value));
|
|
82
|
+
if ([...query].length)
|
|
83
|
+
target += `?${query}`;
|
|
84
|
+
const headers = { Accept: 'application/json' };
|
|
85
|
+
let body;
|
|
86
|
+
if (options.body !== undefined) {
|
|
87
|
+
const request = json(options.body);
|
|
88
|
+
Object.assign(headers, request.headers);
|
|
89
|
+
body = request.body;
|
|
90
|
+
}
|
|
91
|
+
if (options.idempotency)
|
|
92
|
+
headers['Idempotency-Key'] = options.idempotency;
|
|
93
|
+
const init = { method, headers };
|
|
94
|
+
if (body !== undefined)
|
|
95
|
+
init.body = body;
|
|
96
|
+
if (options.signal)
|
|
97
|
+
init.signal = options.signal;
|
|
98
|
+
const response = await this.#send(target, init);
|
|
99
|
+
const text = await response.text();
|
|
100
|
+
const what = `${method} /${target.split('?')[0]}`;
|
|
101
|
+
if (!response.ok)
|
|
102
|
+
throw rejection(response, text, what);
|
|
103
|
+
if (response.status === 204 || !text.trim())
|
|
104
|
+
return null;
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(text);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new ProtocolError(`${what} answer is not JSON`);
|
|
111
|
+
}
|
|
112
|
+
return (parsed && typeof parsed === 'object' && 'success' in parsed ? (parsed.data ?? null) : parsed);
|
|
113
|
+
}
|
|
114
|
+
/** `GET` of a list route with paging (see PageQuery). */
|
|
115
|
+
page(path, query = {}) {
|
|
116
|
+
return this.call('GET', path, { query: query });
|
|
117
|
+
}
|
|
118
|
+
/** Every item of a list route, following the cursor (`after`) page by page. */
|
|
119
|
+
async *all(path, query = {}) {
|
|
120
|
+
let after;
|
|
121
|
+
do {
|
|
122
|
+
const page = await this.page(path, { ...query, after });
|
|
123
|
+
yield* page.items ?? [];
|
|
124
|
+
after = page.next || undefined;
|
|
125
|
+
} while (after);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const seg = (value) => encodeURIComponent(value);
|
|
129
|
+
/** The account the key acts as. Scope `users:read`. */
|
|
130
|
+
export class User {
|
|
131
|
+
#api;
|
|
132
|
+
constructor(api) {
|
|
133
|
+
this.#api = api;
|
|
134
|
+
}
|
|
135
|
+
/** `GET /v1/user`: the account. */
|
|
136
|
+
get() {
|
|
137
|
+
return this.#api.call('GET', 'user');
|
|
138
|
+
}
|
|
139
|
+
/** `GET /v1/user/profile`: name, address, workspace, role, plan, storage. */
|
|
140
|
+
profile() {
|
|
141
|
+
return this.#api.call('GET', 'user/profile');
|
|
142
|
+
}
|
|
143
|
+
/** `GET /v1/user/keys`: the API keys made by this account (never the keys themselves). */
|
|
144
|
+
keys(query = {}) {
|
|
145
|
+
return this.#api.page('user/keys', query);
|
|
146
|
+
}
|
|
147
|
+
/** `GET /v1/user/aliases` (scope `aliases:read`). */
|
|
148
|
+
aliases() {
|
|
149
|
+
return this.#api.call('GET', 'user/aliases');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Mail over REST. Reading needs `mail:read`, changes `mail:write`, sending `mail:send`. */
|
|
153
|
+
export class MailApi {
|
|
154
|
+
#api;
|
|
155
|
+
constructor(api) {
|
|
156
|
+
this.#api = api;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* `POST /v1/user/mail/send`: a plain-text message, a copy filed in Sent;
|
|
160
|
+
* returns its hex. `from` defaults to the default identity (an address the
|
|
161
|
+
* account may not send as is `403`). Sent with an `Idempotency-Key`
|
|
162
|
+
* (`idempotency`, else a random one): a retry with the same key never
|
|
163
|
+
* sends twice. A full queue is `RateLimitedError` (`mail.queue_busy`).
|
|
164
|
+
*/
|
|
165
|
+
async send(input, options = {}) {
|
|
166
|
+
const body = { to: input.to, subject: input.subject, text: input.text };
|
|
167
|
+
if (input.cc?.length)
|
|
168
|
+
body.cc = input.cc;
|
|
169
|
+
if (input.from)
|
|
170
|
+
body.from = input.from;
|
|
171
|
+
const data = await this.#api.call('POST', 'user/mail/send', { body, idempotency: options.idempotency ?? this.#api.random() });
|
|
172
|
+
if (typeof data?.hex !== 'string')
|
|
173
|
+
throw new ProtocolError('Send answer has no hex');
|
|
174
|
+
return data.hex;
|
|
175
|
+
}
|
|
176
|
+
/** `GET /v1/user/mail/<folder>`: the messages of a folder, newest first. */
|
|
177
|
+
list(folder, query = {}) {
|
|
178
|
+
return this.#api.page(`user/mail/${folder}`, query);
|
|
179
|
+
}
|
|
180
|
+
/** `GET /v1/user/mail/inbox/unread`. */
|
|
181
|
+
/** `GET /v1/user/mail/{hex}`: one message (scope `mail:read`); a deleted or unknown one is a 404. */
|
|
182
|
+
get(hex) {
|
|
183
|
+
return this.#api.call('GET', `user/mail/${seg(hex)}`);
|
|
184
|
+
}
|
|
185
|
+
unread(query = {}) {
|
|
186
|
+
return this.#api.page('user/mail/inbox/unread', query);
|
|
187
|
+
}
|
|
188
|
+
/** `GET /v1/user/mail/folder/{name}`: a mailbox by name. */
|
|
189
|
+
folder(name, query = {}) {
|
|
190
|
+
return this.#api.page(`user/mail/folder/${seg(name)}`, query);
|
|
191
|
+
}
|
|
192
|
+
/** `GET /v1/user/mail/search/{text}`. */
|
|
193
|
+
search(text, query = {}) {
|
|
194
|
+
return this.#api.page(`user/mail/search/${seg(text)}`, query);
|
|
195
|
+
}
|
|
196
|
+
/** `GET /v1/user/mail/threads`. */
|
|
197
|
+
threads(query = {}) {
|
|
198
|
+
return this.#api.page('user/mail/threads', query);
|
|
199
|
+
}
|
|
200
|
+
/** `GET /v1/user/mail/thread/{thread}`: the messages of a thread. */
|
|
201
|
+
thread(thread, query = {}) {
|
|
202
|
+
return this.#api.page(`user/mail/thread/${seg(thread)}`, query);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* `GET /v1/user/mail/changes`: what changed in a mailbox since `since` (a
|
|
206
|
+
* modseq, 0 for everything). Pass the `uidvalidity` last seen: a stale one
|
|
207
|
+
* comes back with `reset`.
|
|
208
|
+
*/
|
|
209
|
+
changes(input) {
|
|
210
|
+
return this.#api.call('GET', 'user/mail/changes', {
|
|
211
|
+
query: { mailbox: input.mailbox, since: input.since ?? 0, uidvalidity: input.uidvalidity, limit: input.limit },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/** `PATCH /v1/user/mail/{hex}/flags`: adds and removes flags (`\\Seen`, `\\Flagged`, ...). */
|
|
215
|
+
async flags(hex, change) {
|
|
216
|
+
await this.#api.call('PATCH', `user/mail/${seg(hex)}/flags`, { body: { add: change.add ?? [], remove: change.remove ?? [] } });
|
|
217
|
+
}
|
|
218
|
+
/** `PATCH /v1/user/mail/{hex}/mailbox`: moves a message to another mailbox (hex). */
|
|
219
|
+
move(hex, mailbox) {
|
|
220
|
+
return this.#api.call('PATCH', `user/mail/${seg(hex)}/mailbox`, { body: { mailbox } });
|
|
221
|
+
}
|
|
222
|
+
/** `DELETE /v1/user/mail/{hex}`. */
|
|
223
|
+
async delete(hex) {
|
|
224
|
+
await this.#api.call('DELETE', `user/mail/${seg(hex)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Mailboxes over REST: `mailbox:read`. */
|
|
228
|
+
export class Mailboxes {
|
|
229
|
+
#api;
|
|
230
|
+
constructor(api) {
|
|
231
|
+
this.#api = api;
|
|
232
|
+
}
|
|
233
|
+
/** `GET /v1/user/mailbox`. */
|
|
234
|
+
list(query = {}) {
|
|
235
|
+
return this.#api.page('user/mailbox', query);
|
|
236
|
+
}
|
|
237
|
+
/** `GET /v1/user/mailbox/unread`: the mailboxes with unread mail. */
|
|
238
|
+
unread(query = {}) {
|
|
239
|
+
return this.#api.page('user/mailbox/unread', query);
|
|
240
|
+
}
|
|
241
|
+
/** `GET /v1/user/mailbox/search/{text}`. */
|
|
242
|
+
search(text, query = {}) {
|
|
243
|
+
return this.#api.page(`user/mailbox/search/${seg(text)}`, query);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** The address book over REST: `contacts:read`, `contacts:write` to delete. */
|
|
247
|
+
export class ContactsApi {
|
|
248
|
+
#api;
|
|
249
|
+
constructor(api) {
|
|
250
|
+
this.#api = api;
|
|
251
|
+
}
|
|
252
|
+
/** `GET /v1/user/contacts`. */
|
|
253
|
+
list(query = {}) {
|
|
254
|
+
return this.#api.page('user/contacts', query);
|
|
255
|
+
}
|
|
256
|
+
/** `GET /v1/user/contacts/{hex}`: one contact (scope `contacts:read`). */
|
|
257
|
+
get(hex) {
|
|
258
|
+
return this.#api.call('GET', `user/contacts/${seg(hex)}`);
|
|
259
|
+
}
|
|
260
|
+
/** `GET /v1/user/contacts/search/{text}`. */
|
|
261
|
+
search(text, query = {}) {
|
|
262
|
+
return this.#api.page(`user/contacts/search/${seg(text)}`, query);
|
|
263
|
+
}
|
|
264
|
+
/** `GET /v1/user/contacts/groups`. */
|
|
265
|
+
groups(query = {}) {
|
|
266
|
+
return this.#api.page('user/contacts/groups', query);
|
|
267
|
+
}
|
|
268
|
+
/** `GET /v1/user/contacts/group/{group}`: the cards of a group. */
|
|
269
|
+
group(group, query = {}) {
|
|
270
|
+
return this.#api.page(`user/contacts/group/${seg(group)}`, query);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* `GET /v1/user/contacts/sync`: one page of changes since `since` (the
|
|
274
|
+
* `token` of the last answer; none for the whole book). A token the server
|
|
275
|
+
* no longer knows throws SyncResetError: sync again without one.
|
|
276
|
+
*/
|
|
277
|
+
sync(input = {}) {
|
|
278
|
+
return this.#api.call('GET', 'user/contacts/sync', { query: { since: input.since, limit: input.limit } });
|
|
279
|
+
}
|
|
280
|
+
/** `DELETE /v1/user/contacts/{hex}`. */
|
|
281
|
+
async delete(hex) {
|
|
282
|
+
await this.#api.call('DELETE', `user/contacts/${seg(hex)}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Calendars and events over REST: `calendars:read`, `calendars:write` to delete. */
|
|
286
|
+
export class CalendarApi {
|
|
287
|
+
#api;
|
|
288
|
+
constructor(api) {
|
|
289
|
+
this.#api = api;
|
|
290
|
+
}
|
|
291
|
+
/** `GET /v1/user/calendars`. */
|
|
292
|
+
/** `GET /v1/user/calendars/{hex}`: one calendar (scope `calendars:read`). */
|
|
293
|
+
calendar(hex) {
|
|
294
|
+
return this.#api.call('GET', `user/calendars/${seg(hex)}`);
|
|
295
|
+
}
|
|
296
|
+
/** `GET /v1/user/events/{hex}`: one event with its `calendar` hex (scope `calendars:read`). */
|
|
297
|
+
event(hex) {
|
|
298
|
+
return this.#api.call('GET', `user/events/${seg(hex)}`);
|
|
299
|
+
}
|
|
300
|
+
calendars(query = {}) {
|
|
301
|
+
return this.#api.page('user/calendars', query);
|
|
302
|
+
}
|
|
303
|
+
/** `GET /v1/user/events`. */
|
|
304
|
+
events(query = {}) {
|
|
305
|
+
return this.#api.page('user/events', query);
|
|
306
|
+
}
|
|
307
|
+
/** `GET /v1/user/events/range/{from}/{to}`: occurrences between two instants (RFC 3339, or Dates). */
|
|
308
|
+
range(from, to, query = {}) {
|
|
309
|
+
const iso = (t) => (t instanceof Date ? t.toISOString() : t);
|
|
310
|
+
return this.#api.page(`user/events/range/${seg(iso(from))}/${seg(iso(to))}`, query);
|
|
311
|
+
}
|
|
312
|
+
/** `GET /v1/user/events/upcoming`. */
|
|
313
|
+
upcoming(query = {}) {
|
|
314
|
+
return this.#api.page('user/events/upcoming', query);
|
|
315
|
+
}
|
|
316
|
+
/** `GET /v1/user/events/search/{text}`. */
|
|
317
|
+
search(text, query = {}) {
|
|
318
|
+
return this.#api.page(`user/events/search/${seg(text)}`, query);
|
|
319
|
+
}
|
|
320
|
+
/** `DELETE /v1/user/events/{hex}`. */
|
|
321
|
+
async deleteEvent(hex) {
|
|
322
|
+
await this.#api.call('DELETE', `user/events/${seg(hex)}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The workspace's API keys: `tenant.keys:read`, `tenant.keys:write` to
|
|
327
|
+
* rename, rotate and revoke. Making a key or changing its scopes is for a
|
|
328
|
+
* workspace owner signed in (a key is never an owner: `403`).
|
|
329
|
+
*/
|
|
330
|
+
export class Keys {
|
|
331
|
+
#api;
|
|
332
|
+
constructor(api) {
|
|
333
|
+
this.#api = api;
|
|
334
|
+
}
|
|
335
|
+
/** `GET /v1/tenant/keys`, `.../active` or `.../expired`. */
|
|
336
|
+
list(query = {}) {
|
|
337
|
+
const { state, ...rest } = query;
|
|
338
|
+
return this.#api.page(state ? `tenant/keys/${state}` : 'tenant/keys', rest);
|
|
339
|
+
}
|
|
340
|
+
/** `GET /v1/tenant/keys/{hex}`: name, prefix, scopes, dates (never the hash). */
|
|
341
|
+
get(hex) {
|
|
342
|
+
return this.#api.call('GET', `tenant/keys/${seg(hex)}`);
|
|
343
|
+
}
|
|
344
|
+
/** `PATCH /v1/tenant/keys/{hex}/name`. */
|
|
345
|
+
async rename(hex, name) {
|
|
346
|
+
await this.#api.call('PATCH', `tenant/keys/${seg(hex)}/name`, { body: { name } });
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* A new secret for key `hex` (`PATCH /v1/tenant/keys/{hex}/hash`): makes a
|
|
350
|
+
* random key, stores its hash and returns it. The old secret stops working
|
|
351
|
+
* at once (also for this client, if it is the key in use: make a new Api
|
|
352
|
+
* with the returned key); the key keeps its hex, name and scopes, and its
|
|
353
|
+
* prefix becomes the new key's (servers before ADK-MAIL-001 v52 ignore
|
|
354
|
+
* `prefix` and keep the old one).
|
|
355
|
+
*/
|
|
356
|
+
async rotate(hex) {
|
|
357
|
+
const made = await createKey();
|
|
358
|
+
await this.#api.call('PATCH', `tenant/keys/${seg(hex)}/hash`, { body: { hash: made.hash, prefix: made.prefix } });
|
|
359
|
+
return made.key;
|
|
360
|
+
}
|
|
361
|
+
/** `DELETE /v1/tenant/keys/{hex}`: deletes the key; it stops working at once. */
|
|
362
|
+
async delete(hex) {
|
|
363
|
+
await this.#api.call('DELETE', `tenant/keys/${seg(hex)}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
package/dist/api/id.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { TokenSource } from '../client.js';
|
|
2
|
+
import { Dpop } from '../dpop.js';
|
|
3
|
+
import type { KeyStore } from '../dpop.js';
|
|
4
|
+
import { SpaceApi } from './space.js';
|
|
5
|
+
import type { SpaceApiOptions } from './space.js';
|
|
6
|
+
import { StoreApi } from './store.js';
|
|
7
|
+
import type { StoreApiOptions } from './store.js';
|
|
8
|
+
/** Aduki ID (the token issuer). */
|
|
9
|
+
export declare const ID = "https://id.aduki.pro";
|
|
10
|
+
export interface IdClientOptions {
|
|
11
|
+
/** the service-client key, `adk_<client hex>_<secret>` (made by a workspace owner in Aduki ID; shown once) */
|
|
12
|
+
key: string;
|
|
13
|
+
/**
|
|
14
|
+
* Aduki ID's origin, exactly its issuer (default `https://id.aduki.pro`):
|
|
15
|
+
* the proof of the exchange is signed for `<base>/v1/tokens`
|
|
16
|
+
*/
|
|
17
|
+
base?: string | undefined;
|
|
18
|
+
/** the transport for every call, Aduki ID, Store and Space (default global fetch) */
|
|
19
|
+
fetch?: typeof fetch | undefined;
|
|
20
|
+
/** the DPoP key the tokens are bound to (default: generated, see `storage`) */
|
|
21
|
+
dpop?: Dpop | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Where a generated key is kept (key `dpop.key`): any async store that
|
|
24
|
+
* holds a CryptoKeyPair as is. Default: memory, a new key per IdClient.
|
|
25
|
+
* Ignored with `dpop`.
|
|
26
|
+
*/
|
|
27
|
+
storage?: KeyStore | undefined;
|
|
28
|
+
/** renew a token this many seconds before it ends (default 60; at most half its lifetime) */
|
|
29
|
+
renew?: number | undefined;
|
|
30
|
+
/** the clock in milliseconds (default `Date.now`; tests pass a fake) */
|
|
31
|
+
clock?: (() => number) | undefined;
|
|
32
|
+
/** Store's roots and timer (`api.store`) */
|
|
33
|
+
store?: StoreApiOptions | undefined;
|
|
34
|
+
/** Space's root (`api.space`) */
|
|
35
|
+
space?: SpaceApiOptions | undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A workspace's program on Store and Space. `store` is the workspace drive,
|
|
39
|
+
* `space` the non-strict, non-money routes of Aduki Space; `token` and
|
|
40
|
+
* `fetch` reach any other audience the client may hold.
|
|
41
|
+
*/
|
|
42
|
+
export declare class IdClient implements TokenSource {
|
|
43
|
+
#private;
|
|
44
|
+
/** the workspace drive on Aduki Store (`store.read`, `store.write`) */
|
|
45
|
+
readonly store: StoreApi;
|
|
46
|
+
/** Aduki Space's routes that are neither strict nor money (`space.use` and the route's bit) */
|
|
47
|
+
readonly space: SpaceApi;
|
|
48
|
+
/** Aduki ID's origin */
|
|
49
|
+
readonly base: string;
|
|
50
|
+
constructor(options: IdClientOptions);
|
|
51
|
+
/** The DPoP key the tokens are bound to (made on first use). */
|
|
52
|
+
key(): Promise<Dpop>;
|
|
53
|
+
/**
|
|
54
|
+
* A valid token for `audience` (`store`, `space`, ...): the cached one, or
|
|
55
|
+
* a new one from Aduki ID when there is none or it ends within `renew`
|
|
56
|
+
* seconds. Concurrent callers share one exchange. A refused exchange
|
|
57
|
+
* throws the typed error (`UnauthorizedError` for a bad, revoked or expired
|
|
58
|
+
* key; `NetworkError 403 access.denied` when the client's grants hold no
|
|
59
|
+
* right to the audience; `403 app.disabled` when the app is off).
|
|
60
|
+
*/
|
|
61
|
+
token(audience: string): Promise<string>;
|
|
62
|
+
/** Renews the token of `audience` unless it was already replaced since `stale` (`TokenSource`). */
|
|
63
|
+
rotate(audience: string, stale: string | null): Promise<{
|
|
64
|
+
token: string;
|
|
65
|
+
}>;
|
|
66
|
+
/** Drops the cached token of `audience`, or every token. */
|
|
67
|
+
forget(audience?: string): void;
|
|
68
|
+
/**
|
|
69
|
+
* Sends a request to a service of `audience` with its token
|
|
70
|
+
* (`Authorization: DPoP`) and a proof; a 401 renews the token once and
|
|
71
|
+
* sends again. Returns the raw response; a transport failure is a
|
|
72
|
+
* NetworkError. The body must be re-sendable (not a stream).
|
|
73
|
+
*/
|
|
74
|
+
fetch(audience: string, input: Request | string | URL, init?: RequestInit): Promise<Response>;
|
|
75
|
+
}
|
package/dist/api/id.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Machine access: a program of a workspace on Aduki Store and Aduki Space
|
|
2
|
+
// with an Aduki ID service-client key (`adk_<client hex>_<secret>`).
|
|
3
|
+
//
|
|
4
|
+
// The key never goes to Store or Space. `IdClient` trades it at Aduki ID
|
|
5
|
+
// (`POST /v1/tokens {key, audience}`) for a ten-minute token per audience,
|
|
6
|
+
// always with a DPoP proof, so the token is bound to a key pair the program
|
|
7
|
+
// holds (`cnf.jkt`): Store and Space accept bound tokens only. The key pair is
|
|
8
|
+
// a non-extractable ES256 (P-256) WebCrypto key, generated in memory by
|
|
9
|
+
// default (no IndexedDB, no browser API: Node 20+, Deno, Bun, workers), or
|
|
10
|
+
// loaded from a `KeyStore` the caller passes, or a `Dpop` the caller made.
|
|
11
|
+
//
|
|
12
|
+
// Tokens are cached per audience and renewed before they end; a `DPoP-Nonce`
|
|
13
|
+
// challenge (`401 auth.nonce`) is answered once; a 401 from Store or Space
|
|
14
|
+
// renews the token once (the core `createClient`). Every refusal is the typed
|
|
15
|
+
// error of the core `refusal`: `ClientRefusedError` for a route that is not
|
|
16
|
+
// for programs (`403 access.denied`, reason `client`).
|
|
17
|
+
import { claims } from '../base64.js';
|
|
18
|
+
import { createClient } from '../client.js';
|
|
19
|
+
import { Dpop } from '../dpop.js';
|
|
20
|
+
import { failure, parse, rejection } from '../envelope.js';
|
|
21
|
+
import { ProtocolError } from '../errors.js';
|
|
22
|
+
import { send } from '../http.js';
|
|
23
|
+
import { SpaceApi } from './space.js';
|
|
24
|
+
import { StoreApi } from './store.js';
|
|
25
|
+
/** Aduki ID (the token issuer). */
|
|
26
|
+
export const ID = 'https://id.aduki.pro';
|
|
27
|
+
const SHAPE = /^adk_[0-9a-f]+_\S+$/;
|
|
28
|
+
/**
|
|
29
|
+
* A workspace's program on Store and Space. `store` is the workspace drive,
|
|
30
|
+
* `space` the non-strict, non-money routes of Aduki Space; `token` and
|
|
31
|
+
* `fetch` reach any other audience the client may hold.
|
|
32
|
+
*/
|
|
33
|
+
export class IdClient {
|
|
34
|
+
/** the workspace drive on Aduki Store (`store.read`, `store.write`) */
|
|
35
|
+
store;
|
|
36
|
+
/** Aduki Space's routes that are neither strict nor money (`space.use` and the route's bit) */
|
|
37
|
+
space;
|
|
38
|
+
/** Aduki ID's origin */
|
|
39
|
+
base;
|
|
40
|
+
#key;
|
|
41
|
+
#transport;
|
|
42
|
+
#clock;
|
|
43
|
+
#renew;
|
|
44
|
+
#storage;
|
|
45
|
+
#dpop;
|
|
46
|
+
#client;
|
|
47
|
+
#nonce = null;
|
|
48
|
+
#held = new Map();
|
|
49
|
+
#pending = new Map();
|
|
50
|
+
constructor(options) {
|
|
51
|
+
const key = options?.key?.trim();
|
|
52
|
+
if (!key)
|
|
53
|
+
throw new TypeError('IdClient needs a key');
|
|
54
|
+
if (!SHAPE.test(key)) {
|
|
55
|
+
throw new TypeError(/^adk_[0-9a-f]{64}$/.test(key)
|
|
56
|
+
? 'This is an Aduki Mail API key: use Api for Mail; IdClient takes an Aduki ID service-client key (adk_<hex>_<secret>)'
|
|
57
|
+
: 'An Aduki ID service-client key is adk_<hex>_<secret>');
|
|
58
|
+
}
|
|
59
|
+
this.#key = key;
|
|
60
|
+
this.base = (options.base ?? ID).replace(/\/+$/, '');
|
|
61
|
+
this.#transport = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
62
|
+
this.#clock = options.clock ?? Date.now;
|
|
63
|
+
const renew = options.renew ?? 60;
|
|
64
|
+
if (!(Number.isFinite(renew) && renew >= 0))
|
|
65
|
+
throw new TypeError('renew is a number of seconds, not negative');
|
|
66
|
+
this.#renew = renew * 1000;
|
|
67
|
+
this.#storage = options.storage;
|
|
68
|
+
if (options.dpop)
|
|
69
|
+
this.#dpop = Promise.resolve(options.dpop);
|
|
70
|
+
this.store = new StoreApi(this, options.store);
|
|
71
|
+
this.space = new SpaceApi(this, options.space);
|
|
72
|
+
}
|
|
73
|
+
/** The DPoP key the tokens are bound to (made on first use). */
|
|
74
|
+
key() {
|
|
75
|
+
this.#dpop ??= (this.#storage ? Dpop.load(this.#storage, { clock: this.#clock }) : Dpop.generate({ clock: this.#clock })).catch((error) => {
|
|
76
|
+
this.#dpop = undefined;
|
|
77
|
+
throw error;
|
|
78
|
+
});
|
|
79
|
+
return this.#dpop;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A valid token for `audience` (`store`, `space`, ...): the cached one, or
|
|
83
|
+
* a new one from Aduki ID when there is none or it ends within `renew`
|
|
84
|
+
* seconds. Concurrent callers share one exchange. A refused exchange
|
|
85
|
+
* throws the typed error (`UnauthorizedError` for a bad, revoked or expired
|
|
86
|
+
* key; `NetworkError 403 access.denied` when the client's grants hold no
|
|
87
|
+
* right to the audience; `403 app.disabled` when the app is off).
|
|
88
|
+
*/
|
|
89
|
+
async token(audience) {
|
|
90
|
+
const held = this.#held.get(audience);
|
|
91
|
+
if (held && this.#clock() < held.renew)
|
|
92
|
+
return held.token;
|
|
93
|
+
return (await this.#fresh(audience)).token;
|
|
94
|
+
}
|
|
95
|
+
/** Renews the token of `audience` unless it was already replaced since `stale` (`TokenSource`). */
|
|
96
|
+
async rotate(audience, stale) {
|
|
97
|
+
const held = this.#held.get(audience);
|
|
98
|
+
if (held && held.token !== stale && this.#clock() < held.expires)
|
|
99
|
+
return { token: held.token };
|
|
100
|
+
if (held && held.token === stale)
|
|
101
|
+
this.#held.delete(audience);
|
|
102
|
+
return { token: (await this.#fresh(audience)).token };
|
|
103
|
+
}
|
|
104
|
+
/** Drops the cached token of `audience`, or every token. */
|
|
105
|
+
forget(audience) {
|
|
106
|
+
if (audience === undefined)
|
|
107
|
+
this.#held.clear();
|
|
108
|
+
else
|
|
109
|
+
this.#held.delete(audience);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Sends a request to a service of `audience` with its token
|
|
113
|
+
* (`Authorization: DPoP`) and a proof; a 401 renews the token once and
|
|
114
|
+
* sends again. Returns the raw response; a transport failure is a
|
|
115
|
+
* NetworkError. The body must be re-sendable (not a stream).
|
|
116
|
+
*/
|
|
117
|
+
async fetch(audience, input, init) {
|
|
118
|
+
if (!this.#client) {
|
|
119
|
+
const dpop = await this.key();
|
|
120
|
+
const transport = this.#transport;
|
|
121
|
+
this.#client ??= createClient({ tokens: this, fetch: (i, n) => send(transport, i, n), dpop });
|
|
122
|
+
}
|
|
123
|
+
return this.#client.fetch(audience, input, init);
|
|
124
|
+
}
|
|
125
|
+
#fresh(audience) {
|
|
126
|
+
let pending = this.#pending.get(audience);
|
|
127
|
+
if (!pending) {
|
|
128
|
+
pending = this.#exchange(audience).then((held) => {
|
|
129
|
+
this.#held.set(audience, held);
|
|
130
|
+
this.#pending.delete(audience);
|
|
131
|
+
return held;
|
|
132
|
+
}, (error) => {
|
|
133
|
+
this.#pending.delete(audience);
|
|
134
|
+
throw error;
|
|
135
|
+
});
|
|
136
|
+
this.#pending.set(audience, pending);
|
|
137
|
+
}
|
|
138
|
+
return pending;
|
|
139
|
+
}
|
|
140
|
+
/** `POST /v1/tokens {key, audience}` with a proof; once more with the server nonce on `401 auth.nonce`. */
|
|
141
|
+
async #exchange(audience) {
|
|
142
|
+
if (!audience || audience === 'id')
|
|
143
|
+
throw new TypeError('An audience other than id is needed (store, space, ...)');
|
|
144
|
+
const dpop = await this.key();
|
|
145
|
+
const target = `${this.base}/v1/tokens`;
|
|
146
|
+
const what = `POST /v1/tokens (${audience})`;
|
|
147
|
+
const body = JSON.stringify({ key: this.#key, audience });
|
|
148
|
+
let nonce = this.#nonce;
|
|
149
|
+
for (let attempt = 0;; attempt++) {
|
|
150
|
+
const headers = {
|
|
151
|
+
'Content-Type': 'application/json',
|
|
152
|
+
Accept: 'application/json',
|
|
153
|
+
DPoP: await dpop.proof({ method: 'POST', url: target, nonce }),
|
|
154
|
+
};
|
|
155
|
+
const response = await send(this.#transport, target, { method: 'POST', headers, body });
|
|
156
|
+
const fresh = response.headers.get('DPoP-Nonce');
|
|
157
|
+
if (fresh)
|
|
158
|
+
this.#nonce = fresh;
|
|
159
|
+
const text = await response.text();
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
const refused = failure(text);
|
|
162
|
+
const offered = fresh ?? refused?.details?.nonce;
|
|
163
|
+
if (attempt === 0 && response.status === 401 && refused?.kind === 'auth.nonce' && typeof offered === 'string' && offered) {
|
|
164
|
+
nonce = offered;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
throw rejection(response, text, what);
|
|
168
|
+
}
|
|
169
|
+
const data = parse(text)?.data;
|
|
170
|
+
const access = data?.access;
|
|
171
|
+
if (typeof access !== 'string' || !access)
|
|
172
|
+
throw new ProtocolError(`${what}: the answer has no token`);
|
|
173
|
+
if (!dpop.bound(access)) {
|
|
174
|
+
throw new ProtocolError(`${what}: the token is not bound to the DPoP key (an Aduki ID without machine access)`);
|
|
175
|
+
}
|
|
176
|
+
const now = this.#clock();
|
|
177
|
+
const exp = Number(claims(access).exp);
|
|
178
|
+
const lifetime = Number(data.expires);
|
|
179
|
+
const expires = Number.isFinite(exp) && exp > 0 ? exp * 1000 : now + (Number.isFinite(lifetime) && lifetime > 0 ? lifetime : 600) * 1000;
|
|
180
|
+
const margin = Math.min(this.#renew, Math.max(0, (expires - now) / 2));
|
|
181
|
+
return { token: access, expires, renew: expires - margin };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { Api, CalendarApi, ContactsApi, Keys, MailApi, Mailboxes, ROOT, User } from './api.js';
|
|
2
|
+
export type { ApiOptions, CallOptions, Folder, Query } from './api.js';
|
|
3
|
+
export { Tenant, TenantAliases, TenantAudits, TenantDkim, TenantDomains, TenantHolidays, TenantMembers, TenantQuotas, TenantRules, TenantSchedule, TenantUsage, TenantWebhooks, } from './tenant.js';
|
|
4
|
+
export { ID, IdClient } from './id.js';
|
|
5
|
+
export type { IdClientOptions } from './id.js';
|
|
6
|
+
export { FILES, STORE, StoreApi } from './store.js';
|
|
7
|
+
export type { AudienceFetch, FileChange, FolderChange, StoreApiOptions, StoreDownload, StoreUploaded, StoreUploadOptions, UploadProgress, VersionAttrs } from './store.js';
|
|
8
|
+
export { SPACE, SpaceApi } from './space.js';
|
|
9
|
+
export type { SpaceActivityFilters, SpaceApiOptions, SpaceCall, SpacePage, SpaceQuery, SpaceTaskFilters, SpaceTaskInput, SpaceTicketFilters, SpaceTicketInput } from './space.js';
|
|
10
|
+
export { Dpop } from '../dpop.js';
|
|
11
|
+
export type { KeyStore, Locks, PublicJwk } from '../dpop.js';
|
|
12
|
+
export { manifest } from '../store/chunks.js';
|
|
13
|
+
export type { Chunk, Content, Manifest } from '../store/chunks.js';
|
|
14
|
+
export type { Begun, Derivative, FileEntry, Folder as StoreFolder, Listing, Quota as StoreQuota, Version, VersionStatus } from '../store/model.js';
|
|
15
|
+
export type { Activity, Checkin, Found, Member, Message, Page as SpaceList, Reply, Settings, SupportTicket, Task, Team } from '../space/model.js';
|
|
16
|
+
export { createKey, hashKey } from './key.js';
|
|
17
|
+
export type { NewKey } from './key.js';
|
|
18
|
+
export type * from './model.js';
|
|
19
|
+
export { AdukiError, ClientRefusedError, LimitError, NetworkError, ProofError, ProtocolError, RateLimitedError, ServerError, StepUpError, SyncResetError, UnauthorizedError, refusal, retryAfter, } from '../errors.js';
|
|
20
|
+
export type { NetworkErrorOptions, Refused } from '../errors.js';
|
|
21
|
+
export { kinds } from '../envelope.js';
|
|
22
|
+
export { CORE, Jmap, JmapError, MAIL, SUBMISSION, USING } from '../jmap.js';
|
|
23
|
+
export type { Invocation, JmapSession, Uploaded } from '../jmap.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// sdk/api (published on npm as `@adukilabs/sdk`): the clients for programs.
|
|
2
|
+
// - `Api`: an Aduki Mail API key (`Authorization: Key <key>`) on Aduki Mail's
|
|
3
|
+
// REST API and JMAP.
|
|
4
|
+
// - `IdClient`: an Aduki ID service-client key, traded for DPoP-bound tokens,
|
|
5
|
+
// on the workspace drive of Aduki Store and on Aduki Space (machine access).
|
|
6
|
+
// Only the core they need comes with them (errors, envelope, http, jmap,
|
|
7
|
+
// base64, the DPoP key and the authenticated fetch, Store's chunker): no
|
|
8
|
+
// sign-in, session or browser storage.
|
|
9
|
+
export { Api, CalendarApi, ContactsApi, Keys, MailApi, Mailboxes, ROOT, User } from './api.js';
|
|
10
|
+
export { Tenant, TenantAliases, TenantAudits, TenantDkim, TenantDomains, TenantHolidays, TenantMembers, TenantQuotas, TenantRules, TenantSchedule, TenantUsage, TenantWebhooks, } from './tenant.js';
|
|
11
|
+
export { ID, IdClient } from './id.js';
|
|
12
|
+
export { FILES, STORE, StoreApi } from './store.js';
|
|
13
|
+
export { SPACE, SpaceApi } from './space.js';
|
|
14
|
+
export { Dpop } from '../dpop.js';
|
|
15
|
+
export { manifest } from '../store/chunks.js';
|
|
16
|
+
export { createKey, hashKey } from './key.js';
|
|
17
|
+
export { AdukiError, ClientRefusedError, LimitError, NetworkError, ProofError, ProtocolError, RateLimitedError, ServerError, StepUpError, SyncResetError, UnauthorizedError, refusal, retryAfter, } from '../errors.js';
|
|
18
|
+
export { kinds } from '../envelope.js';
|
|
19
|
+
export { CORE, Jmap, JmapError, MAIL, SUBMISSION, USING } from '../jmap.js';
|