@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.
- package/CHANGELOG.md +54 -0
- package/README.md +280 -2
- package/dist/config.d.ts +30 -0
- package/dist/config.js +68 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +27 -0
- package/dist/identity.d.ts +58 -0
- package/dist/identity.js +237 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/manifest.d.ts +49 -0
- package/dist/manifest.js +71 -0
- package/dist/platform-api.d.ts +6 -0
- package/dist/platform-api.js +8 -0
- package/dist/request-headers.d.ts +8 -0
- package/dist/request-headers.js +16 -0
- package/dist/service.d.ts +74 -0
- package/dist/service.js +296 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +8 -0
- package/package.json +40 -4
package/dist/identity.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Verifies the x-platform-identity token. Every check here is required by
|
|
2
|
+
// spec/identity-token.md in firetail-io/finchly, and the conformance vectors
|
|
3
|
+
// in test/conformance pin the verdict for each kind of bad token.
|
|
4
|
+
import { createLocalJWKSet, createRemoteJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
5
|
+
import { FinchlyError, IdentityError } from "./errors.js";
|
|
6
|
+
import { SUPPORTED_PLATFORM_API_VERSIONS } from "./platform-api.js";
|
|
7
|
+
import { SDK_REQUEST_HEADERS } from "./request-headers.js";
|
|
8
|
+
/** Header the gateway sets on every request it proxies. */
|
|
9
|
+
export const IDENTITY_HEADER = "x-platform-identity";
|
|
10
|
+
const REQUIRED_CLAIMS = ["iss", "sub", "aud", "iat", "exp"];
|
|
11
|
+
const DEFAULT_CLOCK_SKEW_SECONDS = 30;
|
|
12
|
+
const KEY_CACHE_MS = 60 * 60 * 1000;
|
|
13
|
+
const KEY_REFETCH_COOLDOWN_MS = 30 * 1000;
|
|
14
|
+
const GATEWAY_TIMEOUT_MS = 5000;
|
|
15
|
+
const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
16
|
+
function stringArray(value) {
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
return [];
|
|
19
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
|
20
|
+
return Object.freeze([...value]);
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function optionalString(value) {
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return null;
|
|
27
|
+
return typeof value === "string" ? value : undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Build the Identity from verified claims, rejecting wrongly typed ones. */
|
|
30
|
+
function toIdentity(payload, slug) {
|
|
31
|
+
// A token is for exactly one service. A list of audiences that happens to
|
|
32
|
+
// include this one is not accepted.
|
|
33
|
+
if (payload.aud !== slug) {
|
|
34
|
+
throw new IdentityError("audience");
|
|
35
|
+
}
|
|
36
|
+
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
|
|
37
|
+
throw new IdentityError("claims");
|
|
38
|
+
}
|
|
39
|
+
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") {
|
|
40
|
+
throw new IdentityError("claims");
|
|
41
|
+
}
|
|
42
|
+
const email = optionalString(payload.email);
|
|
43
|
+
const name = optionalString(payload.name);
|
|
44
|
+
const sessionId = optionalString(payload.sid);
|
|
45
|
+
const groups = stringArray(payload.groups);
|
|
46
|
+
const roles = stringArray(payload.roles);
|
|
47
|
+
const amr = stringArray(payload.amr);
|
|
48
|
+
let actor = null;
|
|
49
|
+
if (payload.act !== undefined) {
|
|
50
|
+
const act = payload.act;
|
|
51
|
+
actor = typeof act === "object" && act !== null && typeof act.sub === "string" ? act.sub : undefined;
|
|
52
|
+
}
|
|
53
|
+
if (email === undefined ||
|
|
54
|
+
name === undefined ||
|
|
55
|
+
sessionId === undefined ||
|
|
56
|
+
groups === undefined ||
|
|
57
|
+
roles === undefined ||
|
|
58
|
+
amr === undefined ||
|
|
59
|
+
actor === undefined) {
|
|
60
|
+
throw new IdentityError("claims");
|
|
61
|
+
}
|
|
62
|
+
return Object.freeze({
|
|
63
|
+
subject: payload.sub,
|
|
64
|
+
service: payload.sub.startsWith("service:"),
|
|
65
|
+
email,
|
|
66
|
+
name,
|
|
67
|
+
groups,
|
|
68
|
+
roles,
|
|
69
|
+
amr,
|
|
70
|
+
actor,
|
|
71
|
+
sessionId,
|
|
72
|
+
claims: Object.freeze({ ...payload }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function rejectionFor(err) {
|
|
76
|
+
if (err instanceof IdentityError)
|
|
77
|
+
return err;
|
|
78
|
+
if (err instanceof errors.JWTExpired)
|
|
79
|
+
return new IdentityError("expired");
|
|
80
|
+
if (err instanceof errors.JWTClaimValidationFailed) {
|
|
81
|
+
if (err.reason === "missing")
|
|
82
|
+
return new IdentityError("claims");
|
|
83
|
+
if (err.claim === "iss")
|
|
84
|
+
return new IdentityError("issuer");
|
|
85
|
+
if (err.claim === "aud")
|
|
86
|
+
return new IdentityError("audience");
|
|
87
|
+
return new IdentityError("claims");
|
|
88
|
+
}
|
|
89
|
+
if (err instanceof errors.JWKSNoMatchingKey || err instanceof errors.JWKSMultipleMatchingKeys) {
|
|
90
|
+
return new IdentityError("unknown_key");
|
|
91
|
+
}
|
|
92
|
+
if (err instanceof errors.JWSSignatureVerificationFailed)
|
|
93
|
+
return new IdentityError("signature");
|
|
94
|
+
if (err instanceof errors.JOSEAlgNotAllowed)
|
|
95
|
+
return new IdentityError("algorithm");
|
|
96
|
+
if (err instanceof errors.JWSInvalid || err instanceof errors.JWTInvalid) {
|
|
97
|
+
return new IdentityError("malformed");
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Create a verifier for this service.
|
|
103
|
+
*
|
|
104
|
+
* Create one at startup and reuse it: it caches the gateway's issuer and
|
|
105
|
+
* signing keys.
|
|
106
|
+
*/
|
|
107
|
+
export function createIdentityVerifier(options) {
|
|
108
|
+
const problems = [];
|
|
109
|
+
if (typeof options.slug !== "string" || !SLUG.test(options.slug)) {
|
|
110
|
+
problems.push("slug must be this service's slug");
|
|
111
|
+
}
|
|
112
|
+
const fixed = options.issuer !== undefined || options.jwks !== undefined;
|
|
113
|
+
if (fixed && options.gatewayUrl !== undefined) {
|
|
114
|
+
problems.push("give either gatewayUrl, or issuer and jwks, not both");
|
|
115
|
+
}
|
|
116
|
+
else if (fixed && (options.issuer === undefined || options.jwks === undefined)) {
|
|
117
|
+
problems.push("issuer and jwks must be given together");
|
|
118
|
+
}
|
|
119
|
+
else if (!fixed && !options.gatewayUrl) {
|
|
120
|
+
problems.push("gatewayUrl is required");
|
|
121
|
+
}
|
|
122
|
+
const skew = options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
|
|
123
|
+
if (!Number.isFinite(skew) || skew < 0) {
|
|
124
|
+
problems.push("clockSkewSeconds must be zero or more");
|
|
125
|
+
}
|
|
126
|
+
if (problems.length > 0) {
|
|
127
|
+
throw new FinchlyError("config_invalid", "Identity verifier options are invalid", problems);
|
|
128
|
+
}
|
|
129
|
+
const slug = options.slug;
|
|
130
|
+
const now = options.now ?? (() => new Date());
|
|
131
|
+
const gatewayUrl = options.gatewayUrl?.replace(/\/+$/, "");
|
|
132
|
+
let trust = options.issuer !== undefined && options.jwks !== undefined
|
|
133
|
+
? Promise.resolve({ issuer: options.issuer, getKey: createLocalJWKSet(options.jwks) })
|
|
134
|
+
: undefined;
|
|
135
|
+
async function discover() {
|
|
136
|
+
const url = `${gatewayUrl}/_gateway/.well-known/platform.json`;
|
|
137
|
+
let body;
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetch(url, {
|
|
140
|
+
headers: SDK_REQUEST_HEADERS,
|
|
141
|
+
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
|
|
142
|
+
});
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error(`HTTP ${response.status}`);
|
|
145
|
+
}
|
|
146
|
+
body = await response.json();
|
|
147
|
+
}
|
|
148
|
+
catch (cause) {
|
|
149
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
150
|
+
throw new FinchlyError("gateway_unreachable", `Cannot read ${url}`, [reason]);
|
|
151
|
+
}
|
|
152
|
+
const doc = (typeof body === "object" && body !== null ? body : {});
|
|
153
|
+
if (typeof doc.issuer !== "string" || doc.issuer.length === 0) {
|
|
154
|
+
throw new FinchlyError("gateway_unreachable", `${url} does not name an issuer`);
|
|
155
|
+
}
|
|
156
|
+
const served = Array.isArray(doc.platformApi) ? doc.platformApi : [];
|
|
157
|
+
if (!served.some((v) => SUPPORTED_PLATFORM_API_VERSIONS.includes(v))) {
|
|
158
|
+
throw new FinchlyError("platform_api_unsupported", `The gateway serves platform API ${JSON.stringify(served)}; this SDK speaks ${SUPPORTED_PLATFORM_API_VERSIONS.join(", ")}`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
issuer: doc.issuer,
|
|
162
|
+
getKey: createRemoteJWKSet(new URL(`${gatewayUrl}/_gateway/.well-known/jwks.json`), {
|
|
163
|
+
cacheMaxAge: KEY_CACHE_MS,
|
|
164
|
+
cooldownDuration: KEY_REFETCH_COOLDOWN_MS,
|
|
165
|
+
timeoutDuration: GATEWAY_TIMEOUT_MS,
|
|
166
|
+
headers: SDK_REQUEST_HEADERS,
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function getTrust() {
|
|
171
|
+
if (trust === undefined) {
|
|
172
|
+
const attempt = discover();
|
|
173
|
+
trust = attempt;
|
|
174
|
+
// A failed discovery is not cached: the next request tries again.
|
|
175
|
+
attempt.catch(() => {
|
|
176
|
+
if (trust === attempt)
|
|
177
|
+
trust = undefined;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return trust;
|
|
181
|
+
}
|
|
182
|
+
async function verify(token) {
|
|
183
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
184
|
+
throw new IdentityError("missing");
|
|
185
|
+
}
|
|
186
|
+
let alg;
|
|
187
|
+
try {
|
|
188
|
+
alg = decodeProtectedHeader(token).alg;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new IdentityError("malformed");
|
|
192
|
+
}
|
|
193
|
+
// Checked first and by hand, so that no other property of the token can
|
|
194
|
+
// influence what happens to one with the wrong algorithm.
|
|
195
|
+
if (alg !== "ES256") {
|
|
196
|
+
throw new IdentityError("algorithm");
|
|
197
|
+
}
|
|
198
|
+
const { issuer, getKey } = await getTrust();
|
|
199
|
+
let payload;
|
|
200
|
+
try {
|
|
201
|
+
({ payload } = await jwtVerify(token, getKey, {
|
|
202
|
+
algorithms: ["ES256"],
|
|
203
|
+
issuer,
|
|
204
|
+
audience: slug,
|
|
205
|
+
clockTolerance: skew,
|
|
206
|
+
currentDate: now(),
|
|
207
|
+
requiredClaims: REQUIRED_CLAIMS,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
const rejection = rejectionFor(err);
|
|
212
|
+
if (rejection)
|
|
213
|
+
throw rejection;
|
|
214
|
+
// Anything else is a failure to fetch the keys, not a bad token. It
|
|
215
|
+
// must not be reported as the caller's fault.
|
|
216
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
217
|
+
throw new FinchlyError("gateway_unreachable", "Cannot fetch the gateway's signing keys", [reason]);
|
|
218
|
+
}
|
|
219
|
+
return toIdentity(payload, slug);
|
|
220
|
+
}
|
|
221
|
+
function fromHeaders(headers) {
|
|
222
|
+
let value;
|
|
223
|
+
if (typeof headers.get === "function") {
|
|
224
|
+
value = headers.get(IDENTITY_HEADER);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
value = headers[IDENTITY_HEADER];
|
|
228
|
+
}
|
|
229
|
+
// The gateway sets the header exactly once. More than one value means
|
|
230
|
+
// something else added one.
|
|
231
|
+
if (Array.isArray(value)) {
|
|
232
|
+
return Promise.reject(new IdentityError(value.length === 0 ? "missing" : "malformed"));
|
|
233
|
+
}
|
|
234
|
+
return verify(value);
|
|
235
|
+
}
|
|
236
|
+
return Object.freeze({ verify, fromHeaders });
|
|
237
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
|
+
export type { PlatformConfig } from "./config.js";
|
|
3
|
+
export { FinchlyError, IdentityError } from "./errors.js";
|
|
4
|
+
export type { FinchlyErrorCode, IdentityRejection } from "./errors.js";
|
|
5
|
+
export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
|
6
|
+
export type { Identity, IdentityVerifier, IdentityVerifierOptions } from "./identity.js";
|
|
7
|
+
export { checkManifest, loadManifest } from "./manifest.js";
|
|
8
|
+
export type { Manifest, ManifestPermissions, PublicPath } from "./manifest.js";
|
|
9
|
+
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.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";
|
|
13
|
+
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
|
+
export { FinchlyError, IdentityError } from "./errors.js";
|
|
3
|
+
export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
|
4
|
+
export { checkManifest, loadManifest } from "./manifest.js";
|
|
5
|
+
export { MANIFEST_VERSION, PLATFORM_API_VERSION, SUPPORTED_PLATFORM_API_VERSIONS, } from "./platform-api.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";
|
|
8
|
+
export { SDK_NAME, SDK_VERSION } from "./version.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface PublicPath {
|
|
2
|
+
readonly pattern: string;
|
|
3
|
+
readonly methods?: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
export interface ManifestPermissions {
|
|
6
|
+
readonly db?: boolean;
|
|
7
|
+
readonly blobs?: boolean;
|
|
8
|
+
readonly secrets?: boolean;
|
|
9
|
+
readonly llm?: boolean;
|
|
10
|
+
readonly vectors?: boolean;
|
|
11
|
+
readonly notify?: boolean;
|
|
12
|
+
readonly schedule?: boolean;
|
|
13
|
+
readonly connectors?: boolean;
|
|
14
|
+
readonly queue?: {
|
|
15
|
+
readonly publish?: readonly string[];
|
|
16
|
+
readonly consume?: readonly string[];
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** A Finchly manifest, version 1. The schema is in firetail-io/finchly, spec/manifest. */
|
|
20
|
+
export interface Manifest {
|
|
21
|
+
readonly manifestVersion: 1;
|
|
22
|
+
readonly platformApi: string;
|
|
23
|
+
readonly slug: string;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly version: string;
|
|
26
|
+
readonly port: number;
|
|
27
|
+
readonly healthPath: string;
|
|
28
|
+
readonly description?: string;
|
|
29
|
+
readonly icon?: string;
|
|
30
|
+
readonly tags?: readonly string[];
|
|
31
|
+
readonly permissions?: ManifestPermissions;
|
|
32
|
+
readonly calls?: readonly string[];
|
|
33
|
+
readonly publicPaths?: readonly PublicPath[];
|
|
34
|
+
readonly proxyTimeoutSeconds?: number;
|
|
35
|
+
readonly maxBodyBytes?: number;
|
|
36
|
+
readonly websocket?: boolean;
|
|
37
|
+
readonly audit?: "full" | "metadata";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Check the parts of a manifest this SDK itself depends on.
|
|
41
|
+
*
|
|
42
|
+
* This is deliberately not the full schema. The platform runs the full
|
|
43
|
+
* check when the service registers, and is the authority. The point here is
|
|
44
|
+
* to stop a container at startup, with a clear message, when the manifest
|
|
45
|
+
* is missing something the SDK cannot work without.
|
|
46
|
+
*/
|
|
47
|
+
export declare function checkManifest(value: unknown): Manifest;
|
|
48
|
+
/** Read and check the manifest from the container's filesystem. */
|
|
49
|
+
export declare function loadManifest(path?: string): Promise<Manifest>;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { DEFAULT_MANIFEST_PATH } from "./config.js";
|
|
3
|
+
import { FinchlyError } from "./errors.js";
|
|
4
|
+
import { MANIFEST_VERSION, SUPPORTED_PLATFORM_API_VERSIONS } from "./platform-api.js";
|
|
5
|
+
const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
6
|
+
/**
|
|
7
|
+
* Check the parts of a manifest this SDK itself depends on.
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately not the full schema. The platform runs the full
|
|
10
|
+
* check when the service registers, and is the authority. The point here is
|
|
11
|
+
* to stop a container at startup, with a clear message, when the manifest
|
|
12
|
+
* is missing something the SDK cannot work without.
|
|
13
|
+
*/
|
|
14
|
+
export function checkManifest(value) {
|
|
15
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
16
|
+
throw new FinchlyError("manifest_invalid", "Manifest is not a JSON object");
|
|
17
|
+
}
|
|
18
|
+
const m = value;
|
|
19
|
+
const problems = [];
|
|
20
|
+
if (m.manifestVersion !== MANIFEST_VERSION) {
|
|
21
|
+
problems.push(`manifestVersion must be ${MANIFEST_VERSION}`);
|
|
22
|
+
}
|
|
23
|
+
if (typeof m.platformApi !== "string") {
|
|
24
|
+
problems.push("platformApi must be a string");
|
|
25
|
+
}
|
|
26
|
+
else if (!SUPPORTED_PLATFORM_API_VERSIONS.includes(m.platformApi)) {
|
|
27
|
+
problems.push(`platformApi "${m.platformApi}" is not one this SDK speaks (${SUPPORTED_PLATFORM_API_VERSIONS.join(", ")})`);
|
|
28
|
+
}
|
|
29
|
+
const slugOk = typeof m.slug === "string" && m.slug.length >= 2 && m.slug.length <= 40 && SLUG.test(m.slug);
|
|
30
|
+
if (!slugOk) {
|
|
31
|
+
problems.push("slug must be 2 to 40 lowercase letters, digits and single hyphens, starting with a letter");
|
|
32
|
+
}
|
|
33
|
+
if (typeof m.name !== "string" || m.name.length === 0) {
|
|
34
|
+
problems.push("name must be a non-empty string");
|
|
35
|
+
}
|
|
36
|
+
if (typeof m.version !== "string" || m.version.length === 0) {
|
|
37
|
+
problems.push("version must be a non-empty string");
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isInteger(m.port) || m.port < 1 || m.port > 65535) {
|
|
40
|
+
problems.push("port must be an integer from 1 to 65535");
|
|
41
|
+
}
|
|
42
|
+
if (typeof m.healthPath !== "string") {
|
|
43
|
+
problems.push("healthPath must be a string");
|
|
44
|
+
}
|
|
45
|
+
else if (slugOk && !m.healthPath.startsWith(`/${m.slug}/`)) {
|
|
46
|
+
problems.push(`healthPath must start with /${m.slug}/`);
|
|
47
|
+
}
|
|
48
|
+
if (problems.length > 0) {
|
|
49
|
+
throw new FinchlyError("manifest_invalid", "Manifest is invalid", problems);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
/** Read and check the manifest from the container's filesystem. */
|
|
54
|
+
export async function loadManifest(path = DEFAULT_MANIFEST_PATH) {
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = await readFile(path, "utf8");
|
|
58
|
+
}
|
|
59
|
+
catch (cause) {
|
|
60
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
61
|
+
throw new FinchlyError("manifest_unreadable", `Cannot read manifest at ${path}`, [reason]);
|
|
62
|
+
}
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(text);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new FinchlyError("manifest_invalid", `Manifest at ${path} is not valid JSON`);
|
|
69
|
+
}
|
|
70
|
+
return checkManifest(parsed);
|
|
71
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Platform API version this SDK release speaks by default. */
|
|
2
|
+
export declare const PLATFORM_API_VERSION = "v1";
|
|
3
|
+
/** Every platform API version this SDK release can speak. */
|
|
4
|
+
export declare const SUPPORTED_PLATFORM_API_VERSIONS: readonly string[];
|
|
5
|
+
/** Manifest schema version this SDK release reads. */
|
|
6
|
+
export declare const MANIFEST_VERSION = 1;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The platform API is specified in firetail-io/finchly under spec/. This
|
|
2
|
+
// package is one implementation of it and defines no protocol of its own.
|
|
3
|
+
/** Platform API version this SDK release speaks by default. */
|
|
4
|
+
export const PLATFORM_API_VERSION = "v1";
|
|
5
|
+
/** Every platform API version this SDK release can speak. */
|
|
6
|
+
export const SUPPORTED_PLATFORM_API_VERSIONS = Object.freeze(["v1"]);
|
|
7
|
+
/** Manifest schema version this SDK release reads. */
|
|
8
|
+
export const MANIFEST_VERSION = 1;
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PlatformConfig } from "./config.js";
|
|
2
|
+
import type { Manifest } from "./manifest.js";
|
|
3
|
+
export interface ServiceClientOptions {
|
|
4
|
+
/** This service's slug. */
|
|
5
|
+
readonly slug: string;
|
|
6
|
+
/** The gateway's internal address (PLATFORM_GATEWAY_URL). */
|
|
7
|
+
readonly gatewayUrl: string;
|
|
8
|
+
/**
|
|
9
|
+
* The credential this built-in service was deployed with
|
|
10
|
+
* (PLATFORM_SERVICE_CREDENTIAL). It is what gets the instance key
|
|
11
|
+
* accepted. An app has none; its key is accepted when it registers.
|
|
12
|
+
*/
|
|
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;
|
|
20
|
+
/** For tests. */
|
|
21
|
+
readonly fetch?: typeof fetch;
|
|
22
|
+
/** For tests. */
|
|
23
|
+
readonly now?: () => Date;
|
|
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;
|
|
48
|
+
export interface CallOptions {
|
|
49
|
+
/**
|
|
50
|
+
* The id of the user this call is made for. The target then sees that
|
|
51
|
+
* user, and this service as the one acting for them. Leave it out to
|
|
52
|
+
* call as the service itself.
|
|
53
|
+
*/
|
|
54
|
+
readonly asUser?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface ServiceClient {
|
|
57
|
+
/**
|
|
58
|
+
* Make the instance key and have the gateway accept it. Optional: the
|
|
59
|
+
* first call does this. Call it at startup to fail early instead.
|
|
60
|
+
*/
|
|
61
|
+
start(): Promise<void>;
|
|
62
|
+
/** The id of this instance's key, once it has one. */
|
|
63
|
+
readonly keyId: string | undefined;
|
|
64
|
+
/** A signed token for a call to `target`. Most code wants `call`. */
|
|
65
|
+
token(target: string, options?: CallOptions): Promise<string>;
|
|
66
|
+
/**
|
|
67
|
+
* Call another service. `path` is the path under the target, starting
|
|
68
|
+
* with "/". Resolves with the response, whatever its status: a 403 means
|
|
69
|
+
* this service's manifest does not declare the target in `calls`, or the
|
|
70
|
+
* user has no access to it.
|
|
71
|
+
*/
|
|
72
|
+
call(target: string, path: string, init?: RequestInit & CallOptions): Promise<Response>;
|
|
73
|
+
}
|
|
74
|
+
export declare function createServiceClient(options: ServiceClientOptions): ServiceClient;
|