@firetail/finchly 0.0.0-stage → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ // Calls from this service to another, through the gateway.
2
+ //
3
+ // Services never address each other directly. A call goes to the gateway's
4
+ // internal address with a token this instance signs with its own key; the
5
+ // gateway checks it, decides whether the call is allowed, and forwards it.
6
+ // The token format is in spec/service-token.md and key registration in
7
+ // spec/registration.md, both in firetail-io/finchly.
8
+ import { randomUUID } from "node:crypto";
9
+ import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair } from "jose";
10
+ import { hostname } from "node:os";
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";
15
+ const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
16
+ const USER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
17
+ /** The audience of a token meant for the gateway itself. */
18
+ const GATEWAY_AUDIENCE = "gateway";
19
+ const KEYS_PATH = "/_gateway/keys";
20
+ const ENROL_PATH = "/_gateway/enrol";
21
+ /** How long a token is valid. The specification allows up to 300 seconds. */
22
+ const TOKEN_TTL_SECONDS = 60;
23
+ const REGISTRATION_TIMEOUT_MS = 10_000;
24
+ /** After a call is refused as unauthenticated, wait this long before registering again. */
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
+ }
45
+ function invalid(message) {
46
+ return new FinchlyError("service_call_invalid", message);
47
+ }
48
+ export function createServiceClient(options) {
49
+ if (!SLUG.test(options.slug))
50
+ throw invalid(`slug ${JSON.stringify(options.slug)} is not a slug`);
51
+ const gatewayUrl = options.gatewayUrl.replace(/\/+$/, "");
52
+ if (!/^https?:\/\//.test(gatewayUrl))
53
+ throw invalid("gatewayUrl must be an http or https URL");
54
+ const { slug, credential } = options;
55
+ const doFetch = options.fetch ?? fetch;
56
+ const now = options.now ?? (() => new Date());
57
+ const issuer = `service:${slug}`;
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;
63
+ let pending;
64
+ /** Set when a call came back 401: the gateway may no longer know the key. */
65
+ let doubtedAt;
66
+ let lastForcedAt = Number.NEGATIVE_INFINITY;
67
+ async function sign(instance, audience, subject) {
68
+ const issuedAt = Math.floor(now().getTime() / 1000);
69
+ return new SignJWT({})
70
+ .setProtectedHeader({ alg: "ES256", kid: instance.kid, typ: "JWT" })
71
+ .setIssuer(issuer)
72
+ .setSubject(subject)
73
+ .setAudience(audience)
74
+ .setIssuedAt(issuedAt)
75
+ .setExpirationTime(issuedAt + TOKEN_TTL_SECONDS)
76
+ .setJti(randomUUID())
77
+ .sign(instance.privateKey);
78
+ }
79
+ /** Ask the gateway to accept the key, or to extend it. */
80
+ async function register(instance, authorization, withSlug) {
81
+ const body = {
82
+ ...(withSlug ? { slug } : {}),
83
+ key: { kid: instance.kid, jwk: instance.publicJwk },
84
+ };
85
+ try {
86
+ return await doFetch(`${gatewayUrl}${KEYS_PATH}`, {
87
+ method: "POST",
88
+ headers: { ...SDK_REQUEST_HEADERS, authorization, "content-type": "application/json", accept: "application/json" },
89
+ body: JSON.stringify(body),
90
+ // The credential goes to the gateway and nowhere a response points.
91
+ redirect: "error",
92
+ signal: AbortSignal.timeout(REGISTRATION_TIMEOUT_MS),
93
+ });
94
+ }
95
+ catch (err) {
96
+ throw new FinchlyError("gateway_unreachable", "Cannot register this instance's key with the gateway", [
97
+ err instanceof Error ? err.message : String(err),
98
+ ]);
99
+ }
100
+ }
101
+ async function accept(instance, response) {
102
+ let body = {};
103
+ try {
104
+ body = (await response.json());
105
+ }
106
+ catch {
107
+ // Reported below, by status.
108
+ }
109
+ const expiresAt = typeof body.keyExpiresAt === "string" ? Date.parse(body.keyExpiresAt) : Number.NaN;
110
+ if (response.status !== 200 || Number.isNaN(expiresAt)) {
111
+ const reason = typeof body.error === "string" ? body.error : `status ${response.status}`;
112
+ throw new FinchlyError("service_key_rejected", "The gateway did not accept this instance's key", [reason]);
113
+ }
114
+ const nowMs = now().getTime();
115
+ instance.expiresAt = expiresAt;
116
+ // Extend it when two thirds of its life has gone.
117
+ instance.renewAt = nowMs + ((expiresAt - nowMs) * 2) / 3;
118
+ return instance;
119
+ }
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`,
126
+ ]);
127
+ }
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
+ ]);
186
+ }
187
+ async function obtain(force) {
188
+ const nowMs = now().getTime();
189
+ if (key === undefined) {
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);
205
+ return key;
206
+ }
207
+ if (!force && nowMs < key.renewAt)
208
+ return key;
209
+ if (!force && nowMs < key.expiresAt) {
210
+ // Still accepted: extend it with a token it signs itself.
211
+ const response = await register(key, `Bearer ${await sign(key, GATEWAY_AUDIENCE, issuer)}`, false);
212
+ if (response.status !== 401)
213
+ return accept(key, response);
214
+ await response.body?.cancel();
215
+ }
216
+ // Expired, or the gateway no longer knows it: get it accepted again from scratch.
217
+ return firstRegistration(key);
218
+ }
219
+ /** The instance key, registered and current. Concurrent callers share one registration. */
220
+ function instanceKey() {
221
+ const nowMs = now().getTime();
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;
226
+ if (key !== undefined && !force && nowMs < key.renewAt)
227
+ return Promise.resolve(key);
228
+ if (pending === undefined) {
229
+ if (force) {
230
+ lastForcedAt = nowMs;
231
+ doubtedAt = undefined;
232
+ }
233
+ pending = obtain(force).finally(() => {
234
+ pending = undefined;
235
+ });
236
+ }
237
+ return pending;
238
+ }
239
+ function subjectFor(callOptions) {
240
+ const user = callOptions?.asUser;
241
+ if (user === undefined)
242
+ return issuer;
243
+ if (!USER_ID.test(user))
244
+ throw invalid("asUser must be a user's platform id");
245
+ return user;
246
+ }
247
+ return {
248
+ async start() {
249
+ await instanceKey();
250
+ },
251
+ get keyId() {
252
+ return key?.kid;
253
+ },
254
+ async token(target, callOptions) {
255
+ if (!SLUG.test(target))
256
+ throw invalid(`target ${JSON.stringify(target)} is not a slug`);
257
+ const subject = subjectFor(callOptions);
258
+ return sign(await instanceKey(), target, subject);
259
+ },
260
+ async call(target, path, init = {}) {
261
+ if (!SLUG.test(target))
262
+ throw invalid(`target ${JSON.stringify(target)} is not a slug`);
263
+ // The path is joined as text. It must not be able to climb out of the target.
264
+ if (!path.startsWith("/") || path.includes("\\") || /(^|\/)\.\.?(\/|$|\?)/.test(path.split("?")[0] ?? "")) {
265
+ throw invalid('path must start with "/" and contain no "." or ".." segments');
266
+ }
267
+ const { asUser, ...request } = init;
268
+ const subject = subjectFor(asUser !== undefined ? { asUser } : undefined);
269
+ const token = await sign(await instanceKey(), target, subject);
270
+ const headers = new Headers(request.headers);
271
+ // Whatever the caller set, the gateway reads this one.
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);
276
+ let response;
277
+ try {
278
+ // A redirect from the target is handed back, not followed: the
279
+ // token is for the target and goes nowhere else.
280
+ response = await doFetch(`${gatewayUrl}/${target}${path}`, { redirect: "manual", ...request, headers });
281
+ }
282
+ catch (err) {
283
+ const name = err?.name;
284
+ // The caller's own abort or timeout is theirs to handle.
285
+ if (name === "AbortError" || name === "TimeoutError")
286
+ throw err;
287
+ throw new FinchlyError("gateway_unreachable", `Cannot reach ${target} through the gateway`, [
288
+ err instanceof Error ? err.message : String(err),
289
+ ]);
290
+ }
291
+ if (response.status === 401)
292
+ doubtedAt = now().getTime();
293
+ return response;
294
+ },
295
+ };
296
+ }
@@ -0,0 +1,4 @@
1
+ /** Package name, as reported to the platform at registration. */
2
+ export declare const SDK_NAME: string;
3
+ /** Package version, as reported to the platform at registration. */
4
+ export declare const SDK_VERSION: string;
@@ -0,0 +1,8 @@
1
+ import { createRequire } from "node:module";
2
+ // package.json sits one level above both src/ and dist/, so this resolves
3
+ // the same way from source and from the published build.
4
+ const pkg = createRequire(import.meta.url)("../package.json");
5
+ /** Package name, as reported to the platform at registration. */
6
+ export const SDK_NAME = pkg.name;
7
+ /** Package version, as reported to the platform at registration. */
8
+ export const SDK_VERSION = pkg.version;
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@firetail/finchly",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.4",
4
+ "description": "Node SDK for the Finchly platform",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=22"
21
+ },
22
+ "scripts": {
23
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
24
+ "build": "npm run clean && tsc -p tsconfig.json",
25
+ "test": "npm run build && node --test \"test/**/*.test.mjs\"",
26
+ "prepublishOnly": "npm test"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/FireTail-io/finchly-sdk-js.git"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "typescript": "~5.9.3"
38
+ },
39
+ "dependencies": {
40
+ "jose": "^6.2.17"
41
+ }
42
+ }