diff --git a/.env.example b/.env.example index 9eb74bf..588d684 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,9 @@ HEALTHCHECK_SECRET= REDIS_URL=redis://localhost:6395 JOBS_INPROCESS= JOBS_HEALTH_PORT= +# dev:stack uses separate loopback health ports for its two background processes. +SWI_DEV_WORKER_PORT=3460 +SWI_DEV_BEAT_PORT=3461 QUEUE_OUTBOX_CONCURRENCY= QUEUE_SCHEDULED_CONCURRENCY= @@ -53,16 +56,17 @@ S3_FORCE_PATH_STYLE=1 UPLOAD_MAX_BYTES= # --- Email (bootstrap fallback; /admin settings override it) --- -# Without SMTP in development, emails are written to .data/mail/. -EMAIL_FROM= -SMTP_HOST= -SMTP_PORT= -SMTP_SECURE= +# Local Mailpit captures mail and never delivers it externally. Replace these +# local defaults with your authenticated TLS SMTP relay in production. +EMAIL_FROM="Space Works Inventory " +SMTP_HOST=127.0.0.1 +SMTP_PORT=1025 +SMTP_SECURE=false SMTP_SERVICE= SMTP_USER= SMTP_PASSWORD= -# Local Mailpit only (docker compose): SMTP_HOST=localhost SMTP_PORT=1025. -SMTP_ALLOW_UNAUTHENTICATED= +# Local Mailpit only; do not enable for a public SMTP relay. +SMTP_ALLOW_UNAUTHENTICATED=1 OUTBOX_RETENTION_DAYS= # --- Outbound webhooks --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 444ffcd..bdd983f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,13 @@ jobs: env: TEST_DATABASE_URL: postgresql://swi:swi_ci@localhost:5432/swi_ci_test TEST_QUEUE_REDIS_URL: redis://127.0.0.1:6379/0 + # Exercise the same private S3 backend as production, not disk fallback. + TEST_S3_BUCKET: swi-docsfiles + S3_ENDPOINT: http://127.0.0.1:9010 + S3_REGION: us-east-1 + S3_ACCESS_KEY_ID: swidev + S3_SECRET_ACCESS_KEY: swi_dev_storage_pw + S3_FORCE_PATH_STYLE: "1" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -76,9 +83,9 @@ jobs: run: | docker run -d --name silo -p 127.0.0.1:9010:9000 \ -e MINIO_ROOT_USER=swidev -e MINIO_ROOT_PASSWORD=swi_dev_storage_pw \ - pgsty/silo:RELEASE.2026-09-16T00-00-00Z server /data + pgsty/silo:RELEASE.2026-10-26T00-00-00Z@sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7 server /data for i in $(seq 1 30); do curl -fs http://127.0.0.1:9010/minio/health/live && break; sleep 1; done - docker run --rm --network host --entrypoint /bin/sh pgsty/mc:RELEASE.2026-09-16T00-00-00Z -c \ + docker run --rm --network host --entrypoint /bin/sh pgsty/mc:RELEASE.2026-10-26T00-00-00Z@sha256:ce62c9053c69ce12a527a322345b5ad9f44b5f8a7b8189f82e516f2e3f30f1d8 -c \ 'mc alias set l http://127.0.0.1:9010 swidev swi_dev_storage_pw && mc mb --ignore-existing l/swi-docsfiles' # Applies committed migrations; also proves they run on an empty database. - run: npm run db:test:prepare @@ -102,6 +109,33 @@ jobs: name: Production hosting smoke runs-on: ubuntu-latest timeout-minutes: 45 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: swi + POSTGRES_PASSWORD: swi_ci + POSTGRES_DB: swi_ci_test + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U swi -d swi_ci_test" + --health-interval 5s --health-timeout 5s --health-retries 10 + redis: + image: redis:7-alpine + ports: ["6379:6379"] + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + DATABASE_URL: postgresql://swi:swi_ci@localhost:5432/swi_ci_test + DIRECT_URL: postgresql://swi:swi_ci@localhost:5432/swi_ci_test + REDIS_URL: redis://127.0.0.1:6379/0 + S3_ENDPOINT: http://127.0.0.1:9010 + S3_BUCKET: swi-browser + S3_REGION: us-east-1 + S3_ACCESS_KEY_ID: swidev + S3_SECRET_ACCESS_KEY: swi_dev_storage_pw + S3_FORCE_PATH_STYLE: "1" strategy: fail-fast: false matrix: @@ -112,4 +146,12 @@ jobs: with: {node-version: 22, cache: npm} - run: npm ci - run: npx playwright install --with-deps chromium - - run: npm run test:e2e:${{ matrix.deployment }} + - name: Start Silo storage + run: | + docker run -d --name silo -p 127.0.0.1:9010:9000 \ + -e MINIO_ROOT_USER=swidev -e MINIO_ROOT_PASSWORD=swi_dev_storage_pw \ + pgsty/silo:RELEASE.2026-10-26T00-00-00Z@sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7 server /data + for i in $(seq 1 30); do curl -fs http://127.0.0.1:9010/minio/health/live && break; sleep 1; done + docker run --rm --network host --entrypoint /bin/sh pgsty/mc:RELEASE.2026-10-26T00-00-00Z@sha256:ce62c9053c69ce12a527a322345b5ad9f44b5f8a7b8189f82e516f2e3f30f1d8 -c \ + 'mc alias set l http://127.0.0.1:9010 swidev swi_dev_storage_pw && mc mb --ignore-existing l/swi-browser' + - run: npm run test:e2e:${{ matrix.deployment }} -- --external-services --demo diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e60a8b3..0e57a34 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -19,6 +19,28 @@ jobs: - run: npm run deployment:check application: uses: ./.github/workflows/ci.yml + cloudflare-cron: + name: Independent cron dependency and bundle check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: {node-version: 22} + - name: Audit and dry-run the isolated cron package without credentials + shell: bash + run: | + set -euo pipefail + work=$(mktemp -d) + trap 'rm -rf -- "$work"' EXIT + mkdir -p "$work/source" "$work/home" "$work/cache" + git archive HEAD deploy/cloudflare-cron | tar -xf - -C "$work/source" + cd "$work/source/deploy/cloudflare-cron" + rm -f -- .env .env.* .dev.vars .dev.vars.* + clean_env=(env -i "PATH=$PATH" "HOME=$work/home" "XDG_CONFIG_HOME=$work/home/config" "XDG_CACHE_HOME=$work/cache" CI=true WRANGLER_SEND_METRICS=false) + "${clean_env[@]}" npm ci --ignore-scripts --no-fund + "${clean_env[@]}" npm audit --audit-level=high --ignore-scripts + "${clean_env[@]}" npm run check container: name: Two-image installer smoke runs-on: ubuntu-latest @@ -26,4 +48,5 @@ jobs: steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v4 + - run: sudo apt-get update && sudo apt-get install -y age - run: bash scripts/test-vps-install.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index d98a274..6c20b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ build is tagged `v-main..`; see ## [Unreleased] +## [0.6.0] - 2026-10-29 + +### Fixed + +- Preserve public asset access for the nonroot backend when installation files use private directory and file modes. +- Preserve nonroot access to directly copied operator scripts and verify private script/directory modes in the image fixture. +- Pass explicit nonsecret installer fixture coordinates through environment-clearing sudo implementations. +- Update the independent Cloudflare cron tooling lockfile and audit its dependencies and credential-free bundle separately in CI. +- Stage private PostgreSQL bootstrap inputs for its service user and prevent an encrypted-backup output creation race. + +- Serialize borrower allowance and shared-container lending decisions and reject ineligible borrowers. +- Block mapped IPv6 webhook targets and update Nodemailer for security fixes. +- Supervise the complete local stack with private storage, captured email, durable Redis and local service guards. +- Restore the complete application schema atomically, remove newer-release table residue, keep failed post-commit recoveries offline and tear down inactive layout services. +- Preserve dropdown labels and verify full Linux browser, private-storage and encrypted recovery journeys with real queued SMTP before and after restore. +- Pin the migration CLI security override and bound production image build concurrency. +- Keep mobile transfer references on one line so they do not overlap direction captions. +- Remove the unused global npm package manager from published runtime images while retaining Node and the isolated migration CLI. +- Pin the coordinated October 26 Silo server and client release by immutable multi-architecture digest across local, production and CI storage. +- Build the bundled Caddy edge with a patched Go toolchain, locked dependencies and Alpine security updates, and propagate installer build failures. + ## [0.5.0] - Unreleased The first public release: everything since the split from SpaceWorks diff --git a/Dockerfile b/Dockerfile index a433860..aacaeec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,11 @@ CMD ["node", "scripts/prisma.mjs", "migrate", "deploy"] FROM node:22-alpine AS prisma-cli WORKDIR /opt/prisma-cli COPY --from=deps /app/node_modules/prisma/package.json /tmp/prisma-package.json -RUN npm install --omit=dev --no-save --no-audit --no-fund \ +COPY package.json /tmp/application-package.json +# Keep the isolated migration CLI's transitive security pins aligned with the app. +# Copy only dependency overrides; application scripts/workspaces do not run here. +RUN node -e 'const fs=require("node:fs"),p=require("/tmp/application-package.json");fs.writeFileSync("package.json",JSON.stringify({private:true,overrides:p.overrides||{}}))' \ + && npm install --omit=dev --no-save --no-audit --no-fund \ "prisma@$(node -p "require('/tmp/prisma-package.json').version")" \ && npm cache clean --force @@ -40,10 +44,12 @@ WORKDIR /opt/ops COPY --from=deps /app/node_modules/ioredis/package.json /tmp/ioredis-package.json COPY --from=deps /app/node_modules/@aws-sdk/client-s3/package.json /tmp/s3-package.json COPY --from=deps /app/node_modules/nodemailer/package.json /tmp/nodemailer-package.json +COPY --from=deps /app/node_modules/bullmq/package.json /tmp/bullmq-package.json RUN npm install --omit=dev --no-save --no-audit --no-fund \ "ioredis@$(node -p "require('/tmp/ioredis-package.json').version")" \ "@aws-sdk/client-s3@$(node -p "require('/tmp/s3-package.json').version")" \ "nodemailer@$(node -p "require('/tmp/nodemailer-package.json').version")" \ + "bullmq@$(node -p "require('/tmp/bullmq-package.json').version")" \ && npm cache clean --force # Stage 2: Rebuild the source code @@ -122,6 +128,8 @@ COPY --from=frontend-builder --chown=nextjs:nodejs /app/frontend/.next/static ./ COPY --from=frontend-builder --chown=nextjs:nodejs /app/frontend/public ./frontend/public COPY --chmod=755 scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN rm -f frontend/.env frontend/.env.local frontend/.env.production .env +# Runtime roles execute Node directly; dependency installation stays in build stages. +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx USER nextjs EXPOSE 3000 ENV PORT=3000 @@ -149,7 +157,7 @@ RUN apk add --no-cache font-dejavu fontconfig openssl && fc-cache -f RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs -COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/public ./public RUN mkdir .next RUN chown nextjs:nodejs .next @@ -163,15 +171,18 @@ COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist # Prisma CLI for the optional MIGRATE_ON_START=1 path (root-owned, read-only). COPY --from=prisma-cli /opt/prisma-cli/node_modules /opt/prisma-cli/node_modules COPY --chmod=755 scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -# Operator tools (setup, preflight, schema checks, smoke tests), root-owned. -COPY scripts/*.mjs ./scripts/ -COPY docker/vps ./docker/vps +# Operator sources stay readable when the installer uses private file modes. +# Tools needing configuration writes still run through the root operator role. +COPY --chown=nextjs:nodejs scripts/*.mjs ./scripts/ +COPY --chown=nextjs:nodejs docker/vps ./docker/vps COPY --from=ops-modules /opt/ops/node_modules ./docker/vps/node_modules # Next's standalone trace may include dotenv files when a build is performed # from a developer checkout. Secrets belong only in the runtime environment. RUN rm -f .env .env.local .env.production .env.development +# Migrations use the isolated Prisma CLI through Node, without a package manager. +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx USER nextjs EXPOSE 3000 diff --git a/README.md b/README.md index 48f1975..d7f4190 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,12 @@ hostnames, one container on one hostname, or the website on Netlify), hostnames, HTTPS, storage (bundled Silo or an external S3/R2 bucket), SMTP, backups and automatic deploys. One summary lists every choice and **nothing changes until you confirm it**. Then it installs Docker if needed, generates every secret without printing it, downloads the release's ready-made images from -[GitHub Packages](docs/CONTAINER_IMAGES.md), migrates and checks each service. Nothing is compiled on -your server. +[GitHub Packages](docs/CONTAINER_IMAGES.md), migrates and checks each service. The application images +are prebuilt; setup builds the bundled HTTPS edge from pinned Caddy sources and security updates. +An installation behind its own reverse proxy skips that build. -**Before you start:** 2 vCPU, 2 GB RAM and 30 GB disk, DNS records for your hostnames, inbound ports +**Before you start:** 2 vCPU, 4 GB RAM with swap available and 30 GB disk for the bundled edge, +DNS records for your hostnames, inbound ports 80 and 443, an SMTP relay on a verified sending domain, and your admin email. Phone cameras need HTTPS to scan QR codes. @@ -241,18 +243,25 @@ You need Node.js 22 and Docker (the development Compose file provides PostgreSQL Mailpit). ```bash -npm ci -cp .env.example .env -docker compose up -d -npm run db:deploy -DEMO_PASSWORD='choose-12-or-more-characters' npm run db:seed:demo -npm run dev +./local.sh start ``` -Open http://localhost:3000 and sign in as `anjali@tinkerspace.test` or `meera@tinkerspace.test` -(inventory managers), `joel@tinkerspace.test` (staff) or `fathima@tinkerspace.test` (member) with your -demo password. To try the first-run wizard instead, skip the seed and open `/setup`. Without SMTP, -development emails (magic links, invitations) are written to `.data/mail/`. +The helper generates local secrets, initializes the private Silo bucket, applies migrations, then +supervises the web app, BullMQ worker and beat. Open http://localhost:3000/setup to create your first +account and space. Email stays in local Mailpit at http://127.0.0.1:8025; Redis persists jobs with +append-only storage and refuses eviction. Ctrl+C stops all three Node processes; `./local.sh stop` +also stops the Docker services while keeping their data. + +For Windows or manual startup: run `npm ci`, copy `.env.example` to `.env` and fill its empty auth +and encryption keys, then run `docker compose up -d --wait`, `npm run storage:init`, +`npm run db:deploy` and `npm run dev:stack`. The worker and beat health endpoints use separate +loopback ports 3460 and 3461 (overrides: `SWI_DEV_WORKER_PORT`, `SWI_DEV_BEAT_PORT`). +For demo accounts, stop the stack and run `DEMO_PASSWORD='choose-12-or-more-characters' npm run +db:seed:demo`, then restart it. Inventory managers: `anjali@tinkerspace.test` or +`meera@tinkerspace.test`; staff: `joel@tinkerspace.test`; member: `fathima@tinkerspace.test`. +`npm run dev` runs the web process alone for focused frontend work; background processing requires +`dev:stack` or separately running worker and beat. Existing `.env` files are preserved: point SMTP +at local Mailpit explicitly if they predate these defaults. Admin email settings override the env. ## Design system @@ -300,7 +309,8 @@ How this app applies the brand is in [docs/DESIGN.md](docs/DESIGN.md). Rebuild t ## Common commands ```bash -npm run dev # development server +npm run dev:stack # web + BullMQ worker + beat (after local services setup) +npm run dev # web process only npm run lint # ESLint (with the 300-line limit) npm run typecheck # TypeScript npm run test:unit # unit tests diff --git a/SECURITY.md b/SECURITY.md index 12b9535..1e3e301 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,7 +30,7 @@ the newest image) rather than patching an older version. | Version | Supported | | --- | --- | -| 0.1.x (latest release) | Yes | +| 0.6.x (latest release) | Yes | | Earlier builds, pre-releases and `dev` | No | ## Security model in brief @@ -44,7 +44,7 @@ permissions). In short: archived and foreign spaces, records and QR codes all answer the same not-found. A transfer between spaces checks each side against its own membership. -- **Roles.** Owner, admin, inventory manager, staff and member, checked on the +- **Roles.** Inventory manager, staff, member and repair-only technician, checked on the server for every request (`lib/roles.ts`); hiding a button is never the only check. The instance admin area (`/admin`) needs a fresh authenticator code. - **Sign-in.** Email and password, emailed single-use sign-in links and TOTP diff --git a/VERSION b/VERSION index 8f0916f..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.6.0 diff --git a/components/transfers/TransfersTable.tsx b/components/transfers/TransfersTable.tsx index 1b860a6..be3778a 100644 --- a/components/transfers/TransfersTable.tsx +++ b/components/transfers/TransfersTable.tsx @@ -23,7 +23,7 @@ export function TransfersTable({ slug, rows }: { slug: string; rows: TransferRow {rows.map((row) => ( - {row.reference} + {row.reference} {row.directionLabel} diff --git a/components/ui/field.tsx b/components/ui/field.tsx index 5d57de9..999319a 100644 --- a/components/ui/field.tsx +++ b/components/ui/field.tsx @@ -53,7 +53,9 @@ export function Field({ label, optional, helperText, error, hideLabel, className // attributes after hydration, so the label would point nowhere. Re-wire it. const box = useRef(null); useEffect(() => { - const control = box.current?.querySelector("input, select, textarea, [role=combobox]"); + // Dropdown keeps an aria-hidden native select before its visible trigger. + // Giving that select the trigger's id would duplicate it and steal the label. + const control = box.current?.querySelector("input:not([aria-hidden=true]), select:not([aria-hidden=true]), textarea:not([aria-hidden=true]), [role=combobox]:not([aria-hidden=true])"); if (!control || control.id) return; control.id = controlId; if (describedBy && !control.getAttribute("aria-describedby")) control.setAttribute("aria-describedby", describedBy); diff --git a/compose.internal-tls.yml b/compose.internal-tls.yml index 76c3cd6..da6be2b 100644 --- a/compose.internal-tls.yml +++ b/compose.internal-tls.yml @@ -9,7 +9,7 @@ services: SWI_CADDY_OPTIONS: local_certs # Hands the frontend only the local CA's public certificate, never its keys. caddy-root: - image: caddy:2-alpine + image: spaceworks-caddy:2.11.7-security-20261029 profiles: [split] restart: "no" network_mode: none diff --git a/compose.production.yml b/compose.production.yml index b43144a..f59041c 100644 --- a/compose.production.yml +++ b/compose.production.yml @@ -40,8 +40,8 @@ services: - postgres_data:/var/lib/postgresql/data - ${SWI_CONFIG_DIR}/tls/postgres:/run/server-tls:ro - ./docker/vps/postgres-start.sh:/bootstrap/postgres-start.sh:ro - - ./docker/vps/init-database.sh:/docker-entrypoint-initdb.d/10-app-role.sh:ro - - ./docker/vps/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro + - ./docker/vps/init-database.sh:/bootstrap/init-database.sh:ro + - ./docker/vps/pg_hba.conf:/bootstrap/pg_hba.conf:ro healthcheck: test: ["CMD", "pg_isready", "-h", "postgres", "-U", "swi", "-d", "swi"] interval: 5s @@ -72,7 +72,7 @@ services: # Bundled Silo object storage (profile silo); external S3 skips both services. storage: profiles: [silo] - image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z + image: pgsty/silo:RELEASE.2026-10-26T00-00-00Z@sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7 restart: unless-stopped env_file: ${SWI_CONFIG_DIR}/storage.env command: ["server", "/data", "--certs-dir", "/server-tls", "--console-address", ":9001"] @@ -90,7 +90,7 @@ services: storage-init: profiles: [silo] - image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z + image: pgsty/silo:RELEASE.2026-10-26T00-00-00Z@sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7 restart: "no" env_file: ${SWI_CONFIG_DIR}/storage-init.env entrypoint: ["/bin/sh", "/bootstrap/init-storage.sh"] @@ -154,7 +154,9 @@ services: # Caddy HTTPS edge (profile caddy); an existing reverse proxy replaces it. proxy: profiles: [caddy] - image: caddy:2-alpine + image: spaceworks-caddy:2.11.7-security-20261029 + build: + context: ./docker/caddy restart: unless-stopped environment: PUBLIC_HOST: ${PUBLIC_HOST} diff --git a/deploy/cloudflare-cron/README.md b/deploy/cloudflare-cron/README.md index 5ff52b3..a7195de 100644 --- a/deploy/cloudflare-cron/README.md +++ b/deploy/cloudflare-cron/README.md @@ -19,6 +19,10 @@ this Worker. This folder is a separate package: it is excluded from the app's TypeScript, ESLint, Vitest and Docker build, and its only dependency is `wrangler`. +The root dependency audit does not cover this lockfile. The quality workflow +installs and audits it separately, then bundles it with `npm run check` in a +Git-visible source copy with an empty user profile and metrics disabled. +That check has no Cloudflare credentials and does not deploy the Worker. ## Deploy diff --git a/deploy/cloudflare-cron/package-lock.json b/deploy/cloudflare-cron/package-lock.json index 5891e6f..651958c 100644 --- a/deploy/cloudflare-cron/package-lock.json +++ b/deploy/cloudflare-cron/package-lock.json @@ -39,9 +39,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20261012.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261012.1.tgz", - "integrity": "sha512-dkTL9aY3V4D40b+jBpGPCistWUPFQ/M61wlWi6i1o8MvHFFgEw7EQkcaYEv6Yd3bkPDD/3TFT4hRsMb2xVZSwQ==", + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261023.1.tgz", + "integrity": "sha512-lsEx0i7nR0PNG/erUFFHmQmiidEhLfDE5uQmAL9421/y4/ycuxDDOSaxu3VGGYFFPK5sduyUeMmjIlX4CwOBFg==", "cpu": [ "x64" ], @@ -56,9 +56,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20261012.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261012.1.tgz", - "integrity": "sha512-2/nVGcKXjNNI+DhxKSepTKvFng9H7aN2WXv6Zu67iqgI6JrxypNt5kTPv4ZHosxrcfyKMci/2RfN7GIrN0BitQ==", + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261023.1.tgz", + "integrity": "sha512-5e79fHgabdDD8EgmjOhJ1S+xMsf6DbC7fL7IgTBizMnzOp0P2isDn7pYmUxI9AJZE9FVQ4RUilH/I36YNg+GQQ==", "cpu": [ "arm64" ], @@ -73,9 +73,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20261012.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261012.1.tgz", - "integrity": "sha512-uMe7ONHnLxStD1Ye0YFHBO36uj4cStv5P53rW+5GVWXFT8BRjqVixlH0IqCAHe1Pmcwyd1+7fVoGde89MZSCJg==", + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261023.1.tgz", + "integrity": "sha512-VHQ6jKFSihjR2+x1buby8XTdBLLJztVnYiB7OLOTYO8aDXB+c1Rd2410PdgFuFYc4AgFEk6PCJLyCLL5L1KBnw==", "cpu": [ "x64" ], @@ -90,9 +90,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20261012.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261012.1.tgz", - "integrity": "sha512-I0VMdcXk7ygG5vYDce0ePTBFdnBXC0i9WiBY5mWiv0iTinbfea6/rHqSQ7B1UMpjgrYVs9E0m4bZ9ufsk5GCFA==", + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261023.1.tgz", + "integrity": "sha512-ilpRHzfDN8YFRM5CLRKuFw40x/LJBS89yVdhdQmxehlVDuBqjKpJB++zPFbTgKRRFaNupgnXc8RHktXAIhgCmQ==", "cpu": [ "arm64" ], @@ -107,9 +107,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20261012.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261012.1.tgz", - "integrity": "sha512-c5hQvNx574UEoknaCFvMa/NdLrrYiALNbe6sx1okW8uOiRYWxrFuA8WpXFnBJ5XJypm0TEAHYaUTGv3rRLpY7Q==", + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261023.1.tgz", + "integrity": "sha512-279WLVjNsMycAwr0EZNiNEf0l2SY6NUtsN80NwZzbTHzakmiN+WSx4w38muxPm+PAU0oSPPAPXKVD+H6Hya8xw==", "cpu": [ "x64" ], @@ -1350,16 +1350,16 @@ } }, "node_modules/miniflare": { - "version": "5.20261012.1-beta", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261012.1-beta.tgz", - "integrity": "sha512-8Canhy6g9+vAhqMc0o80YphqYIwpiA3aAp8aX+uDXU7J/qbUeoZzcII9c2V1P4uT/eHD4SB93qet1o1bRY3BuQ==", + "version": "5.20261023.3-beta", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261023.3-beta.tgz", + "integrity": "sha512-thdXzlktu5GE1o/BlmMAPeJ0Cu6Tu5LpMbRlyfRrpVvMRrhd6p52TMqcITCZZLVzt1UO92VGBkLDhLOpryUAyQ==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.5", - "undici": "7.29.1", - "workerd": "1.20261012.1", + "undici": "7.30.1", + "workerd": "1.20261023.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -1466,9 +1466,9 @@ "optional": true }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { @@ -1486,9 +1486,9 @@ } }, "node_modules/workerd": { - "version": "1.20261012.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261012.1.tgz", - "integrity": "sha512-tEHoMPsMga0seVjxfsR8Pd3f8IRqYwkq++dTUKI5Gdgj6zuSDluYwisydq+dn90SsIACwBquJUQ1nBpcOqaWTA==", + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261023.1.tgz", + "integrity": "sha512-NeofJFyaYNpqKpFRJW3ApuonaerPd6/JWlfTtLDYf2A6ygqrDEFnYgEXmc0hhOxqnJsSM+kXJN6VebPojzRrsg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1499,17 +1499,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20261012.1", - "@cloudflare/workerd-darwin-arm64": "1.20261012.1", - "@cloudflare/workerd-linux-64": "1.20261012.1", - "@cloudflare/workerd-linux-arm64": "1.20261012.1", - "@cloudflare/workerd-windows-64": "1.20261012.1" + "@cloudflare/workerd-darwin-64": "1.20261023.1", + "@cloudflare/workerd-darwin-arm64": "1.20261023.1", + "@cloudflare/workerd-linux-64": "1.20261023.1", + "@cloudflare/workerd-linux-arm64": "1.20261023.1", + "@cloudflare/workerd-windows-64": "1.20261023.1" } }, "node_modules/wrangler": { - "version": "4.151.1", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.151.1.tgz", - "integrity": "sha512-OAsa9UAHZshM6mq2PXcVoyxXgWThAanZMBc/IirtqEWd/BauRtmUKzAniOu/pU0pd3pxKuMZ2vsbF4eo6RiNDA==", + "version": "4.156.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.156.0.tgz", + "integrity": "sha512-kVLAShuLiNgE3jcrmXcymDf/iPKDPrSxACe5ueugW/33HeNB9E9CkKYchudCeBwgmsnl4k+1zt4NO7vqb/fRuQ==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -1517,10 +1517,10 @@ "@cloudflare/unenv-preset": "2.16.2", "blake3-wasm": "2.1.5", "esbuild": "0.28.2", - "miniflare": "5.20261012.1-beta", + "miniflare": "5.20261023.3-beta", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20261012.1" + "workerd": "1.20261023.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -1534,7 +1534,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20261006.1" + "@cloudflare/workers-types": "^5.20261023.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/deploy/cloudflare-cron/package.json b/deploy/cloudflare-cron/package.json index 40a6928..3d6f078 100644 --- a/deploy/cloudflare-cron/package.json +++ b/deploy/cloudflare-cron/package.json @@ -15,6 +15,6 @@ }, "allowScripts": { "esbuild@0.28.2": true, - "workerd@1.20261012.1": true + "workerd@1.20261023.1": true } } diff --git a/docker-compose.yml b/docker-compose.yml index 3baa8c5..328f95a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# Local services only. App development runs with npm run dev or local.sh. +# Local services only. App + background jobs run with npm run dev:stack or local.sh. services: postgres: image: postgres:16-alpine @@ -8,17 +8,19 @@ services: healthcheck: {test: [CMD, pg_isready, -U, swi, -d, swi_dev], interval: 5s, timeout: 5s, retries: 20} redis: image: redis:7-alpine + command: [redis-server, --appendonly, "yes", --maxmemory-policy, noeviction] ports: ["127.0.0.1:${SWI_DEV_REDIS_PORT:-6395}:6379"] + volumes: [redis_dev:/data] healthcheck: {test: [CMD, redis-cli, ping], interval: 5s, timeout: 5s, retries: 20} silo: - image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z + image: pgsty/silo:RELEASE.2026-10-26T00-00-00Z@sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7 command: [server, /data, --console-address, ":9001"] environment: {MINIO_ROOT_USER: swidev, MINIO_ROOT_PASSWORD: swi_dev_storage_pw} ports: ["127.0.0.1:${SWI_DEV_STORAGE_PORT:-9010}:9000", "127.0.0.1:${SWI_DEV_STORAGE_CONSOLE_PORT:-9011}:9001"] volumes: [storage_dev:/data] healthcheck: {test: [CMD, curl, -f, "http://localhost:9000/minio/health/live"], interval: 5s, timeout: 5s, retries: 20} silo-init: - image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z + image: pgsty/silo:RELEASE.2026-10-26T00-00-00Z@sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7 depends_on: {silo: {condition: service_healthy}} entrypoint: [/bin/sh, -ec] command: ["mc alias set local http://silo:9000 swidev swi_dev_storage_pw; mc mb --ignore-existing local/swi-dev; mc anonymous set none local/swi-dev"] @@ -27,4 +29,5 @@ services: ports: ["127.0.0.1:1025:1025", "127.0.0.1:8025:8025"] volumes: postgres_dev: + redis_dev: storage_dev: diff --git a/docker/caddy/Dockerfile b/docker/caddy/Dockerfile new file mode 100644 index 0000000..423e593 --- /dev/null +++ b/docker/caddy/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 +# Keep the standard Caddy modules and runtime contract; patch the toolchain and +# dependency versions until the official release image incorporates the fixes. +FROM golang:1.26.10-alpine3.24@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder +WORKDIR /build +ARG GOPROXY=https://proxy.golang.org,direct +ENV GOPROXY=$GOPROXY +ENV GOTOOLCHAIN=local CGO_ENABLED=0 GOMAXPROCS=2 GOFLAGS=-p=2 +COPY go.mod go.sum ./ +RUN --mount=type=cache,id=spaceworks-caddy-gomod,target=/go/pkg/mod \ + go mod download && go mod verify +COPY main.go ./ +RUN --mount=type=cache,id=spaceworks-caddy-gomod,target=/go/pkg/mod \ + --mount=type=cache,id=spaceworks-caddy-build,target=/root/.cache/go-build \ + go build -mod=readonly -trimpath -ldflags='-s -w' -o /out/caddy . \ + && go version -m /out/caddy + +# Preserve the official config, directories, root UID, command and curl utility. +FROM caddy:2.11.7-alpine@sha256:eb4d35b7452d1ab6171fbdbe8d9275b2301dad6dad92115d91e19fb9b24d510f +RUN apk --no-cache upgrade +COPY --from=builder /out/caddy /usr/bin/caddy +# COPY replaces the original inode and therefore its privileged-port capability. +RUN setcap cap_net_bind_service=+ep /usr/bin/caddy && caddy version diff --git a/docker/caddy/README.md b/docker/caddy/README.md new file mode 100644 index 0000000..595a6e6 --- /dev/null +++ b/docker/caddy/README.md @@ -0,0 +1,92 @@ +# Patched standard Caddy image + +The bundled edge keeps Caddy **2.11.7** and all its standard modules. Its Go build +uses the official **1.26.10** builder, with `golang.org/x/net` **0.62.0** and +`github.com/go-jose/go-jose/v4` **4.1.6** and `github.com/go-chi/chi/v5` **5.4.0** +selected in the checked-in module lock. +`go mod verify` checks downloaded modules against `go.sum`; `-mod=readonly` +prevents compilation from silently changing the dependency selection. + +The final stage inherits the digest-pinned official Caddy image, upgrades its +Alpine packages, and replaces only the Caddy executable. It retains the official +root user, `/srv`, `/config`, `/data`, stock configuration, exposed ports and +command. The replacement executable receives the original privileged-port +capability again. Alpine repository security updates are resolved when rebuilding; +record the resulting image digest and package versions with each release. + +```bash +docker build -t spaceworks-caddy:2.11.7-security-20261029 docker/caddy +docker run --rm spaceworks-caddy:2.11.7-security-20261029 caddy version +``` + +Setup (published app images or source), upgrades and compose smoke all build the +edge locally. No unpublished registry image is required. An installation behind +its own reverse proxy skips this build. Upgrades include these build sources in +their saved/replaced host bundle. + +## Remaining advisory assessment + +The standard module graph retains `github.com/go-jose/go-jose/v3` **3.0.5**. Its +upstream JWK/RSA parsing advisory lists **no patched v3 release**. The actual +executable contains `JSONWebKey.UnmarshalJSON`, `rawJSONWebKey.rsaPrivateKey` +and `ParseSigned`; updating Go and v4 does not remove this vulnerable code. + +The shipped single/split reverse-proxy Caddyfiles across the four apps enable no +`acme_server`, JWK ingestion or certificate-provisioner HTTP handler. Smallstep's +ACME-server middleware calls the v3 parser; the outbound ACME client uses v4. +The internal issuer signs locally generated X.509 certificates. In actual runtime +testing, the adapted stock configuration had no ACME-server handler, public +`/acme/new-account` traffic reached the application, and trusted internal TLS, +proxy headers, hidden internal jobs and unchanged standard module IDs all passed. + +Binary `govulncheck` 1.9.0 ran at symbol level. Its database timestamp was +2026-10-27T21:16:18Z and did not yet include this JOSE advisory, so its absence in +that report is not proof of safety. Raw Trivy retains the high advisory. Adding +an ACME server, custom JWK handling, or exposing the administrative configuration +API changes the assessment. This is a configured-path assessment, not a removal +of vulnerable code. OpenPGP remains an unmaintained transitive dependency; shipped +proxy routes do not configure it. No source call-graph analysis was completed. + +The first patched candidate removed the original OS/Go/v4 high findings; its +symbol scan also identified two fixable HTML issues, prompting the x/net 0.62 +lock update; the complete raw scan identified the fixed Chi 5.4.0 release too. +The final image was rebuilt and independently scanned without suppressions: + +`sha256:0956bb7459167ed6f47cc9fbb29e2d3ddb452f7ba1b7313f175a1ebd74c91293` + +Its extracted executable confirms Go 1.26.10, Caddy 2.11.7, x/net 0.62.0, +Chi 5.4.0 and JOSE v4.1.6. Trusted HTTPS using its generated local CA explicitly +negotiated HTTP/2 version 2. Reverse proxying, trusted-header sanitation, internal +job denial, disabled public ACME-server routing and standard-module parity passed. +The final raw scan retains **0 critical, 1 high, 2 medium and 1 unknown** findings: +only the unpatched JOSE v3 and unmaintained OpenPGP entries described here. +The repeated binary symbol scan reports OpenPGP; its database still predates +the JOSE advisories, so that absence does not establish safety. +Keep both raw scans. Removing PKI/ACME modules or backporting cryptographic +dependency code requires a separate reviewed change. + +| Known finding | Final lock disposition | +| --- | --- | +| CVE-2026-114437, Chi forwarded-client IP middleware | Patched Chi 5.4.0 | +| CVE-2026-78661 and CVE-2026-97045, x/net HTML parsing/rendering | Patched x/net 0.62.0 | +| CVE-2026-116585, CVE-2026-116586, CVE-2026-116587, Go JOSE v3 | No fix listed; retain raw two medium/one high findings and the configured-path assessment above | +| GO-2026-5932, unmaintained OpenPGP | No fix listed; retained standard dependency, unused by shipped proxy configuration | + +## Build resources + +The first cold Go compilation took 164.8 seconds with two CPUs; the whole +BuildKit cgroup peaked at 2.21 GiB, including module/filesystem caches, under a +3 GiB limit. Allow at least 4 GiB RAM with swap and headroom for running app/data +services during upgrades. Published-app installations also compile this edge. +An external reverse proxy skips it. Dependency downloads add network-dependent +time; subsequent builds reuse the independent module/compiler cache mounts. + +Primary references: + +- [Official manual build instructions](https://caddyserver.com/docs/build) +- [Caddy 2.11.7 release](https://github.com/caddyserver/caddy/releases/tag/v2.11.7) +- [Official image sources](https://github.com/caddyserver/caddy-docker) +- [Go security releases](https://go.dev/doc/devel/release) +- [Go JOSE advisory](https://github.com/go-jose/go-jose/security/advisories/GHSA-mgcc-cww5-fp2q) +- [Chi advisory](https://github.com/advisories/GHSA-j6qf-3m9c-vwj3) +- [Alpine Brotli package](https://pkgs.alpinelinux.org/package/v3.23/main/x86_64/brotli-libs) diff --git a/docker/caddy/go.mod b/docker/caddy/go.mod new file mode 100644 index 0000000..58fd795 --- /dev/null +++ b/docker/caddy/go.mod @@ -0,0 +1,171 @@ +module spaceworks.local/caddy + +go 1.26.0 + +require github.com/caddyserver/caddy/v2 v2.11.7 + +require ( + cel.dev/cel-go v0.32.0 // indirect + cel.dev/expr v0.25.3 // indirect + cloud.google.com/go/auth v0.23.3 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.1 // indirect + dario.cat/mergo v1.0.2 // indirect + filippo.io/bigmod v0.1.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/DeRuina/timberjack v1.4.8 // indirect + github.com/KimMachineGun/automemlimit v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.25.0 // indirect + github.com/caddyserver/certmagic v0.25.6 // indirect + github.com/caddyserver/zerossl v0.1.6 // indirect + github.com/ccoveille/go-safecast/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chzyer/readline v1.5.1 // indirect + github.com/cloudflare/circl v1.6.5 // indirect + github.com/coreos/go-oidc/v3 v3.21.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/dgraph-io/badger v1.6.2 // indirect + github.com/dgraph-io/badger/v2 v2.2007.4 // indirect + github.com/dgraph-io/ristretto v0.2.0 // indirect + github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect + github.com/dlclark/regexp2/v2 v2.8.0 // indirect + github.com/dunglas/go-urlpattern v1.0.0 // indirect + github.com/dunglas/httpsfv v1.1.1 // indirect + github.com/dustin/go-humanize v1.1.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.4 // indirect + github.com/go-chi/chi/v5 v5.4.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.6 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-sql-driver/mysql v1.10.1 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/go-tspi v0.3.0 // indirect + github.com/google/s2a-go v0.1.10 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect + github.com/googleapis/gax-go/v2 v2.25.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect + github.com/huandu/xstrings v1.6.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.11.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.20.1 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/mholt/acmez/v3 v3.1.7 // indirect + github.com/miekg/dns v1.1.73 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nlnwa/whatwg-url v0.6.2 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pires/go-proxyproto v0.15.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.24.1 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.71.0 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.63.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect + github.com/slackhq/nebula v1.11.2 // indirect + github.com/smallstep/certificates v0.30.2 // indirect + github.com/smallstep/cli-utils v0.12.2 // indirect + github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68 // indirect + github.com/smallstep/linkedca v0.26.0 // indirect + github.com/smallstep/nosql v0.8.0 // indirect + github.com/smallstep/pkcs7 v0.2.3 // indirect + github.com/smallstep/scep v0.0.0-20260331191114-261f960a40d1 // indirect + github.com/smallstep/truststore v0.13.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 // indirect + github.com/urfave/cli v1.22.17 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/yuin/goldmark v1.8.6 // indirect + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.etcd.io/bbolt v1.5.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.71.0 // indirect + go.opentelemetry.io/contrib/exporters/autoexport v0.71.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect + go.opentelemetry.io/contrib/propagators/autoprop v0.71.0 // indirect + go.opentelemetry.io/contrib/propagators/aws v1.46.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.46.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.46.0 // indirect + go.opentelemetry.io/contrib/propagators/ot v1.46.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.68.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect + go.opentelemetry.io/otel/log v0.22.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/sdk v1.46.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.22.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.step.sm/crypto v0.91.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20260929172509-b39ff6d641ec // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect + golang.org/x/net v0.62.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/api v0.298.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + howett.net/plist v1.0.1 // indirect +) diff --git a/docker/caddy/go.sum b/docker/caddy/go.sum new file mode 100644 index 0000000..5648a37 --- /dev/null +++ b/docker/caddy/go.sum @@ -0,0 +1,587 @@ +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.23.3 h1:UMK+oBtuNGMCR/6i6mmySUItqjOazpJrbmZyhGbGBWo= +cloud.google.com/go/auth v0.23.3/go.mod h1:fClbry28fo7XkxhSeT6AQtAVAp6Jy0fW9N99PoPNPFM= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.1 h1:CTE1OWBQ0vnF5uHwdFAQJvMQ0Fi/KRcqqKTo9V0F8Ik= +cloud.google.com/go/compute/metadata v0.9.1/go.mod h1:NtnlvB6X3t4R6xSWyVX/ZWk493PCxGQlhI/iqxh4M8I= +cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= +cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= +cloud.google.com/go/kms v1.33.0 h1:pG0X78m212b2pv9N4fdMoUO69LuZGQ9kSvn8sHBOFAo= +cloud.google.com/go/kms v1.33.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/bigmod v0.1.0 h1:UNzDk7y9ADKST+axd9skUpBQeW7fG2KrTZyOE4uGQy8= +filippo.io/bigmod v0.1.0/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DeRuina/timberjack v1.4.8 h1:V7SIoCzkyWD93WmyKECSX+tMu/hLqQQvptlhEsbBIhI= +github.com/DeRuina/timberjack v1.4.8/go.mod h1:DCXjOescYyzTrOryf57SEbSeCOqkBQ3QjH7Pn03i5Cs= +github.com/KimMachineGun/automemlimit v1.0.0 h1:+MqlvDE/pkJNjk1rU+O14QsH8k10nJAD0frB0lsxyvw= +github.com/KimMachineGun/automemlimit v1.0.0/go.mod h1:n+BSXxQWDFS1DKh67Rqo0lgTsowsg6x65ak5uyngML0= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ= +github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/config v1.33.4 h1:FzvkXKSzwqHni4U7nDigHg4jjtqMpVUuHgmZfSoJVQ0= +github.com/aws/aws-sdk-go-v2/config v1.33.4/go.mod h1:VZqGZnZsCWVfK/iGPptJIyNIX3XEX6iQU2Rel4sLrr8= +github.com/aws/aws-sdk-go-v2/credentials v1.20.4 h1:hTvrJJseKbvw32kmiE0G+u/9ZqpqscjDrTigHIXP2qs= +github.com/aws/aws-sdk-go-v2/credentials v1.20.4/go.mod h1:gWp9O1ZBWwpcIrgV+mVHk4gZUurAEDkgypu/OXOlIaw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY= +github.com/aws/aws-sdk-go-v2/service/kms v1.60.0 h1:O/xKUsrz7J69Q0yca9XeYxwgi+NbBVbxczcZWudJdU0= +github.com/aws/aws-sdk-go-v2/service/kms v1.60.0/go.mod h1:/eZ9xj8IZyb7fj+/t3HO7x89X+7Vn+gCDDbQ2NHCVtM= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0= +github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 h1:khXV3+K5D3f4e8xtplaRdSFn1bEg3gj5EBHQvbCOZbQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.50.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.25.0 h1:0Ro0qF4abCkM6SqWPVj29sFhAbMPAZpaDD7xhJ10beM= +github.com/bits-and-blooms/bitset v1.25.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/caddyserver/caddy/v2 v2.11.7 h1:yj0Y4fYZGPkSvibBJ1sTWE33xC0fxztVyXEW5iIdUT4= +github.com/caddyserver/caddy/v2 v2.11.7/go.mod h1:Hy0hh5OTFu/INfBPyvkzImXF4wh6O91jyrZLWeB0fx0= +github.com/caddyserver/certmagic v0.25.6 h1:vHMtFSLKTa6kuZz6w0SnRMpQ388tSkww8ye355BgAM4= +github.com/caddyserver/certmagic v0.25.6/go.mod h1:xq6cRNimqW+Tv91XNc5lNHs2XYTsLhQhYiH01H4AySs= +github.com/caddyserver/zerossl v0.1.6 h1:1yrhTx5DWi43wOJPQ+Y4mVl++EPmSqqT/4REW//lsas= +github.com/caddyserver/zerossl v0.1.6/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/ccoveille/go-safecast/v2 v2.0.1 h1:2+mIu3gXtwmWelBia2kkxfB8eP4orTHDH7ClSlWkd6I= +github.com/ccoveille/go-safecast/v2 v2.0.1/go.mod h1:JIYA4CAR33blIDuE6fSwCp2sz1oOBahXnvmdBhOAABs= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= +github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger v1.6.2 h1:mNw0qs90GVgGGWylh0umH5iag1j6n/PeJtNvL6KY/x8= +github.com/dgraph-io/badger v1.6.2/go.mod h1:JW2yswe3V058sS0kZ2h/AXeDSqFjxnZcRrVH//y2UQE= +github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o= +github.com/dgraph-io/badger/v2 v2.2007.4/go.mod h1:vSw/ax2qojzbN6eXHIx6KPKtCSHJN/Uz0X0VPruTIhk= +github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= +github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw= +github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dunglas/go-urlpattern v1.0.0 h1:AmMwIDXWJFezeE1v38rxvQRGvvzTlityFuifLLVfoQQ= +github.com/dunglas/go-urlpattern v1.0.0/go.mod h1:9qyjDljBPOWyWCGz7vo3Ek7cdnoG/DVk0Ucle7gWVS8= +github.com/dunglas/httpsfv v1.1.1 h1:HoSs101zIE9I23DlqlmljJ/OIi7ILwrH347pXhRZdxI= +github.com/dunglas/httpsfv v1.1.1/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= +github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo= +github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-chi/chi/v5 v5.4.0 h1:+ufzvFTGKyin26Or5DLrlIDq4ygIDBVcj7oVNeeaYFE= +github.com/go-chi/chi/v5 v5.4.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= +github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.4.9 h1:jZEhnE4WRFbomSssBH2gWaIViIHU1gjH1jz76+xC9bI= +github.com/google/go-tpm-tools v0.4.9/go.mod h1:Omb8zosA8qY9URn1gsrO2i4b6DFqGp29BqNx18V66c4= +github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus= +github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI= +github.com/google/s2a-go v0.1.10 h1:EMp+aOuXN6l8cE/gjF5Bt+vyZxsUuyCWe9chDWR/+uU= +github.com/google/s2a-go v0.1.10/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.22 h1:NU4XpII6jD+Dxcot94fqjE+AfJoE/lQP9q3faYGzC/c= +github.com/googleapis/enterprise-certificate-proxy v0.3.22/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.25.0 h1:77gyzCOrTyzPKfzGZJG/V5DO4/Kuamek8W+cYJQ4U8k= +github.com/googleapis/gax-go/v2 v2.25.0/go.mod h1:sMKqnMesnKH+3wiRJROcttA+cJoZoGbZl1vDQ8XYtGk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/huandu/xstrings v1.6.1 h1:yYdKSd6Sjv3fNZt9BDjl1FDsPNfQEaYi19L5LzK2JKs= +github.com/huandu/xstrings v1.6.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.1 h1:oKHx3lgN4e5Nno2LKTMrVx+b+NkDptkO9aDireiBDGE= +github.com/letsencrypt/pebble/v2 v2.10.1/go.mod h1:KtYhQ4YTjT5MtoCZ6RTCXlbrrz6cKyXROCuTpIUDJFY= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mholt/acmez/v3 v3.1.7 h1:XTqpuUcRdRoIBuDoI+M4mCnRnPTPN4rqH3Mb723Rg4U= +github.com/mholt/acmez/v3 v3.1.7/go.mod h1:Lwv6P/czh/AOq+c99tAzP68/VP92s+8y+hDs4FCgxvo= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q= +github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU= +github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= +github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A= +github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/jsonstore v1.1.0 h1:WZBDjgezFS34CHI+myb4s8GGpir3UMpy7vWoCeO0n6E= +github.com/schollz/jsonstore v1.1.0/go.mod h1:15c6+9guw8vDRyozGjN3FoILt0wpruJk9Pi66vjaZfg= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/slackhq/nebula v1.11.2 h1:eP61cSfUuoEVGViD50gZBsAbBLPUAhoNOYewPmEwPG4= +github.com/slackhq/nebula v1.11.2/go.mod h1:xz3SEyYeEhXY8MDlVu8S3Qt+SjncsSJ8bl4RXUgxtXQ= +github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= +github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= +github.com/smallstep/certificates v0.30.2 h1:1G3xBi8sJ740iA1mMPW2Svv7EIZKJ4Zf/iQtA5QlN0Y= +github.com/smallstep/certificates v0.30.2/go.mod h1:oyaE/aEYUGDr+YiCZLAxxP22bOQqcSHTeDgp8Vv2rlY= +github.com/smallstep/cli-utils v0.12.2 h1:lGzM9PJrH/qawbzMC/s2SvgLdJPKDWKwKzx9doCVO+k= +github.com/smallstep/cli-utils v0.12.2/go.mod h1:uCPqefO29goHLGqFnwk0i8W7XJu18X3WHQFRtOm/00Y= +github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68 h1:KcK2guFXrE5sX/nvF1b+atHP6DjRB1gCv3ppyTXB2Zk= +github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= +github.com/smallstep/linkedca v0.26.0 h1:NsxTVo3zI3KwOFFiVodeHtuGgKq0b5kOUXVLjjNTvXY= +github.com/smallstep/linkedca v0.26.0/go.mod h1:Z8c7EgVrSHNhshIhRnUGfKfE796+TwF2eyErhqANmJQ= +github.com/smallstep/nosql v0.8.0 h1:FBTCUfKPmWYbrozW+RBKu+fnvbn+zr5rVli/XB4Jp4A= +github.com/smallstep/nosql v0.8.0/go.mod h1:5dUpNotHLHhOUapP0PLBVVfp3tG1DFC31VRccg+Cqwo= +github.com/smallstep/pkcs7 v0.2.1/go.mod h1:RcXHsMfL+BzH8tRhmrF1NkkpebKpq3JEM66cOFxanf0= +github.com/smallstep/pkcs7 v0.2.3 h1:bhoQ3TeZmdoXTatcwxCbk+FMcdsyr0gYrrW2Xq2qr+s= +github.com/smallstep/pkcs7 v0.2.3/go.mod h1:7STkdKhZaZe4xNEXTtY4j1NGeST1gYM4GA40kC5iqr8= +github.com/smallstep/scep v0.0.0-20260331191114-261f960a40d1 h1:lpXBkQKj1rT1oGX/2idvt8xbrOrnoQxH/+CjoeMxs9E= +github.com/smallstep/scep v0.0.0-20260331191114-261f960a40d1/go.mod h1:QQhwLqCS13nhv8L5ov7NgusowENUtXdEzdytjmJHdZQ= +github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4= +github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 h1:RnBbFMmodYzhC6adOjTbtUQXyzV8dcvKYbolzs6Qch0= +github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747/go.mod h1:ejPAJui3kVK4u5TgMtqtXlWf5HnKh9fLy5kvpaeuas0= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= +github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= +go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.71.0 h1:9qgxsFLskbDMXl8WMqThoF6w8yGJgCumn9qRc67OmnI= +go.opentelemetry.io/contrib/bridges/prometheus v0.71.0/go.mod h1:2rCjF4F2siiTeLCzJsaGZ3CK0XIoimCSKXEBPdv+Je0= +go.opentelemetry.io/contrib/exporters/autoexport v0.71.0 h1:VCsJbp0YLyPtx2tu5Vgv2a2/qLoaMCj8hT2uZ34+Mx0= +go.opentelemetry.io/contrib/exporters/autoexport v0.71.0/go.mod h1:qxZqn7e10f6ajmMCkg/47rMS7qQYfaOl2nj/4aytHUQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/contrib/propagators/autoprop v0.71.0 h1:uy7dcw6v3XK3LMGj2sB9CXtJtTHX8QA9pjSrLhvf86c= +go.opentelemetry.io/contrib/propagators/autoprop v0.71.0/go.mod h1:IknVHC4K/KkF0Kx+GpTlhlbW96XOq4m0O/FHauFVZtA= +go.opentelemetry.io/contrib/propagators/aws v1.46.0 h1:JslT1wq/5vb6lQsbdOqShvIEs7sDlf0IvKxNZdadfjY= +go.opentelemetry.io/contrib/propagators/aws v1.46.0/go.mod h1:JE4srRJf2cRJcJjRaNhViFjyFJqiCZJiDVlqe6GWXsA= +go.opentelemetry.io/contrib/propagators/b3 v1.46.0 h1:OFVqWObn7xLIbOjE/koO0LS9fZJNgAyBD0msA+UQAoc= +go.opentelemetry.io/contrib/propagators/b3 v1.46.0/go.mod h1:t/d64xy7xuuEDJN/4ThqohLgRhIuQxL9y7P1v02bYuM= +go.opentelemetry.io/contrib/propagators/jaeger v1.46.0 h1:uxl0SGcmuBkHj/Adl9oftEAyiawQBPL5RzMAmt/Yvq4= +go.opentelemetry.io/contrib/propagators/jaeger v1.46.0/go.mod h1:LiOkxCIvoLofmRps7f8l0NkBtmObnAyQ5trteFs6wj8= +go.opentelemetry.io/contrib/propagators/ot v1.46.0 h1:SxC/1xmJJJdGQTNmavqW8ju85TRXiZHeFaQdnEMCWLE= +go.opentelemetry.io/contrib/propagators/ot v1.46.0/go.mod h1:ZrntlVZOzRMetoU2y7eIHUbJ7riKEOqIyLXkuu91sxU= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 h1:Bu39F5tzJct+f2IZbB8989fwyTps3c8e7EsUQsz+vs8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0/go.mod h1:dJUwod88EsFgYCqrDHaSPzhiY9pBUpt0d85/qSfua7k= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 h1:lYk7RmxdLK865qLwibroNGldHa1U7SWKYYvNjlK7PIo= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0/go.mod h1:6GvlND0H0xdUJanOtIAn0xfwLkauh1tmsYEEVSMDdqY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 h1:AP23h/mFgb/lc7tdck1Kfn9qxsM8TAeNPCU5C3pzaps= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0/go.mod h1:K4EqCe1b4kGk5WR690ntg9LaBfsPoV32FwthbyoptuA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= +go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4= +go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0 h1:kvMAiLEudKmk+CSG+iYbU8vTUGNNDaf/V09OO5lrTwI= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0/go.mod h1:L9Dlksri+MdT1cb2gIiA1cJJYW3Y92ipvDjNxYEyaDI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 h1:PR9eAf7o0dQs3hshZNZpE9aW2dXWX/KdDf6pJilVD3U= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0/go.mod h1:2Z4KyNdH1uuzivdinyfGsxzNNT/Rl45pwtVwfYVI0xk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= +go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0= +go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/log v0.22.0 h1:PRL+s6P63XT4E/bheEflopPUpVxuvANqZwtt89yhoGk= +go.opentelemetry.io/otel/sdk/log v0.22.0/go.mod h1:JNp0sBELrjCTcu5W3GzABVypeU6vDJjBS+X0JISuz+g= +go.opentelemetry.io/otel/sdk/log/logtest v0.22.0 h1:infPnfNrhCNgOUZRs3gWUg8vhoBUHihq02gwK05gzlg= +go.opentelemetry.io/otel/sdk/log/logtest v0.22.0/go.mod h1:gkQZA3z15Bv3KU9vigBTi8dFechSozRP7v94X4VZv+s= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.step.sm/crypto v0.91.0 h1:0mN0DwVOvUuh7VbyTnxABZuAkxwak/Grp8Y/b4YaZDU= +go.step.sm/crypto v0.91.0/go.mod h1:NxxObRBymdbyXLoTCW5Ea6sLxuy5yI/xr9+hSBlbU/Q= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260929172509-b39ff6d641ec h1:6gLejUU4OPMnmDEz8DkDkWMoyDj2Ny5iUy1Ze3raJhs= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260929172509-b39ff6d641ec/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.298.0 h1:YW18RkHBMZBA1ergX0m4biagzgbiPTb2uTsRsDPWNRY= +google.golang.org/api v0.298.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 h1:GS9OIt/j7c8bvBjYNgnKQysVfmV7e4jM0H8ZK95G4t8= +google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459/go.mod h1:PX5/4vemwVoXtwEcRDWwcR1/r0qrosfx3qoVADMwnVE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 h1:b0xCahf3FK2m2Cv0p4vTozGPWncCvLfwV86UNg8xWU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 h1:rgSNvqscFZ1JgV/4wH5GOsZFSFkR2Eua9As3KIr2LlM= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= diff --git a/docker/caddy/main.go b/docker/caddy/main.go new file mode 100644 index 0000000..7f9183e --- /dev/null +++ b/docker/caddy/main.go @@ -0,0 +1,10 @@ +package main + +import ( + caddycmd "github.com/caddyserver/caddy/v2/cmd" + _ "github.com/caddyserver/caddy/v2/modules/standard" +) + +func main() { + caddycmd.Main() +} diff --git a/docker/vps/jobs-smoke.mjs b/docker/vps/jobs-smoke.mjs new file mode 100644 index 0000000..08b71a0 --- /dev/null +++ b/docker/vps/jobs-smoke.mjs @@ -0,0 +1,54 @@ +// Real BullMQ worker/SMTP delivery in the disposable installer deployment. +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import { PrismaClient } from "@prisma/client"; +import { Queue } from "bullmq"; + +assert.equal(process.env.SWI_RECOVERY_SMOKE, "1", "Explicit disposable deployment opt-in required"); +assert.equal(new URL(process.env.AUTH_URL).origin, new URL(process.env.NEXT_PUBLIC_BASE_URL).origin, + "The restored sign-in and public origins must agree"); +const prisma = new PrismaClient(); +const url = new URL(process.env.REDIS_URL); +const connection = { host: url.hostname, port: Number(url.port || 6379), + username: decodeURIComponent(url.username), password: decodeURIComponent(url.password), + maxRetriesPerRequest: 1, ...(url.protocol === "rediss:" ? { tls: {} } : {}) }; +const queue = new Queue("outbox", { connection, prefix: process.env.JOBS_QUEUE_PREFIX || "swi:jobs" }); +const subject = `Disposable worker smoke ${randomUUID()}`; +let id; +try { + let ready = false; + for (let attempt = 0; attempt < 50; attempt++) { + try { + const response = await fetch("http://mailpit:8025/api/v1/messages", { signal: AbortSignal.timeout(1000) }); + await response.body?.cancel(); + if (response.ok) { ready = true; break; } + } catch { /* Mailpit is starting. */ } + await delay(200); + } + assert.ok(ready, "Disposable SMTP capture must be ready before queueing mail"); + const row = await prisma.outboxMessage.create({ data: { kind: "password-reset", payload: { + to: "worker-fixture@example.test", resetUrl: new URL(`/reset-password/${randomUUID()}`, process.env.AUTH_URL).href, + expiresAt: new Date(Date.now() + 600_000).toISOString(), + } } }); + id = row.id; + // The reset template has a stable subject; correlate this fixture by recipient. + await queue.add(subject, { kind: "outbox", outboxId: id }, { removeOnComplete: true }); + let delivered = false; + for (let attempt = 0; attempt < 100; attempt++) { + const response = await fetch("http://mailpit:8025/api/v1/messages", { signal: AbortSignal.timeout(3000) }); + const messages = await response.json(); + const captured = messages.messages?.some(message => message.To?.some(to => to.Address === "worker-fixture@example.test")); + if (captured && !(await prisma.outboxMessage.findUnique({ where: { id } }))) { delivered = true; break; } + await delay(300); + } + assert.ok(delivered, "The real worker must deliver through SMTP and delete the bearer payload"); + console.log("Real Redis/BullMQ worker delivered SMTP over STARTTLS; Mailpit captured it and the bearer outbox payload was deleted."); +} catch { + console.error("Disposable worker delivery failed; credentials and message contents were suppressed."); + process.exitCode = 1; +} finally { + if (id) await prisma.outboxMessage.deleteMany({ where: { id } }).catch(() => {}); + await queue.close(); + await prisma.$disconnect(); +} diff --git a/docker/vps/postgres-start.sh b/docker/vps/postgres-start.sh index 03af39c..5c1d4fc 100755 --- a/docker/vps/postgres-start.sh +++ b/docker/vps/postgres-start.sh @@ -1,11 +1,16 @@ #!/bin/sh set -eu -mkdir -p /var/lib/postgresql/tls +mkdir -p /var/lib/postgresql/tls /var/lib/postgresql/bootstrap /docker-entrypoint-initdb.d cp /run/server-tls/public.crt /var/lib/postgresql/tls/server.crt cp /run/server-tls/private.key /var/lib/postgresql/tls/server.key -chown -R postgres:postgres /var/lib/postgresql/tls -chmod 600 /var/lib/postgresql/tls/server.key +# Host inputs can be installer-owned 0600/0700; the entrypoint drops to postgres. +cp /bootstrap/pg_hba.conf /var/lib/postgresql/bootstrap/pg_hba.conf +cp /bootstrap/init-database.sh /docker-entrypoint-initdb.d/10-app-role.sh +chown -R postgres:postgres /var/lib/postgresql/tls /var/lib/postgresql/bootstrap +chown postgres:postgres /docker-entrypoint-initdb.d/10-app-role.sh +chmod 700 /var/lib/postgresql/tls /var/lib/postgresql/bootstrap /docker-entrypoint-initdb.d/10-app-role.sh +chmod 600 /var/lib/postgresql/tls/server.key /var/lib/postgresql/bootstrap/pg_hba.conf exec /usr/local/bin/docker-entrypoint.sh postgres \ -c ssl=on -c ssl_cert_file=/var/lib/postgresql/tls/server.crt \ -c ssl_key_file=/var/lib/postgresql/tls/server.key \ - -c hba_file=/etc/postgresql/pg_hba.conf + -c hba_file=/var/lib/postgresql/bootstrap/pg_hba.conf diff --git a/docker/vps/recovery-smoke.mjs b/docker/vps/recovery-smoke.mjs new file mode 100644 index 0000000..b4c3de7 --- /dev/null +++ b/docker/vps/recovery-smoke.mjs @@ -0,0 +1,49 @@ +// Synthetic fixtures only; invoked by the isolated CI recovery smoke. +import assert from "node:assert/strict"; +import { PrismaClient } from "@prisma/client"; +import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +import cryptoHelpers from "../../dist/ops-crypto.js"; + +const { decodeAes256Key, encryptWithKey, decryptWithKey } = cryptoHelpers; +assert.equal(process.env.SWI_RECOVERY_SMOKE, "1", "Explicit disposable recovery opt-in required"); +const mode = process.argv[2]; +assert.ok(["seed", "verify", "empty"].includes(mode), "Choose seed, verify or empty"); +const prisma = new PrismaClient(); +const s3 = new S3Client({ region: process.env.S3_REGION, endpoint: process.env.S3_ENDPOINT, + forcePathStyle: true, credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY } }); +const email = "recovery-fixture@example.test", slug = "deployment-recovery-fixture"; +const plaintext = "synthetic-recovery-secret", content = "private inventory recovery bytes"; +const object = { Bucket: process.env.S3_BUCKET, Key: "spaces/deployment-recovery/fixture.txt" }; +try { + const key = decodeAes256Key(process.env.MFA_ENCRYPTION_KEY, "MFA_ENCRYPTION_KEY"); + if (mode === "empty") { + assert.equal(await prisma.user.count({ where: { email } }), 0, "Recovery target must have no fixture account"); + assert.equal(await prisma.space.count({ where: { slug } }), 0, "Recovery target must have no fixture space"); + await assert.rejects(s3.send(new GetObjectCommand(object)), (error) => error?.$metadata?.httpStatusCode === 404, + "Recovery target must have no private fixture object"); + } else if (mode === "seed") { + const user = await prisma.user.create({ data: { email, name: "Recovery fixture", mfaSecretEncrypted: encryptWithKey(key, plaintext) } }); + await prisma.space.create({ data: { slug, name: "Recovery fixture", memberships: { create: { userId: user.id, role: "MANAGER" } }, + items: { create: { name: "Recovery catalogue fixture", sku: "RECOVERY-FIXTURE", qrCode: "SW-I-RECOVERY-FIXTURE" } } } }); + await s3.send(new PutObjectCommand({ ...object, Body: content })); + } else { + const user = await prisma.user.findUniqueOrThrow({ where: { email } }); + assert.equal(decryptWithKey(key, user.mfaSecretEncrypted), plaintext, "Restored keys must decrypt restored ciphertext"); + const space = await prisma.space.findUniqueOrThrow({ where: { slug }, include: { items: true, memberships: true } }); + assert.equal(space.items[0]?.sku, "RECOVERY-FIXTURE"); + assert.equal(space.memberships[0]?.userId, user.id); + assert.equal(space.memberships[0]?.role, "MANAGER"); + const result = await s3.send(new GetObjectCommand(object)); + assert.equal(await result.Body.transformToString(), content); + const anonymous = await fetch(`${process.env.S3_ENDPOINT}/${object.Bucket}/${object.Key}`); + await anonymous.body?.cancel(); + assert.equal(anonymous.status, 403, "Restored files remain private"); + } + console.log(`Disposable Inventory recovery fixture ${mode} passed (catalogue, membership, ciphertext, keys and private object).`); +} catch { + console.error("Disposable Inventory recovery verification failed; credentials and fixture content were suppressed."); + process.exitCode = 1; +} finally { + s3.destroy(); + await prisma.$disconnect(); +} diff --git a/docs/DEPLOYMENT_VPS.md b/docs/DEPLOYMENT_VPS.md index d8631b9..b3df2d1 100644 --- a/docs/DEPLOYMENT_VPS.md +++ b/docs/DEPLOYMENT_VPS.md @@ -7,8 +7,9 @@ Compose. The stack uses two images of its own: the **backend** (the web application and API, the one-shot migrations, the background worker and beat, and the operator tools) and the **frontend** (the website alone, used only by the two-hostname layout). PostgreSQL 16, Redis 7, private Silo S3-compatible -storage and Caddy HTTPS run from their standard images in the same Compose -file. Node.js, PostgreSQL and Redis do not need to be installed on the host. +storage run from their standard images. The bundled Caddy HTTPS edge is built +locally from pinned sources and security updates in the same Compose file. +Node.js, Go, PostgreSQL and Redis do not need to be installed on the host. Production uses `compose.production.yml` through `scripts/vps-compose.sh`. `docker-compose.yml` starts local development services only; never use it on a @@ -18,12 +19,18 @@ server. - A 64-bit (x86_64 or arm64) Linux VPS: Ubuntu/Debian, Fedora/RHEL/Alma/Rocky, Arch or openSUSE. The installer installs Docker Engine + Compose when missing. - Start with **2 vCPU, 2 GB RAM and 30 GB SSD** for a small installation, with + Start with **2 vCPU, 4 GB RAM with swap available and 30 GB SSD** for the bundled + edge and a small installation, with extra space for photos and backups (setup refuses less than 10 GB free). These are starting estimates, not load-tested capacity limits. Releases run - published images; only a source build (`--ref`) compiles on the server, which - needs about 4 GB of memory. The installer offers a swap file when memory is - short. + published application images and build the bundled patched Caddy edge locally. + A source build (`--ref`) also compiles the applications and needs build + headroom. The installer offers a 4 GB swap file when combined memory and swap + are short. Leave build reserve during updates while the previous services + still run. The measured edge build cgroup peak was 2.21 GiB at two CPUs, + including reclaimable module/file cache; this is evidence rather than a + minimum isolated compiler RSS. An external reverse proxy skips the bundled + edge build. - A domain, such as `inventory.example.org`, whose DNS A record points to the VPS. Add an AAAA record only if IPv6 reaches this host too. Start with DNS-only records; the supplied proxy assumes clients connect directly to @@ -209,6 +216,17 @@ choice (`SWI_CADDY_TLS`: `internal`, `proxy`, or unset for automatic). ### File storage +The bundled server and its CI client use the coordinated October 26, 2026 +Silo release. Images retain readable release tags and immutable multi-architecture +manifest digests (amd64 and arm64): + +- `pgsty/silo:RELEASE.2026-10-26T00-00-00Z@sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7` +- `pgsty/mc:RELEASE.2026-10-26T00-00-00Z@sha256:ce62c9053c69ce12a527a322345b5ad9f44b5f8a7b8189f82e516f2e3f30f1d8` + +Upgrade the storage server and client together, retain encryption keys and +independent backups, then verify private-object access and a restore before +accepting traffic. + Setup also asks where uploads (item photos and handover evidence photos) live: bundled **Silo** on this server (default) or an **external S3-compatible bucket** such as Cloudflare R2 or Amazon S3. For an external bucket, create a private diff --git a/docs/PRODUCTION_AUDIT_2026-10-29.md b/docs/PRODUCTION_AUDIT_2026-10-29.md new file mode 100644 index 0000000..02e44ab --- /dev/null +++ b/docs/PRODUCTION_AUDIT_2026-10-29.md @@ -0,0 +1,398 @@ +# Production audit — 29 October 2026 + +## Verdict and scope + +This is a bounded source, integration, browser and deployment review of the +standalone Space Works Inventory repository, with the other products audited +separately. Verified security, lending and accessibility defects were fixed. +Version **0.6.0** passed the available Linux suites and local clean-stack +acceptance. Both layouts, real queued STARTTLS email before/after recovery, +empty-volume encrypted recovery, private objects, usable restored keys and +complete schema replacement passed. Final backend/frontend image scans report +zero OS/library findings; shared service findings and applicability limits +remain documented below. Native application compilation is pinned to +`7d37829`; the subsequent `07ae15c` changes only runtime copy ownership and CI +fixtures/documentation. Its corrected backend was verified through the +explicit runtime-only derivation and fresh-stack recovery described below. +This is not represented as another local Next.js compilation of that follow-up. +The exact ownership follow-up independently passed all eight CI jobs, including +a complete fresh Dockerfile build, private operator probes and recovery. +Historical 0.5.1 evidence remains labelled separately. No live deployment was +performed by this audit agent. +This report does not certify the absence of other defects or replace validation +of a real host, public DNS, external providers, load and operational ownership. + +The checkout was initially clean. No production database, existing service +container or credential file was used as a test target. No push, merge or release +was performed by this audit agent. Initial Windows checks used disposable +loopback PostgreSQL/Redis; those containers were removed. Final Linux acceptance +uses only `sw-audit-inventory-20261029-*` services in an isolated runner network, +with no host service ports published. The installed Next.js route-handler and +Playwright guides were consulted before framework-related verification. + +## Verified defects and changes + +| Severity | Finding and evidence | Remediation and regression coverage | +| --- | --- | --- | +| High | `lib/events/network.ts` accepted canonical hexadecimal IPv4-mapped IPv6 loopback/private addresses and expanded IPv6 loopback. WHATWG URL canonicalization itself converts dotted mapped addresses to the previously accepted hexadecimal form. A manager-controlled webhook could therefore reach local/private network endpoints. | Normalize and expand IPv6 before checking its words; delegate mapped IPv4 and 6to4 destinations to IPv4 policy; refuse non-global IPv6 scopes. `tests/unit/webhook-network.test.ts` covers 28 private/public/URL-canonicalization cases. Existing DNS pinning and redirect refusal remain in the delivery layer. | +| High | `issueLoan` locks only its own loan row; simultaneous handovers for distinct loans of one borrower could each read the same active-loan/item allowance and exceed the space limit. Self-checkout checked the same allowance before shared sequence/stock locks. | Add a transaction-scoped, tenant-and-borrower advisory lock before enforced handover eligibility in both desk issue and self-checkout. It avoids inverting membership row locks used after returns/incidents. Real PostgreSQL regressions cover desk/desk, self/self and mixed handovers. | +| Medium | `containerFor` checked whether a container was already out without locking the shared container. Two loans for different borrowers could both claim it. | Lock the tenant-scoped container before checking active loans and reread archive state under that lock. Concurrent shared-container regression proves only one handover succeeds. | +| Medium | Borrowing eligibility considered membership activity but omitted account disablement and the repair-only technician role. Staff/API workflows could issue loans to accounts denied the member UI. | Check current user `disabledAt` and require at least `MEMBER`. Database regression covers both refusals. | +| Medium | Self-checkout obtained StockLevel locks before `takeFree` obtained Item locks. Lending loops also acquired items one at a time in arbitrary line order, creating avoidable deadlock paths. | Acquire the complete Item set in sorted order before lending approve/cancel/issue/return/self-checkout stock loops. Existing availability code continues to own quantity and asset writes. Affected lending integration suites were rerun. | +| Medium | Registry dependency audit reported five direct Nodemailer advisories in installed 10.0.10 (recipient parsing and SMTP authentication regex behavior). | Upgrade exact dependency and lockfile to 10.3.1. Audit again after installation; no incompatible major dependency migration was attempted. | +| High advisory; migration CLI | Prisma's development/migration dependency resolved vulnerable `deepmerge-ts` 7. The isolated Docker migration CLI install did not inherit root dependency overrides. No public request path into this CLI dependency was identified. | Pin `deepmerge-ts` exactly 8.0.2 via root override/lockfile and copy only sanitized overrides into the isolated CLI stage before installation. Prisma generation, migrations, application suites and full dependency audit pass. | +| Medium | A `Field` hydration fallback assigned the visible Radix dropdown's ID to its preceding aria-hidden native select. Duplicate IDs stole the visible control's accessible label; the full transfer receipt browser journey failed at the labelled destination selector. | Ignore aria-hidden controls in the fallback selector. Keep the existing labelled browser assertion unchanged; complete monolith and split transfer receipt journeys now pass. | +| Low; mobile UI | Authenticated 390px visual acceptance showed a wrapped transfer reference colliding with the direction caption because the touch-target link uses negative vertical margins. | Keep the transfer reference on one line; the table still scrolls within its own region. Both production backend/frontend images rebuilt successfully. Eight authenticated desktop/mobile catalogue, lending, transfer and transfer-detail screenshots passed; DOM geometry confirms a single-line reference with no caption overlap, and both existing transfer journeys pass against the final backend image. | +| Low; validation gap | CI provisioned Silo without wiring its endpoint into integration tests, and hosting jobs supplied no demo seed, silently skipping product journeys. Four evidence tests also assumed disk storage paths. | Wire real private Silo into CI, assert stored objects through the configured backend, and run fresh onboarding before seeded full browser journeys in both hosting layouts. Add an LF/CRLF equivalence/content-change regression for the existing normalized frontend source hash. | +| Low; acceptance gap | The initial recovery harness removed its SMTP capture before restore and only checked generic worker health afterward. Its split-to-single switch also kept the API hostname while authentication used the website origin. | Extract a reusable guarded worker/STARTTLS smoke and invoke it both before backup and after restore on the new network. Align the single public hostname with the configured auth/public origins and assert their equality. Both final harness contracts passed in the packaged clean-stack acceptance. | +| High; recovery correctness | Both VPS and alternate database CLI used `pg_restore --clean`, which removes only objects present in the archive. Newer-release tables absent from an older backup survived recovery. A direct producer pipe could also apply a valid truncated prefix after archive generation failed. | Stage the complete SQL privately before applying one transaction that replaces `public`. VPS restore retains archive schema ACL/comments and narrow runtime/default grants; the generic CLI preserves `--no-owner --no-privileges` and refuses other user schemas. Seven helper/source-execution regressions and four actual PostgreSQL 16 generic-CLI scenarios passed. Actual encrypted VPS recovery explicitly creates and then verifies removal of a target-only table. | +| High; failed recovery availability | Cleanup restarted the deployment after a database commit even when object restoration, migrations or readiness failed, potentially serving recovered rows against incomplete files. | Track successful SQL commit. Before commit, rollback failures may restart the previous deployment; after commit, stop all serving processes (including any partially restarted ones), keep the site offline and instruct the operator to repair and retry the full restore. Six actual cleanup-function phase regressions passed in Inventory and independently against Workspace source, including object/migration/readiness failures. | +| Low; lifecycle correctness | Switching from split to single left the inactive profiled frontend container behind, so ordinary teardown did not fully remove the prior layout. | `vps-compose.sh down` selects all profiles. An actual shell-selection regression and final clean-stack teardown passed; no final split frontend or deployment service remains. | +| Low | `SECURITY.md` documented an unreleased 0.1 release line and obsolete owner/admin tenant roles. | Align security policy with 0.6 and the authoritative manager/staff/member/technician model. | +| Medium | Local quickstart configured Redis but started only the Next.js process, leaving the BullMQ outbox and schedules unprocessed. Mailpit existed in Compose but SMTP defaults were blank; local Redis had no durable job volume. | Add a cross-platform `dev:stack` supervisor, wire `local.sh` to web/worker/beat, use separate loopback worker health ports, drain workers with IPC/signals before stopping Docker, and stop siblings when a child fails. Add loopback-only PostgreSQL/direct/Redis/S3/SMTP checks before local migrations. Enable local Redis append-only persistence/noeviction and configure local-only Mailpit capture defaults. Eleven launcher/guard tests, targeted lint/types and ShellCheck passed. | + +## Version 0.6.0 follow-up + +The backend copied `public/` as root while preserving file modes. A checkout +created under a private umask therefore yielded root-owned 0700 directories +that the serving UID 1001 could not enumerate. Copy public assets with explicit +`nextjs:nodejs` ownership; existing frontend copies already use that owner. +The real CI installer creates only a new owned nested 0700/0600 probe, builds +it into the image and runs Node as the default nonroot user to verify ownership, +mode retention, directory enumeration and file contents. Its existing cleanup +trap removes the probe even when a build fails; existing checkout permissions +are never changed. A source-executed failure regression checks this lifecycle. + +The first PR 5 CI run passed application lint/types/units/integration and both +browser layouts, then failed in the installer fixture because sudo-rs ignored +`sudo -E` and dropped `SWI_CONFIG_DIR`. The fixture now passes only `CI`, the +configuration directory and disposable project name through `sudo env`; +credentials remain in private configuration files and unrelated environment +secrets are not forwarded. The root-run native proof remains valid for its +original scope; it did not exercise this nonroot CI handoff. + +Focused follow-up verification: **11 tests in two files passed**, focused +ESLint, Bash syntax and ShellCheck passed. Separate disposable Linux execution +of the actual harness confirms 0700/0700/0600 probe modes, cleanup after a +build failure, unchanged existing asset contents/modes, and correct minimal +environment handoff through an environment-clearing fake sudo. The actual +0.6.0 container assertion subsequently passed: UID 1001 enumerates and reads +the packaged private-mode assets. The earlier Windows Git Bash focused rerun +encountered two child-process timeouts during contention; the final three +affected Linux tests passed in 1.90 seconds without relaxing their assertions. + +The same private-mode class affected directly copied `scripts/*.mjs` and +`docker/vps` sources. Both copies now use explicit `nextjs:nodejs` ownership. +The CI fixture adds only new owned 0600 scripts and a nested 0700 operator +directory with a 0600 module, then verifies UID 1001 ownership, exact private +modes, enumeration and contents. It does not assert a private mode on the +shared parent directory, whose metadata Docker can update during a later copy. +Cleanup regressions verify all new fixtures are removed after a failed build +without changing existing checkout files. + +The independent `deploy/cloudflare-cron/package-lock.json` was also audited +separately. The root audit does not include this auxiliary deployment package. +Its initial audit reported **one high and two moderate** entries through +undici 7.29.1 and its parent tooling. A compatible lockfile update resolves +Wrangler 4.156.0, undici **7.30.1** and workerd 1.20261023.1; the exact workerd +install-script allowlist pin follows that locked version without broadening it. +The nested audit now reports **zero vulnerabilities**. The upstream +[undici advisory](https://github.com/nodejs/undici/security/advisories/GHSA-xjj6-f33q-929c) +identifies 7.30.1 as a fixed release. No application request path into Wrangler +was identified; it is excluded from application builds. + +An actual Wrangler `deploy --dry-run` succeeded from a Git-visible-only copy +with an explicit environment allowlist, private temporary HOME/profile/cache, +metrics disabled and no inherited Cloudflare credentials. It bundled 2.10 KiB; +no Worker was deployed. Evidence is under `inventory/v060/cron-audit/`. A separate +quality job now installs this independent lockfile with scripts disabled, +checks high-severity advisories and repeats the bundle dry-run with an empty +profile. This result is separate from the root application's two moderate +ExcelJS/UUID entries, which remain tracked. + +Private PostgreSQL HBA/init inputs are now staged from read-only bootstrap +mounts into postgres-owned runtime files before the official entrypoint drops +privileges. A real PostgreSQL 16 probe passed with source modes 0600/0700, +TLS enabled, `swi_app` initialized and nonroot read/execute/mode assertions. +The exact installer now runs this isolated probe without changing existing +checkout permissions. Evidence: `inventory/v060/bootstrap-acceptance/probe.log`. + +Encrypted backup packaging precreates its excluded output before GNU tar reads +directory metadata. A deterministic checkpoint regression reproduces the prior +`file changed as we read it` failure and passes with the actual precreation +step. The actual age pipeline also passed checkpoint injection, decryption and +byte comparison (`inventory/v060/backup-encrypted-race-proof.log`). Tar errors +remain fatal. Both the full 0.6.0 native drill and the ownership-corrected +runtime recovery drill subsequently passed. + +## Final 0.6.0 acceptance + +The actual source build at `7d3782960211aada9499c2c5855f0250e569d8bd` +passed backend compilation/types/static generation and the frontend +contract/types/build/boundary checks. Its exact checked-in two-image installer, +private public-asset and PostgreSQL probes, both layouts, real queued STARTTLS +delivery and age-encrypted recovery passed at **2026-10-29 21:19:43 UTC**. + +The ownership follow-up is `07ae15ca78614bd8caf5cabde8927c38f73e3eee`. +Git-tree and complete sanitized source-archive comparisons found changes only +in `Dockerfile`, the CI installer fixture, its existing unit test and the +changelog, plus generated `.release` metadata. All other source bytes/modes +match. Both archives were independently checked against every Git-tracked path: +**1,067 of 1,067 files present, zero omissions or extras** (apart from generated +release metadata). `inventory/v060/source-coverage.json` records that check. +Build-instruction comparison permits only the two final runtime COPY +ownership changes. The application Git-tree hash is +`97819092af86ebbdf5e49650c99099a42cc9b89e817068ae467020859062fc29`; +the corresponding sanitized application input hash is +`c27d81579a2be0607120ef65ca8be92ba3d45c184f0037c070c48950a461aa08`. + +Local verification reuses the compiled application image above, applies only +those two direct `COPY --chown` operations and restores the default user. All +base filesystem layers remain unchanged, with exactly two added COPY layers; +the frontend image is unchanged. The actual private script/operator assertions +passed as UID 1001. The checked-in installer configuration/recovery tail then +passed again with a fresh project/configuration and empty database/object +volumes at **2026-10-29 21:34:40 UTC**. This is explicitly runtime-only reuse, +not a fresh local Next.js compilation of the ownership follow-up. Exact recipe, +base ID, source equivalence, layer proof, deployed image IDs and logs are in +`inventory/v060/runtime-only/` outside the repository. CI must independently +build and test the complete committed Dockerfile: all eight jobs for `07ae15c` +subsequently passed, including that fresh build and recovery. The final +documentation commit still requires its own checks before merge. + +| Final gate | Result and evidence | +| --- | --- | +| Available application CI | All **8 jobs passed** for both `7d37829` and `07ae15c`: full lint/types, **1,320 unit tests / 64 files**, **189 PostgreSQL integrations / 33 files**, both full browser layouts, nested cron audit/bundle, root advisory gate and actual installer/recovery. The latter builds the exact committed Dockerfile with all private operator probes. The final documentation head requires its own green checks before merge. | +| Full deployment gate | **73 scripts/manifests passed**, including default-severity ShellCheck, Bash/MJS syntax and YAML parsing. `v060/ownership-deployment.log`. | +| Affected source regressions | **3 Linux tests passed / 2 files**, including actual fixture cleanup/modes/sudo and deterministic GNU tar race behavior; focused ESLint passed. `v060/ownership-linux-units.log`, `ownership-focused-eslint.log`. | +| Native/runtime proof | Both images: Node 22, package version **0.6.0**, UID 1001 and no global npm/npx. Backend: isolated CLI `deepmerge-ts` **8.0.2**, native Sharp WebP/SVG fonts and nonroot readability of real operator sources. Frontend: no backend database/mail/Redis/storage clients. `v060/final-native-images.json`. | +| Corrected clean-stack recovery | Both layouts, strict internal TLS, least-privilege runtime role, **61 models**, private object HTTP 403/bytes, usable recovered ciphertext keys and membership/catalogue data, target-only schema residue removal, and actual BullMQ STARTTLS delivery before **and after** restore passed. `v060/runtime-only/acceptance.log`, `status`, `finished`. | +| Root and independent dependency audits | Root: **0 critical / 0 high / 2 moderate**, tracked ExcelJS/UUID advisory. Independent cron lock: **zero findings** and credential-free bundle dry-run. These audits cover different lockfiles. | +| Exact image vulnerability scans | Both final application images: **zero OS/library findings** at all severities with the frozen October 29 database. Complete unsuppressed reports: `image-security/inventory-060-backend-final/` and `inventory-060-frontend-final/`. Shared stack findings remain below. | + +Final local image IDs: + +- Backend runtime correction: `sha256:cf235b648d56faedfdaec6ba866364fd3c25334ea04b65cde3beb88a5fa372a7`. +- Unchanged frontend: `sha256:2130446bab21b86cd1b16dbcd853adbb47393ab8b5510a2df7e5bdfb3d71996f`. +- Actual deployed edge: `sha256:f5a8fdd2c2aeefcd1f2baa7d7a7a0add61422c50f47bd0e24e70412dd4606b08`. + +The user authorized publication through reviewed `dev` and then `main` PRs +after their required checks pass. Publication is coordinated separately; this +report does not claim a real-host deployment, provider validation or future +CI results. The remaining external acceptance boundaries below still apply. + +## Coverage and evidence + +| Area | Reviewed paths / verification | +| --- | --- | +| Accounts, authentication, authorization | `lib/auth.ts`, `lib/permissions.ts`, `lib/actionContext.ts`, `lib/mfa.ts`, `app/actions/account.ts`, `lib/roles.ts`, `lib/membership.ts`, API key authentication and scopes. Checked disabled-user/session-version revocation, password/magic-link MFA, TOTP replay claims, admin step-up, active tenant membership and last-manager protections. Existing role/page guard/member integration tests passed. Production browser journeys cover password, one-use magic-link and member/staff boundaries; MFA replay/recovery remains covered by unit/database suites, not a new interactive MFA journey. | +| Tenant boundaries and public access | Space context and query scoping, loan ownership not-found behavior, API principal-derived space IDs, transfer side checks, public catalogue and private evidence/image routes. Database suites exercise API workflows, member ownership, catalogue images and evidence visibility. | +| Stock, loans and transfers | Central availability locking and CHECK constraints; receipts, weighted cost, holds/releases, serialized issue/return/loss, consumables, approval/payment gates, renewals, restrictions, transfer mapping/dispatch/receipt/returns and loan limits. Full availability, lending, transfers and regression suites passed on PostgreSQL. | +| Counts, purchasing and finance | Count staleness/four-eyes policy, receipt into stock, purchase payment ledger posting, partial returns/incidents, repair quarantine and payout posting, payment/refund row locks, webhook signatures and deduplication. Full count/purchase/repair/finance/payment integration suites passed. Real payment-provider settlement was not attempted. | +| Files and imports | Tenant-prefixed object keys, authorized response routes, image decode/pixel limits and re-encoding, CSV/XLSX limits and import workflows. All image/import/evidence integration tests passed with real private Silo, including successful photo writes and rollback cleanup. XLSX expansion/resource-exhaustion limits and large-file load behavior need separate stress testing. | +| Integrations and jobs | Hashed API secrets, scoped API wrappers, signed outbound delivery, DNS pinning, webhook retry policy, SQL durable outbox, BullMQ queue definitions/worker/beat, scheduler registrations and production Compose worker health. Added real Redis worker integration: SQL outbox commits without sending, worker invokes a mocked transport and marks SENT, Redis holds only the outbox ID, repeated relay IDs deduplicate. A separate real worker/beat smoke delivered a synthetic email exactly once into local Mailpit and drained both processes through IPC. No external recipient was contacted. | +| Deployment and operations | One-line `install.sh`, `local.sh`, production/local Compose, migrator/worker/beat, TLS/private data network, external proxy/storage overrides, CI, release scripts, backup/recovery configuration and environment preflight. Compared Events reference read-only: its legacy local Compose contains PostgreSQL only, while Inventory already contains requested PostgreSQL/Redis/Silo stack and private bucket initialization. Packaged split/single bootstrap, authenticated STARTTLS worker delivery, encrypted fresh-volume/key/private-object restore and target-only schema cleanup passed. Full VM/systemd/public-DNS provisioning and failed upgrade/rollback remain separate boundaries. | + +## Historical 0.5.1 functional baseline + +Final acceptance ran source snapshots excluding credential files in +`spaceworks-acceptance:20261029`: Ubuntu 24.04, Node 22.23.4, Playwright +1.64.1 Chromium, Docker CLI 29.1.3, Compose 2.40.3, Buildx 0.20.1, age and +ShellCheck. Exact toolchain, command harnesses, logs, statuses, browser traces +and source archives are outside the repository in +`../audit-evidence/2026-10-29/inventory/`. The runner was limited to 5.5 GiB, +3 CPUs, with Vitest workers limited to one or two. Dependencies were installed +with `npm ci`; the security override was then applied with a bounded install. +All database URLs, Redis namespaces, object buckets and email recipients were +explicit disposable fixtures. No ambient application configuration was loaded. + +| Baseline gate | Historical 0.5.1 result | +| --- | --- | +| Prisma validate and migrations | Passed; all six committed migrations applied to empty test databases. | +| Full lint and root typecheck | Final `npm run lint` and `npm run typecheck` passed after all application and installer fixture changes. `final-security-lint.log`, `final-security-types.log`. | +| Full Linux units | Final `npx vitest run --maxWorkers=1`: **62 files / 1,315 tests passed, zero skips**, with the disposable Redis fixture active. Includes launch/signal/remote-service guards, hash portability, operator scripts, file limits and nine source-executed edge-build failure/conditional-caller regressions. | +| Full database integrations | `npx vitest run --config vitest.integration.config.mts --maxWorkers=1`: **33 files / 189 tests passed**, using actual PostgreSQL, Redis and private Silo. Includes handover concurrency and worker delivery. | +| Worker and beat bundles | `npm run build:jobs` passed, including the authoritative crypto helper used by recovery verification. | +| Backend production build | Final Linux `npm run build` passed. Earlier Windows retry also passed after avoiding the concurrent Prisma DLL lock. | +| Frontend contract/types/build | Regenerated stale contracts after source changes; 64 guarded views, 158 actions, 222 transport types and 12 UI types. Frontend typecheck and build passed; dependency traces contain no backend persistence/mail/storage client. | +| Empty-install onboarding | Independent fresh-database production browser run: **1 passed in monolith; 1 passed in split**. | +| Full monolith browsers | `npx playwright test --workers=1`: **23 passed / 2 expected skips** (setup already completed and split-only contract). The initial labelled-transfer failure is preserved separately and fixed without weakening the assertion. | +| Full split browsers | Same full suite against a narrow-environment frontend and separate action keys: **24 passed / 1 expected setup skip**. | +| Real local architecture | Built worker and beat became healthy, delivered a SQL-outbox email exactly once through BullMQ into Mailpit, verified private Silo put/get and anonymous HTTP 403, then drained through supervisor IPC. | +| Deployment syntax | Last complete `npm run deployment:check`: **72 scripts/manifests passed** (Bash syntax, ShellCheck, MJS syntax and YAML parsing). No Helm chart or Python source gate exists in this repository. | +| Packaged installer/recovery | **Passed three times; the final run covers npm-free images, pinned October 26 storage, the patched edge, complete schema replacement/residue removal and the final cleanup guard.** Real `scripts/test-vps-install.sh` builds/loads both images, bootstraps split and single layouts, rejects plaintext PostgreSQL and anonymous storage, checks least-privilege runtime role/readiness, and delivers actual BullMQ SMTP over authenticated STARTTLS. Age-encrypted off-host archive restores onto a new project/configuration and destroyed/recreated database/object volumes. Fixture account/space/object absence is checked first; target-only table disappears, recovered keys decrypt ciphertext, membership/catalogue/object bytes match, private object stays HTTP 403, and queued SMTP passes again after restart. Logs: final `installer.log`/`installer.status`, earlier `before-image-hardening-installer.log` and `installer-first-pass.log`. | +| Generic CLI restore PostgreSQL contract | **4 passed** on own PostgreSQL 16: complete older-schema replacement/data/public CREATE denial; late SQL failure atomically rolls back; generation failure never mutates target and cleans staging; unrelated user schema is refused unchanged. `restore-pg-contract.log`, helper SHA-256 `b68c2f460256a18b2c63187b4dcbb26dc123c9c771db1be4edfb43b6d4d0f214`. | +| Packaged native/runtime dependency proof | Both final images: Node 22, UID 1001 and absent global npm/npx passed. Backend isolated migration CLI `deepmerge-ts` **8.0.2**, native Sharp WebP/SVG fonts and frontend exclusion of backend database/mail/storage/queue clients passed. `final-native-images.json`, `final-backend-native.log`, `final-frontend-native.log`. | +| Selected visual evidence | Eight final authenticated catalogue, lending queue, transfer list and actual transfer-detail screenshots at desktop 1440x1000/mobile 390x844, using the rebuilt backend image: all page overflow zero; transfer reference single-line/caption separation asserted. Existing `transfers.spec.ts`: **2 passed / 0 retries**, including approval, scanned dispatch, photo evidence and receipt. `visual-product-final/`, `visual-product-final.log`; representative screens inspected. Earlier HTTPS-edge login screenshots remain under `visual/`. This is representative visual acceptance, not every possible screen/state. | +| Dependency audits | Both complete and production-only npm audits: **0 critical, 0 high, 2 moderate**; remaining entries are ExcelJS and transitive UUID under the same advisory. | +| Final installer source checks | After pinned storage, edge wiring and the swap-budget change: **24 focused tests in 3 files passed**, deployment/ShellCheck **72 checks passed**, and focused ESLint passed. Seven extracted actual setup branches verify source/published installs, bundled/external edge, existing memory/swap and an existing swapfile without changing the host. | +| Whitespace | Host/Linux source diff checks passed. | + +Earlier Windows runs were not all green: the constrained full unit rerun had +1,263 passes and five Bash/POSIX-path/fixture timing failures. The final complete +Linux suite above is the release-platform result. Initial isolated Linux +installer fixtures also passed 5 files / 24 tests. The initial Windows local +SMTP probe exceeded its deadline during host contention; the complete actual +Linux worker/Mailpit/Silo probe now supersedes that incomplete experiment. + +The shared Next config now explicitly sets `experimental.cpus: 2`, inherited +by the frontend; installed memory/static-generation documentation and worker +selection code were consulted. Final Alpine images rebuilt successfully using +two workers after the earlier Docker export outage. That incident and the +truncated first font download are preserved in historical logs, superseded by +the complete successful final drill. No mirror/TLS bypass was introduced. + +The final host-only recovery cleanup guard leaves serving processes offline +after post-commit object/migration/readiness failures. Focused source-executed +phase tests, ShellCheck, ESLint and the complete unit suite passed; the final +full packaged happy-path drill subsequently passed with this guard present. +No live object-failure injection is claimed. The host restore script is not +copied into the runtime app image. + +A subsequent authenticated mobile capture found a transfer-reference overlap; +the final no-wrap fix was linted, its frontend contract regenerated and checked +on Windows/Linux (source hash `7758b3f61555487fdfcd926b6dad12b0ce8fd5950646e673ba92f5efffe8ee63`), +and both actual production images rebuilt with type/static/boundary checks. +Eight final image-based product screenshots and the two existing transfer +journeys then passed. Prior complete domain/browser suites remain the broader +coverage; they were not repeated solely for this single CSS class. + +UI-tested image IDs, before the package-manager removal below: + +- Backend: `sha256:74d38959b749b019d34ecf32f79661bb057aee50a5b577909a99b17b9fb434b1`. +- Frontend: `sha256:d3988e0fa283b858092bc20f76819d22e7681f788199f527a85ec3d293b97e5d`. + +Historical 0.5.1 image-security/installer-tested image IDs: + +- Backend: `sha256:80d7634c48051b4df737e0fdb2ccb08c4bba177d96ddf00b319555b6e8133184`. +- Frontend: `sha256:1204d5fd590f23f7cc71125b4da81840ad15cb4d0366e0d6e2a018a3b576dcf4`. + +The exact checked-in installer/restore harness finished successfully at +2026-10-29 19:41:35 UTC. Its first final-image attempt encountered a registry +`ECONNRESET` during dependency installation; the unchanged locked build was +retried and completed. The failed attempt remains under +`final-attempt1-network-installer.log`; no registry/TLS bypass was introduced. + +## Container vulnerability follow-up + +Official Trivy 0.76.1 was verified through its release ZIP checksum and exact +executable member hash. Its vulnerability database v2 was updated on +2026-10-29 at 13:09:46 UTC. Existing local images were scanned for OS and library +vulnerabilities, with offline dependency analysis, telemetry disabled and no +severity or unfixed-package suppression. Complete JSON, image IDs, provenance +and findings are outside the repository in +`../audit-evidence/2026-10-29/image-security/`. + +The earlier UI-tested Inventory images contained 10 high, 10 medium and one low finding, +all inside the global npm bundled with the Node base image. No finding was +reported in their detected application, operator or isolated Prisma CLI +packages. The final runtime Dockerfile stages now remove npm/npx; Node and the +isolated CLI remain. Web, worker, beat, migrations and VPS operator entrypoints +invoke Node directly. The separate managed-database CLI remains documented for +a trusted source checkout with PostgreSQL client tools and npm. Both rebuilt +npm-free images passed native and packaged runtime verification; their exact-ID +OS/library scans report **zero findings** at all severities. This does not +supersede the separate moderate UUID source dependency advisory or prove that +all compiled/bundled code is free of vulnerabilities. + +The shared cached stack also has findings. Redis reports zero. PostgreSQL's +one critical and 25 high findings all refer to Go stdlib functions in the gosu +privilege-switch helper, rather than PostgreSQL packages; the upstream +[gosu security policy](https://github.com/tianon/gosu/security) explains why +unused network/archive interfaces need reachability assessment. All 26 CVEs +were mapped to official Go advisory metadata and the exact binary's 3,019 +compiled function names: affected packages or os.Root methods were absent. +No affected gosu call path was identified. This is an independent ELF function +table assessment, rather than a binary govulncheck or exhaustive call-graph proof. + +Refreshing official Caddy retained the same image digest and its one critical +and nine high findings, including an installed Brotli library and Go HTTP/2 +dependencies. The initial shared patched Caddy candidate (x/net 0.60.0) +`sha256:a82d89dfe088d6a703584a22e0d9f1f5f1f1a083242df9261a1e2fe1a4530eab` +was independently scanned: **zero critical, one high, three medium and three +unknown**, with no remaining OS finding. The high entry is go-jose/v3 3.0.5, +which has no listed patched version; its configured-path assessment remains +separate from the raw scan. Inventory now builds that pinned-toolchain/locked +dependency edge after either published app pulls or source builds, with failure +propagation and an external-proxy skip. Internal certificate export uses the +same edge image. The final shared source subsequently advances x/net to 0.62.0 +and Chi to 5.4.0 with verified Go 1.26.10 module locks to address the remaining +fixable findings. The replacement image +`sha256:0956bb7459167ed6f47cc9fbb29e2d3ddb452f7ba1b7313f175a1ebd74c91293` +was scanned unsuppressed: **zero critical, one high, two medium and one unknown**, +with no OS findings. These are the three unpatched go-jose/v3 advisories and +the unmaintained OpenPGP advisory; the earlier fixable findings are absent. +Inventory's actual Compose export is +`sha256:f5a8fdd2c2aeefcd1f2baa7d7a7a0add61422c50f47bd0e24e70412dd4606b08`; +its eight filesystem layers are identical to the reviewed reference above, +while image configuration/Compose metadata differs. Its own unsuppressed scan +also reports **zero critical, one high, two medium and one unknown**. Exact-ID +scan evidence and filesystem equivalence are recorded in +`image-security/shared-caddy-inventory-060-final/`. Both the original and +restored deployment's actual proxy image ID were checked. Final 0.6.0 +fresh-volume recovery passed with this actual edge; the older 0.5.1 variant +`3235b24a…` remains historical evidence. +The final shared assessment identifies the go-jose/v3 vulnerable +symbols in the retained standard ACME-server module, with no such configured +JWK ingress in the shipped reverse-proxy configurations. The older govulncheck +database does not contain that advisory and is not evidence of its absence. + +Silo's official October 26 image removes the prior Go, +pcre2 and JOSE high findings: its remaining one high scanner attribution is +the inherited MinIO CSV advisory, while the exact released Silo source already +uses bounded reads and a 1 MiB record limit. Raw findings remain retained; +this is a source-backport assessment. Its 15 medium and six unknown occurrences +remain in the raw report. Unknown entries include x/net HTML advisories +`CVE-2026-78661` and `CVE-2026-97045` per server/client binary, with module fixes +in 0.62.0; the official image contains 0.61.0 and needs upstream image tracking. +`GO-2026-5932` separately identifies unmaintained OpenPGP code. No affected +application path into these optional parsers was established; absence of such +a path is not claimed. The matching October 26 mc utility has +zero critical/high findings, but retains its medium/unknown inventory. Local, +production and CI image references now pin +those coordinated October 26 releases by verified multi-architecture manifest +digest. Compatibility probes +confirmed the mc/mcli aliases and curl are present. The pinned October 26 stack +passed both the 0.6.0 full native drill and the subsequent ownership-corrected +fresh-volume encrypted recovery drill. + +## Remaining limits and owner checks + +1. A real fresh supported host with systemd, public DNS, ACME, firewall and + off-site provider access remains an operator acceptance boundary. Container + bootstrap/recovery does not prove those external conditions or a complete + failed-upgrade/rollback drill. +2. Real outbound SMTP provider acceptance/inbox delivery and sandbox payment, + refund and duplicate-provider-webhook validation need the owner's external + accounts. Local captures and signed fixture integrations do not prove them. +3. Keep the moderate ExcelJS/UUID advisory tracked. Installed ExcelJS uses + `uuid.v4()`; the advisory affects v3/v5/v6 methods with caller buffers, so no + affected ExcelJS call path was identified. This is reachability analysis, + not a clean audit. [Upstream advisory](https://github.com/advisories/GHSA-w5hq-g745-h8pq). +4. This is not an independent penetration test, exhaustive query authorization + proof, load test or availability guarantee. XLSX expansion/resource usage, + large imports, multi-item transfer/count/repair races and concurrent policy + changes deserve targeted stress testing beyond the complete available suites. + +## Coordinator follow-up + +Standalone preparation now preserves the installed `@img` packages in full, +including Sharp companion DLLs needed by Windows standalone servers. The same +tracing omission was reproduced and its correction verified in Machines. + +The remaining UUID advisory was also checked against ExcelJS's installed source: +its conditional-formatting implementation calls only `uuid.v4()`. The advisory +affects v3/v5/v6 methods with caller-provided buffers, so no affected ExcelJS call +path was identified in this inspection. This is a reachability assessment, not +a clean dependency audit; the two moderate entries remain tracked. +[Upstream advisory](https://github.com/advisories/GHSA-w5hq-g745-h8pq). + +The coordinator executed standalone preparation and verified the prepared Sharp +package loads successfully. Final worker/beat bundles were rebuilt after all +local runtime changes. Earlier temporary Inventory services were removed; final +acceptance services are removed or stopped. Audit cleanup removes only owned +Inventory runtime/source volumes and image tags after their final proof, while +retaining the final source archive, raw logs, screenshots and scan evidence. +The shared Buildx container/state and shared images remain available for the +other products. No global image/volume pruning or unowned-service changes were +performed by this agent. diff --git a/docs/production-operations.md b/docs/production-operations.md index 6065f37..7026ce1 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -80,7 +80,11 @@ connection. Archives and their checksum metadata stay in the ignored `npm run db:restore` takes `--file ` (or `--s3-key`), `--production` and `--confirm-database `, and takes a safety backup first. Photos live in the bucket: copy it with your provider's tools. Do not put secrets in -support tickets or shell history. +support tickets or shell history. The CLI supports the application public +schema only and refuses archives or targets with other user schemas. It stages +the entire archive before one atomic schema replacement, removes tables absent +from the backup, and retains its `--no-owner --no-privileges` policy; the +restoring database user owns recovered objects. ## Background work diff --git a/frontend/contracts.d.ts b/frontend/contracts.d.ts index 5afce70..09e477a 100644 --- a/frontend/contracts.d.ts +++ b/frontend/contracts.d.ts @@ -1,5 +1,5 @@ // Generated transport DTOs. No backend imports. -// Source SHA256: 5749846c8f12359f22cb2b2e8f41ef938240ba1134f581443d8fbd0852500609 +// Source SHA256: 7758b3f61555487fdfcd926b6dad12b0ce8fd5950646e673ba92f5efffe8ee63 export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; export type Views = { "account-page:loadAccountPage": { "user": { "email": string; "name": null | string; "phone": null | string; "mfaEnabledAt": null | Date }; "spaces": Array<{ "id": string; "slug": string; "name": string; "role": "MANAGER" | "STAFF" | "MEMBER" | "TECHNICIAN" }>; "remaining": number; "hasPassword": boolean }; diff --git a/frontend/package.json b/frontend/package.json index aa96551..f865686 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "spaceworks-inventory-frontend", - "version": "0.5.0", + "version": "0.6.0", "private": true, "scripts": { "build": "node ../scripts/generate-frontend-catalog.mjs --check && node ../scripts/prepare-frontend.mjs && next build && node ../scripts/check-frontend-build.mjs", diff --git a/lib/events/network.ts b/lib/events/network.ts index 799676d..a302d37 100644 --- a/lib/events/network.ts +++ b/lib/events/network.ts @@ -12,11 +12,23 @@ export function isPrivateAddress(address: string): boolean { return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || a >= 224; } if (version === 6) { - const lower = address.toLowerCase(); - if (lower === "::" || lower === "::1") return true; - const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mapped) return isPrivateAddress(mapped[1]!); - return /^(fc|fd|fe8|fe9|fea|feb|ff)/.test(lower); + // URL canonicalization also converts dotted IPv4 tails to hexadecimal. + // Expand before classifying so alternate spellings cannot hide loopback, + // mapped IPv4, unique-local or link-local addresses from the check. + const canonical = new URL(`http://[${address}]/`).hostname.slice(1, -1); + const halves = canonical.split("::"); + const left = halves[0] ? halves[0].split(":") : []; + const right = halves[1] ? halves[1].split(":") : []; + const words = [...left, ...Array(8 - left.length - right.length).fill("0"), ...right].map((word) => parseInt(word, 16)); + if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) { + return isPrivateAddress(`${words[6]! >>> 8}.${words[6]! & 255}.${words[7]! >>> 8}.${words[7]! & 255}`); + } + // Only global unicast is routable for outbound webhooks. This excludes + // unspecified/loopback, local scopes and translation-only address ranges. + if ((words[0]! & 0xe000) !== 0x2000) return true; + // 6to4 embeds the destination IPv4 in its next two words. + if (words[0] === 0x2002) return isPrivateAddress(`${words[1]! >>> 8}.${words[1]! & 255}.${words[2]! >>> 8}.${words[2]! & 255}`); + return false; } return true; } diff --git a/lib/lending/access.ts b/lib/lending/access.ts index da9626f..8346abe 100644 --- a/lib/lending/access.ts +++ b/lib/lending/access.ts @@ -5,6 +5,7 @@ import type { DbClient } from "@/lib/db"; import { recordAuditEvent } from "@/lib/audit"; import { emitEvent } from "@/lib/events/emit"; import { createNotifications } from "@/lib/notifications"; +import { hasRoleAtLeast } from "@/lib/roles"; type Result = { ok: true } | { ok: false; error: string }; @@ -77,12 +78,12 @@ export async function checkBorrowingEligibility(client: DbClient, input: { space const membership = await client.membership.findUnique({ where: { userId_spaceId: { userId: input.userId, spaceId: input.spaceId } }, select: { - status: true, borrowingStatus: true, borrowingNote: true, memberNumber: true, - user: { select: { emailVerified: true } }, + status: true, role: true, borrowingStatus: true, borrowingNote: true, memberNumber: true, + user: { select: { emailVerified: true, disabledAt: true } }, space: { select: { lendingEnabled: true, requireVerifiedEmail: true, requireMemberNumber: true, maxActiveLoans: true, maxItemsOnLoan: true } }, }, }); - if (!membership || membership.status !== "ACTIVE") return { ok: false, code: "standing", error: "This person isn't an active member of the space." }; + if (!membership || membership.status !== "ACTIVE" || membership.user.disabledAt || !hasRoleAtLeast(membership.role, "MEMBER")) return { ok: false, code: "standing", error: "This person isn't an active member of the space." }; const { space } = membership; if (!space.lendingEnabled) return { ok: false, code: "lending_off", error: "Lending is switched off in this space." }; if (membership.borrowingStatus !== "GOOD") { diff --git a/lib/lending/workflow/allowance.ts b/lib/lending/workflow/allowance.ts new file mode 100644 index 0000000..0d2c70f --- /dev/null +++ b/lib/lending/workflow/allowance.ts @@ -0,0 +1,14 @@ +import "server-only"; + +import type { Tx } from "@/lib/db"; + +/** + * Different loans have different row locks, but share one borrowing limit. + * Serialize handovers for that borrower before reading active loans. An + * advisory lock avoids taking Membership out of order with return/incident + * workflows, which can update the member's standing after moving stock. + */ +export async function lockBorrowingAllowance(tx: Tx, spaceId: string, borrowerId: string) { + const key = JSON.stringify(["inventory-borrowing-allowance", spaceId, borrowerId]); + await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text`; +} diff --git a/lib/lending/workflow/issue.ts b/lib/lending/workflow/issue.ts index 494d21a..f08ea17 100644 --- a/lib/lending/workflow/issue.ts +++ b/lib/lending/workflow/issue.ts @@ -6,7 +6,7 @@ import { loanRef } from "@/lib/catalogue/references"; import { DomainError } from "@/lib/domainError"; import { emitEvent } from "@/lib/events/emit"; import { recordEvidence, type EvidenceInput } from "@/lib/evidence"; -import { releaseHolds, takeAsset, takeFromHolds } from "@/lib/inventory/availability"; +import { lockItems, releaseHolds, takeAsset, takeFromHolds } from "@/lib/inventory/availability"; import { notifyUsers } from "@/lib/notify"; import { assertIssueAllowed } from "@/lib/payments/charges"; import { formatDayLong } from "@/lib/format/datetime"; @@ -14,6 +14,7 @@ import { checkBorrowingEligibility } from "../access"; import { addDays } from "../policy"; import { lockLoan, loanUrl, type Actor, type LockedLoan } from "./common"; import { resolveScans, type ScanSet } from "./scans"; +import { lockBorrowingAllowance } from "./allowance"; /** Check the container scan: required by policy, free (not on another active loan) and active. */ async function containerFor(tx: Tx, loan: LockedLoan, scans: ScanSet) { @@ -22,6 +23,8 @@ async function containerFor(tx: Tx, loan: LockedLoan, scans: ScanSet) { if (loan.space.requireContainer) throw new DomainError("Pack the items in a container and scan its label.", "invalid"); return null; } + // Two handovers of different loans must not claim the same container. + await tx.$queryRaw`SELECT "id" FROM "Container" WHERE "id" = ${container.id} AND "spaceId" = ${loan.spaceId} FOR UPDATE`; const busy = await tx.loan.findFirst({ where: { containerId: container.id, status: { in: ["ISSUED", "PARTIALLY_RETURNED"] } }, select: { number: true } }); if (busy) throw new DomainError(`${container.label} is already out on ${loanRef(busy.number)}.`, "conflict"); const row = await tx.container.findUniqueOrThrow({ where: { id: container.id }, select: { archivedAt: true } }); @@ -52,12 +55,14 @@ async function issueSerializedLine(tx: Tx, loan: LockedLoan, line: LockedLoan["l */ export async function issueLoan(tx: Tx, input: { spaceId: string; loanId: string; scannedCodes: string[]; evidence: EvidenceInput }, actor: Actor) { const loan = await lockLoan(tx, input.spaceId, input.loanId, "issue"); + await lockBorrowingAllowance(tx, input.spaceId, loan.borrowerId); const units = loan.lines.reduce((sum, line) => sum + line.quantityApproved, 0); const eligible = await checkBorrowingEligibility(tx, { spaceId: input.spaceId, userId: loan.borrowerId, incomingUnits: units }); if (!eligible.ok) throw new DomainError(eligible.error, "forbidden"); await assertIssueAllowed(tx, { loanId: loan.id }); const scans = await resolveScans(tx, input.spaceId, input.scannedCodes); const container = await containerFor(tx, loan, scans); + await lockItems(tx, loan.lines.map((line) => line.itemId)); let allConsumed = true; for (const line of loan.lines) { if (line.quantityApproved === 0) continue; diff --git a/lib/lending/workflow/request.ts b/lib/lending/workflow/request.ts index 39bc54c..4506862 100644 --- a/lib/lending/workflow/request.ts +++ b/lib/lending/workflow/request.ts @@ -6,7 +6,7 @@ import { loanRef } from "@/lib/catalogue/references"; import { nextSpaceSequence } from "@/lib/counters"; import { DomainError } from "@/lib/domainError"; import { emitEvent } from "@/lib/events/emit"; -import { releaseHolds, reserve } from "@/lib/inventory/availability"; +import { lockItems, releaseHolds, reserve } from "@/lib/inventory/availability"; import { notifyUsers, staffIds } from "@/lib/notify"; import { cancelOpenCharges, createLoanCharges } from "@/lib/payments/charges"; import { checkBorrowingEligibility } from "../access"; @@ -64,6 +64,7 @@ export async function requestLoan(tx: Tx, input: { spaceId: string; borrowerId: */ export async function approveLoan(tx: Tx, input: { spaceId: string; loanId: string; quantities: Record | null }, actor: Actor) { const loan = await lockLoan(tx, input.spaceId, input.loanId, "approve"); + await lockItems(tx, loan.lines.map((line) => line.itemId)); let total = 0; for (const line of loan.lines) { const quantity = input.quantities === null ? line.quantityRequested : input.quantities[line.id] ?? 0; @@ -97,6 +98,7 @@ export async function rejectLoan(tx: Tx, input: { spaceId: string; loanId: strin */ export async function cancelLoan(tx: Tx, input: { spaceId: string; loanId: string; reason: string | null; byBorrowerId?: string }, actor: Actor) { const loan = await lockLoan(tx, input.spaceId, input.loanId, "cancel", input.byBorrowerId); + await lockItems(tx, loan.lines.map((line) => line.itemId)); for (const line of loan.lines) { await releaseHolds(tx, { spaceId: input.spaceId, itemId: line.itemId, owner: { loanLineId: line.id } }, actor, { refType: "Loan", refId: loan.id }); } diff --git a/lib/lending/workflow/return.ts b/lib/lending/workflow/return.ts index cb3dfad..4f60915 100644 --- a/lib/lending/workflow/return.ts +++ b/lib/lending/workflow/return.ts @@ -6,7 +6,7 @@ import { loanRef } from "@/lib/catalogue/references"; import { DomainError } from "@/lib/domainError"; import { emitEvent } from "@/lib/events/emit"; import { recordEvidence, type EvidenceInput } from "@/lib/evidence"; -import { bringBackAsset, putBack, recordLossWhileOut } from "@/lib/inventory/availability"; +import { bringBackAsset, lockItems, putBack, recordLossWhileOut } from "@/lib/inventory/availability"; import { notifyUsers, staffIds } from "@/lib/notify"; import { addIncidentCharge, addLateFee } from "@/lib/payments/charges"; import { syncAutomaticRestriction } from "../access"; @@ -35,6 +35,7 @@ async function openIncident(tx: Tx, loan: LockedLoan, line: LockedLoan["lines"][ export async function returnLoan(tx: Tx, input: { spaceId: string; loanId: string; scannedCodes: string[]; lines: ReturnLine[]; evidence: EvidenceInput; byBorrowerId?: string }, actor: Actor) { const loan = await lockLoan(tx, input.spaceId, input.loanId, "return", input.byBorrowerId); if (input.byBorrowerId && !loan.selfCheckout) throw new DomainError("Return this loan at the desk.", "forbidden"); + await lockItems(tx, loan.lines.map((line) => line.itemId)); const scans = await resolveScans(tx, input.spaceId, input.scannedCodes); const byId = new Map(loan.lines.map((line) => [line.id, line])); if (new Set(input.lines.map((l) => l.lineId)).size !== input.lines.length) throw new DomainError("List each line once.", "invalid"); diff --git a/lib/lending/workflow/selfCheckout.ts b/lib/lending/workflow/selfCheckout.ts index c22c893..3e64ec1 100644 --- a/lib/lending/workflow/selfCheckout.ts +++ b/lib/lending/workflow/selfCheckout.ts @@ -7,13 +7,14 @@ import { nextSpaceSequence } from "@/lib/counters"; import { DomainError } from "@/lib/domainError"; import { emitEvent } from "@/lib/events/emit"; import { recordEvidence, type EvidenceInput } from "@/lib/evidence"; -import { availableOf, lockLevels, takeAsset, takeFree } from "@/lib/inventory/availability"; +import { availableOf, lockItems, lockLevels, takeAsset, takeFree } from "@/lib/inventory/availability"; import { notifyUsers, staffIds } from "@/lib/notify"; import { assertIssueAllowed, createLoanCharges, payLaterForLoan } from "@/lib/payments/charges"; import { checkBorrowingEligibility } from "../access"; import { addDays, loanDaysFor } from "../policy"; import { loanUrl, type Actor } from "./common"; import { resolveScans } from "./scans"; +import { lockBorrowingAllowance } from "./allowance"; /** * A member checks items out themselves by scanning their labels (one unit @@ -22,6 +23,7 @@ import { resolveScans } from "./scans"; * standing. Fees must be zero or pay-later must be allowed. */ export async function selfCheckout(tx: Tx, input: { spaceId: string; borrowerId: string; scannedCodes: string[]; quantities?: Record; evidence: EvidenceInput }, actor: Actor) { + await lockBorrowingAllowance(tx, input.spaceId, input.borrowerId); const space = await tx.space.findUniqueOrThrow({ where: { id: input.spaceId } }); if (!space.lendingEnabled || !space.selfCheckoutEnabled) throw new DomainError("Self-checkout is switched off in this space.", "forbidden"); const scans = await resolveScans(tx, input.spaceId, input.scannedCodes); @@ -42,6 +44,7 @@ export async function selfCheckout(tx: Tx, input: { spaceId: string; borrowerId: data: { spaceId: input.spaceId, number: await nextSpaceSequence(tx, input.spaceId, "loan"), borrowerId: input.borrowerId, selfCheckout: true, status: "APPROVED", approvedAt: now, dueAt: addDays(now, loanDaysFor(space, items)) }, }); const ref = { refType: "Loan", refId: loan.id }; + await lockItems(tx, itemIds); for (const item of items) { if (item.tracking === "SERIALIZED") { for (const asset of assets.filter((a) => a.itemId === item.id)) { diff --git a/local.sh b/local.sh index 17faa1e..c5fe754 100644 --- a/local.sh +++ b/local.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Local development helper for macOS (Homebrew) and Linux. # -# ./local.sh start Docker services + .env + migrations + `npm run dev` +# ./local.sh start Docker services + .env + migrations + web/worker/beat # ./local.sh services only start PostgreSQL, Redis, Silo and Mailpit # ./local.sh stop stop the dev server and the Docker services (data kept) # ./local.sh status what is running @@ -58,6 +58,8 @@ start_services() { say 'Starting PostgreSQL, Redis, Silo and Mailpit (docker compose)...' "$yellow" docker compose up -d --wait ensure_env + # Refuse an existing .env that targets a live/remote installation before writes. + node --env-file-if-exists=.env scripts/check-local-services.mjs [[ -d node_modules ]] || { say 'Installing dependencies...' "$yellow"; npm install; } npm run storage:init # Applies committed migrations only; create new ones with `npm run db:migrate -- --name `. @@ -74,12 +76,21 @@ dev_pids() { } stop_all() { - local pids + local pids attempts pids=$(dev_pids) if [[ -n "$pids" ]]; then - say "Stopping the dev server ($pids)..." "$yellow" + say "Stopping the local web/worker/beat stack ($pids)..." "$yellow" # shellcheck disable=SC2086 # one pid per word kill $pids 2>/dev/null || true + # Let worker/beat finish their SQL/SMTP updates before stopping their services. + for ((attempts=0; attempts<20; attempts++)); do + [[ -z "$(dev_pids)" ]] && break + sleep 1 + done + if [[ -n "$(dev_pids)" ]]; then + say 'The local stack is still stopping. Docker services were kept running; retry stop shortly.' "$yellow" + return 1 + fi fi docker compose stop say 'Stopped. Data is kept in the Docker volumes.' "$green" @@ -93,13 +104,14 @@ status() { case "${1:-start}" in start) start_services - say "Starting the dev server on http://localhost:$port (first run: open /setup)..." "$green" + say "Starting web, worker and beat on http://localhost:$port (first run: open /setup)..." "$green" npx prisma generate + npm run build:jobs mkdir -p .data - node node_modules/next/dist/bin/next dev --port "$port" & + PORT="$port" node --env-file-if-exists=.env scripts/dev-stack.mjs & child=$! printf '%s' "$child" > .data/dev.pid - trap 'kill "$child" 2>/dev/null || true; rm -f .data/dev.pid' EXIT + trap 'kill "$child" 2>/dev/null || true; wait "$child" 2>/dev/null || true; rm -f .data/dev.pid' EXIT trap 'exit 130' INT TERM wait "$child" ;; diff --git a/next.config.ts b/next.config.ts index a047f18..178e00d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -20,6 +20,8 @@ const nextConfig: NextConfig = { poweredByHeader: false, allowedDevOrigins, experimental: { + // Bound page-generation workers on shared hosts; CPU quotas do not change os.cpus(). + cpus: 2, proxyClientMaxBodySize: "40mb", serverActions: { // Same-origin by default. Configure explicitly when a trusted proxy diff --git a/package-lock.json b/package-lock.json index 3b17c4d..241a87a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "spaceworks-inventory", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spaceworks-inventory", - "version": "0.5.0", + "version": "0.6.0", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -39,7 +39,7 @@ "lucide-react": "1.49.0", "next": "^16.3.5", "next-auth": "^5.0.0-beta.32", - "nodemailer": "10.0.10", + "nodemailer": "10.3.1", "otpauth": "^9.5.1", "pdf-lib": "1.17.1", "qrcode": "^1.5.4", @@ -69,7 +69,7 @@ }, "frontend": { "name": "spaceworks-inventory-frontend", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "next": "^16.3.5", "react": "19.2.4", @@ -4160,6 +4160,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -6189,13 +6255,23 @@ "license": "MIT" }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", + "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", "dev": true, + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { - "node": ">=16.0.0" + "node": ">=16.9.0" } }, "node_modules/define-data-property": { @@ -9401,9 +9477,9 @@ } }, "node_modules/nodemailer": { - "version": "10.0.10", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz", - "integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.3.1.tgz", + "integrity": "sha512-uF0AFsJC07Y1vdW7p580DKAnh7rAO+4UPSSB3mDCPNDufoiaOebV6JmrzpxB14OjEEEiBsNdYoM2Y+fN8M7BBA==", "license": "MIT-0", "engines": { "node": ">=20.0.0" diff --git a/package.json b/package.json index db215ad..bfb22fc 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "spaceworks-inventory", - "version": "0.5.0", + "version": "0.6.0", "private": true, "license": "AGPL-3.0-or-later", "scripts": { "dev": "prisma generate && next dev", + "dev:stack": "node --env-file-if-exists=.env scripts/check-local-services.mjs && prisma generate && npm run build:jobs && node --env-file-if-exists=.env scripts/dev-stack.mjs", "build": "prisma generate && next build", "start": "next start", "lint": "eslint", @@ -69,7 +70,7 @@ "lucide-react": "1.49.0", "next": "^16.3.5", "next-auth": "^5.0.0-beta.32", - "nodemailer": "10.0.10", + "nodemailer": "10.3.1", "otpauth": "^9.5.1", "pdf-lib": "1.17.1", "qrcode": "^1.5.4", @@ -96,6 +97,9 @@ "vitest": "^4.1.12", "yaml": "^2.8.1" }, + "overrides": { + "deepmerge-ts": "8.0.2" + }, "allowScripts": { "@prisma/client@6.19.3": true, "@prisma/engines@6.19.3": true, diff --git a/scripts/build-jobs.mjs b/scripts/build-jobs.mjs index 76421f6..02ed653 100644 --- a/scripts/build-jobs.mjs +++ b/scripts/build-jobs.mjs @@ -2,7 +2,7 @@ import { build } from 'esbuild'; // Bundle application dependencies too: Next standalone tracing omits worker-only imports. // Prisma remains external so its generated client and native engine retain their paths. await build({ - entryPoints: { worker: 'scripts/queue/worker.ts', beat: 'scripts/queue/beat.ts' }, + entryPoints: { worker: 'scripts/queue/worker.ts', beat: 'scripts/queue/beat.ts', 'ops-crypto': 'lib/crypto.ts' }, outdir: 'dist', bundle: true, platform: 'node', target: 'node22', format: 'cjs', external: ['@prisma/client', 'sharp'], plugins: [{ name: 'server-boundary', setup(builder) { diff --git a/scripts/check-local-services.mjs b/scripts/check-local-services.mjs new file mode 100644 index 0000000..6603a48 --- /dev/null +++ b/scripts/check-local-services.mjs @@ -0,0 +1,9 @@ +import { assertLocalServices } from "./dev-stack.mjs"; + +try { + assertLocalServices(); + console.info("Local PostgreSQL, Redis, storage and email targets verified."); +} catch (error) { + console.error(error instanceof Error ? error.message : "Local service configuration is invalid."); + process.exitCode = 1; +} diff --git a/scripts/db-backup-lib.mjs b/scripts/db-backup-lib.mjs index 91117f9..1870905 100644 --- a/scripts/db-backup-lib.mjs +++ b/scripts/db-backup-lib.mjs @@ -1,3 +1,4 @@ +import { restorePublicArchive } from "./db-restore-public.mjs"; import { createHash, randomUUID } from "node:crypto"; import { access, chmod, lstat, mkdir, readFile, readdir, rename, rm, stat, statfs, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -281,7 +282,7 @@ export async function restoreArchive(archive) { const database = databaseDetails(); const connection = await createDatabaseEnvironment(database); try { - await run("pg_restore", ["--clean", "--if-exists", "--no-owner", "--no-privileges", "--exit-on-error", "--single-transaction", `--dbname=${database.databaseName}`, archive], connection.environment); + await restorePublicArchive({ archive, directory: backupDirectory, environment: connection.environment, run, commandOutput, fail }); } finally { await connection.dispose(); } diff --git a/scripts/db-restore-public.mjs b/scripts/db-restore-public.mjs new file mode 100644 index 0000000..79ee8de --- /dev/null +++ b/scripts/db-restore-public.mjs @@ -0,0 +1,43 @@ +import { appendFile, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createReadStream, createWriteStream } from "node:fs"; +import { pipeline } from "node:stream/promises"; +import path from "node:path"; + +// This CLI restores the application's public schema. Refuse other user schemas +// rather than silently preserving residue or dropping unrelated applications. +const otherSchemas = "SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname <> 'public' AND nspname <> 'information_schema' AND nspname !~ '^pg_')"; + +export async function restorePublicArchive({ archive, directory, environment, run, commandOutput, fail }) { + const contents = await commandOutput("pg_restore", ["--list", archive], environment); + const list = contents.split(/\r?\n/).map((line) => { + if (line.startsWith(";")) return line; + const fields = line.trim().split(/\s+/); + if (fields[3] !== "SCHEMA" || fields[4] !== "-") return line; + if (fields[5] !== "public") fail("Restore supports only public-schema application backups."); + // Preserve separate schema COMMENT entries; --no-privileges remains the + // documented CLI policy, unlike the VPS restore's runtime-role ACL policy. + return `;${line}`; + }).join("\n"); + if ((await commandOutput("psql", ["-X", "-At", "--set", "ON_ERROR_STOP=1", "--command", otherSchemas], environment)).trim() !== "f") { + fail("Restore refuses a target containing other user schemas."); + } + const stage = await mkdtemp(path.join(directory, ".restore-sql-")); + try { + const selection = path.join(stage, "restore.list"); + const payload = path.join(stage, "payload.sql"); + const complete = path.join(stage, "restore.sql"); + await writeFile(selection, list, { mode: 0o600 }); + await writeFile(payload, "", { mode: 0o600 }); + // Materialize the entire archive before psql: a failed producer must never + // allow a valid SQL prefix to commit just because a pipe reached EOF. + await run("pg_restore", ["--no-owner", "--no-privileges", "--exit-on-error", `--use-list=${selection}`, `--file=${payload}`, archive], environment); + await writeFile(complete, `DO $restore_guard$ BEGIN IF (${otherSchemas}) THEN RAISE EXCEPTION 'Restore refuses other user schemas'; END IF; END $restore_guard$;\nDROP SCHEMA IF EXISTS public CASCADE;\nCREATE SCHEMA public AUTHORIZATION CURRENT_USER;\nREVOKE ALL ON SCHEMA public FROM PUBLIC;\n`, { mode: 0o600 }); + await pipeline(createReadStream(payload), createWriteStream(complete, { flags: "a" })); + await appendFile(complete, "\nREVOKE ALL ON SCHEMA public FROM PUBLIC;\n"); + // Credentials and the validated database name stay in the private PG + // environment, never a connection URI or unescaped --dbname argument. + await run("psql", ["-X", "--single-transaction", "--set", "ON_ERROR_STOP=1", `--file=${complete}`], environment); + } finally { + await rm(stage, { recursive: true, force: true }); + } +} diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 0000000..b81df58 --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,133 @@ +// Cross-platform local supervisor. Service provisioning remains in local.sh/Compose. +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function port(value, fallback) { + const number = Number(value || fallback); + if (!Number.isInteger(number) || number < 1 || number > 65535) throw new Error("Local service ports must be integers between 1 and 65535."); + return number; +} + +const loopback = (host) => ["localhost", "127.0.0.1", "::1"].includes(host.replace(/^\[|\]$/g, "").toLowerCase()); + +/** + * Local tooling must never migrate or process jobs against a remote install. + * @param {Record} env + */ +export function assertLocalServices(env = process.env) { + for (const [name, protocols] of [ + ["DATABASE_URL", ["postgresql:", "postgres:"]], ["DIRECT_URL", ["postgresql:", "postgres:"]], + ["REDIS_URL", ["redis:", "rediss:"]], ["S3_ENDPOINT", ["http:", "https:"]], + ]) { + const value = env[name]; + if (!value && ["DIRECT_URL", "S3_ENDPOINT"].includes(name)) continue; + let url; + try { url = new URL(value); } catch { throw new Error(`${name} must name a local service before starting dev:stack.`); } + if (!protocols.includes(url.protocol) || !loopback(url.hostname)) throw new Error(`${name} must use a loopback host for the local stack. Remote services were not changed.`); + } + if (env.S3_BUCKET && !env.S3_ENDPOINT) throw new Error("Set S3_ENDPOINT to local Silo for the local stack."); + if (env.SMTP_SERVICE || (env.SMTP_HOST && !loopback(env.SMTP_HOST))) throw new Error("Use local Mailpit SMTP_HOST for the local stack; remote email was not contacted."); +} + +/** + * @param {Record} env + * @returns {Array<{name: string, args: string[], env: Record, ipc?: boolean}>} + */ +export function localServices(env = process.env) { + if (env.NODE_ENV === "production") throw new Error("dev:stack is for local development only."); + assertLocalServices(env); + const ports = [port(env.PORT, 3000), port(env.SWI_DEV_WORKER_PORT, 3460), port(env.SWI_DEV_BEAT_PORT, 3461)]; + if (new Set(ports).size !== ports.length) throw new Error("Web, worker and beat must use different local ports."); + const common = { ...env, NODE_ENV: "development", JOBS_INPROCESS: "false", SPACEWORKS_LOCAL_STACK: "1" }; + return [ + { name: "web", args: ["node_modules/next/dist/bin/next", "dev", "--port", String(ports[0])], env: common }, + { name: "worker", args: ["dist/worker.js"], env: { ...common, JOBS_HEALTH_PORT: String(ports[1]) }, ipc: true }, + { name: "beat", args: ["dist/beat.js"], env: { ...common, JOBS_HEALTH_PORT: String(ports[2]) }, ipc: true }, + ]; +} + +function forceStop(child) { + if (process.platform === "win32" && child.pid) { + // Next spawns its own server child. Stop only this supervisor's process tree. + const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); + killer.on("error", () => child.kill("SIGKILL")); + } else child.kill("SIGKILL"); +} + +/** Any unexpected child exit fails the stack; signals drain workers before exit. */ +export function supervise(services, { spawnProcess = spawn, signals = process, graceMs = 10_000 } = {}) { + return new Promise((resolve) => { + const children = new Set(); + let stopping = false; + let result = 0; + let timer; + const finish = () => { + if (!stopping || children.size) return; + clearTimeout(timer); + signals.removeListener("SIGINT", interrupt); + signals.removeListener("SIGTERM", terminate); + resolve(result); + }; + const stop = (code) => { + if (stopping) return; + stopping = true; + result = code; + timer = setTimeout(() => { + for (const child of children) forceStop(child); + }, graceMs); + for (const child of children) { + if (child.connected) child.send({ type: "spaceworks.local.shutdown" }, (error) => { if (error) child.kill("SIGTERM"); }); + else if (process.platform === "win32") forceStop(child); + else child.kill("SIGTERM"); + } + finish(); + }; + const interrupt = () => stop(130); + const terminate = () => stop(143); + signals.once("SIGINT", interrupt); + signals.once("SIGTERM", terminate); + for (const service of services) { + if (stopping) break; + let child; + try { + child = spawnProcess(process.execPath, service.args, { + env: service.env, stdio: service.ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit", windowsHide: true, + }); + } catch { + console.error(`[dev:stack] ${service.name} could not start.`); + stop(1); + break; + } + children.add(child); + child.once("error", () => { + children.delete(child); + console.error(`[dev:stack] ${service.name} could not start.`); + stop(1); + finish(); + }); + child.once("exit", (code) => { + children.delete(child); + if (!stopping) { + console.error(`[dev:stack] ${service.name} exited. Stopping local services.`); + stop(typeof code === "number" && code !== 0 ? code : 1); + } + finish(); + }); + } + if (!services.length) stop(1); + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + const services = localServices(); + if (!existsSync("dist/worker.js") || !existsSync("dist/beat.js")) throw new Error("Build the background jobs first with npm run build:jobs."); + console.info("[dev:stack] Starting web, BullMQ worker and beat. Local mail: http://127.0.0.1:8025"); + process.exitCode = await supervise(services); + } catch (error) { + console.error(`[dev:stack] ${error instanceof Error ? error.message : "Startup failed."}`); + process.exitCode = 1; + } +} diff --git a/scripts/prepare-standalone.mjs b/scripts/prepare-standalone.mjs index 32c5f99..b4a50e4 100644 --- a/scripts/prepare-standalone.mjs +++ b/scripts/prepare-standalone.mjs @@ -3,5 +3,7 @@ import { cp, mkdir } from "node:fs/promises"; await mkdir(".next/standalone/.next", { recursive: true }); await cp("public", ".next/standalone/public", { recursive: true, force: true }); await cp(".next/static", ".next/standalone/.next/static", { recursive: true, force: true }); +// Native tracing can omit Sharp's companion libvips libraries on Windows. +await cp("node_modules/@img", ".next/standalone/node_modules/@img", { recursive: true, force: true }); console.log("Prepared the standalone server with public and static assets."); diff --git a/scripts/queue/runtime.ts b/scripts/queue/runtime.ts index c972c2f..ee10346 100644 --- a/scripts/queue/runtime.ts +++ b/scripts/queue/runtime.ts @@ -33,7 +33,7 @@ export async function runProcess(role: "worker" | "beat", start: () => Promise<{ }); const port = Number(process.env.JOBS_HEALTH_PORT || 3460); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid jobs health port."); - server.listen(port, "0.0.0.0"); + server.listen(port, process.env.SPACEWORKS_LOCAL_STACK === "1" ? "127.0.0.1" : "0.0.0.0"); server.on("error", () => { console.error("[queue] health server failed"); void shutdown().then(() => process.exit(1), () => process.exit(1)); }); let shutdownPromise: Promise | undefined; function shutdown(): Promise { @@ -51,4 +51,10 @@ export async function runProcess(role: "worker" | "beat", start: () => Promise<{ })(); } for (const signal of ["SIGTERM", "SIGINT"] as const) process.once(signal, () => { void shutdown().then(() => process.exit(0), () => process.exit(1)); }); + // The local supervisor can drain Node workers on Windows through IPC too. + if (process.env.SPACEWORKS_LOCAL_STACK === "1") process.on("message", (message: unknown) => { + if (message && typeof message === "object" && "type" in message && message.type === "spaceworks.local.shutdown") { + void shutdown().then(() => process.exit(0), () => process.exit(1)); + } + }); } diff --git a/scripts/setup-vps.sh b/scripts/setup-vps.sh index 5ef998f..d680384 100755 --- a/scripts/setup-vps.sh +++ b/scripts/setup-vps.sh @@ -141,12 +141,6 @@ if [[ $has_docker = 1 ]]; then printf '%s\n%s\n' 2.24.4 "$compose_version" | sort -V -C \ || die "Docker Compose ${compose_version:-unknown} is too old; 2.24.4 or newer is needed (Docker's official packages provide it)." fi -# Published images only need memory to run; a source build needs about 4 GB. -offer_swap=0 needed_mb=1800 -[[ $published = 1 ]] || needed_mb=3800 -memory=$(memory_mb) -if (( memory + $(swap_mb) < needed_mb )) && [[ ! -e /swapfile ]]; then offer_swap=1; fi - cat >/dev/tty </dev/tty <<'EOF' File storage (item photos and handover evidence) diff --git a/scripts/test-postgres-bootstrap.sh b/scripts/test-postgres-bootstrap.sh new file mode 100755 index 0000000..9a7213f --- /dev/null +++ b/scripts/test-postgres-bootstrap.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# CI-only proof that PostgreSQL can initialize from installer-owned private inputs. +set -euo pipefail +set +x +[[ ${CI:-} = true ]] || { echo 'PostgreSQL bootstrap acceptance requires isolated CI.' >&2; exit 64; } +: "${SWI_CONFIG_DIR:?}" "${SWI_PROJECT_NAME:?}" +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +name="$SWI_PROJECT_NAME-bootstrap-probe" +if docker container inspect "$name" >/dev/null 2>&1; then + echo 'Refusing an existing PostgreSQL bootstrap probe container.' >&2 + exit 64 +fi +work=$(mktemp -d) +cleanup() { + status=$? + [[ $status = 0 ]] || docker logs --tail 30 "$name" >&2 || true + docker rm -fv "$name" >/dev/null 2>&1 || true + rm -rf -- "$work" + exit "$status" +} +trap cleanup EXIT +umask 077 +mkdir "$work/bootstrap" +cp "$root/docker/vps/"{postgres-start.sh,init-database.sh,pg_hba.conf} "$work/bootstrap/" +chmod 600 "$work/bootstrap/pg_hba.conf" +chmod 700 "$work/bootstrap/init-database.sh" +docker run -d --name "$name" --network none --memory 256m --cpus 1 \ + --tmpfs /var/lib/postgresql/data \ + --volume "$work/bootstrap:/bootstrap:ro" \ + --volume "$SWI_CONFIG_DIR/tls/postgres:/run/server-tls:ro" \ + --env POSTGRES_USER=swi --env POSTGRES_DB=swi \ + --env POSTGRES_PASSWORD=disposable-bootstrap-only \ + --env POSTGRES_APP_PASSWORD=disposable-bootstrap-app-only \ + --entrypoint /bin/sh postgres:16-alpine /bootstrap/postgres-start.sh >/dev/null +for ((attempt = 0; attempt < 60; attempt++)); do + if docker exec "$name" pg_isready -h 127.0.0.1 -U swi -d swi >/dev/null 2>&1; then break; fi + sleep 0.5 +done +docker exec --user postgres "$name" sh -ec ' +test -r /var/lib/postgresql/bootstrap/pg_hba.conf +test -x /docker-entrypoint-initdb.d/10-app-role.sh +test "$(stat -c %a /var/lib/postgresql/tls/server.key)" = 600 +test "$(stat -c %a /var/lib/postgresql/bootstrap/pg_hba.conf)" = 600 +test "$(stat -c %a /docker-entrypoint-initdb.d/10-app-role.sh)" = 700 +test "$(psql -U swi -d swi -Atqc "SHOW ssl")" = on +test "$(psql -U swi -d swi -Atqc "SELECT rolname FROM pg_roles WHERE rolname = '\''swi_app'\''")" = swi_app +' +printf 'Private-mode PostgreSQL bootstrap and runtime role passed.\n' diff --git a/scripts/test-split-hosting.mjs b/scripts/test-split-hosting.mjs index f3d2a72..059ce87 100644 --- a/scripts/test-split-hosting.mjs +++ b/scripts/test-split-hosting.mjs @@ -79,7 +79,6 @@ if (process.argv[2] === "serve-frontend") { if (!ready) throw new Error("Disposable PostgreSQL did not become ready."); } await run(process.execPath, ["scripts/prisma.mjs", "migrate", "deploy"], env); - if (demo) await run(process.execPath, ["scripts/seed-demo.mjs"], { ...env, NODE_ENV: "test" }); await run("npm", ["run", "build"], env); await run(process.execPath, ["scripts/prepare-standalone.mjs"], env); for (const name of [".env", ".env.local", ".env.production", ".env.production.local", ".env.development"]) await rm(`.next/standalone/${name}`, { force: true }); @@ -94,7 +93,14 @@ if (process.argv[2] === "serve-frontend") { } if (!ready) throw new Error("Disposable backend did not become ready."); } - await run("npx", ["playwright", "test", "--workers=1"], { ...env, ...(process.env.PLAYWRIGHT_BROWSERS_PATH ? { PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH } : {}) }); + const browserEnv = { ...env, ...(process.env.PLAYWRIGHT_BROWSERS_PATH ? { PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH } : {}) }; + if (demo) { + // Exercise empty-install onboarding before the demo seed makes setup unavailable. + await run("npx", ["playwright", "test", "tests/e2e/00-setup.spec.ts", "--workers=1"], { ...browserEnv, DEMO_PASSWORD: "" }); + await rm(".data/e2e-auth", { recursive: true, force: true }); + await run(process.execPath, ["scripts/seed-demo.mjs"], { ...env, NODE_ENV: "test" }); + } + await run("npx", ["playwright", "test", "--workers=1"], browserEnv); if (monolith) console.log("Whole-VPS production browser suite passed against disposable dependencies."); else console.log("Split hosting passed with separate action keys and no database/auth/mail/storage credentials on the frontend."); } finally { cleanup(); } diff --git a/scripts/test-vps-install.sh b/scripts/test-vps-install.sh index ea504db..273a4f7 100755 --- a/scripts/test-vps-install.sh +++ b/scripts/test-vps-install.sh @@ -11,29 +11,78 @@ SWI_PROJECT_NAME="swi-smoke-$(date +%s)" export SWI_CONFIG_DIR SWI_PROJECT_NAME cleanup() { status=$? + docker rm -f "$SWI_PROJECT_NAME-mailpit" >/dev/null 2>&1 || true if [[ $status != 0 && -f "$SWI_CONFIG_DIR/compose.env" ]]; then # Only this disposable deployment; never dump interpolated configuration. as_root sh scripts/vps-compose.sh ps || true as_root sh scripts/vps-compose.sh logs --tail 40 app frontend worker beat || true fi [[ ! -f "$SWI_CONFIG_DIR/compose.env" ]] || as_root sh scripts/vps-compose.sh down --volumes --remove-orphans >/dev/null 2>&1 || true + [[ -z ${public_probe:-} ]] || rm -rf -- "$public_probe" + [[ -z ${scripts_probe:-} ]] || rm -f -- "$scripts_probe" + [[ -z ${vps_probe:-} ]] || rm -rf -- "$vps_probe" as_root rm -rf -- "$work" } trap cleanup EXIT # The real installer runs as root; config files are root-owned with mode 600. -as_root() { if [[ $(id -u) = 0 ]]; then "$@"; else sudo -E "$@"; fi; } +as_root() { + if [[ $(id -u) = 0 ]]; then + "$@" + else + # sudo-rs may ignore -E; only these nonsecret fixture coordinates are needed. + sudo env CI=true "SWI_CONFIG_DIR=$SWI_CONFIG_DIR" "SWI_PROJECT_NAME=$SWI_PROJECT_NAME" "$@" + fi +} mkdir -m 700 "$SWI_CONFIG_DIR" +# Only this new owned fixture is restrictive; existing checkout permissions stay intact. +public_probe=$(mktemp -d "$root/public/audit-permissions.XXXXXX") +mkdir -m 700 "$public_probe/nested" +(umask 077; printf 'inventory-public-permission-fixture\n' > "$public_probe/nested/readable.txt") +scripts_probe=$(mktemp "$root/scripts/audit-permissions.XXXXXX.mjs") +vps_probe=$(mktemp -d "$root/docker/vps/audit-permissions.XXXXXX") +(umask 077 + printf 'export default "inventory-script-permission-fixture";\n' > "$scripts_probe" + printf 'export default "inventory-operator-permission-fixture";\n' > "$vps_probe/readable.mjs") export SWI_BUILD_KEY SWI_BUILD_KEY=$(openssl rand -base64 32) docker build --target runner --secret id=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,env=SWI_BUILD_KEY \ --build-arg NEXT_PUBLIC_BASE_URL=https://spaceworks-inventory-origin.invalid -t swi-backend:smoke . +# The default serving user must retain access without relaxing the private modes. +docker run --rm --network none --entrypoint node \ + -e SWI_PUBLIC_PERMISSION_PROBE="${public_probe#"$root/"}" \ + -e SWI_SCRIPT_PERMISSION_PROBE="${scripts_probe#"$root/"}" \ + -e SWI_VPS_PERMISSION_PROBE="${vps_probe#"$root/"}/readable.mjs" swi-backend:smoke -e ' +const assert = require("node:assert/strict"), fs = require("node:fs"), path = require("node:path"); +const probe = path.join("/app", process.env.SWI_PUBLIC_PERMISSION_PROBE); +assert.equal(process.getuid(), 1001); +for (const directory of [probe, path.join(probe, "nested")]) { + assert.equal(fs.statSync(directory).mode & 0o777, 0o700); + assert.equal(fs.statSync(directory).uid, 1001); + fs.readdirSync(directory); +} +const asset = path.join(probe, "nested/readable.txt"); +assert.equal(fs.statSync(asset).mode & 0o777, 0o600); +assert.equal(fs.readFileSync(asset, "utf8"), "inventory-public-permission-fixture\n"); +for (const [name, expected] of [["SWI_SCRIPT_PERMISSION_PROBE", "script"], ["SWI_VPS_PERMISSION_PROBE", "operator"]]) { + const file = path.join("/app", process.env[name]); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + assert.equal(fs.statSync(file).uid, 1001); + assert.equal(fs.readFileSync(file, "utf8"), `export default "inventory-${expected}-permission-fixture";\n`); +} +const operatorDirectory = path.dirname(path.join("/app", process.env.SWI_VPS_PERMISSION_PROBE)); +assert.equal(fs.statSync(operatorDirectory).mode & 0o777, 0o700); +assert.equal(fs.statSync(operatorDirectory).uid, 1001); +fs.readdirSync(operatorDirectory); +console.log("Nonroot backend read private-mode public assets, scripts and operator directory."); +' docker build --target frontend --secret id=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,env=SWI_BUILD_KEY \ --build-arg NEXT_PUBLIC_BASE_URL=https://spaceworks-inventory-origin.invalid \ --build-arg NEXT_PUBLIC_BACKEND_ORIGIN=https://spaceworks-inventory-backend.invalid -t swi-frontend:smoke . # Exercise the exact configuration writer the confirmed installer runs. docker run --rm --user 0:0 -v "$SWI_CONFIG_DIR:/config" swi-backend:smoke \ node docker/vps/setup.mjs api.space.example.test owner@example.test smoke space.example.test split -export EMAIL_FROM='Space Works Inventory ' SMTP_HOST=smtp.example.test SMTP_PORT=587 SMTP_SECURE=false SMTP_USER=smoke SMTP_PASSWORD=disposable-ci-smtp +as_root bash scripts/test-postgres-bootstrap.sh +export EMAIL_FROM='Space Works Inventory ' SMTP_HOST=mailpit SMTP_PORT=1025 SMTP_SECURE=false SMTP_USER=smoke SMTP_PASSWORD=disposable-ci-smtp # Credentials travel in environment, never arguments. docker run --rm --user 0:0 -v "$SWI_CONFIG_DIR:/config" \ -e EMAIL_FROM -e SMTP_HOST -e SMTP_PORT -e SMTP_SECURE -e SMTP_USER -e SMTP_PASSWORD \ @@ -41,6 +90,7 @@ docker run --rm --user 0:0 -v "$SWI_CONFIG_DIR:/config" \ printf "SWI_CADDY_TLS='internal'\n" | as_root tee -a "$SWI_CONFIG_DIR/compose.env" >/dev/null as_root mkdir -m 755 "$SWI_CONFIG_DIR/status" as_root sh scripts/vps-compose.sh config --quiet +as_root sh scripts/vps-compose.sh build proxy as_root sh scripts/vps-compose.sh up -d --wait --wait-timeout 300 as_root sh scripts/vps-compose.sh run --rm --no-deps -T ops node docker/vps/check.mjs as_root sh scripts/vps-compose.sh run --rm --no-deps -T ops node docker/vps/smoke.mjs @@ -49,7 +99,17 @@ as_root sh scripts/vps-compose.sh run --rm --no-deps -T ops node docker/vps/smok as_root bash -c '. scripts/vps-lib.sh conf_set "$1/compose.env" SWI_LAYOUT single conf_set "$1/compose.env" SWI_CADDYFILE Caddyfile +conf_set "$1/compose.env" PUBLIC_HOST "$(conf_get "$1/compose.env" SITE_HOST)" conf_set "$1/app.env" DEPLOYMENT_MODE ""' _ "$SWI_CONFIG_DIR" +# The single hostname must be the same origin that auth and generated links use. +# shellcheck disable=SC2016 # expanded by the inner shell +as_root bash -c '. scripts/vps-lib.sh +origin="https://$(conf_get "$1/compose.env" PUBLIC_HOST)" +[[ "$(conf_get "$1/compose.env" PUBLIC_ORIGIN)" = "$origin" ]] +[[ "$(conf_get "$1/app.env" AUTH_URL)" = "$origin" ]] +[[ "$(conf_get "$1/app.env" NEXT_PUBLIC_BASE_URL)" = "$origin" ]]' _ "$SWI_CONFIG_DIR" as_root sh scripts/vps-compose.sh up -d --wait --wait-timeout 300 --remove-orphans as_root sh scripts/vps-compose.sh run --rm --no-deps -T ops node docker/vps/smoke.mjs printf 'Two-image installer smoke passed in split and single layouts.\n' +as_root bash scripts/test-vps-jobs.sh +as_root bash scripts/test-vps-recovery.sh diff --git a/scripts/test-vps-jobs.sh b/scripts/test-vps-jobs.sh new file mode 100755 index 0000000..7b550d3 --- /dev/null +++ b/scripts/test-vps-jobs.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Real worker SMTP proof, reusable before and after a disposable recovery. +set -euo pipefail +set +x +[[ ${CI:-} = true ]] || { echo 'Worker smoke requires an isolated CI runner.' >&2; exit 1; } +: "${SWI_CONFIG_DIR:?Disposable configuration required}" +: "${SWI_PROJECT_NAME:?Disposable project required}" +case "$SWI_PROJECT_NAME" in swi-smoke-*) ;; *) echo 'Refusing a non-smoke project.' >&2; exit 1 ;; esac +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" +work=$(mktemp -d) +cleanup() { + local status=$? + docker rm -f "$SWI_PROJECT_NAME-mailpit" >/dev/null 2>&1 || true + rm -rf -- "$work" + exit "$status" +} +trap cleanup EXIT +umask 077 +mkdir -p "$SWI_CONFIG_DIR/tls/mailpit" +chmod 700 "$SWI_CONFIG_DIR/tls/mailpit" +openssl req -newkey rsa:2048 -nodes -keyout "$SWI_CONFIG_DIR/tls/mailpit/key.pem" \ + -out "$SWI_CONFIG_DIR/tls/mailpit/request.csr" -subj /CN=mailpit >/dev/null 2>&1 +printf 'subjectAltName=DNS:mailpit\nextendedKeyUsage=serverAuth\n' > "$work/mailpit.ext" +openssl x509 -req -in "$SWI_CONFIG_DIR/tls/mailpit/request.csr" \ + -CA "$SWI_CONFIG_DIR/tls/ca.crt" -CAkey "$SWI_CONFIG_DIR/tls/ca.key" -CAcreateserial \ + -out "$SWI_CONFIG_DIR/tls/mailpit/cert.pem" -days 1 -extfile "$work/mailpit.ext" >/dev/null 2>&1 +network=$(docker inspect "$(sh scripts/vps-compose.sh ps -q app)" \ + --format '{{range $name, $settings := .NetworkSettings.Networks}}{{println $name}}{{end}}' | sed -n '/_edge$/p' | head -n 1) +[[ -n "$network" ]] || { echo 'Disposable edge network was not found.' >&2; exit 1; } +docker run -d --name "$SWI_PROJECT_NAME-mailpit" --user 0:0 \ + --network "$network" --network-alias mailpit \ + -v "$SWI_CONFIG_DIR/tls/mailpit:/tls:ro" axllent/mailpit:v1.27 \ + --smtp-auth-accept-any --smtp-require-starttls --smtp-tls-cert /tls/cert.pem --smtp-tls-key /tls/key.pem +sh scripts/vps-compose.sh run --rm --no-deps -T -e SWI_RECOVERY_SMOKE=1 ops node docker/vps/jobs-smoke.mjs diff --git a/scripts/test-vps-recovery.sh b/scripts/test-vps-recovery.sh new file mode 100755 index 0000000..8cb548d --- /dev/null +++ b/scripts/test-vps-recovery.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Called only against the disposable installation created by test-vps-install.sh. +set -euo pipefail +set +x +[[ ${CI:-} = true ]] || { echo 'Recovery smoke requires an isolated CI runner.' >&2; exit 1; } +: "${SWI_CONFIG_DIR:?Disposable configuration required}" +: "${SWI_PROJECT_NAME:?Disposable project required}" +case "$SWI_PROJECT_NAME" in swi-smoke-*) ;; *) echo 'Refusing a non-smoke project.' >&2; exit 1 ;; esac +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" +original_config=$SWI_CONFIG_DIR +work=$(mktemp -d) +cleanup() { rm -rf -- "$work"; } +trap cleanup EXIT +umask 077 +age-keygen -o "$work/identity" 2>/dev/null +recipient=$(age-keygen -y "$work/identity") +printf "BACKUP_AGE_RECIPIENT='%s'\n" "$recipient" > "$SWI_CONFIG_DIR/backup.env" +sh scripts/vps-compose.sh run --rm --no-deps -T -e SWI_RECOVERY_SMOKE=1 ops node docker/vps/recovery-smoke.mjs seed +bash scripts/vps-backup.sh +backup=$(find "$SWI_CONFIG_DIR/backups" -mindepth 1 -maxdepth 1 -type d -name '2*Z' | sort | tail -n 1) +test -s "$backup/backup.tar.age" +test ! -e "$backup/database.dump" +cp -R "$backup" "$work/off-host-backup" +age --decrypt --identity "$work/identity" "$work/off-host-backup/backup.tar.age" | tar -xf - -C "$work" +tar -tzf "$work/config.tar.gz" | grep -qx './app.env' +sh scripts/vps-compose.sh down --volumes --remove-orphans +export SWI_PROJECT_NAME="${SWI_PROJECT_NAME}-restored" +export SWI_CONFIG_DIR="$work/recovered-config" +mkdir -m 700 "$SWI_CONFIG_DIR" +tar -xzf "$work/config.tar.gz" -C "$SWI_CONFIG_DIR" +cmp "$original_config/app.env" "$SWI_CONFIG_DIR/app.env" +cmp "$original_config/actions-key" "$SWI_CONFIG_DIR/actions-key" +recover_cleanup() { + local status=$? + sh scripts/vps-compose.sh down --volumes --remove-orphans >/dev/null 2>&1 || true + rm -rf -- "$work" + exit "$status" +} +trap recover_cleanup EXIT +sh scripts/vps-compose.sh up -d --wait --wait-timeout 300 +sh scripts/vps-compose.sh run --rm --no-deps -T -e SWI_RECOVERY_SMOKE=1 ops node docker/vps/recovery-smoke.mjs empty +# A genuinely fresh database has no fixture user; restore must also remove +# schema residue created after the archived release. +test "$(sh scripts/vps-compose.sh exec -T postgres psql -U swi -d swi -Atqc \ + "SELECT count(*) FROM public.\"User\" WHERE email = 'recovery-fixture@example.test'")" = 0 +sh scripts/vps-compose.sh exec -T postgres psql -U swi -d swi -v ON_ERROR_STOP=1 \ + -c 'CREATE TABLE public."RecoveryTargetResidue" (id integer PRIMARY KEY);' +bash scripts/vps-restore.sh "$work/off-host-backup" --identity "$work/identity" --yes +test "$(sh scripts/vps-compose.sh exec -T postgres psql -U swi -d swi -Atqc \ + "SELECT to_regclass('public.\"RecoveryTargetResidue\"') IS NULL")" = t +sh scripts/vps-compose.sh run --rm --no-deps -T -e SWI_RECOVERY_SMOKE=1 ops node docker/vps/recovery-smoke.mjs verify +sh scripts/vps-compose.sh run --rm --no-deps -T ops node docker/vps/check.mjs +bash scripts/test-vps-jobs.sh +printf 'Encrypted Inventory recovery onto empty database/storage volumes with restored usable keys passed.\n' diff --git a/scripts/vps-backup.sh b/scripts/vps-backup.sh index 7873633..8eb84a3 100755 --- a/scripts/vps-backup.sh +++ b/scripts/vps-backup.sh @@ -94,7 +94,9 @@ printf '%s\n' "$kind" > "$target.partial/kind" # goes into backup.tar.age, which only the off-server private key opens. if [[ -n "$recipient" ]]; then command -v age >/dev/null || die 'Backups are set to be encrypted, but age is not installed. Install it (package "age") and rerun.' - # The archive is written into the folder it archives, so it skips itself. + # Precreate the excluded output before tar reads directory metadata. Creating + # it concurrently through the pipeline redirection can make GNU tar fail. + : > "$target.partial/backup.tar.age" tar -cf - -C "$target.partial" --exclude=./version --exclude=./kind --exclude=./backup.tar.age . \ | age --encrypt --recipient "$recipient" > "$target.partial/backup.tar.age" find "$target.partial" -mindepth 1 -maxdepth 1 ! -name backup.tar.age ! -name version ! -name kind -exec rm -rf -- {} + diff --git a/scripts/vps-compose.sh b/scripts/vps-compose.sh index 52bc346..907bd95 100755 --- a/scripts/vps-compose.sh +++ b/scripts/vps-compose.sh @@ -21,6 +21,8 @@ profiles='' [ "$(setting SWI_LAYOUT)" != split ] || profiles='split' [ "$tls" = proxy ] || profiles=${profiles:+$profiles,}caddy [ "$storage" = external ] || profiles=${profiles:+$profiles,}silo +# Teardown includes inactive layout services left by a split-to-single change. +[ "${1:-}" != down ] || profiles='*' export COMPOSE_PROFILES="$profiles" # Prepended in reverse: the base file first, then the overrides. if [ "$storage" = external ]; then set -- --file "$repo_dir/compose.external-storage.yml" "$@"; fi diff --git a/scripts/vps-lib.sh b/scripts/vps-lib.sh index eb6f7dd..2f314b6 100755 --- a/scripts/vps-lib.sh +++ b/scripts/vps-lib.sh @@ -149,13 +149,20 @@ fetch_images() { local version=$1 layout=${2:-single} services=(app) [[ "$layout" != split ]] || services+=(frontend) if is_published_version "$version"; then - ghcr_login + ghcr_login || return say 'Downloading the release images from GitHub Packages.' - compose pull --quiet "${services[@]}" + compose pull --quiet "${services[@]}" || return else say 'Building images from source. This takes several minutes on a small server.' - compose build "${services[@]}" + compose build "${services[@]}" || return fi + build_web_edge +} + +# Published app images still use the locally built, security-patched edge. +# An existing reverse proxy has no bundled edge to build. +build_web_edge() { + [[ "$(conf_get "$SWI_CONFIG_DIR/compose.env" SWI_CADDY_TLS)" = proxy ]] || compose build proxy } # One installer, updater, backup or restore at a time per installation. Child diff --git a/scripts/vps-restore.sh b/scripts/vps-restore.sh index f8a6a54..3985ca9 100755 --- a/scripts/vps-restore.sh +++ b/scripts/vps-restore.sh @@ -37,15 +37,22 @@ umask 077 # taken before the restore never archives, and are removed on exit. mkdir -p "$SWI_CONFIG_DIR/backups" work='' key_file='' -stopped=0 +stopped=0 database_committed=0 cleanup() { local status=$? set +e [[ -z "$work" ]] || rm -rf -- "$work" [[ -z "$key_file" ]] || rm -f -- "$key_file" if [[ $status != 0 && $stopped = 1 ]]; then - warn 'The restore failed; starting the services again.' - compose up -d >/dev/null 2>&1 || warn 'Start them with scripts/vps-compose.sh up -d.' + if [[ $database_committed = 1 ]]; then + # Objects, migrations or readiness can fail after SQL committed. A partly + # restarted deployment must not serve that incomplete recovered state. + compose stop "${serving[@]}" >/dev/null 2>&1 || warn 'Could not stop every serving process; stop them before accepting traffic.' + warn 'The database restore committed, but recovery did not finish. Serving processes remain stopped. Fix the failure and rerun the complete restore before starting the site.' + else + warn 'The database restore did not commit; starting the previous services again.' + compose up -d >/dev/null 2>&1 || warn 'Start them with scripts/vps-compose.sh up -d.' + fi fi exit "$status" } @@ -112,12 +119,47 @@ if [[ $assume_yes = 0 ]]; then [[ "$answer" = RESTORE ]] || { say 'Cancelled. Nothing was changed.'; exit 2; } fi SWI_BACKUP_PROTECT=$source_backup bash "$SWI_REPO_DIR/scripts/vps-backup.sh" --no-objects +# Materialize the complete restore before stopping services or executing any SQL. +# A producer failure in a pg_restore | psql pipeline could otherwise commit a +# valid but truncated prefix when psql reaches EOF. Work stays private and is +# excluded from safety backups by living beneath backups/. +[[ -n "$work" ]] || work=$(mktemp -d "$SWI_CONFIG_DIR/backups/.restore.XXXXXX") +restore_sql="$work/restore.sql" +{ + cat <<'SQL' +DROP SCHEMA IF EXISTS public CASCADE; +CREATE SCHEMA public AUTHORIZATION swi; +SQL + # A custom archive can define public itself. Exclude only that definition: + # its separate COMMENT and ACL entries still restore, and swi owns the schema. + # The private container scratch directory needs no extra host bind mount. + # shellcheck disable=SC2016 # Expanded by the container's shell. + compose exec postgres sh -c ' + set -eu + umask 077 + stage=$(mktemp -d) + trap '\''rm -rf -- "$stage"'\'' EXIT + cat > "$stage/archive.dump" + pg_restore --list "$stage/archive.dump" > "$stage/contents" + awk "\$4 == \"SCHEMA\" && \$5 == \"-\" && \$6 == \"public\" { print \";\" \$0; next } { print }" \ + "$stage/contents" > "$stage/restore.list" + pg_restore --file=- --exit-on-error --use-list="$stage/restore.list" "$stage/archive.dump" + ' < "$backup/database.dump" + cat <<'SQL' +REVOKE ALL ON SCHEMA public FROM PUBLIC, swi_app; +GRANT CONNECT ON DATABASE swi TO swi_app; +GRANT USAGE ON SCHEMA public TO swi_app; +ALTER DEFAULT PRIVILEGES FOR ROLE swi IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO swi_app; +ALTER DEFAULT PRIVILEGES FOR ROLE swi IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO swi_app; +SQL +} > "$restore_sql" # A failed restore must not leave the site down; the database restore is one # transaction, so a failure there keeps the current data. stopped=1 compose stop "${serving[@]}" -compose exec postgres pg_restore -U swi -d swi --clean --if-exists --single-transaction --exit-on-error \ - < "$backup/database.dump" +compose exec postgres psql -X -U swi -d swi --single-transaction --set ON_ERROR_STOP=1 --file=- \ + < "$restore_sql" +database_committed=1 if [[ $has_objects = 1 ]]; then compose run --rm --no-deps --volume "$backup:/backup:ro" --entrypoint mc storage-init \ mirror --overwrite --quiet /backup/objects local/spaceworks-inventory > /dev/null diff --git a/tests/helpers/storedObjects.ts b/tests/helpers/storedObjects.ts new file mode 100644 index 0000000..69e65f2 --- /dev/null +++ b/tests/helpers/storedObjects.ts @@ -0,0 +1,32 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"; +import { deleteObjects, storageConfigured } from "@/lib/storage"; + +/** Inspect the configured test backend, including unreferenced failed uploads. */ +export async function storedObjectKeys(spaceId: string, folder = "") { + if (!/^[A-Za-z0-9_-]+$/.test(spaceId) || !/^[A-Za-z0-9_-]*$/.test(folder)) throw new Error("Invalid test storage scope."); + const prefix = `spaces/${spaceId}/${folder ? `${folder}/` : ""}`; + if (!storageConfigured()) { + const directory = path.resolve(process.cwd(), ".data/storage", prefix); + const entries = await readdir(directory, { recursive: true }).catch(() => [] as string[]); + return entries.filter((name) => name.endsWith(".webp")).map((name) => prefix + name.split(path.sep).join("/")); + } + const client = new S3Client({ region: process.env.S3_REGION, endpoint: process.env.S3_ENDPOINT, + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "1", + credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID!, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY! } }); + try { + const keys: string[] = []; + let continuation: string | undefined; + do { + const result = await client.send(new ListObjectsV2Command({ Bucket: process.env.S3_BUCKET!, Prefix: prefix, ContinuationToken: continuation })); + keys.push(...(result.Contents ?? []).flatMap((object) => object.Key?.startsWith(prefix) ? [object.Key] : [])); + continuation = result.IsTruncated ? result.NextContinuationToken : undefined; + } while (continuation); + return keys; + } finally { client.destroy(); } +} + +export async function cleanupStoredObjects(spaceId: string) { + await deleteObjects(await storedObjectKeys(spaceId)); +} diff --git a/tests/integration/desk-evidence.db.test.ts b/tests/integration/desk-evidence.db.test.ts index 8648a84..81df36e 100644 --- a/tests/integration/desk-evidence.db.test.ts +++ b/tests/integration/desk-evidence.db.test.ts @@ -1,5 +1,3 @@ -import { existsSync, rmSync } from "node:fs"; -import path from "node:path"; import sharp from "sharp"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; @@ -10,13 +8,14 @@ import { evidencePhotoForViewer } from "@/lib/desk/evidence"; import { readHandoverForm, withEvidencePhotos } from "@/lib/desk/photos"; import { scanTarget } from "@/lib/desk/scan"; import { addMember, cleanup, createSpace, createUser, seedSpace, uniqueSuffix } from "../helpers/fixtures"; +import { cleanupStoredObjects } from "../helpers/storedObjects"; +import { getObject } from "@/lib/storage"; const suffix = uniqueSuffix(); let a: Awaited>; let b: { id: string; slug: string }; let otherMember: { id: string }; let bStaff: { id: string }; -const storageRoot = path.resolve(process.cwd(), ".data/storage"); const fakePhoto = (spaceId: string) => ({ storageKey: `spaces/${spaceId}/evidence/${crypto.randomUUID()}/p.webp`, contentType: "image/webp", sizeBytes: 10, sha256: "0".repeat(64) }); beforeAll(async () => { @@ -29,7 +28,7 @@ beforeAll(async () => { await addMember(b.id, a.member.id, "MEMBER"); }); afterAll(async () => { - for (const id of [a?.space.id, b?.id]) if (id) rmSync(path.join(storageRoot, "spaces", id), { recursive: true, force: true }); + for (const id of [a?.space.id, b?.id]) if (id) await cleanupStoredObjects(id); await cleanup([a.space.slug, b.slug], a.domain); }); @@ -84,11 +83,12 @@ describe("handover photo storage", () => { let keys: string[] = []; await expect(withEvidencePhotos(a.space.id, [file(), file()], async (photos) => { keys = photos.map((photo) => photo.storageKey); - expect(keys.every((key) => key.startsWith(`spaces/${a.space.id}/evidence/`) && existsSync(path.join(storageRoot, key)))).toBe(true); + expect(keys.every((key) => key.startsWith(`spaces/${a.space.id}/evidence/`))).toBe(true); + expect((await Promise.all(keys.map(getObject))).every(Boolean)).toBe(true); throw new DomainError("Refused."); })).rejects.toThrow("Refused."); expect(keys).toHaveLength(2); - expect(keys.some((key) => existsSync(path.join(storageRoot, key)))).toBe(false); + expect(await Promise.all(keys.map(getObject))).toEqual([null, null]); await expect(withEvidencePhotos(a.space.id, [new File([new Uint8Array([1, 2, 3])], "x.png", { type: "image/png" })], async () => "never")).rejects.toThrow(/couldn't be read/); await expect(withEvidencePhotos(a.space.id, Array.from({ length: 7 }, file), async () => "never")).rejects.toThrow(/at most 6/); expect(await withEvidencePhotos(a.space.id, [file()], async (photos) => photos.length)).toBe(1); diff --git a/tests/integration/desk-loans.db.test.ts b/tests/integration/desk-loans.db.test.ts index 0e14d1c..9a4d3fe 100644 --- a/tests/integration/desk-loans.db.test.ts +++ b/tests/integration/desk-loans.db.test.ts @@ -1,5 +1,3 @@ -import { readdir, rm } from "node:fs/promises"; -import path from "node:path"; import sharp from "sharp"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; @@ -12,6 +10,7 @@ import { memberLoans } from "@/lib/desk/loansMember"; import { loanQueue } from "@/lib/desk/loansQueue"; import { approveStep, cancelStep, issueStep, rejectStep, renewStep, requestStep, returnStep, selfCheckoutStep, selfReturnStep, type StepContext } from "@/lib/desk/loanSteps"; import { cleanup, seedSpace, uniqueSuffix } from "../helpers/fixtures"; +import { cleanupStoredObjects, storedObjectKeys } from "../helpers/storedObjects"; const suffixA = uniqueSuffix(); const suffixB = uniqueSuffix(); @@ -25,7 +24,7 @@ beforeAll(async () => { png = await sharp({ create: { width: 16, height: 16, channels: 3, background: { r: 0, g: 110, b: 99 } } }).png().toBuffer(); }); afterAll(async () => { - for (const space of [a.space, b.space]) await rm(path.resolve(process.cwd(), ".data/storage/spaces", space.id), { recursive: true, force: true }); + for (const space of [a.space, b.space]) await cleanupStoredObjects(space.id); await cleanup([a.space.slug], a.domain); await cleanup([b.space.slug], b.domain); }); @@ -34,11 +33,7 @@ const photo = () => new File([new Uint8Array(png)], "evidence.png", { type: "ima const ctxOf = (space: { id: string; timezone: string }, userId: string): StepContext => ({ spaceId: space.id, userId, timeZone: space.timezone }); const deskA = () => ctxOf(a.space, a.staff.id); const memberA = () => ctxOf(a.space, a.member.id); -const storedPhotos = async (spaceId: string) => { - const dir = path.resolve(process.cwd(), ".data/storage/spaces", spaceId, "evidence"); - // Deleting a photo leaves its (empty) folder behind, so count the files themselves. - return (await readdir(dir, { recursive: true }).catch(() => [] as string[])).filter((name) => name.endsWith(".webp")).length; -}; +const storedPhotos = async (spaceId: string) => (await storedObjectKeys(spaceId, "evidence")).length; async function lendable(space: { id: string }, shelf: { id: string }, name: string, quantity: number, extra: Record = {}) { const item = await prisma.$transaction((tx) => createItem(tx, space.id, { name, lendable: true, ...extra }, { userId: null })); diff --git a/tests/integration/desk-transfers.db.test.ts b/tests/integration/desk-transfers.db.test.ts index 72f0ba2..3e9fba6 100644 --- a/tests/integration/desk-transfers.db.test.ts +++ b/tests/integration/desk-transfers.db.test.ts @@ -1,5 +1,3 @@ -import { existsSync, rmSync } from "node:fs"; -import path from "node:path"; import sharp from "sharp"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; @@ -11,6 +9,8 @@ import { localStock, otherSpaces, sharedStock } from "@/lib/desk/transferOptions import { listTransfers, transferDetail } from "@/lib/desk/transfers"; import { approveStep, cancelStep, dispatchStep, missingStep, receiveReturnStep, receiveStep, requestTransferStep, sendBackStep } from "@/lib/desk/transferSteps"; import { cleanup, createSpace, seedSpace, uniqueSuffix } from "../helpers/fixtures"; +import { cleanupStoredObjects } from "../helpers/storedObjects"; +import { getObject } from "@/lib/storage"; const suffix = uniqueSuffix(); let a: Awaited>; @@ -18,7 +18,6 @@ let b: { id: string; slug: string; timezone: string }; let c: { id: string; slug: string; timezone: string }; let bShelf: { id: string }; const actor = { userId: null }; -const storageRoot = path.resolve(process.cwd(), ".data/storage"); const stored = (spaceId: string) => prisma.evidencePhoto.findMany({ where: { evidence: { spaceId } }, select: { storageKey: true } }); const ctx = (space: { id: string; timezone: string }, userId: string) => ({ space: { id: space.id, timezone: space.timezone }, userId }); @@ -41,7 +40,7 @@ beforeAll(async () => { bShelf = await prisma.location.create({ data: { spaceId: b.id, name: "Intake", code: "IN", kind: "SHELF", qrCode: newQrCode("location") } }); }); afterAll(async () => { - for (const space of [a?.space.id, b?.id, c?.id]) if (space) rmSync(path.join(storageRoot, "spaces", space), { recursive: true, force: true }); + for (const space of [a?.space.id, b?.id, c?.id]) if (space) await cleanupStoredObjects(space); await cleanup([a.space.slug, b.slug, c.slug], a.domain); }); @@ -79,7 +78,7 @@ describe("transfer desk handovers", () => { await dispatchStep(ctx(a.space, a.staff.id), { transferId: transfer.id, quantities: { [line.id]: 4 }, scannedCodes: [wire.qrCode], carrier: "Van", trackingRef: null, expectedAt: "2026-11-02", remarks: "Boxed" }, [await photoFile()]); const keys = await stored(a.space.id); expect(keys).toHaveLength(1); - expect(existsSync(path.join(storageRoot, keys[0]!.storageKey))).toBe(true); + expect(await getObject(keys[0]!.storageKey)).not.toBeNull(); // Each side sees its own view; the receiver doesn't learn the sender's shelf. const fromB = (await transferDetail(b.slug, b.id, transfer.id))!; diff --git a/tests/integration/loan-handover-concurrency.db.test.ts b/tests/integration/loan-handover-concurrency.db.test.ts new file mode 100644 index 0000000..42d83a8 --- /dev/null +++ b/tests/integration/loan-handover-concurrency.db.test.ts @@ -0,0 +1,67 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { createItem } from "@/lib/catalogue/items"; +import { newQrCode } from "@/lib/catalogue/qr"; +import { receiveStock } from "@/lib/inventory/availability"; +import { approveLoan, issueLoan, requestLoan, selfCheckout } from "@/lib/lending/workflow"; +import { checkBorrowingEligibility } from "@/lib/lending/access"; +import { cleanup, seedSpace, uniqueSuffix } from "../helpers/fixtures"; +import { evidence } from "../helpers/evidence"; + +let ctx: Awaited>; +const system = { userId: null }; +beforeAll(async () => { ctx = await seedSpace(uniqueSuffix()); }); +afterAll(async () => { if (ctx) await cleanup([ctx.space.slug], ctx.domain); }); + +async function prepare() { + const borrower = await prisma.user.create({ data: { email: `${uniqueSuffix()}${ctx.domain}`, emailVerified: new Date() } }); + await prisma.membership.create({ data: { spaceId: ctx.space.id, userId: borrower.id, role: "MEMBER" } }); + const item = await prisma.$transaction((tx) => createItem(tx, ctx.space.id, { name: uniqueSuffix(), lendable: true, selfCheckout: true }, system)); + await prisma.$transaction((tx) => receiveStock(tx, { spaceId: ctx.space.id, itemId: item.id, locationId: ctx.shelfA.id, quantity: 4 }, system)); + return { borrower, item }; +} + +async function approved(borrowerId: string, itemId: string) { + const loan = await prisma.$transaction((tx) => requestLoan(tx, { spaceId: ctx.space.id, borrowerId, lines: [{ itemId, quantity: 1 }], purpose: null, neededFrom: null }, system)); + await prisma.$transaction((tx) => approveLoan(tx, { spaceId: ctx.space.id, loanId: loan.id, quantities: null }, system)); + return loan; +} + +function handover(loanId: string, codes: string[]) { + return prisma.$transaction((tx) => issueLoan(tx, { spaceId: ctx.space.id, loanId, scannedCodes: codes, evidence: { ...evidence(ctx.space.id), scannedCodes: codes } }, system), { timeout: 15_000 }); +} + +describe("serialized handover admission", () => { + it.each(["desk", "self", "mixed"] as const)("enforces a shared loan limit for concurrent %s handovers", async (mode) => { + await prisma.space.update({ where: { id: ctx.space.id }, data: { maxActiveLoans: 1, maxItemsOnLoan: 0, selfCheckoutEnabled: true } }); + const { borrower, item } = await prepare(); + const loans = mode === "self" ? [] : [await approved(borrower.id, item.id), ...(mode === "desk" ? [await approved(borrower.id, item.id)] : [])]; + const self = () => prisma.$transaction((tx) => selfCheckout(tx, { spaceId: ctx.space.id, borrowerId: borrower.id, scannedCodes: [item.qrCode], evidence: evidence(ctx.space.id) }, system), { timeout: 15_000 }); + const results = await Promise.allSettled([mode === "self" ? self() : handover(loans[0]!.id, [item.qrCode]), mode === "desk" ? handover(loans[1]!.id, [item.qrCode]) : self()]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.find((result) => result.status === "rejected")).toMatchObject({ reason: { code: "forbidden" } }); + expect(await prisma.loan.count({ where: { spaceId: ctx.space.id, borrowerId: borrower.id, status: "ISSUED" } })).toBe(1); + }); + + it("allows a container to be out on only one concurrent loan", async () => { + const first = await prepare(); + const second = await prepare(); + const a = await approved(first.borrower.id, first.item.id); + const b = await approved(second.borrower.id, second.item.id); + const container = await prisma.container.create({ data: { spaceId: ctx.space.id, name: "Shared carrier", code: uniqueSuffix(), qrCode: newQrCode("container") } }); + const results = await Promise.allSettled([handover(a.id, [first.item.qrCode, container.qrCode]), handover(b.id, [second.item.qrCode, container.qrCode])]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.find((result) => result.status === "rejected")).toMatchObject({ reason: { code: "conflict" } }); + }); + + it("rejects disabled users and repair-only technicians as borrowers", async () => { + const { borrower } = await prepare(); + const check = () => checkBorrowingEligibility(prisma, { spaceId: ctx.space.id, userId: borrower.id, incomingUnits: 1 }); + expect(await check()).toEqual({ ok: true }); + await prisma.user.update({ where: { id: borrower.id }, data: { disabledAt: new Date() } }); + expect(await check()).toMatchObject({ ok: false, code: "standing" }); + await prisma.user.update({ where: { id: borrower.id }, data: { disabledAt: null } }); + await prisma.membership.update({ where: { userId_spaceId: { userId: borrower.id, spaceId: ctx.space.id } }, data: { role: "TECHNICIAN" } }); + expect(await check()).toMatchObject({ ok: false, code: "standing" }); + }); +}); diff --git a/tests/integration/queue-worker.db.test.ts b/tests/integration/queue-worker.db.test.ts new file mode 100644 index 0000000..09d38ff --- /dev/null +++ b/tests/integration/queue-worker.db.test.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const delivery = vi.hoisted(() => ({ send: vi.fn(async () => undefined) })); +vi.mock("@/lib/email/send", () => ({ sendRenderedEmail: delivery.send })); + +import { prisma } from "@/lib/db"; +import { enqueueEmail } from "@/lib/outbox"; +import { closeQueues, createQueues, queueConnection, type Queues } from "@/lib/queue/client"; +import { jobId } from "@/lib/queue/definitions"; +import { createWorkers } from "@/lib/queue/worker"; + +const redisUrl = process.env.TEST_QUEUE_REDIS_URL; +const prefix = `swi-audit-${randomUUID()}`; +let queues: Queues; +let workers: ReturnType; +const outboxIds: string[] = []; + +describe.skipIf(!redisUrl)("BullMQ transactional email delivery", () => { + beforeAll(async () => { + queues = createQueues(prefix, queueConnection(false, redisUrl)); + workers = createWorkers({ prefix, redisUrl }); + await workers.ready(); + }); + + afterAll(async () => { + await workers?.close(); + if (queues) { + await Promise.all(Object.values(queues).map((queue) => queue.obliterate({ force: true }))); + await closeQueues(queues); + } + await prisma.outboxMessage.deleteMany({ where: { id: { in: outboxIds } } }); + }); + + it("delivers committed SQL outbox messages in the real Redis worker without placing content in Redis", async () => { + const message = await prisma.$transaction((tx) => enqueueEmail(tx, { to: "audit-recipient@example.test", subject: "Worker delivery", html: "

