@firetail/finchly 0.0.6 → 0.0.8
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 +22 -0
- package/README.md +44 -16
- package/dist/db.d.ts +31 -14
- package/dist/db.js +150 -26
- package/dist/identity.d.ts +7 -0
- package/dist/identity.js +9 -1
- package/dist/index.d.ts +1 -1
- package/dist/service.d.ts +9 -1
- package/dist/service.js +15 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.0.8 - 2026-11-12
|
|
8
|
+
|
|
9
|
+
- **Breaking:** the database is per tenant. `connectDatabase` no longer
|
|
10
|
+
runs SQL itself: `database.forTenant(caller.tenantId)` gives the handle
|
|
11
|
+
with `execute`, `transaction` and `withAdvisoryLock`, and everything on
|
|
12
|
+
it happens in that tenant's schema. **Needs a platform that gives a
|
|
13
|
+
schema per tenant.**
|
|
14
|
+
- `database.migrate` applies migrations in every tenant's schema and
|
|
15
|
+
resolves with the versions applied, by tenant id. A tenant that starts
|
|
16
|
+
using the service later is migrated on first use.
|
|
17
|
+
- `database.tenants()` lists the tenants the service has a database for.
|
|
18
|
+
|
|
19
|
+
## 0.0.7 - 2026-11-12
|
|
20
|
+
|
|
21
|
+
- An identity names its tenant: `caller.tenantId`. One deployment serves
|
|
22
|
+
several tenants, and every request is for exactly one. A token with no
|
|
23
|
+
`tid` claim, or one that is not a tenant id, is rejected with reason
|
|
24
|
+
`claims`. **Needs a platform that sends `tid`.**
|
|
25
|
+
- `service.call` and `service.token` take `tenantId`, which says which
|
|
26
|
+
tenant a call to another service is for.
|
|
27
|
+
- New conformance vectors for `tid`.
|
|
28
|
+
|
|
7
29
|
## 0.0.6 - 2026-11-12
|
|
8
30
|
|
|
9
31
|
- `connectDatabase`: PostgreSQL for a service whose manifest sets
|
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ const identity = createIdentityVerifier({
|
|
|
51
51
|
// Per request.
|
|
52
52
|
try {
|
|
53
53
|
const caller = await identity.fromHeaders(req.headers);
|
|
54
|
+
caller.tenantId; // the tenant this request is for
|
|
54
55
|
caller.subject; // user id, or "service:<slug>"
|
|
55
56
|
caller.email; // null for a service calling as itself
|
|
56
57
|
caller.groups; // platform groups
|
|
@@ -71,6 +72,12 @@ publishes, names that gateway as issuer and this service as its only
|
|
|
71
72
|
audience, and has not expired. The rules are in `spec/identity-token.md` in
|
|
72
73
|
`FireTail-io/finchly`.
|
|
73
74
|
|
|
75
|
+
**The tenant.** One deployment serves several tenants, and every request
|
|
76
|
+
is for exactly one: `caller.tenantId`. Do everything for the caller within
|
|
77
|
+
that tenant, and store whatever you keep for them under it. Take it from
|
|
78
|
+
the verified identity only, never from a path, a header or a request body.
|
|
79
|
+
A token that names no tenant is rejected.
|
|
80
|
+
|
|
74
81
|
No other header is ever consulted. On a path the manifest declares in
|
|
75
82
|
`publicPaths` there is no token, and the service authenticates the request
|
|
76
83
|
itself.
|
|
@@ -91,20 +98,27 @@ const service = createServiceClient({
|
|
|
91
98
|
});
|
|
92
99
|
await service.start(); // optional: fail now, not on the first call
|
|
93
100
|
|
|
94
|
-
// As the service itself.
|
|
101
|
+
// As the service itself, for the tenant of the request being handled.
|
|
95
102
|
const res = await service.call("notification-service", "/api/send", {
|
|
103
|
+
tenantId: caller.tenantId,
|
|
96
104
|
method: "POST",
|
|
97
105
|
headers: { "content-type": "application/json" },
|
|
98
106
|
body: JSON.stringify({ text: "hello" }),
|
|
99
107
|
});
|
|
100
108
|
|
|
101
109
|
// For a user: the target sees that user, and this service acting for them.
|
|
102
|
-
await service.call("notification-service", "/api/inbox", {
|
|
110
|
+
await service.call("notification-service", "/api/inbox", {
|
|
111
|
+
tenantId: caller.tenantId,
|
|
112
|
+
asUser: caller.subject,
|
|
113
|
+
});
|
|
103
114
|
```
|
|
104
115
|
|
|
105
116
|
`call` takes the same options as `fetch` and resolves with the response,
|
|
106
117
|
whatever its status.
|
|
107
118
|
|
|
119
|
+
- **`tenantId` says which tenant the call is for.** The target then works
|
|
120
|
+
within that tenant. Pass it on every call to another service, and always
|
|
121
|
+
from a verified identity. A user named in `asUser` must belong to it.
|
|
108
122
|
- **403** means this service's manifest does not list the target in
|
|
109
123
|
`calls`, or the user has no access to the target.
|
|
110
124
|
- **A redirect is handed back, not followed.**
|
|
@@ -162,8 +176,8 @@ an app.
|
|
|
162
176
|
|
|
163
177
|
### A database of its own
|
|
164
178
|
|
|
165
|
-
A service whose manifest sets `permissions.db` is given a PostgreSQL
|
|
166
|
-
|
|
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:
|
|
167
181
|
|
|
168
182
|
```
|
|
169
183
|
npm install pg
|
|
@@ -172,9 +186,13 @@ npm install pg
|
|
|
172
186
|
```js
|
|
173
187
|
import { connectDatabase } from "@firetail/finchly";
|
|
174
188
|
|
|
175
|
-
// Once, at startup. Asks the platform for the role and
|
|
176
|
-
|
|
177
|
-
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);
|
|
178
196
|
|
|
179
197
|
const { rows } = await db.execute("SELECT id, body FROM notes WHERE author = $1", [caller.subject]);
|
|
180
198
|
|
|
@@ -183,28 +201,38 @@ await db.transaction(async (tx) => {
|
|
|
183
201
|
await tx.execute("UPDATE authors SET notes = notes + 1 WHERE id = $1", [caller.subject]);
|
|
184
202
|
});
|
|
185
203
|
|
|
186
|
-
// 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.
|
|
187
205
|
await db.withAdvisoryLock("nightly-import", async (tx) => {
|
|
188
206
|
// ...
|
|
189
207
|
});
|
|
190
208
|
```
|
|
191
209
|
|
|
192
|
-
- **
|
|
193
|
-
|
|
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.
|
|
194
216
|
- **Values go in the second argument**, as `$1`, `$2`. They are never built
|
|
195
217
|
into the SQL.
|
|
196
218
|
- **A transaction** commits if its function returns and rolls back if it
|
|
197
219
|
throws.
|
|
198
220
|
- **Migrations** are files named `NNNN_name.sql`, applied in order, once
|
|
199
|
-
each, in
|
|
200
|
-
they take turns. A
|
|
201
|
-
|
|
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.
|
|
202
228
|
- `connectDatabase` rejects with `database_not_granted` if the manifest
|
|
203
229
|
does not ask for a database, and with `database_unavailable` if the
|
|
204
|
-
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`.
|
|
205
232
|
|
|
206
233
|
To run a service on its own, without a platform, give the URL directly:
|
|
207
|
-
`connectDatabase({ url, slug })`.
|
|
234
|
+
`connectDatabase({ url, slug })`. Each tenant's schema is then made the
|
|
235
|
+
first time it is used.
|
|
208
236
|
|
|
209
237
|
### What every request carries
|
|
210
238
|
|
|
@@ -214,7 +242,7 @@ release is asking:
|
|
|
214
242
|
| Header | Value |
|
|
215
243
|
|---|---|
|
|
216
244
|
| `x-platform-sdk` | `@firetail/finchly` |
|
|
217
|
-
| `x-platform-sdk-version` | This release, for example `0.0.
|
|
245
|
+
| `x-platform-sdk-version` | This release, for example `0.0.8` |
|
|
218
246
|
| `x-platform-api-version` | The platform API version it speaks, `v1` |
|
|
219
247
|
|
|
220
248
|
That covers fetching the gateway's issuer and keys, registering an instance
|
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/identity.d.ts
CHANGED
|
@@ -3,6 +3,13 @@ import type { JSONWebKeySet, JWTPayload } from "jose";
|
|
|
3
3
|
export declare const IDENTITY_HEADER = "x-platform-identity";
|
|
4
4
|
/** Who is calling, as asserted by the gateway. */
|
|
5
5
|
export interface Identity {
|
|
6
|
+
/**
|
|
7
|
+
* The tenant the request is for. One deployment serves several tenants:
|
|
8
|
+
* everything done for this caller is done within this one, and whatever
|
|
9
|
+
* the service stores for them is stored under it. It comes from the
|
|
10
|
+
* verified token and from nowhere else.
|
|
11
|
+
*/
|
|
12
|
+
readonly tenantId: string;
|
|
6
13
|
/** The user's platform id, or `service:<slug>` for a service calling as itself. */
|
|
7
14
|
readonly subject: string;
|
|
8
15
|
/** True when the caller is a service calling as itself, with no user. */
|
package/dist/identity.js
CHANGED
|
@@ -7,7 +7,9 @@ import { SUPPORTED_PLATFORM_API_VERSIONS } from "./platform-api.js";
|
|
|
7
7
|
import { SDK_REQUEST_HEADERS } from "./request-headers.js";
|
|
8
8
|
/** Header the gateway sets on every request it proxies. */
|
|
9
9
|
export const IDENTITY_HEADER = "x-platform-identity";
|
|
10
|
-
const REQUIRED_CLAIMS = ["iss", "sub", "aud", "iat", "exp"];
|
|
10
|
+
const REQUIRED_CLAIMS = ["iss", "tid", "sub", "aud", "iat", "exp"];
|
|
11
|
+
/** What a tenant id looks like. See spec/identity-token.md. */
|
|
12
|
+
const TENANT_ID = /^t_[a-z2-7]{16}$/;
|
|
11
13
|
const DEFAULT_CLOCK_SKEW_SECONDS = 30;
|
|
12
14
|
const KEY_CACHE_MS = 60 * 60 * 1000;
|
|
13
15
|
const KEY_REFETCH_COOLDOWN_MS = 30 * 1000;
|
|
@@ -39,6 +41,11 @@ function toIdentity(payload, slug) {
|
|
|
39
41
|
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") {
|
|
40
42
|
throw new IdentityError("claims");
|
|
41
43
|
}
|
|
44
|
+
// Every token is for one tenant. One that does not say which is not usable.
|
|
45
|
+
const tenantId = payload.tid;
|
|
46
|
+
if (typeof tenantId !== "string" || !TENANT_ID.test(tenantId)) {
|
|
47
|
+
throw new IdentityError("claims");
|
|
48
|
+
}
|
|
42
49
|
const email = optionalString(payload.email);
|
|
43
50
|
const name = optionalString(payload.name);
|
|
44
51
|
const sessionId = optionalString(payload.sid);
|
|
@@ -60,6 +67,7 @@ function toIdentity(payload, slug) {
|
|
|
60
67
|
throw new IdentityError("claims");
|
|
61
68
|
}
|
|
62
69
|
return Object.freeze({
|
|
70
|
+
tenantId,
|
|
63
71
|
subject: payload.sub,
|
|
64
72
|
service: payload.sub.startsWith("service:"),
|
|
65
73
|
email,
|
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";
|
package/dist/service.d.ts
CHANGED
|
@@ -46,10 +46,18 @@ export declare function enrolmentFrom(config: PlatformConfig, manifest: Manifest
|
|
|
46
46
|
readonly bundle?: string;
|
|
47
47
|
}): Enrolment | undefined;
|
|
48
48
|
export interface CallOptions {
|
|
49
|
+
/**
|
|
50
|
+
* The tenant this call is made for: `caller.tenantId` of the request
|
|
51
|
+
* being handled. The target then works within that tenant. Take it from
|
|
52
|
+
* a verified identity, never from the request itself. Needed on every
|
|
53
|
+
* call to another service; only the service's own calls to the platform
|
|
54
|
+
* (its settings) name none.
|
|
55
|
+
*/
|
|
56
|
+
readonly tenantId?: string;
|
|
49
57
|
/**
|
|
50
58
|
* The id of the user this call is made for. The target then sees that
|
|
51
59
|
* user, and this service as the one acting for them. Leave it out to
|
|
52
|
-
* call as the service itself.
|
|
60
|
+
* call as the service itself. The user must belong to `tenantId`.
|
|
53
61
|
*/
|
|
54
62
|
readonly asUser?: string;
|
|
55
63
|
}
|
package/dist/service.js
CHANGED
|
@@ -14,6 +14,7 @@ import { SDK_REQUEST_HEADERS } from "./request-headers.js";
|
|
|
14
14
|
import { SDK_NAME, SDK_VERSION } from "./version.js";
|
|
15
15
|
const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
16
16
|
const USER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
17
|
+
const TENANT_ID = /^t_[a-z2-7]{16}$/;
|
|
17
18
|
/** The audience of a token meant for the gateway itself. */
|
|
18
19
|
const GATEWAY_AUDIENCE = "gateway";
|
|
19
20
|
const KEYS_PATH = "/_gateway/keys";
|
|
@@ -64,9 +65,9 @@ export function createServiceClient(options) {
|
|
|
64
65
|
/** Set when a call came back 401: the gateway may no longer know the key. */
|
|
65
66
|
let doubtedAt;
|
|
66
67
|
let lastForcedAt = Number.NEGATIVE_INFINITY;
|
|
67
|
-
async function sign(instance, audience, subject) {
|
|
68
|
+
async function sign(instance, audience, subject, tenantId) {
|
|
68
69
|
const issuedAt = Math.floor(now().getTime() / 1000);
|
|
69
|
-
return new SignJWT({})
|
|
70
|
+
return new SignJWT(tenantId !== undefined ? { tid: tenantId } : {})
|
|
70
71
|
.setProtectedHeader({ alg: "ES256", kid: instance.kid, typ: "JWT" })
|
|
71
72
|
.setIssuer(issuer)
|
|
72
73
|
.setSubject(subject)
|
|
@@ -236,6 +237,13 @@ export function createServiceClient(options) {
|
|
|
236
237
|
}
|
|
237
238
|
return pending;
|
|
238
239
|
}
|
|
240
|
+
function tenantFor(callOptions) {
|
|
241
|
+
const tenantId = callOptions?.tenantId;
|
|
242
|
+
if (tenantId !== undefined && (typeof tenantId !== "string" || !TENANT_ID.test(tenantId))) {
|
|
243
|
+
throw invalid("tenantId must be a tenant id, as in caller.tenantId");
|
|
244
|
+
}
|
|
245
|
+
return tenantId;
|
|
246
|
+
}
|
|
239
247
|
function subjectFor(callOptions) {
|
|
240
248
|
const user = callOptions?.asUser;
|
|
241
249
|
if (user === undefined)
|
|
@@ -255,7 +263,8 @@ export function createServiceClient(options) {
|
|
|
255
263
|
if (!SLUG.test(target))
|
|
256
264
|
throw invalid(`target ${JSON.stringify(target)} is not a slug`);
|
|
257
265
|
const subject = subjectFor(callOptions);
|
|
258
|
-
|
|
266
|
+
const tenantId = tenantFor(callOptions);
|
|
267
|
+
return sign(await instanceKey(), target, subject, tenantId);
|
|
259
268
|
},
|
|
260
269
|
async call(target, path, init = {}) {
|
|
261
270
|
if (!SLUG.test(target))
|
|
@@ -264,9 +273,10 @@ export function createServiceClient(options) {
|
|
|
264
273
|
if (!path.startsWith("/") || path.includes("\\") || /(^|\/)\.\.?(\/|$|\?)/.test(path.split("?")[0] ?? "")) {
|
|
265
274
|
throw invalid('path must start with "/" and contain no "." or ".." segments');
|
|
266
275
|
}
|
|
267
|
-
const { asUser, ...request } = init;
|
|
276
|
+
const { asUser, tenantId, ...request } = init;
|
|
268
277
|
const subject = subjectFor(asUser !== undefined ? { asUser } : undefined);
|
|
269
|
-
const
|
|
278
|
+
const tenant = tenantFor(tenantId !== undefined ? { tenantId } : undefined);
|
|
279
|
+
const token = await sign(await instanceKey(), target, subject, tenant);
|
|
270
280
|
const headers = new Headers(request.headers);
|
|
271
281
|
// Whatever the caller set, the gateway reads this one.
|
|
272
282
|
headers.set("authorization", `Bearer ${token}`);
|