@firetail/finchly 0.0.0-stage → 0.0.4
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 +54 -0
- package/README.md +280 -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 +237 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -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/request-headers.d.ts +8 -0
- package/dist/request-headers.js +16 -0
- package/dist/service.d.ts +74 -0
- package/dist/service.js +296 -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,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.0.4 - 2026-11-12
|
|
8
|
+
|
|
9
|
+
No change to the package. The first release published by the release
|
|
10
|
+
workflow: 0.0.2 and 0.0.3 were tagged but did not reach npm, because the
|
|
11
|
+
workflow published with a placeholder token in place of the trusted
|
|
12
|
+
publisher. Everything listed under them arrives in this release.
|
|
13
|
+
|
|
14
|
+
## 0.0.3 - 2026-11-12
|
|
15
|
+
|
|
16
|
+
Tagged but not published to npm.
|
|
17
|
+
|
|
18
|
+
- Every request to the platform carries three headers: `x-platform-sdk`
|
|
19
|
+
(the package name), `x-platform-sdk-version` (this release) and
|
|
20
|
+
`x-platform-api-version` (the platform API version it speaks). A caller
|
|
21
|
+
cannot replace them. Exported as `SDK_REQUEST_HEADERS`.
|
|
22
|
+
|
|
23
|
+
## 0.0.2 - 2026-11-12
|
|
24
|
+
|
|
25
|
+
Tagged but not published to npm.
|
|
26
|
+
|
|
27
|
+
- An app can register itself: `createServiceClient` takes an `enrolment`
|
|
28
|
+
(token, manifest, address) in place of a credential, and
|
|
29
|
+
`enrolmentFrom(config, manifest)` builds one from the platform settings.
|
|
30
|
+
Registration is what gets an app's instance key accepted.
|
|
31
|
+
- New error codes: `app_pending_approval`, `app_registration_refused`.
|
|
32
|
+
|
|
33
|
+
## 0.0.1 - 2026-11-11
|
|
34
|
+
|
|
35
|
+
The first release, published by hand so that the package exists and
|
|
36
|
+
trusted publishing can be set up. Later releases come from the release
|
|
37
|
+
workflow.
|
|
38
|
+
|
|
39
|
+
- Package skeleton under its final name.
|
|
40
|
+
- `loadConfig`: read and validate the `PLATFORM_*` settings.
|
|
41
|
+
- `loadManifest`, `checkManifest`: load a manifest and check the fields the
|
|
42
|
+
SDK depends on.
|
|
43
|
+
- `PLATFORM_API_VERSION`, `SUPPORTED_PLATFORM_API_VERSIONS`, `SDK_NAME`,
|
|
44
|
+
`SDK_VERSION`.
|
|
45
|
+
- `createIdentityVerifier`: verify the `x-platform-identity` token against
|
|
46
|
+
the gateway's published keys. Rejections are an `IdentityError` with a
|
|
47
|
+
`reason` from the specification. Tested against the identity token
|
|
48
|
+
conformance vectors.
|
|
49
|
+
- New dependency: `jose`.
|
|
50
|
+
- `createServiceClient`: call another service through the gateway, as the
|
|
51
|
+
service itself or for a user. Makes the instance key, registers it with
|
|
52
|
+
the service credential, and extends it before it expires.
|
|
53
|
+
- `loadConfig` reads `PLATFORM_SERVICE_CREDENTIAL`.
|
|
54
|
+
- New error codes: `service_call_invalid`, `service_key_rejected`.
|
package/README.md
CHANGED
|
@@ -1,3 +1,281 @@
|
|
|
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
|
+
### An app registers itself
|
|
121
|
+
|
|
122
|
+
A built-in service has a credential. An app does not: it registers itself
|
|
123
|
+
with the platform, and that is what gets its key accepted. Give the client
|
|
124
|
+
an `enrolment` in place of a `credential`:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
import { createServiceClient, enrolmentFrom, loadConfig, loadManifest } from "@firetail/finchly";
|
|
128
|
+
|
|
129
|
+
const config = loadConfig();
|
|
130
|
+
const manifest = await loadManifest(config.manifestPath);
|
|
131
|
+
|
|
132
|
+
const service = createServiceClient({
|
|
133
|
+
slug: manifest.slug,
|
|
134
|
+
gatewayUrl: config.gatewayUrl,
|
|
135
|
+
credential: config.serviceCredential, // a built-in service
|
|
136
|
+
enrolment: enrolmentFrom(config, manifest), // an app
|
|
137
|
+
});
|
|
138
|
+
await service.start();
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`enrolmentFrom` reads the enrolment token (`PLATFORM_ENROLMENT_TOKEN`) and
|
|
142
|
+
the app's address (`PLATFORM_APP_ADDRESS`, or this machine's hostname and
|
|
143
|
+
the manifest's port). It returns `undefined` when there is no token, so the
|
|
144
|
+
same code serves a built-in service and an app.
|
|
145
|
+
|
|
146
|
+
Registration is the first call the client makes, and it sends the manifest
|
|
147
|
+
exactly as it was loaded. What can come back:
|
|
148
|
+
|
|
149
|
+
| Error code | Meaning | What to do |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| none | Registered. Calls work | |
|
|
152
|
+
| `app_pending_approval` | The release waits for an administrator: it asks for more than the last one, or the app is at an address the deployment does not expect | Keep running and try again. The client does not ask the platform again before it was told to |
|
|
153
|
+
| `app_registration_refused` | The platform refused, and `problems` says why: a bad token, a manifest or address it will not accept, a lower version than the one running | Fix the deployment. Trying again will not help |
|
|
154
|
+
| `gateway_unreachable` | The gateway could not be reached | Try again |
|
|
155
|
+
|
|
156
|
+
The protocol is in `spec/registration.md` in `firetail-io/finchly`.
|
|
157
|
+
|
|
158
|
+
The other half of the `app` module is not built: serving the
|
|
159
|
+
`/{slug}/_platform/*` endpoints, which let an administrator register an app
|
|
160
|
+
that is already running, and fetching the settings the platform creates for
|
|
161
|
+
an app.
|
|
162
|
+
|
|
163
|
+
### What every request carries
|
|
164
|
+
|
|
165
|
+
Every request this SDK makes to the platform says which SDK and which
|
|
166
|
+
release is asking:
|
|
167
|
+
|
|
168
|
+
| Header | Value |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `x-platform-sdk` | `@firetail/finchly` |
|
|
171
|
+
| `x-platform-sdk-version` | This release, for example `0.0.4` |
|
|
172
|
+
| `x-platform-api-version` | The platform API version it speaks, `v1` |
|
|
173
|
+
|
|
174
|
+
That covers fetching the gateway's issuer and keys, registering an instance
|
|
175
|
+
key, registering an app, and every call to another service. On a call the
|
|
176
|
+
headers reach the target service too, and a caller cannot replace them.
|
|
177
|
+
|
|
178
|
+
### Settings
|
|
179
|
+
|
|
180
|
+
| Variable | Required | Meaning |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `PLATFORM_GATEWAY_URL` | yes | The gateway's internal address |
|
|
183
|
+
| `PLATFORM_MANIFEST_PATH` | no | Manifest path in the container. Default `/finchly/manifest.json` |
|
|
184
|
+
| `PLATFORM_APP_ADDRESS` | no | Address the gateway should use to reach this service |
|
|
185
|
+
| `PLATFORM_ENROLMENT_TOKEN` | no | Token for self-registration |
|
|
186
|
+
| `PLATFORM_SERVICE_CREDENTIAL` | no | What a built-in service registers its instance key with |
|
|
187
|
+
|
|
188
|
+
The enrolment token and the service credential are kept off the enumerable
|
|
189
|
+
properties of the config object, so logging or serialising the config does
|
|
190
|
+
not leak them.
|
|
191
|
+
|
|
192
|
+
## Modules
|
|
193
|
+
|
|
194
|
+
| Module | What it does | State |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `config` | Read and validate the platform settings | Done |
|
|
197
|
+
| `manifest` | Load the manifest and check what the SDK depends on | Done |
|
|
198
|
+
| `identity` | Verify `x-platform-identity` | Done. Directory lookups (`listUsers`, `membersOf`) and `require*` helpers are planned |
|
|
199
|
+
| `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
|
+
| `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 |
|
|
202
|
+
| `blobs` | Object storage with conditional writes | Planned |
|
|
203
|
+
| `queue` | Publish, lease, ack, nack | Planned |
|
|
204
|
+
| `secrets` | Secrets and envelope encryption | Planned |
|
|
205
|
+
| `llm` | Model calls through the platform | Planned |
|
|
206
|
+
| `audit`, `usage`, `notify`, `schedule`, `connectors` | Clients for platform services | Planned |
|
|
207
|
+
| `ui` | Shared header, footer and theme | Planned |
|
|
208
|
+
|
|
209
|
+
The manifest check here is not the full schema. The platform runs the full
|
|
210
|
+
check at registration and is the authority.
|
|
211
|
+
|
|
212
|
+
## Develop
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
npm install
|
|
216
|
+
npm test
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`npm test` compiles with `tsc` and runs the tests in `test/` against the
|
|
220
|
+
build, which is what a consumer imports.
|
|
221
|
+
|
|
222
|
+
### Conformance vectors
|
|
223
|
+
|
|
224
|
+
`test/conformance/` holds test vectors copied from `firetail-io/finchly`
|
|
225
|
+
(`conformance/vectors/`). Both SDKs must reach the verdict each vector
|
|
226
|
+
states. Do not edit them here: change them in `finchly`, regenerate, and
|
|
227
|
+
copy the file to both SDK repositories. Until `finchly` publishes its
|
|
228
|
+
conformance suite as an image, the copy is made by hand.
|
|
229
|
+
|
|
230
|
+
## Release
|
|
231
|
+
|
|
232
|
+
Each package version is semver, independent of the Python SDK and of the
|
|
233
|
+
platform.
|
|
234
|
+
|
|
235
|
+
1. Changes land on `main` by pull request. Each one adds a line to
|
|
236
|
+
`CHANGELOG.md` under "Unreleased".
|
|
237
|
+
2. To release, set the version in `package.json`, move the changelog
|
|
238
|
+
entries under that version, merge, and tag the merge commit `vX.Y.Z`.
|
|
239
|
+
3. The `release` workflow checks the tag matches `package.json`, runs the
|
|
240
|
+
tests, and publishes.
|
|
241
|
+
4. A version with a pre-release part (`1.2.0-rc.1`) is published under the
|
|
242
|
+
`next` dist-tag. Anything else is published as `latest`.
|
|
243
|
+
5. A bad release is deprecated with `npm deprecate` and followed by a patch
|
|
244
|
+
release. It is not unpublished.
|
|
245
|
+
|
|
246
|
+
Publishing is meant to use npm trusted publishing: the workflow proves its
|
|
247
|
+
identity to npm with its GitHub Actions token, so no npm token is stored,
|
|
248
|
+
and npm attaches a provenance attestation.
|
|
249
|
+
|
|
250
|
+
npm matches the trusted publisher against the identity GitHub issues to the
|
|
251
|
+
workflow, and every field must match exactly, including case:
|
|
252
|
+
|
|
253
|
+
| Field on npmjs.com | Value |
|
|
254
|
+
| -------------------- | ------------- |
|
|
255
|
+
| Organization or user | `FireTail-io` |
|
|
256
|
+
| Repository | `finchly-sdk-js` |
|
|
257
|
+
| Workflow filename | `release.yml` |
|
|
258
|
+
| Environment name | `npm` |
|
|
259
|
+
|
|
260
|
+
The organisation is `FireTail-io`, not `firetail-io`. GitHub accepts either
|
|
261
|
+
spelling in a URL; npm does not, and answers "package not found" when the
|
|
262
|
+
two differ. `repository.url` in `package.json` must use the same spelling.
|
|
263
|
+
The workflow prints the identity it was issued, so a refusal can be compared
|
|
264
|
+
with what npmjs.com holds. A trusted publisher's fields cannot be edited: to
|
|
265
|
+
correct one, delete it and create another.
|
|
266
|
+
|
|
267
|
+
The workflow can also be given a token, for a release that trusted
|
|
268
|
+
publishing refuses:
|
|
269
|
+
|
|
270
|
+
- `NPM_TOKEN`, a secret of the `npm` environment in this repository. It is
|
|
271
|
+
a granular access token that can publish `@firetail/finchly` and nothing
|
|
272
|
+
else, with a short expiry.
|
|
273
|
+
- npm tries the trusted publisher first and uses the token only when that
|
|
274
|
+
is refused. Without the secret, nothing in the workflow needs to change.
|
|
275
|
+
- While a token is in use, the package's "Publishing access" on npmjs.com
|
|
276
|
+
must allow a granular access token with bypass 2FA.
|
|
277
|
+
- No provenance attestation is attached to a release published with the
|
|
278
|
+
token. npm also does not generate one from a private repository.
|
|
279
|
+
|
|
280
|
+
Still to add: the conformance suite from `firetail-io/finchly`, run in CI on
|
|
281
|
+
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" | "app_registration_refused" | "app_pending_approval";
|
|
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 {};
|