Private content

", text: "Private content" })); + outboxIds.push(message.id); + expect(delivery.send).not.toHaveBeenCalled(); + const id = jobId("outbox", message.id); + await queues.outbox.add("deliver", { kind: "outbox", outboxId: message.id }, { jobId: id }); + await expect.poll(async () => (await prisma.outboxMessage.findUniqueOrThrow({ where: { id: message.id } })).status, { timeout: 10_000 }).toBe("SENT"); + expect(delivery.send).toHaveBeenCalledOnce(); + expect(delivery.send).toHaveBeenCalledWith("audit-recipient@example.test", expect.objectContaining({ text: "Private content" }), ``); + expect((await queues.outbox.getJob(id))?.data).toEqual({ kind: "outbox", outboxId: message.id }); + // Repeated relays use the same stable id; completed email is not resent. + await queues.outbox.add("deliver", { kind: "outbox", outboxId: message.id }, { jobId: id }); + expect(delivery.send).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/backup-output-race.test.ts b/tests/unit/backup-output-race.test.ts new file mode 100644 index 0000000..4a73de6 --- /dev/null +++ b/tests/unit/backup-output-race.test.ts @@ -0,0 +1,42 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; + +it("precreates excluded encryption output before GNU tar observes directory metadata", () => { + const source = readFileSync("scripts/vps-backup.sh", "utf8"); + // Exercise the actual preparation, with a deterministic checkpoint forcing + // output creation after tar has already observed the source directory. + const preparation = source.match(/^\s*: > "\$target\.partial\/backup\.tar\.age"$/m)?.[0] ?? ""; + const fixture = mkdtempSync(path.join(tmpdir(), "inventory tar race ")); + const windowsBash = "C:/Program Files/Git/bin/bash.exe"; + const bash = process.platform === "win32" && existsSync(windowsBash) ? windowsBash : "bash"; + try { + const result = spawnSync(bash, ["-c", ` +set -euo pipefail +mkdir old.partial fixed.partial +head -c 200000 /dev/zero > old.partial/database.dump +cp old.partial/database.dump fixed.partial/database.dump +checkpoint='if [ ! -e "$SWI_TAR_FIXTURE/backup.tar.age" ]; then sleep 1.1; fi; touch "$SWI_TAR_FIXTURE/backup.tar.age"' +export SWI_TAR_FIXTURE="$PWD/old.partial" +set +e +tar --checkpoint=1 --checkpoint-action="exec=$checkpoint" -cf old.tar -C "$SWI_TAR_FIXTURE" --exclude=./backup.tar.age . 2>old.stderr +old_status=$? +set -e +printf 'old_status=%s\\n' "$old_status" +cat old.stderr +target="$PWD/fixed" +${preparation} +export SWI_TAR_FIXTURE="$target.partial" +tar --checkpoint=1 --checkpoint-action="exec=$checkpoint" -cf fixed.tar -C "$SWI_TAR_FIXTURE" --exclude=./backup.tar.age . +tar -tf fixed.tar +`], { cwd: fixture, encoding: "utf8", timeout: 20_000, env: { ...process.env, NODE_ENV: "test" } }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("old_status=1"); + expect(result.stdout).toContain("file changed as we read it"); + expect(result.stdout).toContain("./database.dump"); + expect(result.stdout).not.toContain("./backup.tar.age"); + } finally { rmSync(fixture, { recursive: true, force: true }); } +}, 25_000); diff --git a/tests/unit/db-restore-public.test.ts b/tests/unit/db-restore-public.test.ts new file mode 100644 index 0000000..98d63b4 --- /dev/null +++ b/tests/unit/db-restore-public.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { restorePublicArchive } from "../../scripts/db-restore-public.mjs"; + +const toc = "3; 2615 2200 SCHEMA - public owner\n4; 0 0 COMMENT - SCHEMA public owner\n5; 1259 1 TABLE public recovered owner\n"; + +async function fixture(options: { contents?: string; target?: string; generationFailure?: boolean; applyFailure?: boolean } = {}) { + const directory = await mkdtemp(path.join(tmpdir(), "public-restore-")); + const calls: { command: string; args: string[]; environment: object }[] = []; + const environment = { PGDATABASE: "validated-database", PGPASSFILE: "private-passfile" }; + let selection = "", applied = "", failure: unknown; + try { + await restorePublicArchive({ archive: "/synthetic/archive.dump", directory, environment, + fail: (message: string) => { throw new Error(message); }, + commandOutput: async (command: string, args: string[], env: object) => { + calls.push({ command, args, environment: env }); + return command === "pg_restore" ? options.contents ?? toc : options.target ?? "f\n"; + }, + run: async (command: string, args: string[], env: object) => { + calls.push({ command, args, environment: env }); + const file = args.find((arg) => arg.startsWith("--file="))!.slice(7); + if (command === "pg_restore") { + selection = await readFile(args.find((arg) => arg.startsWith("--use-list="))!.slice(11), "utf8"); + await writeFile(file, "CREATE TABLE public.recovered (id integer);\n"); + if (options.generationFailure) throw new Error("synthetic archive failure"); + } else { + applied = await readFile(file, "utf8"); + if (options.applyFailure) throw new Error("synthetic SQL failure"); + } + }, + }); + } catch (error) { failure = error; } + const remaining = await readdir(directory); + await rm(directory, { recursive: true, force: true }); + return { calls, selection, applied, failure, remaining, environment }; +} + +describe("complete public-schema archive restore", () => { + it("materializes SQL, filters only the public definition, and atomically replaces target residue", async () => { + const result = await fixture(); + expect(result.failure).toBeUndefined(); + expect(result.calls.map(({ command }) => command)).toEqual(["pg_restore", "psql", "pg_restore", "psql"]); + expect(result.selection).toContain(";3; 2615 2200 SCHEMA - public owner"); + expect(result.selection).toContain("\n4; 0 0 COMMENT - SCHEMA public owner"); + expect(result.applied).toContain("DROP SCHEMA IF EXISTS public CASCADE;\nCREATE SCHEMA public AUTHORIZATION CURRENT_USER;"); + expect(result.applied).toContain("CREATE TABLE public.recovered"); + expect(result.applied).toContain("RAISE EXCEPTION 'Restore refuses other user schemas'"); + expect(result.calls[2].args).toEqual(expect.arrayContaining(["--no-owner", "--no-privileges", "--exit-on-error"])); + expect(result.calls[3].args).toEqual(expect.arrayContaining(["--single-transaction", "ON_ERROR_STOP=1"])); + expect(result.calls.every(({ environment }) => environment === result.environment)).toBe(true); + expect(JSON.stringify(result.calls.map(({ args }) => args))).not.toContain("private-passfile"); + expect(result.remaining).toEqual([]); + }); + + it("never applies a truncated but valid SQL prefix after archive generation fails", async () => { + const result = await fixture({ generationFailure: true }); + expect(result.failure).toBeInstanceOf(Error); + expect(result.calls.filter(({ command }) => command === "psql")).toHaveLength(1); + expect(result.applied).toBe(""); + expect(result.remaining).toEqual([]); + }); + + it("cleans private staging even if transactional SQL application fails", async () => { + const result = await fixture({ applyFailure: true }); + expect(result.failure).toBeInstanceOf(Error); + expect(result.remaining).toEqual([]); + }); + + it.each([ + { contents: `${toc}6; 2615 123 SCHEMA - another_application owner\n` }, + { target: "t\n" }, + ])("refuses archives or targets with other user schemas before mutation: %j", async (options) => { + const result = await fixture(options); + expect(result.failure).toBeInstanceOf(Error); + expect(result.applied).toBe(""); + expect(result.calls.filter(({ command, args }) => command === "pg_restore" && args[0] !== "--list")).toHaveLength(0); + expect(result.remaining).toEqual([]); + }); +}); diff --git a/tests/unit/dev-stack.test.ts b/tests/unit/dev-stack.test.ts new file mode 100644 index 0000000..0d8cb17 --- /dev/null +++ b/tests/unit/dev-stack.test.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from "node:events"; +import { spawn } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { assertLocalServices, localServices, supervise } from "@/scripts/dev-stack.mjs"; + +const local = { DATABASE_URL: "postgresql://local:local@127.0.0.1:5440/local_test", REDIS_URL: "redis://127.0.0.1:6395" }; + +class Child extends EventEmitter { + connected = true; + kill = vi.fn(() => { queueMicrotask(() => this.emit("exit", 0)); return true; }); + send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { + callback(null); + this.kill(); + }); +} + +function harness() { + const children: Child[] = []; + const signals = new EventEmitter(); + const spawnProcess = vi.fn(() => { const child = new Child(); children.push(child); return child; }); + const done = supervise(localServices(local), { + spawnProcess: spawnProcess as unknown as typeof spawn, + signals: signals as typeof process, + }); + return { children, signals, spawnProcess, done }; +} + +describe("complete local development stack", () => { + it("starts web, worker and beat with separate health ports and no in-process job poller", () => { + const services = localServices({ ...local, PORT: "3100" }); + expect(services.map((service) => service.name)).toEqual(["web", "worker", "beat"]); + expect(services[0].args).toContain("3100"); + expect(services[1].env.JOBS_HEALTH_PORT).toBe("3460"); + expect(services[2].env.JOBS_HEALTH_PORT).toBe("3461"); + expect(services.every((service) => service.env.JOBS_INPROCESS === "false")).toBe(true); + expect(services.slice(1).every((service) => service.ipc)).toBe(true); + }); + + it("rejects missing Redis, conflicting ports and production use", () => { + expect(() => localServices({ DATABASE_URL: local.DATABASE_URL })).toThrow("REDIS_URL"); + expect(() => localServices({ ...local, REDIS_URL: "memory://" })).toThrow("REDIS_URL"); + expect(() => localServices({ ...local, SWI_DEV_BEAT_PORT: "3460" })).toThrow("different"); + expect(() => localServices({ ...local, NODE_ENV: "production" })).toThrow("local development"); + }); + + it.each([ + { DATABASE_URL: "postgresql://db.example.test/prod" }, + { DIRECT_URL: "postgresql://db.example.test/prod" }, + { REDIS_URL: "redis://redis.example.test" }, + { S3_ENDPOINT: "https://bucket.example.test" }, + { S3_BUCKET: "production" }, + { SMTP_HOST: "smtp.example.test" }, + { SMTP_SERVICE: "gmail" }, + ])("refuses a remote service before local startup: %j", (override) => { + expect(() => assertLocalServices({ ...local, ...override })).toThrow(); + }); + + it("drains all children on a termination signal and removes signal listeners", async () => { + const { children, signals, done } = harness(); + signals.emit("SIGTERM"); + expect(await done).toBe(143); + expect(children.every((child) => child.send.mock.calls[0][0] && child.kill.mock.calls.length === 1)).toBe(true); + expect(signals.listenerCount("SIGTERM")).toBe(0); + expect(signals.listenerCount("SIGINT")).toBe(0); + }); + + it("stops siblings and fails the stack if one child exits unexpectedly", async () => { + const { children, done } = harness(); + children[0].emit("exit", 7); + expect(await done).toBe(7); + expect(children[1].send).toHaveBeenCalledWith({ type: "spaceworks.local.shutdown" }, expect.any(Function)); + expect(children[2].send).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/frontend-build-scripts.test.ts b/tests/unit/frontend-build-scripts.test.ts index 3b0ed0b..30146dd 100644 --- a/tests/unit/frontend-build-scripts.test.ts +++ b/tests/unit/frontend-build-scripts.test.ts @@ -16,7 +16,7 @@ function fixture(files: Record) { writeFileSync(path.join(directory, file), source); } mkdirSync(path.join(directory, "scripts"), { recursive: true }); - for (const name of ["prepare-frontend.mjs", "check-frontend-boundary.mjs", "frontend-render-declarations.mjs"]) { + for (const name of ["prepare-frontend.mjs", "check-frontend-boundary.mjs", "frontend-render-declarations.mjs", "frontend-source-hash.mjs"]) { cpSync(path.join(root, "scripts", name), path.join(directory, "scripts", name)); } return directory; @@ -30,6 +30,24 @@ afterEach(() => { }); describe("split frontend scripts", () => { + it("hashes LF and CRLF source identically but detects actual content changes", () => { + const files = { + "app/page.tsx": "export default function Page() { return null; }\n", + "components/label.ts": 'export const label = "Label";\n', + "lib/example.ts": 'export const example = "Example";\n', + "prisma/schema.prisma": 'generator client {\n provider = "prisma-client-js"\n}\n', + }; + const directory = fixture(files); + const hash = () => execFileSync(process.execPath, ["--input-type=module", "-e", + 'import { frontendSourceHash } from "./scripts/frontend-source-hash.mjs"; process.stdout.write(frontendSourceHash());', + ], { cwd: directory, encoding: "utf8" }); + const lf = hash(); + for (const [file, source] of Object.entries(files)) writeFileSync(path.join(directory, file), source.replace(/\n/g, "\r\n")); + expect(hash()).toBe(lf); + writeFileSync(path.join(directory, "lib/example.ts"), 'export const example = "Changed";\r\n'); + expect(hash()).not.toBe(lf); + }); + it("prepares an empty facade directory without skipping any source walks", () => { const directory = fixture({ "app/globals.css": '@import "tailwindcss";', diff --git a/tests/unit/public-asset-packaging.test.ts b/tests/unit/public-asset-packaging.test.ts new file mode 100644 index 0000000..258827e --- /dev/null +++ b/tests/unit/public-asset-packaging.test.ts @@ -0,0 +1,81 @@ +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; + +it("passes only fixture coordinates through an environment-clearing sudo", () => { + const source = readFileSync("scripts/test-vps-install.sh", "utf8").replace(/\r\n/g, "\n"); + const helper = source.match(/as_root\(\) \{[\s\S]*?\n\}/)?.[0]; + expect(helper).toBeTruthy(); + const windowsBash = "C:/Program Files/Git/bin/bash.exe"; + const bash = process.platform === "win32" && existsSync(windowsBash) ? windowsBash : "bash"; + const result = spawnSync(bash, ["-c", `${helper} +id() { printf '1000\\n'; } +sudo() { + if [[ "$1" = -E ]]; then shift; fi + env -i PATH="$PATH" SYSTEMROOT="\${SYSTEMROOT:-}" "$@" +} +export CI=true SWI_CONFIG_DIR='/tmp/private fixture/config' SWI_PROJECT_NAME=swi-smoke-disposable +export UNRELATED_TEST_SECRET=not-forwarded +as_root "$BASH" -c 'printf "%s\\n" "$CI" "$SWI_CONFIG_DIR" "$SWI_PROJECT_NAME" "\${UNRELATED_TEST_SECRET-unset}"' +`], { encoding: "utf8", timeout: 20_000, env: { ...process.env, NODE_ENV: "test" } }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual(["true", "/tmp/private fixture/config", "swi-smoke-disposable", "unset"]); +}, 25_000); + +it("cleans only its restrictive public fixture when an actual installer build fails", () => { + const fixture = mkdtempSync(path.join(tmpdir(), "inventory packaging ")); + const scripts = path.join(fixture, "scripts"), publicDir = path.join(fixture, "public"); + const windowsBash = "C:/Program Files/Git/bin/bash.exe"; + const bash = process.platform === "win32" && existsSync(windowsBash) ? windowsBash : "bash"; + try { + mkdirSync(scripts); mkdirSync(publicDir); mkdirSync(path.join(fixture, "tmp")); + mkdirSync(path.join(fixture, "docker/vps"), { recursive: true }); + cpSync(path.join(process.cwd(), "scripts/test-vps-install.sh"), path.join(scripts, "test-vps-install.sh")); + const existing = path.join(publicDir, "existing.txt"); + writeFileSync(existing, "existing checkout asset", { mode: 0o640 }); + const mode = statSync(existing).mode; + const result = spawnSync(bash, ["-c", ` +docker() { + if [[ "$1" = build ]]; then + probe=$(find "$PWD/public" -mindepth 1 -maxdepth 1 -type d -name 'audit-permissions.*') + scripts_probe=$(find "$PWD/scripts" -maxdepth 1 -type f -name 'audit-permissions.*.mjs') + vps_probe=$(find "$PWD/docker/vps" -mindepth 1 -maxdepth 1 -type d -name 'audit-permissions.*') + [[ -n "$probe" && -n "$scripts_probe" && -n "$vps_probe" ]] || return 91 + cat "$probe/nested/readable.txt" > "$PROBE_CONTENT_LOG" + stat -c '%a' "$probe" "$probe/nested" "$probe/nested/readable.txt" "$scripts_probe" "$vps_probe" "$vps_probe/readable.mjs" > "$PROBE_MODE_LOG" + return 23 + fi +} +id() { printf '0\\n'; } +export -f docker id +if [[ -n "\${SWI_WINDOWS_FIXTURE:-}" ]]; then + # Windows has no POSIX chmod contract; Linux checks the real modes below. + mkdir() { if [[ "$1" = -m ]]; then shift 2; fi; command mkdir "$@"; } + export -f mkdir +fi +exec "$BASH" scripts/test-vps-install.sh +`], { + cwd: fixture, encoding: "utf8", timeout: 20_000, + env: { + ...process.env, NODE_ENV: "test", CI: "true", + TMPDIR: path.join(fixture, "tmp").replaceAll("\\", "/"), + PROBE_CONTENT_LOG: path.join(fixture, "content.log").replaceAll("\\", "/"), + PROBE_MODE_LOG: path.join(fixture, "modes.log").replaceAll("\\", "/"), + SWI_WINDOWS_FIXTURE: process.platform === "win32" ? "1" : "", + }, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(23); + expect(readFileSync(path.join(fixture, "content.log"), "utf8")).toBe("inventory-public-permission-fixture\n"); + if (process.platform !== "win32") expect(readFileSync(path.join(fixture, "modes.log"), "utf8").trim().split("\n")).toEqual(["700", "700", "600", "600", "700", "600"]); + expect(readdirSync(publicDir)).toEqual(["existing.txt"]); + expect(readFileSync(existing, "utf8")).toBe("existing checkout asset"); + expect(statSync(existing).mode).toBe(mode); + expect(readdirSync(scripts)).toEqual(["test-vps-install.sh"]); + expect(readdirSync(path.join(fixture, "docker/vps"))).toEqual([]); + expect(readdirSync(path.join(fixture, "tmp"))).toEqual([]); + } finally { rmSync(fixture, { recursive: true, force: true }); } +}, 25_000); diff --git a/tests/unit/vps-edge-build.test.ts b/tests/unit/vps-edge-build.test.ts new file mode 100644 index 0000000..771d367 --- /dev/null +++ b/tests/unit/vps-edge-build.test.ts @@ -0,0 +1,64 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +function fetchImages(tls: string, published: boolean, failure: "edge" | "login" | "pull" | "build" | null = null) { + const root = mkdtempSync(join(tmpdir(), "swi-edge-build-")); + const windowsBash = "C:/Program Files/Git/bin/bash.exe"; + const bash = process.platform === "win32" && existsSync(windowsBash) ? windowsBash : "bash"; + try { + writeFileSync(join(root, "compose.env"), `SWI_CADDY_TLS='${tls}'\n`); + return spawnSync(bash, ["-s"], { + encoding: "utf8", timeout: 15_000, + env: { NODE_ENV: "test", PATH: process.env.PATH, SYSTEMROOT: process.env.SYSTEMROOT, SWI_CONFIG_DIR: root.replaceAll("\\", "/") }, + input: `set -euo pipefail +source scripts/vps-lib.sh +is_published_version() { return ${published ? 0 : 1}; } +ghcr_login() { return ${failure === "login" ? 23 : 0}; } +say() { :; } +compose() { + printf '%s\\n' "$*" + if [[ "$1" = "${failure}" ]]; then return 23; fi + if [[ "$*" = 'build proxy' ]]; then return ${failure === "edge" ? 23 : 0}; fi +} +if fetch_images 0.6.0 split; then printf 'ready\\n'; else exit $?; fi +`, + }); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +describe("installer security-patched web edge", () => { + it.each(["internal", "public", ""])("builds the edge after published app pulls for %j TLS", tls => { + const result = fetchImages(tls, true); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual(["pull --quiet app frontend", "build proxy", "ready"]); + }); + + it("builds app images and the edge from a source release", () => { + const result = fetchImages("internal", false); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual(["build app frontend", "build proxy", "ready"]); + }); + + it("skips the bundled edge behind the operator's existing reverse proxy", () => { + const result = fetchImages("proxy", true); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual(["pull --quiet app frontend", "ready"]); + }); + + it.each(["login", "pull", "build"] as const)("preserves an earlier %s failure in conditional callers", failure => { + const result = fetchImages("internal", failure !== "build", failure); + expect(result.status, result.stderr).toBe(23); + expect(result.stdout).not.toContain("build proxy"); + expect(result.stdout).not.toContain("ready"); + }); + + it("does not start the stack after an edge build failure", () => { + const result = fetchImages("internal", true, "edge"); + expect(result.status, result.stderr).toBe(23); + expect(result.stdout).not.toContain("ready"); + }); +}); diff --git a/tests/unit/vps-installer-deployment.test.ts b/tests/unit/vps-installer-deployment.test.ts index bb0ce81..291d888 100644 --- a/tests/unit/vps-installer-deployment.test.ts +++ b/tests/unit/vps-installer-deployment.test.ts @@ -1,9 +1,30 @@ -import { readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { root } from "./vps-installer-fixtures"; describe("VPS installer deployment", () => { + it("tears down all profiles after a layout change while normal startup follows the saved layout", () => { + const directory = mkdtempSync(path.join(tmpdir(), "swi-compose-profiles-")); + const windowsBash = "C:/Program Files/Git/bin/bash.exe"; + const bash = process.platform === "win32" && existsSync(windowsBash) ? windowsBash : "bash"; + const wrapper = readFileSync(path.join(root, "scripts/vps-compose.sh"), "utf8").replace(/\r\n/g, "\n"); + // Exercise all real profile selection before replacing only process launch. + const script = wrapper.replace(/exec docker compose[^\n]*/, 'printf "%s\\n" "$COMPOSE_PROFILES"'); + try { + writeFileSync(path.join(directory, "compose.env"), "SWI_LAYOUT='single'\nSWI_CADDY_TLS='internal'\nSWI_STORAGE='bundled'\n"); + for (const [command, profiles] of [["up", "caddy,silo"], ["down", "*"]]) { + const result = spawnSync(bash, ["-c", script, "swi-compose-fixture", command], { + cwd: root, env: { ...process.env, SWI_CONFIG_DIR: directory.replaceAll("\\", "/"), SWI_BUILD_KEY: "disposable-fixture" }, encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(profiles); + } + } finally { rmSync(directory, { recursive: true, force: true }); } + }); + it("runs the split layout as website and API containers behind one Caddy", () => { const compose = readFileSync(path.join(root, "compose.production.yml"), "utf8"); expect(compose).toMatch(/migrator:\n(?:.*\n)*?\s+command: \["migrate"\]/); diff --git a/tests/unit/vps-restore-cleanup.test.ts b/tests/unit/vps-restore-cleanup.test.ts new file mode 100644 index 0000000..9b50507 --- /dev/null +++ b/tests/unit/vps-restore-cleanup.test.ts @@ -0,0 +1,52 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(path.resolve(import.meta.dirname, "../../scripts/vps-restore.sh"), "utf8").replace(/\r\n/g, "\n"); +const cleanup = source.slice(source.indexOf("cleanup() {"), source.indexOf("\ntrap cleanup EXIT")); +const windowsBash = "C:/Program Files/Git/bin/bash.exe"; +const bash = process.platform === "win32" && existsSync(windowsBash) ? windowsBash : "bash"; + +function execute(stopped: number, committed: number, status = 7) { + return spawnSync(bash, ["-c", `set -euo pipefail +work='' key_file='' +stopped=${stopped} +database_committed=${committed} +serving=(app worker beat proxy frontend) +warn() { printf '%s\\n' "$*" >&2; } +compose() { printf 'compose %s\\n' "$*" >&3; } +${cleanup} +trap cleanup EXIT +exit ${status}`, "restore-cleanup-fixture"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe", "pipe"] }); +} + +describe("VPS restore failure phases", () => { + it("does not change services when validation or SQL materialization fails before stopping", () => { + const result = execute(0, 0); + expect(result.status).toBe(7); + expect(result.output[3]).toBe(""); + }); + + it("restarts the previous deployment only after a failed uncommitted database transaction", () => { + const result = execute(1, 0); + expect(result.status).toBe(7); + expect(result.output[3]).toBe("compose up -d\n"); + expect(result.stderr).toContain("did not commit"); + }); + + it.each(["object restore", "migration", "readiness"])("leaves serving processes stopped after committed SQL and %s failure", () => { + const result = execute(1, 1); + expect(result.status).toBe(7); + expect(result.output[3]).toBe("compose stop app worker beat proxy frontend\n"); + expect(result.output[3]).not.toContain("up"); + expect(result.stderr).toContain("rerun the complete restore"); + }); + + it("leaves a successful recovered deployment running", () => { + const result = execute(0, 1, 0); + expect(result.status).toBe(0); + expect(result.output[3]).toBe(""); + expect(result.stderr).toBe(""); + }); +}); diff --git a/tests/unit/vps-restore-transaction.test.ts b/tests/unit/vps-restore-transaction.test.ts new file mode 100644 index 0000000..59a6124 --- /dev/null +++ b/tests/unit/vps-restore-transaction.test.ts @@ -0,0 +1,84 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = path.resolve(import.meta.dirname, "../.."); +const source = readFileSync(path.join(root, "scripts/vps-restore.sh"), "utf8").replace(/\r\n/g, "\n"); +const block = source.slice(source.indexOf("# Materialize the complete restore"), source.indexOf("\nif [[ $has_objects = 1 ]]; then\n compose run")); +const windowsBash = "C:/Program Files/Git/bin/bash.exe"; +const bash = process.platform === "win32" && existsSync(windowsBash) ? windowsBash : "bash"; + +function execute(failGeneration = false) { + const directory = mkdtempSync(path.join(tmpdir(), "swi-restore-transaction-")); + try { + writeFileSync(path.join(directory, "database.dump"), "synthetic custom archive"); + const result = spawnSync(bash, ["-c", `set -euo pipefail +work="$RESTORE_FIXTURE_DIR" +backup="$RESTORE_FIXTURE_DIR" +stopped=0 +serving=(app worker beat) +pg_restore() { + if [[ "$1" == --list ]]; then + printf '3; 2615 2200 SCHEMA - public swi\\n4; 0 0 COMMENT - SCHEMA public swi\\n5; 0 0 ACL - SCHEMA public swi\\n6; 1259 1 TABLE public recovered swi\\n' + return + fi + printf 'generate\\n' >> "$RESTORE_FIXTURE_DIR/trace" + local list + for argument in "$@"; do + case "$argument" in --use-list=*) list="$argument" ;; esac + done + list="$(printf '%s' "$list" | cut -d= -f2-)" + if grep -q '^3;' "$list"; then printf 'CREATE SCHEMA public;\\n'; fi + if grep -q '^4;' "$list"; then printf "COMMENT ON SCHEMA public IS 'Recovered schema comment';\\n"; fi + if grep -q '^5;' "$list"; then printf 'GRANT USAGE ON SCHEMA public TO synthetic_reader;\\n'; fi + printf 'CREATE TABLE public.recovered (id integer);\\n' + [[ "$FAIL_GENERATION" != 1 ]] || return 7 +} +export -f pg_restore +compose() { + if [[ "$1:$2:$3" == exec:postgres:sh ]]; then + shift 3 + bash "$@" + elif [[ "$1:$2:$3" == exec:postgres:psql ]]; then + printf 'execute %s\\n' "$*" >> "$RESTORE_FIXTURE_DIR/trace" + cat > "$RESTORE_FIXTURE_DIR/applied.sql" + elif [[ "$1" == stop ]]; then + printf 'stop\\n' >> "$RESTORE_FIXTURE_DIR/trace" + else + return 99 + fi +} +${block}`], { cwd: root, env: { ...process.env, RESTORE_FIXTURE_DIR: directory.replaceAll("\\", "/"), FAIL_GENERATION: failGeneration ? "1" : "0" }, encoding: "utf8" }); + return { ...result, trace: readFileSync(path.join(directory, "trace"), "utf8"), applied: existsSync(path.join(directory, "applied.sql")) ? readFileSync(path.join(directory, "applied.sql"), "utf8") : null }; + } finally { rmSync(directory, { recursive: true, force: true }); } +} + +describe("VPS custom archive restore transaction", () => { + it("prepares a complete schema replacement before stopping or applying it", () => { + const result = execute(); + expect(result.status, result.stderr).toBe(0); + const [generation, stopped, applied] = result.trace.trim().split("\n"); + expect(generation).toBe("generate"); + expect(stopped).toBe("stop"); + expect(applied).toContain("psql -X -U swi -d swi --single-transaction --set ON_ERROR_STOP=1 --file=-"); + expect(result.applied).toContain("DROP SCHEMA IF EXISTS public CASCADE;\nCREATE SCHEMA public AUTHORIZATION swi;"); + expect(result.applied).not.toContain("CREATE SCHEMA public;"); + expect(result.applied).toContain("CREATE TABLE public.recovered"); + expect(result.applied).toContain("COMMENT ON SCHEMA public IS 'Recovered schema comment';"); + expect(result.applied).toContain("GRANT USAGE ON SCHEMA public TO synthetic_reader;"); + expect(result.applied).toContain("REVOKE ALL ON SCHEMA public FROM PUBLIC, swi_app;"); + expect(result.applied).toContain("GRANT USAGE ON SCHEMA public TO swi_app;"); + expect(result.applied).toContain("GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO swi_app;"); + expect(result.applied).toContain("GRANT USAGE, SELECT ON SEQUENCES TO swi_app;"); + expect(result.applied).not.toContain("GRANT ALL"); + }); + + it("does not stop services or execute a valid partial SQL prefix when archive generation fails", () => { + const result = execute(true); + expect(result.status).toBe(7); + expect(result.trace).toBe("generate\n"); + expect(result.applied).toBeNull(); + }); +}); diff --git a/tests/unit/webhook-network.test.ts b/tests/unit/webhook-network.test.ts new file mode 100644 index 0000000..be6a175 --- /dev/null +++ b/tests/unit/webhook-network.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isPrivateAddress, webhookUrlProblem } from "@/lib/events/network"; + +describe("webhook address isolation", () => { + it.each([ + "127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", + "::", "::1", "0:0:0:0:0:0:0:1", "0:0:0:0:0:0:0:0", "::ffff:127.0.0.1", + "::ffff:7f00:1", "0:0:0:0:0:ffff:a00:1", "::ffff:a9fe:a9fe", + "fe80::1", "0:0:0:0:0:ffff:c0a8:101", "fc00::1", "ff02::1", + "64:ff9b::a9fe:a9fe", "2002:7f00:1::", "invalid", + ])("blocks non-public destination %s", (address) => { + expect(isPrivateAddress(address)).toBe(true); + }); + + it.each(["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111", "2001:4860:4860::8888", "::ffff:808:808"])("allows public destination %s", (address) => { + expect(isPrivateAddress(address)).toBe(false); + }); + + it.each(["https://[::ffff:127.0.0.1]/hook", "https://[::ffff:7f00:1]/hook", "https://[0:0:0:0:0:0:0:1]/hook"])("rejects canonicalized private endpoint %s", (url) => { + expect(webhookUrlProblem(url, { NODE_ENV: "production" })).toBe("Webhooks can only reach public addresses."); + }); +});