@firetail/finchly 0.0.0-stage → 0.0.1
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 +28 -0
- package/README.md +207 -2
- package/dist/config.d.ts +30 -0
- package/dist/config.js +68 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +27 -0
- package/dist/identity.d.ts +58 -0
- package/dist/identity.js +232 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/manifest.d.ts +49 -0
- package/dist/manifest.js +71 -0
- package/dist/platform-api.d.ts +6 -0
- package/dist/platform-api.js +8 -0
- package/dist/service.d.ts +43 -0
- package/dist/service.js +201 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +8 -0
- package/package.json +40 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.0.1 - 2026-11-11
|
|
8
|
+
|
|
9
|
+
The first release, published by hand so that the package exists and
|
|
10
|
+
trusted publishing can be set up. Later releases come from the release
|
|
11
|
+
workflow.
|
|
12
|
+
|
|
13
|
+
- Package skeleton under its final name.
|
|
14
|
+
- `loadConfig`: read and validate the `PLATFORM_*` settings.
|
|
15
|
+
- `loadManifest`, `checkManifest`: load a manifest and check the fields the
|
|
16
|
+
SDK depends on.
|
|
17
|
+
- `PLATFORM_API_VERSION`, `SUPPORTED_PLATFORM_API_VERSIONS`, `SDK_NAME`,
|
|
18
|
+
`SDK_VERSION`.
|
|
19
|
+
- `createIdentityVerifier`: verify the `x-platform-identity` token against
|
|
20
|
+
the gateway's published keys. Rejections are an `IdentityError` with a
|
|
21
|
+
`reason` from the specification. Tested against the identity token
|
|
22
|
+
conformance vectors.
|
|
23
|
+
- New dependency: `jose`.
|
|
24
|
+
- `createServiceClient`: call another service through the gateway, as the
|
|
25
|
+
service itself or for a user. Makes the instance key, registers it with
|
|
26
|
+
the service credential, and extends it before it expires.
|
|
27
|
+
- `loadConfig` reads `PLATFORM_SERVICE_CREDENTIAL`.
|
|
28
|
+
- New error codes: `service_call_invalid`, `service_key_rejected`.
|
package/README.md
CHANGED
|
@@ -1,3 +1,208 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @firetail/finchly
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Node SDK for the [Finchly](https://github.com/firetail-io/finchly) platform.
|
|
4
|
+
A service or app uses it to register with a Finchly deployment, verify who
|
|
5
|
+
is calling it, and reach the platform's database, object store, queue and
|
|
6
|
+
other services.
|
|
7
|
+
|
|
8
|
+
Status: early. The package builds and is tested, but only the first four
|
|
9
|
+
modules exist.
|
|
10
|
+
|
|
11
|
+
The SDK implements the platform API, which is specified in
|
|
12
|
+
`firetail-io/finchly` under `spec/`. This release speaks platform API `v1`
|
|
13
|
+
(draft).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
npm install @firetail/finchly
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Node 22 or later. ESM only.
|
|
22
|
+
|
|
23
|
+
## Use
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import { loadConfig, loadManifest } from "@firetail/finchly";
|
|
27
|
+
|
|
28
|
+
const config = loadConfig(); // reads PLATFORM_* settings
|
|
29
|
+
const manifest = await loadManifest(config.manifestPath);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Both throw a `FinchlyError` with a stable `code` and a `problems` list that
|
|
33
|
+
names everything wrong, so a misconfigured container fails once with the
|
|
34
|
+
full picture.
|
|
35
|
+
|
|
36
|
+
### Who is calling
|
|
37
|
+
|
|
38
|
+
The gateway puts a short-lived signed token in the `x-platform-identity`
|
|
39
|
+
header of every request it proxies. It is the only identity a service
|
|
40
|
+
should accept.
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { IdentityError, createIdentityVerifier } from "@firetail/finchly";
|
|
44
|
+
|
|
45
|
+
// Once, at startup. It caches the gateway's issuer and signing keys.
|
|
46
|
+
const identity = createIdentityVerifier({
|
|
47
|
+
slug: manifest.slug,
|
|
48
|
+
gatewayUrl: config.gatewayUrl,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Per request.
|
|
52
|
+
try {
|
|
53
|
+
const caller = await identity.fromHeaders(req.headers);
|
|
54
|
+
caller.subject; // user id, or "service:<slug>"
|
|
55
|
+
caller.email; // null for a service calling as itself
|
|
56
|
+
caller.groups; // platform groups
|
|
57
|
+
caller.roles; // roles for this service: "user", "operator"
|
|
58
|
+
caller.actor; // "service:<slug>" when a service acts for the user
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err instanceof IdentityError) {
|
|
61
|
+
// Unauthenticated: respond 401. err.reason is for logs only.
|
|
62
|
+
} else {
|
|
63
|
+
// The gateway could not be reached: respond 503.
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A token is rejected unless it is signed with ES256 by a key the gateway
|
|
70
|
+
publishes, names that gateway as issuer and this service as its only
|
|
71
|
+
audience, and has not expired. The rules are in `spec/identity-token.md` in
|
|
72
|
+
`firetail-io/finchly`.
|
|
73
|
+
|
|
74
|
+
No other header is ever consulted. On a path the manifest declares in
|
|
75
|
+
`publicPaths` there is no token, and the service authenticates the request
|
|
76
|
+
itself.
|
|
77
|
+
|
|
78
|
+
### Calling another service
|
|
79
|
+
|
|
80
|
+
Services never address each other directly. A call goes through the
|
|
81
|
+
gateway, signed by this instance's own key.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
import { createServiceClient } from "@firetail/finchly";
|
|
85
|
+
|
|
86
|
+
// Once, at startup.
|
|
87
|
+
const service = createServiceClient({
|
|
88
|
+
slug: manifest.slug,
|
|
89
|
+
gatewayUrl: config.gatewayUrl,
|
|
90
|
+
credential: config.serviceCredential,
|
|
91
|
+
});
|
|
92
|
+
await service.start(); // optional: fail now, not on the first call
|
|
93
|
+
|
|
94
|
+
// As the service itself.
|
|
95
|
+
const res = await service.call("notification-service", "/api/send", {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "content-type": "application/json" },
|
|
98
|
+
body: JSON.stringify({ text: "hello" }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// 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 });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`call` takes the same options as `fetch` and resolves with the response,
|
|
106
|
+
whatever its status.
|
|
107
|
+
|
|
108
|
+
- **403** means this service's manifest does not list the target in
|
|
109
|
+
`calls`, or the user has no access to the target.
|
|
110
|
+
- **A redirect is handed back, not followed.**
|
|
111
|
+
- **Nothing is retried.** After a 401 the next call registers the key
|
|
112
|
+
again, in case the gateway no longer knows it.
|
|
113
|
+
|
|
114
|
+
The instance makes an ES256 key pair in memory the first time it is needed.
|
|
115
|
+
The private half never leaves the process. The public half is registered
|
|
116
|
+
with the gateway using the credential the service was deployed with, and
|
|
117
|
+
extended by itself for as long as the instance runs. The rules are in
|
|
118
|
+
`spec/service-token.md` and `spec/registration.md` in `firetail-io/finchly`.
|
|
119
|
+
|
|
120
|
+
Only a built-in service has a credential. An app's key is accepted when it
|
|
121
|
+
registers, which the `app` module will do; until then an app cannot use
|
|
122
|
+
`service`.
|
|
123
|
+
|
|
124
|
+
### Settings
|
|
125
|
+
|
|
126
|
+
| Variable | Required | Meaning |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `PLATFORM_GATEWAY_URL` | yes | The gateway's internal address |
|
|
129
|
+
| `PLATFORM_MANIFEST_PATH` | no | Manifest path in the container. Default `/finchly/manifest.json` |
|
|
130
|
+
| `PLATFORM_APP_ADDRESS` | no | Address the gateway should use to reach this service |
|
|
131
|
+
| `PLATFORM_ENROLMENT_TOKEN` | no | Token for self-registration |
|
|
132
|
+
| `PLATFORM_SERVICE_CREDENTIAL` | no | What a built-in service registers its instance key with |
|
|
133
|
+
|
|
134
|
+
The enrolment token and the service credential are kept off the enumerable
|
|
135
|
+
properties of the config object, so logging or serialising the config does
|
|
136
|
+
not leak them.
|
|
137
|
+
|
|
138
|
+
## Modules
|
|
139
|
+
|
|
140
|
+
| Module | What it does | State |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `config` | Read and validate the platform settings | Done |
|
|
143
|
+
| `manifest` | Load the manifest and check what the SDK depends on | Done |
|
|
144
|
+
| `identity` | Verify `x-platform-identity` | Done. Directory lookups (`listUsers`, `membersOf`) and `require*` helpers are planned |
|
|
145
|
+
| `app` | Register with the platform, serve `/_platform/*`, renew keys, fetch settings | Next |
|
|
146
|
+
| `service` | Call other services through the gateway, as the service or for a user | Done for built-in services. An app's first key waits on `app` |
|
|
147
|
+
| `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Planned |
|
|
148
|
+
| `blobs` | Object storage with conditional writes | Planned |
|
|
149
|
+
| `queue` | Publish, lease, ack, nack | Planned |
|
|
150
|
+
| `secrets` | Secrets and envelope encryption | Planned |
|
|
151
|
+
| `llm` | Model calls through the platform | Planned |
|
|
152
|
+
| `audit`, `usage`, `notify`, `schedule`, `connectors` | Clients for platform services | Planned |
|
|
153
|
+
| `ui` | Shared header, footer and theme | Planned |
|
|
154
|
+
|
|
155
|
+
The manifest check here is not the full schema. The platform runs the full
|
|
156
|
+
check at registration and is the authority.
|
|
157
|
+
|
|
158
|
+
## Develop
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
npm install
|
|
162
|
+
npm test
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`npm test` compiles with `tsc` and runs the tests in `test/` against the
|
|
166
|
+
build, which is what a consumer imports.
|
|
167
|
+
|
|
168
|
+
### Conformance vectors
|
|
169
|
+
|
|
170
|
+
`test/conformance/` holds test vectors copied from `firetail-io/finchly`
|
|
171
|
+
(`conformance/vectors/`). Both SDKs must reach the verdict each vector
|
|
172
|
+
states. Do not edit them here: change them in `finchly`, regenerate, and
|
|
173
|
+
copy the file to both SDK repositories. Until `finchly` publishes its
|
|
174
|
+
conformance suite as an image, the copy is made by hand.
|
|
175
|
+
|
|
176
|
+
## Release
|
|
177
|
+
|
|
178
|
+
Each package version is semver, independent of the Python SDK and of the
|
|
179
|
+
platform.
|
|
180
|
+
|
|
181
|
+
1. Changes land on `main` by pull request. Each one adds a line to
|
|
182
|
+
`CHANGELOG.md` under "Unreleased".
|
|
183
|
+
2. To release, set the version in `package.json`, move the changelog
|
|
184
|
+
entries under that version, merge, and tag the merge commit `vX.Y.Z`.
|
|
185
|
+
3. The `release` workflow checks the tag matches `package.json`, runs the
|
|
186
|
+
tests, and publishes.
|
|
187
|
+
4. A version with a pre-release part (`1.2.0-rc.1`) is published under the
|
|
188
|
+
`next` dist-tag. Anything else is published as `latest`.
|
|
189
|
+
5. A bad release is deprecated with `npm deprecate` and followed by a patch
|
|
190
|
+
release. It is not unpublished.
|
|
191
|
+
|
|
192
|
+
Publishing uses npm trusted publishing: the workflow proves its identity to
|
|
193
|
+
npm with its GitHub Actions token, so no npm token is stored in this
|
|
194
|
+
repository, and npm attaches a provenance attestation.
|
|
195
|
+
|
|
196
|
+
One-time setup, not yet done:
|
|
197
|
+
|
|
198
|
+
- On npmjs.com, add this repository and the `release.yml` workflow as a
|
|
199
|
+
trusted publisher for `@firetail/finchly`, with the `npm` environment.
|
|
200
|
+
- Create a GitHub environment called `npm` in this repository, restricted
|
|
201
|
+
to tags.
|
|
202
|
+
- npm may require the package to exist before a trusted publisher can be
|
|
203
|
+
added. If so, the very first publish is done by hand by an owner of the
|
|
204
|
+
`@firetail` scope, and every release after that goes through the
|
|
205
|
+
workflow.
|
|
206
|
+
|
|
207
|
+
Still to add: the conformance suite from `firetail-io/finchly`, run in CI on
|
|
208
|
+
every change, once that suite exists.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Where the manifest lives in an image unless PLATFORM_MANIFEST_PATH says otherwise. */
|
|
2
|
+
export declare const DEFAULT_MANIFEST_PATH = "/finchly/manifest.json";
|
|
3
|
+
/** Settings a service is deployed with. See spec/registration.md, section 1. */
|
|
4
|
+
export interface PlatformConfig {
|
|
5
|
+
/** The gateway's internal address, without a trailing slash. */
|
|
6
|
+
readonly gatewayUrl: string;
|
|
7
|
+
/** Path of the manifest inside the container. */
|
|
8
|
+
readonly manifestPath: string;
|
|
9
|
+
/** Address the gateway should use to reach this service, if set. */
|
|
10
|
+
readonly appAddress?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Enrolment token for self-registration, if set. Not enumerable, so it
|
|
13
|
+
* does not appear when the config is logged or serialised.
|
|
14
|
+
*/
|
|
15
|
+
readonly enrolmentToken?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The credential a built-in service registers its instance key with, if
|
|
18
|
+
* set. Not enumerable, like the enrolment token.
|
|
19
|
+
*/
|
|
20
|
+
readonly serviceCredential?: string;
|
|
21
|
+
}
|
|
22
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
23
|
+
/**
|
|
24
|
+
* Read the platform settings from the environment.
|
|
25
|
+
*
|
|
26
|
+
* Throws a FinchlyError listing every problem, so a misconfigured container
|
|
27
|
+
* fails once with the full picture.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadConfig(env?: Env): PlatformConfig;
|
|
30
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { FinchlyError } from "./errors.js";
|
|
2
|
+
/** Where the manifest lives in an image unless PLATFORM_MANIFEST_PATH says otherwise. */
|
|
3
|
+
export const DEFAULT_MANIFEST_PATH = "/finchly/manifest.json";
|
|
4
|
+
function httpUrl(name, value, problems) {
|
|
5
|
+
let url;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(value);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
problems.push(`${name} is not a URL`);
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
14
|
+
problems.push(`${name} must be an http or https URL`);
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
if (url.username || url.password) {
|
|
18
|
+
problems.push(`${name} must not contain credentials`);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (url.search || url.hash) {
|
|
22
|
+
problems.push(`${name} must not have a query string or fragment`);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return url.toString().replace(/\/+$/, "");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read the platform settings from the environment.
|
|
29
|
+
*
|
|
30
|
+
* Throws a FinchlyError listing every problem, so a misconfigured container
|
|
31
|
+
* fails once with the full picture.
|
|
32
|
+
*/
|
|
33
|
+
export function loadConfig(env = process.env) {
|
|
34
|
+
const problems = [];
|
|
35
|
+
const rawGateway = env.PLATFORM_GATEWAY_URL?.trim();
|
|
36
|
+
let gatewayUrl;
|
|
37
|
+
if (!rawGateway) {
|
|
38
|
+
problems.push("PLATFORM_GATEWAY_URL is not set");
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
gatewayUrl = httpUrl("PLATFORM_GATEWAY_URL", rawGateway, problems);
|
|
42
|
+
}
|
|
43
|
+
const rawAddress = env.PLATFORM_APP_ADDRESS?.trim();
|
|
44
|
+
const appAddress = rawAddress ? httpUrl("PLATFORM_APP_ADDRESS", rawAddress, problems) : undefined;
|
|
45
|
+
const manifestPath = env.PLATFORM_MANIFEST_PATH?.trim() || DEFAULT_MANIFEST_PATH;
|
|
46
|
+
if (!manifestPath.startsWith("/")) {
|
|
47
|
+
problems.push("PLATFORM_MANIFEST_PATH must be an absolute path");
|
|
48
|
+
}
|
|
49
|
+
const enrolmentToken = env.PLATFORM_ENROLMENT_TOKEN?.trim() || undefined;
|
|
50
|
+
const serviceCredential = env.PLATFORM_SERVICE_CREDENTIAL?.trim() || undefined;
|
|
51
|
+
if (problems.length > 0 || gatewayUrl === undefined) {
|
|
52
|
+
throw new FinchlyError("config_invalid", "Platform settings are invalid", problems);
|
|
53
|
+
}
|
|
54
|
+
const config = {
|
|
55
|
+
gatewayUrl,
|
|
56
|
+
manifestPath,
|
|
57
|
+
...(appAddress !== undefined ? { appAddress } : {}),
|
|
58
|
+
};
|
|
59
|
+
for (const [name, value] of [
|
|
60
|
+
["enrolmentToken", enrolmentToken],
|
|
61
|
+
["serviceCredential", serviceCredential],
|
|
62
|
+
]) {
|
|
63
|
+
if (value !== undefined) {
|
|
64
|
+
Object.defineProperty(config, name, { value, enumerable: false });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze(config);
|
|
68
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected";
|
|
2
|
+
/**
|
|
3
|
+
* Every error this SDK throws on purpose. `code` is stable and safe to
|
|
4
|
+
* branch on; `problems` lists each thing that was wrong, so a caller can
|
|
5
|
+
* report all of them at once instead of one per restart.
|
|
6
|
+
*/
|
|
7
|
+
export declare class FinchlyError extends Error {
|
|
8
|
+
readonly code: FinchlyErrorCode;
|
|
9
|
+
readonly problems: readonly string[];
|
|
10
|
+
constructor(code: FinchlyErrorCode, message: string, problems?: readonly string[]);
|
|
11
|
+
}
|
|
12
|
+
/** Why an identity token was rejected. Defined in spec/identity-token.md. */
|
|
13
|
+
export type IdentityRejection = "missing" | "malformed" | "algorithm" | "unknown_key" | "signature" | "issuer" | "audience" | "expired" | "claims";
|
|
14
|
+
/**
|
|
15
|
+
* The request carries no acceptable identity. Treat it as unauthenticated
|
|
16
|
+
* (HTTP 401). `reason` is for logs; do not show it to the caller.
|
|
17
|
+
*/
|
|
18
|
+
export declare class IdentityError extends FinchlyError {
|
|
19
|
+
readonly reason: IdentityRejection;
|
|
20
|
+
constructor(reason: IdentityRejection);
|
|
21
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every error this SDK throws on purpose. `code` is stable and safe to
|
|
3
|
+
* branch on; `problems` lists each thing that was wrong, so a caller can
|
|
4
|
+
* report all of them at once instead of one per restart.
|
|
5
|
+
*/
|
|
6
|
+
export class FinchlyError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
problems;
|
|
9
|
+
constructor(code, message, problems = []) {
|
|
10
|
+
super(problems.length > 0 ? `${message}: ${problems.join("; ")}` : message);
|
|
11
|
+
this.name = "FinchlyError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.problems = Object.freeze([...problems]);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The request carries no acceptable identity. Treat it as unauthenticated
|
|
18
|
+
* (HTTP 401). `reason` is for logs; do not show it to the caller.
|
|
19
|
+
*/
|
|
20
|
+
export class IdentityError extends FinchlyError {
|
|
21
|
+
reason;
|
|
22
|
+
constructor(reason) {
|
|
23
|
+
super("identity_invalid", `Identity token rejected: ${reason}`);
|
|
24
|
+
this.name = "IdentityError";
|
|
25
|
+
this.reason = reason;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { JSONWebKeySet, JWTPayload } from "jose";
|
|
2
|
+
/** Header the gateway sets on every request it proxies. */
|
|
3
|
+
export declare const IDENTITY_HEADER = "x-platform-identity";
|
|
4
|
+
/** Who is calling, as asserted by the gateway. */
|
|
5
|
+
export interface Identity {
|
|
6
|
+
/** The user's platform id, or `service:<slug>` for a service calling as itself. */
|
|
7
|
+
readonly subject: string;
|
|
8
|
+
/** True when the caller is a service calling as itself, with no user. */
|
|
9
|
+
readonly service: boolean;
|
|
10
|
+
readonly email: string | null;
|
|
11
|
+
readonly name: string | null;
|
|
12
|
+
/** Platform group names. */
|
|
13
|
+
readonly groups: readonly string[];
|
|
14
|
+
/** The user's roles for this service. */
|
|
15
|
+
readonly roles: readonly string[];
|
|
16
|
+
/** How the caller was authenticated. */
|
|
17
|
+
readonly amr: readonly string[];
|
|
18
|
+
/** `service:<slug>` when a service is acting on the user's behalf. */
|
|
19
|
+
readonly actor: string | null;
|
|
20
|
+
readonly sessionId: string | null;
|
|
21
|
+
/** Every claim in the token. */
|
|
22
|
+
readonly claims: Readonly<JWTPayload>;
|
|
23
|
+
}
|
|
24
|
+
export interface IdentityVerifierOptions {
|
|
25
|
+
/** This service's slug. A token for any other service is rejected. */
|
|
26
|
+
readonly slug: string;
|
|
27
|
+
/**
|
|
28
|
+
* The gateway's internal address (PLATFORM_GATEWAY_URL). The issuer and
|
|
29
|
+
* signing keys are fetched from it. This is the normal way to configure
|
|
30
|
+
* the verifier.
|
|
31
|
+
*/
|
|
32
|
+
readonly gatewayUrl?: string;
|
|
33
|
+
/** Fixed issuer, used with `jwks` in place of `gatewayUrl`. For tests. */
|
|
34
|
+
readonly issuer?: string;
|
|
35
|
+
/** Fixed key set, used with `issuer` in place of `gatewayUrl`. For tests. */
|
|
36
|
+
readonly jwks?: JSONWebKeySet;
|
|
37
|
+
/** Allowed clock difference with the gateway. Default 30. */
|
|
38
|
+
readonly clockSkewSeconds?: number;
|
|
39
|
+
/** Clock, for tests. */
|
|
40
|
+
readonly now?: () => Date;
|
|
41
|
+
}
|
|
42
|
+
type HeaderBag = {
|
|
43
|
+
get(name: string): string | null;
|
|
44
|
+
} | Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
45
|
+
export interface IdentityVerifier {
|
|
46
|
+
/** Verify a token. Throws IdentityError if it is not acceptable. */
|
|
47
|
+
verify(token: string | null | undefined): Promise<Identity>;
|
|
48
|
+
/** Verify the token in a request's headers (Node or Fetch style). */
|
|
49
|
+
fromHeaders(headers: HeaderBag): Promise<Identity>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create a verifier for this service.
|
|
53
|
+
*
|
|
54
|
+
* Create one at startup and reuse it: it caches the gateway's issuer and
|
|
55
|
+
* signing keys.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createIdentityVerifier(options: IdentityVerifierOptions): IdentityVerifier;
|
|
58
|
+
export {};
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Verifies the x-platform-identity token. Every check here is required by
|
|
2
|
+
// spec/identity-token.md in firetail-io/finchly, and the conformance vectors
|
|
3
|
+
// in test/conformance pin the verdict for each kind of bad token.
|
|
4
|
+
import { createLocalJWKSet, createRemoteJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
5
|
+
import { FinchlyError, IdentityError } from "./errors.js";
|
|
6
|
+
import { SUPPORTED_PLATFORM_API_VERSIONS } from "./platform-api.js";
|
|
7
|
+
/** Header the gateway sets on every request it proxies. */
|
|
8
|
+
export const IDENTITY_HEADER = "x-platform-identity";
|
|
9
|
+
const REQUIRED_CLAIMS = ["iss", "sub", "aud", "iat", "exp"];
|
|
10
|
+
const DEFAULT_CLOCK_SKEW_SECONDS = 30;
|
|
11
|
+
const KEY_CACHE_MS = 60 * 60 * 1000;
|
|
12
|
+
const KEY_REFETCH_COOLDOWN_MS = 30 * 1000;
|
|
13
|
+
const GATEWAY_TIMEOUT_MS = 5000;
|
|
14
|
+
const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
15
|
+
function stringArray(value) {
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
return [];
|
|
18
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
|
19
|
+
return Object.freeze([...value]);
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
function optionalString(value) {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return null;
|
|
26
|
+
return typeof value === "string" ? value : undefined;
|
|
27
|
+
}
|
|
28
|
+
/** Build the Identity from verified claims, rejecting wrongly typed ones. */
|
|
29
|
+
function toIdentity(payload, slug) {
|
|
30
|
+
// A token is for exactly one service. A list of audiences that happens to
|
|
31
|
+
// include this one is not accepted.
|
|
32
|
+
if (payload.aud !== slug) {
|
|
33
|
+
throw new IdentityError("audience");
|
|
34
|
+
}
|
|
35
|
+
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
|
|
36
|
+
throw new IdentityError("claims");
|
|
37
|
+
}
|
|
38
|
+
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") {
|
|
39
|
+
throw new IdentityError("claims");
|
|
40
|
+
}
|
|
41
|
+
const email = optionalString(payload.email);
|
|
42
|
+
const name = optionalString(payload.name);
|
|
43
|
+
const sessionId = optionalString(payload.sid);
|
|
44
|
+
const groups = stringArray(payload.groups);
|
|
45
|
+
const roles = stringArray(payload.roles);
|
|
46
|
+
const amr = stringArray(payload.amr);
|
|
47
|
+
let actor = null;
|
|
48
|
+
if (payload.act !== undefined) {
|
|
49
|
+
const act = payload.act;
|
|
50
|
+
actor = typeof act === "object" && act !== null && typeof act.sub === "string" ? act.sub : undefined;
|
|
51
|
+
}
|
|
52
|
+
if (email === undefined ||
|
|
53
|
+
name === undefined ||
|
|
54
|
+
sessionId === undefined ||
|
|
55
|
+
groups === undefined ||
|
|
56
|
+
roles === undefined ||
|
|
57
|
+
amr === undefined ||
|
|
58
|
+
actor === undefined) {
|
|
59
|
+
throw new IdentityError("claims");
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
subject: payload.sub,
|
|
63
|
+
service: payload.sub.startsWith("service:"),
|
|
64
|
+
email,
|
|
65
|
+
name,
|
|
66
|
+
groups,
|
|
67
|
+
roles,
|
|
68
|
+
amr,
|
|
69
|
+
actor,
|
|
70
|
+
sessionId,
|
|
71
|
+
claims: Object.freeze({ ...payload }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function rejectionFor(err) {
|
|
75
|
+
if (err instanceof IdentityError)
|
|
76
|
+
return err;
|
|
77
|
+
if (err instanceof errors.JWTExpired)
|
|
78
|
+
return new IdentityError("expired");
|
|
79
|
+
if (err instanceof errors.JWTClaimValidationFailed) {
|
|
80
|
+
if (err.reason === "missing")
|
|
81
|
+
return new IdentityError("claims");
|
|
82
|
+
if (err.claim === "iss")
|
|
83
|
+
return new IdentityError("issuer");
|
|
84
|
+
if (err.claim === "aud")
|
|
85
|
+
return new IdentityError("audience");
|
|
86
|
+
return new IdentityError("claims");
|
|
87
|
+
}
|
|
88
|
+
if (err instanceof errors.JWKSNoMatchingKey || err instanceof errors.JWKSMultipleMatchingKeys) {
|
|
89
|
+
return new IdentityError("unknown_key");
|
|
90
|
+
}
|
|
91
|
+
if (err instanceof errors.JWSSignatureVerificationFailed)
|
|
92
|
+
return new IdentityError("signature");
|
|
93
|
+
if (err instanceof errors.JOSEAlgNotAllowed)
|
|
94
|
+
return new IdentityError("algorithm");
|
|
95
|
+
if (err instanceof errors.JWSInvalid || err instanceof errors.JWTInvalid) {
|
|
96
|
+
return new IdentityError("malformed");
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Create a verifier for this service.
|
|
102
|
+
*
|
|
103
|
+
* Create one at startup and reuse it: it caches the gateway's issuer and
|
|
104
|
+
* signing keys.
|
|
105
|
+
*/
|
|
106
|
+
export function createIdentityVerifier(options) {
|
|
107
|
+
const problems = [];
|
|
108
|
+
if (typeof options.slug !== "string" || !SLUG.test(options.slug)) {
|
|
109
|
+
problems.push("slug must be this service's slug");
|
|
110
|
+
}
|
|
111
|
+
const fixed = options.issuer !== undefined || options.jwks !== undefined;
|
|
112
|
+
if (fixed && options.gatewayUrl !== undefined) {
|
|
113
|
+
problems.push("give either gatewayUrl, or issuer and jwks, not both");
|
|
114
|
+
}
|
|
115
|
+
else if (fixed && (options.issuer === undefined || options.jwks === undefined)) {
|
|
116
|
+
problems.push("issuer and jwks must be given together");
|
|
117
|
+
}
|
|
118
|
+
else if (!fixed && !options.gatewayUrl) {
|
|
119
|
+
problems.push("gatewayUrl is required");
|
|
120
|
+
}
|
|
121
|
+
const skew = options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
|
|
122
|
+
if (!Number.isFinite(skew) || skew < 0) {
|
|
123
|
+
problems.push("clockSkewSeconds must be zero or more");
|
|
124
|
+
}
|
|
125
|
+
if (problems.length > 0) {
|
|
126
|
+
throw new FinchlyError("config_invalid", "Identity verifier options are invalid", problems);
|
|
127
|
+
}
|
|
128
|
+
const slug = options.slug;
|
|
129
|
+
const now = options.now ?? (() => new Date());
|
|
130
|
+
const gatewayUrl = options.gatewayUrl?.replace(/\/+$/, "");
|
|
131
|
+
let trust = options.issuer !== undefined && options.jwks !== undefined
|
|
132
|
+
? Promise.resolve({ issuer: options.issuer, getKey: createLocalJWKSet(options.jwks) })
|
|
133
|
+
: undefined;
|
|
134
|
+
async function discover() {
|
|
135
|
+
const url = `${gatewayUrl}/_gateway/.well-known/platform.json`;
|
|
136
|
+
let body;
|
|
137
|
+
try {
|
|
138
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS) });
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`HTTP ${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
body = await response.json();
|
|
143
|
+
}
|
|
144
|
+
catch (cause) {
|
|
145
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
146
|
+
throw new FinchlyError("gateway_unreachable", `Cannot read ${url}`, [reason]);
|
|
147
|
+
}
|
|
148
|
+
const doc = (typeof body === "object" && body !== null ? body : {});
|
|
149
|
+
if (typeof doc.issuer !== "string" || doc.issuer.length === 0) {
|
|
150
|
+
throw new FinchlyError("gateway_unreachable", `${url} does not name an issuer`);
|
|
151
|
+
}
|
|
152
|
+
const served = Array.isArray(doc.platformApi) ? doc.platformApi : [];
|
|
153
|
+
if (!served.some((v) => SUPPORTED_PLATFORM_API_VERSIONS.includes(v))) {
|
|
154
|
+
throw new FinchlyError("platform_api_unsupported", `The gateway serves platform API ${JSON.stringify(served)}; this SDK speaks ${SUPPORTED_PLATFORM_API_VERSIONS.join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
issuer: doc.issuer,
|
|
158
|
+
getKey: createRemoteJWKSet(new URL(`${gatewayUrl}/_gateway/.well-known/jwks.json`), {
|
|
159
|
+
cacheMaxAge: KEY_CACHE_MS,
|
|
160
|
+
cooldownDuration: KEY_REFETCH_COOLDOWN_MS,
|
|
161
|
+
timeoutDuration: GATEWAY_TIMEOUT_MS,
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function getTrust() {
|
|
166
|
+
if (trust === undefined) {
|
|
167
|
+
const attempt = discover();
|
|
168
|
+
trust = attempt;
|
|
169
|
+
// A failed discovery is not cached: the next request tries again.
|
|
170
|
+
attempt.catch(() => {
|
|
171
|
+
if (trust === attempt)
|
|
172
|
+
trust = undefined;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return trust;
|
|
176
|
+
}
|
|
177
|
+
async function verify(token) {
|
|
178
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
179
|
+
throw new IdentityError("missing");
|
|
180
|
+
}
|
|
181
|
+
let alg;
|
|
182
|
+
try {
|
|
183
|
+
alg = decodeProtectedHeader(token).alg;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
throw new IdentityError("malformed");
|
|
187
|
+
}
|
|
188
|
+
// Checked first and by hand, so that no other property of the token can
|
|
189
|
+
// influence what happens to one with the wrong algorithm.
|
|
190
|
+
if (alg !== "ES256") {
|
|
191
|
+
throw new IdentityError("algorithm");
|
|
192
|
+
}
|
|
193
|
+
const { issuer, getKey } = await getTrust();
|
|
194
|
+
let payload;
|
|
195
|
+
try {
|
|
196
|
+
({ payload } = await jwtVerify(token, getKey, {
|
|
197
|
+
algorithms: ["ES256"],
|
|
198
|
+
issuer,
|
|
199
|
+
audience: slug,
|
|
200
|
+
clockTolerance: skew,
|
|
201
|
+
currentDate: now(),
|
|
202
|
+
requiredClaims: REQUIRED_CLAIMS,
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
const rejection = rejectionFor(err);
|
|
207
|
+
if (rejection)
|
|
208
|
+
throw rejection;
|
|
209
|
+
// Anything else is a failure to fetch the keys, not a bad token. It
|
|
210
|
+
// must not be reported as the caller's fault.
|
|
211
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
212
|
+
throw new FinchlyError("gateway_unreachable", "Cannot fetch the gateway's signing keys", [reason]);
|
|
213
|
+
}
|
|
214
|
+
return toIdentity(payload, slug);
|
|
215
|
+
}
|
|
216
|
+
function fromHeaders(headers) {
|
|
217
|
+
let value;
|
|
218
|
+
if (typeof headers.get === "function") {
|
|
219
|
+
value = headers.get(IDENTITY_HEADER);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
value = headers[IDENTITY_HEADER];
|
|
223
|
+
}
|
|
224
|
+
// The gateway sets the header exactly once. More than one value means
|
|
225
|
+
// something else added one.
|
|
226
|
+
if (Array.isArray(value)) {
|
|
227
|
+
return Promise.reject(new IdentityError(value.length === 0 ? "missing" : "malformed"));
|
|
228
|
+
}
|
|
229
|
+
return verify(value);
|
|
230
|
+
}
|
|
231
|
+
return Object.freeze({ verify, fromHeaders });
|
|
232
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
|
+
export type { PlatformConfig } from "./config.js";
|
|
3
|
+
export { FinchlyError, IdentityError } from "./errors.js";
|
|
4
|
+
export type { FinchlyErrorCode, IdentityRejection } from "./errors.js";
|
|
5
|
+
export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
|
6
|
+
export type { Identity, IdentityVerifier, IdentityVerifierOptions } from "./identity.js";
|
|
7
|
+
export { checkManifest, loadManifest } from "./manifest.js";
|
|
8
|
+
export type { Manifest, ManifestPermissions, PublicPath } from "./manifest.js";
|
|
9
|
+
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.js";
|
|
10
|
+
export { createServiceClient } from "./service.js";
|
|
11
|
+
export type { CallOptions, ServiceClient, ServiceClientOptions } from "./service.js";
|
|
12
|
+
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
|
+
export { FinchlyError, IdentityError } from "./errors.js";
|
|
3
|
+
export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
|
4
|
+
export { checkManifest, loadManifest } from "./manifest.js";
|
|
5
|
+
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.js";
|
|
6
|
+
export { createServiceClient } from "./service.js";
|
|
7
|
+
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface PublicPath {
|
|
2
|
+
readonly pattern: string;
|
|
3
|
+
readonly methods?: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
export interface ManifestPermissions {
|
|
6
|
+
readonly db?: boolean;
|
|
7
|
+
readonly blobs?: boolean;
|
|
8
|
+
readonly secrets?: boolean;
|
|
9
|
+
readonly llm?: boolean;
|
|
10
|
+
readonly vectors?: boolean;
|
|
11
|
+
readonly notify?: boolean;
|
|
12
|
+
readonly schedule?: boolean;
|
|
13
|
+
readonly connectors?: boolean;
|
|
14
|
+
readonly queue?: {
|
|
15
|
+
readonly publish?: readonly string[];
|
|
16
|
+
readonly consume?: readonly string[];
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** A Finchly manifest, version 1. The schema is in firetail-io/finchly, spec/manifest. */
|
|
20
|
+
export interface Manifest {
|
|
21
|
+
readonly manifestVersion: 1;
|
|
22
|
+
readonly platformApi: string;
|
|
23
|
+
readonly slug: string;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly version: string;
|
|
26
|
+
readonly port: number;
|
|
27
|
+
readonly healthPath: string;
|
|
28
|
+
readonly description?: string;
|
|
29
|
+
readonly icon?: string;
|
|
30
|
+
readonly tags?: readonly string[];
|
|
31
|
+
readonly permissions?: ManifestPermissions;
|
|
32
|
+
readonly calls?: readonly string[];
|
|
33
|
+
readonly publicPaths?: readonly PublicPath[];
|
|
34
|
+
readonly proxyTimeoutSeconds?: number;
|
|
35
|
+
readonly maxBodyBytes?: number;
|
|
36
|
+
readonly websocket?: boolean;
|
|
37
|
+
readonly audit?: "full" | "metadata";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Check the parts of a manifest this SDK itself depends on.
|
|
41
|
+
*
|
|
42
|
+
* This is deliberately not the full schema. The platform runs the full
|
|
43
|
+
* check when the service registers, and is the authority. The point here is
|
|
44
|
+
* to stop a container at startup, with a clear message, when the manifest
|
|
45
|
+
* is missing something the SDK cannot work without.
|
|
46
|
+
*/
|
|
47
|
+
export declare function checkManifest(value: unknown): Manifest;
|
|
48
|
+
/** Read and check the manifest from the container's filesystem. */
|
|
49
|
+
export declare function loadManifest(path?: string): Promise<Manifest>;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { DEFAULT_MANIFEST_PATH } from "./config.js";
|
|
3
|
+
import { FinchlyError } from "./errors.js";
|
|
4
|
+
import { MANIFEST_VERSION, SUPPORTED_PLATFORM_API_VERSIONS } from "./platform-api.js";
|
|
5
|
+
const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
6
|
+
/**
|
|
7
|
+
* Check the parts of a manifest this SDK itself depends on.
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately not the full schema. The platform runs the full
|
|
10
|
+
* check when the service registers, and is the authority. The point here is
|
|
11
|
+
* to stop a container at startup, with a clear message, when the manifest
|
|
12
|
+
* is missing something the SDK cannot work without.
|
|
13
|
+
*/
|
|
14
|
+
export function checkManifest(value) {
|
|
15
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
16
|
+
throw new FinchlyError("manifest_invalid", "Manifest is not a JSON object");
|
|
17
|
+
}
|
|
18
|
+
const m = value;
|
|
19
|
+
const problems = [];
|
|
20
|
+
if (m.manifestVersion !== MANIFEST_VERSION) {
|
|
21
|
+
problems.push(`manifestVersion must be ${MANIFEST_VERSION}`);
|
|
22
|
+
}
|
|
23
|
+
if (typeof m.platformApi !== "string") {
|
|
24
|
+
problems.push("platformApi must be a string");
|
|
25
|
+
}
|
|
26
|
+
else if (!SUPPORTED_PLATFORM_API_VERSIONS.includes(m.platformApi)) {
|
|
27
|
+
problems.push(`platformApi "${m.platformApi}" is not one this SDK speaks (${SUPPORTED_PLATFORM_API_VERSIONS.join(", ")})`);
|
|
28
|
+
}
|
|
29
|
+
const slugOk = typeof m.slug === "string" && m.slug.length >= 2 && m.slug.length <= 40 && SLUG.test(m.slug);
|
|
30
|
+
if (!slugOk) {
|
|
31
|
+
problems.push("slug must be 2 to 40 lowercase letters, digits and single hyphens, starting with a letter");
|
|
32
|
+
}
|
|
33
|
+
if (typeof m.name !== "string" || m.name.length === 0) {
|
|
34
|
+
problems.push("name must be a non-empty string");
|
|
35
|
+
}
|
|
36
|
+
if (typeof m.version !== "string" || m.version.length === 0) {
|
|
37
|
+
problems.push("version must be a non-empty string");
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isInteger(m.port) || m.port < 1 || m.port > 65535) {
|
|
40
|
+
problems.push("port must be an integer from 1 to 65535");
|
|
41
|
+
}
|
|
42
|
+
if (typeof m.healthPath !== "string") {
|
|
43
|
+
problems.push("healthPath must be a string");
|
|
44
|
+
}
|
|
45
|
+
else if (slugOk && !m.healthPath.startsWith(`/${m.slug}/`)) {
|
|
46
|
+
problems.push(`healthPath must start with /${m.slug}/`);
|
|
47
|
+
}
|
|
48
|
+
if (problems.length > 0) {
|
|
49
|
+
throw new FinchlyError("manifest_invalid", "Manifest is invalid", problems);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
/** Read and check the manifest from the container's filesystem. */
|
|
54
|
+
export async function loadManifest(path = DEFAULT_MANIFEST_PATH) {
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = await readFile(path, "utf8");
|
|
58
|
+
}
|
|
59
|
+
catch (cause) {
|
|
60
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
61
|
+
throw new FinchlyError("manifest_unreadable", `Cannot read manifest at ${path}`, [reason]);
|
|
62
|
+
}
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(text);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new FinchlyError("manifest_invalid", `Manifest at ${path} is not valid JSON`);
|
|
69
|
+
}
|
|
70
|
+
return checkManifest(parsed);
|
|
71
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Platform API version this SDK release speaks by default. */
|
|
2
|
+
export declare const PLATFORM_API_VERSION = "v1";
|
|
3
|
+
/** Every platform API version this SDK release can speak. */
|
|
4
|
+
export declare const SUPPORTED_PLATFORM_API_VERSIONS: readonly string[];
|
|
5
|
+
/** Manifest schema version this SDK release reads. */
|
|
6
|
+
export declare const MANIFEST_VERSION = 1;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The platform API is specified in firetail-io/finchly under spec/. This
|
|
2
|
+
// package is one implementation of it and defines no protocol of its own.
|
|
3
|
+
/** Platform API version this SDK release speaks by default. */
|
|
4
|
+
export const PLATFORM_API_VERSION = "v1";
|
|
5
|
+
/** Every platform API version this SDK release can speak. */
|
|
6
|
+
export const SUPPORTED_PLATFORM_API_VERSIONS = Object.freeze(["v1"]);
|
|
7
|
+
/** Manifest schema version this SDK release reads. */
|
|
8
|
+
export const MANIFEST_VERSION = 1;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface ServiceClientOptions {
|
|
2
|
+
/** This service's slug. */
|
|
3
|
+
readonly slug: string;
|
|
4
|
+
/** The gateway's internal address (PLATFORM_GATEWAY_URL). */
|
|
5
|
+
readonly gatewayUrl: string;
|
|
6
|
+
/**
|
|
7
|
+
* The credential this built-in service was deployed with
|
|
8
|
+
* (PLATFORM_SERVICE_CREDENTIAL). It is what gets the instance key
|
|
9
|
+
* accepted. An app has none; its key is accepted when it registers.
|
|
10
|
+
*/
|
|
11
|
+
readonly credential?: string;
|
|
12
|
+
/** For tests. */
|
|
13
|
+
readonly fetch?: typeof fetch;
|
|
14
|
+
/** For tests. */
|
|
15
|
+
readonly now?: () => Date;
|
|
16
|
+
}
|
|
17
|
+
export interface CallOptions {
|
|
18
|
+
/**
|
|
19
|
+
* The id of the user this call is made for. The target then sees that
|
|
20
|
+
* user, and this service as the one acting for them. Leave it out to
|
|
21
|
+
* call as the service itself.
|
|
22
|
+
*/
|
|
23
|
+
readonly asUser?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ServiceClient {
|
|
26
|
+
/**
|
|
27
|
+
* Make the instance key and have the gateway accept it. Optional: the
|
|
28
|
+
* first call does this. Call it at startup to fail early instead.
|
|
29
|
+
*/
|
|
30
|
+
start(): Promise<void>;
|
|
31
|
+
/** The id of this instance's key, once it has one. */
|
|
32
|
+
readonly keyId: string | undefined;
|
|
33
|
+
/** A signed token for a call to `target`. Most code wants `call`. */
|
|
34
|
+
token(target: string, options?: CallOptions): Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Call another service. `path` is the path under the target, starting
|
|
37
|
+
* with "/". Resolves with the response, whatever its status: a 403 means
|
|
38
|
+
* this service's manifest does not declare the target in `calls`, or the
|
|
39
|
+
* user has no access to it.
|
|
40
|
+
*/
|
|
41
|
+
call(target: string, path: string, init?: RequestInit & CallOptions): Promise<Response>;
|
|
42
|
+
}
|
|
43
|
+
export declare function createServiceClient(options: ServiceClientOptions): ServiceClient;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Calls from this service to another, through the gateway.
|
|
2
|
+
//
|
|
3
|
+
// Services never address each other directly. A call goes to the gateway's
|
|
4
|
+
// internal address with a token this instance signs with its own key; the
|
|
5
|
+
// gateway checks it, decides whether the call is allowed, and forwards it.
|
|
6
|
+
// The token format is in spec/service-token.md and key registration in
|
|
7
|
+
// spec/registration.md, both in firetail-io/finchly.
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair } from "jose";
|
|
10
|
+
import { FinchlyError } from "./errors.js";
|
|
11
|
+
const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
12
|
+
const USER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
13
|
+
/** The audience of a token meant for the gateway itself. */
|
|
14
|
+
const GATEWAY_AUDIENCE = "gateway";
|
|
15
|
+
const KEYS_PATH = "/_gateway/keys";
|
|
16
|
+
/** How long a token is valid. The specification allows up to 300 seconds. */
|
|
17
|
+
const TOKEN_TTL_SECONDS = 60;
|
|
18
|
+
const REGISTRATION_TIMEOUT_MS = 10_000;
|
|
19
|
+
/** After a call is refused as unauthenticated, wait this long before registering again. */
|
|
20
|
+
const REREGISTER_COOLDOWN_MS = 60_000;
|
|
21
|
+
function invalid(message) {
|
|
22
|
+
return new FinchlyError("service_call_invalid", message);
|
|
23
|
+
}
|
|
24
|
+
export function createServiceClient(options) {
|
|
25
|
+
if (!SLUG.test(options.slug))
|
|
26
|
+
throw invalid(`slug ${JSON.stringify(options.slug)} is not a slug`);
|
|
27
|
+
const gatewayUrl = options.gatewayUrl.replace(/\/+$/, "");
|
|
28
|
+
if (!/^https?:\/\//.test(gatewayUrl))
|
|
29
|
+
throw invalid("gatewayUrl must be an http or https URL");
|
|
30
|
+
const { slug, credential } = options;
|
|
31
|
+
const doFetch = options.fetch ?? fetch;
|
|
32
|
+
const now = options.now ?? (() => new Date());
|
|
33
|
+
const issuer = `service:${slug}`;
|
|
34
|
+
let key;
|
|
35
|
+
let pending;
|
|
36
|
+
/** Set when a call came back 401: the gateway may no longer know the key. */
|
|
37
|
+
let doubtedAt;
|
|
38
|
+
let lastForcedAt = Number.NEGATIVE_INFINITY;
|
|
39
|
+
async function sign(instance, audience, subject) {
|
|
40
|
+
const issuedAt = Math.floor(now().getTime() / 1000);
|
|
41
|
+
return new SignJWT({})
|
|
42
|
+
.setProtectedHeader({ alg: "ES256", kid: instance.kid, typ: "JWT" })
|
|
43
|
+
.setIssuer(issuer)
|
|
44
|
+
.setSubject(subject)
|
|
45
|
+
.setAudience(audience)
|
|
46
|
+
.setIssuedAt(issuedAt)
|
|
47
|
+
.setExpirationTime(issuedAt + TOKEN_TTL_SECONDS)
|
|
48
|
+
.setJti(randomUUID())
|
|
49
|
+
.sign(instance.privateKey);
|
|
50
|
+
}
|
|
51
|
+
/** Ask the gateway to accept the key, or to extend it. */
|
|
52
|
+
async function register(instance, authorization, withSlug) {
|
|
53
|
+
const body = {
|
|
54
|
+
...(withSlug ? { slug } : {}),
|
|
55
|
+
key: { kid: instance.kid, jwk: instance.publicJwk },
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
return await doFetch(`${gatewayUrl}${KEYS_PATH}`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
|
61
|
+
body: JSON.stringify(body),
|
|
62
|
+
// The credential goes to the gateway and nowhere a response points.
|
|
63
|
+
redirect: "error",
|
|
64
|
+
signal: AbortSignal.timeout(REGISTRATION_TIMEOUT_MS),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
throw new FinchlyError("gateway_unreachable", "Cannot register this instance's key with the gateway", [
|
|
69
|
+
err instanceof Error ? err.message : String(err),
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function accept(instance, response) {
|
|
74
|
+
let body = {};
|
|
75
|
+
try {
|
|
76
|
+
body = (await response.json());
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Reported below, by status.
|
|
80
|
+
}
|
|
81
|
+
const expiresAt = typeof body.keyExpiresAt === "string" ? Date.parse(body.keyExpiresAt) : Number.NaN;
|
|
82
|
+
if (response.status !== 200 || Number.isNaN(expiresAt)) {
|
|
83
|
+
const reason = typeof body.error === "string" ? body.error : `status ${response.status}`;
|
|
84
|
+
throw new FinchlyError("service_key_rejected", "The gateway did not accept this instance's key", [reason]);
|
|
85
|
+
}
|
|
86
|
+
const nowMs = now().getTime();
|
|
87
|
+
instance.expiresAt = expiresAt;
|
|
88
|
+
// Extend it when two thirds of its life has gone.
|
|
89
|
+
instance.renewAt = nowMs + ((expiresAt - nowMs) * 2) / 3;
|
|
90
|
+
return instance;
|
|
91
|
+
}
|
|
92
|
+
async function withCredential(instance) {
|
|
93
|
+
if (credential === undefined || credential === "") {
|
|
94
|
+
throw new FinchlyError("service_key_rejected", "This instance has no key the gateway accepts, and no credential to register one", [
|
|
95
|
+
"A built-in service is deployed with PLATFORM_SERVICE_CREDENTIAL.",
|
|
96
|
+
"An app's key is accepted when it registers, which this SDK release cannot do yet.",
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
return accept(instance, await register(instance, `Service-Credential ${credential}`, true));
|
|
100
|
+
}
|
|
101
|
+
async function obtain(force) {
|
|
102
|
+
const nowMs = now().getTime();
|
|
103
|
+
if (key === undefined) {
|
|
104
|
+
const { publicKey, privateKey } = await generateKeyPair("ES256");
|
|
105
|
+
const { kty, crv, x, y } = await exportJWK(publicKey);
|
|
106
|
+
const publicJwk = { kty: kty, crv: crv, x: x, y: y };
|
|
107
|
+
const fresh = {
|
|
108
|
+
kid: await calculateJwkThumbprint(publicJwk),
|
|
109
|
+
privateKey,
|
|
110
|
+
publicJwk,
|
|
111
|
+
expiresAt: 0,
|
|
112
|
+
renewAt: 0,
|
|
113
|
+
};
|
|
114
|
+
key = await withCredential(fresh);
|
|
115
|
+
return key;
|
|
116
|
+
}
|
|
117
|
+
if (!force && nowMs < key.renewAt)
|
|
118
|
+
return key;
|
|
119
|
+
if (!force && nowMs < key.expiresAt) {
|
|
120
|
+
// Still accepted: extend it with a token it signs itself.
|
|
121
|
+
const response = await register(key, `Bearer ${await sign(key, GATEWAY_AUDIENCE, issuer)}`, false);
|
|
122
|
+
if (response.status !== 401)
|
|
123
|
+
return accept(key, response);
|
|
124
|
+
await response.body?.cancel();
|
|
125
|
+
}
|
|
126
|
+
// Expired, or the gateway no longer knows it: start again from the credential.
|
|
127
|
+
return withCredential(key);
|
|
128
|
+
}
|
|
129
|
+
/** The instance key, registered and current. Concurrent callers share one registration. */
|
|
130
|
+
function instanceKey() {
|
|
131
|
+
const nowMs = now().getTime();
|
|
132
|
+
// Only a credential can re-register, so only then is it worth trying.
|
|
133
|
+
const force = doubtedAt !== undefined && Boolean(credential) && nowMs - lastForcedAt >= REREGISTER_COOLDOWN_MS;
|
|
134
|
+
if (key !== undefined && !force && nowMs < key.renewAt)
|
|
135
|
+
return Promise.resolve(key);
|
|
136
|
+
if (pending === undefined) {
|
|
137
|
+
if (force) {
|
|
138
|
+
lastForcedAt = nowMs;
|
|
139
|
+
doubtedAt = undefined;
|
|
140
|
+
}
|
|
141
|
+
pending = obtain(force).finally(() => {
|
|
142
|
+
pending = undefined;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return pending;
|
|
146
|
+
}
|
|
147
|
+
function subjectFor(callOptions) {
|
|
148
|
+
const user = callOptions?.asUser;
|
|
149
|
+
if (user === undefined)
|
|
150
|
+
return issuer;
|
|
151
|
+
if (!USER_ID.test(user))
|
|
152
|
+
throw invalid("asUser must be a user's platform id");
|
|
153
|
+
return user;
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
async start() {
|
|
157
|
+
await instanceKey();
|
|
158
|
+
},
|
|
159
|
+
get keyId() {
|
|
160
|
+
return key?.kid;
|
|
161
|
+
},
|
|
162
|
+
async token(target, callOptions) {
|
|
163
|
+
if (!SLUG.test(target))
|
|
164
|
+
throw invalid(`target ${JSON.stringify(target)} is not a slug`);
|
|
165
|
+
const subject = subjectFor(callOptions);
|
|
166
|
+
return sign(await instanceKey(), target, subject);
|
|
167
|
+
},
|
|
168
|
+
async call(target, path, init = {}) {
|
|
169
|
+
if (!SLUG.test(target))
|
|
170
|
+
throw invalid(`target ${JSON.stringify(target)} is not a slug`);
|
|
171
|
+
// The path is joined as text. It must not be able to climb out of the target.
|
|
172
|
+
if (!path.startsWith("/") || path.includes("\\") || /(^|\/)\.\.?(\/|$|\?)/.test(path.split("?")[0] ?? "")) {
|
|
173
|
+
throw invalid('path must start with "/" and contain no "." or ".." segments');
|
|
174
|
+
}
|
|
175
|
+
const { asUser, ...request } = init;
|
|
176
|
+
const subject = subjectFor(asUser !== undefined ? { asUser } : undefined);
|
|
177
|
+
const token = await sign(await instanceKey(), target, subject);
|
|
178
|
+
const headers = new Headers(request.headers);
|
|
179
|
+
// Whatever the caller set, the gateway reads this one.
|
|
180
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
181
|
+
let response;
|
|
182
|
+
try {
|
|
183
|
+
// A redirect from the target is handed back, not followed: the
|
|
184
|
+
// token is for the target and goes nowhere else.
|
|
185
|
+
response = await doFetch(`${gatewayUrl}/${target}${path}`, { redirect: "manual", ...request, headers });
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const name = err?.name;
|
|
189
|
+
// The caller's own abort or timeout is theirs to handle.
|
|
190
|
+
if (name === "AbortError" || name === "TimeoutError")
|
|
191
|
+
throw err;
|
|
192
|
+
throw new FinchlyError("gateway_unreachable", `Cannot reach ${target} through the gateway`, [
|
|
193
|
+
err instanceof Error ? err.message : String(err),
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
if (response.status === 401)
|
|
197
|
+
doubtedAt = now().getTime();
|
|
198
|
+
return response;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
// package.json sits one level above both src/ and dist/, so this resolves
|
|
3
|
+
// the same way from source and from the published build.
|
|
4
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
5
|
+
/** Package name, as reported to the platform at registration. */
|
|
6
|
+
export const SDK_NAME = pkg.name;
|
|
7
|
+
/** Package version, as reported to the platform at registration. */
|
|
8
|
+
export const SDK_VERSION = pkg.version;
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firetail/finchly",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Node SDK for the Finchly platform",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
24
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
25
|
+
"test": "npm run build && node --test \"test/**/*.test.mjs\"",
|
|
26
|
+
"prepublishOnly": "npm test"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/firetail-io/finchly-sdk-js.git"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"typescript": "~5.9.3"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"jose": "^6.2.17"
|
|
41
|
+
}
|
|
42
|
+
}
|