@betterportal/plugin-bsb 0.0.1

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/lib/service.js ADDED
@@ -0,0 +1,1308 @@
1
+ import { BSBService } from "@bsb/base";
2
+ import * as av from "anyvali";
3
+ import { randomBytes, timingSafeEqual } from "node:crypto";
4
+ import { createServer } from "node:http";
5
+ import { resolve, dirname } from "node:path";
6
+ import { FileBackedBetterPortalConfigProvider, FileBackedServiceConfigStore, InMemoryServiceConfigStore, buildOriginPolicy, buildManifestFromRegistry, buildBpSchema, createJwksVerifier, createStaticJwksVerifier, describeEmbeddedContextResolution, hostFromHeaderValue, registerBpWellKnownRoutes, registerServiceConfigRoutes, resolveEmbeddedSourceHeader, resolveEmbeddedRequestContext, resolveThemeSourceHeader, verifySetupToken, verifyServiceConfigTicket } from "@betterportal/framework";
7
+ import { createH3Router } from "@betterportal/framework/lib/adapters/h3.js";
8
+ import { BootstrapStateStore } from "./bootstrapState.js";
9
+ import { ScopedConfigCache } from "./scopedConfigCache.js";
10
+ import { createBetterPortalApp, createBetterPortalNodeHandler, eventObservability, eventHeaders, getEventPeerIp, handleCorsRequest, jsonResponse } from "@betterportal/framework/lib/runtime/h3.js";
11
+ import { createBsbObservability } from "./index.js";
12
+ export const BetterPortalConfigSchema = av.optional(av.object({
13
+ bpConfigPath: av.optional(av.string().minLength(1)),
14
+ // Optional dev-only shared secret for the static config-token fallback. NOT
15
+ // set by default - production verifies CP-signed tickets via the CP JWKS and
16
+ // never needs this. The fallback is additionally gated behind
17
+ // BP_ALLOW_DEV_CONFIG_TOKEN=true (see validateConfigTicket).
18
+ configApiToken: av.optional(av.string().minLength(1)),
19
+ configEncryptionKey: av.optional(av.string().minLength(16)),
20
+ controlPlaneUrl: av.optional(av.string().minLength(1)),
21
+ serviceApiKey: av.optional(av.string().minLength(1)),
22
+ bootstrapStatePath: av.string().minLength(1).default("./.bp-bootstrap/state.enc"),
23
+ scopedConfigCachePath: av.string().minLength(1).default("./.bp-sync-cache/scoped.json"),
24
+ trustedProxyHeaders: av.bool().default(false),
25
+ cfProxy: av.bool().default(false),
26
+ // Proxy-supplied host headers (X-Forwarded-Host, Forwarded, CF-*) are only
27
+ // honoured when the direct socket peer IP is in this allowlist. Empty list
28
+ // (the default) means proxy headers are never trusted, even if
29
+ // trustedProxyHeaders/cfProxy are enabled.
30
+ trustedProxyIps: av.array(av.string().minLength(1)).default([])
31
+ }, { unknownKeys: "strip" }));
32
+ // Base class
33
+ export class BPService extends BSBService {
34
+ get service() {
35
+ return this.config;
36
+ }
37
+ get bp() {
38
+ const cfg = this.service;
39
+ if (cfg.betterportal) {
40
+ return cfg.betterportal;
41
+ }
42
+ return {
43
+ bpConfigPath: cfg.bpConfigPath,
44
+ configApiToken: cfg.configApiToken,
45
+ configEncryptionKey: cfg.configEncryptionKey,
46
+ controlPlaneUrl: cfg.controlPlaneUrl,
47
+ serviceApiKey: cfg.serviceApiKey,
48
+ trustedProxyHeaders: cfg.trustedProxyHeaders,
49
+ cfProxy: cfg.cfProxy,
50
+ trustedProxyIps: cfg.trustedProxyIps
51
+ };
52
+ }
53
+ /**
54
+ * Resolve header-trust options for a request. Proxy-supplied host headers are
55
+ * only honoured when the request's direct socket peer IP is in the configured
56
+ * `trustedProxyIps` allowlist - otherwise an attacker connecting directly
57
+ * could spoof X-Forwarded-Host/Forwarded/CF-* to impersonate another tenant.
58
+ */
59
+ headerTrustOptions(event) {
60
+ const peerIp = getEventPeerIp(event);
61
+ const allowlist = this.bp.trustedProxyIps ?? [];
62
+ const peerIsTrustedProxy = !!peerIp && allowlist.includes(peerIp);
63
+ if (!peerIsTrustedProxy) {
64
+ return { trustedProxyHeaders: false, cfProxy: false };
65
+ }
66
+ return {
67
+ trustedProxyHeaders: this.bp.trustedProxyHeaders,
68
+ cfProxy: this.bp.cfProxy
69
+ };
70
+ }
71
+ initBeforePlugins = [];
72
+ initAfterPlugins = [];
73
+ runBeforePlugins = [];
74
+ runAfterPlugins = [];
75
+ requireBetterPortalConfigSource = true;
76
+ app;
77
+ server;
78
+ observability;
79
+ manifest;
80
+ configStore = new InMemoryServiceConfigStore();
81
+ runtimeConfigEncryptionKey;
82
+ configProvider = null;
83
+ scopedConfig = null;
84
+ scopedConfigCache;
85
+ sseAbortController = null;
86
+ bootstrapState;
87
+ /**
88
+ * Synthesize a BetterPortalConfig-shaped view from the synced scoped config.
89
+ * Lets services that need the full-portal-config API (e.g. themes for
90
+ * `resolveThemeRequestContext` / `resolveServiceForTenant`) operate without
91
+ * sharing CM's bp-config.yaml. Returns null until the first sync completes.
92
+ */
93
+ getPortalConfig() {
94
+ const s = this.scopedConfig;
95
+ if (!s)
96
+ return null;
97
+ return {
98
+ configManagement: { adminTenantId: undefined, auth: { mechanism: "none", requiredPermissions: [] } },
99
+ platformServices: [],
100
+ sharedServiceCatalog: [],
101
+ tenantSharedServiceActivations: [],
102
+ manifestCache: [],
103
+ tenants: s.tenants.map((t) => ({
104
+ id: t.id,
105
+ slug: t.slug,
106
+ title: t.title,
107
+ active: t.active,
108
+ branding: t.branding,
109
+ services: (t.services ?? []).map((svc) => ({ ...svc, apiKeyHash: "" })),
110
+ activatedPlatformServices: [...(t.activatedPlatformServices ?? [])]
111
+ })),
112
+ apps: s.apps.map((a) => ({
113
+ id: a.id,
114
+ tenantId: a.tenantId,
115
+ slug: a.slug,
116
+ title: a.title,
117
+ hostnames: [...a.hostnames],
118
+ originOverrides: [...(a.originOverrides ?? [])],
119
+ refererOverrides: [...(a.refererOverrides ?? [])],
120
+ shell: a.shell,
121
+ themeId: a.themeId,
122
+ themeConfig: a.themeConfig,
123
+ defaultRoute: a.defaultRoute ?? "/",
124
+ routes: [...a.routes],
125
+ menu: [...(a.menu ?? [])],
126
+ slots: [...(a.slots ?? [])],
127
+ fragments: a.fragments,
128
+ auth: a.auth
129
+ }))
130
+ };
131
+ }
132
+ resolvedApiKey = null;
133
+ resolvedCpUrl = null;
134
+ inSetupMode = false;
135
+ /**
136
+ * Override to provide a JWT verifier for incoming requests.
137
+ * Receives the resolved tenant/app context. Return undefined to skip auth for the request.
138
+ */
139
+ getJwtVerifier(_tenantId, _appId) {
140
+ const auth = this.getAppAuthConfig(_tenantId, _appId);
141
+ if (!auth)
142
+ return undefined;
143
+ if (auth.publicKeys) {
144
+ return createStaticJwksVerifier({
145
+ jwks: auth.publicKeys,
146
+ expectedIssuer: auth.expectedIssuer,
147
+ expectedAudience: auth.expectedAudience,
148
+ expectedTokenType: "access"
149
+ });
150
+ }
151
+ return createJwksVerifier({
152
+ jwksUri: auth.jwksUri,
153
+ expectedIssuer: auth.expectedIssuer,
154
+ expectedAudience: auth.expectedAudience,
155
+ expectedTokenType: "access"
156
+ });
157
+ }
158
+ /**
159
+ * Override to provide the app's resolved auth config (roles[], expectedIssuer, etc).
160
+ * Default: reads from scopedConfig synced from the control plane.
161
+ */
162
+ getAppAuthConfig(tenantId, appId) {
163
+ if (!this.scopedConfig)
164
+ return undefined;
165
+ const app = this.scopedConfig.apps.find((a) => a.id === appId && a.tenantId === tenantId);
166
+ return app?.auth;
167
+ }
168
+ /**
169
+ * Override to provide the service-instance-id -> pluginId alias map used by the
170
+ * permission check (role grants use instance ids, route auth uses pluginIds).
171
+ * Default: reads the tenant's service bindings from scopedConfig.
172
+ */
173
+ getServiceIdAliases(tenantId) {
174
+ const tenant = this.scopedConfig?.tenants.find((t) => t.id === tenantId);
175
+ if (!tenant)
176
+ return undefined;
177
+ const aliases = {};
178
+ for (const svc of tenant.services) {
179
+ if (svc.serviceId)
180
+ aliases[svc.id] = svc.serviceId;
181
+ }
182
+ return aliases;
183
+ }
184
+ /**
185
+ * Override to validate that a given (tenantId, appId) is allowed to consume this service.
186
+ *
187
+ * Default behavior: auto-single-tenant via lock. On first request from a tenant, the
188
+ * tenant is stored as the lock. Subsequent requests from other tenants are blocked
189
+ * with 426 Upgrade Required. Services wanting shared/multi-tenant behavior must override.
190
+ */
191
+ async validateTenantApp(tenantId, _appId) {
192
+ const state = this.bootstrapState.read();
193
+ if (!state.tenantLock) {
194
+ this.bootstrapState.write({ tenantLock: tenantId });
195
+ return { allowed: true };
196
+ }
197
+ if (state.tenantLock === tenantId) {
198
+ return { allowed: true };
199
+ }
200
+ return {
201
+ allowed: false,
202
+ reason: `Service locked to tenant ${state.tenantLock}; received request for ${tenantId}. Override validateTenantApp() to allow multi-tenant.`
203
+ };
204
+ }
205
+ /**
206
+ * Register this service as an auth provider by exposing a JWKS endpoint.
207
+ *
208
+ * Mounts `GET /.well-known/jwks.json` returning the supplied JWK set.
209
+ * Call this from `init()` AFTER `super.init()` so the H3 app exists.
210
+ */
211
+ /** JWKS published by this service when it acts as an auth provider; sent
212
+ * to the CP at /redeem so verifiers can use static keys (no network fetch). */
213
+ publishedJwks = null;
214
+ registerAsAuthProvider(input) {
215
+ const cacheMaxAge = input.cacheMaxAgeSeconds ?? 600;
216
+ const payload = JSON.stringify(input.jwks);
217
+ this.publishedJwks = input.jwks;
218
+ this.app.get("/.well-known/jwks.json", () => new Response(payload, {
219
+ status: 200,
220
+ headers: {
221
+ "content-type": "application/jwk-set+json",
222
+ "cache-control": `public, max-age=${cacheMaxAge}`
223
+ }
224
+ }));
225
+ }
226
+ constructor(cfg) {
227
+ super(cfg);
228
+ }
229
+ async init(obs) {
230
+ const def = this.definition();
231
+ const span = createBsbObservability(obs).startSpan("bp.plugin.init", {
232
+ "bp.plugin.id": def.manifest.pluginId,
233
+ "bp.plugin.category": "service"
234
+ });
235
+ try {
236
+ this.bootstrapState = new BootstrapStateStore({
237
+ filePath: this.bp.bootstrapStatePath ?? "./.bp-bootstrap/state.enc",
238
+ encryptionKey: this.bp.configEncryptionKey
239
+ });
240
+ this.scopedConfigCache = new ScopedConfigCache({
241
+ filePath: this.bp.scopedConfigCachePath ?? "./.bp-sync-cache/scoped.json"
242
+ });
243
+ // Pre-load cached scoped config so the service can serve requests
244
+ // immediately on restart, before the first sync push from the CP completes.
245
+ const cached = this.scopedConfigCache.read();
246
+ if (cached) {
247
+ this.scopedConfig = cached;
248
+ obs.log.info("Loaded scoped config from local cache ({tenants} tenants, {apps} apps)", {
249
+ tenants: this.scopedConfig?.tenants?.length ?? 0,
250
+ apps: this.scopedConfig?.apps?.length ?? 0
251
+ });
252
+ }
253
+ this.resolveCredentials(obs);
254
+ this.validateBetterPortalConfig(obs);
255
+ this.runtimeConfigEncryptionKey = this.resolveConfigEncryptionKey();
256
+ this.observability = createBsbObservability(obs).setAttributes({
257
+ "bp.plugin.id": def.manifest.pluginId,
258
+ "bp.plugin.category": "service"
259
+ });
260
+ this.app = createBetterPortalApp({
261
+ createRequestObservability: (name, attributes) => createBsbObservability(this.createTrace(name, attributes))
262
+ });
263
+ this.server = createServer(createBetterPortalNodeHandler(this.app));
264
+ if (this.bp.bpConfigPath) {
265
+ this.configProvider = new FileBackedBetterPortalConfigProvider(this.bp.bpConfigPath);
266
+ }
267
+ this.manifest = buildManifestFromRegistry(def.registry, { version: "1.0.0" }, def.manifest);
268
+ if (this.manifest.configSchemas.length > 0 && this.runtimeConfigEncryptionKey) {
269
+ this.configStore = new FileBackedServiceConfigStore({
270
+ filePath: this.serviceConfigStorePath(def.manifest.pluginId),
271
+ configSchemas: this.manifest.configSchemas,
272
+ encryptionKey: this.runtimeConfigEncryptionKey
273
+ });
274
+ }
275
+ this.app.use("/**", (event) => this.handleWithCors(event));
276
+ this.app.use("/**", (event) => this.requireTenantConfigSource(event));
277
+ if (this.manifest.configSchemas.length > 0) {
278
+ this.registerDefaultConfigRoutes();
279
+ }
280
+ this.registerInstallEndpoint(obs);
281
+ createH3Router(def.registry, this.app, {
282
+ serviceId: def.manifest.pluginId,
283
+ resolveAuth: (event) => this.resolveAuthForRequest(event),
284
+ validateTenantApp: (tenantId, appId) => this.validateTenantApp(tenantId, appId),
285
+ resolveContext: (event) => this.resolveHandlerContext(event)
286
+ });
287
+ const bpSchema = buildBpSchema(def.registry, this.manifest);
288
+ registerBpWellKnownRoutes(this.app, this.manifest, bpSchema, {
289
+ health: () => this.renderHealth()
290
+ });
291
+ if (this.onRegistered) {
292
+ const registeredSpan = this.observability.startSpan("bp.plugin.on_registered", {
293
+ "bp.plugin.id": def.manifest.pluginId
294
+ });
295
+ try {
296
+ await this.onRegistered(def.registry, obs);
297
+ registeredSpan.end();
298
+ }
299
+ catch (error) {
300
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
301
+ registeredSpan.error(normalizedError, { "error.name": normalizedError.name });
302
+ registeredSpan.end();
303
+ throw error;
304
+ }
305
+ }
306
+ if (this.inSetupMode) {
307
+ obs.log.warn("{pluginId} initialized in SETUP MODE - awaiting POST to /.well-known/bp/install", {
308
+ pluginId: def.manifest.pluginId
309
+ });
310
+ }
311
+ else {
312
+ obs.log.info("{pluginId} initialized", { pluginId: def.manifest.pluginId });
313
+ }
314
+ span.end({ "bp.plugin.setup_mode": this.inSetupMode });
315
+ }
316
+ catch (error) {
317
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
318
+ span.error(normalizedError, { "error.name": normalizedError.name });
319
+ span.end({ "bp.plugin.setup_mode": this.inSetupMode });
320
+ throw error;
321
+ }
322
+ }
323
+ async run(obs) {
324
+ const pluginId = this.manifest?.pluginId ?? this.definition().manifest.pluginId;
325
+ const span = createBsbObservability(obs).startSpan("bp.plugin.run", {
326
+ "bp.plugin.id": pluginId,
327
+ "bp.plugin.category": "service"
328
+ });
329
+ try {
330
+ if (this.server.listening) {
331
+ span.end({ "bp.plugin.already_listening": true });
332
+ return;
333
+ }
334
+ await new Promise((resolve, reject) => {
335
+ this.server.once("error", reject);
336
+ this.server.listen(this.service.port, this.service.host, () => {
337
+ this.server.off("error", reject);
338
+ resolve();
339
+ });
340
+ });
341
+ if (this.resolvedApiKey && this.resolvedCpUrl) {
342
+ const syncSpan = createBsbObservability(obs).startSpan("bp.plugin.connect_control_plane", {
343
+ "bp.plugin.id": pluginId,
344
+ "bp.control_plane.url": this.resolvedCpUrl
345
+ });
346
+ try {
347
+ this.connectToControlPlane(obs);
348
+ syncSpan.end();
349
+ }
350
+ catch (error) {
351
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
352
+ syncSpan.error(normalizedError, { "error.name": normalizedError.name });
353
+ syncSpan.end();
354
+ throw error;
355
+ }
356
+ }
357
+ obs.log.info("{pluginId} serving at http://{host}:{port}{mode}", {
358
+ pluginId: this.manifest.pluginId,
359
+ host: this.service.host,
360
+ port: this.service.port,
361
+ mode: this.inSetupMode ? " [SETUP MODE]" : ""
362
+ });
363
+ span.end({
364
+ "server.address": this.service.host,
365
+ "server.port": this.service.port,
366
+ "bp.plugin.setup_mode": this.inSetupMode
367
+ });
368
+ }
369
+ catch (error) {
370
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
371
+ span.error(normalizedError, { "error.name": normalizedError.name });
372
+ span.end({
373
+ "server.address": this.service.host,
374
+ "server.port": this.service.port,
375
+ "bp.plugin.setup_mode": this.inSetupMode
376
+ });
377
+ throw error;
378
+ }
379
+ }
380
+ async dispose() {
381
+ this.sseAbortController?.abort();
382
+ if (this.server.listening) {
383
+ await new Promise((resolve, reject) => {
384
+ this.server.close((err) => err ? reject(err) : resolve());
385
+ });
386
+ }
387
+ }
388
+ // Control plane sync
389
+ connectToControlPlane(obs) {
390
+ const baseUrl = this.resolvedCpUrl.replace(/\/+$/, "");
391
+ const url = `${baseUrl}/.well-known/bp/sync`;
392
+ const pollUrl = `${url}/poll`;
393
+ const apiKey = this.resolvedApiKey;
394
+ const fetchErrorDetails = (error) => {
395
+ const err = error;
396
+ const cause = err.cause;
397
+ return {
398
+ name: err.name ?? "",
399
+ msg: err.message ?? String(error),
400
+ code: err.code ?? cause?.code ?? "",
401
+ causeName: cause?.name ?? "",
402
+ causeMsg: cause?.message ?? "",
403
+ errno: String(err.errno ?? cause?.errno ?? ""),
404
+ syscall: err.syscall ?? cause?.syscall ?? "",
405
+ address: err.address ?? cause?.address ?? "",
406
+ port: String(err.port ?? cause?.port ?? "")
407
+ };
408
+ };
409
+ const applyScopedConfig = (rawConfig, source) => {
410
+ this.scopedConfig = rawConfig;
411
+ // Persist for restart resilience - the service owns its cache; CM's
412
+ // bp-config.yaml is never shared.
413
+ try {
414
+ this.scopedConfigCache.write(rawConfig);
415
+ }
416
+ catch (err) {
417
+ obs.log.warn("Failed to persist scoped config cache: {msg}", { msg: err.message });
418
+ }
419
+ obs.log.info("BP SYNC CLIENT: config applied service={serviceId} source={source} tenants={tenants} apps={apps} managementOrigins={managementOrigins}", {
420
+ serviceId: this.manifest.pluginId,
421
+ source,
422
+ tenants: this.scopedConfig?.tenants.length ?? 0,
423
+ apps: this.scopedConfig?.apps.length ?? 0,
424
+ managementOrigins: this.scopedConfig?.managementOrigins?.length ?? 0
425
+ });
426
+ this.logScopedConfigDebug(obs);
427
+ if ((this.scopedConfig?.apps.length ?? 0) === 0) {
428
+ obs.log.warn("Control plane sync returned no apps for this service; tenant/app requests will not resolve until the service is mounted in an app route or fragment.");
429
+ }
430
+ };
431
+ const bootstrapFromPoll = async () => {
432
+ obs.log.info("Control plane sync bootstrap polling: {url}", { url: pollUrl });
433
+ // POST manifest with the poll so CP can cache it for resolvedServicePath injection
434
+ // AND surface per-view permission requirements to the admin role editor.
435
+ const viewIndex = {};
436
+ for (const view of this.manifest.views) {
437
+ const viewWithAuth = view;
438
+ const themeRenderers = viewWithAuth.html?.themeRenderers ?? {};
439
+ const renderable = Object.keys(themeRenderers).length > 0;
440
+ const schemas = Object.fromEntries([
441
+ ["params", viewWithAuth.paramsSchema],
442
+ ["query", viewWithAuth.querySchema],
443
+ ["headers", viewWithAuth.headersSchema],
444
+ ["request", viewWithAuth.bodySchema],
445
+ ["response", viewWithAuth.jsonResponseSchema],
446
+ ["metadataResponse", viewWithAuth.metadataResponseSchema]
447
+ ].filter((entry) => Boolean(entry[1])));
448
+ viewIndex[view.viewId] = {
449
+ viewId: view.viewId,
450
+ path: view.path,
451
+ methods: [...view.methods],
452
+ ...(view.role ? { role: view.role } : {}),
453
+ ...(viewWithAuth.chrome ? { chrome: viewWithAuth.chrome } : {}),
454
+ dependencies: [...(viewWithAuth.dependencies ?? [])],
455
+ permissions: viewWithAuth.auth?.permissions ?? [],
456
+ renderable,
457
+ ...(Object.keys(schemas).length ? { schemas } : {}),
458
+ ...(viewWithAuth.raw === true ? { raw: true } : {}),
459
+ ...(view.demoScenarios.length ? { demoScenarios: [...view.demoScenarios] } : {})
460
+ };
461
+ }
462
+ const response = await fetch(pollUrl, {
463
+ method: "POST",
464
+ headers: {
465
+ Accept: "application/json",
466
+ "content-type": "application/json",
467
+ Authorization: `Bearer ${apiKey}`
468
+ },
469
+ body: JSON.stringify({
470
+ manifestVersion: this.manifest.version,
471
+ title: this.manifest.title,
472
+ capabilities: this.manifest.capabilities,
473
+ configSchemas: this.manifest.configSchemas,
474
+ webhooks: this.manifest.webhooks,
475
+ viewIndex
476
+ })
477
+ });
478
+ if (!response.ok) {
479
+ let body = "";
480
+ try {
481
+ body = await response.text();
482
+ }
483
+ catch { /* ignore */ }
484
+ obs.log.warn("Control plane sync bootstrap failed: {status} {body}", {
485
+ status: response.status,
486
+ body
487
+ });
488
+ return;
489
+ }
490
+ const config = await response.json();
491
+ obs.log.info("BP SYNC CLIENT: bootstrap poll succeeded service={serviceId} status={status}", {
492
+ serviceId: this.manifest.pluginId,
493
+ status: response.status
494
+ });
495
+ applyScopedConfig(config, "poll");
496
+ };
497
+ const logBootstrapPollError = (error) => {
498
+ const details = fetchErrorDetails(error);
499
+ obs.log.warn("BP SYNC CLIENT: bootstrap poll error service={serviceId} url={url} name={name} code={code} errno={errno} syscall={syscall} address={address} port={port} msg={msg} cause={causeName}:{causeMsg}", {
500
+ serviceId: this.manifest.pluginId,
501
+ url: pollUrl,
502
+ name: details.name,
503
+ code: details.code,
504
+ errno: details.errno,
505
+ syscall: details.syscall,
506
+ address: details.address,
507
+ port: details.port,
508
+ msg: details.msg,
509
+ causeName: details.causeName,
510
+ causeMsg: details.causeMsg
511
+ });
512
+ };
513
+ const connect = () => {
514
+ void bootstrapFromPoll().catch(logBootstrapPollError);
515
+ this.sseAbortController = new AbortController();
516
+ obs.log.info("BP SYNC CLIENT: opening SSE update stream service={serviceId} url={url}", {
517
+ serviceId: this.manifest.pluginId,
518
+ url
519
+ });
520
+ fetch(url, {
521
+ headers: {
522
+ Accept: "text/event-stream",
523
+ Authorization: `Bearer ${apiKey}`
524
+ },
525
+ signal: this.sseAbortController.signal
526
+ }).then(async (response) => {
527
+ if (!response.ok || !response.body) {
528
+ let body = "";
529
+ try {
530
+ body = await response.text();
531
+ }
532
+ catch { /* ignore */ }
533
+ obs.log.warn("Control plane sync failed: {status} {body}", {
534
+ status: response.status,
535
+ body
536
+ });
537
+ scheduleReconnect();
538
+ return;
539
+ }
540
+ obs.log.info("BP SYNC CLIENT: SSE update stream connected service={serviceId} status={status}; awaiting config changes", {
541
+ serviceId: this.manifest.pluginId,
542
+ status: response.status
543
+ });
544
+ const reader = response.body.getReader();
545
+ const decoder = new TextDecoder();
546
+ let buffer = "";
547
+ let eventType = "";
548
+ let dataLines = [];
549
+ const dispatchEvent = () => {
550
+ const data = dataLines.join("\n");
551
+ if (eventType === "config" && dataLines.length > 0) {
552
+ obs.log.info("BP SYNC CLIENT: SSE config event received service={serviceId} bytes={bytes} lines={lines}", {
553
+ serviceId: this.manifest.pluginId,
554
+ bytes: data.length,
555
+ lines: dataLines.length
556
+ });
557
+ try {
558
+ const parsed = JSON.parse(data);
559
+ applyScopedConfig(parsed, "stream");
560
+ }
561
+ catch (error) {
562
+ obs.log.warn("Control plane config parse failed: {msg}", {
563
+ msg: error instanceof Error ? error.message : String(error)
564
+ });
565
+ }
566
+ }
567
+ else if (dataLines.length > 0) {
568
+ obs.log.warn("BP SYNC CLIENT: ignored SSE event service={serviceId} event={event} bytes={bytes}", {
569
+ serviceId: this.manifest.pluginId,
570
+ event: eventType,
571
+ bytes: data.length
572
+ });
573
+ }
574
+ eventType = "";
575
+ dataLines = [];
576
+ };
577
+ while (true) {
578
+ const { done, value } = await reader.read();
579
+ if (done)
580
+ break;
581
+ buffer += decoder.decode(value, { stream: true });
582
+ const lines = buffer.split("\n");
583
+ buffer = lines.pop() ?? "";
584
+ for (const line of lines) {
585
+ const normalizedLine = line.endsWith("\r") ? line.slice(0, -1) : line;
586
+ if (normalizedLine.startsWith("event:")) {
587
+ eventType = normalizedLine.slice(6).trim();
588
+ }
589
+ else if (normalizedLine.startsWith("data:")) {
590
+ const value = normalizedLine.slice(5);
591
+ dataLines.push(value.startsWith(" ") ? value.slice(1) : value);
592
+ }
593
+ else if (normalizedLine === "") {
594
+ dispatchEvent();
595
+ }
596
+ }
597
+ }
598
+ obs.log.warn("BP SYNC CLIENT: SSE update stream closed service={serviceId}; reconnecting", {
599
+ serviceId: this.manifest.pluginId
600
+ });
601
+ scheduleReconnect();
602
+ }).catch((err) => {
603
+ if (err.name !== "AbortError") {
604
+ const details = fetchErrorDetails(err);
605
+ obs.log.warn("BP SYNC CLIENT: stream connection error service={serviceId} url={url} name={name} code={code} errno={errno} syscall={syscall} address={address} port={port} msg={msg} cause={causeName}:{causeMsg}", {
606
+ serviceId: this.manifest.pluginId,
607
+ url,
608
+ name: details.name,
609
+ code: details.code,
610
+ errno: details.errno,
611
+ syscall: details.syscall,
612
+ address: details.address,
613
+ port: details.port,
614
+ msg: details.msg,
615
+ causeName: details.causeName,
616
+ causeMsg: details.causeMsg
617
+ });
618
+ scheduleReconnect();
619
+ }
620
+ });
621
+ };
622
+ const scheduleReconnect = () => {
623
+ setTimeout(connect, 5000);
624
+ };
625
+ bootstrapFromPoll()
626
+ .catch(logBootstrapPollError)
627
+ .finally(connect);
628
+ }
629
+ logScopedConfigDebug(obs) {
630
+ if (!this.scopedConfig)
631
+ return;
632
+ obs.log.debug("BP management origins: {origins}", {
633
+ origins: (this.scopedConfig.managementOrigins ?? []).join(",")
634
+ });
635
+ for (const tenant of this.scopedConfig.tenants) {
636
+ obs.log.debug("{tenantName}: {tenantId}", {
637
+ tenantName: tenant.title,
638
+ tenantId: tenant.id
639
+ });
640
+ for (const app of this.scopedConfig.apps.filter((entry) => entry.tenantId === tenant.id)) {
641
+ obs.log.debug(" -> [{themeId}@{appHostnames}] {appName}: {appId}", {
642
+ themeId: app.themeId,
643
+ appHostnames: app.hostnames.join(","),
644
+ appName: app.title,
645
+ appId: app.id
646
+ });
647
+ }
648
+ }
649
+ }
650
+ // CORS
651
+ async resolveCorsContext(event) {
652
+ if (this.scopedConfig) {
653
+ const origin = event.req.headers.get("origin");
654
+ if (!origin)
655
+ return null;
656
+ return this.resolveFromScopedConfig(origin);
657
+ }
658
+ if (!this.configProvider) {
659
+ return null;
660
+ }
661
+ const portalConfig = await this.configProvider.loadConfig();
662
+ return resolveEmbeddedRequestContext(portalConfig, eventHeaders(event), this.headerTrustOptions(event));
663
+ }
664
+ resolveAuthForRequest(event) {
665
+ const ctx = event;
666
+ if (!ctx.__bpTenantId || !ctx.__bpAppId)
667
+ return undefined;
668
+ const verifier = this.getJwtVerifier(ctx.__bpTenantId, ctx.__bpAppId);
669
+ if (!verifier)
670
+ return undefined;
671
+ return {
672
+ verifier,
673
+ tenantId: ctx.__bpTenantId,
674
+ appId: ctx.__bpAppId,
675
+ appAuthConfig: this.getAppAuthConfig(ctx.__bpTenantId, ctx.__bpAppId),
676
+ serviceIdAliases: this.getServiceIdAliases(ctx.__bpTenantId)
677
+ };
678
+ }
679
+ async handleWithCors(event) {
680
+ const requestedHeaders = event.req.headers.get("access-control-request-headers");
681
+ const allowHeaders = requestedHeaders?.trim().length
682
+ ? requestedHeaders.split(",").map((v) => v.trim())
683
+ : ["Accept", "Authorization", "Content-Type", "HX-Current-URL", "HX-Request", "HX-Target", "HX-Trigger", "HX-Trigger-Name", "X-BP-App-Id", "X-BP-Tenant-Id", "BP-SetHeader", "BP-RemoveHeader"];
684
+ const origin = event.req.headers.get("origin");
685
+ if (origin && this.isPublicBpDiscoveryPath(event.url.pathname)) {
686
+ // Public-discovery: CORS open to any origin, but ALSO try to resolve scope
687
+ // so themed responses (login page, etc.) know which theme + tenant context to render under.
688
+ try {
689
+ const ctx = await this.resolveCorsContext(event);
690
+ if (ctx)
691
+ this.applyRequestContext(event, ctx);
692
+ }
693
+ catch {
694
+ // ignore - public path stays open even if scope can't be resolved
695
+ }
696
+ const corsResult = handleCorsRequest(event, {
697
+ origin: [origin],
698
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
699
+ allowHeaders,
700
+ credentials: true,
701
+ exposeHeaders: ["HX-Trigger", "HX-Trigger-After-Swap", "HX-Trigger-After-Settle", "HX-Location", "HX-Push-Url", "HX-Redirect", "HX-Refresh", "HX-Replace-Url", "HX-Reswap", "HX-Retarget", "BP-SetHeader", "BP-RemoveHeader"],
702
+ preflight: { statusCode: 204 }
703
+ });
704
+ if (corsResult)
705
+ return corsResult;
706
+ return undefined;
707
+ }
708
+ if (origin && this.isConfigManagementPath(event.url.pathname)) {
709
+ const allowedOrigins = await this.managementOrigins();
710
+ if (!allowedOrigins.includes(origin)) {
711
+ return handleCorsRequest(event, {
712
+ origin: [],
713
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
714
+ allowHeaders,
715
+ credentials: true,
716
+ preflight: { statusCode: 403 }
717
+ }) || undefined;
718
+ }
719
+ const corsResult = handleCorsRequest(event, {
720
+ origin: allowedOrigins,
721
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
722
+ allowHeaders,
723
+ credentials: true,
724
+ exposeHeaders: ["HX-Trigger", "HX-Trigger-After-Swap", "HX-Trigger-After-Settle", "HX-Location", "HX-Push-Url", "HX-Redirect", "HX-Refresh", "HX-Replace-Url", "HX-Reswap", "HX-Retarget", "BP-SetHeader", "BP-RemoveHeader"],
725
+ preflight: { statusCode: 204 }
726
+ });
727
+ if (corsResult)
728
+ return corsResult;
729
+ return undefined;
730
+ }
731
+ if (!origin) {
732
+ return handleCorsRequest(event, {
733
+ origin: [],
734
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
735
+ allowHeaders,
736
+ credentials: true,
737
+ preflight: { statusCode: 403 }
738
+ }) || undefined;
739
+ }
740
+ let requestContext = null;
741
+ try {
742
+ requestContext = await this.resolveCorsContext(event);
743
+ }
744
+ catch (error) {
745
+ this.logContextResolutionFailure(event, "embedded", error);
746
+ }
747
+ if (!requestContext) {
748
+ this.logContextResolutionFailure(event, "embedded", undefined, await this.describeCorsContextFailure(event));
749
+ return handleCorsRequest(event, {
750
+ origin: [],
751
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
752
+ allowHeaders,
753
+ credentials: true,
754
+ preflight: { statusCode: 403 }
755
+ }) || undefined;
756
+ }
757
+ const allowedOrigins = buildOriginPolicy(requestContext).allowedOrigins;
758
+ this.applyRequestContext(event, requestContext);
759
+ const corsResult = handleCorsRequest(event, {
760
+ origin: allowedOrigins,
761
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
762
+ allowHeaders,
763
+ credentials: true,
764
+ exposeHeaders: ["HX-Trigger", "HX-Trigger-After-Swap", "HX-Trigger-After-Settle", "HX-Location", "HX-Push-Url", "HX-Redirect", "HX-Refresh", "HX-Replace-Url", "HX-Reswap", "HX-Retarget", "BP-SetHeader", "BP-RemoveHeader"],
765
+ preflight: { statusCode: 204 }
766
+ });
767
+ if (corsResult)
768
+ return corsResult;
769
+ return undefined;
770
+ }
771
+ isPublicBpDiscoveryPath(pathname) {
772
+ return [
773
+ "/.well-known/bp/health",
774
+ "/.well-known/bp/manifest",
775
+ "/.well-known/bp/schema.json",
776
+ "/.well-known/bp/config/schema",
777
+ "/.well-known/bp/install",
778
+ "/.well-known/bp/services/redeem",
779
+ "/.well-known/bp/bootstrap",
780
+ "/.well-known/bp/bootstrap/commit",
781
+ "/.well-known/bp/admin/services/begin-install",
782
+ "/.well-known/jwks.json",
783
+ // Auth endpoints - explicitly cross-origin (login form posts from any app).
784
+ "/login",
785
+ "/logout",
786
+ "/refresh",
787
+ "/register"
788
+ ].includes(pathname);
789
+ }
790
+ isConfigManagementPath(pathname) {
791
+ return pathname === "/.well-known/bp/config" || pathname.startsWith("/.well-known/bp/config/");
792
+ }
793
+ async managementOrigins() {
794
+ if (this.scopedConfig) {
795
+ return [...new Set(this.scopedConfig.managementOrigins ?? [])];
796
+ }
797
+ if (!this.configProvider) {
798
+ return [];
799
+ }
800
+ const config = await this.configProvider.loadConfig();
801
+ const adminTenantId = config.configManagement.adminTenantId;
802
+ if (!adminTenantId)
803
+ return [];
804
+ return [...new Set(config.apps
805
+ .filter((app) => app.tenantId === adminTenantId)
806
+ .flatMap((app) => [
807
+ ...app.hostnames.flatMap((hostname) => {
808
+ if (hostname.startsWith("http://") || hostname.startsWith("https://")) {
809
+ return [hostname.replace(/\/+$/, "")];
810
+ }
811
+ return [`https://${hostname}`, `http://${hostname}`];
812
+ }),
813
+ ...app.originOverrides.map((originOverride) => originOverride.replace(/\/+$/, ""))
814
+ ]))];
815
+ }
816
+ resolveFromScopedConfig(origin) {
817
+ if (!this.scopedConfig)
818
+ return null;
819
+ for (const app of this.scopedConfig.apps) {
820
+ const origins = app.hostnames.flatMap((h) => {
821
+ if (h.startsWith("http://") || h.startsWith("https://"))
822
+ return [h];
823
+ return [`http://${h}`, `https://${h}`];
824
+ });
825
+ if (origins.includes(origin)) {
826
+ const tenant = this.scopedConfig.tenants.find((t) => t.id === app.tenantId) ?? null;
827
+ if (!tenant || !tenant.active)
828
+ return null;
829
+ return {
830
+ tenant: {
831
+ ...tenant,
832
+ services: tenant.services.map((service) => ({ ...service, apiKeyHash: "" })),
833
+ activatedPlatformServices: [...tenant.activatedPlatformServices]
834
+ },
835
+ app: {
836
+ ...app,
837
+ hostnames: [...app.hostnames],
838
+ originOverrides: [...app.originOverrides],
839
+ refererOverrides: [...app.refererOverrides],
840
+ shell: app.shell,
841
+ defaultRoute: app.defaultRoute,
842
+ routes: [...app.routes],
843
+ menu: [...app.menu],
844
+ slots: [...app.slots],
845
+ fragments: { ...app.fragments }
846
+ }
847
+ };
848
+ }
849
+ }
850
+ return null;
851
+ }
852
+ applyRequestContext(event, context) {
853
+ const bpContext = event;
854
+ bpContext.__bpTenantId = context.tenant.id;
855
+ bpContext.__bpAppId = context.app.id;
856
+ bpContext.__bpTenant = context.tenant;
857
+ bpContext.__bpApp = context.app;
858
+ bpContext.__bpThemeId = context.app.themeId ?? "bootstrap1";
859
+ bpContext.__bpAppAuth = context.app.auth;
860
+ }
861
+ resolveHandlerContext(event) {
862
+ const bpContext = event;
863
+ return {
864
+ plugin: this,
865
+ ...(bpContext.__bpTenant ? { tenant: bpContext.__bpTenant } : {}),
866
+ ...(bpContext.__bpApp ? { app: bpContext.__bpApp } : {}),
867
+ config: this.effectiveServiceConfig(bpContext.__bpTenantId, bpContext.__bpAppId),
868
+ ...(bpContext.__bpResponseModel ? { responseModel: bpContext.__bpResponseModel } : {}),
869
+ webhook: (eventId, payload, options) => this.emitWebhook(event, eventId, payload, {
870
+ tenantId: options?.tenantId ?? bpContext.__bpTenantId,
871
+ appId: options?.appId ?? bpContext.__bpAppId
872
+ })
873
+ };
874
+ }
875
+ async emitWebhook(event, eventId, payload, scope) {
876
+ const cpUrl = this.bp.controlPlaneUrl?.replace(/\/+$/, "");
877
+ const apiKey = this.bp.serviceApiKey;
878
+ const obs = eventObservability(event);
879
+ if (!cpUrl || !apiKey) {
880
+ obs?.logger.warn("BP WEBHOOK: skipped event={eventId} service={serviceId} reason=missing_control_plane", {
881
+ eventId,
882
+ serviceId: this.manifest.pluginId
883
+ });
884
+ return;
885
+ }
886
+ const response = await fetch(`${cpUrl}/.well-known/bp/webhooks/events`, {
887
+ method: "POST",
888
+ headers: {
889
+ Accept: "application/json",
890
+ "content-type": "application/json",
891
+ Authorization: `Bearer ${apiKey}`
892
+ },
893
+ body: JSON.stringify({
894
+ eventId,
895
+ payload,
896
+ tenantId: scope.tenantId,
897
+ appId: scope.appId
898
+ })
899
+ });
900
+ if (!response.ok) {
901
+ const body = await response.text().catch(() => "");
902
+ obs?.logger.warn("BP WEBHOOK: emit failed event={eventId} service={serviceId} status={status} body={body}", {
903
+ eventId,
904
+ serviceId: this.manifest.pluginId,
905
+ status: response.status,
906
+ body
907
+ });
908
+ }
909
+ }
910
+ effectiveServiceConfig(tenantId, appId) {
911
+ if (!tenantId)
912
+ return {};
913
+ const state = this.configStore.read(this.internalConfigReadTicket(tenantId));
914
+ return {
915
+ ...state.tenant,
916
+ ...(appId ? state.app[appId] ?? {} : {})
917
+ };
918
+ }
919
+ internalConfigReadTicket(tenantId) {
920
+ const now = Math.floor(Date.now() / 1000);
921
+ return {
922
+ iss: this.manifest.pluginId,
923
+ aud: this.manifest.pluginId,
924
+ sub: this.manifest.pluginId,
925
+ iat: now,
926
+ exp: now + 60,
927
+ jti: `${tenantId}:${now}`,
928
+ realm: "control-plane",
929
+ tenantId,
930
+ serviceId: this.manifest.pluginId,
931
+ actions: ["config.read"]
932
+ };
933
+ }
934
+ async describeCorsContextFailure(event) {
935
+ const headers = eventHeaders(event);
936
+ if (this.scopedConfig) {
937
+ const candidateHosts = [
938
+ hostFromHeaderValue(resolveEmbeddedSourceHeader(headers, this.headerTrustOptions(event))),
939
+ hostFromHeaderValue(resolveThemeSourceHeader(headers, this.headerTrustOptions(event)))
940
+ ].filter((value) => !!value);
941
+ return {
942
+ candidateHosts: [...new Set(candidateHosts)].join(","),
943
+ configuredAppHosts: this.scopedConfig.apps
944
+ .map((app) => `${app.id}:[${app.hostnames.map((hostname) => hostFromHeaderValue(hostname) ?? hostname).join(",")}]`)
945
+ .join(";")
946
+ };
947
+ }
948
+ if (!this.configProvider) {
949
+ return undefined;
950
+ }
951
+ const portalConfig = await this.configProvider.loadConfig();
952
+ const details = describeEmbeddedContextResolution(portalConfig, headers, this.headerTrustOptions(event));
953
+ return {
954
+ candidateHosts: details.candidates.join(","),
955
+ configuredAppHosts: details.appHosts.map((app) => `${app.appId}:[${app.hosts.join(",")}]`).join(";")
956
+ };
957
+ }
958
+ logContextResolutionFailure(event, mode, error, details) {
959
+ const obs = eventObservability(event);
960
+ if (!obs)
961
+ return;
962
+ const normalizedError = error instanceof Error ? error : null;
963
+ obs.logger.warn("BetterPortal {mode} context not resolved for request host={host} origin={origin} referer={referer} candidateHosts={candidateHosts} configuredAppHosts={configuredAppHosts}: {reason}", {
964
+ mode,
965
+ host: event.req.headers.get("host") ?? "",
966
+ origin: event.req.headers.get("origin") ?? "",
967
+ referer: event.req.headers.get("referer") ?? "",
968
+ candidateHosts: details?.candidateHosts ?? "",
969
+ configuredAppHosts: details?.configuredAppHosts ?? "",
970
+ reason: normalizedError?.message ?? "no active app matched request host/origin/referer"
971
+ });
972
+ }
973
+ /**
974
+ * Resolve API key + CP URL using the 3-layer chain:
975
+ * 1. Bootstrap state store (default)
976
+ * 2. sec-config (this.bp.serviceApiKey + this.bp.controlPlaneUrl)
977
+ * 3. Process env BP_SERVICE_API_KEY + BP_CONTROL_PLANE_URL (arg layer)
978
+ * If none yield credentials, enter setup mode.
979
+ */
980
+ resolveCredentials(obs) {
981
+ // Self-hosted services (the CP itself - e.g. config-manager) don't poll a
982
+ // remote CP and never enter setup mode.
983
+ if (!this.requireBetterPortalConfigSource) {
984
+ this.inSetupMode = false;
985
+ this.resolvedApiKey = null;
986
+ this.resolvedCpUrl = null;
987
+ return;
988
+ }
989
+ const stored = this.bootstrapState.read();
990
+ const envKey = process.env.BP_SERVICE_API_KEY;
991
+ const envCp = process.env.BP_CONTROL_PLANE_URL;
992
+ this.resolvedApiKey =
993
+ stored.apiKey ?? this.bp.serviceApiKey ?? envKey ?? null;
994
+ this.resolvedCpUrl =
995
+ stored.cpUrl ?? this.bp.controlPlaneUrl ?? envCp ?? null;
996
+ if (this.resolvedApiKey && this.resolvedCpUrl) {
997
+ this.inSetupMode = false;
998
+ const source = stored.apiKey ? "bootstrap-state"
999
+ : this.bp.serviceApiKey ? "sec-config"
1000
+ : "env";
1001
+ obs.log.info("Credentials loaded from {source}; CP={cpUrl}", {
1002
+ source,
1003
+ cpUrl: this.resolvedCpUrl
1004
+ });
1005
+ }
1006
+ else {
1007
+ this.inSetupMode = true;
1008
+ }
1009
+ }
1010
+ validateBetterPortalConfig(obs) {
1011
+ if (!this.requireBetterPortalConfigSource) {
1012
+ return;
1013
+ }
1014
+ const bp = this.bp;
1015
+ const localPath = bp.bpConfigPath;
1016
+ const hasLocalPath = !!localPath;
1017
+ const hasSync = !!this.resolvedApiKey && !!this.resolvedCpUrl;
1018
+ if (!hasLocalPath && !hasSync) {
1019
+ // Setup mode - service will accept POST /.well-known/bp/install
1020
+ obs.log.warn("No credentials available - entering setup mode. POST /.well-known/bp/install with setupToken+cpUrl to provision.");
1021
+ return;
1022
+ }
1023
+ if (!hasSync && hasLocalPath) {
1024
+ obs.log.warn("BetterPortal control-plane sync is disabled; using local file config at {path}. Dev mode only.", { path: localPath });
1025
+ }
1026
+ else if (hasLocalPath) {
1027
+ obs.log.warn("BetterPortal local file config at {path} configured alongside control-plane sync; sync is authoritative after connect.", { path: localPath });
1028
+ }
1029
+ }
1030
+ requireTenantConfigSource(event) {
1031
+ if (!this.requireBetterPortalConfigSource) {
1032
+ return undefined;
1033
+ }
1034
+ if (this.scopedConfig || this.configProvider) {
1035
+ return undefined;
1036
+ }
1037
+ if (this.isPreSyncCorePath(event.url.pathname)) {
1038
+ return undefined;
1039
+ }
1040
+ const detail = this.inSetupMode
1041
+ ? "Service is in setup mode. POST /.well-known/bp/install with {setupToken, cpUrl} to provision."
1042
+ : "The service is running in control-plane sync mode, but no tenant/app config has been received.";
1043
+ return jsonResponse({
1044
+ error: this.inSetupMode ? "BetterPortal service awaiting setup" : "BetterPortal tenant/app config has not synced yet",
1045
+ detail
1046
+ }, 503);
1047
+ }
1048
+ resolveConfigEncryptionKey() {
1049
+ const stored = this.bootstrapState.read();
1050
+ return stored.configEncryptionKey ?? this.bp.configEncryptionKey;
1051
+ }
1052
+ isPreSyncCorePath(pathname) {
1053
+ if (pathname === "/.well-known/bp/health")
1054
+ return true;
1055
+ if (pathname === "/.well-known/bp/manifest")
1056
+ return true;
1057
+ if (pathname === "/.well-known/bp/schema.json")
1058
+ return true;
1059
+ if (pathname === "/.well-known/bp/install")
1060
+ return true;
1061
+ if (pathname === "/.well-known/jwks.json")
1062
+ return true;
1063
+ if (pathname === "/.well-known/bp/bootstrap")
1064
+ return true;
1065
+ if (pathname === "/.well-known/bp/bootstrap/commit")
1066
+ return true;
1067
+ if (pathname === "/.well-known/bp/services/redeem")
1068
+ return true;
1069
+ if (pathname === "/.well-known/bp/admin/services/begin-install")
1070
+ return true;
1071
+ return false;
1072
+ }
1073
+ renderHealth() {
1074
+ const synced = Boolean(this.scopedConfig);
1075
+ const localConfig = Boolean(this.configProvider);
1076
+ const ready = !this.requireBetterPortalConfigSource || this.inSetupMode || synced || localConfig;
1077
+ const status = ready ? 200 : 503;
1078
+ return jsonResponse({
1079
+ ok: ready,
1080
+ ready,
1081
+ pluginId: this.manifest.pluginId,
1082
+ setupMode: this.inSetupMode,
1083
+ config: {
1084
+ synced,
1085
+ localConfig,
1086
+ tenants: this.scopedConfig?.tenants.length ?? 0,
1087
+ apps: this.scopedConfig?.apps.length ?? 0
1088
+ },
1089
+ sync: {
1090
+ mode: this.inSetupMode
1091
+ ? "setup"
1092
+ : !this.requireBetterPortalConfigSource
1093
+ ? "control-plane"
1094
+ : localConfig
1095
+ ? "local"
1096
+ : this.resolvedApiKey && this.resolvedCpUrl
1097
+ ? "control-plane"
1098
+ : "missing",
1099
+ state: ready
1100
+ ? this.inSetupMode
1101
+ ? "awaiting-install"
1102
+ : !this.requireBetterPortalConfigSource
1103
+ ? "source"
1104
+ : synced
1105
+ ? "synced"
1106
+ : "local-config"
1107
+ : "awaiting-sync"
1108
+ }
1109
+ }, status);
1110
+ }
1111
+ // -- Install endpoint ----------------------------------------------
1112
+ /**
1113
+ * Mounts POST /.well-known/bp/install - the browser-driven service installer.
1114
+ * Caller posts { setupToken, cpUrl }. Service fetches CP JWKS, verifies the
1115
+ * setup token, then redeems it for the real apiKey via CP /services/redeem.
1116
+ * Persists credentials and starts CP sync.
1117
+ */
1118
+ registerInstallEndpoint(obs) {
1119
+ this.app.post("/.well-known/bp/install", async (event) => {
1120
+ // CORS already handled by handleWithCors for public discovery paths.
1121
+ const body = await event.req.json().catch(() => null);
1122
+ if (!body || typeof body !== "object") {
1123
+ return jsonResponse({ error: "Request body must be JSON object" }, 400);
1124
+ }
1125
+ const { setupToken, cpUrl } = body;
1126
+ if (typeof setupToken !== "string" || setupToken.length === 0) {
1127
+ return jsonResponse({ error: "Missing setupToken" }, 400);
1128
+ }
1129
+ if (typeof cpUrl !== "string" || cpUrl.length === 0) {
1130
+ return jsonResponse({ error: "Missing cpUrl" }, 400);
1131
+ }
1132
+ const normalizedCp = cpUrl.replace(/\/+$/, "");
1133
+ const jwksUri = `${normalizedCp}/.well-known/jwks.json`;
1134
+ try {
1135
+ const claims = await verifySetupToken(setupToken, {
1136
+ jwks: { jwksUri, issuer: normalizedCp },
1137
+ expectedIssuer: normalizedCp
1138
+ });
1139
+ if (claims.cpJwksUri && claims.cpJwksUri !== jwksUri) {
1140
+ return jsonResponse({ error: "Setup token cpJwksUri mismatch" }, 400);
1141
+ }
1142
+ // Redeem token at CP - exchanges single-use setup token for the real apiKey.
1143
+ // Also pushes our JWKS (if we're an auth provider) so the CP can verify
1144
+ // JWTs we issue WITHOUT fetching JWKS from us (CM cannot reach services).
1145
+ const redeemResponse = await fetch(`${normalizedCp}/.well-known/bp/services/redeem`, {
1146
+ method: "POST",
1147
+ headers: { "content-type": "application/json", "accept": "application/json" },
1148
+ body: JSON.stringify({
1149
+ setupToken,
1150
+ pluginId: this.manifest.pluginId,
1151
+ serviceUrl: this.deriveOwnUrl(event),
1152
+ ...(this.publishedJwks ? { jwks: this.publishedJwks } : {})
1153
+ })
1154
+ });
1155
+ if (!redeemResponse.ok) {
1156
+ const text = await redeemResponse.text().catch(() => "");
1157
+ obs.log.warn("CP redeem failed: status={status} body={body}", { status: redeemResponse.status, body: text });
1158
+ return jsonResponse({ error: "CP rejected redeem", detail: text }, 502);
1159
+ }
1160
+ const redeemBody = await redeemResponse.json();
1161
+ if (typeof redeemBody.apiKey !== "string" || redeemBody.apiKey.length === 0) {
1162
+ return jsonResponse({ error: "CP redeem response missing apiKey" }, 502);
1163
+ }
1164
+ // Persist + log + reconnect to CP.
1165
+ const configEncryptionKey = this.resolveConfigEncryptionKey() ?? `bp_cek_${randomBytes(32).toString("base64url")}`;
1166
+ this.bootstrapState.write({
1167
+ apiKey: redeemBody.apiKey,
1168
+ cpUrl: normalizedCp,
1169
+ cpId: redeemBody.cpId,
1170
+ cpJwksUri: redeemBody.cpJwksUri ?? jwksUri,
1171
+ configEncryptionKey,
1172
+ installedAt: new Date().toISOString()
1173
+ });
1174
+ this.runtimeConfigEncryptionKey = configEncryptionKey;
1175
+ if (this.manifest.configSchemas.length > 0) {
1176
+ this.configStore = new FileBackedServiceConfigStore({
1177
+ filePath: this.serviceConfigStorePath(this.manifest.pluginId),
1178
+ configSchemas: this.manifest.configSchemas,
1179
+ encryptionKey: configEncryptionKey
1180
+ });
1181
+ }
1182
+ this.resolvedApiKey = redeemBody.apiKey;
1183
+ this.resolvedCpUrl = normalizedCp;
1184
+ this.inSetupMode = false;
1185
+ // eslint-disable-next-line no-console
1186
+ console.log(`\n*** BP install complete for ${this.manifest.pluginId} ***\n apiKey: ${redeemBody.apiKey}\n cpUrl: ${normalizedCp}\n`);
1187
+ obs.log.info("Install complete for {pluginId}; apiKey persisted; starting CP sync", { pluginId: this.manifest.pluginId });
1188
+ // Kick off CP sync (idempotent - connectToControlPlane uses resolved fields)
1189
+ this.connectToControlPlane(obs);
1190
+ return jsonResponse({
1191
+ ok: true,
1192
+ pluginId: this.manifest.pluginId,
1193
+ apiKey: redeemBody.apiKey,
1194
+ cpUrl: normalizedCp,
1195
+ manifestVersion: this.manifest.version
1196
+ }, 200);
1197
+ }
1198
+ catch (err) {
1199
+ obs.log.warn("Install handler error: {msg}", { msg: err.message });
1200
+ return jsonResponse({ error: "Install failed", detail: err.message }, 400);
1201
+ }
1202
+ });
1203
+ }
1204
+ serviceConfigStorePath(pluginId) {
1205
+ if (this.bp.bpConfigPath) {
1206
+ return resolve(dirname(this.bp.bpConfigPath), ".bp-config-state", `${pluginId}.json`);
1207
+ }
1208
+ return resolve(dirname(this.bp.bootstrapStatePath ?? "./.bp-bootstrap/state.enc"), "config.json");
1209
+ }
1210
+ deriveOwnUrl(event) {
1211
+ const host = event.req.headers.get("host") ?? `${this.service.host}:${this.service.port}`;
1212
+ const proto = event.req.headers.get("x-forwarded-proto") ?? "http";
1213
+ return `${proto}://${host}`;
1214
+ }
1215
+ // Config management
1216
+ registerDefaultConfigRoutes() {
1217
+ registerServiceConfigRoutes({
1218
+ app: this.app,
1219
+ serviceId: this.manifest.pluginId,
1220
+ configSchemas: this.manifest.configSchemas,
1221
+ mode: "hybrid",
1222
+ validateTicket: (ticketValue, event, action) => this.validateConfigTicket(ticketValue, event, action),
1223
+ validateScope: (scope) => this.validateConfigScope(scope.tenantId, scope.appId),
1224
+ readConfig: ({ ticket }) => this.configStore.read(ticket),
1225
+ writeConfig: ({ tenantId, appId, values }, { ticket }) => this.configStore.write(tenantId, appId, values, ticket),
1226
+ clearConfigKey: ({ tenantId, appId, key }, { ticket }) => this.configStore.clearKey?.(tenantId, appId, key, ticket) ?? this.configStore.read(ticket)
1227
+ });
1228
+ }
1229
+ async validateConfigScope(tenantId, appId) {
1230
+ if (this.scopedConfig) {
1231
+ const tenant = this.scopedConfig.tenants.find((entry) => entry.id === tenantId);
1232
+ if (!tenant?.active)
1233
+ return false;
1234
+ if (!appId)
1235
+ return true;
1236
+ const configApps = this.scopedConfig.configApps ?? this.scopedConfig.apps;
1237
+ return configApps.some((entry) => entry.id === appId && entry.tenantId === tenantId);
1238
+ }
1239
+ if (!this.configProvider) {
1240
+ return false;
1241
+ }
1242
+ const config = await this.configProvider.loadConfig();
1243
+ const tenant = config.tenants.find((entry) => entry.id === tenantId);
1244
+ if (!tenant?.active)
1245
+ return false;
1246
+ if (!appId)
1247
+ return true;
1248
+ return config.apps.some((entry) => entry.id === appId && entry.tenantId === tenantId);
1249
+ }
1250
+ /**
1251
+ * Validate a service-config ticket. Primary path: verify a CP-signed RS256
1252
+ * ticket against the CP JWKS learned at install/redeem - there is no shared
1253
+ * secret and only the CP can mint tickets. Before install (no cpJwksUri yet)
1254
+ * the service fails closed: config endpoints reject every request until it has
1255
+ * been provisioned.
1256
+ */
1257
+ async validateConfigTicket(ticketValue, event, action) {
1258
+ if (!ticketValue)
1259
+ return null;
1260
+ const { cpUrl, cpJwksUri } = this.bootstrapState.read();
1261
+ if (cpUrl && cpJwksUri) {
1262
+ try {
1263
+ return await verifyServiceConfigTicket(ticketValue, {
1264
+ jwksUri: cpJwksUri,
1265
+ issuer: cpUrl,
1266
+ serviceId: this.manifest.pluginId
1267
+ });
1268
+ }
1269
+ catch {
1270
+ // Not a valid CP ticket - fall through to the dev path (only if enabled).
1271
+ }
1272
+ }
1273
+ return this.validateDevConfigToken(ticketValue, event, action);
1274
+ }
1275
+ /**
1276
+ * Static shared-secret fallback for LOCAL DEVELOPMENT ONLY. Disabled unless
1277
+ * BP_ALLOW_DEV_CONFIG_TOKEN=true AND configApiToken is explicitly set. It
1278
+ * trusts the x-bp-tenant-id header to choose the tenant, so it must never be
1279
+ * enabled in production.
1280
+ */
1281
+ validateDevConfigToken(ticketValue, event, action) {
1282
+ if (process.env.BP_ALLOW_DEV_CONFIG_TOKEN !== "true")
1283
+ return null;
1284
+ const expectedToken = this.bp.configApiToken;
1285
+ if (!expectedToken)
1286
+ return null;
1287
+ const expected = Buffer.from(expectedToken);
1288
+ const actual = Buffer.from(ticketValue);
1289
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
1290
+ return null;
1291
+ }
1292
+ const tenantId = event.req.headers.get("x-bp-tenant-id") ?? "tenant-main";
1293
+ const now = Math.floor(Date.now() / 1000);
1294
+ return {
1295
+ iss: "betterportal-dev",
1296
+ aud: ["betterportal-service-config"],
1297
+ sub: "admin.dev",
1298
+ exp: now + 300,
1299
+ iat: now,
1300
+ jti: `bp-config-${now}`,
1301
+ realm: "control-plane",
1302
+ tenantId,
1303
+ serviceId: this.manifest.pluginId,
1304
+ actions: [action]
1305
+ };
1306
+ }
1307
+ }
1308
+ //# sourceMappingURL=service.js.map