@firetail/finchly 0.0.5 → 0.0.7

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 CHANGED
@@ -4,6 +4,27 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.0.7 - 2026-11-12
8
+
9
+ - An identity names its tenant: `caller.tenantId`. One deployment serves
10
+ several tenants, and every request is for exactly one. A token with no
11
+ `tid` claim, or one that is not a tenant id, is rejected with reason
12
+ `claims`. **Needs a platform that sends `tid`.**
13
+ - `service.call` and `service.token` take `tenantId`, which says which
14
+ tenant a call to another service is for.
15
+ - New conformance vectors for `tid`.
16
+
17
+ ## 0.0.6 - 2026-11-12
18
+
19
+ - `connectDatabase`: PostgreSQL for a service whose manifest sets
20
+ `permissions.db`. It asks the platform for the role and schema made for
21
+ the service and connects with the `pg` package, which the service
22
+ installs itself. `execute`, `transaction`, `withAdvisoryLock`, `migrate`.
23
+ - `fetchSettings`: what the platform holds for the service.
24
+ - New error codes: `database_unavailable`, `database_not_granted`,
25
+ `migration_invalid`.
26
+ - `pg` is an optional peer dependency.
27
+
7
28
  ## 0.0.5 - 2026-11-12
8
29
 
9
30
  - The README covers using the SDK only. Notes for people working on the
package/README.md CHANGED
@@ -5,7 +5,7 @@ A service or app uses it to register with a Finchly deployment, verify who
5
5
  is calling it, and reach the platform's database, object store, queue and
6
6
  other services.
7
7
 
8
- Status: early. The package builds and is tested, but only the first four
8
+ Status: early. The package builds and is tested, but only the first five
9
9
  modules exist.
10
10
 
11
11
  The SDK implements the platform API, which is specified in
@@ -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", { asUser: caller.subject });
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.**
@@ -160,6 +174,52 @@ The other half of the `app` module is not built: serving the
160
174
  that is already running, and fetching the settings the platform creates for
161
175
  an app.
162
176
 
177
+ ### A database of its own
178
+
179
+ A service whose manifest sets `permissions.db` is given a PostgreSQL role
180
+ and a schema that only that role can use. The service installs `pg` itself:
181
+
182
+ ```
183
+ npm install pg
184
+ ```
185
+
186
+ ```js
187
+ import { connectDatabase } from "@firetail/finchly";
188
+
189
+ // Once, at startup. Asks the platform for the role and schema it made.
190
+ const db = await connectDatabase({ service });
191
+ await db.migrate(new URL("./migrations/", import.meta.url).pathname);
192
+
193
+ const { rows } = await db.execute("SELECT id, body FROM notes WHERE author = $1", [caller.subject]);
194
+
195
+ await db.transaction(async (tx) => {
196
+ await tx.execute("INSERT INTO notes (body, author) VALUES ($1, $2)", ["hello", caller.subject]);
197
+ await tx.execute("UPDATE authors SET notes = notes + 1 WHERE id = $1", [caller.subject]);
198
+ });
199
+
200
+ // One instance at a time, across every instance of this service.
201
+ await db.withAdvisoryLock("nightly-import", async (tx) => {
202
+ // ...
203
+ });
204
+ ```
205
+
206
+ - **Names need no schema.** `notes` means the service's own table. The
207
+ service cannot read the platform's tables or another service's.
208
+ - **Values go in the second argument**, as `$1`, `$2`. They are never built
209
+ into the SQL.
210
+ - **A transaction** commits if its function returns and rolls back if it
211
+ throws.
212
+ - **Migrations** are files named `NNNN_name.sql`, applied in order, once
213
+ each, in one transaction. Every instance may call `migrate` at startup:
214
+ they take turns. A migration that was applied is never edited; add a new
215
+ one. A problem is a `FinchlyError` with code `migration_invalid`.
216
+ - `connectDatabase` rejects with `database_not_granted` if the manifest
217
+ does not ask for a database, and with `database_unavailable` if the
218
+ platform or the database cannot be reached.
219
+
220
+ To run a service on its own, without a platform, give the URL directly:
221
+ `connectDatabase({ url, slug })`.
222
+
163
223
  ### What every request carries
164
224
 
165
225
  Every request this SDK makes to the platform says which SDK and which
@@ -168,7 +228,7 @@ release is asking:
168
228
  | Header | Value |
169
229
  |---|---|
170
230
  | `x-platform-sdk` | `@firetail/finchly` |
171
- | `x-platform-sdk-version` | This release, for example `0.0.5` |
231
+ | `x-platform-sdk-version` | This release, for example `0.0.7` |
172
232
  | `x-platform-api-version` | The platform API version it speaks, `v1` |
173
233
 
