@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 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, published by hand so that the package exists and
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/firetail-io/finchly) platform.
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
- `firetail-io/finchly` under `spec/`. This release speaks platform API `v1`
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
- `firetail-io/finchly`.
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 `firetail-io/finchly`.
118
+ `spec/service-token.md` and `spec/registration.md` in `FireTail-io/finchly`.
119
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`.
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 | 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` |
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
- ## Develop
159
-
160
- ```
161
- npm install
162
- npm test
163
- ```
212
+ ## Changes
164
213
 
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.
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, { signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS) });
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 { createServiceClient } from "./service.js";
11
- export type { CallOptions, ServiceClient, ServiceClientOptions } from "./service.js";
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 { createServiceClient } from "./service.js";
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
- 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.",
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
- return accept(instance, await register(instance, `Service-Credential ${credential}`, true));
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
- 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);
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: start again from the credential.
127
- return withCredential(key);
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
- // 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;
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.1",
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/firetail-io/finchly-sdk-js.git"
30
+ "url": "git+https://github.com/FireTail-io/finchly-sdk-js.git"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"