@openparachute/hub 0.5.0 → 0.5.2
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/package.json +1 -1
- package/src/__tests__/auth.test.ts +352 -1
- package/src/__tests__/hub-server.test.ts +580 -1
- package/src/__tests__/lifecycle.test.ts +101 -4
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/services-manifest.test.ts +26 -0
- package/src/commands/auth.ts +200 -1
- package/src/commands/lifecycle.ts +39 -13
- package/src/hub-server.ts +126 -29
- package/src/jwt-audience.ts +40 -0
- package/src/module-manifest.ts +19 -0
- package/src/oauth-handlers.ts +1 -32
- package/src/service-spec.ts +8 -0
- package/src/services-manifest.ts +21 -0
package/src/hub-server.ts
CHANGED
|
@@ -147,40 +147,39 @@ export function findVaultUpstream(
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
150
|
+
* Forward a request to a loopback service on `127.0.0.1:<port>`. By default
|
|
151
|
+
* the incoming pathname + query are preserved verbatim; pass `targetPath` to
|
|
152
|
+
* rewrite the path (e.g. when the caller has stripped a mount prefix because
|
|
153
|
+
* the backend serves bare routes). Query string is always preserved from the
|
|
154
|
+
* incoming URL.
|
|
154
155
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
156
|
+
* Note: this is **not** equivalent to the tailscale convention. `tailscale
|
|
157
|
+
* serve <mount>=<target>` strips the mount before forwarding, so
|
|
158
|
+
* `serviceProxyTarget` in `commands/expose.ts` works by making mount and
|
|
159
|
+
* target byte-equal. The hub's fetch-based proxy does no stripping unless the
|
|
160
|
+
* caller asks; per-service preferences vary (scribe wants bare paths, notes
|
|
161
|
+
* / agent / vault want the prefix), so the decision lives one layer up in
|
|
162
|
+
* `proxyToService` / `proxyToVault`.
|
|
158
163
|
*
|
|
159
|
-
* Returns
|
|
160
|
-
*
|
|
161
|
-
* the
|
|
162
|
-
*
|
|
164
|
+
* Returns 502 when the loopback fetch fails — port valid, target unreachable
|
|
165
|
+
* (service crashed, port shifted, mid-restart). `serviceLabel` is folded into
|
|
166
|
+
* the error message so 502 bodies say `vault upstream unreachable` /
|
|
167
|
+
* `scribe upstream unreachable` etc.
|
|
163
168
|
*
|
|
164
169
|
* Hop-by-hop notes: WebSocket upgrades and HTTP/2 trailers don't traverse
|
|
165
|
-
* fetch-based proxies cleanly.
|
|
166
|
-
* needs them, switch to a Node http.IncomingMessage / http.request
|
|
170
|
+
* fetch-based proxies cleanly. No on-box service uses either today; if one
|
|
171
|
+
* eventually needs them, switch to a Node http.IncomingMessage / http.request
|
|
172
|
+
* pair.
|
|
167
173
|
*/
|
|
168
|
-
async function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return new Response(JSON.stringify({ error: `vault routing failed: ${msg}` }), {
|
|
175
|
-
status: 500,
|
|
176
|
-
headers: { "content-type": "application/json" },
|
|
177
|
-
});
|
|
178
|
-
}
|
|
174
|
+
async function proxyRequest(
|
|
175
|
+
req: Request,
|
|
176
|
+
port: number,
|
|
177
|
+
serviceLabel: string,
|
|
178
|
+
targetPath?: string,
|
|
179
|
+
): Promise<Response> {
|
|
179
180
|
const url = new URL(req.url);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const upstream = `http://127.0.0.1:${match.port}${url.pathname}${url.search}`;
|
|
181
|
+
const path = targetPath ?? url.pathname;
|
|
182
|
+
const upstream = `http://127.0.0.1:${port}${path}${url.search}`;
|
|
184
183
|
const headers = new Headers(req.headers);
|
|
185
184
|
// Host comes from the requester (tailnet FQDN); the loopback target wants
|
|
186
185
|
// its own. Bun's fetch fills it in when omitted.
|
|
@@ -199,13 +198,104 @@ async function proxyToVault(req: Request, manifestPath: string): Promise<Respons
|
|
|
199
198
|
return await fetch(upstream, init);
|
|
200
199
|
} catch (err) {
|
|
201
200
|
const msg = err instanceof Error ? err.message : String(err);
|
|
202
|
-
return new Response(JSON.stringify({ error:
|
|
201
|
+
return new Response(JSON.stringify({ error: `${serviceLabel} upstream unreachable: ${msg}` }), {
|
|
203
202
|
status: 502,
|
|
204
203
|
headers: { "content-type": "application/json" },
|
|
205
204
|
});
|
|
206
205
|
}
|
|
207
206
|
}
|
|
208
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Reverse-proxy a `/vault/<name>/*` request onto the vault backend.
|
|
210
|
+
* `manifestPath` is the services.json path from `HubFetchDeps`. Read on every
|
|
211
|
+
* proxied request so a vault created seconds ago is reachable without a
|
|
212
|
+
* re-expose — same dynamism as the well-known doc (#135).
|
|
213
|
+
*
|
|
214
|
+
* Returns `undefined` when no vault claims this pathname so the caller can
|
|
215
|
+
* fall through to the SPA shell fallback for unknown vault names (the seam
|
|
216
|
+
* #173 introduced).
|
|
217
|
+
*/
|
|
218
|
+
async function proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
|
|
219
|
+
let services: readonly ServiceEntry[];
|
|
220
|
+
try {
|
|
221
|
+
services = readManifest(manifestPath).services;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
224
|
+
return new Response(JSON.stringify({ error: `vault routing failed: ${msg}` }), {
|
|
225
|
+
status: 500,
|
|
226
|
+
headers: { "content-type": "application/json" },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const url = new URL(req.url);
|
|
230
|
+
const match = findVaultUpstream(services, url.pathname);
|
|
231
|
+
if (!match) return undefined;
|
|
232
|
+
return proxyRequest(req, match.port, "vault");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Resolve which (non-vault) ServiceEntry should handle a given request.
|
|
237
|
+
* Generic longest-prefix match across every service's `paths[]`. Vault
|
|
238
|
+
* entries are filtered out — they're routed by `findVaultUpstream` /
|
|
239
|
+
* `proxyToVault`, which encode the vault-specific SPA-fallback seam.
|
|
240
|
+
*
|
|
241
|
+
* Returns `undefined` when no service claims the pathname; the caller 404s.
|
|
242
|
+
*/
|
|
243
|
+
export function findServiceUpstream(
|
|
244
|
+
services: readonly ServiceEntry[],
|
|
245
|
+
pathname: string,
|
|
246
|
+
): { port: number; mount: string; entry: ServiceEntry } | undefined {
|
|
247
|
+
let best: { port: number; mount: string; entry: ServiceEntry } | undefined;
|
|
248
|
+
for (const s of services) {
|
|
249
|
+
if (isVaultEntry(s)) continue;
|
|
250
|
+
for (const path of s.paths) {
|
|
251
|
+
if (pathname === path || pathname.startsWith(`${path}/`)) {
|
|
252
|
+
if (!best || path.length > best.mount.length) {
|
|
253
|
+
best = { port: s.port, mount: path, entry: s };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return best;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Reverse-proxy a request onto whichever non-vault service registers a
|
|
263
|
+
* matching `paths[]` prefix in services.json. Wired after every specific
|
|
264
|
+
* handler in `hubFetch` so the exclusion list (`/`, `/admin/*`, `/oauth/*`,
|
|
265
|
+
* `/.well-known/*`, `/hub/*`, `/vault/*`, `/api/*`) is enforced by ordering:
|
|
266
|
+
* those specific handlers run first and never reach this dispatch.
|
|
267
|
+
*
|
|
268
|
+
* Read services.json on every request so a `parachute install <svc>` made
|
|
269
|
+
* seconds ago is reachable without a hub restart — same dynamism as the
|
|
270
|
+
* well-known doc and `proxyToVault`.
|
|
271
|
+
*
|
|
272
|
+
* Honors `entry.stripPrefix`: when `true` the matched mount prefix is
|
|
273
|
+
* removed from the forwarded path so the backend sees a bare route
|
|
274
|
+
* (`/scribe/health` becomes `/health`). Default (`false` / absent) forwards
|
|
275
|
+
* the full path — matches what notes / agent / vault expect.
|
|
276
|
+
*
|
|
277
|
+
* Returns `undefined` when no service claims the pathname; caller 404s.
|
|
278
|
+
*/
|
|
279
|
+
async function proxyToService(req: Request, manifestPath: string): Promise<Response | undefined> {
|
|
280
|
+
let services: readonly ServiceEntry[];
|
|
281
|
+
try {
|
|
282
|
+
services = readManifest(manifestPath).services;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
285
|
+
return new Response(JSON.stringify({ error: `service routing failed: ${msg}` }), {
|
|
286
|
+
status: 500,
|
|
287
|
+
headers: { "content-type": "application/json" },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const url = new URL(req.url);
|
|
291
|
+
const match = findServiceUpstream(services, url.pathname);
|
|
292
|
+
if (!match) return undefined;
|
|
293
|
+
const targetPath = match.entry.stripPrefix
|
|
294
|
+
? url.pathname.slice(match.mount.length) || "/"
|
|
295
|
+
: undefined;
|
|
296
|
+
return proxyRequest(req, match.port, match.entry.name, targetPath);
|
|
297
|
+
}
|
|
298
|
+
|
|
209
299
|
export interface HubFetchDeps {
|
|
210
300
|
/**
|
|
211
301
|
* Lazily opens (or returns a cached handle to) the hub DB. Optional so
|
|
@@ -690,6 +780,13 @@ export function hubFetch(
|
|
|
690
780
|
return serveSpa(spaDistDir, pathname, "/vault");
|
|
691
781
|
}
|
|
692
782
|
|
|
783
|
+
// Generic services.json-driven dispatch for non-vault modules. Reaches
|
|
784
|
+
// here only after every hub-owned prefix above has had its turn — so
|
|
785
|
+
// `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
|
|
786
|
+
// `/api/*` are excluded by ordering, not by an explicit denylist (#182).
|
|
787
|
+
const proxied = await proxyToService(req, manifestPath);
|
|
788
|
+
if (proxied) return proxied;
|
|
789
|
+
|
|
693
790
|
return new Response("not found", { status: 404 });
|
|
694
791
|
};
|
|
695
792
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audience derivation for hub-issued JWTs. Used by both:
|
|
3
|
+
* - `/oauth/token` (auth_code redemption + refresh rotation)
|
|
4
|
+
* - `parachute auth mint-token` (CLI shortcut for scope-narrow tokens)
|
|
5
|
+
*
|
|
6
|
+
* Per the vault-config-and-scopes design (Phase 1+2):
|
|
7
|
+
* - A named `vault:<name>:<verb>` → `vault.<name>` (RFC 8707-style resource
|
|
8
|
+
* binding; vault enforces this strict-equality against the URL-derived
|
|
9
|
+
* vault name).
|
|
10
|
+
* - An unnamed `<service>:<verb>` → `<service>` (legacy shape; vault's
|
|
11
|
+
* strict-check rejects unnamed `vault:*` audiences, so the consent
|
|
12
|
+
* picker rewrites those before this is reached).
|
|
13
|
+
* - Fallback: `hub` (no namespaced scope).
|
|
14
|
+
*
|
|
15
|
+
* Named vault scopes win over unnamed ones — an OAuth flow that mixes
|
|
16
|
+
* `vault:work:read` + `scribe:transcribe` audiences is grounded on the vault
|
|
17
|
+
* (the more sensitive resource), and tokens are issued per-flow anyway.
|
|
18
|
+
*
|
|
19
|
+
* Hoisted from `oauth-handlers.ts` so CLI mints and OAuth mints can't diverge
|
|
20
|
+
* on audience semantics — a divergence here means tokens minted via CLI fail
|
|
21
|
+
* audience strict-check at the resource server even though scopes match.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const VAULT_VERBS = new Set(["read", "write", "admin"]);
|
|
25
|
+
|
|
26
|
+
export function inferAudience(scopes: readonly string[]): string {
|
|
27
|
+
for (const s of scopes) {
|
|
28
|
+
const parts = s.split(":");
|
|
29
|
+
const name = parts[1];
|
|
30
|
+
const verb = parts[2];
|
|
31
|
+
if (parts.length === 3 && parts[0] === "vault" && name && verb && VAULT_VERBS.has(verb)) {
|
|
32
|
+
return `vault.${name}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const s of scopes) {
|
|
36
|
+
const colon = s.indexOf(":");
|
|
37
|
+
if (colon > 0) return s.slice(0, colon);
|
|
38
|
+
}
|
|
39
|
+
return "hub";
|
|
40
|
+
}
|
package/src/module-manifest.ts
CHANGED
|
@@ -114,6 +114,15 @@ export interface ModuleManifest {
|
|
|
114
114
|
* as `hasAuth` / `init` / `urlForEntry`.
|
|
115
115
|
*/
|
|
116
116
|
readonly managementUrl?: string;
|
|
117
|
+
/**
|
|
118
|
+
* When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
|
|
119
|
+
* before forwarding (so the backend sees `/health` rather than
|
|
120
|
+
* `/<name>/health`). Default `false` matches the prefix-aware convention
|
|
121
|
+
* notes / agent / vault already follow. Carried into services.json via
|
|
122
|
+
* `seedEntryFromManifest`. See `ServiceEntry.stripPrefix` for the full
|
|
123
|
+
* per-module rationale.
|
|
124
|
+
*/
|
|
125
|
+
readonly stripPrefix?: boolean;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
export class ModuleManifestError extends Error {
|
|
@@ -365,6 +374,13 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
|
|
|
365
374
|
const dependencies = asDependencies(m.dependencies, where);
|
|
366
375
|
const configSchema = asConfigSchema(m.configSchema, where);
|
|
367
376
|
const managementUrl = asManagementUrl(m.managementUrl, where);
|
|
377
|
+
let stripPrefix: boolean | undefined;
|
|
378
|
+
if (m.stripPrefix !== undefined) {
|
|
379
|
+
if (typeof m.stripPrefix !== "boolean") {
|
|
380
|
+
throw new ModuleManifestError(`${where}: "stripPrefix" must be a boolean if present`);
|
|
381
|
+
}
|
|
382
|
+
stripPrefix = m.stripPrefix;
|
|
383
|
+
}
|
|
368
384
|
|
|
369
385
|
const out: ModuleManifest = { name, manifestName, kind, port, paths, health };
|
|
370
386
|
if (displayName !== undefined) (out as { displayName?: string }).displayName = displayName;
|
|
@@ -380,6 +396,9 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
|
|
|
380
396
|
if (managementUrl !== undefined) {
|
|
381
397
|
(out as { managementUrl?: string }).managementUrl = managementUrl;
|
|
382
398
|
}
|
|
399
|
+
if (stripPrefix !== undefined) {
|
|
400
|
+
(out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
|
|
401
|
+
}
|
|
383
402
|
return out;
|
|
384
403
|
}
|
|
385
404
|
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
} from "./clients.ts";
|
|
44
44
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
45
45
|
import { isCoveredByGrant, recordGrant } from "./grants.ts";
|
|
46
|
+
import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
|
|
46
47
|
import {
|
|
47
48
|
ACCESS_TOKEN_TTL_SECONDS,
|
|
48
49
|
RefreshTokenInsertError,
|
|
@@ -69,8 +70,6 @@ import {
|
|
|
69
70
|
import { getUserByUsername, verifyPassword } from "./users.ts";
|
|
70
71
|
import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
|
|
71
72
|
|
|
72
|
-
const VAULT_VERBS = new Set(["read", "write", "admin"]);
|
|
73
|
-
|
|
74
73
|
/** Verbs whose unnamed `vault:<verb>` form needs picker disambiguation. */
|
|
75
74
|
function unnamedVaultVerbs(scopes: string[]): string[] {
|
|
76
75
|
const verbs: string[] = [];
|
|
@@ -1056,36 +1055,6 @@ function mapAuthCodeError(err: unknown): Response {
|
|
|
1056
1055
|
return jsonResponse({ error: "server_error", error_description: msg }, 500);
|
|
1057
1056
|
}
|
|
1058
1057
|
|
|
1059
|
-
/**
|
|
1060
|
-
* Picks the JWT `aud` claim based on the requested scopes. Per the
|
|
1061
|
-
* vault-config-and-scopes design (Phase 1+2):
|
|
1062
|
-
* - A named `vault:<name>:<verb>` → `vault.<name>` (RFC 8707-style resource
|
|
1063
|
-
* binding; vault enforces this strict-equality against the URL-derived
|
|
1064
|
-
* vault name).
|
|
1065
|
-
* - An unnamed `<service>:<verb>` → `<service>` (legacy shape; vault's
|
|
1066
|
-
* strict-check rejects unnamed `vault:*` audiences, so the consent
|
|
1067
|
-
* picker rewrites those before this is reached).
|
|
1068
|
-
*
|
|
1069
|
-
* Named vault scopes win over unnamed ones — an OAuth flow that mixes
|
|
1070
|
-
* `vault:work:read` + `scribe:transcribe` audiences is grounded on the vault
|
|
1071
|
-
* (the more sensitive resource), and tokens are issued per-flow anyway.
|
|
1072
|
-
*/
|
|
1073
|
-
function inferAudience(scopes: string[]): string {
|
|
1074
|
-
for (const s of scopes) {
|
|
1075
|
-
const parts = s.split(":");
|
|
1076
|
-
const name = parts[1];
|
|
1077
|
-
const verb = parts[2];
|
|
1078
|
-
if (parts.length === 3 && parts[0] === "vault" && name && verb && VAULT_VERBS.has(verb)) {
|
|
1079
|
-
return `vault.${name}`;
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
for (const s of scopes) {
|
|
1083
|
-
const colon = s.indexOf(":");
|
|
1084
|
-
if (colon > 0) return s.slice(0, colon);
|
|
1085
|
-
}
|
|
1086
|
-
return "hub";
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
1058
|
// --- /oauth/register -------------------------------------------------------
|
|
1090
1059
|
|
|
1091
1060
|
interface RegisterRequestBody {
|
package/src/service-spec.ts
CHANGED
|
@@ -199,6 +199,7 @@ export function seedEntryFromManifest(manifest: ModuleManifest): ServiceEntry {
|
|
|
199
199
|
};
|
|
200
200
|
if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
|
|
201
201
|
if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
|
|
202
|
+
if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
|
|
202
203
|
return entry;
|
|
203
204
|
}
|
|
204
205
|
|
|
@@ -319,6 +320,13 @@ const SCRIBE_FALLBACK: FirstPartyFallback = {
|
|
|
319
320
|
paths: ["/scribe"],
|
|
320
321
|
health: "/scribe/health",
|
|
321
322
|
startCmd: ["parachute-scribe", "serve"],
|
|
323
|
+
// Scribe's HTTP routes are bare (`/health`, `/v1/...`), unlike notes /
|
|
324
|
+
// agent which strip the mount themselves. Until scribe ships a `--mount`
|
|
325
|
+
// flag (tracked upstream in parachute-scribe), the hub strips the
|
|
326
|
+
// `/scribe` prefix before forwarding so a request to
|
|
327
|
+
// `hub:1939/scribe/v1/audio/transcriptions` reaches scribe as
|
|
328
|
+
// `/v1/audio/transcriptions`.
|
|
329
|
+
stripPrefix: true,
|
|
322
330
|
},
|
|
323
331
|
extras: {
|
|
324
332
|
// No auth gate today. Scribe's launch PR adds optional SCRIBE_AUTH_TOKEN;
|
package/src/services-manifest.ts
CHANGED
|
@@ -45,6 +45,22 @@ export interface ServiceEntry {
|
|
|
45
45
|
* can use clean relative paths in their `startCmd`.
|
|
46
46
|
*/
|
|
47
47
|
installDir?: string;
|
|
48
|
+
/**
|
|
49
|
+
* When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
|
|
50
|
+
* before forwarding so the backend sees a bare path (e.g. `/health` rather
|
|
51
|
+
* than `/scribe/health`). Default `false` keeps the prefix intact, which
|
|
52
|
+
* matches what notes / agent / vault expect today.
|
|
53
|
+
*
|
|
54
|
+
* Per-module rather than uniform because conventions differ:
|
|
55
|
+
* - notes-serve.ts strips internally via `--mount`; expects the prefix.
|
|
56
|
+
* - parachute-agent reads PARACHUTE_AGENT_WEB_MOUNT and strips itself.
|
|
57
|
+
* - parachute-vault routes by `/vault/<name>/...` and expects the prefix.
|
|
58
|
+
* - parachute-scribe serves bare paths (`/health`, `/v1/...`); the proxy
|
|
59
|
+
* must strip. Eventually scribe should accept its own `--mount` flag
|
|
60
|
+
* and join the always-prefixed convention; until then this opt-in
|
|
61
|
+
* bridges the gap. Tracked in parachute-scribe (separate issue).
|
|
62
|
+
*/
|
|
63
|
+
stripPrefix?: boolean;
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
export interface ServicesManifest {
|
|
@@ -105,11 +121,16 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
105
121
|
if (installDir !== undefined && (typeof installDir !== "string" || installDir.length === 0)) {
|
|
106
122
|
throw new ServicesManifestError(`${where}: "installDir" must be a non-empty string if present`);
|
|
107
123
|
}
|
|
124
|
+
const stripPrefix = e.stripPrefix;
|
|
125
|
+
if (stripPrefix !== undefined && typeof stripPrefix !== "boolean") {
|
|
126
|
+
throw new ServicesManifestError(`${where}: "stripPrefix" must be a boolean if present`);
|
|
127
|
+
}
|
|
108
128
|
const entry: ServiceEntry = { name, port, paths: paths as string[], health, version };
|
|
109
129
|
if (displayName !== undefined) entry.displayName = displayName;
|
|
110
130
|
if (tagline !== undefined) entry.tagline = tagline;
|
|
111
131
|
if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
|
|
112
132
|
if (installDir !== undefined) entry.installDir = installDir;
|
|
133
|
+
if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
|
|
113
134
|
return entry;
|
|
114
135
|
}
|
|
115
136
|
|