@firetail/finchly 0.0.1 → 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 +26 -0
- package/README.md +92 -19
- 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,6 +4,32 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
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
|
+
|
|
7
33
|
## 0.0.1 - 2026-11-11
|
|
8
34
|
|
|
9
35
|
The first release, published by hand so that the package exists and
|
package/README.md
CHANGED
|
@@ -117,9 +117,63 @@ 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
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.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.
|
|
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 |
|
|
@@ -189,20 +243,39 @@ platform.
|
|
|
189
243
|
5. A bad release is deprecated with `npm deprecate` and followed by a patch
|
|
190
244
|
release. It is not unpublished.
|
|
191
245
|
|
|
192
|
-
Publishing
|
|
193
|
-
npm with its GitHub Actions token, so no npm token is stored
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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.
|
|
206
279
|
|
|
207
280
|
Still to add: the conformance suite from `firetail-io/finchly`, run in CI on
|
|
208
281
|
every change, once that suite exists.
|
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.4",
|
|
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"
|