@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 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
- 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.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 | 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 |
@@ -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 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.
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, { 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.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/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"