import { BSBService, type BSBServiceConstructor, type BSBEventSchemas, type BSBPluginConfig, type BSBReferencePluginConfigType, type Observable } from "@bsb/base"; import * as av from "anyvali"; import { type Server } from "node:http"; import { type ElevationRequirement, type JwtClaims, type BpTokenIssuer, type AppAuthConfig, type AppAuthRole, type BetterPortalResolvedRequestContext, type BetterPortalObservability, type BetterPortalRegistry, type RegisteredRoute, type BetterPortalThemeConfig, type JwtVerifier, type ManifestBaseFields, type PluginManifest, type PublicJwks, type BetterPortalSseContracts, type BetterPortalConfig as PlatformConfig, type ServiceConfigAction, type ServiceConfigStore, type ServiceConfigTicketClaims, type RouteHandlerContext, type TenantAppValidation } from "@betterportal/framework"; import { BootstrapStateStore } from "./bootstrapState.js"; import { type BetterPortalEvent, type BetterPortalH3App } from "@betterportal/framework/lib/runtime/h3.js"; export interface BPServiceConfig { host: string; port: number; betterportal?: BetterPortalConfig; bpConfigPath?: string; configApiToken?: string; controlPlaneUrl?: string; serviceApiKey?: string; bootstrapStatePath?: string; trustedProxyHeaders?: boolean; cfProxy?: boolean; trustedProxyIps?: string[]; } type BPServicePluginConfig = BSBPluginConfig>; export interface BetterPortalConfig { bpConfigPath?: string; configApiToken?: string; controlPlaneUrl?: string; serviceApiKey?: string; bootstrapStatePath?: string; /** * Local cache of the scoped platform config delivered by the CP. Persisted * on each sync so the service can serve requests immediately on restart, * without sharing CM's source-of-truth bp-config.yaml. Default is per-service. */ scopedConfigCachePath?: string; trustedProxyHeaders?: boolean; cfProxy?: boolean; trustedProxyIps?: string[]; } export declare const BetterPortalConfigSchema: av.OptionalSchema; configApiToken: av.OptionalSchema; controlPlaneUrl: av.OptionalSchema; serviceApiKey: av.OptionalSchema; bootstrapStatePath: av.StringSchema; scopedConfigCachePath: av.StringSchema; trustedProxyHeaders: av.BoolSchema; cfProxy: av.BoolSchema; trustedProxyIps: av.ArraySchema; }>>; export type AuthoritativeServiceType = "auth" | "theme"; export type AuthoritativeServiceMutation = T extends "auth" ? { roles: AppAuthRole[]; } : { themeConfig: BetterPortalThemeConfig; }; export interface BPServiceDefinition { manifest: ManifestBaseFields; } export interface BPServiceClientRuntime { readonly baseUrl: string; readonly headers: Record | (() => Record); readonly token: () => string; readonly fetch: typeof globalThis.fetch; } export interface BetterPortalSseEmitScope { readonly tenantId: string; readonly appId: string; } export interface BetterPortalRuntime { readonly sse: { emit>(viewId: TViewId, scope: BetterPortalSseEmitScope, input: BetterPortalSseContracts[TViewId]): void; }; } export declare abstract class BPService extends BSBService { /** Build-time metadata extraction without constructing or starting the service. */ static getBPDefinition(this: { prototype: { definition(): BPServiceDefinition; }; }): BPServiceDefinition; private readonly bpPluginVersion; private get service(); protected get bp(): BetterPortalConfig; /** * Resolve header-trust options for a request. Proxy-supplied host headers are * only honoured when the request's direct socket peer IP is in the configured * `trustedProxyIps` allowlist - otherwise an attacker connecting directly * could spoof X-Forwarded-Host/Forwarded/CF-* to impersonate another tenant. */ protected headerTrustOptions(event: BetterPortalEvent): { trustedProxyHeaders?: boolean; cfProxy?: boolean; }; readonly initBeforePlugins: string[]; readonly initAfterPlugins: string[]; readonly runBeforePlugins: string[]; readonly runAfterPlugins: string[]; protected readonly requireBetterPortalConfigSource: boolean; protected app: BetterPortalH3App; protected server: Server; protected observability: BetterPortalObservability; protected manifest: PluginManifest; protected configStore: ServiceConfigStore; private runtimeConfigEncryptionKey; private previewConfigRevision; private configProvider; private scopedConfig; private registeredRoutes; private scopedConfigCache; private s2sKeyPair; private s2sIdentityReady; private sseAbortController; private syncReconnectTimer?; /** Manifest acceptance is separate from receiving a cached config over SSE. */ private manifestSync; private readonly seoProbeCache; protected bootstrapState: BootstrapStateStore; /** * Synthesize a BetterPortalConfig-shaped view from the synced scoped config. * Lets shell services that need the full-portal-config API (for * `resolveThemeRequestContext` / `resolveServiceForTenant`) operate without * sharing CM's bp-config.yaml. Returns null until the first sync completes. */ protected getPortalConfig(): PlatformConfig | null; private resolvedApiKey; private resolvedCpUrl; private inSetupMode; protected abstract definition(): BPServiceDefinition; protected onRegistered?(registry: BetterPortalRegistry, obs: Observable): void | Promise; private registerShellFragmentRoutes; protected readonly betterPortal: BetterPortalRuntime; protected controlPlaneCredentials(): { url: string; apiKey: string; } | null; /** * Resolve an installed-service dependency from the last-known-good snapshot. * The returned shape is accepted directly by generated BP clients. */ m2mClient(requestId: string, ctx: Pick): BPServiceClientRuntime; m2mClient(requestId: string, tenantId: string, appId: string): BPServiceClientRuntime; /** Resolve an installed-service dependency while preserving the current BP user identity. */ delegatedM2mClient(requestId: string, ctx: Pick): BPServiceClientRuntime; private m2mFetch; private resolveM2MClient; private initializeS2SIdentity; private updateS2SIdentityState; private getServiceTokenVerifier; protected isAuthoritativeService(tenantId: string, appId: string, serviceType: AuthoritativeServiceType): boolean; protected updateAuthoritativeService(tenantId: string, appId: string, serviceType: T, mutation: AuthoritativeServiceMutation): Promise; /** * Override to provide a JWT verifier for incoming requests. * Receives the resolved tenant/app context. Return undefined to skip auth for the request. */ protected getJwtVerifier(_tenantId: string, _appId: string): JwtVerifier | undefined; private getConfiguredJwtVerifier; /** * Override to provide the app's resolved auth config (roles[], expectedIssuer, etc). * Default: reads from scopedConfig synced from the control plane. */ protected getAppAuthConfig(tenantId: string, appId: string): AppAuthConfig | undefined; /** * Override to provide the service-instance-id → pluginId alias map used by the * permission check (role grants use instance ids, route auth uses pluginIds). * Default: reads the tenant's service bindings from scopedConfig. */ protected getServiceIdAliases(tenantId: string): Record | undefined; /** * Override to validate that a given (tenantId, appId) is allowed to consume this service. * * Default behavior: auto-single-tenant via lock. On first request from a tenant, the * tenant is stored as the lock. Subsequent requests from other tenants are blocked * with 426 Upgrade Required. Services wanting shared/multi-tenant behavior must override. */ protected validateTenantApp(tenantId: string, _appId: string): Promise; /** * Register this service as an auth provider by exposing a JWKS endpoint. * * Mounts `GET /.well-known/jwks.json` returning the supplied JWK set. * Call this from `init()` AFTER `super.init()` so the H3 app exists. */ /** Runtime auth metadata published when this service acts as an auth provider. * Sent during install and sync so config-manager can configure app verifiers * without guessing issuer/audience/JWKS from hostnames. */ private publishedJwks; private publishedAuthProvider; protected registerAsAuthProvider(input: { issuer: string; audience: string; jwksUri: string; jwks: PublicJwks; cacheMaxAgeSeconds?: number; tokenIssuer?: BpTokenIssuer; }): void; private elevationIssuer?; /** Providers may override these two hooks to persist challenges and require enrolled factors. */ protected beginAuthElevation(user: JwtClaims, requirement: ElevationRequirement, _event: BetterPortalEvent, _actionContext?: Record): Promise>; protected finishAuthElevation(user: JwtClaims, body: Record, _event: BetterPortalEvent): Promise<{ user: JwtClaims; assurance: "confirmed" | "mfa"; verifiedAt?: number; }>; private registerElevationEndpoint; constructor(cfg: BSBServiceConstructor); init(obs: Observable): Promise; run(obs: Observable): Promise; dispose(): Promise; private connectToControlPlane; private logScopedConfigDebug; protected resolveRequestContext(event: BetterPortalEvent): Promise; private resolveAuthForRequest; protected getPlatformRootAuthScope(_tenantId: string, _appId: string): { tenantId?: string; appId?: string; } | undefined; private rejectCors; private handleWithCors; private isPublicBpDiscoveryPath; private isConfigManagementPath; private managementOrigins; private resolveScopedContextById; private managementRequestContext; private resolveScopedRequestContext; protected applyRequestContext(event: BetterPortalEvent, context: BetterPortalResolvedRequestContext): void; protected resolveHandlerContext(event: BetterPortalEvent, route?: RegisteredRoute): Partial>; private emitWebhook; protected effectiveServiceConfig(tenantId?: string, appId?: string): Record; private applyPreviewConfig; private internalConfigReadTicket; protected describeCorsContextFailure(event: BetterPortalEvent): Promise<{ candidateHosts: string; configuredAppHosts: string; } | undefined>; private logContextResolutionFailure; /** * Resolve API key + CP URL using the 3-layer chain: * 1. Bootstrap state store (default) * 2. sec-config (this.bp.serviceApiKey + this.bp.controlPlaneUrl) * 3. Process env BP_SERVICE_API_KEY + BP_CONTROL_PLANE_URL (arg layer) * If none yield credentials, enter setup mode. */ private resolveCredentials; private validateBetterPortalConfig; private requireTenantConfigSource; private resolveConfigEncryptionKey; private isPreSyncCorePath; /** Verify a management-app user before disclosing live service diagnostics. */ private canReadHealthDiagnostics; /** Keep live probes minimal; setup and verified management users may see diagnostics. */ private renderHealth; private emitSse; private localServiceInstanceIds; private normalizeSitemapEntries; private serviceSeoRoutes; private appRequestOrigin; private seoCacheTtl; private probeServiceSeo; private shellSeoDocuments; private registerSeoRoutes; /** * Mounts POST /.well-known/bp/install - the browser-driven service installer. * Caller posts `{ setupToken, cpUrl }`. Service fetches CP JWKS, verifies the * setup token, then redeems it for the real apiKey via CP /services/redeem. * Persists credentials and starts CP sync. */ private registerInstallEndpoint; private registerHostnameChangeEndpoint; private serviceConfigStorePath; private deriveOwnUrl; private registerDefaultConfigRoutes; /** Providers can fence configuration changes against identity creation. */ protected mutateServiceConfiguration(_tenantId: string, _appId: string | undefined, _values: Record, write: () => T | Promise): Promise; protected validateConfigScope(tenantId: string, appId?: string): Promise; /** * Validate a service-config ticket. Primary path: verify a CP-signed RS256 * ticket against the CP JWKS learned at install/redeem - there is no shared * secret and only the CP can mint tickets. Before install (no cpJwksUri yet) * the service fails closed: config endpoints reject every request until it has * been provisioned. */ protected validateConfigTicket(ticketValue: string | null, event: BetterPortalEvent, action: ServiceConfigAction): Promise; /** * Static shared-secret fallback for LOCAL DEVELOPMENT ONLY. Disabled unless * BP_ALLOW_DEV_CONFIG_TOKEN=true AND configApiToken is explicitly set. It * trusts the x-bp-tenant-id header to choose the tenant, so it must never be * enabled in production. */ private validateDevConfigToken; } export {}; //# sourceMappingURL=service.d.ts.map