diff --git a/.changeset/eso-bearer-refresher.md b/.changeset/eso-bearer-refresher.md new file mode 100644 index 0000000..a445b5c --- /dev/null +++ b/.changeset/eso-bearer-refresher.md @@ -0,0 +1,5 @@ +--- +'@smooai/config': minor +--- + +SMOODEV-1523: Add an ESO bearer-token refresher (`@smooai/config/eso-refresher` + `smooai-config-eso-refresher` bin). It re-mints the OAuth2 `client_credentials` access token on a short interval (reusing `TokenProvider`) and writes it into the ExternalSecrets bootstrap Kubernetes Secret, so ESO's webhook provider always reads a fresh, non-expired bearer. This is what lets workload secrets sync via ESO instead of being Pulumi-baked at SST deploy time — decoupling `@smooai/config` secret-value changes from the ~1h platform deploy (epic SMOODEV-1522). diff --git a/Dockerfile.eso-refresher b/Dockerfile.eso-refresher new file mode 100644 index 0000000..f4ff818 --- /dev/null +++ b/Dockerfile.eso-refresher @@ -0,0 +1,40 @@ +# ESO bearer-token refresher sidecar (SMOODEV-1523, epic SMOODEV-1522). +# +# Builds the @smooai/config package and runs the standalone refresher process +# (`smooai-config-eso-refresher` bin). Deployed into the cluster to keep the +# ESO bootstrap Secret's bearer token fresh — see docs/ESO-Bearer-Refresher.md. +# +# Build context = repo root (~/dev/smooai/config). Built + pushed by the +# image pipeline; the k8s Deployment + RBAC live in the smooai monorepo +# (SMOODEV-1525). + +# ---- build stage ---------------------------------------------------------- +FROM node:22-slim AS build +WORKDIR /app + +# Corepack gives us the repo-pinned pnpm. +RUN corepack enable + +# Install with the lockfile first (cache-friendly), then build. +COPY package.json pnpm-lock.yaml .npmrc ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm build:lib + +# Prune to production deps for the runtime stage. +RUN pnpm prune --prod + +# ---- runtime stage -------------------------------------------------------- +FROM node:22-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production + +# Run as a non-root, unprivileged user. +USER node + +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/dist ./dist +COPY --from=build --chown=node:node /app/package.json ./package.json + +# The refresher is a long-lived loop; SIGTERM is handled for graceful drain. +ENTRYPOINT ["node", "dist/eso-refresher/run.mjs"] diff --git a/docs/ESO-Bearer-Refresher.md b/docs/ESO-Bearer-Refresher.md new file mode 100644 index 0000000..a288270 --- /dev/null +++ b/docs/ESO-Bearer-Refresher.md @@ -0,0 +1,46 @@ +# ESO Bearer-Token Refresher + +`@smooai/config/eso-refresher` (bin `smooai-config-eso-refresher`) — SMOODEV-1523, epic SMOODEV-1522. + +## Problem + +The [ExternalSecrets Operator](https://external-secrets.io) (ESO) `webhook` provider authenticates to the `@smooai/config` HTTP API (`api.smoo.ai`) with a **static** bearer token it reads from a Kubernetes Secret (`external-secrets/smooai-config-bootstrap`, key `bearer-token`). + +But `@smooai/config` issues short-lived OAuth2 `client_credentials` JWTs (~1h TTL). A static token therefore expires within the hour and every ESO sync silently begins to 401. That is precisely why workload secrets (litellm, voice, api-prime) were instead **Pulumi-baked** into Kubernetes Secrets at SST deploy time (SMOODEV-1347) — which couples _every_ secret-value change to a ~1h platform deploy. + +## What the refresher does + +A small sidecar/Deployment that, on a short interval (default 15m, well under the JWT TTL): + +1. Re-mints a fresh access token using the same `TokenProvider` the runtime SDK uses (`invalidate()` then `getAccessToken()` so the token always has a near-full TTL ahead). +2. Patches `bearer-token` in the bootstrap Secret via a JSON merge-patch. + +ESO then always reads a fresh bearer. A `th config set --environment=production` becomes live on ESO's next `refreshInterval` plus a `kubectl rollout restart` of the consuming workload — **no platform deploy**. + +The initial mint+write is awaited at startup and **fails loud** (non-zero exit → visible crash-loop) on misconfiguration. Later loop failures are logged and retried on the next tick — the existing Secret token is still valid for the remainder of its TTL. + +## Env contract + +| Var | Required | Default | Purpose | +| ------------------------------------- | -------- | ------------------------- | -------------------------------------------------------------- | +| `SMOOAI_CONFIG_CLIENT_ID` | yes | — | M2M OAuth client id (config-read scoped) | +| `SMOOAI_CONFIG_CLIENT_SECRET` | yes | — | M2M OAuth client secret (legacy alias `SMOOAI_CONFIG_API_KEY`) | +| `SMOOAI_CONFIG_AUTH_URL` | no | `https://auth.smoo.ai` | OAuth issuer base URL | +| `SMOOAI_ESO_SECRET_NAMESPACE` | no | `external-secrets` | Bootstrap Secret namespace | +| `SMOOAI_ESO_SECRET_NAME` | no | `smooai-config-bootstrap` | Bootstrap Secret name | +| `SMOOAI_ESO_SECRET_KEY` | no | `bearer-token` | Data key to write | +| `SMOOAI_ESO_REFRESH_INTERVAL_SECONDS` | no | `900` | Re-mint + write interval | + +`orgId` / `environment` are **not** needed — those are query params ESO supplies when it calls the config API; the token itself is org-agnostic. + +## Deployment + +- **Image**: `Dockerfile.eso-refresher` (this repo) builds the `smooai-config-eso-refresher` process. +- **k8s wiring** (Deployment + RBAC + the refresher's own M2M Secret) lives in the smooai monorepo under SMOODEV-1525. RBAC must allow `patch` on the single bootstrap Secret only. +- **Root-of-trust**: the refresher's `SMOOAI_CONFIG_CLIENT_ID/SECRET` are provided as a one-time Kubernetes Secret (or IRSA-fronted). This is the only secret that does not flow through ESO — everything else syncs from it. + +## Related + +- Epic: SMOODEV-1522 (restore ESO secret sync). +- SMOODEV-1524 — schema-driven ESO manifest generator. +- SMOODEV-1525 — smooai: repoint ClusterSecretStore to `api.smoo.ai`, restore per-workload `ExternalSecret`s, drop the Pulumi-bake. diff --git a/package.json b/package.json index 268cd52..d36812c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "url": "https://github.com/SmooAI/config.git" }, "bin": { - "smooai-config": "./dist/cli.mjs" + "smooai-config": "./dist/cli.mjs", + "smooai-config-eso-refresher": "./dist/eso-refresher/run.mjs" }, "files": [ "dist/**" @@ -170,6 +171,11 @@ "import": "./dist/container/index.mjs", "require": "./dist/container/index.js" }, + "./eso-refresher": { + "types": "./dist/eso-refresher/index.d.ts", + "import": "./dist/eso-refresher/index.mjs", + "require": "./dist/eso-refresher/index.js" + }, "./platform/client": { "types": "./dist/platform/client.d.ts", "browser": { @@ -249,6 +255,7 @@ }, "dependencies": { "@isaacs/ttlcache": "^1.4.1", + "@kubernetes/client-node": "^1.4.0", "@smooai/fetch": "^3.3.5", "@smooai/logger": "^4.1.4", "@smooai/utils": "^1.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f7d5e2..c03cf34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: '@isaacs/ttlcache': specifier: ^1.4.1 version: 1.4.1 + '@kubernetes/client-node': + specifier: ^1.4.0 + version: 1.4.0 '@smooai/fetch': specifier: ^3.3.5 version: 3.3.5(@types/node@22.13.10)(typescript@5.8.2) @@ -769,6 +772,21 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@jsep-plugin/assignment@1.3.0': + resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@jsep-plugin/regex@1.0.4': + resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@kubernetes/client-node@1.4.0': + resolution: {integrity: sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1396,18 +1414,27 @@ packages: '@types/gradient-string@1.1.6': resolution: {integrity: sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/lodash.snakecase@4.1.9': resolution: {integrity: sha512-emBZJUiNlo+QPXr1junMKXwzHJK9zbFvTVdyAoorFcm1YRsbzkZCYPTVMM9AW+dlnA6utG7vpfvOs8alxv/TMw==} '@types/lodash@4.17.16': resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} '@types/node@22.13.10': resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1419,6 +1446,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/stream-buffers@3.0.8': + resolution: {integrity: sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==} + '@types/tinycolor2@1.4.6': resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} @@ -1527,6 +1557,9 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -1548,13 +1581,65 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + auto-bind@5.0.1: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.2: + resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.1: + resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -1694,6 +1779,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -1772,6 +1861,10 @@ packages: resolution: {integrity: sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==} engines: {node: '>=0.10.0'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1823,6 +1916,9 @@ packages: resolution: {integrity: sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA==} engines: {node: '>=14'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1850,6 +1946,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-toolkit@1.44.0: resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} @@ -1884,6 +1984,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expect-type@1.2.1: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} @@ -1902,6 +2005,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1943,6 +2049,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -2028,6 +2138,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2043,6 +2157,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + hpagent@1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} + engines: {node: '>=14'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2123,6 +2241,10 @@ packages: react-devtools-core: optional: true + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + is-accessor-descriptor@1.0.1: resolution: {integrity: sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==} engines: {node: '>= 0.10'} @@ -2208,6 +2330,11 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + jackspeak@2.3.6: resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} @@ -2221,6 +2348,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2232,6 +2362,10 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + jsdom@28.1.0: resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2241,6 +2375,10 @@ packages: canvas: optional: true + jsep@1.4.0: + resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} + engines: {node: '>= 10.16.0'} + json-schema-to-zod@2.6.1: resolution: {integrity: sha512-uiHmWH21h9FjKJkRBntfVGTLpYlCZ1n98D0izIlByqQLqpmkQpNTBtfbdP04Na6+43lgsvrShFh2uWLkQDKJuQ==} hasBin: true @@ -2258,6 +2396,11 @@ packages: jsonify@0.0.1: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + jsonpath-plus@10.4.0: + resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==} + engines: {node: '>=18.0.0'} + hasBin: true + kind-of@3.2.2: resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==} engines: {node: '>=0.10.0'} @@ -2343,6 +2486,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -2415,6 +2566,15 @@ packages: sass: optional: true + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + npm-package-arg@11.0.3: resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} engines: {node: ^16.14.0 || >=18.0.0} @@ -2497,6 +2657,9 @@ packages: - which - write-file-atomic + oauth4webapi@3.8.6: + resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2513,10 +2676,16 @@ packages: resolution: {integrity: sha512-Y6tg5rHfsefSkfKujv2SwHulInROy/rCL5F4w0QOWxut8AnxYxf0YmNhTh95Zfyxpsudo66uqkux0ACFnyMSgQ==} engines: {node: '>= 16'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openid-client@6.8.4: + resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==} + os-tmpdir@1.0.2: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} @@ -2673,6 +2842,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2747,6 +2919,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfc4648@1.5.4: + resolution: {integrity: sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==} + rollup@4.35.0: resolution: {integrity: sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2830,6 +3005,18 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2865,6 +3052,13 @@ packages: std-env@3.8.1: resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==} + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + + streamx@2.26.0: + resolution: {integrity: sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -2944,6 +3138,15 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -2952,6 +3155,9 @@ packages: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3010,6 +3216,9 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} @@ -3095,6 +3304,9 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.22.0: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} @@ -3218,6 +3430,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -3233,6 +3448,9 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -3283,6 +3501,9 @@ packages: resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -4170,6 +4391,41 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@jsep-plugin/regex@1.0.4(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@kubernetes/client-node@1.4.0': + dependencies: + '@types/js-yaml': 4.0.9 + '@types/node': 24.12.4 + '@types/node-fetch': 2.6.13 + '@types/stream-buffers': 3.0.8 + form-data: 4.0.5 + hpagent: 1.2.0 + isomorphic-ws: 5.0.0(ws@8.19.0) + js-yaml: 4.2.0 + jsonpath-plus: 10.4.0 + node-fetch: 2.7.0 + openid-client: 6.8.4 + rfc4648: 1.5.4 + socks-proxy-agent: 8.0.5 + stream-buffers: 3.0.3 + tar-fs: 3.1.2 + ws: 8.19.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - encoding + - react-native-b4a + - supports-color + - utf-8-validate + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.26.9 @@ -4879,18 +5135,29 @@ snapshots: dependencies: '@types/tinycolor2': 1.4.6 + '@types/js-yaml@4.0.9': {} + '@types/lodash.snakecase@4.1.9': dependencies: '@types/lodash': 4.17.16 '@types/lodash@4.17.16': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.13.10 + form-data: 4.0.5 + '@types/node@12.20.55': {} '@types/node@22.13.10': dependencies: undici-types: 6.20.0 + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -4901,6 +5168,10 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/stream-buffers@3.0.8': + dependencies: + '@types/node': 22.13.10 + '@types/tinycolor2@1.4.6': {} '@types/uuid@9.0.8': {} @@ -5001,6 +5272,8 @@ snapshots: dependencies: sprintf-js: 1.0.3 + argparse@2.0.1: {} + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -5018,10 +5291,46 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + auto-bind@5.0.1: {} + b4a@1.8.1: {} + balanced-match@1.0.2: {} + bare-events@2.9.1: {} + + bare-fs@4.7.2: + dependencies: + bare-events: 2.9.1 + bare-path: 3.0.1 + bare-stream: 2.13.1(bare-events@2.9.1) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.1: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.9.1): + dependencies: + streamx: 2.26.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.1 + baseline-browser-mapping@2.10.0: {} better-path-resolve@1.0.0: @@ -5152,6 +5461,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@13.1.0: {} commander@4.1.1: {} @@ -5217,6 +5530,8 @@ snapshots: dependencies: is-descriptor: 1.0.3 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -5257,6 +5572,10 @@ snapshots: empathic@1.1.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -5276,6 +5595,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + es-toolkit@1.44.0: {} esbuild-plugin-alias@0.2.1: {} @@ -5325,6 +5651,12 @@ snapshots: dependencies: '@types/estree': 1.0.7 + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + expect-type@1.2.1: {} extendable-error@0.1.7: {} @@ -5341,6 +5673,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5387,6 +5721,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -5476,6 +5818,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -5488,6 +5834,8 @@ snapshots: dependencies: lru-cache: 10.4.3 + hpagent@1.2.0: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.14.1 @@ -5583,6 +5931,8 @@ snapshots: - bufferutil - utf-8-validate + ip-address@10.2.0: {} + is-accessor-descriptor@1.0.1: dependencies: hasown: 2.0.2 @@ -5646,6 +5996,10 @@ snapshots: isexe@3.1.1: {} + isomorphic-ws@5.0.0(ws@8.19.0): + dependencies: + ws: 8.19.0 + jackspeak@2.3.6: dependencies: '@isaacs/cliui': 8.0.2 @@ -5660,6 +6014,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -5669,6 +6025,10 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + jsdom@28.1.0: dependencies: '@acemir/cssom': 0.9.31 @@ -5696,6 +6056,8 @@ snapshots: - '@noble/hashes' - supports-color + jsep@1.4.0: {} + json-schema-to-zod@2.6.1: {} json-schema-traverse@1.0.0: {} @@ -5714,6 +6076,12 @@ snapshots: jsonify@0.0.1: {} + jsonpath-plus@10.4.0: + dependencies: + '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) + '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) + jsep: 1.4.0 + kind-of@3.2.2: dependencies: is-buffer: 1.1.6 @@ -5777,6 +6145,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-fn@2.1.0: {} minimatch@5.1.6: @@ -5856,6 +6230,10 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + npm-package-arg@11.0.3: dependencies: hosted-git-info: 7.0.2 @@ -5869,6 +6247,8 @@ snapshots: npm@10.9.3: {} + oauth4webapi@3.8.6: {} + object-assign@4.1.1: {} object-keys@1.1.1: {} @@ -5877,10 +6257,19 @@ snapshots: object-treeify@4.0.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 + openid-client@6.8.4: + dependencies: + jose: 6.2.3 + oauth4webapi: 3.8.6 + os-tmpdir@1.0.2: {} outdent@0.5.0: {} @@ -6018,6 +6407,11 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -6074,6 +6468,8 @@ snapshots: reusify@1.1.0: {} + rfc4648@1.5.4: {} + rollup@4.35.0: dependencies: '@types/estree': 1.0.6 @@ -6218,6 +6614,21 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.1(supports-color@8.1.1) + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6248,6 +6659,17 @@ snapshots: std-env@3.8.1: {} + stream-buffers@3.0.3: {} + + streamx@2.26.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.5.1: {} string-width@4.2.3: @@ -6325,10 +6747,46 @@ snapshots: tagged-tag@1.0.0: {} + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.2 + bare-path: 3.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.2 + fast-fifo: 1.3.2 + streamx: 2.26.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.26.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + term-size@2.2.1: {} terminal-size@4.0.1: {} + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -6379,6 +6837,8 @@ snapshots: dependencies: tldts: 7.0.23 + tr46@0.0.3: {} + tr46@1.0.1: dependencies: punycode: 2.3.1 @@ -6461,6 +6921,8 @@ snapshots: undici-types@6.20.0: {} + undici-types@7.16.0: {} + undici@7.22.0: {} unicorn-magic@0.1.0: {} @@ -6568,6 +7030,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + webidl-conversions@3.0.1: {} + webidl-conversions@4.0.2: {} webidl-conversions@8.0.1: {} @@ -6582,6 +7046,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -6640,6 +7109,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.0 + wrappy@1.0.2: {} + ws@8.19.0: {} xml-name-validator@5.0.0: {} diff --git a/src/eso-refresher/__tests__/index.test.ts b/src/eso-refresher/__tests__/index.test.ts new file mode 100644 index 0000000..be3eb7b --- /dev/null +++ b/src/eso-refresher/__tests__/index.test.ts @@ -0,0 +1,140 @@ +import { ConfigBootstrapError } from '@/container/errors'; +import type { TokenProvider } from '@/platform/TokenProvider'; +/** + * SMOODEV-1523 — ESO bearer-token refresher unit tests. + * + * Exercises the refresh loop without a live cluster or auth server by injecting + * a fake `TokenProvider` and `SecretWriter` and a controllable scheduler. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runEsoRefresher, type SecretWriter } from '../index'; + +function fakeTokenProvider(tokens: string[]): { tp: TokenProvider; invalidations: () => number; calls: () => number } { + let i = 0; + let invalidations = 0; + let calls = 0; + const tp = { + getAccessToken: vi.fn(async () => { + calls++; + // Hand out a new token each call so we can assert freshness. + return tokens[Math.min(i++, tokens.length - 1)]; + }), + invalidate: vi.fn(() => { + invalidations++; + }), + } as unknown as TokenProvider; + return { tp, invalidations: () => invalidations, calls: () => calls }; +} + +function recordingWriter(): { writer: SecretWriter; written: string[]; fail: (n: number) => void } { + const written: string[] = []; + let failOnCall = -1; + let call = 0; + const writer: SecretWriter = { + patchBearerToken: vi.fn(async (token: string) => { + call++; + if (call === failOnCall) throw new Error('simulated k8s patch failure'); + written.push(token); + }), + }; + return { writer, written, fail: (n) => (failOnCall = n) }; +} + +/** Captures the scheduled tick fn so tests can drive it deterministically. */ +function manualScheduler(): { + scheduler: (fn: () => void, ms: number) => { clear: () => void }; + tick: () => void; + cleared: () => boolean; + intervalMs: () => number; +} { + let captured: (() => void) | undefined; + let cleared = false; + let intervalMs = 0; + return { + scheduler: (fn, ms) => { + captured = fn; + intervalMs = ms; + return { clear: () => (cleared = true) }; + }, + tick: () => captured?.(), + cleared: () => cleared, + intervalMs: () => intervalMs, + }; +} + +describe('runEsoRefresher (SMOODEV-1523)', () => { + beforeEach(() => vi.clearAllMocks()); + + it('mints and writes the bearer token at startup (fail-loud initial sync)', async () => { + const { tp } = fakeTokenProvider(['tok-1']); + const { writer, written } = recordingWriter(); + const sched = manualScheduler(); + + await runEsoRefresher({ tokenProvider: tp, secretWriter: writer, scheduler: sched.scheduler }); + + expect(written).toEqual(['tok-1']); + }); + + it('throws ConfigBootstrapError when client credentials are missing and no provider injected', async () => { + const prevId = process.env.SMOOAI_CONFIG_CLIENT_ID; + const prevSecret = process.env.SMOOAI_CONFIG_CLIENT_SECRET; + const prevApiKey = process.env.SMOOAI_CONFIG_API_KEY; + delete process.env.SMOOAI_CONFIG_CLIENT_ID; + delete process.env.SMOOAI_CONFIG_CLIENT_SECRET; + delete process.env.SMOOAI_CONFIG_API_KEY; + try { + await expect(runEsoRefresher({ secretWriter: recordingWriter().writer })).rejects.toBeInstanceOf(ConfigBootstrapError); + } finally { + if (prevId !== undefined) process.env.SMOOAI_CONFIG_CLIENT_ID = prevId; + if (prevSecret !== undefined) process.env.SMOOAI_CONFIG_CLIENT_SECRET = prevSecret; + if (prevApiKey !== undefined) process.env.SMOOAI_CONFIG_API_KEY = prevApiKey; + } + }); + + it('forces a fresh token each cycle (invalidate before every mint)', async () => { + const { tp, invalidations, calls } = fakeTokenProvider(['tok-1', 'tok-2', 'tok-3']); + const { writer, written } = recordingWriter(); + const sched = manualScheduler(); + + await runEsoRefresher({ tokenProvider: tp, secretWriter: writer, scheduler: sched.scheduler }); + sched.tick(); + await vi.waitFor(() => expect(written).toHaveLength(2)); + + // Startup + one tick = two mints, each preceded by an invalidate. + expect(calls()).toBe(2); + expect(invalidations()).toBe(2); + expect(written).toEqual(['tok-1', 'tok-2']); + }); + + it('survives a tick failure and keeps refreshing on later ticks', async () => { + const { tp } = fakeTokenProvider(['tok-1', 'tok-2', 'tok-3']); + const { writer, written, fail } = recordingWriter(); + const sched = manualScheduler(); + fail(2); // second patch (first scheduled tick) throws + + await runEsoRefresher({ tokenProvider: tp, secretWriter: writer, scheduler: sched.scheduler }); + sched.tick(); // fails internally, must not throw out of the loop + sched.tick(); // recovers + await vi.waitFor(() => expect(written).toEqual(['tok-1', 'tok-3'])); + }); + + it('stop() clears the scheduled loop', async () => { + const { tp } = fakeTokenProvider(['tok-1']); + const { writer } = recordingWriter(); + const sched = manualScheduler(); + + const handle = await runEsoRefresher({ tokenProvider: tp, secretWriter: writer, scheduler: sched.scheduler }); + expect(sched.cleared()).toBe(false); + handle.stop(); + expect(sched.cleared()).toBe(true); + }); + + it('honors an explicit interval override', async () => { + const { tp } = fakeTokenProvider(['tok-1']); + const { writer } = recordingWriter(); + const sched = manualScheduler(); + + await runEsoRefresher({ tokenProvider: tp, secretWriter: writer, scheduler: sched.scheduler, intervalMs: 12345 }); + expect(sched.intervalMs()).toBe(12345); + }); +}); diff --git a/src/eso-refresher/index.ts b/src/eso-refresher/index.ts new file mode 100644 index 0000000..47e26e3 --- /dev/null +++ b/src/eso-refresher/index.ts @@ -0,0 +1,250 @@ +import { ConfigBootstrapError } from '@/container/errors'; +import { TokenProvider } from '@/platform/TokenProvider'; +/** + * `@smooai/config/eso-refresher` — ExternalSecrets Operator (ESO) bearer-token + * refresher (SMOODEV-1523, epic SMOODEV-1522). + * + * ## Why this exists + * + * ESO's `webhook` provider authenticates to the @smooai/config HTTP API with a + * **static** bearer token read from a Kubernetes Secret + * (`external-secrets/smooai-config-bootstrap`, key `bearer-token`). But the + * config API issues short-lived OAuth2 `client_credentials` JWTs (~1h TTL). + * A static token therefore goes stale within the hour and every ESO sync + * silently starts 401-ing — which is exactly why workload secrets were + * Pulumi-baked at SST deploy time instead (SMOODEV-1347), coupling every + * secret-value change to a ~1h platform deploy. + * + * This sidecar closes that gap: it re-mints a fresh access token on a short + * interval (well under the JWT TTL) using the **same** `TokenProvider` the + * runtime SDK uses, and writes it into the bootstrap Secret. ESO then always + * reads a fresh bearer, so a `th config set …` becomes live on ESO's next + * `refreshInterval` + a `kubectl rollout restart` — no platform deploy. + * + * ## Env contract (mirrors container mode §1, minus orgId/env) + * + * SMOOAI_CONFIG_AUTH_URL OAuth issuer base URL (default https://auth.smoo.ai). + * SMOOAI_CONFIG_CLIENT_ID (required) M2M OAuth client id (config-read scoped). + * SMOOAI_CONFIG_CLIENT_SECRET (required) M2M OAuth client secret + * (legacy alias SMOOAI_CONFIG_API_KEY accepted). + * + * SMOOAI_ESO_SECRET_NAMESPACE Namespace of the bootstrap Secret (default `external-secrets`). + * SMOOAI_ESO_SECRET_NAME Bootstrap Secret name (default `smooai-config-bootstrap`). + * SMOOAI_ESO_SECRET_KEY Data key to write the bearer into (default `bearer-token`). + * SMOOAI_ESO_REFRESH_INTERVAL_SECONDS How often to re-mint + write (default 900 = 15m). + * + * orgId/env are NOT needed here — they are query params ESO supplies when it + * calls the config API; the token itself is org-agnostic. + * + * Fail-loud: the initial mint+write runs synchronously at startup and throws on + * failure so the pod crash-loops visibly rather than running blind. Subsequent + * loop failures are logged and retried on the next tick (the existing Secret is + * still valid for the remainder of its TTL), never silently swallowed. + */ +import { CoreV1Api, KubeConfig, PatchStrategy, setHeaderOptions } from '@kubernetes/client-node'; +import Logger from '@smooai/logger/Logger'; + +const logger = new Logger({ name: '@smooai/config/eso-refresher' }); + +function readEnv(name: string): string | undefined { + if (typeof process !== 'undefined' && process.env) return process.env[name]; + return undefined; +} + +/** Blank-aware presence check — a set-but-empty var counts as missing. */ +function nonBlank(v: string | undefined): string | undefined { + if (v === undefined) return undefined; + return v.trim().length > 0 ? v : undefined; +} + +export const ESO_REFRESHER_DEFAULTS = { + namespace: 'external-secrets', + secretName: 'smooai-config-bootstrap', + secretKey: 'bearer-token', + intervalSeconds: 900, +} as const; + +/** + * Writes the freshly-minted bearer token into the target Kubernetes Secret. + * Abstracted behind an interface so the refresh loop can be unit-tested + * without a live cluster (inject a fake writer). + */ +export interface SecretWriter { + /** Patch the configured Secret's data key with `token` (writer base64-encodes). */ + patchBearerToken(token: string): Promise; +} + +/** + * Default {@link SecretWriter} backed by `@kubernetes/client-node`. Uses an + * in-cluster KubeConfig (falls back to the local kubeconfig for dev) and a + * JSON merge-patch so only the one data key is touched — the Secret's other + * keys (if any) are left intact. + */ +export class K8sSecretWriter implements SecretWriter { + private readonly core: CoreV1Api; + + constructor( + private readonly namespace: string, + private readonly secretName: string, + private readonly secretKey: string, + core?: CoreV1Api, + ) { + if (core) { + this.core = core; + } else { + const kc = new KubeConfig(); + // In-cluster when the ServiceAccount token is mounted; otherwise + // the developer's local kubeconfig (handy for dry-runs). + kc.loadFromDefault(); + this.core = kc.makeApiClient(CoreV1Api); + } + } + + async patchBearerToken(token: string): Promise { + const base64 = Buffer.from(token, 'utf8').toString('base64'); + await this.core.patchNamespacedSecret( + { + name: this.secretName, + namespace: this.namespace, + body: { data: { [this.secretKey]: base64 } }, + // fieldManager — identifies us as the owner of this field. + fieldManager: 'smooai-config-eso-refresher', + }, + setHeaderOptions('Content-Type', PatchStrategy.MergePatch), + ); + } +} + +export interface EsoRefresherOptions { + /** OAuth issuer base URL. Falls back to `SMOOAI_CONFIG_AUTH_URL`, then `https://auth.smoo.ai`. */ + authUrl?: string; + /** M2M OAuth client id. Falls back to `SMOOAI_CONFIG_CLIENT_ID`. */ + clientId?: string; + /** M2M OAuth client secret. Falls back to `SMOOAI_CONFIG_CLIENT_SECRET`, then `SMOOAI_CONFIG_API_KEY`. */ + clientSecret?: string; + /** Target Secret namespace. Falls back to `SMOOAI_ESO_SECRET_NAMESPACE` / default. */ + namespace?: string; + /** Target Secret name. Falls back to `SMOOAI_ESO_SECRET_NAME` / default. */ + secretName?: string; + /** Target Secret data key. Falls back to `SMOOAI_ESO_SECRET_KEY` / default. */ + secretKey?: string; + /** Re-mint + write interval in ms. Falls back to `SMOOAI_ESO_REFRESH_INTERVAL_SECONDS` / default. */ + intervalMs?: number; + /** Test/embedding seam — inject a pre-built `TokenProvider`. */ + tokenProvider?: TokenProvider; + /** Test/embedding seam — inject a `SecretWriter` (skips k8s client construction). */ + secretWriter?: SecretWriter; + /** Test seam — override the scheduler (default `setInterval`). */ + scheduler?: (fn: () => void, ms: number) => { clear: () => void }; +} + +export interface EsoRefresherHandle { + /** Force an immediate re-mint + write (also used by tests). */ + refreshNow: () => Promise; + /** Stop the refresh loop. Idempotent. */ + stop: () => void; +} + +function defaultScheduler(fn: () => void, ms: number): { clear: () => void } { + const t = setInterval(fn, ms); + // Don't keep the event loop alive solely for the timer in tests/CLI teardown. + if (typeof t.unref === 'function') t.unref(); + return { clear: () => clearInterval(t) }; +} + +/** + * Start the ESO bearer-token refresher. + * + * Performs an initial mint+write **synchronously** (awaited) so startup fails + * loud on misconfiguration, then schedules periodic refreshes. Returns a handle + * to force a refresh or stop the loop. + */ +export async function runEsoRefresher(options: EsoRefresherOptions = {}): Promise { + const authUrl = nonBlank(options.authUrl) ?? nonBlank(readEnv('SMOOAI_CONFIG_AUTH_URL')) ?? 'https://auth.smoo.ai'; + const clientId = nonBlank(options.clientId) ?? nonBlank(readEnv('SMOOAI_CONFIG_CLIENT_ID')); + const clientSecret = nonBlank(options.clientSecret) ?? nonBlank(readEnv('SMOOAI_CONFIG_CLIENT_SECRET')) ?? nonBlank(readEnv('SMOOAI_CONFIG_API_KEY')); + + // Validate required env up front with the same fail-loud contract as container mode. + if (!options.tokenProvider) { + const missing: string[] = []; + if (!clientId) missing.push('SMOOAI_CONFIG_CLIENT_ID'); + if (!clientSecret) missing.push('SMOOAI_CONFIG_CLIENT_SECRET'); + if (missing.length > 0) throw new ConfigBootstrapError(missing); + } + + const namespace = nonBlank(options.namespace) ?? nonBlank(readEnv('SMOOAI_ESO_SECRET_NAMESPACE')) ?? ESO_REFRESHER_DEFAULTS.namespace; + const secretName = nonBlank(options.secretName) ?? nonBlank(readEnv('SMOOAI_ESO_SECRET_NAME')) ?? ESO_REFRESHER_DEFAULTS.secretName; + const secretKey = nonBlank(options.secretKey) ?? nonBlank(readEnv('SMOOAI_ESO_SECRET_KEY')) ?? ESO_REFRESHER_DEFAULTS.secretKey; + + const intervalSecondsEnv = Number(nonBlank(readEnv('SMOOAI_ESO_REFRESH_INTERVAL_SECONDS')) ?? ''); + const intervalMs = + options.intervalMs ?? + (Number.isFinite(intervalSecondsEnv) && intervalSecondsEnv > 0 ? intervalSecondsEnv * 1000 : ESO_REFRESHER_DEFAULTS.intervalSeconds * 1000); + + const tokenProvider = + options.tokenProvider ?? + new TokenProvider({ + authUrl, + clientId: clientId!, + clientSecret: clientSecret!, + }); + + const writer = options.secretWriter ?? new K8sSecretWriter(namespace, secretName, secretKey); + const schedule = options.scheduler ?? defaultScheduler; + + const refreshNow = async (): Promise => { + // Force a brand-new token each cycle so the Secret always holds one with + // (close to) a full TTL ahead — ESO must never read a token about to expire. + tokenProvider.invalidate(); + const token = await tokenProvider.getAccessToken(); + await writer.patchBearerToken(token); + logger.info('Refreshed ESO bootstrap bearer token', { namespace, secretName, secretKey }); + }; + + // Initial mint+write — fail-loud (caller exits non-zero → visible crash-loop). + await refreshNow(); + + const handle = schedule(() => { + refreshNow().catch((err) => { + // Loop failures are non-fatal: the current Secret token is still + // valid for the rest of its TTL. Log and retry next tick. + logger.error('ESO bearer refresh tick failed (will retry next interval)', err as Error, { namespace, secretName }); + }); + }, intervalMs); + + let stopped = false; + return { + refreshNow, + stop: () => { + if (stopped) return; + stopped = true; + handle.clear(); + }, + }; +} + +/** + * CLI/container entrypoint. Starts the refresher, wires graceful shutdown, and + * keeps the process alive. Exits non-zero if the initial mint+write fails. + */ +export async function main(): Promise { + let handle: EsoRefresherHandle; + try { + handle = await runEsoRefresher(); + } catch (err) { + logger.error('ESO refresher failed to start', err as Error); + process.exitCode = 1; + return; + } + + const shutdown = (signal: string) => { + logger.info(`Received ${signal}, stopping ESO refresher`); + handle.stop(); + process.exit(0); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + // Keep alive — the interval timer is unref'd, so hold the loop open explicitly. + await new Promise(() => {}); +} diff --git a/src/eso-refresher/run.ts b/src/eso-refresher/run.ts new file mode 100644 index 0000000..8e80b55 --- /dev/null +++ b/src/eso-refresher/run.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +/** + * Standalone entrypoint for the ESO bearer-token refresher (SMOODEV-1523). + * + * Built to `dist/eso-refresher/run.mjs` and exposed as the + * `smooai-config-eso-refresher` bin. This is the process the sidecar container + * runs; all behavior + the env contract live in `./index`. + */ +import { main } from './index'; + +void main(); diff --git a/tsup.config.ts b/tsup.config.ts index c584c21..fe461cf 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -21,6 +21,8 @@ const serverEntry = [ 'src/server/sync-worker.ts', 'src/container/index.ts', 'src/container/errors.ts', + 'src/eso-refresher/index.ts', + 'src/eso-refresher/run.ts', 'src/platform/client.ts', 'src/platform/build.ts', 'src/nextjs/index.ts',