174
234
  That covers fetching the gateway's issuer and keys, registering an instance
@@ -198,7 +258,7 @@ not leak them.
198
258
  | `identity` | Verify `x-platform-identity` | Done. Directory lookups (`listUsers`, `membersOf`) and `require*` helpers are planned |
199
259
  | `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 |
200
260
  | `service` | Call other services through the gateway, as the service or for a user | Done, for built-in services and for apps |
201
- | `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Planned |
261
+ | `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Done |
202
262
  | `blobs` | Object storage with conditional writes | Planned |
203
263
  | `queue` | Publish, lease, ack, nack | Planned |
204
264
  | `secrets` | Secrets and envelope encryption | Planned |
package/dist/db.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { ServiceClient } from "./service.js";
2
+ export interface QueryResult<Row> {
3
+ readonly rows: Row[];
4
+ readonly rowCount: number;
5
+ }
6
+ /** Something SQL can be run on: the database, or one transaction. */
7
+ export interface Queryable {
8
+ /**
9
+ * Run a statement. Values go in `params` and are referred to as $1, $2
10
+ * and so on; never build them into the SQL. With no `params`, `sql` may
11
+ * hold several statements.
12
+ */
13
+ execute<Row = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<QueryResult<Row>>;
14
+ }
15
+ export interface Database extends Queryable {
16
+ /** The service's schema, when the platform said which it is. Unqualified names already mean this schema. */
17
+ readonly schema: string | undefined;
18
+ /** Run `fn` in one transaction. It commits if `fn` returns and rolls back if it throws. */
19
+ transaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
20
+ /**
21
+ * Run `fn` in a transaction that holds a lock named `key`. Another
22
+ * instance of this service that asks for the same key waits until this
23
+ * transaction ends. The lock is this service's own: another service
24
+ * using the same name does not wait for it.
25
+ */
26
+ withAdvisoryLock<T>(key: string, fn: (tx: Queryable) => Promise<T>): Promise<T>;
27
+ /**
28
+ * Apply the migrations in `directory` that have not been applied: files
29
+ * named NNNN_name.sql, in order, once each. Safe to call from every
30
+ * instance at startup. Resolves with the versions this call applied.
31
+ */
32
+ migrate(directory: string): Promise<string[]>;
33
+ close(): Promise<void>;
34
+ }
35
+ /** What the platform holds for this service. See spec/registration.md, section 4. */
36
+ export interface PlatformSettings {
37
+ readonly slug: string;
38
+ readonly database?: {
39
+ readonly url: string;
40
+ readonly schema: string;
41
+ };
42
+ }
43
+ export interface DatabaseOptions {
44
+ /** The service's client. The database settings are fetched with it. */
45
+ readonly service?: Pick<ServiceClient, "call">;
46
+ /**
47
+ * Connect to this URL and do not ask the platform. For tests and for
48
+ * running a service on its own. Needs `slug`.
49
+ */
50
+ readonly url?: string;
51
+ /** The service's slug. Only needed with `url`. */
52
+ readonly slug?: string;
53
+ /** The most connections to hold open. Default 10. */
54
+ readonly maxConnections?: number;
55
+ }
56
+ /** Ask the platform what it holds for this service. */
57
+ export declare function fetchSettings(service: Pick<ServiceClient, "call">): Promise<PlatformSettings>;
58
+ /**
59
+ * Connect to this service's database. With `service`, the platform is
60
+ * asked for the role and schema it made; the manifest must set
61
+ * permissions.db. Rejects if the database cannot be reached.
62
+ */
63
+ export declare function connectDatabase(options: DatabaseOptions): Promise<Database>;
package/dist/db.js ADDED
@@ -0,0 +1,211 @@
1
+ // PostgreSQL for a service: the role and schema the platform made for it.
2
+ //
3
+ // The platform gives each service that asks for one (permissions.db in the
4
+ // manifest) a database role and a schema only that role can use. This
5
+ // module asks the platform for them and connects. It needs the `pg`
6
+ // package, which the service installs itself.
7
+ import { createHash } from "node:crypto";
8
+ import { readdir, readFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { FinchlyError } from "./errors.js";
11
+ /** Ask the platform what it holds for this service. */
12
+ export async function fetchSettings(service) {
13
+ const response = await service.call("core", "/api/apps/self/config");
14
+ if (response.status !== 200) {
15
+ throw new FinchlyError("database_unavailable", "The platform did not give this service its settings", [
16
+ `GET /core/api/apps/self/config answered ${response.status}`,
17
+ ]);
18
+ }
19
+ const body = (await response.json().catch(() => null));
20
+ if (body === null || typeof body !== "object" || typeof body.slug !== "string") {
21
+ throw new FinchlyError("database_unavailable", "The platform's settings answer could not be read");
22
+ }
23
+ return body;
24
+ }
25
+ async function loadPg() {
26
+ // A variable, so that TypeScript does not look for pg's types.
27
+ const name = "pg";
28
+ try {
29
+ const loaded = (await import(name));
30
+ return loaded.default ?? loaded;
31
+ }
32
+ catch {
33
+ throw new FinchlyError("database_unavailable", "The db module needs the `pg` package", [
34
+ "Install it in the service: npm install pg",
35
+ ]);
36
+ }
37
+ }
38
+ function toResult(result) {
39
+ // Several statements in one call give one result each. The last is the answer.
40
+ const last = (Array.isArray(result) ? result[result.length - 1] : result);
41
+ return { rows: last?.rows ?? [], rowCount: last?.rowCount ?? 0 };
42
+ }
43
+ function queryable(target) {
44
+ return {
45
+ async execute(sql, params) {
46
+ return toResult(params === undefined ? await target.query(sql) : await target.query(sql, [...params]));
47
+ },
48
+ };
49
+ }
50
+ const MIGRATION_FILE = /^(\d{4})_([a-z0-9_]+)\.sql$/;
51
+ async function readMigrations(directory) {
52
+ let files;
53
+ try {
54
+ files = (await readdir(directory)).filter((file) => file.endsWith(".sql")).sort();
55
+ }
56
+ catch {
57
+ throw new FinchlyError("migration_invalid", "The migrations directory could not be read", [directory]);
58
+ }
59
+ const problems = [];
60
+ const migrations = [];
61
+ for (const file of files) {
62
+ const match = MIGRATION_FILE.exec(file);
63
+ if (!match) {
64
+ problems.push(`${file} is not named NNNN_name.sql`);
65
+ continue;
66
+ }
67
+ const sql = await readFile(join(directory, file), "utf8");
68
+ if (migrations.at(-1)?.version === match[1]) {
69
+ problems.push(`Two migrations share the version ${match[1]}`);
70
+ }
71
+ migrations.push({
72
+ version: match[1],
73
+ name: match[2],
74
+ sql,
75
+ checksum: createHash("sha256").update(sql).digest("hex"),
76
+ });
77
+ }
78
+ if (problems.length > 0) {
79
+ throw new FinchlyError("migration_invalid", "The migrations cannot be applied", problems);
80
+ }
81
+ return migrations;
82
+ }
83
+ async function applyMigrations(tx, migrations) {
84
+ await tx.execute(`
85
+ CREATE TABLE IF NOT EXISTS platform_migrations (
86
+ version text PRIMARY KEY,
87
+ name text NOT NULL,
88
+ checksum text NOT NULL,
89
+ applied_at timestamptz NOT NULL DEFAULT now()
90
+ )
91
+ `);
92
+ const { rows } = await tx.execute("SELECT version, checksum FROM platform_migrations ORDER BY version");
93
+ const applied = new Map(rows.map((row) => [row.version, row.checksum]));
94
+ const known = new Set(migrations.map((migration) => migration.version));
95
+ const newestApplied = rows.at(-1)?.version ?? "";
96
+ const problems = [];
97
+ for (const version of applied.keys()) {
98
+ if (!known.has(version)) {
99
+ problems.push(`The database has migration ${version}, which this release of the service does not have`);
100
+ }
101
+ }
102
+ for (const migration of migrations) {
103
+ const checksum = applied.get(migration.version);
104
+ if (checksum !== undefined && checksum !== migration.checksum) {
105
+ problems.push(`${migration.version}_${migration.name} has changed since it was applied. Add a new migration instead`);
106
+ }
107
+ if (checksum === undefined && migration.version < newestApplied) {
108
+ problems.push(`${migration.version}_${migration.name} is older than ${newestApplied}, which is already applied`);
109
+ }
110
+ }
111
+ if (problems.length > 0) {
112
+ throw new FinchlyError("migration_invalid", "The migrations cannot be applied", problems);
113
+ }
114
+ const done = [];
115
+ for (const migration of migrations) {
116
+ if (applied.has(migration.version))
117
+ continue;
118
+ await tx.execute(migration.sql);
119
+ await tx.execute("INSERT INTO platform_migrations (version, name, checksum) VALUES ($1, $2, $3)", [
120
+ migration.version,
121
+ migration.name,
122
+ migration.checksum,
123
+ ]);
124
+ done.push(migration.version);
125
+ }
126
+ return done;
127
+ }
128
+ /**
129
+ * Connect to this service's database. With `service`, the platform is
130
+ * asked for the role and schema it made; the manifest must set
131
+ * permissions.db. Rejects if the database cannot be reached.
132
+ */
133
+ export async function connectDatabase(options) {
134
+ let url;
135
+ let slug;
136
+ let schema;
137
+ if (options.url !== undefined) {
138
+ if (!options.slug) {
139
+ throw new FinchlyError("database_unavailable", "connectDatabase needs `slug` when it is given `url`");
140
+ }
141
+ ({ url, slug } = { url: options.url, slug: options.slug });
142
+ }
143
+ else if (options.service) {
144
+ const settings = await fetchSettings(options.service);
145
+ if (!settings.database) {
146
+ throw new FinchlyError("database_not_granted", "The platform made no database for this service", [
147
+ "Set permissions.db to true in the manifest",
148
+ ]);
149
+ }
150
+ ({ url, schema } = settings.database);
151
+ slug = settings.slug;
152
+ }
153
+ else {
154
+ throw new FinchlyError("database_unavailable", "connectDatabase needs `service`, or `url` and `slug`");
155
+ }
156
+ const pg = await loadPg();
157
+ const pool = new pg.Pool({ connectionString: url, max: options.maxConnections ?? 10 });
158
+ // An idle connection that drops must not take the process down.
159
+ pool.on("error", () => { });
160
+ const onPool = queryable(pool);
161
+ try {
162
+ await pool.query("SELECT 1");
163
+ }
164
+ catch (err) {
165
+ await pool.end().catch(() => { });
166
+ // pg's message names the host and the role, never the password.
167
+ throw new FinchlyError("database_unavailable", "The database could not be reached", [err.message]);
168
+ }
169
+ async function transaction(fn) {
170
+ const client = await pool.connect();
171
+ let broken;
172
+ try {
173
+ await client.query("BEGIN");
174
+ const result = await fn(queryable(client));
175
+ await client.query("COMMIT");
176
+ return result;
177
+ }
178
+ catch (err) {
179
+ try {
180
+ await client.query("ROLLBACK");
181
+ }
182
+ catch (rollbackError) {
183
+ // The connection is in an unknown state. Do not put it back.
184
+ broken = rollbackError;
185
+ }
186
+ throw err;
187
+ }
188
+ finally {
189
+ client.release(broken);
190
+ }
191
+ }
192
+ const withAdvisoryLock = (key, fn) => transaction(async (tx) => {
193
+ // Advisory locks are shared by everything in the database, so the
194
+ // name carries the service's slug.
195
+ await tx.execute("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`svc:${slug}:${key}`]);
196
+ return fn(tx);
197
+ });
198
+ return {
199
+ schema,
200
+ execute: onPool.execute,
201
+ transaction,
202
+ withAdvisoryLock,
203
+ async migrate(directory) {
204
+ const migrations = await readMigrations(directory);
205
+ // One transaction: instances starting together take turns, and a
206
+ // migration that fails leaves nothing behind.
207
+ return withAdvisoryLock("migrate", (tx) => applyMigrations(tx, migrations));
208
+ },
209
+ close: () => pool.end(),
210
+ };
211
+ }
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";
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";
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
@@ -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,5 +1,7 @@
1
1
  export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
2
2
  export type { PlatformConfig } from "./config.js";
3
+ export { connectDatabase, fetchSettings } from "./db.js";
4
+ export type { Database, DatabaseOptions, PlatformSettings, QueryResult, Queryable } from "./db.js";
3
5
  export { FinchlyError, IdentityError } from "./errors.js";
4
6
  export type { FinchlyErrorCode, IdentityRejection } from "./errors.js";
5
7
  export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
2
+ export { connectDatabase, fetchSettings } from "./db.js";
2
3
  export { FinchlyError, IdentityError } from "./errors.js";
3
4
  export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
4
5
  export { checkManifest, loadManifest } from "./manifest.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
- return sign(await instanceKey(), target, subject);
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 token = await sign(await instanceKey(), target, subject);
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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firetail/finchly",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Node SDK for the Finchly platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "scripts": {
23
23
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
24
24
  "build": "npm run clean && tsc -p tsconfig.json",
25
- "test": "npm run build && node --test \"test/**/*.test.mjs\"",
25
+ "test": "npm run build && node test/support/with-postgres.mjs node --test \"test/**/*.test.mjs\"",
26
26
  "prepublishOnly": "npm test"
27
27
  },
28
28
  "repository": {
@@ -34,9 +34,18 @@
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^22.0.0",
37
- "typescript": "~5.9.3"
37
+ "typescript": "~5.9.3",
38
+ "pg": "^8.11.0"
38
39
  },
39
40
  "dependencies": {
40
41
  "jose": "^6.2.17"
42
+ },
43
+ "peerDependencies": {
44
+ "pg": "^8.11.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "pg": {
48
+ "optional": true
49
+ }
41
50
  }
42
51
  }