@firetail/finchly 0.0.7 → 0.0.9
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 +27 -0
- package/README.md +115 -16
- package/dist/db.d.ts +31 -14
- package/dist/db.js +150 -26
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -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,33 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.0.9 - 2026-11-12
|
|
8
|
+
|
|
9
|
+
- `createSecrets({ service })`: secrets kept with the platform for each
|
|
10
|
+
tenant. `secrets.forTenant(caller.tenantId)` gives `get`, `set`,
|
|
11
|
+
`delete` and `list`, and `encrypt` and `decrypt` under the tenant's key.
|
|
12
|
+
The manifest must set `permissions.secrets`.
|
|
13
|
+
- `createQueue({ service })`: the platform queue.
|
|
14
|
+
`queue.forTenant(caller.tenantId).publish(topic, bodies)` publishes for
|
|
15
|
+
a tenant; `queue.lease(topic)` hands out messages of every tenant, each
|
|
16
|
+
with `tenantId`, `ack()`, `retry()` and `deadLetter()`. The manifest
|
|
17
|
+
lists the topics in `permissions.queue`.
|
|
18
|
+
- New error codes: `secrets_not_granted`, `secrets_unavailable`,
|
|
19
|
+
`secrets_cannot_decrypt`, `queue_not_granted`, `queue_unavailable`.
|
|
20
|
+
- **Needs a platform that serves secrets and the queue.**
|
|
21
|
+
|
|
22
|
+
## 0.0.8 - 2026-11-12
|
|
23
|
+
|
|
24
|
+
- **Breaking:** the database is per tenant. `connectDatabase` no longer
|
|
25
|
+
runs SQL itself: `database.forTenant(caller.tenantId)` gives the handle
|
|
26
|
+
with `execute`, `transaction` and `withAdvisoryLock`, and everything on
|
|
27
|
+
it happens in that tenant's schema. **Needs a platform that gives a
|
|
28
|
+
schema per tenant.**
|
|
29
|
+
- `database.migrate` applies migrations in every tenant's schema and
|
|
30
|
+
resolves with the versions applied, by tenant id. A tenant that starts
|
|
31
|
+
using the service later is migrated on first use.
|
|
32
|
+
- `database.tenants()` lists the tenants the service has a database for.
|
|
33
|
+
|
|
7
34
|
## 0.0.7 - 2026-11-12
|
|
8
35
|
|
|
9
36
|
- An identity names its tenant: `caller.tenantId`. One deployment serves
|
package/README.md
CHANGED
|
@@ -176,8 +176,8 @@ an app.
|
|
|
176
176
|
|
|
177
177
|
### A database of its own
|
|
178
178
|
|
|
179
|
-
A service whose manifest sets `permissions.db` is given a PostgreSQL
|
|
180
|
-
|
|
179
|
+
A service whose manifest sets `permissions.db` is given a PostgreSQL schema
|
|
180
|
+
of its own for each tenant that uses it. The service installs `pg` itself:
|
|
181
181
|
|
|
182
182
|
```
|
|
183
183
|
npm install pg
|
|
@@ -186,9 +186,13 @@ npm install pg
|
|
|
186
186
|
```js
|
|
187
187
|
import { connectDatabase } from "@firetail/finchly";
|
|
188
188
|
|
|
189
|
-
// Once, at startup. Asks the platform for the role and
|
|
190
|
-
|
|
191
|
-
await
|
|
189
|
+
// Once, at startup. Asks the platform for the role and schemas it made,
|
|
190
|
+
// and brings every tenant's schema up to date.
|
|
191
|
+
const database = await connectDatabase({ service });
|
|
192
|
+
await database.migrate(new URL("./migrations/", import.meta.url).pathname);
|
|
193
|
+
|
|
194
|
+
// Per request: the database of the caller's tenant.
|
|
195
|
+
const db = database.forTenant(caller.tenantId);
|
|
192
196
|
|
|
193
197
|
const { rows } = await db.execute("SELECT id, body FROM notes WHERE author = $1", [caller.subject]);
|
|
194
198
|
|
|
@@ -197,28 +201,123 @@ await db.transaction(async (tx) => {
|
|
|
197
201
|
await tx.execute("UPDATE authors SET notes = notes + 1 WHERE id = $1", [caller.subject]);
|
|
198
202
|
});
|
|
199
203
|
|
|
200
|
-
// One instance at a time, across every instance of this service.
|
|
204
|
+
// One instance at a time, for this tenant, across every instance of this service.
|
|
201
205
|
await db.withAdvisoryLock("nightly-import", async (tx) => {
|
|
202
206
|
// ...
|
|
203
207
|
});
|
|
204
208
|
```
|
|
205
209
|
|
|
206
|
-
- **
|
|
207
|
-
|
|
210
|
+
- **There is no database without a tenant.** SQL is run on the handle
|
|
211
|
+
`forTenant` gives, and happens in that tenant's schema. Take the tenant
|
|
212
|
+
id from a verified identity, never from the request itself.
|
|
213
|
+
- **Names need no schema.** `notes` means that tenant's table. The service
|
|
214
|
+
cannot read the platform's tables or another service's, and working for
|
|
215
|
+
one tenant it cannot reach another's.
|
|
208
216
|
- **Values go in the second argument**, as `$1`, `$2`. They are never built
|
|
209
217
|
into the SQL.
|
|
210
218
|
- **A transaction** commits if its function returns and rolls back if it
|
|
211
219
|
throws.
|
|
212
220
|
- **Migrations** are files named `NNNN_name.sql`, applied in order, once
|
|
213
|
-
each, in
|
|
214
|
-
they take turns. A
|
|
215
|
-
|
|
221
|
+
each, in every tenant's schema. Every instance may call `migrate` at
|
|
222
|
+
startup: they take turns. A tenant that starts using the service later is
|
|
223
|
+
migrated the first time its database is used. A migration that was
|
|
224
|
+
applied is never edited; add a new one. A problem is a `FinchlyError`
|
|
225
|
+
with code `migration_invalid`.
|
|
226
|
+
- **Work with no request behind it**, such as a schedule, runs once for
|
|
227
|
+
each tenant: `await database.tenants()` lists them.
|
|
216
228
|
- `connectDatabase` rejects with `database_not_granted` if the manifest
|
|
217
229
|
does not ask for a database, and with `database_unavailable` if the
|
|
218
|
-
platform or the database cannot be reached.
|
|
230
|
+
platform or the database cannot be reached. Using a tenant the service
|
|
231
|
+
has no database for rejects with `database_not_granted`.
|
|
219
232
|
|
|
220
233
|
To run a service on its own, without a platform, give the URL directly:
|
|
221
|
-
`connectDatabase({ url, slug })`.
|
|
234
|
+
`connectDatabase({ url, slug })`. Each tenant's schema is then made the
|
|
235
|
+
first time it is used.
|
|
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
|
+
### The queue
|
|
278
|
+
|
|
279
|
+
A message belongs to a tenant. The manifest lists the topics the service
|
|
280
|
+
publishes to and consumes, in `permissions.queue.publish` and
|
|
281
|
+
`permissions.queue.consume`.
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
import { createQueue } from "@firetail/finchly";
|
|
285
|
+
|
|
286
|
+
const queue = createQueue({ service });
|
|
287
|
+
|
|
288
|
+
// Per request: publish for the caller's tenant.
|
|
289
|
+
await queue.forTenant(caller.tenantId).publish("notifications", [{ to: caller.subject, text: "Done" }]);
|
|
290
|
+
|
|
291
|
+
// A worker: take messages of every tenant, and handle each in its own.
|
|
292
|
+
for (const message of await queue.lease("notifications", { max: 10, visibilitySeconds: 60 })) {
|
|
293
|
+
const db = database.forTenant(message.tenantId);
|
|
294
|
+
try {
|
|
295
|
+
await deliver(db, message.body);
|
|
296
|
+
await message.ack();
|
|
297
|
+
} catch {
|
|
298
|
+
await message.retry();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
- **Publishing** takes 1 to 100 bodies, each any JSON value of at most
|
|
304
|
+
256 KiB, and resolves with their ids. All are published or none.
|
|
305
|
+
`{ delaySeconds }` holds them back, up to 7 days.
|
|
306
|
+
- **Leasing** hands out messages for `visibilitySeconds` (default 60). A
|
|
307
|
+
message not answered for in that time is handed out again. An empty list
|
|
308
|
+
means there is nothing to do; ask again later. `{ tenantId }` takes one
|
|
309
|
+
tenant's messages only.
|
|
310
|
+
- **Each message says which tenant it is for.** Handle it in that tenant.
|
|
311
|
+
- **Answer for every message**: `ack()` when it is handled, `retry()` to
|
|
312
|
+
have it handed out again after a wait that doubles each time (or
|
|
313
|
+
`retry({ delaySeconds })`), `deadLetter(reason)` when it never will be
|
|
314
|
+
handled. Each resolves with `false` if the time ran out and the message
|
|
315
|
+
went to another consumer. A message handed out five times without being
|
|
316
|
+
handled is set aside.
|
|
317
|
+
- A message may be handed out more than once, so handling one twice must be
|
|
318
|
+
harmless.
|
|
319
|
+
- A topic the manifest does not list rejects with `queue_not_granted`. Any
|
|
320
|
+
other refusal is `queue_unavailable`.
|
|
222
321
|
|
|
223
322
|
### What every request carries
|
|
224
323
|
|
|
@@ -228,7 +327,7 @@ release is asking:
|
|
|
228
327
|
| Header | Value |
|
|
229
328
|
|---|---|
|
|
230
329
|
| `x-platform-sdk` | `@firetail/finchly` |
|
|
231
|
-
| `x-platform-sdk-version` | This release, for example `0.0.
|
|
330
|
+
| `x-platform-sdk-version` | This release, for example `0.0.8` |
|
|
232
331
|
| `x-platform-api-version` | The platform API version it speaks, `v1` |
|
|
233
332
|
|
|
234
333
|
That covers fetching the gateway's issuer and keys, registering an instance
|
|
@@ -260,8 +359,8 @@ not leak them.
|
|
|
260
359
|
| `service` | Call other services through the gateway, as the service or for a user | Done, for built-in services and for apps |
|
|
261
360
|
| `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Done |
|
|
262
361
|
| `blobs` | Object storage with conditional writes | Planned |
|
|
263
|
-
| `queue` | Publish, lease, ack,
|
|
264
|
-
| `secrets` | Secrets and
|
|
362
|
+
| `queue` | Publish, lease, ack, retry, dead-letter | Done |
|
|
363
|
+
| `secrets` | Secrets, and encryption under the tenant's key | Done |
|
|
265
364
|
| `llm` | Model calls through the platform | Planned |
|
|
266
365
|
| `audit`, `usage`, `notify`, `schedule`, `connectors` | Clients for platform services | Planned |
|
|
267
366
|
| `ui` | Shared header, footer and theme | Planned |
|
package/dist/db.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export interface QueryResult<Row> {
|
|
|
3
3
|
readonly rows: Row[];
|
|
4
4
|
readonly rowCount: number;
|
|
5
5
|
}
|
|
6
|
-
/** Something SQL can be run on:
|
|
6
|
+
/** Something SQL can be run on: a tenant's database, or one transaction in it. */
|
|
7
7
|
export interface Queryable {
|
|
8
8
|
/**
|
|
9
9
|
* Run a statement. Values go in `params` and are referred to as $1, $2
|
|
@@ -12,24 +12,36 @@ export interface Queryable {
|
|
|
12
12
|
*/
|
|
13
13
|
execute<Row = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<QueryResult<Row>>;
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
readonly
|
|
15
|
+
/** This service's database for one tenant. Unqualified names mean that tenant's tables. */
|
|
16
|
+
export interface TenantDatabase extends Queryable {
|
|
17
|
+
readonly tenantId: string;
|
|
18
18
|
/** Run `fn` in one transaction. It commits if `fn` returns and rolls back if it throws. */
|
|
19
19
|
transaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
|
|
20
20
|
/**
|
|
21
21
|
* Run `fn` in a transaction that holds a lock named `key`. Another
|
|
22
|
-
* instance of this service that asks for the same key
|
|
23
|
-
* transaction ends. The lock is this
|
|
24
|
-
*
|
|
22
|
+
* instance of this service that asks for the same key, for the same
|
|
23
|
+
* tenant, waits until this transaction ends. The lock is this tenant's
|
|
24
|
+
* and this service's: the same name elsewhere does not wait for it.
|
|
25
25
|
*/
|
|
26
26
|
withAdvisoryLock<T>(key: string, fn: (tx: Queryable) => Promise<T>): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
export interface Database {
|
|
29
|
+
/**
|
|
30
|
+
* The database for one tenant: `caller.tenantId` of the request being
|
|
31
|
+
* handled. Take the id from a verified identity, never from the request
|
|
32
|
+
* itself. Everything run on the handle happens in that tenant's schema.
|
|
33
|
+
*/
|
|
34
|
+
forTenant(tenantId: string): TenantDatabase;
|
|
35
|
+
/** The tenants this service has a database for now. */
|
|
36
|
+
tenants(): Promise<string[]>;
|
|
27
37
|
/**
|
|
28
|
-
* Apply the migrations in `directory
|
|
29
|
-
*
|
|
30
|
-
* instance at startup.
|
|
38
|
+
* Apply the migrations in `directory`: files named NNNN_name.sql, in
|
|
39
|
+
* order, once each, in every tenant's schema. Safe to call from every
|
|
40
|
+
* instance at startup. A tenant that starts using the service later is
|
|
41
|
+
* migrated the first time its database is used. Resolves with the
|
|
42
|
+
* versions this call applied, by tenant id.
|
|
31
43
|
*/
|
|
32
|
-
migrate(directory: string): Promise<string[]
|
|
44
|
+
migrate(directory: string): Promise<Record<string, string[]>>;
|
|
33
45
|
close(): Promise<void>;
|
|
34
46
|
}
|
|
35
47
|
/** What the platform holds for this service. See spec/registration.md, section 4. */
|
|
@@ -37,7 +49,11 @@ export interface PlatformSettings {
|
|
|
37
49
|
readonly slug: string;
|
|
38
50
|
readonly database?: {
|
|
39
51
|
readonly url: string;
|
|
40
|
-
|
|
52
|
+
/** By tenant id: the tenant's schema, and the role to take to work in it. */
|
|
53
|
+
readonly tenants: Readonly<Record<string, {
|
|
54
|
+
readonly schema: string;
|
|
55
|
+
readonly role: string;
|
|
56
|
+
}>>;
|
|
41
57
|
};
|
|
42
58
|
}
|
|
43
59
|
export interface DatabaseOptions {
|
|
@@ -45,7 +61,8 @@ export interface DatabaseOptions {
|
|
|
45
61
|
readonly service?: Pick<ServiceClient, "call">;
|
|
46
62
|
/**
|
|
47
63
|
* Connect to this URL and do not ask the platform. For tests and for
|
|
48
|
-
* running a service on its own. Needs `slug`.
|
|
64
|
+
* running a service on its own. Needs `slug`. Each tenant's schema is
|
|
65
|
+
* made the first time it is used.
|
|
49
66
|
*/
|
|
50
67
|
readonly url?: string;
|
|
51
68
|
/** The service's slug. Only needed with `url`. */
|
|
@@ -57,7 +74,7 @@ export interface DatabaseOptions {
|
|
|
57
74
|
export declare function fetchSettings(service: Pick<ServiceClient, "call">): Promise<PlatformSettings>;
|
|
58
75
|
/**
|
|
59
76
|
* Connect to this service's database. With `service`, the platform is
|
|
60
|
-
* asked for the role and
|
|
77
|
+
* asked for the role and schemas it made; the manifest must set
|
|
61
78
|
* permissions.db. Rejects if the database cannot be reached.
|
|
62
79
|
*/
|
|
63
80
|
export declare function connectDatabase(options: DatabaseOptions): Promise<Database>;
|
package/dist/db.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
// PostgreSQL for a service:
|
|
1
|
+
// PostgreSQL for a service: a schema of its own for each tenant.
|
|
2
2
|
//
|
|
3
|
-
// The platform gives
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// One deployment serves several tenants. The platform gives a service that
|
|
4
|
+
// asks for a database (permissions.db in the manifest) a role to log in as,
|
|
5
|
+
// which can read nothing by itself, and for each tenant that uses the
|
|
6
|
+
// service a schema and a role that may use it. This module asks the
|
|
7
|
+
// platform for them and connects.
|
|
8
|
+
//
|
|
9
|
+
// Nothing can be read or written without naming a tenant: the handle that
|
|
10
|
+
// runs SQL is made with `forTenant`. It needs the `pg` package, which the
|
|
11
|
+
// service installs itself.
|
|
7
12
|
import { createHash } from "node:crypto";
|
|
8
13
|
import { readdir, readFile } from "node:fs/promises";
|
|
9
14
|
import { join } from "node:path";
|
|
10
15
|
import { FinchlyError } from "./errors.js";
|
|
16
|
+
const TENANT_ID = /^t_[a-z2-7]{16}$/;
|
|
17
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/;
|
|
18
|
+
/** The least time between two questions to the platform about a tenant it did not list. */
|
|
19
|
+
const SETTINGS_COOLDOWN_MS = 5_000;
|
|
11
20
|
/** Ask the platform what it holds for this service. */
|
|
12
21
|
export async function fetchSettings(service) {
|
|
13
22
|
const response = await service.call("core", "/api/apps/self/config");
|
|
@@ -125,30 +134,51 @@ async function applyMigrations(tx, migrations) {
|
|
|
125
134
|
}
|
|
126
135
|
return done;
|
|
127
136
|
}
|
|
137
|
+
/** A name the platform gave, quoted. Anything that is not such a name is refused, not escaped. */
|
|
138
|
+
function quoted(name) {
|
|
139
|
+
if (typeof name !== "string" || !IDENTIFIER.test(name)) {
|
|
140
|
+
throw new FinchlyError("database_unavailable", "The platform named a schema or role that is not usable");
|
|
141
|
+
}
|
|
142
|
+
return `"${name}"`;
|
|
143
|
+
}
|
|
144
|
+
function placesFrom(settings) {
|
|
145
|
+
const places = new Map();
|
|
146
|
+
for (const [tenantId, place] of Object.entries(settings.database?.tenants ?? {})) {
|
|
147
|
+
if (!TENANT_ID.test(tenantId))
|
|
148
|
+
continue;
|
|
149
|
+
// Checked now, so that a bad name is never sent to the database.
|
|
150
|
+
quoted(place.schema);
|
|
151
|
+
quoted(place.role);
|
|
152
|
+
places.set(tenantId, { schema: place.schema, role: place.role });
|
|
153
|
+
}
|
|
154
|
+
return places;
|
|
155
|
+
}
|
|
128
156
|
/**
|
|
129
157
|
* Connect to this service's database. With `service`, the platform is
|
|
130
|
-
* asked for the role and
|
|
158
|
+
* asked for the role and schemas it made; the manifest must set
|
|
131
159
|
* permissions.db. Rejects if the database cannot be reached.
|
|
132
160
|
*/
|
|
133
161
|
export async function connectDatabase(options) {
|
|
134
162
|
let url;
|
|
135
163
|
let slug;
|
|
136
|
-
let
|
|
164
|
+
let places = new Map();
|
|
165
|
+
const service = options.url === undefined ? options.service : undefined;
|
|
137
166
|
if (options.url !== undefined) {
|
|
138
167
|
if (!options.slug) {
|
|
139
168
|
throw new FinchlyError("database_unavailable", "connectDatabase needs `slug` when it is given `url`");
|
|
140
169
|
}
|
|
141
170
|
({ url, slug } = { url: options.url, slug: options.slug });
|
|
142
171
|
}
|
|
143
|
-
else if (
|
|
144
|
-
const settings = await fetchSettings(
|
|
172
|
+
else if (service) {
|
|
173
|
+
const settings = await fetchSettings(service);
|
|
145
174
|
if (!settings.database) {
|
|
146
175
|
throw new FinchlyError("database_not_granted", "The platform made no database for this service", [
|
|
147
176
|
"Set permissions.db to true in the manifest",
|
|
148
177
|
]);
|
|
149
178
|
}
|
|
150
|
-
|
|
179
|
+
url = settings.database.url;
|
|
151
180
|
slug = settings.slug;
|
|
181
|
+
places = placesFrom(settings);
|
|
152
182
|
}
|
|
153
183
|
else {
|
|
154
184
|
throw new FinchlyError("database_unavailable", "connectDatabase needs `service`, or `url` and `slug`");
|
|
@@ -157,7 +187,6 @@ export async function connectDatabase(options) {
|
|
|
157
187
|
const pool = new pg.Pool({ connectionString: url, max: options.maxConnections ?? 10 });
|
|
158
188
|
// An idle connection that drops must not take the process down.
|
|
159
189
|
pool.on("error", () => { });
|
|
160
|
-
const onPool = queryable(pool);
|
|
161
190
|
try {
|
|
162
191
|
await pool.query("SELECT 1");
|
|
163
192
|
}
|
|
@@ -166,11 +195,64 @@ export async function connectDatabase(options) {
|
|
|
166
195
|
// pg's message names the host and the role, never the password.
|
|
167
196
|
throw new FinchlyError("database_unavailable", "The database could not be reached", [err.message]);
|
|
168
197
|
}
|
|
169
|
-
|
|
198
|
+
/** Ask the platform again which tenants there are. At most one question at a time. */
|
|
199
|
+
let refreshing;
|
|
200
|
+
let refreshedAt = Date.now();
|
|
201
|
+
function refresh() {
|
|
202
|
+
if (!service)
|
|
203
|
+
return Promise.resolve();
|
|
204
|
+
refreshing ??= fetchSettings(service)
|
|
205
|
+
.then((settings) => {
|
|
206
|
+
places = placesFrom(settings);
|
|
207
|
+
refreshedAt = Date.now();
|
|
208
|
+
})
|
|
209
|
+
.finally(() => {
|
|
210
|
+
refreshing = undefined;
|
|
211
|
+
});
|
|
212
|
+
return refreshing;
|
|
213
|
+
}
|
|
214
|
+
/** Where a tenant's data is, or a refusal if this service has none for it. */
|
|
215
|
+
async function placeOf(tenantId) {
|
|
216
|
+
let place = places.get(tenantId);
|
|
217
|
+
if (place)
|
|
218
|
+
return place;
|
|
219
|
+
if (!service) {
|
|
220
|
+
// Running on its own: the schema is named as the platform would name
|
|
221
|
+
// it, and made by whoever is connected.
|
|
222
|
+
const schema = `${tenantId}_${slug.replaceAll("-", "_")}`.slice(0, 63);
|
|
223
|
+
try {
|
|
224
|
+
await pool.query(`CREATE SCHEMA IF NOT EXISTS ${quoted(schema)}`);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
// Two connections making it at once: one of them is told it exists.
|
|
228
|
+
const code = err.code;
|
|
229
|
+
if (code !== "23505" && code !== "42P06")
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
place = { schema };
|
|
233
|
+
places.set(tenantId, place);
|
|
234
|
+
return place;
|
|
235
|
+
}
|
|
236
|
+
// A tenant may have started using the service since the platform was asked.
|
|
237
|
+
if (Date.now() - refreshedAt >= SETTINGS_COOLDOWN_MS || refreshing)
|
|
238
|
+
await refresh();
|
|
239
|
+
place = places.get(tenantId);
|
|
240
|
+
if (!place) {
|
|
241
|
+
throw new FinchlyError("database_not_granted", "This service has no database for that tenant", [
|
|
242
|
+
"The tenant does not use this service, or is not active",
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
245
|
+
return place;
|
|
246
|
+
}
|
|
247
|
+
/** Run `fn` in a transaction, as the tenant's role, in the tenant's schema. Both end with the transaction. */
|
|
248
|
+
async function inTenant(place, fn) {
|
|
170
249
|
const client = await pool.connect();
|
|
171
250
|
let broken;
|
|
172
251
|
try {
|
|
173
252
|
await client.query("BEGIN");
|
|
253
|
+
if (place.role !== undefined)
|
|
254
|
+
await client.query(`SET LOCAL ROLE ${quoted(place.role)}`);
|
|
255
|
+
await client.query(`SET LOCAL search_path = ${quoted(place.schema)}`);
|
|
174
256
|
const result = await fn(queryable(client));
|
|
175
257
|
await client.query("COMMIT");
|
|
176
258
|
return result;
|
|
@@ -189,22 +271,64 @@ export async function connectDatabase(options) {
|
|
|
189
271
|
client.release(broken);
|
|
190
272
|
}
|
|
191
273
|
}
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const migrations = await readMigrations(directory);
|
|
274
|
+
const lockKey = (tenantId, key) => `t:${tenantId}:svc:${slug}:${key}`;
|
|
275
|
+
const lock = (tx, tenantId, key) => tx.execute("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [lockKey(tenantId, key)]);
|
|
276
|
+
// Migrations, once `migrate` has been called: each tenant's schema is
|
|
277
|
+
// brought up to date once per process, before anything else runs in it.
|
|
278
|
+
let migrations;
|
|
279
|
+
const migrated = new Map();
|
|
280
|
+
function migrateTenant(tenantId, place) {
|
|
281
|
+
const known = migrations;
|
|
282
|
+
if (known === undefined)
|
|
283
|
+
return Promise.resolve([]);
|
|
284
|
+
let pending = migrated.get(tenantId);
|
|
285
|
+
if (!pending) {
|
|
205
286
|
// One transaction: instances starting together take turns, and a
|
|
206
287
|
// migration that fails leaves nothing behind.
|
|
207
|
-
|
|
288
|
+
pending = inTenant(place, async (tx) => {
|
|
289
|
+
await lock(tx, tenantId, "migrate");
|
|
290
|
+
return applyMigrations(tx, known);
|
|
291
|
+
});
|
|
292
|
+
migrated.set(tenantId, pending);
|
|
293
|
+
// A failure is reported to whoever asked, and tried again next time.
|
|
294
|
+
pending.catch(() => migrated.delete(tenantId));
|
|
295
|
+
}
|
|
296
|
+
return pending;
|
|
297
|
+
}
|
|
298
|
+
/** Run `fn` for a tenant, once its schema is known and up to date. */
|
|
299
|
+
async function run(tenantId, fn) {
|
|
300
|
+
const place = await placeOf(tenantId);
|
|
301
|
+
await migrateTenant(tenantId, place);
|
|
302
|
+
return inTenant(place, fn);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
forTenant(tenantId) {
|
|
306
|
+
if (typeof tenantId !== "string" || !TENANT_ID.test(tenantId)) {
|
|
307
|
+
throw new FinchlyError("database_unavailable", "forTenant needs a tenant id, as in caller.tenantId");
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
tenantId,
|
|
311
|
+
execute: (sql, params) => run(tenantId, (tx) => tx.execute(sql, params)),
|
|
312
|
+
transaction: (fn) => run(tenantId, fn),
|
|
313
|
+
withAdvisoryLock: (key, fn) => run(tenantId, async (tx) => {
|
|
314
|
+
await lock(tx, tenantId, key);
|
|
315
|
+
return fn(tx);
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
async tenants() {
|
|
320
|
+
await refresh();
|
|
321
|
+
return [...places.keys()].sort();
|
|
322
|
+
},
|
|
323
|
+
async migrate(directory) {
|
|
324
|
+
migrations = await readMigrations(directory);
|
|
325
|
+
migrated.clear();
|
|
326
|
+
await refresh();
|
|
327
|
+
const applied = {};
|
|
328
|
+
for (const [tenantId, place] of [...places].sort(([a], [b]) => a.localeCompare(b))) {
|
|
329
|
+
applied[tenantId] = await migrateTenant(tenantId, place);
|
|
330
|
+
}
|
|
331
|
+
return applied;
|
|
208
332
|
},
|
|
209
333
|
close: () => pool.end(),
|
|
210
334
|
};
|
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";
|
|
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,7 +1,7 @@
|
|
|
1
1
|
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
2
|
export type { PlatformConfig } from "./config.js";
|
|
3
3
|
export { connectDatabase, fetchSettings } from "./db.js";
|
|
4
|
-
export type { Database, DatabaseOptions, PlatformSettings, QueryResult, Queryable } from "./db.js";
|
|
4
|
+
export type { Database, DatabaseOptions, PlatformSettings, QueryResult, Queryable, TenantDatabase } from "./db.js";
|
|
5
5
|
export { FinchlyError, IdentityError } from "./errors.js";
|
|
6
6
|
export type { FinchlyErrorCode, IdentityRejection } from "./errors.js";
|
|
7
7
|
export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
|
@@ -10,6 +10,10 @@ export { checkManifest, loadManifest } from "./manifest.js";
|
|
|
10
10
|
export type { Manifest, ManifestPermissions, PublicPath } from "./manifest.js";
|
|
11
11
|
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.js";
|
|
12
12
|
export { PLATFORM_API_VERSION_HEADER, SDK_HEADER, SDK_REQUEST_HEADERS, SDK_VERSION_HEADER, } from "./request-headers.js";
|
|
13
|
+
export { createQueue } from "./queue.js";
|
|
14
|
+
export type { LeaseOptions, Message, PublishOptions, Queue, QueueOptions, TenantQueue } from "./queue.js";
|
|
15
|
+
export { createSecrets } from "./secrets.js";
|
|
16
|
+
export type { Secrets, SecretsOptions, TenantSecrets } from "./secrets.js";
|
|
13
17
|
export { createServiceClient, enrolmentFrom } from "./service.js";
|
|
14
18
|
export type { CallOptions, Enrolment, ServiceClient, ServiceClientOptions } from "./service.js";
|
|
15
19
|
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
package/dist/index.js
CHANGED
|
@@ -5,5 +5,7 @@ export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
|
|
5
5
|
export { checkManifest, loadManifest } from "./manifest.js";
|
|
6
6
|
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.js";
|
|
7
7
|
export { PLATFORM_API_VERSION_HEADER, SDK_HEADER, SDK_REQUEST_HEADERS, SDK_VERSION_HEADER, } from "./request-headers.js";
|
|
8
|
+
export { createQueue } from "./queue.js";
|
|
9
|
+
export { createSecrets } from "./secrets.js";
|
|
8
10
|
export { createServiceClient, enrolmentFrom } from "./service.js";
|
|
9
11
|
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
|
+
}
|