@firetail/finchly 0.0.1 → 0.0.5
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 +20 -3
- package/README.md +65 -59
- package/dist/errors.d.ts +1 -1
- package/dist/identity.js +6 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/request-headers.d.ts +8 -0
- package/dist/request-headers.js +16 -0
- package/dist/service.d.ts +31 -0
- package/dist/service.js +117 -22
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,28 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.0.5 - 2026-11-12
|
|
8
|
+
|
|
9
|
+
- The README covers using the SDK only. Notes for people working on the
|
|
10
|
+
SDK are no longer part of the package.
|
|
11
|
+
|
|
12
|
+
## 0.0.4 - 2026-11-12
|
|
13
|
+
|
|
14
|
+
- Every request to the platform carries three headers: `x-platform-sdk`
|
|
15
|
+
(the package name), `x-platform-sdk-version` (this release) and
|
|
16
|
+
`x-platform-api-version` (the platform API version it speaks). A caller
|
|
17
|
+
cannot replace them. Exported as `SDK_REQUEST_HEADERS`.
|
|
18
|
+
- An app can register itself: `createServiceClient` takes an `enrolment`
|
|
19
|
+
(token, manifest, address) in place of a credential, and
|
|
20
|
+
`enrolmentFrom(config, manifest)` builds one from the platform settings.
|
|
21
|
+
Registration is what gets an app's instance key accepted.
|
|
22
|
+
- New error codes: `app_pending_approval`, `app_registration_refused`.
|
|
23
|
+
|
|
24
|
+
0.0.2 and 0.0.3 were not published.
|
|
25
|
+
|
|
7
26
|
## 0.0.1 - 2026-11-11
|
|
8
27
|
|
|
9
|
-
The first release
|
|
10
|
-
trusted publishing can be set up. Later releases come from the release
|
|
11
|
-
workflow.
|
|
28
|
+
The first release.
|
|
12
29
|
|
|
13
30
|
- Package skeleton under its final name.
|
|
14
31
|
- `loadConfig`: read and validate the `PLATFORM_*` settings.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @firetail/finchly
|
|
2
2
|
|
|
3
|
-
Node SDK for the [Finchly](https://github.com/
|
|
3
|
+
Node SDK for the [Finchly](https://github.com/FireTail-io/finchly) platform.
|
|
4
4
|
A service or app uses it to register with a Finchly deployment, verify who
|
|
5
5
|
is calling it, and reach the platform's database, object store, queue and
|
|
6
6
|
other services.
|
|
@@ -9,7 +9,7 @@ Status: early. The package builds and is tested, but only the first four
|
|
|
9
9
|
modules exist.
|
|
10
10
|
|
|
11
11
|
The SDK implements the platform API, which is specified in
|
|
12
|
-
`
|
|
12
|
+
`FireTail-io/finchly` under `spec/`. This release speaks platform API `v1`
|
|
13
13
|
(draft).
|
|
14
14
|
|
|
15
15
|
## Install
|
|
@@ -69,7 +69,7 @@ try {
|
|
|
69
69
|
A token is rejected unless it is signed with ES256 by a key the gateway
|
|
70
70
|
publishes, names that gateway as issuer and this service as its only
|
|
71
71
|
audience, and has not expired. The rules are in `spec/identity-token.md` in
|
|
72
|
-
`
|
|
72
|
+
`FireTail-io/finchly`.
|
|
73
73
|
|
|
74
74
|
No other header is ever consulted. On a path the manifest declares in
|
|
75
75
|
`publicPaths` there is no token, and the service authenticates the request
|
|
@@ -115,11 +115,65 @@ The instance makes an ES256 key pair in memory the first time it is needed.
|
|
|
115
115
|
The private half never leaves the process. The public half is registered
|
|
116
116
|
with the gateway using the credential the service was deployed with, and
|
|
117
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 `
|
|
118
|
+
`spec/service-token.md` and `spec/registration.md` in `FireTail-io/finchly`.
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.5` |
|
|
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.
|
|
123
177
|
|
|
124
178
|
### Settings
|
|
125
179
|
|
|
@@ -142,8 +196,8 @@ not leak them.
|
|
|
142
196
|
| `config` | Read and validate the platform settings | Done |
|
|
143
197
|
| `manifest` | Load the manifest and check what the SDK depends on | Done |
|
|
144
198
|
| `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 |
|
|
146
|
-
| `service` | Call other services through the gateway, as the service or for a user | Done for built-in services
|
|
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 |
|
|
147
201
|
| `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Planned |
|
|
148
202
|
| `blobs` | Object storage with conditional writes | Planned |
|
|
149
203
|
| `queue` | Publish, lease, ack, nack | Planned |
|
|
@@ -155,54 +209,6 @@ not leak them.
|
|
|
155
209
|
The manifest check here is not the full schema. The platform runs the full
|
|
156
210
|
check at registration and is the authority.
|
|
157
211
|
|
|
158
|
-
##
|
|
159
|
-
|
|
160
|
-
```
|
|
161
|
-
npm install
|
|
162
|
-
npm test
|
|
163
|
-
```
|
|
212
|
+
## Changes
|
|
164
213
|
|
|
165
|
-
|
|
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.
|
|
214
|
+
See `CHANGELOG.md`.
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected";
|
|
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
2
|
/**
|
|
3
3
|
* Every error this SDK throws on purpose. `code` is stable and safe to
|
|
4
4
|
* branch on; `problems` lists each thing that was wrong, so a caller can
|
package/dist/identity.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { createLocalJWKSet, createRemoteJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
5
5
|
import { FinchlyError, IdentityError } from "./errors.js";
|
|
6
6
|
import { SUPPORTED_PLATFORM_API_VERSIONS } from "./platform-api.js";
|
|
7
|
+
import { SDK_REQUEST_HEADERS } from "./request-headers.js";
|
|
7
8
|
/** Header the gateway sets on every request it proxies. */
|
|
8
9
|
export const IDENTITY_HEADER = "x-platform-identity";
|
|
9
10
|
const REQUIRED_CLAIMS = ["iss", "sub", "aud", "iat", "exp"];
|
|
@@ -135,7 +136,10 @@ export function createIdentityVerifier(options) {
|
|
|
135
136
|
const url = `${gatewayUrl}/_gateway/.well-known/platform.json`;
|
|
136
137
|
let body;
|
|
137
138
|
try {
|
|
138
|
-
const response = await fetch(url, {
|
|
139
|
+
const response = await fetch(url, {
|
|
140
|
+
headers: SDK_REQUEST_HEADERS,
|
|
141
|
+
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
|
|
142
|
+
});
|
|
139
143
|
if (!response.ok) {
|
|
140
144
|
throw new Error(`HTTP ${response.status}`);
|
|
141
145
|
}
|
|
@@ -159,6 +163,7 @@ export function createIdentityVerifier(options) {
|
|
|
159
163
|
cacheMaxAge: KEY_CACHE_MS,
|
|
160
164
|
cooldownDuration: KEY_REFETCH_COOLDOWN_MS,
|
|
161
165
|
timeoutDuration: GATEWAY_TIMEOUT_MS,
|
|
166
|
+
headers: SDK_REQUEST_HEADERS,
|
|
162
167
|
}),
|
|
163
168
|
};
|
|
164
169
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type { Identity, IdentityVerifier, IdentityVerifierOptions } from "./iden
|
|
|
7
7
|
export { checkManifest, loadManifest } from "./manifest.js";
|
|
8
8
|
export type { Manifest, ManifestPermissions, PublicPath } from "./manifest.js";
|
|
9
9
|
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.js";
|
|
10
|
-
export {
|
|
11
|
-
export
|
|
10
|
+
export { PLATFORM_API_VERSION_HEADER, SDK_HEADER, SDK_REQUEST_HEADERS, SDK_VERSION_HEADER, } from "./request-headers.js";
|
|
11
|
+
export { createServiceClient, enrolmentFrom } from "./service.js";
|
|
12
|
+
export type { CallOptions, Enrolment, ServiceClient, ServiceClientOptions } from "./service.js";
|
|
12
13
|
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
package/dist/index.js
CHANGED
|
@@ -3,5 +3,6 @@ export { FinchlyError, IdentityError } from "./errors.js";
|
|
|
3
3
|
export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
|
4
4
|
export { checkManifest, loadManifest } from "./manifest.js";
|
|
5
5
|
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.js";
|
|
6
|
-
export {
|
|
6
|
+
export { PLATFORM_API_VERSION_HEADER, SDK_HEADER, SDK_REQUEST_HEADERS, SDK_VERSION_HEADER, } from "./request-headers.js";
|
|
7
|
+
export { createServiceClient, enrolmentFrom } from "./service.js";
|
|
7
8
|
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Header naming the SDK package, for example `@firetail/finchly`. */
|
|
2
|
+
export declare const SDK_HEADER = "x-platform-sdk";
|
|
3
|
+
/** Header carrying the SDK's version, for example `0.0.3`. */
|
|
4
|
+
export declare const SDK_VERSION_HEADER = "x-platform-sdk-version";
|
|
5
|
+
/** Header carrying the platform API version the SDK speaks, for example `v1`. */
|
|
6
|
+
export declare const PLATFORM_API_VERSION_HEADER = "x-platform-api-version";
|
|
7
|
+
/** The three headers, as sent on every request to the gateway. */
|
|
8
|
+
export declare const SDK_REQUEST_HEADERS: Readonly<Record<string, string>>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// What every request this SDK makes to the platform carries, so the
|
|
2
|
+
// platform can tell which SDK and which release it is talking to.
|
|
3
|
+
import { PLATFORM_API_VERSION } from "./platform-api.js";
|
|
4
|
+
import { SDK_NAME, SDK_VERSION } from "./version.js";
|
|
5
|
+
/** Header naming the SDK package, for example `@firetail/finchly`. */
|
|
6
|
+
export const SDK_HEADER = "x-platform-sdk";
|
|
7
|
+
/** Header carrying the SDK's version, for example `0.0.3`. */
|
|
8
|
+
export const SDK_VERSION_HEADER = "x-platform-sdk-version";
|
|
9
|
+
/** Header carrying the platform API version the SDK speaks, for example `v1`. */
|
|
10
|
+
export const PLATFORM_API_VERSION_HEADER = "x-platform-api-version";
|
|
11
|
+
/** The three headers, as sent on every request to the gateway. */
|
|
12
|
+
export const SDK_REQUEST_HEADERS = Object.freeze({
|
|
13
|
+
[SDK_HEADER]: SDK_NAME,
|
|
14
|
+
[SDK_VERSION_HEADER]: SDK_VERSION,
|
|
15
|
+
[PLATFORM_API_VERSION_HEADER]: PLATFORM_API_VERSION,
|
|
16
|
+
});
|
package/dist/service.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PlatformConfig } from "./config.js";
|
|
2
|
+
import type { Manifest } from "./manifest.js";
|
|
1
3
|
export interface ServiceClientOptions {
|
|
2
4
|
/** This service's slug. */
|
|
3
5
|
readonly slug: string;
|
|
@@ -9,11 +11,40 @@ export interface ServiceClientOptions {
|
|
|
9
11
|
* accepted. An app has none; its key is accepted when it registers.
|
|
10
12
|
*/
|
|
11
13
|
readonly credential?: string;
|
|
14
|
+
/**
|
|
15
|
+
* How an app gets its instance key accepted: by registering itself. Use
|
|
16
|
+
* `enrolmentFrom` to build this from the platform settings and the
|
|
17
|
+
* manifest. Ignored when `credential` is set.
|
|
18
|
+
*/
|
|
19
|
+
readonly enrolment?: Enrolment;
|
|
12
20
|
/** For tests. */
|
|
13
21
|
readonly fetch?: typeof fetch;
|
|
14
22
|
/** For tests. */
|
|
15
23
|
readonly now?: () => Date;
|
|
16
24
|
}
|
|
25
|
+
/** What an app presents to register itself. See spec/registration.md, section 2. */
|
|
26
|
+
export interface Enrolment {
|
|
27
|
+
/** The enrolment token an administrator issued for this app's slug (PLATFORM_ENROLMENT_TOKEN). */
|
|
28
|
+
readonly token: string;
|
|
29
|
+
/** The app's manifest, as loaded from its image. */
|
|
30
|
+
readonly manifest: Readonly<Record<string, unknown>>;
|
|
31
|
+
/** Where the gateway can reach this app, for example http://expense-tracker.apps.local:3005. */
|
|
32
|
+
readonly address: string;
|
|
33
|
+
/** The manifest's signature bundle, when the release is signed. */
|
|
34
|
+
readonly bundle?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build what an app presents to register itself, from its platform
|
|
38
|
+
* settings and manifest. Returns undefined if the app was given no
|
|
39
|
+
* enrolment token, which is right for a built-in service.
|
|
40
|
+
*
|
|
41
|
+
* The address is PLATFORM_APP_ADDRESS if set. Otherwise it is this
|
|
42
|
+
* machine's hostname and the manifest's port, which is right when the
|
|
43
|
+
* container's hostname is the name the gateway resolves.
|
|
44
|
+
*/
|
|
45
|
+
export declare function enrolmentFrom(config: PlatformConfig, manifest: Manifest, options?: {
|
|
46
|
+
readonly bundle?: string;
|
|
47
|
+
}): Enrolment | undefined;
|
|
17
48
|
export interface CallOptions {
|
|
18
49
|
/**
|
|
19
50
|
* The id of the user this call is made for. The target then sees that
|
package/dist/service.js
CHANGED
|
@@ -7,17 +7,41 @@
|
|
|
7
7
|
// spec/registration.md, both in firetail-io/finchly.
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair } from "jose";
|
|
10
|
+
import { hostname } from "node:os";
|
|
10
11
|
import { FinchlyError } from "./errors.js";
|
|
12
|
+
import { PLATFORM_API_VERSION } from "./platform-api.js";
|
|
13
|
+
import { SDK_REQUEST_HEADERS } from "./request-headers.js";
|
|
14
|
+
import { SDK_NAME, SDK_VERSION } from "./version.js";
|
|
11
15
|
const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
12
16
|
const USER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
13
17
|
/** The audience of a token meant for the gateway itself. */
|
|
14
18
|
const GATEWAY_AUDIENCE = "gateway";
|
|
15
19
|
const KEYS_PATH = "/_gateway/keys";
|
|
20
|
+
const ENROL_PATH = "/_gateway/enrol";
|
|
16
21
|
/** How long a token is valid. The specification allows up to 300 seconds. */
|
|
17
22
|
const TOKEN_TTL_SECONDS = 60;
|
|
18
23
|
const REGISTRATION_TIMEOUT_MS = 10_000;
|
|
19
24
|
/** After a call is refused as unauthenticated, wait this long before registering again. */
|
|
20
25
|
const REREGISTER_COOLDOWN_MS = 60_000;
|
|
26
|
+
/**
|
|
27
|
+
* Build what an app presents to register itself, from its platform
|
|
28
|
+
* settings and manifest. Returns undefined if the app was given no
|
|
29
|
+
* enrolment token, which is right for a built-in service.
|
|
30
|
+
*
|
|
31
|
+
* The address is PLATFORM_APP_ADDRESS if set. Otherwise it is this
|
|
32
|
+
* machine's hostname and the manifest's port, which is right when the
|
|
33
|
+
* container's hostname is the name the gateway resolves.
|
|
34
|
+
*/
|
|
35
|
+
export function enrolmentFrom(config, manifest, options = {}) {
|
|
36
|
+
if (!config.enrolmentToken)
|
|
37
|
+
return undefined;
|
|
38
|
+
return {
|
|
39
|
+
token: config.enrolmentToken,
|
|
40
|
+
manifest: manifest,
|
|
41
|
+
address: config.appAddress ?? `http://${hostname()}:${manifest.port}`,
|
|
42
|
+
...(options.bundle !== undefined ? { bundle: options.bundle } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
21
45
|
function invalid(message) {
|
|
22
46
|
return new FinchlyError("service_call_invalid", message);
|
|
23
47
|
}
|
|
@@ -32,6 +56,10 @@ export function createServiceClient(options) {
|
|
|
32
56
|
const now = options.now ?? (() => new Date());
|
|
33
57
|
const issuer = `service:${slug}`;
|
|
34
58
|
let key;
|
|
59
|
+
/** A key pair made but not yet accepted. */
|
|
60
|
+
let candidate;
|
|
61
|
+
/** While a release waits for approval: when the gateway said to ask again, in ms. */
|
|
62
|
+
let enrolNotBeforeMs = 0;
|
|
35
63
|
let pending;
|
|
36
64
|
/** Set when a call came back 401: the gateway may no longer know the key. */
|
|
37
65
|
let doubtedAt;
|
|
@@ -57,7 +85,7 @@ export function createServiceClient(options) {
|
|
|
57
85
|
try {
|
|
58
86
|
return await doFetch(`${gatewayUrl}${KEYS_PATH}`, {
|
|
59
87
|
method: "POST",
|
|
60
|
-
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
|
88
|
+
headers: { ...SDK_REQUEST_HEADERS, authorization, "content-type": "application/json", accept: "application/json" },
|
|
61
89
|
body: JSON.stringify(body),
|
|
62
90
|
// The credential goes to the gateway and nowhere a response points.
|
|
63
91
|
redirect: "error",
|
|
@@ -89,29 +117,91 @@ export function createServiceClient(options) {
|
|
|
89
117
|
instance.renewAt = nowMs + ((expiresAt - nowMs) * 2) / 3;
|
|
90
118
|
return instance;
|
|
91
119
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
120
|
+
/** An app registers itself, which is also what gets its key accepted. */
|
|
121
|
+
async function enrol(instance, enrolment) {
|
|
122
|
+
const nowMs = now().getTime();
|
|
123
|
+
if (nowMs < enrolNotBeforeMs) {
|
|
124
|
+
throw new FinchlyError("app_pending_approval", "This app's release is waiting for an administrator's approval", [
|
|
125
|
+
`asking again in ${Math.ceil((enrolNotBeforeMs - nowMs) / 1000)} seconds`,
|
|
97
126
|
]);
|
|
98
127
|
}
|
|
99
|
-
|
|
128
|
+
let response;
|
|
129
|
+
try {
|
|
130
|
+
response = await doFetch(`${gatewayUrl}${ENROL_PATH}`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: {
|
|
133
|
+
...SDK_REQUEST_HEADERS,
|
|
134
|
+
authorization: `Enrol ${enrolment.token}`,
|
|
135
|
+
"content-type": "application/json",
|
|
136
|
+
accept: "application/json",
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
platformApi: PLATFORM_API_VERSION,
|
|
140
|
+
manifest: enrolment.manifest,
|
|
141
|
+
...(enrolment.bundle !== undefined ? { bundle: enrolment.bundle } : {}),
|
|
142
|
+
address: enrolment.address,
|
|
143
|
+
key: { kid: instance.kid, jwk: instance.publicJwk },
|
|
144
|
+
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
|
145
|
+
}),
|
|
146
|
+
// The token goes to the gateway and nowhere a response points.
|
|
147
|
+
redirect: "error",
|
|
148
|
+
signal: AbortSignal.timeout(REGISTRATION_TIMEOUT_MS),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw new FinchlyError("gateway_unreachable", "Cannot register this app with the gateway", [
|
|
153
|
+
err instanceof Error ? err.message : String(err),
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
if (response.status === 200)
|
|
157
|
+
return accept(instance, response);
|
|
158
|
+
let body = {};
|
|
159
|
+
try {
|
|
160
|
+
body = (await response.json());
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Reported below, by status.
|
|
164
|
+
}
|
|
165
|
+
if (response.status === 202) {
|
|
166
|
+
const seconds = typeof body.retryAfterSeconds === "number" && body.retryAfterSeconds > 0 ? body.retryAfterSeconds : 30;
|
|
167
|
+
// Do not ask again before the gateway said to.
|
|
168
|
+
enrolNotBeforeMs = nowMs + seconds * 1000;
|
|
169
|
+
throw new FinchlyError("app_pending_approval", "This app's release is waiting for an administrator's approval", [
|
|
170
|
+
`asking again in ${seconds} seconds`,
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
const reasons = Array.isArray(body.reasons) ? body.reasons.filter((r) => typeof r === "string") : [];
|
|
174
|
+
throw new FinchlyError("app_registration_refused", "The platform refused to register this app", reasons.length > 0 ? reasons : [typeof body.error === "string" ? body.error : `status ${response.status}`]);
|
|
175
|
+
}
|
|
176
|
+
/** Get a key accepted from scratch: with the credential, or by registering. */
|
|
177
|
+
async function firstRegistration(instance) {
|
|
178
|
+
if (credential)
|
|
179
|
+
return accept(instance, await register(instance, `Service-Credential ${credential}`, true));
|
|
180
|
+
if (options.enrolment)
|
|
181
|
+
return enrol(instance, options.enrolment);
|
|
182
|
+
throw new FinchlyError("service_key_rejected", "This instance has no key the gateway accepts, and no way to get one accepted", [
|
|
183
|
+
"A built-in service is deployed with PLATFORM_SERVICE_CREDENTIAL.",
|
|
184
|
+
"An app registers itself: pass `enrolment`, built with enrolmentFrom(config, manifest).",
|
|
185
|
+
]);
|
|
100
186
|
}
|
|
101
187
|
async function obtain(force) {
|
|
102
188
|
const nowMs = now().getTime();
|
|
103
189
|
if (key === undefined) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
190
|
+
if (candidate === undefined) {
|
|
191
|
+
const { publicKey, privateKey } = await generateKeyPair("ES256");
|
|
192
|
+
const { kty, crv, x, y } = await exportJWK(publicKey);
|
|
193
|
+
const publicJwk = { kty: kty, crv: crv, x: x, y: y };
|
|
194
|
+
candidate = {
|
|
195
|
+
kid: await calculateJwkThumbprint(publicJwk),
|
|
196
|
+
privateKey,
|
|
197
|
+
publicJwk,
|
|
198
|
+
expiresAt: 0,
|
|
199
|
+
renewAt: 0,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// The same key pair is offered until it is accepted: a release that
|
|
203
|
+
// is waiting for approval is asked about again with it.
|
|
204
|
+
key = await firstRegistration(candidate);
|
|
115
205
|
return key;
|
|
116
206
|
}
|
|
117
207
|
if (!force && nowMs < key.renewAt)
|
|
@@ -123,14 +213,16 @@ export function createServiceClient(options) {
|
|
|
123
213
|
return accept(key, response);
|
|
124
214
|
await response.body?.cancel();
|
|
125
215
|
}
|
|
126
|
-
// Expired, or the gateway no longer knows it:
|
|
127
|
-
return
|
|
216
|
+
// Expired, or the gateway no longer knows it: get it accepted again from scratch.
|
|
217
|
+
return firstRegistration(key);
|
|
128
218
|
}
|
|
129
219
|
/** The instance key, registered and current. Concurrent callers share one registration. */
|
|
130
220
|
function instanceKey() {
|
|
131
221
|
const nowMs = now().getTime();
|
|
132
|
-
//
|
|
133
|
-
const force = doubtedAt !== undefined &&
|
|
222
|
+
// Worth trying only if there is a way to register again.
|
|
223
|
+
const force = doubtedAt !== undefined &&
|
|
224
|
+
Boolean(credential || options.enrolment) &&
|
|
225
|
+
nowMs - lastForcedAt >= REREGISTER_COOLDOWN_MS;
|
|
134
226
|
if (key !== undefined && !force && nowMs < key.renewAt)
|
|
135
227
|
return Promise.resolve(key);
|
|
136
228
|
if (pending === undefined) {
|
|
@@ -178,6 +270,9 @@ export function createServiceClient(options) {
|
|
|
178
270
|
const headers = new Headers(request.headers);
|
|
179
271
|
// Whatever the caller set, the gateway reads this one.
|
|
180
272
|
headers.set("authorization", `Bearer ${token}`);
|
|
273
|
+
// And these say which SDK is calling, which is not the caller's to choose.
|
|
274
|
+
for (const [name, value] of Object.entries(SDK_REQUEST_HEADERS))
|
|
275
|
+
headers.set(name, value);
|
|
181
276
|
let response;
|
|
182
277
|
try {
|
|
183
278
|
// A redirect from the target is handed back, not followed: the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firetail/finchly",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Node SDK for the Finchly platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "git+https://github.com/
|
|
30
|
+
"url": "git+https://github.com/FireTail-io/finchly-sdk-js.git"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|