@firetail/finchly 0.0.8 → 0.0.10
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/CHANGELOG.md +24 -0
- package/README.md +140 -3
- package/dist/blobs.d.ts +71 -0
- package/dist/blobs.js +134 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/queue.d.ts +70 -0
- package/dist/queue.js +95 -0
- package/dist/secrets.d.ts +39 -0
- package/dist/secrets.js +97 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.0.10 - 2026-11-12
|
|
8
|
+
|
|
9
|
+
- `createBlobs({ service })`: objects kept with the platform for each
|
|
10
|
+
tenant. `blobs.forTenant(caller.tenantId)` gives `get`, `put`,
|
|
11
|
+
`putIfAbsent`, `putIfMatch`, `delete` and `list`. The manifest must set
|
|
12
|
+
`permissions.blobs`.
|
|
13
|
+
- New error codes: `blobs_not_granted`, `blobs_unavailable`.
|
|
14
|
+
- **Needs a platform that serves objects.**
|
|
15
|
+
|
|
16
|
+
## 0.0.9 - 2026-11-12
|
|
17
|
+
|
|
18
|
+
- `createSecrets({ service })`: secrets kept with the platform for each
|
|
19
|
+
tenant. `secrets.forTenant(caller.tenantId)` gives `get`, `set`,
|
|
20
|
+
`delete` and `list`, and `encrypt` and `decrypt` under the tenant's key.
|
|
21
|
+
The manifest must set `permissions.secrets`.
|
|
22
|
+
- `createQueue({ service })`: the platform queue.
|
|
23
|
+
`queue.forTenant(caller.tenantId).publish(topic, bodies)` publishes for
|
|
24
|
+
a tenant; `queue.lease(topic)` hands out messages of every tenant, each
|
|
25
|
+
with `tenantId`, `ack()`, `retry()` and `deadLetter()`. The manifest
|
|
26
|
+
lists the topics in `permissions.queue`.
|
|
27
|
+
- New error codes: `secrets_not_granted`, `secrets_unavailable`,
|
|
28
|
+
`secrets_cannot_decrypt`, `queue_not_granted`, `queue_unavailable`.
|
|
29
|
+
- **Needs a platform that serves secrets and the queue.**
|
|
30
|
+
|
|
7
31
|
## 0.0.8 - 2026-11-12
|
|
8
32
|
|
|
9
33
|
- **Breaking:** the database is per tenant. `connectDatabase` no longer
|
package/README.md
CHANGED
|
@@ -234,6 +234,143 @@ To run a service on its own, without a platform, give the URL directly:
|
|
|
234
234
|
`connectDatabase({ url, slug })`. Each tenant's schema is then made the
|
|
235
235
|
first time it is used.
|
|
236
236
|
|
|
237
|
+
### Secrets
|
|
238
|
+
|
|
239
|
+
A service whose manifest sets `permissions.secrets` keeps secrets with the
|
|
240
|
+
platform, separately for each tenant:
|
|
241
|
+
|
|
242
|
+
```js
|
|
243
|
+
import { createSecrets } from "@firetail/finchly";
|
|
244
|
+
|
|
245
|
+
const secrets = createSecrets({ service });
|
|
246
|
+
|
|
247
|
+
// Per request: the secrets of the caller's tenant.
|
|
248
|
+
const mine = secrets.forTenant(caller.tenantId);
|
|
249
|
+
|
|
250
|
+
await mine.set("slack.token", token);
|
|
251
|
+
const value = await mine.get("slack.token"); // undefined if there is none
|
|
252
|
+
await mine.list(); // ["slack.token"]
|
|
253
|
+
await mine.delete("slack.token"); // true if there was one
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
- A name is 1 to 100 letters, digits, dots, dashes or underscores. A value
|
|
257
|
+
is text of at most 64 KiB. Do not log a value.
|
|
258
|
+
- A tenant's secrets are read with that tenant's id and no other. Take the
|
|
259
|
+
id from a verified identity, never from the request itself.
|
|
260
|
+
|
|
261
|
+
For values the service keeps in its own tables, the platform encrypts and
|
|
262
|
+
decrypts under the tenant's key:
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
const sealed = await mine.encrypt(cardNumber, `card:${id}`);
|
|
266
|
+
const opened = await mine.decrypt(sealed, `card:${id}`); // a Uint8Array
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The context says what the value is. The same context must be given to
|
|
270
|
+
decrypt, so a value copied to another row does not open there. What was
|
|
271
|
+
encrypted for another tenant, another service or another context, or has
|
|
272
|
+
been changed, rejects with `secrets_cannot_decrypt`.
|
|
273
|
+
|
|
274
|
+
A manifest that does not ask for secrets gets `secrets_not_granted`. Any
|
|
275
|
+
other refusal is `secrets_unavailable`.
|
|
276
|
+
|
|
277
|
+
### Objects
|
|
278
|
+
|
|
279
|
+
A service whose manifest sets `permissions.blobs` keeps objects with the
|
|
280
|
+
platform, separately for each tenant: receipts, exports, files. The service
|
|
281
|
+
holds no credentials for the object store; the platform does.
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
import { createBlobs } from "@firetail/finchly";
|
|
285
|
+
|
|
286
|
+
const blobs = createBlobs({ service });
|
|
287
|
+
|
|
288
|
+
// Per request: the objects of the caller's tenant.
|
|
289
|
+
const mine = blobs.forTenant(caller.tenantId);
|
|
290
|
+
|
|
291
|
+
await mine.put(`receipts/${id}.pdf`, bytes, { contentType: "application/pdf" });
|
|
292
|
+
const receipt = await mine.get(`receipts/${id}.pdf`); // undefined if there is none
|
|
293
|
+
receipt.data; // a Uint8Array
|
|
294
|
+
receipt.contentType; // "application/pdf"
|
|
295
|
+
|
|
296
|
+
const { objects, next } = await mine.list({ prefix: "receipts/", limit: 100 });
|
|
297
|
+
await mine.delete(`receipts/${id}.pdf`);
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
- A key is 1 to 512 letters, digits, dots, dashes and underscores, in
|
|
301
|
+
segments divided by `/`. Do not use a key that is also the start of
|
|
302
|
+
another followed by `/`, such as `a` and `a/b`.
|
|
303
|
+
- An object is text or bytes, of at most 16 MiB.
|
|
304
|
+
- A tenant's objects are reached with that tenant's id and no other. Take
|
|
305
|
+
the id from a verified identity, never from the request itself.
|
|
306
|
+
- A listing is in key order. When `next` is not undefined there are more:
|
|
307
|
+
ask again with `{ after: next }`.
|
|
308
|
+
|
|
309
|
+
Two writers do not lose each other's work if each writes on a condition:
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
// Only if there is none by that key. Undefined if there was.
|
|
313
|
+
const made = await mine.putIfAbsent("state.json", "{}", { contentType: "application/json" });
|
|
314
|
+
|
|
315
|
+
// Only if it has not changed since it was read. Undefined if it has:
|
|
316
|
+
// read again and decide again.
|
|
317
|
+
const state = await mine.get("state.json");
|
|
318
|
+
const next = change(JSON.parse(Buffer.from(state.data).toString()));
|
|
319
|
+
const written = await mine.putIfMatch("state.json", JSON.stringify(next), state.etag, { contentType: "application/json" });
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
State that needs transactions or queries belongs in the service's
|
|
323
|
+
database, not in an object.
|
|
324
|
+
|
|
325
|
+
A manifest that does not ask for objects gets `blobs_not_granted`. Any
|
|
326
|
+
other refusal, a deployment with no object store among them, is
|
|
327
|
+
`blobs_unavailable`.
|
|
328
|
+
|
|
329
|
+
### The queue
|
|
330
|
+
|
|
331
|
+
A message belongs to a tenant. The manifest lists the topics the service
|
|
332
|
+
publishes to and consumes, in `permissions.queue.publish` and
|
|
333
|
+
`permissions.queue.consume`.
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
import { createQueue } from "@firetail/finchly";
|
|
337
|
+
|
|
338
|
+
const queue = createQueue({ service });
|
|
339
|
+
|
|
340
|
+
// Per request: publish for the caller's tenant.
|
|
341
|
+
await queue.forTenant(caller.tenantId).publish("notifications", [{ to: caller.subject, text: "Done" }]);
|
|
342
|
+
|
|
343
|
+
// A worker: take messages of every tenant, and handle each in its own.
|
|
344
|
+
for (const message of await queue.lease("notifications", { max: 10, visibilitySeconds: 60 })) {
|
|
345
|
+
const db = database.forTenant(message.tenantId);
|
|
346
|
+
try {
|
|
347
|
+
await deliver(db, message.body);
|
|
348
|
+
await message.ack();
|
|
349
|
+
} catch {
|
|
350
|
+
await message.retry();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
- **Publishing** takes 1 to 100 bodies, each any JSON value of at most
|
|
356
|
+
256 KiB, and resolves with their ids. All are published or none.
|
|
357
|
+
`{ delaySeconds }` holds them back, up to 7 days.
|
|
358
|
+
- **Leasing** hands out messages for `visibilitySeconds` (default 60). A
|
|
359
|
+
message not answered for in that time is handed out again. An empty list
|
|
360
|
+
means there is nothing to do; ask again later. `{ tenantId }` takes one
|
|
361
|
+
tenant's messages only.
|
|
362
|
+
- **Each message says which tenant it is for.** Handle it in that tenant.
|
|
363
|
+
- **Answer for every message**: `ack()` when it is handled, `retry()` to
|
|
364
|
+
have it handed out again after a wait that doubles each time (or
|
|
365
|
+
`retry({ delaySeconds })`), `deadLetter(reason)` when it never will be
|
|
366
|
+
handled. Each resolves with `false` if the time ran out and the message
|
|
367
|
+
went to another consumer. A message handed out five times without being
|
|
368
|
+
handled is set aside.
|
|
369
|
+
- A message may be handed out more than once, so handling one twice must be
|
|
370
|
+
harmless.
|
|
371
|
+
- A topic the manifest does not list rejects with `queue_not_granted`. Any
|
|
372
|
+
other refusal is `queue_unavailable`.
|
|
373
|
+
|
|
237
374
|
### What every request carries
|
|
238
375
|
|
|
239
376
|
Every request this SDK makes to the platform says which SDK and which
|
|
@@ -273,9 +410,9 @@ not leak them.
|
|
|
273
410
|
| `app` | Register with the platform, serve `/_platform/*`, renew keys, fetch settings | Self-registration and key renewal are done, through `service`. Serving `/_platform/*` and fetching settings are not |
|
|
274
411
|
| `service` | Call other services through the gateway, as the service or for a user | Done, for built-in services and for apps |
|
|
275
412
|
| `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Done |
|
|
276
|
-
| `blobs` |
|
|
277
|
-
| `queue` | Publish, lease, ack,
|
|
278
|
-
| `secrets` | Secrets and
|
|
413
|
+
| `blobs` | Objects, with conditional writes | Done. Objects over 16 MiB are not |
|
|
414
|
+
| `queue` | Publish, lease, ack, retry, dead-letter | Done |
|
|
415
|
+
| `secrets` | Secrets, and encryption under the tenant's key | Done |
|
|
279
416
|
| `llm` | Model calls through the platform | Planned |
|
|
280
417
|
| `audit`, `usage`, `notify`, `schedule`, `connectors` | Clients for platform services | Planned |
|
|
281
418
|
| `ui` | Shared header, footer and theme | Planned |
|
package/dist/blobs.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ServiceClient } from "./service.js";
|
|
2
|
+
/** An object, as it was read. */
|
|
3
|
+
export interface StoredBlob {
|
|
4
|
+
readonly data: Uint8Array;
|
|
5
|
+
/** Changes whenever the content does. Hand it to `putIfMatch` as it came. */
|
|
6
|
+
readonly etag: string;
|
|
7
|
+
readonly contentType: string;
|
|
8
|
+
}
|
|
9
|
+
/** What a listing says of an object. */
|
|
10
|
+
export interface BlobInfo {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
/** In bytes. */
|
|
13
|
+
readonly size: number;
|
|
14
|
+
readonly etag: string;
|
|
15
|
+
readonly modifiedAt: Date;
|
|
16
|
+
}
|
|
17
|
+
export interface PutOptions {
|
|
18
|
+
/** Kept with the object. Default: text/plain for text, application/octet-stream for bytes. */
|
|
19
|
+
readonly contentType?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ListOptions {
|
|
22
|
+
/** The start of the keys to list. May end anywhere. Default: every key. */
|
|
23
|
+
readonly prefix?: string;
|
|
24
|
+
/** List the keys after this one: the `next` of the page before. */
|
|
25
|
+
readonly after?: string;
|
|
26
|
+
/** The most objects to list. 1 to 1000, default 100. */
|
|
27
|
+
readonly limit?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface BlobPage {
|
|
30
|
+
/** In key order. */
|
|
31
|
+
readonly objects: BlobInfo[];
|
|
32
|
+
/** Pass as `after` for the next page. Undefined when there are no more. */
|
|
33
|
+
readonly next: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** A service's objects in one tenant. */
|
|
36
|
+
export interface TenantBlobs {
|
|
37
|
+
readonly tenantId: string;
|
|
38
|
+
/** The object, or undefined if there is none by that key. */
|
|
39
|
+
get(key: string): Promise<StoredBlob | undefined>;
|
|
40
|
+
/** Write an object, replacing any there was. At most 16 MiB. Resolves with its etag. */
|
|
41
|
+
put(key: string, data: string | Uint8Array, options?: PutOptions): Promise<string>;
|
|
42
|
+
/**
|
|
43
|
+
* Write an object only if there is none by that key. Resolves with its
|
|
44
|
+
* etag, or undefined if there was one, which is left as it is.
|
|
45
|
+
*/
|
|
46
|
+
putIfAbsent(key: string, data: string | Uint8Array, options?: PutOptions): Promise<string | undefined>;
|
|
47
|
+
/**
|
|
48
|
+
* Write an object only if it still has the etag it was read with.
|
|
49
|
+
* Resolves with the new etag, or undefined if it has changed or gone:
|
|
50
|
+
* read it again and decide again. This is how two writers do not lose
|
|
51
|
+
* each other's work.
|
|
52
|
+
*/
|
|
53
|
+
putIfMatch(key: string, data: string | Uint8Array, etag: string, options?: PutOptions): Promise<string | undefined>;
|
|
54
|
+
/** Delete an object. Not an error if there is none. */
|
|
55
|
+
delete(key: string): Promise<void>;
|
|
56
|
+
/** The service's objects in this tenant, a page at a time. */
|
|
57
|
+
list(options?: ListOptions): Promise<BlobPage>;
|
|
58
|
+
}
|
|
59
|
+
export interface Blobs {
|
|
60
|
+
/**
|
|
61
|
+
* The objects of one tenant: `caller.tenantId` of the request being
|
|
62
|
+
* handled. Take the id from a verified identity, never from the request
|
|
63
|
+
* itself.
|
|
64
|
+
*/
|
|
65
|
+
forTenant(tenantId: string): TenantBlobs;
|
|
66
|
+
}
|
|
67
|
+
export interface BlobsOptions {
|
|
68
|
+
/** The service's client. Every call goes through it. */
|
|
69
|
+
readonly service: Pick<ServiceClient, "call">;
|
|
70
|
+
}
|
|
71
|
+
export declare function createBlobs(options: BlobsOptions): Blobs;
|
package/dist/blobs.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Objects a service keeps with the platform, for each tenant. See
|
|
2
|
+
// spec/blobs.md in the platform repository.
|
|
3
|
+
//
|
|
4
|
+
// The manifest must set permissions.blobs. Everything here is for one
|
|
5
|
+
// tenant: the handle that reads and writes is made with `forTenant`. The
|
|
6
|
+
// service holds no credentials for the object store; the platform does.
|
|
7
|
+
import { FinchlyError } from "./errors.js";
|
|
8
|
+
const TENANT_ID = /^t_[a-z2-7]{16}$/;
|
|
9
|
+
const KEY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
10
|
+
const PREFIX = /^[A-Za-z0-9._/-]*$/;
|
|
11
|
+
const MAX_KEY_LENGTH = 512;
|
|
12
|
+
/** The largest object the platform takes. */
|
|
13
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
const BASE = "/api/apps/self/blobs";
|
|
15
|
+
const unavailable = (message) => new FinchlyError("blobs_unavailable", message);
|
|
16
|
+
function isKey(key) {
|
|
17
|
+
return typeof key === "string" && key.length <= MAX_KEY_LENGTH && KEY.test(key) && !key.split("/").some((segment) => segment === "." || segment === "..");
|
|
18
|
+
}
|
|
19
|
+
function checkKey(key) {
|
|
20
|
+
if (!isKey(key)) {
|
|
21
|
+
throw unavailable('A key is 1 to 512 letters, digits, dots, dashes and underscores, in segments divided by "/"');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function failure(response, what) {
|
|
25
|
+
const answer = (await response.json().catch(() => null));
|
|
26
|
+
const error = typeof answer?.error === "string" ? answer.error : undefined;
|
|
27
|
+
if (error === "not_granted") {
|
|
28
|
+
return new FinchlyError("blobs_not_granted", "The platform keeps no objects for this service", [
|
|
29
|
+
"Set permissions.blobs to true in the manifest",
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
return new FinchlyError("blobs_unavailable", `The platform did not ${what}`, [
|
|
33
|
+
`it answered ${response.status}${error ? ` ${error}` : ""}${typeof answer?.detail === "string" ? `: ${answer.detail}` : ""}`,
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
export function createBlobs(options) {
|
|
37
|
+
const { service } = options;
|
|
38
|
+
return {
|
|
39
|
+
forTenant(tenantId) {
|
|
40
|
+
if (typeof tenantId !== "string" || !TENANT_ID.test(tenantId)) {
|
|
41
|
+
throw unavailable("forTenant needs a tenant id, as in caller.tenantId");
|
|
42
|
+
}
|
|
43
|
+
/** Write, with a condition or none. Undefined if the condition did not hold. */
|
|
44
|
+
async function write(key, data, condition, putOptions = {}) {
|
|
45
|
+
checkKey(key);
|
|
46
|
+
const text = typeof data === "string";
|
|
47
|
+
if (!text && !(data instanceof Uint8Array))
|
|
48
|
+
throw unavailable("An object is text or bytes");
|
|
49
|
+
const bytes = text ? Buffer.from(data, "utf8") : data;
|
|
50
|
+
if (bytes.byteLength > MAX_BYTES)
|
|
51
|
+
throw unavailable("An object is at most 16 MiB");
|
|
52
|
+
const contentType = putOptions.contentType ?? (text ? "text/plain; charset=utf-8" : "application/octet-stream");
|
|
53
|
+
if (typeof contentType !== "string" || !/^[\x20-\x7e]{1,200}$/.test(contentType))
|
|
54
|
+
throw unavailable("The content type is not one");
|
|
55
|
+
const response = await service.call("core", `${BASE}/${key}`, {
|
|
56
|
+
tenantId,
|
|
57
|
+
method: "PUT",
|
|
58
|
+
headers: { "content-type": contentType, ...condition },
|
|
59
|
+
body: bytes,
|
|
60
|
+
});
|
|
61
|
+
if (response.status === 412) {
|
|
62
|
+
await response.body?.cancel();
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (response.status !== 200)
|
|
66
|
+
throw await failure(response, "keep the object");
|
|
67
|
+
return (await response.json()).etag;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
tenantId,
|
|
71
|
+
async get(key) {
|
|
72
|
+
checkKey(key);
|
|
73
|
+
const response = await service.call("core", `${BASE}/${key}`, { tenantId });
|
|
74
|
+
if (response.status === 404) {
|
|
75
|
+
await response.body?.cancel();
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
if (response.status !== 200)
|
|
79
|
+
throw await failure(response, "give the object");
|
|
80
|
+
return {
|
|
81
|
+
data: new Uint8Array(await response.arrayBuffer()),
|
|
82
|
+
etag: response.headers.get("etag") ?? "",
|
|
83
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
async put(key, data, putOptions) {
|
|
87
|
+
// With no condition there is nothing not to hold.
|
|
88
|
+
return (await write(key, data, {}, putOptions));
|
|
89
|
+
},
|
|
90
|
+
putIfAbsent(key, data, putOptions) {
|
|
91
|
+
return write(key, data, { "if-none-match": "*" }, putOptions);
|
|
92
|
+
},
|
|
93
|
+
async putIfMatch(key, data, etag, putOptions) {
|
|
94
|
+
if (typeof etag !== "string" || etag === "" || etag.length > 200) {
|
|
95
|
+
throw unavailable("putIfMatch needs the etag the object was read with");
|
|
96
|
+
}
|
|
97
|
+
return write(key, data, { "if-match": etag }, putOptions);
|
|
98
|
+
},
|
|
99
|
+
async delete(key) {
|
|
100
|
+
checkKey(key);
|
|
101
|
+
const response = await service.call("core", `${BASE}/${key}`, { tenantId, method: "DELETE" });
|
|
102
|
+
if (response.status !== 204)
|
|
103
|
+
throw await failure(response, "delete the object");
|
|
104
|
+
},
|
|
105
|
+
async list(listOptions = {}) {
|
|
106
|
+
const { prefix, after, limit } = listOptions;
|
|
107
|
+
if (prefix !== undefined && (typeof prefix !== "string" || prefix.length > MAX_KEY_LENGTH || !PREFIX.test(prefix))) {
|
|
108
|
+
throw unavailable("A prefix is the start of a key");
|
|
109
|
+
}
|
|
110
|
+
if (after !== undefined)
|
|
111
|
+
checkKey(after);
|
|
112
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 1000)) {
|
|
113
|
+
throw unavailable("limit is 1 to 1000");
|
|
114
|
+
}
|
|
115
|
+
const query = new URLSearchParams();
|
|
116
|
+
if (prefix)
|
|
117
|
+
query.set("prefix", prefix);
|
|
118
|
+
if (after !== undefined)
|
|
119
|
+
query.set("after", after);
|
|
120
|
+
if (limit !== undefined)
|
|
121
|
+
query.set("limit", String(limit));
|
|
122
|
+
const response = await service.call("core", query.size > 0 ? `${BASE}?${query}` : BASE, { tenantId });
|
|
123
|
+
if (response.status !== 200)
|
|
124
|
+
throw await failure(response, "list the objects");
|
|
125
|
+
const page = (await response.json());
|
|
126
|
+
return {
|
|
127
|
+
objects: page.objects.map(({ key, size, etag, modifiedAt }) => ({ key, size, etag, modifiedAt: new Date(modifiedAt) })),
|
|
128
|
+
next: page.next ?? undefined,
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected" | "app_registration_refused" | "app_pending_approval" | "database_unavailable" | "database_not_granted" | "migration_invalid";
|
|
1
|
+
export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected" | "app_registration_refused" | "app_pending_approval" | "database_unavailable" | "database_not_granted" | "migration_invalid" | "secrets_not_granted" | "secrets_unavailable" | "secrets_cannot_decrypt" | "queue_not_granted" | "queue_unavailable" | "blobs_not_granted" | "blobs_unavailable";
|
|
2
2
|
/**
|
|
3
3
|
* Every error this SDK throws on purpose. `code` is stable and safe to
|
|
4
4
|
* branch on; `problems` lists each thing that was wrong, so a caller can
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { createBlobs } from "./blobs.js";
|
|
2
|
+
export type { BlobInfo, BlobPage, Blobs, BlobsOptions, ListOptions, PutOptions, StoredBlob, TenantBlobs } from "./blobs.js";
|
|
1
3
|
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
4
|
export type { PlatformConfig } from "./config.js";
|
|
3
5
|
export { connectDatabase, fetchSettings } from "./db.js";
|
|
@@ -10,6 +12,10 @@ export { checkManifest, loadManifest } from "./manifest.js";
|
|
|
10
12
|
export type { Manifest, ManifestPermissions, PublicPath } from "./manifest.js";
|
|
11
13
|
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.js";
|
|
12
14
|
export { PLATFORM_API_VERSION_HEADER, SDK_HEADER, SDK_REQUEST_HEADERS, SDK_VERSION_HEADER, } from "./request-headers.js";
|
|
15
|
+
export { createQueue } from "./queue.js";
|
|
16
|
+
export type { LeaseOptions, Message, PublishOptions, Queue, QueueOptions, TenantQueue } from "./queue.js";
|
|
17
|
+
export { createSecrets } from "./secrets.js";
|
|
18
|
+
export type { Secrets, SecretsOptions, TenantSecrets } from "./secrets.js";
|
|
13
19
|
export { createServiceClient, enrolmentFrom } from "./service.js";
|
|
14
20
|
export type { CallOptions, Enrolment, ServiceClient, ServiceClientOptions } from "./service.js";
|
|
15
21
|
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { createBlobs } from "./blobs.js";
|
|
1
2
|
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
3
|
export { connectDatabase, fetchSettings } from "./db.js";
|
|
3
4
|
export { FinchlyError, IdentityError } from "./errors.js";
|
|
@@ -5,5 +6,7 @@ export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
|
|
5
6
|
export { checkManifest, loadManifest } from "./manifest.js";
|
|
6
7
|
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.js";
|
|
7
8
|
export { PLATFORM_API_VERSION_HEADER, SDK_HEADER, SDK_REQUEST_HEADERS, SDK_VERSION_HEADER, } from "./request-headers.js";
|
|
9
|
+
export { createQueue } from "./queue.js";
|
|
10
|
+
export { createSecrets } from "./secrets.js";
|
|
8
11
|
export { createServiceClient, enrolmentFrom } from "./service.js";
|
|
9
12
|
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ServiceClient } from "./service.js";
|
|
2
|
+
export interface PublishOptions {
|
|
3
|
+
/** Not handed out before this many seconds have passed. Up to 7 days. */
|
|
4
|
+
readonly delaySeconds?: number;
|
|
5
|
+
}
|
|
6
|
+
/** The queue, for one tenant: where messages are published. */
|
|
7
|
+
export interface TenantQueue {
|
|
8
|
+
readonly tenantId: string;
|
|
9
|
+
/**
|
|
10
|
+
* Publish messages to a topic, for this tenant. Each body is any JSON
|
|
11
|
+
* value of at most 256 KiB; 1 to 100 at a time. Resolves with their ids.
|
|
12
|
+
* All of them are published, or none.
|
|
13
|
+
*/
|
|
14
|
+
publish(topic: string, bodies: readonly unknown[], options?: PublishOptions): Promise<string[]>;
|
|
15
|
+
}
|
|
16
|
+
/** A message that was handed out. Answer for it with `ack`, `retry` or `deadLetter`. */
|
|
17
|
+
export interface Message {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** The tenant the message is for. Handle it in that tenant, and no other. */
|
|
20
|
+
readonly tenantId: string;
|
|
21
|
+
readonly body: unknown;
|
|
22
|
+
/** How many times it has been handed out, this time included. */
|
|
23
|
+
readonly attempts: number;
|
|
24
|
+
/** The service that published it. */
|
|
25
|
+
readonly publishedBy: string;
|
|
26
|
+
readonly publishedAt: Date;
|
|
27
|
+
/**
|
|
28
|
+
* It was handled: delete it. Resolves with false if this consumer no
|
|
29
|
+
* longer holds it, because the time ran out and it was handed to another.
|
|
30
|
+
*/
|
|
31
|
+
ack(): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* It was not handled: have it handed out again, after `delaySeconds`, or
|
|
34
|
+
* after a wait that doubles each time if that is left out.
|
|
35
|
+
*/
|
|
36
|
+
retry(options?: {
|
|
37
|
+
readonly delaySeconds?: number;
|
|
38
|
+
}): Promise<boolean>;
|
|
39
|
+
/** It never will be handled: set it aside. It is kept, and not handed out again. */
|
|
40
|
+
deadLetter(reason?: string): Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
export interface LeaseOptions {
|
|
43
|
+
/** The most messages to take. 1 to 100, default 10. */
|
|
44
|
+
readonly max?: number;
|
|
45
|
+
/**
|
|
46
|
+
* How long this consumer has to answer before a message is handed to
|
|
47
|
+
* another. 1 second to 12 hours, default 60 seconds.
|
|
48
|
+
*/
|
|
49
|
+
readonly visibilitySeconds?: number;
|
|
50
|
+
/** Take one tenant's messages only. Default: every tenant that uses this service. */
|
|
51
|
+
readonly tenantId?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface Queue {
|
|
54
|
+
/**
|
|
55
|
+
* The queue for one tenant, to publish to: `caller.tenantId` of the
|
|
56
|
+
* request being handled. Take the id from a verified identity, never from
|
|
57
|
+
* the request itself.
|
|
58
|
+
*/
|
|
59
|
+
forTenant(tenantId: string): TenantQueue;
|
|
60
|
+
/**
|
|
61
|
+
* Take messages from a topic. Each comes with the tenant it is for. An
|
|
62
|
+
* empty list means there is nothing to do; ask again later.
|
|
63
|
+
*/
|
|
64
|
+
lease(topic: string, options?: LeaseOptions): Promise<Message[]>;
|
|
65
|
+
}
|
|
66
|
+
export interface QueueOptions {
|
|
67
|
+
/** The service's client. Every call goes through it. */
|
|
68
|
+
readonly service: Pick<ServiceClient, "call">;
|
|
69
|
+
}
|
|
70
|
+
export declare function createQueue(options: QueueOptions): Queue;
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The platform queue. See spec/queue.md in the platform repository.
|
|
2
|
+
//
|
|
3
|
+
// A message belongs to a tenant. Publishing is done for one tenant, on a
|
|
4
|
+
// handle made with `forTenant`. Consuming is the service's own work: one
|
|
5
|
+
// running copy serves every tenant, so `lease` hands out messages of every
|
|
6
|
+
// tenant that uses the service, each with the tenant it is for.
|
|
7
|
+
//
|
|
8
|
+
// The manifest lists what may be published and consumed, in
|
|
9
|
+
// permissions.queue.
|
|
10
|
+
import { FinchlyError } from "./errors.js";
|
|
11
|
+
const TENANT_ID = /^t_[a-z2-7]{16}$/;
|
|
12
|
+
const TOPIC = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
13
|
+
const BASE = "/api/apps/self/queue";
|
|
14
|
+
async function failure(response, what, topic) {
|
|
15
|
+
const answer = (await response.json().catch(() => null));
|
|
16
|
+
const error = typeof answer?.error === "string" ? answer.error : undefined;
|
|
17
|
+
if (error === "not_granted") {
|
|
18
|
+
return new FinchlyError("queue_not_granted", `This service may not ${what}${topic ? ` ${topic}` : ""}`, [
|
|
19
|
+
`List the topic in permissions.queue.${what === "publish to" ? "publish" : "consume"} in the manifest`,
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
return new FinchlyError("queue_unavailable", `The platform queue did not ${what === "publish to" ? "take the messages" : "answer"}`, [
|
|
23
|
+
`it answered ${response.status}${error ? ` ${error}` : ""}${typeof answer?.detail === "string" ? `: ${answer.detail}` : ""}`,
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
function checkTopic(topic) {
|
|
27
|
+
if (typeof topic !== "string" || topic.length < 2 || topic.length > 60 || !TOPIC.test(topic)) {
|
|
28
|
+
throw new FinchlyError("queue_unavailable", "A topic is 2 to 60 lower-case letters, digits and dashes");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function checkTenant(tenantId, where) {
|
|
32
|
+
if (typeof tenantId !== "string" || !TENANT_ID.test(tenantId)) {
|
|
33
|
+
throw new FinchlyError("queue_unavailable", `${where} needs a tenant id, as in caller.tenantId`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function createQueue(options) {
|
|
37
|
+
const { service } = options;
|
|
38
|
+
const post = (path, body, tenantId) => service.call("core", `${BASE}${path}`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
...(tenantId !== undefined ? { tenantId } : {}),
|
|
43
|
+
});
|
|
44
|
+
/** Answer for a message that was handed out. False if this consumer no longer holds it. */
|
|
45
|
+
async function answer(action, held, extra) {
|
|
46
|
+
const response = await post(`/${action}`, { ...held, ...extra });
|
|
47
|
+
if (response.status === 409) {
|
|
48
|
+
await response.body?.cancel();
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (response.status !== 204)
|
|
52
|
+
throw await failure(response, "consume");
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
forTenant(tenantId) {
|
|
57
|
+
checkTenant(tenantId, "forTenant");
|
|
58
|
+
return {
|
|
59
|
+
tenantId,
|
|
60
|
+
async publish(topic, bodies, publishOptions = {}) {
|
|
61
|
+
checkTopic(topic);
|
|
62
|
+
if (!Array.isArray(bodies) || bodies.length === 0) {
|
|
63
|
+
throw new FinchlyError("queue_unavailable", "publish takes a list of at least one message body");
|
|
64
|
+
}
|
|
65
|
+
const { delaySeconds } = publishOptions;
|
|
66
|
+
const messages = bodies.map((body) => ({ body: body === undefined ? null : body, ...(delaySeconds !== undefined ? { delaySeconds } : {}) }));
|
|
67
|
+
const response = await post(`/${topic}/messages`, { messages }, tenantId);
|
|
68
|
+
if (response.status !== 201)
|
|
69
|
+
throw await failure(response, "publish to", topic);
|
|
70
|
+
return (await response.json()).ids;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
async lease(topic, leaseOptions = {}) {
|
|
75
|
+
checkTopic(topic);
|
|
76
|
+
const { max, visibilitySeconds, tenantId } = leaseOptions;
|
|
77
|
+
if (tenantId !== undefined)
|
|
78
|
+
checkTenant(tenantId, "lease");
|
|
79
|
+
const response = await post(`/${topic}/lease`, { ...(max !== undefined ? { max } : {}), ...(visibilitySeconds !== undefined ? { visibilitySeconds } : {}) }, tenantId);
|
|
80
|
+
if (response.status !== 200)
|
|
81
|
+
throw await failure(response, "consume", topic);
|
|
82
|
+
const { messages } = (await response.json());
|
|
83
|
+
return messages.map(({ leaseToken, publishedAt, ...message }) => {
|
|
84
|
+
const held = { tenantId: message.tenantId, id: message.id, leaseToken };
|
|
85
|
+
return {
|
|
86
|
+
...message,
|
|
87
|
+
publishedAt: new Date(publishedAt),
|
|
88
|
+
ack: () => answer("ack", held, {}),
|
|
89
|
+
retry: (retryOptions = {}) => answer("nack", held, retryOptions.delaySeconds !== undefined ? { delaySeconds: retryOptions.delaySeconds } : {}),
|
|
90
|
+
deadLetter: (reason) => answer("dead-letter", held, reason !== undefined ? { reason } : {}),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ServiceClient } from "./service.js";
|
|
2
|
+
/** A service's secrets in one tenant. */
|
|
3
|
+
export interface TenantSecrets {
|
|
4
|
+
readonly tenantId: string;
|
|
5
|
+
/** A secret's value, or undefined if there is none by that name. Do not log it. */
|
|
6
|
+
get(name: string): Promise<string | undefined>;
|
|
7
|
+
/** Keep a secret, replacing any there was. The value is text of at most 64 KiB. */
|
|
8
|
+
set(name: string, value: string): Promise<void>;
|
|
9
|
+
/** Forget a secret. Resolves with whether there was one. */
|
|
10
|
+
delete(name: string): Promise<boolean>;
|
|
11
|
+
/** The names of the secrets kept. Never the values. */
|
|
12
|
+
list(): Promise<string[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Have the platform encrypt something under the tenant's key, to keep in
|
|
15
|
+
* the service's own tables. `context` says what the value is, for example
|
|
16
|
+
* "payments:card:7"; the same context must be given to decrypt, so a
|
|
17
|
+
* value moved to another row does not open there.
|
|
18
|
+
*/
|
|
19
|
+
encrypt(plaintext: string | Uint8Array, context?: string): Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Open what `encrypt` gave. Rejects with `secrets_cannot_decrypt` if it
|
|
22
|
+
* was made for another tenant, another service or another context, or
|
|
23
|
+
* has been changed.
|
|
24
|
+
*/
|
|
25
|
+
decrypt(ciphertext: string, context?: string): Promise<Uint8Array>;
|
|
26
|
+
}
|
|
27
|
+
export interface Secrets {
|
|
28
|
+
/**
|
|
29
|
+
* The secrets of one tenant: `caller.tenantId` of the request being
|
|
30
|
+
* handled. Take the id from a verified identity, never from the request
|
|
31
|
+
* itself.
|
|
32
|
+
*/
|
|
33
|
+
forTenant(tenantId: string): TenantSecrets;
|
|
34
|
+
}
|
|
35
|
+
export interface SecretsOptions {
|
|
36
|
+
/** The service's client. Every call goes through it. */
|
|
37
|
+
readonly service: Pick<ServiceClient, "call">;
|
|
38
|
+
}
|
|
39
|
+
export declare function createSecrets(options: SecretsOptions): Secrets;
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Secrets a service keeps with the platform, for each tenant, and
|
|
2
|
+
// encryption under the tenant's key. See spec/secrets.md in the platform
|
|
3
|
+
// repository.
|
|
4
|
+
//
|
|
5
|
+
// The manifest must set permissions.secrets. Everything here is for one
|
|
6
|
+
// tenant: the handle that reads and writes is made with `forTenant`.
|
|
7
|
+
import { FinchlyError } from "./errors.js";
|
|
8
|
+
const TENANT_ID = /^t_[a-z2-7]{16}$/;
|
|
9
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
|
10
|
+
const BASE = "/api/apps/self";
|
|
11
|
+
async function failure(response, what) {
|
|
12
|
+
const answer = (await response.json().catch(() => null));
|
|
13
|
+
const error = typeof answer?.error === "string" ? answer.error : undefined;
|
|
14
|
+
if (error === "not_granted") {
|
|
15
|
+
return new FinchlyError("secrets_not_granted", "The platform keeps no secrets for this service", [
|
|
16
|
+
"Set permissions.secrets to true in the manifest",
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
return new FinchlyError("secrets_unavailable", `The platform did not ${what}`, [
|
|
20
|
+
`it answered ${response.status}${error ? ` ${error}` : ""}`,
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
export function createSecrets(options) {
|
|
24
|
+
const { service } = options;
|
|
25
|
+
return {
|
|
26
|
+
forTenant(tenantId) {
|
|
27
|
+
if (typeof tenantId !== "string" || !TENANT_ID.test(tenantId)) {
|
|
28
|
+
throw new FinchlyError("secrets_unavailable", "forTenant needs a tenant id, as in caller.tenantId");
|
|
29
|
+
}
|
|
30
|
+
const call = (path, method = "GET", body) => service.call("core", `${BASE}${path}`, {
|
|
31
|
+
tenantId,
|
|
32
|
+
method,
|
|
33
|
+
...(body !== undefined ? { headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {}),
|
|
34
|
+
});
|
|
35
|
+
const named = (name) => {
|
|
36
|
+
if (typeof name !== "string" || !NAME.test(name)) {
|
|
37
|
+
throw new FinchlyError("secrets_unavailable", "A secret's name is 1 to 100 letters, digits, dots, dashes or underscores");
|
|
38
|
+
}
|
|
39
|
+
return `/secrets/${name}`;
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
tenantId,
|
|
43
|
+
async get(name) {
|
|
44
|
+
const response = await call(named(name));
|
|
45
|
+
if (response.status === 404) {
|
|
46
|
+
await response.body?.cancel();
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (response.status !== 200)
|
|
50
|
+
throw await failure(response, "give the secret");
|
|
51
|
+
return (await response.json()).value;
|
|
52
|
+
},
|
|
53
|
+
async set(name, value) {
|
|
54
|
+
if (typeof value !== "string")
|
|
55
|
+
throw new FinchlyError("secrets_unavailable", "A secret's value is text");
|
|
56
|
+
const response = await call(named(name), "PUT", { value });
|
|
57
|
+
if (response.status !== 204)
|
|
58
|
+
throw await failure(response, "keep the secret");
|
|
59
|
+
},
|
|
60
|
+
async delete(name) {
|
|
61
|
+
const response = await call(named(name), "DELETE");
|
|
62
|
+
if (response.status === 404) {
|
|
63
|
+
await response.body?.cancel();
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (response.status !== 204)
|
|
67
|
+
throw await failure(response, "forget the secret");
|
|
68
|
+
return true;
|
|
69
|
+
},
|
|
70
|
+
async list() {
|
|
71
|
+
const response = await call("/secrets");
|
|
72
|
+
if (response.status !== 200)
|
|
73
|
+
throw await failure(response, "list the secrets");
|
|
74
|
+
return (await response.json()).names;
|
|
75
|
+
},
|
|
76
|
+
async encrypt(plaintext, context) {
|
|
77
|
+
const bytes = typeof plaintext === "string" ? Buffer.from(plaintext, "utf8") : Buffer.from(plaintext);
|
|
78
|
+
const response = await call("/encrypt", "POST", { plaintext: bytes.toString("base64"), ...(context !== undefined ? { context } : {}) });
|
|
79
|
+
if (response.status !== 200)
|
|
80
|
+
throw await failure(response, "encrypt");
|
|
81
|
+
return (await response.json()).ciphertext;
|
|
82
|
+
},
|
|
83
|
+
async decrypt(ciphertext, context) {
|
|
84
|
+
const response = await call("/decrypt", "POST", { ciphertext, ...(context !== undefined ? { context } : {}) });
|
|
85
|
+
if (response.status === 400) {
|
|
86
|
+
await response.body?.cancel();
|
|
87
|
+
// Another tenant's, another service's, another context's, or changed: the platform does not say which.
|
|
88
|
+
throw new FinchlyError("secrets_cannot_decrypt", "The platform could not decrypt this");
|
|
89
|
+
}
|
|
90
|
+
if (response.status !== 200)
|
|
91
|
+
throw await failure(response, "decrypt");
|
|
92
|
+
return Buffer.from((await response.json()).plaintext, "base64");
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|