@firetail/finchly 0.0.6 → 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 +10 -0
- package/README.md +17 -3
- package/dist/identity.d.ts +7 -0
- package/dist/identity.js +9 -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,16 @@ 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
|
+
|
|
7
17
|
## 0.0.6 - 2026-11-12
|
|
8
18
|
|
|
9
19
|
- `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.**
|
|
@@ -214,7 +228,7 @@ release is asking:
|
|
|
214
228
|
| Header | Value |
|
|
215
229
|
|---|---|
|
|
216
230
|
| `x-platform-sdk` | `@firetail/finchly` |
|
|
217
|
-
| `x-platform-sdk-version` | This release, for example `0.0.
|
|
231
|
+
| `x-platform-sdk-version` | This release, for example `0.0.7` |
|
|
218
232
|
| `x-platform-api-version` | The platform API version it speaks, `v1` |
|
|
219
233
|
|
|
220
234
|
That covers fetching the gateway's issuer and keys, registering an instance
|
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/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}`);
|