@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.
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # @adukilabs/sdk
2
2
 
3
- The Aduki API client for programs that use an API key: servers, scripts, scheduled jobs and workers. It calls Aduki Mail's REST API and JMAP with `Authorization: Key <key>`. There is no sign-in, no browser storage and no DPoP. It is written in TypeScript and ships as ES2022 modules with declarations. It uses web platform APIs only (`fetch`, WebCrypto) and has no dependencies. It runs on Node 20+, Deno, Bun and in workers. Apache 2.0.
3
+ The Aduki API clients for programs: servers, scripts, scheduled jobs and workers. There are two entry points:
4
+ - `Api` calls Aduki Mail's REST API and JMAP with a Mail API key (`Authorization: Key <key>`).
5
+ - `IdClient` reaches the workspace drive on Aduki Store and Aduki Space with an Aduki ID service-client key. It trades the key for DPoP-bound tokens ([Machine access](#machine-access-store-and-space-with-a-service-client-key)).
6
+
7
+ There is no sign-in and no browser storage. It is written in TypeScript and ships as ES2022 modules with declarations. It uses web platform APIs only (`fetch`, WebCrypto) and has no dependencies. It runs on Node 20+, Deno, Bun and in workers. Apache 2.0.
4
8
 
5
9
  ```sh
6
10
  npm install @adukilabs/sdk
@@ -48,6 +52,7 @@ Every call is on `https://mail.aduki.pro` (option `root` changes it). The scope
48
52
  | `api.contacts` | `get(hex)`, `list`, `search`, `groups`, `group`, `sync` [`contacts:read`]; `delete` [`contacts:write`] |
49
53
  | `api.calendar` | `calendar(hex)`, `event(hex)`, `calendars`, `events`, `range`, `upcoming`, `search` [`calendars:read`]; `deleteEvent` [`calendars:write`] |
50
54
  | `api.keys` | the workspace's keys: `list` (all, `active`, `expired`), `get` [`tenant.keys:read`]; `rename`, `rotate` (a new secret, returned once), `delete` [`tenant.keys:write`] |
55
+ | `api.tenant` | the workspace: `get`, `bySlug` [`tenants:read`], `security` [`security:read`], `services` [`mailservices:read`]; see [Workspace administration](#workspace-administration) |
51
56
  | `api.jmap` | JMAP (RFC 8620, 8621) on the same key: `session`, `call`, `method`, `upload`, `download`. Signing in needs `mail:read`; `EmailSubmission/set` needs `mail:send` |
52
57
  | `api.call` | any other `user` or `tenant` route under `/v1`: `api.call('GET', 'tenant/domains')` |
53
58
 
@@ -55,9 +60,116 @@ Lists take `{ page, limit }` (pages from 1) or `{ after, limit }` (a cursor), wi
55
60
 
56
61
  Making a key and changing a key's scopes are only for a signed-in owner; a key gets `403` for both. `createKey()` makes a random key with its SHA-256 hash and prefix, as the server stores them.
57
62
 
63
+ ## Workspace administration
64
+
65
+ `api.tenant` covers the workspace routes of Aduki Mail (`/v1/tenant/*`). A key reaches them only with `tenant` scopes (`tenant.domains:read`, `tenant.**`, ...), which only a workspace owner can give it. The scope each call needs is in brackets.
66
+
67
+ | | Calls |
68
+ |---|---|
69
+ | `api.tenant.domains` | `add({name, kind?, selector?, meta?})` returns the hex; `list` (all, `active`, `pending`), `get(hex)` (the record with its DNS records: `dkim`, the SPF, DMARC, MTA-STS and BIMI snapshots), `byName` [`domains:read`]; `rename`, `kind`, `selector`, `auth`, `meta`, `delete` [`domains:write`] |
70
+ | `api.tenant.domains.dkim` | a DKIM key change with no gap: `stage` (publish `pending.value` as TXT at `pending.name`), `check` [`domains:read`], `confirm`, `cancel` [`domains:write`]; `state` [`domains:read`] |
71
+ | `api.tenant.aliases` | extra addresses of a member's mailbox on a verified domain: `list({user?})` [`aliases:read`]; `add(user, address)`, `remove(address)` [`aliases:write`] |
72
+ | `api.tenant.members` | `list()` [`tenants:read`]; `list({state: 'active' \| 'owners'})`, `search` [`members:read`]; `remove(user)` [`tenants:write`] |
73
+ | `api.tenant.rules` | spam rules: `list` (all, `active`, by `target`), `get`, `byName` [`rules:read`]; `add`, `edit` (one call per field), `delete` [`rules:write`] |
74
+ | `api.tenant.webhooks` | delivery events: `list` (all, `active`, by `event`), `get` [`webhooks:read`]; `add` (returns the secret once), `edit`, `rotate` (a new secret, returned once), `delete` [`webhooks:write`] |
75
+ | `api.tenant.quotas` | `list`, `get(metric)` [`quotas:read`]; `add`, `edit`, `delete` [`quotas:write`] |
76
+ | `api.tenant.audits` | `list({user?, action? \| outcome? \| ip?})` [`audits:read`]; `get(hex)` [`tenants:read`] |
77
+ | `api.tenant.usage` | `list`, `summary`, `metric` [`usage:read`] |
78
+ | `api.tenant.holidays` | `list({region?, year?})`, `region` [`tenant:read`]; `add` (a closure, or `{exclude: true, region, date}`), `remove`, `setRegion` (null clears it) [`tenants:write`] |
79
+ | `api.tenant.schedule` | booking templates and resources (rooms, equipment): `templates`, `template`, `resources`, `resource` [`tenant:read`]; `addTemplate`, `editTemplate`, `setRules`, `deleteTemplate`, `addResource`, `editResource`, `deleteResource` [`tenants:write`] |
80
+ | `api.keys` | the workspace's API keys (see above) |
81
+
82
+ ```js
83
+ const hex = await api.tenant.domains.add({ name: 'acme.example' }); // domains:write
84
+ const domain = await api.tenant.domains.get(hex); // domains:read
85
+ const staged = await api.tenant.domains.dkim.stage(hex); // domains:write
86
+ // publish staged.pending.value as a TXT record at staged.pending.name, then:
87
+ if ((await api.tenant.domains.dkim.check(hex)).ready) await api.tenant.domains.dkim.confirm(hex);
88
+ ```
89
+
90
+ What a key cannot do:
91
+ - Invite people, change roles or grants, add or remove service clients, or change the workspace's policy. Those are Aduki ID's, and Aduki ID's own routes take no key and no service-client token: they need a signed-in person.
92
+ - Make a key or change a key's scopes (`403`).
93
+ - Reach Store or Space: they take only DPoP-bound Aduki ID tokens. Use `IdClient` with a service-client key (below).
94
+
95
+ ## Machine access: Store and Space with a service-client key
96
+
97
+ `IdClient` is the second entry point. It is for a workspace's programs on Aduki Store (the workspace drive) and Aduki Space. It takes an Aduki ID **service-client key**, `adk_<client hex>_<secret>`. That is a different key from a Mail API key (`adk_<64 hex>`): a workspace owner makes the service client and its key in Aduki ID and grants the client its permissions. `Api` and `IdClient` are separate because their keys, services and rules differ. A program that needs both makes both.
98
+
99
+ ```js
100
+ import { IdClient } from '@adukilabs/sdk';
101
+
102
+ const id = new IdClient({ key: process.env.ADUKI_CLIENT_KEY });
103
+
104
+ const quota = await id.store.quota(); // store.read
105
+ const root = await id.store.list(); // the workspace drive root
106
+ const up = await id.store.upload(bytes, { name: 'report.pdf', folder: root.folders[0]?.id }); // store.write
107
+ const tasks = await id.space.tasks({ status: 'open' }); // space.use + tasks.read
108
+ await id.space.createTask({ title: 'Nightly import' }); // tasks.write
109
+ ```
110
+
111
+ How it works:
112
+ - The key never goes to Store or Space. `IdClient` trades it at Aduki ID (`POST https://id.aduki.pro/v1/tokens {key, audience}`) for a 10-minute token per audience (`store`, `space`).
113
+ - Every exchange carries a DPoP proof, so the token is bound to a key pair the program holds (`cnf.jkt`). Store and Space accept only bound tokens. A token that comes back unbound (an Aduki ID without machine access) is a `ProtocolError`.
114
+ - The key pair is a non-extractable ES256 (P-256) WebCrypto key, made in memory on first use. It needs no IndexedDB and no browser API, so it runs on Node 20+, Deno, Bun and in workers.
115
+ - Option `storage` keeps the key pair in any async store that holds a `CryptoKeyPair` as is (key `dpop.key`).
116
+ - Option `dpop` takes a `Dpop` you made yourself.
117
+ - A new key pair costs nothing: a restarted program simply gets new tokens.
118
+ - Tokens are cached per audience and renewed before they end: `renew` seconds before (default 60), at most half their lifetime. Callers that ask at the same time share one exchange.
119
+ - If Aduki ID asks for a nonce (`401 auth.nonce`), the exchange is sent once more with it.
120
+ - A `401` from Store or Space renews the token once and sends the request again.
121
+ - Options: `base` (Aduki ID's origin, exactly its issuer), `fetch`, `clock`, `store: {root, files}`, `space: {root}`. `id.token(audience)` and `id.fetch(audience, url, init)` reach any other audience the client may hold.
122
+
123
+ What Aduki ID requires:
124
+ - A `store` token needs `store.read` or `store.write` in the client's grants.
125
+ - A `space` token needs `space.use`, Space switched on, and the workspace in good standing.
126
+ - Otherwise the exchange is refused: `403 access.denied`, or `403 app.disabled` when the app is off. A bad, revoked or expired key is `UnauthorizedError` (`auth.invalid`).
127
+ - Strict permissions never reach a service client.
128
+
129
+ ### `id.store`: the workspace drive
130
+
131
+ A program reaches the workspace drive only, outside the Space folder. It never has a drive of its own and never reaches a member's or a personal drive (`404`). What it may do depends on its permissions: `store.read` reads, and `store.write` also creates, renames, moves, trashes, restores and uploads. `store.share` and `store.admin` give it nothing more.
132
+
133
+ | | Calls |
134
+ |---|---|
135
+ | quota | `quota()` (`GET /v1/quota`) |
136
+ | folders and files | `list({parent?, trashed?})`, `folder(id)`, `file(id)`; `createFolder(name, {parent?})`, `createFile(name, {folder?, mime?})`, `updateFolder`, `updateFile` (rename, move, trash, restore), `trashFolder`, `trashFile`, `restoreFolder`, `restoreFile` |
137
+ | uploads | `upload(content, {name, folder?} \| {file})`. Hashes the content as Store does (FastCDC 2020 cut points, BLAKE3), begins a version, sends only the chunks the server lacks to `files.aduki.pro` and commits. Run it again with the same `key` to resume. Options: `progress`, `attrs`, `signal`, a precomputed `manifest`. Each step is also a call of its own: `begin`, `putChunk`, `commit`, `abort` |
138
+ | versions | `versions(file)`, `version(id)` (the chunks still missing) |
139
+ | downloads | `content(file, {version?, range?, etag?})` (a Response: `206` for a range, `304` for an unchanged `etag`), `download` (a Blob) |
140
+ | renditions | `media(version)` (thumbnails, posters, previews), `derivative(id, {range?})` |
141
+ | other routes | `call(method, path, {query?, body?})` |
142
+
143
+ What Store refuses to programs (`ClientRefusedError`, `403 access.denied` with reason `client`), so `id.store` has no call for it: shares and public links, devices, reports, search, read tickets, and the change feed.
144
+
145
+ ### `id.space`: Space routes that are neither strict nor money
146
+
147
+ A program may call every Space route that is neither strict nor a money route, within the bits its grant gives (`space.use` plus the route's bit, for example `tasks.write` or `tickets.write`). It sees the rows a person with those bits and no team would see.
148
+
149
+ | | Calls |
150
+ |---|---|
151
+ | any route | `call(method, path, {query, body, key, version})` returns `{data, status, etag}`; `list(path, query, {limit, after})`; `all(path, query)` goes through every page |
152
+ | tasks | `tasks(filters)`, `task(id)`, `createTask` (a new `Idempotency-Key` each time unless you pass `key`), `updateTask(id, changes, {version})` (`If-Match`) |
153
+ | check-ins | `checkins(task)`, `checkin(task, {body, minutes, proof?})` |
154
+ | tickets | `tickets(filters)`, `ticket(id)`, `createTicket`, `updateTicket`, `messages(ticket)`, `reply(ticket, body, {internal?})` |
155
+ | the workspace | `teams()`, `directory()`, `activity(filters)` (what a program did has `kind: "client"`), `search(q, {entity?, limit?})`, `settings()` (read only) |
156
+
157
+ What Space refuses to programs (`ClientRefusedError`): every strict route and every money route. These are refunds, charges, payouts, rails and gateways, staff management and payroll, node management, settings changes, hooks, endpoints and exports. A program has no shifts, payslips or leave of its own.
158
+
159
+ ### Errors of `IdClient`
160
+
161
+ | Answer | Error | What it means |
162
+ |---|---|---|
163
+ | `401` at Aduki ID | `UnauthorizedError` (`auth.invalid`) | the key is unknown, revoked, expired or used outside its networks |
164
+ | `401 auth.dpop`, `auth.nonce` | `ProofError` | the proof was refused (a clock or key problem) |
165
+ | `403 access.denied`, reason `client` | `ClientRefusedError` | the route is for people only; another token does not help |
166
+ | `403 access.denied` | `NetworkError` | the client's grants lack the permission |
167
+ | `403 app.disabled` | `NetworkError` | the app is off, or the workspace is not in good standing (Space: read-only) |
168
+ | `429` | `RateLimitedError` | `retryAfter` holds the seconds to wait |
169
+
58
170
  ## Errors
59
171
 
60
- A refused call throws a typed error:
172
+ A refused call of `Api` throws a typed error:
61
173
 
62
174
  | Answer | Error | What it means |
63
175
  |---|---|---|
package/dist/api/api.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Jmap } from '../jmap.js';
2
+ import { Tenant } from './tenant.js';
2
3
  import type * as model from './model.js';
3
4
  import type { Page, PageQuery } from './model.js';
4
5
  /** The mail host (REST under `/v1`, JMAP at the root). */
@@ -39,6 +40,8 @@ export declare class Api {
39
40
  readonly contacts: ContactsApi;
40
41
  readonly calendar: CalendarApi;
41
42
  readonly keys: Keys;
43
+ /** the workspace: domains, aliases, members, rules, webhooks, quotas, audits, usage, holidays, scheduling (`tenant.*` scopes) */
44
+ readonly tenant: Tenant;
42
45
  readonly random: () => string;
43
46
  constructor(options: ApiOptions);
44
47
  /**
package/dist/api/api.js CHANGED
@@ -4,14 +4,15 @@
4
4
  // (narrowed to what the workspace owner who made it may do) are its rights.
5
5
  //
6
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; never
8
- // `platform`) and its JMAP endpoints. Aduki ID, Store and Space take only
9
- // Aduki ID tokens.
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
10
  import { rejection } from '../envelope.js';
11
11
  import { ProtocolError } from '../errors.js';
12
12
  import { json, send, url } from '../http.js';
13
13
  import { Jmap } from '../jmap.js';
14
14
  import { createKey } from './key.js';
15
+ import { Tenant } from './tenant.js';
15
16
  /** The mail host (REST under `/v1`, JMAP at the root). */
16
17
  export const ROOT = 'https://mail.aduki.pro';
17
18
  export class Api {
@@ -23,6 +24,8 @@ export class Api {
23
24
  contacts;
24
25
  calendar;
25
26
  keys;
27
+ /** the workspace: domains, aliases, members, rules, webhooks, quotas, audits, usage, holidays, scheduling (`tenant.*` scopes) */
28
+ tenant;
26
29
  #key;
27
30
  #root;
28
31
  #fetch;
@@ -46,6 +49,7 @@ export class Api {
46
49
  this.contacts = new ContactsApi(this);
47
50
  this.calendar = new CalendarApi(this);
48
51
  this.keys = new Keys(this);
52
+ this.tenant = new Tenant(this);
49
53
  }
50
54
  /** The request with the key added (an absolute URL, or a path on the mail host). */
51
55
  #send(input, init) {
@@ -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
+ }
@@ -1,9 +1,22 @@
1
1
  export { Api, CalendarApi, ContactsApi, Keys, MailApi, Mailboxes, ROOT, User } from './api.js';
2
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';
3
16
  export { createKey, hashKey } from './key.js';
4
17
  export type { NewKey } from './key.js';
5
18
  export type * from './model.js';
6
- export { AdukiError, LimitError, NetworkError, ProtocolError, RateLimitedError, ServerError, StepUpError, SyncResetError, UnauthorizedError, refusal, retryAfter, } from '../errors.js';
19
+ export { AdukiError, ClientRefusedError, LimitError, NetworkError, ProofError, ProtocolError, RateLimitedError, ServerError, StepUpError, SyncResetError, UnauthorizedError, refusal, retryAfter, } from '../errors.js';
7
20
  export type { NetworkErrorOptions, Refused } from '../errors.js';
8
21
  export { kinds } from '../envelope.js';
9
22
  export { CORE, Jmap, JmapError, MAIL, SUBMISSION, USING } from '../jmap.js';
package/dist/api/index.js CHANGED
@@ -1,9 +1,19 @@
1
- // sdk/api (published on npm as `@adukilabs/sdk`): the client for programs
2
- // that use an Aduki API key (`Authorization: Key <key>`) on Aduki Mail's
3
- // REST API and JMAP. Only the core it needs (errors, envelope, http, jmap)
4
- // comes with it: no sign-in, session, browser storage or DPoP.
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.
5
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';
6
16
  export { createKey, hashKey } from './key.js';
7
- export { AdukiError, LimitError, NetworkError, ProtocolError, RateLimitedError, ServerError, StepUpError, SyncResetError, UnauthorizedError, refusal, retryAfter, } from '../errors.js';
17
+ export { AdukiError, ClientRefusedError, LimitError, NetworkError, ProofError, ProtocolError, RateLimitedError, ServerError, StepUpError, SyncResetError, UnauthorizedError, refusal, retryAfter, } from '../errors.js';
8
18
  export { kinds } from '../envelope.js';
9
19
  export { CORE, Jmap, JmapError, MAIL, SUBMISSION, USING } from '../jmap.js';