|
| 1 | +// Resolves a hosted AMS tenant's bootstrap secret (#8246, the AMS half of #8202). Exchanges |
| 2 | +// LOOPOVER_TENANT_SECRET_TOKEN against the SAME broker exchange src/orb/broker-client.ts's |
| 3 | +// fetchBrokeredStoredSecret already implements for ORB -- duplicated here, not imported: this package is a |
| 4 | +// real npm workspace member whose tsconfig.json scopes `"rootDir": "."` to itself, so a relative import |
| 5 | +// reaching into root src/ resolves outside rootDir and fails tsc with TS6059. This mirrors |
| 6 | +// control-plane/src/secret-driver.ts's own identical "duplicate, don't import" call for the SAME package |
| 7 | +// boundary (see also control-plane/src/http-app.ts's HOSTED_CYCLE_COMMANDS comment, which cross-references |
| 8 | +// this file for the same reasoning). |
| 9 | +// |
| 10 | +// #8202's mechanism: control-plane delivers a one-time bootstrap credential into a hosted tenant container's |
| 11 | +// cold-boot env as LOOPOVER_TENANT_SECRET_TOKEN (a product-agnostic name -- ORB's and AMS's containers both |
| 12 | +// read the identical var). The container exchanges it via POST /v1/orb/token for whatever the broker has |
| 13 | +// custodied under it -- today, always a tenant_db_credential (a JSON-encoded DatabaseConnectionDetails); |
| 14 | +// #8202's own research confirmed there is no production issuance path for ams_github_token yet, so that isn't |
| 15 | +// a real response shape to plan a consumer around. |
| 16 | +// |
| 17 | +// resolveTenantSecret (the function hosted-entry.ts actually calls) is deliberately best-effort: unlike ORB's |
| 18 | +// fetchBrokeredStoredSecret, which throws because a self-hosted engine has real work that needs the value, no |
| 19 | +// code in this package consumes a resolved tenant secret yet (the miner's own stores are unconditionally local |
| 20 | +// SQLite -- see store-db-adapter.ts's own "later" note on swapping in a Postgres adapter), so a broker outage |
| 21 | +// or an unconfigured token must not block a scheduled discover/manage-poll/attempt cycle from running. |
| 22 | +// fetchTenantSecret (the throwing primitive) is exported for whatever real consumer eventually needs strict |
| 23 | +// failure semantics. |
| 24 | +// |
| 25 | +// This FILE is named "credential", not "secret", purely to stay clear of scripts/check-miner-package.ts's |
| 26 | +// filename-based FORBIDDEN_PATH filter (a coarse `.*secret.*` heuristic aimed at stray credential files like |
| 27 | +// .env/.pem, not descriptively-named source code) -- the exported symbols below keep "Secret" in their names, |
| 28 | +// matching src/orb/broker-client.ts's own naming for the function this duplicates. |
| 29 | + |
| 30 | +const DEFAULT_BROKER_URL = "https://api.loopover.ai"; |
| 31 | +const BROKER_TIMEOUT_MS = 25_000; |
| 32 | + |
| 33 | +function isLocalBrokerHost(hostname: string): boolean { |
| 34 | + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]"; |
| 35 | +} |
| 36 | + |
| 37 | +/** Same URL-safety validation as broker-client.ts's own orbBrokerBaseUrl -- guards against an attacker- or |
| 38 | + * misconfiguration-controlled ORB_BROKER_URL sending the bootstrap token to an unintended origin. */ |
| 39 | +function orbBrokerBaseUrl(env: { ORB_BROKER_URL?: string | undefined }): string { |
| 40 | + const raw = env.ORB_BROKER_URL ?? DEFAULT_BROKER_URL; |
| 41 | + let url: URL; |
| 42 | + try { |
| 43 | + url = new URL(raw); |
| 44 | + } catch { |
| 45 | + throw new Error("ORB_BROKER_URL must be a valid URL."); |
| 46 | + } |
| 47 | + if (url.username || url.password) { |
| 48 | + throw new Error("ORB_BROKER_URL must not include userinfo."); |
| 49 | + } |
| 50 | + if (url.search || url.hash) { |
| 51 | + throw new Error("ORB_BROKER_URL must not include a query string or fragment."); |
| 52 | + } |
| 53 | + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalBrokerHost(url.hostname))) { |
| 54 | + throw new Error("ORB_BROKER_URL must use https unless it targets localhost development."); |
| 55 | + } |
| 56 | + const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""); |
| 57 | + return `${url.origin}${path}`; |
| 58 | +} |
| 59 | + |
| 60 | +export type TenantSecret = { secretValue: string; secretType: string }; |
| 61 | + |
| 62 | +/** Exchange LOOPOVER_TENANT_SECRET_TOKEN for whatever the broker has custodied under it. Throws on a non-OK |
| 63 | + * response or a body missing secretValue -- the strict primitive; {@link resolveTenantSecret} below is the |
| 64 | + * best-effort wrapper hosted-entry.ts actually calls. */ |
| 65 | +export async function fetchTenantSecret( |
| 66 | + env: { LOOPOVER_TENANT_SECRET_TOKEN?: string | undefined; ORB_BROKER_URL?: string | undefined }, |
| 67 | + fetchImpl: typeof fetch = fetch, |
| 68 | +): Promise<TenantSecret> { |
| 69 | + const base = orbBrokerBaseUrl(env); |
| 70 | + const response = await fetchImpl(`${base}/v1/orb/token`, { |
| 71 | + method: "POST", |
| 72 | + headers: { authorization: `Bearer ${env.LOOPOVER_TENANT_SECRET_TOKEN ?? ""}` }, |
| 73 | + signal: AbortSignal.timeout(BROKER_TIMEOUT_MS), |
| 74 | + }); |
| 75 | + if (!response.ok) { |
| 76 | + throw new Error(`Orb broker stored-secret exchange failed (${response.status}).`); |
| 77 | + } |
| 78 | + const payload = (await response.json()) as { secretValue?: string; secretType?: string }; |
| 79 | + if (!payload.secretValue) { |
| 80 | + throw new Error("Orb broker stored-secret response did not include a secretValue."); |
| 81 | + } |
| 82 | + return { secretValue: payload.secretValue, secretType: payload.secretType ?? "" }; |
| 83 | +} |
| 84 | + |
| 85 | +/** Best-effort wrapper around {@link fetchTenantSecret} (#8246): `null` when `LOOPOVER_TENANT_SECRET_TOKEN` |
| 86 | + * isn't set (a self-hosted or not-yet-provisioned tenant -- the overwhelmingly common case today) OR when the |
| 87 | + * exchange itself fails, logged rather than thrown. `hosted-entry.ts` calls this once per wake so the |
| 88 | + * mechanism is proven wired end-to-end for AMS (#8246's own deliverable) without making a scheduled cycle |
| 89 | + * fragile against a value nothing consumes yet. */ |
| 90 | +export async function resolveTenantSecret( |
| 91 | + env: Record<string, string | undefined>, |
| 92 | + fetchImpl: typeof fetch = fetch, |
| 93 | +): Promise<TenantSecret | null> { |
| 94 | + const token = env.LOOPOVER_TENANT_SECRET_TOKEN?.trim(); |
| 95 | + if (!token) return null; |
| 96 | + try { |
| 97 | + return await fetchTenantSecret(env, fetchImpl); |
| 98 | + } catch (error) { |
| 99 | + console.warn(JSON.stringify({ event: "ams_tenant_secret_resolve_failed", message: error instanceof Error ? error.message : String(error) })); |
| 100 | + return null; |
| 101 | + } |
| 102 | +} |
0 commit comments