diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61c5044ff2..a3e9920d98 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -244,6 +244,8 @@ jobs: run: npm config set workspaces-update false - name: Snapshot workflow-executor tags (pre-release) run: git tag --list '@forestadmin/workflow-executor@*' | sort > /tmp/we-tags-before.txt + - name: Snapshot agent-bff tags (pre-release) + run: git tag --list '@forestadmin/agent-bff@*' | sort > /tmp/bff-tags-before.txt - name: "Run multi-semantic-release" run: "$(yarn bin)/multi-semantic-release --deps.bump=override" env: @@ -282,6 +284,30 @@ jobs: echo "workflow-executor@${VERSION} released — dispatching docker-publish.yml." gh workflow run docker-publish.yml --ref "$GITHUB_REF_NAME" -f version="$VERSION" + # Same reasoning as the workflow-executor dispatch above: the release commit + # carries `[skip ci]`, so the tag push cannot build the image itself. + - name: Trigger agent-bff Docker image publish (if released) + if: ${{ !cancelled() }} + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + if [ ! -f /tmp/bff-tags-before.txt ]; then + echo "Pre-release snapshot missing — release step never ran; nothing to publish." + exit 0 + fi + git tag --list '@forestadmin/agent-bff@*' | sort > /tmp/bff-tags-after.txt + NEW_TAGS=$(comm -13 /tmp/bff-tags-before.txt /tmp/bff-tags-after.txt) + if [ -z "$NEW_TAGS" ]; then + echo "No new @forestadmin/agent-bff tag in this run — nothing to publish." + exit 0 + fi + # If more than one appears, pick the highest so :latest never regresses. + VERSION=$(echo "$NEW_TAGS" \ + | sed 's|^@forestadmin/agent-bff@||' \ + | sort -V | tail -n1) + echo "agent-bff@${VERSION} released — dispatching docker-publish-bff.yml." + gh workflow run docker-publish-bff.yml --ref "$GITHUB_REF_NAME" -f version="$VERSION" + publish-api-reference: name: Publish API Reference runs-on: ubuntu-latest diff --git a/.github/workflows/docker-publish-bff.yml b/.github/workflows/docker-publish-bff.yml new file mode 100644 index 0000000000..c83e297641 --- /dev/null +++ b/.github/workflows/docker-publish-bff.yml @@ -0,0 +1,324 @@ +name: Publish agent-bff Docker image + +on: + # Releases publish via workflow_dispatch from build.yml's `release` job, not a + # tag-push trigger — the release commit's `[skip ci]` would suppress it. + pull_request: + # Validate on any change to the BFF or to a dependency package's manifest — a + # dep bump in one of the 4 source packages can drift the image's dedicated + # lockfile (see packages/agent-bff/docker/). + paths: + - 'packages/agent-bff/**' + - 'packages/agent-client/package.json' + - 'packages/agent-toolkit/package.json' + - 'packages/datasource-toolkit/package.json' + - 'packages/forestadmin-client/package.json' + # The builder runs `yarn install --frozen-lockfile` from the repo root, so a + # lockfile-only change can alter the build (build.yml installs without + # --frozen-lockfile and wouldn't catch an inconsistency). + - 'yarn.lock' + - '.github/workflows/docker-publish-bff.yml' + workflow_dispatch: + inputs: + version: + description: 'Version tag to publish (e.g. 1.20.2)' + required: true + +permissions: + contents: read + packages: write + +concurrency: + # Include the dispatch version so two manual runs for different versions from the + # same branch don't cancel each other mid-release. Two versions therefore CAN publish + # concurrently and finish out of order; the merge job re-decides latest-stable against + # the tags as they are then, so the finishing order cannot regress the mutable tags. + group: docker-publish-bff-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || '' }} + cancel-in-progress: true + +jobs: + # On PRs: build the image (no push) to catch Dockerfile breakage before release. + validate: + name: Validate Dockerfile build + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Check @forestadmin dependency closure + run: node packages/agent-bff/docker/check-deps-closure.js + + - uses: docker/setup-buildx-action@v3 + - name: Build (no push) + uses: docker/build-push-action@v6 + with: + context: . + file: packages/agent-bff/Dockerfile + platforms: linux/amd64 + push: false + load: true + tags: agent-bff:pr + cache-from: type=gha,scope=bff-amd64 + + # The build only proves the image compiles. Run it to catch entrypoint + # breakage, a missing module (e.g. an un-copied @forestadmin package), a + # missing Redoc bundle, or a startup crash — none of which a build-only step + # would surface. + - name: Smoke test (CLI + module graph + boot) + run: sh packages/agent-bff/docker/smoke-test.sh agent-bff:pr + + # Gate by ORIGIN, not severity. OS packages can only be fixed here, so they + # BLOCK; the BFF's npm deps are already shipped via the package, so blocking + # the image would just desync GHCR from npm — those are report-only (fixed at + # the source). + - name: Scan OS packages (blocking) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: agent-bff:pr + vuln-type: os + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '1' + - name: Scan libraries (report) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: agent-bff:pr + vuln-type: library + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '0' + format: json + output: trivy-libs.json + - name: Report npm deps + run: node packages/agent-bff/docker/scan-gate.js trivy-libs.json + + extract-version: + name: Extract version + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + outputs: + full: ${{ steps.version.outputs.full }} + minor: ${{ steps.version.outputs.minor }} + major: ${{ steps.version.outputs.major }} + # No checkout: splitting a version string needs no repository. Whether this is + # the latest stable version is decided once, in the merge job, against the tags + # as they are at that moment (see there). + steps: + - name: Parse version from input + id: version + # The dispatch input reaches the shell through `env`, never interpolated into + # the script: `${{ }}` is substituted as raw text before bash sees it, so a + # crafted version would run as commands with this job's packages:write token. + # It is then validated as a semver before anything is done with it — the + # `ref:` the build job checks out is built from the same value. + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + VERSION="$INPUT_VERSION" + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::Invalid version input: expected MAJOR.MINOR.PATCH[-prerelease]." + exit 1 + fi + echo "full=$VERSION" >> $GITHUB_OUTPUT + echo "minor=${VERSION%.*}" >> $GITHUB_OUTPUT + echo "major=${VERSION%%.*}" >> $GITHUB_OUTPUT + + build: + name: Build (${{ matrix.arch }}) + if: github.event_name != 'pull_request' + needs: extract-version + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + with: + # Build from the requested version's tag, not the default branch. Taken from + # extract-version rather than the raw input, so only a validated semver ever + # reaches a ref. + ref: ${{ format('@forestadmin/agent-bff@{0}', needs.extract-version.outputs.full) }} + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + # Also run here, not only in the PR job: a dependency change can reach main + # through a path the `paths:` filter above does not cover, and would then hit + # the release build unchecked. The failure mode is a container that dies on + # `Cannot find module`, which no other step in this job would catch. + - name: Check @forestadmin dependency closure + run: node packages/agent-bff/docker/check-deps-closure.js + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Build and LOAD locally first (no push) so we can smoke-test and scan the + # exact image before it reaches the registry. Nothing is published until + # both gates pass — otherwise a vulnerable/broken image would be pullable + # by digest even when the gate "fails". + - name: Build (load for gating) + uses: docker/build-push-action@v6 + with: + context: . + file: packages/agent-bff/Dockerfile + platforms: ${{ matrix.platform }} + load: true + tags: agent-bff:${{ matrix.arch }} + cache-from: type=gha,scope=bff-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=bff-${{ matrix.arch }} + + - name: Smoke test (CLI + module graph + boot) + run: sh packages/agent-bff/docker/smoke-test.sh agent-bff:${{ matrix.arch }} + + # Gate by origin (see the validate job): OS packages block; the BFF's npm + # deps are report-only. + - name: Scan OS packages (blocking) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: agent-bff:${{ matrix.arch }} + vuln-type: os + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '1' + - name: Scan libraries (report) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: agent-bff:${{ matrix.arch }} + vuln-type: library + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '0' + format: json + output: trivy-libs.json + - name: Report npm deps + run: node packages/agent-bff/docker/scan-gate.js trivy-libs.json + + # All gates passed (smoke + OS scan). Publish by digest. + # + # This is a SECOND build, not a push of the image the steps above scanned: the + # docker driver cannot load a push-by-digest result, and push-by-digest is what + # the multi-arch manifest below is assembled from. It is the same context and + # the same Dockerfile, and the gating build wrote every layer to the cache this + # one reads, so buildx reuses them — but that is a cache hit, not a checked + # identity. What the gate really buys is that a build which fails smoke or scan + # never reaches the registry at all. + - name: Push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: packages/agent-bff/Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,name=ghcr.io/forestadmin/agent-bff,push-by-digest=true,name-canonical=true,push=true + sbom: true + cache-from: type=gha,scope=bff-${{ matrix.arch }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.build.outputs.digest }} + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digest-bff-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Publish multi-arch manifest + if: github.event_name != 'pull_request' + needs: [extract-version, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Tags only — this job builds nothing, it re-reads the release history below. + fetch-depth: 0 + + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: digest-bff-* + merge-multiple: true + path: /tmp/digests + + # The mutable tags (:latest, :major, :minor) move only for the highest STABLE + # version of this package. Deciding it HERE rather than at the start of the run + # is what makes it safe: two dispatches for different versions run concurrently + # (their concurrency groups differ by version), and an older one finishing last + # would otherwise move :latest back onto a stale image. + # + # Comparing against stable-only tags covers the other two cases for free — a + # prerelease never matches the pattern, and neither does a rebuild of an older + # version. We look at this package's tags specifically: the repo-wide GitHub + # "latest release" belongs to whichever monorepo package shipped last. + - name: Decide latest-stable against current tags + id: recheck + env: + FULL: ${{ needs.extract-version.outputs.full }} + run: | + PREFIX="@forestadmin/agent-bff@" + LATEST_STABLE=$(git tag --list "${PREFIX}*" \ + | sed "s|^${PREFIX}||" \ + | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V | tail -n1) + if [ "$FULL" = "$LATEST_STABLE" ]; then + echo "is_latest=true" >> $GITHUB_OUTPUT + else + echo "::notice::${FULL} is no longer the latest stable version (${LATEST_STABLE}); leaving the mutable tags alone." + echo "is_latest=false" >> $GITHUB_OUTPUT + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push multi-arch manifest + working-directory: /tmp/digests + env: + IMAGE: ghcr.io/forestadmin/agent-bff + FULL: ${{ needs.extract-version.outputs.full }} + MINOR: ${{ needs.extract-version.outputs.minor }} + MAJOR: ${{ needs.extract-version.outputs.major }} + IS_LATEST: ${{ steps.recheck.outputs.is_latest }} + run: | + # Always publish the immutable full-version tag. The mutable tags + # (minor, major, latest) only move when this is the latest stable + # version, so neither a prerelease nor a rebuild of an older version + # ever overwrites the current stable :latest / :1 images. + TAGS="-t $IMAGE:$FULL" + if [ "$IS_LATEST" = "true" ]; then + TAGS="$TAGS -t $IMAGE:$MINOR -t $IMAGE:$MAJOR -t $IMAGE:latest" + fi + docker buildx imagetools create $TAGS \ + $(printf "$IMAGE@sha256:%s " *) + + - name: Inspect manifest + run: docker buildx imagetools inspect ghcr.io/forestadmin/agent-bff:${{ needs.extract-version.outputs.full }} diff --git a/packages/agent-bff/.env.example b/packages/agent-bff/.env.example index 901c23d4d2..28bf749e78 100644 --- a/packages/agent-bff/.env.example +++ b/packages/agent-bff/.env.example @@ -2,9 +2,17 @@ FOREST_AUTH_SECRET= FOREST_ENV_SECRET= FOREST_SERVER_URL=https://api.forestadmin.com FOREST_APP_URL=https://app.forestadmin.com +# Running under docker compose, swap this for the line below: inside the container +# `localhost` is the container itself, not the host your agent runs on. AGENT_URL=http://localhost:3351 +# AGENT_URL=http://host.docker.internal:3351 BFF_TOKEN_ENCRYPTION_KEY= +# The port the BFF listens on, inside the container as well. `0` (an OS-assigned +# ephemeral port) is valid for a local run but cannot be published by compose. HTTP_PORT=3450 +# Host-side port for docker compose only; the BFF never reads it. Change it to +# publish on a different port without touching HTTP_PORT. +# BFF_HOST_PORT=3450 BFF_ALLOWED_ORIGINS=http://localhost:4200 BFF_DEFAULT_TIMEZONE=Europe/Paris # BFF_OPENAPI_ENABLED=true diff --git a/packages/agent-bff/Dockerfile b/packages/agent-bff/Dockerfile new file mode 100644 index 0000000000..59e009ee4b --- /dev/null +++ b/packages/agent-bff/Dockerfile @@ -0,0 +1,112 @@ +# syntax=docker/dockerfile:1 +# +# Production image for @forestadmin/agent-bff. +# The build context MUST be the monorepo root (yarn workspaces + yarn.lock): +# +# docker build -f packages/agent-bff/Dockerfile -t ghcr.io/forestadmin/agent-bff:latest . +# +# Configuration is passed entirely via environment variables — see README for the full list. + +# Base image pinned by digest for reproducible builds (node:22-bookworm-slim). +# Bump via Renovate/Dependabot. +FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 AS base + +# ── Stage 1: build ──────────────────────────────────────────────────────────── +FROM base AS builder +WORKDIR /app + +# Install from manifests + lockfile only (before the source COPY) so editing +# source doesn't bust the install layer — keeps the install cached on +# source-only changes (CI cache-warm + local rebuilds). --parents preserves the +# packages/*/ layout that yarn workspaces needs. +# --frozen-lockfile guarantees the resolved tree matches yarn.lock exactly. +# --ignore-scripts skips husky, native (node-gyp) builds, and binary downloads +# irrelevant here (sqlite3 is dev-only and never loaded at runtime). +# The second glob covers the workspaces nested one level under a package (today +# only workflow-executor/example): naming them would make this build fail with an +# opaque yarn error the next time someone adds one. +COPY --parents package.json yarn.lock packages/*/package.json packages/*/*/package.json ./ +RUN yarn install --frozen-lockfile --ignore-scripts + +COPY . . + +# Build the BFF and only its 4 workspace dependencies, in topological order. +# `build` also copies the Redoc bundle into dist/docs (see build:copy). +RUN node_modules/.bin/lerna run build \ + --scope @forestadmin/agent-bff \ + --include-dependencies + +# ── Stage 2: isolated prod deps ─────────────────────────────────────────────── +# Install only the image's runtime deps (external deps of the 5 workspace packages) +# into a clean, standalone node_modules. Avoids hoisting deps of the other workspace +# packages and cuts the runtime image substantially. +# +# The manifest is regenerated from the live package.json files; the committed +# yarn.lock pins every (transitive) version. --frozen-lockfile makes a workspace +# dependency change that the lock does not cover fail the build instead of +# silently shipping an unpinned version. +FROM base AS prod-deps +WORKDIR /deps + +COPY packages/agent-bff/docker/build-deps-manifest.js ./build-deps-manifest.js +COPY packages/agent-bff/docker/deps/yarn.lock ./yarn.lock +COPY packages/agent-bff/package.json ./packages/agent-bff/package.json +COPY packages/agent-client/package.json ./packages/agent-client/package.json +COPY packages/agent-toolkit/package.json ./packages/agent-toolkit/package.json +COPY packages/datasource-toolkit/package.json ./packages/datasource-toolkit/package.json +COPY packages/forestadmin-client/package.json ./packages/forestadmin-client/package.json + +RUN node build-deps-manifest.js packages package.json +RUN yarn install --frozen-lockfile --ignore-scripts + +# ── Stage 3: runtime ────────────────────────────────────────────────────────── +FROM base AS runtime +WORKDIR /app +ENV NODE_ENV=production + +# External runtime deps (clean, reproducible install — no monorepo workspace noise). +COPY --from=prod-deps /deps/node_modules ./node_modules + +# @forestadmin/* workspace packages placed directly in node_modules. +# Node resolves them by walking up to /app/node_modules/@forestadmin//, +# where package.json's "main" field points into dist/. +COPY --from=builder /app/packages/agent-client/dist ./node_modules/@forestadmin/agent-client/dist +COPY --from=builder /app/packages/agent-client/package.json ./node_modules/@forestadmin/agent-client/package.json +COPY --from=builder /app/packages/agent-toolkit/dist ./node_modules/@forestadmin/agent-toolkit/dist +COPY --from=builder /app/packages/agent-toolkit/package.json ./node_modules/@forestadmin/agent-toolkit/package.json +COPY --from=builder /app/packages/datasource-toolkit/dist ./node_modules/@forestadmin/datasource-toolkit/dist +COPY --from=builder /app/packages/datasource-toolkit/package.json ./node_modules/@forestadmin/datasource-toolkit/package.json +COPY --from=builder /app/packages/forestadmin-client/dist ./node_modules/@forestadmin/forestadmin-client/dist +COPY --from=builder /app/packages/forestadmin-client/package.json ./node_modules/@forestadmin/forestadmin-client/package.json + +# BFF entry point. dist/docs carries the Redoc bundle the docs page serves. +COPY --from=builder /app/packages/agent-bff/dist ./packages/agent-bff/dist +COPY --from=builder /app/packages/agent-bff/package.json ./packages/agent-bff/package.json + +USER node + +# HTTP server (GET /health, the /agent edge, GET /docs). +# Override with the HTTP_PORT env var — but only with a real port. `0` asks the OS +# for an ephemeral one, which is useful for a local run and unusable here: nothing +# outside the process learns which port it got, so it can be neither published nor +# probed, and the healthcheck below would report the container unhealthy forever. +EXPOSE 3450 + +# Trimmed and defaulted exactly the way `parsePort` does it (src/config/env-config.ts), +# so the probe and the server always agree on the port: ` 8080 ` is a port the server +# binds, and an untrimmed one would build a URL that cannot be requested — reporting a +# perfectly healthy container unhealthy. Blank and whitespace-only fall back to 3450 +# there too, hence the trim BEFORE the default rather than after. +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD node -e "require('http').get('http://localhost:'+(String(process.env.HTTP_PORT||'').trim()||3450)+'/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" + +# OCI image metadata (https://github.com/opencontainers/image-spec). +LABEL org.opencontainers.image.title="forestadmin-agent-bff" \ + org.opencontainers.image.description="Backend-for-frontend for Forest Admin agents." \ + org.opencontainers.image.source="https://github.com/ForestAdmin/agent-nodejs" \ + org.opencontainers.image.licenses="GPL-3.0" + +# Exec form, no wrapper script: the CLI takes positional subcommands (`openapi`), +# so a "is the first argument a flag?" wrapper would misread them as a command to +# exec. Use `--entrypoint` to run anything else in the image. +ENTRYPOINT ["node", "/app/packages/agent-bff/dist/cli.js"] diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index 0b9edf3084..329af01614 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -31,6 +31,61 @@ dropped collection reachable. ## Usage +### Docker (recommended) + +```bash +cp .env.example .env # then fill in the secrets +docker compose up +``` + +The template targets a local, non-containerised run, so one value has to change for Docker: +set `AGENT_URL=http://host.docker.internal:3351`. Left at `localhost`, it resolves to the BFF +container itself and every agent call fails (see the note below). + +The `docker-compose.yml` at the root of this package starts a single BFF instance. See +`.env.example` for the full list of environment variables and their descriptions. + +Or run the image directly: + +```bash +docker run -d \ + -p 3450:3450 \ + --stop-timeout 15 \ + --add-host host.docker.internal:host-gateway \ + -e FOREST_AUTH_SECRET="..." \ + -e FOREST_ENV_SECRET="..." \ + -e FOREST_SERVER_URL="https://api.forestadmin.com" \ + -e FOREST_APP_URL="https://app.forestadmin.com" \ + -e AGENT_URL="http://host.docker.internal:3351" \ + -e BFF_TOKEN_ENCRYPTION_KEY="$(openssl rand -base64 32)" \ + ghcr.io/forestadmin/agent-bff:latest +``` + +> **Note:** When the BFF runs in Docker and your agent runs on the host machine, use +> `host.docker.internal` instead of `localhost` in `AGENT_URL`. Docker Desktop resolves that +> name natively; on Docker Engine for Linux it does not exist unless you map it, hence the +> `--add-host` above (the Compose setup does the same through `extra_hosts`). + +The image's entry point is the CLI, so the subcommands below work the same way: + +```bash +docker run --rm ghcr.io/forestadmin/agent-bff:latest openapi > openapi.json +``` + +Tags follow the npm package: `:latest`, `:1`, `:1.20` and the immutable `:1.20.2`. + +On `SIGTERM` or `SIGINT` the BFF stops accepting connections and gives the requests already in +flight 10 seconds to finish before cutting their sockets, then exits 0. A second signal gives up on +the wait and exits 1. + +Allow for that in your orchestrator's grace period. The whole budget is up to 11 seconds — the 10 +second deadline plus a 1 second fallback for the exit itself — and `docker stop` defaults to 10, +so under load it would SIGKILL exactly when the shutdown is doing its job. Hence `--stop-timeout 15` +above and `stop_grace_period: 15s` in the Compose file; on Kubernetes the default +`terminationGracePeriodSeconds` of 30 already covers it. + +### Without Docker + Packaged / production — run the bin: ```bash @@ -90,7 +145,7 @@ yarn start:dev # node --env-file=.env dist/cli.js | `FOREST_APP_URL` | yes | Forest front base URL, used to build the OAuth front-channel redirect (`src/oauth/oauth-routes.ts`). | | `AGENT_URL` | yes | The customer agent base URL the BFF calls via agent-client. | | `BFF_TOKEN_ENCRYPTION_KEY`| for OAuth | Base64-encoded 32-byte AES-256 key encrypting stored refresh tokens. Until it is set, the `/oauth/*` token-issuance routes are disabled and `/health` reports `degraded`; already-issued `bff_access` tokens still authenticate on `/agent/*` whenever `FOREST_AUTH_SECRET` is present. | -| `HTTP_PORT` | no | Server port, integer 0–65535. Defaults to `3450`. `0` binds an OS-assigned ephemeral port. | +| `HTTP_PORT` | no | Server port, integer 0–65535. Defaults to `3450`. `0` binds an OS-assigned ephemeral port — useful for a local run, unusable in the Docker image, where nothing outside the process learns which port it got: it can be neither published nor probed, and the image's healthcheck would report the container unhealthy forever. | | `BFF_ALLOWED_ORIGINS`| no | Comma-separated CORS allow-list of exact origins (scheme + host + port). No wildcard. Empty ⇒ no cross-origin browser access. | | `BFF_DEFAULT_TIMEZONE`| no | Fallback IANA timezone used when a request carries neither an `X-Forest-Timezone` header nor a body `timezone`. | | `BFF_OPENAPI_ENABLED` | no | Serve `GET/HEAD /agent/openapi.json` (auth-gated) when `true`. Defaults to `true`. Set to `false` for customers who do not want the HTTP surface exposed: an authenticated `GET`/`HEAD` then gets `404 openapi_disabled`, other methods fall through to the agent routes exactly as they do when enabled, and `forest-bff openapi` keeps working either way. Accepted values: `true`/`false`. **The served document is unfolded and is not filtered per caller**: any authenticated caller, whatever their role, reads the name of every exposed collection, relation and field. Set this to `false` if that surface must not be reachable over HTTP. | diff --git a/packages/agent-bff/docker-compose.yml b/packages/agent-bff/docker-compose.yml new file mode 100644 index 0000000000..cb34cef7bd --- /dev/null +++ b/packages/agent-bff/docker-compose.yml @@ -0,0 +1,30 @@ +# Minimal single-instance setup for the agent BFF. +# +# 1. Copy .env.example to .env and fill in your secrets. +# 2. docker compose up +# +# Point your frontend at: http://localhost:3450 + +services: + agent-bff: + image: ghcr.io/forestadmin/agent-bff:latest + restart: unless-stopped + # Forward the whole .env into the container (see .env.example for the full + # list of knobs). Unset optional vars fall back to the image's built-in + # defaults — no whitelist to keep in sync. + env_file: + - .env + # Lets AGENT_URL reach host services via host.docker.internal on Linux too + # (resolves natively on Docker Desktop, no-op there). + extra_hosts: + - "host.docker.internal:host-gateway" + # Host side and container side are separate knobs: BFF_HOST_PORT remaps the + # published port without touching what the BFF listens on. HTTP_PORT=0 (an + # ephemeral port the app picks at boot) is a valid app value but cannot be + # published — there is no target port to write here — so leave it set. + ports: + - "${BFF_HOST_PORT:-3450}:${HTTP_PORT:-3450}" + # Longer than the BFF's own 10s grace period for in-flight requests, so the + # process gets to finish its shutdown instead of being SIGKILLed mid-way. + stop_grace_period: 15s + # Healthcheck is inherited from the image's HEALTHCHECK (see Dockerfile). diff --git a/packages/agent-bff/docker/README.md b/packages/agent-bff/docker/README.md new file mode 100644 index 0000000000..eeebe0c696 --- /dev/null +++ b/packages/agent-bff/docker/README.md @@ -0,0 +1,56 @@ +# Docker image build assets + +The production image installs its runtime dependencies into an **isolated** +`node_modules` (rather than shipping the whole monorepo's hoisted tree). This +keeps the image small while staying reproducible. + +## How it works + +- [`build-deps-manifest.js`](./build-deps-manifest.js) merges the external + (non-`@forestadmin`) runtime dependencies of the BFF and its 4 workspace + dependencies into a single `package.json`. +- [`deps/yarn.lock`](./deps/) pins every transitive version. +- The Docker build regenerates the manifest from the live `package.json` files + and runs `yarn install --frozen-lockfile`. If a workspace dependency changes + in a way the committed lock does not cover, **the build fails** instead of + silently shipping an unpinned version. +- [`check-deps-closure.js`](./check-deps-closure.js) recomputes the real + transitive `@forestadmin` closure of the BFF and fails when the hardcoded list + or the Dockerfile `COPY` lines drift from it. +- [`smoke-test.sh`](./smoke-test.sh) runs a locally-loaded image before it is + published: CLI surface, module graph, Redoc bundle, boot and `/health`. + +## Running the smoke test + +```bash +sh packages/agent-bff/docker/smoke-test.sh +``` + +It needs `docker`, `curl`, `openssl` and **`python3`** on the host. The last one +serves a two-line stub of the Forest server so the second boot can be fully +configured and reach `/health` 200 — the path the image's own HEALTHCHECK +demands, which nothing else exercises. All four are present on the GitHub +runners; on a slim box python3 is the one likely to be missing. + +## Updating the lockfile + +Only `deps/yarn.lock` is committed — the manifest is generated on demand (the +Docker build regenerates it too), so there is no stale `package.json` to drift. +Run this whenever a runtime dependency of one of the 5 workspace packages +changes (the build will fail with `--frozen-lockfile` until you do): + +The generated manifest carries the repo's pinned `packageManager` +(`yarn@1.22.19`), so with Corepack enabled (`corepack enable`) the refresh uses +the same Yarn as the Docker build — a global Yarn 4 would otherwise emit an +incompatible lockfile format. + +```bash +# from the monorepo root (Corepack enabled) +TMP=$(mktemp -d) +node packages/agent-bff/docker/build-deps-manifest.js packages "$TMP/package.json" +( cd "$TMP" && yarn install --ignore-scripts ) +cp "$TMP/yarn.lock" packages/agent-bff/docker/deps/yarn.lock +rm -rf "$TMP" +``` + +Then commit the updated `deps/yarn.lock`. diff --git a/packages/agent-bff/docker/build-deps-manifest.js b/packages/agent-bff/docker/build-deps-manifest.js new file mode 100644 index 0000000000..be363fdefc --- /dev/null +++ b/packages/agent-bff/docker/build-deps-manifest.js @@ -0,0 +1,105 @@ +// Generates the package.json for the Docker image's isolated runtime deps. +// +// It merges the external (non-@forestadmin) runtime dependencies of the BFF and +// its 4 workspace dependencies into a single manifest. +// +// The output is deterministic (sorted keys). A committed yarn.lock sits next to +// the generated manifest; the Docker build regenerates the manifest and runs +// `yarn install --frozen-lockfile`, so any workspace dependency change that the +// lock does not cover fails the build instead of silently drifting. +// +// Usage: node build-deps-manifest.js +// directory containing the workspace packages (e.g. "packages") +// path to write the merged package.json + +const fs = require('fs'); +const path = require('path'); + +const WORKSPACE_PACKAGES = [ + 'agent-bff', + 'agent-client', + 'agent-toolkit', + 'datasource-toolkit', + 'forestadmin-client', +]; + +// Security pins for transitive deps whose parents never ship a patched range. +// They mirror the monorepo root's `resolutions` for the packages that actually +// appear in this closure — the isolated install does not inherit the root ones. +const RESOLUTIONS = { + // superagent pins qs ^6.x (GHSA-hrpp-h998-j3pp prototype pollution). + '**/qs': '>=6.15.2', + // jsonapi-serializer pins lodash ^4.17.x. + '**/lodash': '^4.18.0', +}; + +function generate(packagesDir, outFile) { + // A flat install holds one version per dependency, so two packages asking for + // different ranges is a decision, not something to let iteration order settle. + // It has never happened here — refusing keeps it that way, and keeps the answer + // out of this script. + const deps = {}; + const declaredBy = {}; + + for (const pkg of WORKSPACE_PACKAGES) { + const manifestPath = path.join(packagesDir, pkg, 'package.json'); + const { dependencies = {} } = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + for (const [name, range] of Object.entries(dependencies)) { + if (name.startsWith('@forestadmin/')) continue; + + if (deps[name] !== undefined && deps[name] !== range) { + throw new Error( + `Conflicting ranges for "${name}": ${deps[name]} (${declaredBy[name]}) and ` + + `${range} (${pkg}). Align the version in the source packages.`, + ); + } + + deps[name] = range; + declaredBy[name] = pkg; + } + } + + const sorted = Object.fromEntries(Object.keys(deps).sort().map(key => [key, deps[key]])); + + const manifest = { name: 'agent-bff-docker-deps', private: true }; + + // Carry the monorepo's pinned package manager so a manual lockfile refresh + // (yarn install on this generated manifest) uses the same Yarn via Corepack, + // not a contributor's global Yarn 4 which would emit an incompatible lockfile. + // Resolved from the repo root relative to this script; absent in the Docker + // build (root package.json isn't copied there) — harmless, the image uses its + // own bundled Yarn 1.x. + const packageManager = rootPackageManager(); + if (packageManager) manifest.packageManager = packageManager; + + manifest.dependencies = sorted; + manifest.resolutions = RESOLUTIONS; + + fs.writeFileSync(outFile, `${JSON.stringify(manifest, null, 2)}\n`); +} + +function rootPackageManager() { + try { + const root = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', '..', '..', 'package.json'), 'utf8'), + ); + + return root.packageManager; + } catch { + return undefined; + } +} + +if (require.main === module) { + const [, , packagesDir, outFile] = process.argv; + + if (!packagesDir || !outFile) { + console.error('Usage: node build-deps-manifest.js '); + process.exit(1); + } + + generate(packagesDir, outFile); +} + +module.exports = { WORKSPACE_PACKAGES, generate }; diff --git a/packages/agent-bff/docker/check-deps-closure.js b/packages/agent-bff/docker/check-deps-closure.js new file mode 100644 index 0000000000..00420d8d21 --- /dev/null +++ b/packages/agent-bff/docker/check-deps-closure.js @@ -0,0 +1,96 @@ +// Guard-rail against silent drift in the Docker image's @forestadmin closure. +// +// The image's runtime deps are assembled from a HARDCODED set in two places: +// - WORKSPACE_PACKAGES in build-deps-manifest.js (gathers their external deps) +// - the `COPY --from=builder .../dist` lines in the Dockerfile (ships their build) +// +// If the BFF gains/loses an @forestadmin/* dependency, both must change or the +// runtime image breaks at startup (Cannot find module) — invisible to a build-only CI. +// This check recomputes the real transitive @forestadmin closure of agent-bff +// and fails if the hardcoded list or the Dockerfile COPYs don't match it. +// +// Usage: node check-deps-closure.js (exits non-zero on drift) + +const fs = require('fs'); +const path = require('path'); +const { WORKSPACE_PACKAGES } = require('./build-deps-manifest'); + +const PACKAGES_DIR = path.resolve(__dirname, '../..'); +const DOCKERFILE = path.resolve(__dirname, '../Dockerfile'); +const ROOT_PACKAGE = '@forestadmin/agent-bff'; + +// Map every @forestadmin/* package name to its directory under packages/. +const nameToDir = {}; +for (const dir of fs.readdirSync(PACKAGES_DIR)) { + const manifestPath = path.join(PACKAGES_DIR, dir, 'package.json'); + if (!fs.existsSync(manifestPath)) continue; + const { name } = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (name) nameToDir[name] = dir; +} + +// BFS the transitive @forestadmin closure (including the root itself). +const closure = new Set(); +const queue = [ROOT_PACKAGE]; +while (queue.length) { + const name = queue.shift(); + const dir = nameToDir[name]; + + // An @forestadmin package that lives outside this monorepo would fall through every + // mechanism the image has: the walk cannot reach its dependencies, build-deps-manifest + // skips the whole @forestadmin/ prefix when gathering external deps, and there is no + // dist to COPY. It would be absent from the image entirely, so refuse rather than + // report a clean closure. + if (!dir) { + console.error(`Unknown @forestadmin dependency "${name}": no package under packages/ declares it.`); + console.error('The Docker image can only ship workspace packages — vendor it or add it to the monorepo.'); + process.exit(1); + } + + if (closure.has(dir)) continue; + closure.add(dir); + const { dependencies = {} } = JSON.parse( + fs.readFileSync(path.join(PACKAGES_DIR, dir, 'package.json'), 'utf8'), + ); + for (const dep of Object.keys(dependencies)) { + if (dep.startsWith('@forestadmin/')) queue.push(dep); + } +} + +const actual = [...closure].sort(); +const declared = [...WORKSPACE_PACKAGES].sort(); +const dockerfile = fs.readFileSync(DOCKERFILE, 'utf8'); + +const errors = []; + +const missingFromList = actual.filter(p => !declared.includes(p)); +const extraInList = declared.filter(p => !actual.includes(p)); +if (missingFromList.length) errors.push(`WORKSPACE_PACKAGES is missing: ${missingFromList.join(', ')}`); +if (extraInList.length) errors.push(`WORKSPACE_PACKAGES has stale entries: ${extraInList.join(', ')}`); + +// Only active COPY instructions count — a path that survives in a comment or in prose +// would otherwise pass the check while shipping nothing. +const copied = dockerfile + .split('\n') + .filter(line => /^\s*COPY\s/.test(line)) + .join('\n'); + +// Every closure package is copied out of the builder, and BOTH halves are required: +// without the dist there is no code, and without the package.json there is no "main" +// for Node to resolve the package by. The dependencies land in node_modules and the BFF +// in packages/agent-bff/, but they are all copied FROM the same builder paths. +for (const pkg of actual) { + for (const file of ['dist', 'package.json']) { + if (!copied.includes(`/app/packages/${pkg}/${file}`)) { + errors.push(`Dockerfile is missing a COPY for packages/${pkg}/${file}`); + } + } +} + +if (errors.length) { + console.error('@forestadmin dependency closure drift detected:\n - ' + errors.join('\n - ')); + console.error(`\nActual closure: ${actual.join(', ')}`); + console.error('Update WORKSPACE_PACKAGES (build-deps-manifest.js) and the Dockerfile COPY lines to match.'); + process.exit(1); +} + +console.log(`@forestadmin closure OK (${actual.length} packages): ${actual.join(', ')}`); diff --git a/packages/agent-bff/docker/deps/yarn.lock b/packages/agent-bff/docker/deps/yarn.lock new file mode 100644 index 0000000000..c2cf2a1b3a --- /dev/null +++ b/packages/agent-bff/docker/deps/yarn.lock @@ -0,0 +1,831 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@asteasolutions/zod-to-openapi@8.5.0": + version "8.5.0" + resolved "https://registry.yarnpkg.com/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz#f073822daad87c4ab2ae991ee86b1a5070ac9942" + integrity sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q== + dependencies: + openapi3-ts "^4.1.2" + +"@hapi/bourne@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" + integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== + +"@koa/bodyparser@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@koa/bodyparser/-/bodyparser-6.1.0.tgz#8e001da5eedac39ef68ebcefc57f706e9a29423e" + integrity sha512-thVG/Utbz9+dB4Nl8EBJKoaOI1DzO74dJqeDFILbAVhUq6C+4rmmrKFwxiDE63ScywOs0CHypx1IUrSRMueFTw== + dependencies: + "@types/co-body" "^6.1.3" + co-body "^6.2.0" + lodash.merge "^4.6.2" + type-is "^2.0.1" + +"@noble/hashes@^1.1.5": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@paralleldrive/cuid2@^2.2.2": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz#3d62ea9e7be867d3fa94b9897fab5b0ae187d784" + integrity sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw== + dependencies: + "@noble/hashes" "^1.1.5" + +"@types/co-body@^6.1.3": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.3.tgz#201796c6389066b400cfcb4e1ec5c3db798265a2" + integrity sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + +"@types/node@*": + version "26.3.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.3.0.tgz#757c33b17fe06db5a356582e9658603a1bd80caf" + integrity sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw== + dependencies: + undici-types "~8.3.0" + +"@types/qs@*": + version "6.15.1" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.15.1.tgz#8606884272c63f0db96986bd3548650d8a9388bf" + integrity sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw== + +accepts@^1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + +bytes@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +co-body@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.2.0.tgz#afd776d60e5659f4eee862df83499698eb1aea1b" + integrity sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA== + dependencies: + "@hapi/bourne" "^3.0.0" + inflation "^2.0.0" + qs "^6.5.2" + raw-body "^2.3.3" + type-is "^1.6.16" + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +component-emitter@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" + integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + +content-disposition@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.1.tgz#a8b7bbeb2904befdfb6787e5c0c086959f605f9b" + integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q== + +content-type@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +content-type@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.1.0.tgz#d9389c43c0a8cf6a355db464d21e07092a40493a" + integrity sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag== + +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + +cookies@~0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.9.1.tgz#3ffed6f60bb4fb5f146feeedba50acc418af67e3" + integrity sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw== + dependencies: + depd "~2.0.0" + keygrip "~1.1.0" + +debug@^4.3.7: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deep-equal@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw== + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + +depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +encodeurl@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +escape-html@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +eventsource@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" + integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== + +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + +form-data@^4.0.5: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + +formidable@^3.5.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.4.tgz#ac9a593b951e829b3298f21aa9a2243932f32ed9" + integrity sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug== + dependencies: + "@paralleldrive/cuid2" "^2.2.2" + dezalgo "^1.0.4" + once "^1.4.0" + +fresh@~0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2, hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + +http-assert@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f" + integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w== + dependencies: + deep-equal "~1.0.1" + http-errors "~1.8.0" + +http-errors@^2.0.0, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +http-errors@~1.8.0: + version "1.8.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.1" + +iconv-lite@~0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +inflation@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.1.0.tgz#9214db11a47e6f756d111c4f9df96971c60f886c" + integrity sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ== + +inflected@^1.1.6: + version "1.1.7" + resolved "https://registry.yarnpkg.com/inflected/-/inflected-1.1.7.tgz#c393df6e28472d0d77b3082ec3aa2091f4bc96f9" + integrity sha512-3lz7idKIPmKvz0wqlu1PUPSg5strJnCh2v2NldPQy13Fmd6WsWQ5yExDoiIX48lQ9mo8N7ztdDlkZxOauZ/E5g== + +inherits@2.0.4, inherits@~2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +jose@^4.15.9: + version "4.15.9" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.9.tgz#9b68eda29e9a0614c042fa29387196c7dd800100" + integrity sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA== + +json-api-serializer@^2.6.6: + version "2.7.0" + resolved "https://registry.yarnpkg.com/json-api-serializer/-/json-api-serializer-2.7.0.tgz#ca2f36c714ffd1522fb6daa473cb085646b3b8ae" + integrity sha512-Q21X9pIBo53RiJfOat9xlhAOM7aYsgo5Pw+FlSA33Col5pQ3OdAHUwHuZcDGA/lbCrF8FvKDK0g+6GCGxLdhlg== + dependencies: + setimmediate "^1.0.5" + +jsonapi-serializer@^3.6.9: + version "3.6.9" + resolved "https://registry.yarnpkg.com/jsonapi-serializer/-/jsonapi-serializer-3.6.9.tgz#a2ea0b53a24cf4bb7659232406ed8caa2423e9b2" + integrity sha512-LeRPlP93Mz6+Klu13OKcnXNLvtH1gbeo/yfThqihAMw7vUBCWWs6jHImpR/tQwzAxJi7F1+bfVJxeHoNCrbZiQ== + dependencies: + inflected "^1.1.6" + lodash "^4.16.3" + +jsonwebtoken@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + +keygrip@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" + integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ== + dependencies: + tsscmp "1.0.6" + +koa-compose@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877" + integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw== + +koa@^3.0.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/koa/-/koa-3.2.1.tgz#c99b0c17469418ad5672d3f5832a5610cd6500e4" + integrity sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA== + dependencies: + accepts "^1.3.8" + content-disposition "~1.0.1" + content-type "^1.0.5" + cookies "~0.9.1" + delegates "^1.0.0" + destroy "^1.2.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + fresh "~0.5.2" + http-assert "^1.5.0" + http-errors "^2.0.0" + koa-compose "^4.1.0" + mime-types "^3.0.1" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + +lodash@^4.16.3, lodash@^4.18.0: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +luxon@^3.2.1: + version "3.7.2" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.7.2.tgz#d697e48f478553cca187a0f8436aff468e3ba0ba" + integrity sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +media-typer@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.1.tgz#6f035400dfe3ab9d5607bc77546ce30cc2f9c6b8" + integrity sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ== + +methods@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + +mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime-types@^3.0.0, mime-types@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" + +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +object-hash@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" + integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== + +object-hash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +oidc-token-hash@^5.0.3: + version "5.2.0" + resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz#be8a8885c7e2478d21a674e15afa31f1bcc4a61f" + integrity sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw== + +on-finished@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +openapi3-ts@^4.1.2: + version "4.6.1" + resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-4.6.1.tgz#aaabcab1cf1d17cf754fb49f3041f3cf808e1f38" + integrity sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA== + dependencies: + yaml "^2.9.0" + +openid-client@^5.7.1: + version "5.7.1" + resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.7.1.tgz#34cace862a3e6472ed7d0a8616ef73b7fb85a9c3" + integrity sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew== + dependencies: + jose "^4.15.9" + lru-cache "^6.0.0" + object-hash "^2.2.0" + oidc-token-hash "^5.0.3" + +parseurl@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +qs@>=6.15.2, qs@^6.14.1, qs@^6.5.2: + version "6.15.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" + integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== + dependencies: + es-define-property "^1.0.1" + side-channel "^1.1.1" + +raw-body@^2.3.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" + integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + unpipe "~1.0.0" + +safe-buffer@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +semver@^7.5.4: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + +setprototypeof@1.2.0, setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +"statuses@>= 1.5.0 < 2": + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +statuses@^2.0.1, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +superagent@^10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-10.3.0.tgz#ff1e39e7976b63f8084291d65f5bfbbbbd156989" + integrity sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ== + dependencies: + component-emitter "^1.3.1" + cookiejar "^2.1.4" + debug "^4.3.7" + fast-safe-stringify "^2.1.1" + form-data "^4.0.5" + formidable "^3.5.4" + methods "^1.1.2" + mime "2.6.0" + qs "^6.14.1" + +toidentifier@1.0.1, toidentifier@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + +type-is@^1.6.16: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +type-is@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" + integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== + dependencies: + content-type "^2.0.0" + media-typer "^1.1.0" + mime-types "^3.0.0" + +undici-types@~8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" + integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +uuid@11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad" + integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ== + +vary@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== + +zod@4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== diff --git a/packages/agent-bff/docker/scan-gate.js b/packages/agent-bff/docker/scan-gate.js new file mode 100644 index 0000000000..8adef8a0e7 --- /dev/null +++ b/packages/agent-bff/docker/scan-gate.js @@ -0,0 +1,48 @@ +// Reports the image's library (npm) vulnerabilities. +// +// Trivy splits findings into OS packages (gated natively in the workflow, blocking — +// only fixable by bumping the base image here) and libraries. The libraries here are +// the BFF's npm dependencies, already shipped to npm consumers — blocking the image +// would desync GHCR from npm without removing the vuln, which is fixed at the source +// via a dependency bump — plus the npm CLI the base image bundles, fixed by bumping +// that base image. Neither is fixable in this Dockerfile → REPORT only. +// +// The image ships no Docker-only npm dependency today. The day it does (APM), that +// dependency exists ONLY here and can only be fixed here — it must then BLOCK, the +// way the workflow-executor image gates its @opentelemetry packages. +// +// Usage: node scan-gate.js + +const fs = require('fs'); + +const file = process.argv[2]; +if (!file) { + console.error('usage: node scan-gate.js '); + process.exit(1); +} + +const report = JSON.parse(fs.readFileSync(file, 'utf8')); +const findings = (report.Results || []) + .flatMap(r => r.Vulnerabilities || []) + .map(v => ({ + id: v.VulnerabilityID, + pkg: v.PkgName, + severity: v.Severity, + installed: v.InstalledVersion, + fixed: v.FixedVersion || '(none)', + })); + +const lines = ['## Image dependency scan (CRITICAL,HIGH, fixable)', '']; +if (findings.length === 0) { + lines.push('No library vulnerabilities found.'); +} else { + lines.push('| Package | Severity | ID | Installed | Fixed |', '|---|---|---|---|---|'); + for (const f of findings) { + lines.push(`| ${f.pkg} | ${f.severity} | ${f.id} | ${f.installed} | ${f.fixed} |`); + } +} +const summary = lines.join('\n'); +console.log(summary); +if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + +console.log('\nEvery library finding above is report-only — this step gates nothing today.'); diff --git a/packages/agent-bff/docker/smoke-test.sh b/packages/agent-bff/docker/smoke-test.sh new file mode 100755 index 0000000000..c84c3b3d17 --- /dev/null +++ b/packages/agent-bff/docker/smoke-test.sh @@ -0,0 +1,173 @@ +#!/bin/sh +# Smoke-test a built agent-bff image: prove the entrypoint works, the full module +# graph loads (cli.js eagerly imports cli-core -> every @forestadmin + external +# dep), the Redoc bundle shipped, and the server boots and answers. +# Run against a locally-loaded image before it is published. +# +# Usage: smoke-test.sh +set -eu + +IMAGE="${1:?usage: smoke-test.sh }" +CLI=/app/packages/agent-bff/dist/cli.js +PORT=13450 + +# Entrypoint + CLI surface. Each output is captured before it is matched rather than +# piped into `grep -q`, which closes the pipe on its first hit and leaves the +# container writing to a broken one. +docker run --rm "$IMAGE" --version +docker run --rm "$IMAGE" --help > /tmp/bff-help.txt +grep -q "Usage: forest-bff" /tmp/bff-help.txt + +# Module graph: requiring the entry point pulls cli-core and, through it, every +# @forestadmin package and external dependency. A missing module fails here. +# (`require.main !== module` under -e, so nothing is dispatched.) +docker run --rm --entrypoint node "$IMAGE" -e "require('$CLI')" + +# The Redoc viewer is served from a bundle copied at build time (`build:copy`). +# A dist without it silently degrades the docs page to a 404. +docker run --rm --entrypoint node "$IMAGE" \ + -e "require('fs').accessSync('/app/packages/agent-bff/dist/docs/redoc.standalone.js')" + +# The openapi command runs with no configuration at all and must emit a document. +docker run --rm "$IMAGE" openapi > /tmp/bff-openapi.json +grep -q '"openapi"' /tmp/bff-openapi.json + +# Boot with everything the agent edge needs EXCEPT the token encryption key: the +# whole middleware chain (permissions, data, action, OpenAPI, docs) is built and +# nothing reaches the network, whereas a fully configured boot would fetch the +# environment id from FOREST_SERVER_URL and die on an unreachable host. +# /health therefore reports `degraded` — the point is that it answers at all. +CONTAINER=$(docker run -d -p "127.0.0.1:$PORT:3450" \ + -e FOREST_AUTH_SECRET=smoke-test \ + -e FOREST_ENV_SECRET="$(openssl rand -hex 32)" \ + -e FOREST_SERVER_URL=http://127.0.0.1:1 \ + -e FOREST_APP_URL=http://127.0.0.1:1 \ + -e AGENT_URL=http://127.0.0.1:1 \ + "$IMAGE") +trap 'docker logs "$CONTAINER" 2>&1 || true; docker rm -f "$CONTAINER" >/dev/null 2>&1 || true' EXIT + +status="" +attempt=0 +while [ "$attempt" -lt 30 ]; do + status=$(curl -s -o /tmp/bff-health.json -w '%{http_code}' "http://127.0.0.1:$PORT/health" || true) + [ "$status" != "000" ] && [ -n "$status" ] && break + attempt=$((attempt + 1)) + sleep 1 +done + +logs=$(docker logs "$CONTAINER" 2>&1) + +if echo "$logs" | grep -qiE "Cannot find module|MODULE_NOT_FOUND"; then + echo "::error::module resolution failure in the image" + exit 1 +fi +if ! echo "$logs" | grep -q "Forest BFF started"; then + echo "::error::the BFF did not reach startup — boot failure" + exit 1 +fi +if [ "$status" != "503" ]; then + echo "::error::/health answered '$status', expected 503 (degraded: no BFF_TOKEN_ENCRYPTION_KEY)" + cat /tmp/bff-health.json 2>/dev/null || true + exit 1 +fi +if ! grep -q '"status":"degraded"' /tmp/bff-health.json; then + echo "::error::/health body is not the degraded payload" + cat /tmp/bff-health.json + exit 1 +fi + +# The viewer and its bundle are public routes. The bundle proves the asset is served +# and not merely present on disk; the page itself is what a user actually opens, and +# it is rendered separately. +for path in /docs /docs/redoc.standalone.js; do + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT$path") + if [ "$code" != "200" ]; then + echo "::error::$path answered '$code', expected 200" + exit 1 + fi +done + +docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +trap - EXIT + +# Second boot, fully configured, to reach /health 200. Nothing else proves that path: +# the image's own HEALTHCHECK demands a 200, so a regression making `hasAllRequired` +# always false would leave every published container permanently unhealthy with CI +# still green. +# +# A complete configuration makes the BFF fetch its environment id from +# FOREST_SERVER_URL at boot and die if that host is unreachable, so a stub answers +# /liana/environment. It is served over host-gateway rather than from a second +# container, which keeps this to curl, openssl and python3. +STUB_PORT=13451 +STUB_DIR=$(mktemp -d) +mkdir -p "$STUB_DIR/liana" +printf '{"data":{"id":1}}' > "$STUB_DIR/liana/environment" +# Bound to every interface, not just loopback: host-gateway resolves to the docker +# bridge address on Linux, so a stub listening only on 127.0.0.1 is unreachable from +# the container. It serves one static file for the length of this script. +# +# --directory rather than a `cd` subshell: `$!` must be python's own pid, or the +# kill below reaps the subshell and leaves the server holding the port. +python3 -m http.server "$STUB_PORT" --bind 0.0.0.0 --directory "$STUB_DIR" >/dev/null 2>&1 & +STUB_PID=$! + +CONTAINER="" +cleanup() { + [ -n "$CONTAINER" ] && docker logs "$CONTAINER" 2>&1 || true + [ -n "$CONTAINER" ] && docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + # `wait` reaps the stub inside this redirect, so the shell does not report the + # terminated job on its own after the script has already printed its result. + kill "$STUB_PID" 2>/dev/null || true + wait "$STUB_PID" 2>/dev/null || true + rm -rf "$STUB_DIR" +} +trap cleanup EXIT + +stub_up="" +attempt=0 +while [ "$attempt" -lt 15 ]; do + stub_up=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$STUB_PORT/liana/environment" || true) + [ "$stub_up" = "200" ] && break + attempt=$((attempt + 1)) + sleep 1 +done +if [ "$stub_up" != "200" ]; then + echo "::error::the Forest server stub did not come up on $STUB_PORT (answered '$stub_up')" + exit 1 +fi + +rm -f /tmp/bff-health-ok.json + +CONTAINER=$(docker run -d -p "127.0.0.1:$PORT:3450" \ + --add-host "smoke-host:host-gateway" \ + -e FOREST_AUTH_SECRET=smoke-test \ + -e FOREST_ENV_SECRET="$(openssl rand -hex 32)" \ + -e FOREST_SERVER_URL="http://smoke-host:$STUB_PORT" \ + -e FOREST_APP_URL=http://127.0.0.1:1 \ + -e AGENT_URL=http://127.0.0.1:1 \ + -e BFF_TOKEN_ENCRYPTION_KEY="$(openssl rand -base64 32)" \ + "$IMAGE") + +status="" +attempt=0 +while [ "$attempt" -lt 30 ]; do + status=$(curl -s -o /tmp/bff-health-ok.json -w '%{http_code}' "http://127.0.0.1:$PORT/health" || true) + [ "$status" = "200" ] && break + attempt=$((attempt + 1)) + sleep 1 +done + +if [ "$status" != "200" ]; then + echo "::error::/health answered '$status' with a complete configuration, expected 200" + cat /tmp/bff-health-ok.json 2>/dev/null || true + docker logs "$CONTAINER" 2>&1 || true + exit 1 +fi +if ! grep -q '"status":"ok"' /tmp/bff-health-ok.json; then + echo "::error::/health body is not the ok payload" + cat /tmp/bff-health-ok.json + exit 1 +fi + +echo "smoke test passed for $IMAGE" diff --git a/packages/agent-bff/src/cli.ts b/packages/agent-bff/src/cli.ts index 5ba6f0d4dd..cd669ab847 100644 --- a/packages/agent-bff/src/cli.ts +++ b/packages/agent-bff/src/cli.ts @@ -1,11 +1,16 @@ #!/usr/bin/env node /* istanbul ignore file */ +import createConsoleLogger from './adapters/console-logger'; import { reportFatalError } from './cli-core'; import dispatchCli from './cli-dispatch'; +import armShutdown from './shutdown'; if (require.main === module) { dispatchCli(process.argv.slice(2), process.env) - .then(({ exitCode }) => { + .then(({ exitCode, server }) => { + // Only the server command has anything to shut down; `openapi` and the flags + // have already finished by the time they return. + if (server) armShutdown({ server, logger: createConsoleLogger() }); if (exitCode !== 0) process.exitCode = exitCode; }) .catch(reportFatalError); diff --git a/packages/agent-bff/src/http/bff-http-server.ts b/packages/agent-bff/src/http/bff-http-server.ts index 7f6a086121..e2d01c94a1 100644 --- a/packages/agent-bff/src/http/bff-http-server.ts +++ b/packages/agent-bff/src/http/bff-http-server.ts @@ -87,15 +87,30 @@ export default class BFFHttpServer { }); } - async stop(): Promise { + /** + * `close` stops accepting and drops idle keep-alive sockets, but it then waits for the requests + * still in flight — and a request that never completes would hold shutdown open forever. Passing + * `graceMs` bounds that wait: once it elapses the remaining sockets are cut and `close` returns. + * Without it the caller has no way to bound its own shutdown. + */ + async stop(graceMs?: number): Promise { return new Promise((resolve, reject) => { - if (!this.server) { + const { server } = this; + + if (!server) { resolve(); return; } - this.server.close(err => { + const timer = + graceMs === undefined + ? undefined + : setTimeout(() => server.closeAllConnections(), graceMs).unref(); + + server.close(err => { + if (timer) clearTimeout(timer); + if (err) { reject(err); } else { diff --git a/packages/agent-bff/src/shutdown.ts b/packages/agent-bff/src/shutdown.ts new file mode 100644 index 0000000000..53194dc6a7 --- /dev/null +++ b/packages/agent-bff/src/shutdown.ts @@ -0,0 +1,99 @@ +import type { Logger } from './ports/logger-port'; + +/** Just enough of `BFFHttpServer` to shut it down, so a test needs no real socket. */ +export interface StoppableServer { + stop(graceMs?: number): Promise; +} + +export interface ShutdownOptions { + server: StoppableServer; + logger?: Logger; + /** How long in-flight requests get before their sockets are cut. */ + graceMs?: number; + /** Signal registration, as a seam: a test must not arm a handler on the real process. */ + onSignal?: (signal: NodeJS.Signals, handler: () => void) => void; + exit?: (code: number) => void; +} + +export const SIGNALS: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; +export const DEFAULT_GRACE_MS = 10_000; +export const FORCE_EXIT_MS = 1_000; + +/** + * Ends the process without `process.exit`, which would discard whatever is still buffered on stdout + * — and stdout is a pipe under Docker, so writes to it are asynchronous. Exiting outright drops the + * shutdown log lines, in the one place an operator goes looking for them. + * + * Setting the code and letting the loop drain flushes them. The fallback timer is unref'd on + * purpose: it does not hold the process open by itself, so a clean drain still exits immediately, + * and it only fires if some other handle is keeping the loop alive — which is the case where the + * process would otherwise never end. + */ +function defaultExit(code: number): void { + process.exitCode = code; + setTimeout(() => process.exit(code), FORCE_EXIT_MS).unref(); +} + +/** + * Stops the server on a termination signal, then exits. + * + * The exit has to be explicit. In the Docker image node runs as PID 1, and the kernel gives PID 1 no + * default disposition for a signal it has no handler for — so a container with no handler at all is + * never terminated by `docker stop`, it waits out the grace period and is SIGKILLed, dropping every + * in-flight request. Registering a handler and then falling through to "the default action" does not + * work either, for the same reason: nothing would end the process. + * + * A second signal gives up on the grace period. Someone pressing Ctrl-C twice, or an orchestrator + * escalating, is asking to stop waiting, and the exit code says the shutdown did not complete. + */ +export default function armShutdown(options: ShutdownOptions): void { + const { + server, + logger, + graceMs = DEFAULT_GRACE_MS, + onSignal = (signal, handler) => { + process.on(signal, handler); + }, + exit = defaultExit, + } = options; + + let stopping = false; + // The escalation has already reported failure and set the exit code. A `stop()` that finishes + // afterwards must stay quiet: announcing a clean stop and exiting 0 would overwrite it and + // report success for a shutdown someone had to interrupt. + let interrupted = false; + + const handle = (signal: NodeJS.Signals) => () => { + if (stopping) { + interrupted = true; + logger?.('Warn', 'Second termination signal, giving up on the grace period', { signal }); + exit(1); + + return; + } + + stopping = true; + logger?.('Info', 'Shutting down', { signal, graceMs }); + + server + .stop(graceMs) + .then(() => { + if (interrupted) return; + + logger?.('Info', 'Forest BFF stopped'); + exit(0); + }) + .catch(() => { + if (interrupted) return; + + // The server failed to close cleanly. Nothing left to salvage, and staying alive would + // hold the container open until it is killed — report it through the exit code instead. + logger?.('Error', 'Shutdown failed, exiting anyway', { signal }); + exit(1); + }); + }; + + for (const signal of SIGNALS) { + onSignal(signal, handle(signal)); + } +} diff --git a/packages/agent-bff/test/http/bff-http-server.test.ts b/packages/agent-bff/test/http/bff-http-server.test.ts index ef4073d4aa..ff0e164c7f 100644 --- a/packages/agent-bff/test/http/bff-http-server.test.ts +++ b/packages/agent-bff/test/http/bff-http-server.test.ts @@ -208,6 +208,44 @@ describe('BFFHttpServer', () => { }); }); + describe('when stopping a running server', () => { + it('should cut the remaining sockets once the grace period elapses', async () => { + jest.useFakeTimers(); + const server = createServer({ ...VALID_ENV }); + await server.start(); + + const internal = (server as unknown as { server: Server }).server; + const closeAll = jest.spyOn(internal, 'closeAllConnections'); + jest.spyOn(internal, 'close').mockImplementation((() => internal) as Server['close']); + + void server.stop(5_000); + jest.advanceTimersByTime(5_000); + + expect(closeAll).toHaveBeenCalledTimes(1); + + jest.restoreAllMocks(); + jest.useRealTimers(); + await closeServer(internal); + }); + + it('should not arm a deadline when no grace period is given', async () => { + jest.useFakeTimers(); + const server = createServer({ ...VALID_ENV }); + await server.start(); + + const internal = (server as unknown as { server: Server }).server; + const closeAll = jest.spyOn(internal, 'closeAllConnections'); + + await server.stop(); + jest.advanceTimersByTime(60_000); + + expect(closeAll).not.toHaveBeenCalled(); + + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + }); + describe('when the underlying server fails to close', () => { it('should reject with the close error', async () => { const server = createServer({ ...VALID_ENV }); diff --git a/packages/agent-bff/test/http/healthcheck-port.test.ts b/packages/agent-bff/test/http/healthcheck-port.test.ts new file mode 100644 index 0000000000..6220e21109 --- /dev/null +++ b/packages/agent-bff/test/http/healthcheck-port.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'fs'; +import path from 'path'; + +import { parseConfig } from '../../src/config/env-config'; + +/** + * The image's HEALTHCHECK builds its URL from HTTP_PORT itself, so it has to read the variable the + * same way the server does — a probe on a different port than the one bound reports a perfectly + * healthy container unhealthy, and nothing in the image would say why. + * + * The expression is lifted out of the Dockerfile rather than restated here, so editing one without + * the other fails this test instead of shipping. + */ +const DOCKERFILE = path.resolve(__dirname, '../../Dockerfile'); +const PORT_EXPRESSION = /'http:\/\/localhost:'\s*\+\s*\((.+?)\)\s*\+\s*'\/health'/; + +function probePortExpression(): string { + const match = readFileSync(DOCKERFILE, 'utf8').match(PORT_EXPRESSION); + + if (!match) { + throw new Error( + 'Could not find the healthcheck port expression in the Dockerfile. If the HEALTHCHECK was ' + + 'rewritten, update PORT_EXPRESSION here so this stays a guard rather than a passing test.', + ); + } + + return match[1]; +} + +function probePort(raw?: string): string { + const env = raw === undefined ? {} : { HTTP_PORT: raw }; + // The expression comes from a file in this repository, not from input — evaluating it is the + // whole point: restating it here is what would let the two drift apart unnoticed. + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + const evaluate = new Function('process', `return ${probePortExpression()};`) as ( + fake: unknown, + ) => string | number; + + return String(evaluate({ env })); +} + +describe('healthcheck port', () => { + it('should be readable out of the Dockerfile', () => { + expect(probePortExpression()).toContain('HTTP_PORT'); + }); + + it.each([ + ['unset', undefined], + ['empty', ''], + ['whitespace only', ' '], + ['a plain port', '8080'], + ['a padded port', ' 8080 '], + ['the default spelled out', '3450'], + ['zero', '0'], + ])('should agree with the server on %s', (_label, raw) => { + const env = (raw === undefined ? {} : { HTTP_PORT: raw }) as NodeJS.ProcessEnv; + + expect(probePort(raw)).toBe(String(parseConfig(env).httpPort)); + }); +}); diff --git a/packages/agent-bff/test/shutdown.test.ts b/packages/agent-bff/test/shutdown.test.ts new file mode 100644 index 0000000000..eca0bdb9d3 --- /dev/null +++ b/packages/agent-bff/test/shutdown.test.ts @@ -0,0 +1,185 @@ +import type { Logger } from '../src/ports/logger-port'; + +import armShutdown, { DEFAULT_GRACE_MS, FORCE_EXIT_MS } from '../src/shutdown'; + +const flush = () => + new Promise(resolve => { + setImmediate(resolve); + }); + +describe('armShutdown', () => { + let stop: jest.Mock; + let exit: jest.Mock; + let logger: jest.MockedFunction; + let handlers: Record void>; + let onSignal: jest.Mock; + + const arm = (graceMs?: number) => + armShutdown({ server: { stop }, logger, onSignal, exit, graceMs }); + + beforeEach(() => { + stop = jest.fn().mockResolvedValue(undefined); + exit = jest.fn(); + logger = jest.fn(); + handlers = {}; + onSignal = jest.fn((signal: string, handler: () => void) => { + handlers[signal] = handler; + }); + }); + + it('should listen for both termination signals', () => { + arm(); + + expect(onSignal.mock.calls.map(([signal]) => signal)).toEqual(['SIGTERM', 'SIGINT']); + }); + + describe('on the first signal', () => { + it('should stop the server with the grace period, then exit 0', async () => { + arm(500); + + handlers.SIGTERM(); + await flush(); + + expect(stop).toHaveBeenCalledWith(500); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('should default the grace period rather than wait forever', async () => { + arm(); + + handlers.SIGINT(); + await flush(); + + expect(stop).toHaveBeenCalledWith(DEFAULT_GRACE_MS); + }); + + // Node gives PID 1 no default disposition for an unhandled signal, so a handler that + // does not exit explicitly leaves the container running until it is SIGKILLed. + it('should exit even when the server fails to close', async () => { + stop.mockRejectedValue(new Error('close failed')); + arm(); + + handlers.SIGTERM(); + await flush(); + + expect(exit).toHaveBeenCalledWith(1); + expect(logger).toHaveBeenCalledWith('Error', 'Shutdown failed, exiting anyway', { + signal: 'SIGTERM', + }); + }); + }); + + describe('on a second signal', () => { + it('should exit 1 instead of stopping the server again', async () => { + let release: () => void = () => undefined; + stop.mockReturnValue( + new Promise(resolve => { + release = resolve; + }), + ); + arm(); + + handlers.SIGTERM(); + handlers.SIGINT(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + + release(); + await flush(); + }); + + it('should not let the interrupted stop report success once it finishes', async () => { + let release: () => void = () => undefined; + stop.mockReturnValue( + new Promise(resolve => { + release = resolve; + }), + ); + arm(); + + handlers.SIGTERM(); + handlers.SIGINT(); + release(); + await flush(); + + expect(exit).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + expect(logger).not.toHaveBeenCalledWith('Info', 'Forest BFF stopped'); + }); + + it('should stay quiet when the interrupted stop fails too', async () => { + let fail: (error: Error) => void = () => undefined; + stop.mockReturnValue( + new Promise((_, reject) => { + fail = reject; + }), + ); + arm(); + + handlers.SIGTERM(); + handlers.SIGINT(); + fail(new Error('close failed')); + await flush(); + + expect(exit).toHaveBeenCalledTimes(1); + expect(logger).not.toHaveBeenCalledWith( + 'Error', + 'Shutdown failed, exiting anyway', + expect.anything(), + ); + }); + }); + + describe('with the signal seam left to its default', () => { + it('should register both handlers on the real process', () => { + const on = jest.spyOn(process, 'on').mockReturnValue(process); + + armShutdown({ server: { stop }, exit }); + + expect(on).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(on).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + jest.restoreAllMocks(); + }); + }); + + describe('with the exit seam left to its default', () => { + // process.exit would discard whatever is still buffered on stdout, and stdout is a pipe + // under Docker — the shutdown lines would never reach `docker logs`. + it('should set the exit code and let the loop drain rather than exit outright', async () => { + jest.useFakeTimers(); + const hardExit = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const previous = process.exitCode; + + armShutdown({ server: { stop }, onSignal }); + handlers.SIGTERM(); + await Promise.resolve(); + await Promise.resolve(); + + expect(process.exitCode).toBe(0); + expect(hardExit).not.toHaveBeenCalled(); + + process.exitCode = previous; + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('should force the exit when something else keeps the loop alive', async () => { + jest.useFakeTimers(); + const hardExit = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const previous = process.exitCode; + + armShutdown({ server: { stop }, onSignal }); + handlers.SIGTERM(); + await Promise.resolve(); + await Promise.resolve(); + jest.advanceTimersByTime(FORCE_EXIT_MS); + + expect(hardExit).toHaveBeenCalledWith(0); + + process.exitCode = previous; + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + }); +});