From 7ab7aa135e5a7fb166ce328eaf192e5b7b491b45 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:20:08 +0530 Subject: [PATCH 01/79] fix(ci): pin every external action to a verified commit SHA Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/quality.yml | 4 ++-- .github/workflows/release.yml | 20 ++++++++++---------- .github/workflows/yuyu-upstream.yml | 2 +- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f30cd207..f6eb845e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm @@ -64,8 +64,8 @@ jobs: --health-timeout 5s --health-retries 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm @@ -86,8 +86,8 @@ jobs: matrix: deployment: [split, vps] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm @@ -97,7 +97,7 @@ jobs: # PostgreSQL/Redis containers. No deployed database or hosting keys needed. - run: npm run test:e2e:${{ matrix.deployment }} # Retry traces of failed specs (disposable data and credentials only). - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: failure() with: name: playwright-${{ matrix.deployment }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 64d586c4..fba5974c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -45,8 +45,8 @@ jobs: S3_REGION: us-east-1 ALLOW_INSECURE_PRODUCTION_TESTS: "1" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44147a91..2b326c0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: release: ${{ steps.release.outputs.release }} prerelease: ${{ steps.release.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ github.event.workflow_run.head_sha || github.sha }} persist-credentials: false @@ -116,12 +116,12 @@ jobs: org.opencontainers.image.version=${{ needs.prepare.outputs.version }} org.opencontainers.image.licenses=AGPL-3.0-or-later steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ needs.prepare.outputs.sha }} persist-credentials: false - - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v4 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -137,7 +137,7 @@ jobs: - name: Build backend id: backend - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ env.BUILD_ACTIONS_KEY }} with: @@ -157,7 +157,7 @@ jobs: - name: Build frontend id: frontend - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ env.BUILD_ACTIONS_KEY }} with: @@ -183,7 +183,7 @@ jobs: mkdir -p digests/spaceworks-events-backend digests/spaceworks-events-frontend touch "digests/spaceworks-events-backend/${BACKEND#sha256:}" \ "digests/spaceworks-events-frontend/${FRONTEND#sha256:}" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: digests-${{ matrix.runner }} path: digests/ @@ -196,13 +196,13 @@ jobs: contents: read packages: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: digests-* merge-multiple: true path: digests - - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v4 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/yuyu-upstream.yml b/.github/workflows/yuyu-upstream.yml index aea0d632..aef866fc 100644 --- a/.github/workflows/yuyu-upstream.yml +++ b/.github/workflows/yuyu-upstream.yml @@ -17,7 +17,7 @@ jobs: check: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: dev fetch-depth: 0 From c666ee46261fc164606908a90600775bb00968f0 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:27:26 +0530 Subject: [PATCH 02/79] fix(docker): install the Prisma CLI and operator clients with npm ci from committed lockfiles Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 31 +- docker/deps/ops/package-lock.json | 508 +++++++++++++++++++++++ docker/deps/ops/package.json | 10 + docker/deps/prisma-cli/package-lock.json | 386 +++++++++++++++++ docker/deps/prisma-cli/package.json | 8 + tests/unit/docker-deps.test.ts | 36 ++ 6 files changed, 962 insertions(+), 17 deletions(-) create mode 100644 docker/deps/ops/package-lock.json create mode 100644 docker/deps/ops/package.json create mode 100644 docker/deps/prisma-cli/package-lock.json create mode 100644 docker/deps/prisma-cli/package.json create mode 100644 tests/unit/docker-deps.test.ts diff --git a/Dockerfile b/Dockerfile index 2805767d..7f0e5547 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,28 +23,25 @@ COPY prisma ./prisma COPY scripts/prisma.mjs ./scripts/prisma.mjs CMD ["node", "scripts/prisma.mjs", "migrate", "deploy"] -# Prisma CLI only, pinned to the lockfile's version, so the runner can apply -# migrations on start (MIGRATE_ON_START=1) on platforms without a release job, -# such as the Render free plan. Kept apart from the app's node_modules. +# Prisma CLI only, so the runner can apply migrations on start +# (MIGRATE_ON_START=1) on platforms without a release job, such as the Render +# free plan. Kept apart from the app's node_modules and installed with `npm ci` +# from its own committed lockfile, so every transitive dependency is pinned +# too. tests/unit/docker-deps.test.ts keeps its version equal to the root +# lockfile's. 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 \ - "prisma@$(node -p "require('/tmp/prisma-package.json').version")" \ - && npm cache clean --force +COPY docker/deps/prisma-cli/package.json docker/deps/prisma-cli/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force -# Clients the VPS operator tools (docker/vps/*.mjs) import, pinned to the -# lockfile's versions. The application bundles its own copies into its build. +# Clients the VPS operator tools (docker/vps/*.mjs) import, installed with +# `npm ci` from their own committed lockfile (same direct versions as the root +# lockfile, checked by tests/unit/docker-deps.test.ts). The application +# bundles its own copies into its build. FROM node:22-alpine AS ops-modules 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 -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")" \ - && npm cache clean --force +COPY docker/deps/ops/package.json docker/deps/ops/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force # Stage 2: Rebuild the source code FROM node:22-alpine AS builder diff --git a/docker/deps/ops/package-lock.json b/docker/deps/ops/package-lock.json new file mode 100644 index 00000000..cfd0e833 --- /dev/null +++ b/docker/deps/ops/package-lock.json @@ -0,0 +1,508 @@ +{ + "name": "spaceworks-events-ops-modules", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spaceworks-events-ops-modules", + "dependencies": { + "@aws-sdk/client-s3": "3.1116.0", + "ioredis": "6.0.0", + "nodemailer": "10.2.1" + } + }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1001.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.7.tgz", + "integrity": "sha512-hUlYwqlsiDuPyhI5nQLgLZYKo0bFvMKaVk9JRfpb9S2kXxT2ZhyHZ5RTfH3PBpD3dMnDkGrA0/aY1AeAMFLmrQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1116.0.tgz", + "integrity": "sha512-UKRl9qSVW0rZpvSOauQNpYAy8+ONBAVYnpfKVtCyOF+FZVT1tl6MunYuHvuarCrroD2/YJs+tHTALYNgAlec3Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.29", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.81", + "@aws-sdk/middleware-sdk-s3": "^3.972.75", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.7.tgz", + "integrity": "sha512-QsYdn5xhvEBzcDC6bgKuvPv87kFtXK/3f5NqKfR+eok5AAn7RaZnkgBKncX+UW3ZMcjSELyAMATN0iqgZmvWVg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@aws-sdk/xml-builder": "^3.972.42", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.37.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.78.tgz", + "integrity": "sha512-d8V47OxkM/FKe7bVfk5Rga4aNGUaQNk3ip1PFIYhxtup9r8SuNIHLs4HfBAmHT25ZbzowluMHp833UXfFMQbhA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.80.tgz", + "integrity": "sha512-OQqanXiRhnl/wBIJc3TtA7TYmnN7VO0mP95eVoHsLXtPwHz6HeccMpi7cqtnhHw+K7p6VIoAt2IK4M9Ixd9uGg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.13.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.23.tgz", + "integrity": "sha512-pTTRK7xoi8NNbrcjVUSd9YdMTUxiWEPfk8newBQCzV+VS5STh5sDPAy3iO29fSqipqNuCpILJcD6wAawxHvG+w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/credential-provider-env": "^3.972.78", + "@aws-sdk/credential-provider-http": "^3.972.80", + "@aws-sdk/credential-provider-login": "^3.972.85", + "@aws-sdk/credential-provider-process": "^3.972.78", + "@aws-sdk/credential-provider-sso": "^3.973.22", + "@aws-sdk/credential-provider-web-identity": "^3.972.84", + "@aws-sdk/nested-clients": "^3.997.52", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.85", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.85.tgz", + "integrity": "sha512-UHjnTeS+5OBFfRL+BQwCGJPJvnzjhGD6dIwncBJDa+ziyQjPUte9Q0I4BCkybGrHotnyiiluXj13bgelP14J5Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/nested-clients": "^3.997.52", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.90", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.90.tgz", + "integrity": "sha512-+McgOQuAUze95AumVmoOBRp++Z8IjsZ0qOASb6+igHdKNzA0FiIf5kbTFaZ64txfd9KTBDpgvlEDGQE41lSq/A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.78", + "@aws-sdk/credential-provider-http": "^3.972.80", + "@aws-sdk/credential-provider-ini": "^3.973.23", + "@aws-sdk/credential-provider-process": "^3.972.78", + "@aws-sdk/credential-provider-sso": "^3.973.22", + "@aws-sdk/credential-provider-web-identity": "^3.972.84", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.78.tgz", + "integrity": "sha512-bzB92SriflYf6LVRJcDv/a9I87tmkdmdE8hAqT1oRGKFkEH2rxxHeHZVK/4vsiRsFi44cUj0J9+bUSmqYX+26w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.22.tgz", + "integrity": "sha512-MXUkWHgcf6G+TowL+QPvn4kJTEYKd9qHKMV6eNGXSA5lilDgGRrP3EXPHChZ3eZ7z+oprn/ZvOovLoS2WR3D8g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/nested-clients": "^3.997.52", + "@aws-sdk/token-providers": "3.1159.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.84.tgz", + "integrity": "sha512-6I3zYcDHt5ZM0J4gEuRgCHgN505k5haUiiPM46iIQgrHm2p70zpnssHFVYqHYfgJe3Mf+a5+nsF3BZmW4xcZNg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/nested-clients": "^3.997.52", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.83.tgz", + "integrity": "sha512-SPZ4knkYFQfNv423Yu5nCxKwRXjWCRWv2OIzO+t+ffjdu0Ux6HlClFjy10OBKwzzVFJs9YvgiHYSS21p+TG4xg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.50", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.52.tgz", + "integrity": "sha512-/bNl6b3VEoWQg3Z8yHt3gzYjSFoOOv7+eUglzITmGP8H7jovN8c9NoOI+6bU25+lSLvyRpJOs4n8YSaX+BcUPg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.50", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.13.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.50.tgz", + "integrity": "sha512-Ob9GCZ3YnGPws696zTYW/rLFYHa9QLh6DgTCnkHb+0jhHNBgHWM8Ui2b+yrIjkfDsRVsglyrOey6tOOG/VWxMA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1159.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1159.0.tgz", + "integrity": "sha512-IJvghXXcEpqTWGjrjJwxBdEecHeXp+t9FaybRf8Zf0wYW84KN1snXBsYOdfDBBx78ChwfJxH8KIHqmhNZuDLcw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.7", + "@aws-sdk/nested-clients": "^3.997.52", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz", + "integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.42.tgz", + "integrity": "sha512-smX8/gNP8Kjq+CWTBMZQLQ912vOQjiWExzwzvMO+eujPB5RY8IbtF0AGdUUXHKN7KoDWK9dIcuW0WG269kiP6w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@ioredis/commands": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz", + "integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==", + "license": "MIT" + }, + "node_modules/@smithy/core": { + "version": "3.37.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.37.0.tgz", + "integrity": "sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.13.0.tgz", + "integrity": "sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bowser": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.15.0.tgz", + "integrity": "sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==", + "license": "MIT" + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/ioredis": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz", + "integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "2.0.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz", + "integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/docker/deps/ops/package.json b/docker/deps/ops/package.json new file mode 100644 index 00000000..f87e0993 --- /dev/null +++ b/docker/deps/ops/package.json @@ -0,0 +1,10 @@ +{ + "name": "spaceworks-events-ops-modules", + "private": true, + "description": "Clients the VPS operator tools (docker/vps/*.mjs) import. Keep the versions equal to the root lockfile's (tests/unit/docker-deps.test.ts).", + "dependencies": { + "@aws-sdk/client-s3": "3.1116.0", + "ioredis": "6.0.0", + "nodemailer": "10.2.1" + } +} diff --git a/docker/deps/prisma-cli/package-lock.json b/docker/deps/prisma-cli/package-lock.json new file mode 100644 index 00000000..4119cc8b --- /dev/null +++ b/docker/deps/prisma-cli/package-lock.json @@ -0,0 +1,386 @@ +{ + "name": "spaceworks-events-prisma-cli", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spaceworks-events-prisma-cli", + "dependencies": { + "prisma": "6.19.3" + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/exsolve": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.2.0.tgz", + "integrity": "sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.12.tgz", + "integrity": "sha512-4AhWntz+ds/P5kz1rN25yGegTPYsO5ffOy+8S0TeuXKZrgjlmgcEBLA1xubzcP/dWX4zYfNybv4TQERlL9Z0LQ==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.3.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz", + "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==", + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.3", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.2.tgz", + "integrity": "sha512-ZUAKhKlkCOrIGKc8cj7zek9s9z1mveHif/25nUshI2BzCRzF7KH7KHa+Lnh2ubq+e1tIlaFuP763mzW4qVoehg==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/docker/deps/prisma-cli/package.json b/docker/deps/prisma-cli/package.json new file mode 100644 index 00000000..e8cd5a18 --- /dev/null +++ b/docker/deps/prisma-cli/package.json @@ -0,0 +1,8 @@ +{ + "name": "spaceworks-events-prisma-cli", + "private": true, + "description": "Prisma CLI for the backend image's MIGRATE_ON_START path. Keep the version equal to the root lockfile's (tests/unit/docker-deps.test.ts).", + "dependencies": { + "prisma": "6.19.3" + } +} diff --git a/tests/unit/docker-deps.test.ts b/tests/unit/docker-deps.test.ts new file mode 100644 index 00000000..76c74376 --- /dev/null +++ b/tests/unit/docker-deps.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +type Lock = { packages: Record }> }; + +const json = (file: string): T => JSON.parse(readFileSync(resolve(__dirname, "../..", file), "utf8")) as T; +const rootLock = json("package-lock.json"); + +describe.each(["docker/deps/prisma-cli", "docker/deps/ops"])("image dependency set %s", (dir) => { + const manifest = json<{ dependencies: Record }>(`${dir}/package.json`); + const lock = json(`${dir}/package-lock.json`); + + it("pins each direct dependency to the root lockfile's version", () => { + for (const [name, version] of Object.entries(manifest.dependencies)) { + expect(version, name).toBe(rootLock.packages[`node_modules/${name}`]?.version); + } + }); + + it("has a lockfile that matches its manifest, so npm ci installs it", () => { + expect(lock.packages[""]?.dependencies).toEqual(manifest.dependencies); + for (const [name, version] of Object.entries(manifest.dependencies)) { + expect(lock.packages[`node_modules/${name}`]?.version, name).toBe(version); + } + }); +}); + +describe("Dockerfile", () => { + const dockerfile = readFileSync(resolve(__dirname, "../..", "Dockerfile"), "utf8"); + + it("installs the image-only dependency sets from their lockfiles, never with npm install", () => { + expect(dockerfile).not.toMatch(/npm install /); + expect(dockerfile).toContain("COPY docker/deps/prisma-cli/package.json docker/deps/prisma-cli/package-lock.json ./"); + expect(dockerfile).toContain("COPY docker/deps/ops/package.json docker/deps/ops/package-lock.json ./"); + }); +}); From 639623c4b134a4a419048acd08618932fc8e4c31 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:31:13 +0530 Subject: [PATCH 03/79] fix(api): cap request bodies on client errors, RSVP and check-in station Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/api/check-in/station/route.ts | 10 ++++- app/api/client-errors/route.ts | 14 +++--- app/api/rsvp/route.ts | 14 +++--- lib/boundedBody.ts | 52 +++++++++++++++++++++++ lib/mobile/boundedBody.ts | 39 +---------------- tests/helpers/chunkedRequest.ts | 25 +++++++++++ tests/unit/check-in-station-route.test.ts | 22 ++++++++++ tests/unit/client-errors-route.test.ts | 35 +++++++++++++++ tests/unit/rsvp-route.test.ts | 23 ++++++++++ 9 files changed, 180 insertions(+), 54 deletions(-) create mode 100644 lib/boundedBody.ts create mode 100644 tests/helpers/chunkedRequest.ts create mode 100644 tests/unit/client-errors-route.test.ts diff --git a/app/api/check-in/station/route.ts b/app/api/check-in/station/route.ts index c95ed064..e647a4b6 100644 --- a/app/api/check-in/station/route.ts +++ b/app/api/check-in/station/route.ts @@ -13,6 +13,9 @@ import { commitCheckInProjection, commitUndoCheckInProjection } from "@/lib/chec import { listRecentCheckIns } from "@/lib/checkInRecent"; import { badgeBrandingFor, badgeDesignFor, loadBadgeEvent } from "@/lib/badge/data"; import { attendeePhotoUrl } from "@/lib/badge/imageUrl"; +import { readBoundedJson } from "@/lib/boundedBody"; + +const MAX_BODY_BYTES = 128 * 1024; const bodySchema = z.object({ organisationSlug: z.string().trim().min(1).max(120), eventSlug: z.string().trim().min(1).max(160), @@ -40,8 +43,11 @@ function toData(rsvp: StationRsvp, force = false) { } export async function POST(request: Request) { - let input: z.infer; - try { input = bodySchema.parse(await request.json()); } catch { return failure("Invalid request."); } + const read = await readBoundedJson(request, MAX_BODY_BYTES); + if (read === "too_large") return failure("Request is too large.", 413); + const parsed = read === "invalid" ? null : bodySchema.safeParse(read.value); + if (!parsed?.success) return failure("Invalid request."); + const input = parsed.data; const event = await prisma.event.findFirst({ where: { slug: input.eventSlug, organisation: { slug: input.organisationSlug } }, select: { id: true, title: true, slug: true, organisationId: true, startDateTime: true, endDateTime: true, checkInStationPinHash: true, checkInStationSecretVersion: true, organisation: { select: { slug: true, name: true } } } }); if (!event || !event.checkInStationPinHash) return failure(); if (input.action === "unlock") { diff --git a/app/api/client-errors/route.ts b/app/api/client-errors/route.ts index 05d4e97b..41e89a96 100644 --- a/app/api/client-errors/route.ts +++ b/app/api/client-errors/route.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { readBoundedJson } from "@/lib/boundedBody"; import { reportOpsAlert, sanitizeAlertPath } from "@/lib/opsAlerts"; import { checkRateLimitById, getClientIpFromHeaders } from "@/lib/rateLimit"; @@ -28,15 +29,10 @@ export async function POST(request: Request) { if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) return noContent(); if (!(await checkRateLimitById("clientError", `ip:${getClientIpFromHeaders(request.headers)}`))) return noContent(); - let body: unknown; - try { - const text = await request.text(); - if (text.length > MAX_BODY_BYTES) return noContent(); - body = JSON.parse(text); - } catch { - return noContent(); - } - const parsed = reportSchema.safeParse(body); + // Read with a hard cap, so a chunked body without Content-Length stops at 4 KiB. + const body = await readBoundedJson(request, MAX_BODY_BYTES); + if (typeof body === "string") return noContent(); + const parsed = reportSchema.safeParse(body.value); if (!parsed.success) return noContent(); const { boundary, name, message, path } = parsed.data; diff --git a/app/api/rsvp/route.ts b/app/api/rsvp/route.ts index c96974ad..aece8e40 100644 --- a/app/api/rsvp/route.ts +++ b/app/api/rsvp/route.ts @@ -7,14 +7,16 @@ import { flattenZodErrors } from "@/app/actions/utils"; import { withApiMonitoring } from "@/lib/apiMonitor"; import crypto from "node:crypto"; import { checkRateLimitById, getClientIpFromHeaders } from "@/lib/rateLimit"; +import { readBoundedJson } from "@/lib/boundedBody"; + +// The same cap as the app's registration route (POST /api/mobile/v1/rsvp). +const MAX_BODY_BYTES = 128 * 1024; export const POST = withApiMonitoring("POST /api/rsvp", async (request: Request) => { - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json({ ok: false, error: "Invalid JSON." }, { status: 400 }); - } + const read = await readBoundedJson(request, MAX_BODY_BYTES); + if (read === "too_large") return NextResponse.json({ ok: false, error: "Request is too large." }, { status: 413 }); + if (read === "invalid") return NextResponse.json({ ok: false, error: "Invalid JSON." }, { status: 400 }); + const body = read.value; const guestEmail = typeof body === "object" && body !== null && "guestEmail" in body && typeof (body as { guestEmail?: unknown }).guestEmail === "string" ? (body as { guestEmail: string }).guestEmail.trim().toLowerCase() diff --git a/lib/boundedBody.ts b/lib/boundedBody.ts new file mode 100644 index 00000000..2bd7acec --- /dev/null +++ b/lib/boundedBody.ts @@ -0,0 +1,52 @@ +/** + * A request body read with a hard cap, whether or not `Content-Length` was + * sent (chunked uploads): reading stops, and the stream is cancelled, as soon + * as the cap is passed. The same pattern as the payment webhooks and + * POST /api/feedback. + */ +export async function readBoundedBody(request: Request, maxBytes: number): Promise { + const declared = Number(request.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) return "too_large"; + if (!request.body) return new Uint8Array(); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > maxBytes) { + await reader.cancel().catch(() => undefined); + return "too_large"; + } + chunks.push(part.value); + } + } catch { + return "unreadable"; + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +/** + * A JSON body read through `readBoundedBody`. An unreadable, empty or + * malformed body is "invalid", like a failed `request.json()`. + */ +export async function readBoundedJson(request: Request, maxBytes: number): Promise<{ value: unknown } | "too_large" | "invalid"> { + const bytes = await readBoundedBody(request, maxBytes); + if (bytes === "too_large") return "too_large"; + if (bytes === "unreadable") return "invalid"; + try { + return { value: JSON.parse(new TextDecoder().decode(bytes)) as unknown }; + } catch { + return "invalid"; + } +} diff --git a/lib/mobile/boundedBody.ts b/lib/mobile/boundedBody.ts index 632c056e..eedc554b 100644 --- a/lib/mobile/boundedBody.ts +++ b/lib/mobile/boundedBody.ts @@ -1,37 +1,2 @@ -/** - * A request body read with a hard cap, whether or not `Content-Length` was - * sent (chunked uploads): reading stops, and the stream is cancelled, as soon - * as the cap is passed. The same pattern as the payment webhooks and - * POST /api/feedback. - */ -export async function readBoundedBody(request: Request, maxBytes: number): Promise { - const declared = Number(request.headers.get("content-length")); - if (Number.isFinite(declared) && declared > maxBytes) return "too_large"; - if (!request.body) return new Uint8Array(); - const reader = request.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - try { - for (;;) { - const part = await reader.read(); - if (part.done) break; - size += part.value.byteLength; - if (size > maxBytes) { - await reader.cancel().catch(() => undefined); - return "too_large"; - } - chunks.push(part.value); - } - } catch { - return "unreadable"; - } finally { - reader.releaseLock(); - } - const body = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.byteLength; - } - return body; -} +// Moved to lib/boundedBody.ts so the public web routes share it. +export { readBoundedBody } from "@/lib/boundedBody"; diff --git a/tests/helpers/chunkedRequest.ts b/tests/helpers/chunkedRequest.ts new file mode 100644 index 00000000..91ecfc21 --- /dev/null +++ b/tests/helpers/chunkedRequest.ts @@ -0,0 +1,25 @@ +/** + * A chunked POST with no Content-Length: the body arrives in pieces, and the + * helper reports how many pieces were read and whether the reader cancelled. + */ +export function chunkedRequest(url: string, parts: string[], contentType = "application/json") { + let pulled = 0; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + if (pulled >= parts.length) return controller.close(); + controller.enqueue(new TextEncoder().encode(parts[pulled++])); + }, + cancel() { + cancelled = true; + }, + }); + const request = new Request(url, { method: "POST", body, headers: { "content-type": contentType }, duplex: "half" } as RequestInit); + return { request, pulled: () => pulled, cancelled: () => cancelled }; +} + +/** A JSON object padded past `bytes` and split into 1 KiB chunks. */ +export function oversizedJsonParts(bytes: number): string[] { + const json = JSON.stringify({ padding: "x".repeat(bytes) }); + return Array.from({ length: Math.ceil(json.length / 1024) }, (_, index) => json.slice(index * 1024, (index + 1) * 1024)); +} diff --git a/tests/unit/check-in-station-route.test.ts b/tests/unit/check-in-station-route.test.ts index 103d9fe0..65b183dc 100644 --- a/tests/unit/check-in-station-route.test.ts +++ b/tests/unit/check-in-station-route.test.ts @@ -34,6 +34,7 @@ vi.mock("next/headers", () => ({ cookies: async () => ({ get: () => ({ value: "p vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); import { POST } from "@/app/api/check-in/station/route"; +import { chunkedRequest, oversizedJsonParts } from "@/tests/helpers/chunkedRequest"; const event = { id: "event_1", @@ -90,6 +91,27 @@ describe("venue check-in station route", () => { expect(await late.json()).toEqual({ ok: false, error: "This check-in station has closed." }); }); + it("stops reading a chunked body past 128 KiB and answers 413 before any lookup", async () => { + const stream = chunkedRequest("https://events.example.test/api/check-in/station", oversizedJsonParts(1024 * 1024)); + const response = await POST(stream.request); + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ ok: false, error: "Request is too large." }); + expect(stream.cancelled()).toBe(true); + expect(stream.pulled()).toBeLessThan(200); + expect(mocks.eventFind).not.toHaveBeenCalled(); + }); + + it("reads a normal chunked body and keeps the invalid-request answer", async () => { + const body = JSON.stringify({ organisationSlug: "org", eventSlug: "event", action: "lookup", query: "Guest" }); + mocks.rsvpFindMany.mockResolvedValue([]); + const ok = await POST(chunkedRequest("https://events.example.test/api/check-in/station", [body.slice(0, 10), body.slice(10)]).request); + expect(ok.status).toBe(200); + expect(await ok.json()).toEqual({ ok: true, data: { rows: [] } }); + const broken = await POST(chunkedRequest("https://events.example.test/api/check-in/station", ["{"]).request); + expect(broken.status).toBe(400); + expect(await broken.json()).toEqual({ ok: false, error: "Invalid request." }); + }); + it("rejects action-specific payloads before querying attendee data", async () => { const response = await POST(request("lookup")); expect(response.status).toBe(400); diff --git a/tests/unit/client-errors-route.test.ts b/tests/unit/client-errors-route.test.ts new file mode 100644 index 00000000..69ae6b0f --- /dev/null +++ b/tests/unit/client-errors-route.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ report: vi.fn(), limit: vi.fn() })); +vi.mock("@/lib/opsAlerts", () => ({ reportOpsAlert: mocks.report, sanitizeAlertPath: (path: string) => path })); +vi.mock("@/lib/rateLimit", () => ({ getClientIpFromHeaders: () => "203.0.113.5", checkRateLimitById: mocks.limit })); + +import { POST } from "@/app/api/client-errors/route"; +import { chunkedRequest, oversizedJsonParts } from "@/tests/helpers/chunkedRequest"; + +const ENDPOINT = "https://events.example.test/api/client-errors"; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.limit.mockResolvedValue(true); +}); + +describe("POST /api/client-errors body cap", () => { + it("stops reading a chunked report without Content-Length past 4 KiB and still answers an empty 204", async () => { + const stream = chunkedRequest(ENDPOINT, oversizedJsonParts(512 * 1024)); + const response = await POST(stream.request); + expect(response.status).toBe(204); + expect(await response.text()).toBe(""); + expect(stream.cancelled()).toBe(true); + expect(stream.pulled()).toBeLessThan(10); + expect(mocks.report).not.toHaveBeenCalled(); + }); + + it("forwards a normal chunked report", async () => { + const body = JSON.stringify({ boundary: "route", name: "TypeError", message: "x is undefined", path: "/acme" }); + const response = await POST(chunkedRequest(ENDPOINT, [body.slice(0, 12), body.slice(12)]).request); + expect(response.status).toBe(204); + expect(await response.text()).toBe(""); + expect(mocks.report).toHaveBeenCalledWith(expect.objectContaining({ source: "browser", title: "Page crashed in the browser", context: { page: "/acme" } })); + }); +}); diff --git a/tests/unit/rsvp-route.test.ts b/tests/unit/rsvp-route.test.ts index eb157015..47484381 100644 --- a/tests/unit/rsvp-route.test.ts +++ b/tests/unit/rsvp-route.test.ts @@ -3,12 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ auth: vi.fn(), submit: vi.fn(), limit: vi.fn() })); vi.mock("@/lib/auth", () => ({ auth: mocks.auth })); vi.mock("@/lib/rsvpCore", () => ({ submitRsvpCore: mocks.submit })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("@/lib/rateLimit", () => ({ getClientIpFromHeaders: () => "203.0.113.7", checkRateLimitById: mocks.limit, })); import { POST } from "@/app/api/rsvp/route"; +import { chunkedRequest, oversizedJsonParts } from "@/tests/helpers/chunkedRequest"; beforeEach(() => { vi.clearAllMocks(); mocks.limit.mockResolvedValue(true); mocks.auth.mockResolvedValue(null); mocks.submit.mockResolvedValue({ ok: true, data: { count: 1 } }); @@ -51,4 +53,25 @@ describe("public RSVP route", () => { await expect(response.json()).resolves.toMatchObject({ ok: false, error: "Invalid link" }); expect(mocks.submit).not.toHaveBeenCalled(); }); + + it("stops reading a chunked body past 128 KiB and answers 413 before any RSVP work", async () => { + const stream = chunkedRequest("https://events.example.test/api/rsvp", oversizedJsonParts(1024 * 1024)); + const response = await POST(stream.request); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ ok: false, error: "Request is too large." }); + expect(stream.cancelled()).toBe(true); + expect(stream.pulled()).toBeLessThan(200); + expect(mocks.limit).not.toHaveBeenCalled(); + expect(mocks.submit).not.toHaveBeenCalled(); + }); + + it("still reads a normal chunked body and keeps the invalid-JSON answer", async () => { + const body = JSON.stringify({ orgSlug: "org", eventSlug: "event", guestEmail: "a@example.test", name: "Ada" }); + const ok = await POST(chunkedRequest("https://events.example.test/api/rsvp", [body.slice(0, 20), body.slice(20)]).request); + expect(ok.status).toBe(200); + expect(mocks.submit).toHaveBeenCalledWith(JSON.parse(body), { userId: null, team: undefined }); + const broken = await POST(chunkedRequest("https://events.example.test/api/rsvp", ["{\"orgSlug\":"]).request); + expect(broken.status).toBe(400); + await expect(broken.json()).resolves.toEqual({ ok: false, error: "Invalid JSON." }); + }); }); From 7e07579d1b7a80cc70c6bfb1d6d694f4f1399716 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:38:26 +0530 Subject: [PATCH 04/79] fix(rsvp): return the public registration count only when the event shows it Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/rsvp-admin.ts | 2 +- app/actions/rsvp.ts | 2 +- docs/ROUTES.md | 6 +- lib/payments/orders.ts | 3 +- lib/publicRegistrationCount.ts | 27 ++++ lib/rsvpCore/instance.ts | 5 +- lib/rsvpCore/manual.ts | 5 +- lib/rsvpCore/orders.ts | 5 +- lib/rsvpCore/reserve.ts | 4 +- lib/rsvpCore/submit.ts | 3 +- lib/rsvpCore/submitGuest.ts | 5 +- .../integration/rsvp-public-count.db.test.ts | 152 ++++++++++++++++++ 12 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 lib/publicRegistrationCount.ts create mode 100644 tests/integration/rsvp-public-count.db.test.ts diff --git a/app/actions/rsvp-admin.ts b/app/actions/rsvp-admin.ts index fb58c9fa..4498eea9 100644 --- a/app/actions/rsvp-admin.ts +++ b/app/actions/rsvp-admin.ts @@ -22,7 +22,7 @@ import { emitTeamWebhooks, type RegistrationStatusWebhook } from "@/lib/teamUnit export async function addManualRsvp( input: unknown, -): Promise> { +): Promise> { const session = await auth(); if (!session?.user?.id) return { ok: false, error: "You must be signed in." }; if (await isActionRateLimited("rsvp", session.user.id)) { diff --git a/app/actions/rsvp.ts b/app/actions/rsvp.ts index 6967906a..ff56e3a2 100644 --- a/app/actions/rsvp.ts +++ b/app/actions/rsvp.ts @@ -11,7 +11,7 @@ const revalidatePath = trackedRevalidation(nextRevalidatePath); export async function submitRsvp( input: unknown, -): Promise> { +): Promise> { const session = await auth(); const subject = typeof input === "object" && input !== null && "guestEmail" in input && typeof (input as { guestEmail?: unknown; }).guestEmail === "string" diff --git a/docs/ROUTES.md b/docs/ROUTES.md index f4d6d024..7af58977 100644 --- a/docs/ROUTES.md +++ b/docs/ROUTES.md @@ -83,15 +83,15 @@ The super admin signs in with email and password; there is no separate authentic | Method and route | Authentication | Purpose | | --- | --- | --- | -| `POST /api/rsvp` | Optional session | Submit an event or instance RSVP; on a team event the optional `team` field creates a team, joins one by join token, or registers solo | +| `POST /api/rsvp` | Optional session, 128 KiB body cap (`413`) | Submit an event or instance RSVP; on a team event the optional `team` field creates a team, joins one by join token, or registers solo. The response carries the registration `count` only when the event shows it, and then the same number as the event page | | `POST /api/feedback` | Public, rate-limited | Submit feedback; email is used only for certificate-enabled forms | | `GET /api/exports/member-ids/:programmeId/cards/:memberId` | Session; programme organisation OWNER/ADMIN | One approved member's card: `format=pdf` (two pages, front and back) or `format=png&side=front|back` (300 dpi); audited, `no-store` | | `GET /api/exports/member-ids/:programmeId/cards` | Session; programme organisation OWNER/ADMIN | Every approved card as one PDF (up to 500) or `format=zip` with one PDF per member (up to 200); one bulk render per process; audited | | `GET /api/exports/account/ids/:memberId` | Session; the card's own holder | The signed-in user's own approved card as PDF or PNG | -| `POST /api/check-in/station` | PIN-scoped station session, rate-limited | Open and operate a venue check-in station | +| `POST /api/check-in/station` | PIN-scoped station session, rate-limited, 128 KiB body cap (`413`) | Open and operate a venue check-in station | | `GET /api/search?q=...` | Public, rate-limited | Return matching public events | | `GET /api/help/search-index` | Optional session, global API rate limit | Plain text of every guide article the viewer may read, searched in the browser; restricted sections only for owners/admins or the super admin; `private, no-store` | -| `POST /api/client-errors` | Public, same-origin, rate-limited per IP (`clientError`, 10/min), 4 KB body cap | Browser crash report from the app error boundaries; forwarded (redacted) to the alert webhook when configured. Always an empty `204` | +| `POST /api/client-errors` | Public, same-origin, rate-limited per IP (`clientError`, 10/min), 4 KiB body cap, read with a hard limit even when chunked | Browser crash report from the app error boundaries; forwarded (redacted) to the alert webhook when configured. Always an empty `204` | | `GET /api/ticket/:token/download` | Opaque token | Download the attendee ticket | | `POST /api/ticket/:token/checkout` | Opaque token, rate-limited per IP (`ticketCheckout`, 20/min) | "Pay now" for a held ticket: opens (or reuses) the hosted checkout for its whole order and returns only that address (`no-store`) | | `POST /api/orders/:token/checkout` | Opaque order access token, rate-limited per IP (`ticketCheckout`) | "Pay now" on the buyer's order page | diff --git a/lib/payments/orders.ts b/lib/payments/orders.ts index f9d2aee3..ef552921 100644 --- a/lib/payments/orders.ts +++ b/lib/payments/orders.ts @@ -19,6 +19,7 @@ import { ticketSalesState } from "./tickets"; import { OrderError } from "./orderError"; import { placeTeamOrderRegistration, planTeamOrder } from "./teamOrders"; import type { TeamRegistrationIntent } from "@/lib/validators"; +import { publicRegistrationCount } from "@/lib/publicRegistrationCount"; export { OrderError } from "./orderError"; @@ -339,6 +340,6 @@ export async function reserveOrder(tx: Prisma.TransactionClient, params: { else if (orderStatus === OrderStatus.CONFIRMED) { await notifyChat(tx, sellable.organisationId, "registration", `${params.buyer.name || "Someone"} booked ${rsvps.length} ${rsvps.length === 1 ? "ticket" : "tickets"} for ${sellable.slots[0]!.label}.`); } - const count = await tx.rSVP.count({ where: sellable.slots[0]!.target }); + const count = await publicRegistrationCount(tx, sellable.slots[0]!.target); return { orderId: order.id, reference: order.reference, accessToken: order.accessToken, status, buyerRsvpId: rsvps[0]!.id, buyerTicketToken: rsvps[0]!.checkInToken, paymentId, count, teamId: placedTeam?.teamId ?? null, rsvpIds: rsvps.map((rsvp) => rsvp.id) }; } diff --git a/lib/publicRegistrationCount.ts b/lib/publicRegistrationCount.ts new file mode 100644 index 00000000..1105612b --- /dev/null +++ b/lib/publicRegistrationCount.ts @@ -0,0 +1,27 @@ +import "server-only"; + +import { RegistrationMode, RsvpStatus, type Prisma } from "@prisma/client"; +import { countConfirmedForCapacity } from "@/lib/teamUnits"; +import type { SeatTarget } from "@/lib/payments/targets"; + +/** + * The registration count a registrant sees after signing up: the number on + * the page they registered from, or null when the event hides it + * (`showRegistrationCount`). An event counts as its page does: team units + * ("places taken") on a TEAM event, otherwise confirmed attendees plus crew + * tickets ("going"). A series date has no such setting and shows its + * confirmed registrations, as its page does. Capacity checks never use this. + */ +export async function publicRegistrationCount(tx: Prisma.TransactionClient, target: SeatTarget): Promise { + if ("eventInstanceId" in target) return tx.rSVP.count({ where: { eventInstanceId: target.eventInstanceId, status: RsvpStatus.CONFIRMED } }); + const event = await tx.event.findUnique({ where: { id: target.eventId }, select: { showRegistrationCount: true, registrationMode: true } }); + if (!event?.showRegistrationCount) return null; + const confirmed = await countConfirmedForCapacity(tx, target.eventId, event.registrationMode); + if (event.registrationMode === RegistrationMode.TEAM) return confirmed; + return confirmed + await tx.rSVP.count({ where: { eventId: target.eventId, isCrew: true, status: RsvpStatus.CONFIRMED } }); +} + +/** The `count` of a registration result: left out when the event hides it. */ +export function publicCountField(count: number | null): { count?: number } { + return count === null ? {} : { count }; +} diff --git a/lib/rsvpCore/instance.ts b/lib/rsvpCore/instance.ts index 3c1359d7..670e9e00 100644 --- a/lib/rsvpCore/instance.ts +++ b/lib/rsvpCore/instance.ts @@ -3,6 +3,7 @@ import { prisma } from "@/lib/db"; import { isValidAttendeeName } from "@/lib/validators"; import type { ActionResult } from "@/app/actions/org"; import { reserveRsvp } from "./reserve"; +import { publicCountField } from "@/lib/publicRegistrationCount"; import { sellsTickets, seriesOrderTarget, submitTicketOrder } from "./orders"; import { isRsvpTargetClosedError, requestedItems, resolveAttendeeEmail, type OrderRequest } from "./shared"; @@ -14,7 +15,7 @@ export async function submitInstanceRsvp(params: { guestName?: string | null; name?: string | null; request: OrderRequest; -}): Promise> { +}): Promise> { const { org, eventInstanceId, userId } = params; const instance = await prisma.eventInstance.findFirst({ @@ -151,7 +152,7 @@ export async function submitInstanceRsvp(params: { return { ok: true, - data: { count: reservation.count, status: reservation.status, ticketToken: reservation.checkInToken }, + data: { ...publicCountField(reservation.count), status: reservation.status, ticketToken: reservation.checkInToken }, }; } catch (e: unknown) { if (isRsvpTargetClosedError(e)) return { ok: false, error: "This event is not open for RSVP." }; diff --git a/lib/rsvpCore/manual.ts b/lib/rsvpCore/manual.ts index 5b140659..6260b51c 100644 --- a/lib/rsvpCore/manual.ts +++ b/lib/rsvpCore/manual.ts @@ -9,6 +9,7 @@ import { OrderError, reserveOrder } from "@/lib/payments/orders"; import { validateAndNormalizeAnswers } from "./answers"; import { paymentContextFor, sellsTickets } from "./orders"; import { reserveRsvp } from "./reserve"; +import { publicCountField } from "@/lib/publicRegistrationCount"; import { TeamRegistrationError, teamRegistrationFailure } from "./team"; import { normalizeGuestEmail } from "./shared"; @@ -21,7 +22,7 @@ import { normalizeGuestEmail } from "./shared"; export async function submitManualGuestRsvpCore( input: unknown, opts: { organisationId: string }, -): Promise> { +): Promise> { const parsed = manualRsvpSchema.safeParse(input); if (!parsed.success) { return { @@ -91,7 +92,7 @@ export async function submitManualGuestRsvpCore( return { ok: true, data: { - count: reservation.count, + ...publicCountField(reservation.count), status: reservation.status, rsvpId: reservation.id, }, diff --git a/lib/rsvpCore/orders.ts b/lib/rsvpCore/orders.ts index a8501716..1fc7493e 100644 --- a/lib/rsvpCore/orders.ts +++ b/lib/rsvpCore/orders.ts @@ -8,6 +8,7 @@ import type { OrderTarget } from "@/lib/payments/targets"; import { loadPaymentAccount } from "@/lib/payments/settings"; import type { TeamRegistrationIntent } from "@/lib/validators"; import { emitWebhookEvent } from "@/lib/webhooks/emit"; +import { publicCountField } from "@/lib/publicRegistrationCount"; import { requestedItems, type AnswerRow, type OrderRequest } from "./shared"; import { TeamRegistrationError, teamRegistrationFailure, isUniqueViolationOn } from "./team"; @@ -40,7 +41,7 @@ export async function submitTicketOrder(params: { setUserName?: string | null; /** TEAM events (docs/TEAMS.md): solo, create a team, or join one by paying for a ticket. */ team?: TeamRegistrationIntent; -}): Promise & { code?: "TEAM_REQUIRED" }> { +}): Promise & { code?: "TEAM_REQUIRED" }> { const items = requestedItems(params.request); if (items.length === 0) return { ok: false, error: "Choose a ticket." }; try { @@ -75,7 +76,7 @@ export async function submitTicketOrder(params: { return { ok: true, data: { - count: order.count, + ...publicCountField(order.count), status: order.status, ticketToken: order.buyerTicketToken, orderToken: order.accessToken, diff --git a/lib/rsvpCore/reserve.ts b/lib/rsvpCore/reserve.ts index 71f0b696..039809e2 100644 --- a/lib/rsvpCore/reserve.ts +++ b/lib/rsvpCore/reserve.ts @@ -7,6 +7,7 @@ import { notifyChatAbout } from "@/lib/chat/notify"; import { isRegistrationClosed } from "@/lib/registrationCutoff"; import { seatTakingWhere } from "@/lib/payments/holds"; import { lockSeriesDates } from "@/lib/payments/targets"; +import { publicRegistrationCount } from "@/lib/publicRegistrationCount"; import { emitWebhookEvent } from "@/lib/webhooks/emit"; import { CREW_EMAIL_REFUSED, hasCrewEmail } from "@/lib/core/crew/shared"; import { RsvpTargetClosedError } from "./shared"; @@ -126,7 +127,8 @@ export async function reserveRsvp(params: { await notifyChatAbout(tx, params.target, "registration", (title) => `${who} registered for ${title}${status === RsvpStatus.WAITLISTED ? " (waitlist)" : ""}.`); } await params.afterCreate?.(tx, rsvp.id); - const count = await tx.rSVP.count({ where: params.target }); + // Only what the event page shows (null when hidden), never a capacity input. + const count = await publicRegistrationCount(tx, params.target); return { ...rsvp, status, count, teamId: team?.id ?? null }; }); emitWebhookEvent({ type: "registration.created", rsvpId: reservation.id }); diff --git a/lib/rsvpCore/submit.ts b/lib/rsvpCore/submit.ts index 1bd9c9a4..c1c89584 100644 --- a/lib/rsvpCore/submit.ts +++ b/lib/rsvpCore/submit.ts @@ -8,6 +8,7 @@ import { validateAndNormalizeAnswers } from "./answers"; import { submitInstanceRsvp } from "./instance"; import { joinsFree, sellsTickets, submitTicketOrder } from "./orders"; import { reserveRsvp } from "./reserve"; +import { publicCountField } from "@/lib/publicRegistrationCount"; import { isUniqueViolationOn, TeamRegistrationError, teamRegistrationFailure } from "./team"; import { isRsvpTargetClosedError, resolveAttendeeEmail } from "./shared"; import { submitGuestRsvp, type SubmitRsvpResult } from "./submitGuest"; @@ -172,7 +173,7 @@ export async function submitRsvpCore( return { ok: true, - data: { count: reservation.count, status: reservation.status, ticketToken: reservation.checkInToken, teamId: reservation.teamId }, + data: { ...publicCountField(reservation.count), status: reservation.status, ticketToken: reservation.checkInToken, teamId: reservation.teamId }, }; } catch (e: unknown) { if (isRsvpTargetClosedError(e)) return { ok: false, error: "This event is not open for RSVP." }; diff --git a/lib/rsvpCore/submitGuest.ts b/lib/rsvpCore/submitGuest.ts index d1390d6f..b162552c 100644 --- a/lib/rsvpCore/submitGuest.ts +++ b/lib/rsvpCore/submitGuest.ts @@ -9,10 +9,11 @@ import { validateAndNormalizeAnswers } from "./answers"; import { submitInstanceRsvp } from "./instance"; import { joinsFree, sellsTickets, submitTicketOrder } from "./orders"; import { reserveRsvp } from "./reserve"; +import { publicCountField } from "@/lib/publicRegistrationCount"; import { isUniqueViolationOn, TeamRegistrationError, teamRegistrationFailure } from "./team"; import { isRsvpTargetClosedError, normalizeGuestEmail } from "./shared"; -export type SubmitRsvpResult = ActionResult<{ count: number; status: RsvpStatus; ticketToken: string; orderToken?: string; checkoutUrl?: string | null; teamId?: string | null }>; +export type SubmitRsvpResult = ActionResult<{ count?: number; status: RsvpStatus; ticketToken: string; orderToken?: string; checkoutUrl?: string | null; teamId?: string | null }>; /** The signed-out (guest) branch of `submitRsvpCore`. */ export async function submitGuestRsvp( @@ -141,7 +142,7 @@ export async function submitGuestRsvp( return { ok: true, - data: { count: reservation.count, status: reservation.status, ticketToken: reservation.checkInToken, teamId: reservation.teamId }, + data: { ...publicCountField(reservation.count), status: reservation.status, ticketToken: reservation.checkInToken, teamId: reservation.teamId }, }; } catch (e: unknown) { if (isRsvpTargetClosedError(e)) return { ok: false, error: "This event is not open for RSVP." }; diff --git a/tests/integration/rsvp-public-count.db.test.ts b/tests/integration/rsvp-public-count.db.test.ts new file mode 100644 index 00000000..58d7672c --- /dev/null +++ b/tests/integration/rsvp-public-count.db.test.ts @@ -0,0 +1,152 @@ +import { EventStatus, RegistrationMode, RsvpStatus, SoloPolicy } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const identity = vi.hoisted(() => ({ id: "" })); +vi.mock("@/lib/auth", () => ({ auth: async () => (identity.id ? { user: { id: identity.id } } : null) })); +vi.mock("next/cache", () => ({ revalidatePath: () => undefined, revalidateTag: () => undefined, updateTag: () => undefined })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("@/lib/rateLimit", async (original) => ({ ...(await original()), checkRateLimitById: async () => true })); + +import { prisma } from "@/lib/db"; +import { submitRsvpCore } from "@/lib/rsvpCore"; +import { submitRsvp } from "@/app/actions/rsvp"; +import { POST as postRsvp } from "@/app/api/rsvp/route"; +import { countConfirmedForEvent } from "@/lib/rsvpCapacity"; +import { countCrewTickets } from "@/lib/core/crew/public"; +import { paid, setUpPaidOrg, tearDownPaidOrg } from "./helpers/paidOrg"; + +/* + * The registration count returned to a registrant (audit: Public HTTP + * Routes): left out when the event hides its count, and otherwise the same + * number the event page shows (confirmed attendees plus crew, or team units), + * on the free guest, signed-in, paid-order and series paths. + */ + +let counter = 0; +let userId = ""; +const email = (who: string) => `${who}-${paid.suffix}@example.test`; + +async function makeEvent(options: { show: boolean; capacity?: number | null; team?: boolean; price?: number }) { + counter += 1; + const event = await prisma.event.create({ data: { + organisationId: paid.organisationId, title: `Count ${counter}`, slug: `count-${paid.suffix}-${counter}`, currency: "INR", + startDateTime: new Date("2035-01-01T10:00:00.000Z"), endDateTime: new Date("2035-01-02T10:00:00.000Z"), timezone: "UTC", + capacity: options.capacity ?? null, status: EventStatus.PUBLISHED, showRegistrationCount: options.show, + ...(options.team ? { registrationMode: RegistrationMode.TEAM, teamMaxSize: 3, soloPolicy: SoloPolicy.SOLO_ALLOWED } : {}), + page: { create: { isPublished: true } }, + } }); + const ticket = options.price === undefined ? null : await prisma.ticketType.create({ data: { eventId: event.id, name: "Entry", priceMinor: BigInt(options.price) } }); + return { event, ticket }; +} + +/** Registrations the page never counts, plus one crew ticket it does (INDIVIDUAL events only). */ +async function seedOthers(eventId: string, crew = true) { + for (const status of [RsvpStatus.WAITLISTED, RsvpStatus.REJECTED, RsvpStatus.PENDING_APPROVAL]) { + const address = email(`${status.toLowerCase()}-${eventId}`); + await prisma.rSVP.create({ data: { eventId, guestEmail: address, guestName: status, attendeeKey: `guest:${address}`, status } }); + } + if (crew) { + const address = email(`crew-${eventId}`); + await prisma.rSVP.create({ data: { eventId, guestEmail: address, guestName: "Crew", attendeeKey: `guest:${address}`, status: RsvpStatus.CONFIRMED, isCrew: true } }); + } +} + +async function pageCount(eventId: string, mode: RegistrationMode = RegistrationMode.INDIVIDUAL) { + return (await countConfirmedForEvent(eventId, mode)) + (mode === RegistrationMode.TEAM ? 0 : await countCrewTickets(eventId)); +} + +const guest = (eventSlug: string, who: string, extra: Record = {}) => + submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug, guestEmail: email(who), name: "Count Guest", answers: {}, ...extra }, { userId: null }); + +beforeAll(async () => { + await setUpPaidOrg("Count"); + userId = (await prisma.user.create({ data: { name: "Signed In", email: email("signed-in"), emailVerified: new Date() } })).id; +}); + +afterAll(async () => { + await prisma.user.deleteMany({ where: { id: userId } }); + await tearDownPaidOrg(); +}); + +describe("registration count in the registration response", () => { + it("leaves the count out of free guest, signed-in, action and route answers when the event hides it", async () => { + const { event } = await makeEvent({ show: false }); + await seedOthers(event.id); + const asGuest = await guest(event.slug, "hidden-guest"); + expect(asGuest.ok).toBe(true); + expect(asGuest.data).not.toHaveProperty("count"); + const signedIn = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug: event.slug, answers: {} }, { userId }); + expect(signedIn.ok).toBe(true); + expect(signedIn.data?.count).toBeUndefined(); + const action = await submitRsvp({ orgSlug: paid.orgSlug, eventSlug: event.slug, guestEmail: email("hidden-action"), name: "Count Guest", answers: {} }); + expect(action.ok).toBe(true); + expect(action.data?.count).toBeUndefined(); + const response = await postRsvp(new Request("https://events.example.test/api/rsvp", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ orgSlug: paid.orgSlug, eventSlug: event.slug, guestEmail: email("hidden-route"), name: "Count Guest", answers: {} }), + })); + expect(response.status).toBe(200); + const body = await response.json() as { ok: boolean; data: Record }; + expect(body.ok).toBe(true); + expect(body.data).not.toHaveProperty("count"); + }); + + it("returns the page's count when shown: confirmed attendees plus crew, never waitlisted, rejected or pending", async () => { + const { event } = await makeEvent({ show: true, capacity: 2 }); + await seedOthers(event.id); + const first = await guest(event.slug, "shown-first"); + expect(first).toMatchObject({ ok: true, data: { status: RsvpStatus.CONFIRMED, count: 2 } }); + expect(first.data?.count).toBe(await pageCount(event.id)); + const signedIn = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug: event.slug, answers: {} }, { userId }); + expect(signedIn).toMatchObject({ ok: true, data: { status: RsvpStatus.CONFIRMED, count: 3 } }); + const waitlisted = await guest(event.slug, "shown-waitlisted"); + expect(waitlisted).toMatchObject({ ok: true, data: { status: RsvpStatus.WAITLISTED, count: 3 } }); + expect(waitlisted.data?.count).toBe(await pageCount(event.id)); + }); + + it("counts team units on a TEAM event, as its page does", async () => { + const { event } = await makeEvent({ show: true, team: true }); + const solo = await guest(event.slug, "team-solo"); + expect(solo).toMatchObject({ ok: true, data: { count: 1 } }); + const lead = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug: event.slug, guestEmail: email("team-create"), name: "Team Lead", answers: {} }, { userId: null, team: { mode: "create", name: `Squad ${paid.suffix}` } }); + expect(lead).toMatchObject({ ok: true, data: { count: 2 } }); + const squad = await prisma.team.findFirstOrThrow({ where: { eventId: event.id } }); + const member = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug: event.slug, guestEmail: email("team-join"), name: "Team Member", answers: {} }, { userId: null, team: { mode: "join", joinToken: squad.joinToken } }); + // Joining a team takes no new place: "2 places taken", as on the page. + expect(member).toMatchObject({ ok: true, data: { count: 2 } }); + expect(member.data?.count).toBe(await pageCount(event.id, RegistrationMode.TEAM)); + }); + + it("applies the same rule to a paid ticket order", async () => { + const hidden = await makeEvent({ show: false, price: 49900 }); + await seedOthers(hidden.event.id); + const items = (ticketTypeId: string) => ({ items: [{ ticketTypeId, quantity: 1 }], attendees: [] }); + const hiddenOrder = await guest(hidden.event.slug, "paid-hidden", items(hidden.ticket!.id)); + expect(hiddenOrder.ok).toBe(true); + expect(hiddenOrder.data).not.toHaveProperty("count"); + expect(hiddenOrder.data?.orderToken).toBeTruthy(); + const hiddenSignedIn = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug: hidden.event.slug, answers: {}, ...items(hidden.ticket!.id) }, { userId }); + expect(hiddenSignedIn.ok).toBe(true); + expect(hiddenSignedIn.data?.count).toBeUndefined(); + + const shown = await makeEvent({ show: true, price: 49900 }); + await seedOthers(shown.event.id); + const shownOrder = await guest(shown.event.slug, "paid-shown", items(shown.ticket!.id)); + expect(shownOrder.ok).toBe(true); + // The held, unpaid ticket is not "going" yet: only the crew ticket is counted. + expect(shownOrder.data?.count).toBe(1); + expect(shownOrder.data?.count).toBe(await pageCount(shown.event.id)); + }); + + it("returns a series date's confirmed registrations, as its page does", async () => { + counter += 1; + const series = await prisma.eventSeries.create({ data: { + organisationId: paid.organisationId, title: `Series ${counter}`, slug: `count-series-${paid.suffix}-${counter}`, recurrenceRule: "FREQ=WEEKLY;COUNT=2", timezone: "UTC", status: EventStatus.PUBLISHED, + } }); + const date = await prisma.eventInstance.create({ data: { eventSeriesId: series.id, startDateTime: new Date("2035-02-01T10:00:00.000Z"), endDateTime: new Date("2035-02-01T12:00:00.000Z") } }); + const waiting = email("series-waiting"); + await prisma.rSVP.create({ data: { eventInstanceId: date.id, guestEmail: waiting, guestName: "Waiting", attendeeKey: `guest:${waiting}`, status: RsvpStatus.WAITLISTED } }); + const result = await submitRsvpCore({ orgSlug: paid.orgSlug, eventInstanceId: date.id, guestEmail: email("series-guest"), name: "Series Guest", answers: {} }, { userId: null }); + expect(result).toMatchObject({ ok: true, data: { status: RsvpStatus.CONFIRMED, count: 1 } }); + }); +}); From be5262c6895b84bc7353b62f5ad3382a4d43cf15 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:39:33 +0530 Subject: [PATCH 05/79] fix(db): write trigger timestamps in UTC and document the admission backfill clash Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- docs/production-operations.md | 5 +- .../migration.sql | 120 ++++++++++++++++++ tests/integration/trigger-timezone.db.test.ts | 81 ++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20261120010000_utc_trigger_timestamps/migration.sql create mode 100644 tests/integration/trigger-timezone.db.test.ts diff --git a/docs/production-operations.md b/docs/production-operations.md index 9abe0d3d..cc0ae6f1 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -83,7 +83,7 @@ then re-run the check. ### Recent schema migrations -Apply these with `npm run db:deploy` before rolling out the matching application version. Release 0.10.0 ends at `20261102000000_event_broadcast_drafts`; release 0.11.0 adds everything from `20261103000000_check_in_lists` to `20261119030000_admission_audit_fixes`. The **Rolling deploy** column says whether instances of the previous release keep working while the migration has run and they have not yet been replaced, and what to do before or after upgrading. +Apply these with `npm run db:deploy` before rolling out the matching application version. Release 0.10.0 ends at `20261102000000_event_broadcast_drafts`; release 0.11.0 adds everything from `20261103000000_check_in_lists` to `20261119030000_admission_audit_fixes`; later migrations belong to the next release. The **Rolling deploy** column says whether instances of the previous release keep working while the migration has run and they have not yet been replaced, and what to do before or after upgrading. How to read the lock notes. Prisma runs each migration in one transaction without a lock timeout, and every lock is held until that transaction commits, so a table altered early in a migration stays locked through the migration's later steps: @@ -161,11 +161,14 @@ How to read the lock notes. Prisma runs each migration in one transaction withou | `20261118050000_reimbursement_admission_stage` | `ReimbursementPolicy.admissionStageId` (`NO ACTION` foreign key to `AdmissionStage`) and `requireAccepted` (default `false`); the stage-shape CHECK and stage trigger are replaced to cover admission stages | Safe | | `20261119000000_judging_counted_reviews_recusal` | Nullable `JudgeAssignment.recusedAt` (judge self-recusal); nullable `JudgingRound.countedFrozenAt` with a CHECK that it is set only on a finalised round (a scan of `JudgingRound`); new `JudgingRoundCountedReview` table (the reviews a finalised round counted) cascading on `JudgingRound`. No backfill: rounds finalised earlier keep counting live | Safe | | `20261119030000_admission_audit_fixes` | `rsvp_admission_crew_detached` trigger (`BEFORE UPDATE OF "isCrew" ON "RSVP"`, a brief SHARE ROW EXCLUSIVE lock, no scan): a registration becomes crew only once its application is detached; drops `ReimbursementPolicy.requireAccepted` (ADMISSION_STAGE policies always need an accepted application) and re-adds the stage-shape CHECK without it (a scan of the small `ReimbursementPolicy` table); `AdmissionStage.freshEyes` (default `false`) | Safe from 0.10.0: `requireAccepted` arrived in the same release, so no released build reads it | +| `20261120010000_utc_trigger_timestamps` | Replaces the trigger functions `rsvp_waitlist_dates`, `write_security_audit_fallback` and `write_api_security_audit_fallback` so the times they write are UTC whatever the session time zone (`CURRENT_TIMESTAMP` was stored as the session's local time). Function bodies only: no table lock, scan or backfill; rows written earlier by a database whose time zone is not UTC keep their shifted times | Safe | Release 0.11.0 reserves the organisation addresses `/attend`, `/crew`, `/help`, `/submit` and `/team` (and `/order` and `/verify-mfa`, which 0.10.0 already served): the pre-migration check and `npm run db:verify` fail if an organisation already uses one (see [Reserved organisation addresses](#reserved-organisation-addresses)). Rename it before upgrading. **Upgrade note (judging rounds, `20261112000000_judging_rounds`):** the migration replaces the judge review and assignment unique keys with round-scoped ones, so an instance of the previous release that is still running after the migration fails to save judge reviews (its `ON CONFLICT ("submissionId", "judgeUserId")` no longer matches an index) until the rollout completes. This only matters when the running build already has judging (`20261104010000_hackathon_judging`); release 0.10.0 has none. Close judging on events that are being judged while you upgrade, or replace instances without overlap: on Helm set `updateStrategy: {type: Recreate, rollingUpdate: null}` for this one release (the chart's default rolling update runs both releases side by side for a moment). The one-line VPS updater (`scripts/update-vps.sh`) runs the migration while the old containers still serve, then replaces them (`compose up -d --wait`), so review saves can fail only in that short window between the two steps. +**Upgrade note (admission answer revisions, `20261117040000_admission_hardening`):** that migration backfills the new `AdmissionAnswerRevision."personKey"` (`UPDATE "AdmissionAnswerRevision" v SET "personKey" = … FROM "RSVP" r WHERE v."rsvpId" = r."id" AND v."personKey" IS NULL`), but the append-only trigger `admission_answer_revision_append_only` installed by `20261117030000_admission_retention` allows only `rsvpId` to change, so the backfill fails with SQLSTATE `23514` (`AdmissionAnswerRevision rows are append-only`) when the table already holds a revision whose registration still exists. Only databases that ran the admission migrations one at a time are affected: a development or staging database that applied `20261117010000_hackathon_admission` through `20261117030000_admission_retention` and then created answer revisions (applications with edited answers) before `20261117040000` ran. A fresh install and an upgrade from 0.10.0 apply all of them in one `npm run db:deploy` with an empty table, and are not affected. The shipped migrations stay as they are (their checksums are recorded in every database that applied them). Prisma rolls the failed migration back as a whole, so nothing is half-applied. To recover a disposable database, reset it (`npx prisma migrate reset` against that database only; it deletes all data) and deploy again. Never mark the migration applied with `prisma migrate resolve --applied`: its backfill, functions and triggers would be missing. For a database whose data must be kept, mark the failed attempt rolled back (`npx prisma migrate resolve --rolled-back 20261117040000_admission_hardening`), then run `ALTER TABLE "AdmissionAnswerRevision" DISABLE TRIGGER "admission_answer_revision_append_only";`, `npm run db:deploy`, and `ALTER TABLE "AdmissionAnswerRevision" ENABLE TRIGGER "admission_answer_revision_append_only";` straight after, with no application instance running in between. + #### Lock checklist On a quiet database the whole upgrade from 0.10.0 takes well under a second. On a large or busy one: diff --git a/prisma/migrations/20261120010000_utc_trigger_timestamps/migration.sql b/prisma/migrations/20261120010000_utc_trigger_timestamps/migration.sql new file mode 100644 index 00000000..f1366321 --- /dev/null +++ b/prisma/migrations/20261120010000_utc_trigger_timestamps/migration.sql @@ -0,0 +1,120 @@ +-- Trigger timestamps in UTC whatever the session time zone. +-- CURRENT_TIMESTAMP is a timestamptz; assigning it to a timestamp(3) column +-- converts it to the session's TimeZone, so a database (or connection) whose +-- TimeZone is not UTC stored local wall-clock time. Each function below is the +-- latest definition with only CURRENT_TIMESTAMP replaced by an explicit UTC +-- conversion; the rest of its logic is unchanged. + +CREATE OR REPLACE FUNCTION rsvp_waitlist_dates() RETURNS trigger AS $$ +BEGIN + IF NEW."status"::text = 'WAITLISTED' AND NEW."waitlistedAt" IS NULL THEN + NEW."waitlistedAt" := (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3); + END IF; + -- Leaving the waitlist for anything but a rejection is a promotion (a + -- confirmed place, or a place held for payment). + IF TG_OP = 'UPDATE' AND OLD."status"::text = 'WAITLISTED' AND NEW."status"::text NOT IN ('WAITLISTED', 'REJECTED') + AND NEW."promotedAt" IS NULL THEN + NEW."promotedAt" := (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION "write_security_audit_fallback"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + row_data jsonb; + organisation_id text; + target_id text; +BEGIN + row_data := CASE WHEN TG_OP = 'DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END; + target_id := row_data ->> 'id'; + IF TG_OP = 'DELETE' THEN + organisation_id := NULL; + ELSE + organisation_id := CASE TG_TABLE_NAME + WHEN 'Organisation' THEN target_id + WHEN 'Membership' THEN row_data ->> 'organisationId' + WHEN 'Event' THEN row_data ->> 'organisationId' + WHEN 'EventSeries' THEN row_data ->> 'organisationId' + WHEN 'OrganisationInvite' THEN row_data ->> 'organisationId' + ELSE NULL + END; + IF TG_TABLE_NAME IN ('EventInvite', 'EventCollaborator', 'EventCollaboratorInvite') + AND row_data ->> 'eventId' IS NOT NULL THEN + SELECT "organisationId" INTO organisation_id FROM "Event" WHERE "id" = row_data ->> 'eventId'; + ELSIF TG_TABLE_NAME IN ('SeriesInvite', 'EventCollaborator', 'EventCollaboratorInvite') + AND row_data ->> 'eventSeriesId' IS NOT NULL THEN + SELECT "organisationId" INTO organisation_id FROM "EventSeries" WHERE "id" = row_data ->> 'eventSeriesId'; + ELSIF TG_TABLE_NAME = 'RSVP' THEN + IF row_data ->> 'eventId' IS NOT NULL THEN + SELECT "organisationId" INTO organisation_id FROM "Event" WHERE "id" = row_data ->> 'eventId'; + ELSE + SELECT s."organisationId" INTO organisation_id + FROM "EventInstance" i INNER JOIN "EventSeries" s ON s."id" = i."eventSeriesId" + WHERE i."id" = row_data ->> 'eventInstanceId'; + END IF; + END IF; + END IF; + INSERT INTO "AuditEvent" ("id", "action", "organisationId", "targetType", "targetId", "metadata", "createdAt") + VALUES (gen_random_uuid()::text, 'DB_' || upper(TG_TABLE_NAME) || '_' || TG_OP, + organisation_id, TG_TABLE_NAME, target_id, + jsonb_build_object('source', 'database-trigger'), (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3)); + RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END; +END; +$$; + +CREATE OR REPLACE FUNCTION "write_api_security_audit_fallback"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + row_data jsonb; + organisation_id text; + target_id text; +BEGIN + row_data := CASE WHEN TG_OP = 'DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END; + + IF TG_TABLE_NAME = 'ApiCredential' AND TG_OP = 'UPDATE' THEN + IF NEW."apiClientId" = OLD."apiClientId" + AND NEW."name" = OLD."name" + AND NEW."secretHash" = OLD."secretHash" + AND NEW."expiresAt" IS NOT DISTINCT FROM OLD."expiresAt" + AND NEW."revokedAt" IS NOT DISTINCT FROM OLD."revokedAt" THEN + RETURN NEW; + END IF; + END IF; + + IF TG_TABLE_NAME = 'ApiClient' THEN + target_id := row_data ->> 'id'; + organisation_id := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE row_data ->> 'organisationId' END; + ELSE + target_id := row_data ->> 'apiClientId'; + IF TG_OP = 'DELETE' THEN + organisation_id := NULL; + ELSE + SELECT "organisationId" INTO organisation_id + FROM "ApiClient" WHERE "id" = target_id; + END IF; + END IF; + + INSERT INTO "AuditEvent" ( + "id", "action", "organisationId", "targetType", "targetId", "metadata", "createdAt" + ) VALUES ( + gen_random_uuid()::text, + 'DB_' || upper(TG_TABLE_NAME) || '_' || TG_OP, + organisation_id, + TG_TABLE_NAME, + target_id, + jsonb_build_object('source', 'database-trigger'), + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) + ); + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; diff --git a/tests/integration/trigger-timezone.db.test.ts b/tests/integration/trigger-timezone.db.test.ts new file mode 100644 index 00000000..804fddea --- /dev/null +++ b/tests/integration/trigger-timezone.db.test.ts @@ -0,0 +1,81 @@ +import { randomUUID } from "node:crypto"; +import { EventPrivacyType, EventStatus, RsvpStatus } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +/* + * Database triggers stamp timestamp(3) columns. CURRENT_TIMESTAMP is converted + * with the session TimeZone on assignment, so a session in Asia/Kolkata wrote + * times 5h30 ahead. Each test runs its writes in a transaction whose session + * TimeZone is Asia/Kolkata and checks the stored value is UTC. + */ + +import { prisma } from "@/lib/db"; + +const suffix = randomUUID().replace(/-/g, ""); +let organisationId = ""; +let eventId = ""; + +type Tx = Parameters[0]>[0]; +async function inKolkata(work: (tx: Tx) => Promise) { + return prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe("SET LOCAL TIME ZONE 'Asia/Kolkata'"); + return work(tx); + }); +} + +function expectNow(value: Date | null | undefined) { + expect(value).toBeInstanceOf(Date); + expect(Math.abs(value!.getTime() - Date.now())).toBeLessThan(60_000); +} + +beforeAll(async () => { + organisationId = (await prisma.organisation.create({ data: { name: "Trigger zone org", slug: `trigger-tz-${suffix}` } })).id; + eventId = ( + await prisma.event.create({ + data: { + organisationId, + title: "Trigger zone night", + slug: `trigger-tz-${suffix}`, + startDateTime: new Date("2031-01-01T12:00:00.000Z"), + endDateTime: new Date("2031-01-01T15:00:00.000Z"), + timezone: "Asia/Kolkata", + status: EventStatus.PUBLISHED, + privacyType: EventPrivacyType.PUBLIC, + }, + }) + ).id; +}); + +afterAll(async () => { + await prisma.organisation.deleteMany({ where: { id: organisationId } }); + await prisma.$disconnect(); +}); + +describe("trigger timestamps on a non-UTC session", () => { + it("dates joining and leaving the waitlist in UTC", async () => { + const email = `waitlist-tz-${suffix}@example.com`; + const joined = await inKolkata(async (tx) => { + expect((await tx.$queryRaw>`SHOW timezone`)[0]!.TimeZone).toBe("Asia/Kolkata"); + return tx.rSVP.create({ data: { eventId, guestEmail: email, guestName: email, attendeeKey: `guest:${email}`, status: RsvpStatus.WAITLISTED } }); + }); + expectNow(joined.waitlistedAt); + const promoted = await inKolkata((tx) => tx.rSVP.update({ where: { id: joined.id }, data: { status: RsvpStatus.CONFIRMED } })); + expectNow(promoted.promotedAt); + }); + + it("writes the security audit fallback row in UTC", async () => { + const renamed = `Trigger zone org ${suffix}`; + await inKolkata((tx) => tx.organisation.update({ where: { id: organisationId }, data: { name: renamed } })); + const audit = await prisma.auditEvent.findFirstOrThrow({ + where: { action: "DB_ORGANISATION_UPDATE", targetId: organisationId }, + orderBy: { createdAt: "desc" }, + }); + expectNow(audit.createdAt); + }); + + it("writes the machine API audit fallback row in UTC", async () => { + const client = await inKolkata((tx) => tx.apiClient.create({ data: { organisationId, name: `Zone client ${suffix}` } })); + const audit = await prisma.auditEvent.findFirstOrThrow({ where: { action: "DB_APICLIENT_INSERT", targetId: client.id } }); + expectNow(audit.createdAt); + }); +}); From 0cdffbb8df77f0fa2d000e82b9a28dc46fda6281 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:41:14 +0530 Subject: [PATCH 06/79] fix(exports): keep every multi-select choice in the attendee CSV Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../events/[eventId]/attendees/route.ts | 38 +++++++-- content/help/guests/exports.mdx | 1 + .../attendee-export-answers.db.test.ts | 84 +++++++++++++++++++ 3 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 tests/integration/attendee-export-answers.db.test.ts diff --git a/app/api/exports/events/[eventId]/attendees/route.ts b/app/api/exports/events/[eventId]/attendees/route.ts index 895aeadb..2e32d8aa 100644 --- a/app/api/exports/events/[eventId]/attendees/route.ts +++ b/app/api/exports/events/[eventId]/attendees/route.ts @@ -1,4 +1,4 @@ -import { EventPermission, type Prisma } from "@prisma/client"; +import { EventPermission, RegistrationFieldType, type Prisma } from "@prisma/client"; import { auth } from "@/lib/auth"; import { recordAuditEvent } from "@/lib/audit"; import { csvStreamResponse, exportFilename, exportFilterNames, exportNotFound } from "@/lib/csvExport"; @@ -29,7 +29,9 @@ function isFiltered(view: AttendeeListView) { return view.status !== "all" || view.kind !== "all" || view.team !== "all" || view.attendance !== "all" || Boolean(view.query); } -function answerValue(answer: { valueText: string | null; valueBool: boolean | null; valueNumber: number | null; valueDate: Date | null }) { +type StoredAnswer = { fieldId: string; valueText: string | null; valueBool: boolean | null; valueNumber: number | null; valueDate: Date | null }; + +function answerValue(answer: StoredAnswer) { return answer.valueText ?? (answer.valueBool == null ? null : answer.valueBool ? "Yes" : "No") ?? (answer.valueNumber == null ? null : String(answer.valueNumber)) @@ -37,6 +39,32 @@ function answerValue(answer: { valueText: string | null; valueBool: boolean | nu ?? ""; } +type AnswerColumn = { id: string; multi: boolean; rank: Map }; + +/** One column per question; a multi-select's options are ranked in the question's order. */ +function answerColumns(fields: ReadonlyArray<{ id: string; type: RegistrationFieldType; options: Prisma.JsonValue }>): AnswerColumn[] { + return fields.map((field) => { + const options = Array.isArray(field.options) ? field.options.filter((option): option is string => typeof option === "string").map((option) => option.trim()) : []; + return { id: field.id, multi: field.type === RegistrationFieldType.MULTI_SELECT, rank: new Map(options.map((option, index) => [option, index])) }; + }); +} + +/** + * One cell per question. A multi-select keeps one answer row per choice, so + * its cell joins every choice with "; ": the question's options in their + * order, then any other text ("Other", or an option since removed) by value. + */ +function answerCells(columns: AnswerColumn[], answers: ReadonlyArray) { + const byField = new Map(); + for (const answer of answers) byField.set(answer.fieldId, [...(byField.get(answer.fieldId) ?? []), answerValue(answer)]); + return columns.map((column) => { + const values = byField.get(column.id) ?? []; + if (!column.multi) return values[0] ?? ""; + const rank = (value: string) => column.rank.get(value) ?? column.rank.size; + return values.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)).join("; "); + }); +} + /** Team name and role for one registration; solo registrants get an empty team and "Solo", crew "Crew". */ function teamCells(rsvp: { id: string; isCrew: boolean; team?: { name: string; leaderRsvpId: string | null } | null }) { if (rsvp.isCrew) return ["", "Crew"]; @@ -62,7 +90,7 @@ export async function GET(request: Request, context: RouteContext) { organisationId: true, registrationMode: true, registrationForm: { - select: { fields: { select: { id: true, label: true }, orderBy: { sortOrder: "asc" } } }, + select: { fields: { select: { id: true, label: true, type: true, options: true }, orderBy: { sortOrder: "asc" } } }, }, // One column per custom check-in list, after the registration answers, // so existing columns keep their positions. @@ -79,6 +107,7 @@ export async function GET(request: Request, context: RouteContext) { if (!(await canAccessEvent({ userId: session.user.id, organisationId: event.organisationId, eventId: event.id, permission: EventPermission.MANAGE_REGISTRATIONS }))) return exportNotFound(); const fields = event.registrationForm?.fields ?? []; + const columns = answerColumns(fields); // TEAM events add Team and Team role columns (docs/TEAMS.md); other exports keep their columns. const teamColumns = event.registrationMode === "TEAM"; const lists = event.checkInLists; @@ -130,7 +159,6 @@ export async function GET(request: Request, context: RouteContext) { if (!rsvps.length) break; const rows = rsvps.map((rsvp) => { - const answers = new Map(rsvp.answers.map((answer) => [answer.fieldId, answerValue(answer)])); const listCheckIns = new Map(rsvp.checkInListRecords.map((record) => [record.listId, record.checkedInAt.toISOString()])); return [ rsvp.user?.name ?? rsvp.guestName ?? "", @@ -145,7 +173,7 @@ export async function GET(request: Request, context: RouteContext) { rsvp.order?.reference ?? "", rsvp.order?.status ?? "", rsvp.order ? `${rsvp.order.currency} ${minorToDecimalString(rsvp.order.totalMinor, rsvp.order.currency)}` : "", - ...fields.map((field) => answers.get(field.id) ?? ""), + ...answerCells(columns, rsvp.answers), ...lists.map((list) => listCheckIns.get(list.id) ?? ""), ...attendanceCsvCells(rsvp, { showNotAsked: attendanceAsked }), ]; diff --git a/content/help/guests/exports.mdx b/content/help/guests/exports.mdx index b3f20a25..8d3a2bdb 100644 --- a/content/help/guests/exports.mdx +++ b/content/help/guests/exports.mdx @@ -53,6 +53,7 @@ A few things to know when you open it: - **Status** uses the stored names: CONFIRMED (going), WAITLISTED, PENDING_APPROVAL (to approve), REJECTED (declined) and PENDING_PAYMENT (unpaid). - Dates and times are in UTC in a standard format, for example `2026-10-14T09:30:00.000Z`. Most spreadsheets can convert them to your time zone. - Yes / no answers show as Yes or No. +- A question where guests can tick several choices shows every choice in one cell, separated by semicolons, in the order the choices appear on your form. Anything typed into **Other** comes last. diff --git a/tests/integration/attendee-export-answers.db.test.ts b/tests/integration/attendee-export-answers.db.test.ts new file mode 100644 index 00000000..5987ed35 --- /dev/null +++ b/tests/integration/attendee-export-answers.db.test.ts @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto"; +import { EventStatus, MembershipRole, RegistrationFieldType, RsvpStatus, type Event } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const session = vi.hoisted(() => ({ userId: null as string | null })); +vi.mock("@/lib/auth", () => ({ auth: async () => (session.userId ? { user: { id: session.userId } } : null) })); +vi.mock("@/lib/audit", () => ({ recordAuditEvent: async () => undefined })); +vi.mock("@/lib/publicUrl", () => ({ getRequestOrigin: async () => "https://events.example.test" })); + +import { prisma } from "@/lib/db"; +import { GET as exportAttendees } from "@/app/api/exports/events/[eventId]/attendees/route"; +import { csv } from "./helpers/filteredExports"; + +/* + * Attendee CSV answers (audit: Data Layer, Privacy): a multi-select stores + * one answer row per choice, and the export keeps every choice in one cell, + * in the question's option order and then by value, still formula-safe. + */ + +const suffix = randomUUID().replaceAll("-", ""); +const FORMULA = '=HYPERLINK("https://evil.example")'; +let organisationId = ""; +let ownerId = ""; +let event: Event; +let workshopsId = ""; +let roleId = ""; + +async function register(name: string, answers: Array<{ fieldId: string; valueText: string }>) { + const email = `${name.toLowerCase()}-${suffix}@example.test`; + const rsvp = await prisma.rSVP.create({ data: { eventId: event.id, guestEmail: email, guestName: name, attendeeKey: `guest:${email}`, status: RsvpStatus.CONFIRMED } }); + // Stored one row at a time, deliberately out of option order. + for (const answer of answers) await prisma.rsvpAnswer.create({ data: { rsvpId: rsvp.id, ...answer } }); +} + +beforeAll(async () => { + organisationId = (await prisma.organisation.create({ data: { name: `Answers ${suffix}`, slug: `answers-${suffix}` } })).id; + ownerId = (await prisma.user.create({ data: { name: "Answers owner", email: `owner-${suffix}@example.test`, memberships: { create: { organisationId, role: MembershipRole.OWNER } } } })).id; + event = await prisma.event.create({ data: { + organisationId, title: `Answers ${suffix}`, slug: `answers-${suffix}`, timezone: "UTC", status: EventStatus.PUBLISHED, + startDateTime: new Date("2035-01-01T10:00:00.000Z"), endDateTime: new Date("2035-01-02T10:00:00.000Z"), + } }); + const form = await prisma.eventRegistrationForm.create({ data: { eventId: event.id, fields: { create: [ + { key: "workshops", label: "Workshops", type: RegistrationFieldType.MULTI_SELECT, sortOrder: 0, options: ["Workshop C", " Workshop A ", "Workshop B"], settings: { allowOther: true } }, + { key: "role", label: "Role", type: RegistrationFieldType.TEXT, sortOrder: 1 }, + ] } }, include: { fields: true } }); + workshopsId = form.fields.find((field) => field.key === "workshops")!.id; + roleId = form.fields.find((field) => field.key === "role")!.id; + await register("Ada", [ + { fieldId: workshopsId, valueText: "Zine making" }, + { fieldId: workshopsId, valueText: "Workshop B" }, + { fieldId: roleId, valueText: "Designer" }, + { fieldId: workshopsId, valueText: "Workshop A" }, + { fieldId: workshopsId, valueText: "Bookbinding" }, + { fieldId: workshopsId, valueText: "Workshop C" }, + ]); + await register("Grace", [{ fieldId: workshopsId, valueText: FORMULA }, { fieldId: roleId, valueText: "-1+1" }]); + await register("Linus", []); + session.userId = ownerId; +}); + +afterAll(async () => { + await prisma.organisation.deleteMany({ where: { id: organisationId } }); + await prisma.user.deleteMany({ where: { id: ownerId } }); + await prisma.$disconnect(); +}); + +describe("attendee CSV answers", () => { + it("keeps every multi-select choice in option order, then other text by value, with formulas neutralised", async () => { + const { header, rows } = await csv(await exportAttendees(new Request(`https://events.example.test/api/exports/events/${event.id}/attendees`), { params: Promise.resolve({ eventId: event.id }) })); + const workshops = header.indexOf("Workshops"); + const role = header.indexOf("Role"); + expect(workshops).toBeGreaterThan(-1); + expect(role).toBe(workshops + 1); + const byName = new Map(rows.map((row) => [row[0], row])); + expect(byName.get("Ada")![workshops]).toBe("Workshop C; Workshop A; Workshop B; Bookbinding; Zine making"); + expect(byName.get("Ada")![role]).toBe("Designer"); + expect(byName.get("Grace")![workshops]).toBe(`'${FORMULA}`); + expect(byName.get("Grace")![role]).toBe("'-1+1"); + expect(byName.get("Linus")![workshops]).toBe(""); + // The same file twice: the order never depends on how rows came back. + const again = await csv(await exportAttendees(new Request(`https://events.example.test/api/exports/events/${event.id}/attendees`), { params: Promise.resolve({ eventId: event.id }) })); + expect(again.rows).toEqual(rows); + }); +}); From 97c7f792eb2e46dad5a5aa40d87bf8ce0b3e7000 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:43:47 +0530 Subject: [PATCH 07/79] fix(volunteers): stream every sign-up in the hours CSV and audit the rows sent Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../events/[eventId]/volunteer-hours/route.ts | 21 ++++-- docs/VOLUNTEERS.md | 6 +- lib/volunteer/export.ts | 61 ++++++++++++---- .../volunteer-hours-export.db.test.ts | 72 +++++++++++++++++++ 4 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 tests/integration/volunteer-hours-export.db.test.ts diff --git a/app/api/exports/events/[eventId]/volunteer-hours/route.ts b/app/api/exports/events/[eventId]/volunteer-hours/route.ts index 74f4be2f..6311c22f 100644 --- a/app/api/exports/events/[eventId]/volunteer-hours/route.ts +++ b/app/api/exports/events/[eventId]/volunteer-hours/route.ts @@ -3,7 +3,7 @@ import { recordAuditEvent } from "@/lib/audit"; import { resolveOrganiserEvent } from "@/lib/core/teams"; import { csvStreamResponse, exportFilename, exportNotFound } from "@/lib/csvExport"; import { prisma } from "@/lib/db"; -import { VOLUNTEER_HOURS_HEADER, volunteerHoursRows } from "@/lib/volunteer/export"; +import { VOLUNTEER_HOURS_HEADER, volunteerHoursBatches } from "@/lib/volunteer/export"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -27,13 +27,24 @@ export async function GET(_request: Request, context: RouteContext) { if (!event) return exportNotFound(); const ctx = await resolveOrganiserEvent(userId, event.organisation.slug, event.id); if (!ctx) return exportNotFound(); - const rows = await volunteerHoursRows(ctx.eventId); - await recordAuditEvent({ action: "VOLUNTEER_HOURS_EXPORTED", actorUserId: userId, organisationId: ctx.organisationId, targetType: "Event", targetId: ctx.eventId, metadata: { rows: rows.length } }); return csvStreamResponse({ header: [...VOLUNTEER_HOURS_HEADER], filename: exportFilename("volunteer-hours", event.title), errorMessage: "Unable to export volunteer hours.", - // One bounded batch: at most 200 shifts of at most 500 crew each, read above. - batches: async function* batches() { yield rows; }, + // Every sign-up, a page at a time. The audit counts the rows actually sent, + // and records a stopped or failed download too (`complete: false`). + batches: async function* batches() { + let rows = 0; + let complete = false; + try { + for await (const batch of volunteerHoursBatches(ctx.eventId)) { + rows += batch.length; + yield batch; + } + complete = true; + } finally { + await recordAuditEvent({ action: "VOLUNTEER_HOURS_EXPORTED", actorUserId: userId, organisationId: ctx.organisationId, targetType: "Event", targetId: ctx.eventId, metadata: { rows, complete } }); + } + }, }); } diff --git a/docs/VOLUNTEERS.md b/docs/VOLUNTEERS.md index 6a71b6c4..dca3ec96 100644 --- a/docs/VOLUNTEERS.md +++ b/docs/VOLUNTEERS.md @@ -213,8 +213,10 @@ data, and no chat notice is posted. - **Hours CSV** `/api/exports/events/:eventId/volunteer-hours`: one row per sign-up (name, kind, shift, zone, times, status, attendance, break, minutes, hours, state). Names only: no emails, tokens or adjustment - reasons. Cells are formula-neutralised (`lib/csv.ts`). Audited - `VOLUNTEER_HOURS_EXPORTED` with a row count. + reasons. Cells are formula-neutralised (`lib/csv.ts`). Every sign-up is + streamed in keyset pages (shift start, queue time, id), with no row + ceiling. Audited `VOLUNTEER_HOURS_EXPORTED` once the download ends, with + the rows sent and `complete` (false for a stopped or failed download). ## Permissions diff --git a/lib/volunteer/export.ts b/lib/volunteer/export.ts index 3b6e29ed..5e0c42b4 100644 --- a/lib/volunteer/export.ts +++ b/lib/volunteer/export.ts @@ -1,5 +1,6 @@ import "server-only"; +import type { Prisma } from "@prisma/client"; import { prisma } from "@/lib/db"; import { crewKindLabel } from "@/lib/crewKind"; import { formatHours } from "./math"; @@ -9,21 +10,53 @@ export const VOLUNTEER_HOURS_HEADER = ["Volunteer", "Kind", "Shift", "Zone", "Sh const iso = (value: Date | null) => value?.toISOString() ?? ""; -export async function volunteerHoursRows(eventId: string): Promise { - const signups = await prisma.volunteerShiftSignup.findMany({ - where: { eventId }, - orderBy: [{ shift: { startsAt: "asc" } }, { queuedAt: "asc" }, { id: "asc" }], - take: 20_000, - select: { - status: true, attendance: true, checkedInAt: true, checkedOutAt: true, breakMinutes: true, - crewMember: { select: { name: true, kind: true } }, - shift: { select: { title: true, zone: true, startsAt: true, endsAt: true } }, - hours: { select: { minutes: true, state: true } }, - }, - }); - return signups.map((row) => [ +const PAGE_SIZE = 1_000; + +const signupSelect = { + id: true, queuedAt: true, status: true, attendance: true, checkedInAt: true, checkedOutAt: true, breakMinutes: true, + crewMember: { select: { name: true, kind: true } }, + shift: { select: { title: true, zone: true, startsAt: true, endsAt: true } }, + hours: { select: { minutes: true, state: true } }, +} satisfies Prisma.VolunteerShiftSignupSelect; + +type Signup = Prisma.VolunteerShiftSignupGetPayload<{ select: typeof signupSelect }>; + +/** Sign-ups after `last` in the export's order: shift start, then queue time, then id. */ +function after(last: Signup): Prisma.VolunteerShiftSignupWhereInput { + const startsAt = last.shift.startsAt; + return { + OR: [ + { shift: { startsAt: { gt: startsAt } } }, + { shift: { startsAt }, queuedAt: { gt: last.queuedAt } }, + { shift: { startsAt }, queuedAt: last.queuedAt, id: { gt: last.id } }, + ], + }; +} + +function toRow(row: Signup): string[] { + return [ row.crewMember.name, crewKindLabel(row.crewMember.kind), row.shift.title, row.shift.zone, iso(row.shift.startsAt), iso(row.shift.endsAt), row.status, row.attendance, iso(row.checkedInAt), iso(row.checkedOutAt), String(row.breakMinutes), row.hours ? String(row.hours.minutes) : "", row.hours ? formatHours(row.hours.minutes) : "", row.hours?.state ?? "", - ]); + ]; +} + +/** + * Every sign-up of the event as CSV rows, one keyset page at a time in a + * stable order, so the file is complete however many sign-ups there are and + * only one page is held in memory. `pageSize` is for tests. + */ +export async function* volunteerHoursBatches(eventId: string, pageSize = PAGE_SIZE): AsyncGenerator { + let last: Signup | null = null; + for (;;) { + const page: Signup[] = await prisma.volunteerShiftSignup.findMany({ + where: { eventId, ...(last ? after(last) : {}) }, + orderBy: [{ shift: { startsAt: "asc" } }, { queuedAt: "asc" }, { id: "asc" }], + take: pageSize, + select: signupSelect, + }); + if (page.length) yield page.map(toRow); + if (page.length < pageSize) return; + last = page.at(-1)!; + } } diff --git a/tests/integration/volunteer-hours-export.db.test.ts b/tests/integration/volunteer-hours-export.db.test.ts new file mode 100644 index 00000000..c581675f --- /dev/null +++ b/tests/integration/volunteer-hours-export.db.test.ts @@ -0,0 +1,72 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const session = vi.hoisted(() => ({ userId: null as string | null })); +const audit = vi.hoisted(() => ({ events: [] as Array<{ action: string; metadata?: Record }> })); +vi.mock("@/lib/auth", () => ({ auth: async () => (session.userId ? { user: { id: session.userId } } : null) })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("@/lib/audit", () => ({ recordAuditEvent: async (event: { action: string; metadata?: Record }) => void audit.events.push(event) })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import type { Event } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { volunteerHoursBatches } from "@/lib/volunteer/export"; +import { GET as exportHours } from "@/app/api/exports/events/[eventId]/volunteer-hours/route"; +import { crew, setUpCrewOrgs, tearDownCrewOrgs } from "./helpers/crew"; +import { makeShift, shiftsEvent, volunteer } from "./helpers/volunteers"; +import { csv } from "./helpers/filteredExports"; + +/* + * Volunteer-hours CSV (audit: Data Layer, Privacy): every sign-up is + * exported, page after page in the stable order (shift start, queue time, + * id), with no silent ceiling, and the audit counts the rows sent. + */ + +let event: Event; +let expected: Array<[string, string]> = []; + +describe.sequential("volunteer hours export", () => { + beforeAll(async () => { + await setUpCrewOrgs("vexport"); + event = await shiftsEvent(); + // Two shifts start together, so the order falls through to queue time and id. + const early = await makeShift(event, { startIn: 60, title: "Early" }); + const twinA = await makeShift(event, { startIn: 120, title: "Twin A" }); + const twinB = await prisma.volunteerShift.update({ where: { id: (await makeShift(event, { startIn: 300, title: "Twin B" })).id }, data: { startsAt: twinA.startsAt, signupDeadline: twinA.startsAt } }); + const members = await Promise.all(["Ada", "Grace", "Linus"].map((name) => volunteer(event, { name }))); + const queued = new Date("2030-01-01T09:00:00.000Z"); + for (const shift of [twinB, early, twinA]) { + for (const [index, { member }] of members.entries()) { + // Equal queue times inside each shift but one, so ids decide. + await prisma.volunteerShiftSignup.create({ data: { eventId: event.id, shiftId: shift.id, crewMemberId: member.id, status: "APPROVED", queuedAt: index === 2 ? new Date(queued.getTime() - 1) : queued } }); + } + } + const all = await prisma.volunteerShiftSignup.findMany({ + where: { eventId: event.id }, + orderBy: [{ shift: { startsAt: "asc" } }, { queuedAt: "asc" }, { id: "asc" }], + select: { crewMember: { select: { name: true } }, shift: { select: { title: true } } }, + }); + expected = all.map((row) => [row.crewMember.name, row.shift.title]); + session.userId = crew.organiserId; + }); + afterAll(() => tearDownCrewOrgs()); + + it("reads every sign-up across small pages, in order, without repeats", async () => { + const batches: string[][][] = []; + for await (const batch of volunteerHoursBatches(event.id, 2)) batches.push(batch); + expect(expected).toHaveLength(9); + expect(batches.map((batch) => batch.length)).toEqual([2, 2, 2, 2, 1]); + expect(batches.flat().map((row) => [row[0], row[2]])).toEqual(expected); + // An exact multiple of the page size ends cleanly too. + const thirds: string[][][] = []; + for await (const batch of volunteerHoursBatches(event.id, 3)) thirds.push(batch); + expect(thirds.flat().map((row) => [row[0], row[2]])).toEqual(expected); + }); + + it("streams the whole file and audits the rows sent", async () => { + audit.events.length = 0; + const { header, rows } = await csv(await exportHours(new Request(`https://events.example.test/api/exports/events/${event.id}/volunteer-hours`), { params: Promise.resolve({ eventId: event.id }) })); + expect(header[0]).toBe("Volunteer"); + expect(rows.map((row) => [row[0], row[2]])).toEqual(expected); + expect(audit.events).toEqual([expect.objectContaining({ action: "VOLUNTEER_HOURS_EXPORTED", metadata: { rows: 9, complete: true } })]); + }); +}); From c781680ae40b3587036b141555628c9f2560212d Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:45:56 +0530 Subject: [PATCH 08/79] fix(teams): team undo rechecks the deleter's registration access Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/rsvp-admin/restore.ts | 7 +++++- tests/integration/teams-organiser.db.test.ts | 24 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/actions/rsvp-admin/restore.ts b/app/actions/rsvp-admin/restore.ts index 60db23b7..702935af 100644 --- a/app/actions/rsvp-admin/restore.ts +++ b/app/actions/rsvp-admin/restore.ts @@ -36,7 +36,12 @@ export async function restoreRsvp(input: unknown): Promise { if (!undo) return { ok: false, error: "This undo has expired or is unavailable." }; if (isTeamDeletionSnapshot(undo.payload)) { const teamEventId = (undo.payload as { eventId?: unknown }).eventId; - if (typeof teamEventId === "string" && (await prisma.event.count({ where: { id: teamEventId, admissionEnabled: true } }))) return { ok: false, error: ADMISSION_NO_RESTORE }; + if (typeof teamEventId !== "string" || !teamEventId) return { ok: false, error: "Could not restore the team." }; + // As for a single RSVP: the deleter's access may have changed since. + if (!(await canManageRegistrationsFor({ userId: session.user.id, organisationId: org.id, eventId: teamEventId }))) { + return { ok: false, error: "You do not have permission to restore RSVPs." }; + } + if (await prisma.event.count({ where: { id: teamEventId, admissionEnabled: true } })) return { ok: false, error: ADMISSION_NO_RESTORE }; const restoredTeam = await restoreTeamDeletionSnapshot(undo, org.id); if (!restoredTeam.ok || !restoredTeam.data) return restoredTeam.ok ? { ok: false, error: "Could not restore the team." } : restoredTeam; revalidateRsvpPaths(org.slug, { eventId: restoredTeam.data.eventId, eventInstanceId: null, eventSlug: restoredTeam.data.eventSlug }); diff --git a/tests/integration/teams-organiser.db.test.ts b/tests/integration/teams-organiser.db.test.ts index fb7ff1b8..150a1cd5 100644 --- a/tests/integration/teams-organiser.db.test.ts +++ b/tests/integration/teams-organiser.db.test.ts @@ -14,6 +14,7 @@ import { organiserMoveMember, organiserRenameTeam, removeTeamMember, + resolveOrganiserEvent, } from "@/lib/core/teams"; import { actingAs, @@ -210,4 +211,27 @@ describe.sequential("team registration integration", () => { { listId: swag.id, source: "mobile-manual" }, ]); }); + + it("refuses a team undo once the deleter's registration access is revoked", async () => { + const event = await createEvent({ capacity: 5 }); + const collaborator = await prisma.user.create({ data: { name: "Desk volunteer", email: `desk-${suffix}@example.test` } }); + try { + const grant = await prisma.eventCollaborator.create({ data: { eventId: event.id, userId: collaborator.id, permissions: ["MANAGE_REGISTRATIONS"] } }); + const ctx = await resolveOrganiserEvent(collaborator.id, orgSlug, event.id); + if (!ctx) throw new Error("collaborator context missing"); + const { team } = await createTeam(event, "Revoked crew"); + const deleted = await organiserDeleteTeam(ctx, team.id); + if (!deleted.ok || !deleted.data) throw new Error("team delete failed"); + + await prisma.eventCollaborator.delete({ where: { id: grant.id } }); + await expect(as(collaborator.id, () => restoreRsvp({ organisationSlug: orgSlug, undoId: deleted.data!.undoId }))).resolves.toEqual({ + ok: false, + error: "You do not have permission to restore RSVPs.", + }); + await expect(prisma.team.findUnique({ where: { id: team.id } })).resolves.toBeNull(); + await expect(prisma.rsvpDeletionUndo.count({ where: { id: deleted.data.undoId } })).resolves.toBe(1); + } finally { + await prisma.user.delete({ where: { id: collaborator.id } }); + } + }); }); From 5b22a48cd9f5b912a6224d82d9ade29faf5122b3 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:45:57 +0530 Subject: [PATCH 09/79] fix(feedback): feedback follows the event page's access rule Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/api/feedback/route.ts | 8 +- .../[orgSlug]/[eventSlug]/feedback/route.ts | 2 + content/help/after/feedback.mdx | 2 +- .../attending/feedback-and-certificates.mdx | 2 + docs/MOBILE_API.md | 9 +- docs/mobile-api.openapi.yaml | 6 +- lib/feedback.ts | 18 ++- lib/mobile/publicEvents.ts | 12 +- lib/publicEventAccess.ts | 16 ++- ...app__orgSlug__eventSlug__feedback__page.ts | 6 +- tests/e2e/public-smoke.spec.ts | 2 + tests/integration/feedback-access.db.test.ts | 111 ++++++++++++++++++ tests/integration/feedback.db.test.ts | 8 +- tests/unit/feedback-route.test.ts | 14 ++- tests/unit/feedback.test.ts | 11 +- 15 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 tests/integration/feedback-access.db.test.ts diff --git a/app/api/feedback/route.ts b/app/api/feedback/route.ts index d937daeb..5d3835be 100644 --- a/app/api/feedback/route.ts +++ b/app/api/feedback/route.ts @@ -3,6 +3,8 @@ import { NextResponse } from "next/server"; import { checkRateLimitById, getClientIpFromHeaders } from "@/lib/rateLimit"; import { submitFeedback } from "@/lib/feedback"; import { withApiMonitoring } from "@/lib/apiMonitor"; +import { optionalMobileUser } from "@/lib/mobileAuth"; +import { runAsRequestActor } from "@/lib/requestActor"; const MAX_FEEDBACK_BODY_BYTES = 128 * 1024; @@ -46,6 +48,10 @@ export const POST = withApiMonitoring("POST /api/feedback", async (request: Requ const subject = crypto.createHash("sha256").update(email).digest("hex").slice(0, 32); if (!(await checkRateLimitById("feedback", `email:${subject}`))) return json({ ok: false, error: "Too many attempts. Try again shortly." }, 429); } - const result = await submitFeedback(body); + // The website sends its session cookie; the app may send its bearer, so an + // invited guest can answer an invite-only event's form (lib/feedback.ts). + const appUser = await optionalMobileUser(request); + if (request.headers.get("authorization") && !appUser) return json({ ok: false, error: "Sign in again to send feedback." }, 401); + const result = appUser ? await runAsRequestActor(appUser, () => submitFeedback(body)) : await submitFeedback(body); return json(result, result.ok ? 200 : 400); }); diff --git a/app/api/mobile/v1/public/events/[orgSlug]/[eventSlug]/feedback/route.ts b/app/api/mobile/v1/public/events/[orgSlug]/[eventSlug]/feedback/route.ts index 6a6861c4..1fb1fe59 100644 --- a/app/api/mobile/v1/public/events/[orgSlug]/[eventSlug]/feedback/route.ts +++ b/app/api/mobile/v1/public/events/[orgSlug]/[eventSlug]/feedback/route.ts @@ -12,4 +12,6 @@ export const GET = mobileRoute<{ orgSlug: string; eventSlug: string }>( const form = await loadPublicFeedbackForm(orgSlug.data, eventSlug.data); return form ? mobileOk(form) : notFound(); }, + // A signed-in invited guest may open an invite-only event's form. + { optionalAuth: true }, ); diff --git a/content/help/after/feedback.mdx b/content/help/after/feedback.mdx index 5b8a7e5b..ed4321d5 100644 --- a/content/help/after/feedback.mdx +++ b/content/help/after/feedback.mdx @@ -61,7 +61,7 @@ Space Works Events never sends the feedback link by itself. Opening the form doe -The form only works while the event is published. Turn the switch back to **Closed** to stop taking responses. +The form only works while the event is published and its website is live, and only for people who can open the event page: for an invite-only event, that means invited guests signed in with the invited address. Turn the switch back to **Closed** to stop taking responses. ## Anonymous or verified diff --git a/content/help/attending/feedback-and-certificates.mdx b/content/help/attending/feedback-and-certificates.mdx index f0de9ea3..d757d241 100644 --- a/content/help/attending/feedback-and-certificates.mdx +++ b/content/help/attending/feedback-and-certificates.mdx @@ -6,6 +6,8 @@ Organisers share the feedback link themselves, usually by email after the event, The form only works while the organisers have it open. If the link shows "not found", the form hasn't opened yet or has closed; check with the organisers. +The form follows the event page: if you can't open the event page, you can't open its form either. For an invite-only event, sign in with the invited email address first. + ## Give feedback diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 50ab9a26..3b0cb1e1 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -526,9 +526,12 @@ their orders by id, and anyone else's are `NOT_FOUND`. ### `GET /public/events/:orgSlug/:eventSlug/feedback` `{ "title", "intro": null, "thankYouMessage", "open": true, "certificatesEnabled", "fields": [RegistrationField] }` -or `NOT_FOUND` unless the event is published and its form is open (as the web -feedback page). Submit with the existing `POST /api/feedback`; its response -contains the certificate token when one is issued. +or `NOT_FOUND` unless the event is published, its form is open and the caller +may open the event page (as the web feedback page): nothing while the website +is unreleased, and an invite-only event's form only with the bearer of a +signed-in invited guest. Submit with the existing `POST /api/feedback`, sending +the same bearer for an invite-only event (a bearer that does not sign in gets +`401`); its response contains the certificate token when one is issued. ### Existing web endpoints reused unchanged diff --git a/docs/mobile-api.openapi.yaml b/docs/mobile-api.openapi.yaml index e6e5917a..87af80d4 100644 --- a/docs/mobile-api.openapi.yaml +++ b/docs/mobile-api.openapi.yaml @@ -341,10 +341,12 @@ paths: get: tags: [Public] summary: The open feedback form + description: Follows the event page's access rule; an invite-only event's form needs the invited guest's bearer. security: [{}, { bearerAuth: [] }] parameters: [{ $ref: "#/components/parameters/orgSlug" }, { $ref: "#/components/parameters/eventSlug" }] responses: "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/FeedbackFormResponse" } } } } + "401": { $ref: "#/components/responses/Error" } "404": { $ref: "#/components/responses/Error" } default: { $ref: "#/components/responses/Error" } @@ -2185,7 +2187,8 @@ paths: post: tags: [Reused web endpoints] summary: Submit feedback (not enveloped like the mobile API) - security: [] + description: Optional bearer, so an invited guest can answer an invite-only event's form; the event page's access rule applies. + security: [{}, { bearerAuth: [] }] requestBody: required: true content: @@ -2210,6 +2213,7 @@ paths: ok: { const: true } data: { type: object, properties: { certificateToken: { type: [string, "null"] } } } "400": { description: "`{ ok: false, error: string }`." } + "401": { description: "A bearer was sent but does not sign in: `{ ok: false, error: string }`." } "413": { description: Body over 128 KiB. } "429": { description: Rate limited. } default: { $ref: "#/components/responses/Error" } diff --git a/lib/feedback.ts b/lib/feedback.ts index d1abbd6a..74cc6d00 100644 --- a/lib/feedback.ts +++ b/lib/feedback.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import type { ActionResult } from "@/app/actions/org"; import { prisma } from "@/lib/db"; import { emitWebhookEvent } from "@/lib/webhooks/emit"; +import { resolvePublicEventAccess } from "@/lib/publicEventAccess"; import { isValidInternationalPhone, normalizeInternationalPhone, @@ -96,7 +97,11 @@ function validateAnswers(fields: Array<{ id: string; key: string; label: string; return { rows }; } -/** Submit repeatable anonymous feedback, optionally verifying an attendee for a certificate. */ +/** + * Submit repeatable anonymous feedback, optionally verifying an attendee for a + * certificate. Only for a published event whose page the sender may open: a + * released website, and for an invite-only event a signed-in invited guest. + */ export async function submitFeedback(input: unknown): Promise> { const parsed = submissionSchema.safeParse(input); if (!parsed.success) return { ok: false, error: "Complete the feedback form and try again." }; @@ -111,12 +116,21 @@ export async function submitFeedback(input: unknown): Promise> => { await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${event.id} FOR UPDATE`; await tx.$queryRaw`SELECT "id" FROM "EventFeedbackForm" WHERE "id" = ${formId} FOR UPDATE`; - const currentEvent = await tx.event.findUnique({ where: { id: event.id }, select: { status: true } }); + const currentEvent = await tx.event.findUnique({ where: { id: event.id }, select: { organisationId: true, status: true, privacyType: true, page: { select: { isPublished: true } } } }); const form = await tx.eventFeedbackForm.findUnique({ where: { id: formId }, include: { fields: { orderBy: { sortOrder: "asc" } } }, }); if (currentEvent?.status !== EventStatus.PUBLISHED || !form?.isOpen) return { ok: false, error: "Feedback is not open for this event." }; + // The event page's own rule (privacy, release, invitation), read in this transaction. + const access = await resolvePublicEventAccess({ + organisationId: currentEvent.organisationId, + eventId: event.id, + status: currentEvent.status, + privacyType: currentEvent.privacyType, + websiteReleased: currentEvent.page?.isPublished ?? false, + }, tx); + if (!access.allowed) return { ok: false, error: "Feedback is not open for this event." }; const normalized = validateAnswers(form.fields, parsed.data.answers); if ("error" in normalized) return { ok: false, error: normalized.error }; let rsvpId: string | null = null; diff --git a/lib/mobile/publicEvents.ts b/lib/mobile/publicEvents.ts index 5f37ca38..e4c2ec31 100644 --- a/lib/mobile/publicEvents.ts +++ b/lib/mobile/publicEvents.ts @@ -219,11 +219,17 @@ async function loadTicketing(organisationId: string, event: Parameters; }; export async function loadRouteMetadata(): Promise { return { title: "Event feedback", robots: { index: false, follow: false } }; } export async function loadFeedbackView({ params }: Props) { const { orgSlug, eventSlug } = await params; - const event = await prisma.event.findFirst({ where: { organisation: { slug: orgSlug }, slug: eventSlug, status: EventStatus.PUBLISHED }, include: { page: { select: { accentColor: true } }, feedbackForm: { include: { fields: { orderBy: { sortOrder: "asc" } } } } } }); + const event = await prisma.event.findFirst({ where: { organisation: { slug: orgSlug }, slug: eventSlug, status: EventStatus.PUBLISHED }, include: { page: { select: { accentColor: true, isPublished: true } }, feedbackForm: { include: { fields: { orderBy: { sortOrder: "asc" } } } } } }); if (!event?.feedbackForm?.isOpen) notFound(); + // Only for people who may open the event page itself: invite-only and unreleased events stay hidden. + const access = await resolvePublicEventAccess({ organisationId: event.organisationId, eventId: event.id, status: event.status, privacyType: event.privacyType, websiteReleased: event.page?.isPublished ?? false }); + if (!access.allowed) notFound(); const fields = event.feedbackForm.fields.map((field) => ({ id: field.id, key: field.key, label: field.label, type: field.type, required: field.required, sortOrder: field.sortOrder, options: Array.isArray(field.options) ? field.options.filter((value): value is string => typeof value === "string") : [] })); return { event: { ...eventForView(event), feedbackForm: event.feedbackForm }, orgSlug, eventSlug, fields }; } diff --git a/tests/e2e/public-smoke.spec.ts b/tests/e2e/public-smoke.spec.ts index 0ebba461..9e259eef 100644 --- a/tests/e2e/public-smoke.spec.ts +++ b/tests/e2e/public-smoke.spec.ts @@ -27,6 +27,8 @@ test.beforeAll(async () => { location: "Test venue", status: EventStatus.PUBLISHED, privacyType: EventPrivacyType.PUBLIC, + // Feedback follows the event page, which needs a released website. + page: { create: { isPublished: true } }, }, }); const rsvp = await prisma.rSVP.create({ diff --git a/tests/integration/feedback-access.db.test.ts b/tests/integration/feedback-access.db.test.ts new file mode 100644 index 00000000..991fe149 --- /dev/null +++ b/tests/integration/feedback-access.db.test.ts @@ -0,0 +1,111 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventPrivacyType } from "@prisma/client"; + +// Never let an ambient Redis or base URL leak into this suite. +process.env.REDIS_URL = ""; +process.env.NEXT_PUBLIC_BASE_URL = "https://events.example.test"; +process.env.TRUSTED_PROXY_IP_HEADER = "x-real-ip"; + +// Auth.js can't load under Vitest; the real lib/auth.ts presents the app +// session to bearer requests, and `web.session` stands in for a browser cookie. +const web = vi.hoisted(() => ({ session: null as null | { user: { id: string; }; } })); +vi.mock("next-auth", () => ({ default: () => ({ auth: async () => web.session, handlers: {}, signIn: vi.fn(), signOut: vi.fn() }), CredentialsSignin: class extends Error {} })); +vi.mock("next-auth/providers/google", () => ({ default: vi.fn() })); +vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn() })); +vi.mock("@/lib/authAdapter", () => ({ authAdapter: {} })); +vi.mock("next/headers", () => ({ headers: async () => new Headers({ "x-real-ip": "198.51.100.20" }), cookies: async () => ({ get: () => undefined, getAll: () => [], set: vi.fn(), delete: vi.fn() }) })); +vi.mock("next/navigation", () => ({ notFound: () => { throw new Error("not-found"); }, redirect: (path: string) => { throw new Error(`redirect:${path}`); } })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/outboxWorker", () => ({ startOutboxWorker: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { appSession, createEvent, createUser, get, params, post } from "../helpers/mobileApi"; +import { submitFeedback } from "@/lib/feedback"; +import { loadFeedbackView } from "@/lib/views/app__orgSlug__eventSlug__feedback__page"; +import { GET as mobileFeedback } from "@/app/api/mobile/v1/public/events/[orgSlug]/[eventSlug]/feedback/route"; +import { POST as feedbackRoute } from "@/app/api/feedback/route"; + +/* + * Feedback follows the event page's access rule (lib/publicEventAccess.ts): an + * invite-only event's form is for signed-in invited guests, and nothing is + * open while the event's website is unreleased. Web page, web submission and + * the app's form and submission all agree. + */ +const suffix = randomUUID().replace(/-/g, "").slice(0, 12); +const orgSlug = `feedback-access-${suffix}`; +const slugs = { invite: `invite-${suffix}`, unreleased: `unreleased-${suffix}`, open: `open-${suffix}` }; +const users = { invited: "", stranger: "" }; +let organisationId = ""; + +const form = { create: { isOpen: true, fields: { create: [{ key: "comment", label: "Comment", type: "TEXTAREA" as const, required: true, sortOrder: 1 }] } } }; +const answer = (eventSlug: string) => ({ orgSlug, eventSlug, answers: { comment: "Useful" } }); +const page = (eventSlug: string) => loadFeedbackView({ params: Promise.resolve({ orgSlug, eventSlug }) }); +const appForm = (eventSlug: string, token?: string) => mobileFeedback(get(`/api/mobile/v1/public/events/${orgSlug}/${eventSlug}/feedback`, token), params({ orgSlug, eventSlug })); +const appSubmit = (eventSlug: string, token?: string) => { + const request = post("/api/feedback", answer(eventSlug), token); + request.headers.delete("idempotency-key"); + return feedbackRoute(request); +}; + +beforeAll(async () => { + organisationId = (await prisma.organisation.create({ data: { name: "Feedback access", slug: orgSlug } })).id; + users.invited = await createUser(`invited-${suffix}@example.test`, "Invited guest"); + users.stranger = await createUser(`stranger-${suffix}@example.test`, "Stranger"); + const invite = await createEvent(organisationId, slugs.invite, { privacyType: EventPrivacyType.INVITE_ONLY, feedbackForm: form }); + await prisma.eventInvite.create({ data: { eventId: invite, email: `invited-${suffix}@example.test` } }); + await createEvent(organisationId, slugs.unreleased, { page: { create: { isPublished: false } }, feedbackForm: form }); + await createEvent(organisationId, slugs.open, { feedbackForm: form }); +}); + +beforeEach(() => { web.session = null; }); + +afterAll(async () => { + await prisma.organisation.deleteMany({ where: { id: organisationId } }); + await prisma.user.deleteMany({ where: { id: { in: Object.values(users).filter(Boolean) } } }); + await prisma.$disconnect(); +}); + +describe.sequential("feedback access", () => { + it("keeps an invite-only event's form from anyone but a signed-in invited guest on the web", async () => { + await expect(page(slugs.invite)).rejects.toThrow("not-found"); + await expect(submitFeedback(answer(slugs.invite))).resolves.toEqual({ ok: false, error: "Feedback is not open for this event." }); + web.session = { user: { id: users.stranger } }; + await expect(page(slugs.invite)).rejects.toThrow("not-found"); + await expect(submitFeedback(answer(slugs.invite))).resolves.toEqual({ ok: false, error: "Feedback is not open for this event." }); + + web.session = { user: { id: users.invited } }; + await expect(page(slugs.invite)).resolves.toMatchObject({ eventSlug: slugs.invite }); + await expect(submitFeedback(answer(slugs.invite))).resolves.toEqual({ ok: true, data: { certificateToken: null } }); + }); + + it("opens nothing for an event whose website is unreleased", async () => { + web.session = { user: { id: users.invited } }; + await expect(page(slugs.unreleased)).rejects.toThrow("not-found"); + await expect(submitFeedback(answer(slugs.unreleased))).resolves.toEqual({ ok: false, error: "Feedback is not open for this event." }); + expect((await appForm(slugs.unreleased)).status).toBe(404); + expect((await appSubmit(slugs.unreleased)).status).toBe(400); + }); + + it("applies the same rule to the app, which may sign in so an invited guest can answer", async () => { + expect((await appForm(slugs.invite)).status).toBe(404); + expect((await appSubmit(slugs.invite)).status).toBe(400); + const stranger = await appSession(users.stranger); + expect((await appForm(slugs.invite, stranger)).status).toBe(404); + expect((await appSubmit(slugs.invite, stranger)).status).toBe(400); + + const invited = await appSession(users.invited); + expect((await appForm(slugs.invite, invited)).status).toBe(200); + const submitted = await appSubmit(slugs.invite, invited); + expect(submitted.status).toBe(200); + await expect(submitted.json()).resolves.toEqual({ ok: true, data: { certificateToken: null } }); + // A bearer that doesn't sign in is refused, never treated as anonymous. + expect((await appSubmit(slugs.open, "not-a-token")).status).toBe(401); + }); + + it("keeps a public, released event's form open to everyone", async () => { + await expect(page(slugs.open)).resolves.toMatchObject({ eventSlug: slugs.open }); + await expect(submitFeedback(answer(slugs.open))).resolves.toEqual({ ok: true, data: { certificateToken: null } }); + expect((await appForm(slugs.open)).status).toBe(200); + }); +}); diff --git a/tests/integration/feedback.db.test.ts b/tests/integration/feedback.db.test.ts index 13b02d44..eb5d244a 100644 --- a/tests/integration/feedback.db.test.ts +++ b/tests/integration/feedback.db.test.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import { EventPrivacyType, EventStatus, RsvpStatus } from "@prisma/client"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +// Feedback follows the event page's access rule; these events are public, so nobody signs in. +vi.mock("@/lib/auth", () => ({ auth: async () => null })); import { prisma } from "@/lib/db"; import { submitFeedback } from "@/lib/feedback"; @@ -18,12 +20,12 @@ describe.sequential("event feedback integration", () => { anonymousEventSlug = `anonymous-${suffix}`; const organisation = await prisma.organisation.create({ data: { name: "Feedback test", slug: orgSlug } }); organisationId = organisation.id; - const event = await prisma.event.create({ data: { organisationId, title: "Feedback event", slug: eventSlug, startDateTime: new Date("2030-01-01T10:00:00Z"), endDateTime: new Date("2030-01-01T11:00:00Z"), timezone: "UTC", status: EventStatus.PUBLISHED, privacyType: EventPrivacyType.PUBLIC } }); + const event = await prisma.event.create({ data: { organisationId, title: "Feedback event", slug: eventSlug, startDateTime: new Date("2030-01-01T10:00:00Z"), endDateTime: new Date("2030-01-01T11:00:00Z"), timezone: "UTC", status: EventStatus.PUBLISHED, privacyType: EventPrivacyType.PUBLIC, page: { create: { isPublished: true } } } }); const form = await prisma.eventFeedbackForm.create({ data: { eventId: event.id, isOpen: true, certificateEnabled: true } }); await prisma.eventFeedbackField.create({ data: { formId: form.id, key: "comment", label: "Your feedback", type: "TEXTAREA", required: true, sortOrder: 1 } }); await prisma.rSVP.create({ data: { eventId: event.id, attendeeKey: `feedback:${suffix}`, guestEmail: `attendee-${suffix}@example.test`, guestName: "Feedback attendee", status: RsvpStatus.CONFIRMED } }); await prisma.rSVP.create({ data: { eventId: event.id, attendeeKey: `feedback-wait:${suffix}`, guestEmail: `wait-${suffix}@example.test`, guestName: "Waitlisted attendee", status: RsvpStatus.WAITLISTED } }); - const anonymousEvent = await prisma.event.create({ data: { organisationId, title: "Anonymous feedback", slug: anonymousEventSlug, startDateTime: new Date("2030-01-02T10:00:00Z"), endDateTime: new Date("2030-01-02T11:00:00Z"), timezone: "UTC", status: EventStatus.PUBLISHED, privacyType: EventPrivacyType.PUBLIC } }); + const anonymousEvent = await prisma.event.create({ data: { organisationId, title: "Anonymous feedback", slug: anonymousEventSlug, startDateTime: new Date("2030-01-02T10:00:00Z"), endDateTime: new Date("2030-01-02T11:00:00Z"), timezone: "UTC", status: EventStatus.PUBLISHED, privacyType: EventPrivacyType.PUBLIC, page: { create: { isPublished: true } } } }); const anonymousForm = await prisma.eventFeedbackForm.create({ data: { eventId: anonymousEvent.id, isOpen: true, certificateEnabled: false } }); const anonymousField = await prisma.eventFeedbackField.create({ data: { formId: anonymousForm.id, key: "comment", label: "Comment", type: "TEXTAREA", required: true } }); anonymousFieldId = anonymousField.id; diff --git a/tests/unit/feedback-route.test.ts b/tests/unit/feedback-route.test.ts index 4a4d77b4..4f369855 100644 --- a/tests/unit/feedback-route.test.ts +++ b/tests/unit/feedback-route.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ limit: vi.fn(), submit: vi.fn() })); +const mocks = vi.hoisted(() => ({ limit: vi.fn(), submit: vi.fn(), appUser: vi.fn() })); +vi.mock("@/lib/mobileAuth", () => ({ optionalMobileUser: mocks.appUser })); vi.mock("@/lib/rateLimit", () => ({ getClientIpFromHeaders: () => "203.0.113.4", checkRateLimitById: mocks.limit })); vi.mock("@/lib/feedback", () => ({ submitFeedback: mocks.submit })); vi.mock("@/lib/apiMonitor", () => ({ withApiMonitoring: (_label: string, handler: unknown) => handler })); @@ -11,6 +12,7 @@ beforeEach(() => { vi.clearAllMocks(); mocks.limit.mockResolvedValue(true); mocks.submit.mockResolvedValue({ ok: true, data: { certificateToken: "secret-capability" } }); + mocks.appUser.mockResolvedValue(null); }); describe("feedback HTTP boundary", () => { @@ -35,4 +37,14 @@ describe("feedback HTTP boundary", () => { expect(response.status).toBe(413); expect(mocks.submit).not.toHaveBeenCalled(); }); + + it("refuses an app bearer that does not sign in, before submitting", async () => { + const response = await POST(new Request("https://events.example.test/api/feedback", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer revoked" }, + body: JSON.stringify({ orgSlug: "org", eventSlug: "event", answers: {} }), + })); + expect(response.status).toBe(401); + expect(mocks.submit).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/feedback.test.ts b/tests/unit/feedback.test.ts index bf84709f..b7badb30 100644 --- a/tests/unit/feedback.test.ts +++ b/tests/unit/feedback.test.ts @@ -8,7 +8,9 @@ const mocks = vi.hoisted(() => ({ responseCreate: vi.fn(), transaction: vi.fn(), query: vi.fn(), + access: vi.fn(), })); +vi.mock("@/lib/publicEventAccess", () => ({ resolvePublicEventAccess: mocks.access })); vi.mock("@/lib/db", () => ({ prisma: { event: { findFirst: mocks.eventFind }, @@ -30,13 +32,20 @@ const commentField = { id: "field_1", key: "comment", label: "Comment", type: Re beforeEach(() => { vi.clearAllMocks(); mocks.eventFind.mockResolvedValue({ id: "event_1", feedbackForm: { id: "form_1" } }); - tx.event.findUnique.mockResolvedValue({ status: EventStatus.PUBLISHED }); + tx.event.findUnique.mockResolvedValue({ organisationId: "org_1", status: EventStatus.PUBLISHED, privacyType: "INVITE_ONLY", page: { isPublished: true } }); + mocks.access.mockResolvedValue({ allowed: true, preview: false }); mocks.formFind.mockResolvedValue({ id: "form_1", isOpen: true, certificateEnabled: false, fields: [commentField] }); mocks.responseCreate.mockResolvedValue({ id: "response_1" }); mocks.transaction.mockImplementation(async (callback: (client: typeof tx) => Promise) => callback(tx)); }); describe("feedback privacy and validation", () => { + it("applies the event page's access rule to the state read in the transaction", async () => { + mocks.access.mockResolvedValue({ allowed: false, preview: false }); + await expect(submitFeedback({ orgSlug: "org", eventSlug: "event", answers: { comment: "Useful" } })).resolves.toEqual({ ok: false, error: "Feedback is not open for this event." }); + expect(mocks.access).toHaveBeenCalledWith({ organisationId: "org_1", eventId: "event_1", status: EventStatus.PUBLISHED, privacyType: "INVITE_ONLY", websiteReleased: true }, tx); + expect(mocks.responseCreate).not.toHaveBeenCalled(); + }); it("snapshots the certificate design in the locked submission transaction", async () => { const certificateTemplate = { backgroundKey: "organisations/org/certificate-backgrounds/example.webp", font: "serif" }; mocks.formFind.mockResolvedValue({ id: "form_1", isOpen: true, certificateEnabled: true, certificateTemplate, fields: [commentField] }); From 002c731bf8e81d79ed5c17350101e92375660b84 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:45:59 +0530 Subject: [PATCH 10/79] fix(auth): sign-up spends its limit first and answers every attempt alike Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/auth.ts | 12 +++++----- content/help/account/sign-up-and-sign-in.mdx | 2 +- tests/unit/auth-actions.test.ts | 23 ++++++++++++++++---- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/actions/auth.ts b/app/actions/auth.ts index 33bb0b17..ddae122e 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -42,6 +42,11 @@ export async function signUpWithPassword( return { ok: false, error: "New account creation is currently unavailable." }; } + // Every attempt spends the address's creation quota before any lookup or + // hashing. A limited attempt creates and sends nothing but answers exactly + // like the others, so the limit can't tell existing addresses from new ones. + if (await isActionRateLimited("signup", email)) return { ok: true, data: { email } }; + // The super admin's account and password come from the server's recovery // tool. Answer like any other sign-up so the address isn't revealed. if (isSuperAdminEmail(email)) return { ok: true, data: { email } }; @@ -59,13 +64,6 @@ export async function signUpWithPassword( return { ok: true, data: { email } }; } - // A double-submit or network retry can arrive just after the first request - // created the account. Check for that case before consuming the creation - // quota so a real account creation is never presented as a rate-limit error. - if (await isActionRateLimited("signup", email)) { - return { ok: false, error: "Too many attempts. Please try again later." }; - } - try { await prisma.$transaction(async (tx) => { const user = await tx.user.create({ diff --git a/content/help/account/sign-up-and-sign-in.mdx b/content/help/account/sign-up-and-sign-in.mdx index 71126599..d0448c3d 100644 --- a/content/help/account/sign-up-and-sign-in.mdx +++ b/content/help/account/sign-up-and-sign-in.mdx @@ -13,7 +13,7 @@ You need an account to run events, keep your registrations in one place, or see -The page always answers "If this email can be registered, check your inbox for a verification link", so nobody can use the form to find out who has an account. If you already have an account with that address, no new one is made; sign in or [reset your password](/help/account/forgotten-password) instead. +The page always answers "If this email can be registered, check your inbox for a verification link", so nobody can use the form to find out who has an account. If you already have an account with that address, no new one is made; sign in or [reset your password](/help/account/forgotten-password) instead. Sign-ups are also limited to a few an hour for each address and network; past that the page gives the same answer but sends nothing, so if no email arrives, wait an hour and try again. ### Password rules diff --git a/tests/unit/auth-actions.test.ts b/tests/unit/auth-actions.test.ts index 9af329be..0e6e1698 100644 --- a/tests/unit/auth-actions.test.ts +++ b/tests/unit/auth-actions.test.ts @@ -45,14 +45,29 @@ describe("password account creation", () => { expect(mocks.transaction).not.toHaveBeenCalled(); }); - it("does not mislabel a retried account creation as rate-limited", async () => { - mocks.userFind.mockResolvedValue({ id: "new_user", passwordHash: "hash" }); + it("spends the limit first and answers a limited attempt like any other, creating nothing", async () => { mocks.rateLimit.mockResolvedValue(true); - await expect(signUpWithPassword({ name: "Person", email: "person@example.test", password: "new-password" })) .resolves.toEqual({ ok: true, data: { email: "person@example.test" } }); - expect(mocks.rateLimit).not.toHaveBeenCalled(); + expect(mocks.rateLimit).toHaveBeenCalledWith("signup", "person@example.test"); + expect(mocks.hash).not.toHaveBeenCalled(); + expect(mocks.userFind).not.toHaveBeenCalled(); expect(mocks.transaction).not.toHaveBeenCalled(); + expect(mocks.issueVerification).not.toHaveBeenCalled(); + }); + + it("gives the same answer for an existing, a new and a rate-limited address", async () => { + const attempt = () => signUpWithPassword({ name: "Person", email: "person@example.test", password: "new-password" }); + mocks.userFind.mockResolvedValue({ id: "existing", passwordHash: "hash" }); + const existing = await attempt(); + mocks.userFind.mockResolvedValue(null); + const created = await attempt(); + mocks.rateLimit.mockResolvedValue(true); + const limited = await attempt(); + expect(existing).toEqual(created); + expect(limited).toEqual(created); + // The existing address spent its quota too. + expect(mocks.rateLimit).toHaveBeenCalledTimes(3); }); it("never creates the super-admin account from sign-up, and answers as for anyone else", async () => { From 94ca83bf758cd92be963016106baff7984c644df Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:46:02 +0530 Subject: [PATCH 11/79] fix(auth): MFA-aware org settings, password-only super admin, proof bound to sign-in Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/mfa-step-up.ts | 6 +- app/actions/org.ts | 12 +-- .../help/account/two-step-verification.mdx | 2 +- content/help/instance/super-admin-access.mdx | 3 +- docs/SECURITY.md | 8 +- lib/auth.ts | 23 +++-- lib/googleSignIn.ts | 13 ++- lib/mfaProof.ts | 15 ++-- lib/permissions.ts | 15 ++-- lib/sessionSid.ts | 43 +++++++++ lib/staffMfa.ts | 4 +- lib/views/app__help__layout.ts | 4 +- tests/integration/auth-audit.db.test.ts | 80 +++++++++++++++++ tests/integration/login-security.db.test.ts | 25 +++++- tests/unit/auth-session-sid.test.ts | 89 +++++++++++++++++++ tests/unit/auth-session.test.ts | 30 ++++++- tests/unit/help-access.test.ts | 10 ++- tests/unit/mfa-proof.test.ts | 24 +++-- tests/unit/mfa-step-up-action.test.ts | 12 +++ tests/unit/super-admin-permissions.test.ts | 16 +++- types/next-auth.d.ts | 2 + 21 files changed, 381 insertions(+), 55 deletions(-) create mode 100644 lib/sessionSid.ts create mode 100644 tests/integration/auth-audit.db.test.ts create mode 100644 tests/unit/auth-session-sid.test.ts diff --git a/app/actions/mfa-step-up.ts b/app/actions/mfa-step-up.ts index 6381bd22..a49580cd 100644 --- a/app/actions/mfa-step-up.ts +++ b/app/actions/mfa-step-up.ts @@ -6,6 +6,7 @@ import { prisma } from "@/lib/db"; import { requireAuth } from "@/lib/permissions"; import { decryptMfaSecret, consumeMfaCode } from "@/lib/mfa"; import { createMfaProof, MFA_PROOF_COOKIE, MFA_PROOF_MAX_AGE_SECONDS } from "@/lib/mfaProof"; +import { currentSessionSid } from "@/lib/sessionSid"; import { isActionRateLimited } from "@/lib/actionRateLimit"; import type { ActionResult } from "./org"; @@ -19,6 +20,9 @@ export async function verifyStepUpMfa(input: unknown): Promise { } const parsed = schema.safeParse(input); if (!parsed.success) return { ok: false, error: "Enter a six-digit authenticator code." }; + // The proof is bound to this browser's sign-in (lib/sessionSid.ts); without one there is nothing to bind it to. + const sid = await currentSessionSid(session.user.id); + if (!sid) return { ok: false, error: "Sign in again, then confirm your authenticator code." }; const user = await prisma.user.findUnique({ where: { id: session.user.id }, @@ -36,7 +40,7 @@ export async function verifyStepUpMfa(input: unknown): Promise { } if (!valid) return { ok: false, error: "Invalid authenticator code." }; - (await cookies()).set(MFA_PROOF_COOKIE, createMfaProof(session.user.id, user.sessionVersion), { + (await cookies()).set(MFA_PROOF_COOKIE, createMfaProof(session.user.id, user.sessionVersion, sid), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", diff --git a/app/actions/org.ts b/app/actions/org.ts index 27a04cfa..b2b47f68 100644 --- a/app/actions/org.ts +++ b/app/actions/org.ts @@ -4,6 +4,7 @@ import { revalidatePath as nextRevalidatePath } from "next/cache"; import { trackedRevalidation } from "@/lib/frontend/revalidation"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db"; +import { getMembership } from "@/lib/permissions"; import { createOrganisationSchema, updateOrganisationSchema, @@ -128,15 +129,8 @@ export async function updateOrganisation( }); if (!org) return { ok: false, error: "Organisation not found." }; - const membership = await prisma.membership.findUnique({ - where: { - userId_organisationId: { - userId: session.user.id, - organisationId: org.id, - }, - }, - select: { role: true }, - }); + // The MFA-aware helper: staff powers wait for this session's authenticator check. + const membership = await getMembership(session.user.id, org.id); if ( !membership || diff --git a/content/help/account/two-step-verification.mdx b/content/help/account/two-step-verification.mdx index 33b3f991..f6b691e1 100644 --- a/content/help/account/two-step-verification.mdx +++ b/content/help/account/two-step-verification.mdx @@ -56,7 +56,7 @@ Google sign-in follows your Google account's own security, so it doesn't ask for -You're asked again after 12 hours, and after you sign out everywhere or reset your password. This page only takes a code from the app, not a recovery code. +You're asked again after 12 hours, each time you sign in again, and after you sign out everywhere or reset your password. This page only takes a code from the app, not a recovery code. ## Recovery codes diff --git a/content/help/instance/super-admin-access.mdx b/content/help/instance/super-admin-access.mdx index 52ca0e6f..27a613a5 100644 --- a/content/help/instance/super-admin-access.mdx +++ b/content/help/instance/super-admin-access.mdx @@ -11,11 +11,12 @@ Only the account whose verified email matches the `SUPER_ADMIN_EMAIL` setting on The app lets you in only when all of these are true: - `SUPER_ADMIN_EMAIL` is set on the server, and you're signed in with the account whose email matches it (capital letters don't matter). +- You signed in with that account's **password**, not with Google. - That account's email address is **verified**. A signed-out visitor to `/super-admin` is sent to the sign-in page. Anyone else who is signed in sees an ordinary "not found" page, so the area's existence isn't revealed. Every super admin page checks this again on its own, including when you move between sections. -There's no separate authenticator step-up: once you're signed in with the right account, the area opens. To change who the super admin is, change `SUPER_ADMIN_EMAIL` on the server and restart, then set that account's password as described below. +There's no separate authenticator step-up: once you're signed in with the right account, the area opens. **Continue with Google** is refused for the super admin's address, even if a Google account was linked to it before. To change who the super admin is, change `SUPER_ADMIN_EMAIL` on the server and restart, then set that account's password as described below. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 21319284..a58fb327 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -8,11 +8,12 @@ This page documents implemented controls and security boundaries. It is not a su - Passwords are bcrypt hashes and are never stored or logged in plaintext. New passwords require at least 12 characters and must fit bcrypt’s 72-byte UTF-8 limit; existing passwords remain usable for sign-in. - Auth.js sessions include a user `sessionVersion`; incrementing it invalidates issued sessions. +- Each web sign-in records how it happened (`authMethod`: `credentials` or `google`) and a random 128-bit sign-in id (`sid`), both fixed for the session's life. The sid stays inside the encrypted session token: it is not on the session object, so `/api/auth/session` never returns it, and the server reads it only through `lib/sessionSid.ts`, which decodes the session cookie (joining Auth.js's chunks) with the same secret and cookie name and accepts it only for the user `auth()` validated. Sessions issued before sign-in ids existed are retired like revoked ones, so after that release every web user signs in once more, staff confirm their authenticator again, and the super admin uses the password. - Password reset uses expiring verification tokens and the transactional outbox. - Password accounts require a one-time, expiring inbox-verification link before sign-in or event creation. Tokens are stored only as hashes and consumed once. - Sign-up, password reset and password change refuse passwords found in Have I Been Pwned. The check uses k-anonymity: only the first five hex characters of the password's SHA-1 leave the server, with `Add-Padding`. Any failure or a 3-second timeout counts as not breached, so the check never blocks a sign-up. Self-hosted installs need outbound HTTPS to `api.pwnedpasswords.com`; `PWNED_PASSWORDS_CHECK=false` turns it off (air-gapped installs, tests). - Users can enable TOTP MFA and receive one-use recovery codes. TOTP use is claimed atomically in PostgreSQL, so a code cannot be replayed across replicas or between sign-in and the authenticator check on `/verify-mfa`. Wait for the next authenticator code when completing consecutive challenges. MFA enrollment is single-use, bound to the account session version, and cannot replace an active authenticator. -- Organisation staff must use an authenticator app. Staff means anyone with an organisation membership (any role) or an event or series co-organiser grant; attendees and the super admin are exempt. Staff without an authenticator are sent to `/account/security?mfa=required`. Once one is set up, each session needs an authenticator check: given at the password sign-in or, after Google sign-in, on `/verify-mfa`. That page sets a signed, HttpOnly proof cookie (`swe.mfa-proof.v1`) for 12 hours, bound to the user and session version, so signing out everywhere or a password reset ends it. Until then staff checks in actions (`getMembership`, `canAccessEvent`, `canViewEventDashboard`) return no access. `STAFF_MFA_REQUIRED=false` turns this off (local development and tests only). +- Organisation staff must use an authenticator app. Staff means anyone with an organisation membership (any role) or an event or series co-organiser grant; attendees and the super admin are exempt. Staff without an authenticator are sent to `/account/security?mfa=required`. Once one is set up, each session needs an authenticator check: given at the password sign-in or, after Google sign-in, on `/verify-mfa`. That page sets a signed, HttpOnly proof cookie (`swe.mfa-proof.v1`) for 12 hours, bound to the user, the session version and the session's sign-in id, so signing out everywhere or a password reset ends it, and a new sign-in in the same browser needs a new check. A session without a sign-in id gets no proof. Until then staff checks in actions (`getMembership`, `canAccessEvent`, `canViewEventDashboard`) return no access. `STAFF_MFA_REQUIRED=false` turns this off (local development and tests only). - A request that is not a browser with a cookie session runs as an explicit actor (`lib/requestActor.ts`, an `AsyncLocalStorage` on a `Symbol.for` global). A mobile app session is judged by its own `mfaVerifiedAt`, which counts for the same 12 hours as the proof cookie; a cookie session on the same request is ignored. A staff check with neither an actor nor a cookie session fails closed (`verify` or `enrol`), so a bearer route that forgets to declare its actor gets no staff access. - TOTP seeds are encrypted with AES-256-GCM using `MFA_ENCRYPTION_KEY`; recovery codes are stored as keyed hashes. Super-admin SMTP and Google client secrets, and the operational alerts webhook URL, are separately domain-encrypted from that key and never rendered back to the browser (only the webhook host is shown). `scripts/check-instance-secrets.mjs` verifies that the current key can decrypt them, the authenticator seeds and every organisation's secrets (mail server, chat channels, payment keys, station PINs), printing only counts: no values, ciphertexts or identifiers. - Organisation payment keys (Razorpay key and webhook secrets, Stripe secret key and signing secret) are encrypted with AES-256-GCM under their own HKDF subkey of `MFA_ENCRYPTION_KEY`, with the organisation id and column bound as associated data, so a value copied to another organisation or column does not decrypt. Only the owner can view or change them, changes need a recent sign-in, the page receives only "is set" flags, and the column names are on the split gateway's forbidden-field list. Audit metadata for payment settings is boolean only. @@ -24,7 +25,7 @@ This page documents implemented controls and security boundaries. It is not a su - A sign-in from a new device sends a "New sign-in to your account" alert email and is audited as `SIGN_IN_NEW_DEVICE`. - Devices are remembered in the `UserDevice` table: only a SHA-256 of the browser's random id, a short browser description and the last IP. Rows are removed with the account. - Account security lists "Signed in from a new device" and "Authenticator code confirmed" among recent security activity. -- The super-admin panel opens with email and password; there is no separate authenticator step-up. The super admin's password is set only from the server (`scripts/vps-recover.sh` or `docker/vps/recover-super-admin.mjs`); the app refuses its sign-up, forgot-password and password changes. The sign-in protections above still apply to it. +- The super-admin panel opens with email and password only; there is no separate authenticator step-up. Google sign-in is refused for `SUPER_ADMIN_EMAIL` on the web and in the app, including through a Google account linked to that user earlier, and `requireSuperAdmin` (with the guide's super-admin sections) accepts only a session whose `authMethod` is `credentials`: a Google, app or older session never opens it. The super admin's password is set only from the server (`scripts/vps-recover.sh` or `docker/vps/recover-super-admin.mjs`); the app refuses its sign-up, forgot-password and password changes. The sign-in protections above still apply to it. - Google OAuth security, including MFA, is governed by the linked Google account; organisation staff still confirm an authenticator code on `/verify-mfa`. Linking to a password account requires that account’s email to be verified first; an unverified account must use inbox password recovery before linking. New Google links retain only provider identity fields, not reusable access/refresh/ID tokens. Previously stored provider tokens are not retroactively purged. - Super-admins can disable new password and Google account creation; existing users can continue to sign in. - External applications use high-entropy, tenant-bound API credentials under `/api/v1`; raw secrets are displayed once and only SHA-256 digests are stored. @@ -124,7 +125,7 @@ This page documents implemented controls and security boundaries. It is not a su ## Abuse protection -- Password login applies account and IP limits inside the credential verifier, in addition to proxy limits. Missing and OAuth-only identities perform dummy bcrypt work; signup returns the same response for existing and newly created accounts. Auth.js errors are logged without exception payloads. +- Password login applies account and IP limits inside the credential verifier, in addition to proxy limits. Missing and OAuth-only identities perform dummy bcrypt work; signup spends its creation limit (per address and IP) before any lookup or hashing, and returns the same response for existing, newly created and rate-limited attempts; a limited attempt creates and sends nothing. Auth.js errors are logged without exception payloads. - Redis-backed limits cover organisation webhook changes (`webhookManage`, 30 per minute) and test sends/redeliveries (`webhookTest`, 20 per hour), APIs, authentication mutations, RSVP, feedback, search/discovery, uploads (event page images, 60 an hour, apart from the 12-an-hour budget for every other upload), Server Actions, object creation, invitations, broadcasts, attendance confirmation sends and reminders (`attendanceCheckSend`, 10 per hour, shared by both), shared attendance links (`attendanceCheckLink`, 60 per 10 minutes) and replies (`attendanceCheckReply`, 10 per minute per link and per address), check-in, personal data exports (per user), public ticket status and event streams (per IP), and short-link lookups (per IP). Bucket sizes live in `lib/rateLimit.ts`. - Machine API traffic has an additional per-client limit so credential rotation cannot multiply request capacity. - Security-sensitive production traffic fails closed when Redis is unavailable. Every Redis command on a request path (rate limits, realtime check-in) has a 500 ms deadline, and while the client is reconnecting the limiter answers at once instead of waiting on the backoff (`lib/redisOptions.ts`), so an outage refuses quickly rather than stalling requests. @@ -135,6 +136,7 @@ This page documents implemented controls and security boundaries. It is not a su ## Data privacy +- The feedback form (web page, `POST /api/feedback`, and the app's form) follows the event page's access rule (`resolvePublicEventAccess`) on top of requiring a published event: nothing while the event's website is unreleased, and for an invite-only event only a signed-in guest whose verified email is invited. Submission re-reads privacy, release and the invitation inside its locked transaction. A certificate email is matched to a registration, never treated as sign-in. The app signs in to the form and to `POST /api/feedback` with its bearer; a bearer that does not sign in is refused. - Certificate-disabled feedback stores answers without email or RSVP identity and allows repeat anonymous submissions. - Certificate-enabled feedback requires an email matching a confirmed RSVP, links the response for certificate generation, and allows repeat submissions. - Feedback mode, eligibility, typed answers, and response creation are one locked transaction. PostgreSQL forbids partial identity/certificate linkage, while immutable question snapshots keep historical answers meaningful after form maintenance. diff --git a/lib/auth.ts b/lib/auth.ts index b0b379c7..776aaf0a 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,5 +1,6 @@ import "server-only"; +import { randomBytes } from "node:crypto"; import NextAuth, { CredentialsSignin } from "next-auth"; import { authAdapter } from "@/lib/authAdapter"; import Google from "next-auth/providers/google"; @@ -13,8 +14,11 @@ import { getGoogleSsoSettings } from "@/lib/instanceSettings"; import { logAuthError } from "@/lib/authLogger"; import { actorSession } from "@/lib/actorSession"; import { currentRequestActor } from "@/lib/requestActor"; +import { SESSION_COOKIE_NAME } from "@/lib/sessionSid"; import type { Session } from "next-auth"; +type AuthMethod = Session["authMethod"]; + /** * This code is returned only after the supplied password is valid. It lets the * sign-in screen show the second factor as a distinct step without exposing @@ -49,7 +53,7 @@ const nextAuth = NextAuth(async () => { // browsers. A versioned name retires that incompatible token cleanly rather // than asking Auth.js to decode it on every request. cookies: { - sessionToken: { name: "swe.session-token.v1" }, + sessionToken: { name: SESSION_COOKIE_NAME }, }, trustHost: true, logger: { @@ -102,7 +106,7 @@ const nextAuth = NextAuth(async () => { // Returning true lets Auth.js create or link the adapter user after this callback. return decision === "account_creation_disabled" ? "/login?error=account_creation_disabled" : true; }, - async jwt({ token, user }) { + async jwt({ token, user, account }) { const userId = user?.id ?? token.sub; if (!userId) return null; @@ -112,7 +116,7 @@ const nextAuth = NextAuth(async () => { }); if (!currentUser) return null; - const sessionToken = token as typeof token & { authenticatedAt?: number; sessionVersion?: number; sessionRevoked?: boolean; mfaVerified?: boolean }; + const sessionToken = token as typeof token & { authenticatedAt?: number; sessionVersion?: number; sessionRevoked?: boolean; mfaVerified?: boolean; sid?: string; authMethod?: AuthMethod }; if (user) { // Bind credentials to the version checked with the password. A reset // racing token issuance must not mint a fresh session from old proof. @@ -124,19 +128,28 @@ const nextAuth = NextAuth(async () => { sessionToken.sessionRevoked = false; // Set only by a password sign-in that included an authenticator code. sessionToken.mfaVerified = (user as typeof user & { mfaVerified?: boolean }).mfaVerified === true; - } else if (sessionToken.sessionRevoked || sessionToken.sessionVersion !== currentUser.sessionVersion) { + // How this sign-in happened; the super admin gate accepts only a password. + sessionToken.authMethod = account?.provider === "google" ? "google" : account?.provider === "credentials" ? "credentials" : undefined; + // This sign-in's id, fixed for the session's life: an MFA proof is + // bound to it (lib/mfaProof.ts), so a new sign-in needs a new proof. + // Read on the server only (lib/sessionSid.ts), never via the session. + sessionToken.sid = randomBytes(16).toString("base64url"); + } else if (sessionToken.sessionRevoked || sessionToken.sessionVersion !== currentUser.sessionVersion || !sessionToken.sid) { // Auth.js clears the cookie and returns a genuinely anonymous session. + // A token from before sign-in ids existed is retired the same way. return null; } return token; }, session({ session, token }) { - const sessionToken = token as typeof token & { authenticatedAt?: number; sessionRevoked?: boolean; mfaVerified?: boolean }; + const sessionToken = token as typeof token & { authenticatedAt?: number; sessionRevoked?: boolean; mfaVerified?: boolean; authMethod?: AuthMethod }; if (session.user && token.sub && !sessionToken.sessionRevoked) session.user.id = token.sub; if (sessionToken.sessionRevoked && session.user) session.user.id = ""; (session as typeof session & { authenticatedAt?: number }).authenticatedAt = sessionToken.authenticatedAt; (session as typeof session & { mfaVerified?: boolean }).mfaVerified = sessionToken.mfaVerified === true; + // The sid stays in the token: this object is what /api/auth/session returns. + session.authMethod = sessionToken.authMethod; return session; }, }, diff --git a/lib/googleSignIn.ts b/lib/googleSignIn.ts index b1b995ca..ee62db07 100644 --- a/lib/googleSignIn.ts +++ b/lib/googleSignIn.ts @@ -3,11 +3,13 @@ import "server-only"; import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"; import { prisma } from "@/lib/db"; import { getGoogleSsoSettings, getMobileGoogleClientIds, isNewUserRegistrationEnabled } from "@/lib/instanceSettings"; +import { isSuperAdminEmail } from "@/lib/superAdminEmail"; /* * Google sign-in policy shared by the web Auth.js callback and the mobile - * ID-token exchange: verified email only, never link to an unverified - * password account, and create new accounts only while the instance allows it. + * ID-token exchange: verified email only, never the super admin, never link + * to an unverified password account, and create new accounts only while the + * instance allows it. */ export type GoogleSignInDecision = "allow" | "deny" | "account_creation_disabled"; @@ -20,6 +22,8 @@ export async function evaluateGoogleSignIn(params: { if (!params.emailVerified) return "deny"; const email = params.email?.trim().toLowerCase(); if (!email) return "deny"; + // The super admin signs in with the server-set password only, never Google. + if (isSuperAdminEmail(email)) return "deny"; const existing = await prisma.user.findUnique({ where: { email }, select: { passwordHash: true, emailVerified: true }, }); @@ -29,8 +33,11 @@ export async function evaluateGoogleSignIn(params: { const existingAccount = await prisma.account.findUnique({ where: { provider_providerAccountId: { provider: "google", providerAccountId: params.providerAccountId } }, - select: { id: true }, + select: { id: true, user: { select: { email: true } } }, }); + // A Google account linked earlier signs in as its local user, whatever + // address Google now reports: refuse it when that user is the super admin. + if (existingAccount && isSuperAdminEmail(existingAccount.user.email)) return "deny"; if (existingAccount || existing) return "allow"; // A verified Google identity may create an account only while the diff --git a/lib/mfaProof.ts b/lib/mfaProof.ts index e21acfbe..49f83ff9 100644 --- a/lib/mfaProof.ts +++ b/lib/mfaProof.ts @@ -4,8 +4,10 @@ import crypto from "crypto"; /** * Proof that this session passed an authenticator check after a sign-in that - * had none (Google). A signed HttpOnly cookie bound to the user and session - * version, so signing out everywhere or a password reset ends it at once. + * had none (Google). A signed HttpOnly cookie bound to the user, the session + * version and the sign-in id (`sid`, lib/sessionSid.ts): signing out + * everywhere or a password reset ends it at once, and a new sign-in in the + * same browser needs a new check. */ export const MFA_PROOF_COOKIE = "swe.mfa-proof.v1"; export const MFA_PROOF_MAX_AGE_SECONDS = 12 * 60 * 60; @@ -13,6 +15,7 @@ export const MFA_PROOF_MAX_AGE_SECONDS = 12 * 60 * 60; type MfaProofPayload = { userId: string; sessionVersion: number; + sid: string; expiresAt: number; }; @@ -26,10 +29,11 @@ function signature(payload: string) { return crypto.createHmac("sha256", signingKey()).update(`mfa-proof:${payload}`).digest("base64url"); } -export function createMfaProof(userId: string, sessionVersion: number, now = Date.now()) { +export function createMfaProof(userId: string, sessionVersion: number, sid: string, now = Date.now()) { const payload = Buffer.from(JSON.stringify({ userId, sessionVersion, + sid, expiresAt: now + MFA_PROOF_MAX_AGE_SECONDS * 1000, } satisfies MfaProofPayload)).toString("base64url"); return `${payload}.${signature(payload)}`; @@ -39,9 +43,10 @@ export function hasValidMfaProof( value: string | undefined, userId: string, sessionVersion: number, + sid: string | null, now = Date.now(), ) { - if (!value) return false; + if (!value || !sid) return false; const [payload, providedSignature, ...extra] = value.split("."); if (!payload || !providedSignature || extra.length > 0) return false; const expectedSignature = signature(payload); @@ -51,7 +56,7 @@ export function hasValidMfaProof( try { const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as MfaProofPayload; - return decoded.userId === userId && decoded.sessionVersion === sessionVersion && Number.isSafeInteger(decoded.expiresAt) && decoded.expiresAt > now; + return decoded.userId === userId && decoded.sessionVersion === sessionVersion && decoded.sid === sid && Number.isSafeInteger(decoded.expiresAt) && decoded.expiresAt > now; } catch { return false; } diff --git a/lib/permissions.ts b/lib/permissions.ts index 8c4c33a3..36bc161f 100644 --- a/lib/permissions.ts +++ b/lib/permissions.ts @@ -39,13 +39,16 @@ function normalizeEmail(email: string): string { /** - * The user's email when they are the configured, verified super admin, else - * null. Pages and actions use `requireSuperAdminMfa`; this is for readers that - * only need to know (the guide's section list, lib/views/app__help__layout.ts). + * The user's email when they are the configured, verified super admin signed + * in with the password (`session.authMethod`), else null. A Google sign-in, an + * app session or a token from before sign-in methods were recorded never + * counts. Pages and actions use `requireSuperAdminMfa`; this is for readers + * that only need to know (the guide's section list, lib/views/app__help__layout.ts). */ -export async function superAdminEmailFor(userId: string): Promise { +export async function superAdminEmailFor(session: Pick): Promise { const configured = process.env.SUPER_ADMIN_EMAIL; - if (!configured) return null; + if (!configured || session.authMethod !== "credentials") return null; + const userId = session.user.id; const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, emailVerified: true }, @@ -68,7 +71,7 @@ export async function requireSuperAdmin(): Promise< const session = await auth(); if (!session?.user?.id) redirect("/login"); - const userEmail = await superAdminEmailFor(session.user.id); + const userEmail = await superAdminEmailFor(session); if (!userEmail) notFound(); return { diff --git a/lib/sessionSid.ts b/lib/sessionSid.ts new file mode 100644 index 00000000..b9da94a3 --- /dev/null +++ b/lib/sessionSid.ts @@ -0,0 +1,43 @@ +import "server-only"; + +import { cookies } from "next/headers"; +import { decode } from "next-auth/jwt"; + +/** The Auth.js session cookie (lib/auth.ts); also the salt its JWT is encrypted with. */ +export const SESSION_COOKIE_NAME = "swe.session-token.v1"; + +/** + * The session token from the request's cookies. Auth.js splits a large token + * across `.0`, `.1`, …; the parts are joined in index order, as + * Auth.js's own session store does. + */ +export function sessionTokenFromCookies(all: Array<{ name: string; value: string }>): string | null { + const parts: Array<{ index: number; value: string }> = []; + for (const { name, value } of all) { + if (!value) continue; + if (name === SESSION_COOKIE_NAME) parts.push({ index: 0, value }); + const chunk = name.startsWith(`${SESSION_COOKIE_NAME}.`) ? name.slice(SESSION_COOKIE_NAME.length + 1) : ""; + if (/^\d+$/.test(chunk)) parts.push({ index: Number(chunk), value }); + } + return parts.length > 0 ? parts.sort((a, b) => a.index - b.index).map((part) => part.value).join("") : null; +} + +/** + * The sign-in id (`sid`) of this browser's session, read on the server only. + * It is never put on the session object, so `/api/auth/session` does not + * return it. The cookie is decoded with the secret and salt Auth.js uses, and + * the sid counts only when the token belongs to `userId`, the user `auth()` + * already validated for this request. Null when there is no such session (a + * mobile request has no session cookie). + */ +export async function currentSessionSid(userId: string): Promise { + const token = sessionTokenFromCookies((await cookies()).getAll()); + const secret = process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET; + if (!token || !secret) return null; + try { + const payload = await decode({ token, secret, salt: SESSION_COOKIE_NAME }); + return payload?.sub === userId && typeof payload.sid === "string" && payload.sid ? payload.sid : null; + } catch { + return null; + } +} diff --git a/lib/staffMfa.ts b/lib/staffMfa.ts index eded616f..c2fded26 100644 --- a/lib/staffMfa.ts +++ b/lib/staffMfa.ts @@ -9,6 +9,7 @@ import { hasValidMfaProof, MFA_PROOF_COOKIE } from "@/lib/mfaProof"; import { mobileMfaFresh } from "@/lib/staffMfaFresh"; import { currentRequestActor } from "@/lib/requestActor"; import { isSuperAdminEmail } from "@/lib/superAdminEmail"; +import { currentSessionSid } from "@/lib/sessionSid"; /** * Organisation staff (anyone with a membership, or an event or series @@ -72,7 +73,8 @@ export const staffMfaState = cache(async (userId: string): Promise { const session = await auth(); const userId = session?.user?.id; - if (!userId) return ["public"]; - if (await superAdminEmailFor(userId)) return ["public", "org-admin", "super-admin"]; + if (!session || !userId) return ["public"]; + if (await superAdminEmailFor(session)) return ["public", "org-admin", "super-admin"]; const adminMembership = await prisma.membership.findFirst({ where: { userId, role: { in: ["OWNER", "ADMIN"] } }, select: { id: true }, diff --git a/tests/integration/auth-audit.db.test.ts b/tests/integration/auth-audit.db.test.ts new file mode 100644 index 00000000..ac2d4967 --- /dev/null +++ b/tests/integration/auth-audit.db.test.ts @@ -0,0 +1,80 @@ +import { randomUUID } from "node:crypto"; +import { MembershipRole } from "@prisma/client"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const session = vi.hoisted(() => ({ value: null as null | { user: { id: string; }; mfaVerified?: boolean; authMethod?: "credentials" | "google"; } })); +vi.mock("@/lib/auth", () => ({ auth: async () => session.value })); +vi.mock("next/headers", () => ({ + headers: async () => new Headers({ "x-real-ip": "198.51.100.30" }), + cookies: async () => ({ get: () => undefined, getAll: () => [] }), +})); +vi.mock("next/navigation", () => ({ notFound: () => { throw new Error("not-found"); }, redirect: (path: string) => { throw new Error(`redirect:${path}`); } })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { updateOrganisation } from "@/app/actions/org"; +import { evaluateGoogleSignIn } from "@/lib/googleSignIn"; +import { requireSuperAdmin } from "@/lib/permissions"; + +const suffix = randomUUID().replace(/-/g, "").slice(0, 12); +const email = (name: string) => `${name}-${suffix}@example.test`; +const users: Record = {}; +let organisationId = ""; +const orgSlug = `audit-auth-${suffix}`; + +beforeAll(async () => { + for (const name of ["admin", "root", "person"]) { + users[name] = (await prisma.user.create({ data: { name, email: email(name), emailVerified: new Date() } })).id; + } + organisationId = (await prisma.organisation.create({ data: { name: "Audit Makers", slug: orgSlug } })).id; + await prisma.membership.create({ data: { userId: users.admin!, organisationId, role: MembershipRole.ADMIN } }); + // The super admin once linked a Google account, before Google was refused for them. + await prisma.account.create({ data: { userId: users.root!, type: "oidc", provider: "google", providerAccountId: `google-root-${suffix}` } }); +}); + +beforeEach(() => { + session.value = null; + vi.stubEnv("SUPER_ADMIN_EMAIL", email("root")); +}); + +afterAll(async () => { + vi.unstubAllEnvs(); + await prisma.organisation.deleteMany({ where: { id: organisationId } }); + await prisma.user.deleteMany({ where: { id: { in: Object.values(users) } } }); + await prisma.$disconnect(); +}); + +describe("organisation settings and the staff authenticator rule", () => { + beforeAll(() => vi.stubEnv("STAFF_MFA_REQUIRED", "true")); + afterAll(() => vi.stubEnv("STAFF_MFA_REQUIRED", "false")); + + it("refuses an admin whose session has not passed the authenticator check", async () => { + await prisma.user.update({ where: { id: users.admin! }, data: { mfaEnabledAt: new Date() } }); + const edit = () => updateOrganisation({ organisationSlug: orgSlug, name: "Renamed Makers", description: "", logoUrl: "" }); + + session.value = { user: { id: users.admin! } }; + await expect(edit()).resolves.toEqual({ ok: false, error: "Only organisation admins can edit settings." }); + await expect(prisma.organisation.findUniqueOrThrow({ where: { id: organisationId } })).resolves.toMatchObject({ name: "Audit Makers" }); + + session.value = { user: { id: users.admin! }, mfaVerified: true }; + await expect(edit()).resolves.toEqual({ ok: true }); + await expect(prisma.organisation.findUniqueOrThrow({ where: { id: organisationId } })).resolves.toMatchObject({ name: "Renamed Makers" }); + }); +}); + +describe("the super admin and Google", () => { + it("denies Google for the super admin's address and for a Google account already linked to them", async () => { + await expect(evaluateGoogleSignIn({ email: email("ROOT"), emailVerified: true, providerAccountId: `google-new-${suffix}` })).resolves.toBe("deny"); + await expect(evaluateGoogleSignIn({ email: email("root-alias"), emailVerified: true, providerAccountId: `google-root-${suffix}` })).resolves.toBe("deny"); + await expect(evaluateGoogleSignIn({ email: email("person"), emailVerified: true, providerAccountId: `google-person-${suffix}` })).resolves.toBe("allow"); + }); + + it("opens the super admin area only to a password sign-in", async () => { + session.value = { user: { id: users.root! }, authMethod: "credentials" }; + await expect(requireSuperAdmin()).resolves.toMatchObject({ user: { id: users.root!, email: email("root") } }); + for (const authMethod of ["google", undefined] as const) { + session.value = { user: { id: users.root! }, authMethod }; + await expect(requireSuperAdmin()).rejects.toThrow("not-found"); + } + }); +}); diff --git a/tests/integration/login-security.db.test.ts b/tests/integration/login-security.db.test.ts index 874d984d..73c69acc 100644 --- a/tests/integration/login-security.db.test.ts +++ b/tests/integration/login-security.db.test.ts @@ -2,14 +2,18 @@ import { randomUUID } from "node:crypto"; import { MembershipRole } from "@prisma/client"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const session = vi.hoisted(() => ({ value: null as null | { user: { id: string; }; mfaVerified?: boolean; }, proof: undefined as string | undefined })); +const session = vi.hoisted(() => ({ value: null as null | { user: { id: string; }; mfaVerified?: boolean; }, proof: undefined as string | undefined, token: undefined as string | undefined })); vi.mock("@/lib/auth", () => ({ auth: async () => session.value })); -vi.mock("next/headers", () => ({ cookies: async () => ({ get: () => (session.proof ? { value: session.proof } : undefined) }) })); +vi.mock("next/headers", () => ({ cookies: async () => ({ + get: () => (session.proof ? { value: session.proof } : undefined), + getAll: () => (session.token ? [{ name: "swe.session-token.v1", value: session.token }] : []), +}) })); import { prisma } from "@/lib/db"; import { accountUnderAttack, consumeSignInCode, deviceHash, isAddressBlocked, knownDevice, noteFailedSignIn, rememberSignIn, sendSignInCode, } from "@/lib/loginSecurity"; +import { encode } from "next-auth/jwt"; import { createMfaProof } from "@/lib/mfaProof"; import { getMembership } from "@/lib/permissions"; import { staffMfaState } from "@/lib/staffMfa"; @@ -31,6 +35,7 @@ beforeAll(async () => { beforeEach(() => { session.value = null; session.proof = undefined; + session.token = undefined; }); afterAll(async () => { @@ -99,11 +104,23 @@ describe("staff authenticator rule", () => { session.value = { user: { id: users.member! }, mfaVerified: true }; expect(await getMembership(users.member!, organisationId)).toMatchObject({ role: "ADMIN" }); - // A Google sign-in confirms with a code afterwards: the proof cookie. + // A Google sign-in confirms with a code afterwards: the proof cookie, + // bound to this browser's sign-in id inside the session cookie. const { sessionVersion } = await prisma.user.findUniqueOrThrow({ where: { id: users.member! }, select: { sessionVersion: true } }); + const sessionCookie = (sid: string) => encode({ token: { sub: users.member!, sid }, secret: "integration-auth-secret-with-at-least-32-characters", salt: "swe.session-token.v1" }); session.value = { user: { id: users.member! } }; - session.proof = createMfaProof(users.member!, sessionVersion); + session.token = await sessionCookie("sign-in-a"); + session.proof = createMfaProof(users.member!, sessionVersion, "sign-in-a"); expect(await staffMfaState(users.member!)).toBe("ok"); + expect(await getMembership(users.member!, organisationId)).toMatchObject({ role: "ADMIN" }); + + // A new sign-in in the same browser leaves the old proof behind. + session.token = await sessionCookie("sign-in-b"); + expect(await staffMfaState(users.member!)).toBe("verify"); + expect(await getMembership(users.member!, organisationId)).toBeNull(); + // So does a session cookie without a sign-in id (or none at all). + session.token = await encode({ token: { sub: users.member! }, secret: "integration-auth-secret-with-at-least-32-characters", salt: "swe.session-token.v1" }); + expect(await staffMfaState(users.member!)).toBe("verify"); }); it("leaves attendees, other people's checks and the super admin alone", async () => { diff --git a/tests/unit/auth-session-sid.test.ts b/tests/unit/auth-session-sid.test.ts new file mode 100644 index 00000000..3dfc9061 --- /dev/null +++ b/tests/unit/auth-session-sid.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { encode } from "next-auth/jwt"; +import type { NextAuthConfig } from "next-auth"; + +/* + * The MFA proof's binding to a sign-in, through real Auth.js session tokens: + * the jwt callback mints the sign-in id, the token is encrypted as Auth.js + * writes the cookie (whole or in chunks), and lib/sessionSid.ts reads it back. + */ +const mocks = vi.hoisted(() => ({ config: null as unknown, find: vi.fn(), jar: [] as Array<{ name: string; value: string }> })); +vi.mock("next-auth", () => ({ + default: (factory: unknown) => { mocks.config = factory; return {}; }, + CredentialsSignin: class extends Error {}, +})); +vi.mock("next-auth/providers/credentials", () => ({ default: (options: unknown) => options })); +vi.mock("@/lib/authAdapter", () => ({ authAdapter: {} })); +vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: mocks.find } } })); +vi.mock("@/lib/instanceSettings", () => ({ getGoogleSsoSettings: async () => null, isNewUserRegistrationEnabled: vi.fn() })); +vi.mock("next/headers", () => ({ cookies: async () => ({ getAll: () => mocks.jar }) })); + +import "@/lib/auth"; +import { createMfaProof, hasValidMfaProof } from "@/lib/mfaProof"; +import { currentSessionSid, SESSION_COOKIE_NAME, sessionTokenFromCookies } from "@/lib/sessionSid"; + +const SECRET = "unit-test-auth-secret-with-at-least-32-characters"; +let config: NextAuthConfig; +const jwt = (args: unknown) => config.callbacks!.jwt!(args as Parameters["jwt"]>>[0]) as Promise | null>; + +/** Store a token in the jar the way Auth.js writes the session cookie. */ +async function setSessionCookie(token: Record, chunks = 1) { + const value = await encode({ token, secret: SECRET, salt: SESSION_COOKIE_NAME }); + if (chunks === 1) { mocks.jar = [{ name: SESSION_COOKIE_NAME, value }]; return; } + const size = Math.ceil(value.length / chunks); + // Out of order on purpose: the reader sorts by chunk index. + mocks.jar = Array.from({ length: chunks }, (_, index) => ({ name: `${SESSION_COOKIE_NAME}.${index}`, value: value.slice(index * size, (index + 1) * size) })).reverse(); +} + +beforeEach(async () => { + vi.stubEnv("AUTH_SECRET", SECRET); + config = await (mocks.config as () => Promise)(); + mocks.find.mockResolvedValue({ sessionVersion: 2 }); + mocks.jar = []; +}); + +describe("sign-in id cookie flow", () => { + it("keeps one sid across refreshes, and a new sign-in voids the old proof", async () => { + const signedIn = (await jwt({ token: {}, user: { id: "user_1" }, account: { provider: "google" } }))!; + await setSessionCookie(signedIn, 3); + const sid = await currentSessionSid("user_1"); + expect(sid).toBe(signedIn.sid); + const proof = createMfaProof("user_1", 2, sid!); + + // Ordinary reads re-run the callback on the decoded token; the sid never changes. + for (let read = 0; read < 3; read += 1) { + const refreshed = (await jwt({ token: { ...signedIn } }))!; + await setSessionCookie(refreshed); + expect(hasValidMfaProof(proof, "user_1", 2, await currentSessionSid("user_1"))).toBe(true); + } + + const again = (await jwt({ token: {}, user: { id: "user_1" }, account: { provider: "google" } }))!; + await setSessionCookie(again, 2); + expect(await currentSessionSid("user_1")).toBe(again.sid); + expect(hasValidMfaProof(proof, "user_1", 2, await currentSessionSid("user_1"))).toBe(false); + }); + + it("binds the sid to the validated user, and ignores tokens it cannot decode", async () => { + await setSessionCookie({ sub: "user_2", sid: "someone-else" }); + expect(await currentSessionSid("user_1")).toBeNull(); + mocks.jar = [{ name: SESSION_COOKIE_NAME, value: "not-a-token" }]; + expect(await currentSessionSid("user_1")).toBeNull(); + await setSessionCookie({ sub: "user_1" }); + expect(await currentSessionSid("user_1")).toBeNull(); + }); + + it("retires a legacy session cookie without a sid", async () => { + expect(await jwt({ token: { sub: "user_1", sessionVersion: 2 } })).toBeNull(); + }); + + it("reads only the session cookie and its numbered chunks", () => { + expect(sessionTokenFromCookies([ + { name: `${SESSION_COOKIE_NAME}.1`, value: "b" }, + { name: "other", value: "x" }, + { name: `${SESSION_COOKIE_NAME}.0`, value: "a" }, + { name: `${SESSION_COOKIE_NAME}.x`, value: "y" }, + { name: `${SESSION_COOKIE_NAME}-other`, value: "z" }, + ])).toBe("ab"); + expect(sessionTokenFromCookies([])).toBeNull(); + }); +}); diff --git a/tests/unit/auth-session.test.ts b/tests/unit/auth-session.test.ts index 341e59bc..497e5b96 100644 --- a/tests/unit/auth-session.test.ts +++ b/tests/unit/auth-session.test.ts @@ -72,9 +72,25 @@ describe("authentication boundary", () => { expect(await jwt({ token: { sub: "user_1", ...token } })).toBeNull(); }); it("does not let session updates renew authentication freshness", async () => { - const token = { sub: "user_1", sessionVersion: 2, authenticatedAt: 123 }; + const token = { sub: "user_1", sessionVersion: 2, authenticatedAt: 123, sid: "sid-a" }; expect(await jwt({ token, trigger: "update", session: { authenticatedAt: Date.now(), sessionVersion: 99 } })).toEqual(token); }); + it("retires a token from before sign-in ids, like a revoked one", async () => { + expect(await jwt({ token: { sub: "user_1", sessionVersion: 2, authenticatedAt: 123 } })).toBeNull(); + }); + it("records the sign-in method and a fresh sign-in id, and keeps both on refresh", async () => { + const first = await jwt({ token: {}, user: { id: "user_1", sessionVersion: 2 }, account: { provider: "credentials" } }) as Record; + expect(first).toMatchObject({ authMethod: "credentials", sid: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/) }); + expect(await jwt({ token: { ...first } })).toMatchObject({ sid: first.sid, authMethod: "credentials" }); + const google = await jwt({ token: {}, user: { id: "user_1" }, account: { provider: "google" } }) as Record; + expect(google.authMethod).toBe("google"); + expect(google.sid).not.toBe(first.sid); + }); + it("puts the sign-in method on the session but never the sign-in id", async () => { + const session = await config.callbacks!.session!({ session: { user: {}, expires: "" }, token: { sub: "user_1", sid: "sid-a", authMethod: "credentials" } } as never) as unknown as Record; + expect(session).toMatchObject({ user: { id: "user_1" }, authMethod: "credentials" }); + expect(JSON.stringify(session)).not.toContain("sid-a"); + }); it("clears deleted users", async () => { mocks.find.mockResolvedValue(null); expect(await jwt({ token: { sub: "user_1", sessionVersion: 2 } })).toBeNull(); @@ -85,6 +101,18 @@ describe("authentication boundary", () => { expect(await callback({ user: { email: "person@example.test" }, account: { provider: "google" }, profile: { email_verified: true } } as Parameters[0])).toBe(false); }); + it("refuses Google for the super admin's address, and for a Google account linked to the super admin", async () => { + vi.stubEnv("SUPER_ADMIN_EMAIL", "admin@example.test"); + const callback = config.callbacks!.signIn!; + const attempt = (email: string) => callback({ user: { email }, account: { provider: "google", providerAccountId: "google-user" }, profile: { email_verified: true } } as Parameters[0]); + await expect(attempt("ADMIN@example.test")).resolves.toBe(false); + mocks.accountFind.mockResolvedValue({ id: "account_1", user: { email: "admin@example.test" } }); + await expect(attempt("renamed-in-google@example.test")).resolves.toBe(false); + mocks.accountFind.mockResolvedValue({ id: "account_1", user: { email: "person@example.test" } }); + await expect(attempt("person@example.test")).resolves.toBe(true); + vi.unstubAllEnvs(); + }); + it("creates an account for a verified new Google identity when registration is enabled", async () => { mocks.find.mockResolvedValue(null); const callback = config.callbacks!.signIn!; diff --git a/tests/unit/help-access.test.ts b/tests/unit/help-access.test.ts index 41881176..2c437b2d 100644 --- a/tests/unit/help-access.test.ts +++ b/tests/unit/help-access.test.ts @@ -50,7 +50,7 @@ describe("guide access", () => { }); it("opens everything to the verified super admin, and nothing extra when unverified", async () => { - mocks.session.mockResolvedValue({ user: { id: "root" } }); + mocks.session.mockResolvedValue({ user: { id: "root" }, authMethod: "credentials" }); mocks.findUser.mockResolvedValue({ email: "Root@Example.org", emailVerified: new Date() }); expect((await loadHelpAccess()).sort()).toEqual(["org-admin", "public", "super-admin"]); @@ -58,9 +58,15 @@ describe("guide access", () => { expect(await loadHelpAccess()).toEqual(["public"]); }); + it("opens nothing extra to the super admin's account signed in without the password", async () => { + mocks.session.mockResolvedValue({ user: { id: "root" }, authMethod: "google" }); + mocks.findUser.mockResolvedValue({ email: "root@example.org", emailVerified: new Date() }); + expect(await loadHelpAccess()).toEqual(["public"]); + }); + it("opens nothing extra when no super admin is configured", async () => { vi.stubEnv("SUPER_ADMIN_EMAIL", ""); - mocks.session.mockResolvedValue({ user: { id: "root" } }); + mocks.session.mockResolvedValue({ user: { id: "root" }, authMethod: "credentials" }); mocks.findUser.mockResolvedValue({ email: "root@example.org", emailVerified: new Date() }); expect(await loadHelpAccess()).toEqual(["public"]); }); diff --git a/tests/unit/mfa-proof.test.ts b/tests/unit/mfa-proof.test.ts index 27d45309..59acfad2 100644 --- a/tests/unit/mfa-proof.test.ts +++ b/tests/unit/mfa-proof.test.ts @@ -6,18 +6,26 @@ describe("MFA step-up proof", () => { process.env.AUTH_SECRET = "unit-test-auth-secret-with-at-least-32-characters"; }); - it("accepts a proof only for the verified user and session version", () => { + it("accepts a proof only for the verified user, session version and sign-in", () => { const now = 1_000_000; - const proof = createMfaProof("user_1", 3, now); - expect(hasValidMfaProof(proof, "user_1", 3, now + 1)).toBe(true); - expect(hasValidMfaProof(proof, "user_2", 3, now + 1)).toBe(false); - expect(hasValidMfaProof(proof, "user_1", 4, now + 1)).toBe(false); + const proof = createMfaProof("user_1", 3, "sid-a", now); + expect(hasValidMfaProof(proof, "user_1", 3, "sid-a", now + 1)).toBe(true); + expect(hasValidMfaProof(proof, "user_2", 3, "sid-a", now + 1)).toBe(false); + expect(hasValidMfaProof(proof, "user_1", 4, "sid-a", now + 1)).toBe(false); + }); + + it("is void for another sign-in, or a session without a sign-in id", () => { + const now = 1_000_000; + const proof = createMfaProof("user_1", 3, "sid-a", now); + expect(hasValidMfaProof(proof, "user_1", 3, "sid-b", now + 1)).toBe(false); + expect(hasValidMfaProof(proof, "user_1", 3, null, now + 1)).toBe(false); + expect(hasValidMfaProof(proof, "user_1", 3, "", now + 1)).toBe(false); }); it("rejects tampered and expired proofs", () => { const now = 1_000_000; - const proof = createMfaProof("user_1", 3, now); - expect(hasValidMfaProof(`${proof}x`, "user_1", 3, now + 1)).toBe(false); - expect(hasValidMfaProof(proof, "user_1", 3, now + MFA_PROOF_MAX_AGE_SECONDS * 1000)).toBe(false); + const proof = createMfaProof("user_1", 3, "sid-a", now); + expect(hasValidMfaProof(`${proof}x`, "user_1", 3, "sid-a", now + 1)).toBe(false); + expect(hasValidMfaProof(proof, "user_1", 3, "sid-a", now + MFA_PROOF_MAX_AGE_SECONDS * 1000)).toBe(false); }); }); diff --git a/tests/unit/mfa-step-up-action.test.ts b/tests/unit/mfa-step-up-action.test.ts index cc4a2ed0..e385c691 100644 --- a/tests/unit/mfa-step-up-action.test.ts +++ b/tests/unit/mfa-step-up-action.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ verify: vi.fn(), createProof: vi.fn(), cookieSet: vi.fn(), + sid: vi.fn(), })); vi.mock("@/lib/permissions", () => ({ requireAuth: mocks.requireAuth })); @@ -27,6 +28,7 @@ vi.mock("@/lib/mfaProof", () => ({ MFA_PROOF_MAX_AGE_SECONDS: 43200, })); vi.mock("next/headers", () => ({ cookies: vi.fn().mockResolvedValue({ set: mocks.cookieSet }) })); +vi.mock("@/lib/sessionSid", () => ({ currentSessionSid: mocks.sid })); import { verifyStepUpMfa } from "@/app/actions/mfa-step-up"; @@ -43,6 +45,7 @@ beforeEach(() => { mocks.decrypt.mockReturnValue("SECRET"); mocks.verify.mockReturnValue(true); mocks.createProof.mockReturnValue("signed-proof"); + mocks.sid.mockResolvedValue("sid-a"); }); describe("MFA step-up action", () => { @@ -65,5 +68,14 @@ describe("MFA step-up action", () => { path: "/", })); expect(mocks.auditCreate).toHaveBeenCalledOnce(); + expect(mocks.sid).toHaveBeenCalledWith("admin_1"); + expect(mocks.createProof).toHaveBeenCalledWith("admin_1", 4, "sid-a"); + }); + + it("issues no proof for a session without a sign-in id, before spending the code", async () => { + mocks.sid.mockResolvedValue(null); + await expect(verifyStepUpMfa({ code: "123456" })).resolves.toMatchObject({ ok: false }); + expect(mocks.verify).not.toHaveBeenCalled(); + expect(mocks.cookieSet).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/super-admin-permissions.test.ts b/tests/unit/super-admin-permissions.test.ts index f0d1433d..5f873a51 100644 --- a/tests/unit/super-admin-permissions.test.ts +++ b/tests/unit/super-admin-permissions.test.ts @@ -9,14 +9,14 @@ vi.mock("next/navigation", () => ({ })); vi.mock("next/headers", () => ({ cookies: vi.fn() })); -import { requireSuperAdmin } from "@/lib/permissions"; +import { requireSuperAdmin, superAdminEmailFor } from "@/lib/permissions"; const originalEmail = process.env.SUPER_ADMIN_EMAIL; beforeEach(() => { vi.clearAllMocks(); process.env.SUPER_ADMIN_EMAIL = "admin@example.test"; - mocks.auth.mockResolvedValue({ user: { id: "user_1", email: "stale@example.test" } }); + mocks.auth.mockResolvedValue({ user: { id: "user_1", email: "stale@example.test" }, authMethod: "credentials" }); mocks.userFind.mockResolvedValue({ email: "admin@example.test", emailVerified: new Date() }); }); @@ -37,7 +37,7 @@ describe("super-admin identity boundary", () => { }); it("rejects a stale session after the database email no longer matches", async () => { - mocks.auth.mockResolvedValue({ user: { id: "user_1", email: "admin@example.test" } }); + mocks.auth.mockResolvedValue({ user: { id: "user_1", email: "admin@example.test" }, authMethod: "credentials" }); mocks.userFind.mockResolvedValue({ email: "former-admin@example.test", emailVerified: new Date() }); await expect(requireSuperAdmin()).rejects.toThrow("not-found"); }); @@ -46,4 +46,14 @@ describe("super-admin identity boundary", () => { mocks.userFind.mockResolvedValue({ email: "admin@example.test", emailVerified: null }); await expect(requireSuperAdmin()).rejects.toThrow("not-found"); }); + + it.each([ + ["a Google sign-in", { authMethod: "google" as const }], + ["a session from before sign-in methods were recorded", {}], + ])("refuses %s, even for the super admin's own account", async (_label, extra) => { + mocks.auth.mockResolvedValue({ user: { id: "user_1", email: "admin@example.test" }, ...extra }); + await expect(requireSuperAdmin()).rejects.toThrow("not-found"); + await expect(superAdminEmailFor({ user: { id: "user_1" }, ...extra })).resolves.toBeNull(); + expect(mocks.userFind).not.toHaveBeenCalled(); + }); }); diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index 5832ba0e..ab287b78 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -5,5 +5,7 @@ declare module "next-auth" { user: { id: string; } & DefaultSession["user"]; + /** How this web session signed in; absent for app sessions and old tokens. */ + authMethod?: "credentials" | "google"; } } From ad63b05c138041ac2a435a8ab6224fe3ff4ce708 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:47:32 +0530 Subject: [PATCH 12/79] fix(mobile): judge crew tickets by the crew claims switch Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- lib/claims/ticketView.ts | 4 ++-- lib/mobile/attendee.ts | 4 ++-- tests/integration/reimbursements-admission.db.test.ts | 2 +- tests/integration/reimbursements-claims.db.test.ts | 4 ++-- tests/integration/reimbursements-eligibility.db.test.ts | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/claims/ticketView.ts b/lib/claims/ticketView.ts index edf1ed71..65d95111 100644 --- a/lib/claims/ticketView.ts +++ b/lib/claims/ticketView.ts @@ -15,11 +15,11 @@ import { CATEGORY_LABELS, financeCurrency } from "./shared"; * policies open to this registration now, with what is left under each. * Null when there is nothing to claim and nothing claimed. */ -export async function loadTicketClaims(rsvp: { id: string; eventId: string | null; status: RsvpStatus; isCrew?: boolean; }) { +export async function loadTicketClaims(rsvp: { id: string; eventId: string | null; status: RsvpStatus; isCrew: boolean; }) { if (!rsvp.eventId || rsvp.status !== RsvpStatus.CONFIRMED) return null; const event = await prisma.event.findUnique({ where: { id: rsvp.eventId }, select: { organisationId: true, currency: true, participantClaimsEnabled: true, participantClaimCapMinor: true, crewClaimsEnabled: true } }); if (!event) return null; - const crew = rsvp.isCrew === true; + const crew = rsvp.isCrew; const general = crew ? event.crewClaimsEnabled : event.participantClaimsEnabled; const claimant = await loadClaimant(prisma, rsvp.id, rsvp.eventId); const open = claimant ? await availablePolicies(prisma, claimant) : []; diff --git a/lib/mobile/attendee.ts b/lib/mobile/attendee.ts index d22c2dee..62e1db01 100644 --- a/lib/mobile/attendee.ts +++ b/lib/mobile/attendee.ts @@ -18,10 +18,10 @@ import { myPrizesDtoSchema, myReimbursementsDtoSchema, mySubmissionsDtoSchema, p * drive the website's own code and never returned. */ -/** The caller's own registration and its ticket token, or null. */ +/** The caller's own registration, its ticket token and whether it is a crew ticket, or null. */ export async function ownRegistration(userId: string, rsvpId: string) { if (!rsvpId || rsvpId.length > 128) return null; - return prisma.rSVP.findFirst({ where: { id: rsvpId, userId }, select: { id: true, eventId: true, status: true, checkInToken: true } }); + return prisma.rSVP.findFirst({ where: { id: rsvpId, userId }, select: { id: true, eventId: true, status: true, isCrew: true, checkInToken: true } }); } /** This registration's claims, cap and categories; null when the event takes no participant claims. */ diff --git a/tests/integration/reimbursements-admission.db.test.ts b/tests/integration/reimbursements-admission.db.test.ts index 2b4d162c..d2b9f46a 100644 --- a/tests/integration/reimbursements-admission.db.test.ts +++ b/tests/integration/reimbursements-admission.db.test.ts @@ -22,7 +22,7 @@ import { admin, claimInput, ctx, freeEvent, person, setUpReimbursements, team, t */ const claim = (token: string, policyId: string) => submitParticipantClaim({ ticketToken: token, actorUserId: null }, claimInput("100", policyId), []); -const offered = async (rsvp: { id: string; eventId: string | null; status: RsvpStatus }) => ((await loadTicketClaims(rsvp))?.policies ?? []).map((row) => row.id); +const offered = async (rsvp: { id: string; eventId: string | null; status: RsvpStatus; isCrew: boolean }) => ((await loadTicketClaims(rsvp))?.policies ?? []).map((row) => row.id); const base = { eligibility: "ADMISSION_STAGE", cap: "1000", categories: ["TRAVEL"] }; /** An event taking applications with stages 1 and 2 (applications are opened by triggers on registration). */ diff --git a/tests/integration/reimbursements-claims.db.test.ts b/tests/integration/reimbursements-claims.db.test.ts index cc800e67..f6173095 100644 --- a/tests/integration/reimbursements-claims.db.test.ts +++ b/tests/integration/reimbursements-claims.db.test.ts @@ -77,7 +77,7 @@ describe.sequential("claims under a reimbursement policy", () => { expect(await claim(bob.checkInToken, "10", later)).toMatchObject({ ok: false, error: expect.stringContaining("aren't open yet") }); expect(await claim(bob.checkInToken, "10", closed)).toMatchObject({ ok: false, error: expect.stringContaining("isn't open") }); expect(await claim(waiting.checkInToken, "10", open)).toMatchObject({ ok: false }); - const view = await loadTicketClaims({ id: bob.id, eventId: event.id, status: bob.status }); + const view = await loadTicketClaims({ id: bob.id, eventId: event.id, status: bob.status, isCrew: bob.isCrew }); expect(view?.general).toBe(false); expect(view?.policies.map((row) => row.id)).toEqual([open]); expect(view?.policies[0]).toMatchObject({ left: expect.stringContaining("1,000") }); @@ -192,7 +192,7 @@ describe.sequential("claims under a reimbursement policy", () => { const row = await prisma.expenseClaim.findFirstOrThrow({ where: { rsvpId: gina.id } }); expect(row).toMatchObject({ policyId: null, eligibilitySnapshot: null, kind: "PARTICIPANT" }); expect(await approveClaim(approver(), { claimId: row.id, amount: "800" })).toMatchObject({ ok: true }); - expect(await loadTicketClaims({ id: gina.id, eventId: event.id, status: gina.status })).toMatchObject({ general: true, policies: [], cap: expect.stringContaining("1,000") }); + expect(await loadTicketClaims({ id: gina.id, eventId: event.id, status: gina.status, isCrew: gina.isCrew })).toMatchObject({ general: true, policies: [], cap: expect.stringContaining("1,000") }); }); it("serves the mobile reimbursements and organiser policy list from the same rules", async () => { diff --git a/tests/integration/reimbursements-eligibility.db.test.ts b/tests/integration/reimbursements-eligibility.db.test.ts index 4b2ad7db..f417f4b0 100644 --- a/tests/integration/reimbursements-eligibility.db.test.ts +++ b/tests/integration/reimbursements-eligibility.db.test.ts @@ -21,8 +21,8 @@ import { checkIn, claimInput, ctx, finalsRound, freeEvent, mentoringRound, perso */ const claim = (token: string, amount: string, policyId: string) => submitParticipantClaim({ ticketToken: token, actorUserId: null }, claimInput(amount, policyId), []); -const offered = async (rsvp: { id: string; eventId: string | null; status: "CONFIRMED" | string }) => - ((await loadTicketClaims({ id: rsvp.id, eventId: rsvp.eventId, status: rsvp.status as "CONFIRMED" }))?.policies ?? []).map((row) => row.id); +const offered = async (rsvp: { id: string; eventId: string | null; status: "CONFIRMED" | string; isCrew: boolean }) => + ((await loadTicketClaims({ id: rsvp.id, eventId: rsvp.eventId, status: rsvp.status as "CONFIRMED", isCrew: rsvp.isCrew }))?.policies ?? []).map((row) => row.id); beforeAll(() => setUpReimbursements("ReimbElig")); afterAll(() => tearDownReimbursements()); From c5e8aa7350757ae02d5bfdd2c30ae7862f3dd1f7 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:47:33 +0530 Subject: [PATCH 13/79] fix(mobile): keep unknown idempotent outcomes stale, claim id fallback, review grants in /me Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/participant-claims.ts | 6 +- .../me/registrations/[rsvpId]/claims/route.ts | 10 +- docs/MOBILE_API.md | 24 ++-- .../participant-claim-filed.json | 6 + docs/mobile-api.openapi.yaml | 12 +- lib/mobile/account.ts | 8 +- lib/mobile/idempotency.ts | 46 ++++--- lib/mobile/schemas/commerce.ts | 3 + lib/mobile/schemas/core.ts | 2 +- prisma/schema.prisma | 2 +- tests/integration/mobile-claims.db.test.ts | 127 ++++++++++++++++++ .../integration/mobile-idempotency.db.test.ts | 28 +++- tests/integration/mobile-me-grants.db.test.ts | 62 +++++++++ tests/unit/mobile-api-contract/catalog.ts | 2 + 14 files changed, 294 insertions(+), 44 deletions(-) create mode 100644 docs/mobile-api-fixtures/participant-claim-filed.json create mode 100644 tests/integration/mobile-claims.db.test.ts create mode 100644 tests/integration/mobile-me-grants.db.test.ts diff --git a/app/actions/participant-claims.ts b/app/actions/participant-claims.ts index 93240351..0b6b5053 100644 --- a/app/actions/participant-claims.ts +++ b/app/actions/participant-claims.ts @@ -16,8 +16,8 @@ import { requestRefund } from "@/lib/refundRequests"; const TOKEN = /^[A-Za-z0-9_-]{8,256}$/; -/** File a claim from a ticket (multipart: `ticketToken`, the fields, an optional `policyId`, up to three `receipts`). */ -export async function submitParticipantClaimAction(formData: FormData): Promise { +/** File a claim from a ticket (multipart: `ticketToken`, the fields, an optional `policyId`, up to three `receipts`); answers the new claim's id. */ +export async function submitParticipantClaimAction(formData: FormData): Promise> { const ticketToken = String(formData.get("ticketToken") ?? ""); if (!TOKEN.test(ticketToken)) return { ok: false, error: "Registration not found." }; if (await isActionRateLimited("action", ticketToken)) return { ok: false, error: "Too many claims. Try again shortly." }; @@ -25,7 +25,7 @@ export async function submitParticipantClaimAction(formData: FormData): Promise< const field = (name: string) => String(formData.get(name) ?? ""); const files = formData.getAll("receipts").filter((value): value is File => value instanceof File); const result = await submitParticipantClaim({ ticketToken, actorUserId: session?.user?.id ?? null }, { category: field("category"), description: field("description"), amount: field("amount"), policyId: field("policyId") }, files); - return result.ok ? { ok: true } : result; + return result.ok ? { ok: true, data: { claimId: result.data.claimId } } : result; } /** Withdraw one of this ticket's claims before it is decided. */ diff --git a/app/api/mobile/v1/me/registrations/[rsvpId]/claims/route.ts b/app/api/mobile/v1/me/registrations/[rsvpId]/claims/route.ts index 9a64c0bc..eb6be495 100644 --- a/app/api/mobile/v1/me/registrations/[rsvpId]/claims/route.ts +++ b/app/api/mobile/v1/me/registrations/[rsvpId]/claims/route.ts @@ -2,6 +2,7 @@ import { submitParticipantClaimAction } from "@/app/actions/participant-claims"; import { mobileActionError, mobileOk, readFormBody, notFound } from "@/lib/mobile/http"; import { loadMyParticipantClaims, ownRegistration } from "@/lib/mobile/attendee"; import { authedMobileRoute } from "@/lib/mobile/route"; +import { claimFiledDtoSchema } from "@/lib/mobile/schemas"; /** Three 5 MB receipt photos and the fields; read with a hard cap before parsing. */ const MAX_BYTES = 16 * 1024 * 1024; @@ -16,7 +17,8 @@ export const GET = authedMobileRoute<{ rsvpId: string }>("GET /api/mobile/v1/me/ * File a claim: multipart form with `category`, `description`, `amount`, an * optional `policyId` (a reimbursement policy from GET …/reimbursements) and * up to three `receipts` photos, exactly as the ticket page. Receipts are - * re-encoded and stored privately. + * re-encoded and stored privately. Answers the claim list, or only the new + * claim's id when the claim was filed but the list could not be read back. */ export const POST = authedMobileRoute<{ rsvpId: string }>("POST /api/mobile/v1/me/registrations/:rsvpId/claims", async (request, { rsvpId }, { auth }) => { const rsvp = await ownRegistration(auth.userId, rsvpId); @@ -30,5 +32,9 @@ export const POST = authedMobileRoute<{ rsvpId: string }>("POST /api/mobile/v1/m for (const file of form.getAll("receipts")) if (file instanceof File) fields.append("receipts", file); const result = await submitParticipantClaimAction(fields); if (!result.ok) return mobileActionError(result); - return mobileOk(await loadMyParticipantClaims(auth.userId, rsvpId), 201); + const claims = await loadMyParticipantClaims(auth.userId, rsvpId).catch(() => undefined); + if (claims !== undefined) return mobileOk(claims, 201); + // The claim is filed: answer with its id rather than a 5xx, whose outcome a retry could not tell. + console.error("[mobile-api] claim filed but its list could not be read back"); + return mobileOk(claimFiledDtoSchema.parse({ claimId: result.data?.claimId }), 201); }, { idempotent: { maxBytes: MAX_BYTES } }); diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 50ab9a26..4c3445fe 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -134,15 +134,16 @@ new boundary. The same key on another path or with another body, or while the first is still running, is `CONFLICT`; it never replays another resource's answer. -- Only definitive answers are kept. A 5xx, `RATE_LIMITED`, `UNAUTHENTICATED` - or a staff authenticator refusal is not, so retry it with the same key - (after signing in or confirming the code). +- Only definitive answers are kept. `RATE_LIMITED`, `UNAUTHENTICATED` or a + staff authenticator refusal is not, so retry it with the same key (after + signing in or confirming the code). - Use a new key only after a definitive 4xx answer, which the same key would replay. -- `IDEMPOTENCY_STALE` (409): an earlier request with this key started over a - minute ago and never finished, so its outcome is unknown and it is never run - again under that key. Re-read the record; if the change is not there, send - the action again with a new key. +- `IDEMPOTENCY_STALE` (409): an earlier request with this key answered a 5xx + (or failed on the server), or started over a minute ago and never finished. + Either may have happened after the change was saved, so its outcome is + unknown and it is never run again under that key. Re-read the record; if the + change is not there, send the action again with a new key. - A replayed answer never carries a ticket token: every `ticketToken` is kept as `null` (re-read the registration or order for it). Keys and bodies are kept only as HMACs, and kept answers are deleted after a day. @@ -281,8 +282,9 @@ Account creation and password reset happen on the website. `{ "user": User, "organisations": [OrgSummary], "collaborations": [{ "organisation": { "slug", "name" }, "eventId", "eventTitle", "permissions": ["CHECK_IN", …] }], "judging": [JudgingEvent] }` `OrgSummary = { "id", "slug", "name", "logoUrl|null", "role": "OWNER|ADMIN|MEMBER" }`. -Collaborations list standalone-event grants only; `JUDGE` is never listed in -`permissions`, and a `JUDGE`-only grant is not a collaboration. +Collaborations list standalone-event grants only; `JUDGE`, `MENTOR` and +`REVIEW_APPLICATIONS` open their own workspaces, so they are never listed in +`permissions`, and a grant of only those is not a collaboration. `JudgingEvent = { "organisation": { "slug", "name" }, "eventId", "eventTitle", "startDateTime", "endDateTime", "timezone", "judgingStatus": "NOT_STARTED|OPEN|CLOSED" }`: events the caller judges through a `JUDGE` grant, once the event takes @@ -505,7 +507,9 @@ their orders by id, and anyone else's are `NOT_FOUND`. `policyId` are open: show the general form only when true), cap and categories, or `null` when nothing is open and nothing was claimed. `POST` the same path as `multipart/form-data` (`category`, `description`, `amount`, an optional `policyId`, up to three `receipts` - photos) to file one; + photos) to file one (201 with the claim list; if the claim was filed but + the list could not be read back, 201 with only `{ "claimId" }`, so GET the + list again); `POST …/claims/:claimId/withdraw` before it is decided. Receipt photos of the caller's own claims: `GET /me/claims/:claimId/receipts/:receiptId`. Each claim carries `policy` (the reimbursement policy's name, or null). diff --git a/docs/mobile-api-fixtures/participant-claim-filed.json b/docs/mobile-api-fixtures/participant-claim-filed.json new file mode 100644 index 00000000..6989857f --- /dev/null +++ b/docs/mobile-api-fixtures/participant-claim-filed.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "data": { + "claimId": "clclaim0000000000000004" + } +} diff --git a/docs/mobile-api.openapi.yaml b/docs/mobile-api.openapi.yaml index e6e5917a..c644cef3 100644 --- a/docs/mobile-api.openapi.yaml +++ b/docs/mobile-api.openapi.yaml @@ -1233,7 +1233,7 @@ paths: policyId: { type: string, description: File under this reimbursement policy (from GET …/reimbursements); blank for general claims. } receipts: { type: array, maxItems: 3, items: { type: string, format: binary } } responses: - "201": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ParticipantClaimsResponse" } } } } + "201": { description: "The claim list; only the new claim's id (ClaimFiled) when it was filed but the list could not be read back", content: { application/json: { schema: { $ref: "#/components/schemas/ParticipantClaimFiledResponse" } } } } "400": { $ref: "#/components/responses/Error" } "404": { $ref: "#/components/responses/Error" } "409": { $ref: "#/components/responses/Error" } @@ -2451,7 +2451,7 @@ components: EventStatus: { type: string, enum: [DRAFT, PUBLISHED, HIDDEN, CANCELLED] } PrivacyType: { type: string, enum: [PUBLIC, HIDDEN_LINK, APPROVAL_REQUIRED, INVITE_ONLY] } MembershipRole: { type: string, enum: [OWNER, ADMIN, MEMBER] } - EventPermission: { type: string, enum: [EDIT_DETAILS, MANAGE_REGISTRATIONS, MANAGE_INVITATIONS, CHECK_IN, PUBLISH_AND_SCHEDULE, JUDGE, MENTOR] } + EventPermission: { type: string, enum: [EDIT_DETAILS, MANAGE_REGISTRATIONS, MANAGE_INVITATIONS, CHECK_IN, PUBLISH_AND_SCHEDULE, JUDGE, MENTOR, REVIEW_APPLICATIONS] } FieldType: { type: string, enum: [TEXT, TEXTAREA, EMAIL, PHONE, SELECT, MULTI_SELECT, RADIO, CHECKBOX, NUMBER, DATE] } Attendance: { type: string, enum: [ANY, CHECKED_IN, NOT_CHECKED_IN], default: ANY } RegistrationMode: { type: string, enum: [INDIVIDUAL, TEAM], description: "TEAM: capacity counts teams plus solo registrants (\"teams & solo places\")." } @@ -3835,6 +3835,13 @@ components: cap: { type: [string, "null"] } categories: { type: array, items: { type: object, additionalProperties: false, required: [value, label], properties: { value: { type: string }, label: { type: string } } } } claims: { type: array, items: { $ref: "#/components/schemas/Claim" } } + ClaimFiled: + description: The claim was filed but the list could not be read back; GET …/claims shows it. + type: object + additionalProperties: false + required: [claimId] + properties: + claimId: { type: string } StaffClaim: description: Closed form of StaffClaimFields (no other properties). allOf: [{ $ref: "#/components/schemas/StaffClaimFields" }] @@ -4011,6 +4018,7 @@ components: CheckInListsResponse: { type: object, additionalProperties: false, required: [ok, data], properties: { ok: { const: true }, data: { $ref: "#/components/schemas/CheckInLists" } } } ListCheckInResponse: { type: object, additionalProperties: false, required: [ok, data], properties: { ok: { const: true }, data: { $ref: "#/components/schemas/ListCheckInResult" } } } ParticipantClaimsResponse: { type: object, additionalProperties: false, required: [ok, data], properties: { ok: { const: true }, data: { oneOf: [{ $ref: "#/components/schemas/ParticipantClaims" }, { type: "null" }] } } } + ParticipantClaimFiledResponse: { type: object, additionalProperties: false, required: [ok, data], properties: { ok: { const: true }, data: { oneOf: [{ $ref: "#/components/schemas/ParticipantClaims" }, { $ref: "#/components/schemas/ClaimFiled" }, { type: "null" }] } } } MyReimbursementsResponse: { type: object, additionalProperties: false, required: [ok, data], properties: { ok: { const: true }, data: { oneOf: [{ $ref: "#/components/schemas/MyReimbursements" }, { type: "null" }] } } } ReimbursementPoliciesResponse: { type: object, additionalProperties: false, required: [ok, data], properties: { ok: { const: true }, data: { $ref: "#/components/schemas/ReimbursementPolicies" } } } MyReimbursements: diff --git a/lib/mobile/account.ts b/lib/mobile/account.ts index c81e18f1..4b571d1a 100644 --- a/lib/mobile/account.ts +++ b/lib/mobile/account.ts @@ -7,6 +7,8 @@ import { absoluteUrl, eventSummarySelect, mobileUserSelect, toEventSummary, toMy import { staffMfaState } from "@/lib/staffMfa"; import { meDtoSchema, myTeamResultDtoSchema, myTeamSchema, registrationsDtoSchema, type MyTeamDto } from "@/lib/mobile/schemas"; +const ISOLATED_GRANTS = new Set([EventPermission.JUDGE, EventPermission.MENTOR, EventPermission.REVIEW_APPLICATIONS]); + export async function loadMe(userId: string, baseUrl: string) { const [user, memberships, collaborations, judgeGrants] = await Promise.all([ prisma.user.findUniqueOrThrow({ where: { id: userId }, select: mobileUserSelect }), @@ -64,11 +66,9 @@ export async function loadMe(userId: string, baseUrl: string) { logoUrl: absoluteUrl(membership.organisation.logoUrl, baseUrl), role: membership.role, })), - // JUDGE is web-only (docs/HACKATHON.md) and not part of the mobile DTO: - // drop it, and drop JUDGE-only grants entirely. collaborations: collaborations.flatMap((grant) => { - // JUDGE and MENTOR open their own workspaces (`judging`, `mentoring`), not the organiser tools. - const permissions = grant.permissions.filter((permission) => permission !== EventPermission.JUDGE && permission !== EventPermission.MENTOR); + // JUDGE, MENTOR and REVIEW_APPLICATIONS open their own workspaces (`judging`, `mentoring`, the application reviews), not the organiser tools. + const permissions = grant.permissions.filter((permission) => !ISOLATED_GRANTS.has(permission)); return grant.event && permissions.length > 0 ? [{ organisation: { slug: grant.event.organisation.slug, name: grant.event.organisation.name }, eventId: grant.event.id, diff --git a/lib/mobile/idempotency.ts b/lib/mobile/idempotency.ts index 1a712ba7..6d180dc3 100644 --- a/lib/mobile/idempotency.ts +++ b/lib/mobile/idempotency.ts @@ -18,13 +18,15 @@ import { MAX_BODY_BYTES, mobileError, readJsonBody } from "@/lib/mobile/http"; * acting twice. The same key on another path (another event, request, slot or * round) or with another body is CONFLICT, never another resource's answer. * - * Only definitive answers are kept. Transient and sign-in refusals (5xx, 429, - * 401, the staff authenticator checks) are not: retry them with the same key. - * Use a new key only after a definitive 4xx answer, which the same key would - * replay. A first request still running is CONFLICT; one that started more - * than STALE_AFTER_MS ago and never finished (the process died mid-write) is - * IDEMPOTENCY_STALE: its outcome is unknown, so it is never run again under - * that key. Re-read the record and, if the change is not there, send a new key. + * Only definitive answers are kept. Refusals made before anything is done + * (429, 401, the staff authenticator checks) are not: retry them with the same + * key. Use a new key only after a definitive 4xx answer, which the same key + * would replay. A first request still running is CONFLICT. A 5xx answer or an + * exception after the write ran may come after a commit, so its outcome is + * unknown: the key then answers IDEMPOTENCY_STALE and never runs again, as does + * one that started more than STALE_AFTER_MS ago and never finished (the + * process died mid-write). Re-read the record and, if the change is not there, + * send a new key. * * Keys and bodies are stored only as HMACs under a key derived from * MFA_ENCRYPTION_KEY for this purpose alone. The kept answers are the routes' @@ -37,11 +39,13 @@ export const IDEMPOTENCY_TTL_MS = 24 * 60 * 60_000; export const STALE_AFTER_MS = 60_000; const KEY_PATTERN = /^[\x21-\x7e]{8,128}$/; const IN_PROGRESS = 0; +/** The write ran but its outcome is unknown (a 5xx or an exception): never run again under this key. Not an HTTP status (the table checks status >= 0). */ +const UNKNOWN = 1; -/** Refusals a retry may change: never kept, so the same key runs again. */ -const TRANSIENT_CODES = new Set([ +/** Refusals made before acting, which a retry may change: never kept, so the same key runs again. */ +const REFUSAL_CODES = new Set([ "RATE_LIMITED", "TOO_MANY_ATTEMPTS", "UNAUTHENTICATED", "MFA_REQUIRED", "REAUTH_REQUIRED", "EMAIL_CODE_REQUIRED", - "EMAIL_VERIFICATION_REQUIRED", "STAFF_MFA_REQUIRED", "STAFF_MFA_ENROLMENT_REQUIRED", "INTERNAL_ERROR", "PAYMENT_UNAVAILABLE", + "EMAIL_VERIFICATION_REQUIRED", "STAFF_MFA_REQUIRED", "STAFF_MFA_ENROLMENT_REQUIRED", ]); const digest = (value: string) => crypto.createHmac("sha256", deriveSecretKey("spaceworks-events-mobile-idempotency-v1")).update(value).digest("hex"); @@ -50,10 +54,10 @@ function replay(row: { status: number; body: Prisma.JsonValue | null; }) { return NextResponse.json(row.body, { status: row.status, headers: { "Cache-Control": "no-store", "Idempotent-Replayed": "true" } }); } -function isTransient(status: number, kept: unknown) { - if (status >= 500 || status === 401 || status === 429) return true; +function isRefusal(status: number, kept: unknown) { + if (status === 401 || status === 429) return true; const code = (kept as { error?: { code?: unknown } } | null)?.error?.code; - return typeof code === "string" && TRANSIENT_CODES.has(code); + return typeof code === "string" && REFUSAL_CODES.has(code); } /** Ticket links are bearer capabilities: never kept in a stored answer. */ @@ -89,8 +93,8 @@ async function bodyFingerprint(contentType: string, bytes: Uint8Array) { /** * Run `run` at most once per Idempotency-Key. The body is read here (up to * `maxBytes`) and handed to `run` in a fresh copy of the request, so a route - * reads it as usual. Transient answers (see above) are not kept, so a retry - * runs again. + * reads it as usual. Refusals (see above) are not kept, so a retry runs again; + * a 5xx or an exception leaves the outcome unknown (IDEMPOTENCY_STALE). */ export async function idempotentRequest( request: Request, @@ -110,6 +114,8 @@ export async function idempotentRequest( const requestHash = digest(`request\n${request.method}\n${url.pathname}${url.search}\n${await bodyFingerprint(contentType, bytes)}`); const where = { userId_route_keyHash: { userId, route: routeId, keyHash } }; const forget = () => prisma.mobileIdempotencyKey.deleteMany({ where: { userId, route: routeId, keyHash } }).catch(() => undefined); + // If even this fails the row stays in progress and turns stale: never run twice either way. + const markUnknown = () => prisma.mobileIdempotencyKey.update({ where, data: { status: UNKNOWN } }).catch(() => undefined); const now = new Date(); // Expired keys are free again. @@ -121,6 +127,7 @@ export async function idempotentRequest( const existing = await prisma.mobileIdempotencyKey.findUnique({ where }); if (!existing) return mobileError("CONFLICT", "Retry with a new Idempotency-Key."); if (existing.requestHash !== requestHash) return mobileError("CONFLICT", "This Idempotency-Key was used for a different request."); + if (existing.status === UNKNOWN) return mobileError("IDEMPOTENCY_STALE"); if (existing.status !== IN_PROGRESS) return replay(existing); if (now.getTime() - existing.createdAt.getTime() > STALE_AFTER_MS) return mobileError("IDEMPOTENCY_STALE"); return mobileError("CONFLICT", "The first request with this Idempotency-Key is still running. Try again shortly."); @@ -132,11 +139,16 @@ export async function idempotentRequest( try { response = await run(new Request(request.url, { method: request.method, headers, body: bytes.byteLength > 0 ? Buffer.from(bytes) : undefined })); } catch (error) { - await forget(); + // It may have failed after a commit (reading the result back, say). + await markUnknown(); throw error; } + if (response.status >= 500) { + await markUnknown(); + return response; + } const kept = await response.clone().json().catch(() => null) as Prisma.InputJsonValue | null; - if (isTransient(response.status, kept)) { + if (isRefusal(response.status, kept)) { await forget(); return response; } diff --git a/lib/mobile/schemas/commerce.ts b/lib/mobile/schemas/commerce.ts index 0523f2c6..c882764a 100644 --- a/lib/mobile/schemas/commerce.ts +++ b/lib/mobile/schemas/commerce.ts @@ -186,6 +186,9 @@ export const participantClaimsDtoSchema = z.object({ claims: z.array(claimSchema), }).strict(); +/** POST …/claims when the claim was filed but its list could not be read back: re-read it with GET. */ +export const claimFiledDtoSchema = z.object({ claimId: z.string() }).strict(); + export const staffClaimSchema = claimSchema.extend({ description: z.string(), createdAt: iso, receiptIds: z.array(z.string()) }).strict(); export const staffClaimsDtoSchema = z.object({ currency: z.string(), claims: z.array(staffClaimSchema) }).strict(); diff --git a/lib/mobile/schemas/core.ts b/lib/mobile/schemas/core.ts index f763fdb7..59895dca 100644 --- a/lib/mobile/schemas/core.ts +++ b/lib/mobile/schemas/core.ts @@ -16,7 +16,7 @@ export const rsvpStatusSchema = z.enum(["CONFIRMED", "WAITLISTED", "PENDING_APPR export const eventStatusSchema = z.enum(["DRAFT", "PUBLISHED", "HIDDEN", "CANCELLED"]); export const privacyTypeSchema = z.enum(["PUBLIC", "HIDDEN_LINK", "APPROVAL_REQUIRED", "INVITE_ONLY"]); export const membershipRoleSchema = z.enum(["OWNER", "ADMIN", "MEMBER"]); -export const eventPermissionSchema = z.enum(["EDIT_DETAILS", "MANAGE_REGISTRATIONS", "MANAGE_INVITATIONS", "CHECK_IN", "PUBLISH_AND_SCHEDULE", "JUDGE", "MENTOR"]); +export const eventPermissionSchema = z.enum(["EDIT_DETAILS", "MANAGE_REGISTRATIONS", "MANAGE_INVITATIONS", "CHECK_IN", "PUBLISH_AND_SCHEDULE", "JUDGE", "MENTOR", "REVIEW_APPLICATIONS"]); export const fieldTypeSchema = z.enum(["TEXT", "TEXTAREA", "EMAIL", "PHONE", "SELECT", "MULTI_SELECT", "RADIO", "CHECKBOX", "NUMBER", "DATE"]); export const attendanceSchema = z.enum(["ANY", "CHECKED_IN", "NOT_CHECKED_IN"]); export const registrationModeSchema = z.enum(["INDIVIDUAL", "TEAM"]); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b03c6c14..80328c9e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -553,7 +553,7 @@ model MobileIdempotencyKey { keyHash String /// HMAC of the method, path and body; the same key on another path or with another body is refused. requestHash String - /// 0 while the first request is still running. + /// 0 while the first request is still running; 1 when it ran with an unknown outcome (a 5xx or an exception). status Int @default(0) body Json? createdAt DateTime @default(now()) diff --git a/tests/integration/mobile-claims.db.test.ts b/tests/integration/mobile-claims.db.test.ts new file mode 100644 index 00000000..53e417e7 --- /dev/null +++ b/tests/integration/mobile-claims.db.test.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto"; +import { RsvpStatus } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * Participant expense claims through the mobile API: a claim filed when its + * list can't be read back answers with its id (never a 5xx, so a retry with + * the same key replays rather than filing it twice), and a crew ticket is + * judged by the event's crew claims switch (docs/CREW.md). + */ + +process.env.REDIS_URL = ""; +process.env.TRUSTED_PROXY_IP_HEADER = "x-real-ip"; + +const listFails = vi.hoisted(() => ({ next: false })); + +vi.mock("next-auth", () => ({ default: () => ({ auth: async () => null, handlers: {}, signIn: vi.fn(), signOut: vi.fn() }), CredentialsSignin: class extends Error {} })); +vi.mock("next-auth/providers/google", () => ({ default: vi.fn() })); +vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn() })); +vi.mock("@/lib/authAdapter", () => ({ authAdapter: {} })); +vi.mock("next/headers", () => ({ headers: async () => new Headers({ "x-real-ip": `198.18.0.${Math.floor(Math.random() * 250)}` }), cookies: async () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn() }) })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/outboxWorker", () => ({ startOutboxWorker: vi.fn() })); +vi.mock("@/lib/mobile/attendee", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // The read after the claim commits fails once, as a dropped database connection would. + loadMyParticipantClaims: async (userId: string, rsvpId: string) => { + if (listFails.next) { + listFails.next = false; + throw new Error("connection lost"); + } + return actual.loadMyParticipantClaims(userId, rsvpId); + }, + }; +}); + +import { prisma } from "@/lib/db"; +import { BASE, createEvent, createUser, get, newKey, params, signIn, type SignedIn } from "../helpers/mobileApi"; +import { GET as myClaims, POST as fileClaim } from "@/app/api/mobile/v1/me/registrations/[rsvpId]/claims/route"; +import { GET as myReimbursements } from "@/app/api/mobile/v1/me/registrations/[rsvpId]/reimbursements/route"; + +const suffix = randomUUID().replaceAll("-", "").slice(0, 16); +const email = (name: string) => `${name}-${suffix}@example.test`; +const ids = { org: "", event: "", crewEvent: "", participant: "", crewUser: "", rsvp: "", crewRsvp: "" }; +let participant: SignedIn; +let crewmate: SignedIn; + +/** A multipart claim, as the app files it, under the given Idempotency-Key. */ +function claimRequest(rsvpId: string, token: string, key: string) { + const form = new FormData(); + form.set("category", "TRAVEL"); + form.set("description", "Train to the venue"); + form.set("amount", "120"); + return new Request(`${BASE}/api/mobile/v1/me/registrations/${rsvpId}/claims`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "idempotency-key": key, "x-real-ip": "198.51.100.77" }, + body: form, + }); +} + +beforeAll(async () => { + ids.org = (await prisma.organisation.create({ data: { name: "Claims mobile", slug: `mob-claims-${suffix}` } })).id; + ids.event = await createEvent(ids.org, `claims-${suffix}`, { participantClaimsEnabled: true }); + ids.crewEvent = await createEvent(ids.org, `claims-crew-${suffix}`); + ids.participant = await createUser(email("participant"), "Participant"); + ids.crewUser = await createUser(email("crew"), "Crew Mate"); + ids.rsvp = (await prisma.rSVP.create({ data: { eventId: ids.event, userId: ids.participant, attendeeKey: `user:${ids.participant}`, status: RsvpStatus.CONFIRMED } })).id; + // An account-owned free ticket the organisers turned into a crew ticket (lib/core/crew/members.ts). + ids.crewRsvp = (await prisma.rSVP.create({ data: { eventId: ids.crewEvent, userId: ids.crewUser, attendeeKey: `user:${ids.crewUser}`, status: RsvpStatus.CONFIRMED, isCrew: true } })).id; + participant = await signIn(email("participant")); + crewmate = await signIn(email("crew")); +}); + +afterAll(async () => { + await prisma.outboxMessage.deleteMany({ where: { payload: { path: ["to"], string_contains: suffix } } }); + await prisma.organisation.deleteMany({ where: { id: ids.org } }); + await prisma.user.deleteMany({ where: { id: { in: [ids.participant, ids.crewUser] } } }); +}); + +describe.sequential("expense claims through the mobile API", () => { + it("answers a filed claim's id when its list can't be read back, and a retry replays it rather than filing again", async () => { + const key = newKey(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + listFails.next = true; + let first: Response; + try { + first = await fileClaim(claimRequest(ids.rsvp, participant.accessToken, key), params({ rsvpId: ids.rsvp })); + } finally { + error.mockRestore(); + } + const body = await first.json(); + expect(first.status, JSON.stringify(body)).toBe(201); + const claims = await prisma.expenseClaim.findMany({ where: { rsvpId: ids.rsvp }, select: { id: true } }); + expect(claims).toHaveLength(1); + expect(body).toEqual({ ok: true, data: { claimId: claims[0]!.id } }); + + const retried = await fileClaim(claimRequest(ids.rsvp, participant.accessToken, key), params({ rsvpId: ids.rsvp })); + expect(retried.status).toBe(201); + expect(retried.headers.get("idempotent-replayed")).toBe("true"); + await expect(retried.json()).resolves.toEqual(body); + await expect(prisma.expenseClaim.count({ where: { rsvpId: ids.rsvp } })).resolves.toBe(1); + + // The list reads normally again. + const list = await (await myClaims(get("/x", participant.accessToken), params({ rsvpId: ids.rsvp }))).json(); + expect(list.data.claims).toEqual([expect.objectContaining({ id: claims[0]!.id, status: "SUBMITTED" })]); + }); + + it("answers the claim list when it reads back", async () => { + const filed = await fileClaim(claimRequest(ids.rsvp, participant.accessToken, newKey()), params({ rsvpId: ids.rsvp })); + expect(filed.status).toBe(201); + await expect(filed.json()).resolves.toMatchObject({ ok: true, data: { general: true, claims: [expect.anything(), expect.anything()] } }); + }); + + it("judges a crew ticket by the crew claims switch, not the participant one", async () => { + const at = params({ rsvpId: ids.crewRsvp }); + await prisma.event.update({ where: { id: ids.crewEvent }, data: { crewClaimsEnabled: true, participantClaimsEnabled: false } }); + const open = await (await myClaims(get("/x", crewmate.accessToken), at)).json(); + expect(open.data).toMatchObject({ general: true, cap: null, claims: [] }); + await expect((await myReimbursements(get("/x", crewmate.accessToken), at)).json()).resolves.toMatchObject({ data: { general: { cap: null } } }); + + await prisma.event.update({ where: { id: ids.crewEvent }, data: { crewClaimsEnabled: false, participantClaimsEnabled: true } }); + await expect((await myClaims(get("/x", crewmate.accessToken), at)).json()).resolves.toEqual({ ok: true, data: null }); + await expect((await myReimbursements(get("/x", crewmate.accessToken), at)).json()).resolves.toEqual({ ok: true, data: null }); + }); +}); diff --git a/tests/integration/mobile-idempotency.db.test.ts b/tests/integration/mobile-idempotency.db.test.ts index 507beb47..8eb15b68 100644 --- a/tests/integration/mobile-idempotency.db.test.ts +++ b/tests/integration/mobile-idempotency.db.test.ts @@ -2,9 +2,10 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; /* * Idempotency-Key handling for mobile writes (lib/mobile/idempotency.ts): a - * key replays only for the same path and body, transient and sign-in - * refusals are never kept, an unfinished first request turns stale rather - * than running twice, and only HMACs are stored. + * key replays only for the same path and body, rate-limit and sign-in + * refusals are never kept, a 5xx or an exception leaves the outcome unknown + * (stale), an unfinished first request turns stale rather than running twice, + * and only HMACs are stored. */ process.env.MFA_ENCRYPTION_KEY ||= Buffer.alloc(32, 7).toString("base64"); @@ -60,7 +61,6 @@ describe.sequential("mobile idempotent writes", () => { ["a rate limit", () => mobileError("RATE_LIMITED")], ["a sign-in refusal", () => mobileError("UNAUTHENTICATED")], ["a staff authenticator refusal", () => mobileError("STAFF_MFA_REQUIRED")], - ["a server error", () => mobileError("INTERNAL_ERROR")], ])("never keeps %s, so the same key runs again", async (_name, refusal) => { const key = `transient-${Math.random().toString(36).slice(2, 10)}`; const refused = counter(refusal); @@ -73,6 +73,26 @@ describe.sequential("mobile idempotent writes", () => { expect(write).toHaveBeenCalledTimes(1); }); + it.each([ + ["answers a server error", () => mobileError("INTERNAL_ERROR")], + ["answers that payments are unavailable", () => mobileError("PAYMENT_UNAVAILABLE")], + ["throws after the write committed", () => { throw new Error("reading the result back failed"); }], + ])("calls the outcome unknown when the write %s, and never runs it again under that key", async (_name, outcome) => { + const key = `unknown-${Math.random().toString(36).slice(2, 10)}`; + const first = vi.fn(async () => outcome()); + const answered = await idempotentWrite(request("/api/mobile/v1/test/x/act", key), userId, ROUTE, first).catch((error: unknown) => error); + expect(answered instanceof Response ? answered.status : 0).not.toBe(200); + const write = counter(() => mobileOk({ id: "twice" })); + const retried = await idempotentWrite(request("/api/mobile/v1/test/x/act", key), userId, ROUTE, write); + expect(retried.status).toBe(409); + await expect(retried.json()).resolves.toMatchObject({ error: { code: "IDEMPOTENCY_STALE" } }); + expect(write).not.toHaveBeenCalled(); + // Another body under that key is still a plain conflict, never the unknown answer. + const other = await idempotentWrite(request("/api/mobile/v1/test/x/act", key, { n: 2 }), userId, ROUTE, write); + await expect(other.json()).resolves.toMatchObject({ error: { code: "CONFLICT" } }); + expect(write).not.toHaveBeenCalled(); + }); + it("never keeps a ticket token in the stored answer", async () => { const write = counter(() => mobileOk({ rsvpId: "r1", ticketToken: "secret-ticket-token", order: { tickets: [{ ticketToken: "another-secret" }] } })); const first = await idempotentWrite(request("/api/mobile/v1/test/k/act", "ticket-key-01"), userId, ROUTE, write); diff --git a/tests/integration/mobile-me-grants.db.test.ts b/tests/integration/mobile-me-grants.db.test.ts new file mode 100644 index 00000000..9c936148 --- /dev/null +++ b/tests/integration/mobile-me-grants.db.test.ts @@ -0,0 +1,62 @@ +import { randomUUID } from "node:crypto"; +import { EventPermission } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * GET /me with application-review grants (docs/ADMISSION.md): REVIEW_APPLICATIONS + * opens its own workspace like JUDGE and MENTOR, so it is never listed as an + * organiser collaboration, and it never breaks the account response. + */ + +process.env.REDIS_URL = ""; +process.env.TRUSTED_PROXY_IP_HEADER = "x-real-ip"; + +vi.mock("next-auth", () => ({ default: () => ({ auth: async () => null, handlers: {}, signIn: vi.fn(), signOut: vi.fn() }), CredentialsSignin: class extends Error {} })); +vi.mock("next-auth/providers/google", () => ({ default: vi.fn() })); +vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn() })); +vi.mock("@/lib/authAdapter", () => ({ authAdapter: {} })); +vi.mock("next/headers", () => ({ headers: async () => new Headers({ "x-real-ip": `198.18.0.${Math.floor(Math.random() * 250)}` }), cookies: async () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn() }) })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/outboxWorker", () => ({ startOutboxWorker: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { createEvent, createUser, get, none, signIn } from "../helpers/mobileApi"; +import { GET as me } from "@/app/api/mobile/v1/me/route"; + +const suffix = randomUUID().replaceAll("-", "").slice(0, 16); +const email = (name: string) => `${name}-${suffix}@example.test`; +const ids = { org: "", reviewOnly: "", mixed: "", reviewer: "", door: "" }; + +beforeAll(async () => { + ids.org = (await prisma.organisation.create({ data: { name: "Review grants", slug: `mob-review-${suffix}` } })).id; + ids.reviewOnly = await createEvent(ids.org, `review-only-${suffix}`); + ids.mixed = await createEvent(ids.org, `review-mixed-${suffix}`); + ids.reviewer = await createUser(email("reviewer"), "Reviewer"); + ids.door = await createUser(email("door"), "Door"); + await prisma.eventCollaborator.create({ data: { userId: ids.reviewer, eventId: ids.reviewOnly, permissions: [EventPermission.REVIEW_APPLICATIONS] } }); + await prisma.eventCollaborator.create({ data: { userId: ids.door, eventId: ids.reviewOnly, permissions: [EventPermission.REVIEW_APPLICATIONS] } }); + await prisma.eventCollaborator.create({ data: { userId: ids.door, eventId: ids.mixed, permissions: [EventPermission.CHECK_IN, EventPermission.REVIEW_APPLICATIONS] } }); +}); + +afterAll(async () => { + await prisma.organisation.deleteMany({ where: { id: ids.org } }); + await prisma.user.deleteMany({ where: { id: { in: [ids.reviewer, ids.door] } } }); +}); + +describe("GET /me with application-review grants", () => { + it("answers for a review-only collaborator, without listing the grant as organiser tools", async () => { + const reviewer = await signIn(email("reviewer")); + const response = await me(get("/api/mobile/v1/me", reviewer.accessToken), none); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + expect(body.data.collaborations).toEqual([]); + }); + + it("lists only the organiser permissions of a mixed grant", async () => { + const door = await signIn(email("door")); + const response = await me(get("/api/mobile/v1/me", door.accessToken), none); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + expect(body.data.collaborations).toEqual([expect.objectContaining({ eventId: ids.mixed, permissions: [EventPermission.CHECK_IN] })]); + }); +}); diff --git a/tests/unit/mobile-api-contract/catalog.ts b/tests/unit/mobile-api-contract/catalog.ts index 6fc47133..db172263 100644 --- a/tests/unit/mobile-api-contract/catalog.ts +++ b/tests/unit/mobile-api-contract/catalog.ts @@ -36,6 +36,7 @@ export const fixtures: Array<[file: string, component: string, zod: z.ZodType]> ["order.json", "OrderDetailResponse", dto.successEnvelope(dto.orderDetailDtoSchema)], ["checkout.json", "CheckoutResponse", dto.successEnvelope(dto.checkoutDtoSchema)], ["participant-claims.json", "ParticipantClaimsResponse", dto.successEnvelope(dto.participantClaimsDtoSchema.nullable())], + ["participant-claim-filed.json", "ParticipantClaimFiledResponse", dto.successEnvelope(dto.claimFiledDtoSchema)], ["admin-claims.json", "AdminClaimsResponse", dto.successEnvelope(dto.adminClaimsDtoSchema)], ["my-submissions.json", "MySubmissionsResponse", dto.successEnvelope(dto.mySubmissionsDtoSchema)], ["my-prizes.json", "MyPrizesResponse", dto.successEnvelope(dto.myPrizesDtoSchema)], @@ -87,6 +88,7 @@ export const parity: Array<[component: string, zod: z.ZodObject]> = [ ["Checkout", dto.checkoutDtoSchema], ["Claim", dto.claimSchema], ["ParticipantClaims", dto.participantClaimsDtoSchema], + ["ClaimFiled", dto.claimFiledDtoSchema], ["StaffClaim", dto.staffClaimSchema], ["StaffClaims", dto.staffClaimsDtoSchema], ["AdminClaim", dto.adminClaimSchema], From 1c8e54178ca27ab56f16b91a4cab2c8f4c9de941 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:47:34 +0530 Subject: [PATCH 14/79] fix(net): share the webhook address classifier with SMTP, chat and ops Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- lib/net/publicAddress.ts | 56 +++++++++----------- tests/unit/organisation-email-safety.test.ts | 27 +++++++++- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/lib/net/publicAddress.ts b/lib/net/publicAddress.ts index 84a3245d..dae75be1 100644 --- a/lib/net/publicAddress.ts +++ b/lib/net/publicAddress.ts @@ -1,23 +1,8 @@ import "server-only"; import { lookup } from "node:dns/promises"; -import { BlockList, isIP } from "node:net"; - -/** - * Networks an organisation's server address may never reach: this host, its - * LAN and cloud metadata. Kept as two lists: one list would also match IPv4 - * addresses against the IPv6 ranges that embed IPv4 (::ffff:0:0/96, NAT64). - */ -const BLOCKED_V4 = new BlockList(); -const BLOCKED_V6 = new BlockList(); -for (const [network, prefix] of [ - ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], - ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], - ["224.0.0.0", 4], ["240.0.0.0", 4], -] as const) BLOCKED_V4.addSubnet(network, prefix, "ipv4"); -for (const [network, prefix] of [ - ["::", 128], ["::1", 128], ["::ffff:0:0", 96], ["64:ff9b::", 96], ["100::", 64], ["2001:db8::", 32], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8], -] as const) BLOCKED_V6.addSubnet(network, prefix, "ipv6"); +import { isIP } from "node:net"; +import { isBlockedWebhookAddress, type ResolvedAddress } from "@/lib/webhooks/urlSafety"; export class PrivateAddressError extends Error { override name = "PrivateAddressError"; @@ -26,26 +11,35 @@ export class PrivateAddressError extends Error { } } +/** + * Networks an organisation's server address may never reach: this host, its + * LAN, cloud metadata and every other non-public range. The classifier is the + * organisation webhooks' (lib/webhooks/urlSafety.ts), so IPv4 written as IPv6 + * (mapped, NAT64, 6to4) is judged by its IPv4 address, and site-local, Teredo + * and documentation ranges are refused too. + */ export function isBlockedAddress(address: string) { - const family = isIP(address); - if (family === 0) return true; - if (family === 6 && address.toLowerCase().startsWith("::ffff:")) { - // An IPv4 address written as IPv6: judge the IPv4 address. - const v4 = address.slice(7); - if (isIP(v4) === 4) return BLOCKED_V4.check(v4, "ipv4"); - } - return family === 4 ? BLOCKED_V4.check(address, "ipv4") : BLOCKED_V6.check(address, "ipv6"); + return isBlockedWebhookAddress(address); } /** - * Resolve a host an organisation entered to one public address to connect - * to. Every address it resolves to must be public, so a name can't hide a - * private one. `allowPrivate` is for development and tests only. + * Resolve a host an organisation entered to the public addresses to connect + * to; pin the connection to them. Every address it resolves to must be + * public, so a name can't hide a private one. `allowPrivate` is for + * development and tests only. */ -export async function resolvePublicAddress(host: string, options: { allowPrivate?: boolean; } = {}) { +export async function resolvePublicAddresses(host: string, options: { allowPrivate?: boolean; } = {}): Promise { const allowPrivate = options.allowPrivate ?? process.env.NODE_ENV !== "production"; - const addresses = isIP(host) ? [{ address: host }] : await lookup(host, { all: true, verbatim: true }); + const literal = isIP(host); + const addresses: ResolvedAddress[] = literal + ? [{ address: host, family: literal === 6 ? 6 : 4 }] + : (await lookup(host, { all: true, verbatim: true })).map((entry) => ({ address: entry.address, family: entry.family === 6 ? 6 : 4 })); if (addresses.length === 0) throw new PrivateAddressError(); if (!allowPrivate && addresses.some(({ address }) => isBlockedAddress(address))) throw new PrivateAddressError(); - return addresses[0]!.address; + return addresses; +} + +/** One public address to connect to, for a client given a literal address (SMTP). */ +export async function resolvePublicAddress(host: string, options: { allowPrivate?: boolean; } = {}) { + return (await resolvePublicAddresses(host, options))[0]!.address; } diff --git a/tests/unit/organisation-email-safety.test.ts b/tests/unit/organisation-email-safety.test.ts index b9d8e0e8..0261da78 100644 --- a/tests/unit/organisation-email-safety.test.ts +++ b/tests/unit/organisation-email-safety.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; beforeAll(() => vi.stubEnv("MFA_ENCRYPTION_KEY", process.env.MFA_ENCRYPTION_KEY || Buffer.alloc(32, 7).toString("base64"))); -import { isBlockedAddress, PrivateAddressError, resolvePublicAddress } from "@/lib/net/publicAddress"; +import { isBlockedAddress, PrivateAddressError, resolvePublicAddress, resolvePublicAddresses } from "@/lib/net/publicAddress"; import { decryptOrgSecret, encryptOrgSecret } from "@/lib/orgSecrets"; import { senderAddress } from "@/lib/email/transporter"; @@ -15,6 +15,31 @@ describe("organisation server addresses", () => { } }); + it("judges IPv6 forms that embed or reach private networks, as the organisation webhooks do", () => { + for (const address of [ + "fec0::1", // site-local + "2002:c0a8:101::1", // 6to4 of 192.168.1.1 + "2002:7f00:1::", // 6to4 of 127.0.0.1 + "2001:0:4136:e378:8000:63bf:3fff:fdd2", // Teredo + "::ffff:7f00:1", // IPv4-mapped 127.0.0.1, hex form + "::ffff:169.254.169.254", // IPv4-mapped cloud metadata + "64:ff9b::a00:1", // NAT64 of 10.0.0.1 + "192.88.99.1", // 6to4 relay anycast + ]) { + expect(isBlockedAddress(address), address).toBe(true); + } + for (const address of ["2002:808:808::1", "64:ff9b::808:808"]) { + expect(isBlockedAddress(address), address).toBe(false); + } + }); + + it("answers every vetted address with its family, for a pinned connection", async () => { + await expect(resolvePublicAddresses("2606:4700:4700::1111", { allowPrivate: false })).resolves.toEqual([{ address: "2606:4700:4700::1111", family: 6 }]); + await expect(resolvePublicAddresses("8.8.8.8", { allowPrivate: false })).resolves.toEqual([{ address: "8.8.8.8", family: 4 }]); + await expect(resolvePublicAddresses("fec0::1", { allowPrivate: false })).rejects.toBeInstanceOf(PrivateAddressError); + await expect(resolvePublicAddresses("fec0::1", { allowPrivate: true })).resolves.toEqual([{ address: "fec0::1", family: 6 }]); + }); + it("refuses private addresses in production, however they are written", async () => { await expect(resolvePublicAddress("127.0.0.1", { allowPrivate: false })).rejects.toBeInstanceOf(PrivateAddressError); await expect(resolvePublicAddress("::ffff:192.168.0.1", { allowPrivate: false })).rejects.toBeInstanceOf(PrivateAddressError); From 220c9a06ddbece084c8e48416e1545589112f140 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:47:35 +0530 Subject: [PATCH 15/79] fix(chat): pin Mattermost delivery with the shared lookup that honours all: true Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- lib/chat/send.ts | 11 +++++++---- tests/unit/chat-send.test.ts | 28 +++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/lib/chat/send.ts b/lib/chat/send.ts index 88e1caeb..be9d9cf5 100644 --- a/lib/chat/send.ts +++ b/lib/chat/send.ts @@ -1,7 +1,9 @@ import "server-only"; import { request } from "node:https"; -import { resolvePublicAddress } from "@/lib/net/publicAddress"; +import type { LookupFunction } from "node:net"; +import { resolvePublicAddresses } from "@/lib/net/publicAddress"; +import { pinnedLookup } from "@/lib/webhooks/urlSafety"; import type { ChatConfig } from "./config"; import type { ChatKind } from "./events"; @@ -34,12 +36,13 @@ async function postJson(url: string, body: unknown, headers: Record((resolve, reject) => { const req = request({ @@ -48,7 +51,7 @@ async function postJsonToOwnServer(url: string, body: unknown) { path: `${target.pathname}${target.search}`, method: "POST", servername: target.hostname, - lookup: (_hostname, _options, callback) => callback(null, address, address.includes(":") ? 6 : 4), + lookup: pinnedLookup(addresses) as unknown as LookupFunction, headers: { "Content-Type": "application/json", "Content-Length": payload.length }, timeout: TIMEOUT_MS, }, (response) => { diff --git a/tests/unit/chat-send.test.ts b/tests/unit/chat-send.test.ts index 85e4b491..98cc983a 100644 --- a/tests/unit/chat-send.test.ts +++ b/tests/unit/chat-send.test.ts @@ -1,8 +1,8 @@ +import net from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/lib/net/publicAddress", () => ({ - resolvePublicAddress: async () => { throw new (class PrivateAddressError extends Error {})("private"); }, -})); +const resolved = vi.hoisted(() => ({ addresses: vi.fn() })); +vi.mock("@/lib/net/publicAddress", () => ({ resolvePublicAddresses: resolved.addresses })); import { describeChatTarget, parseChatConfig } from "@/lib/chat/config"; import { ChatDeliveryError, sendChatMessage } from "@/lib/chat/send"; @@ -60,9 +60,31 @@ describe("sending", () => { }); it("never connects to a Mattermost server on a private address", async () => { + resolved.addresses.mockRejectedValue(new (class PrivateAddressError extends Error {})("private")); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); await expect(sendChatMessage("MATTERMOST", { url: "https://chat.internal.example/hooks/abc" }, "x")).rejects.toThrow("private"); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("connects a Mattermost host name to its vetted address when Node asks the lookup for every address", async () => { + // Node's default family autoselection calls the lookup with `all: true`, which needs an array answer. + expect(net.getDefaultAutoSelectFamily()).toBe(true); + const connections: string[] = []; + const server = net.createServer((socket) => { + connections.push(socket.remoteAddress ?? ""); + socket.destroy(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as net.AddressInfo; + resolved.addresses.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]); + try { + // A plain TCP server: reaching it proves the pinned lookup answered (the name itself never resolves); TLS then fails. + await expect(sendChatMessage("MATTERMOST", { url: `https://chat.example.test:${port}/hooks/abc` }, "x")).rejects.toBeInstanceOf(ChatDeliveryError); + expect(connections).toHaveLength(1); + expect(resolved.addresses).toHaveBeenCalledWith("chat.example.test"); + } finally { + server.close(); + } + }); }); From a0db3d4a728cb6bd95ccd2023c80806f6dcf3350 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:47:35 +0530 Subject: [PATCH 16/79] fix(admission): refuse stale reviews, freeze the review target, filter the queue Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/admission-applicant.ts | 2 +- app/actions/admission/schemas.ts | 1 + .../reviews/[assignmentId]/route.ts | 8 +- .../event-dashboard/admission/ReviewQueue.tsx | 2 +- content/help/applications/reviewing.mdx | 3 +- .../applications/stages-and-reviewers.mdx | 2 + docs/ADMISSION.md | 16 ++- docs/MOBILE_API.md | 15 ++- docs/TESTING.md | 2 +- docs/mobile-api-fixtures/review-item.json | 1 + docs/mobile-api.openapi.yaml | 8 +- lib/admission/assignment.ts | 4 + lib/admission/reviews.ts | 47 ++++++- lib/mobile/admission.ts | 2 +- lib/mobile/schemas/admission.ts | 2 + .../admission-fresh-eyes.db.test.ts | 6 +- .../admission-retention.db.test.ts | 4 +- .../admission-review-guards.db.test.ts | 123 ++++++++++++++++++ tests/integration/admission-review.db.test.ts | 12 +- tests/integration/mobile-admission.db.test.ts | 2 +- 20 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 tests/integration/admission-review-guards.db.test.ts diff --git a/app/actions/admission-applicant.ts b/app/actions/admission-applicant.ts index c2355b65..9b068f69 100644 --- a/app/actions/admission-applicant.ts +++ b/app/actions/admission-applicant.ts @@ -44,7 +44,7 @@ export async function loadApplicationReview(input: unknown): Promise { const gate = await reviewerGate(reviewSchema, input); - return gate.ok ? done(gate.ctx, await submitReview(gate.ctx, gate.data.assignmentId, { scores: gate.data.scores, notes: gate.data.notes })) : gate; + return gate.ok ? done(gate.ctx, await submitReview(gate.ctx, gate.data.assignmentId, { answersRevision: gate.data.answersRevision, scores: gate.data.scores, notes: gate.data.notes })) : gate; } export async function recuseFromApplication(input: unknown): Promise> { diff --git a/app/actions/admission/schemas.ts b/app/actions/admission/schemas.ts index b979a8c0..cbbec3b1 100644 --- a/app/actions/admission/schemas.ts +++ b/app/actions/admission/schemas.ts @@ -69,6 +69,7 @@ export const onBehalfSchema = eventScope.extend({ applicationId: id, response: z export const reviewSchema = eventScope.extend({ assignmentId: id, + answersRevision: z.number().int().min(1), scores: z.record(z.string().max(ADMISSION_CRITERION_MAX), z.number()), notes: z.string().max(ADMISSION_NOTES_MAX), }).strict(); diff --git a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/applications/reviews/[assignmentId]/route.ts b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/applications/reviews/[assignmentId]/route.ts index 5187be26..cc29fd0a 100644 --- a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/applications/reviews/[assignmentId]/route.ts +++ b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/applications/reviews/[assignmentId]/route.ts @@ -6,7 +6,7 @@ import { authedMobileRoute } from "@/lib/mobile/route"; type Params = { orgSlug: string; eventId: string; assignmentId: string }; const POST_ROUTE = "POST /api/mobile/v1/orgs/:orgSlug/events/:eventId/applications/reviews/:assignmentId"; -const bodySchema = z.object({ scores: z.record(z.string().max(60), z.number()), notes: z.string().max(2000) }).strict(); +const bodySchema = z.object({ answersRevision: z.number().int().min(1), scores: z.record(z.string().max(60), z.number()), notes: z.string().max(2000) }).strict(); /** * One assigned application, as the stage allows: a blind stage shows no @@ -23,8 +23,10 @@ export const GET = authedMobileRoute("GET /api/mobile/v1/orgs/:orgSlug/e /** * Submit the review: every criterion scored 1 to 5 and optional notes for * the organisers. Reviews are final once submitted. Only while the stage is - * open and the application is in review there. Needs an Idempotency-Key, - * scoped to this assignment. + * open and the application is in review there. `answersRevision` is the + * one GET returned: answers changed since are CONFLICT ("The answers + * changed. Reload to see them."), and the reloaded submission needs a new + * Idempotency-Key, scoped to this assignment. */ export const POST = authedMobileRoute(POST_ROUTE, async (request, { orgSlug, eventId, assignmentId }, { auth }) => { const ctx = await resolveMobileReviewer(auth.userId, orgSlug, eventId); diff --git a/components/event-dashboard/admission/ReviewQueue.tsx b/components/event-dashboard/admission/ReviewQueue.tsx index 25bcce49..5bb11277 100644 --- a/components/event-dashboard/admission/ReviewQueue.tsx +++ b/components/event-dashboard/admission/ReviewQueue.tsx @@ -60,7 +60,7 @@ export function ReviewQueue({ scope, items }: { scope: AdmissionScope; items: Re onSubmit={(event) => { event.preventDefault(); const numeric = Object.fromEntries(Object.entries(scores).map(([key, value]) => [key, Number(value)])); - run("submit", () => submitApplicationReview({ ...scope, assignmentId: open.assignmentId, scores: numeric, notes }), "Review submitted.", (result) => result.ok && setOpen(null)); + run("submit", () => submitApplicationReview({ ...scope, assignmentId: open.assignmentId, answersRevision: open.answersRevision, scores: numeric, notes }), "Review submitted.", (result) => result.ok && setOpen(null)); }} > {open.criteria.map((criterion) => ( diff --git a/content/help/applications/reviewing.mdx b/content/help/applications/reviewing.mdx index 541389ae..006cfcbf 100644 --- a/content/help/applications/reviewing.mdx +++ b/content/help/applications/reviewing.mdx @@ -17,7 +17,7 @@ Co-organisers with the **Review applications** permission, and owners and admins -A review can't be changed once submitted. If the applicant changes their answers afterwards, the application comes back to your list so you can review the new answers. +A review can't be changed once submitted. If the applicant changes their answers while you have the application open, **Submit review** is refused with "The answers changed. Reload to see them.": choose **Open** again to read the new answers, then score them. If they change their answers after you've submitted, the application comes back to your list so you can review the new answers. ## Conflicts of interest @@ -26,4 +26,5 @@ If you know the applicant, or anything else makes a fair review difficult, choos ## Good to know - You can review only while the stage is **Open** and the application is still in review there. +- If you're taken off a stage's panel, that stage's applications leave your list; so does an application you have a conflict with. - Your notes are for the organisers. Applicants never see reviews or scores. diff --git a/content/help/applications/stages-and-reviewers.mdx b/content/help/applications/stages-and-reviewers.mdx index f6a9db60..2189302c 100644 --- a/content/help/applications/stages-and-reviewers.mdx +++ b/content/help/applications/stages-and-reviewers.mdx @@ -54,6 +54,8 @@ A stage with no panel is reviewed by everyone with **Review applications**. To u A random assignment is drawn from a stored seed, so the same seed always gives the same plan, and every applied run is recorded. If anything changed between the preview and **Apply** (a new application, a reviewer removed), Apply is refused: preview again. +Once anyone has submitted a review in the stage, **Reviewers per application** is fixed, here as in the stage settings: Apply is refused with a different number. Keep the current number; to release a decision short of it, give a reason. + ## Conflicts of interest A reviewer is never assigned an application they have a conflict with: diff --git a/docs/ADMISSION.md b/docs/ADMISSION.md index d1f4984d..be9c6eb5 100644 --- a/docs/ADMISSION.md +++ b/docs/ADMISSION.md @@ -160,7 +160,11 @@ for its team. Per-stage reimbursements key off it. seed and is refused if the plan fingerprint changed; a RANDOM preview without a seed re-rolls. Applied runs are stored (`AdmissionAssignmentRun`, CHECK seed iff RANDOM) and audited (`ADMISSION_REVIEWERS_ASSIGNED`). - Manual assign/unassign is audited too. + Manual assign/unassign is audited too. Apply also sets the stage's + reviews per application; once the stage has a review, a changed number is + refused (as in `updateStage`) before anything is written. Applying with + the current number still works; a shortfall needs the decision's + override reason instead. - Conflicts: the reviewer's own application or their team's; every application when the reviewer applied to the event; declared conflicts (`AdmissionReviewConflict`). Recusing records a conflict and gives the @@ -175,6 +179,16 @@ for its team. Per-stage reimbursements key off it. - Reviews (`AdmissionReview`) are append-only, one per assignment and answers revision. A review counts towards the stage target when it is on the current answers, by someone in the stage's pool now, without a conflict. + The review page returns the `answersRevision` it shows and the review sends + it back; `submitReview` takes the Event lock (as answer edits do) before + reading the assignment and refuses a review of answers that changed since: + "The answers changed. Reload to see them." (web and mobile; on mobile the + reloaded submission needs a new Idempotency-Key). +- The review queue (`listMyReviews`) shows only assignments the reviewer may + still read, checked a stage at a time before any label is built: they are + in the stage's pool now and have no conflict (the same check as the review + page). A closed stage, or an application now in a later stage, doesn't hide a + reviewer's own earlier work. - Blind stages hide names, emails, phone and email answers and team names. ## Decisions diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 50ab9a26..9fefaeb7 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -601,14 +601,21 @@ REVIEW_APPLICATIONS (with JUDGE or MENTOR) gets no organiser routes, like a judg - Reviewers (REVIEW_APPLICATIONS or OWNER/ADMIN, behind the staff authenticator; anyone else, or an event not taking applications, is `NOT_FOUND`): - `GET .../applications/reviews` → `{ "reviews": [{ "assignmentId", "stageName", "stagePosition", "stageOpen", "label", "status", "reviewedCurrent" }] }` - (the caller's own assignments, open stages first; `label` is "Application n" on a blind stage). - - `GET .../applications/reviews/:assignmentId` → `{ "assignmentId", "stageName", "stageOpen", "blind", "criteria", "guidance", "teamName|null", "people": [{ "name", "email|null", "isLeader", "answers": [{ "label", "value" }] }], "reviewed|null", "canReview" }`. + (the caller's own assignments that they may still read, open stages first; + `label` is "Application n" on a blind stage). Assignments on a stage + whose panel no longer includes the caller, or with a conflict of interest, + are left out. + - `GET .../applications/reviews/:assignmentId` → `{ "assignmentId", "answersRevision", "stageName", "stageOpen", "blind", "criteria", "guidance", "teamName|null", "people": [{ "name", "email|null", "isLeader", "answers": [{ "label", "value" }] }], "reviewed|null", "canReview" }`. A blind stage has no names, emails, phone or email answers, or team name. Another reviewer's, a recused one, or one the caller may no longer review (off the stage's panel, a conflict of interest) is `NOT_FOUND`. - - `POST .../applications/reviews/:assignmentId` `{ "scores": { criterion: 1..5 }, "notes" }` + - `POST .../applications/reviews/:assignmentId` `{ "answersRevision", "scores": { criterion: 1..5 }, "notes" }` (Idempotency-Key) → `{ "assignmentId", "outcome": "SUBMITTED", "replaced": null }`. - Final once submitted; only while the stage is open. + Final once submitted; only while the stage is open. `answersRevision` is + the one `GET` returned: when the applicant has changed their answers since, + the review is refused with `CONFLICT` ("The answers changed. Reload to see + them."). Reload, then submit with a new Idempotency-Key: the refused + request's key is spent. - `POST .../applications/reviews/:assignmentId/recuse` (Idempotency-Key) → `{ "assignmentId", "outcome": "RECUSED", "replaced" }`: a conflict of interest is recorded and the least-loaded panel reviewer takes it. diff --git a/docs/TESTING.md b/docs/TESTING.md index 723ac250..b924e477 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -87,7 +87,7 @@ The machine API integration suite additionally exercises persisted credential ha | `judging-rounds.db.test.ts`, `judging-rounds-advance.db.test.ts`, `judging-rounds-assign.db.test.ts`, `judging-rounds-migration.db.test.ts` | Judging rounds ([HACKATHON.md](HACKATHON.md#rounds)): one OPEN round per event, order and status rules, panels, tenancy and owner/admin-only actions; finalist advancement (preview, fingerprint, finalised source, frozen entries, disqualified never advance), final-round results and awards, finalist publication and email; seeded Random runs, fresh eyes, audited manual changes; the migration backfill and expand-phase triggers. The existing judging suites run unchanged on single-round events | | `judging-rounds-frozen.db.test.ts`, `judging-results-guards.db.test.ts`, `judging-recusal.db.test.ts`, `judging-judge-mfa.db.test.ts` | A finalised round's counted reviews frozen until unfinalised, and deletion undo not re-entering a round opened since; award winners kept to the final round's finalists (advancement, publication, public page, payouts), multi-round results only once finalised, the finalists-only page title, crew left out of the results email; judge recusal (assigned, open round, before a submitted review; hidden, never reassigned, in Progress, undo, audited); staff MFA for `JUDGE` collaborators in the Server Actions and the cores' re-check | | `admission-free.db.test.ts`, `admission-paid.db.test.ts`, `admission-holds.db.test.ts`, `admission-review.db.test.ts`, `admission-guards.db.test.ts`, `admission-batches.db.test.ts`, `admission-outputs.db.test.ts`, `admission-retention.db.test.ts` | Applications ([ADMISSION.md](ADMISSION.md)): a free non-hackathon event end to end (stage 1 → shortlist → offer → accept with ticket, decline, expiry, waitlist promotion) and a free team; paid solo and PER_TEAM places never confirmed unpaid (database guard), a free ticket type on a paid event, the PER_MEMBER refusal; ended holds back to OFFERED or EXPIRED with the order cancelled, applying teams kept; offer capacity counting places being paid; seeded reviewer assignment, conflicts, coverage and overrides, append-only reviews and answer revisions, recusal, reviewer isolation behind staff MFA; crew never applications; the old approve/decline/promote/re-accept/waitlist paths refused; tenancy; batch atomicity and cancel; FIFO promotion passing over a full ticket type and an incomplete team; the webhook and machine API carrying ids and counts only; the stage email switch; retention (a deleted registration withdraws and detaches its application with reviews, decisions and answer revisions kept, history deleted only with the event, one application per person, nothing created confirmed, decided teams take nobody new, no offers after the start, a reviewed stage keeps its rubric, reviewed assignments stay, trigger-made transitions recorded and sent). `assignment-planner-parity.test.ts` and `admission-rules.test.ts` (unit) pin the shared planner to the pre-extraction judging plans and cover the pure rules | -| `admission-free.db.test.ts`, `admission-paid.db.test.ts`, `admission-holds.db.test.ts`, `admission-review.db.test.ts`, `admission-guards.db.test.ts`, `admission-batches.db.test.ts`, `admission-outputs.db.test.ts`, `admission-retention.db.test.ts`, `admission-fresh-eyes.db.test.ts`, `admission-crew.db.test.ts` | Applications ([ADMISSION.md](ADMISSION.md)): a free non-hackathon event end to end (stage 1 → shortlist → offer → accept with ticket, decline, expiry, waitlist promotion) and a free team; paid solo and PER_TEAM places never confirmed unpaid (database guard), a free ticket type on a paid event, the PER_MEMBER refusal; ended holds back to OFFERED or EXPIRED with the order cancelled, applying teams kept; offer capacity counting places being paid; seeded reviewer assignment, conflicts, coverage and overrides, append-only reviews and answer revisions, recusal, reviewer isolation behind staff MFA; crew never applications; the old approve/decline/promote/re-accept/waitlist paths refused; tenancy; batch atomicity and cancel; FIFO promotion passing over a full ticket type and an incomplete team; the webhook and machine API carrying ids and counts only; the stage email switch; retention (a deleted registration withdraws and detaches its application with reviews, decisions and answer revisions kept, history deleted only with the event, one application per person, nothing created confirmed, decided teams take nobody new, no offers after the start, a reviewed stage keeps its rubric, reviewed assignments stay, trigger-made transitions recorded and sent); fresh-eyes stages (no earlier reviewer assigned, assigned by hand or reviewing; fixed once opened); applicants added to the crew (an open application withdrawn only once confirmed, then re-offered; a closed one detached so a rejected applicant can volunteer; the crew trigger). `assignment-planner-parity.test.ts` and `admission-rules.test.ts` (unit) pin the shared planner to the pre-extraction judging plans and cover the pure rules | +| `admission-free.db.test.ts`, `admission-paid.db.test.ts`, `admission-holds.db.test.ts`, `admission-review.db.test.ts`, `admission-guards.db.test.ts`, `admission-batches.db.test.ts`, `admission-outputs.db.test.ts`, `admission-retention.db.test.ts`, `admission-fresh-eyes.db.test.ts`, `admission-crew.db.test.ts`, `admission-review-guards.db.test.ts` | Applications ([ADMISSION.md](ADMISSION.md)): a free non-hackathon event end to end (stage 1 → shortlist → offer → accept with ticket, decline, expiry, waitlist promotion) and a free team; paid solo and PER_TEAM places never confirmed unpaid (database guard), a free ticket type on a paid event, the PER_MEMBER refusal; ended holds back to OFFERED or EXPIRED with the order cancelled, applying teams kept; offer capacity counting places being paid; seeded reviewer assignment, conflicts, coverage and overrides, append-only reviews and answer revisions, recusal, reviewer isolation behind staff MFA; crew never applications; the old approve/decline/promote/re-accept/waitlist paths refused; tenancy; batch atomicity and cancel; FIFO promotion passing over a full ticket type and an incomplete team; the webhook and machine API carrying ids and counts only; the stage email switch; retention (a deleted registration withdraws and detaches its application with reviews, decisions and answer revisions kept, history deleted only with the event, one application per person, nothing created confirmed, decided teams take nobody new, no offers after the start, a reviewed stage keeps its rubric, reviewed assignments stay, trigger-made transitions recorded and sent); fresh-eyes stages (no earlier reviewer assigned, assigned by hand or reviewing; fixed once opened); applicants added to the crew (an open application withdrawn only once confirmed, then re-offered; a closed one detached so a rejected applicant can volunteer; the crew trigger); review guards (a review of changed answers refused on the web and mobile, a reviewed stage's target kept by assignment, the queue limited to the current panel without conflicts). `assignment-planner-parity.test.ts` and `admission-rules.test.ts` (unit) pin the shared planner to the pre-extraction judging plans and cover the pure rules | | `saved-views.db.test.ts` | Saved views: each list's own permission (People/Teams `MANAGE_REGISTRATIONS`, Judging owner/admin; a `JUDGE`, a plain member and another organisation refused), params sanitised on write and read, private versus shared listing, only creators rename or share, owners/admins delete shared views (a non-admin can't), event/surface scoping, audits without names, name and object-params CHECKs, cascades from user and event; and the preset filters: People `notCheckedIn`, Teams `looking`, Submissions *Not submitted* entrants (teams and solos, search, no emails, organisation-scoped) and Results `scored` with unchanged ranks | | `filtered-exports.db.test.ts` | Filtered CSV exports on more rows than one server batch (531 registrations, 211 teams, 205 projects): People, Teams, Submissions and Results files hold exactly the rows their filters match with the unfiltered People file unchanged, formula-like cells neutralised, the search kept inside the event, audits with filter names only, each export's own access (a `MANAGE_REGISTRATIONS` co-organiser for People and Teams, owners/admins only for judging) and an empty 404 for members, other organisations and malformed or other-view parameters; Feedback responses paged 25 at a time once each with stable numbers, owner/admin only | Attendance confirmation ([ATTENDANCE.md](ATTENDANCE.md)) unit suites are `attendance-check` (the eligibility rule, badge states and "Said No · paid", open strictly until the start, the 12-hour interval, closed reasons with cancellation first, the `?answer=` preselect, the 256-bit token and its digest, the strict validators and the 500-character note, and the email's absolute links, escaping, reminder subject and release copy for free and paid places), `attendance-outbox` (the bulk capability kind, payload checks, delivery that deletes the row, drops rotated, answered, ineligible and cancelled-event questions, expires at the start, and passes the release kind computed at delivery), `attendance-guest-list` (the People filter, badge, counts and CSV cells), `attend-page` (`/attend/:token`: `noindex`, `no-referrer`, real 404s, own details only, the release warning for free and paid places, the closed states) and `attendance-migration`, plus the attendance rows of `proxy-headers`, `next-config-headers`, `robots`, `chrome-switch`, `check-in-station-route` (the reply, never the note), `event-dashboard-overview-metrics` (the Overview line only for people who may email guests), `attendee-export-route`, `guest-paging`, `account-data-export` and `event-client-dto`. diff --git a/docs/mobile-api-fixtures/review-item.json b/docs/mobile-api-fixtures/review-item.json index ad255978..24bb990f 100644 --- a/docs/mobile-api-fixtures/review-item.json +++ b/docs/mobile-api-fixtures/review-item.json @@ -2,6 +2,7 @@ "ok": true, "data": { "assignmentId": "classign00000000000000001", + "answersRevision": 1, "stageName": "Review", "stageOpen": true, "blind": true, diff --git a/docs/mobile-api.openapi.yaml b/docs/mobile-api.openapi.yaml index e6e5917a..b3167461 100644 --- a/docs/mobile-api.openapi.yaml +++ b/docs/mobile-api.openapi.yaml @@ -2368,7 +2368,7 @@ paths: post: tags: [Applications] summary: Submit the review - description: Every criterion (or "overall") scored 1 to 5, and notes for the organisers. Final once submitted; only while the stage is open. + description: Every criterion (or "overall") scored 1 to 5, and notes for the organisers. Final once submitted; only while the stage is open. `answersRevision` is the one GET returned; answers changed since are `CONFLICT` ("The answers changed. Reload to see them."), and the reloaded submission needs a new Idempotency-Key. parameters: [{ $ref: "#/components/parameters/orgSlug" }, { $ref: "#/components/parameters/eventId" }, { name: assignmentId, in: path, required: true, schema: { type: string } }, { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 8, maxLength: 128 }, description: "One per user action; a retry with the same key and body replays the first answer." }] requestBody: required: true @@ -2377,8 +2377,9 @@ paths: schema: type: object additionalProperties: false - required: [scores, notes] + required: [answersRevision, scores, notes] properties: + answersRevision: { type: integer, minimum: 1 } scores: { type: object, additionalProperties: { type: integer, minimum: 1, maximum: 5 } } notes: { type: string, maxLength: 2000 } responses: @@ -2964,9 +2965,10 @@ components: ReviewItem: type: object additionalProperties: false - required: [assignmentId, stageName, stageOpen, blind, criteria, guidance, teamName, people, reviewed, canReview] + required: [assignmentId, answersRevision, stageName, stageOpen, blind, criteria, guidance, teamName, people, reviewed, canReview] properties: assignmentId: { type: string } + answersRevision: { type: integer, description: "The answers shown; send it back with the review." } stageName: { type: string } stageOpen: { type: boolean } blind: { type: boolean } diff --git a/lib/admission/assignment.ts b/lib/admission/assignment.ts index 42b98acf..d61c3501 100644 --- a/lib/admission/assignment.ts +++ b/lib/admission/assignment.ts @@ -38,6 +38,7 @@ type Result = { ok: true; data: T } | { ok: false; error: string }; const fail = (error: string) => ({ ok: false as const, error }); const POOL_CHANGED = "Some of the people you picked can't review this stage any more. Refresh and try again."; const PLAN_CHANGED = "Applications, reviewers or assignments changed since the preview. Preview again before applying."; +const TARGET_FROZEN = "This stage already has reviews, so its reviews per application can't change. Keep the current number."; export function newAdmissionSeed(): string { return randomBytes(16).toString("hex"); @@ -95,6 +96,9 @@ export async function applyAssignment(ctx: AdmissionContext, input: AssignInput) const seed = input.strategy === "random" ? input.seed! : null; return prisma.$transaction(async (tx) => { await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${ctx.eventId} FOR UPDATE`; + // The review target is frozen once the stage has a review, as in the stage editor. + const current = await tx.admissionStage.findFirst({ where: { id: input.stageId, eventId: ctx.eventId }, select: { reviewsPerApplication: true, _count: { select: { reviews: true } } } }); + if (current && current.reviewsPerApplication !== input.perApplication && current._count.reviews > 0) return fail(TARGET_FROZEN); const built = await buildPlan(tx, ctx, input, seed); if (!built.ok) return built; const { plan, stage } = built.data; diff --git a/lib/admission/reviews.ts b/lib/admission/reviews.ts index 7e5f9e9c..fb8e3fcf 100644 --- a/lib/admission/reviews.ts +++ b/lib/admission/reviews.ts @@ -25,15 +25,16 @@ const NOT_YOURS = "This review isn't assigned to you."; export type ReviewQueueItem = { assignmentId: string; stageName: string; stagePosition: number; stageOpen: boolean; label: string; status: AdmissionAssignmentStatus; reviewedCurrent: boolean }; -/** The reviewer's assignments on this event, open stages first. */ +/** The reviewer's assignments on this event that they may still read, open stages first. */ export async function listMyReviews(ctx: AdmissionContext): Promise { const rows = await prisma.admissionReviewAssignment.findMany({ where: { reviewerUserId: ctx.userId, status: { not: AdmissionAssignmentStatus.RECUSED }, stage: { eventId: ctx.eventId, event: { organisationId: ctx.organisationId } }, application: { status: AdmissionStatus.IN_REVIEW } }, - select: { id: true, status: true, applicationId: true, stage: { select: { name: true, position: true, status: true, blindReview: true } }, application: { select: { answersRevision: true, team: { select: { name: true } }, rsvp: { select: { guestName: true, user: { select: { name: true } } } } } }, reviews: { select: { answersRevision: true } } }, + select: { id: true, status: true, stageId: true, stage: { select: { name: true, position: true, status: true, blindReview: true, panel: { select: { userId: true } } } }, application: { select: { id: true, rsvpId: true, teamId: true, answersRevision: true, team: { select: { name: true } }, rsvp: { select: { guestName: true, user: { select: { name: true } } } } } }, reviews: { select: { answersRevision: true } } }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], take: 500, }); - return rows + // Filtered before any label is built: off the panel now, or a conflict, shows nothing. + return (await readable(prisma, ctx, rows)) .map((row, index) => ({ assignmentId: row.id, stageName: row.stage.name, @@ -46,6 +47,28 @@ export async function listMyReviews(ctx: AdmissionContext): Promise Number(b.stageOpen) - Number(a.stageOpen) || Number(a.reviewedCurrent) - Number(b.reviewedCurrent) || a.stagePosition - b.stagePosition); } +type ReadableRow = { stageId: string; stage: { panel: Array<{ userId: string }> }; application: { id: string; rsvpId: string | null; teamId: string | null } }; + +/** + * The assignments the reviewer may still read, whether or not their stage + * is open: the reviewer is in the stage's pool now and has no conflict with + * the application. Checked a stage at a time; shared by the queue and the + * detail. + */ +async function readable(client: Prisma.TransactionClient, ctx: AdmissionContext, rows: T[]): Promise { + if (rows.length === 0) return rows; + const eligible = await eligibleReviewers(client, ctx.organisationId, ctx.eventId); + const byStage = new Map(); + for (const row of rows) byStage.set(row.stageId, [...(byStage.get(row.stageId) ?? []), row]); + const keep = new Set(); + for (const [stageId, group] of byStage) { + if (!stagePool(group[0]!.stage.panel.map((member) => member.userId), eligible).has(ctx.userId)) continue; + const blocked = await blockedPairs(client, ctx.eventId, group.map((row) => row.application), [ctx.userId], stageId); + for (const row of group) if (!blocked.has(pairKey(ctx.userId, row.application.id))) keep.add(row); + } + return rows.filter((row) => keep.has(row)); +} + async function ownAssignment(client: Prisma.TransactionClient, ctx: AdmissionContext, assignmentId: string) { return client.admissionReviewAssignment.findFirst({ where: { id: assignmentId, reviewerUserId: ctx.userId, stage: { eventId: ctx.eventId, event: { organisationId: ctx.organisationId } } }, @@ -53,20 +76,21 @@ async function ownAssignment(client: Prisma.TransactionClient, ctx: AdmissionCon }); } -export type ReviewItem = { assignmentId: string; stageName: string; stageOpen: boolean; blind: boolean; criteria: string[]; guidance: string; teamName: string | null; people: ApplicantPerson[]; reviewed: { scores: Record; notes: string } | null; canReview: boolean }; +export type ReviewItem = { assignmentId: string; answersRevision: number; stageName: string; stageOpen: boolean; blind: boolean; criteria: string[]; guidance: string; teamName: string | null; people: ApplicantPerson[]; reviewed: { scores: Record; notes: string } | null; canReview: boolean }; export async function loadReviewItem(ctx: AdmissionContext, assignmentId: string): Promise { const assignment = await ownAssignment(prisma, ctx, assignmentId); if (!assignment || assignment.status === AdmissionAssignmentStatus.RECUSED) return null; const { stage, application } = assignment; // Off the panel now, or a conflict: no more reading either. - const pool = stagePool(stage.panel.map((row) => row.userId), await eligibleReviewers(prisma, ctx.organisationId, ctx.eventId)); - if (!pool.has(ctx.userId) || (await blockedPairs(prisma, ctx.eventId, [application], [ctx.userId], assignment.stageId)).size) return null; + if ((await readable(prisma, ctx, [assignment])).length === 0) return null; const shown = await applicationPeople(prisma, application, stage.blindReview); const review = await prisma.admissionReview.findFirst({ where: { assignmentId, answersRevision: application.answersRevision }, select: { scores: true, notes: true } }); const open = stage.status === AdmissionStageStatus.OPEN && application.status === AdmissionStatus.IN_REVIEW && application.currentStageId === assignment.stageId; return { assignmentId, + // Sent back with the review: answers changed since are refused (reload). + answersRevision: application.answersRevision, stageName: stage.name, stageOpen: open, blind: stage.blindReview, @@ -78,13 +102,22 @@ export async function loadReviewItem(ctx: AdmissionContext, assignmentId: string }; } -export async function submitReview(ctx: AdmissionContext, assignmentId: string, input: { scores: Record; notes: string }): Promise { +export const STALE_ANSWERS = "The answers changed. Reload to see them."; + +/** + * Review the answers the reviewer saw (`answersRevision`, from + * loadReviewItem). The Event lock comes first, as for answer edits, so the + * answers can't change between this check and the review. + */ +export async function submitReview(ctx: AdmissionContext, assignmentId: string, input: { answersRevision: number; scores: Record; notes: string }): Promise { return prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${ctx.eventId} FOR UPDATE`; const assignment = await ownAssignment(tx, ctx, assignmentId); if (!assignment || assignment.status === AdmissionAssignmentStatus.RECUSED) return fail(NOT_YOURS); const { stage, application } = assignment; if (stage.status !== AdmissionStageStatus.OPEN) return fail("This stage isn't open for reviews."); if (application.status !== AdmissionStatus.IN_REVIEW || application.currentStageId !== assignment.stageId) return fail("This application has moved on, so it can't be reviewed here any more."); + if (application.answersRevision !== input.answersRevision) return fail(STALE_ANSWERS); const pool = stagePool(stage.panel.map((row) => row.userId), await eligibleReviewers(tx, ctx.organisationId, ctx.eventId)); if (!pool.has(ctx.userId)) return fail("You aren't on this stage's reviewer panel any more."); if ((await blockedPairs(tx, ctx.eventId, [application], [ctx.userId], assignment.stageId)).size) return fail("You have a conflict with this application (or reviewed it in an earlier stage, and this stage wants fresh eyes), so you can't review it."); diff --git a/lib/mobile/admission.ts b/lib/mobile/admission.ts index d86b5cf7..6124fc24 100644 --- a/lib/mobile/admission.ts +++ b/lib/mobile/admission.ts @@ -102,7 +102,7 @@ export async function loadReview(ctx: AdmissionContext, assignmentId: string) { }); } -export async function submitMyReview(ctx: AdmissionContext, assignmentId: string, input: { scores: Record; notes: string }) { +export async function submitMyReview(ctx: AdmissionContext, assignmentId: string, input: { answersRevision: number; scores: Record; notes: string }) { const result = await submitReview(ctx, assignmentId, input); return result.ok ? { ok: true as const, data: reviewWriteDtoSchema.parse({ assignmentId, outcome: "SUBMITTED", replaced: null }) } : result; } diff --git a/lib/mobile/schemas/admission.ts b/lib/mobile/schemas/admission.ts index 8ccaf79f..d408ef10 100644 --- a/lib/mobile/schemas/admission.ts +++ b/lib/mobile/schemas/admission.ts @@ -64,6 +64,8 @@ export const reviewQueueDtoSchema = z.object({ reviews: z.array(reviewQueueItemS export const reviewItemDtoSchema = z.object({ assignmentId: z.string(), + /** The answers shown; send it back with the review (changed answers are refused). */ + answersRevision: z.number().int(), stageName: z.string(), stageOpen: z.boolean(), blind: z.boolean(), diff --git a/tests/integration/admission-fresh-eyes.db.test.ts b/tests/integration/admission-fresh-eyes.db.test.ts index bf8d8ad2..e2c888a2 100644 --- a/tests/integration/admission-fresh-eyes.db.test.ts +++ b/tests/integration/admission-fresh-eyes.db.test.ts @@ -38,7 +38,7 @@ describe.sequential("fresh-eyes admission stages", () => { // Stage 1: r1 reviews Ada, r2 reviews Bo. for (const [reviewer, app] of [[people.r1, a], [people.r2, b]] as const) { const assignment = await prisma.admissionReviewAssignment.create({ data: { stageId: first.id, applicationId: app.application.id, reviewerUserId: reviewer } }); - expect(await submitReview(await reviewerCtx(event.id, reviewer), assignment.id, { scores: { overall: 4 }, notes: "" })).toMatchObject({ ok: true }); + expect(await submitReview(await reviewerCtx(event.id, reviewer), assignment.id, { answersRevision: 1, scores: { overall: 4 }, notes: "" })).toMatchObject({ ok: true }); } const created = await createStage(ctx, { ...stageInput, freshEyes: true }); if (!created.ok) throw new Error(created.error); @@ -69,9 +69,9 @@ describe.sequential("fresh-eyes admission stages", () => { const stale = await prisma.admissionReviewAssignment.create({ data: { stageId: second, applicationId: a.application.id, reviewerUserId: people.r1 } }); const r1 = await reviewerCtx(event.id, people.r1); expect(await loadReviewItem(r1, stale.id)).toBeNull(); - expect(await submitReview(r1, stale.id, { scores: { overall: 5 }, notes: "" })).toMatchObject({ ok: false, error: expect.stringContaining("earlier stage") }); + expect(await submitReview(r1, stale.id, { answersRevision: 1, scores: { overall: 5 }, notes: "" })).toMatchObject({ ok: false, error: expect.stringContaining("earlier stage") }); const own = await prisma.admissionReviewAssignment.findFirstOrThrow({ where: { stageId: second, reviewerUserId: people.r1, applicationId: b.application.id } }); - expect(await submitReview(r1, own.id, { scores: { overall: 5 }, notes: "" })).toMatchObject({ ok: true }); + expect(await submitReview(r1, own.id, { answersRevision: 1, scores: { overall: 5 }, notes: "" })).toMatchObject({ ok: true }); }); it("lets the option change only while the stage is a draft with no assignments or reviews", async () => { diff --git a/tests/integration/admission-retention.db.test.ts b/tests/integration/admission-retention.db.test.ts index 1664b22e..8ab4fcf8 100644 --- a/tests/integration/admission-retention.db.test.ts +++ b/tests/integration/admission-retention.db.test.ts @@ -38,7 +38,7 @@ describe.sequential("application retention", () => { const stage = await prisma.admissionStage.findFirstOrThrow({ where: { eventId: event.id } }); const assignment = await prisma.admissionReviewAssignment.create({ data: { stageId: stage.id, applicationId: a.application.id, reviewerUserId: people.r1 } }); expect(await editApplicationAnswers(a.token, { why: "First" })).toEqual({ ok: true }); - expect(await submitReview(reviewCtx(event.id, event.organisationId, people.r1), assignment.id, { scores: { overall: 3 }, notes: "" })).toMatchObject({ ok: true }); + expect(await submitReview(reviewCtx(event.id, event.organisationId, people.r1), assignment.id, { answersRevision: 2, scores: { overall: 3 }, notes: "" })).toMatchObject({ ok: true }); await decide(ctx, [a.application.id], "WAITLIST"); // Unassigning a reviewed assignment is refused; the review stays either way. expect(await unassignReviewer(ctx, assignment.id)).toMatchObject({ ok: false }); @@ -101,7 +101,7 @@ describe.sequential("application retention", () => { const input = { name: "Review", reviewsPerApplication: 1, blindReview: false, criteria: [], guidance: "", opensAt: null, closesAt: null, status: "OPEN" as const }; expect(await updateStage(ctx, stage.id, { ...input, criteria: ["Fit"] })).toMatchObject({ ok: true }); const assignment = await prisma.admissionReviewAssignment.create({ data: { stageId: stage.id, applicationId: a.application.id, reviewerUserId: people.r2 } }); - expect(await submitReview(reviewCtx(event.id, event.organisationId, people.r2), assignment.id, { scores: { Fit: 4 }, notes: "" })).toMatchObject({ ok: true }); + expect(await submitReview(reviewCtx(event.id, event.organisationId, people.r2), assignment.id, { answersRevision: 1, scores: { Fit: 4 }, notes: "" })).toMatchObject({ ok: true }); expect(await updateStage(ctx, stage.id, { ...input, criteria: ["Fit", "Impact"] })).toMatchObject({ ok: false }); expect(await updateStage(ctx, stage.id, { ...input, criteria: ["Fit"], name: "Screening" })).toMatchObject({ ok: true }); // A withdrawn applicant's offer path is closed too. diff --git a/tests/integration/admission-review-guards.db.test.ts b/tests/integration/admission-review-guards.db.test.ts new file mode 100644 index 00000000..48ad7b55 --- /dev/null +++ b/tests/integration/admission-review-guards.db.test.ts @@ -0,0 +1,123 @@ +import { AdmissionStageStatus } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const identity = vi.hoisted(() => ({ id: "" })); +vi.mock("@/lib/auth", () => ({ auth: vi.fn(async () => (identity.id ? { user: { id: identity.id } } : null)) })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { submitApplicationReview } from "@/app/actions/admission-applicant"; +import { resolveAdmissionReviewer } from "@/lib/admission/access"; +import { editApplicationAnswers } from "@/lib/admission/answers"; +import { applyAssignment, previewAssignment, type AssignInput } from "@/lib/admission/assignment"; +import { listMyReviews, loadReviewItem, STALE_ANSWERS, submitReview } from "@/lib/admission/reviews"; +import { submitMyReview } from "@/lib/mobile/admission"; +import { apply, createAdmissionEvent, grantReviewers, paid, people, setUpAdmission, tearDownAdmission } from "./helpers/admission"; + +/* + * Review guards (docs/ADMISSION.md "Reviewers"): a review of answers that + * changed since the reviewer loaded them is refused (web and mobile); the + * review target is frozen once the stage has a review, assignment included; + * and the queue lists only what the reviewer may still read. + */ + +beforeAll(() => setUpAdmission("Application review guards")); +afterAll(() => tearDownAdmission()); + +const reviewerCtx = async (eventId: string, userId: string) => { + const ctx = await resolveAdmissionReviewer(userId, paid.orgSlug, eventId); + if (!ctx) throw new Error("no reviewer access"); + return ctx; +}; + +describe.sequential("application review guards", () => { + it("refuses a review of answers that changed since they were loaded, on the web and on mobile", async () => { + const { event } = await createAdmissionEvent(); + await grantReviewers(event, people.r1); + await prisma.eventRegistrationForm.create({ data: { eventId: event.id, fields: { create: [{ key: "why", label: "Why?", type: "TEXT", required: false }] } } }); + const a = await apply(event, "changes"); + const b = await apply(event, "steady"); + const stage = await prisma.admissionStage.findFirstOrThrow({ where: { eventId: event.id } }); + const onA = await prisma.admissionReviewAssignment.create({ data: { stageId: stage.id, applicationId: a.application.id, reviewerUserId: people.r1 } }); + const onB = await prisma.admissionReviewAssignment.create({ data: { stageId: stage.id, applicationId: b.application.id, reviewerUserId: people.r1 } }); + const r1 = await reviewerCtx(event.id, people.r1); + + expect(await loadReviewItem(r1, onA.id)).toMatchObject({ answersRevision: 1, canReview: true }); + expect(await editApplicationAnswers(a.token, { why: "Changed after the reviewer opened it" })).toEqual({ ok: true }); + + const web = (assignmentId: string, answersRevision: number) => submitApplicationReview({ organisationSlug: paid.orgSlug, eventId: event.id, assignmentId, answersRevision, scores: { overall: 3 }, notes: "" }); + identity.id = people.r1; + try { + expect(await web(onA.id, 1)).toEqual({ ok: false, error: STALE_ANSWERS }); + expect(await submitMyReview(r1, onA.id, { answersRevision: 1, scores: { overall: 3 }, notes: "" })).toEqual({ ok: false, error: STALE_ANSWERS }); + expect(await prisma.admissionReview.count({ where: { assignmentId: onA.id } })).toBe(0); + + // Reloaded: the current revision is accepted, on either surface. + expect(await loadReviewItem(r1, onA.id)).toMatchObject({ answersRevision: 2 }); + expect(await web(onA.id, 2)).toMatchObject({ ok: true }); + expect(await submitMyReview(r1, onB.id, { answersRevision: 1, scores: { overall: 4 }, notes: "" })).toMatchObject({ ok: true, data: { outcome: "SUBMITTED" } }); + } finally { + identity.id = ""; + } + expect(await prisma.admissionReview.findFirstOrThrow({ where: { assignmentId: onA.id } })).toMatchObject({ answersRevision: 2 }); + }); + + it("keeps the review target once the stage has a review, unless it is unchanged", async () => { + const { event, ctx } = await createAdmissionEvent(); + await grantReviewers(event, people.r1, people.r2); + const a = await apply(event, "target-a"); + await apply(event, "target-b"); + const stage = await prisma.admissionStage.findFirstOrThrow({ where: { eventId: event.id } }); + const assign = async (perApplication: number) => { + const input: AssignInput = { stageId: stage.id, perApplication, mode: "keep", strategy: "balanced", reviewerIds: [people.r1, people.r2] }; + const preview = await previewAssignment(ctx, input); + if (!preview.ok) throw new Error(preview.error); + return applyAssignment(ctx, { ...input, fingerprint: preview.data.fingerprint }); + }; + + // No reviews yet: the target may change. + expect(await assign(2)).toMatchObject({ ok: true }); + const mine = await prisma.admissionReviewAssignment.findFirstOrThrow({ where: { stageId: stage.id, applicationId: a.application.id, reviewerUserId: people.r1 } }); + expect(await submitReview(await reviewerCtx(event.id, people.r1), mine.id, { answersRevision: 1, scores: { overall: 4 }, notes: "" })).toMatchObject({ ok: true }); + + const runs = await prisma.admissionAssignmentRun.count({ where: { stageId: stage.id } }); + expect(await assign(1)).toEqual({ ok: false, error: expect.stringContaining("already has reviews") }); + expect(await prisma.admissionAssignmentRun.count({ where: { stageId: stage.id } })).toBe(runs); + expect(await prisma.admissionStage.findUniqueOrThrow({ where: { id: stage.id } })).toMatchObject({ reviewsPerApplication: 2 }); + // The same target is still a valid assignment. + expect(await assign(2)).toMatchObject({ ok: true }); + expect(await prisma.admissionAssignmentRun.count({ where: { stageId: stage.id } })).toBe(runs + 1); + }); + + it("lists only assignments the reviewer may still read, keeping their submitted reviews", async () => { + const { event } = await createAdmissionEvent(); + await grantReviewers(event, people.r1, people.r2); + const a = await apply(event, "conflicted"); + const b = await apply(event, "listed"); + const stage = await prisma.admissionStage.findFirstOrThrow({ where: { eventId: event.id } }); + const onA = await prisma.admissionReviewAssignment.create({ data: { stageId: stage.id, applicationId: a.application.id, reviewerUserId: people.r1 } }); + const onB = await prisma.admissionReviewAssignment.create({ data: { stageId: stage.id, applicationId: b.application.id, reviewerUserId: people.r1 } }); + const r1 = await reviewerCtx(event.id, people.r1); + expect((await listMyReviews(r1)).map((item) => item.assignmentId).sort()).toEqual([onA.id, onB.id].sort()); + + // Off the stage's panel (still a reviewer of the event): nothing from that stage, names included. + await prisma.admissionStageReviewer.create({ data: { stageId: stage.id, userId: people.r2 } }); + const off = await listMyReviews(r1); + expect(off).toEqual([]); + expect(JSON.stringify(off)).not.toContain("conflicted Person"); + await prisma.admissionStageReviewer.deleteMany({ where: { stageId: stage.id } }); + + // A conflict hides that application only. + await prisma.admissionReviewConflict.create({ data: { eventId: event.id, reviewerUserId: people.r1, applicationId: a.application.id, reason: "declared" } }); + const listed = await listMyReviews(r1); + expect(listed.map((item) => item.assignmentId)).toEqual([onB.id]); + expect(JSON.stringify(listed)).not.toContain("conflicted Person"); + + // A submitted review stays listed once the stage closes: closed isn't unauthorised. + expect(await submitReview(r1, onB.id, { answersRevision: 1, scores: { overall: 5 }, notes: "" })).toMatchObject({ ok: true }); + await prisma.admissionStage.update({ where: { id: stage.id }, data: { status: AdmissionStageStatus.CLOSED } }); + expect(await listMyReviews(r1)).toEqual([expect.objectContaining({ assignmentId: onB.id, stageOpen: false, reviewedCurrent: true, label: expect.stringContaining("listed Person") })]); + expect(await loadReviewItem(r1, onB.id)).toMatchObject({ reviewed: { scores: { overall: 5 } }, canReview: false }); + }); +}); diff --git a/tests/integration/admission-review.db.test.ts b/tests/integration/admission-review.db.test.ts index a8ff9dba..79307f31 100644 --- a/tests/integration/admission-review.db.test.ts +++ b/tests/integration/admission-review.db.test.ts @@ -57,7 +57,7 @@ describe.sequential("application review", () => { expect(await releaseDraftBatch(ctx)).toMatchObject({ ok: false, error: expect.stringMatching(/short of the stage's reviews/) }); for (const reviewer of [people.r1, people.r2]) { const mine = assignments.find((row) => row.applicationId === target && row.reviewerUserId === reviewer)!; - expect(await submitReview(await reviewerCtx(event.id, reviewer), mine.id, { scores: { overall: 4 }, notes: "Strong" })).toMatchObject({ ok: true }); + expect(await submitReview(await reviewerCtx(event.id, reviewer), mine.id, { answersRevision: 1, scores: { overall: 4 }, notes: "Strong" })).toMatchObject({ ok: true }); } expect(await releaseDraftBatch(ctx)).toMatchObject({ ok: true, data: { ACCEPT: 1 } }); expect(await appOf(target)).toMatchObject({ status: AdmissionStatus.OFFERED }); @@ -71,9 +71,9 @@ describe.sequential("application review", () => { const stage = await prisma.admissionStage.findFirstOrThrow({ where: { eventId: event.id } }); const assignment = await prisma.admissionReviewAssignment.create({ data: { stageId: stage.id, applicationId: a.application.id, reviewerUserId: people.r1 } }); const r1 = await reviewerCtx(event.id, people.r1); - expect(await submitReview(r1, assignment.id, { scores: { overall: 9 }, notes: "" })).toMatchObject({ ok: false }); - expect(await submitReview(r1, assignment.id, { scores: { overall: 3 }, notes: "" })).toMatchObject({ ok: true }); - expect(await submitReview(r1, assignment.id, { scores: { overall: 5 }, notes: "" })).toMatchObject({ ok: false }); + expect(await submitReview(r1, assignment.id, { answersRevision: 1, scores: { overall: 9 }, notes: "" })).toMatchObject({ ok: false }); + expect(await submitReview(r1, assignment.id, { answersRevision: 1, scores: { overall: 3 }, notes: "" })).toMatchObject({ ok: true }); + expect(await submitReview(r1, assignment.id, { answersRevision: 1, scores: { overall: 5 }, notes: "" })).toMatchObject({ ok: false }); await expect(prisma.admissionReview.updateMany({ where: { assignmentId: assignment.id }, data: { notes: "rewritten" } })).rejects.toThrow(/append-only/); expect(await editApplicationAnswers(a.token, { why: "Because" })).toEqual({ ok: true }); @@ -85,7 +85,7 @@ describe.sequential("application review", () => { // The old review no longer counts, so the decision needs a fresh one (or an override). expect(await draftDecisions(ctx, { applicationIds: [a.application.id], outcome: "REJECT" })).toMatchObject({ ok: true }); expect(await releaseDraftBatch(ctx)).toMatchObject({ ok: false }); - expect(await submitReview(r1, assignment.id, { scores: { overall: 2 }, notes: "" })).toMatchObject({ ok: true }); + expect(await submitReview(r1, assignment.id, { answersRevision: 2, scores: { overall: 2 }, notes: "" })).toMatchObject({ ok: true }); expect(await releaseDraftBatch(ctx)).toMatchObject({ ok: true }); // Decided: the answers are locked. expect(await editApplicationAnswers(a.token, { why: "Changed my mind" })).toMatchObject({ ok: false }); @@ -106,7 +106,7 @@ describe.sequential("application review", () => { expect(item).toMatchObject({ blind: true, teamName: null }); expect(item!.people[0]).toMatchObject({ name: "Applicant 1", email: null }); expect(await loadReviewItem(r2, mine.id)).toBeNull(); - expect(await submitReview(r2, mine.id, { scores: { overall: 3 }, notes: "" })).toMatchObject({ ok: false }); + expect(await submitReview(r2, mine.id, { answersRevision: 1, scores: { overall: 3 }, notes: "" })).toMatchObject({ ok: false }); expect(await resolveAdmissionReviewer(people.outsider, paid.orgSlug, event.id)).toBeNull(); expect(await recuse(r1, mine.id)).toMatchObject({ ok: true, data: { replacedBy: true } }); diff --git a/tests/integration/mobile-admission.db.test.ts b/tests/integration/mobile-admission.db.test.ts index b36d8540..59bcb61f 100644 --- a/tests/integration/mobile-admission.db.test.ts +++ b/tests/integration/mobile-admission.db.test.ts @@ -105,7 +105,7 @@ describe.sequential("applications through the mobile API", () => { // A reviewer isn't an organiser: no event routes, no summary. expect((await summary(get("/x", r1), at())).status).toBe(404); - const submitted = await json(await submitReview(keyed("/x", { scores: { overall: 4 }, notes: "Good" }, r1, nextKey()), at({ assignmentId: mineToReview.id }))); + const submitted = await json(await submitReview(keyed("/x", { answersRevision: item.body.data.answersRevision, scores: { overall: 4 }, notes: "Good" }, r1, nextKey()), at({ assignmentId: mineToReview.id }))); expect(submitted.body.data).toEqual({ assignmentId: mineToReview.id, outcome: "SUBMITTED", replaced: null }); const b = await apply(event, "recused"); const other = await prisma.admissionReviewAssignment.create({ data: { stageId: stage.id, applicationId: b.application.id, reviewerUserId: people.r1 } }); From a05d46a3c031a002299e795a4c11679970731d83 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:47:35 +0530 Subject: [PATCH 17/79] fix(ops): pin alert webhook delivery to the vetted addresses Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- docs/CONFIGURATION.md | 2 +- docs/SECURITY.md | 2 +- lib/opsAlerts.ts | 26 +++----- lib/opsWebhookAddress.ts | 73 ++++++++++++++++++++--- tests/unit/instance-admin-actions.test.ts | 22 +++---- tests/unit/ops-alerts.test.ts | 13 ++-- tests/unit/ops-webhook-address.test.ts | 69 ++++++++++++++++++++- 7 files changed, 159 insertions(+), 48 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a846dd90..b9b06444 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -107,7 +107,7 @@ Configure SMTP, Google SSO, paid events, backup status, and operational alerts u - **Google SSO** uses a saved client ID only with the saved secret. If that secret is missing or cannot be decrypted, Google sign-in is off rather than paired with an environment secret, and password sign-in keeps working. With no saved client ID, `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` (or the legacy `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`) apply, and the panel cannot switch those off; remove the variables instead. Register `${AUTH_URL}/api/auth/callback/google` with Google. - **Backup posture:** any saved backup field replaces the `BACKUP_*` values together; the last successful backup is entered in UTC. - **Not required in the environment.** Neither the startup check nor `npm run production:check` requires `EMAIL_FROM`, `SMTP_*`, or `AUTH_GOOGLE_*`; the preflight only notes when no SMTP fallback exists. Until SMTP is saved in the panel (or provided as a fallback), production email stays queued. Keeping an environment SMTP fallback is still recommended for bootstrap and disaster recovery (for example, a lost `MFA_ENCRYPTION_KEY`). -- **Operational alerts** are configured only in the panel. Every application process posts them directly to the webhook (for example Mattermost) over outbound HTTPS, so app containers/pods need egress to the webhook host on port 443, in addition to SMTP (typically 465/587), PostgreSQL, Redis, and object storage. Browser crash reports reach the app at `/api/client-errors` and are forwarded the same way. The webhook host must resolve only to public addresses, checked when it is saved and before every post (including **Send test message**), so it cannot be used to probe this host, its network or cloud metadata. Set `OPS_WEBHOOK_ALLOW_PRIVATE=1` to allow a chat server on a private network. Ordinary sign-in outcomes (a wrong password, the authenticator or new-device code step, an unverified email, too many attempts, a missing CSRF token, a declined or expired sign-in) are not alerts; provider and configuration failures still raise "Sign-in failed (…)" (`lib/authLogger.ts`). +- **Operational alerts** are configured only in the panel. Every application process posts them directly to the webhook (for example Mattermost) over outbound HTTPS, so app containers/pods need egress to the webhook host on port 443, in addition to SMTP (typically 465/587), PostgreSQL, Redis, and object storage. Browser crash reports reach the app at `/api/client-errors` and are forwarded the same way. The webhook host must resolve only to public addresses, checked when it is saved and before every post (including **Send test message**), and each post connects only to the addresses just checked, so it cannot be used to probe this host, its network or cloud metadata. Set `OPS_WEBHOOK_ALLOW_PRIVATE=1` to allow a chat server on a private network. Ordinary sign-in outcomes (a wrong password, the authenticator or new-device code step, an unverified email, too many attempts, a missing CSRF token, a declined or expired sign-in) are not alerts; provider and configuration failures still raise "Sign-in failed (…)" (`lib/authLogger.ts`). ## Object storage diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 21319284..39f62ee2 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -140,7 +140,7 @@ This page documents implemented controls and security boundaries. It is not a su - Feedback mode, eligibility, typed answers, and response creation are one locked transaction. PostgreSQL forbids partial identity/certificate linkage, while immutable question snapshots keep historical answers meaningful after form maintenance. - Registration counts are omitted from public payloads when the organiser disables count display. - The personal data export (`GET /api/exports/account`) resolves the user only from the session, returns allow-listed fields of the user's own profile, memberships, registrations (with the user's own attendance confirmation answers: when asked, the reply, when replied, and the note), feedback, co-organiser roles, hackathon projects (without any disqualification reason), applications (status, stage and the account's own answers, never reviews), and participation (`lib/accountDataExport/participation.ts`): crew places (food preference, bio, and whether a photo is on file, never its key), expense claims the user filed, volunteer shifts, hours and contributor certificates (never their tokens), mentor profiles (with the user's own affiliation), help requests and comments the user wrote, office-hour bookings the user made (time and place, never the mentor), team seeker profiles and join requests, and prize payouts to the user's projects (award, amount and date, never the payment reference or note); it excludes passwords, hashes, MFA material, and every bearer token, is sent `private, no-store`, and is audited as `ACCOUNT_DATA_EXPORTED`. -- Operational alerts leave the instance, so `lib/opsAlerts.ts` redacts their text (URLs and connection strings, emails, bearer values, `token=`-style parameters, long opaque strings), reports route patterns instead of request paths, and keeps the fixed console log lines unchanged. Error detail goes only to the alert, never to stdout or audit metadata. Webhook URLs must be `https:` in production and may not embed user info. Any host is accepted, including internal addresses; this outbound request is deliberately limited to the MFA-gated super-admin, uses no redirects, and never returns the response body. `POST /api/client-errors` accepts only same-origin, size-capped, rate-limited reports and always answers an empty `204`. +- Operational alerts leave the instance, so `lib/opsAlerts.ts` redacts their text (URLs and connection strings, emails, bearer values, `token=`-style parameters, long opaque strings), reports route patterns instead of request paths, and keeps the fixed console log lines unchanged. Error detail goes only to the alert, never to stdout or audit metadata. Webhook URLs must be `https:` in production and may not embed user info. Any port is accepted, but the host must resolve only to public addresses (the organisation webhooks' address classifier, `lib/net/publicAddress.ts`) unless the operator sets `OPS_WEBHOOK_ALLOW_PRIVATE=1`, for example for a Mattermost on the same network. The host is checked when the URL is saved and again before every delivery, and the delivery connects only to the addresses just checked (`lib/opsWebhookAddress.ts`), so a DNS answer that changes in between can't reach a private host. The request is limited to the MFA-gated super-admin, follows no redirects, checks TLS against the host name, and never returns the response body. `POST /api/client-errors` accepts only same-origin, size-capped, rate-limited reports and always answers an empty `204`. - Hackathon data ([Hackathon judging](HACKATHON.md)): judges see project content only (title, summary, links, team or entrant display name), never entrant emails, answers, or ticket links. Judges' private comments and the disqualification reason are organiser-only; the entrant sees a neutral "not in judging" state and the reason is excluded from audit metadata and the personal data export. Public results and the project gallery apply the event page's access rules first and show no scores or comments. The scores CSV (`GET /api/exports/events/:eventId/judging`) is owner/admin only, formula-neutralised, `private, no-store`, contains no emails or comments, and is audited as `JUDGING_SCORES_EXPORTED`. The Judging → Submissions CSV (`view=submissions`, owner/admin only) includes the organiser-only disqualification reason and assigned judge names, no emails, and is audited as `JUDGING_SUBMISSIONS_EXPORTED` (filter names only). Every export action is listed on the super-admin Security page (`SECURITY_AUDIT_ACTIONS`). - Mentor Desk ([Mentor Desk](MENTOR_DESK.md)): `MENTOR` is an isolated grant like `JUDGE` and needs the staff authenticator check; every command re-checks the live grant inside its transaction, and queue and visit commands the mentor's availability. Help text and comments are visible only to the team, mentors and organisers and never reach webhooks, chat notifications, the outbox or audit metadata (audit events carry ids only); the CSV leaves the text and all contact details out. A mentor's affiliation is private to organisers and the mentor; the public Mentors section shows only mentors who opted in, with name, bio and skills. Participants prove themselves with the team management link or a signed-in CONFIRMED registration, never the ticket link alone. Nominations are an organiser-only shortlist and never enter judging scores. The help activity, comments, assignment runs, office-hour changes, check-ins and nominations are append-only (database trigger). - Super-admin insight pages never load tokens, credential hashes, registration answers, or message bodies; lists are capped and queries are parameterised. diff --git a/lib/opsAlerts.ts b/lib/opsAlerts.ts index 9bc87006..a041b102 100644 --- a/lib/opsAlerts.ts +++ b/lib/opsAlerts.ts @@ -7,7 +7,7 @@ import { prisma } from "@/lib/db"; import { decryptInstanceConfigSecret } from "@/lib/instanceConfigSecrets"; import { INSTANCE_SETTINGS_ID } from "@/lib/instanceSettings"; import { describeOperationalError, redactSensitiveText } from "@/lib/redactSensitiveText"; -import { isAllowedOpsWebhook, PRIVATE_WEBHOOK_ERROR } from "@/lib/opsWebhookAddress"; +import { postOpsWebhook, PRIVATE_WEBHOOK_ERROR, type OpsWebhookPost } from "@/lib/opsWebhookAddress"; /** * Operational alerts posted to a super-admin configured incoming webhook @@ -263,23 +263,13 @@ export type OpsWebhookResult = { ok: true } | { ok: false; error: string }; async function post(url: string, text: string): Promise { return deliveryScope().run(true, async () => { - // Checked on every post (deliveries and the test), not only when saved. - if (!(await isAllowedOpsWebhook(url))) return { ok: false, error: PRIVATE_WEBHOOK_ERROR }; - try { - const response = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ username: "Space Works Events", text }), - redirect: "error", - cache: "no-store", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - await response.body?.cancel().catch(() => undefined); - return response.ok ? { ok: true } : { ok: false, error: `The webhook responded with HTTP ${response.status}.` }; - } catch (error) { - const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError"); - return { ok: false, error: timedOut ? "The webhook did not respond within 5 seconds." : "Could not reach the webhook." }; - } + // The host is checked on every post (deliveries and the test), not only when + // saved, and the connection is pinned to the addresses just checked. + const outcome = await postOpsWebhook(url, JSON.stringify({ username: "Space Works Events", text }), REQUEST_TIMEOUT_MS) + .catch((): OpsWebhookPost => ({ error: "unreachable" })); + if ("status" in outcome) return outcome.status >= 200 && outcome.status < 300 ? { ok: true } : { ok: false, error: `The webhook responded with HTTP ${outcome.status}.` }; + if (outcome.error === "blocked") return { ok: false, error: PRIVATE_WEBHOOK_ERROR }; + return { ok: false, error: outcome.error === "timeout" ? "The webhook did not respond within 5 seconds." : "Could not reach the webhook." }; }); } diff --git a/lib/opsWebhookAddress.ts b/lib/opsWebhookAddress.ts index 8ae4b784..762f4607 100644 --- a/lib/opsWebhookAddress.ts +++ b/lib/opsWebhookAddress.ts @@ -1,21 +1,76 @@ import "server-only"; -import { resolvePublicAddress } from "@/lib/net/publicAddress"; +import http from "node:http"; +import https from "node:https"; +import net from "node:net"; +import { resolvePublicAddresses } from "@/lib/net/publicAddress"; +import { pinnedLookup, type ResolvedAddress } from "@/lib/webhooks/urlSafety"; export const PRIVATE_WEBHOOK_ERROR = "That webhook address points to a private or local network. Set OPS_WEBHOOK_ALLOW_PRIVATE=1 to allow one, for example a Mattermost on the same network."; /** - * Whether the alert webhook's host resolves only to public addresses, so the - * webhook (and its "Send test message", which reports the HTTP status) can't - * be used to probe this host, its LAN or cloud metadata. A chat server on the - * operator's own network needs the explicit OPS_WEBHOOK_ALLOW_PRIVATE=1. + * The alert webhook host's addresses when they are all public, else null, so + * the webhook (and its "Send test message", which reports the HTTP status) + * can't be used to probe this host, its LAN or cloud metadata. A chat server + * on the operator's own network needs the explicit OPS_WEBHOOK_ALLOW_PRIVATE=1. + * Any port is allowed: only the MFA-gated super admin sets this address. */ -export async function isAllowedOpsWebhook(url: string) { +async function vettedAddresses(url: string): Promise { try { const host = new URL(url).hostname.replace(/^\[|\]$/g, ""); - await resolvePublicAddress(host, process.env.OPS_WEBHOOK_ALLOW_PRIVATE === "1" ? { allowPrivate: true } : {}); - return true; + return await resolvePublicAddresses(host, process.env.OPS_WEBHOOK_ALLOW_PRIVATE === "1" ? { allowPrivate: true } : {}); } catch { - return false; + return null; } } + +/** Whether the alert webhook's host is allowed (see vettedAddresses); checked when the address is saved. */ +export async function isAllowedOpsWebhook(url: string) { + return (await vettedAddresses(url)) !== null; +} + +export type OpsWebhookPost = { status: number; } | { error: "blocked" | "timeout" | "unreachable"; }; + +/** + * POST JSON to the alert webhook, connected only to the addresses checked a + * moment before (pinned, so DNS can't switch them to a private host in + * between), with TLS checked against the host name, no redirects followed + * and no response body read. + */ +export async function postOpsWebhook(url: string, json: string, timeoutMs: number): Promise { + const addresses = await vettedAddresses(url); + if (!addresses) return { error: "blocked" }; + const target = new URL(url); + const hostname = target.hostname.replace(/^\[|\]$/g, ""); + const isHttps = target.protocol === "https:"; + const body = Buffer.from(json, "utf8"); + return new Promise((resolve) => { + let settled = false; + const finish = (outcome: OpsWebhookPost) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(outcome); + }; + const request = (isHttps ? https : http).request({ + protocol: target.protocol, + hostname, + port: target.port || (isHttps ? 443 : 80), + path: `${target.pathname}${target.search}`, + method: "POST", + agent: false, + lookup: pinnedLookup(addresses) as unknown as net.LookupFunction, + ...(isHttps && !net.isIP(hostname) ? { servername: hostname } : {}), + headers: { "content-type": "application/json", "content-length": String(body.length) }, + }, (response) => { + response.resume(); + finish({ status: response.statusCode ?? 0 }); + }); + const timer = setTimeout(() => { + finish({ error: "timeout" }); + request.destroy(); + }, timeoutMs); + request.on("error", () => finish({ error: "unreachable" })); + request.end(body); + }); +} diff --git a/tests/unit/instance-admin-actions.test.ts b/tests/unit/instance-admin-actions.test.ts index a17c4ae6..534c6dc6 100644 --- a/tests/unit/instance-admin-actions.test.ts +++ b/tests/unit/instance-admin-actions.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ closeTransport: vi.fn(), reportOpsAlert: vi.fn(), allowedWebhook: vi.fn(), + postWebhook: vi.fn(), })); vi.mock("@/lib/permissions", () => ({ requireSuperAdminMfa: mocks.requireSuperAdminMfa })); @@ -31,7 +32,7 @@ vi.mock("@/lib/instanceConfigSecrets", async (importOriginal) => ({ encryptInstanceConfigSecret: mocks.encrypt, decryptInstanceConfigSecret: mocks.decrypt, })); vi.mock("@/lib/audit", () => ({ recordAuditEvent: mocks.audit })); -vi.mock("@/lib/opsWebhookAddress", () => ({ isAllowedOpsWebhook: mocks.allowedWebhook, PRIVATE_WEBHOOK_ERROR: "That webhook address points to a private or local network." })); +vi.mock("@/lib/opsWebhookAddress", () => ({ isAllowedOpsWebhook: mocks.allowedWebhook, postOpsWebhook: mocks.postWebhook, PRIVATE_WEBHOOK_ERROR: "That webhook address points to a private or local network." })); vi.mock("@/lib/outbox", () => ({ deliverOutboxBatch: mocks.deliver })); vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidate })); vi.mock("@/lib/email/transporter", async (importOriginal) => ({ @@ -148,18 +149,13 @@ describe("super-admin alert webhook settings", () => { }); it("sends a test message to the saved URL and reports HTTP failures without echoing the URL", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 404 })); - vi.stubGlobal("fetch", fetchMock); - try { - mocks.findSetting.mockResolvedValue({ opsWebhookUrlEncrypted: "encrypted:saved" }); - mocks.decrypt.mockReturnValue(url); - const result = await sendOpsWebhookTest({}); - expect(fetchMock).toHaveBeenCalledWith(url, expect.objectContaining({ method: "POST" })); - expect(result).toEqual({ ok: false, error: "The webhook responded with HTTP 404." }); - expect(JSON.stringify(mocks.audit.mock.calls)).not.toContain("secret-hook-key"); - } finally { - vi.unstubAllGlobals(); - } + mocks.postWebhook.mockResolvedValue({ status: 404 }); + mocks.findSetting.mockResolvedValue({ opsWebhookUrlEncrypted: "encrypted:saved" }); + mocks.decrypt.mockReturnValue(url); + const result = await sendOpsWebhookTest({}); + expect(mocks.postWebhook).toHaveBeenCalledWith(url, expect.stringContaining("Test alert"), expect.any(Number)); + expect(result).toEqual({ ok: false, error: "The webhook responded with HTTP 404." }); + expect(JSON.stringify(mocks.audit.mock.calls)).not.toContain("secret-hook-key"); }); }); diff --git a/tests/unit/ops-alerts.test.ts b/tests/unit/ops-alerts.test.ts index f0766d84..8c951649 100644 --- a/tests/unit/ops-alerts.test.ts +++ b/tests/unit/ops-alerts.test.ts @@ -1,10 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ findSetting: vi.fn(), decrypt: vi.fn(), allowed: vi.fn() })); +const mocks = vi.hoisted(() => ({ findSetting: vi.fn(), decrypt: vi.fn(), allowed: vi.fn(), fetch: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: { instanceSetting: { findUnique: mocks.findSetting } } })); vi.mock("@/lib/instanceConfigSecrets", () => ({ decryptInstanceConfigSecret: mocks.decrypt })); -vi.mock("@/lib/opsWebhookAddress", () => ({ isAllowedOpsWebhook: mocks.allowed, PRIVATE_WEBHOOK_ERROR: "private" })); +// The real post checks the host and connects over a pinned socket (tests/unit/ops-webhook-address.test.ts); here it goes through a fetch-shaped mock. +vi.mock("@/lib/opsWebhookAddress", () => ({ + PRIVATE_WEBHOOK_ERROR: "private", + postOpsWebhook: async (url: string, json: string) => (await mocks.allowed(url)) + ? { status: ((await mocks.fetch(url, { method: "POST", body: json })) as Response).status } + : { error: "blocked" }, +})); import { deliverOpsAlert, @@ -20,7 +26,7 @@ import { } from "@/lib/opsAlerts"; const WEBHOOK = "https://chat.example.test/hooks/abc123"; -const fetchMock = vi.fn(); +const fetchMock = mocks.fetch; function resetAlertState() { delete (globalThis as Record)[Symbol.for("swe.opsAlerts.state")]; @@ -37,7 +43,6 @@ beforeEach(() => { mocks.findSetting.mockResolvedValue({ opsWebhookUrlEncrypted: "enc", opsWebhookEnabled: true, opsWebhookIncludeWarnings: true }); mocks.decrypt.mockReturnValue(WEBHOOK); fetchMock.mockResolvedValue(new Response(null, { status: 200 })); - vi.stubGlobal("fetch", fetchMock); mocks.allowed.mockResolvedValue(true); }); diff --git a/tests/unit/ops-webhook-address.test.ts b/tests/unit/ops-webhook-address.test.ts index 2fef93bc..7e0bb84e 100644 --- a/tests/unit/ops-webhook-address.test.ts +++ b/tests/unit/ops-webhook-address.test.ts @@ -1,7 +1,36 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { isAllowedOpsWebhook } from "@/lib/opsWebhookAddress"; -afterEach(() => vi.unstubAllEnvs()); +const dns = vi.hoisted(() => ({ lookup: vi.fn() })); +vi.mock("node:dns/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, default: { ...actual, lookup: dns.lookup }, lookup: dns.lookup }; +}); + +import { isAllowedOpsWebhook, postOpsWebhook } from "@/lib/opsWebhookAddress"; + +afterEach(() => { + vi.unstubAllEnvs(); + dns.lookup.mockReset(); +}); + +/** A local receiver that counts connections and answers 200. */ +async function receiver() { + const bodies: string[] = []; + let connections = 0; + const server = http.createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + bodies.push(body); + response.end("ok"); + }); + }); + server.on("connection", () => { connections += 1; }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { port: (server.address() as AddressInfo).port, bodies, connections: () => connections, close: () => server.close() }; +} describe("alert webhook address", () => { it("refuses this host, its network and cloud metadata in production", async () => { @@ -26,3 +55,39 @@ describe("alert webhook address", () => { await expect(isAllowedOpsWebhook("not a url")).resolves.toBe(false); }); }); + +describe("alert webhook delivery", () => { + it("never connects to a host that re-resolves to loopback after it was checked", async () => { + vi.stubEnv("NODE_ENV", "production"); + const local = await receiver(); + try { + const url = `https://hooks.example.test:${local.port}/hooks/x`; + // Public when the super admin saved it; loopback by the time an alert goes out. + dns.lookup.mockResolvedValueOnce([{ address: "93.184.215.14", family: 4 }]).mockResolvedValue([{ address: "127.0.0.1", family: 4 }]); + await expect(isAllowedOpsWebhook(url)).resolves.toBe(true); + await expect(postOpsWebhook(url, "{}", 2_000)).resolves.toEqual({ error: "blocked" }); + expect(local.connections()).toBe(0); + } finally { + local.close(); + } + }); + + it("connects only to the address it checked, without resolving the name again", async () => { + const local = await receiver(); + try { + // The name has no real DNS answer: the request reaches the receiver only through the pinned address. + dns.lookup.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]); + await expect(postOpsWebhook(`http://hooks.example.test:${local.port}/hooks/x`, JSON.stringify({ text: "hi" }), 2_000)).resolves.toEqual({ status: 200 }); + expect(dns.lookup).toHaveBeenCalledTimes(1); + expect(local.bodies).toEqual([JSON.stringify({ text: "hi" })]); + } finally { + local.close(); + } + }); + + it("reports a refused connection as unreachable", async () => { + const local = await receiver(); + local.close(); + await expect(postOpsWebhook(`http://127.0.0.1:${local.port}/hooks/x`, "{}", 2_000)).resolves.toEqual({ error: "unreachable" }); + }); +}); From bc5034d926981e7e1775c0c9bdd68b0a5ec0130d Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:47:36 +0530 Subject: [PATCH 18/79] fix(mentor-desk): serialise office-hour slot creation and rescheduling per mentor Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- docs/MENTOR_DESK.md | 12 +++- lib/core/mentorDesk/access.ts | 9 ++- lib/core/mentorDesk/officeHours.ts | 14 ++-- .../mentor-desk-office-overlap.db.test.ts | 69 +++++++++++++++++++ 4 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 tests/integration/mentor-desk-office-overlap.db.test.ts diff --git a/docs/MENTOR_DESK.md b/docs/MENTOR_DESK.md index a1bbdbf5..822a8b8c 100644 --- a/docs/MENTOR_DESK.md +++ b/docs/MENTOR_DESK.md @@ -223,7 +223,13 @@ conflicts stay, so turning the desk off never clears mentoring that happened. `OfficeHourSlot`: a mentor's slot inside the event and the mentor's availability, no overlap with their other slots, capacity 1–50, booking deadline before the start (CHECK `OfficeHourSlot_shape`). Mentors open their -own; organisers open, cancel and reschedule anyone's. +own; organisers open, cancel and reschedule anyone's. Creating or +rescheduling a slot locks the mentor's grant FOR UPDATE as its first lock +(`liveMentor(..., { exclusive: true })`), before any team or slot, so two +schedule changes for one mentor run one after the other and the second sees +the first's slot in its overlap check ("This overlaps another of the +mentor's slots."). Booking keeps the shared grant lock, and a grant removal +still takes the grant before the slots. Booking (teams only: a team member signed in, or the management link) locks the team, then the slot: open, before the start and the deadline; the mentor @@ -338,4 +344,6 @@ earlier visits are not shown to other mentors. `mentor-desk-audit` / `mentor-desk-audit-assign` (grant fencing and work in progress, crew refused, help-claimed recipient, reopen races, round close locks, closed rounds, skipped covered teams, off-shift preview, seed - fingerprint, volunteer-shift clashes, cancellation reasons). + fingerprint, volunteer-shift clashes, cancellation reasons), and + `mentor-desk-office-overlap` (concurrent overlapping slot creations and a + reschedule racing a creation: exactly one succeeds; booking unaffected). diff --git a/lib/core/mentorDesk/access.ts b/lib/core/mentorDesk/access.ts index 24b0a82a..908148ca 100644 --- a/lib/core/mentorDesk/access.ts +++ b/lib/core/mentorDesk/access.ts @@ -75,9 +75,14 @@ export type LiveMentor = { userId: string; skills: string[]; maxActiveTeams: num * window (profile edits, conflict declarations, nominations). The grant row * is read FOR SHARE, so a grant removal (which locks it FOR UPDATE before its * cleanup, ./grant.ts) waits for the command or the command sees it gone. + * `exclusive` reads it FOR UPDATE instead, serialising the mentor's schedule + * changes (office-hour slot creation and rescheduling check overlaps under + * it). It must be the transaction's first lock on the grant: a shared lock + * is never upgraded. */ -export async function liveMentor(tx: Prisma.TransactionClient, eventId: string, userId: string, options: { at?: Date; onShift?: boolean } = {}): Promise { - await tx.$queryRaw`SELECT "id" FROM "EventCollaborator" WHERE "eventId" = ${eventId} AND "userId" = ${userId} FOR SHARE`; +export async function liveMentor(tx: Prisma.TransactionClient, eventId: string, userId: string, options: { at?: Date; onShift?: boolean; exclusive?: boolean } = {}): Promise { + if (options.exclusive) await tx.$queryRaw`SELECT "id" FROM "EventCollaborator" WHERE "eventId" = ${eventId} AND "userId" = ${userId} FOR UPDATE`; + else await tx.$queryRaw`SELECT "id" FROM "EventCollaborator" WHERE "eventId" = ${eventId} AND "userId" = ${userId} FOR SHARE`; const [grant, profile] = await Promise.all([ tx.eventCollaborator.findFirst({ where: { eventId, userId, permissions: { has: EventPermission.MENTOR } }, select: { id: true } }), tx.mentorProfile.findUnique({ where: { eventId_userId: { eventId, userId } }, select: { id: true, version: true, skills: true, maxActiveTeams: true, availability: true } }), diff --git a/lib/core/mentorDesk/officeHours.ts b/lib/core/mentorDesk/officeHours.ts index 73ebe7d8..1f7435e6 100644 --- a/lib/core/mentorDesk/officeHours.ts +++ b/lib/core/mentorDesk/officeHours.ts @@ -7,7 +7,9 @@ import "server-only"; * under the slot lock, and no team cancellation once the slot has started. * Every schedule change and cancellation is logged with its reason * (append-only). Lock order: the mentor's grant (FOR SHARE), the teams, then - * the slot, as the grant removal takes the grant before the slots. + * the slot, as the grant removal takes the grant before the slots. Creating + * or rescheduling a slot takes the grant FOR UPDATE as its first lock, so + * two schedule changes for one mentor can't both pass the overlap check. */ import { OfficeHourBookingStatus, Prisma } from "@prisma/client"; @@ -39,11 +41,11 @@ async function lockSlot(tx: Prisma.TransactionClient, eventId: string, slotId: s return slot; } -/** Takes the slot mentor's grant (FOR SHARE) before any team or slot lock; a slot's mentor never changes. */ -async function slotMentorFirst(tx: Prisma.TransactionClient, eventId: string, slotId: string) { +/** Takes the slot mentor's grant (FOR SHARE, or FOR UPDATE when `exclusive`) before any team or slot lock; a slot's mentor never changes. */ +async function slotMentorFirst(tx: Prisma.TransactionClient, eventId: string, slotId: string, exclusive = false) { const slot = await tx.officeHourSlot.findFirst({ where: { id: slotId, eventId }, select: { mentorUserId: true } }); if (!slot) refuse(COPY.notFound); - return liveMentor(tx, eventId, slot.mentorUserId, { onShift: false }); + return liveMentor(tx, eventId, slot.mentorUserId, { onShift: false, exclusive }); } /** Parses a slot window in the event's zone and checks it against the event, the mentor and their other slots. */ @@ -69,6 +71,8 @@ export async function createOfficeHourSlot(actor: Staff, input: unknown): Promis if (actor.kind === "mentor" && parsed.data.mentorUserId !== actor.userId) return fail(COPY.notFound); return inTransaction(async (tx) => { const event = await deskEvent(tx, actor); + // The mentor's schedule lock first; slotWindow's own (shared) read then holds nothing new. + await liveMentor(tx, event.id, parsed.data.mentorUserId, { onShift: false, exclusive: true }); const window = await slotWindow(tx, event, parsed.data.mentorUserId, parsed.data); return tx.officeHourSlot.create({ data: { eventId: event.id, mentorUserId: parsed.data.mentorUserId, ...window, location: parsed.data.location, capacity: parsed.data.capacity }, select: { id: true } }); }); @@ -90,7 +94,7 @@ export async function rescheduleOfficeHourSlot(actor: Staff, input: unknown): Pr if (!parsed.success) return fail(COPY.invalid); return inTransaction(async (tx) => { const event = await deskEvent(tx, actor); - await slotMentorFirst(tx, event.id, parsed.data.slotId); + await slotMentorFirst(tx, event.id, parsed.data.slotId, true); // Lock order as everywhere: teams before the slot (booking takes them in that order too). const teams = await tx.officeHourBooking.findMany({ where: { slotId: parsed.data.slotId, eventId: event.id, status: OfficeHourBookingStatus.BOOKED }, select: { teamId: true }, orderBy: { teamId: "asc" } }); for (const { teamId } of teams) await lockTeam(tx, teamId); diff --git a/tests/integration/mentor-desk-office-overlap.db.test.ts b/tests/integration/mentor-desk-office-overlap.db.test.ts new file mode 100644 index 00000000..e309dd1a --- /dev/null +++ b/tests/integration/mentor-desk-office-overlap.db.test.ts @@ -0,0 +1,69 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/auth", () => ({ auth: async () => null })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { bookOfficeHour, createOfficeHourSlot, rescheduleOfficeHourSlot } from "@/lib/core/mentorDesk/officeHours"; +import { createTeam, setUpJudgingSuite, tearDownJudgingSuite } from "./helpers/judging"; +import { addMentor, createDeskEvent, goLive, HOUR, local, teamParticipant } from "./helpers/mentorDesk"; + +// docs/MENTOR_DESK.md "Office hours": a mentor's schedule changes are serialised, so slots never overlap. +describe.sequential("mentor desk office-hour overlaps", () => { + beforeAll(setUpJudgingSuite); + afterAll(tearDownJudgingSuite); + + const MINUTE = 60_000; + const OVERLAP = "This overlaps another of the mentor's slots."; + + async function setUp() { + let event = await createDeskEvent(); + const { teamId } = await createTeam(event, `Overlap ${Math.random().toString(36).slice(2, 7)}`); + event = await goLive(event); + const mentor = await addMentor(event, "overlap"); + // Whole minutes, as the slot form sends them. + const base = Math.ceil((Date.now() + 30 * MINUTE) / MINUTE) * MINUTE; + const window = (from: number, minutes = 30) => ({ start: local(new Date(base + from)), end: local(new Date(base + from + minutes * MINUTE)) }); + return { event, mentor, team: await teamParticipant(teamId), window }; + } + + it("lets exactly one of two concurrent overlapping slot creations through", async () => { + const { mentor, window } = await setUp(); + for (const at of [0, HOUR, 2 * HOUR]) { + const results = await Promise.all([ + createOfficeHourSlot(mentor, { mentorUserId: mentor.userId, ...window(at) }), + createOfficeHourSlot(mentor, { mentorUserId: mentor.userId, ...window(at + 15 * MINUTE) }), + ]); + expect(results.filter((result) => result.ok)).toHaveLength(1); + expect(results.filter((result) => !result.ok)).toEqual([{ ok: false, error: OVERLAP }]); + } + expect(await prisma.officeHourSlot.count({ where: { mentorUserId: mentor.userId, cancelledAt: null } })).toBe(3); + }); + + it("refuses a reschedule into an overlap, also against a concurrent creation, and leaves booking alone", async () => { + const { mentor, team, window } = await setUp(); + const create = async (at: number) => { + const created = await createOfficeHourSlot(mentor, { mentorUserId: mentor.userId, ...window(at) }); + if (!created.ok) throw new Error(created.error); + return prisma.officeHourSlot.findUniqueOrThrow({ where: { id: created.data.id } }); + }; + const first = await create(0); + const second = await create(HOUR); + const move = (slot: typeof second, at: number) => rescheduleOfficeHourSlot(mentor, { slotId: slot.id, expectedVersion: slot.version, reason: "Moved", ...window(at) }); + expect(await move(second, 10 * MINUTE)).toEqual({ ok: false, error: OVERLAP }); + + // A reschedule and a creation racing for the same free time: one wins. + const raced = await Promise.all([move(second, 2 * HOUR), createOfficeHourSlot(mentor, { mentorUserId: mentor.userId, ...window(2 * HOUR + 10 * MINUTE) })]); + expect(raced.filter((result) => result.ok)).toHaveLength(1); + expect(raced.filter((result) => !result.ok)).toEqual([{ ok: false, error: OVERLAP }]); + + // Booking keeps its shared lock: it runs alongside a schedule change for the same mentor. + const [booked, added] = await Promise.all([ + bookOfficeHour(team, { slotId: first.id, expectedVersion: first.version }), + createOfficeHourSlot(mentor, { mentorUserId: mentor.userId, ...window(3 * HOUR) }), + ]); + expect(booked).toMatchObject({ ok: true }); + expect(added).toMatchObject({ ok: true }); + expect(await prisma.officeHourBooking.count({ where: { slotId: first.id, status: "BOOKED" } })).toBe(1); + }); +}); From c57aeae08735bca503c22e6db4eb62933e0e22d1 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 17:47:36 +0530 Subject: [PATCH 19/79] fix(judging): freeze entrant projects once results are published Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../help/attending/submitting-a-project.mdx | 2 + content/help/hackathons/submissions.mdx | 2 +- docs/HACKATHON.md | 8 +++ lib/core/judging/entrantSubmissions.ts | 6 ++- lib/core/judging/shared.ts | 11 ++++ .../judging-results-freeze.db.test.ts | 52 +++++++++++++++++++ 6 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 tests/integration/judging-results-freeze.db.test.ts diff --git a/content/help/attending/submitting-a-project.mdx b/content/help/attending/submitting-a-project.mdx index d5f2915a..eebe4e7f 100644 --- a/content/help/attending/submitting-a-project.mdx +++ b/content/help/attending/submitting-a-project.mdx @@ -61,6 +61,8 @@ While submissions are open you can keep improving a submitted project: make your To take it out of judging, choose **Withdraw…** and confirm with **Withdraw**. A withdrawn project isn't judged. You can bring it back with **Submit again** while submissions are still open. +Once the organisers publish the results, projects are final, even if submissions haven't closed yet: you can't edit, submit or withdraw yours. If they unpublish the results, you can make changes again while submissions are open. + ## The submission window The chip at the top of the project shows where things stand: diff --git a/content/help/hackathons/submissions.mdx b/content/help/hackathons/submissions.mdx index a29b5ef4..bac8506d 100644 --- a/content/help/hackathons/submissions.mdx +++ b/content/help/hackathons/submissions.mdx @@ -15,7 +15,7 @@ Set the window in **Judging** → **Setup**, in the **Project submissions** card Only confirmed entrants can submit. On a team event, that's a confirmed team. On an individual event, or for a solo registrant at a team event, it's a confirmed registration. Entrants reach the form from their ticket or team page while the event is live. -A project has a title (up to 120 characters), a plain-text summary (up to 4,000 characters), and optional links to the code, a demo and a video. Entrants can save a draft, submit, edit while the window is open, and withdraw. If you charge an entry fee, the project is only submitted once the fee is paid; see [Project entry fees](/help/hackathons/entry-fees). The entrants' side is covered in [Submitting a hackathon project](/help/attending/submitting-a-project). +A project has a title (up to 120 characters), a plain-text summary (up to 4,000 characters), and optional links to the code, a demo and a video. Entrants can save a draft, submit, edit while the window is open, and withdraw. Publishing the results freezes every project, even before the window closes; unpublishing lifts the freeze. If you charge an entry fee, the project is only submitted once the fee is paid; see [Project entry fees](/help/hackathons/entry-fees). The entrants' side is covered in [Submitting a hackathon project](/help/attending/submitting-a-project). ## Submission receipts diff --git a/docs/HACKATHON.md b/docs/HACKATHON.md index 06bc22b5..5fcfd910 100644 --- a/docs/HACKATHON.md +++ b/docs/HACKATHON.md @@ -123,6 +123,11 @@ Who may edit an entrant's submission (always re-scoped by event): regenerating it retires the old one. Entrants can save a draft, submit, edit while the window is open, and withdraw. +Published results freeze every project, even while the window is still open: +saving, submitting and withdrawing are refused ("Results are published, so +projects can no longer change.") and the editor is read-only +(`resultsFreezeSubmissions` in lib/core/judging/shared.ts, read under the +Event lock that publishing takes for update). Unpublishing lifts the freeze. The editor's window line says when submissions open or close, that they are closed, or, if the dates are missing, that the organisers haven't set them. A team that is disbanded or a solo RSVP that is cancelled has its submission @@ -635,6 +640,9 @@ audited (`JUDGING_ROUND_CREATED`, `_UPDATED`, `_DELETED`, progress filters and totals, search (wildcards literal, surnames never matched), assignments, conflict of interest, event scoping, forged cursors, page clamping, and every winner kept on the results page. + `tests/integration/judging-results-freeze.db.test.ts`: saving, submitting + and withdrawing refused once results are published (the award kept), + allowed before and again after unpublishing. `tests/integration/gallery.db.test.ts`: gallery orders with id tiebreaks, search (title, team, first name only, literal wildcards, bounded), the winners filter and awards order hidden until results are published and diff --git a/lib/core/judging/entrantSubmissions.ts b/lib/core/judging/entrantSubmissions.ts index 416dfe83..73f0be8c 100644 --- a/lib/core/judging/entrantSubmissions.ts +++ b/lib/core/judging/entrantSubmissions.ts @@ -28,6 +28,8 @@ import { ok, personEmail, personSelect, + RESULTS_PUBLISHED, + resultsFreezeSubmissions, WINDOW_CLOSED, } from "./shared"; import { entrantWhere, lockedEntrant, lockEntrantRow } from "./entrantAuth"; @@ -58,6 +60,7 @@ async function writeSubmission( const refusal = entrantRefusal(entrant.status); if (refusal) return fail(refusal); if (!isSubmissionWindowOpen(event, new Date())) return fail(WINDOW_CLOSED); + if (resultsFreezeSubmissions(event)) return fail(RESULTS_PUBLISHED); const existing = await tx.submission.findFirst({ where: entrantWhere(entrant), select: submissionStateSelect }); if (existing?.status === SubmissionStatus.DISQUALIFIED) return fail("This submission can no longer be changed. Contact the organisers."); @@ -138,6 +141,7 @@ export async function withdrawSubmission(auth: EntrantAuth): Promise ({ auth: async () => null })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { createEventAward, loadEntrantSubmission, publishResults, saveSubmissionDraft, setAwardWinner, setJudgingStatus, submitSubmission, unpublishResults, withdrawSubmission } from "@/lib/core/judging"; +import { RESULTS_PUBLISHED } from "@/lib/core/judging/shared"; +import { content, createEvent, criterion, openWindow, organiser, register, setUpJudgingSuite, soloAuth, submissionIdFor, tearDownJudgingSuite } from "./helpers/judging"; + +// docs/HACKATHON.md "Results": published results freeze every project, even while the submission window is open. +describe.sequential("published results freeze projects", () => { + beforeAll(setUpJudgingSuite); + afterAll(tearDownJudgingSuite); + + it("refuses edits and withdrawal once results are published, and allows them before", async () => { + const event = await createEvent({ registrationMode: RegistrationMode.INDIVIDUAL }); + const ctx = await organiser(event); + await openWindow(ctx); + const solo = await register(event, "frozen"); + const entrant = await soloAuth(solo.rsvpId, event.id); + + // Before publication: save, submit and edit as usual. + expect(await saveSubmissionDraft(entrant, content)).toMatchObject({ ok: true }); + expect(await submitSubmission(entrant, content)).toMatchObject({ ok: true, data: { status: SubmissionStatus.SUBMITTED } }); + expect(await loadEntrantSubmission(entrant)).toMatchObject({ canEdit: true }); + const submissionId = await submissionIdFor({ rsvpId: solo.rsvpId }); + + await criterion(ctx, { name: "Impact", weight: 1, maxScore: 10 }); + expect(await setJudgingStatus(ctx, JudgingStatus.OPEN)).toMatchObject({ ok: true }); + expect(await setJudgingStatus(ctx, JudgingStatus.CLOSED)).toMatchObject({ ok: true }); + const award = await createEventAward(ctx, { name: "Overall", description: "" }); + if (!award.ok) throw new Error(award.error); + expect(await setAwardWinner(ctx, { awardId: award.data.id, submissionId, place: 1 })).toMatchObject({ ok: true }); + expect(await publishResults(ctx)).toMatchObject({ ok: true }); + + // Published, with the window still open: nothing changes, and the award stays. + expect(await saveSubmissionDraft(entrant, { ...content, title: "Rewritten" })).toEqual({ ok: false, error: RESULTS_PUBLISHED }); + expect(await submitSubmission(entrant, { ...content, title: "Rewritten" })).toEqual({ ok: false, error: RESULTS_PUBLISHED }); + expect(await withdrawSubmission(entrant)).toEqual({ ok: false, error: RESULTS_PUBLISHED }); + expect(await loadEntrantSubmission(entrant)).toMatchObject({ canEdit: false, window: { isOpen: true } }); + expect(await prisma.submission.findUniqueOrThrow({ where: { id: submissionId } })).toMatchObject({ title: content.title, status: SubmissionStatus.SUBMITTED }); + expect(await prisma.eventAwardWinner.count({ where: { submissionId } })).toBe(1); + + // Unpublished again: the entrant may change the project. + expect(await unpublishResults(ctx)).toMatchObject({ ok: true }); + expect(await loadEntrantSubmission(entrant)).toMatchObject({ canEdit: true }); + expect(await withdrawSubmission(entrant)).toMatchObject({ ok: true, data: { status: SubmissionStatus.WITHDRAWN } }); + }); +}); From 0dc15a6778e9dc3cd722b026d3f48f8d0307583e Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:03:44 +0530 Subject: [PATCH 20/79] fix(payments): payment-first refund writes, provider refund reconciliation, Razorpay refund idempotency Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- lib/payments/http.ts | 4 +- lib/payments/maintenance.ts | 8 +- lib/payments/providers/razorpay.ts | 40 ++++++++-- lib/payments/providers/stripe.ts | 7 ++ lib/payments/providers/types.ts | 6 ++ lib/payments/refundReconcile.ts | 50 +++++++++++++ lib/payments/refunds.ts | 74 ++++++++++++------- .../migration.sql | 13 ++++ prisma/schema.prisma | 11 ++- scripts/verify-production-schema.mjs | 4 +- tests/helpers/fakeProviderFetch.ts | 44 ++++++++--- 11 files changed, 213 insertions(+), 48 deletions(-) create mode 100644 lib/payments/refundReconcile.ts create mode 100644 prisma/migrations/20261120000000_payment_audit_fixes/migration.sql diff --git a/lib/payments/http.ts b/lib/payments/http.ts index 6fc70014..0d1c9c3e 100644 --- a/lib/payments/http.ts +++ b/lib/payments/http.ts @@ -46,13 +46,15 @@ type ProviderRequest = { body?: string; contentType?: string; idempotencyKey?: string; + /** The header that carries `idempotencyKey`: Stripe's Idempotency-Key, or Razorpay's X-Refund-Idempotency for refunds. */ + idempotencyHeader?: "Idempotency-Key" | "X-Refund-Idempotency"; }; /** One JSON request to a provider API. Never logs bodies, keys or URLs with ids. */ export async function providerRequest>(request: ProviderRequest): Promise { const headers: Record = { Authorization: request.authorization, Accept: "application/json" }; if (request.body !== undefined) headers["Content-Type"] = request.contentType ?? "application/json"; - if (request.idempotencyKey) headers["Idempotency-Key"] = request.idempotencyKey; + if (request.idempotencyKey) headers[request.idempotencyHeader ?? "Idempotency-Key"] = request.idempotencyKey; let response: Response; try { response = await fetch(`${providerApiRoot(request.provider)}${request.path}`, { diff --git a/lib/payments/maintenance.ts b/lib/payments/maintenance.ts index 3fd684e6..6d738296 100644 --- a/lib/payments/maintenance.ts +++ b/lib/payments/maintenance.ts @@ -8,6 +8,7 @@ import { enqueuePaymentEmail } from "./email"; import { fillFromWaitlist } from "./operations"; import { lockOrderTargets, seatTargetOf } from "./targets"; import { submitPendingRefund } from "./refunds"; +import { reconcileProviderRefunds } from "./refundReconcile"; import { releaseUnpaidTeam } from "./teamOrders"; const BATCH = 25; @@ -92,7 +93,8 @@ async function sendPaymentReminders(now: Date) { /** * One pass of payment housekeeping, run by the one-minute outbox scheduler: - * release ended holds, and submit or re-check refunds left pending. + * release ended holds, submit or re-check refunds left pending, and ask the + * provider about refunds it accepted but never finished. */ export async function runPaymentMaintenance(now = new Date()) { let expired = 0; @@ -132,5 +134,7 @@ export async function runPaymentMaintenance(now = new Date()) { for (const { id } of refunds) { if ((await submitPendingRefund(id).catch(() => "skipped" as const)) === "submitted") refundsSubmitted += 1; } - return { expired, settled, refundsSubmitted, reminded }; + // Refunds the provider accepted but never reported finished (docs/PAYMENTS.md). + const refundsReconciled = await reconcileProviderRefunds(now).catch(() => 0); + return { expired, settled, refundsSubmitted, refundsReconciled, reminded }; } diff --git a/lib/payments/providers/razorpay.ts b/lib/payments/providers/razorpay.ts index ff4716da..e8833074 100644 --- a/lib/payments/providers/razorpay.ts +++ b/lib/payments/providers/razorpay.ts @@ -56,13 +56,14 @@ export class RazorpayGateway implements PaymentGateway { if (!keyId || !keySecret) throw new PaymentsUnavailable("Razorpay credentials are not configured."); } - private call(method: "GET" | "POST", path: string, body?: unknown) { + private call(method: "GET" | "POST", path: string, body?: unknown, refundIdempotencyKey?: string) { return providerRequest({ provider: "RAZORPAY", method, path, authorization: `Basic ${Buffer.from(`${this.keyId}:${this.keySecret}`).toString("base64")}`, body: body === undefined ? undefined : JSON.stringify(body), + ...(refundIdempotencyKey ? { idempotencyKey: refundIdempotencyKey, idempotencyHeader: "X-Refund-Idempotency" as const } : {}), }); } @@ -141,7 +142,7 @@ export class RazorpayGateway implements PaymentGateway { speed: "normal", receipt: request.reference.slice(0, 40), notes: { refund_id: request.reference }, - }); + }, refundIdempotencyKey(request.idempotencyKey)); if (!isProviderId(result.id)) throw new PaymentsUnavailable("Razorpay did not return a refund id."); return { refundId: result.id, state: REFUND_STATES[String(result.status)] ?? "PENDING" }; } @@ -153,13 +154,40 @@ export class RazorpayGateway implements PaymentGateway { return describePaymentMethod({ type: payment.method, brand: card.network, last4: card.last4, vpa: payment.vpa, bank: payment.bank, wallet: payment.wallet }); } + /** + * Every refund on the payment, page by page. A lookup that can't read them + * all throws (unknown), so a missing match never licenses a second refund. + */ async findRefund(paymentId: string, reference: string): Promise { if (!isProviderId(paymentId)) throw new PaymentsUnavailable("Invalid Razorpay payment id."); - const list = await this.call("GET", `/v1/payments/${paymentId}/refunds?count=100`); - const items = Array.isArray(list.items) ? list.items.map(record) : []; - const match = items.find((item) => record(item.notes).refund_id === reference && isProviderId(item.id)); - return match ? { refundId: match.id as string, state: REFUND_STATES[String(match.status)] ?? "PENDING" } : null; + for (let page = 0; page < REFUND_LOOKUP_PAGES; page += 1) { + const list = await this.call("GET", `/v1/payments/${paymentId}/refunds?count=${REFUND_PAGE_SIZE}&skip=${page * REFUND_PAGE_SIZE}`); + if (!Array.isArray(list.items)) throw new PaymentsUnavailable("Razorpay sent an unreadable refund list."); + const items = list.items.map(record); + const match = items.find((item) => record(item.notes).refund_id === reference && isProviderId(item.id)); + if (match) return { refundId: match.id as string, state: REFUND_STATES[String(match.status)] ?? "PENDING" }; + if (items.length < REFUND_PAGE_SIZE) return null; + } + throw new PaymentsUnavailable("Too many Razorpay refunds to look through."); } + + async getRefundStatus(paymentId: string, refundId: string): Promise { + if (!isProviderId(paymentId) || !isProviderId(refundId)) throw new PaymentsUnavailable("Invalid Razorpay refund id."); + const refund = await this.call("GET", `/v1/payments/${paymentId}/refunds/${refundId}`); + if (refund.id !== refundId) throw new PaymentsUnavailable("Razorpay sent an unreadable refund."); + return REFUND_STATES[String(refund.status)] ?? "PENDING"; + } +} + +const REFUND_PAGE_SIZE = 100; +const REFUND_LOOKUP_PAGES = 20; + +/** + * Razorpay's X-Refund-Idempotency takes letters, digits, "_" and "-" only: + * our `refund:` key, made safe the same way every time. + */ +export function refundIdempotencyKey(key: string) { + return key.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 100); } /** diff --git a/lib/payments/providers/stripe.ts b/lib/payments/providers/stripe.ts index 0c7bf1f5..513c8641 100644 --- a/lib/payments/providers/stripe.ts +++ b/lib/payments/providers/stripe.ts @@ -164,6 +164,13 @@ export class StripeGateway implements PaymentGateway { const match = items.find((item) => record(item.metadata).refund_id === reference && isProviderId(item.id)); return match ? { refundId: match.id as string, state: REFUND_STATES[String(match.status)] ?? "PENDING" } : null; } + + async getRefundStatus(_paymentId: string, refundId: string): Promise { + if (!isProviderId(refundId)) throw new PaymentsUnavailable("Invalid Stripe refund id."); + const refund = await this.call("GET", `/v1/refunds/${refundId}`); + if (refund.id !== refundId) throw new PaymentsUnavailable("Stripe sent an unreadable refund."); + return REFUND_STATES[String(refund.status)] ?? "PENDING"; + } } function refundNotification(refund: Entity): RefundNotification | null { diff --git a/lib/payments/providers/types.ts b/lib/payments/providers/types.ts index f3ee1a57..187f74a8 100644 --- a/lib/payments/providers/types.ts +++ b/lib/payments/providers/types.ts @@ -83,6 +83,12 @@ export interface PaymentGateway { * an unknown outcome checks this first so money is never returned twice. */ findRefund(paymentId: string, reference: string): Promise; + /** + * Where a refund the provider already accepted stands now, by its id, for + * maintenance when no webhook settled it. Throws PaymentsUnavailable when + * the answer is unknown (a timeout or an outage), which is never a failure. + */ + getRefundStatus(paymentId: string, refundId: string): Promise; /** How a captured payment was made, for the receipt; null when the provider doesn't say. */ getPaymentMethod(paymentId: string): Promise; } diff --git a/lib/payments/refundReconcile.ts b/lib/payments/refundReconcile.ts new file mode 100644 index 00000000..1d326a62 --- /dev/null +++ b/lib/payments/refundReconcile.ts @@ -0,0 +1,50 @@ +import "server-only"; + +import { PaymentRefundStatus } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import type { OnlineProvider } from "./http"; +import { lockRefundPayment, settleRefundRow } from "./refunds"; +import { loadPaymentAccount } from "./settings"; + +/** A refund the provider accepted is asked about once it has been pending this long. */ +const RECONCILE_AFTER_MS = 60 * 60_000; +const BATCH = 25; + +/** + * Refunds the provider accepted (they have its id) but whose final webhook + * never arrived: ask the provider where each stands, least recently checked + * first so a few slow refunds never starve the rest. The provider is asked + * outside any lock; the row then moves from PENDING only by a conditional + * update under the payment lock, and only a row that changed is told about. + * A timeout or unreadable answer leaves the refund PENDING, never FAILED, + * and nothing here ever creates a provider refund. + */ +export async function reconcileProviderRefunds(now = new Date()) { + const due = await prisma.paymentRefund.findMany({ + where: { status: PaymentRefundStatus.PENDING, offline: false, externalRefundId: { not: null }, createdAt: { lte: new Date(now.getTime() - RECONCILE_AFTER_MS) } }, + orderBy: [{ lastCheckedAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }], + take: BATCH, + select: { id: true, lastCheckedAt: true, externalRefundId: true, payment: { select: { organisationId: true, provider: true, externalPaymentId: true } } }, + }); + let settled = 0; + for (const refund of due) { + // Marked as checked first, so a refund the provider can't answer for moves to the back. + const claimed = await prisma.$transaction(async (tx) => { + await lockRefundPayment(tx, refund.id); + return tx.paymentRefund.updateMany({ where: { id: refund.id, status: PaymentRefundStatus.PENDING, lastCheckedAt: refund.lastCheckedAt }, data: { lastCheckedAt: now } }); + }); + const { payment } = refund; + if (claimed.count !== 1 || payment.provider === "OFFLINE" || !payment.externalPaymentId || !refund.externalRefundId) continue; + const account = await loadPaymentAccount(payment.organisationId, payment.provider as OnlineProvider); + if (!account) continue; + let state; + try { + state = await account.gateway.getRefundStatus(payment.externalPaymentId, refund.externalRefundId); + } catch { + continue; // Unknown: asked again on a later pass. + } + if (state === PaymentRefundStatus.PENDING) continue; + if (await settleRefundRow(refund.id, payment.organisationId, { state }, new Date())) settled += 1; + } + return settled; +} diff --git a/lib/payments/refunds.ts b/lib/payments/refunds.ts index aea85f68..a3eeca99 100644 --- a/lib/payments/refunds.ts +++ b/lib/payments/refunds.ts @@ -38,11 +38,43 @@ export async function createPendingRefund(tx: Prisma.TransactionClient, params: }); } +/** + * Lock a refund's payment row, the first lock of every refund write: the + * ledger trigger locks Payment after any PaymentRefund write, so writing the + * refund row first would take the two in the opposite order (docs/PAYMENTS.md). + */ +export async function lockRefundPayment(tx: Prisma.TransactionClient, refundId: string) { + const row = await tx.paymentRefund.findUnique({ where: { id: refundId }, select: { paymentId: true } }); + if (row) await tx.$queryRaw`SELECT "id" FROM "Payment" WHERE "id" = ${row.paymentId} FOR UPDATE`; + return row; +} + +/** + * Settle a PENDING refund from the provider's answer, under the payment lock. + * Only a row this call changed is audited and told about, so a webhook, a + * retry and maintenance never send the same failure notice twice. + */ +export async function settleRefundRow(refundId: string, organisationId: string, result: { refundId?: string; state: "PENDING" | "SUCCEEDED" | "FAILED"; }, now: Date) { + return prisma.$transaction(async (tx) => { + if (!(await lockRefundPayment(tx, refundId))) return false; + const changed = await tx.paymentRefund.updateMany({ + where: { id: refundId, status: PaymentRefundStatus.PENDING }, + data: { ...(result.refundId ? { externalRefundId: result.refundId } : {}), status: result.state, settledAt: result.state === "PENDING" ? null : now }, + }); + if (changed.count === 1 && result.state === PaymentRefundStatus.FAILED) { + await recordAuditEvent({ action: "PAYMENT_REFUND_FAILED", organisationId, targetType: "PaymentRefund", targetId: refundId, client: tx }); + await noticeRefundFailed(tx, refundId); + } + return changed.count === 1; + }); +} + /** * Steps 2 and 3: ask the provider, then settle the row from its answer. * Safe to call repeatedly (the outbox tick retries anything left pending): a * lease stops two workers submitting at once, and a refund that was already * submitted is first looked up by our id so money is never returned twice. + * Every write locks the payment first; the provider is called outside them. */ export async function submitPendingRefund(refundId: string): Promise<"submitted" | "skipped" | "failed"> { const now = new Date(); @@ -56,9 +88,12 @@ export async function submitPendingRefund(refundId: string): Promise<"submitted" if (!refund || refund.status !== PaymentRefundStatus.PENDING || refund.offline || refund.externalRefundId) return "skipped"; if (refund.submittedAt && refund.submittedAt.getTime() > now.getTime() - SUBMISSION_LEASE_MS) return "skipped"; // Optimistic claim on the previous submission time: only one worker wins. - const claimed = await prisma.paymentRefund.updateMany({ - where: { id: refund.id, status: PaymentRefundStatus.PENDING, externalRefundId: null, submittedAt: refund.submittedAt }, - data: { submittedAt: now }, + const claimed = await prisma.$transaction(async (tx) => { + await lockRefundPayment(tx, refund.id); + return tx.paymentRefund.updateMany({ + where: { id: refund.id, status: PaymentRefundStatus.PENDING, externalRefundId: null, submittedAt: refund.submittedAt }, + data: { submittedAt: now }, + }); }); if (claimed.count !== 1) return "skipped"; const isRetry = refund.submittedAt !== null; @@ -82,27 +117,13 @@ export async function submitPendingRefund(refundId: string): Promise<"submitted" // A definite refusal (for example the balance is too low) fails the row; // anything else stays pending and is retried on the next tick. if (error instanceof PaymentsUnavailable && error.status && error.status >= 400 && error.status < 500 && error.status !== 409 && error.status !== 429) { - await prisma.paymentRefund.updateMany({ where: { id: refund.id, status: PaymentRefundStatus.PENDING }, data: { status: PaymentRefundStatus.FAILED, settledAt: new Date() } }); - await recordAuditEvent({ action: "PAYMENT_REFUND_FAILED", organisationId: payment.organisationId, targetType: "PaymentRefund", targetId: refund.id }); - await noticeRefundFailed(prisma, refund.id); - return "failed"; + return (await settleRefundRow(refund.id, payment.organisationId, { state: PaymentRefundStatus.FAILED }, new Date())) ? "failed" : "skipped"; } return "skipped"; } - await prisma.paymentRefund.updateMany({ - where: { id: refund.id, status: PaymentRefundStatus.PENDING }, - data: { - externalRefundId: result.refundId, - status: result.state, - settledAt: result.state === "PENDING" ? null : new Date(), - }, - }); - if (result.state === "FAILED") { - await recordAuditEvent({ action: "PAYMENT_REFUND_FAILED", organisationId: payment.organisationId, targetType: "PaymentRefund", targetId: refund.id }); - await noticeRefundFailed(prisma, refund.id); - return "failed"; - } - return "submitted"; + const changed = await settleRefundRow(refund.id, payment.organisationId, result, new Date()); + if (result.state !== PaymentRefundStatus.FAILED) return "submitted"; + return changed ? "failed" : "skipped"; } /** @@ -123,7 +144,7 @@ export async function applyRefundNotification(tx: Prisma.TransactionClient, cont if (refund.externalRefundId && refund.externalRefundId !== notification.refundId) return; await tx.$queryRaw`SELECT "id" FROM "Payment" WHERE "id" = ${refund.paymentId} FOR UPDATE`; if (refund.status !== PaymentRefundStatus.PENDING) return; - await tx.paymentRefund.updateMany({ + const changed = await tx.paymentRefund.updateMany({ where: { id: refund.id, status: PaymentRefundStatus.PENDING }, data: { externalRefundId: notification.refundId, @@ -131,27 +152,28 @@ export async function applyRefundNotification(tx: Prisma.TransactionClient, cont settledAt: notification.state === "PENDING" ? null : new Date(), }, }); - if (notification.state === "FAILED") { + // Only the delivery that changed the row tells anyone. + if (changed.count === 1 && notification.state === "FAILED") { await recordAuditEvent({ action: "PAYMENT_REFUND_FAILED", organisationId: context.organisationId, targetType: "PaymentRefund", targetId: refund.id, client: tx }); await noticeRefundFailed(tx, refund.id); } } /** A refund the provider refused: the organisation's owners and admins are told, so the buyer isn't left waiting. */ -async function noticeRefundFailed(client: Prisma.TransactionClient | typeof prisma, refundId: string) { +async function noticeRefundFailed(client: Prisma.TransactionClient, refundId: string) { const refund = await client.paymentRefund.findUnique({ where: { id: refundId }, select: { amountMinor: true, payment: { select: { currency: true, organisationId: true, order: { select: { reference: true, buyerEmail: true } } } } }, }); if (!refund) return; const { payment } = refund; - await notifyChat(client, payment.organisationId, "refund", `A refund of ${formatMoney(refund.amountMinor, payment.currency)} on order ${payment.order.reference} failed: the payment provider refused it. Refund again from the guest list.`); + await notifyChat(client, payment.organisationId, "refund", `A refund of ${formatMoney(refund.amountMinor, payment.currency)} on order ${payment.order.reference} failed: the payment provider refused it. Retry it from Failed refunds in the event's Finance tab, or on the series page.`); await noticeEach(client, await organiserEmails(client, payment.organisationId), { organisationId: payment.organisationId, subject: `A refund failed: order ${payment.order.reference}`, lines: [ `The payment provider refused a refund of ${formatMoney(refund.amountMinor, payment.currency)} on order ${payment.order.reference} (${payment.order.buyerEmail}).`, - "Check your balance with the provider, then refund again from the guest list, or refund the buyer another way and record it there.", + "Check your balance with the provider, then choose Retry refund under Failed refunds in the event's Finance tab (or on the series page), or refund the buyer another way.", ], }); } diff --git a/prisma/migrations/20261120000000_payment_audit_fixes/migration.sql b/prisma/migrations/20261120000000_payment_audit_fixes/migration.sql new file mode 100644 index 00000000..495886c6 --- /dev/null +++ b/prisma/migrations/20261120000000_payment_audit_fixes/migration.sql @@ -0,0 +1,13 @@ +-- Payment audit fixes (docs/PAYMENTS.md). +-- A failed refund is retried by a new row pointing at it; the unique index +-- lets each failed refund be retried once, even by two organisers at once. +-- Maintenance re-checks refunds the provider accepted but never finished, +-- oldest check first, so the batches stay fair. +ALTER TABLE "PaymentRefund" ADD COLUMN "lastCheckedAt" TIMESTAMP(3), +ADD COLUMN "retryOfRefundId" TEXT; + +CREATE UNIQUE INDEX "PaymentRefund_retryOfRefundId_key" ON "PaymentRefund"("retryOfRefundId"); + +CREATE INDEX "PaymentRefund_status_lastCheckedAt_idx" ON "PaymentRefund"("status", "lastCheckedAt"); + +ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_retryOfRefundId_fkey" FOREIGN KEY ("retryOfRefundId") REFERENCES "PaymentRefund"("id") ON DELETE NO ACTION ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b03c6c14..a2834f9b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1251,16 +1251,23 @@ model PaymentRefund { createdByUserId String? /// When the provider was last asked; a retry first looks the refund up by our id. submittedAt DateTime? + /// When maintenance last asked the provider about a refund it accepted but hasn't finished (docs/PAYMENTS.md). + lastCheckedAt DateTime? + /// The FAILED refund this one retries. Unique, so each failed refund is retried once; the failed row never changes. + retryOfRefundId String? @unique settledAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade) - createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) + payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade) + createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) + retryOf PaymentRefund? @relation("PaymentRefundRetry", fields: [retryOfRefundId], references: [id], onDelete: NoAction) + retry PaymentRefund? @relation("PaymentRefundRetry") @@unique([paymentId, externalRefundId]) @@index([paymentId, status]) @@index([status, createdAt]) + @@index([status, lastCheckedAt]) } /// An append-only correction to an offline payment's method or reference. diff --git a/scripts/verify-production-schema.mjs b/scripts/verify-production-schema.mjs index d4eef4e1..aedaa404 100644 --- a/scripts/verify-production-schema.mjs +++ b/scripts/verify-production-schema.mjs @@ -87,7 +87,7 @@ try { prisma.$queryRaw` SELECT indexname FROM pg_indexes - WHERE schemaname = 'public' AND indexname IN ('Asset_key_key', 'RSVP_event_attendee_unique', 'RSVP_instance_attendee_unique', 'ApiClient_organisationId_status_idx', 'ApiCredential_apiClientId_revokedAt_idx', 'OrganisationPaymentSettings_webhookPathId_key', 'ProcessedWebhookEvent_organisationId_provider_eventId_key', 'Payment_one_pending_per_order', 'PaymentCheckoutAttempt_provider_externalId_key', 'PromoCode_eventId_codeDigest_key', 'PromoCode_eventSeriesId_codeDigest_key', 'CheckInList_eventId_active_name_key', 'CheckInListRecord_listId_rsvpId_key', 'Team_eventId_nameKey_key', 'Team_joinToken_key', 'Team_manageTokenHash_key', 'TeamJoinRequest_one_pending_per_team_rsvp', 'Submission_teamId_key', 'Submission_rsvpId_key', 'SubmissionLink_tokenHash_key', 'JudgeScore_submissionId_judgeUserId_criterionId_key', 'JudgeReview_roundId_submissionId_judgeUserId_key', 'JudgeAssignment_roundId_judgeUserId_submissionId_key', 'JudgingRound_eventId_position_key', 'JudgingRound_one_open_per_event', 'JudgingRoundEntry_roundId_submissionId_key', 'JudgingFinalistNotice_roundId_emailHash_key', 'EventAwardWinner_awardId_place_key', 'EventAwardWinner_awardId_submissionId_key', 'SavedView_eventId_surface_idx', 'RefundRequest_one_open_per_order', 'ExpenseReceipt_storageKey_key', 'RSVP_eventId_createdAt_id_idx', 'RSVP_eventId_checkedInAt_id_idx', 'Team_eventId_createdAt_id_idx', 'Submission_eventId_createdAt_id_idx', 'Submission_eventId_status_submittedAt_id_idx', 'EventInvite_eventId_createdAt_id_idx', 'HelpRequest_one_claim_per_mentor', 'MentorTeamAssignment_live_unique', 'MentorConflict_live_kind', 'OfficeHourBooking_live_team_slot', 'MentoringRound_one_live', 'MentorProfile_eventId_userId_key', 'HelpCategory_eventId_key_key', 'MentoringCheckIn_roundId_teamId_key', 'MentoringVisit_roundId_teamId_key', 'ContributorCertificate_one_live_per_member', 'VolunteerShiftSignup_shiftId_crewMemberId_key', 'AdmissionDecisionBatch_one_draft_per_event', 'AdmissionApplication_rsvpId_key', 'AdmissionApplication_teamId_key', 'AdmissionStage_eventId_position_key', 'AdmissionStageEntry_stageId_applicationId_key', 'AdmissionReviewAssignment_stageId_applicationId_reviewerUse_key', 'AdmissionReview_assignmentId_answersRevision_key', 'ReimbursementNotice_policyId_rsvpId_key', 'AdmissionStatusEvent_unsent_idx') + WHERE schemaname = 'public' AND indexname IN ('Asset_key_key', 'RSVP_event_attendee_unique', 'RSVP_instance_attendee_unique', 'ApiClient_organisationId_status_idx', 'ApiCredential_apiClientId_revokedAt_idx', 'OrganisationPaymentSettings_webhookPathId_key', 'ProcessedWebhookEvent_organisationId_provider_eventId_key', 'Payment_one_pending_per_order', 'PaymentCheckoutAttempt_provider_externalId_key', 'PromoCode_eventId_codeDigest_key', 'PromoCode_eventSeriesId_codeDigest_key', 'CheckInList_eventId_active_name_key', 'CheckInListRecord_listId_rsvpId_key', 'Team_eventId_nameKey_key', 'Team_joinToken_key', 'Team_manageTokenHash_key', 'TeamJoinRequest_one_pending_per_team_rsvp', 'Submission_teamId_key', 'Submission_rsvpId_key', 'SubmissionLink_tokenHash_key', 'JudgeScore_submissionId_judgeUserId_criterionId_key', 'JudgeReview_roundId_submissionId_judgeUserId_key', 'JudgeAssignment_roundId_judgeUserId_submissionId_key', 'JudgingRound_eventId_position_key', 'JudgingRound_one_open_per_event', 'JudgingRoundEntry_roundId_submissionId_key', 'JudgingFinalistNotice_roundId_emailHash_key', 'EventAwardWinner_awardId_place_key', 'EventAwardWinner_awardId_submissionId_key', 'SavedView_eventId_surface_idx', 'RefundRequest_one_open_per_order', 'ExpenseReceipt_storageKey_key', 'RSVP_eventId_createdAt_id_idx', 'RSVP_eventId_checkedInAt_id_idx', 'Team_eventId_createdAt_id_idx', 'Submission_eventId_createdAt_id_idx', 'Submission_eventId_status_submittedAt_id_idx', 'EventInvite_eventId_createdAt_id_idx', 'HelpRequest_one_claim_per_mentor', 'MentorTeamAssignment_live_unique', 'MentorConflict_live_kind', 'OfficeHourBooking_live_team_slot', 'MentoringRound_one_live', 'MentorProfile_eventId_userId_key', 'HelpCategory_eventId_key_key', 'MentoringCheckIn_roundId_teamId_key', 'MentoringVisit_roundId_teamId_key', 'ContributorCertificate_one_live_per_member', 'VolunteerShiftSignup_shiftId_crewMemberId_key', 'AdmissionDecisionBatch_one_draft_per_event', 'AdmissionApplication_rsvpId_key', 'AdmissionApplication_teamId_key', 'AdmissionStage_eventId_position_key', 'AdmissionStageEntry_stageId_applicationId_key', 'AdmissionReviewAssignment_stageId_applicationId_reviewerUse_key', 'AdmissionReview_assignmentId_answersRevision_key', 'ReimbursementNotice_policyId_rsvpId_key', 'AdmissionStatusEvent_unsent_idx', 'PaymentRefund_retryOfRefundId_key') `, prisma.$queryRaw` SELECT trigger_name @@ -139,7 +139,7 @@ try { ...(constraints.some((row) => row.conname === "Submission_rsvpId_fkey" && row.confdeltype === "c") ? [] : ["Submission_rsvpId_fkey:ON DELETE CASCADE"]), ...(constraints.some((row) => row.conname === "SavedView_eventId_fkey" && row.confdeltype === "c") ? [] : ["SavedView_eventId_fkey:ON DELETE CASCADE"]), ...(constraints.some((row) => row.conname === "ExpenseClaim_policyId_fkey" && row.confdeltype === "a") ? [] : ["ExpenseClaim_policyId_fkey:ON DELETE NO ACTION"]), - ...["Asset_key_key", "RSVP_event_attendee_unique", "RSVP_instance_attendee_unique", "ApiClient_organisationId_status_idx", "ApiCredential_apiClientId_revokedAt_idx", "OrganisationPaymentSettings_webhookPathId_key", "ProcessedWebhookEvent_organisationId_provider_eventId_key", "Payment_one_pending_per_order", "PaymentCheckoutAttempt_provider_externalId_key", "PromoCode_eventId_codeDigest_key", "PromoCode_eventSeriesId_codeDigest_key", "CheckInList_eventId_active_name_key", "CheckInListRecord_listId_rsvpId_key", "Team_eventId_nameKey_key", "Team_joinToken_key", "Team_manageTokenHash_key", "TeamJoinRequest_one_pending_per_team_rsvp", "Submission_teamId_key", "Submission_rsvpId_key", "SubmissionLink_tokenHash_key", "JudgeScore_submissionId_judgeUserId_criterionId_key", "JudgeReview_roundId_submissionId_judgeUserId_key", "JudgeAssignment_roundId_judgeUserId_submissionId_key", "JudgingRound_eventId_position_key", "JudgingRound_one_open_per_event", "JudgingRoundEntry_roundId_submissionId_key", "JudgingFinalistNotice_roundId_emailHash_key", "EventAwardWinner_awardId_place_key", "EventAwardWinner_awardId_submissionId_key", "SavedView_eventId_surface_idx", "RefundRequest_one_open_per_order", "ExpenseReceipt_storageKey_key", "RSVP_eventId_createdAt_id_idx", "RSVP_eventId_checkedInAt_id_idx", "Team_eventId_createdAt_id_idx", "Submission_eventId_createdAt_id_idx", "Submission_eventId_status_submittedAt_id_idx", "EventInvite_eventId_createdAt_id_idx", "HelpRequest_one_claim_per_mentor", "MentorTeamAssignment_live_unique", "MentorConflict_live_kind", "OfficeHourBooking_live_team_slot", "MentoringRound_one_live", "MentorProfile_eventId_userId_key", "HelpCategory_eventId_key_key", "MentoringCheckIn_roundId_teamId_key", "MentoringVisit_roundId_teamId_key", "ContributorCertificate_one_live_per_member", "VolunteerShiftSignup_shiftId_crewMemberId_key", "AdmissionDecisionBatch_one_draft_per_event", "AdmissionApplication_rsvpId_key", "AdmissionApplication_teamId_key", "AdmissionStage_eventId_position_key", "AdmissionStageEntry_stageId_applicationId_key", "AdmissionReviewAssignment_stageId_applicationId_reviewerUse_key", "AdmissionReview_assignmentId_answersRevision_key", "ReimbursementNotice_policyId_rsvpId_key", "AdmissionStatusEvent_unsent_idx"].filter((name) => !indexNames.has(name)), + ...["Asset_key_key", "RSVP_event_attendee_unique", "RSVP_instance_attendee_unique", "ApiClient_organisationId_status_idx", "ApiCredential_apiClientId_revokedAt_idx", "OrganisationPaymentSettings_webhookPathId_key", "ProcessedWebhookEvent_organisationId_provider_eventId_key", "Payment_one_pending_per_order", "PaymentCheckoutAttempt_provider_externalId_key", "PromoCode_eventId_codeDigest_key", "PromoCode_eventSeriesId_codeDigest_key", "CheckInList_eventId_active_name_key", "CheckInListRecord_listId_rsvpId_key", "Team_eventId_nameKey_key", "Team_joinToken_key", "Team_manageTokenHash_key", "TeamJoinRequest_one_pending_per_team_rsvp", "Submission_teamId_key", "Submission_rsvpId_key", "SubmissionLink_tokenHash_key", "JudgeScore_submissionId_judgeUserId_criterionId_key", "JudgeReview_roundId_submissionId_judgeUserId_key", "JudgeAssignment_roundId_judgeUserId_submissionId_key", "JudgingRound_eventId_position_key", "JudgingRound_one_open_per_event", "JudgingRoundEntry_roundId_submissionId_key", "JudgingFinalistNotice_roundId_emailHash_key", "EventAwardWinner_awardId_place_key", "EventAwardWinner_awardId_submissionId_key", "SavedView_eventId_surface_idx", "RefundRequest_one_open_per_order", "ExpenseReceipt_storageKey_key", "RSVP_eventId_createdAt_id_idx", "RSVP_eventId_checkedInAt_id_idx", "Team_eventId_createdAt_id_idx", "Submission_eventId_createdAt_id_idx", "Submission_eventId_status_submittedAt_id_idx", "EventInvite_eventId_createdAt_id_idx", "HelpRequest_one_claim_per_mentor", "MentorTeamAssignment_live_unique", "MentorConflict_live_kind", "OfficeHourBooking_live_team_slot", "MentoringRound_one_live", "MentorProfile_eventId_userId_key", "HelpCategory_eventId_key_key", "MentoringCheckIn_roundId_teamId_key", "MentoringVisit_roundId_teamId_key", "ContributorCertificate_one_live_per_member", "VolunteerShiftSignup_shiftId_crewMemberId_key", "AdmissionDecisionBatch_one_draft_per_event", "AdmissionApplication_rsvpId_key", "AdmissionApplication_teamId_key", "AdmissionStage_eventId_position_key", "AdmissionStageEntry_stageId_applicationId_key", "AdmissionReviewAssignment_stageId_applicationId_reviewerUse_key", "AdmissionReview_assignmentId_answersRevision_key", "ReimbursementNotice_policyId_rsvpId_key", "AdmissionStatusEvent_unsent_idx", "PaymentRefund_retryOfRefundId_key"].filter((name) => !indexNames.has(name)), ...["audit_fallback_organisation", "audit_fallback_membership", "audit_fallback_event", "audit_fallback_eventseries", "audit_fallback_organisationinvite", "audit_fallback_eventinvite", "audit_fallback_seriesinvite", "audit_fallback_rsvp", "audit_fallback_eventcollaborator", "audit_fallback_eventcollaboratorinvite", "audit_fallback_apiclient", "audit_fallback_apiclientscope", "audit_fallback_apicredential", "payment_ledger_guard", "payment_refund_guard", "checkout_attempt_guard", "event_currency_lock", "payment_amendment_append_only", "rsvp_waitlist_dates", "ticket_type_target_fixed", "rsvp_ticket_type_target", "promo_code_ticket_type_target", "series_currency_lock", "judging_criterion_round", "judge_assignment_round", "judge_review_round", "judging_round_entry_event", "event_current_judging_round", "event_judging_round_mirror", "help_request_activity_append_only", "help_request_comment_append_only", "mentor_assignment_run_append_only", "office_hour_change_append_only", "mentoring_check_in_append_only", "mentor_nomination_append_only", "volunteer_hours_entry_guard", "volunteer_hours_guard", "volunteer_correction_append_only", "admission_application_shape", "admission_stage_entry_event", "admission_assignment_event", "admission_review_event", "admission_decision_guard", "admission_conflict_event", "admission_review_append_only", "admission_answer_revision_append_only", "admission_stage_entry_append_only", "rsvp_admission_application", "team_admission_application", "rsvp_admission_status_guard", "team_admission_status_guard", "rsvp_admission_accept", "team_admission_accept", "admission_application_keep", "rsvp_admission_registration_guard", "team_admission_registration_guard", "rsvp_admission_withdraw", "team_admission_withdraw", "admission_status_event", "reimbursement_policy_stage_event", "expense_claim_policy_event", "user_admission_scrub", "rsvp_admission_crew_detached"].filter((name) => !triggerNames.has(name)), ]; // Attendance confirmation (docs/ATTENDANCE.md, 20261111000000_attendance_confirmation). diff --git a/tests/helpers/fakeProviderFetch.ts b/tests/helpers/fakeProviderFetch.ts index 38b6d3b3..2a6aaf48 100644 --- a/tests/helpers/fakeProviderFetch.ts +++ b/tests/helpers/fakeProviderFetch.ts @@ -11,10 +11,13 @@ type Page = { provider: "razorpay" | "stripe"; id: string; reference: string; am export function createFakeProvider() { const pages = new Map(); - const requests: Array<{ provider: string; method: string; path: string; body: string; }> = []; - const refunds: Array<{ id: string; paymentId: string; amount: number; reference: string; }> = []; + const requests: Array<{ provider: string; method: string; path: string; body: string; headers: Record; }> = []; + const refunds: Array<{ id: string; paymentId: string; amount: number; reference: string; status: string; }> = []; let sequence = 0; let failNext: number | null = null; + let unreachableNext = false; + /** The provider's status for refunds it creates: "processed"/"succeeded" unless a test says "pending". */ + let newRefundStatus: "done" | "pending" = "done"; const id = (prefix: string) => `${prefix}_${(sequence += 1).toString(36)}${crypto.randomBytes(3).toString("hex")}`; const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); @@ -24,7 +27,11 @@ export function createFakeProvider() { const path = `/${rest.join("/")}`; const method = init.method ?? "GET"; const body = typeof init.body === "string" ? init.body : ""; - requests.push({ provider, method, path: `${path}${url.search}`, body }); + requests.push({ provider, method, path: `${path}${url.search}`, body, headers: Object.fromEntries(new Headers(init.headers).entries()) }); + if (unreachableNext) { + unreachableNext = false; + throw new TypeError("fetch failed"); + } if (failNext) { const status = failNext; failNext = null; @@ -50,14 +57,24 @@ export function createFakeProvider() { } return json(200, { id: page.id, status: page.status === "open" ? "created" : page.status === "paid" ? "paid" : "cancelled", currency: page.currency, payments: page.paymentId ? [{ payment_id: page.paymentId, amount: page.amount, status: "captured" }] : [] }); } + const one = path.match(/^\/v1\/payments\/(\w+)\/refunds\/(\w+)$/); + if (one && method === "GET") { + const found = refunds.find((item) => item.paymentId === one[1] && item.id === one[2]); + return found ? json(200, { id: found.id, status: found.status }) : json(404, { error: { code: "BAD_REQUEST_ERROR" } }); + } const refund = path.match(/^\/v1\/payments\/(\w+)\/refunds?$/); if (refund && method === "POST") { const data = JSON.parse(body); - const created = { id: id("rfnd"), paymentId: refund[1]!, amount: data.amount, reference: data.notes?.refund_id }; + const created = { id: id("rfnd"), paymentId: refund[1]!, amount: data.amount, reference: data.notes?.refund_id, status: newRefundStatus === "pending" ? "pending" : "processed" }; refunds.push(created); - return json(200, { id: created.id, status: "processed" }); + return json(200, { id: created.id, status: created.status }); + } + if (refund) { + const count = Number(url.searchParams.get("count") ?? 10); + const skip = Number(url.searchParams.get("skip") ?? 0); + const items = refunds.filter((item) => item.paymentId === refund[1]).slice(skip, skip + count); + return json(200, { items: items.map((item) => ({ id: item.id, status: item.status, notes: { refund_id: item.reference } })) }); } - if (refund) return json(200, { items: refunds.filter((item) => item.paymentId === refund[1]).map((item) => ({ id: item.id, status: "processed", notes: { refund_id: item.reference } })) }); } else { const form = new URLSearchParams(body); if (method === "GET" && path === "/v1/balance") return json(200, {}); @@ -83,11 +100,16 @@ export function createFakeProvider() { const intent = path.match(/^\/v1\/payment_intents\/(\w+)$/); if (method === "GET" && intent) return json(200, { id: intent[1], latest_charge: { payment_method_details: { type: "upi", upi: { vpa: "buyer@okicici" } } } }); if (method === "POST" && path === "/v1/refunds") { - const created = { id: id("re"), paymentId: form.get("payment_intent")!, amount: Number(form.get("amount")), reference: form.get("metadata[refund_id]")! }; + const created = { id: id("re"), paymentId: form.get("payment_intent")!, amount: Number(form.get("amount")), reference: form.get("metadata[refund_id]")!, status: newRefundStatus === "pending" ? "pending" : "succeeded" }; refunds.push(created); - return json(200, { id: created.id, status: "succeeded" }); + return json(200, { id: created.id, status: created.status }); + } + if (method === "GET" && path === "/v1/refunds") return json(200, { data: refunds.filter((item) => item.paymentId === url.searchParams.get("payment_intent")).map((item) => ({ id: item.id, status: item.status, metadata: { refund_id: item.reference } })) }); + const stripeRefund = path.match(/^\/v1\/refunds\/(\w+)$/); + if (method === "GET" && stripeRefund) { + const found = refunds.find((item) => item.id === stripeRefund[1]); + return found ? json(200, { id: found.id, status: found.status }) : json(404, { error: { code: "resource_missing" } }); } - if (method === "GET" && path === "/v1/refunds") return json(200, { data: refunds.filter((item) => item.paymentId === url.searchParams.get("payment_intent")).map((item) => ({ id: item.id, status: "succeeded", metadata: { refund_id: item.reference } })) }); } return json(404, { error: { code: "NOT_FOUND" } }); } @@ -98,6 +120,10 @@ export function createFakeProvider() { refunds, /** The next provider call answers with this HTTP status. */ failNextWith(status: number) { failNext = status; }, + /** The next provider call never gets an answer (a timeout or a dropped connection). */ + dropNext() { unreachableNext = true; }, + /** Refunds created from now on stay pending at the provider ("done" settles them at once). */ + refundsSettle(mode: "done" | "pending") { newRefundStatus = mode; }, /** The buyer pays a page; returns the provider payment id. */ pay(pageId: string) { const page = pages.get(pageId)!; From ec0e3e4e0c80f33ad8b9afcf5419e90f9a0600fb Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:03:44 +0530 Subject: [PATCH 21/79] fix(payments): owners and admins can retry a failed refund once Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/payment-operations.ts | 23 ++++- .../[orgSlug]/series/[seriesId]/page.tsx | 12 ++- .../event-dashboard/panels/FinancePanel.tsx | 7 ++ components/payments/FailedRefundsList.tsx | 49 +++++++++++ lib/payments/operations.ts | 1 + lib/payments/operations/retryRefund.ts | 86 +++++++++++++++++++ ...hboard__orgSlug__series__seriesId__page.ts | 5 +- ...__event-dashboard__panels__FinancePanel.ts | 9 +- 8 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 components/payments/FailedRefundsList.tsx create mode 100644 lib/payments/operations/retryRefund.ts diff --git a/app/actions/payment-operations.ts b/app/actions/payment-operations.ts index 7073ad50..a41979fd 100644 --- a/app/actions/payment-operations.ts +++ b/app/actions/payment-operations.ts @@ -10,7 +10,9 @@ import { prisma } from "@/lib/db"; import { canAccessEvent } from "@/lib/eventAccess"; import { trackedRevalidation } from "@/lib/frontend/revalidation"; import { parseMoneyToMinor } from "@/lib/payments/money"; -import { AdmissionManagedError, amendOfflinePayment, PaymentOperationError, refundOrder, sendPaymentLink, settleOrderByHand } from "@/lib/payments/operations"; +import { AdmissionManagedError, amendOfflinePayment, PaymentOperationError, refundOrder, retryFailedRefund, sendPaymentLink, settleOrderByHand } from "@/lib/payments/operations"; +import { getMembership } from "@/lib/permissions"; +import { hasRecentAuthentication } from "@/lib/reauth"; import { submitManualTicketOrderCore } from "@/lib/rsvpCore"; import { attendeeNameSchema } from "@/lib/validators"; @@ -87,6 +89,25 @@ export async function refundPaidOrder(input: unknown): Promise { })); } +const retrySchema = z.object({ ...target, refundId: z.string().trim().min(1).max(64) }).strict(); + +/** + * Retry a refund the provider refused (docs/PAYMENTS.md). Owners and admins + * only (MANAGE_REGISTRATIONS collaborators can't), after a fresh sign-in. + */ +export async function retryFailedRefundAction(input: unknown): Promise { + const parsed = retrySchema.safeParse(input); + if (!parsed.success) return { ok: false, error: "Choose a refund." }; + const context = await access(parsed.data); + if ("error" in context) return { ok: false, error: context.error ?? "Event not found." }; + const membership = await getMembership(context.userId, context.organisation.id); + if (membership?.role !== "OWNER" && membership?.role !== "ADMIN") return { ok: false, error: "Only the organisation's owners and admins can retry refunds." }; + if (!(await hasRecentAuthentication())) return { ok: false, error: "For security, sign in again before retrying a refund." }; + const refund = await prisma.paymentRefund.findFirst({ where: { id: parsed.data.refundId, payment: { organisationId: context.organisation.id, order: context.scope } }, select: { id: true } }); + if (!refund) return { ok: false, error: "Refund not found." }; + return run(context.path, () => retryFailedRefund({ refundId: refund.id, organisationId: context.organisation.id, actorUserId: context.userId })); +} + const settleSchema = z.object({ ...orderTarget, mode: z.enum(["offline", "waive"]), diff --git a/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx b/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx index da99bd3c..e1ded04f 100644 --- a/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx +++ b/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx @@ -10,7 +10,8 @@ import { TicketTypesManager } from "@/components/event-dashboard/settings/Ticket import { AddOnsManager } from "@/components/event-dashboard/settings/AddOnsManager"; import { PromoCodesManager, RefundPolicyControl, TransfersControl } from "@/components/event-dashboard/settings/PromoCodesManager"; import { EventReportDownloadButton } from "@/components/reports/EventReportDownloadButton"; -import { Card, PageHeader } from "@/components/ui/display"; +import { Card, CardHeader, PageHeader } from "@/components/ui/display"; +import { FailedRefundsList } from "@/components/payments/FailedRefundsList"; import { formatDayTime } from "@/lib/format/datetime"; import type { Metadata } from "next"; import { cachedDateTimeFormat } from "@/lib/format/intlCache"; @@ -27,7 +28,7 @@ function eventDateInput(date: Date, timeZone: string) { } export default async function SeriesManagePage({ params }: Props) { - const { series, isAdmin, organisation, canManageInvites, invites, canManageSchedule, canDownloadReports, finishedInstances, firstInstanceId, now, attendeesPage, rollup, dates, tickets, revenue } = await loadSeriesManageView({ params }); + const { series, isAdmin, organisation, canManageInvites, invites, canManageSchedule, canDownloadReports, finishedInstances, firstInstanceId, now, attendeesPage, rollup, dates, tickets, revenue, failedRefunds } = await loadSeriesManageView({ params }); return (
@@ -87,6 +88,13 @@ export default async function SeriesManagePage({ params }: Props) { {revenue && hasRevenue(revenue) ? : null} + {failedRefunds.length > 0 ? ( + + Failed refunds} description="Refunds the payment provider refused. Check your balance with the provider, then retry." /> + + + ) : null} + {canDownloadReports ? (

Reports

diff --git a/components/event-dashboard/panels/FinancePanel.tsx b/components/event-dashboard/panels/FinancePanel.tsx index 701f33cf..c599480d 100644 --- a/components/event-dashboard/panels/FinancePanel.tsx +++ b/components/event-dashboard/panels/FinancePanel.tsx @@ -10,6 +10,7 @@ import { ClaimPayoutsList, FinanceSettingsForm, RefundRequestsList } from "@/com import { ReimbursementPolicies } from "@/components/event-dashboard/finance/ReimbursementPolicies"; import { NoPermission } from "./shared"; import { HelpLink } from "@/components/help/HelpLink"; +import { FailedRefundsList } from "@/components/payments/FailedRefundsList"; /** * The event dashboard's Finance tab (docs/FINANCE.md): money in and out @@ -71,6 +72,12 @@ export async function FinancePanel({ ctx }: { ctx: EventDashboardContext; }) { Refund requests} description="Buyers asking for money back outside your refund policy, from their order page." /> + {data.failedRefunds.length > 0 ? ( + + Failed refunds} description="Refunds the payment provider refused. Check your balance with the provider, then retry." /> + + + ) : null} Claim payments} description="Every payment stays on record. A mistake is reversed, never edited." /> diff --git a/components/payments/FailedRefundsList.tsx b/components/payments/FailedRefundsList.tsx new file mode 100644 index 00000000..cab7b453 --- /dev/null +++ b/components/payments/FailedRefundsList.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { retryFailedRefundAction } from "@/app/actions/payment-operations"; +import { Button } from "@/components/ui/button"; +import { Callout, Chip } from "@/components/ui/display"; +import { useSave } from "@/components/event-dashboard/finance/shared"; +import { formatDayTime } from "@/lib/format/datetime"; +import type { FailedRefundRow } from "@/lib/payments/operations/retryRefund"; + +/** + * Refunds the payment provider refused, by order, for owners and admins + * (docs/PAYMENTS.md). Listed by order rather than by guest, so a cancelled + * order whose tickets are gone can still be refunded. Retrying sends a new + * refund for the same amount; it needs a recent sign-in. + */ +export function FailedRefundsList({ target, refunds, timezone }: { target: { organisationSlug: string; eventId?: string; seriesId?: string; }; refunds: FailedRefundRow[]; timezone: string; }) { + const { pending, error, save } = useSave(); + if (refunds.length === 0) return

No failed refunds.

; + return ( +
+ {error ? {error} : null} +
    + {refunds.map((refund) => ( +
  • +
    +

    {refund.buyer} · order {refund.reference} · {refund.amount}

    +

    {refund.failedAt ? `Refused ${formatDayTime(refund.failedAt, timezone)}` : "Refused"}{refund.orderCancelled ? " · tickets cancelled" : ""}

    + {refund.reason ?

    {refund.reason}

    : null} +
    +
    + Failed + +
    +
  • + ))} +
+
+ ); +} diff --git a/lib/payments/operations.ts b/lib/payments/operations.ts index b979d2a1..6113ec56 100644 --- a/lib/payments/operations.ts +++ b/lib/payments/operations.ts @@ -9,3 +9,4 @@ export { declineRequestedOrder, fillFromWaitlist, fillWaitlists, offerNextWaitli export { reacceptDeclinedOrder } from "./operations/reaccept"; export { amendOfflinePayment, settleOrderByHand } from "./operations/settle"; export { buyerCancellation, cancelOrderAsBuyer, refundOrder, refundWholeOrderTx, submitRefunds } from "./operations/refund"; +export { listFailedRefunds, retryFailedRefund } from "./operations/retryRefund"; diff --git a/lib/payments/operations/retryRefund.ts b/lib/payments/operations/retryRefund.ts new file mode 100644 index 00000000..40554759 --- /dev/null +++ b/lib/payments/operations/retryRefund.ts @@ -0,0 +1,86 @@ +import "server-only"; + +import { OrderStatus, PaymentRefundStatus } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { recordAuditEvent } from "@/lib/audit"; +import { notifyChat } from "@/lib/chat/notify"; +import { formatMoney } from "../money"; +import { lockRefundPayment, submitPendingRefund } from "../refunds"; +import { COLLECTED, COMMITTED_REFUND, PaymentOperationError, ZERO } from "./shared"; + +const RETRYABLE_ORDERS: OrderStatus[] = [OrderStatus.CONFIRMED, OrderStatus.CANCELLED, OrderStatus.EXPIRED]; + +function isUniqueViolation(error: unknown) { + return typeof error === "object" && error !== null && (error as { code?: string; }).code === "P2002"; +} + +/** + * Retry a refund the provider refused (docs/PAYMENTS.md): a new PENDING + * refund for the same amount, pointing at the failed one, which never + * changes. Each failed refund is retried once (`retryOfRefundId` is unique, + * and the payment lock makes a second retry see the first); the database + * guard still refuses anything over what is left on the payment. Works on + * confirmed and cancelled orders alike (a cancelled order's tickets are + * already gone), and on a late payment's expired order. The provider is + * called after commit. + */ +export async function retryFailedRefund(params: { refundId: string; organisationId: string; actorUserId: string; }) { + let retryId: string; + try { + retryId = await prisma.$transaction(async (tx) => { + if (!(await lockRefundPayment(tx, params.refundId))) throw new PaymentOperationError("Refund not found."); + const failed = await tx.paymentRefund.findFirst({ + where: { id: params.refundId, payment: { organisationId: params.organisationId } }, + select: { + id: true, status: true, offline: true, amountMinor: true, reason: true, automatic: true, retry: { select: { id: true } }, + payment: { select: { id: true, status: true, amountMinor: true, currency: true, refunds: { where: { status: { in: COMMITTED_REFUND } }, select: { amountMinor: true } }, order: { select: { id: true, status: true, reference: true, buyerEmail: true } } } }, + }, + }); + if (!failed) throw new PaymentOperationError("Refund not found."); + const { payment } = failed; + if (failed.status !== PaymentRefundStatus.FAILED || failed.offline) throw new PaymentOperationError("Only a refund the provider refused can be retried."); + if (failed.retry) throw new PaymentOperationError("This refund has already been retried."); + // Confirmed or cancelled orders, and the expired order of a payment that arrived too late. + if (!COLLECTED.includes(payment.status) || !RETRYABLE_ORDERS.includes(payment.order.status)) throw new PaymentOperationError("This order can't be refunded."); + const left = payment.amountMinor - payment.refunds.reduce((sum, refund) => sum + refund.amountMinor, ZERO); + if (failed.amountMinor > left) throw new PaymentOperationError(`Only ${formatMoney(left, payment.currency)} is left to refund on this payment.`); + const retry = await tx.paymentRefund.create({ + data: { paymentId: payment.id, amountMinor: failed.amountMinor, reason: `Retry: ${failed.reason}`.slice(0, 500), automatic: failed.automatic, createdByUserId: params.actorUserId, retryOfRefundId: failed.id }, + select: { id: true }, + }); + await notifyChat(tx, params.organisationId, "refund", `Retrying a failed refund of ${formatMoney(failed.amountMinor, payment.currency)} on order ${payment.order.reference} for ${payment.order.buyerEmail}.`); + await recordAuditEvent({ action: "PAYMENT_REFUND_RETRIED", actorUserId: params.actorUserId, organisationId: params.organisationId, targetType: "PaymentRefund", targetId: retry.id, metadata: { retryOf: failed.id, orderId: payment.order.id, amountMinor: failed.amountMinor.toString() }, client: tx }); + return retry.id; + }); + } catch (error) { + if (isUniqueViolation(error)) throw new PaymentOperationError("This refund has already been retried."); + throw error; + } + await submitPendingRefund(retryId).catch(() => undefined); + return { refundId: retryId }; +} + +/** + * Refunds the provider refused and nobody has retried yet, newest first, on + * an event's or a series' orders: the Failed refunds list, which stays + * reachable after the order's tickets were cancelled. + */ +export async function listFailedRefunds(owner: { eventId: string; } | { eventSeriesId: string; }, organisationId: string) { + const rows = await prisma.paymentRefund.findMany({ + where: { status: PaymentRefundStatus.FAILED, offline: false, retry: null, payment: { organisationId, order: owner } }, + orderBy: [{ settledAt: "desc" }, { id: "desc" }], + take: 100, + select: { id: true, amountMinor: true, reason: true, settledAt: true, payment: { select: { currency: true, order: { select: { reference: true, buyerName: true, buyerEmail: true, status: true } } } } }, + }); + return rows.map((row) => ({ + id: row.id, + amount: formatMoney(row.amountMinor, row.payment.currency), + reason: row.reason, + failedAt: row.settledAt?.toISOString() ?? null, + reference: row.payment.order.reference, + buyer: row.payment.order.buyerName || row.payment.order.buyerEmail, + orderCancelled: row.payment.order.status === OrderStatus.CANCELLED, + })); +} + +export type FailedRefundRow = Awaited>[number]; diff --git a/lib/views/app__dashboard__orgSlug__series__seriesId__page.ts b/lib/views/app__dashboard__orgSlug__series__seriesId__page.ts index 212bd04a..8fd9b018 100644 --- a/lib/views/app__dashboard__orgSlug__series__seriesId__page.ts +++ b/lib/views/app__dashboard__orgSlug__series__seriesId__page.ts @@ -8,6 +8,7 @@ import { percentOf } from "@/components/event-dashboard/analytics/metrics"; import { seatTakingWhere } from "@/lib/payments/holds"; import { loadTicketSettings } from "@/lib/payments/ticketSettings"; import { loadRevenue } from "@/lib/payments/revenue"; +import { listFailedRefunds } from "@/lib/payments/operations/retryRefund"; import { listAttendeesPage } from "@/lib/eventDashboard/attendees"; type Props = { params: Promise<{ orgSlug: string; seriesId: string; }>; }; export async function loadSeriesManageView({ params }: Props) { @@ -90,5 +91,7 @@ export async function loadSeriesManageView({ params }: Props) { } // Takings across every date and pass, for those who manage registrations. const revenue = canManageRegistrations ? await loadRevenue({ eventSeriesId: series.id }, series.timezone, now) : null; - return { series, isAdmin, organisation, canManageInvites, invites, canManageSchedule, canDownloadReports, finishedInstances, firstInstanceId, canManageRegistrations, now, attendeesPage, rollup, dates, tickets, revenue }; + // Refunds the provider refused, for owners and admins to retry (docs/PAYMENTS.md). + const failedRefunds = isAdmin ? await listFailedRefunds({ eventSeriesId: series.id }, organisation.id) : []; + return { series, isAdmin, organisation, canManageInvites, invites, canManageSchedule, canDownloadReports, finishedInstances, firstInstanceId, canManageRegistrations, now, attendeesPage, rollup, dates, tickets, revenue, failedRefunds }; } diff --git a/lib/views/components__event-dashboard__panels__FinancePanel.ts b/lib/views/components__event-dashboard__panels__FinancePanel.ts index c28a80d4..5b7f8c85 100644 --- a/lib/views/components__event-dashboard__panels__FinancePanel.ts +++ b/lib/views/components__event-dashboard__panels__FinancePanel.ts @@ -7,10 +7,11 @@ import { eligibilityChanges } from "@/lib/claims/policies/claims"; import { listPolicies, policyAdminRows, policyStageOptions } from "@/lib/claims/policies/view"; import { safeTimeZone } from "@/lib/timeZone"; import { formatMoney } from "@/lib/payments/money"; +import { listFailedRefunds } from "@/lib/payments/operations/retryRefund"; /** * The Finance tab (docs/FINANCE.md). Owners and admins see the event's money, - * every claim, payouts and refund requests; everyone else on the dashboard + * every claim, payouts, refund requests and failed refunds; everyone else on the dashboard * files and follows their own claims. */ export async function loadFinancePanel({ ctx }: { ctx: EventDashboardContext; }) { @@ -21,15 +22,17 @@ export async function loadFinancePanel({ ctx }: { ctx: EventDashboardContext; }) if (ctx.permissions.isAdmin) { const finance = await loadEventFinance(ctx.event.id, ctx.organisation.id); if (!finance) return null; - const [policies, stageOptions, changed] = await Promise.all([ + const [policies, stageOptions, changed, failedRefunds] = await Promise.all([ listPolicies(prisma, ctx.event.id), policyStageOptions(prisma, ctx.event.id), // Waiting policy claims whose person no longer qualifies, flagged for approvers. prisma.expenseClaim.findMany({ where: { id: { in: finance.claims.map((claim) => claim.id) }, status: "SUBMITTED", policyId: { not: null } }, select: { id: true, status: true, eventId: true, rsvpId: true, policyId: true, eligibilitySnapshot: true } }).then((rows) => eligibilityChanges(prisma, rows)), + // Refunds the provider refused, by order (docs/PAYMENTS.md). + listFailedRefunds({ eventId: ctx.event.id }, ctx.organisation.id), ]); const claims = finance.claims.map((claim) => ({ ...claim, eligibilityChanged: changed.get(claim.id) ?? null })); return { - admin: true as const, scope, categories, currency, finance: { ...finance, claims }, userId: ctx.userId, exportHref: `/api/exports/events/${ctx.event.id}/finance`, + admin: true as const, scope, categories, currency, finance: { ...finance, claims }, failedRefunds, userId: ctx.userId, exportHref: `/api/exports/events/${ctx.event.id}/finance`, policies: policyAdminRows(policies, currency, safeTimeZone(ctx.event.timezone)), policyOptions: { ...stageOptions, categories }, }; From e9f35a4952fc5268e17c6445a7e7cd27a28df7b3 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:03:45 +0530 Subject: [PATCH 22/79] fix(payments): refund captures for cancelled targets, no promotion onto them, recheck add-on stock Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/rsvp-lifecycle.ts | 2 ++ lib/payments/addOnStock.ts | 39 +++++++++++++++++++++++++++ lib/payments/email.ts | 10 ++++++- lib/payments/entryFees.ts | 10 +++++-- lib/payments/operations/settle.ts | 7 ++++- lib/payments/operations/waitlist.ts | 11 +++++--- lib/payments/orders.ts | 4 +-- lib/payments/settlement.ts | 41 ++++++++++++++++++++++++++--- lib/payments/targets.ts | 28 +++++++++++++++----- lib/rsvpCapacity.ts | 6 ++++- lib/teamUnits.ts | 4 ++- 11 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 lib/payments/addOnStock.ts diff --git a/app/actions/rsvp-lifecycle.ts b/app/actions/rsvp-lifecycle.ts index 0f46e026..ace0ccab 100644 --- a/app/actions/rsvp-lifecycle.ts +++ b/app/actions/rsvp-lifecycle.ts @@ -270,6 +270,7 @@ export async function promoteFromWaitlist(input: unknown): Promise if (result === "full") { return { ok: false, error: "Event is still at capacity.", code: "CAPACITY_FULL" }; } + if (result === "closed") return { ok: false, error: "This event was cancelled, so nobody can be promoted." }; if (result !== "confirmed") return { ok: false, error: "This RSVP was changed by another organiser." }; await recordAuditEvent({ action: "RSVP_WAITLIST_PROMOTED", actorUserId: session.user.id, organisationId: org.id, targetType: "RSVP", targetId: rsvp.id }); revalidateRsvpPaths({ @@ -292,6 +293,7 @@ export async function promoteFromWaitlist(input: unknown): Promise if (result === "full") { return { ok: false, error: "This occurrence is still at capacity.", code: "CAPACITY_FULL" }; } + if (result === "closed") return { ok: false, error: "This occurrence was cancelled, so nobody can be promoted." }; if (result !== "confirmed") return { ok: false, error: "This RSVP was changed by another organiser." }; await recordAuditEvent({ action: "RSVP_WAITLIST_PROMOTED", actorUserId: session.user.id, organisationId: org.id, targetType: "RSVP", targetId: rsvp.id }); revalidateRsvpPaths({ diff --git a/lib/payments/addOnStock.ts b/lib/payments/addOnStock.ts new file mode 100644 index 00000000..b6633875 --- /dev/null +++ b/lib/payments/addOnStock.ts @@ -0,0 +1,39 @@ +import "server-only"; + +import { OrderStatus, type Prisma } from "@prisma/client"; + +/** + * Add-on stock (docs/PAYMENTS.md). Extras with a limit count toward it in + * confirmed orders and in orders whose hold is live; requests and ended + * holds don't. Callers hold the event or series lock, which every order + * selling these extras takes first, so the count can't change under them. + */ + +/** How many of an add-on are taken, optionally leaving one order out. */ +export async function addOnsTaken(tx: Prisma.TransactionClient, addOnId: string, now: Date, exceptOrderId?: string) { + const sold = await tx.orderLine.aggregate({ + _sum: { quantity: true }, + where: { + addOnId, + order: { + ...(exceptOrderId ? { id: { not: exceptOrderId } } : {}), + OR: [{ status: OrderStatus.CONFIRMED }, { status: OrderStatus.PENDING, holdExpiresAt: { gt: now } }], + }, + }, + }); + return sold._sum.quantity ?? 0; +} + +/** + * Whether an order's add-ons still fit their stock, counting everyone else: + * before a request is held, an ended hold reopens, or a late payment takes + * back what its hold had released. The order's priced lines never change. + */ +export async function addOnsFit(tx: Prisma.TransactionClient, orderId: string, now: Date) { + const lines = await tx.orderLine.findMany({ where: { orderId, addOnId: { not: null } }, select: { quantity: true, addOn: { select: { id: true, capacity: true } } } }); + for (const line of lines) { + if (!line.addOn || line.addOn.capacity == null) continue; + if ((await addOnsTaken(tx, line.addOn.id, now, orderId)) + line.quantity > line.addOn.capacity) return false; + } + return true; +} diff --git a/lib/payments/email.ts b/lib/payments/email.ts index d1a2e0d8..83f64633 100644 --- a/lib/payments/email.ts +++ b/lib/payments/email.ts @@ -13,7 +13,7 @@ type OutboxClient = Prisma.TransactionClient | typeof prisma; export const PAYMENT_EMAIL_KIND = "payment-notice"; /** Notices about one payment. */ -export const PAYMENT_NOTICES = ["pending", "reminder", "desk", "paid", "expired", "late-refund", "organiser-late-refund", "refunded"] as const; +export const PAYMENT_NOTICES = ["pending", "reminder", "desk", "paid", "expired", "late-refund", "organiser-late-refund", "refunded", "cancelled-refund"] as const; /** Notices about an order as a whole. */ export const ORDER_NOTICES = ["attendee-details", "cancelled"] as const; export type PaymentNotice = (typeof PAYMENT_NOTICES)[number]; @@ -94,6 +94,8 @@ function matches(notice: PaymentNotice, payment: { status: PaymentStatus; refund case "expired": return payment.status === PaymentStatus.EXPIRED; case "late-refund": case "organiser-late-refund": return automaticRefund; + // A payment that arrived after its event, series or date was cancelled. + case "cancelled-refund": return automaticRefund && order.status === OrderStatus.CANCELLED; case "refunded": return payment.refunds.some((refund) => !refund.automatic && refund.status !== PaymentRefundStatus.FAILED); } } @@ -174,6 +176,12 @@ export async function deliverPaymentEmail(payload: PaymentEmailPayload, messageI heading: "Your payment was refunded", body: `Your payment of ${escapeHtml(amount)} for ${escapeHtml(title)} arrived after your held tickets had been released, and there weren't enough places left. We've refunded it in full; your bank may take a few days to show it.${testNote}`, }, + "cancelled-refund": { + subject: `Refund on its way: ${title}`, + heading: "Your payment is being refunded", + body: `Your payment of ${escapeHtml(amount)} for ${escapeHtml(title)} arrived after it was cancelled, so no ticket was issued. We've asked the payment provider to return it in full; card and UPI refunds can take 5 to 10 working days to appear once it goes through.${testNote}`, + button: { label: "View your order", href: orderUrl }, + }, "organiser-late-refund": { subject: `A late payment was refunded: ${title}`, heading: "A late payment was refunded automatically", diff --git a/lib/payments/entryFees.ts b/lib/payments/entryFees.ts index f33427fb..44f748bf 100644 --- a/lib/payments/entryFees.ts +++ b/lib/payments/entryFees.ts @@ -1,11 +1,12 @@ import "server-only"; -import { OrderStatus, PaymentRefundStatus, PaymentStatus, SubmissionStatus, type Prisma } from "@prisma/client"; +import { EventStatus, OrderStatus, PaymentRefundStatus, PaymentStatus, SubmissionStatus, type Prisma } from "@prisma/client"; import { recordAuditEvent } from "@/lib/audit"; import { enqueueSubmissionReceived } from "@/lib/outbox"; import { shouldQueueSubmissionReceipt } from "@/lib/hackathonEmails"; import { enqueuePaymentEmail } from "./email"; import { createPendingRefund } from "./refunds"; +import { PaymentOperationError } from "./operations/shared"; /** * Hackathon entry fees (docs/HACKATHON.md). Submitting a project on an event @@ -40,10 +41,15 @@ export async function entryFeeOwed(tx: Prisma.TransactionClient, eventId: string * money stays recorded; the organiser decides). The payment row is already * settled by the caller, which holds the event and order locks. The submission receipt * is opt-in per event (`emailSubmissionReceipts`, lib/hackathonEmails.ts), - * read in this transaction; the payment email is never gated. + * read in this transaction; the payment email is never gated. A cancelled + * event never takes a project in: callers refund the fee (settlement) or + * refuse (settling by hand) first, and this refuses too. */ export async function confirmEntryFeeTx(tx: Prisma.TransactionClient, order: { id: string; organisationId: string; submissionId: string; buyerEmail: string; buyerName: string; }, paymentId: string, now: Date, notifyPaid = true) { + const submission = await tx.submission.findUnique({ where: { id: order.submissionId }, select: { event: { select: { status: true } } } }); + if (submission?.event.status === EventStatus.CANCELLED) throw new PaymentOperationError("This event was cancelled, so the project can't be submitted."); await tx.order.update({ where: { id: order.id }, data: { status: OrderStatus.CONFIRMED, holdExpiresAt: null, holdCapAt: null } }); + // E5: apply results-publication freeze here once fix/audit-admission-mentor merges const submitted = await tx.submission.updateMany({ where: { id: order.submissionId, status: { in: [SubmissionStatus.DRAFT, SubmissionStatus.WITHDRAWN] } }, data: { status: SubmissionStatus.SUBMITTED, submittedAt: now }, diff --git a/lib/payments/operations/settle.ts b/lib/payments/operations/settle.ts index c174b9b9..49961c58 100644 --- a/lib/payments/operations/settle.ts +++ b/lib/payments/operations/settle.ts @@ -8,7 +8,8 @@ import { closeOpenAttempts } from "../checkout"; import { enqueueOrderEmail, enqueuePaymentEmail } from "../email"; import { isActiveHold } from "../holds"; import { effectiveOfflineRecord } from "../offlineRecord"; -import { hasFreeSeats, ORDER_SUBJECT_SELECT } from "../targets"; +import { hasFreeSeats, ORDER_SUBJECT_SELECT, orderTargetCancelled } from "../targets"; +import { addOnsFit } from "../addOnStock"; import { AdmissionManagedError, attendeeEmail, isAdmissionOrder, lockOrder, PaymentOperationError, subjectTitle } from "./shared"; import { heldStatusWebhooks } from "../settlement"; import { placePaidTeamOrder } from "../teamOrders"; @@ -69,6 +70,8 @@ export async function settleOrderByHand(params: { const payment = order.payments[0]; if (order.status !== OrderStatus.PENDING || !payment) throw new PaymentOperationError("Only orders waiting for payment can be settled by hand."); await tx.$queryRaw`SELECT "id" FROM "Payment" WHERE "id" = ${payment.id} FOR UPDATE`; + // Never a ticket or a submitted project for a cancelled event, series or date. + if (await orderTargetCancelled(tx, order)) throw new PaymentOperationError("This event or date was cancelled, so this order can't be confirmed."); // A project entry fee (docs/HACKATHON.md) holds no places: record it and submit the project. if (order.submissionId && order.rsvps.length === 0) { await tx.paymentCheckoutAttempt.updateMany({ where: { paymentId: payment.id, status: CheckoutAttemptStatus.OPEN }, data: { status: CheckoutAttemptStatus.CANCELLED } }); @@ -80,6 +83,8 @@ export async function settleOrderByHand(params: { const held = order.rsvps.filter((rsvp) => rsvp.status === RsvpStatus.PENDING_PAYMENT); if (held.length === 0) throw new PaymentOperationError("This order has no tickets left to confirm."); const holdActive = held.every((rsvp) => isActiveHold(rsvp, now)); + // An ended hold released its extras too: they come back only if still in stock. + if (!holdActive && !(await addOnsFit(tx, order.id, now))) throw new PaymentOperationError("The hold ended and some extras on this order have sold out since."); // TEAM events place the one registration in its team or unit (docs/TEAMS.md). const team = held.length === 1 ? await placePaidTeamOrder(tx, order, held[0]!.id, holdActive, now) : null; if (team === "gone" || (!team && !holdActive && !(await hasFreeSeats(tx, held, now)))) throw new PaymentOperationError("The hold ended and the places have gone."); diff --git a/lib/payments/operations/waitlist.ts b/lib/payments/operations/waitlist.ts index 2c6d3b78..47e19c90 100644 --- a/lib/payments/operations/waitlist.ts +++ b/lib/payments/operations/waitlist.ts @@ -1,6 +1,6 @@ import "server-only"; -import { EventPrivacyType, OrderStatus, PaymentStatus, RegistrationMode, RsvpStatus, type Prisma } from "@prisma/client"; +import { EventPrivacyType, EventStatus, OrderStatus, PaymentStatus, RegistrationMode, RsvpStatus, type Prisma } from "@prisma/client"; import { prisma } from "@/lib/db"; import { recordAuditEvent } from "@/lib/audit"; import { enqueueRsvpStatusNotification } from "@/lib/outbox"; @@ -9,6 +9,7 @@ import { enqueuePaymentEmail } from "../email"; import { HOLD_EXTENSION_CAP_MS, PAYMENT_LINK_HOLD_MS } from "../holds"; import { providerMinimumMinor } from "../money"; import { hasFreeSeats, lockSeriesDates, ORDER_SUBJECT_SELECT, orderSubject, seatTargetOf, type SeatTarget } from "../targets"; +import { addOnsFit } from "../addOnStock"; import { AdmissionManagedError, attendeeEmail, isAdmissionOrder, lockOrder, onlineAccount, PaymentOperationError, subjectTitle, ZERO } from "./shared"; /** @@ -34,6 +35,8 @@ export async function offerInTransaction(tx: Prisma.TransactionClient, params: { if (!subject || !subject.open(params.now)) throw new PaymentOperationError("This event is no longer open for registration."); if (order.totalMinor < providerMinimumMinor(params.provider, order.currency)) throw new PaymentOperationError("This order is below the smallest payment the provider accepts."); if (!(await (params.hasRoom ? params.hasRoom(order.rsvps) : hasFreeSeats(tx, order.rsvps, params.now)))) throw new PaymentOperationError("There aren't enough free places for this order yet."); + // A request never counted toward extras' stock: it must fit before it is held. + if (!(await addOnsFit(tx, order.id, params.now))) throw new PaymentOperationError("Some extras on this order have sold out."); const holdExpiresAt = new Date(params.now.getTime() + PAYMENT_LINK_HOLD_MS); await tx.order.update({ where: { id: order.id }, data: { status: OrderStatus.PENDING, holdExpiresAt, holdCapAt: new Date(holdExpiresAt.getTime() + HOLD_EXTENSION_CAP_MS), testMode: params.testMode } }); await tx.rSVP.updateMany({ where: { orderId: order.id }, data: { status: RsvpStatus.PENDING_PAYMENT, holdExpiresAt } }); @@ -142,9 +145,10 @@ export async function fillWaitlists(now = new Date()) { by: ["eventId", "eventInstanceId"], where: { status: RsvpStatus.WAITLISTED, + // Cancelled events, series and dates promote nobody (the promotion itself re-checks under its lock). OR: [ - { event: { endDateTime: { gt: now }, privacyType: { not: EventPrivacyType.APPROVAL_REQUIRED } } }, - { eventInstance: { endDateTime: { gt: now }, cancelledAt: null, series: { privacyType: { not: EventPrivacyType.APPROVAL_REQUIRED } } } }, + { event: { endDateTime: { gt: now }, status: { not: EventStatus.CANCELLED }, privacyType: { not: EventPrivacyType.APPROVAL_REQUIRED } } }, + { eventInstance: { endDateTime: { gt: now }, cancelledAt: null, series: { status: { not: EventStatus.CANCELLED }, privacyType: { not: EventPrivacyType.APPROVAL_REQUIRED } } } }, ], }, orderBy: [{ eventId: "asc" }, { eventInstanceId: "asc" }], @@ -193,6 +197,7 @@ export async function sendPaymentLink(params: { orderId: string; organisationId: if (current.rsvps.length === 0) throw new PaymentOperationError("The buyer has registered again since this order expired."); if (current.totalMinor < providerMinimumMinor(account.provider, current.currency)) throw new PaymentOperationError("This order is below the smallest payment the provider accepts."); if (!(await hasFreeSeats(tx, current.rsvps, now))) throw new PaymentOperationError("Its places have gone since the hold ended."); + if (!(await addOnsFit(tx, order.id, now))) throw new PaymentOperationError("Some extras on this order have sold out since the hold ended."); const holdExpiresAt = new Date(now.getTime() + PAYMENT_LINK_HOLD_MS); await tx.order.update({ where: { id: order.id }, data: { status: OrderStatus.PENDING, holdExpiresAt, holdCapAt: new Date(holdExpiresAt.getTime() + HOLD_EXTENSION_CAP_MS), testMode: account.testMode } }); await tx.rSVP.updateMany({ where: { orderId: order.id }, data: { status: RsvpStatus.PENDING_PAYMENT, holdExpiresAt } }); diff --git a/lib/payments/orders.ts b/lib/payments/orders.ts index f9d2aee3..0012b390 100644 --- a/lib/payments/orders.ts +++ b/lib/payments/orders.ts @@ -15,6 +15,7 @@ import { DEFAULT_CURRENCY, formatMoney, providerMinimumMinor } from "./money"; import { priceOrder, PricingError, type AddOnItem, type OrderItem } from "./pricing"; import { isValidPromoCode, lockPromoCode, promoCodeDigest, PromoCodeError } from "./promo"; import { lockSellable, passesTaken, type OrderTarget, type Slot } from "./targets"; +import { addOnsTaken } from "./addOnStock"; import { ticketSalesState } from "./tickets"; import { OrderError } from "./orderError"; import { placeTeamOrderRegistration, planTeamOrder } from "./teamOrders"; @@ -191,8 +192,7 @@ export async function reserveOrder(tx: Prisma.TransactionClient, params: { for (const line of priced.lines.filter((candidate) => candidate.kind === "ADD_ON")) { const addOn = addOnTypes.find((candidate) => candidate.id === line.addOnId)!; if (addOn.capacity == null) continue; - const sold = await tx.orderLine.aggregate({ _sum: { quantity: true }, where: { addOnId: addOn.id, order: { OR: [{ status: OrderStatus.CONFIRMED }, { status: OrderStatus.PENDING, holdExpiresAt: { gt: now } }] } } }); - const left = addOn.capacity - (sold._sum.quantity ?? 0); + const left = addOn.capacity - await addOnsTaken(tx, addOn.id, now); if (line.quantity > left) throw new OrderError(left <= 0 ? `${addOn.name} is sold out.` : `Only ${left} ${addOn.name} left.`); } // A pass needs a place on every date it covers. diff --git a/lib/payments/settlement.ts b/lib/payments/settlement.ts index b028a4cf..571d6346 100644 --- a/lib/payments/settlement.ts +++ b/lib/payments/settlement.ts @@ -4,7 +4,8 @@ import { CheckoutAttemptStatus, OrderStatus, PaymentStatus, RsvpStatus, type Pri import { recordAuditEvent } from "@/lib/audit"; import { enqueueRsvpConfirmation } from "@/lib/outbox"; import { enqueueOrderEmail, enqueuePaymentEmail, organisationOwnerEmails } from "./email"; -import { hasFreeSeats, lockOrderTargets, ORDER_SUBJECT_SELECT, orderSubject } from "./targets"; +import { hasFreeSeats, lockOrderTargets, ORDER_SUBJECT_SELECT, orderSubject, orderTargetCancelled } from "./targets"; +import { addOnsFit } from "./addOnStock"; import type { OnlineProvider } from "./http"; import type { PaidNotification } from "./providers/types"; import { createPendingRefund } from "./refunds"; @@ -60,6 +61,8 @@ async function findPayment(tx: Prisma.TransactionClient, context: { organisation * recorded as paid and refunded in full automatically. * - A second capture for an already-paid order is recorded as its own * payment and refunded in full. + * - A payment for a cancelled event, series or date is recorded, its order + * cancelled without tickets, and refunded in full automatically. * * Lock order matches registration: the event row, the order, then the payment. */ @@ -117,6 +120,10 @@ export async function settlePaidNotification( return { outcome: "duplicate", refundIds: [refund.id] }; } + // Cancelled meanwhile (a pass reaches its dates through its registrations): + // refunded before any team place or project is confirmed. + if (await orderTargetCancelled(tx, order)) return refundCancelledCapture(tx, payment, order, paid.paymentId, now); + // A project entry fee (docs/HACKATHON.md) holds no places: it is always accepted. if (order.submissionId && order.rsvps.length === 0) { const refundId = await settleEntryFeePayment(tx, payment, { id: order.id, submissionId: order.submissionId, buyerEmail: order.buyerEmail, buyerName: order.buyerName }, paid, now); @@ -125,9 +132,11 @@ export async function settlePaidNotification( const held = order.rsvps.filter((rsvp) => rsvp.status === RsvpStatus.PENDING_PAYMENT); const holdActive = order.status === OrderStatus.PENDING && Boolean(order.holdExpiresAt && order.holdExpiresAt > now); + // After the hold ended, its extras were released too: only taken back if still in stock. + const stocked = holdActive || await addOnsFit(tx, order.id, now); // TEAM events place the one registration in its team or unit (docs/TEAMS.md). - const team = held.length === 1 ? await placePaidTeamOrder(tx, order, held[0]!.id, holdActive, now) : null; - const seats = team ? team !== "gone" : held.length > 0 && (holdActive || await hasFreeSeats(tx, held, now)); + const team = stocked && held.length === 1 ? await placePaidTeamOrder(tx, order, held[0]!.id, holdActive, now) : null; + const seats = stocked && (team ? team !== "gone" : held.length > 0 && (holdActive || await hasFreeSeats(tx, held, now))); const placed = team && team !== "gone" ? team : null; const status = placed?.status ?? RsvpStatus.CONFIRMED; await tx.payment.update({ where: { id: payment.id }, data: { status: PaymentStatus.PAID_ONLINE, externalPaymentId: paid.paymentId, paidAt: now } }); @@ -164,3 +173,29 @@ export async function settlePaidNotification( await recordAuditEvent({ action: "PAYMENT_LATE_REFUNDED", organisationId: payment.organisationId, targetType: "Payment", targetId: payment.id, client: tx }); return { outcome: "refunding", refundIds: [refund.id] }; } + +/** + * A payment captured for a cancelled event, series or date (the payment, order + * and target locks are held): recorded as paid, the order cancelled with its + * held places, and the whole amount refunded. The buyer is told the refund is + * on its way (it is still pending at the provider) and the organisers' chat + * hears it. + */ +async function refundCancelledCapture( + tx: Prisma.TransactionClient, + payment: { id: string; organisationId: string; amountMinor: bigint; currency: string; description: string; testMode: boolean; }, + order: { id: string; buyerEmail: string; }, + externalPaymentId: string, + now: Date, +): Promise { + await tx.payment.update({ where: { id: payment.id }, data: { status: PaymentStatus.PAID_ONLINE, externalPaymentId, paidAt: now } }); + await tx.order.update({ where: { id: order.id }, data: { status: OrderStatus.CANCELLED, holdExpiresAt: null, holdCapAt: null } }); + // A leader's unpaid team goes first, then the held places: no ticket is issued. + await releaseUnpaidTeam(tx, order.id); + await tx.rSVP.deleteMany({ where: { orderId: order.id, status: RsvpStatus.PENDING_PAYMENT } }); + const refund = await createPendingRefund(tx, { paymentId: payment.id, amountMinor: payment.amountMinor, reason: "Paid after the event was cancelled", automatic: true }); + await enqueuePaymentEmail(tx, { to: order.buyerEmail, paymentId: payment.id, notice: "cancelled-refund" }); + await notifyChat(tx, payment.organisationId, "refund", `A payment of ${formatMoney(payment.amountMinor, payment.currency)} for ${payment.description} arrived after it was cancelled, so no ticket was issued and it is being refunded in full${payment.testMode ? " (test)" : ""}.`); + await recordAuditEvent({ action: "PAYMENT_CANCELLED_EVENT_REFUNDED", organisationId: payment.organisationId, targetType: "Payment", targetId: payment.id, client: tx }); + return { outcome: "refunding", refundIds: [refund.id] }; +} diff --git a/lib/payments/targets.ts b/lib/payments/targets.ts index 7efb7955..6e066b76 100644 --- a/lib/payments/targets.ts +++ b/lib/payments/targets.ts @@ -176,13 +176,14 @@ export async function hasFreeSeats(tx: Prisma.TransactionClient, rsvps: Array<{ } for (const { target, rsvps: seats } of groups.values()) { let capacity: number | null; + // A cancelled event, series or date has no free seats. if ("eventId" in target) { - const event = await tx.event.findUnique({ where: { id: target.eventId }, select: { capacity: true } }); - if (!event) return false; + const event = await tx.event.findUnique({ where: { id: target.eventId }, select: { capacity: true, status: true } }); + if (!event || event.status === EventStatus.CANCELLED) return false; capacity = event.capacity; } else { - const date = await tx.eventInstance.findUnique({ where: { id: target.eventInstanceId }, select: { capacity: true, cancelledAt: true, series: { select: { capacity: true } } } }); - if (!date || date.cancelledAt) return false; + const date = await tx.eventInstance.findUnique({ where: { id: target.eventInstanceId }, select: { capacity: true, cancelledAt: true, series: { select: { capacity: true, status: true } } } }); + if (!date || date.cancelledAt || date.series.status === EventStatus.CANCELLED) return false; capacity = date.capacity ?? date.series.capacity; } if (capacity != null && await tx.rSVP.count({ where: { ...target, ...others } }) + seats.length > capacity) return false; @@ -256,17 +257,32 @@ export function orderSubject(order: SubjectOrder): OrderSubject | null { }; } +/** + * Whether anything an order sells was cancelled: its event, its series, or a + * date it holds seats on (a pass reaches its dates through its + * registrations). Read under the order's target locks. + */ +export async function orderTargetCancelled(tx: Prisma.TransactionClient, order: { event: { status: EventStatus; } | null; series: { status: EventStatus; } | null; instance: { cancelledAt: Date | null; } | null; rsvps: Array<{ eventInstanceId: string | null; }>; }) { + if (order.event?.status === EventStatus.CANCELLED || order.series?.status === EventStatus.CANCELLED || order.instance?.cancelledAt) return true; + const dates = [...new Set(order.rsvps.flatMap((rsvp) => (rsvp.eventInstanceId ? [rsvp.eventInstanceId] : [])))]; + return dates.length > 0 && (await tx.eventInstance.count({ where: { id: { in: dates }, cancelledAt: { not: null } } })) > 0; +} + /** The event or date a freed seat belongs to, for the waitlist. */ export function seatTargetOf(rsvp: { eventId: string | null; eventInstanceId: string | null; }): SeatTarget | null { return rsvp.eventId ? { eventId: rsvp.eventId } : rsvp.eventInstanceId ? { eventInstanceId: rsvp.eventInstanceId } : null; } -/** Lock one date (after its series) and read its capacity, and whether it still takes registrations. */ +/** Lock one date (after its series) and read its capacity, whether it still takes registrations, and whether it (or its series) was cancelled. */ export async function lockDate(tx: Prisma.TransactionClient, instanceId: string, now: Date) { const date = await tx.eventInstance.findUnique({ where: { id: instanceId }, select: { eventSeriesId: true } }); if (!date) return null; await lockSeriesDates(tx, date.eventSeriesId, [instanceId], now); const current = await tx.eventInstance.findUnique({ where: { id: instanceId }, select: { capacity: true, endDateTime: true, cancelledAt: true, series: { select: { capacity: true, status: true } } } }); if (!current) return null; - return { capacity: current.capacity ?? current.series.capacity, open: current.series.status === EventStatus.PUBLISHED && current.endDateTime > now && !current.cancelledAt }; + return { + capacity: current.capacity ?? current.series.capacity, + open: current.series.status === EventStatus.PUBLISHED && current.endDateTime > now && !current.cancelledAt, + cancelled: current.series.status === EventStatus.CANCELLED || current.cancelledAt != null, + }; } diff --git a/lib/rsvpCapacity.ts b/lib/rsvpCapacity.ts index 77cd4326..e9f55f5f 100644 --- a/lib/rsvpCapacity.ts +++ b/lib/rsvpCapacity.ts @@ -83,6 +83,7 @@ export async function confirmRsvpWithinCapacity(params: { const outcome = await prisma.$transaction(async (tx): Promise<"confirmed" | "waitlisted" | "full" | "changed" | "closed"> => { let capacity: number | null; let isOpen: boolean; + let cancelled: boolean; let teamMode = false; if (params.eventId) { const events = await tx.$queryRaw>` @@ -92,6 +93,7 @@ export async function confirmRsvpWithinCapacity(params: { if (!event) return "changed"; capacity = event.capacity; isOpen = event.status === EventStatus.PUBLISHED && event.endDateTime > new Date(); + cancelled = event.status === EventStatus.CANCELLED; teamMode = event.registrationMode === RegistrationMode.TEAM; } else { // The series, then the date: the same lock order as every series path. @@ -99,8 +101,10 @@ export async function confirmRsvpWithinCapacity(params: { if (!date) return "changed"; capacity = date.capacity; isOpen = date.open; + cancelled = date.cancelled; } - if (params.requireOpen && !isOpen) return "closed"; + // Nobody is confirmed onto a cancelled event, series or date, requireOpen or not. + if (cancelled || (params.requireOpen && !isOpen)) return "closed"; // TEAM events count units, and only solo RSVPs are confirmed one by one: // team members change status with their whole team (lib/core/teams.ts). const taken = teamMode diff --git a/lib/teamUnits.ts b/lib/teamUnits.ts index a0ba07d5..d28e14ad 100644 --- a/lib/teamUnits.ts +++ b/lib/teamUnits.ts @@ -1,6 +1,6 @@ import "server-only"; -import { EventPrivacyType, Prisma, RegistrationMode, RsvpStatus, TeamJoinRequestStatus } from "@prisma/client"; +import { EventPrivacyType, EventStatus, Prisma, RegistrationMode, RsvpStatus, TeamJoinRequestStatus } from "@prisma/client"; import { enqueueRsvpStatusNotification, enqueueTeamCreated } from "@/lib/outbox"; import { generateTeamManageToken } from "@/lib/teamToken"; import { emitWebhookEvent, type RsvpWebhookSnapshot, type WebhookEmission } from "@/lib/webhooks/emit"; @@ -173,6 +173,8 @@ export async function promoteOldestWaitlistedUnit( | null > { if (event.privacyType === EventPrivacyType.APPROVAL_REQUIRED) return null; + // A cancelled event promotes nobody; every caller holds its Event lock, so this read is current. + if ((await tx.event.count({ where: { id: event.id, status: EventStatus.CANCELLED } })) > 0) return null; if (event.capacity != null && (await countConfirmedUnits(tx, event.id)) >= event.capacity) return null; const team = await tx.team.findFirst({ where: { eventId: event.id, status: RsvpStatus.WAITLISTED }, From 63090f5d8fc28b971beb1d04e50af8ebdff9cc32 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:03:46 +0530 Subject: [PATCH 23/79] fix(payments): one lock order for date cancellation, hold extension, check-in races and attendance releases Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/series.ts | 9 ++-- lib/attendanceGuard.ts | 26 ++++++++++ lib/core/attendanceCheck/releaseRun.ts | 50 +++++++++++------- lib/core/teams/leader.ts | 11 +++- lib/payments/checkout.ts | 12 ++++- lib/payments/occurrences.ts | 71 ++++++++++++++++++-------- lib/payments/operations/refund.ts | 36 ++++++++----- lib/payments/teamOrders.ts | 19 ++++++- lib/refundRequests.ts | 17 ++++-- lib/rsvpCancel.ts | 18 +++++-- 10 files changed, 202 insertions(+), 67 deletions(-) create mode 100644 lib/attendanceGuard.ts diff --git a/app/actions/series.ts b/app/actions/series.ts index f68909cc..c04cb58f 100644 --- a/app/actions/series.ts +++ b/app/actions/series.ts @@ -1,6 +1,6 @@ "use server"; -import { EventStatus, RsvpStatus } from "@prisma/client"; +import { EventStatus } from "@prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { buildRecurrenceIcs, materializeInstances } from "@/lib/recurrence"; @@ -19,6 +19,7 @@ import { hasPaidOrders, peopleWithPlaces } from "@/lib/payments/eventCancellatio import { noticeEach, noticeSubject, uniqueEmails } from "@/lib/notices"; import { cancelOccurrence, setOccurrenceCapacity } from "@/lib/payments/occurrences"; import { PaymentOperationError } from "@/lib/payments/operations"; +import { seatTakingWhere } from "@/lib/payments/holds"; import { cancelDateSchema, capacitySchema, dateAccess, revalidateAllSeriesPaths, revalidatePath } from "./series/helpers"; export async function createEventSeries( @@ -175,10 +176,12 @@ export async function updateEventSeriesMeta( const saved = await prisma.$transaction(async (tx) => { await tx.$queryRaw`SELECT "id" FROM "EventSeries" WHERE "id" = ${series.id} FOR UPDATE`; if (data.capacity != null) { + // Taken seats (confirmed, and paid holds still live), counted under the + // series lock every reservation takes first, with the time read after it. const counts = await tx.rSVP.groupBy({ by: ["eventInstanceId"], // Dates with their own capacity aren't bound by the series one. - where: { eventInstance: { eventSeriesId: series.id, capacity: null }, status: RsvpStatus.CONFIRMED }, + where: { eventInstance: { eventSeriesId: series.id, capacity: null }, ...seatTakingWhere(new Date()) }, _count: { _all: true }, }); if (counts.some((row) => row._count._all > data.capacity!)) return false; @@ -196,7 +199,7 @@ export async function updateEventSeriesMeta( }); return true; }); - if (!saved) return { ok: false, error: "Capacity cannot be lower than confirmed attendance for an occurrence." }; + if (!saved) return { ok: false, error: "Capacity cannot be lower than the places already taken (or being paid for) on an occurrence." }; // Cancelling the series cancels each upcoming date: refunds, and an email to everyone on it. if (data.status === EventStatus.CANCELLED && series.status !== EventStatus.CANCELLED) { diff --git a/lib/attendanceGuard.ts b/lib/attendanceGuard.ts new file mode 100644 index 00000000..2f41443d --- /dev/null +++ b/lib/attendanceGuard.ts @@ -0,0 +1,26 @@ +import "server-only"; + +import { AttendanceResponse, type Prisma } from "@prisma/client"; + +/** + * The attendance "No" a release acts for (docs/ATTENDANCE.md): its check and + * the time that answer was recorded. A cancellation, refund or refund + * request started by a "No" goes ahead only while that exact answer is still + * the current one, so an older "No" can never undo a newer "Yes". + */ +export type AttendanceNo = { checkId: string; respondedAt: Date; }; + +export const ANSWER_CHANGED = "Your answer changed meanwhile, so nothing was released."; + +/** + * Lock the attendance check row, the last lock of the release transaction + * (after the event or dates, the order, its payments and the registrations), + * and say whether the "No" is still the current answer. A "Yes" written + * after this waits until the release commits; a release that deletes the + * registration deletes the check with it, so that "Yes" then finds no link. + */ +export async function lockCurrentNo(tx: Prisma.TransactionClient, no: AttendanceNo) { + await tx.$queryRaw`SELECT "id" FROM "AttendanceCheck" WHERE "id" = ${no.checkId} FOR UPDATE`; + const check = await tx.attendanceCheck.findUnique({ where: { id: no.checkId }, select: { response: true, respondedAt: true } }); + return check?.response === AttendanceResponse.NOT_ATTENDING && check.respondedAt?.getTime() === no.respondedAt.getTime(); +} diff --git a/lib/core/attendanceCheck/releaseRun.ts b/lib/core/attendanceCheck/releaseRun.ts index c6ef653a..d0d6c97d 100644 --- a/lib/core/attendanceCheck/releaseRun.ts +++ b/lib/core/attendanceCheck/releaseRun.ts @@ -11,6 +11,7 @@ import { PaymentOperationError } from "@/lib/payments/operations/shared"; import { cancelOrderAsBuyer } from "@/lib/payments/operations/refund"; import { ATTENDANCE_RELEASE_OUTCOME_LABELS, type AttendanceReleaseOutcome } from "@/lib/attendanceCheck"; import { ATTENDANCE_AUDIT } from "./shared"; +import type { AttendanceNo } from "@/lib/attendanceGuard"; import type { ReleasePlan } from "./release"; /** What the guest reads when their "No" did not release the place. */ @@ -58,9 +59,9 @@ export type CheckForRelease = { event: { title: string; organisationId: string; }; }; -async function recordOutcome(checkId: string, outcome: AttendanceReleaseOutcome) { - // Only while the answer is still No (the guest may have changed it meanwhile). - await prisma.attendanceCheck.updateMany({ where: { id: checkId, response: "NOT_ATTENDING" }, data: { releaseOutcome: outcome } }); +async function recordOutcome(no: AttendanceNo, outcome: AttendanceReleaseOutcome) { + // Only while this exact No is still the answer (a newer Yes, or a newer No, keeps its own outcome). + await prisma.attendanceCheck.updateMany({ where: { id: no.checkId, response: "NOT_ATTENDING", respondedAt: no.respondedAt }, data: { releaseOutcome: outcome } }); } async function released(check: CheckForRelease, paid: boolean) { @@ -71,8 +72,8 @@ async function released(check: CheckForRelease, paid: boolean) { return { released: true as const }; } -async function failed(check: CheckForRelease, message: string) { - await recordOutcome(check.id, "failed"); +async function failed(no: AttendanceNo, message: string) { + await recordOutcome(no, "failed"); return { released: false as const, message }; } @@ -82,27 +83,29 @@ async function failed(check: CheckForRelease, message: string) { * been decided or withdrawn, organisers are told instead (once). */ async function refundRequestOnce(check: CheckForRelease, orderId: string, note: string, guestName: string, now: Date) { + const no = { checkId: check.id, respondedAt: now }; if (check.refundRequestOpenedAt) { const request = check.refundRequestId ? await prisma.refundRequest.findUnique({ where: { id: check.refundRequestId }, select: { status: true } }) : null; - await recordOutcome(check.id, "refundRequested"); + await recordOutcome(no, "refundRequested"); if (request?.status === RefundRequestStatus.OPEN) return { released: false as const, message: keptMessage("refundRequested") }; await prisma.$transaction(async (tx) => { - const first = await tx.attendanceCheck.updateMany({ where: { id: check.id, organisersNotifiedAt: null }, data: { organisersNotifiedAt: now } }); + const first = await tx.attendanceCheck.updateMany({ where: { id: check.id, organisersNotifiedAt: null, response: "NOT_ATTENDING", respondedAt: now }, data: { organisersNotifiedAt: now } }); if (first.count === 1) await notifyOrganisersOfKeptPlace(tx, { organisationId: check.event.organisationId, eventId: check.eventId, eventTitle: check.event.title, rsvpId: check.rsvpId, guestName, outcome: "refundRequested" }); }); return { released: false as const, message: HANDLED_REQUEST }; } // The guest's note goes to the organisers with the request (Finance). const reason = `Answered No to attendance confirmation for ${check.event.title}${note ? `: ${note}` : "."}`.slice(0, 1000); - const result = await requestRefundForOrder(orderId, reason, (tx, requestId) => tx.attendanceCheck.updateMany({ where: { id: check.id }, data: { refundRequestId: requestId, refundRequestOpenedAt: now } })); + // Opened only while this No is still the answer (checked under the order and check locks). + const result = await requestRefundForOrder(orderId, reason, (tx, requestId) => tx.attendanceCheck.updateMany({ where: { id: check.id }, data: { refundRequestId: requestId, refundRequestOpenedAt: now } }), no); // An open request on the order (the buyer's own) already reaches the organisers. - if (!result.ok && result.error !== ALREADY_REQUESTED) return failed(check, result.error); + if (!result.ok && result.error !== ALREADY_REQUESTED) return failed(no, result.error); if (!result.ok) { // The buyer's own open request stands for this check: a later No or Yes follows that one. const open = await prisma.refundRequest.findFirst({ where: { orderId, status: RefundRequestStatus.OPEN }, select: { id: true } }); - await prisma.attendanceCheck.updateMany({ where: { id: check.id }, data: { refundRequestOpenedAt: now, refundRequestId: open?.id ?? null } }); + await prisma.attendanceCheck.updateMany({ where: { id: check.id, response: "NOT_ATTENDING", respondedAt: now }, data: { refundRequestOpenedAt: now, refundRequestId: open?.id ?? null } }); } - await recordOutcome(check.id, "refundRequested"); + await recordOutcome(no, "refundRequested"); return { released: false as const, message: keptMessage("refundRequested") }; } @@ -117,21 +120,23 @@ async function refundRequestOnce(check: CheckForRelease, orderId: string, note: */ export async function carryOutRelease(params: { check: CheckForRelease; plan: ReleasePlan; note: string; guestName: string; now: Date; }): Promise<{ released: true; } | { released: false; message: string; }> { const { check, plan } = params; + // `now` is when this No was recorded: every step acts only while it is still the answer. + const no: AttendanceNo = { checkId: check.id, respondedAt: params.now }; switch (plan.kind) { case "hold": return { released: false, message: keptMessage(plan.outcome) }; case "none": - return failed(check, plan.reason); + return failed(no, plan.reason); case "free": { - const result = await cancelRegistrationByToken(check.rsvp.userId, check.rsvp.checkInToken, { revalidate: false }); - return result.ok ? released(check, false) : failed(check, result.error); + const result = await cancelRegistrationByToken(check.rsvp.userId, check.rsvp.checkInToken, { revalidate: false, attendanceNo: no }); + return result.ok ? released(check, false) : failed(no, result.error); } case "paid": { try { - await cancelOrderAsBuyer({ orderId: plan.orderId, now: params.now }); + await cancelOrderAsBuyer({ orderId: plan.orderId, now: params.now, attendanceNo: no }); } catch (error) { if (!(error instanceof PaymentOperationError)) console.error("[attendance check] paid release failed"); - return failed(check, error instanceof PaymentOperationError ? error.message : "Your order could not be cancelled automatically."); + return failed(no, error instanceof PaymentOperationError ? error.message : "Your order could not be cancelled automatically."); } return released(check, true); } @@ -147,9 +152,18 @@ export async function carryOutRelease(params: { check: CheckForRelease; plan: Re * guest's current answer beside it. */ export async function withdrawRefundRequestOnYes(check: CheckForRelease, guestName: string, now: Date) { - if (!check.refundRequestId) return; - const requestId = check.refundRequestId; + // Read again: a No answered just before this Yes may have opened a request since. + const current = await prisma.attendanceCheck.findUnique({ where: { id: check.id }, select: { refundRequestId: true, refundRequest: { select: { orderId: true } } } }); + if (!current?.refundRequestId || !current.refundRequest) return; + const requestId = current.refundRequestId; + const orderId = current.refundRequest.orderId; await prisma.$transaction(async (tx) => { + // The order, then the check (docs/PAYMENTS.md), and only while this Yes + // (recorded at `now`) is still the answer: a newer No keeps its request. + await tx.$queryRaw`SELECT "id" FROM "Order" WHERE "id" = ${orderId} FOR UPDATE`; + await tx.$queryRaw`SELECT "id" FROM "AttendanceCheck" WHERE "id" = ${check.id} FOR UPDATE`; + const locked = await tx.attendanceCheck.findUnique({ where: { id: check.id }, select: { response: true, respondedAt: true, refundRequestId: true } }); + if (locked?.response !== "ATTENDING" || locked.respondedAt?.getTime() !== now.getTime() || locked.refundRequestId !== requestId) return; const withdrawn = await tx.refundRequest.updateMany({ where: { id: requestId, status: RefundRequestStatus.OPEN }, data: { status: RefundRequestStatus.WITHDRAWN, decisionNote: "Withdrawn: the guest said they're coming after all.", decidedAt: now }, diff --git a/lib/core/teams/leader.ts b/lib/core/teams/leader.ts index afcb4064..d4c72e5b 100644 --- a/lib/core/teams/leader.ts +++ b/lib/core/teams/leader.ts @@ -115,6 +115,8 @@ export async function removeTeamMember(auth: TeamLeaderAuth, rsvpId: string): Pr const team = await lockedLeaderTeam(tx, auth); if (!team) return fail(TEAM_NOT_FOUND); if (team.leaderRsvpId === rsvpId) return fail("You can't remove yourself. Transfer leadership or disband the team instead."); + // Lock the registration before reading its check-in (docs/PAYMENTS.md): a scan waits, or is seen. + await tx.$queryRaw`SELECT "id" FROM "RSVP" WHERE "id" = ${rsvpId} FOR UPDATE`; const member = await tx.rSVP.findFirst({ where: { id: rsvpId, eventId: event.id, teamId: team.id }, select: { ...rsvpWebhookSnapshotSelect, status: true }, @@ -122,7 +124,8 @@ export async function removeTeamMember(auth: TeamLeaderAuth, rsvpId: string): Pr if (!member) return fail("Member not found."); if (member.checkedInAt) return fail("This member has already checked in."); if (await hasPaidOrder(tx, { id: member.id })) return fail(PAID_MEMBER_REFUSAL); - await tx.rSVP.delete({ where: { id: member.id } }); + const deleted = await tx.rSVP.deleteMany({ where: { id: member.id, checkedInAt: null } }); + if (deleted.count !== 1) throw new Error("The member changed while being removed."); // The team side of the registration cancels it, as when the member leaves. webhooks.push({ type: "registration.status_changed", rsvpId: member.id, status: "CANCELLED", previousStatus: member.status, snapshot: rsvpWebhookSnapshot(member) }); const settled = await settleTeamAfterMemberLeft(tx, { teamId: team.id, eventId: event.id, leftRsvpId: member.id }); @@ -240,6 +243,8 @@ export async function disbandTeam(auth: TeamLeaderAuth): Promise 0) return fail("Someone in this team has already checked in, so it can't be disbanded."); if (await hasPaidOrder(tx, { teamId: team.id })) return fail(PAID_MEMBER_REFUSAL); @@ -249,7 +254,9 @@ export async function disbandTeam(auth: TeamLeaderAuth): Promise { +export async function startCheckout(orderId: string, at?: Date): Promise { const base = publicBaseUrl(); const prepared = await prisma.$transaction(async (tx) => { + const row = await tx.order.findUnique({ where: { id: orderId }, select: { id: true, eventId: true, eventSeriesId: true } }); + if (row) await lockOrderTargets(tx, row); await tx.$queryRaw`SELECT "id" FROM "Order" WHERE "id" = ${orderId} FOR UPDATE`; + const now = at ?? new Date(); const order = await tx.order.findUnique({ where: { id: orderId }, select: { diff --git a/lib/payments/occurrences.ts b/lib/payments/occurrences.ts index 6b0c60bc..fbd38396 100644 --- a/lib/payments/occurrences.ts +++ b/lib/payments/occurrences.ts @@ -7,8 +7,12 @@ import { notifyChat } from "@/lib/chat/notify"; import { noticeEach, noticeSubject, titleWith } from "@/lib/notices"; import { peopleWithPlaces } from "./eventCancellation"; import { seatTakingWhere } from "./holds"; -import { PaymentOperationError, refundOrder } from "./operations"; -import { lockSeriesDates } from "./targets"; +import { PaymentOperationError, refundOrder, submitRefunds } from "./operations"; +import { ORDER_FOR_REFUND, recordRefundsTx } from "./operations/refund"; +import { formatMoney } from "./money"; +import { enqueuePaymentEmail } from "./email"; +import { lockOrderTargets, lockSeriesDates } from "./targets"; +import { lockOrderRegistrations } from "./teamOrders"; const ZERO = BigInt(0); const COLLECTED: PaymentStatus[] = [PaymentStatus.PAID_ONLINE, PaymentStatus.PAID_OFFLINE]; @@ -24,16 +28,20 @@ async function ownedDate(organisationId: string, seriesId: string, instanceId: s /** * Give one date its own capacity, or clear it to use the series capacity. - * Never below the seats already taken (confirmed, or held while paying). + * Never below the seats already taken (confirmed, or held while paying), + * counted under the series and date locks with the time read after them; + * clearing checks the series capacity it would inherit, read in the same + * transaction. */ export async function setOccurrenceCapacity(params: { organisationId: string; seriesId: string; instanceId: string; capacity: number | null; actorUserId: string; now?: Date; }) { - const now = params.now ?? new Date(); const date = await ownedDate(params.organisationId, params.seriesId, params.instanceId); await prisma.$transaction(async (tx) => { - await lockSeriesDates(tx, date.eventSeriesId, [date.id], now); - if (params.capacity != null) { + await lockSeriesDates(tx, date.eventSeriesId, [date.id], params.now ?? new Date()); + const now = params.now ?? new Date(); + const effective = params.capacity ?? (await tx.eventSeries.findUniqueOrThrow({ where: { id: date.eventSeriesId }, select: { capacity: true } })).capacity; + if (effective != null) { const taken = await tx.rSVP.count({ where: { eventInstanceId: date.id, ...seatTakingWhere(now) } }); - if (taken > params.capacity) throw new PaymentOperationError(`${taken} places are already taken on this date.`); + if (taken > effective) throw new PaymentOperationError(params.capacity != null ? `${taken} places are already taken on this date.` : `${taken} places are already taken on this date, more than the series capacity of ${effective}.`); } await tx.eventInstance.update({ where: { id: date.id }, data: { capacity: params.capacity } }); await recordAuditEvent({ action: "EVENT_INSTANCE_CAPACITY_UPDATED", actorUserId: params.actorUserId, organisationId: params.organisationId, targetType: "EventInstance", targetId: date.id, metadata: { capacity: params.capacity }, client: tx }); @@ -94,7 +102,7 @@ export async function cancelOccurrence(params: { organisationId: string; seriesI if (order.eventInstanceId === date.id) { await refundOrder({ orderId: order.id, organisationId: params.organisationId, actorUserId: params.actorUserId, amountMinor: null, reason: `Date cancelled: ${params.reason}`.slice(0, 500), cancelTickets: true, now }); } else { - await refundPassShare({ orderId: order.id, instanceId: date.id, organisationId: params.organisationId, actorUserId: params.actorUserId, reason: params.reason, now }); + await submitRefunds(await refundPassShare({ orderId: order.id, instanceId: date.id, actorUserId: params.actorUserId, reason: params.reason, now })); } refunded += 1; } else if (order.status === OrderStatus.PENDING) { @@ -119,18 +127,37 @@ export async function cancelOccurrence(params: { organisationId: string; seriesI return { orders: orders.length, refunded, failed, notified }; } -/** A pass loses one date: refund that date's share of what's left on it, then remove its seats there. */ -async function refundPassShare(params: { orderId: string; instanceId: string; organisationId: string; actorUserId: string; reason: string; now: Date; }) { - const [onDate, all, payments] = await Promise.all([ - prisma.rSVP.count({ where: { orderId: params.orderId, eventInstanceId: params.instanceId } }), - prisma.rSVP.count({ where: { orderId: params.orderId } }), - prisma.payment.findMany({ where: { orderId: params.orderId, status: { in: COLLECTED } }, select: { amountMinor: true, refunds: { where: { status: { in: ["PENDING", "SUCCEEDED"] } }, select: { amountMinor: true } } } }), - ]); - if (onDate === 0 || all === 0) return; - const left = payments.reduce((sum, payment) => sum + payment.amountMinor - payment.refunds.reduce((refunds, refund) => refunds + refund.amountMinor, ZERO), ZERO); - const share = (left * BigInt(onDate)) / BigInt(all); - if (share > ZERO) { - await refundOrder({ orderId: params.orderId, organisationId: params.organisationId, actorUserId: params.actorUserId, amountMinor: share, reason: `Date cancelled: ${params.reason}`.slice(0, 500), cancelTickets: false, now: params.now }); - } - await prisma.rSVP.deleteMany({ where: { orderId: params.orderId, eventInstanceId: params.instanceId, checkedInAt: null } }); +/** + * A pass loses one date, in one transaction: the series, the pass's dates by + * id, the order, its payments and its registrations are locked (docs/PAYMENTS.md), + * then the share is worked out, recorded and the date's places removed + * together. A pass with nobody left on the date was handled already, so a + * retry (or a second organiser) refunds nothing more; a date someone on the + * pass already checked in to is refused, for the organiser to settle by hand. + * Returns the online refunds to submit after commit. + */ +async function refundPassShare(params: { orderId: string; instanceId: string; actorUserId: string; reason: string; now: Date; }): Promise { + return prisma.$transaction(async (tx) => { + const head = await tx.order.findUnique({ where: { id: params.orderId }, select: { id: true, eventId: true, eventSeriesId: true, teamId: true, teamName: true } }); + if (!head) return []; + await lockOrderTargets(tx, head, params.now); + await tx.$queryRaw`SELECT "id" FROM "Order" WHERE "id" = ${head.id} FOR UPDATE`; + await tx.$queryRaw`SELECT "id" FROM "Payment" WHERE "orderId" = ${head.id} ORDER BY "id" FOR UPDATE`; + await lockOrderRegistrations(tx, head); + const order = await tx.order.findUniqueOrThrow({ where: { id: head.id }, select: { ...ORDER_FOR_REFUND, organisationId: true } }); + if (order.status !== OrderStatus.CONFIRMED) return []; + const onDate = order.rsvps.filter((rsvp) => rsvp.eventInstanceId === params.instanceId); + if (onDate.length === 0) return []; + if (onDate.some((rsvp) => rsvp.checkedInAt)) throw new PaymentOperationError("Someone on this pass has already checked in to this date."); + const reason = `Date cancelled: ${params.reason}`.slice(0, 500); + const { wanted, onlineRefundIds, refundedPaymentId } = await recordRefundsTx(tx, order.payments, { wanted: null, share: { of: onDate.length, over: order.rsvps.length }, currency: order.currency, reason, actorUserId: params.actorUserId, now: params.now }); + const removed = await tx.rSVP.deleteMany({ where: { orderId: order.id, eventInstanceId: params.instanceId, checkedInAt: null } }); + if (removed.count !== onDate.length) throw new Error("The pass changed while its date was being cancelled."); + if (wanted > ZERO && refundedPaymentId) { + await enqueuePaymentEmail(tx, { to: order.buyerEmail, paymentId: refundedPaymentId, notice: "refunded" }); + await notifyChat(tx, order.organisationId, "refund", `Refund of ${formatMoney(wanted, order.currency)} for ${order.buyerEmail}. Reason: ${reason.slice(0, 200)}`); + } + await recordAuditEvent({ action: "PAYMENT_REFUNDED", actorUserId: params.actorUserId, organisationId: order.organisationId, targetType: "Order", targetId: order.id, metadata: { amountMinor: wanted.toString(), cancelled: false, instanceId: params.instanceId }, client: tx }); + return onlineRefundIds; + }); } diff --git a/lib/payments/operations/refund.ts b/lib/payments/operations/refund.ts index 07609dce..a6e5209e 100644 --- a/lib/payments/operations/refund.ts +++ b/lib/payments/operations/refund.ts @@ -10,7 +10,8 @@ import { formatMoney } from "../money"; import { asRefundPolicy, policyRefundMinor } from "../refundPolicy"; import { createPendingRefund, submitPendingRefund } from "../refunds"; import { seatTargetOf } from "../targets"; -import { releaseTeamPlacesForCancelledOrder, type CancelledRegistration } from "../teamOrders"; +import { lockOrderRegistrations, releaseTeamPlacesForCancelledOrder, type CancelledRegistration } from "../teamOrders"; +import { ANSWER_CHANGED, lockCurrentNo, type AttendanceNo } from "@/lib/attendanceGuard"; import { COLLECTED, COMMITTED_REFUND, lockOrder, PaymentOperationError, ZERO } from "./shared"; import { fillFromWaitlist } from "./waitlist"; @@ -26,16 +27,16 @@ function refundable(payments: Array<{ id: string; amountMinor: bigint; refunds: /** * Lock an order's collected payments and record refunds of `wanted` (null: - * everything left) across them, oldest first. Money paid in person is - * recorded as returned in person; online refunds are PENDING rows sent to the - * provider after commit. + * everything left, or `share` of it) across them, oldest first. Money paid in + * person is recorded as returned in person; online refunds are PENDING rows + * sent to the provider after commit. The caller holds the order lock. */ -async function recordRefundsTx(tx: Prisma.TransactionClient, payments: CollectedPayment[], params: { wanted: bigint | null; currency: string; reason: string; actorUserId: string | null; now: Date; }) { +export async function recordRefundsTx(tx: Prisma.TransactionClient, payments: CollectedPayment[], params: { wanted: bigint | null; share?: { of: number; over: number; }; currency: string; reason: string; actorUserId: string | null; now: Date; }) { for (const payment of payments) await tx.$queryRaw`SELECT "id" FROM "Payment" WHERE "id" = ${payment.id} FOR UPDATE`; const refunds = await tx.paymentRefund.findMany({ where: { paymentId: { in: payments.map((payment) => payment.id) } }, select: { paymentId: true, amountMinor: true, status: true } }); const balances = refundable(payments.map((payment) => ({ ...payment, refunds: refunds.filter((refund) => refund.paymentId === payment.id) }))); const available = balances.reduce((sum, balance) => sum + balance.remaining, ZERO); - const wanted = params.wanted ?? available; + const wanted = params.wanted ?? (params.share ? (available * BigInt(params.share.of)) / BigInt(params.share.over) : available); if (wanted < ZERO || wanted > available) throw new PaymentOperationError(`You can refund up to ${formatMoney(available, params.currency)} on this order.`); const onlineRefundIds: string[] = []; let left = wanted; @@ -57,7 +58,7 @@ async function recordRefundsTx(tx: Prisma.TransactionClient, payments: Collected return { wanted, onlineRefundIds, refundedPaymentId }; } -const ORDER_FOR_REFUND = { +export const ORDER_FOR_REFUND = { id: true, status: true, buyerEmail: true, currency: true, eventId: true, teamId: true, teamName: true, teamJoin: true, rsvps: { select: { id: true, checkedInAt: true, eventId: true, eventInstanceId: true, userId: true, guestName: true, createdAt: true, status: true } }, payments: { where: { status: { in: COLLECTED } }, orderBy: { paidAt: "asc" }, select: { id: true, status: true, amountMinor: true } }, @@ -85,12 +86,21 @@ export async function refundOrder(params: { reason: string; cancelTickets: boolean; now?: Date; + /** An attendance "No" cancelling the order: only while that answer is still current. */ + attendanceNo?: AttendanceNo; }) { const now = params.now ?? new Date(); const webhooks: WebhookEmission[] = []; const outcome = await prisma.$transaction(async (tx) => { await lockOrder(tx, params.orderId); - const order = await tx.order.findFirst({ where: { id: params.orderId, organisationId: params.organisationId }, select: ORDER_FOR_REFUND }); + const head = await tx.order.findFirst({ where: { id: params.orderId, organisationId: params.organisationId }, select: { id: true, eventId: true, teamId: true, teamName: true } }); + if (!head) throw new PaymentOperationError("Only confirmed orders can be refunded or cancelled."); + // Then its payments, then its registrations by id (docs/PAYMENTS.md), before + // any check-in is read: a scan waits for this, or is seen below. + await tx.$queryRaw`SELECT "id" FROM "Payment" WHERE "orderId" = ${head.id} ORDER BY "id" FOR UPDATE`; + await lockOrderRegistrations(tx, head); + if (params.attendanceNo && !(await lockCurrentNo(tx, params.attendanceNo))) throw new PaymentOperationError(ANSWER_CHANGED); + const order = await tx.order.findFirst({ where: { id: head.id }, select: ORDER_FOR_REFUND }); if (!order || order.status !== OrderStatus.CONFIRMED) throw new PaymentOperationError("Only confirmed orders can be refunded or cancelled."); if (params.amountMinor === ZERO && !params.cancelTickets) throw new PaymentOperationError("Enter an amount to refund."); if (params.cancelTickets && order.rsvps.some((rsvp) => rsvp.checkedInAt)) throw new PaymentOperationError("Someone on this order has checked in, so its tickets can't be cancelled."); @@ -102,8 +112,10 @@ export async function refundOrder(params: { await tx.order.update({ where: { id: order.id }, data: { status: OrderStatus.CANCELLED } }); // A reservation to be paid at the desk is no longer owed. await tx.payment.updateMany({ where: { orderId: order.id, status: PaymentStatus.PENDING }, data: { status: PaymentStatus.CANCELED } }); - // The order, its lines, payments and refunds keep the record. - await tx.rSVP.deleteMany({ where: { orderId: order.id } }); + // The order, its lines, payments and refunds keep the record. Nothing + // checked in is ever deleted: a mismatch rolls the whole cancellation back. + const deleted = await tx.rSVP.deleteMany({ where: { orderId: order.id, checkedInAt: null } }); + if (deleted.count !== order.rsvps.length) throw new Error("The order changed while it was being cancelled."); webhooks.push(...[...order.rsvps, ...teamRemoved].map(cancelledWebhook)); } if (refundedPaymentId) await enqueuePaymentEmail(tx, { to: order.buyerEmail, paymentId: refundedPaymentId, notice: "refunded" }); @@ -173,13 +185,13 @@ export async function buyerCancellation(orderId: string, now = new Date()) { } /** The buyer cancels their whole order within its refund policy. */ -export async function cancelOrderAsBuyer(params: { orderId: string; now?: Date; }) { +export async function cancelOrderAsBuyer(params: { orderId: string; now?: Date; attendanceNo?: AttendanceNo; }) { const now = params.now ?? new Date(); const order = await prisma.order.findUnique({ where: { id: params.orderId }, select: { organisationId: true, buyerUserId: true } }); const terms = await buyerCancellation(params.orderId, now); if (!order || !terms) throw new PaymentOperationError("This order can't be cancelled online. Contact the organiser."); return refundOrder({ orderId: params.orderId, organisationId: order.organisationId, actorUserId: order.buyerUserId, - amountMinor: terms.refundMinor, reason: "Cancelled by the buyer under the refund policy", cancelTickets: true, now, + amountMinor: terms.refundMinor, reason: "Cancelled by the buyer under the refund policy", cancelTickets: true, now, attendanceNo: params.attendanceNo, }); } diff --git a/lib/payments/teamOrders.ts b/lib/payments/teamOrders.ts index 2c0204d4..4b983d5b 100644 --- a/lib/payments/teamOrders.ts +++ b/lib/payments/teamOrders.ts @@ -174,7 +174,9 @@ export async function releaseTeamPlacesForCancelledOrder(tx: Prisma.TransactionC if (order.teamName != null && order.teamId && event.teamTicketMode === TeamTicketMode.PER_TEAM) { const members = await tx.rSVP.findMany({ where: { teamId: order.teamId, eventId: order.eventId, OR: [{ orderId: null }, { orderId: { not: order.id } }] }, select: CANCELLED_SELECT }); if (members.some((member) => member.checkedInAt)) return { error: "Someone in this team has checked in, so its team ticket can't be cancelled." }; - await tx.rSVP.deleteMany({ where: { id: { in: members.map((member) => member.id) } } }); + // The caller locked these rows (lockOrderRegistrations); a mismatch rolls the whole cancellation back. + const deleted = await tx.rSVP.deleteMany({ where: { id: { in: members.map((member) => member.id) }, checkedInAt: null } }); + if (deleted.count !== members.length) throw new Error("The team changed while its ticket was being cancelled."); await tx.team.deleteMany({ where: { id: order.teamId } }); const emails = [...new Set(members.flatMap((member) => personEmail(member) ?? []))]; await noticeEach(tx, emails, { @@ -190,6 +192,21 @@ export async function releaseTeamPlacesForCancelledOrder(tx: Prisma.TransactionC return []; } +/** + * Lock, by id, every registration that cancelling this order removes: its + * own, and for a team leader's order the whole team (a PER_TEAM ticket takes + * its members with it). Called after the order's payments are locked + * (docs/PAYMENTS.md), so a check-in either commits first and is seen, or + * waits and finds the registration gone. + */ +export async function lockOrderRegistrations(tx: Prisma.TransactionClient, order: { id: string; eventId: string | null; teamId: string | null; teamName: string | null; }) { + if (order.teamName != null && order.teamId && order.eventId) { + await tx.$queryRaw`SELECT "id" FROM "RSVP" WHERE "orderId" = ${order.id} OR ("teamId" = ${order.teamId} AND "eventId" = ${order.eventId}) ORDER BY "id" FOR UPDATE`; + } else { + await tx.$queryRaw`SELECT "id" FROM "RSVP" WHERE "orderId" = ${order.id} ORDER BY "id" FOR UPDATE`; + } +} + /** Whether any of these registrations came from an order that cost money (team actions can't delete those: they are cancelled as an order, with a refund). */ export async function hasPaidOrder(tx: Prisma.TransactionClient, where: Prisma.RSVPWhereInput) { return (await tx.rSVP.count({ where: { ...where, order: { totalMinor: { gt: 0 }, status: OrderStatus.CONFIRMED } } })) > 0; diff --git a/lib/refundRequests.ts b/lib/refundRequests.ts index 99929dec..de8a1d01 100644 --- a/lib/refundRequests.ts +++ b/lib/refundRequests.ts @@ -10,6 +10,7 @@ import { formatMoney, parseMoneyToMinor } from "@/lib/payments/money"; import { PaymentOperationError, refundOrder } from "@/lib/payments/operations"; import { orderSubject, ORDER_SUBJECT_SELECT } from "@/lib/payments/targets"; import type { ClaimAdminContext } from "@/lib/claims/decide"; +import { ANSWER_CHANGED, lockCurrentNo, type AttendanceNo } from "@/lib/attendanceGuard"; /** * Refund requests (docs/FINANCE.md): a buyer asks for money back outside the @@ -48,19 +49,25 @@ export async function requestRefund(orderToken: string, reason: unknown): Promis * `onCreated` runs in the request's own transaction (the attendance check * records which request it opened). */ -export async function requestRefundForOrder(orderId: string, reason: string, onCreated?: (tx: Prisma.TransactionClient, requestId: string) => Promise): Promise { +export async function requestRefundForOrder(orderId: string, reason: string, onCreated?: (tx: Prisma.TransactionClient, requestId: string) => Promise, attendanceNo?: AttendanceNo): Promise { const parsedReason = reasonSchema.safeParse(reason); if (!parsedReason.success) return { ok: false, error: parsedReason.error.issues[0]?.message ?? "Say why." }; const order = await prisma.order.findUnique({ where: { id: orderId }, select: ORDER_FOR_REQUEST }); - return openRefundRequest(order, parsedReason.data, onCreated); + return openRefundRequest(order, parsedReason.data, onCreated, attendanceNo); } -async function openRefundRequest(order: Prisma.OrderGetPayload<{ select: typeof ORDER_FOR_REQUEST; }> | null, reason: string, onCreated?: (tx: Prisma.TransactionClient, requestId: string) => Promise): Promise { +async function openRefundRequest(order: Prisma.OrderGetPayload<{ select: typeof ORDER_FOR_REQUEST; }> | null, reason: string, onCreated?: (tx: Prisma.TransactionClient, requestId: string) => Promise, attendanceNo?: AttendanceNo): Promise { if (!order) return { ok: false, error: "Order not found." }; if (order.status !== OrderStatus.CONFIRMED || order.payments.length === 0) return { ok: false, error: "Only paid orders can ask for a refund." }; const title = orderSubject(order)?.title ?? "your event"; try { - await prisma.$transaction(async (tx) => { + const opened = await prisma.$transaction(async (tx) => { + // An attendance No opens a request only while it is still the answer: + // the order, then the check, locked before anything is written. + if (attendanceNo) { + await tx.$queryRaw`SELECT "id" FROM "Order" WHERE "id" = ${order.id} FOR UPDATE`; + if (!(await lockCurrentNo(tx, attendanceNo))) return false; + } const request = await tx.refundRequest.create({ data: { organisationId: order.organisationId, orderId: order.id, reason }, select: { id: true } }); await recordAuditEvent({ action: "REFUND_REQUESTED", organisationId: order.organisationId, targetType: "RefundRequest", targetId: request.id, metadata: { orderId: order.id }, client: tx }); await onCreated?.(tx, request.id); @@ -70,7 +77,9 @@ async function openRefundRequest(order: Prisma.OrderGetPayload<{ select: typeof subject: noticeSubject("Refund request", title), lines: [`${order.buyerName || "A buyer"} asked for a refund on order ${order.reference}: ${reason}`, "Approve or decline it from the event's Finance tab."], }); + return true; }); + if (!opened) return { ok: false, error: ANSWER_CHANGED }; } catch (error) { if (isUniqueViolation(error)) return { ok: false, error: ALREADY_REQUESTED }; throw error; diff --git a/lib/rsvpCancel.ts b/lib/rsvpCancel.ts index eccfa5a2..2463c7c0 100644 --- a/lib/rsvpCancel.ts +++ b/lib/rsvpCancel.ts @@ -6,7 +6,7 @@ import { prisma } from "@/lib/db"; import { enqueueRsvpStatusNotification } from "@/lib/outbox"; import { recordAuditEvent } from "@/lib/audit"; import type { ActionResult } from "@/app/actions/org"; -import { EventPrivacyType, RegistrationMode, RsvpStatus } from "@prisma/client"; +import { EventPrivacyType, EventStatus, RegistrationMode, RsvpStatus } from "@prisma/client"; import { emitTeamWebhooks, promoteOldestWaitlistedUnit, settleTeamAfterMemberLeft, type RegistrationStatusWebhook } from "@/lib/teamUnits"; import { NOT_FROM_PAID_ORDER } from "@/lib/rsvpCapacity"; import { fillFromWaitlist } from "@/lib/payments/operations"; @@ -15,6 +15,7 @@ import { leadsPaidTeam } from "@/lib/payments/teamOrders"; import { noticeEach, noticeSubject, personEmail } from "@/lib/notices"; import { emitWebhookEvent, type RsvpWebhookSnapshot } from "@/lib/webhooks/emit"; import { notifyChat } from "@/lib/chat/notify"; +import { ANSWER_CHANGED, lockCurrentNo, type AttendanceNo } from "@/lib/attendanceGuard"; const revalidatePath = trackedRevalidation(nextRevalidatePath); @@ -44,6 +45,8 @@ export async function cancelRegistrationByToken( * "Your place has been released". Both public pages render per request. */ revalidate?: boolean; + /** An attendance "No" releasing the place: cancelled only while that answer is still current. */ + attendanceNo?: AttendanceNo; } = {}, ): Promise> { const rsvp = await prisma.rSVP.findUnique({ @@ -96,10 +99,15 @@ export async function cancelRegistrationByToken( } else if (rsvp.eventInstanceId && rsvp.eventInstance) { await lockSeriesDates(tx, rsvp.eventInstance.eventSeriesId, [rsvp.eventInstanceId], new Date()); } + // Then its order and the registration itself (docs/PAYMENTS.md): a check-in + // either commits first and is seen below, or waits and finds it gone. + if (rsvp.orderId) await tx.$queryRaw`SELECT "id" FROM "Order" WHERE "id" = ${rsvp.orderId} FOR UPDATE`; + await tx.$queryRaw`SELECT "id" FROM "RSVP" WHERE "id" = ${rsvp.id} FOR UPDATE`; const current = await tx.rSVP.findUnique({ where: { id: rsvp.id }, include: { user: { select: { email: true } }, order: { select: { id: true, status: true, buyerEmail: true, totalMinor: true, payments: { where: { status: { in: ["PAID_ONLINE", "PAID_OFFLINE", "PENDING"] } }, select: { id: true, status: true } } } } } }); if (!current) return { error: "RSVP not found." }; if (current.isCrew) return { error: "You're on this event's crew. Ask an organiser to remove you from the crew." }; if (current.checkedInAt) return { error: "Cannot cancel — you have already checked in." }; + if (options.attendanceNo && !(await lockCurrentNo(tx, options.attendanceNo))) return { error: ANSWER_CHANGED }; // Money was taken: refunds follow the order's policy, from the order page. if (current.order?.payments.some((payment) => payment.status !== "PENDING")) return { error: "Paid tickets are cancelled from the order page, under the event's refund policy.", code: "PAID_ORDER" as const }; // Money still owed (reserved to pay at the desk), or a PER_TEAM leader's @@ -125,7 +133,9 @@ export async function cancelRegistrationByToken( await tx.rSVP.updateMany({ where: { orderId: current.order.id, status: "PENDING_PAYMENT" }, data: { holdExpiresAt: now } }); return { error: null }; } - await tx.rSVP.delete({ where: { id: current.id } }); + const deleted = await tx.rSVP.deleteMany({ where: { id: current.id, checkedInAt: null } }); + // Never a partial cancellation: anything unexpected rolls the whole transaction back. + if (deleted.count !== 1) throw new Error("The registration changed while it was being cancelled."); removed = { previousStatus: current.status, snapshot: { id: current.id, eventId: current.eventId, eventInstanceId: current.eventInstanceId, userId: current.userId, guestName: current.guestName, createdAt: current.createdAt, checkedInAt: current.checkedInAt } }; const to = personEmail(current); if (to) { @@ -156,7 +166,9 @@ export async function cancelRegistrationByToken( const sold = current.eventInstanceId ? await tx.ticketType.count({ where: { eventSeriesId: rsvp.eventInstance!.eventSeriesId } }) > 0 : true; const freed = seatTargetOf(current); if (current.status === "CONFIRMED" && freed && (current.eventId || sold)) return { error: null, freed }; - if (current.status === "CONFIRMED" && privacyType !== EventPrivacyType.APPROVAL_REQUIRED) { + // A cancelled date or series promotes nobody (read under its lock). + const offDate = current.eventInstanceId ? await tx.eventInstance.count({ where: { id: current.eventInstanceId, OR: [{ cancelledAt: { not: null } }, { series: { status: EventStatus.CANCELLED } }] } }) > 0 : false; + if (current.status === "CONFIRMED" && privacyType !== EventPrivacyType.APPROVAL_REQUIRED && !offDate) { const target = current.eventId ? { eventId: current.eventId } : { eventInstanceId: current.eventInstanceId! }; const nextWaitlisted = await tx.rSVP.findFirst({ // Tickets from paid orders are confirmed only by payment, never by promotion. From e6af2fe7a63bcfbd15a0455aa8144f716b135f09 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:07:40 +0530 Subject: [PATCH 24/79] test(rsvp): narrow registration results in the public count suite Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../integration/rsvp-public-count.db.test.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/integration/rsvp-public-count.db.test.ts b/tests/integration/rsvp-public-count.db.test.ts index 58d7672c..3cfee2f6 100644 --- a/tests/integration/rsvp-public-count.db.test.ts +++ b/tests/integration/rsvp-public-count.db.test.ts @@ -13,6 +13,7 @@ import { submitRsvp } from "@/app/actions/rsvp"; import { POST as postRsvp } from "@/app/api/rsvp/route"; import { countConfirmedForEvent } from "@/lib/rsvpCapacity"; import { countCrewTickets } from "@/lib/core/crew/public"; +import type { ActionResult } from "@/app/actions/org"; import { paid, setUpPaidOrg, tearDownPaidOrg } from "./helpers/paidOrg"; /* @@ -24,6 +25,10 @@ import { paid, setUpPaidOrg, tearDownPaidOrg } from "./helpers/paidOrg"; let counter = 0; let userId = ""; +function dataOf(result: ActionResult) { + if (!result.ok) throw new Error(result.error); + return result.data; +} const email = (who: string) => `${who}-${paid.suffix}@example.test`; async function makeEvent(options: { show: boolean; capacity?: number | null; team?: boolean; price?: number }) { @@ -74,13 +79,13 @@ describe("registration count in the registration response", () => { await seedOthers(event.id); const asGuest = await guest(event.slug, "hidden-guest"); expect(asGuest.ok).toBe(true); - expect(asGuest.data).not.toHaveProperty("count"); + expect(dataOf(asGuest)).not.toHaveProperty("count"); const signedIn = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug: event.slug, answers: {} }, { userId }); expect(signedIn.ok).toBe(true); - expect(signedIn.data?.count).toBeUndefined(); + expect(dataOf(signedIn)?.count).toBeUndefined(); const action = await submitRsvp({ orgSlug: paid.orgSlug, eventSlug: event.slug, guestEmail: email("hidden-action"), name: "Count Guest", answers: {} }); expect(action.ok).toBe(true); - expect(action.data?.count).toBeUndefined(); + expect(dataOf(action)?.count).toBeUndefined(); const response = await postRsvp(new Request("https://events.example.test/api/rsvp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ orgSlug: paid.orgSlug, eventSlug: event.slug, guestEmail: email("hidden-route"), name: "Count Guest", answers: {} }), @@ -96,12 +101,12 @@ describe("registration count in the registration response", () => { await seedOthers(event.id); const first = await guest(event.slug, "shown-first"); expect(first).toMatchObject({ ok: true, data: { status: RsvpStatus.CONFIRMED, count: 2 } }); - expect(first.data?.count).toBe(await pageCount(event.id)); + expect(dataOf(first)?.count).toBe(await pageCount(event.id)); const signedIn = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug: event.slug, answers: {} }, { userId }); expect(signedIn).toMatchObject({ ok: true, data: { status: RsvpStatus.CONFIRMED, count: 3 } }); const waitlisted = await guest(event.slug, "shown-waitlisted"); expect(waitlisted).toMatchObject({ ok: true, data: { status: RsvpStatus.WAITLISTED, count: 3 } }); - expect(waitlisted.data?.count).toBe(await pageCount(event.id)); + expect(dataOf(waitlisted)?.count).toBe(await pageCount(event.id)); }); it("counts team units on a TEAM event, as its page does", async () => { @@ -114,7 +119,7 @@ describe("registration count in the registration response", () => { const member = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug: event.slug, guestEmail: email("team-join"), name: "Team Member", answers: {} }, { userId: null, team: { mode: "join", joinToken: squad.joinToken } }); // Joining a team takes no new place: "2 places taken", as on the page. expect(member).toMatchObject({ ok: true, data: { count: 2 } }); - expect(member.data?.count).toBe(await pageCount(event.id, RegistrationMode.TEAM)); + expect(dataOf(member)?.count).toBe(await pageCount(event.id, RegistrationMode.TEAM)); }); it("applies the same rule to a paid ticket order", async () => { @@ -123,19 +128,19 @@ describe("registration count in the registration response", () => { const items = (ticketTypeId: string) => ({ items: [{ ticketTypeId, quantity: 1 }], attendees: [] }); const hiddenOrder = await guest(hidden.event.slug, "paid-hidden", items(hidden.ticket!.id)); expect(hiddenOrder.ok).toBe(true); - expect(hiddenOrder.data).not.toHaveProperty("count"); - expect(hiddenOrder.data?.orderToken).toBeTruthy(); + expect(dataOf(hiddenOrder)).not.toHaveProperty("count"); + expect(dataOf(hiddenOrder)?.orderToken).toBeTruthy(); const hiddenSignedIn = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug: hidden.event.slug, answers: {}, ...items(hidden.ticket!.id) }, { userId }); expect(hiddenSignedIn.ok).toBe(true); - expect(hiddenSignedIn.data?.count).toBeUndefined(); + expect(dataOf(hiddenSignedIn)?.count).toBeUndefined(); const shown = await makeEvent({ show: true, price: 49900 }); await seedOthers(shown.event.id); const shownOrder = await guest(shown.event.slug, "paid-shown", items(shown.ticket!.id)); expect(shownOrder.ok).toBe(true); // The held, unpaid ticket is not "going" yet: only the crew ticket is counted. - expect(shownOrder.data?.count).toBe(1); - expect(shownOrder.data?.count).toBe(await pageCount(shown.event.id)); + expect(dataOf(shownOrder)?.count).toBe(1); + expect(dataOf(shownOrder)?.count).toBe(await pageCount(shown.event.id)); }); it("returns a series date's confirmed registrations, as its page does", async () => { From a42b2c7ca2d765dff1b56cc0f7d9e6c7b81f94a6 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:20:28 +0530 Subject: [PATCH 25/79] fix(vps): run the deploy runner from a root-written unit and never execute its files as root Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- scripts/install-runner.sh | 32 +++--- scripts/runner-service.sh | 95 +++++++++++++++++ tests/unit/vps-runner-service.test.ts | 148 ++++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 12 deletions(-) create mode 100644 scripts/runner-service.sh create mode 100644 tests/unit/vps-runner-service.test.ts diff --git a/scripts/install-runner.sh b/scripts/install-runner.sh index 37d24907..ebd3a43e 100755 --- a/scripts/install-runner.sh +++ b/scripts/install-runner.sh @@ -9,13 +9,18 @@ # A repository-level runner accepts any workflow that names its label, so it # runs as the unprivileged account swe-runner, without Docker access. Its only # root command is the deploy wrapper, which installs verified, published main -# releases and nothing else. +# releases and nothing else. As root, this script never runs anything from the +# runner's directory: the service is a unit written here (runner-service.sh), +# the runner's files are unpacked as swe-runner, and its system dependencies +# are installed from a fresh root-owned extraction of the verified archive. set -euo pipefail # Tokens must stay out of shell traces, argv, answer files and the runner's .env. set +x source_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) # shellcheck source=scripts/vps-lib.sh . "$source_dir/scripts/vps-lib.sh" +# shellcheck source=scripts/runner-service.sh +. "$source_dir/scripts/runner-service.sh" mode=install case "${1:-}" in '') ;; @@ -40,6 +45,7 @@ repository=${SWE_REPOSITORY:-$(conf_get "$(install_conf)" SWE_REPOSITORY)} repository=${repository:-$SWE_DEFAULT_REPOSITORY} is_repository "$repository" || die 'SWE_REPOSITORY must be a GitHub owner/name.' if [[ $mode = remove && ! -f "$RUNNER_DIR/.runner" ]]; then + stop_runner_services rm -f "$SUDOERS" "$WRAPPER" say 'No configured runner to remove.' exit 0 @@ -68,8 +74,7 @@ configure_runner() ( ) unregister() { read_token removal - cd "$RUNNER_DIR" - if [[ -f .service ]]; then bash ./svc.sh stop; bash ./svc.sh uninstall; fi + stop_runner_services configure_runner remove runner_token='' } @@ -113,18 +118,23 @@ install -d -m 0700 -o "$RUNNER_USER" -g "$RUNNER_USER" "$RUNNER_DIR" if [[ -f "$RUNNER_DIR/.runner" ]]; then say 'Re-registering replaces the existing registration, which needs its removal token first.' unregister +else + stop_runner_services fi read_token registration -tar -xzf "$temporary/runner.tar.gz" --no-same-owner -C "$RUNNER_DIR" -[[ -f "$RUNNER_DIR/config.sh" ]] || die 'The official runner archive is incomplete.' -chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_DIR" +# System packages the runner needs, installed by the archive's own script from a +# fresh root-owned copy, never from the runner-writable directory. +install -d -m 0700 "$temporary/archive" +tar -xzf "$temporary/runner.tar.gz" --no-same-owner -C "$temporary/archive" +[[ -f "$temporary/archive/config.sh" && -f "$temporary/archive/bin/runsvc.sh" ]] || die 'The official runner archive is incomplete.' +[[ ! -f "$temporary/archive/bin/installdependencies.sh" ]] || bash "$temporary/archive/bin/installdependencies.sh" >/dev/null +# Unpacked as the runner account, so root never writes into its directory. +runuser -u "$RUNNER_USER" -- tar -xzf - -C "$RUNNER_DIR" < "$temporary/runner.tar.gz" # The archive resets the directory mode; the registration credentials live here. chmod 0700 "$RUNNER_DIR" -[[ ! -f "$RUNNER_DIR/bin/installdependencies.sh" ]] || bash "$RUNNER_DIR/bin/installdependencies.sh" >/dev/null configure_runner --url "https://github.com/$repository" --name "$(hostname)-spaceworks-events" \ --labels spaceworks-events-prod --unattended --replace runner_token='' -[[ -f "$RUNNER_DIR/svc.sh" ]] || die 'Runner configuration did not create its service helper.' # The single root command the runner may run: deploy a verified published release. cat > "$WRAPPER.new" </dev/null; then visudo -cqf "$SUDOERS.new" || { rm -f "$SUDOERS.new"; die 'The sudoers rule did not validate.'; }; fi mv -f "$SUDOERS.new" "$SUDOERS" -cd "$RUNNER_DIR" -[[ -f .service ]] || bash ./svc.sh install "$RUNNER_USER" -bash ./svc.sh start +write_runner_unit cat < Secrets and variables -> Actions -> Variables -> New repository variable: SWE_DEPLOY = true 2. Settings -> Environments -> New environment "production" -> Deployment branches and diff --git a/scripts/runner-service.sh b/scripts/runner-service.sh new file mode 100644 index 00000000..08518bdb --- /dev/null +++ b/scripts/runner-service.sh @@ -0,0 +1,95 @@ +# The deploy runner's systemd service, for install-runner.sh. Sourced, never +# executed. It runs as root, so it never executes, sources or trusts anything +# in the runner's directory, which swe-runner (and so any workflow) can write: +# the unit is written here, and a unit GitHub's svc.sh installed earlier is +# removed only after systemd's own metadata ties it to this runner. +# Needs RUNNER_USER, RUNNER_DIR and repository from the caller. +# shellcheck shell=bash + +RUNNER_UNIT=swevents-runner.service +SYSTEMD_UNIT_DIR=/etc/systemd/system + +# Same layout as GitHub's service template, started through bash so no file +# mode in the runner's tree matters; it runs as swe-runner only. +write_runner_unit() { + local unit="$SYSTEMD_UNIT_DIR/$RUNNER_UNIT" + cat > "$unit.new" </dev/null 2>&1 + systemctl restart "$RUNNER_UNIT" +} + +remove_runner_unit() { + systemctl disable --now "$RUNNER_UNIT" >/dev/null 2>&1 || true + rm -f "$SYSTEMD_UNIT_DIR/$RUNNER_UNIT" + systemctl daemon-reload +} + +# verify_legacy_runner_unit UNIT: the unit file is a regular, root-owned file +# directly in the systemd directory, and systemd runs it as swe-runner from +# this runner's directory. Its name alone proves nothing. +verify_legacy_runner_unit() { + local unit=$1 path key value fragment='' user='' directory='' exec_start='' program re='path=([^[:space:];]+)' + path="$SYSTEMD_UNIT_DIR/$unit" + [[ -f "$path" && ! -L "$path" && "$(stat -c %u -- "$path")" = 0 ]] || return 1 + while IFS='=' read -r key value; do + case $key in + FragmentPath) fragment=$value ;; + User) user=$value ;; + WorkingDirectory) directory=$value ;; + ExecStart) exec_start=$value ;; + esac + done < <(systemctl show --property=FragmentPath,User,WorkingDirectory,ExecStart -- "$unit") + [[ "$fragment" = "$path" && "$user" = "$RUNNER_USER" && "$directory" = "$RUNNER_DIR" ]] || return 1 + [[ "$exec_start" =~ $re ]] || return 1 + program=${BASH_REMATCH[1]} + [[ "$program" = "$RUNNER_DIR/"* && "$program" != *..* ]] +} + +# Units svc.sh named actions.runner.-..service (the +# repository part cut to 45 characters on long names) for this repository. +migrate_legacy_runner_units() { + local prefix path unit + prefix="actions.runner.$(printf '%s' "${repository//\//-}" | cut -c1-45)" + for path in "$SYSTEMD_UNIT_DIR"/actions.runner.*.service; do + [[ -e "$path" || -L "$path" ]] || continue + unit=${path##*/} + [[ "$unit" =~ ^actions\.runner\.[A-Za-z0-9._-]{1,150}\.service$ && "$unit" = "$prefix"* ]] || continue + if ! verify_legacy_runner_unit "$unit"; then + die "The service $unit does not match this runner ($RUNNER_USER in $RUNNER_DIR), so it was left alone. Check it, remove it by hand (systemctl disable --now $unit; rm $SYSTEMD_UNIT_DIR/$unit; rm -f $RUNNER_DIR/.service; systemctl daemon-reload), then rerun." + fi + systemctl disable --now -- "$unit" >/dev/null 2>&1 || true + rm -f -- "$path" + say "Replaced the earlier runner service $unit." + done + systemctl daemon-reload + # svc.sh's marker; the runner refuses to unregister while it exists. + rm -f -- "$RUNNER_DIR/.service" +} + +# Stop the runner, whichever way it was installed. +stop_runner_services() { + remove_runner_unit + migrate_legacy_runner_units +} diff --git a/tests/unit/vps-runner-service.test.ts b/tests/unit/vps-runner-service.test.ts new file mode 100644 index 00000000..78b16fe7 --- /dev/null +++ b/tests/unit/vps-runner-service.test.ts @@ -0,0 +1,148 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, lstatSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +/* + * Scripted dry runs of the deploy runner's service helpers (scripts/runner-service.sh) + * with a fake systemctl and stat on PATH: nothing touches the real system. + */ +const root = path.resolve(import.meta.dirname, "../.."); +const repository = "SpaceWorks-Labs/SpaceWorks-Events"; +const legacy = "actions.runner.SpaceWorks-Labs-SpaceWorks-Events.host-spaceworks-events.service"; +let dir = ""; +let units = ""; +let runner = ""; + +// Paths as bash sees them (Git Bash on Windows needs /c/... in PATH). +const posix = (value: string) => value.replaceAll("\\", "/").replace(/^([A-Za-z]):\//, (_, drive: string) => `/${drive.toLowerCase()}/`); + +function run(snippet: string, env: Record = {}) { + const fake = posix(dir); + const script = `. scripts/vps-lib.sh; . scripts/runner-service.sh; SYSTEMD_UNIT_DIR='${posix(units)}'; RUNNER_USER=swe-runner; RUNNER_DIR='${posix(runner)}'; repository=${repository}; ${snippet}`; + return spawnSync("bash", ["-c", `PATH="${fake}/bin:$PATH"; ${script}`], { + cwd: root, encoding: "utf8", env: { ...process.env, FAKE: fake, ...env }, + }); +} +const log = () => (existsSync(path.join(dir, "systemctl.log")) ? readFileSync(path.join(dir, "systemctl.log"), "utf8") : ""); + +// What `systemctl show` reports for a unit, as systemd prints it. +function legacyUnit(name: string, overrides: Partial> = {}) { + const file = path.join(units, name); + writeFileSync(file, "[Service]\n"); + const program = `${posix(runner)}/runsvc.sh`; + const show = { + FragmentPath: posix(file), + User: "swe-runner", + WorkingDirectory: posix(runner), + ExecStart: `{ path=${program} ; argv[]=${program} ; ignore_errors=no ; start_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }`, + ...overrides, + }; + writeFileSync(path.join(dir, "show", name), Object.entries(show).map(([key, value]) => `${key}=${value}\n`).join("")); + return file; +} + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), "swe-runner-service-")); + units = path.join(dir, "units"); + runner = path.join(dir, "runner"); + for (const sub of ["bin", "show", "units", "runner"]) mkdirSync(path.join(dir, sub)); + writeFileSync(path.join(dir, "bin", "systemctl"), [ + "#!/bin/bash", + 'printf "%s\\n" "$*" >> "$FAKE/systemctl.log"', + 'if [ "$1" = show ]; then cat "$FAKE/show/${!#}" 2>/dev/null; fi', + "exit 0", + ].join("\n"), { mode: 0o755 }); + writeFileSync(path.join(dir, "bin", "stat"), '#!/bin/bash\necho "${FAKE_UID:-0}"\n', { mode: 0o755 }); + writeFileSync(path.join(runner, ".service"), `${legacy}\n`); +}); + +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe("deploy runner service", { timeout: 30_000 }, () => { + it("writes its own unit, run as swe-runner from the runner's directory", () => { + const result = run("write_runner_unit"); + expect(result.status, result.stderr).toBe(0); + const unit = readFileSync(path.join(units, "swevents-runner.service"), "utf8"); + expect(unit).toContain("User=swe-runner\n"); + expect(unit).toContain(`WorkingDirectory=${posix(runner)}\n`); + expect(unit).toContain(`ExecStart=/bin/bash ${posix(runner)}/bin/runsvc.sh\n`); + expect(log()).toContain("enable swevents-runner.service"); + expect(log()).toContain("restart swevents-runner.service"); + }); + + it("replaces an earlier svc.sh unit once systemd ties it to this runner", () => { + const file = legacyUnit(legacy); + const result = run("stop_runner_services"); + expect(result.status, result.stderr).toBe(0); + expect(existsSync(file)).toBe(false); + expect(existsSync(path.join(runner, ".service"))).toBe(false); + expect(log()).toContain("disable --now swevents-runner.service"); + expect(log()).toContain(`disable --now -- ${legacy}`); + }); + + it.each([ + ["another user", { User: "root" }], + ["another working directory", { WorkingDirectory: "/opt/elsewhere" }], + ["a program outside the runner", { ExecStart: "{ path=/usr/bin/bash ; argv[]=/usr/bin/bash -c id ; }" }], + ["a program escaping the runner", { ExecStart: "{ path=RUNNER/../../bin/sh ; argv[]=x ; }" }], + ["a unit file somewhere else", { FragmentPath: "/run/systemd/transient/x.service" }], + ])("refuses an earlier unit with %s and leaves it in place", (_label, overrides) => { + const patched = Object.fromEntries(Object.entries(overrides).map(([key, value]) => [key, value.replace("RUNNER", posix(runner))])); + const file = legacyUnit(legacy, patched); + const result = run("stop_runner_services"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("does not match this runner"); + expect(existsSync(file)).toBe(true); + expect(log()).not.toContain(`disable --now -- ${legacy}`); + }); + + it("refuses an earlier unit file that root does not own", () => { + const file = legacyUnit(legacy); + const result = run("stop_runner_services", { FAKE_UID: "1001" }); + expect(result.status).toBe(1); + expect(existsSync(file)).toBe(true); + }); + + it("refuses a symlinked earlier unit", () => { + const target = path.join(dir, "elsewhere.service"); + writeFileSync(target, "[Service]\n"); + const link = path.join(units, legacy); + try { + symlinkSync(target, link); + } catch { + return; // No symlinks on this host (Windows without the privilege). + } + if (!lstatSync(link).isSymbolicLink()) return; + writeFileSync(path.join(dir, "show", legacy), `FragmentPath=${posix(link)}\nUser=swe-runner\nWorkingDirectory=${posix(runner)}\nExecStart={ path=${posix(runner)}/runsvc.sh ; }\n`); + const result = run("stop_runner_services"); + expect(result.status).toBe(1); + expect(existsSync(target)).toBe(true); + }); + + it("leaves units of other repositories and unrelated names alone", () => { + const other = legacyUnit("actions.runner.someone-else.host.service", { User: "root" }); + const odd = legacyUnit("actions.runner.SpaceWorks-Labs-SpaceWorks-Events.$(id).service", { User: "root" }); + const result = run("stop_runner_services"); + expect(result.status, result.stderr).toBe(0); + expect(existsSync(other)).toBe(true); + expect(existsSync(odd)).toBe(true); + expect(log()).not.toContain("disable --now -- actions.runner"); + }); + + it("never runs or sources anything from the runner's directory as root", () => { + const installer = readFileSync(path.join(root, "scripts/install-runner.sh"), "utf8"); + const helpers = readFileSync(path.join(root, "scripts/runner-service.sh"), "utf8"); + for (const source of [installer, helpers]) { + expect(source).not.toContain("./svc.sh"); + expect(source).not.toMatch(/svc\.sh (install|uninstall|start|stop)\b/); + expect(source).not.toMatch(/bash "\$RUNNER_DIR/); + expect(source).not.toContain("chown -R"); + } + expect(installer).toContain('bash "$temporary/archive/bin/installdependencies.sh"'); + expect(installer).toContain('runuser -u "$RUNNER_USER" -- tar -xzf - -C "$RUNNER_DIR" < "$temporary/runner.tar.gz"'); + expect(installer).toContain("/usr/local/sbin/swevents-deploy"); + expect(spawnSync("bash", ["-n", "scripts/install-runner.sh", "scripts/runner-service.sh"], { cwd: root }).status).toBe(0); + }); +}); From 11ab4d46a35b07259a03b926909311a1e4357704 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:20:29 +0530 Subject: [PATCH 26/79] fix(vps): refuse plaintext backups while encryption is requested and show why Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/super-admin/operations/page.tsx | 2 +- content/help/index.generated.ts | 2 +- .../installation/updates-backups-status.mdx | 6 + docs/DEPLOYMENT_VPS.md | 22 +++ lib/vpsBackupStatus.ts | 12 ++ scripts/setup-vps.sh | 27 ++-- scripts/vps-backup-settings.sh | 22 ++- scripts/vps-backup.sh | 38 +++-- scripts/vps-lib.sh | 36 +++++ tests/unit/vps-backup-encryption.test.ts | 138 ++++++++++++++++++ tests/unit/vps-backup-status.test.ts | 12 ++ tests/unit/vps-installer.test.ts | 4 +- 12 files changed, 285 insertions(+), 36 deletions(-) create mode 100644 tests/unit/vps-backup-encryption.test.ts diff --git a/app/super-admin/operations/page.tsx b/app/super-admin/operations/page.tsx index e0f3e2e3..a677e038 100644 --- a/app/super-admin/operations/page.tsx +++ b/app/super-admin/operations/page.tsx @@ -44,7 +44,7 @@ export default async function SuperAdminOperationsPage() {
{serverBackupProblem ?

{serverBackupProblem.detail} The super admin is emailed once about it.

: null}

Newest full backup: {time(serverBackup.lastFullSuccessAt)}. Newest of any kind: {time(serverBackup.lastSuccessAt)}.

-

Last run: {time(serverBackup.lastAttemptAt)}, {serverBackup.lastStatus === "ok" ? `succeeded (${serverBackup.lastKind === "full" ? "database, configuration and uploads" : "database and configuration"}, ${(serverBackup.sizeBytes / 1_048_576).toFixed(1)} MB)` : "failed"}. Kept on the server for {serverBackup.keepDays} days.

+

Last run: {time(serverBackup.lastAttemptAt)}, {serverBackup.lastStatus === "ok" ? `succeeded (${serverBackup.lastKind === "full" ? "database, configuration and uploads" : "database and configuration"}, ${(serverBackup.sizeBytes / 1_048_576).toFixed(1)} MB)` : serverBackup.reason ? "refused because encryption is not working" : "failed"}. Kept on the server for {serverBackup.keepDays} days.

) : null} diff --git a/content/help/index.generated.ts b/content/help/index.generated.ts index 780902f8..cc491fa0 100644 --- a/content/help/index.generated.ts +++ b/content/help/index.generated.ts @@ -5637,7 +5637,7 @@ export const helpIndex: Readonly> = { { "heading": "Backups", "anchor": "backups", - "text": "Run the installer and choose 4) Backups. Turn on nightly backups, encryption and, if you like, off-site copies. If you turn on encryption, save the private key it shows once in your password manager. It isn't stored on the server. Setting What it does Nightly backups A full backup (database, configuration and uploaded files) at about 02:30 UTC. Encryption Each backup is encrypted with age to a key only you hold. Off-site copies After each backup, an encrypted copy goes to an S3-compatible bucket at another provider, kept for its own number of days (90 by default). Only encrypted backups are sent. Retention How many days backups stay on the server (14 by default). The newest few are always kept. To back up straight away, choose 2) Back up now. The site keeps running while it works. A backup on the same server isn't a backup of the server. Turn on off-site copies, or copy the backups away yourself, and practise restoring now and then on a separate machine. Restoring is done from the server with the restore tool. It shows what will be replaced, asks you to type RESTORE, and backs up the current database before it starts. Restoring onto a new server has its own order of steps, because the configuration must go back before the database. Follow docs/DEPLOYMENT_VPS.md for both." + "text": "Run the installer and choose 4) Backups. Turn on nightly backups, encryption and, if you like, off-site copies. If you turn on encryption, save the private key it shows once in your password manager. It isn't stored on the server. Setting What it does Nightly backups A full backup (database, configuration and uploaded files) at about 02:30 UTC. Encryption Each backup is encrypted with age to a key only you hold. Off-site copies After each backup, an encrypted copy goes to an S3-compatible bucket at another provider, kept for its own number of days (90 by default). Only encrypted backups are sent. Retention How many days backups stay on the server (14 by default). The newest few are always kept. To back up straight away, choose 2) Back up now. The site keeps running while it works. Once you ask for encryption, backups are never made unencrypted instead. If the age tool can't be installed or doesn't work, setup leaves nightly backups off, and every backup (including the one taken before an update) is refused until encryption works. Operations shows Backups refused: encryption is not working. Install the age package, then in Backups choose Set up the requested encryption and turn nightly backups on. If you really want unencrypted backups, turn the request off in the same menu. A backup on the same server isn't a backup of the server. Turn on off-site copies, or copy the backups away yourself, and practise restoring now and then on a separate machine. Restoring is done from the server with the restore tool. It shows what will be replaced, asks you to type RESTORE, and backs up the current database before it starts. Restoring onto a new server has its own order of steps, because the configuration must go back before the database. Follow docs/DEPLOYMENT_VPS.md for both." }, { "heading": "Checking status", diff --git a/content/help/installation/updates-backups-status.mdx b/content/help/installation/updates-backups-status.mdx index e58f8aeb..726ea0a3 100644 --- a/content/help/installation/updates-backups-status.mdx +++ b/content/help/installation/updates-backups-status.mdx @@ -54,6 +54,12 @@ If you answered yes to automatic deploys, or added them later with **7) Automati To back up straight away, choose **2) Back up now**. The site keeps running while it works. + + +Once you ask for encryption, backups are never made unencrypted instead. If the age tool can't be installed or doesn't work, setup leaves nightly backups off, and every backup (including the one taken before an update) is refused until encryption works. **Operations** shows *Backups refused: encryption is not working*. Install the `age` package, then in **Backups** choose **Set up the requested encryption** and turn nightly backups on. If you really want unencrypted backups, turn the request off in the same menu. + + + A backup on the same server isn't a backup of the server. Turn on off-site copies, or copy the backups away yourself, and practise restoring now and then on a separate machine. diff --git a/docs/DEPLOYMENT_VPS.md b/docs/DEPLOYMENT_VPS.md index 159912c5..ef8f33ad 100644 --- a/docs/DEPLOYMENT_VPS.md +++ b/docs/DEPLOYMENT_VPS.md @@ -179,6 +179,18 @@ this server. That code runs as `swe-runner`, which has no Docker access; its only root permission (`/etc/sudoers.d/spaceworks-events-deploy`) is the deploy wrapper, and `vps-deploy.sh` installs only a published, non-pre-release GitHub Release whose commit is on `main`. Keep write access to trusted maintainers. +Because `swe-runner` can change every file in the runner's directory +(`/opt/spaceworks-events-runner`), the installer never runs anything from it +as root: the systemd unit `swevents-runner.service` is written by the installer +itself, the runner's files are unpacked as `swe-runner`, and the runner's system +dependencies are installed from a fresh root-owned copy of the checksum-verified +archive. A server set up before this change runs the runner under the unit +GitHub's `svc.sh` created (`actions.runner.-..service`); +re-registering or removing the runner replaces that unit only after systemd +confirms it is a root-owned file in `/etc/systemd/system` that runs as +`swe-runner` from the runner's directory. If anything differs, the installer +stops and prints the commands to remove the unit by hand. Re-register once +after upgrading (menu option 4) to move an existing runner onto the new unit. Remove the runner with menu option 4 or `sudo bash /opt/spaceworks-events/current/scripts/install-runner.sh --remove`, then delete the `SWE_DEPLOY` variable. @@ -408,6 +420,16 @@ saved in `/etc/spaceworks-events/backup.env`. Setup asks about the first three: | **Off-site copies** | After each backup, an encrypted copy goes to an S3-compatible bucket at another provider (R2, B2, S3…), kept for its own number of days (default 90). Only encrypted backups are sent. A private endpoint (for example your own Silo or MinIO) can use its own certificate authority: give the CA file when asked, and the installer keeps a copy beside `backup.env`. Keys may contain any characters, including `/` and `+`. | | **Retention** | Days a backup stays on this server. | +Encryption chosen at setup is never silently skipped. Setup installs `age` and +makes the key before it turns on the nightly timer; if `age` cannot be installed +or cannot encrypt to the key, setup leaves the timer off, records the reason in +the status file and says so. While encryption is on, or was requested and is not +working yet, `vps-backup.sh` refuses to run (this includes the backup the updater +takes first) and records the refusal instead of writing a backup that holds every +secret unencrypted. Install the `age` package, then in **Backups** choose +**Set up the requested encryption** and turn nightly backups on; or, to accept +unencrypted backups, turn the request off there. + Each run writes `/etc/spaceworks-events/status/backup.json` (no secrets), which the app reads read-only. **Super admin → Operations** shows the newest backup, schedule, encryption and off-site state. If a backup fails, or scheduled backups are more than diff --git a/lib/vpsBackupStatus.ts b/lib/vpsBackupStatus.ts index f6cb5372..93a36da3 100644 --- a/lib/vpsBackupStatus.ts +++ b/lib/vpsBackupStatus.ts @@ -22,6 +22,9 @@ const statusSchema = z.object({ offsite: z.enum(["off", "ok", "failed"]), scheduled: z.boolean(), keepDays: z.number().int().positive(), + // Why a backup was refused: encryption is on or requested but not working, + // so no unencrypted backup was made. Absent in files from older releases. + reason: z.enum(["encryption-unavailable", "encryption-key-missing"]).nullable().optional(), }); export type VpsBackupStatus = z.infer; @@ -47,6 +50,15 @@ export type BackupProblem = { key: string; subject: string; detail: string }; */ export function backupProblem(status: VpsBackupStatus | null, now = new Date()): BackupProblem | null { if (!status) return null; + if (status.lastStatus === "failed" && status.reason) { + return { + key: `failed:${status.lastAttemptAt}`, + subject: "Backups refused: encryption is not working", + detail: status.reason === "encryption-key-missing" + ? `Backups are set to be encrypted but no encryption key is saved, so no unencrypted backup was made (${status.lastAttemptAt}). Turn on encryption in the installer menu (Backups), or turn the encryption request off there.` + : `Backups are set to be encrypted but the age tool is missing or not working, so no unencrypted backup was made (${status.lastAttemptAt}). Install the "age" package, then turn on encryption in the installer menu (Backups).`, + }; + } if (status.lastStatus === "failed") { const offsite = status.offsite === "failed"; return { diff --git a/scripts/setup-vps.sh b/scripts/setup-vps.sh index d70c1e61..ffc99a38 100755 --- a/scripts/setup-vps.sh +++ b/scripts/setup-vps.sh @@ -82,22 +82,31 @@ EOF # Timers (nightly backups if chosen, certificate renewal) and the encryption key # chosen during setup. The key is shown once; a resumed setup that already made -# one keeps it. +# one keeps it. Encryption comes first: while it was requested but does not +# work, the nightly timer is withheld and the reason recorded in the status +# file the Operations page shows (vps-backup.sh refuses such backups too). finish_backups() { - local file + local file problem='' kind=full keep file=$(backup_env) install -d -m 0755 "$(status_dir)" - if [[ $managed = 1 ]] && has_systemd; then - install_certificate_timer - [[ "$(conf_get "$file" BACKUP_SCHEDULE)" != nightly ]] || install_backup_timer - fi if [[ "$(conf_get "$file" BACKUP_ENCRYPTION)" = requested ]]; then + problem=encryption-unavailable if ensure_age; then new_backup_key - conf_unset "$file" BACKUP_ENCRYPTION - else - warn 'age could not be installed, so backups are not encrypted. Install the "age" package, then turn on encryption in the installer menu (Backups).' + problem=$(backup_encryption_problem "$(conf_get "$file" BACKUP_AGE_RECIPIENT)") fi + [[ -n "$problem" ]] || conf_unset "$file" BACKUP_ENCRYPTION + fi + if [[ $managed = 1 ]] && has_systemd; then + install_certificate_timer + [[ "$(conf_get "$file" BACKUP_SCHEDULE)" != nightly || -n "$problem" ]] || install_backup_timer + fi + if [[ -n "$problem" ]]; then + [[ "$(conf_get "$SWE_CONFIG_DIR/compose.env" SWE_STORAGE)" != external ]] || kind=database + keep=$(conf_get "$file" BACKUP_KEEP_DAYS) + is_days "$keep" || keep=14 + write_backup_status failed "$kind" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 0 false off "$keep" "$problem" + warn 'Backup encryption could not be set up (age is missing or not working), so no backup is made and nightly backups were not turned on: an unencrypted backup would hold every secret. Install the "age" package, then in the installer menu (Backups) turn on encryption and nightly backups, or turn the encryption request off.' fi if [[ -n "$(conf_get "$file" OFFSITE_BUCKET)" ]]; then run_mc "$(silo_image)" ls "offsite/$(conf_get "$file" OFFSITE_BUCKET)" > /dev/null \ diff --git a/scripts/vps-backup-settings.sh b/scripts/vps-backup-settings.sh index 12062bbe..ecd3e878 100755 --- a/scripts/vps-backup-settings.sh +++ b/scripts/vps-backup-settings.sh @@ -21,8 +21,12 @@ while true; do days=$(conf_get "$settings" BACKUP_KEEP_DAYS) recipient=$(conf_get "$settings" BACKUP_AGE_RECIPIENT) bucket=$(conf_get "$settings" OFFSITE_BUCKET) + # "requested": encryption chosen at setup that never worked; backups are refused meanwhile. + requested=$(conf_get "$settings" BACKUP_ENCRYPTION) if timer_enabled backup; then schedule='every night at about 02:30 UTC'; else schedule='off'; fi - if [[ -n "$recipient" ]]; then encryption="on ($recipient)"; else encryption='off'; fi + if [[ -n "$recipient" ]]; then encryption="on ($recipient)" + elif [[ "$requested" = requested ]]; then encryption='requested but not set up: backups are refused until it is (3)' + else encryption='off'; fi if [[ -n "$bucket" ]]; then offsite="on ($bucket/$(conf_get "$settings" OFFSITE_PREFIX), kept $(conf_get "$settings" OFFSITE_KEEP_DAYS) days)"; else offsite='off'; fi cat >/dev/tty </dev/null || true) - success=$(printf '%s' "$previous" | sed -n 's/.*"lastSuccessAt":"\([^"]*\)".*/\1/p') - full=$(printf '%s' "$previous" | sed -n 's/.*"lastFullSuccessAt":"\([^"]*\)".*/\1/p') - if [[ $outcome = ok ]]; then - success=$started - [[ $kind != full ]] || full=$started - fi - ! timer_enabled backup || scheduled=true - [[ -z "$recipient" ]] || encrypted=true - ( umask 022 - mkdir -p "$(status_dir)" - chmod 755 "$(status_dir)" - printf '{"version":1,"lastAttemptAt":"%s","lastStatus":"%s","lastKind":"%s","lastSuccessAt":%s,"lastFullSuccessAt":%s,"sizeBytes":%s,"encrypted":%s,"offsite":"%s","scheduled":%s,"keepDays":%s}\n' \ - "$started" "$outcome" "$kind" "$(json_time "$success")" "$(json_time "$full")" "$size" "$encrypted" "$offsite" "$scheduled" "$keep_days" \ - > "$file.new" - mv -f "$file.new" "$file" ) + local encrypted=false + [[ -z "$recipient" || -n "${3:-}" ]] || encrypted=true + write_backup_status "$1" "$kind" "$started" "${2:-0}" "$encrypted" "$offsite" "$keep_days" "${3:-}" } + +# Encryption that is on, or requested at setup but never completed, must work +# before anything is written: no plaintext backup is made in its place. The +# refusal is recorded first, so the Operations page shows it. +problem='' +if [[ -n "$recipient" || "$(conf_get "$settings" BACKUP_ENCRYPTION)" = requested ]]; then + problem=$(backup_encryption_problem "$recipient") +fi +if [[ -n "$problem" ]]; then + write_status failed 0 "$problem" + die 'Backups are set to be encrypted, but encryption is not working (age is missing, or no key is saved), so no unencrypted backup was made. Install the "age" package, then turn on encryption in the installer menu (Backups), or turn the encryption request off there.' +fi + on_exit() { local status=$? [[ $status = 0 ]] && return 0 @@ -93,7 +92,6 @@ printf '%s\n' "$kind" > "$target.partial/kind" # Encrypted backups keep only `version` and `kind` readable; everything else # 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. tar -cf - -C "$target.partial" --exclude=./version --exclude=./kind --exclude=./backup.tar.age . \ | age --encrypt --recipient "$recipient" > "$target.partial/backup.tar.age" diff --git a/scripts/vps-lib.sh b/scripts/vps-lib.sh index 41e7f0a4..02974348 100644 --- a/scripts/vps-lib.sh +++ b/scripts/vps-lib.sh @@ -255,6 +255,42 @@ backup_env() { printf '%s/backup.env' "$SWE_CONFIG_DIR"; } # Non-secret status the app reads (mounted read-only at /status). status_dir() { printf '%s/status' "$SWE_CONFIG_DIR"; } +json_time() { if [[ -n "$1" ]]; then printf '"%s"' "$1"; else printf null; fi; } +# write_backup_status OUTCOME KIND STARTED SIZE ENCRYPTED OFFSITE KEEP_DAYS [REASON]: +# record a backup attempt in status/backup.json. It carries no secrets, and +# REASON is one of the fixed codes from backup_encryption_problem, never +# command output. The last success times carry over from the previous file. +write_backup_status() { + local outcome=$1 kind=$2 started=$3 size=$4 encrypted=$5 offsite=$6 keep_days=$7 reason=${8:-} + local file previous success full scheduled=false + file="$(status_dir)/backup.json" + previous=$(cat "$file" 2>/dev/null || true) + success=$(printf '%s' "$previous" | sed -n 's/.*"lastSuccessAt":"\([^"]*\)".*/\1/p') + full=$(printf '%s' "$previous" | sed -n 's/.*"lastFullSuccessAt":"\([^"]*\)".*/\1/p') + if [[ $outcome = ok ]]; then + success=$started + [[ $kind != full ]] || full=$started + fi + ! timer_enabled backup || scheduled=true + ( umask 022 + mkdir -p "$(status_dir)" + chmod 755 "$(status_dir)" + printf '{"version":1,"lastAttemptAt":"%s","lastStatus":"%s","lastKind":"%s","lastSuccessAt":%s,"lastFullSuccessAt":%s,"sizeBytes":%s,"encrypted":%s,"offsite":"%s","scheduled":%s,"keepDays":%s,"reason":%s}\n' \ + "$started" "$outcome" "$kind" "$(json_time "$success")" "$(json_time "$full")" "$size" "$encrypted" "$offsite" "$scheduled" "$keep_days" "$(json_time "$reason")" \ + > "$file.new" + mv -f "$file.new" "$file" ) +} + +# backup_encryption_problem RECIPIENT: prints nothing when age encrypts to the +# recipient, otherwise why it can't: encryption-key-missing (no key saved) or +# encryption-unavailable (age missing or failing). +backup_encryption_problem() { + [[ -n "$1" ]] || { printf encryption-key-missing; return 0; } + if ! command -v age >/dev/null || ! printf 'check\n' | age --encrypt --recipient "$1" >/dev/null 2>&1; then + printf encryption-unavailable + fi +} + # install_timer NAME "Description" ONCALENDAR SCRIPT [ARGS...]: a systemd timer # that runs a script of the current release as root. The unit names the # current symlink, so updates never need to rewrite it. diff --git a/tests/unit/vps-backup-encryption.test.ts b/tests/unit/vps-backup-encryption.test.ts new file mode 100644 index 00000000..a10dce2e --- /dev/null +++ b/tests/unit/vps-backup-encryption.test.ts @@ -0,0 +1,138 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { backupProblem, readVpsBackupStatus } from "@/lib/vpsBackupStatus"; + +/* + * Scripted dry runs of backup encryption handling with fake age, id and + * docker on PATH: a backup is refused (and the refusal recorded) while + * encryption is on or requested but not working, and setup withholds the + * nightly timer in that case. Nothing touches the real system. + */ +const root = path.resolve(import.meta.dirname, "../.."); +const recipient = `age1${"q".repeat(58)}`; +let dir = ""; +let config = ""; + +// Paths as bash sees them (Git Bash on Windows needs /c/... in PATH). +const posix = (value: string) => value.replaceAll("\\", "/").replace(/^([A-Za-z]):\//, (_, drive: string) => `/${drive.toLowerCase()}/`); +const bin = (name: string, body: string) => writeFileSync(path.join(dir, "bin", name), `#!/bin/bash\n${body}\n`, { mode: 0o755 }); +const bash = (script: string, env: Record = {}) => spawnSync("bash", ["-c", `PATH="${posix(dir)}/bin:$PATH"; ${script}`], { + cwd: root, encoding: "utf8", + env: { ...process.env, FAKE: posix(dir), SWE_DIR: `${posix(dir)}/no-install`, SWE_CONFIG_DIR: posix(config), ...env }, +}); +const settings = (lines: string[]) => writeFileSync(path.join(config, "backup.env"), lines.map((line) => `${line}\n`).join("")); +const status = () => JSON.parse(readFileSync(path.join(config, "status", "backup.json"), "utf8")) as Record; +const working = () => bin("age", 'cat > /dev/null; echo "age-encrypted"'); +const broken = () => bin("age", "exit 1"); + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), "swe-backup-encryption-")); + config = path.join(dir, "config"); + mkdirSync(path.join(dir, "bin")); + mkdirSync(config); + writeFileSync(path.join(config, "compose.env"), "SWE_VERSION='1.0.0'\n"); + bin("id", '[ "$1" = -u ] && echo 0 || exit 1'); + // The first step of a real backup: reaching it proves the refusal did not trigger. + bin("docker", 'echo docker-called >> "$FAKE/docker.log"; exit 1'); +}); + +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe("backup encryption", { timeout: 30_000 }, () => { + it("tells a working age setup from a missing key or a failing age", () => { + const problem = (key: string) => bash(`. scripts/vps-lib.sh; backup_encryption_problem '${key}'`).stdout; + working(); + expect(problem(recipient)).toBe(""); + expect(problem("")).toBe("encryption-key-missing"); + broken(); + expect(problem(recipient)).toBe("encryption-unavailable"); + }); + + it.each([ + ["requested but no key saved", ["BACKUP_ENCRYPTION='requested'"], working, "encryption-key-missing"], + ["on, with age failing", [`BACKUP_AGE_RECIPIENT='${recipient}'`], broken, "encryption-unavailable"], + ["requested, with age failing", ["BACKUP_ENCRYPTION='requested'", `BACKUP_AGE_RECIPIENT='${recipient}'`], broken, "encryption-unavailable"], + ] as const)("refuses a backup when encryption is %s, recording why first", async (_label, lines, age, reason) => { + settings([...lines]); + age(); + const result = bash("bash scripts/vps-backup.sh"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("no unencrypted backup was made"); + expect(existsSync(path.join(dir, "docker.log"))).toBe(false); + expect(existsSync(path.join(config, "backups"))).toBe(false); + expect(status()).toMatchObject({ lastStatus: "failed", reason, encrypted: false }); + // The Operations page reads the same file and names the refusal. + process.env.SWE_STATUS_DIR = path.join(config, "status"); + try { + const read = await readVpsBackupStatus(); + expect(read?.reason).toBe(reason); + expect(backupProblem(read)?.subject).toBe("Backups refused: encryption is not working"); + } finally { + delete process.env.SWE_STATUS_DIR; + } + }); + + it("goes ahead when encryption works, or was never asked for", () => { + for (const lines of [[`BACKUP_AGE_RECIPIENT='${recipient}'`], ["BACKUP_KEEP_DAYS='14'"]]) { + settings(lines); + working(); + rmSync(path.join(dir, "docker.log"), { force: true }); + const result = bash("bash scripts/vps-backup.sh"); + // The fake docker fails the dump, after the encryption check passed. + expect(result.stderr).not.toContain("no unencrypted backup was made"); + expect(existsSync(path.join(dir, "docker.log"))).toBe(true); + expect(status()).toMatchObject({ lastStatus: "failed", reason: null }); + } + }); + + describe("setup", () => { + // finish_backups from setup-vps.sh, with the steps that need a terminal or systemd stubbed. + const finish = (ensureAge: boolean) => bash(` + . scripts/vps-lib.sh + eval "$(sed -n '/^finish_backups() {/,/^}/p' scripts/setup-vps.sh)" + managed=1 + has_systemd() { return 0; } + timer_enabled() { return 1; } + install_certificate_timer() { echo certificates >> "$FAKE/timers.log"; } + install_backup_timer() { echo backup >> "$FAKE/timers.log"; } + ensure_age() { ${ensureAge ? "return 0" : "return 1"}; } + new_backup_key() { conf_set "$(backup_env)" BACKUP_AGE_RECIPIENT '${recipient}'; } + finish_backups`); + const timers = () => (existsSync(path.join(dir, "timers.log")) ? readFileSync(path.join(dir, "timers.log"), "utf8") : ""); + + it("installs the nightly timer only after age encrypts to the new key", () => { + settings(["BACKUP_SCHEDULE='nightly'", "BACKUP_ENCRYPTION='requested'"]); + working(); + const result = finish(true); + expect(result.status, result.stderr).toBe(0); + expect(timers()).toBe("certificates\nbackup\n"); + const saved = readFileSync(path.join(config, "backup.env"), "utf8"); + expect(saved).not.toContain("BACKUP_ENCRYPTION"); + expect(existsSync(path.join(config, "status", "backup.json"))).toBe(false); + }); + + it.each([ + ["age cannot be installed", false, working], + ["age is installed but fails", true, broken], + ] as const)("withholds the timer and records why when %s", (_label, ensureAge, age) => { + settings(["BACKUP_SCHEDULE='nightly'", "BACKUP_ENCRYPTION='requested'"]); + age(); + const result = finish(ensureAge); + expect(result.status, result.stderr).toBe(0); + expect(timers()).toBe("certificates\n"); + expect(result.stderr).toContain("nightly backups were not turned on"); + expect(readFileSync(path.join(config, "backup.env"), "utf8")).toContain("BACKUP_ENCRYPTION='requested'"); + expect(status()).toMatchObject({ lastStatus: "failed", reason: "encryption-unavailable", scheduled: false }); + }); + }); + + it("clears the request in the settings menu once encryption works or is turned off", () => { + const menu = readFileSync(path.join(root, "scripts/vps-backup-settings.sh"), "utf8"); + expect(menu.match(/conf_unset "\$settings" BACKUP_ENCRYPTION/g)).toHaveLength(3); + expect(menu).toContain("backup_encryption_problem"); + expect(spawnSync("bash", ["-n", "scripts/vps-backup-settings.sh", "scripts/vps-backup.sh", "scripts/setup-vps.sh", "scripts/vps-lib.sh"], { cwd: root }).status).toBe(0); + }); +}); diff --git a/tests/unit/vps-backup-status.test.ts b/tests/unit/vps-backup-status.test.ts index bc90b140..7f600a3b 100644 --- a/tests/unit/vps-backup-status.test.ts +++ b/tests/unit/vps-backup-status.test.ts @@ -46,3 +46,15 @@ describe("VPS backup status", () => { expect(backupProblem({ ...stale, scheduled: false }, now)).toBeNull(); }); }); + +describe("VPS backup refusals", () => { + it("names a backup refused because encryption is not working", () => { + const refused = { ...healthy, lastStatus: "failed" as const, encrypted: false, lastAttemptAt: "2026-10-20T02:31:00Z" }; + const missing = backupProblem({ ...refused, reason: "encryption-key-missing" }, now); + expect(missing).toMatchObject({ key: "failed:2026-10-20T02:31:00Z", subject: "Backups refused: encryption is not working" }); + expect(missing?.detail).toContain("no encryption key is saved"); + expect(backupProblem({ ...refused, reason: "encryption-unavailable" }, now)?.detail).toContain('Install the "age" package'); + // Status files from older releases have no reason. + expect(backupProblem({ ...refused, reason: null }, now)?.subject).toBe("Backup failed"); + }); +}); diff --git a/tests/unit/vps-installer.test.ts b/tests/unit/vps-installer.test.ts index 746c5429..172c8970 100644 --- a/tests/unit/vps-installer.test.ts +++ b/tests/unit/vps-installer.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; const root = path.resolve(import.meta.dirname, "../.."); const scripts = [ - "install.sh", "scripts/vps-lib.sh", "scripts/setup-vps.sh", "scripts/update-vps.sh", "scripts/vps-backup.sh", + "install.sh", "scripts/vps-lib.sh", "scripts/setup-vps.sh", "scripts/update-vps.sh", "scripts/vps-backup.sh", "scripts/runner-service.sh", "scripts/vps-restore.sh", "scripts/vps-status.sh", "scripts/install-docker.sh", "scripts/vps-backup-settings.sh", "scripts/vps-reconfigure.sh", "scripts/vps-harden.sh", "scripts/vps-recover.sh", "scripts/vps-renew-certs.sh", "scripts/vps-support.sh", "scripts/vps-uninstall.sh", @@ -281,7 +281,7 @@ describe("VPS installer", () => { // The archive is written into the folder it archives: it must skip itself. expect(backup).toContain("--exclude=./backup.tar.age ."); expect(backup).toContain("Off-site copies need encrypted backups"); - expect(backup).toContain('"lastFullSuccessAt"'); + expect(readFileSync(path.join(root, "scripts/vps-lib.sh"), "utf8")).toContain('"lastFullSuccessAt"'); const restore = readFileSync(path.join(root, "scripts/vps-restore.sh"), "utf8"); expect(restore).toContain('mktemp -d "$SWE_CONFIG_DIR/backups/.restore.XXXXXX"'); expect(readFileSync(path.join(root, "scripts/vps-lib.sh"), "utf8")).not.toMatch(/AGE-SECRET-KEY.*>\s*"?\$/m); From 138225d15f4a96eef65994222d6cd06bc4bce276 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:27:36 +0530 Subject: [PATCH 27/79] test(payments): cancelled targets, lock-order races, refund retry and reconciliation, attendance No-after-Yes Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../attendance-release-race.db.test.ts | 77 +++++++++ tests/integration/helpers/paymentAudit.ts | 100 ++++++++++++ .../payment-audit-cancelled.db.test.ts | 150 +++++++++++++++++ .../payment-audit-races.db.test.ts | 154 ++++++++++++++++++ .../payment-audit-refunds.db.test.ts | 120 ++++++++++++++ tests/unit/payments-gateways.test.ts | 48 +++++- 6 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 tests/integration/attendance-release-race.db.test.ts create mode 100644 tests/integration/helpers/paymentAudit.ts create mode 100644 tests/integration/payment-audit-cancelled.db.test.ts create mode 100644 tests/integration/payment-audit-races.db.test.ts create mode 100644 tests/integration/payment-audit-refunds.db.test.ts diff --git a/tests/integration/attendance-release-race.db.test.ts b/tests/integration/attendance-release-race.db.test.ts new file mode 100644 index 00000000..1adb6331 --- /dev/null +++ b/tests/integration/attendance-release-race.db.test.ts @@ -0,0 +1,77 @@ +import { AttendanceResponse, RsvpStatus } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * An older attendance "No" never undoes a newer "Yes" (docs/ATTENDANCE.md): + * the release a "No" starts acts only while that exact answer is still the + * current one, checked under the release's own locks. Free places, paid + * cancellations and refund requests alike; a newer "No" still releases. + */ + +vi.mock("@/lib/auth", () => ({ auth: async () => null })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn(), updateTag: vi.fn() })); +vi.mock("@/lib/outboxWorker", async (importOriginal) => ({ ...(await importOriginal()), startOutboxWorker: vi.fn() })); +vi.mock("@/lib/email/attendance", () => ({ sendAttendanceCheckEmail: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { ANSWER_CHANGED } from "@/lib/attendanceGuard"; +import { respondToAttendanceCheck } from "@/lib/core/attendanceCheck"; +import { askAndToken, buyPaid, email, paidEvent, setUpPaidAttendance, tearDownPaidAttendance } from "./helpers/attendancePaid"; +import { whileLocked } from "./helpers/paymentAudit"; + +beforeAll(() => setUpPaidAttendance("Attendance race")); +afterAll(() => tearDownPaidAttendance()); + +const answer = (token: string, response: "ATTENDING" | "NOT_ATTENDING") => respondToAttendanceCheck({ token, response, note: "" }); + +/** + * The "No" commits, then its release waits behind a lock while a "Yes" is + * answered and committed; once the lock goes, the stale release runs. + */ +async function noThenYes(lock: (tx: Parameters[0]>[0]) => Promise, token: string) { + return whileLocked(lock, async () => { + const no = answer(token, "NOT_ATTENDING"); + await new Promise((resolve) => setTimeout(resolve, 250)); + const yes = await answer(token, "ATTENDING"); + return { no: await no, yes }; + }, 500); +} + +describe.sequential("a No racing a newer Yes", () => { + it("keeps a free place, and a later No still releases it", async () => { + const { event } = await paidEvent({ kind: "NONE" }); + const rsvp = await prisma.rSVP.create({ data: { eventId: event.id, guestEmail: email("free-race"), guestName: "Free", attendeeKey: `guest:${email("free-race")}`, status: RsvpStatus.CONFIRMED } }); + const token = await askAndToken(event, rsvp.id); + const { no, yes } = await noThenYes((tx) => tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${event.id} FOR UPDATE`, token); + expect(yes).toEqual({ ok: true, data: { response: "ATTENDING", released: false } }); + expect(no).toMatchObject({ ok: true, data: { released: false, releaseFailed: ANSWER_CHANGED } }); + expect(await prisma.rSVP.findUnique({ where: { id: rsvp.id } })).toMatchObject({ status: RsvpStatus.CONFIRMED }); + expect(await prisma.attendanceCheck.findUniqueOrThrow({ where: { rsvpId: rsvp.id } })).toMatchObject({ response: AttendanceResponse.ATTENDING, releaseOutcome: null }); + // No, Yes, then No again: the newest No releases. + expect(await answer(token, "NOT_ATTENDING")).toEqual({ ok: true, data: { response: "NOT_ATTENDING", released: true } }); + expect(await prisma.rSVP.findUnique({ where: { id: rsvp.id } })).toBeNull(); + }); + + it("keeps a paid order the older No would have cancelled and refunded", async () => { + const { event, ticket } = await paidEvent({ kind: "FULL_UNTIL", hours: 1 }); + const order = await buyPaid(event, ticket.id, "paid-race"); + const token = await askAndToken(event, order.rsvps[0]!.id); + const { no, yes } = await noThenYes((tx) => tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${event.id} FOR UPDATE`, token); + expect(yes).toMatchObject({ ok: true, data: { response: "ATTENDING" } }); + expect(no).toMatchObject({ ok: true, data: { released: false, releaseFailed: ANSWER_CHANGED } }); + expect(await prisma.order.findUniqueOrThrow({ where: { id: order.id }, include: { rsvps: true } })).toMatchObject({ status: "CONFIRMED", rsvps: [expect.objectContaining({ id: order.rsvps[0]!.id })] }); + expect(await prisma.paymentRefund.count({ where: { payment: { orderId: order.id } } })).toBe(0); + }); + + it("opens no refund request for the older No once the policy has closed", async () => { + const { event, ticket } = await paidEvent({ kind: "NONE" }); + const order = await buyPaid(event, ticket.id, "request-race"); + const token = await askAndToken(event, order.rsvps[0]!.id); + const { no, yes } = await noThenYes((tx) => tx.$queryRaw`SELECT "id" FROM "Order" WHERE "id" = ${order.id} FOR UPDATE`, token); + expect(yes).toMatchObject({ ok: true, data: { response: "ATTENDING" } }); + expect(no).toMatchObject({ ok: true, data: { released: false, releaseFailed: ANSWER_CHANGED } }); + expect(await prisma.refundRequest.count({ where: { orderId: order.id } })).toBe(0); + expect(await prisma.attendanceCheck.findUniqueOrThrow({ where: { rsvpId: order.rsvps[0]!.id } })).toMatchObject({ response: AttendanceResponse.ATTENDING, releaseOutcome: null, refundRequestId: null }); + }); +}); diff --git a/tests/integration/helpers/paymentAudit.ts b/tests/integration/helpers/paymentAudit.ts new file mode 100644 index 00000000..ec68dd69 --- /dev/null +++ b/tests/integration/helpers/paymentAudit.ts @@ -0,0 +1,100 @@ +import { EventPrivacyType, EventStatus, RsvpStatus, TicketTypeKind, type Prisma } from "@prisma/client"; +import { expect } from "vitest"; +import { prisma } from "@/lib/db"; +import { submitRsvpCore } from "@/lib/rsvpCore"; +import { paid, setUpPaidOrg, tearDownPaidOrg } from "./paidOrg"; + +/* + * Fixtures for the payment audit suites (docs/PAYMENTS.md): an organisation + * on the fake Razorpay with a verified owner, one-off events and series that + * sell online tickets, and buyers whose orders wait for payment. + */ + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +let counter = 0; +export const owner = { id: "", email: "" }; +export const email = (name: string) => `${name}-${paid.suffix}@example.test`; + +export async function setUpPaymentAudit(name: string) { + await setUpPaidOrg(name); + owner.email = email("owner"); + owner.id = (await prisma.user.create({ data: { email: owner.email, name: "Owner", emailVerified: new Date(), memberships: { create: { organisationId: paid.organisationId, role: "OWNER" } } } })).id; +} + +export async function tearDownPaymentAudit() { + await prisma.$executeRaw`DELETE FROM "OutboxMessage" WHERE "payload"->>'to' LIKE ${`%${paid.suffix}@example.test`}`; + await prisma.user.deleteMany({ where: { email: { endsWith: `${paid.suffix}@example.test` } } }); + await tearDownPaidOrg(); +} + +/** A published one-off event selling one online ticket (49900 paise unless `price`). */ +export async function onlineEvent(options: { capacity?: number | null; privacyType?: EventPrivacyType; price?: number; } = {}) { + counter += 1; + const start = new Date(Date.now() + 30 * DAY); + const event = await prisma.event.create({ data: { + organisationId: paid.organisationId, title: `Audit event ${counter}`, slug: `audit-${paid.suffix}-${counter}`, currency: "INR", capacity: options.capacity ?? null, + startDateTime: start, endDateTime: new Date(start.getTime() + 2 * HOUR), timezone: "UTC", status: EventStatus.PUBLISHED, page: { create: { isPublished: true } }, + ...(options.privacyType ? { privacyType: options.privacyType } : {}), + } }); + const ticket = await prisma.ticketType.create({ data: { eventId: event.id, name: "General", priceMinor: BigInt(options.price ?? 49900) } }); + return { event, ticket }; +} + +/** A published series with three dates ahead, a drop-in ticket and a pass. */ +export async function onlineSeries(options: { capacity?: number | null; } = {}) { + counter += 1; + const series = await prisma.eventSeries.create({ data: { + organisationId: paid.organisationId, title: `Audit course ${counter}`, slug: `audit-course-${paid.suffix}-${counter}`, recurrenceRule: "FREQ=WEEKLY;COUNT=3", + timezone: "UTC", status: EventStatus.PUBLISHED, capacity: options.capacity ?? null, currency: "INR", + } }); + const dates = []; + for (const offset of [7, 14, 21]) { + dates.push(await prisma.eventInstance.create({ data: { eventSeriesId: series.id, startDateTime: new Date(Date.now() + offset * DAY), endDateTime: new Date(Date.now() + offset * DAY + 2 * HOUR) } })); + } + const single = await prisma.ticketType.create({ data: { eventSeriesId: series.id, name: "Drop-in", priceMinor: BigInt(30000) } }); + const pass = await prisma.ticketType.create({ data: { eventSeriesId: series.id, kind: TicketTypeKind.PASS, name: "Course pass", priceMinor: BigInt(90000) } }); + return { series, dates: dates as [typeof dates[number], typeof dates[number], typeof dates[number]], single, pass }; +} + +type Target = { eventSlug: string; } | { eventInstanceId: string; }; + +/** Order online: the order is held while the buyer pays on the fake provider's page. */ +export async function buyOnline(target: Target, ticketTypeId: string, buyer: string, extra: { quantity?: number; addOns?: Array<{ addOnId: string; quantity: number; }>; } = {}) { + const result = await submitRsvpCore({ + orgSlug: paid.orgSlug, ...target, guestEmail: email(buyer), name: "Paying Guest", answers: {}, attendees: [], + items: [{ ticketTypeId, quantity: extra.quantity ?? 1 }], ...(extra.addOns ? { addOns: extra.addOns } : {}), + }, { userId: null }); + expect(result).toMatchObject({ ok: true }); + return prisma.order.findFirstOrThrow({ where: { buyerEmail: email(buyer) }, include: { rsvps: true, payments: { include: { refunds: true } } }, orderBy: { createdAt: "desc" } }); +} + +/** A free event's confirmed registration, or a waitlisted one. */ +export async function freeRsvp(eventId: string, name: string, status: RsvpStatus) { + return prisma.rSVP.create({ data: { eventId, guestEmail: email(name), guestName: name, attendeeKey: `guest:${email(name)}`, status } }); +} + +export const paymentNotices = (paymentId: string, notice: string) => prisma.outboxMessage.count({ where: { kind: "payment-notice", AND: [{ payload: { path: ["paymentId"], equals: paymentId } }, { payload: { path: ["notice"], equals: notice } }] } }); + +/** + * Start `work` while another transaction holds a row lock (`lock` runs a FOR + * UPDATE), so everything in `work` queues behind it; then let it go. Makes a + * race's operations contend for the same rows instead of running one by one. + */ +export async function whileLocked(lock: (tx: Prisma.TransactionClient) => Promise, work: () => Promise, holdMs = 300) { + let release!: () => void; + const released = new Promise((resolve) => { release = resolve; }); + let locked!: () => void; + const isLocked = new Promise((resolve) => { locked = resolve; }); + const holder = prisma.$transaction(async (tx) => { + await lock(tx); + locked(); + await released; + }, { timeout: 15_000 }); + await isLocked; + const running = work(); + await new Promise((resolve) => setTimeout(resolve, holdMs)); + release(); + await holder; + return running; +} diff --git a/tests/integration/payment-audit-cancelled.db.test.ts b/tests/integration/payment-audit-cancelled.db.test.ts new file mode 100644 index 00000000..4034174f --- /dev/null +++ b/tests/integration/payment-audit-cancelled.db.test.ts @@ -0,0 +1,150 @@ +import { EventPrivacyType, EventStatus, OrderStatus, PaymentRefundStatus, PaymentStatus, RsvpStatus, SubmissionStatus, TeamTicketMode } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * Cancelled targets (docs/PAYMENTS.md): a payment captured after its event, + * series or date was cancelled is recorded and refunded in full, with no + * ticket, team place or submitted project; settling by hand refuses; nobody + * is promoted onto a cancelled event or date; and a request is only held + * while its add-ons are still in stock. + */ + +vi.mock("@/lib/auth", () => ({ auth: async () => null })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn(), updateTag: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { submitSubmission } from "@/lib/core/judging"; +import { cancelOccurrence } from "@/lib/payments/occurrences"; +import { offerRequestedOrder, PaymentOperationError, settleOrderByHand } from "@/lib/payments/operations"; +import { cancelRegistrationByToken } from "@/lib/rsvpCancel"; +import { confirmRsvpWithinCapacity } from "@/lib/rsvpCapacity"; +import { submitRsvpCore } from "@/lib/rsvpCore"; +import { createTeamEvent, paid, payOrder } from "./helpers/paidOrg"; +import { buyOnline, email, freeRsvp, onlineEvent, onlineSeries, owner, paymentNotices, setUpPaymentAudit, tearDownPaymentAudit } from "./helpers/paymentAudit"; + +beforeAll(() => setUpPaymentAudit("Audit cancelled")); +afterAll(() => tearDownPaymentAudit()); + +const cancelEvent = (eventId: string) => prisma.event.update({ where: { id: eventId }, data: { status: EventStatus.CANCELLED } }); + +/** A late capture on a cancelled target: recorded, the order cancelled without tickets, refunded in full (pending), the buyer told. */ +async function expectRefundedLateCapture(orderId: string) { + expect(await payOrder(orderId)).toMatchObject({ outcome: "refunding", refundIds: [expect.any(String)] }); + const order = await prisma.order.findUniqueOrThrow({ where: { id: orderId }, include: { rsvps: true, payments: { include: { refunds: true } } } }); + expect(order.status).toBe(OrderStatus.CANCELLED); + expect(order.rsvps).toHaveLength(0); + const payment = order.payments.find((candidate) => candidate.status === PaymentStatus.PAID_ONLINE)!; + expect(payment.refunds).toEqual([expect.objectContaining({ status: PaymentRefundStatus.PENDING, automatic: true, amountMinor: payment.amountMinor })]); + expect(await paymentNotices(payment.id, "cancelled-refund")).toBe(1); + expect(await paymentNotices(payment.id, "paid")).toBe(0); + expect(await prisma.outboxMessage.count({ where: { kind: "rsvp-confirmation", payload: { path: ["to"], equals: order.buyerEmail } } })).toBe(0); + expect(await prisma.auditEvent.count({ where: { action: "PAYMENT_CANCELLED_EVENT_REFUNDED", targetId: payment.id } })).toBe(1); + return order; +} + +describe.sequential("a payment captured after cancellation", () => { + it("refunds an event ticket whose hold was still live when the event was cancelled", async () => { + const { event, ticket } = await onlineEvent({ capacity: 5 }); + const order = await buyOnline({ eventSlug: event.slug }, ticket.id, "late-event"); + await cancelEvent(event.id); + await expectRefundedLateCapture(order.id); + }); + + it("refunds a pass when one of its dates was cancelled before the payment landed", async () => { + const { series, dates, pass } = await onlineSeries(); + const order = await buyOnline({ eventInstanceId: dates[0].id }, pass.id, "late-pass"); + expect(order.rsvps).toHaveLength(3); + await cancelOccurrence({ organisationId: paid.organisationId, seriesId: series.id, instanceId: dates[1].id, actorUserId: owner.id, reason: "Venue closed" }); + await expectRefundedLateCapture(order.id); + }); + + it("refunds a team leader's order and places no team", async () => { + const { event, ticket } = await createTeamEvent({ mode: TeamTicketMode.PER_TEAM, capacity: 3 }); + const created = await submitRsvpCore( + { orgSlug: paid.orgSlug, eventSlug: event.slug, guestEmail: email("late-leader"), name: "Team Lead", answers: {}, items: [{ ticketTypeId: ticket.id, quantity: 1 }] }, + { userId: null, team: { mode: "create", name: `Late ${paid.suffix.slice(0, 6)}` } }, + ); + expect(created).toMatchObject({ ok: true, data: { status: RsvpStatus.PENDING_PAYMENT } }); + const order = await prisma.order.findFirstOrThrow({ where: { eventId: event.id } }); + await cancelEvent(event.id); + await expectRefundedLateCapture(order.id); + expect(await prisma.team.count({ where: { eventId: event.id } })).toBe(0); + }); + + it("refunds an entry fee and leaves the project a draft", async () => { + const event = await prisma.event.create({ data: { + organisationId: paid.organisationId, title: "Late hack", slug: `late-hack-${paid.suffix}`, currency: "INR", timezone: "UTC", status: EventStatus.PUBLISHED, + startDateTime: new Date(Date.now() - 3_600_000), endDateTime: new Date(Date.now() + 48 * 3_600_000), + submissionsEnabled: true, submissionsOpenAt: new Date(Date.now() - 3_600_000), submissionsCloseAt: new Date(Date.now() + 24 * 3_600_000), submissionFeeMinor: BigInt(15000), + page: { create: { isPublished: true } }, + } }); + const user = await prisma.user.create({ data: { email: email("late-entrant"), name: "Entrant" } }); + const rsvp = await prisma.rSVP.create({ data: { eventId: event.id, userId: user.id, attendeeKey: `user:${user.id}`, status: RsvpStatus.CONFIRMED } }); + expect(await submitSubmission({ kind: "solo-owner", eventId: event.id, rsvpId: rsvp.id, userId: user.id }, { title: "Late rocket" })).toMatchObject({ ok: true }); + const submission = await prisma.submission.findFirstOrThrow({ where: { eventId: event.id } }); + const order = await prisma.order.findFirstOrThrow({ where: { submissionId: submission.id } }); + await cancelEvent(event.id); + await expectRefundedLateCapture(order.id); + expect(await prisma.submission.findUniqueOrThrow({ where: { id: submission.id } })).toMatchObject({ status: SubmissionStatus.DRAFT }); + }); + + it("refuses to settle a cancelled event's order by hand", async () => { + const { event, ticket } = await onlineEvent(); + const order = await buyOnline({ eventSlug: event.slug }, ticket.id, "by-hand"); + await cancelEvent(event.id); + await expect(settleOrderByHand({ orderId: order.id, organisationId: paid.organisationId, actorUserId: owner.id, mode: "offline", method: "CASH" })).rejects.toBeInstanceOf(PaymentOperationError); + expect(await prisma.order.findUniqueOrThrow({ where: { id: order.id } })).toMatchObject({ status: OrderStatus.PENDING }); + expect(await prisma.rSVP.count({ where: { orderId: order.id, status: RsvpStatus.CONFIRMED } })).toBe(0); + }); +}); + +describe.sequential("no promotion onto a cancelled event or date", () => { + it("leaves a free event's waitlist alone when a guest cancels after the event was cancelled", async () => { + const { event } = await onlineEvent({ capacity: 1, price: 0 }); + const going = await freeRsvp(event.id, "free-going", RsvpStatus.CONFIRMED); + const waiting = await freeRsvp(event.id, "free-waiting", RsvpStatus.WAITLISTED); + await cancelEvent(event.id); + expect(await cancelRegistrationByToken(null, going.checkInToken)).toMatchObject({ ok: true }); + expect(await prisma.rSVP.findUniqueOrThrow({ where: { id: waiting.id } })).toMatchObject({ status: RsvpStatus.WAITLISTED }); + // An organiser's promotion is refused too, whatever it asks for. + expect(await confirmRsvpWithinCapacity({ rsvpId: waiting.id, eventId: event.id, expectedStatuses: [RsvpStatus.WAITLISTED] })).toBe("closed"); + }); + + it("leaves a cancelled free date's waitlist alone", async () => { + const series = await prisma.eventSeries.create({ data: { organisationId: paid.organisationId, title: "Free course", slug: `free-course-${paid.suffix}`, recurrenceRule: "FREQ=WEEKLY;COUNT=1", timezone: "UTC", status: EventStatus.PUBLISHED, capacity: 1 } }); + const date = await prisma.eventInstance.create({ data: { eventSeriesId: series.id, startDateTime: new Date(Date.now() + 86_400_000), endDateTime: new Date(Date.now() + 90_000_000) } }); + const going = await prisma.rSVP.create({ data: { eventInstanceId: date.id, guestEmail: email("date-going"), attendeeKey: `guest:${email("date-going")}`, status: RsvpStatus.CONFIRMED } }); + const waiting = await prisma.rSVP.create({ data: { eventInstanceId: date.id, guestEmail: email("date-waiting"), attendeeKey: `guest:${email("date-waiting")}`, status: RsvpStatus.WAITLISTED } }); + await prisma.eventInstance.update({ where: { id: date.id }, data: { cancelledAt: new Date() } }); + expect(await cancelRegistrationByToken(null, going.checkInToken)).toMatchObject({ ok: true }); + expect(await prisma.rSVP.findUniqueOrThrow({ where: { id: waiting.id } })).toMatchObject({ status: RsvpStatus.WAITLISTED }); + expect(await confirmRsvpWithinCapacity({ rsvpId: waiting.id, eventInstanceId: date.id, expectedStatuses: [RsvpStatus.WAITLISTED] })).toBe("closed"); + }); + + it("promotes no waitlisted unit on a cancelled team event", async () => { + const { event } = await createTeamEvent({ capacity: 1 }); + const going = await freeRsvp(event.id, "unit-going", RsvpStatus.CONFIRMED); + const waiting = await freeRsvp(event.id, "unit-waiting", RsvpStatus.WAITLISTED); + await cancelEvent(event.id); + expect(await cancelRegistrationByToken(null, going.checkInToken)).toMatchObject({ ok: true }); + expect(await prisma.rSVP.findUniqueOrThrow({ where: { id: waiting.id } })).toMatchObject({ status: RsvpStatus.WAITLISTED }); + }); +}); + +describe.sequential("add-on stock when a request is held", () => { + it("refuses to approve a request whose extra sold out to another approved request", async () => { + const { event, ticket } = await onlineEvent({ privacyType: EventPrivacyType.APPROVAL_REQUIRED }); + const lunch = await prisma.addOn.create({ data: { eventId: event.id, name: "Lunch", priceMinor: BigInt(20000), capacity: 1, maxPerOrder: 1 } }); + const first = await buyOnline({ eventSlug: event.slug }, ticket.id, "lunch-a", { addOns: [{ addOnId: lunch.id, quantity: 1 }] }); + const second = await buyOnline({ eventSlug: event.slug }, ticket.id, "lunch-b", { addOns: [{ addOnId: lunch.id, quantity: 1 }] }); + expect([first.status, second.status]).toEqual([OrderStatus.REQUESTED, OrderStatus.REQUESTED]); + await offerRequestedOrder({ orderId: first.id, organisationId: paid.organisationId, actorUserId: owner.id }); + await expect(offerRequestedOrder({ orderId: second.id, organisationId: paid.organisationId, actorUserId: owner.id })).rejects.toThrow("Some extras on this order have sold out."); + expect(await prisma.order.findUniqueOrThrow({ where: { id: second.id } })).toMatchObject({ status: OrderStatus.REQUESTED }); + // Once the first hold ends unpaid, the extra is free again. + await prisma.order.update({ where: { id: first.id }, data: { holdExpiresAt: new Date(Date.now() - 1000) } }); + await offerRequestedOrder({ orderId: second.id, organisationId: paid.organisationId, actorUserId: owner.id }); + expect(await prisma.order.findUniqueOrThrow({ where: { id: second.id } })).toMatchObject({ status: OrderStatus.PENDING }); + }); +}); diff --git a/tests/integration/payment-audit-races.db.test.ts b/tests/integration/payment-audit-races.db.test.ts new file mode 100644 index 00000000..39c74b16 --- /dev/null +++ b/tests/integration/payment-audit-races.db.test.ts @@ -0,0 +1,154 @@ +import { EventPrivacyType, EventStatus, OrderStatus, PaymentRefundStatus, RsvpStatus } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * Races on money and capacity (docs/PAYMENTS.md lock order): a pass date + * cancelled twice refunds once; extending a hold can't oversell against a + * new reservation; a scan and a cancellation never both win; and series + * capacity counts live holds and the capacity a date would inherit. + */ + +const session = vi.hoisted(() => ({ userId: "" })); +vi.mock("@/lib/auth", () => ({ auth: async () => (session.userId ? { user: { id: session.userId } } : null) })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn(), updateTag: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { updateEventSeriesMeta } from "@/app/actions/series"; +import { commitCheckInProjection } from "@/lib/checkInMutations"; +import { startCheckout } from "@/lib/payments/checkout"; +import { cancelOccurrence, setOccurrenceCapacity } from "@/lib/payments/occurrences"; +import { cancelOrderAsBuyer, PaymentOperationError } from "@/lib/payments/operations"; +import { seatTakingWhere } from "@/lib/payments/holds"; +import { cancelRegistrationByToken } from "@/lib/rsvpCancel"; +import { fake, paid, payOrder } from "./helpers/paidOrg"; +import { buyOnline, freeRsvp, onlineEvent, onlineSeries, owner, setUpPaymentAudit, tearDownPaymentAudit, whileLocked } from "./helpers/paymentAudit"; + +beforeAll(() => setUpPaymentAudit("Audit races")); +afterAll(() => tearDownPaymentAudit()); + +const lockRow = (table: "Order" | "RSVP" | "Event", id: string) => (tx: Parameters[0]>[0]) => ( + table === "Order" ? tx.$queryRaw`SELECT "id" FROM "Order" WHERE "id" = ${id} FOR UPDATE` + : table === "RSVP" ? tx.$queryRaw`SELECT "id" FROM "RSVP" WHERE "id" = ${id} FOR UPDATE` + : tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${id} FOR UPDATE` +); + +async function paidPass() { + const fixture = await onlineSeries(); + const order = await buyOnline({ eventInstanceId: fixture.dates[0].id }, fixture.pass.id, `pass-${Math.random().toString(36).slice(2, 8)}`); + expect((await payOrder(order.id)).outcome).toBe("confirmed"); + return { ...fixture, order }; +} + +const committedRefunds = (orderId: string) => prisma.paymentRefund.findMany({ where: { payment: { orderId }, status: { in: [PaymentRefundStatus.PENDING, PaymentRefundStatus.SUCCEEDED] } } }); + +describe.sequential("cancelling a pass date", () => { + it("refunds the date's share once when two cancellations race", async () => { + const { series, dates, order } = await paidPass(); + const cancel = () => cancelOccurrence({ organisationId: paid.organisationId, seriesId: series.id, instanceId: dates[1].id, actorUserId: owner.id, reason: "Storm" }); + await whileLocked(lockRow("Order", order.id), () => Promise.all([cancel(), cancel()])); + expect((await committedRefunds(order.id)).map((refund) => refund.amountMinor)).toEqual([BigInt(30000)]); + expect(await prisma.rSVP.count({ where: { orderId: order.id, eventInstanceId: dates[1].id } })).toBe(0); + expect(await prisma.rSVP.count({ where: { orderId: order.id } })).toBe(2); + }); + + it("never refunds again when retried after the provider call was lost", async () => { + const { series, dates, order } = await paidPass(); + fake.dropNext(); + const cancel = () => cancelOccurrence({ organisationId: paid.organisationId, seriesId: series.id, instanceId: dates[2].id, actorUserId: owner.id, reason: "Retry" }); + await cancel(); + expect(await committedRefunds(order.id)).toEqual([expect.objectContaining({ status: PaymentRefundStatus.PENDING, externalRefundId: null })]); + await cancel(); + expect(await committedRefunds(order.id)).toHaveLength(1); + }); + + it("refuses a date someone on the pass has checked in to, refunding nothing", async () => { + const { series, dates, order } = await paidPass(); + const onDate = order.rsvps.find((rsvp) => rsvp.eventInstanceId === dates[0].id)!; + await prisma.rSVP.update({ where: { id: onDate.id }, data: { checkedInAt: new Date() } }); + expect(await cancelOccurrence({ organisationId: paid.organisationId, seriesId: series.id, instanceId: dates[0].id, actorUserId: owner.id, reason: "Late" })).toMatchObject({ refunded: 0, failed: 1 }); + expect(await committedRefunds(order.id)).toHaveLength(0); + expect(await prisma.rSVP.count({ where: { orderId: order.id } })).toBe(3); + }); +}); + +describe.sequential("extending a hold", () => { + it("never leaves two live holds on the last place when an extension races a new reservation", async () => { + const { event, ticket } = await onlineEvent({ capacity: 1 }); + const first = await buyOnline({ eventSlug: event.slug }, ticket.id, "extend-a"); + // A's hold ends a moment from now, and its page can't be reused. + const ends = new Date(Date.now() + 150); + await prisma.order.update({ where: { id: first.id }, data: { holdExpiresAt: ends } }); + await prisma.rSVP.updateMany({ where: { orderId: first.id }, data: { holdExpiresAt: ends } }); + await prisma.paymentCheckoutAttempt.updateMany({ where: { payment: { orderId: first.id } }, data: { expiresAt: new Date() } }); + // A asks to extend before its hold ends; B reserves just after, counting A's hold as over. + const [extended, second] = await whileLocked(lockRow("Event", event.id), async () => { + const extending = startCheckout(first.id); + await new Promise((resolve) => setTimeout(resolve, 250)); + return Promise.all([extending, buyOnline({ eventSlug: event.slug }, ticket.id, "extend-b")]); + }, 400); + expect(extended).toEqual({ error: "hold_ended" }); + expect(second.status).toBe(OrderStatus.PENDING); + expect(await prisma.rSVP.count({ where: { eventId: event.id, ...seatTakingWhere(new Date()) } })).toBe(1); + }); +}); + +describe.sequential("a scan racing a cancellation", () => { + it("either checks in and refuses the paid cancellation, or cancels and the scan fails", async () => { + const { event, ticket } = await onlineEvent(); + await prisma.event.update({ where: { id: event.id }, data: { refundPolicyKind: "FULL_UNTIL", refundPolicyHours: 1 } }); + const order = await buyOnline({ eventSlug: event.slug }, ticket.id, "scan-paid"); + expect((await payOrder(order.id)).outcome).toBe("confirmed"); + const rsvp = order.rsvps[0]!; + const [scan, cancel] = await whileLocked(lockRow("RSVP", rsvp.id), () => Promise.allSettled([ + commitCheckInProjection({ rsvpId: rsvp.id, actorUserId: owner.id, source: "online", checkedInAt: new Date(), force: false }), + cancelOrderAsBuyer({ orderId: order.id }), + ])); + const after = await prisma.rSVP.findUnique({ where: { id: rsvp.id } }); + if (after) { + expect(after.checkedInAt).not.toBeNull(); + expect(scan).toMatchObject({ status: "fulfilled", value: { state: "checked-in" } }); + expect(cancel).toMatchObject({ status: "rejected", reason: expect.any(PaymentOperationError) }); + expect(await committedRefunds(order.id)).toHaveLength(0); + } else { + expect(scan).toMatchObject({ status: "fulfilled", value: { state: "missing" } }); + expect(await prisma.order.findUniqueOrThrow({ where: { id: order.id } })).toMatchObject({ status: OrderStatus.CANCELLED }); + } + }); + + it("does the same for a free guest's own cancellation", async () => { + const { event } = await onlineEvent({ price: 0 }); + const rsvp = await freeRsvp(event.id, "scan-free", RsvpStatus.CONFIRMED); + const [scan, cancel] = await whileLocked(lockRow("RSVP", rsvp.id), () => Promise.all([ + commitCheckInProjection({ rsvpId: rsvp.id, actorUserId: owner.id, source: "online", checkedInAt: new Date(), force: false }), + cancelRegistrationByToken(null, rsvp.checkInToken), + ])); + const after = await prisma.rSVP.findUnique({ where: { id: rsvp.id } }); + if (after) expect([after.checkedInAt != null, scan.state, cancel.ok]).toEqual([true, "checked-in", false]); + else expect([scan.state, cancel.ok]).toEqual(["missing", true]); + }); +}); + +describe.sequential("series capacity", () => { + it("refuses a series capacity below a date's confirmed and held places", async () => { + const { series, dates, single } = await onlineSeries({ capacity: 10 }); + await buyOnline({ eventInstanceId: dates[0].id }, single.id, "held-a", { quantity: 2 }); + session.userId = owner.id; + const input = { organisationSlug: paid.orgSlug, eventSeriesId: series.id, title: series.title, description: "", timezone: "UTC", status: EventStatus.PUBLISHED, privacyType: EventPrivacyType.PUBLIC }; + expect(await updateEventSeriesMeta({ ...input, capacity: 1 })).toMatchObject({ ok: false }); + expect(await prisma.eventSeries.findUniqueOrThrow({ where: { id: series.id } })).toMatchObject({ capacity: 10 }); + expect(await updateEventSeriesMeta({ ...input, capacity: 2 })).toMatchObject({ ok: true }); + session.userId = ""; + }); + + it("checks the inherited capacity before clearing a date's own", async () => { + const { series, dates, single } = await onlineSeries({ capacity: 1 }); + await setOccurrenceCapacity({ organisationId: paid.organisationId, seriesId: series.id, instanceId: dates[0].id, capacity: 3, actorUserId: owner.id }); + await buyOnline({ eventInstanceId: dates[0].id }, single.id, "override", { quantity: 2 }); + await expect(setOccurrenceCapacity({ organisationId: paid.organisationId, seriesId: series.id, instanceId: dates[0].id, capacity: null, actorUserId: owner.id })).rejects.toBeInstanceOf(PaymentOperationError); + expect(await prisma.eventInstance.findUniqueOrThrow({ where: { id: dates[0].id } })).toMatchObject({ capacity: 3 }); + await prisma.eventSeries.update({ where: { id: series.id }, data: { capacity: 2 } }); + await setOccurrenceCapacity({ organisationId: paid.organisationId, seriesId: series.id, instanceId: dates[0].id, capacity: null, actorUserId: owner.id }); + expect(await prisma.eventInstance.findUniqueOrThrow({ where: { id: dates[0].id } })).toMatchObject({ capacity: null }); + }); +}); diff --git a/tests/integration/payment-audit-refunds.db.test.ts b/tests/integration/payment-audit-refunds.db.test.ts new file mode 100644 index 00000000..2e4f8836 --- /dev/null +++ b/tests/integration/payment-audit-refunds.db.test.ts @@ -0,0 +1,120 @@ +import { EventPermission, OrderStatus, PaymentRefundStatus } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * Refunds the provider refused or never finished (docs/PAYMENTS.md): a + * failed refund is retried once by an owner or admin, even after its tickets + * were cancelled, and maintenance asks the provider about accepted refunds + * whose webhook never came, without ever failing one on a timeout. + */ + +const session = vi.hoisted(() => ({ value: null as null | { user: { id: string; }; authenticatedAt?: number; } })); +vi.mock("@/lib/auth", () => ({ auth: async () => session.value })); +vi.mock("next/headers", () => ({ cookies: async () => ({ get: () => undefined }) })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn(), updateTag: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { retryFailedRefundAction } from "@/app/actions/payment-operations"; +import { PaymentOperationError, refundOrder, retryFailedRefund } from "@/lib/payments/operations"; +import { reconcileProviderRefunds } from "@/lib/payments/refundReconcile"; +import { applyRefundNotification } from "@/lib/payments/refunds"; +import { fake, paid, payOrder } from "./helpers/paidOrg"; +import { buyOnline, email, onlineEvent, owner, setUpPaymentAudit, tearDownPaymentAudit, whileLocked } from "./helpers/paymentAudit"; + +beforeAll(() => setUpPaymentAudit("Audit refunds")); +afterAll(() => tearDownPaymentAudit()); + +let buyers = 0; + +/** A paid order cancelled with a full refund that the provider refused. */ +async function failedRefund() { + const { event, ticket } = await onlineEvent(); + buyers += 1; + const order = await buyOnline({ eventSlug: event.slug }, ticket.id, `refund-buyer-${buyers}`); + expect((await payOrder(order.id)).outcome).toBe("confirmed"); + fake.failNextWith(400); + await refundOrder({ orderId: order.id, organisationId: paid.organisationId, actorUserId: owner.id, amountMinor: null, reason: "Cancelled", cancelTickets: true }); + const refund = await prisma.paymentRefund.findFirstOrThrow({ where: { payment: { orderId: order.id } } }); + expect(refund.status).toBe(PaymentRefundStatus.FAILED); + return { event, order, refund }; +} + +const failureNotices = () => prisma.outboxMessage.count({ where: { kind: "operator-notice", AND: [{ payload: { path: ["to"], equals: owner.email } }, { payload: { path: ["subject"], string_contains: "A refund failed" } }] } }); + +describe.sequential("retrying a failed refund", () => { + it("creates exactly one replacement when two retries race, and never changes the failed row", async () => { + const { order, refund } = await failedRefund(); + expect(await prisma.order.findUniqueOrThrow({ where: { id: order.id } })).toMatchObject({ status: OrderStatus.CANCELLED }); + const retry = () => retryFailedRefund({ refundId: refund.id, organisationId: paid.organisationId, actorUserId: owner.id }); + const results = await whileLocked((tx) => tx.$queryRaw`SELECT "id" FROM "Payment" WHERE "id" = ${refund.paymentId} FOR UPDATE`, () => Promise.allSettled([retry(), retry()])); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.find((result) => result.status === "rejected")).toMatchObject({ reason: expect.any(PaymentOperationError) }); + const rows = await prisma.paymentRefund.findMany({ where: { paymentId: refund.paymentId }, orderBy: { createdAt: "asc" } }); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ id: refund.id, status: PaymentRefundStatus.FAILED, updatedAt: refund.updatedAt }); + expect(rows[1]).toMatchObject({ retryOfRefundId: refund.id, amountMinor: refund.amountMinor, status: PaymentRefundStatus.SUCCEEDED }); + await expect(retry()).rejects.toThrow("This refund has already been retried."); + }); + + it("is for owners and admins with an authenticator check and a recent sign-in", async () => { + const { event, refund } = await failedRefund(); + const input = { organisationSlug: paid.orgSlug, eventId: event.id, refundId: refund.id }; + const helper = await prisma.user.create({ data: { email: email("registrations-helper"), name: "Helper" } }); + await prisma.eventCollaborator.create({ data: { eventId: event.id, userId: helper.id, permissions: [EventPermission.MANAGE_REGISTRATIONS] } }); + session.value = { user: { id: helper.id }, authenticatedAt: Date.now() }; + expect(await retryFailedRefundAction(input)).toMatchObject({ ok: false, error: expect.stringContaining("owners and admins") }); + session.value = { user: { id: owner.id }, authenticatedAt: Date.now() - 60 * 60_000 }; + expect(await retryFailedRefundAction(input)).toMatchObject({ ok: false, error: expect.stringContaining("sign in again") }); + vi.stubEnv("STAFF_MFA_REQUIRED", "true"); + session.value = { user: { id: owner.id }, authenticatedAt: Date.now() }; + expect(await retryFailedRefundAction(input)).toMatchObject({ ok: false }); + vi.stubEnv("STAFF_MFA_REQUIRED", "false"); + expect(await prisma.paymentRefund.count({ where: { retryOfRefundId: refund.id } })).toBe(0); + expect(await retryFailedRefundAction(input)).toEqual({ ok: true }); + expect(await prisma.paymentRefund.count({ where: { retryOfRefundId: refund.id } })).toBe(1); + session.value = null; + }); +}); + +describe.sequential("refunds the provider accepted but never finished", () => { + /** A full refund the provider accepted and left pending, created over an hour ago. */ + async function providerPending() { + const { event, ticket } = await onlineEvent(); + buyers += 1; + const order = await buyOnline({ eventSlug: event.slug }, ticket.id, `pending-buyer-${buyers}`); + expect((await payOrder(order.id)).outcome).toBe("confirmed"); + fake.refundsSettle("pending"); + await refundOrder({ orderId: order.id, organisationId: paid.organisationId, actorUserId: owner.id, amountMinor: null, reason: "Goodwill", cancelTickets: false }); + fake.refundsSettle("done"); + const refund = await prisma.paymentRefund.findFirstOrThrow({ where: { payment: { orderId: order.id } } }); + expect(refund).toMatchObject({ status: PaymentRefundStatus.PENDING, externalRefundId: expect.any(String) }); + await prisma.paymentRefund.update({ where: { id: refund.id }, data: { createdAt: new Date(Date.now() - 2 * 60 * 60_000) } }); + return refund; + } + const providerSide = (externalRefundId: string | null) => fake.refunds.find((item) => item.id === externalRefundId)!; + + it("keeps a refund PENDING when the provider can't be reached, and moves it to the back", async () => { + const refund = await providerPending(); + fake.dropNext(); + await reconcileProviderRefunds(); + expect(await prisma.paymentRefund.findUniqueOrThrow({ where: { id: refund.id } })).toMatchObject({ status: PaymentRefundStatus.PENDING, lastCheckedAt: expect.any(Date) }); + providerSide(refund.externalRefundId).status = "processed"; + await reconcileProviderRefunds(); + expect(await prisma.paymentRefund.findUniqueOrThrow({ where: { id: refund.id } })).toMatchObject({ status: PaymentRefundStatus.SUCCEEDED, settledAt: expect.any(Date) }); + }); + + it("fails a refund the provider failed, telling the organisers once", async () => { + const refund = await providerPending(); + providerSide(refund.externalRefundId).status = "failed"; + const before = await failureNotices(); + await reconcileProviderRefunds(); + expect(await prisma.paymentRefund.findUniqueOrThrow({ where: { id: refund.id } })).toMatchObject({ status: PaymentRefundStatus.FAILED }); + expect(await failureNotices()).toBe(before + 1); + // A late webhook, or another pass, says nothing more. + await reconcileProviderRefunds(); + await prisma.$transaction((tx) => applyRefundNotification(tx, { organisationId: paid.organisationId, provider: "RAZORPAY" }, { refundId: refund.externalRefundId!, paymentId: null, reference: refund.id, state: "FAILED", amountMinor: null })); + expect(await failureNotices()).toBe(before + 1); + expect(await prisma.auditEvent.count({ where: { action: "PAYMENT_REFUND_FAILED", targetId: refund.id } })).toBe(1); + }); +}); diff --git a/tests/unit/payments-gateways.test.ts b/tests/unit/payments-gateways.test.ts index b6a00854..5cbf426c 100644 --- a/tests/unit/payments-gateways.test.ts +++ b/tests/unit/payments-gateways.test.ts @@ -79,7 +79,47 @@ describe("Razorpay gateway", () => { it("finds a refund it already made by our reference", async () => { fetchMock.mockResolvedValue(reply(200, { items: [{ id: "rfnd_other", notes: { refund_id: "x" }, status: "processed" }, { id: "rfnd_ours", notes: { refund_id: "refund1" }, status: "pending" }] })); await expect(gateway.findRefund("pay_1", "refund1")).resolves.toEqual({ refundId: "rfnd_ours", state: "PENDING" }); - expect(fetchMock.mock.calls[0]![0]).toBe("https://api.razorpay.com/v1/payments/pay_1/refunds?count=100"); + expect(fetchMock.mock.calls[0]![0]).toBe("https://api.razorpay.com/v1/payments/pay_1/refunds?count=100&skip=0"); + }); + + it("looks through every page of refunds, and treats a lookup it can't finish as unknown", async () => { + const others = Array.from({ length: 100 }, (_, index) => ({ id: `rfnd_other${index}`, notes: { refund_id: `x${index}` }, status: "processed" })); + fetchMock + .mockResolvedValueOnce(reply(200, { items: others })) + .mockResolvedValueOnce(reply(200, { items: [{ id: "rfnd_ours", notes: { refund_id: "refund1" }, status: "processed" }] })); + await expect(gateway.findRefund("pay_1", "refund1")).resolves.toEqual({ refundId: "rfnd_ours", state: "SUCCEEDED" }); + expect(fetchMock.mock.calls[1]![0]).toBe("https://api.razorpay.com/v1/payments/pay_1/refunds?count=100&skip=100"); + // A short last page without ours: there is none. + fetchMock.mockReset(); + fetchMock.mockResolvedValueOnce(reply(200, { items: others })).mockResolvedValueOnce(reply(200, { items: [] })); + await expect(gateway.findRefund("pay_1", "refund1")).resolves.toBeNull(); + // An unreadable page, or more pages than it reads, is never "not found". + fetchMock.mockReset(); + fetchMock.mockResolvedValueOnce(reply(200, { error: "nope" })); + await expect(gateway.findRefund("pay_1", "refund1")).rejects.toBeInstanceOf(PaymentsUnavailable); + fetchMock.mockReset(); + fetchMock.mockImplementation(async () => reply(200, { items: others })); + await expect(gateway.findRefund("pay_1", "refund1")).rejects.toBeInstanceOf(PaymentsUnavailable); + }); + + it("sends Razorpay's refund idempotency header with a key it accepts, stable per refund", async () => { + fetchMock.mockImplementation(async () => reply(200, { id: "rfnd_1", status: "pending" })); + const request = { paymentId: "pay_1", amountMinor: BigInt(1000), currency: "INR", reference: "cmrefund1", idempotencyKey: "refund:cmrefund1" }; + await gateway.createRefund(request); + await gateway.createRefund(request); + const [first, second] = fetchMock.mock.calls.map((call) => call[1].headers as Record); + expect(first!["X-Refund-Idempotency"]).toBe("refund_cmrefund1"); + expect(first!["X-Refund-Idempotency"]).toMatch(/^[A-Za-z0-9_-]+$/); + expect(second!["X-Refund-Idempotency"]).toBe(first!["X-Refund-Idempotency"]); + expect(first!["Idempotency-Key"]).toBeUndefined(); + }); + + it("reads one refund's status, and never reads a timeout as failed", async () => { + fetchMock.mockResolvedValueOnce(reply(200, { id: "rfnd_1", status: "failed" })); + await expect(gateway.getRefundStatus("pay_1", "rfnd_1")).resolves.toBe("FAILED"); + expect(fetchMock.mock.calls[0]![0]).toBe("https://api.razorpay.com/v1/payments/pay_1/refunds/rfnd_1"); + fetchMock.mockRejectedValueOnce(new Error("timeout")); + await expect(gateway.getRefundStatus("pay_1", "rfnd_1")).rejects.toBeInstanceOf(PaymentsUnavailable); }); it("reports a paid link when cancelling finds the attendee already paid", async () => { @@ -151,6 +191,12 @@ describe("Stripe gateway", () => { await expect(gateway.getCheckoutStatus("cs_test_1")).resolves.toEqual({ state: "paid", paymentId: "pi_1", amountMinor: BigInt(49900), currency: "INR" }); }); + it("reads one refund's status by its id", async () => { + fetchMock.mockResolvedValueOnce(reply(200, { id: "re_1", status: "succeeded" })); + await expect(gateway.getRefundStatus("pi_1", "re_1")).resolves.toBe("SUCCEEDED"); + expect(fetchMock.mock.calls[0]![0]).toBe("https://api.stripe.com/v1/refunds/re_1"); + }); + it("expires an open session and reports closed", async () => { fetchMock.mockResolvedValue(reply(200, { id: "cs_test_1", status: "expired" })); await expect(gateway.cancelCheckout("cs_test_1")).resolves.toEqual({ state: "closed" }); From 416e36709ec8983af190f19ce815259ebf72a005 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:30:18 +0530 Subject: [PATCH 28/79] docs(payments): lock order, cancelled-target refunds, failed refund retry, refund reconciliation Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../help/attending/orders-and-receipts.mdx | 1 + .../help/guests/attendance-confirmation.mdx | 2 +- content/help/payments/passes.mdx | 2 +- content/help/payments/refunds.mdx | 206 ++++++++++-------- docs/ATTENDANCE.md | 16 +- docs/FINANCE.md | 3 + docs/PAYMENTS.md | 78 ++++++- 7 files changed, 215 insertions(+), 93 deletions(-) diff --git a/content/help/attending/orders-and-receipts.mdx b/content/help/attending/orders-and-receipts.mdx index b7c21ece..26fb55f4 100644 --- a/content/help/attending/orders-and-receipts.mdx +++ b/content/help/attending/orders-and-receipts.mdx @@ -72,6 +72,7 @@ They get a new ticket by email, and the old ticket stops working. The form says | "Reserved, pay at the desk" | You chose to pay in person. | | "Your held tickets were released" | The time to pay ran out. Nothing was charged. | | "Refund" or "Order cancelled" | Money was refunded, or the order was cancelled. | +| "Refund on its way" | Your payment arrived after the event (or the date) was cancelled. No ticket was issued, and the whole amount is being refunded. Card and UPI refunds can take 5 to 10 working days to appear. | ## Good to know diff --git a/content/help/guests/attendance-confirmation.mdx b/content/help/guests/attendance-confirmation.mdx index 0e60d43d..64d2324b 100644 --- a/content/help/guests/attendance-confirmation.mdx +++ b/content/help/guests/attendance-confirmation.mdx @@ -101,7 +101,7 @@ Answers from a link you shared yourself show up in exactly the same places as an - **The Overview tab** adds a line such as "Attendance: 12 coming · 3 not coming · 5 no reply" once anyone has been asked. - **The guest CSV** has **Attendance confirmation** and **Attendance note** columns. See [Exporting guest data](/help/guests/exports). -Guests can change their answer until the event starts. After that, the replies are final. +Guests can change their answer until the event starts. After that, the replies are final. If a guest says No and then quickly Yes (in two windows, say), the Yes wins: the place is only freed while No is still their latest answer. ## Good to know diff --git a/content/help/payments/passes.mdx b/content/help/payments/passes.mdx index 95b12a10..d7d06faa 100644 --- a/content/help/payments/passes.mdx +++ b/content/help/payments/passes.mdx @@ -46,7 +46,7 @@ Owners and admins can cancel one date of a series from **Upcoming dates** on the - each pass is refunded that date's share of what's left on it, and keeps its other dates; - everyone on that date is emailed. -For example, a pass that cost 1,000 and covers five dates gets back 200 when one of them is cancelled, as long as nothing has been refunded on it yet. See [Refunds and cancellations](/help/payments/refunds). +For example, a pass that cost 1,000 and covers five dates gets back 200 when one of them is cancelled, as long as nothing has been refunded on it yet. Cancelling the same date again never refunds a pass twice. If the pass holder already checked in to that date, the pass is left as it is for you to settle by hand. See [Refunds and cancellations](/help/payments/refunds). ## Refunding a pass diff --git a/content/help/payments/refunds.mdx b/content/help/payments/refunds.mdx index f8e5dfc1..ddb24809 100644 --- a/content/help/payments/refunds.mdx +++ b/content/help/payments/refunds.mdx @@ -1,87 +1,119 @@ -Money can go back to buyers in four ways: you refund an order from the guest list, the buyer cancels under your refund policy, you approve a refund request, or you cancel the event or one of its dates. Online refunds always go back the way the buyer paid, through your own Razorpay or Stripe account. - - - -Refunding and cancelling orders: owners, admins, and co-organisers with the **Registrations** permission. Setting the refund policy: owners, admins, and co-organisers with **Edit details**. Cancelling an event or a series date, and deciding refund requests: owners and admins. - - - -## Refunding an order - - - -1. Open the event's **Guests** tab, then **People**, and find the guest. -2. Open their **More actions** menu and choose **Payment**. -3. On **Refund**, enter an **Amount**, or leave it blank to refund everything left on the order. -4. Enter a **Reason**. -5. To cancel the tickets as well, tick the box to also cancel them and free the places. -6. Choose **Refund**. - - - -A partial refund leaves the tickets as they are; the guest list then shows the order as part refunded. You can refund the same order again later, up to what's left on it. - -For money you took in person, the app can't send it back for you. Return it yourself, and the refund is recorded on the order and its receipt. - -## Cancelling tickets - -To cancel an order's tickets, open **Payment** and choose **Cancel tickets**. Enter how much to refund (blank for everything left, or 0 to cancel without a refund), add a **Reason**, and choose **Cancel tickets**. All the order's tickets are cancelled, and the places go to the waitlist. - -Tickets can't be cancelled once anyone on the order has checked in. - -## When refunds arrive - -Online refunds are sent to the provider straight away and usually show as "on its way" until the provider confirms them. Card and UPI refunds can take 5 to 10 working days to reach the buyer. - -If the provider refuses a refund, for example because your account balance is too low, the owners and admins are emailed so they can try again or refund another way. - - - -If someone pays after their time to pay ran out and their place has gone, the payment is refunded in full automatically, and they're told by email. - - - -## The refund policy - -The **Refund policy** card on the event's **Settings** tab (or the series page) appears once a ticket has a price. It decides whether buyers can cancel by themselves for money back. - -| Choice | What buyers can do | -| --- | --- | -| **Guests ask me for refunds** | Nothing automatic. They contact you, or send a refund request. | -| **Guests can cancel for a full refund** | Cancel from their order page for everything back, until **Hours before the start**. | -| **Guests can cancel for part of the price back** | The same, for the **Percent refunded** you set. | - -Set **Hours before the start** to 0 to allow cancelling until the event starts. Add anything else buyers should know, then choose **Save policy**. The card shows the exact line buyers will see before they pay. - - - -Each order keeps the policy it was bought under. Once tickets have been sold, the policy can only become more generous, so what you advertised is never taken back. - - - -When a buyer cancels under the policy, every ticket in their order is cancelled and the refund is sent automatically. A buyer who wants more than the policy gives, or who is past the deadline, can ask you instead. - -## Refund requests from buyers - -From a paid order's page, a buyer can ask for a refund outside your policy, with a reason. Owners and admins decide these on the event's **Finance** tab: approve an amount, choosing whether to cancel the tickets, or decline with a reason the buyer sees. See [Refund requests](/help/finance/refund-requests). - -## Cancelling an event - - - -1. Open the event's **Settings** tab. -2. Under **Cancel event**, choose **Cancel event…**. -3. Read the summary and choose **Cancel event and refund**. - - - -Registration closes, the event page shows it's cancelled, every paid order is refunded in full, orders still being paid for are released, and waiting paid requests are declined. Everyone is emailed. This can't be undone. - -The message afterwards says how many orders were refunded, and how many need settling by hand, such as an order where someone had already checked in. Settle those from the guest list. If the provider later refuses a refund, the owners and admins are emailed. See [Cloning, cancelling and deleting](/help/events/clone-cancel-delete). - -To cancel one date of a series instead, use **Cancel date** under **Upcoming dates** on the series page. Tickets for that date are refunded in full, and each pass gets back that date's share. See [Passes](/help/payments/passes#cancelling-a-date). - -## Good to know - -- Refunds go through the provider the buyer paid with, using its saved keys. Keep those keys connected while you may still need to refund. -- A team event has extra rules about who leaves when an order is cancelled. See [Paid team places](/help/payments/paid-team-places#cancellations-and-refunds). +Money can go back to buyers in four ways: you refund an order from the guest list, the buyer cancels under your refund policy, you approve a refund request, or you cancel the event or one of its dates. Online refunds always go back the way the buyer paid, through your own Razorpay or Stripe account. + + + +Refunding and cancelling orders: owners, admins, and co-organisers with the **Registrations** permission. Setting the refund policy: owners, admins, and co-organisers with **Edit details**. Cancelling an event or a series date, and deciding refund requests: owners and admins. + + + +## Refunding an order + + + +1. Open the event's **Guests** tab, then **People**, and find the guest. +2. Open their **More actions** menu and choose **Payment**. +3. On **Refund**, enter an **Amount**, or leave it blank to refund everything left on the order. +4. Enter a **Reason**. +5. To cancel the tickets as well, tick the box to also cancel them and free the places. +6. Choose **Refund**. + + + +A partial refund leaves the tickets as they are; the guest list then shows the order as part refunded. You can refund the same order again later, up to what's left on it. + +For money you took in person, the app can't send it back for you. Return it yourself, and the refund is recorded on the order and its receipt. + +## Cancelling tickets + +To cancel an order's tickets, open **Payment** and choose **Cancel tickets**. Enter how much to refund (blank for everything left, or 0 to cancel without a refund), add a **Reason**, and choose **Cancel tickets**. All the order's tickets are cancelled, and the places go to the waitlist. + +Tickets can't be cancelled once anyone on the order has checked in. + +## When refunds arrive + +Online refunds are sent to the provider straight away and usually show as "on its way" until the provider confirms them. Card and UPI refunds can take 5 to 10 working days to reach the buyer. + +If the provider refuses a refund, for example because your account balance is too low, the owners and admins are emailed so they can retry it or refund another way. See [Retrying a failed refund](#retrying-a-failed-refund). + +If the provider accepts a refund but never confirms it finished, the app asks the provider about it again after an hour, and keeps asking until it has an answer. A refund is never marked as failed just because the provider didn't answer in time. + + + +If someone pays after their time to pay ran out and their place has gone, the payment is refunded in full automatically, and they're told by email. + + + +## Retrying a failed refund + +A refund the provider refused stays on record as failed. Once you've fixed the cause (usually topping up your provider balance), send it again: + + + +1. Open the event's **Finance** tab (for a series, the series page). +2. Under **Failed refunds**, find the order. Each refused refund is listed with the buyer, the order reference and the amount, even when the order's tickets were already cancelled. +3. Choose **Retry refund**. + + + +The app sends a new refund for the same amount, and it shows as "on its way" until the provider confirms it. The failed one stays on the order's record, so you can always see what happened. + + + +Retrying a refund: owners and admins only, after an authenticator check. If you signed in more than a few minutes ago, you're asked to sign in again first. + + + +Each failed refund can be retried once. If the retry fails too, it's listed again so you can retry that one, or refund the buyer another way. A retry is refused if it would return more than is left on the payment. + +## The refund policy + +The **Refund policy** card on the event's **Settings** tab (or the series page) appears once a ticket has a price. It decides whether buyers can cancel by themselves for money back. + +| Choice | What buyers can do | +| --- | --- | +| **Guests ask me for refunds** | Nothing automatic. They contact you, or send a refund request. | +| **Guests can cancel for a full refund** | Cancel from their order page for everything back, until **Hours before the start**. | +| **Guests can cancel for part of the price back** | The same, for the **Percent refunded** you set. | + +Set **Hours before the start** to 0 to allow cancelling until the event starts. Add anything else buyers should know, then choose **Save policy**. The card shows the exact line buyers will see before they pay. + + + +Each order keeps the policy it was bought under. Once tickets have been sold, the policy can only become more generous, so what you advertised is never taken back. + + + +When a buyer cancels under the policy, every ticket in their order is cancelled and the refund is sent automatically. A buyer who wants more than the policy gives, or who is past the deadline, can ask you instead. + +## Refund requests from buyers + +From a paid order's page, a buyer can ask for a refund outside your policy, with a reason. Owners and admins decide these on the event's **Finance** tab: approve an amount, choosing whether to cancel the tickets, or decline with a reason the buyer sees. See [Refund requests](/help/finance/refund-requests). + +## Cancelling an event + + + +1. Open the event's **Settings** tab. +2. Under **Cancel event**, choose **Cancel event…**. +3. Read the summary and choose **Cancel event and refund**. + + + +Registration closes, the event page shows it's cancelled, every paid order is refunded in full, orders still being paid for are released, and waiting paid requests are declined. Everyone is emailed. This can't be undone. + +Nobody on the waitlist is offered a place on a cancelled event, even when cancelling frees places. + +### Payments that arrive after you cancel + +A buyer may have paid just before you cancelled, or the provider may tell the app about a payment late. That payment never becomes a ticket. The app records it, cancels the order, and refunds the whole amount automatically. The buyer is emailed that their refund is on its way, and your team chat is told if you've connected one. The same happens for a team place (no team is created) and a hackathon entry fee (the project stays a draft), and for a pass when one of its dates was cancelled. + +You can't settle an order by hand for a cancelled event or date either; the app refuses it. + +The message afterwards says how many orders were refunded, and how many need settling by hand, such as an order where someone had already checked in. Settle those from the guest list. If the provider later refuses a refund, the owners and admins are emailed. See [Cloning, cancelling and deleting](/help/events/clone-cancel-delete). + +To cancel one date of a series instead, use **Cancel date** under **Upcoming dates** on the series page. Tickets for that date are refunded in full, and each pass gets back that date's share, once, even if the date is cancelled twice. A pass whose holder already checked in to that date is left for you to settle by hand. See [Passes](/help/payments/passes#cancelling-a-date). + +## Good to know + +- Refunds go through the provider the buyer paid with, using its saved keys. Keep those keys connected while you may still need to refund. +- A team event has extra rules about who leaves when an order is cancelled. See [Paid team places](/help/payments/paid-team-places#cancellations-and-refunds). diff --git a/docs/ATTENDANCE.md b/docs/ATTENDANCE.md index 3b4ed218..1df11f3e 100644 --- a/docs/ATTENDANCE.md +++ b/docs/ATTENDANCE.md @@ -127,6 +127,20 @@ Each reply is written only if the answer it replaces is still the stored one (response and its time), so when two windows answer at once only one plans and runs a release; the other is told what was stored. +A release runs after its No has been recorded, so a Yes can be answered in +between. The release therefore carries the No's own time +(`lib/attendanceGuard.ts`): the free cancellation, the paid cancellation and +the refund request each lock the attendance check last, after the event or +dates, the order, its payments and the registration ([lock order](PAYMENTS.md#locks-and-races-developers)), +and go ahead only while that exact No is still the answer. Otherwise nothing +is released and the No's reply is told its answer changed. A release that +deletes the registration deletes the check with it, so a Yes waiting on it +finds the link gone instead of succeeding over a released place. The +outcome (`releaseOutcome`) and refund-request fields are written only for +that same No, and a Yes withdraws a refund request only while it is still +the answer, after re-reading the check under the order lock, so No, Yes, No +in quick succession ends with whatever the last answer says. + **Where the note goes.** A guest's note is kept with their answer (Guests → People, the attendee CSV, their own data export). When a closed policy opens a refund request, the note is included in the request's reason, which the @@ -174,7 +188,7 @@ token or a note. | Shared link | `lib/core/attendanceCheck/link.ts` | | Card status, Overview counts, preview | `lib/core/attendanceCheck/status.ts`, `lib/eventDashboard/attendanceCheckCard.ts` | | Reply, form view | `lib/core/attendanceCheck/reply.ts` | -| Release on No | `lib/core/attendanceCheck/release.ts`, `releaseRun.ts`, `lib/rsvpCancel.ts`, `lib/refundRequests.ts` | +| Release on No | `lib/core/attendanceCheck/release.ts`, `releaseRun.ts`, `lib/attendanceGuard.ts`, `lib/rsvpCancel.ts`, `lib/refundRequests.ts` | | Email and outbox kind | `lib/email/attendance.ts`, `lib/outbox/attendance.ts` | | Server Actions | `app/actions/attendance-check.ts` | | Page | `app/attend/[token]/page.tsx`, `lib/views/app__attend__token__page.ts`, `components/attendance/` | diff --git a/docs/FINANCE.md b/docs/FINANCE.md index 728c5d94..f476470a 100644 --- a/docs/FINANCE.md +++ b/docs/FINANCE.md @@ -199,6 +199,9 @@ create, edit, close (**Close to new claims**) and delete them approved, so two approvals never refund twice. Or they **decline** with a reason the buyer sees. Audited `REFUND_REQUESTED`, `REFUND_REQUEST_APPROVED`, `REFUND_REQUEST_DECLINED`. +- **Failed refunds**: a refund the provider refused is listed by order on the + Finance tab (and the series page) for owners and admins to retry once, + after a recent sign-in. See [Refused refunds in PAYMENTS.md](PAYMENTS.md#after-the-sale). ## Summary, budget and export diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index c0aae5d9..b556b88c 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -62,7 +62,11 @@ On an event or series dashboard → **Tickets**: At checkout the seats are held for 30 minutes while the buyer pays. Approval requests and waitlist places get a payment link that holds the seats for 24 -hours, with one reminder a few hours before it ends. +hours, with one reminder a few hours before it ends. Opening the payment page +may extend a hold (up to its cap), never revive one that has ended. Extras +with a limit count toward it in confirmed orders and live holds; a request is +only held, an ended hold only reopened, and a late payment only takes its +extras back while they are still in stock. ## Teams, entry fees, prizes and extras @@ -100,11 +104,39 @@ hours, with one reminder a few hours before it ends. admins are emailed and told in chat, and the guest list shows *Said No · paid*. The email and the form only promise a release when it would really happen. - **Cancelling an event or a series date** refunds everyone who paid in full - (a pass gets its share for that date) and tells every guest by email. + (a pass gets its share for that date) and tells every guest by email. A + date someone on a pass already checked in to is left for the organiser to + settle by hand. Nobody is promoted from a waitlist onto a cancelled event, + series or date. +- **A payment that lands after a cancellation** (a delayed webhook, or a + buyer who paid just before) never issues a ticket. It is recorded, the + order is cancelled with its held places (a leader's unpaid team goes too; + an entry fee's project stays a draft), and the whole amount is refunded + automatically. The buyer is emailed that the refund is on its way (it is + still pending at the provider then), and the organisers' chat is told. + This covers the event, its series, and every date a pass holds. Settling + such an order by hand is refused. - **Chargebacks** arrive by webhook: the guest list marks the order, and the owners are emailed (and told in chat, if connected). - **Refused refunds** (for example a low provider balance) are emailed to the - owners and admins so they can retry or refund another way. + owners and admins. They're listed by order under **Failed refunds** (the + event's Finance tab, or the series page), still there after the order's + tickets were cancelled. **Retry refund** sends a new refund for the same + amount: owners and admins only, after an authenticator check and a recent + sign-in (`retryFailedRefundAction`). The failed refund is kept as it is; + the new one points at it (`PaymentRefund.retryOfRefundId`, unique, so a + failed refund is retried once even by two people at once), and the + database guard still refuses anything over what is left on the payment. +- **Refunds the provider accepted but never finished**: when a refund has the + provider's id but no final webhook came, the minute's maintenance asks the + provider about it once it is an hour old, 25 at a time, least recently + checked first (`PaymentRefund.lastCheckedAt`). A finished or failed answer + settles it (a failure is told to the organisers once); a timeout or an + unreadable answer leaves it pending for the next pass. Maintenance never + creates a provider refund. Razorpay refunds are created with its + `X-Refund-Idempotency` header (a key made from our refund id), and the + lookup before a retry reads every page of the payment's refunds; a lookup + it can't finish counts as unknown, never as "no refund yet". ### In the mobile apps @@ -140,6 +172,46 @@ the check-in preview shows what is owed. - Deleting an organisation, event or series is refused while people have paid for something still to come: cancel it instead, which refunds them. +## Locks and races (developers) + +Every transaction that touches seats, orders or money takes its row locks in +one order, so two of them never wait on each other: + +1. the event row, or the series row; +2. the series' dates, by id; +3. the order; +4. its payments, by id; +5. its registrations (RSVPs), by id, including a PER_TEAM leader's whole team; +6. attendance check rows. + +Rules that go with it: + +- Refund writes lock the Payment first (`lockRefundPayment` in + `lib/payments/refunds.ts`): the ledger trigger locks the Payment after any + `PaymentRefund` write, so writing the refund row first would take the two + in the opposite order. Submission claims, provider results, webhooks and + reconciliation all follow it. +- A provider is never called inside a database transaction. Refunds are + recorded PENDING under the locks and submitted after commit. +- A notice (email, chat, audit) for a refund or a release goes out only when + the conditional update it describes changed a row. +- A cancellation locks the registrations before reading their check-in, and + deletes only rows still not checked in; a count that doesn't match throws, + rolling the whole cancellation back. A scan either commits first and the + cancellation is refused, or waits and finds the ticket gone. Organisers + deleting a checked-in guest is unchanged. +- Cancelling a pass's date is one transaction per order: the share is worked + out, recorded and the date's places removed together, so a retry finds + nothing left to refund. +- Extending a hold takes the event (or series and dates) locks before the + order and reads the time after them. +- Capacity edits count confirmed places and live holds with the time read + under the series lock; clearing a date's own capacity checks the series + capacity it would inherit. +- A release started by an attendance "No" (free cancellation, paid + cancellation, refund request) runs only while that exact No is still the + answer (`lockCurrentNo` in `lib/attendanceGuard.ts`, the last lock taken). + ## Applications On events taking applications ([ADMISSION.md](ADMISSION.md)) a paid registration places a REQUESTED order and nothing is charged. An offer leaves the order REQUESTED; the applicant's **Accept and pay** holds the place for a day through the payment-link path (`offerInTransaction`; the PER_TEAM leader's order holds the team's unit), and the settled payment confirms it. A place is never confirmed unpaid (database guard `admission_status_guard`). A hold that ends unpaid puts the order back to REQUESTED while the offer's respond-by time lasts, then the offer expires and the order is CANCELLED; a decline, withdrawal or rejection cancels the unpaid order. A free ticket type on a paid event is free for that applicant. Orders are one seat each on these events, and PER_MEMBER paid team events can't take applications yet. From 19717351745f8878c301db3209976a4971630eac Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:31:08 +0530 Subject: [PATCH 29/79] fix(attendance): name the No's time the release is guarded by Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- lib/core/attendanceCheck/respond.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/core/attendanceCheck/respond.ts b/lib/core/attendanceCheck/respond.ts index 6f6dcfd0..62b123cf 100644 --- a/lib/core/attendanceCheck/respond.ts +++ b/lib/core/attendanceCheck/respond.ts @@ -108,9 +108,11 @@ async function answer(locator: Locator, response: AttendanceResponseValue, note: }); if (!updated) return currentAnswer(locator); + // Likewise a Yes withdraws a refund request only while it is still the answer. if (response === "ATTENDING") await withdrawRefundRequestOnYes(check, guestName, now); if (heldOutcome) return { ok: true, data: { response, released: false, releaseFailed: keptMessage(heldOutcome) } }; if (!plan) return { ok: true, data: { response, released: false } }; + // `now` is when this No was written: the release acts only while it is still the answer (lib/attendanceGuard.ts). const outcome = await carryOutRelease({ check, plan, note, guestName, now }); if (outcome.released) return { ok: true, data: { response, released: true } }; return { ok: true, data: { response, released: false, releaseFailed: outcome.message } }; From 16fcd9d285b116935e7eb8f063743d369559dc05 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:37:41 +0530 Subject: [PATCH 30/79] test(teams): cancelled events promote no waitlisted unit Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- tests/unit/team-units.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/team-units.test.ts b/tests/unit/team-units.test.ts index 14450f30..3c15dabb 100644 --- a/tests/unit/team-units.test.ts +++ b/tests/unit/team-units.test.ts @@ -22,6 +22,7 @@ const tx = { team: { count: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn(), delete: vi.fn() }, rSVP: { count: vi.fn(), findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), updateMany: vi.fn() }, teamJoinRequest: { updateMany: vi.fn() }, + event: { count: vi.fn() }, }; const event = { id: "event_1", title: "Jam", capacity: 2, privacyType: EventPrivacyType.PUBLIC }; @@ -87,6 +88,13 @@ describe("promoteOldestWaitlistedUnit", () => { expect(tx.team.findFirst).not.toHaveBeenCalled(); }); + it("does nothing on a cancelled event", async () => { + tx.event.count.mockResolvedValue(1); + await expect(promoteOldestWaitlistedUnit(tx as never, event)).resolves.toBeNull(); + expect(tx.event.count).toHaveBeenCalledWith({ where: { id: "event_1", status: "CANCELLED" } }); + expect(tx.team.findFirst).not.toHaveBeenCalled(); + }); + it("does nothing while all units are still taken", async () => { tx.team.count.mockResolvedValue(1); tx.rSVP.count.mockResolvedValue(1); From b0a7bcaa5084bef1d678a0019e5a89774e23917c Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 18:44:58 +0530 Subject: [PATCH 31/79] fix(check-in): claim offline check-ins by mutation id and replay their stored outcome Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/checkin/offline.ts | 27 ++-- content/help/check-in/offline.mdx | 4 +- content/help/index.generated.ts | 2 +- docs/MOBILE_API.md | 8 +- docs/SECURITY.md | 1 + docs/production-operations.md | 1 + lib/checkInMutations.ts | 122 +++++++++++---- lib/retention.ts | 7 +- .../migration.sql | 23 +++ prisma/schema.prisma | 24 +++ .../offline-check-in-replay.db.test.ts | 139 ++++++++++++++++++ tests/unit/retention.test.ts | 7 +- tests/unit/ticket-check-in-publish.test.ts | 6 +- 13 files changed, 325 insertions(+), 46 deletions(-) create mode 100644 prisma/migrations/20261120010200_offline_check_in_mutations/migration.sql create mode 100644 tests/integration/offline-check-in-replay.db.test.ts diff --git a/app/actions/checkin/offline.ts b/app/actions/checkin/offline.ts index 22940141..a406d8d2 100644 --- a/app/actions/checkin/offline.ts +++ b/app/actions/checkin/offline.ts @@ -3,7 +3,7 @@ import { gateCheckInForStatus } from "@/lib/checkIn"; import { prisma } from "@/lib/db"; import { offlineCheckInRosterSchema, syncOfflineCheckInsSchema } from "@/lib/validators"; import type { ActionResult } from "../org"; -import { commitCheckInProjection } from "@/lib/checkInMutations"; +import { commitOfflineCheckIn } from "@/lib/checkInMutations"; import { getCheckInDetails, getRegistrationDetails } from "@/lib/checkInDetails"; import { deskAttendanceState } from "@/lib/attendanceCheck"; import { attendeeLabel, requireOrgMemberForEvent, revalidatePath } from "./access"; @@ -78,6 +78,9 @@ export async function downloadOfflineCheckInRoster( }; } +/** Offline check-ins older than this are refused (docs/SECURITY.md); their replay records are kept a day longer. */ +export const OFFLINE_CHECK_IN_MAX_AGE_MS = 7 * 24 * 60 * 60_000; + export async function syncOfflineCheckIns( input: unknown, ): Promise> { @@ -92,7 +95,7 @@ export async function syncOfflineCheckIns( const rsvps = await prisma.rSVP.findMany({ where: { eventId: ctx.event.id, id: { in: checkIns.map((checkIn) => checkIn.rsvpId) } }, - select: { id: true, status: true, checkedInAt: true }, + select: { id: true }, }); const rsvpById = new Map(rsvps.map((rsvp) => [rsvp.id, rsvp])); const syncedIds: string[] = []; @@ -111,11 +114,6 @@ export async function syncOfflineCheckIns( failed.push({ rsvpId: checkIn.rsvpId, error: "RSVP not found for this event." }); continue; } - const gate = gateCheckInForStatus(rsvp.status, checkIn.force); - if (!gate.ok) { - failed.push({ rsvpId: checkIn.rsvpId, error: gate.reason }); - continue; - } const checkedInAt = new Date(checkIn.checkedInAt); const earliest = ctx.event.startDateTime.getTime() - 24 * 60 * 60_000; const latest = Math.min(Date.now() + 5 * 60_000, ctx.event.endDateTime.getTime() + 60 * 60_000); @@ -123,17 +121,26 @@ export async function syncOfflineCheckIns( failed.push({ rsvpId: checkIn.rsvpId, error: "Offline check-in time is outside the event operating window." }); continue; } - if (rsvp.checkedInAt) { - alreadyCheckedInIds.push(rsvp.id); + if (checkedInAt.getTime() < Date.now() - OFFLINE_CHECK_IN_MAX_AGE_MS) { + failed.push({ rsvpId: checkIn.rsvpId, error: "Offline check-ins older than 7 days are no longer accepted." }); continue; } - const committed = await commitCheckInProjection({ + // Claimed with its outcome in the check-in transaction: a retry returns + // the first outcome, whatever happened since (an undo included). + const commit = await commitOfflineCheckIn({ + eventId: ctx.event.id, + clientMutationId: checkIn.clientMutationId, rsvpId: rsvp.id, actorUserId: ctx.actorUserId, source: "offline-sync", checkedInAt, force: checkIn.force, }); + if (commit.kind === "mismatch") { + failed.push({ rsvpId: rsvp.id, error: "This offline check-in was already sent with different details." }); + continue; + } + const committed = commit.result; if (committed.state === "checked-in") { syncedIds.push(rsvp.id); } else if (committed.state === "already-checked-in") { diff --git a/content/help/check-in/offline.mdx b/content/help/check-in/offline.mdx index 6380a692..5c491d78 100644 --- a/content/help/check-in/offline.mdx +++ b/content/help/check-in/offline.mdx @@ -50,7 +50,9 @@ A few things wait for a connection: When the connection returns and the desk is open, it uploads its waiting check-ins by itself. You can also choose **Sync** (the button shows how many are waiting). Every check-in keeps the time it actually happened, not the time it synced. -Some check-ins can't be accepted, for example when a guest's registration was declined in the meantime, or the check-in was recorded outside the event's window (from 24 hours before the start to one hour after the end). The desk then tells you who wasn't checked in and why, and marks them as not checked in on this device. Check those guests on the **Guests** tab. +Some check-ins can't be accepted, for example when a guest's registration was declined in the meantime, the check-in was recorded outside the event's window (from 24 hours before the start to one hour after the end), or it is more than 7 days old. The desk then tells you who wasn't checked in and why, and marks them as not checked in on this device. Check those guests on the **Guests** tab. + +Each waiting check-in is accepted once. If the connection drops while it uploads and the desk sends it again, it gets the same answer as the first time, so a check-in you've since undone on the **Guests** tab stays undone. ## Cleaning up afterwards diff --git a/content/help/index.generated.ts b/content/help/index.generated.ts index cc491fa0..db2aac9b 100644 --- a/content/help/index.generated.ts +++ b/content/help/index.generated.ts @@ -2273,7 +2273,7 @@ export const helpIndex: Readonly> = { { "heading": "Syncing when you're back online", "anchor": "syncing-when-you-re-back-online", - "text": "When the connection returns and the desk is open, it uploads its waiting check-ins by itself. You can also choose Sync (the button shows how many are waiting). Every check-in keeps the time it actually happened, not the time it synced. Some check-ins can't be accepted, for example when a guest's registration was declined in the meantime, or the check-in was recorded outside the event's window (from 24 hours before the start to one hour after the end). The desk then tells you who wasn't checked in and why, and marks them as not checked in on this device. Check those guests on the Guests tab." + "text": "When the connection returns and the desk is open, it uploads its waiting check-ins by itself. You can also choose Sync (the button shows how many are waiting). Every check-in keeps the time it actually happened, not the time it synced. Some check-ins can't be accepted, for example when a guest's registration was declined in the meantime, the check-in was recorded outside the event's window (from 24 hours before the start to one hour after the end), or it is more than 7 days old. The desk then tells you who wasn't checked in and why, and marks them as not checked in on this device. Check those guests on the Guests tab. Each waiting check-in is accepted once. If the connection drops while it uploads and the desk sends it again, it gets the same answer as the first time, so a check-in you've since undone on the Guests tab stays undone." }, { "heading": "Cleaning up afterwards", diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 50ab9a26..9a2fe9db 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -700,7 +700,13 @@ with a member's `rsvpId` for contact details). `UNAUTHENTICATED`. - `POST …/check-in/sync` `{ "checkIns": [{ "rsvpId", "checkedInAt", "force", "clientMutationId"? }] }` (1–500) → `{ "syncedIds", "alreadyCheckedInIds", "failed": [{ "rsvpId", "error" }] }` - (as `syncOfflineCheckIns`, same operating-window rule). + (as `syncOfflineCheckIns`, same operating-window rule; check-ins older than + 7 days are refused). Send each queue entry's own `clientMutationId` and keep + it across retries: the first sync stores its outcome per event, and a retry + returns that outcome without checking the guest in again (even after an + undo), while the same id with another `rsvpId`, time or `force` is refused. + Without an id the server makes one up, so only the request's + `Idempotency-Key` protects a retry. All check-in writes go through `commitCheckInProjection` / `commitUndoCheckInProjection`, so the live ticket tears off exactly as for web diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 21319284..16e4b44b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -54,6 +54,7 @@ This page documents implemented controls and security boundaries. It is not a su - Check-in and certificate tokens are high-entropy random values. - The public ticket status and live-event routes validate the token shape, return only `{ checkedIn, checkedInAt }` with `no-store` and `no-referrer`, and answer unknown tokens with an empty 404. Realtime check-in channels are keyed by RSVP id, so ticket tokens never pass through Redis. +- Offline check-in sync (web desk and mobile) is replay-safe: each queued check-in's `clientMutationId` is claimed per event in the same transaction as the check-in (`commitOfflineCheckIn`, `OfflineCheckInMutation`), with its outcome, including "already checked in" and status refusals. A retry returns that stored outcome and never checks a guest in again after an organiser's undo; the same id with another registration, time or override is refused. Offline check-ins older than 7 days are refused, and the records are deleted 8 days after the check-in time, so a deleted record can never be replayed. - Event short codes are random seven-character values containing at least one uppercase letter, so they cannot collide with a lowercase organisation slug. Only owners and admins can create or regenerate them (audited as `EVENT_SHORT_LINK_CREATED`/`EVENT_SHORT_LINK_REGENERATED`). Resolution redirects only published events with a released page, so a code never reveals a draft or hidden event's slug; unknown, unreleased, and rate-limited lookups return the same not-found response as an unknown organisation, and the destination page still applies its own privacy rules. - Venue check-in stations require an event-scoped, bcrypt-protected rotating PIN. PIN management/recovery requires a fresh administrator sign-in and recoverable PIN ciphertext uses a domain-separated authenticated-encryption key. A station can open only from 24 hours before the event through one hour after it ends. Successful entry creates a signed, HttpOnly proof bound to that event, expiry, and PIN version; rotation and disablement invalidate existing proofs immediately. Station PINs, proofs, attendee exports, and offline rosters are never exposed through the public station. - Team join links and team management links ([Team registration](TEAMS.md)) are random bearer capabilities. The management token is stored only as a SHA-256 hash and is shown once; regenerating either token retires the old link. Leader authority is re-checked under the event row lock on every mutation, a bare team id acts only for its signed-in leader, and the ticket (check-in) token reaches only leave and *Looking for a team* actions, never team management. The join page applies the event page's privacy, invite-only and release checks before revealing anything, and shows only the team name and size. Both pages are `noindex`, private-cache and `no-referrer` (header and meta). The proxy sends these pages and `/ticket/{token}` a `private, no-cache, no-store` `Cache-Control`, because its HTML header would otherwise replace Next's in production. A solo registrant joining a team by link is resolved from their ticket token, and the join token is matched only inside that registration's event, under the event lock, while the event and its page are published. Team audit metadata holds ids only. diff --git a/docs/production-operations.md b/docs/production-operations.md index cc0ae6f1..a0ec4f3a 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -162,6 +162,7 @@ How to read the lock notes. Prisma runs each migration in one transaction withou | `20261119000000_judging_counted_reviews_recusal` | Nullable `JudgeAssignment.recusedAt` (judge self-recusal); nullable `JudgingRound.countedFrozenAt` with a CHECK that it is set only on a finalised round (a scan of `JudgingRound`); new `JudgingRoundCountedReview` table (the reviews a finalised round counted) cascading on `JudgingRound`. No backfill: rounds finalised earlier keep counting live | Safe | | `20261119030000_admission_audit_fixes` | `rsvp_admission_crew_detached` trigger (`BEFORE UPDATE OF "isCrew" ON "RSVP"`, a brief SHARE ROW EXCLUSIVE lock, no scan): a registration becomes crew only once its application is detached; drops `ReimbursementPolicy.requireAccepted` (ADMISSION_STAGE policies always need an accepted application) and re-adds the stage-shape CHECK without it (a scan of the small `ReimbursementPolicy` table); `AdmissionStage.freshEyes` (default `false`) | Safe from 0.10.0: `requireAccepted` arrived in the same release, so no released build reads it | | `20261120010000_utc_trigger_timestamps` | Replaces the trigger functions `rsvp_waitlist_dates`, `write_security_audit_fallback` and `write_api_security_audit_fallback` so the times they write are UTC whatever the session time zone (`CURRENT_TIMESTAMP` was stored as the session's local time). Function bodies only: no table lock, scan or backfill; rows written earlier by a database whose time zone is not UTC keep their shifted times | Safe | +| `20261120010200_offline_check_in_mutations` | New `OfflineCheckInMutation` table (one row per synced offline check-in: event, client mutation id, registration, time, override and stored outcome; unique per event and id; cascades with the `Event`). No scan or backfill | Safe: instances of the previous release keep syncing without replay records until they are replaced | Release 0.11.0 reserves the organisation addresses `/attend`, `/crew`, `/help`, `/submit` and `/team` (and `/order` and `/verify-mfa`, which 0.10.0 already served): the pre-migration check and `npm run db:verify` fail if an organisation already uses one (see [Reserved organisation addresses](#reserved-organisation-addresses)). Rename it before upgrading. diff --git a/lib/checkInMutations.ts b/lib/checkInMutations.ts index df69f339..719a689f 100644 --- a/lib/checkInMutations.ts +++ b/lib/checkInMutations.ts @@ -1,7 +1,9 @@ import "server-only"; -import { AdmissionStatus, type Prisma, type RsvpStatus } from "@prisma/client"; +import { AdmissionStatus, Prisma, type RsvpStatus } from "@prisma/client"; +import { gateCheckInForStatus } from "@/lib/checkIn"; import { prisma } from "@/lib/db"; +import { sqlUtcTimestamp, SQL_UTC_NOW } from "@/lib/sqlTimestamp"; import { publishTicketCheckedIn } from "@/lib/ticketCheckInEvents"; import { emitWebhookEvent } from "@/lib/webhooks/emit"; import { currentMobileActor } from "@/lib/requestActor"; @@ -51,45 +53,105 @@ export function isUnacceptedApplicant(row: ApplicationStatusRow): boolean { return status != null && status !== AdmissionStatus.ACCEPTED; } -export async function commitCheckInProjection(params: { +type CheckInParams = { rsvpId: string; actorUserId: string | null; source: CheckInSource; checkedInAt: Date; force: boolean; -}): Promise { - const result = await prisma.$transaction(async (tx): Promise => { - const allowedStatuses = params.force - ? ["CONFIRMED", "WAITLISTED", "PENDING_APPROVAL"] as const - : ["CONFIRMED"] as const; - const updated = await tx.rSVP.updateMany({ - where: { id: params.rsvpId, checkedInAt: null, status: { in: [...allowedStatuses] }, ...(params.force ? { NOT: unacceptedApplicant } : {}) }, - data: { checkedInAt: params.checkedInAt }, - }); - if (updated.count === 1) { - await tx.checkInEvent.create({ - data: { - rsvpId: params.rsvpId, - actorUserId: params.actorUserId, - action: "CHECKED_IN", - source: recordedSource(params.source), - occurredAt: params.checkedInAt, - }, - }); - return { state: "checked-in", checkedInAt: params.checkedInAt }; - } - const current = await tx.rSVP.findUnique({ where: { id: params.rsvpId }, select: { checkedInAt: true, status: true, ...applicationStatusSelect } }); - if (!current) return { state: "missing" }; - if (current.checkedInAt) return { state: "already-checked-in", checkedInAt: current.checkedInAt }; - return { state: "ineligible", status: current.status, applicant: current.status !== "CONFIRMED" && isUnacceptedApplicant(current) }; +}; + +async function applyCheckIn(tx: Prisma.TransactionClient, params: CheckInParams): Promise { + const allowedStatuses = params.force + ? ["CONFIRMED", "WAITLISTED", "PENDING_APPROVAL"] as const + : ["CONFIRMED"] as const; + const updated = await tx.rSVP.updateMany({ + where: { id: params.rsvpId, checkedInAt: null, status: { in: [...allowedStatuses] }, ...(params.force ? { NOT: unacceptedApplicant } : {}) }, + data: { checkedInAt: params.checkedInAt }, }); - if (result.state === "checked-in") { - publishTicketCheckedIn({ rsvpId: params.rsvpId, checkedInAt: result.checkedInAt }); - emitWebhookEvent({ type: "checkin.created", rsvpId: params.rsvpId, checkedInAt: result.checkedInAt }); + if (updated.count === 1) { + await tx.checkInEvent.create({ + data: { + rsvpId: params.rsvpId, + actorUserId: params.actorUserId, + action: "CHECKED_IN", + source: recordedSource(params.source), + occurredAt: params.checkedInAt, + }, + }); + return { state: "checked-in", checkedInAt: params.checkedInAt }; } + const current = await tx.rSVP.findUnique({ where: { id: params.rsvpId }, select: { checkedInAt: true, status: true, ...applicationStatusSelect } }); + if (!current) return { state: "missing" }; + if (current.checkedInAt) return { state: "already-checked-in", checkedInAt: current.checkedInAt }; + return { state: "ineligible", status: current.status, applicant: current.status !== "CONFIRMED" && isUnacceptedApplicant(current) }; +} + +/** After the transaction commits: the live ticket update and webhook, for a real transition only. */ +function announceCheckIn(rsvpId: string, result: CheckInCommitResult) { + if (result.state !== "checked-in") return; + publishTicketCheckedIn({ rsvpId, checkedInAt: result.checkedInAt }); + emitWebhookEvent({ type: "checkin.created", rsvpId, checkedInAt: result.checkedInAt }); +} + +export async function commitCheckInProjection(params: CheckInParams): Promise { + const result = await prisma.$transaction((tx) => applyCheckIn(tx, params)); + announceCheckIn(params.rsvpId, result); return result; } +export type OfflineCheckInCommit = + | { kind: "applied" | "replayed"; result: CheckInCommitResult } + | { kind: "mismatch" }; + +/** + * An offline desk's queued check-in. Its clientMutationId is claimed in the + * same transaction that checks the guest in, and every outcome is stored with + * it (already checked in and status refusals too), so a retry of the same + * mutation returns that outcome and never checks the guest in again, even + * after an organiser undid the check-in. The same id with another + * registration, time or override is refused. The status gate runs here, on + * the locked row, so an early refusal is recorded like any other outcome. + */ +export async function commitOfflineCheckIn(params: CheckInParams & { eventId: string; clientMutationId: string }): Promise { + const commit = await prisma.$transaction(async (tx): Promise => { + const claimed = await tx.$executeRaw` + INSERT INTO "OfflineCheckInMutation" ("id", "eventId", "clientMutationId", "rsvpId", "checkedInAt", "force", "createdAt") + VALUES (gen_random_uuid()::text, ${params.eventId}, ${params.clientMutationId}, ${params.rsvpId}, ${sqlUtcTimestamp(params.checkedInAt)}, ${params.force}, ${SQL_UTC_NOW}) + ON CONFLICT ("eventId", "clientMutationId") DO NOTHING`; + const key = { eventId_clientMutationId: { eventId: params.eventId, clientMutationId: params.clientMutationId } }; + if (claimed !== 1) { + // A concurrent first attempt has committed by now (the insert waited for it). + const stored = await tx.offlineCheckInMutation.findUnique({ where: key }); + const same = stored && stored.rsvpId === params.rsvpId && stored.force === params.force + && stored.checkedInAt.getTime() === params.checkedInAt.getTime() && stored.outcome != null; + return same ? { kind: "replayed", result: storedOutcome(stored.outcome) } : { kind: "mismatch" }; + } + await tx.$queryRaw`SELECT 1 FROM "RSVP" WHERE "id" = ${params.rsvpId} FOR UPDATE`; + const current = await tx.rSVP.findUnique({ where: { id: params.rsvpId }, select: { status: true, checkedInAt: true } }); + let result: CheckInCommitResult; + if (!current) result = { state: "missing" }; + else if (current.checkedInAt) result = { state: "already-checked-in", checkedInAt: current.checkedInAt }; + else if (!gateCheckInForStatus(current.status, params.force).ok) result = { state: "ineligible", status: current.status, applicant: false }; + else result = await applyCheckIn(tx, params); + await tx.offlineCheckInMutation.update({ where: key, data: { outcome: toStoredOutcome(result) } }); + return { kind: "applied", result }; + }); + if (commit.kind === "applied") announceCheckIn(params.rsvpId, commit.result); + return commit; +} + +function toStoredOutcome(result: CheckInCommitResult): Prisma.InputJsonValue { + return "checkedInAt" in result ? { ...result, checkedInAt: result.checkedInAt.toISOString() } : result; +} + +function storedOutcome(value: Prisma.JsonValue): CheckInCommitResult { + const stored = value as { state: CheckInCommitResult["state"]; checkedInAt?: string; status?: RsvpStatus; applicant?: boolean }; + if (stored.state === "checked-in" || stored.state === "already-checked-in") return { state: stored.state, checkedInAt: new Date(stored.checkedInAt!) }; + if (stored.state === "ineligible") return { state: "ineligible", status: stored.status!, applicant: Boolean(stored.applicant) }; + return { state: "missing" }; +} + export async function commitUndoCheckInProjection(params: { rsvpId: string; actorUserId: string | null; diff --git a/lib/retention.ts b/lib/retention.ts index 00383119..6a729e28 100644 --- a/lib/retention.ts +++ b/lib/retention.ts @@ -6,6 +6,8 @@ import { purgeUnusedCrewPhotos } from "@/lib/core/crew/photos"; const DAY_MS = 24 * 60 * 60 * 1000; const WEBHOOK_EVENT_RETENTION_DAYS = 90; +/** A day past the 7 days an offline check-in is accepted, so a deleted record can never be replayed. */ +const OFFLINE_CHECK_IN_RECORD_DAYS = 8; /** Remove expired operational records without deleting business/audit data. */ export async function purgeExpiredOperationalData(now = new Date()) { @@ -15,7 +17,7 @@ export async function purgeExpiredOperationalData(now = new Date()) { ); const sentBefore = new Date(now.getTime() - outboxRetentionDays * DAY_MS); const webhookBefore = new Date(now.getTime() - 30 * DAY_MS); - const [tokens, sessions, organisationInvites, collaboratorInvites, undoSnapshots, sentMessages, failedMessages, webhookEvents, webhookDeliveries, mobileSessions, idempotencyKeys, applicationEvents] = await prisma.$transaction([ + const [tokens, sessions, organisationInvites, collaboratorInvites, undoSnapshots, sentMessages, failedMessages, webhookEvents, webhookDeliveries, mobileSessions, idempotencyKeys, applicationEvents, offlineCheckIns] = await prisma.$transaction([ prisma.verificationToken.deleteMany({ where: { expires: { lt: now } } }), prisma.session.deleteMany({ where: { expires: { lt: now } } }), prisma.organisationInvite.deleteMany({ where: { usedAt: null, expiresAt: { lt: now } } }), @@ -44,6 +46,8 @@ export async function purgeExpiredOperationalData(now = new Date()) { prisma.mobileIdempotencyKey.deleteMany({ where: { expiresAt: { lt: now } } }), // Application status changes already sent as webhooks (docs/ADMISSION.md): 30 days. prisma.admissionStatusEvent.deleteMany({ where: { emittedAt: { not: null, lt: webhookBefore } } }), + // Offline check-in replay records (app/actions/checkin/offline.ts): past the acceptance window. + prisma.offlineCheckInMutation.deleteMany({ where: { checkedInAt: { lt: new Date(now.getTime() - OFFLINE_CHECK_IN_RECORD_DAYS * DAY_MS) } } }), ]); // Crew photos nobody saved within a day (docs/CREW.md); objects too, so outside the transaction. const unusedCrewPhotos = await purgeUnusedCrewPhotos(now); @@ -60,6 +64,7 @@ export async function purgeExpiredOperationalData(now = new Date()) { mobileSessions: mobileSessions.count, idempotencyKeys: idempotencyKeys.count, applicationStatusEvents: applicationEvents.count, + offlineCheckInRecords: offlineCheckIns.count, unusedCrewPhotos, }; } diff --git a/prisma/migrations/20261120010200_offline_check_in_mutations/migration.sql b/prisma/migrations/20261120010200_offline_check_in_mutations/migration.sql new file mode 100644 index 00000000..019f14dc --- /dev/null +++ b/prisma/migrations/20261120010200_offline_check_in_mutations/migration.sql @@ -0,0 +1,23 @@ +-- Offline check-in replay protection (docs/SECURITY.md, help: check-in/offline). +-- A desk's queued check-in carries a clientMutationId; the first sync claims it +-- inside the check-in transaction and stores the outcome, so a retry returns +-- that outcome and never checks a guest in again after an organiser's undo. +-- New table only: no lock on existing tables beyond the foreign key's brief +-- SHARE ROW EXCLUSIVE on "Event", no scan, no backfill. +CREATE TABLE "OfflineCheckInMutation" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "clientMutationId" TEXT NOT NULL, + "rsvpId" TEXT NOT NULL, + "checkedInAt" TIMESTAMP(3) NOT NULL, + "force" BOOLEAN NOT NULL, + "outcome" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OfflineCheckInMutation_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "OfflineCheckInMutation_eventId_clientMutationId_key" ON "OfflineCheckInMutation"("eventId", "clientMutationId"); +CREATE INDEX "OfflineCheckInMutation_checkedInAt_idx" ON "OfflineCheckInMutation"("checkedInAt"); + +ALTER TABLE "OfflineCheckInMutation" ADD CONSTRAINT "OfflineCheckInMutation_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b03c6c14..6726a2fe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -890,6 +890,7 @@ model Event { shortLinkClickDays ShortLinkClickDay[] promoCodes PromoCode[] checkInLists CheckInList[] + offlineCheckInMutations OfflineCheckInMutation[] savedViews SavedView[] attendanceChecks AttendanceCheck[] crewMembers EventCrewMember[] @@ -2061,6 +2062,29 @@ model CheckInEvent { @@index([actorUserId, occurredAt]) } +/// One offline check-in a desk sent (its queue entry's clientMutationId), +/// claimed in the check-in transaction: a retry with the same id returns this +/// stored outcome instead of checking in again (after an undo, too), and the +/// same id with another registration, time or override is refused. Offline +/// check-ins older than 7 days are not accepted, and rows go 8 days after +/// `checkedInAt` (lib/retention.ts), so a deleted row can never be replayed. +model OfflineCheckInMutation { + id String @id @default(cuid()) + eventId String + clientMutationId String + rsvpId String + checkedInAt DateTime + force Boolean + /// The CheckInCommitResult of the first attempt; null only inside that transaction. + outcome Json? + createdAt DateTime @default(now()) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@unique([eventId, clientMutationId]) + @@index([checkedInAt]) +} + /// An organiser-defined check-in list beyond venue entry ("Swag collection", /// "Lunch – Day 1", "Workshop B"). Standalone events only. Names are unique per /// event among non-archived lists, case-insensitively, through the partial diff --git a/tests/integration/offline-check-in-replay.db.test.ts b/tests/integration/offline-check-in-replay.db.test.ts new file mode 100644 index 00000000..c88a8636 --- /dev/null +++ b/tests/integration/offline-check-in-replay.db.test.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import { EventPrivacyType, EventStatus, MembershipRole, RsvpStatus } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * Offline check-in replay protection: a desk's queued check-in is claimed by + * its clientMutationId with its outcome, so a retry (for example after a lost + * response) never checks a guest in again once an organiser has undone it. + */ +const session = vi.hoisted(() => ({ userId: null as string | null })); +vi.mock("@/lib/auth", () => ({ auth: async () => (session.userId ? { user: { id: session.userId } } : null) })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("@/lib/audit", () => ({ recordAuditEvent: async () => undefined })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { syncOfflineCheckIns, undoCheckIn } from "@/app/actions/checkin"; +import { commitCheckInProjection } from "@/lib/checkInMutations"; +import { purgeExpiredOperationalData } from "@/lib/retention"; + +const suffix = randomUUID().replaceAll("-", ""); +const orgSlug = `offline-replay-${suffix}`; +let organisationId = ""; +let eventId = ""; +let userId = ""; + +async function guest(label: string, status: RsvpStatus = RsvpStatus.CONFIRMED) { + const email = `${label}-${suffix}@example.test`; + return prisma.rSVP.create({ data: { eventId, attendeeKey: `guest:${email}`, guestEmail: email, guestName: label, status } }); +} +const sync = (checkIns: Array<{ rsvpId: string; clientMutationId: string; checkedInAt: string; force?: boolean }>) => + syncOfflineCheckIns({ organisationSlug: orgSlug, eventId, checkIns }); +const undo = (rsvpId: string) => undoCheckIn({ organisationSlug: orgSlug, eventId, rsvpId }); +const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString(); +const checkedIn = async (rsvpId: string) => (await prisma.rSVP.findUniqueOrThrow({ where: { id: rsvpId } })).checkedInAt; + +beforeAll(async () => { + organisationId = (await prisma.organisation.create({ data: { name: "Offline replay", slug: orgSlug } })).id; + userId = (await prisma.user.create({ data: { email: `offline-owner-${suffix}@example.test`, emailVerified: new Date() } })).id; + await prisma.membership.create({ data: { userId, organisationId, role: MembershipRole.OWNER } }); + eventId = (await prisma.event.create({ data: { + organisationId, + title: "Offline replay", + slug: `offline-replay-${suffix}`, + // A long event, so the 7-day bound is reachable inside its operating window. + startDateTime: new Date(Date.now() - 9 * 24 * 60 * 60_000), + endDateTime: new Date(Date.now() + 2 * 60 * 60_000), + timezone: "UTC", + status: EventStatus.PUBLISHED, + privacyType: EventPrivacyType.PUBLIC, + } })).id; + session.userId = userId; +}); + +afterAll(async () => { + session.userId = null; + await prisma.organisation.deleteMany({ where: { id: organisationId } }); + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.$disconnect(); +}); + +describe.sequential("offline check-in replay", () => { + it("returns the stored outcome for a retried check-in, and never checks in again after an undo", async () => { + const rsvp = await guest("retry"); + const entry = { rsvpId: rsvp.id, clientMutationId: `mutation-a-${suffix}`, checkedInAt: minutesAgo(10) }; + expect(await sync([entry])).toMatchObject({ ok: true, data: { syncedIds: [rsvp.id] } }); + expect(await undo(rsvp.id)).toMatchObject({ ok: true }); + // The response was lost, so the device sends the same queue entry again. + expect(await sync([entry])).toMatchObject({ ok: true, data: { syncedIds: [rsvp.id], failed: [] } }); + expect(await checkedIn(rsvp.id)).toBeNull(); + await expect(prisma.checkInEvent.count({ where: { rsvpId: rsvp.id, action: "CHECKED_IN" } })).resolves.toBe(1); + }); + + it("records an already-checked-in outcome, so an undo is not reversed by the replay", async () => { + const rsvp = await guest("existing"); + await commitCheckInProjection({ rsvpId: rsvp.id, actorUserId: null, source: "online", checkedInAt: new Date(minutesAgo(30)), force: false }); + const entry = { rsvpId: rsvp.id, clientMutationId: `mutation-b-${suffix}`, checkedInAt: minutesAgo(20) }; + expect(await sync([entry])).toMatchObject({ ok: true, data: { alreadyCheckedInIds: [rsvp.id] } }); + expect(await undo(rsvp.id)).toMatchObject({ ok: true }); + expect(await sync([entry])).toMatchObject({ ok: true, data: { syncedIds: [], alreadyCheckedInIds: [rsvp.id] } }); + expect(await checkedIn(rsvp.id)).toBeNull(); + const stored = await prisma.offlineCheckInMutation.findUniqueOrThrow({ where: { eventId_clientMutationId: { eventId, clientMutationId: entry.clientMutationId } } }); + expect(stored.outcome).toMatchObject({ state: "already-checked-in" }); + }); + + it("records a status refusal, which a later replay repeats", async () => { + const rsvp = await guest("declined", RsvpStatus.REJECTED); + const entry = { rsvpId: rsvp.id, clientMutationId: `mutation-c-${suffix}`, checkedInAt: minutesAgo(5) }; + const first = await sync([entry]); + expect(first).toMatchObject({ ok: true, data: { syncedIds: [], failed: [{ rsvpId: rsvp.id }] } }); + await prisma.rSVP.update({ where: { id: rsvp.id }, data: { status: RsvpStatus.CONFIRMED } }); + expect(await sync([entry])).toEqual(first); + expect(await checkedIn(rsvp.id)).toBeNull(); + }); + + it("keeps the override gate: a forced offline check-in never admits a pending approval", async () => { + const rsvp = await guest("approval", RsvpStatus.PENDING_APPROVAL); + const result = await sync([{ rsvpId: rsvp.id, clientMutationId: `mutation-d-${suffix}`, checkedInAt: minutesAgo(5), force: true }]); + expect(result).toMatchObject({ ok: true, data: { failed: [{ rsvpId: rsvp.id, error: "Registration is pending approval and cannot be checked in." }] } }); + expect(await checkedIn(rsvp.id)).toBeNull(); + }); + + it("refuses the same mutation id with another registration, time or override", async () => { + const [first, other] = await Promise.all([guest("mismatch-1"), guest("mismatch-2")]); + const entry = { rsvpId: first.id, clientMutationId: `mutation-e-${suffix}`, checkedInAt: minutesAgo(15) }; + expect(await sync([entry])).toMatchObject({ ok: true, data: { syncedIds: [first.id] } }); + for (const changed of [{ ...entry, rsvpId: other.id }, { ...entry, checkedInAt: minutesAgo(14) }, { ...entry, force: true }]) { + const result = await sync([changed]); + expect(result).toMatchObject({ ok: true, data: { syncedIds: [], failed: [{ error: "This offline check-in was already sent with different details." }] } }); + } + expect(await checkedIn(other.id)).toBeNull(); + }); + + it("claims a mutation once when the same retry arrives twice at the same time", async () => { + const rsvp = await guest("concurrent"); + const entry = { rsvpId: rsvp.id, clientMutationId: `mutation-f-${suffix}`, checkedInAt: minutesAgo(3) }; + const results = await Promise.all([sync([entry]), sync([entry])]); + for (const result of results) expect(result).toMatchObject({ ok: true, data: { syncedIds: [rsvp.id] } }); + await expect(prisma.checkInEvent.count({ where: { rsvpId: rsvp.id, action: "CHECKED_IN" } })).resolves.toBe(1); + await expect(prisma.offlineCheckInMutation.count({ where: { eventId, clientMutationId: entry.clientMutationId } })).resolves.toBe(1); + }); + + it("refuses check-ins older than 7 days and keeps records 8 days", async () => { + const rsvp = await guest("old"); + const result = await sync([{ rsvpId: rsvp.id, clientMutationId: `mutation-g-${suffix}`, checkedInAt: minutesAgo(7 * 24 * 60 + 5) }]); + expect(result).toMatchObject({ ok: true, data: { failed: [{ rsvpId: rsvp.id, error: "Offline check-ins older than 7 days are no longer accepted." }] } }); + expect(await checkedIn(rsvp.id)).toBeNull(); + await expect(prisma.offlineCheckInMutation.count({ where: { eventId, clientMutationId: `mutation-g-${suffix}` } })).resolves.toBe(0); + + const kept = await guest("kept"); + const entry = { rsvpId: kept.id, clientMutationId: `mutation-h-${suffix}`, checkedInAt: minutesAgo(6 * 24 * 60) }; + expect(await sync([entry])).toMatchObject({ ok: true, data: { syncedIds: [kept.id] } }); + const key = { eventId_clientMutationId: { eventId, clientMutationId: entry.clientMutationId } }; + await purgeExpiredOperationalData(new Date(Date.now() + 1.5 * 24 * 60 * 60_000)); + expect(await prisma.offlineCheckInMutation.findUnique({ where: key })).not.toBeNull(); + await purgeExpiredOperationalData(new Date(Date.now() + 2.5 * 24 * 60 * 60_000)); + expect(await prisma.offlineCheckInMutation.findUnique({ where: key })).toBeNull(); + }); +}); diff --git a/tests/unit/retention.test.ts b/tests/unit/retention.test.ts index 3b25d816..24e6e260 100644 --- a/tests/unit/retention.test.ts +++ b/tests/unit/retention.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ mobileSessions: vi.fn(), idempotencyKeys: vi.fn(), applicationEvents: vi.fn(), + offlineCheckIns: vi.fn(), crewPhotos: vi.fn(), })); @@ -32,6 +33,7 @@ vi.mock("@/lib/db", () => ({ prisma: { mobileSession: { deleteMany: mocks.mobileSessions }, mobileIdempotencyKey: { deleteMany: mocks.idempotencyKeys }, admissionStatusEvent: { deleteMany: mocks.applicationEvents }, + offlineCheckInMutation: { deleteMany: mocks.offlineCheckIns }, } })); import { purgeExpiredOperationalData } from "@/lib/retention"; @@ -42,7 +44,7 @@ describe("operational retention", () => { beforeEach(() => { vi.clearAllMocks(); process.env.OUTBOX_RETENTION_DAYS = "30"; - for (const mock of [mocks.verification, mocks.session, mocks.orgInvite, mocks.collaboratorInvite, mocks.undo, mocks.outbox, mocks.webhooks, mocks.webhookDeliveries, mocks.mobileSessions, mocks.idempotencyKeys, mocks.applicationEvents]) { + for (const mock of [mocks.verification, mocks.session, mocks.orgInvite, mocks.collaboratorInvite, mocks.undo, mocks.outbox, mocks.webhooks, mocks.webhookDeliveries, mocks.mobileSessions, mocks.idempotencyKeys, mocks.applicationEvents, mocks.offlineCheckIns]) { mock.mockResolvedValue({ count: 1 }); } mocks.crewPhotos.mockResolvedValue(2); @@ -69,6 +71,7 @@ describe("operational retention", () => { mobileSessions: 1, idempotencyKeys: 1, applicationStatusEvents: 1, + offlineCheckInRecords: 1, unusedCrewPhotos: 2, }); expect(mocks.crewPhotos).toHaveBeenCalledWith(now); @@ -78,6 +81,8 @@ describe("operational retention", () => { expect(mocks.outbox).toHaveBeenNthCalledWith(2, { where: { status: OutboxStatus.FAILED, createdAt: { lt: new Date("2030-01-30T00:00:00.000Z") } } }); expect(mocks.webhooks).toHaveBeenCalledWith({ where: { processedAt: { lt: new Date("2029-12-01T00:00:00.000Z") } } }); expect(mocks.applicationEvents).toHaveBeenCalledWith({ where: { emittedAt: { not: null, lt: new Date("2030-01-30T00:00:00.000Z") } } }); + // Eight days: one past the seven-day acceptance window for offline check-ins. + expect(mocks.offlineCheckIns).toHaveBeenCalledWith({ where: { checkedInAt: { lt: new Date("2030-02-21T00:00:00.000Z") } } }); expect(mocks.webhookDeliveries).toHaveBeenCalledWith({ where: { createdAt: { lt: new Date("2030-01-30T00:00:00.000Z") }, status: { not: "PROCESSING" } } }); }); }); diff --git a/tests/unit/ticket-check-in-publish.test.ts b/tests/unit/ticket-check-in-publish.test.ts index b97779fe..24c5a2af 100644 --- a/tests/unit/ticket-check-in-publish.test.ts +++ b/tests/unit/ticket-check-in-publish.test.ts @@ -83,6 +83,10 @@ beforeEach(() => { mocks.transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => callback({ rSVP: { updateMany: mocks.updateMany, findUnique: mocks.rsvpFindUnique }, checkInEvent: { create: mocks.checkInEventCreate }, + // Offline sync: a fresh claim of the mutation id, the row lock, and the stored outcome. + $executeRaw: vi.fn().mockResolvedValue(1), + $queryRaw: vi.fn().mockResolvedValue([]), + offlineCheckInMutation: { update: vi.fn().mockResolvedValue({}), findUnique: vi.fn() }, })); }); @@ -144,7 +148,7 @@ describe("check-in paths publish only on transition", () => { it("offline sync replay", async () => { const checkedInAt = new Date(Date.now() - 5 * 60_000).toISOString(); const input = { organisationSlug: "org", eventId: "event_1", checkIns: [{ rsvpId: "rsvp_1", clientMutationId: "mutation-1", checkedInAt }] }; - transition(true); + transition(true, { checkedInAt: null, status: "CONFIRMED" }); const result = await syncOfflineCheckIns(input); expect(result).toMatchObject({ ok: true, data: { syncedIds: ["rsvp_1"] } }); expect(mocks.publish).toHaveBeenCalledExactlyOnceWith({ rsvpId: "rsvp_1", checkedInAt: new Date(checkedInAt) }); From c7ab80935691a43ca7422eb5984ff14908bccb80 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 19:19:13 +0530 Subject: [PATCH 32/79] fix(judging): late entry fees keep the project unsubmitted once results are published Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- docs/HACKATHON.md | 3 +++ lib/payments/entryFees.ts | 10 ++++++---- .../hackathon-email-toggles-fees.db.test.ts | 10 ++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/HACKATHON.md b/docs/HACKATHON.md index 5fcfd910..47d73fbe 100644 --- a/docs/HACKATHON.md +++ b/docs/HACKATHON.md @@ -128,6 +128,9 @@ saving, submitting and withdrawing are refused ("Results are published, so projects can no longer change.") and the editor is read-only (`resultsFreezeSubmissions` in lib/core/judging/shared.ts, read under the Event lock that publishing takes for update). Unpublishing lifts the freeze. +An entry fee whose payment arrives after publication is kept and its order +confirmed, but the waiting draft is not submitted (`confirmEntryFeeTx`, +lib/payments/entryFees.ts); the organiser decides what to do with it. The editor's window line says when submissions open or close, that they are closed, or, if the dates are missing, that the organisers haven't set them. A team that is disbanded or a solo RSVP that is cancelled has its submission diff --git a/lib/payments/entryFees.ts b/lib/payments/entryFees.ts index 44f748bf..bfe357a7 100644 --- a/lib/payments/entryFees.ts +++ b/lib/payments/entryFees.ts @@ -7,6 +7,7 @@ import { shouldQueueSubmissionReceipt } from "@/lib/hackathonEmails"; import { enqueuePaymentEmail } from "./email"; import { createPendingRefund } from "./refunds"; import { PaymentOperationError } from "./operations/shared"; +import { resultsFreezeSubmissions } from "@/lib/core/judging/shared"; /** * Hackathon entry fees (docs/HACKATHON.md). Submitting a project on an event @@ -43,14 +44,15 @@ export async function entryFeeOwed(tx: Prisma.TransactionClient, eventId: string * is opt-in per event (`emailSubmissionReceipts`, lib/hackathonEmails.ts), * read in this transaction; the payment email is never gated. A cancelled * event never takes a project in: callers refund the fee (settlement) or - * refuse (settling by hand) first, and this refuses too. + * refuse (settling by hand) first, and this refuses too. Once results are + * published the project is frozen, so a late fee is kept but submits nothing. */ export async function confirmEntryFeeTx(tx: Prisma.TransactionClient, order: { id: string; organisationId: string; submissionId: string; buyerEmail: string; buyerName: string; }, paymentId: string, now: Date, notifyPaid = true) { - const submission = await tx.submission.findUnique({ where: { id: order.submissionId }, select: { event: { select: { status: true } } } }); + const submission = await tx.submission.findUnique({ where: { id: order.submissionId }, select: { event: { select: { status: true, resultsPublishedAt: true } } } }); if (submission?.event.status === EventStatus.CANCELLED) throw new PaymentOperationError("This event was cancelled, so the project can't be submitted."); await tx.order.update({ where: { id: order.id }, data: { status: OrderStatus.CONFIRMED, holdExpiresAt: null, holdCapAt: null } }); - // E5: apply results-publication freeze here once fix/audit-admission-mentor merges - const submitted = await tx.submission.updateMany({ + // Published results freeze projects (docs/HACKATHON.md): the fee stays recorded, the project isn't submitted. + const submitted = submission && resultsFreezeSubmissions(submission.event) ? { count: 0 } : await tx.submission.updateMany({ where: { id: order.submissionId, status: { in: [SubmissionStatus.DRAFT, SubmissionStatus.WITHDRAWN] } }, data: { status: SubmissionStatus.SUBMITTED, submittedAt: now }, }); diff --git a/tests/integration/hackathon-email-toggles-fees.db.test.ts b/tests/integration/hackathon-email-toggles-fees.db.test.ts index 62d6f5e1..1a2b2b60 100644 --- a/tests/integration/hackathon-email-toggles-fees.db.test.ts +++ b/tests/integration/hackathon-email-toggles-fees.db.test.ts @@ -104,4 +104,14 @@ describe.sequential("hackathon receipts on the entry-fee path", () => { expect(await disqualifySubmission(organiser, { submissionId: waived.submission.id, reason: "Checking" })).toMatchObject({ ok: true }); expect(await reinstateSubmission(organiser, waived.submission.id)).toMatchObject({ ok: true }); }); + + it("keeps a fee paid after results are published but leaves the project unsubmitted", async () => { + const { event, submission, order } = await feeOwed(true, "frozen"); + await prisma.event.update({ where: { id: event.id }, data: { resultsPublishedAt: new Date() } }); + expect((await payOrder(order.id)).outcome).toBe("confirmed"); + expect(await prisma.order.findUniqueOrThrow({ where: { id: order.id } })).toMatchObject({ status: "CONFIRMED" }); + expect(await prisma.submission.findUniqueOrThrow({ where: { id: submission.id } })).toMatchObject({ status: SubmissionStatus.DRAFT }); + expect(await receipts(submission.id)).toBe(0); + expect(await notices(order.id, "paid")).toBe(1); + }); }); From 926cf79228ef57f3ecfe66554cb5c6d3e223e7fc Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 19:25:57 +0530 Subject: [PATCH 33/79] fix(storage): durable deletion queue with pre-registered uploads for organisation and receipt files Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/membership.ts | 10 +- app/super-admin/operations/page.tsx | 11 +- content/help/index.generated.ts | 2 +- content/help/organisations/settings.mdx | 2 +- docs/CONFIGURATION.md | 19 +- docs/DEPLOYMENT_CLOUD.md | 4 +- docs/DEPLOYMENT_VPS.md | 6 +- docs/SECURITY.md | 1 + docs/production-operations.md | 10 + lib/outboxWorker.ts | 3 + lib/retention.ts | 4 + lib/storage/deletionQueue.ts | 220 ++++++++++++++ lib/storage/index.ts | 60 +--- lib/storage/objects.ts | 92 ++++++ lib/storage/private.ts | 13 +- lib/storage/uploads.ts | 78 +++++ .../app__super-admin__operations__page.ts | 5 +- .../migration.sql | 48 +++ prisma/schema.prisma | 59 ++++ scripts/verify-production-schema.mjs | 20 ++ .../storage-deletion-queue.db.test.ts | 280 ++++++++++++++++++ tests/unit/outbox-worker.test.ts | 2 +- tests/unit/retention.test.ts | 5 + tests/unit/storage-location.test.ts | 37 +++ 24 files changed, 925 insertions(+), 66 deletions(-) create mode 100644 lib/storage/deletionQueue.ts create mode 100644 lib/storage/objects.ts create mode 100644 lib/storage/uploads.ts create mode 100644 prisma/migrations/20261120010100_storage_deletion_queue/migration.sql create mode 100644 tests/integration/storage-deletion-queue.db.test.ts create mode 100644 tests/unit/storage-location.test.ts diff --git a/app/actions/membership.ts b/app/actions/membership.ts index 9213bf83..26a7e93c 100644 --- a/app/actions/membership.ts +++ b/app/actions/membership.ts @@ -22,6 +22,7 @@ import { hasRecentAuthentication } from "@/lib/reauth"; import { EventStatus, OrderStatus } from "@prisma/client"; import { peopleWithPlaces } from "@/lib/payments/eventCancellation"; import { noticeEach, noticeSubject, uniqueEmails } from "@/lib/notices"; +import { queueOrganisationStorage } from "@/lib/storage/deletionQueue"; const revalidatePath = trackedRevalidation(nextRevalidatePath); @@ -199,7 +200,14 @@ export async function deleteOrganisation(input: unknown): Promise targetType: "organisation", targetId: org.id, }); - await prisma.organisation.delete({ where: { id: org.id } }); + // Its stored files are queued in the same transaction (lib/storage/deletionQueue.ts): + // the queue has no foreign key, so it outlives the cascade, and the lock + // orders this against an upload publishing its Asset. + await prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT 1 FROM "Organisation" WHERE "id" = ${org.id} FOR UPDATE`; + await queueOrganisationStorage(tx, org.id); + await tx.organisation.delete({ where: { id: org.id } }); + }, { timeout: 60_000 }); revalidatePath("/dashboard"); revalidatePath(`/${slug}`); diff --git a/app/super-admin/operations/page.tsx b/app/super-admin/operations/page.tsx index a677e038..11a23b4c 100644 --- a/app/super-admin/operations/page.tsx +++ b/app/super-admin/operations/page.tsx @@ -27,12 +27,21 @@ function safeErrorSummary(value: string | null) { export default async function SuperAdminOperationsPage() { await requireSuperAdminMfa(); - const { prerequisites, backupProvider, backupFresh, backupRetentionDays, reportedBackupAt, lastRestoreDrill, outbox, failedCount, expiredTokens, schedulerFresh, schedulerHeartbeat, beatFresh, beatHeartbeat, oldestPending, failedMessages, serverBackup, serverBackupProblem } = await loadSuperAdminOperationsView(); + const { storageDeletions, prerequisites, backupProvider, backupFresh, backupRetentionDays, reportedBackupAt, lastRestoreDrill, outbox, failedCount, expiredTokens, schedulerFresh, schedulerHeartbeat, beatFresh, beatHeartbeat, oldestPending, failedMessages, serverBackup, serverBackupProblem } = await loadSuperAdminOperationsView(); const time = (value: string | null) => (value ? `${new Date(value).toLocaleString()} (${ageLabel(new Date(value))})` : "none yet"); return (
+ {storageDeletions.misplaced + storageDeletions.stillPresent + storageDeletions.failing > 0 ? ( + +
    + {storageDeletions.misplaced > 0 ?
  • {storageDeletions.misplaced} file{storageDeletions.misplaced === 1 ? " belongs" : "s belong"} to a storage bucket that is no longer configured. They are kept and never deleted from the current bucket; restore the earlier storage settings or remove them there by hand.
  • : null} + {storageDeletions.stillPresent > 0 ?
  • {storageDeletions.stillPresent} deleted file{storageDeletions.stillPresent === 1 ? " was" : "s were"} still there when checked twice. The private bucket may have versioning turned on, which keeps deleted files: turn it off.
  • : null} + {storageDeletions.failing > 0 ?
  • {storageDeletions.failing} file{storageDeletions.failing === 1 ? "" : "s"} could not be deleted after several attempts. Check the storage credentials and bucket permissions.
  • : null} +
+
+ ) : null} {serverBackup ? (
diff --git a/content/help/index.generated.ts b/content/help/index.generated.ts index db2aac9b..231150c0 100644 --- a/content/help/index.generated.ts +++ b/content/help/index.generated.ts @@ -6601,7 +6601,7 @@ export const helpIndex: Readonly> = { { "heading": "Delete the organisation", "anchor": "delete-the-organisation", - "text": "Only the owner. Deleting an organisation permanently removes it with all of its events, recurring series, registrations and memberships. It can't be undone. Open Settings and scroll to Delete organisation. Choose Delete organisation…. Type the organisation's exact name to unlock the button. Choose Delete. For security, this is only allowed within 10 minutes of signing in. If you see \"For security, sign in again before deleting an organisation\", sign out, sign back in and try again. What happens to your guests: If anyone has paid for an upcoming event, you can't delete the organisation yet. Cancel those events (or their dates) first, which refunds the buyers. See Cloning, cancelling and deleting. Everyone with a place at an upcoming published event or series date is emailed that it has been cancelled because the organisation closed its account." + "text": "Only the owner. Deleting an organisation permanently removes it with all of its events, recurring series, registrations and memberships. Its stored files (images, ID card photos and expense receipts) are deleted from storage shortly afterwards. It can't be undone. Open Settings and scroll to Delete organisation. Choose Delete organisation…. Type the organisation's exact name to unlock the button. Choose Delete. For security, this is only allowed within 10 minutes of signing in. If you see \"For security, sign in again before deleting an organisation\", sign out, sign back in and try again. What happens to your guests: If anyone has paid for an upcoming event, you can't delete the organisation yet. Cancel those events (or their dates) first, which refunds the buyers. See Cloning, cancelling and deleting. Everyone with a place at an upcoming published event or series date is emailed that it has been cancelled because the organisation closed its account." }, { "heading": "Good to know", diff --git a/content/help/organisations/settings.mdx b/content/help/organisations/settings.mdx index e2145253..8a1ab25d 100644 --- a/content/help/organisations/settings.mdx +++ b/content/help/organisations/settings.mdx @@ -61,7 +61,7 @@ Only the owner. -Deleting an organisation permanently removes it with all of its events, recurring series, registrations and memberships. It can't be undone. +Deleting an organisation permanently removes it with all of its events, recurring series, registrations and memberships. Its stored files (images, ID card photos and expense receipts) are deleted from storage shortly afterwards. It can't be undone. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a846dd90..c467a787 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -119,7 +119,24 @@ Configure SMTP, Google SSO, paid events, backup status, and operational alerts u | `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | Provider-dependent | Configure both, or neither when using a workload/IAM role | | `S3_FORCE_PATH_STYLE` | Optional | Set to `1` for providers that require path-style addressing (`1` for Cloudflare R2 and Silo) | -The bucket must remain private. Existing database-backed assets can be moved with `npm run storage:migrate` after S3 is configured. +The bucket must remain private. Existing database-backed assets can be moved with `npm run storage:migrate` after S3 is configured. That script is an operator-only one-off: do not run it while an organisation is being deleted, because its uploads are not tracked by the deletion queue below. + +### Storage contract and the deletion queue + +Stored files are removed through a durable queue (`StorageDeletion`, worked by the beat every minute; `lib/storage/deletionQueue.ts`): + +- **Every upload is tracked from before its first byte.** An upload records a pending deletion for its fresh key, sends the file (refused once 30 minutes have passed since it was recorded, and aborted then), and removes the pending row only in the transaction that saves its `Asset`. A failed, abandoned or uncertain upload therefore keeps its row, and the beat deletes the file 2 hours after that deadline. Once the beat has claimed a row, that upload can no longer be saved. +- **Deleting an organisation** queues every stored file it owns (images, receipts, unused uploads and every expense receipt key) in the same transaction as the deletion, plus one listing of its `organisations//` prefix for files an earlier release may have left without a record. The queue has no foreign key, so it survives the cascade. User profile images are not touched. +- **Abandoned receipts:** a receipt photo no expense claim refers to after a day is removed by the hourly retention job; its file is queued in the same statement. +- **Deletion is checked.** After a delete succeeds (or finds nothing), the file is checked again 24 hours later; it is deleted again if it is still there, and the queue row goes only once the file is gone. Repeating a delete is harmless; completion is eventual. Failures are retried with back-off and recorded only as short error codes. + +The queue relies on this contract with the storage provider: + +- **No versioning on the private bucket** (S3, Cloudflare R2, Silo/MinIO and any compatible service). A delete on a versioned bucket only adds a delete marker and keeps the bytes. **Super admin → Operations** warns when a deleted file is still there on two checks. +- **One storage location.** `Asset` rows record no bucket, so changing `S3_BUCKET` or `S3_ENDPOINT` while files exist is unsupported unless every object is first copied to the new location (and the deletion queue is empty). Each queued deletion records the location (a hash of the provider, endpoint and bucket) it belongs to; the beat never deletes a row's key in another location. It keeps such rows and **Operations** reports them, so restore the earlier settings or delete those files there by hand. +- **Residual risk (accepted):** an upload that the storage service applies more than 26 hours after the application aborted it would not be removed. No supported provider behaves this way. + +Release requirement: see [the storage queue rollout](production-operations.md#storage-deletion-queue-rollout); every instance of an older release must be stopped within an hour of the `20261120010100_storage_deletion_queue` migration. ## Operational metadata diff --git a/docs/DEPLOYMENT_CLOUD.md b/docs/DEPLOYMENT_CLOUD.md index 0e7c8939..83e46e82 100644 --- a/docs/DEPLOYMENT_CLOUD.md +++ b/docs/DEPLOYMENT_CLOUD.md @@ -76,7 +76,9 @@ month. 1. In the Cloudflare dashboard open **R2 Object Storage** and create a bucket, e.g. `spaceworks-events`. Leave **Public access** disabled and do not attach a custom domain: the app serves every image through its own access-checked - route. + route. Don't turn on object versioning or keep deleted objects: the app's + deletions must really remove files + ([storage contract](CONFIGURATION.md#storage-contract-and-the-deletion-queue)). 2. **R2 → Manage API tokens → Create API token**: permission **Object Read & Write**, **Apply to specific buckets only** → your bucket. Copy the **Access Key ID**, **Secret Access Key** and the **S3 endpoint** diff --git a/docs/DEPLOYMENT_VPS.md b/docs/DEPLOYMENT_VPS.md index ef8f33ad..25e720cf 100644 --- a/docs/DEPLOYMENT_VPS.md +++ b/docs/DEPLOYMENT_VPS.md @@ -146,8 +146,10 @@ region (`auto` for R2), bucket, access key and secret (hidden). The application reads and writes the bucket itself; browsers never talk to it, so no CORS is needed. With external storage (`SWE_STORAGE='external'`) the Silo services do not run and `vps-backup.sh` copies the database and configuration only: back -the bucket up with your provider. To change the bucket later, edit `S3_*` in -`app.env` and recreate the app (`scripts/vps-compose.sh up -d`). +the bucket up with your provider. Leave versioning off on the bucket: deleted files +must really go ([storage contract](CONFIGURATION.md#storage-contract-and-the-deletion-queue)). +To change the bucket later, copy every object to the new bucket first, then edit +`S3_*` in `app.env` and recreate the app (`scripts/vps-compose.sh up -d`). ### Automatic deploys diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 16e4b44b..e3ef52a2 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -93,6 +93,7 @@ This page documents implemented controls and security boundaries. It is not a su ## Event finance - Expense claim receipts are re-encoded WebP photos in private storage (`organisations/{id}/claim-receipts/`, enforced by a CHECK on `ExpenseReceipt`), never served by `/api/uploads`; `/api/claims/receipts/:id` serves them only to the organisation's owners and admins and the claim's own author, with an empty 404 otherwise. +- Stored files do not outlive their records. Every object-storage upload is registered for deletion before its first byte and released only when its `Asset` is saved; deleting an organisation queues all of its files (receipts included) in the same transaction, and receipt photos no claim refers to are removed after a day. The queue (`StorageDeletion`) has no foreign key, records only keys, ids and short error codes, deletes only in the storage location each row was recorded for, and keeps a row until a later check finds the file gone ([CONFIGURATION.md](CONFIGURATION.md#storage-contract-and-the-deletion-queue)). - Deciding and paying claims, the budget, refund requests and the finance CSV are owner/admin only; members and co-organisers file and withdraw only their own claims. Participant claims need a confirmed registration, the event's switch, and the account behind an account-owned ticket; the per-person cap is checked under the event row lock. - `ClaimPayout` and `PrizePayout` are append-only ledgers guarded by database triggers: rows are never deleted except with their organisation, only a reference may be cleared when its target is deleted, and a reversal must match the payout it undoes (same organisation, amount and currency, never a reversal of a reversal). - A refund request is claimed before its refund runs (and released if the refund is refused), so concurrent approvals never refund twice; the refund itself is the normal `refundOrder` with its locks and limits. diff --git a/docs/production-operations.md b/docs/production-operations.md index a0ec4f3a..1d55263c 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -162,6 +162,7 @@ How to read the lock notes. Prisma runs each migration in one transaction withou | `20261119000000_judging_counted_reviews_recusal` | Nullable `JudgeAssignment.recusedAt` (judge self-recusal); nullable `JudgingRound.countedFrozenAt` with a CHECK that it is set only on a finalised round (a scan of `JudgingRound`); new `JudgingRoundCountedReview` table (the reviews a finalised round counted) cascading on `JudgingRound`. No backfill: rounds finalised earlier keep counting live | Safe | | `20261119030000_admission_audit_fixes` | `rsvp_admission_crew_detached` trigger (`BEFORE UPDATE OF "isCrew" ON "RSVP"`, a brief SHARE ROW EXCLUSIVE lock, no scan): a registration becomes crew only once its application is detached; drops `ReimbursementPolicy.requireAccepted` (ADMISSION_STAGE policies always need an accepted application) and re-adds the stage-shape CHECK without it (a scan of the small `ReimbursementPolicy` table); `AdmissionStage.freshEyes` (default `false`) | Safe from 0.10.0: `requireAccepted` arrived in the same release, so no released build reads it | | `20261120010000_utc_trigger_timestamps` | Replaces the trigger functions `rsvp_waitlist_dates`, `write_security_audit_fallback` and `write_api_security_audit_fallback` so the times they write are UTC whatever the session time zone (`CURRENT_TIMESTAMP` was stored as the session's local time). Function bodies only: no table lock, scan or backfill; rows written earlier by a database whose time zone is not UTC keep their shifted times | Safe | +| `20261120010100_storage_deletion_queue` | New enums and the `StorageDeletion` table (the stored-file deletion queue, no foreign keys) and the single-row `StorageProtocol` table holding the time this migration ran. New tables only: no lock on or scan of an existing table, no backfill | **Stop every instance of the previous release within an hour and never run it against this database again** (see [Storage deletion queue rollout](#storage-deletion-queue-rollout)) | | `20261120010200_offline_check_in_mutations` | New `OfflineCheckInMutation` table (one row per synced offline check-in: event, client mutation id, registration, time, override and stored outcome; unique per event and id; cascades with the `Event`). No scan or backfill | Safe: instances of the previous release keep syncing without replay records until they are replaced | Release 0.11.0 reserves the organisation addresses `/attend`, `/crew`, `/help`, `/submit` and `/team` (and `/order` and `/verify-mfa`, which 0.10.0 already served): the pre-migration check and `npm run db:verify` fail if an organisation already uses one (see [Reserved organisation addresses](#reserved-organisation-addresses)). Rename it before upgrading. @@ -170,6 +171,15 @@ Release 0.11.0 reserves the organisation addresses `/attend`, `/crew`, `/help`, **Upgrade note (admission answer revisions, `20261117040000_admission_hardening`):** that migration backfills the new `AdmissionAnswerRevision."personKey"` (`UPDATE "AdmissionAnswerRevision" v SET "personKey" = … FROM "RSVP" r WHERE v."rsvpId" = r."id" AND v."personKey" IS NULL`), but the append-only trigger `admission_answer_revision_append_only` installed by `20261117030000_admission_retention` allows only `rsvpId` to change, so the backfill fails with SQLSTATE `23514` (`AdmissionAnswerRevision rows are append-only`) when the table already holds a revision whose registration still exists. Only databases that ran the admission migrations one at a time are affected: a development or staging database that applied `20261117010000_hackathon_admission` through `20261117030000_admission_retention` and then created answer revisions (applications with edited answers) before `20261117040000` ran. A fresh install and an upgrade from 0.10.0 apply all of them in one `npm run db:deploy` with an empty table, and are not affected. The shipped migrations stay as they are (their checksums are recorded in every database that applied them). Prisma rolls the failed migration back as a whole, so nothing is half-applied. To recover a disposable database, reset it (`npx prisma migrate reset` against that database only; it deletes all data) and deploy again. Never mark the migration applied with `prisma migrate resolve --applied`: its backfill, functions and triggers would be missing. For a database whose data must be kept, mark the failed attempt rolled back (`npx prisma migrate resolve --rolled-back 20261117040000_admission_hardening`), then run `ALTER TABLE "AdmissionAnswerRevision" DISABLE TRIGGER "admission_answer_revision_append_only";`, `npm run db:deploy`, and `ALTER TABLE "AdmissionAnswerRevision" ENABLE TRIGGER "admission_answer_revision_append_only";` straight after, with no application instance running in between. +#### Storage deletion queue rollout + +`20261120010100_storage_deletion_queue` starts the stored-file deletion queue ([Object storage](CONFIGURATION.md#storage-contract-and-the-deletion-queue)): from this release on, every upload records a pending deletion before it sends a byte. Instances of an older release upload without that record, so their files can be missed when an organisation is deleted. This is a **release requirement**: + +1. Stop every instance of the previous release (web, worker and beat; this aborts their uploads in progress) **within one hour** of the migration. Docker Compose, the Helm rolling update, Render and the VPS updater all finish replacing instances within minutes; a canary or blue-green setup must not keep the old release serving longer. +2. **Never run an older image against this database again**, including for a rollback. Roll back by restoring the pre-migration backup instead. + +The migration records when it ran (`StorageProtocol.enabledAt`). The listing of a deleted organisation's legacy prefix starts no earlier than 27 hours after that (the one-hour drain plus the 26-hour late-write bound), so an old upload still in flight during the rollout has settled before the listing looks for it. + #### Lock checklist On a quiet database the whole upgrade from 0.10.0 takes well under a second. On a large or busy one: diff --git a/lib/outboxWorker.ts b/lib/outboxWorker.ts index f22436ee..9d0ad520 100644 --- a/lib/outboxWorker.ts +++ b/lib/outboxWorker.ts @@ -11,6 +11,7 @@ import { recordHeartbeatSuccess, } from "@/lib/operationalHeartbeat"; import { purgeExpiredOperationalData } from "@/lib/retention"; +import { processStorageDeletions } from "@/lib/storage/deletionQueue"; import { runPaymentMaintenance } from "@/lib/payments/maintenance"; import { fillWaitlists } from "@/lib/payments/operations"; import { runAdmissionMaintenance } from "@/lib/admission/maintenance"; @@ -69,6 +70,8 @@ export const SCHEDULED_JOBS: Job[] = [ // Reimbursement policies with the email on: queue "you can claim" once per person (row lock per policy, unique notice rows). { name: "reimbursement-notices", everyMs: 5 * 60_000, run: (now) => queueReimbursementNotices(now) }, { name: "backup-alerts", everyMs: 5 * 60_000, run: () => alertOnBackupProblems() }, + // Stored files of failed uploads and deleted organisations (row claims, SKIP LOCKED; repeated deletes are harmless). + { name: "storage-deletions", everyMs: 60_000, run: (now) => processStorageDeletions(now) }, { name: "retention", everyMs: 60 * 60_000, run: () => purgeExpiredOperationalData() }, ]; diff --git a/lib/retention.ts b/lib/retention.ts index 6a729e28..7aeeb03c 100644 --- a/lib/retention.ts +++ b/lib/retention.ts @@ -3,6 +3,7 @@ import "server-only"; import { OutboxStatus, WebhookDeliveryStatus } from "@prisma/client"; import { prisma } from "@/lib/db"; import { purgeUnusedCrewPhotos } from "@/lib/core/crew/photos"; +import { purgeUnattachedReceipts } from "@/lib/storage/deletionQueue"; const DAY_MS = 24 * 60 * 60 * 1000; const WEBHOOK_EVENT_RETENTION_DAYS = 90; @@ -51,6 +52,8 @@ export async function purgeExpiredOperationalData(now = new Date()) { ]); // Crew photos nobody saved within a day (docs/CREW.md); objects too, so outside the transaction. const unusedCrewPhotos = await purgeUnusedCrewPhotos(now); + // Receipt photos no claim refers to after a day; their objects are queued for deletion. + const unattachedReceipts = await purgeUnattachedReceipts(now); return { verificationTokens: tokens.count, expiredSessions: sessions.count, @@ -66,5 +69,6 @@ export async function purgeExpiredOperationalData(now = new Date()) { applicationStatusEvents: applicationEvents.count, offlineCheckInRecords: offlineCheckIns.count, unusedCrewPhotos, + unattachedReceipts, }; } diff --git a/lib/storage/deletionQueue.ts b/lib/storage/deletionQueue.ts new file mode 100644 index 00000000..bb4bc067 --- /dev/null +++ b/lib/storage/deletionQueue.ts @@ -0,0 +1,220 @@ +import "server-only"; + +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { sqlUtcTimestamp, SQL_UTC_NOW } from "@/lib/sqlTimestamp"; +import { currentStorageLocation, deleteObjects, listObjectsPage, objectExists } from "./objects"; + +/* + * The storage deletion queue's worker and producers (StorageDeletion in + * prisma/schema.prisma; docs/CONFIGURATION.md "Object storage"). Every step is + * safe to repeat: deleting an object twice is harmless, and a row goes only + * after a later HEAD finds its object gone. + */ + +const BATCH = 100; +/** A claimed row is retried after this if its worker never finished. */ +const LEASE_MS = 10 * 60_000; +/** How long after a DELETE the object is checked again. */ +export const VERIFY_AFTER_MS = 24 * 60 * 60_000; +/** Prefix listings wait this long after the protocol epoch: the old-writer drain (1 h) plus the late-write bound (26 h). */ +export const PREFIX_DELAY_MS = 27 * 60 * 60_000; +/** Receipts no claim refers to are removed after a day. */ +export const UNATTACHED_RECEIPT_MS = 24 * 60 * 60_000; + +type Tx = Prisma.TransactionClient; +type Row = { id: string; kind: "OBJECT" | "PREFIX"; state: "PENDING" | "CLAIMED" | "VERIFYING"; location: string; key: string; organisationId: string | null; continuationToken: string | null; attempts: number; lastErrorCode: string | null }; + +/** + * Inside the organisation-deletion transaction, with the organisation row + * locked: queue every object-storage Asset of the organisation (images, + * receipts, unused uploads), every expense receipt key, and one listing of its + * legacy prefix. An upload still in progress keeps its own row (deadline, + * state and fencing) because inserts never change an existing row. + */ +export async function queueOrganisationStorage(tx: Tx, organisationId: string, now = new Date()) { + const location = currentStorageLocation(); + if (!location) return; + const at = sqlUtcTimestamp(now); + await tx.$executeRaw` + INSERT INTO "StorageDeletion" ("id", "kind", "location", "key", "organisationId", "reason", "notBefore", "createdAt") + SELECT gen_random_uuid()::text, 'OBJECT', ${location}, a."key", ${organisationId}, 'ORG_DELETED', ${at}, ${SQL_UTC_NOW} + FROM "Asset" a WHERE a."organisationId" = ${organisationId} AND a."provider" = 's3' + ON CONFLICT ("location", "key", "kind") DO NOTHING`; + await tx.$executeRaw` + INSERT INTO "StorageDeletion" ("id", "kind", "location", "key", "organisationId", "reason", "notBefore", "createdAt") + SELECT gen_random_uuid()::text, 'OBJECT', ${location}, r."storageKey", ${organisationId}, 'ORG_DELETED', ${at}, ${SQL_UTC_NOW} + FROM "ExpenseReceipt" r JOIN "ExpenseClaim" c ON c."id" = r."claimId" + WHERE c."organisationId" = ${organisationId} + AND NOT EXISTS (SELECT 1 FROM "Asset" a WHERE a."key" = r."storageKey" AND a."provider" <> 's3') + ON CONFLICT ("location", "key", "kind") DO NOTHING`; + // Objects an earlier release may have left without an Asset row. Listed no + // earlier than the old-writer drain plus the late-write bound. + await tx.$executeRaw` + INSERT INTO "StorageDeletion" ("id", "kind", "location", "key", "organisationId", "reason", "notBefore", "createdAt") + VALUES (gen_random_uuid()::text, 'PREFIX', ${location}, ${`organisations/${organisationId}/`}, ${organisationId}, 'LEGACY_PREFIX', + GREATEST(${at}, coalesce((SELECT "enabledAt" FROM "StorageProtocol" WHERE "id" = 1), ${at}) + ${Prisma.raw(`interval '${PREFIX_DELAY_MS / 1000} seconds'`)}), + ${SQL_UTC_NOW}) + ON CONFLICT ("location", "key", "kind") DO NOTHING`; +} + +/** + * Receipt photos no expense claim refers to, older than a day (an upload + * whose claim was never saved): the Asset goes and, in the same statement, its + * object is queued. Referenced receipts are never touched. + */ +export async function purgeUnattachedReceipts(now = new Date(), limit = 500) { + const location = currentStorageLocation(); + const before = sqlUtcTimestamp(new Date(now.getTime() - UNATTACHED_RECEIPT_MS)); + const rows = await prisma.$queryRaw>` + WITH gone AS ( + DELETE FROM "Asset" a WHERE a."id" IN ( + SELECT x."id" FROM "Asset" x + WHERE x."key" LIKE 'organisations/%/claim-receipts/%' AND x."createdAt" < ${before} + AND NOT EXISTS (SELECT 1 FROM "ExpenseReceipt" r WHERE r."storageKey" = x."key") + AND (${location}::text IS NOT NULL OR x."provider" <> 's3') + ORDER BY x."createdAt" LIMIT ${limit} + ) + RETURNING a."key", a."organisationId", a."provider" + ), queued AS ( + INSERT INTO "StorageDeletion" ("id", "kind", "location", "key", "organisationId", "reason", "notBefore", "createdAt") + SELECT gen_random_uuid()::text, 'OBJECT', ${location}, g."key", g."organisationId", 'UNATTACHED', ${sqlUtcTimestamp(now)}, ${SQL_UTC_NOW} + FROM gone g WHERE g."provider" = 's3' + ON CONFLICT ("location", "key", "kind") DO NOTHING + ) + SELECT count(*)::int AS "removed" FROM gone`; + return rows[0]?.removed ?? 0; +} + +/** A short, safe code for an error: never a message, key or URL. */ +function errorCode(error: unknown) { + const name = (error as { Code?: unknown; name?: unknown })?.Code ?? (error as { name?: unknown })?.name; + return typeof name === "string" && /^[A-Za-z][A-Za-z0-9]{0,62}$/.test(name) ? name : "Error"; +} + +function backoff(attempts: number) { + return Math.min(2 ** Math.min(attempts, 9) * 60_000, 6 * 60 * 60_000); +} + +async function retryLater(row: Row, now: Date, code: string) { + await prisma.$executeRaw` + UPDATE "StorageDeletion" SET "notBefore" = ${sqlUtcTimestamp(new Date(now.getTime() + backoff(row.attempts)))}, "lastErrorCode" = ${code} + WHERE "id" = ${row.id}`; +} + +/** + * Claim due rows in one short statement before any request to storage: a + * PENDING upload row becomes CLAIMED and can never be published, and each + * claimed row is leased so a crashed worker's rows come back later. + */ +async function claimDue(now: Date) { + return prisma.$queryRaw` + UPDATE "StorageDeletion" d + SET "state" = CASE WHEN d."state" = 'PENDING' THEN 'CLAIMED'::"StorageDeletionState" ELSE d."state" END, + "notBefore" = ${sqlUtcTimestamp(new Date(now.getTime() + LEASE_MS))}, + "attempts" = d."attempts" + 1 + WHERE d."id" IN ( + SELECT "id" FROM "StorageDeletion" WHERE "notBefore" <= ${sqlUtcTimestamp(now)} + ORDER BY "notBefore" LIMIT ${BATCH} FOR UPDATE SKIP LOCKED + ) + RETURNING d."id", d."kind"::text AS "kind", d."state"::text AS "state", d."location", d."key", d."organisationId", + d."continuationToken", d."attempts", d."lastErrorCode"`; +} + +async function deleteClaimed(rows: Row[], now: Date) { + if (rows.length === 0) return 0; + let failures: Map; + try { + failures = await deleteObjects(rows.map((row) => row.key)); + } catch (error) { + for (const row of rows) await retryLater(row, now, errorCode(error)); + return 0; + } + const verifyAt = sqlUtcTimestamp(new Date(now.getTime() + VERIFY_AFTER_MS)); + for (const row of rows) { + const failure = failures.get(row.key); + if (failure) await retryLater(row, now, errorCode({ Code: failure })); + else await prisma.$executeRaw` + UPDATE "StorageDeletion" SET "state" = 'VERIFYING', "notBefore" = ${verifyAt}, "lastErrorCode" = NULL + WHERE "id" = ${row.id} AND "state" = 'CLAIMED'`; + } + return rows.length - failures.size; +} + +async function verify(row: Row, now: Date) { + if (!(await objectExists(row.key))) { + await prisma.$executeRaw`DELETE FROM "StorageDeletion" WHERE "id" = ${row.id} AND "state" = 'VERIFYING'`; + return true; + } + // Still there: a late write, or a versioned bucket keeping it. Delete again and look again later. + const failure = (await deleteObjects([row.key])).get(row.key); + const code = failure ? errorCode({ Code: failure }) : row.lastErrorCode?.startsWith("STILL_PRESENT") ? "STILL_PRESENT_REPEATED" : "STILL_PRESENT"; + await prisma.$executeRaw` + UPDATE "StorageDeletion" SET "notBefore" = ${sqlUtcTimestamp(new Date(now.getTime() + VERIFY_AFTER_MS))}, "lastErrorCode" = ${code} + WHERE "id" = ${row.id}`; + return false; +} + +/** One page of a prefix listing: its keys queued and the next token saved together. */ +async function listPrefixPage(row: Row, now: Date) { + const page = await listObjectsPage(row.key, row.continuationToken); + await prisma.$transaction(async (tx) => { + if (page.keys.length > 0) { + await tx.$executeRaw` + INSERT INTO "StorageDeletion" ("id", "kind", "location", "key", "organisationId", "reason", "notBefore", "createdAt") + SELECT gen_random_uuid()::text, 'OBJECT', ${row.location}, k, ${row.organisationId}, 'LEGACY_PREFIX', ${sqlUtcTimestamp(now)}, ${SQL_UTC_NOW} + FROM unnest(${page.keys}::text[]) AS k + ON CONFLICT ("location", "key", "kind") DO NOTHING`; + } + // The listing is done; the objects it found finish on their own rows. + if (page.next === null) await tx.$executeRaw`DELETE FROM "StorageDeletion" WHERE "id" = ${row.id}`; + else await tx.$executeRaw` + UPDATE "StorageDeletion" SET "continuationToken" = ${page.next}, "notBefore" = ${sqlUtcTimestamp(now)}, "lastErrorCode" = NULL + WHERE "id" = ${row.id}`; + }); + return page.keys.length; +} + +/** The beat job: one batch of due deletions, verifications and listing pages. */ +export async function processStorageDeletions(now = new Date()) { + const result = { deleted: 0, verified: 0, listed: 0, failed: 0, misplaced: 0 }; + const location = currentStorageLocation(); + if (!location) return result; + const rows = await claimDue(now); + const toDelete: Row[] = []; + for (const row of rows) { + if (row.location !== location) { + // Never deleted against another bucket: kept and shown on the Operations page. + result.misplaced += 1; + await prisma.$executeRaw` + UPDATE "StorageDeletion" SET "notBefore" = ${sqlUtcTimestamp(new Date(now.getTime() + VERIFY_AFTER_MS))}, "lastErrorCode" = 'LOCATION_MISMATCH' + WHERE "id" = ${row.id}`; + } else if (row.kind === "OBJECT" && row.state === "CLAIMED") { + toDelete.push(row); + } else { + try { + if (row.kind === "PREFIX") result.listed += await listPrefixPage(row, now); + else if (await verify(row, now)) result.verified += 1; + } catch (error) { + result.failed += 1; + await retryLater(row, now, errorCode(error)); + } + } + } + const deleted = await deleteClaimed(toDelete, now); + result.deleted += deleted; + result.failed += toDelete.length - deleted; + return result; +} + +/** What the Operations page warns about: rows for another storage location, objects that keep coming back, and repeated failures. */ +export async function storageDeletionProblems() { + const [row] = await prisma.$queryRaw>` + SELECT count(*) FILTER (WHERE "lastErrorCode" = 'LOCATION_MISMATCH')::int AS "misplaced", + count(*) FILTER (WHERE "lastErrorCode" = 'STILL_PRESENT_REPEATED')::int AS "stillPresent", + count(*) FILTER (WHERE "attempts" >= 5 AND "lastErrorCode" IS NOT NULL + AND "lastErrorCode" NOT IN ('LOCATION_MISMATCH', 'STILL_PRESENT', 'STILL_PRESENT_REPEATED'))::int AS "failing", + count(*) FILTER (WHERE "reason" <> 'UPLOAD_PENDING')::int AS "pending" + FROM "StorageDeletion"`; + return row ?? { misplaced: 0, stillPresent: 0, failing: 0, pending: 0 }; +} diff --git a/lib/storage/index.ts b/lib/storage/index.ts index 3b8be61a..7dbac6f2 100644 --- a/lib/storage/index.ts +++ b/lib/storage/index.ts @@ -1,12 +1,11 @@ import "server-only"; -import { - DeleteObjectsCommand, - GetObjectCommand, - PutObjectCommand, - S3Client, -} from "@aws-sdk/client-s3"; +import { DeleteObjectsCommand, GetObjectCommand } from "@aws-sdk/client-s3"; import { prisma } from "@/lib/db"; +import { client, storageConfigured } from "./objects"; +import { uploadObject } from "./uploads"; + +export { client, storageConfigured }; export type UploadInput = { key: string; @@ -25,23 +24,6 @@ export function isPublicDerivativeKey(key: string) { return ORGANISATION_DERIVATIVE_KEY.test(key) || USER_DERIVATIVE_KEY.test(key); } -export function storageConfigured() { - return Boolean(process.env.S3_BUCKET && process.env.S3_REGION); -} - -export function client() { - const accessKeyId = process.env.S3_ACCESS_KEY_ID; - const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY; - return new S3Client({ - region: process.env.S3_REGION!, - ...(process.env.S3_ENDPOINT ? { endpoint: process.env.S3_ENDPOINT } : {}), - forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "1", - ...(accessKeyId && secretAccessKey - ? { credentials: { accessKeyId, secretAccessKey } } - : {}), - }); -} - async function bytes(input: UploadInput["body"]) { return input instanceof Blob ? Buffer.from(await input.arrayBuffer()) @@ -60,31 +42,13 @@ export async function uploadFile(input: UploadInput): Promise<{ key: string }> { } const uploadBody = await bytes(input.body); if (storageConfigured()) { - await client().send( - new PutObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: input.key, - Body: uploadBody, - ContentType: input.contentType || "application/octet-stream", - CacheControl: "public, max-age=31536000, immutable", - }), - ); - await prisma.asset.upsert({ - where: { key: input.key }, - create: { - key: input.key, - contentType: input.contentType || "application/octet-stream", - byteSize: BigInt(uploadBody.length), - provider: "s3", - organisationId: input.organisationId || null, - }, - update: { - contentType: input.contentType || "application/octet-stream", - byteSize: BigInt(uploadBody.length), - provider: "s3", - fileData: null, - organisationId: input.organisationId || null, - }, + // Tracked from before the PUT until the Asset exists (lib/storage/uploads.ts). + await uploadObject({ + key: input.key, + organisationId: input.organisationId || null, + body: uploadBody, + contentType: input.contentType, + cacheControl: "public, max-age=31536000, immutable", }); return { key: input.key }; } diff --git a/lib/storage/objects.ts b/lib/storage/objects.ts new file mode 100644 index 00000000..dd220c84 --- /dev/null +++ b/lib/storage/objects.ts @@ -0,0 +1,92 @@ +import "server-only"; + +import { createHash } from "node:crypto"; +import { + DeleteObjectsCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; + +/* + * The object-storage calls the upload protocol and the deletion queue make + * (lib/storage/uploads.ts, lib/storage/deletionQueue.ts), against the + * configured private bucket. + */ + +export function storageConfigured() { + return Boolean(process.env.S3_BUCKET && process.env.S3_REGION); +} + +export function client() { + const accessKeyId = process.env.S3_ACCESS_KEY_ID; + const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY; + return new S3Client({ + region: process.env.S3_REGION!, + ...(process.env.S3_ENDPOINT ? { endpoint: process.env.S3_ENDPOINT } : {}), + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "1", + ...(accessKeyId && secretAccessKey + ? { credentials: { accessKeyId, secretAccessKey } } + : {}), + }); +} + +/** + * A stable id for where objects live now: the provider, endpoint and bucket, + * hashed (no credentials). Deletion rows record it, and the worker never + * deletes a row's key in another location. Null without object storage. + */ +export function currentStorageLocation(): string | null { + if (!storageConfigured()) return null; + const endpoint = (process.env.S3_ENDPOINT?.trim() || `aws:${process.env.S3_REGION}`).replace(/\/+$/, "").toLowerCase(); + return `s3:${createHash("sha256").update(`${endpoint}\n${process.env.S3_BUCKET}`).digest("hex").slice(0, 32)}`; +} + +export async function putObject(input: { key: string; body: Buffer; contentType: string; cacheControl: string; abortSignal: AbortSignal }) { + await client().send( + new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: input.key, Body: input.body, ContentType: input.contentType, CacheControl: input.cacheControl }), + { abortSignal: input.abortSignal }, + ); +} + +/** DELETE each key (a missing key counts as deleted). Returns the keys that failed, with the provider's error code. */ +export async function deleteObjects(keys: string[]): Promise> { + const failed = new Map(); + for (let start = 0; start < keys.length; start += 1000) { + const result = await client().send(new DeleteObjectsCommand({ + Bucket: process.env.S3_BUCKET!, + Delete: { Objects: keys.slice(start, start + 1000).map((Key) => ({ Key })), Quiet: true }, + })); + for (const error of result.Errors ?? []) { + if (error.Key && error.Code !== "NoSuchKey") failed.set(error.Key, error.Code ?? "DeleteFailed"); + } + } + return failed; +} + +/** Whether the object exists (a HEAD request). */ +export async function objectExists(key: string): Promise { + try { + await client().send(new HeadObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key })); + return true; + } catch (error) { + const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; + if (status === 404 || (error as Error).name === "NotFound" || (error as Error).name === "NoSuchKey") return false; + throw error; + } +} + +/** One page of keys under a prefix, and where the next page starts (null at the end). */ +export async function listObjectsPage(prefix: string, continuationToken: string | null): Promise<{ keys: string[]; next: string | null }> { + const page = await client().send(new ListObjectsV2Command({ + Bucket: process.env.S3_BUCKET!, + Prefix: prefix, + MaxKeys: 1000, + ...(continuationToken ? { ContinuationToken: continuationToken } : {}), + })); + return { + keys: (page.Contents ?? []).flatMap((object) => (object.Key ? [object.Key] : [])), + next: page.IsTruncated ? page.NextContinuationToken ?? null : null, + }; +} diff --git a/lib/storage/private.ts b/lib/storage/private.ts index 42fea02a..d6281317 100644 --- a/lib/storage/private.ts +++ b/lib/storage/private.ts @@ -1,9 +1,10 @@ import "server-only"; import { randomUUID } from "node:crypto"; -import { DeleteObjectsCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import { DeleteObjectsCommand, GetObjectCommand } from "@aws-sdk/client-s3"; import { prisma } from "@/lib/db"; -import { client, storageConfigured } from "./index"; +import { client, storageConfigured } from "./objects"; +import { uploadObject } from "./uploads"; /** * Private files: expense claim receipts (docs/FINANCE.md). They never go @@ -27,12 +28,8 @@ export async function uploadReceipt(organisationId: string, key: string, body: B if (RECEIPT_KEY.exec(key)?.[1] !== organisationId) throw new Error("Invalid receipt storage key."); const contentType = "image/webp"; if (storageConfigured()) { - await client().send(new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, Body: body, ContentType: contentType, CacheControl: "private, no-store" })); - await prisma.asset.upsert({ - where: { key }, - create: { key, contentType, byteSize: BigInt(body.length), provider: "s3", organisationId }, - update: { contentType, byteSize: BigInt(body.length), provider: "s3", fileData: null, organisationId }, - }); + // Tracked from before the PUT until the Asset exists (lib/storage/uploads.ts). + await uploadObject({ key, organisationId, body, contentType, cacheControl: "private, no-store" }); return; } if (process.env.NODE_ENV === "production") throw new Error("Private object storage is not configured."); diff --git a/lib/storage/uploads.ts b/lib/storage/uploads.ts new file mode 100644 index 00000000..3bc637cb --- /dev/null +++ b/lib/storage/uploads.ts @@ -0,0 +1,78 @@ +import "server-only"; + +import { randomUUID } from "node:crypto"; +import type { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { sqlUtcTimestamp, SQL_UTC_NOW } from "@/lib/sqlTimestamp"; +import { currentStorageLocation, putObject } from "./objects"; + +/* + * Every object-storage upload in three steps (docs/CONFIGURATION.md "Object + * storage"), so no object can outlive a failed, abandoned or uncertain PUT: + * 1. register: a PENDING deletion row for the fresh key, before any byte is + * sent (a key already in use, by a row or an Asset, aborts the attempt); + * 2. put: refused from the deadline on, and aborted when it comes; + * 3. publish: removing our own row while it is still PENDING, and creating + * the Asset, in one transaction. A row the deletion worker has claimed + * can never be published, so the worker and a late publisher cannot both + * win; the Asset's organisation foreign key fails once it is deleted. + * A failure anywhere leaves the row, and the beat deletes the object after + * the deadline plus a grace period (lib/storage/deletionQueue.ts). + */ + +export const UPLOAD_WINDOW_MS = 30 * 60_000; +export const UPLOAD_GRACE_MS = 2 * 60 * 60_000; + +export type RegisteredUpload = { id: string; key: string; deadline: Date }; + +export async function registerUpload(key: string, organisationId: string | null, now = new Date()): Promise { + const location = currentStorageLocation(); + if (!location) throw new Error("Private object storage is not configured."); + const id = randomUUID(); + const deadline = new Date(now.getTime() + UPLOAD_WINDOW_MS); + const notBefore = new Date(deadline.getTime() + UPLOAD_GRACE_MS); + const registered = await prisma.$executeRaw` + INSERT INTO "StorageDeletion" ("id", "kind", "state", "location", "key", "organisationId", "reason", "deadline", "notBefore", "createdAt") + SELECT ${id}, 'OBJECT', 'PENDING', ${location}, ${key}, ${organisationId}, 'UPLOAD_PENDING', ${sqlUtcTimestamp(deadline)}, ${sqlUtcTimestamp(notBefore)}, ${SQL_UTC_NOW} + WHERE NOT EXISTS (SELECT 1 FROM "Asset" WHERE "key" = ${key}) + ON CONFLICT ("location", "key", "kind") DO NOTHING`; + if (registered !== 1) throw new Error("This storage key is already in use."); + return { id, key, deadline }; +} + +export async function putRegistered( + upload: RegisteredUpload, + object: { body: Buffer; contentType: string; cacheControl: string }, + now = new Date(), +) { + const remaining = upload.deadline.getTime() - now.getTime(); + if (remaining <= 0) throw new Error("The upload did not start in time."); + await putObject({ key: upload.key, ...object, abortSignal: AbortSignal.timeout(remaining) }); +} + +export async function publishUpload(upload: RegisteredUpload, asset: Prisma.AssetUncheckedCreateInput) { + await prisma.$transaction(async (tx) => { + const released = await tx.$executeRaw`DELETE FROM "StorageDeletion" WHERE "id" = ${upload.id} AND "state" = 'PENDING'`; + if (released !== 1) throw new Error("The upload expired before it was saved."); + await tx.asset.create({ data: { ...asset, key: upload.key } }); + }); +} + +/** Register, put and publish one object; any failure leaves the object queued for deletion. */ +export async function uploadObject(input: { + key: string; + organisationId: string | null; + body: Buffer; + contentType: string; + cacheControl: string; +}) { + const upload = await registerUpload(input.key, input.organisationId); + await putRegistered(upload, { body: input.body, contentType: input.contentType, cacheControl: input.cacheControl }); + await publishUpload(upload, { + key: input.key, + contentType: input.contentType, + byteSize: BigInt(input.body.length), + provider: "s3", + organisationId: input.organisationId, + }); +} diff --git a/lib/views/app__super-admin__operations__page.ts b/lib/views/app__super-admin__operations__page.ts index 02daa1e4..24e11884 100644 --- a/lib/views/app__super-admin__operations__page.ts +++ b/lib/views/app__super-admin__operations__page.ts @@ -5,6 +5,7 @@ import { env } from "@/lib/env"; import { BEAT_HEARTBEAT_KEY, OUTBOX_SCHEDULER_HEARTBEAT_KEY } from "@/lib/operationalHeartbeat"; import { getEmailSettingsStatus, INSTANCE_SETTINGS_ID, resolveBackupPosture } from "@/lib/instanceSettings"; import { backupProblem, readVpsBackupStatus } from "@/lib/vpsBackupStatus"; +import { storageDeletionProblems } from "@/lib/storage/deletionQueue"; export async function loadSuperAdminOperationsView() { await requireSuperAdminMfa(); const now = new Date(); @@ -65,5 +66,7 @@ export async function loadSuperAdminOperationsView() { // The VPS installer's own backups, when this runs from it (null elsewhere). const serverBackup = await readVpsBackupStatus(); const serverBackupProblem = backupProblem(serverBackup, now); - return { prerequisites, backupProvider, backupFresh, backupRetentionDays, reportedBackupAt, lastRestoreDrill, outbox, failedCount, expiredTokens, schedulerFresh, schedulerHeartbeat, beatFresh, beatHeartbeat, oldestPending, failedMessages, serverBackup, serverBackupProblem }; + // Stored files waiting to be deleted that need a look (docs/CONFIGURATION.md "Object storage"). + const storageDeletions = await storageDeletionProblems(); + return { storageDeletions, prerequisites, backupProvider, backupFresh, backupRetentionDays, reportedBackupAt, lastRestoreDrill, outbox, failedCount, expiredTokens, schedulerFresh, schedulerHeartbeat, beatFresh, beatHeartbeat, oldestPending, failedMessages, serverBackup, serverBackupProblem }; } diff --git a/prisma/migrations/20261120010100_storage_deletion_queue/migration.sql b/prisma/migrations/20261120010100_storage_deletion_queue/migration.sql new file mode 100644 index 00000000..80de5d13 --- /dev/null +++ b/prisma/migrations/20261120010100_storage_deletion_queue/migration.sql @@ -0,0 +1,48 @@ +-- Object-storage deletion queue (docs/CONFIGURATION.md "Object storage", +-- lib/storage/deletionQueue.ts). New tables only: no lock on or scan of an +-- existing table, no backfill. +-- +-- Rollout requirement (docs/production-operations.md): stop every instance of +-- the previous release within an hour of this migration and never run an +-- older image against this database again. Older instances upload without +-- registering a pending deletion first. +CREATE TYPE "StorageDeletionKind" AS ENUM ('OBJECT', 'PREFIX'); +CREATE TYPE "StorageDeletionState" AS ENUM ('PENDING', 'CLAIMED', 'VERIFYING'); +CREATE TYPE "StorageDeletionReason" AS ENUM ('UPLOAD_PENDING', 'ORG_DELETED', 'LEGACY_PREFIX', 'UNATTACHED'); + +CREATE TABLE "StorageDeletion" ( + "id" TEXT NOT NULL, + "kind" "StorageDeletionKind" NOT NULL, + "state" "StorageDeletionState" NOT NULL DEFAULT 'PENDING', + "location" TEXT NOT NULL, + "key" TEXT NOT NULL, + "organisationId" TEXT, + "reason" "StorageDeletionReason" NOT NULL, + "deadline" TIMESTAMP(3), + "notBefore" TIMESTAMP(3) NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastErrorCode" TEXT, + "continuationToken" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StorageDeletion_pkey" PRIMARY KEY ("id"), + -- Only uploads carry a deadline, and only listings a continuation token. + CONSTRAINT "StorageDeletion_deadline_shape" CHECK (("reason" = 'UPLOAD_PENDING') = ("deadline" IS NOT NULL)), + CONSTRAINT "StorageDeletion_token_shape" CHECK ("kind" = 'PREFIX' OR "continuationToken" IS NULL), + CONSTRAINT "StorageDeletion_error_code_length" CHECK (char_length("lastErrorCode") <= 64) +); + +CREATE UNIQUE INDEX "StorageDeletion_location_key_kind_key" ON "StorageDeletion"("location", "key", "kind"); +CREATE INDEX "StorageDeletion_state_notBefore_idx" ON "StorageDeletion"("state", "notBefore"); +CREATE INDEX "StorageDeletion_notBefore_idx" ON "StorageDeletion"("notBefore"); + +-- The protocol epoch: when this database started registering uploads. The +-- value is UTC wall-clock time, like every other timestamp(3) column. +CREATE TABLE "StorageProtocol" ( + "id" INTEGER NOT NULL, + "enabledAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "StorageProtocol_pkey" PRIMARY KEY ("id"), + CONSTRAINT "StorageProtocol_single_row" CHECK ("id" = 1) +); +INSERT INTO "StorageProtocol" ("id", "enabledAt") VALUES (1, (now() AT TIME ZONE 'UTC')::timestamp(3)); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6726a2fe..a67e3a91 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2222,6 +2222,65 @@ model InstanceSetting { /// Tracks stored files (S3/R2/MinIO/etc.) for per-org storage accounting. /// This is optional for runtime today but enables accurate “bytes stored” metrics. +enum StorageDeletionKind { + OBJECT + PREFIX +} + +enum StorageDeletionState { + PENDING + CLAIMED + VERIFYING +} + +enum StorageDeletionReason { + UPLOAD_PENDING + ORG_DELETED + LEGACY_PREFIX + UNATTACHED +} + +/// The object-storage deletion queue (lib/storage/deletionQueue.ts, +/// docs/CONFIGURATION.md "Object storage"). Every object-storage upload +/// registers a PENDING row before its PUT and removes it only when it +/// publishes its Asset, so a failed, abandoned or uncertain PUT is deleted +/// later. Organisation deletion, abandoned receipts and legacy prefixes queue +/// rows too. No foreign key: rows outlive the organisation. The beat claims a +/// due row (CLAIMED, which can never be published) before any DELETE, then +/// re-checks it a day later (VERIFYING) and removes the row only once the +/// object is gone. `location` is the storage location the key belongs to; a +/// row for another location is kept and reported, never deleted elsewhere. +model StorageDeletion { + id String @id @default(cuid()) + kind StorageDeletionKind + state StorageDeletionState @default(PENDING) + location String + key String + organisationId String? + reason StorageDeletionReason + /// Uploads only: the PUT is refused from, and aborted at, this time. + deadline DateTime? + notBefore DateTime + attempts Int @default(0) + lastErrorCode String? + /// PREFIX rows: where the paginated listing continues. + continuationToken String? + createdAt DateTime @default(now()) + + @@unique([location, key, kind]) + @@index([state, notBefore]) + @@index([notBefore]) +} + +/// When this database started tracking every upload (the storage deletion +/// queue's migration). Prefix listings start no earlier than 27 hours after +/// it, past the old-writer drain and the late-write bound +/// (docs/production-operations.md). +model StorageProtocol { + id Int @id + enabledAt DateTime +} + model Asset { id String @id @default(cuid()) organisationId String? diff --git a/scripts/verify-production-schema.mjs b/scripts/verify-production-schema.mjs index d4eef4e1..45ee0853 100644 --- a/scripts/verify-production-schema.mjs +++ b/scripts/verify-production-schema.mjs @@ -204,6 +204,26 @@ try { ...releaseIndexes.filter((name) => !releaseFound.indexes.has(name)), ...releaseTriggers.filter((name) => !releaseFound.triggers.has(name)), ); + // UTC trigger timestamps, the storage deletion queue and offline check-in + // replay records (20261120010000…20261120010200). + const opsTables = ["StorageDeletion", "StorageProtocol", "OfflineCheckInMutation"]; + const opsIndexes = ["StorageDeletion_location_key_kind_key", "StorageDeletion_state_notBefore_idx", "OfflineCheckInMutation_eventId_clientMutationId_key"]; + const utcFunctions = ["rsvp_waitlist_dates", "write_security_audit_fallback", "write_api_security_audit_fallback"]; + const [opsTableRows, opsIndexRows, utcFunctionRows] = await Promise.all([ + prisma.$queryRaw`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ANY(${opsTables}::text[])`, + prisma.$queryRaw`SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexname = ANY(${opsIndexes}::text[])`, + prisma.$queryRaw`SELECT proname FROM pg_proc WHERE proname = ANY(${utcFunctions}::text[]) AND prosrc LIKE '%(CURRENT_TIMESTAMP AT TIME ZONE ''UTC'')::timestamp(3)%'`, + ]); + const opsFound = { tables: present(opsTableRows, "table_name"), indexes: present(opsIndexRows, "indexname"), functions: present(utcFunctionRows, "proname") }; + missing.push( + ...opsTables.filter((name) => !opsFound.tables.has(name)), + ...opsIndexes.filter((name) => !opsFound.indexes.has(name)), + ...utcFunctions.filter((name) => !opsFound.functions.has(name)).map((name) => `${name}:UTC timestamps`), + ); + // The deletion queue's epoch row, written by its migration. + if (opsFound.tables.has("StorageProtocol") && (await prisma.$queryRaw`SELECT 1 FROM "StorageProtocol" WHERE "id" = 1`).length === 0) { + missing.push("StorageProtocol:epoch row"); + } if (missing.length > 0) { throw new Error(`Database schema contract failed; missing: ${missing.join(", ")}`); } diff --git a/tests/integration/storage-deletion-queue.db.test.ts b/tests/integration/storage-deletion-queue.db.test.ts new file mode 100644 index 00000000..ee5d083b --- /dev/null +++ b/tests/integration/storage-deletion-queue.db.test.ts @@ -0,0 +1,280 @@ +import { randomUUID } from "node:crypto"; +import { EventPrivacyType, EventStatus } from "@prisma/client"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +/* + * The storage deletion queue against a fake object store (docs/CONFIGURATION.md + * "Object storage"): uploads are registered before their PUT, a claimed row + * can never be published, deletions are verified a day later, prefix listings + * never override an upload in progress, and rows for another bucket are kept. + */ +const store = vi.hoisted(() => ({ + objects: new Set(), + deleted: [] as string[], + /** The next PUT fails for the caller but lands later (an uncertain write). */ + lostPuts: [] as string[], + failNextPut: false, + /** The next DELETE removes the objects, then the call fails (a crash after the delete). */ + failAfterDelete: false, + pageSize: 1000, + poison: null as string | null, +})); + +vi.mock("@/lib/storage/objects", async (importOriginal) => ({ + ...(await importOriginal()), + putObject: vi.fn(async ({ key }: { key: string }) => { + if (store.failNextPut) { + store.failNextPut = false; + store.lostPuts.push(key); + throw new Error("socket hang up"); + } + store.objects.add(key); + }), + deleteObjects: vi.fn(async (keys: string[]) => { + for (const key of keys) store.objects.delete(key); + store.deleted.push(...keys); + if (store.failAfterDelete) { + store.failAfterDelete = false; + throw Object.assign(new Error("connection reset"), { name: "TimeoutError" }); + } + return new Map(); + }), + objectExists: vi.fn(async (key: string) => store.objects.has(key)), + listObjectsPage: vi.fn(async (prefix: string, token: string | null) => { + const all = [...store.objects].filter((key) => key.startsWith(prefix)).sort(); + if (store.poison?.startsWith(prefix)) all.push(store.poison); + // Like S3, a page continues after the last key returned, whatever was deleted meanwhile. + const rest = token ? all.filter((key) => key > token) : all; + const keys = rest.slice(0, store.pageSize); + return { keys, next: rest.length > store.pageSize ? keys.at(-1)! : null }; + }), +})); + +import { prisma } from "@/lib/db"; +import { uploadFile } from "@/lib/storage"; +import { currentStorageLocation } from "@/lib/storage/objects"; +import { publishUpload, putRegistered, registerUpload, UPLOAD_GRACE_MS } from "@/lib/storage/uploads"; +import { processStorageDeletions, purgeUnattachedReceipts, queueOrganisationStorage, storageDeletionProblems, VERIFY_AFTER_MS } from "@/lib/storage/deletionQueue"; + +const HOUR = 60 * 60_000; +const object = { body: Buffer.from("webp"), contentType: "image/webp", cacheControl: "private, no-store" }; +const later = (from: Date, ms: number) => new Date(from.getTime() + ms); +const coverKey = (organisationId: string) => `organisations/${organisationId}/event-covers/${randomUUID()}.webp`; +const rowFor = (key: string) => prisma.storageDeletion.findFirst({ where: { key, kind: "OBJECT" } }); +let organisationId = ""; +let enabledAt: Date; + +async function newOrganisation(label: string) { + return (await prisma.organisation.create({ data: { name: label, slug: `storage-${label}-${randomUUID().slice(0, 8)}` } })).id; +} +const assetData = (orgId: string | null) => ({ key: "", contentType: "image/webp", byteSize: BigInt(4), provider: "s3", organisationId: orgId }); + +beforeAll(async () => { + vi.stubEnv("S3_BUCKET", "queue-test"); + vi.stubEnv("S3_REGION", "auto"); + vi.stubEnv("S3_ENDPOINT", "https://storage.example.test"); + organisationId = await newOrganisation("main"); + enabledAt = (await prisma.storageProtocol.findUniqueOrThrow({ where: { id: 1 } })).enabledAt; +}); + +beforeEach(async () => { + await prisma.storageDeletion.deleteMany({}); + store.objects.clear(); + store.deleted.length = 0; + store.lostPuts.length = 0; + store.pageSize = 1000; + store.poison = null; +}); + +afterEach(() => { + vi.stubEnv("S3_BUCKET", "queue-test"); +}); + +afterAll(async () => { + await prisma.storageProtocol.update({ where: { id: 1 }, data: { enabledAt } }); + await prisma.storageDeletion.deleteMany({}); + await prisma.organisation.deleteMany({ where: { name: { in: ["main", "deleted", "receipts"] } } }); + vi.unstubAllEnvs(); + await prisma.$disconnect(); +}); + +describe.sequential("storage deletion queue", () => { + it("publishes an upload and keeps no deletion row for it", async () => { + const key = coverKey(organisationId); + await uploadFile({ key, body: object.body, contentType: "image/webp", organisationId }); + expect(store.objects.has(key)).toBe(true); + expect(await prisma.asset.findUnique({ where: { key } })).toMatchObject({ provider: "s3", organisationId }); + expect(await rowFor(key)).toBeNull(); + // A key already in use is refused before any byte is sent. + await expect(registerUpload(key, organisationId)).rejects.toThrow("already in use"); + }); + + it("refuses a PUT that starts after the deadline, then deletes and verifies its key", async () => { + const now = new Date(); + const upload = await registerUpload(coverKey(organisationId), organisationId, now); + await expect(putRegistered(upload, object, later(upload.deadline, 1))).rejects.toThrow("did not start in time"); + expect(store.objects.size).toBe(0); + expect(await rowFor(upload.key)).toMatchObject({ state: "PENDING", reason: "UPLOAD_PENDING" }); + // Not before the deadline plus the grace period. + await processStorageDeletions(later(upload.deadline, UPLOAD_GRACE_MS - 1)); + expect(await rowFor(upload.key)).toMatchObject({ state: "PENDING" }); + const due = later(upload.deadline, UPLOAD_GRACE_MS + 1); + await processStorageDeletions(due); + expect(await rowFor(upload.key)).toMatchObject({ state: "VERIFYING" }); + await processStorageDeletions(later(due, VERIFY_AFTER_MS + HOUR)); + expect(await rowFor(upload.key)).toBeNull(); + }); + + it("lets a publication that comes first keep its object", async () => { + const upload = await registerUpload(coverKey(organisationId), organisationId); + await putRegistered(upload, object); + await publishUpload(upload, assetData(organisationId)); + await processStorageDeletions(later(upload.deadline, 100 * HOUR)); + expect(store.objects.has(upload.key)).toBe(true); + expect(await prisma.asset.findUnique({ where: { key: upload.key } })).not.toBeNull(); + }); + + it("never publishes once the worker has claimed the row", async () => { + const upload = await registerUpload(coverKey(organisationId), organisationId); + await putRegistered(upload, object); + await processStorageDeletions(later(upload.deadline, UPLOAD_GRACE_MS + 1)); + expect(store.objects.has(upload.key)).toBe(false); + await expect(publishUpload(upload, assetData(organisationId))).rejects.toThrow("expired"); + expect(await prisma.asset.findUnique({ where: { key: upload.key } })).toBeNull(); + expect(await rowFor(upload.key)).toMatchObject({ state: "VERIFYING" }); + }); + + it("repeats a delete safely after the worker failed during it", async () => { + const upload = await registerUpload(coverKey(organisationId), organisationId); + await putRegistered(upload, object); + const due = later(upload.deadline, UPLOAD_GRACE_MS + 1); + store.failAfterDelete = true; + await processStorageDeletions(due); + expect(await rowFor(upload.key)).toMatchObject({ state: "CLAIMED", lastErrorCode: "TimeoutError" }); + // Claimed rows never return to PENDING, so the upload still cannot publish. + await expect(publishUpload(upload, assetData(organisationId))).rejects.toThrow("expired"); + await processStorageDeletions(later(due, HOUR)); + expect(store.deleted.filter((key) => key === upload.key)).toHaveLength(2); + expect(await rowFor(upload.key)).toMatchObject({ state: "VERIFYING", lastErrorCode: null }); + }); + + it("removes an uncertain PUT that lands after the first delete", async () => { + const upload = await registerUpload(coverKey(organisationId), organisationId); + store.failNextPut = true; + await expect(putRegistered(upload, object)).rejects.toThrow(); + const due = later(upload.deadline, UPLOAD_GRACE_MS + 1); + await processStorageDeletions(due); + expect(await rowFor(upload.key)).toMatchObject({ state: "VERIFYING" }); + store.objects.add(store.lostPuts.pop()!); // The lost write takes effect now. + const check = later(due, VERIFY_AFTER_MS + HOUR); + await processStorageDeletions(check); + expect(store.objects.has(upload.key)).toBe(false); + expect(await rowFor(upload.key)).toMatchObject({ state: "VERIFYING", lastErrorCode: "STILL_PRESENT" }); + await processStorageDeletions(later(check, VERIFY_AFTER_MS + HOUR)); + expect(await rowFor(upload.key)).toBeNull(); + }); + + it("removes every object of a deleted organisation, including an upload in progress, without overriding its row", async () => { + await prisma.storageProtocol.update({ where: { id: 1 }, data: { enabledAt: new Date(Date.now() - 48 * HOUR) } }); + const doomed = await newOrganisation("deleted"); + const published = coverKey(doomed); + await uploadFile({ key: published, body: object.body, contentType: "image/webp", organisationId: doomed }); + const inFlight = await registerUpload(coverKey(doomed), doomed); + await putRegistered(inFlight, object); + const legacy = `organisations/${doomed}/event-covers/legacy-without-asset.webp`; + store.objects.add(legacy); + store.pageSize = 2; + // As app/actions/membership.ts deleteOrganisation does it. + await prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT 1 FROM "Organisation" WHERE "id" = ${doomed} FOR UPDATE`; + await queueOrganisationStorage(tx, doomed); + await tx.organisation.delete({ where: { id: doomed } }); + }); + await expect(publishUpload(inFlight, assetData(doomed))).rejects.toThrow(); + const pending = await rowFor(inFlight.key); + const now = new Date(); + await processStorageDeletions(now); // deletes the published object, lists the first page + await processStorageDeletions(now); // the second page, and the legacy object it found + await processStorageDeletions(now); + expect(await rowFor(inFlight.key)).toEqual(pending); // deadline, state and fencing unchanged + expect(store.objects.has(published) || store.objects.has(legacy)).toBe(false); + expect(store.objects.has(inFlight.key)).toBe(true); + expect(await prisma.storageDeletion.count({ where: { kind: "PREFIX" } })).toBe(0); + await processStorageDeletions(later(inFlight.deadline, UPLOAD_GRACE_MS + 1)); + expect(store.objects.has(inFlight.key)).toBe(false); + await processStorageDeletions(later(inFlight.deadline, UPLOAD_GRACE_MS + VERIFY_AFTER_MS + HOUR)); + expect(await prisma.storageDeletion.count({ where: { organisationId: doomed } })).toBe(0); + }); + + it("waits 27 hours after the protocol epoch before listing a prefix", async () => { + await prisma.storageProtocol.update({ where: { id: 1 }, data: { enabledAt: new Date() } }); + const doomed = await newOrganisation("deleted"); + await prisma.$transaction(async (tx) => { + await queueOrganisationStorage(tx, doomed); + await tx.organisation.delete({ where: { id: doomed } }); + }); + const prefix = await prisma.storageDeletion.findFirstOrThrow({ where: { kind: "PREFIX", organisationId: doomed } }); + expect(prefix.notBefore.getTime() - Date.now()).toBeGreaterThan(26.9 * HOUR); + }); + + it("saves a listing page and its continuation token together", async () => { + const prefix = `organisations/${randomUUID()}/`; + await prisma.storageDeletion.create({ data: { kind: "PREFIX", location: currentStorageLocation()!, key: prefix, reason: "LEGACY_PREFIX", notBefore: new Date() } }); + for (const name of ["a", "b", "c"]) store.objects.add(`${prefix}${name}.webp`); + store.pageSize = 2; + await processStorageDeletions(new Date()); + expect((await prisma.storageDeletion.findFirstOrThrow({ where: { key: prefix } })).continuationToken).toBe(`${prefix}b.webp`); + store.poison = `${prefix}z\u0000.webp`; // The next page cannot be saved. + await processStorageDeletions(new Date()); + const stuck = await prisma.storageDeletion.findFirstOrThrow({ where: { key: prefix } }); + expect(stuck.continuationToken).toBe(`${prefix}b.webp`); + expect(stuck.lastErrorCode).not.toBeNull(); + expect(await rowFor(`${prefix}c.webp`)).toBeNull(); + store.poison = null; + await processStorageDeletions(later(new Date(), HOUR)); + expect(await rowFor(`${prefix}c.webp`)).not.toBeNull(); + expect(await prisma.storageDeletion.count({ where: { key: prefix } })).toBe(0); + }); + + it("keeps and reports a row whose storage location is no longer configured", async () => { + const upload = await registerUpload(coverKey(organisationId), organisationId); + await putRegistered(upload, object); + vi.stubEnv("S3_BUCKET", "another-bucket"); + await processStorageDeletions(later(upload.deadline, UPLOAD_GRACE_MS + 1)); + expect(store.deleted).not.toContain(upload.key); + expect(store.objects.has(upload.key)).toBe(true); + expect(await rowFor(upload.key)).toMatchObject({ lastErrorCode: "LOCATION_MISMATCH" }); + expect((await storageDeletionProblems()).misplaced).toBe(1); + }); + + it("removes an abandoned receipt after a day and keeps a referenced one", async () => { + const orgId = await newOrganisation("receipts"); + const receiptKey = () => `organisations/${orgId}/claim-receipts/${randomUUID()}.webp`; + const old = new Date(Date.now() - 2 * 24 * HOUR); + const [abandoned, referenced, fresh] = [receiptKey(), receiptKey(), receiptKey()]; + for (const key of [abandoned, referenced]) await prisma.asset.create({ data: { ...assetData(orgId), key, createdAt: old } }); + await prisma.asset.create({ data: { ...assetData(orgId), key: fresh } }); + const event = await prisma.event.create({ data: { + organisationId: orgId, title: "Receipts", slug: `receipts-${randomUUID().slice(0, 8)}`, timezone: "UTC", + startDateTime: new Date("2031-01-01T10:00:00Z"), endDateTime: new Date("2031-01-01T12:00:00Z"), status: EventStatus.PUBLISHED, privacyType: EventPrivacyType.PUBLIC, + } }); + const claim = await prisma.expenseClaim.create({ data: { + organisationId: orgId, eventId: event.id, eventTitle: event.title, kind: "CREW", payeeName: "Payee", payeeEmail: "payee@example.test", + category: "TRAVEL", description: "Train", amountMinor: BigInt(2500), currency: "INR", + } }); + await prisma.expenseReceipt.create({ data: { claimId: claim.id, storageKey: referenced, byteSize: 4 } }); + expect(await purgeUnattachedReceipts(new Date())).toBe(1); + expect(await prisma.asset.findUnique({ where: { key: abandoned } })).toBeNull(); + expect(await rowFor(abandoned)).toMatchObject({ reason: "UNATTACHED", state: "PENDING" }); + for (const key of [referenced, fresh]) { + expect(await prisma.asset.findUnique({ where: { key } })).not.toBeNull(); + expect(await rowFor(key)).toBeNull(); + } + // Deleting the organisation queues the referenced receipt's key too. + await prisma.$transaction(async (tx) => { + await queueOrganisationStorage(tx, orgId); + await tx.organisation.delete({ where: { id: orgId } }); + }); + expect(await rowFor(referenced)).toMatchObject({ reason: "ORG_DELETED" }); + }); +}); diff --git a/tests/unit/outbox-worker.test.ts b/tests/unit/outbox-worker.test.ts index 62a170d7..3df09496 100644 --- a/tests/unit/outbox-worker.test.ts +++ b/tests/unit/outbox-worker.test.ts @@ -140,7 +140,7 @@ describe("beat", () => { await runScheduledJobs({ now: new Date(start.getTime() + 61 * 60_000), lastRun }); expect(mocks.purge).toHaveBeenCalledTimes(2); expect(mocks.reminders).toHaveBeenCalledTimes(2); - expect(SCHEDULED_JOBS.map((job) => job.name)).toEqual(["payments", "waitlists", "admission", "event-reminders", "webhooks", "help-queue-expiry", "reimbursement-notices", "backup-alerts", "retention"]); + expect(SCHEDULED_JOBS.map((job) => job.name)).toEqual(["payments", "waitlists", "admission", "event-reminders", "webhooks", "help-queue-expiry", "reimbursement-notices", "backup-alerts", "storage-deletions", "retention"]); }); it("delivers organisation webhooks as a beat job that never stops the others", async () => { diff --git a/tests/unit/retention.test.ts b/tests/unit/retention.test.ts index 24e6e260..ad7eac61 100644 --- a/tests/unit/retention.test.ts +++ b/tests/unit/retention.test.ts @@ -16,9 +16,11 @@ const mocks = vi.hoisted(() => ({ applicationEvents: vi.fn(), offlineCheckIns: vi.fn(), crewPhotos: vi.fn(), + receipts: vi.fn(), })); vi.mock("@/lib/core/crew/photos", () => ({ purgeUnusedCrewPhotos: mocks.crewPhotos })); +vi.mock("@/lib/storage/deletionQueue", () => ({ purgeUnattachedReceipts: mocks.receipts })); vi.mock("@/lib/db", () => ({ prisma: { $transaction: mocks.transaction, @@ -48,6 +50,7 @@ describe("operational retention", () => { mock.mockResolvedValue({ count: 1 }); } mocks.crewPhotos.mockResolvedValue(2); + mocks.receipts.mockResolvedValue(3); mocks.transaction.mockImplementation((operations: Array>) => Promise.all(operations)); }); @@ -73,7 +76,9 @@ describe("operational retention", () => { applicationStatusEvents: 1, offlineCheckInRecords: 1, unusedCrewPhotos: 2, + unattachedReceipts: 3, }); + expect(mocks.receipts).toHaveBeenCalledWith(now); expect(mocks.crewPhotos).toHaveBeenCalledWith(now); expect(mocks.orgInvite).toHaveBeenCalledWith({ where: { usedAt: null, expiresAt: { lt: now } } }); expect(mocks.collaboratorInvite).toHaveBeenCalledWith({ where: { usedAt: null, expiresAt: { lt: now } } }); diff --git a/tests/unit/storage-location.test.ts b/tests/unit/storage-location.test.ts new file mode 100644 index 00000000..f4d10a82 --- /dev/null +++ b/tests/unit/storage-location.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { currentStorageLocation } from "@/lib/storage/objects"; + +function configure(values: Record) { + for (const [name, value] of Object.entries(values)) vi.stubEnv(name, value); +} + +describe("storage location", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("is null without object storage", () => { + configure({ S3_BUCKET: "", S3_REGION: "" }); + expect(currentStorageLocation()).toBeNull(); + }); + + it("names the provider, endpoint and bucket without revealing them", () => { + configure({ S3_BUCKET: "events", S3_REGION: "auto", S3_ENDPOINT: "https://acct.r2.cloudflarestorage.com", S3_ACCESS_KEY_ID: "id-1", S3_SECRET_ACCESS_KEY: "secret-1" }); + const location = currentStorageLocation()!; + expect(location).toMatch(/^s3:[0-9a-f]{32}$/); + expect(location).not.toContain("events"); + // Credentials and cosmetic differences in the endpoint do not move it. + configure({ S3_ACCESS_KEY_ID: "id-2", S3_SECRET_ACCESS_KEY: "secret-2", S3_ENDPOINT: "https://ACCT.r2.cloudflarestorage.com/" }); + expect(currentStorageLocation()).toBe(location); + // Another bucket or endpoint is another location. + configure({ S3_BUCKET: "events-2" }); + expect(currentStorageLocation()).not.toBe(location); + configure({ S3_BUCKET: "events", S3_ENDPOINT: "https://other.example.test" }); + expect(currentStorageLocation()).not.toBe(location); + }); + + it("tells AWS regions apart when no endpoint is set", () => { + configure({ S3_BUCKET: "events", S3_REGION: "eu-west-1", S3_ENDPOINT: "" }); + const ireland = currentStorageLocation(); + configure({ S3_REGION: "us-east-1" }); + expect(currentStorageLocation()).not.toBe(ireland); + }); +}); From c1f59e7e15a2b9ab2d4aa219d7f422d1c059fa9c Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 19:34:02 +0530 Subject: [PATCH 34/79] test(vps): allow the scripted shell runs more time on slow hosts Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- tests/unit/vps-backup-encryption.test.ts | 2 +- tests/unit/vps-runner-service.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/vps-backup-encryption.test.ts b/tests/unit/vps-backup-encryption.test.ts index a10dce2e..faba5f3a 100644 --- a/tests/unit/vps-backup-encryption.test.ts +++ b/tests/unit/vps-backup-encryption.test.ts @@ -41,7 +41,7 @@ beforeEach(() => { afterEach(() => rmSync(dir, { recursive: true, force: true })); -describe("backup encryption", { timeout: 30_000 }, () => { +describe("backup encryption", { timeout: 120_000 }, () => { it("tells a working age setup from a missing key or a failing age", () => { const problem = (key: string) => bash(`. scripts/vps-lib.sh; backup_encryption_problem '${key}'`).stdout; working(); diff --git a/tests/unit/vps-runner-service.test.ts b/tests/unit/vps-runner-service.test.ts index 78b16fe7..66d2e82d 100644 --- a/tests/unit/vps-runner-service.test.ts +++ b/tests/unit/vps-runner-service.test.ts @@ -60,7 +60,7 @@ beforeEach(() => { afterEach(() => rmSync(dir, { recursive: true, force: true })); -describe("deploy runner service", { timeout: 30_000 }, () => { +describe("deploy runner service", { timeout: 120_000 }, () => { it("writes its own unit, run as swe-runner from the runner's directory", () => { const result = run("write_runner_unit"); expect(result.status, result.stderr).toBe(0); From 5f1ffaec869037dcdd5fa34f30ef38d6bcea8bdd Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 19:44:30 +0530 Subject: [PATCH 35/79] chore(audit): regenerate the guide index and frontend catalog, list the payment migration Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- content/help/index.generated.ts | 47 ++-- docs/production-operations.md | 1 + frontend/catalog.json | 5 + lib/frontend/registry.ts | 434 ++++++++++++++++---------------- 4 files changed, 255 insertions(+), 232 deletions(-) diff --git a/content/help/index.generated.ts b/content/help/index.generated.ts index 231150c0..acf752ab 100644 --- a/content/help/index.generated.ts +++ b/content/help/index.generated.ts @@ -219,7 +219,7 @@ export const helpIndex: Readonly> = { { "heading": "Create an account with email", "anchor": "create-an-account-with-email", - "text": "Choose Sign in in the site header and open the Create account tab. Enter your Name, Email and a Password, then choose Create account. Open the verification email and follow its link. On the Confirm your email page, choose Confirm email. You're taken back to the sign-in page with \"Email verified. You can now sign in.\" The page always answers \"If this email can be registered, check your inbox for a verification link\", so nobody can use the form to find out who has an account. If you already have an account with that address, no new one is made; sign in or reset your password instead. Password rules 12 to 128 characters. Long passwords with accented letters or non-Latin scripts can hit a lower limit; if so, the app asks you to use fewer characters. Not a password found in a known data breach. The app checks a public list of breached passwords without ever sending your password, and tells you if yours \"has appeared in a data breach\". The same rules apply when you reset or change your password. The verification link works once and expires after one hour. If it has expired, sign in with your email and password: the app asks you to verify first and shows Resend verification email." + "text": "Choose Sign in in the site header and open the Create account tab. Enter your Name, Email and a Password, then choose Create account. Open the verification email and follow its link. On the Confirm your email page, choose Confirm email. You're taken back to the sign-in page with \"Email verified. You can now sign in.\" The page always answers \"If this email can be registered, check your inbox for a verification link\", so nobody can use the form to find out who has an account. If you already have an account with that address, no new one is made; sign in or reset your password instead. Sign-ups are also limited to a few an hour for each address and network; past that the page gives the same answer but sends nothing, so if no email arrives, wait an hour and try again. Password rules 12 to 128 characters. Long passwords with accented letters or non-Latin scripts can hit a lower limit; if so, the app asks you to use fewer characters. Not a password found in a known data breach. The app checks a public list of breached passwords without ever sending your password, and tells you if yours \"has appeared in a data breach\". The same rules apply when you reset or change your password. The verification link works once and expires after one hour. If it has expired, sign in with your email and password: the app asks you to verify first and shows Resend verification email." }, { "heading": "Sign in", @@ -310,7 +310,7 @@ export const helpIndex: Readonly> = { { "heading": "Signing in with a code", "anchor": "signing-in-with-a-code", - "text": "After you enter your email and password, the app shows Verify it's you. Open your authenticator app and find the Space Works Events entry. Type the current six-digit code into Authenticator or recovery code. Choose Verify and sign in. Codes change every 30 seconds. If one is rejected, wait for the next code and try again, and check that your phone sets its clock automatically. To sign in as someone else, choose Use a different account. After signing in with Google Google sign-in follows your Google account's own security, so it doesn't ask for a code at sign-in. If you're organisation staff, the app then shows Confirm it's you before your dashboards open. Enter the current code from your authenticator app in Six-digit authenticator code. Choose Verify and continue. You're asked again after 12 hours, and after you sign out everywhere or reset your password. This page only takes a code from the app, not a recovery code." + "text": "After you enter your email and password, the app shows Verify it's you. Open your authenticator app and find the Space Works Events entry. Type the current six-digit code into Authenticator or recovery code. Choose Verify and sign in. Codes change every 30 seconds. If one is rejected, wait for the next code and try again, and check that your phone sets its clock automatically. To sign in as someone else, choose Use a different account. After signing in with Google Google sign-in follows your Google account's own security, so it doesn't ask for a code at sign-in. If you're organisation staff, the app then shows Confirm it's you before your dashboards open. Enter the current code from your authenticator app in Six-digit authenticator code. Choose Verify and continue. You're asked again after 12 hours, each time you sign in again, and after you sign out everywhere or reset your password. This page only takes a code from the app, not a recovery code." }, { "heading": "Recovery codes", @@ -675,7 +675,7 @@ export const helpIndex: Readonly> = { { "heading": "Opening and sharing the form", "anchor": "opening-and-sharing-the-form", - "text": "The form is closed until you open it. When you're ready, usually at the end of the event: Make sure the form has at least one question. Turn the switch at the top of the Feedback tab to Open. Choose Copy feedback link. Share the link with your guests. Space Works Events never sends the feedback link by itself. Opening the form doesn't email anyone. Share the link yourself: paste it into a chat group or onto a slide, or send the Thank you and feedback email template from the Emails tab. See Emailing your guests. The form only works while the event is published. Turn the switch back to Closed to stop taking responses." + "text": "The form is closed until you open it. When you're ready, usually at the end of the event: Make sure the form has at least one question. Turn the switch at the top of the Feedback tab to Open. Choose Copy feedback link. Share the link with your guests. Space Works Events never sends the feedback link by itself. Opening the form doesn't email anyone. Share the link yourself: paste it into a chat group or onto a slide, or send the Thank you and feedback email template from the Emails tab. See Emailing your guests. The form only works while the event is published and its website is live, and only for people who can open the event page: for an invite-only event, that means invited guests signed in with the invited address. Turn the switch back to Closed to stop taking responses." }, { "heading": "Anonymous or verified", @@ -843,7 +843,7 @@ export const helpIndex: Readonly> = { { "heading": "Reviewing an application", "anchor": "reviewing-an-application", - "text": "Open the event from your dashboard. Your review list is shown (organisers find it under Applications → My reviews). Choose Open next to an application marked To review. Read the guidance and the answers. On a blind stage you won't see names, email addresses, phone numbers or team names. Score each criterion from 1 (weakest) to 5 (strongest), add notes for the organisers if you like, and choose Submit review. A review can't be changed once submitted. If the applicant changes their answers afterwards, the application comes back to your list so you can review the new answers." + "text": "Open the event from your dashboard. Your review list is shown (organisers find it under Applications → My reviews). Choose Open next to an application marked To review. Read the guidance and the answers. On a blind stage you won't see names, email addresses, phone numbers or team names. Score each criterion from 1 (weakest) to 5 (strongest), add notes for the organisers if you like, and choose Submit review. A review can't be changed once submitted. If the applicant changes their answers while you have the application open, Submit review is refused with \"The answers changed. Reload to see them.\": choose Open again to read the new answers, then score them. If they change their answers after you've submitted, the application comes back to your list so you can review the new answers." }, { "heading": "Conflicts of interest", @@ -853,7 +853,7 @@ export const helpIndex: Readonly> = { { "heading": "Good to know", "anchor": "good-to-know", - "text": "You can review only while the stage is Open and the application is still in review there. Your notes are for the organisers. Applicants never see reviews or scores." + "text": "You can review only while the stage is Open and the application is still in review there. If you're taken off a stage's panel, that stage's applications leave your list; so does an application you have a conflict with. Your notes are for the organisers. Applicants never see reviews or scores." } ] }, @@ -909,7 +909,7 @@ export const helpIndex: Readonly> = { { "heading": "Assigning reviewers", "anchor": "assigning-reviewers", - "text": "Open Applications → Reviewers and choose the stage. Set Reviewers per application. Choose Balanced (even workloads, the same plan every time) or Random (even workloads, shuffled). Choose whether to Keep existing assignments and add more, or Start over (assignments already reviewed always stay). Untick anyone who shouldn't get work this time, then choose Preview. Check the workloads and any applications that can't reach the target. With Random, choose Re-roll for a different shuffle. Choose Apply. A random assignment is drawn from a stored seed, so the same seed always gives the same plan, and every applied run is recorded. If anything changed between the preview and Apply (a new application, a reviewer removed), Apply is refused: preview again." + "text": "Open Applications → Reviewers and choose the stage. Set Reviewers per application. Choose Balanced (even workloads, the same plan every time) or Random (even workloads, shuffled). Choose whether to Keep existing assignments and add more, or Start over (assignments already reviewed always stay). Untick anyone who shouldn't get work this time, then choose Preview. Check the workloads and any applications that can't reach the target. With Random, choose Re-roll for a different shuffle. Choose Apply. A random assignment is drawn from a stored seed, so the same seed always gives the same plan, and every applied run is recorded. If anything changed between the preview and Apply (a new application, a reviewer removed), Apply is refused: preview again. Once anyone has submitted a review in the stage, Reviewers per application is fixed, here as in the stage settings: Apply is refused with a different number. Keep the current number; to release a decision short of it, give a reason." }, { "heading": "Conflicts of interest", @@ -1107,7 +1107,7 @@ export const helpIndex: Readonly> = { { "heading": "Find the feedback form", "anchor": "find-the-feedback-form", - "text": "Organisers share the feedback link themselves, usually by email after the event, or on screen at the end of the day. Space Works Events doesn't send it automatically, and it isn't shown on the event page. The form only works while the organisers have it open. If the link shows \"not found\", the form hasn't opened yet or has closed; check with the organisers." + "text": "Organisers share the feedback link themselves, usually by email after the event, or on screen at the end of the day. Space Works Events doesn't send it automatically, and it isn't shown on the event page. The form only works while the organisers have it open. If the link shows \"not found\", the form hasn't opened yet or has closed; check with the organisers. The form follows the event page: if you can't open the event page, you can't open its form either. For an invite-only event, sign in with the invited email address first." }, { "heading": "Give feedback", @@ -1391,7 +1391,7 @@ export const helpIndex: Readonly> = { { "heading": "Emails about your order", "anchor": "emails-about-your-order", - "text": "Email When \"Complete your payment\" Your places are held and waiting for payment. A reminder follows for a payment link. \"Receipt\" with your order reference Your payment arrived and your tickets are confirmed. \"Reserved, pay at the desk\" You chose to pay in person. \"Your held tickets were released\" The time to pay ran out. Nothing was charged. \"Refund\" or \"Order cancelled\" Money was refunded, or the order was cancelled." + "text": "Email When \"Complete your payment\" Your places are held and waiting for payment. A reminder follows for a payment link. \"Receipt\" with your order reference Your payment arrived and your tickets are confirmed. \"Reserved, pay at the desk\" You chose to pay in person. \"Your held tickets were released\" The time to pay ran out. Nothing was charged. \"Refund\" or \"Order cancelled\" Money was refunded, or the order was cancelled. \"Refund on its way\" Your payment arrived after the event (or the date) was cancelled. No ticket was issued, and the whole amount is being refunded. Card and UPI refunds can take 5 to 10 working days to appear." }, { "heading": "Good to know", @@ -1720,7 +1720,7 @@ export const helpIndex: Readonly> = { { "heading": "Edit or withdraw", "anchor": "edit-or-withdraw", - "text": "While submissions are open you can keep improving a submitted project: make your changes and choose Save changes. It stays submitted. To take it out of judging, choose Withdraw… and confirm with Withdraw. A withdrawn project isn't judged. You can bring it back with Submit again while submissions are still open." + "text": "While submissions are open you can keep improving a submitted project: make your changes and choose Save changes. It stays submitted. To take it out of judging, choose Withdraw… and confirm with Withdraw. A withdrawn project isn't judged. You can bring it back with Submit again while submissions are still open. Once the organisers publish the results, projects are final, even if submissions haven't closed yet: you can't edit, submit or withdraw yours. If they unpublish the results, you can make changes again while submissions are open." }, { "heading": "The submission window", @@ -4016,7 +4016,7 @@ export const helpIndex: Readonly> = { { "heading": "Following the replies", "anchor": "following-the-replies", - "text": "Answers from a link you shared yourself show up in exactly the same places as answers from the email. The card shows how many guests were asked, when and by whom the last email went, and counts for Coming, Not coming, No reply and Newly confirmed, not asked. If places were released, it says how many. Guests → People shows each guest's answer as a chip (Said No · paid when a paid place was kept) and any note under their row. A drop-down filters the list by answer, with counts, and saved views keep it. See The guest list. The check-in desk shows the answer chip for guests who were asked, never their note. See The check-in desk. The Overview tab adds a line such as \"Attendance: 12 coming · 3 not coming · 5 no reply\" once anyone has been asked. The guest CSV has Attendance confirmation and Attendance note columns. See Exporting guest data. Guests can change their answer until the event starts. After that, the replies are final." + "text": "Answers from a link you shared yourself show up in exactly the same places as answers from the email. The card shows how many guests were asked, when and by whom the last email went, and counts for Coming, Not coming, No reply and Newly confirmed, not asked. If places were released, it says how many. Guests → People shows each guest's answer as a chip (Said No · paid when a paid place was kept) and any note under their row. A drop-down filters the list by answer, with counts, and saved views keep it. See The guest list. The check-in desk shows the answer chip for guests who were asked, never their note. See The check-in desk. The Overview tab adds a line such as \"Attendance: 12 coming · 3 not coming · 5 no reply\" once anyone has been asked. The guest CSV has Attendance confirmation and Attendance note columns. See Exporting guest data. Guests can change their answer until the event starts. After that, the replies are final. If a guest says No and then quickly Yes (in two windows, say), the Yes wins: the place is only freed while No is still their latest answer." }, { "heading": "Good to know", @@ -4385,7 +4385,7 @@ export const helpIndex: Readonly> = { { "heading": "What's in the guest CSV", "anchor": "what-s-in-the-guest-csv", - "text": "Columns appear in this order: Name, Email and Status. Team and Team role (Leader, Member or Solo), on team events only. Registration Date and Checked In At. Ticket URL: each guest's private ticket link. Ticket, Order, Order status and Order total: the ticket type, order number, order state and total with its currency. Empty for registrations without an order. One column for each question on your registration form, headed with the question. One column for each custom check-in list, headed \"Check-in:\" and the list name, with the time the guest was checked into it. A few things to know when you open it: Status uses the stored names: CONFIRMED (going), WAITLISTED, PENDINGAPPROVAL (to approve), REJECTED (declined) and PENDINGPAYMENT (unpaid). Dates and times are in UTC in a standard format, for example 2026-10-14T09:30:00.000Z. Most spreadsheets can convert them to your time zone. Yes / no answers show as Yes or No. The Ticket URL column contains working ticket links. Anyone with a link can open that guest's ticket and QR code, so store and share the file carefully, and delete the column before sending the file outside your team." + "text": "Columns appear in this order: Name, Email and Status. Team and Team role (Leader, Member or Solo), on team events only. Registration Date and Checked In At. Ticket URL: each guest's private ticket link. Ticket, Order, Order status and Order total: the ticket type, order number, order state and total with its currency. Empty for registrations without an order. One column for each question on your registration form, headed with the question. One column for each custom check-in list, headed \"Check-in:\" and the list name, with the time the guest was checked into it. A few things to know when you open it: Status uses the stored names: CONFIRMED (going), WAITLISTED, PENDINGAPPROVAL (to approve), REJECTED (declined) and PENDINGPAYMENT (unpaid). Dates and times are in UTC in a standard format, for example 2026-10-14T09:30:00.000Z. Most spreadsheets can convert them to your time zone. Yes / no answers show as Yes or No. A question where guests can tick several choices shows every choice in one cell, separated by semicolons, in the order the choices appear on your form. Anything typed into Other comes last. The Ticket URL column contains working ticket links. Anyone with a link can open that guest's ticket and QR code, so store and share the file carefully, and delete the column before sending the file outside your team." }, { "heading": "The email list", @@ -5490,7 +5490,7 @@ export const helpIndex: Readonly> = { { "heading": "The submission window", "anchor": "the-submission-window", - "text": "Set the window in Judging → Setup, in the Project submissions card: Opens and Closes are in the event's time zone. The app checks the time every time an entrant saves, so nobody can sneak a change in after the deadline. Choose Save settings after changing them. Only confirmed entrants can submit. On a team event, that's a confirmed team. On an individual event, or for a solo registrant at a team event, it's a confirmed registration. Entrants reach the form from their ticket or team page while the event is live. A project has a title (up to 120 characters), a plain-text summary (up to 4,000 characters), and optional links to the code, a demo and a video. Entrants can save a draft, submit, edit while the window is open, and withdraw. If you charge an entry fee, the project is only submitted once the fee is paid; see Project entry fees. The entrants' side is covered in Submitting a hackathon project." + "text": "Set the window in Judging → Setup, in the Project submissions card: Opens and Closes are in the event's time zone. The app checks the time every time an entrant saves, so nobody can sneak a change in after the deadline. Choose Save settings after changing them. Only confirmed entrants can submit. On a team event, that's a confirmed team. On an individual event, or for a solo registrant at a team event, it's a confirmed registration. Entrants reach the form from their ticket or team page while the event is live. A project has a title (up to 120 characters), a plain-text summary (up to 4,000 characters), and optional links to the code, a demo and a video. Entrants can save a draft, submit, edit while the window is open, and withdraw. Publishing the results freezes every project, even before the window closes; unpublishing lifts the freeze. If you charge an entry fee, the project is only submitted once the fee is paid; see Project entry fees. The entrants' side is covered in Submitting a hackathon project." }, { "heading": "Submission receipts", @@ -6033,7 +6033,7 @@ export const helpIndex: Readonly> = { { "heading": "Who can open it", "anchor": "who-can-open-it", - "text": "The app lets you in only when all of these are true: SUPERADMINEMAIL is set on the server, and you're signed in with the account whose email matches it (capital letters don't matter). That account's email address is verified. A signed-out visitor to /super-admin is sent to the sign-in page. Anyone else who is signed in sees an ordinary \"not found\" page, so the area's existence isn't revealed. Every super admin page checks this again on its own, including when you move between sections. There's no separate authenticator step-up: once you're signed in with the right account, the area opens. To change who the super admin is, change SUPERADMINEMAIL on the server and restart, then set that account's password as described below. There's no link to the super admin area in the app's menus. Bookmark /super-admin on your server's address. Once you're signed in as the super admin, the Instance administration and Installing and maintaining sections of this guide also appear." + "text": "The app lets you in only when all of these are true: SUPERADMINEMAIL is set on the server, and you're signed in with the account whose email matches it (capital letters don't matter). You signed in with that account's password, not with Google. That account's email address is verified. A signed-out visitor to /super-admin is sent to the sign-in page. Anyone else who is signed in sees an ordinary \"not found\" page, so the area's existence isn't revealed. Every super admin page checks this again on its own, including when you move between sections. There's no separate authenticator step-up: once you're signed in with the right account, the area opens. Continue with Google is refused for the super admin's address, even if a Google account was linked to it before. To change who the super admin is, change SUPERADMINEMAIL on the server and restart, then set that account's password as described below. There's no link to the super admin area in the app's menus. Bookmark /super-admin on your server's address. Once you're signed in as the super admin, the Instance administration and Installing and maintaining sections of this guide also appear." }, { "heading": "Setting the password", @@ -6946,7 +6946,7 @@ export const helpIndex: Readonly> = { { "heading": "Cancelling a date", "anchor": "cancelling-a-date", - "text": "Owners and admins can cancel one date of a series from Upcoming dates on the series page, with Cancel date. When they do: tickets for that date alone are refunded in full; each pass is refunded that date's share of what's left on it, and keeps its other dates; everyone on that date is emailed. For example, a pass that cost 1,000 and covers five dates gets back 200 when one of them is cancelled, as long as nothing has been refunded on it yet. See Refunds and cancellations." + "text": "Owners and admins can cancel one date of a series from Upcoming dates on the series page, with Cancel date. When they do: tickets for that date alone are refunded in full; each pass is refunded that date's share of what's left on it, and keeps its other dates; everyone on that date is emailed. For example, a pass that cost 1,000 and covers five dates gets back 200 when one of them is cancelled, as long as nothing has been refunded on it yet. Cancelling the same date again never refunds a pass twice. If the pass holder already checked in to that date, the pass is left as it is for you to settle by hand. See Refunds and cancellations." }, { "heading": "Refunding a pass", @@ -7053,6 +7053,11 @@ export const helpIndex: Readonly> = { "title": "When refunds arrive", "level": 2 }, + { + "id": "retrying-a-failed-refund", + "title": "Retrying a failed refund", + "level": 2 + }, { "id": "the-refund-policy", "title": "The refund policy", @@ -7068,6 +7073,11 @@ export const helpIndex: Readonly> = { "title": "Cancelling an event", "level": 2 }, + { + "id": "payments-that-arrive-after-you-cancel", + "title": "Payments that arrive after you cancel", + "level": 3 + }, { "id": "good-to-know", "title": "Good to know", @@ -7093,7 +7103,12 @@ export const helpIndex: Readonly> = { { "heading": "When refunds arrive", "anchor": "when-refunds-arrive", - "text": "Online refunds are sent to the provider straight away and usually show as \"on its way\" until the provider confirms them. Card and UPI refunds can take 5 to 10 working days to reach the buyer. If the provider refuses a refund, for example because your account balance is too low, the owners and admins are emailed so they can try again or refund another way. If someone pays after their time to pay ran out and their place has gone, the payment is refunded in full automatically, and they're told by email." + "text": "Online refunds are sent to the provider straight away and usually show as \"on its way\" until the provider confirms them. Card and UPI refunds can take 5 to 10 working days to reach the buyer. If the provider refuses a refund, for example because your account balance is too low, the owners and admins are emailed so they can retry it or refund another way. See Retrying a failed refund. If the provider accepts a refund but never confirms it finished, the app asks the provider about it again after an hour, and keeps asking until it has an answer. A refund is never marked as failed just because the provider didn't answer in time. If someone pays after their time to pay ran out and their place has gone, the payment is refunded in full automatically, and they're told by email." + }, + { + "heading": "Retrying a failed refund", + "anchor": "retrying-a-failed-refund", + "text": "A refund the provider refused stays on record as failed. Once you've fixed the cause (usually topping up your provider balance), send it again: Open the event's Finance tab (for a series, the series page). Under Failed refunds, find the order. Each refused refund is listed with the buyer, the order reference and the amount, even when the order's tickets were already cancelled. Choose Retry refund. The app sends a new refund for the same amount, and it shows as \"on its way\" until the provider confirms it. The failed one stays on the order's record, so you can always see what happened. Retrying a refund: owners and admins only, after an authenticator check. If you signed in more than a few minutes ago, you're asked to sign in again first. Each failed refund can be retried once. If the retry fails too, it's listed again so you can retry that one, or refund the buyer another way. A retry is refused if it would return more than is left on the payment." }, { "heading": "The refund policy", @@ -7108,7 +7123,7 @@ export const helpIndex: Readonly> = { { "heading": "Cancelling an event", "anchor": "cancelling-an-event", - "text": "Open the event's Settings tab. Under Cancel event, choose Cancel event…. Read the summary and choose Cancel event and refund. Registration closes, the event page shows it's cancelled, every paid order is refunded in full, orders still being paid for are released, and waiting paid requests are declined. Everyone is emailed. This can't be undone. The message afterwards says how many orders were refunded, and how many need settling by hand, such as an order where someone had already checked in. Settle those from the guest list. If the provider later refuses a refund, the owners and admins are emailed. See Cloning, cancelling and deleting. To cancel one date of a series instead, use Cancel date under Upcoming dates on the series page. Tickets for that date are refunded in full, and each pass gets back that date's share. See Passes." + "text": "Open the event's Settings tab. Under Cancel event, choose Cancel event…. Read the summary and choose Cancel event and refund. Registration closes, the event page shows it's cancelled, every paid order is refunded in full, orders still being paid for are released, and waiting paid requests are declined. Everyone is emailed. This can't be undone. Nobody on the waitlist is offered a place on a cancelled event, even when cancelling frees places. Payments that arrive after you cancel A buyer may have paid just before you cancelled, or the provider may tell the app about a payment late. That payment never becomes a ticket. The app records it, cancels the order, and refunds the whole amount automatically. The buyer is emailed that their refund is on its way, and your team chat is told if you've connected one. The same happens for a team place (no team is created) and a hackathon entry fee (the project stays a draft), and for a pass when one of its dates was cancelled. You can't settle an order by hand for a cancelled event or date either; the app refuses it. The message afterwards says how many orders were refunded, and how many need settling by hand, such as an order where someone had already checked in. Settle those from the guest list. If the provider later refuses a refund, the owners and admins are emailed. See Cloning, cancelling and deleting. To cancel one date of a series instead, use Cancel date under Upcoming dates on the series page. Tickets for that date are refunded in full, and each pass gets back that date's share, once, even if the date is cancelled twice. A pass whose holder already checked in to that date is left for you to settle by hand. See Passes." }, { "heading": "Good to know", diff --git a/docs/production-operations.md b/docs/production-operations.md index 1d55263c..1eda2afa 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -161,6 +161,7 @@ How to read the lock notes. Prisma runs each migration in one transaction withou | `20261118050000_reimbursement_admission_stage` | `ReimbursementPolicy.admissionStageId` (`NO ACTION` foreign key to `AdmissionStage`) and `requireAccepted` (default `false`); the stage-shape CHECK and stage trigger are replaced to cover admission stages | Safe | | `20261119000000_judging_counted_reviews_recusal` | Nullable `JudgeAssignment.recusedAt` (judge self-recusal); nullable `JudgingRound.countedFrozenAt` with a CHECK that it is set only on a finalised round (a scan of `JudgingRound`); new `JudgingRoundCountedReview` table (the reviews a finalised round counted) cascading on `JudgingRound`. No backfill: rounds finalised earlier keep counting live | Safe | | `20261119030000_admission_audit_fixes` | `rsvp_admission_crew_detached` trigger (`BEFORE UPDATE OF "isCrew" ON "RSVP"`, a brief SHARE ROW EXCLUSIVE lock, no scan): a registration becomes crew only once its application is detached; drops `ReimbursementPolicy.requireAccepted` (ADMISSION_STAGE policies always need an accepted application) and re-adds the stage-shape CHECK without it (a scan of the small `ReimbursementPolicy` table); `AdmissionStage.freshEyes` (default `false`) | Safe from 0.10.0: `requireAccepted` arrived in the same release, so no released build reads it | +| `20261120000000_payment_audit_fixes` | Nullable `PaymentRefund.lastCheckedAt` (provider reconciliation) and `retryOfRefundId` (a failed refund's single replacement: unique index and a `NO ACTION` self-reference); a `(status, lastCheckedAt)` index. Nullable columns, no backfill; the two index builds briefly lock `PaymentRefund` (a small table) | Safe | | `20261120010000_utc_trigger_timestamps` | Replaces the trigger functions `rsvp_waitlist_dates`, `write_security_audit_fallback` and `write_api_security_audit_fallback` so the times they write are UTC whatever the session time zone (`CURRENT_TIMESTAMP` was stored as the session's local time). Function bodies only: no table lock, scan or backfill; rows written earlier by a database whose time zone is not UTC keep their shifted times | Safe | | `20261120010100_storage_deletion_queue` | New enums and the `StorageDeletion` table (the stored-file deletion queue, no foreign keys) and the single-row `StorageProtocol` table holding the time this migration ran. New tables only: no lock on or scan of an existing table, no backfill | **Stop every instance of the previous release within an hour and never run it against this database again** (see [Storage deletion queue rollout](#storage-deletion-queue-rollout)) | | `20261120010200_offline_check_in_mutations` | New `OfflineCheckInMutation` table (one row per synced offline check-in: event, client mutation id, registration, time, override and stored outcome; unique per event and id; cascades with the `Event`). No scan or backfill | Safe: instances of the previous release keep syncing without replay records until they are replaced | diff --git a/frontend/catalog.json b/frontend/catalog.json index eb624cde..ac478217 100644 --- a/frontend/catalog.json +++ b/frontend/catalog.json @@ -1962,6 +1962,11 @@ "name": "refundPaidOrder", "id": "payment-operations:refundPaidOrder" }, + { + "module": "app/actions/payment-operations", + "name": "retryFailedRefundAction", + "id": "payment-operations:retryFailedRefundAction" + }, { "module": "app/actions/payment-operations", "name": "settlePaidOrder", diff --git a/lib/frontend/registry.ts b/lib/frontend/registry.ts index f8f3689a..ba0f00e4 100644 --- a/lib/frontend/registry.ts +++ b/lib/frontend/registry.ts @@ -392,114 +392,115 @@ import { requestRefundAction as endpoint388 } from "@/app/actions/participant-cl import { requestPasswordReset as endpoint389 } from "@/app/actions/password-reset"; import { confirmPasswordReset as endpoint390 } from "@/app/actions/password-reset"; import { refundPaidOrder as endpoint391 } from "@/app/actions/payment-operations"; -import { settlePaidOrder as endpoint392 } from "@/app/actions/payment-operations"; -import { sendOrderPaymentLink as endpoint393 } from "@/app/actions/payment-operations"; -import { correctOfflinePayment as endpoint394 } from "@/app/actions/payment-operations"; -import { addTicketGuest as endpoint395 } from "@/app/actions/payment-operations"; -import { savePaymentSettings as endpoint396 } from "@/app/actions/payment-settings"; -import { disconnectPaymentProvider as endpoint397 } from "@/app/actions/payment-settings"; -import { testPaymentConnection as endpoint398 } from "@/app/actions/payment-settings"; -import { saveReceiptDetails as endpoint399 } from "@/app/actions/payment-settings"; -import { setAwardPrizeAction as endpoint400 } from "@/app/actions/prizes"; -import { recordPrizePayoutAction as endpoint401 } from "@/app/actions/prizes"; -import { reversePrizePayoutAction as endpoint402 } from "@/app/actions/prizes"; -import { checkPromoCode as endpoint403 } from "@/app/actions/promo"; -import { unlockTickets as endpoint404 } from "@/app/actions/promo"; -import { upsertEventRegistrationField as endpoint405 } from "@/app/actions/registration-form"; -import { deleteEventRegistrationField as endpoint406 } from "@/app/actions/registration-form"; -import { reorderEventRegistrationFields as endpoint407 } from "@/app/actions/registration-form"; -import { addManualRsvp as endpoint408 } from "@/app/actions/rsvp-admin"; -import { updateRsvpRegistration as endpoint409 } from "@/app/actions/rsvp-admin"; -import { deleteRsvp as endpoint410 } from "@/app/actions/rsvp-admin"; -import { restoreRsvp as endpoint411 } from "@/app/actions/rsvp-admin"; -import { cancelRsvp as endpoint412 } from "@/app/actions/rsvp-cancel"; -import { approveRsvp as endpoint413 } from "@/app/actions/rsvp-lifecycle"; -import { rejectRsvp as endpoint414 } from "@/app/actions/rsvp-lifecycle"; -import { reacceptRsvp as endpoint415 } from "@/app/actions/rsvp-lifecycle"; -import { promoteFromWaitlist as endpoint416 } from "@/app/actions/rsvp-lifecycle"; -import { submitRsvp as endpoint417 } from "@/app/actions/rsvp"; -import { saveView as endpoint418 } from "@/app/actions/saved-views"; -import { renameView as endpoint419 } from "@/app/actions/saved-views"; -import { shareView as endpoint420 } from "@/app/actions/saved-views"; -import { deleteView as endpoint421 } from "@/app/actions/saved-views"; -import { saveScheduleItem as endpoint422 } from "@/app/actions/schedule"; -import { setScheduleDelay as endpoint423 } from "@/app/actions/schedule"; -import { beginMfaEnrollment as endpoint424 } from "@/app/actions/security"; -import { confirmMfaEnrollment as endpoint425 } from "@/app/actions/security"; -import { disableMfa as endpoint426 } from "@/app/actions/security"; -import { revokeAllSessions as endpoint427 } from "@/app/actions/security"; -import { createEventSeries as endpoint428 } from "@/app/actions/series"; -import { updateEventSeriesMeta as endpoint429 } from "@/app/actions/series"; -import { deleteEventSeries as endpoint430 } from "@/app/actions/series"; -import { setSeriesDateCapacity as endpoint431 } from "@/app/actions/series"; -import { cancelSeriesDate as endpoint432 } from "@/app/actions/series"; -import { createEventShortLink as endpoint433 } from "@/app/actions/shortLink"; -import { regenerateEventShortLink as endpoint434 } from "@/app/actions/shortLink"; -import { saveSubmissionDraftAction as endpoint435 } from "@/app/actions/submissions"; -import { submitSubmissionAction as endpoint436 } from "@/app/actions/submissions"; -import { withdrawSubmissionAction as endpoint437 } from "@/app/actions/submissions"; -import { requestSubmissionLinkAction as endpoint438 } from "@/app/actions/submissions"; -import { approveTeam as endpoint439 } from "@/app/actions/team-admin"; -import { rejectTeam as endpoint440 } from "@/app/actions/team-admin"; -import { promoteTeam as endpoint441 } from "@/app/actions/team-admin"; -import { renameTeamAsOrganiser as endpoint442 } from "@/app/actions/team-admin"; -import { moveTeamMember as endpoint443 } from "@/app/actions/team-admin"; -import { deleteTeam as endpoint444 } from "@/app/actions/team-admin"; -import { loadTeamsPage as endpoint445 } from "@/app/actions/team-admin"; -import { searchMoveTargets as endpoint446 } from "@/app/actions/team-admin"; -import { renameTeamAction as endpoint447 } from "@/app/actions/teams"; -import { regenerateTeamJoinLinkAction as endpoint448 } from "@/app/actions/teams"; -import { regenerateTeamManageLinkAction as endpoint449 } from "@/app/actions/teams"; -import { addTeamMemberAction as endpoint450 } from "@/app/actions/teams"; -import { removeTeamMemberAction as endpoint451 } from "@/app/actions/teams"; -import { transferTeamLeadershipAction as endpoint452 } from "@/app/actions/teams"; -import { setTeamLookingForMembersAction as endpoint453 } from "@/app/actions/teams"; -import { disbandTeamAction as endpoint454 } from "@/app/actions/teams"; -import { inviteSeekerAction as endpoint455 } from "@/app/actions/teams"; -import { respondToTeamRequestAction as endpoint456 } from "@/app/actions/teams"; -import { cancelTeamInviteAction as endpoint457 } from "@/app/actions/teams"; -import { leaveTeamAction as endpoint458 } from "@/app/actions/teams"; -import { saveSeekerProfileAction as endpoint459 } from "@/app/actions/teams"; -import { requestToJoinTeamAction as endpoint460 } from "@/app/actions/teams"; -import { respondToTeamInviteAction as endpoint461 } from "@/app/actions/teams"; -import { cancelTeamRequestAction as endpoint462 } from "@/app/actions/teams"; -import { joinTeamWithTicketAction as endpoint463 } from "@/app/actions/teams"; -import { loadTeamSeekersPageAction as endpoint464 } from "@/app/actions/teams"; -import { loadTeamJoinRequestsPageAction as endpoint465 } from "@/app/actions/teams"; -import { loadTicketSeekersPageAction as endpoint466 } from "@/app/actions/teams"; -import { loadTicketTeamsPageAction as endpoint467 } from "@/app/actions/teams"; -import { loadTicketInvitesPageAction as endpoint468 } from "@/app/actions/teams"; -import { loadTicketRequestsPageAction as endpoint469 } from "@/app/actions/teams"; -import { createTicketDesignAction as endpoint470 } from "@/app/actions/ticket-designs"; -import { saveTicketDesignAction as endpoint471 } from "@/app/actions/ticket-designs"; -import { deleteTicketDesignAction as endpoint472 } from "@/app/actions/ticket-designs"; -import { assignTicketDesignAction as endpoint473 } from "@/app/actions/ticket-designs"; -import { uploadTicketDesignImageAction as endpoint474 } from "@/app/actions/ticket-designs"; -import { saveTicketType as endpoint475 } from "@/app/actions/ticket-types"; -import { archiveTicketType as endpoint476 } from "@/app/actions/ticket-types"; -import { createPromoCode as endpoint477 } from "@/app/actions/ticket-types"; -import { archivePromoCode as endpoint478 } from "@/app/actions/ticket-types"; -import { saveRefundPolicy as endpoint479 } from "@/app/actions/ticket-types"; -import { saveAddOn as endpoint480 } from "@/app/actions/ticket-types"; -import { archiveAddOn as endpoint481 } from "@/app/actions/ticket-types"; -import { saveTransferPolicy as endpoint482 } from "@/app/actions/ticket-types"; -import { saveTeamTicketMode as endpoint483 } from "@/app/actions/ticket-types"; -import { setVolunteerShifts as endpoint484 } from "@/app/actions/volunteer-shifts"; -import { saveVolunteerShift as endpoint485 } from "@/app/actions/volunteer-shifts"; -import { closeVolunteerShift as endpoint486 } from "@/app/actions/volunteer-shifts"; -import { deleteVolunteerShift as endpoint487 } from "@/app/actions/volunteer-shifts"; -import { decideVolunteerSignup as endpoint488 } from "@/app/actions/volunteer-shifts"; -import { recordVolunteerAttendance as endpoint489 } from "@/app/actions/volunteer-shifts"; -import { reviewVolunteerHours as endpoint490 } from "@/app/actions/volunteer-shifts"; -import { manageContributorCertificate as endpoint491 } from "@/app/actions/volunteer-shifts"; -import { saveContributorCertificateDesign as endpoint492 } from "@/app/actions/volunteer-shifts"; -import { createWebhookEndpoint as endpoint493 } from "@/app/actions/webhooks"; -import { updateWebhookEndpoint as endpoint494 } from "@/app/actions/webhooks"; -import { deleteWebhookEndpoint as endpoint495 } from "@/app/actions/webhooks"; -import { rotateWebhookSecret as endpoint496 } from "@/app/actions/webhooks"; -import { setWebhookEndpointEnabled as endpoint497 } from "@/app/actions/webhooks"; -import { sendWebhookTest as endpoint498 } from "@/app/actions/webhooks"; -import { redeliverWebhook as endpoint499 } from "@/app/actions/webhooks"; +import { retryFailedRefundAction as endpoint392 } from "@/app/actions/payment-operations"; +import { settlePaidOrder as endpoint393 } from "@/app/actions/payment-operations"; +import { sendOrderPaymentLink as endpoint394 } from "@/app/actions/payment-operations"; +import { correctOfflinePayment as endpoint395 } from "@/app/actions/payment-operations"; +import { addTicketGuest as endpoint396 } from "@/app/actions/payment-operations"; +import { savePaymentSettings as endpoint397 } from "@/app/actions/payment-settings"; +import { disconnectPaymentProvider as endpoint398 } from "@/app/actions/payment-settings"; +import { testPaymentConnection as endpoint399 } from "@/app/actions/payment-settings"; +import { saveReceiptDetails as endpoint400 } from "@/app/actions/payment-settings"; +import { setAwardPrizeAction as endpoint401 } from "@/app/actions/prizes"; +import { recordPrizePayoutAction as endpoint402 } from "@/app/actions/prizes"; +import { reversePrizePayoutAction as endpoint403 } from "@/app/actions/prizes"; +import { checkPromoCode as endpoint404 } from "@/app/actions/promo"; +import { unlockTickets as endpoint405 } from "@/app/actions/promo"; +import { upsertEventRegistrationField as endpoint406 } from "@/app/actions/registration-form"; +import { deleteEventRegistrationField as endpoint407 } from "@/app/actions/registration-form"; +import { reorderEventRegistrationFields as endpoint408 } from "@/app/actions/registration-form"; +import { addManualRsvp as endpoint409 } from "@/app/actions/rsvp-admin"; +import { updateRsvpRegistration as endpoint410 } from "@/app/actions/rsvp-admin"; +import { deleteRsvp as endpoint411 } from "@/app/actions/rsvp-admin"; +import { restoreRsvp as endpoint412 } from "@/app/actions/rsvp-admin"; +import { cancelRsvp as endpoint413 } from "@/app/actions/rsvp-cancel"; +import { approveRsvp as endpoint414 } from "@/app/actions/rsvp-lifecycle"; +import { rejectRsvp as endpoint415 } from "@/app/actions/rsvp-lifecycle"; +import { reacceptRsvp as endpoint416 } from "@/app/actions/rsvp-lifecycle"; +import { promoteFromWaitlist as endpoint417 } from "@/app/actions/rsvp-lifecycle"; +import { submitRsvp as endpoint418 } from "@/app/actions/rsvp"; +import { saveView as endpoint419 } from "@/app/actions/saved-views"; +import { renameView as endpoint420 } from "@/app/actions/saved-views"; +import { shareView as endpoint421 } from "@/app/actions/saved-views"; +import { deleteView as endpoint422 } from "@/app/actions/saved-views"; +import { saveScheduleItem as endpoint423 } from "@/app/actions/schedule"; +import { setScheduleDelay as endpoint424 } from "@/app/actions/schedule"; +import { beginMfaEnrollment as endpoint425 } from "@/app/actions/security"; +import { confirmMfaEnrollment as endpoint426 } from "@/app/actions/security"; +import { disableMfa as endpoint427 } from "@/app/actions/security"; +import { revokeAllSessions as endpoint428 } from "@/app/actions/security"; +import { createEventSeries as endpoint429 } from "@/app/actions/series"; +import { updateEventSeriesMeta as endpoint430 } from "@/app/actions/series"; +import { deleteEventSeries as endpoint431 } from "@/app/actions/series"; +import { setSeriesDateCapacity as endpoint432 } from "@/app/actions/series"; +import { cancelSeriesDate as endpoint433 } from "@/app/actions/series"; +import { createEventShortLink as endpoint434 } from "@/app/actions/shortLink"; +import { regenerateEventShortLink as endpoint435 } from "@/app/actions/shortLink"; +import { saveSubmissionDraftAction as endpoint436 } from "@/app/actions/submissions"; +import { submitSubmissionAction as endpoint437 } from "@/app/actions/submissions"; +import { withdrawSubmissionAction as endpoint438 } from "@/app/actions/submissions"; +import { requestSubmissionLinkAction as endpoint439 } from "@/app/actions/submissions"; +import { approveTeam as endpoint440 } from "@/app/actions/team-admin"; +import { rejectTeam as endpoint441 } from "@/app/actions/team-admin"; +import { promoteTeam as endpoint442 } from "@/app/actions/team-admin"; +import { renameTeamAsOrganiser as endpoint443 } from "@/app/actions/team-admin"; +import { moveTeamMember as endpoint444 } from "@/app/actions/team-admin"; +import { deleteTeam as endpoint445 } from "@/app/actions/team-admin"; +import { loadTeamsPage as endpoint446 } from "@/app/actions/team-admin"; +import { searchMoveTargets as endpoint447 } from "@/app/actions/team-admin"; +import { renameTeamAction as endpoint448 } from "@/app/actions/teams"; +import { regenerateTeamJoinLinkAction as endpoint449 } from "@/app/actions/teams"; +import { regenerateTeamManageLinkAction as endpoint450 } from "@/app/actions/teams"; +import { addTeamMemberAction as endpoint451 } from "@/app/actions/teams"; +import { removeTeamMemberAction as endpoint452 } from "@/app/actions/teams"; +import { transferTeamLeadershipAction as endpoint453 } from "@/app/actions/teams"; +import { setTeamLookingForMembersAction as endpoint454 } from "@/app/actions/teams"; +import { disbandTeamAction as endpoint455 } from "@/app/actions/teams"; +import { inviteSeekerAction as endpoint456 } from "@/app/actions/teams"; +import { respondToTeamRequestAction as endpoint457 } from "@/app/actions/teams"; +import { cancelTeamInviteAction as endpoint458 } from "@/app/actions/teams"; +import { leaveTeamAction as endpoint459 } from "@/app/actions/teams"; +import { saveSeekerProfileAction as endpoint460 } from "@/app/actions/teams"; +import { requestToJoinTeamAction as endpoint461 } from "@/app/actions/teams"; +import { respondToTeamInviteAction as endpoint462 } from "@/app/actions/teams"; +import { cancelTeamRequestAction as endpoint463 } from "@/app/actions/teams"; +import { joinTeamWithTicketAction as endpoint464 } from "@/app/actions/teams"; +import { loadTeamSeekersPageAction as endpoint465 } from "@/app/actions/teams"; +import { loadTeamJoinRequestsPageAction as endpoint466 } from "@/app/actions/teams"; +import { loadTicketSeekersPageAction as endpoint467 } from "@/app/actions/teams"; +import { loadTicketTeamsPageAction as endpoint468 } from "@/app/actions/teams"; +import { loadTicketInvitesPageAction as endpoint469 } from "@/app/actions/teams"; +import { loadTicketRequestsPageAction as endpoint470 } from "@/app/actions/teams"; +import { createTicketDesignAction as endpoint471 } from "@/app/actions/ticket-designs"; +import { saveTicketDesignAction as endpoint472 } from "@/app/actions/ticket-designs"; +import { deleteTicketDesignAction as endpoint473 } from "@/app/actions/ticket-designs"; +import { assignTicketDesignAction as endpoint474 } from "@/app/actions/ticket-designs"; +import { uploadTicketDesignImageAction as endpoint475 } from "@/app/actions/ticket-designs"; +import { saveTicketType as endpoint476 } from "@/app/actions/ticket-types"; +import { archiveTicketType as endpoint477 } from "@/app/actions/ticket-types"; +import { createPromoCode as endpoint478 } from "@/app/actions/ticket-types"; +import { archivePromoCode as endpoint479 } from "@/app/actions/ticket-types"; +import { saveRefundPolicy as endpoint480 } from "@/app/actions/ticket-types"; +import { saveAddOn as endpoint481 } from "@/app/actions/ticket-types"; +import { archiveAddOn as endpoint482 } from "@/app/actions/ticket-types"; +import { saveTransferPolicy as endpoint483 } from "@/app/actions/ticket-types"; +import { saveTeamTicketMode as endpoint484 } from "@/app/actions/ticket-types"; +import { setVolunteerShifts as endpoint485 } from "@/app/actions/volunteer-shifts"; +import { saveVolunteerShift as endpoint486 } from "@/app/actions/volunteer-shifts"; +import { closeVolunteerShift as endpoint487 } from "@/app/actions/volunteer-shifts"; +import { deleteVolunteerShift as endpoint488 } from "@/app/actions/volunteer-shifts"; +import { decideVolunteerSignup as endpoint489 } from "@/app/actions/volunteer-shifts"; +import { recordVolunteerAttendance as endpoint490 } from "@/app/actions/volunteer-shifts"; +import { reviewVolunteerHours as endpoint491 } from "@/app/actions/volunteer-shifts"; +import { manageContributorCertificate as endpoint492 } from "@/app/actions/volunteer-shifts"; +import { saveContributorCertificateDesign as endpoint493 } from "@/app/actions/volunteer-shifts"; +import { createWebhookEndpoint as endpoint494 } from "@/app/actions/webhooks"; +import { updateWebhookEndpoint as endpoint495 } from "@/app/actions/webhooks"; +import { deleteWebhookEndpoint as endpoint496 } from "@/app/actions/webhooks"; +import { rotateWebhookSecret as endpoint497 } from "@/app/actions/webhooks"; +import { setWebhookEndpointEnabled as endpoint498 } from "@/app/actions/webhooks"; +import { sendWebhookTest as endpoint499 } from "@/app/actions/webhooks"; +import { redeliverWebhook as endpoint500 } from "@/app/actions/webhooks"; export const viewReaders = Object.freeze({ "app__about__page:loadView": endpoint0, @@ -896,112 +897,113 @@ export const backendActions = Object.freeze({ "password-reset:requestPasswordReset": endpoint389, "password-reset:confirmPasswordReset": endpoint390, "payment-operations:refundPaidOrder": endpoint391, - "payment-operations:settlePaidOrder": endpoint392, - "payment-operations:sendOrderPaymentLink": endpoint393, - "payment-operations:correctOfflinePayment": endpoint394, - "payment-operations:addTicketGuest": endpoint395, - "payment-settings:savePaymentSettings": endpoint396, - "payment-settings:disconnectPaymentProvider": endpoint397, - "payment-settings:testPaymentConnection": endpoint398, - "payment-settings:saveReceiptDetails": endpoint399, - "prizes:setAwardPrizeAction": endpoint400, - "prizes:recordPrizePayoutAction": endpoint401, - "prizes:reversePrizePayoutAction": endpoint402, - "promo:checkPromoCode": endpoint403, - "promo:unlockTickets": endpoint404, - "registration-form:upsertEventRegistrationField": endpoint405, - "registration-form:deleteEventRegistrationField": endpoint406, - "registration-form:reorderEventRegistrationFields": endpoint407, - "rsvp-admin:addManualRsvp": endpoint408, - "rsvp-admin:updateRsvpRegistration": endpoint409, - "rsvp-admin:deleteRsvp": endpoint410, - "rsvp-admin:restoreRsvp": endpoint411, - "rsvp-cancel:cancelRsvp": endpoint412, - "rsvp-lifecycle:approveRsvp": endpoint413, - "rsvp-lifecycle:rejectRsvp": endpoint414, - "rsvp-lifecycle:reacceptRsvp": endpoint415, - "rsvp-lifecycle:promoteFromWaitlist": endpoint416, - "rsvp:submitRsvp": endpoint417, - "saved-views:saveView": endpoint418, - "saved-views:renameView": endpoint419, - "saved-views:shareView": endpoint420, - "saved-views:deleteView": endpoint421, - "schedule:saveScheduleItem": endpoint422, - "schedule:setScheduleDelay": endpoint423, - "security:beginMfaEnrollment": endpoint424, - "security:confirmMfaEnrollment": endpoint425, - "security:disableMfa": endpoint426, - "security:revokeAllSessions": endpoint427, - "series:createEventSeries": endpoint428, - "series:updateEventSeriesMeta": endpoint429, - "series:deleteEventSeries": endpoint430, - "series:setSeriesDateCapacity": endpoint431, - "series:cancelSeriesDate": endpoint432, - "shortLink:createEventShortLink": endpoint433, - "shortLink:regenerateEventShortLink": endpoint434, - "submissions:saveSubmissionDraftAction": endpoint435, - "submissions:submitSubmissionAction": endpoint436, - "submissions:withdrawSubmissionAction": endpoint437, - "submissions:requestSubmissionLinkAction": endpoint438, - "team-admin:approveTeam": endpoint439, - "team-admin:rejectTeam": endpoint440, - "team-admin:promoteTeam": endpoint441, - "team-admin:renameTeamAsOrganiser": endpoint442, - "team-admin:moveTeamMember": endpoint443, - "team-admin:deleteTeam": endpoint444, - "team-admin:loadTeamsPage": endpoint445, - "team-admin:searchMoveTargets": endpoint446, - "teams:renameTeamAction": endpoint447, - "teams:regenerateTeamJoinLinkAction": endpoint448, - "teams:regenerateTeamManageLinkAction": endpoint449, - "teams:addTeamMemberAction": endpoint450, - "teams:removeTeamMemberAction": endpoint451, - "teams:transferTeamLeadershipAction": endpoint452, - "teams:setTeamLookingForMembersAction": endpoint453, - "teams:disbandTeamAction": endpoint454, - "teams:inviteSeekerAction": endpoint455, - "teams:respondToTeamRequestAction": endpoint456, - "teams:cancelTeamInviteAction": endpoint457, - "teams:leaveTeamAction": endpoint458, - "teams:saveSeekerProfileAction": endpoint459, - "teams:requestToJoinTeamAction": endpoint460, - "teams:respondToTeamInviteAction": endpoint461, - "teams:cancelTeamRequestAction": endpoint462, - "teams:joinTeamWithTicketAction": endpoint463, - "teams:loadTeamSeekersPageAction": endpoint464, - "teams:loadTeamJoinRequestsPageAction": endpoint465, - "teams:loadTicketSeekersPageAction": endpoint466, - "teams:loadTicketTeamsPageAction": endpoint467, - "teams:loadTicketInvitesPageAction": endpoint468, - "teams:loadTicketRequestsPageAction": endpoint469, - "ticket-designs:createTicketDesignAction": endpoint470, - "ticket-designs:saveTicketDesignAction": endpoint471, - "ticket-designs:deleteTicketDesignAction": endpoint472, - "ticket-designs:assignTicketDesignAction": endpoint473, - "ticket-designs:uploadTicketDesignImageAction": endpoint474, - "ticket-types:saveTicketType": endpoint475, - "ticket-types:archiveTicketType": endpoint476, - "ticket-types:createPromoCode": endpoint477, - "ticket-types:archivePromoCode": endpoint478, - "ticket-types:saveRefundPolicy": endpoint479, - "ticket-types:saveAddOn": endpoint480, - "ticket-types:archiveAddOn": endpoint481, - "ticket-types:saveTransferPolicy": endpoint482, - "ticket-types:saveTeamTicketMode": endpoint483, - "volunteer-shifts:setVolunteerShifts": endpoint484, - "volunteer-shifts:saveVolunteerShift": endpoint485, - "volunteer-shifts:closeVolunteerShift": endpoint486, - "volunteer-shifts:deleteVolunteerShift": endpoint487, - "volunteer-shifts:decideVolunteerSignup": endpoint488, - "volunteer-shifts:recordVolunteerAttendance": endpoint489, - "volunteer-shifts:reviewVolunteerHours": endpoint490, - "volunteer-shifts:manageContributorCertificate": endpoint491, - "volunteer-shifts:saveContributorCertificateDesign": endpoint492, - "webhooks:createWebhookEndpoint": endpoint493, - "webhooks:updateWebhookEndpoint": endpoint494, - "webhooks:deleteWebhookEndpoint": endpoint495, - "webhooks:rotateWebhookSecret": endpoint496, - "webhooks:setWebhookEndpointEnabled": endpoint497, - "webhooks:sendWebhookTest": endpoint498, - "webhooks:redeliverWebhook": endpoint499, + "payment-operations:retryFailedRefundAction": endpoint392, + "payment-operations:settlePaidOrder": endpoint393, + "payment-operations:sendOrderPaymentLink": endpoint394, + "payment-operations:correctOfflinePayment": endpoint395, + "payment-operations:addTicketGuest": endpoint396, + "payment-settings:savePaymentSettings": endpoint397, + "payment-settings:disconnectPaymentProvider": endpoint398, + "payment-settings:testPaymentConnection": endpoint399, + "payment-settings:saveReceiptDetails": endpoint400, + "prizes:setAwardPrizeAction": endpoint401, + "prizes:recordPrizePayoutAction": endpoint402, + "prizes:reversePrizePayoutAction": endpoint403, + "promo:checkPromoCode": endpoint404, + "promo:unlockTickets": endpoint405, + "registration-form:upsertEventRegistrationField": endpoint406, + "registration-form:deleteEventRegistrationField": endpoint407, + "registration-form:reorderEventRegistrationFields": endpoint408, + "rsvp-admin:addManualRsvp": endpoint409, + "rsvp-admin:updateRsvpRegistration": endpoint410, + "rsvp-admin:deleteRsvp": endpoint411, + "rsvp-admin:restoreRsvp": endpoint412, + "rsvp-cancel:cancelRsvp": endpoint413, + "rsvp-lifecycle:approveRsvp": endpoint414, + "rsvp-lifecycle:rejectRsvp": endpoint415, + "rsvp-lifecycle:reacceptRsvp": endpoint416, + "rsvp-lifecycle:promoteFromWaitlist": endpoint417, + "rsvp:submitRsvp": endpoint418, + "saved-views:saveView": endpoint419, + "saved-views:renameView": endpoint420, + "saved-views:shareView": endpoint421, + "saved-views:deleteView": endpoint422, + "schedule:saveScheduleItem": endpoint423, + "schedule:setScheduleDelay": endpoint424, + "security:beginMfaEnrollment": endpoint425, + "security:confirmMfaEnrollment": endpoint426, + "security:disableMfa": endpoint427, + "security:revokeAllSessions": endpoint428, + "series:createEventSeries": endpoint429, + "series:updateEventSeriesMeta": endpoint430, + "series:deleteEventSeries": endpoint431, + "series:setSeriesDateCapacity": endpoint432, + "series:cancelSeriesDate": endpoint433, + "shortLink:createEventShortLink": endpoint434, + "shortLink:regenerateEventShortLink": endpoint435, + "submissions:saveSubmissionDraftAction": endpoint436, + "submissions:submitSubmissionAction": endpoint437, + "submissions:withdrawSubmissionAction": endpoint438, + "submissions:requestSubmissionLinkAction": endpoint439, + "team-admin:approveTeam": endpoint440, + "team-admin:rejectTeam": endpoint441, + "team-admin:promoteTeam": endpoint442, + "team-admin:renameTeamAsOrganiser": endpoint443, + "team-admin:moveTeamMember": endpoint444, + "team-admin:deleteTeam": endpoint445, + "team-admin:loadTeamsPage": endpoint446, + "team-admin:searchMoveTargets": endpoint447, + "teams:renameTeamAction": endpoint448, + "teams:regenerateTeamJoinLinkAction": endpoint449, + "teams:regenerateTeamManageLinkAction": endpoint450, + "teams:addTeamMemberAction": endpoint451, + "teams:removeTeamMemberAction": endpoint452, + "teams:transferTeamLeadershipAction": endpoint453, + "teams:setTeamLookingForMembersAction": endpoint454, + "teams:disbandTeamAction": endpoint455, + "teams:inviteSeekerAction": endpoint456, + "teams:respondToTeamRequestAction": endpoint457, + "teams:cancelTeamInviteAction": endpoint458, + "teams:leaveTeamAction": endpoint459, + "teams:saveSeekerProfileAction": endpoint460, + "teams:requestToJoinTeamAction": endpoint461, + "teams:respondToTeamInviteAction": endpoint462, + "teams:cancelTeamRequestAction": endpoint463, + "teams:joinTeamWithTicketAction": endpoint464, + "teams:loadTeamSeekersPageAction": endpoint465, + "teams:loadTeamJoinRequestsPageAction": endpoint466, + "teams:loadTicketSeekersPageAction": endpoint467, + "teams:loadTicketTeamsPageAction": endpoint468, + "teams:loadTicketInvitesPageAction": endpoint469, + "teams:loadTicketRequestsPageAction": endpoint470, + "ticket-designs:createTicketDesignAction": endpoint471, + "ticket-designs:saveTicketDesignAction": endpoint472, + "ticket-designs:deleteTicketDesignAction": endpoint473, + "ticket-designs:assignTicketDesignAction": endpoint474, + "ticket-designs:uploadTicketDesignImageAction": endpoint475, + "ticket-types:saveTicketType": endpoint476, + "ticket-types:archiveTicketType": endpoint477, + "ticket-types:createPromoCode": endpoint478, + "ticket-types:archivePromoCode": endpoint479, + "ticket-types:saveRefundPolicy": endpoint480, + "ticket-types:saveAddOn": endpoint481, + "ticket-types:archiveAddOn": endpoint482, + "ticket-types:saveTransferPolicy": endpoint483, + "ticket-types:saveTeamTicketMode": endpoint484, + "volunteer-shifts:setVolunteerShifts": endpoint485, + "volunteer-shifts:saveVolunteerShift": endpoint486, + "volunteer-shifts:closeVolunteerShift": endpoint487, + "volunteer-shifts:deleteVolunteerShift": endpoint488, + "volunteer-shifts:decideVolunteerSignup": endpoint489, + "volunteer-shifts:recordVolunteerAttendance": endpoint490, + "volunteer-shifts:reviewVolunteerHours": endpoint491, + "volunteer-shifts:manageContributorCertificate": endpoint492, + "volunteer-shifts:saveContributorCertificateDesign": endpoint493, + "webhooks:createWebhookEndpoint": endpoint494, + "webhooks:updateWebhookEndpoint": endpoint495, + "webhooks:deleteWebhookEndpoint": endpoint496, + "webhooks:rotateWebhookSecret": endpoint497, + "webhooks:setWebhookEndpointEnabled": endpoint498, + "webhooks:sendWebhookTest": endpoint499, + "webhooks:redeliverWebhook": endpoint500, }); From 200aa6451e8ae58ba83caedf3303e8f3083b5f09 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 22:21:25 +0530 Subject: [PATCH 36/79] fix(auth): validate the normalised redirect so dot segments cannot reach another host Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- lib/authRedirect.ts | 5 ++++- tests/unit/auth-inputs.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/authRedirect.ts b/lib/authRedirect.ts index 80753e94..1885804c 100644 --- a/lib/authRedirect.ts +++ b/lib/authRedirect.ts @@ -5,7 +5,10 @@ export function safeAuthRedirect(value: string | null | undefined) { try { const base = "https://auth.invalid"; const url = new URL(path, base); - return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : "/dashboard"; + // Dot segments are resolved here, so `/x/..//host` becomes `//host`: check + // the normalised result, which a browser would read as another origin. + const result = `${url.pathname}${url.search}${url.hash}`; + return url.origin === base && result.startsWith("/") && !/^\/[/\\]/.test(result) ? result : "/dashboard"; } catch { return "/dashboard"; } diff --git a/tests/unit/auth-inputs.test.ts b/tests/unit/auth-inputs.test.ts index 586c65f4..a6235c1e 100644 --- a/tests/unit/auth-inputs.test.ts +++ b/tests/unit/auth-inputs.test.ts @@ -19,6 +19,31 @@ describe("auth inputs", () => { it.each(["//evil.test", "/\\evil.test", "/\n/evil.test", "https://evil.test", "javascript:alert(1)"])("rejects redirect %j", (path) => { expect(safeAuthRedirect(path)).toBe("/dashboard"); }); + it.each([ + "/x/..//attacker.example", + "/x/../..//attacker.example/path?q=1", + "/x/%2e%2e//attacker.example", + "/x/%2E%2E//attacker.example", + "/x/.%2e//attacker.example", + "/./..//attacker.example", + "/x/..%2F/attacker.example", + "/x/..\\/attacker.example", + "/x/..\\\\attacker.example", + "/%5C%5Cattacker.example", + ])("never normalises %j to another origin", (path) => { + const result = safeAuthRedirect(path); + expect(result).toMatch(/^\/(?![/\\])/); + expect(new URL(result, "https://app.example").origin).toBe("https://app.example"); + }); + it("rejects the dot-segment bypass outright", () => { + expect(safeAuthRedirect("/x/..//attacker.example")).toBe("/dashboard"); + expect(safeAuthRedirect("/x/%2e%2e//attacker.example")).toBe("/dashboard"); + expect(safeAuthRedirect("/x/..\\/attacker.example")).toBe("/dashboard"); + }); + it("keeps normalised local destinations", () => { + expect(safeAuthRedirect("/a/b/../c?x=1#h")).toBe("/a/c?x=1#h"); + expect(safeAuthRedirect("/%2F%2Fattacker.example")).toBe("/%2F%2Fattacker.example"); + }); it("preserves local invitation destinations", () => { expect(safeAuthRedirect("/join/org/example?from=login")).toBe("/join/org/example?from=login"); }); From 88269d20d27444c013e83ea9bf53f88612038876 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 22:21:28 +0530 Subject: [PATCH 37/79] fix(broadcasts): resolve placeholders from own properties only Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- lib/broadcastTemplate.ts | 5 +++-- tests/unit/broadcast-actions.test.ts | 6 ++++++ tests/unit/broadcast-template.test.ts | 6 ++++++ tests/unit/outbox.test.ts | 7 +++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/broadcastTemplate.ts b/lib/broadcastTemplate.ts index f86b6c2e..9a78fed2 100644 --- a/lib/broadcastTemplate.ts +++ b/lib/broadcastTemplate.ts @@ -63,6 +63,7 @@ function cleanName(value?: string | null) { return trimmed || "there"; } +/** Own properties only: `{{constructor}}` or `{{toString}}` must stay literal, not resolve to Object.prototype. */ function resolveValues(variables: BroadcastVariables): Record { return { name: cleanName(variables.name), @@ -86,7 +87,7 @@ export function renderBroadcastTemplate(template: string, variables: BroadcastVa const values = resolveValues(variables); return template .replace(CONTROL_CHARACTERS, "") - .replace(PLACEHOLDER_PATTERN, (match, key: string) => (key in values ? values[key]! : match)); + .replace(PLACEHOLDER_PATTERN, (match, key: string) => (Object.hasOwn(values, key) ? values[key]! : match)); } /** @@ -98,7 +99,7 @@ export function renderBroadcastHtml(html: string, variables: BroadcastVariables) const values = resolveValues(variables); return html .replace(CONTROL_CHARACTERS, "") - .replace(PLACEHOLDER_PATTERN, (match, key: string) => (key in values ? escapeBroadcastHtml(values[key]!) : match)); + .replace(PLACEHOLDER_PATTERN, (match, key: string) => (Object.hasOwn(values, key) ? escapeBroadcastHtml(values[key]!) : match)); } const NAMED_ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }; diff --git a/tests/unit/broadcast-actions.test.ts b/tests/unit/broadcast-actions.test.ts index 64d5d8d3..7e16cdc0 100644 --- a/tests/unit/broadcast-actions.test.ts +++ b/tests/unit/broadcast-actions.test.ts @@ -197,6 +197,12 @@ describe("event broadcast actions", () => { expect(result.data?.text).toContain("Ticket (http://localhost:3000/org/mixer)"); }); + it("previews inherited property names as literal placeholders", async () => { + const result = await previewEventBroadcast({ ...baseInput, subject: "{{toString}}", body: "

{{constructor}} {{name}}

" }); + expect(result.ok && result.data?.subject).toBe("{{toString}}"); + expect(result.ok && result.data?.html).toContain("{{constructor}} Priya"); + }); + it("clears the sender's own draft inside a successful send transaction only", async () => { await sendEventBroadcast(baseInput); expect(mocks.draftDeleteMany).toHaveBeenCalledWith({ where: { eventId: "event_1", userId: "admin_1" } }); diff --git a/tests/unit/broadcast-template.test.ts b/tests/unit/broadcast-template.test.ts index bfd54982..e93ab3d2 100644 --- a/tests/unit/broadcast-template.test.ts +++ b/tests/unit/broadcast-template.test.ts @@ -135,4 +135,10 @@ describe("audience helpers", () => { expect(broadcastHtmlToText(html)).toBe("Form (https://forms.test/?n=�)"); expect(broadcastHtmlToText("

� � A

")).toBe("� � A"); }); + + it("keeps inherited property names literally instead of resolving Object.prototype", () => { + const tokens = "{{constructor}} {{toString}} {{hasOwnProperty}} {{valueOf}} {{name}}"; + expect(renderBroadcastTemplate(tokens, variables)).toBe("{{constructor}} {{toString}} {{hasOwnProperty}} {{valueOf}} Priya Nair"); + expect(renderBroadcastHtml(`

${tokens}

`, variables)).toBe("

{{constructor}} {{toString}} {{hasOwnProperty}} {{valueOf}} Priya Nair

"); + }); }); diff --git a/tests/unit/outbox.test.ts b/tests/unit/outbox.test.ts index 6876f954..e94e95c1 100644 --- a/tests/unit/outbox.test.ts +++ b/tests/unit/outbox.test.ts @@ -209,6 +209,13 @@ describe("event broadcast delivery", () => { expect(mocks.deleteMany).not.toHaveBeenCalled(); }); + it("delivers a broadcast whose body names inherited properties, keeping them literal", async () => { + mocks.broadcastFind.mockResolvedValue({ subject: "{{toString}}", body: "

{{constructor}} {{name}}

", statuses: [RsvpStatus.CONFIRMED], attendance: "ANY", eventId: "event_1", event: broadcastEvent }); + mocks.findMany.mockResolvedValue([message({ kind: "event-broadcast", payload: { to: "priya@example.test", broadcastId: "broadcast_1", rsvpId: "rsvp_1" } })]); + await expect(deliverOutboxBatch()).resolves.toEqual({ sent: 1, failed: 0 }); + expect(mocks.broadcast.mock.calls[0]?.[0]).toMatchObject({ subject: "{{toString}}", bodyText: "{{constructor}} Priya" }); + }); + it("does not expose a ticket link to registrations that are not confirmed", async () => { mocks.broadcastFind.mockResolvedValue({ subject: "Waitlist update", From 8caf47b6dd6d4a678011cc1e910c22b17410a705 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 22:25:14 +0530 Subject: [PATCH 38/79] fix(admission): no offers or acceptances on cancelled events Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../applications/decisions-and-offers.mdx | 1 + docs/ADMISSION.md | 8 +- lib/admission/decisions.ts | 5 +- lib/admission/maintenance.ts | 17 ++-- lib/admission/promotion.ts | 8 +- lib/admission/respond.ts | 6 +- lib/admission/transitions.ts | 6 +- lib/outbox/admission.ts | 10 +- .../admission-cancelled.db.test.ts | 93 +++++++++++++++++++ 9 files changed, 134 insertions(+), 20 deletions(-) create mode 100644 tests/integration/admission-cancelled.db.test.ts diff --git a/content/help/applications/decisions-and-offers.mdx b/content/help/applications/decisions-and-offers.mdx index d190a317..d4992a95 100644 --- a/content/help/applications/decisions-and-offers.mdx +++ b/content/help/applications/decisions-and-offers.mdx @@ -51,4 +51,5 @@ Waitlisted applicants are offered places first in, first offered, whenever a pla - Moving an application to the next stage emails the applicant only if you turned that email on. - The guest list's **Approve**, **Decline** and **Promote** don't work for applications. +- Once you cancel the event, no more places are offered and open offers can't be accepted. Offer and reminder emails still waiting to go out are not sent. Applicants can still decline or withdraw. - Every release, override and assignment is recorded in the organisation's audit log. diff --git a/docs/ADMISSION.md b/docs/ADMISSION.md index be9c6eb5..c2b819ff 100644 --- a/docs/ADMISSION.md +++ b/docs/ADMISSION.md @@ -253,6 +253,10 @@ rate limited), or an organiser answers for them (audited): at a time); the applicant is emailed. - Offers being paid count as OFFERED against the offer capacity, and a live hold keeps its seat from other acceptances. +- A cancelled event makes no offers and accepts none, checked under the Event + lock (`offerPlace`, release, promotion, AUTO, accept): the beat skips it + for reminders, reopened holds, AUTO and the waitlist, and only expires its + ended offers. Decline and withdraw still work. ## Waitlist @@ -304,7 +308,9 @@ stage-advanced. The payload carries ids and the decision revision only; the ticket link is added at send time, and a notice is dropped when the application moved on. `stage-advanced` is queued only when the organiser turned `emailApplicantsOnStageAdvance` on; every other notice always goes -out (each carries a decision or a deadline). Accepting a free place sends +out (each carries a decision or a deadline). Once the event is cancelled, +offer, reminder, extended, waitlisted and stage-advanced notices still +queued are dropped at delivery. Accepting a free place sends the usual `rsvp-status` ticket email; paid places get the payment emails. The registration email says "Application received" on these events. diff --git a/lib/admission/decisions.ts b/lib/admission/decisions.ts index ab1af20a..6034bf0a 100644 --- a/lib/admission/decisions.ts +++ b/lib/admission/decisions.ts @@ -1,6 +1,6 @@ import "server-only"; -import { AdmissionBatchStatus, AdmissionDecisionOutcome, Prisma } from "@prisma/client"; +import { AdmissionBatchStatus, AdmissionDecisionOutcome, EventStatus, Prisma } from "@prisma/client"; import { prisma } from "@/lib/db"; import { recordAuditEvent } from "@/lib/audit"; import type { WebhookEmission } from "@/lib/webhooks/emit"; @@ -9,7 +9,7 @@ import { stageCoverage } from "./coverage"; import { emitAll } from "./notify"; import { allowedOutcomes, coverageMet, offerCeiling, offersLeft } from "./rules"; import { AdmissionError, applicationSelect, countOfferHolding, lockAdmissionEvent, type ApplicationRow } from "./state"; -import { advanceApplication, OFFERS_CLOSED, offerPlace, rejectApplication, waitlistApplication } from "./transitions"; +import { advanceApplication, EVENT_CANCELLED, OFFERS_CLOSED, offerPlace, rejectApplication, waitlistApplication } from "./transitions"; /* * Decisions (docs/ADMISSION.md "Decisions"). Organisers draft decisions into @@ -109,6 +109,7 @@ export async function releaseDraftBatch(ctx: AdmissionContext, now = new Date()) const stale = batch.decisions.filter(isStale); const live = batch.decisions.filter((d) => !isStale(d)); if (live.length === 0) return fail(`${stale.length === 1 ? "The application has" : "Every application has"} changed since the decision was drafted. Draft again.`); + if (live.some((d) => d.outcome === "ACCEPT") && event.status === EventStatus.CANCELLED) return fail(EVENT_CANCELLED); if (live.some((d) => d.outcome === "ACCEPT") && event.startDateTime <= now) return fail(OFFERS_CLOSED); // Review coverage, per stage. diff --git a/lib/admission/maintenance.ts b/lib/admission/maintenance.ts index f900d257..cd6f9bf8 100644 --- a/lib/admission/maintenance.ts +++ b/lib/admission/maintenance.ts @@ -1,6 +1,6 @@ import "server-only"; -import { AdmissionMode, AdmissionStatus, OrderStatus, RsvpStatus } from "@prisma/client"; +import { AdmissionMode, AdmissionStatus, EventStatus, OrderStatus, RsvpStatus } from "@prisma/client"; import { prisma } from "@/lib/db"; import type { WebhookEmission } from "@/lib/webhooks/emit"; import { emitAll, queueNotice } from "./notify"; @@ -22,6 +22,9 @@ import { closeApplication } from "./transitions"; * 3. One reminder per offer, a day before it ends. * 4. AUTO events decide new applications; every event's waitlist is offered * any free places. + * + * A cancelled event gets no reopened offers, reminders or new offers: only + * its ended offers expire. */ const BATCH = 50; @@ -37,7 +40,7 @@ async function settleOffer(applicationId: string, now: Date, webhooks: WebhookEm if (!event || !app || app.status !== AdmissionStatus.OFFERED) return null; const members = await unitMembers(tx, app); if (members.some((member) => member.status === RsvpStatus.PENDING_PAYMENT && member.holdExpiresAt && member.holdExpiresAt > now)) return null; - if (app.respondBy && app.respondBy > now) return (await reopenEndedHold(tx, app)) ? "reopened" : null; + if (app.respondBy && app.respondBy > now) return event.status !== EventStatus.CANCELLED && (await reopenEndedHold(tx, app)) ? "reopened" : null; webhooks.push(...(await closeApplication({ tx, event, app, now }, AdmissionStatus.EXPIRED))); return "expired"; }, { timeout: 20_000, maxWait: 10_000 }); @@ -55,8 +58,8 @@ export async function runAdmissionMaintenance(now = new Date()) { status: AdmissionStatus.OFFERED, OR: [ { respondBy: { lte: now } }, - { rsvp: { order: { status: OrderStatus.EXPIRED } } }, - { team: { orders: { some: { teamName: { not: null }, status: OrderStatus.EXPIRED } } } }, + { rsvp: { order: { status: OrderStatus.EXPIRED } }, event: { status: { not: EventStatus.CANCELLED } } }, + { team: { orders: { some: { teamName: { not: null }, status: OrderStatus.EXPIRED } } }, event: { status: { not: EventStatus.CANCELLED } } }, ], }, select: { id: true, eventId: true }, @@ -71,7 +74,7 @@ export async function runAdmissionMaintenance(now = new Date()) { } const remind = await prisma.admissionApplication.findMany({ - where: { status: AdmissionStatus.OFFERED, offerReminderSentAt: null, respondBy: { gt: now, lte: new Date(now.getTime() + OFFER_REMINDER_BEFORE_MS) }, offeredAt: { lte: new Date(now.getTime() - REMINDER_MIN_AGE_MS) } }, + where: { status: AdmissionStatus.OFFERED, offerReminderSentAt: null, event: { status: { not: EventStatus.CANCELLED } }, respondBy: { gt: now, lte: new Date(now.getTime() + OFFER_REMINDER_BEFORE_MS) }, offeredAt: { lte: new Date(now.getTime() - REMINDER_MIN_AGE_MS) } }, select: { id: true, rsvpId: true, teamId: true, decisionRevision: true, event: { select: { organisationId: true } } }, take: BATCH, }); @@ -84,12 +87,12 @@ export async function runAdmissionMaintenance(now = new Date()) { }); } - const auto = await prisma.event.findMany({ where: { admissionEnabled: true, admissionMode: AdmissionMode.AUTO, startDateTime: { gt: now }, admissionApplications: { some: { status: AdmissionStatus.IN_REVIEW } } }, select: { id: true }, take: BATCH }); + const auto = await prisma.event.findMany({ where: { admissionEnabled: true, status: { not: EventStatus.CANCELLED }, admissionMode: AdmissionMode.AUTO, startDateTime: { gt: now }, admissionApplications: { some: { status: AdmissionStatus.IN_REVIEW } } }, select: { id: true }, take: BATCH }); for (const event of auto) { await autoDecide(event.id, now).catch(() => 0); touched.add(event.id); } - const waiting = await prisma.event.findMany({ where: { admissionEnabled: true, startDateTime: { gt: now }, admissionApplications: { some: { status: AdmissionStatus.WAITLISTED } } }, select: { id: true }, take: BATCH }); + const waiting = await prisma.event.findMany({ where: { admissionEnabled: true, status: { not: EventStatus.CANCELLED }, startDateTime: { gt: now }, admissionApplications: { some: { status: AdmissionStatus.WAITLISTED } } }, select: { id: true }, take: BATCH }); let promoted = 0; for (const event of new Set([...touched, ...waiting.map((row) => row.id)])) promoted += await promoteWaitlisted(event, now).catch(() => 0); diff --git a/lib/admission/promotion.ts b/lib/admission/promotion.ts index a3789cd6..3305e96f 100644 --- a/lib/admission/promotion.ts +++ b/lib/admission/promotion.ts @@ -1,6 +1,6 @@ import "server-only"; -import { AdmissionMode, AdmissionStatus, RegistrationMode } from "@prisma/client"; +import { AdmissionMode, AdmissionStatus, EventStatus, RegistrationMode } from "@prisma/client"; import { prisma } from "@/lib/db"; import type { WebhookEmission } from "@/lib/webhooks/emit"; import { emitAll } from "./notify"; @@ -51,8 +51,8 @@ export async function promoteWaitlisted(eventId: string, now = new Date()): Prom const webhooks: WebhookEmission[] = []; const offered = await prisma.$transaction(async (tx) => { const event = await lockAdmissionEvent(tx, eventId); - // No offers once the event has started: an offer couldn't be answered in time. - if (!event?.admissionEnabled || event.startDateTime <= now) return 0; + // No offers once the event has started (an offer couldn't be answered in time) or was cancelled. + if (!event?.admissionEnabled || event.status === EventStatus.CANCELLED || event.startDateTime <= now) return 0; const left = offersLeft(offerCeiling(event), await countOfferHolding(tx, event.id)); if (left === 0) return 0; const queue = await tx.admissionApplication.findMany({ where: { eventId, status: AdmissionStatus.WAITLISTED }, orderBy: [{ queuedAt: "asc" }, { id: "asc" }], take: QUEUE_PAGE, select: applicationSelect }); @@ -83,7 +83,7 @@ export async function autoDecide(eventId: string, now = new Date()): Promise { const event = await lockAdmissionEvent(tx, eventId); - if (!event?.admissionEnabled || event.admissionMode !== AdmissionMode.AUTO || event.startDateTime <= now) return 0; + if (!event?.admissionEnabled || event.status === EventStatus.CANCELLED || event.admissionMode !== AdmissionMode.AUTO || event.startDateTime <= now) return 0; let left = offersLeft(offerCeiling(event), await countOfferHolding(tx, event.id)); const pending = await tx.admissionApplication.findMany({ where: { eventId, status: AdmissionStatus.IN_REVIEW }, orderBy: [{ submittedAt: "asc" }, { id: "asc" }], take: QUEUE_PAGE, select: applicationSelect }); for (const app of pending) { diff --git a/lib/admission/respond.ts b/lib/admission/respond.ts index 79b75d41..234e8f73 100644 --- a/lib/admission/respond.ts +++ b/lib/admission/respond.ts @@ -1,6 +1,6 @@ import "server-only"; -import { AdmissionStatus, RsvpStatus } from "@prisma/client"; +import { AdmissionStatus, EventStatus, RsvpStatus } from "@prisma/client"; import { prisma } from "@/lib/db"; import { recordAuditEvent } from "@/lib/audit"; import { enqueueRsvpStatusNotification } from "@/lib/outbox"; @@ -12,7 +12,7 @@ import { holdPaidPlace } from "./payment"; import { promoteWaitlisted } from "./promotion"; import { extensionRefusal } from "./rules"; import { AdmissionError, applicationSelect, isPaid, lockAdmissionEvent, personEmailOf, placesTaken, setUnitStatus, ticketTypeHasRoom, unitMembers, unitOrder, updateApplication } from "./state"; -import { closeApplication } from "./transitions"; +import { closeApplication, EVENT_CANCELLED } from "./transitions"; import { queueNotice } from "./notify"; /* @@ -24,6 +24,7 @@ import { queueNotice } from "./notify"; * lock) with the usual ticket email. * - Accept, paid place: the place is held while the order is paid (see * payment.ts); the payment confirms it. Never confirmed unpaid. + * - Accept is refused once the event is cancelled (checked under the lock). * - Decline (an offer) or withdraw (in review, waitlisted or offered): closed * for good, any unpaid order cancelled, and the next waitlisted application * is offered the place. @@ -79,6 +80,7 @@ async function respond(eventId: string, applicationId: string, action: RespondAc if (app.status === AdmissionStatus.ACCEPTED) return { ok: true, status: app.status, paying: false }; if (app.status !== AdmissionStatus.OFFERED) return fail("There's no offer to accept."); if (!app.respondBy || app.respondBy <= now) return fail("This offer has ended."); + if (event.status === EventStatus.CANCELLED) return fail(EVENT_CANCELLED); if (paying) return { ok: true, status: app.status, paying: true }; if (account) { await holdPaidPlace(tx, { event, app, account, actorUserId, now }); diff --git a/lib/admission/transitions.ts b/lib/admission/transitions.ts index 5076c03a..b75f1341 100644 --- a/lib/admission/transitions.ts +++ b/lib/admission/transitions.ts @@ -1,6 +1,6 @@ import "server-only"; -import { AdmissionEntrySource, AdmissionStatus, RsvpStatus, type Prisma } from "@prisma/client"; +import { AdmissionEntrySource, AdmissionStatus, EventStatus, RsvpStatus, type Prisma } from "@prisma/client"; import type { WebhookEmission } from "@/lib/webhooks/emit"; import { queueNotice } from "./notify"; import { respondByFor } from "./rules"; @@ -15,6 +15,7 @@ import { AdmissionError, cancelUnpaidOrder, setUnitStatus, updateApplication, ty */ export const OFFERS_CLOSED = "The event has started, so no more places can be offered."; +export const EVENT_CANCELLED = "This event has been cancelled, so no places can be offered or accepted."; type Change = { tx: Prisma.TransactionClient; event: AdmissionEvent; app: ApplicationRow; now: Date }; @@ -22,7 +23,8 @@ const lockAnswers = (app: ApplicationRow, now: Date) => (app.answersLockedAt ? { /** Offer a place: OFFERED until respondBy; the registrations stay pending (nothing is held or charged). */ export async function offerPlace({ tx, event, app, now }: Change): Promise { - // An offer must be answerable before the event starts. + // An offer must be answerable before the event starts, and the event must still be on. + if (event.status === EventStatus.CANCELLED) throw new AdmissionError(EVENT_CANCELLED); if (event.startDateTime <= now) throw new AdmissionError(OFFERS_CLOSED); const respondBy = respondByFor(now, event.admissionRsvpDays, event.startDateTime); await updateApplication(tx, app, { status: AdmissionStatus.OFFERED, offeredAt: now, respondBy, queuedAt: null, skipReason: null, offerReminderSentAt: null, decisionRevision: { increment: 1 }, ...lockAnswers(app, now) }); diff --git a/lib/outbox/admission.ts b/lib/outbox/admission.ts index 36f9ef89..8fcab8ac 100644 --- a/lib/outbox/admission.ts +++ b/lib/outbox/admission.ts @@ -10,9 +10,11 @@ import "server-only"; * Offers, reminders, expiries, extensions, waitlist places and declines * always go out (they carry a decision or a deadline). `stage-advanced` is * queued only when the organiser turned Event.emailApplicantsOnStageAdvance on. + * Once the event is cancelled, notices that invite an answer or promise a + * place later are dropped; expiries and declines still go out. */ -import { AdmissionStatus, Prisma } from "@prisma/client"; +import { AdmissionStatus, EventStatus, Prisma } from "@prisma/client"; import { prisma } from "@/lib/db"; import { sendAdmissionNoticeEmail, type AdmissionNoticeKind } from "@/lib/email/admission"; import { getBaseUrl } from "@/lib/email"; @@ -35,6 +37,9 @@ const NEEDS: Record = { "stage-advanced": AdmissionStatus.IN_REVIEW, }; +/** Notices that are obsolete once the event is cancelled. */ +const OBSOLETE_WHEN_CANCELLED: ReadonlySet = new Set(["offer", "reminder", "extended", "waitlisted", "stage-advanced"]); + export type AdmissionNoticePayload = { to: string; organisationId: string; applicationId: string; rsvpId: string; notice: AdmissionNoticeKind; revision: number; recipientName?: string }; export async function enqueueAdmissionNotices(client: OutboxClient, payloads: AdmissionNoticePayload[]) { @@ -64,11 +69,12 @@ export async function deliverAdmissionNotice(value: Prisma.JsonValue, messageId: respondBy: true, currentStage: { select: { name: true } }, team: { select: { name: true } }, - event: { select: { title: true, timezone: true } }, + event: { select: { title: true, timezone: true, status: true } }, }, }); const rsvp = await prisma.rSVP.findUnique({ where: { id: payload.rsvpId }, select: { id: true, teamId: true, checkInToken: true, order: { select: { totalMinor: true } } } }); if (!app || !rsvp || app.status !== NEEDS[payload.notice] || app.decisionRevision !== payload.revision) return "dropped"; + if (app.event.status === EventStatus.CANCELLED && OBSOLETE_WHEN_CANCELLED.has(payload.notice)) return "dropped"; // The recipient must still be this application's registration (or one of its team). if (app.rsvpId ? app.rsvpId !== rsvp.id : app.teamId !== rsvp.teamId) return "dropped"; const order = app.teamId diff --git a/tests/integration/admission-cancelled.db.test.ts b/tests/integration/admission-cancelled.db.test.ts new file mode 100644 index 00000000..7e4892ff --- /dev/null +++ b/tests/integration/admission-cancelled.db.test.ts @@ -0,0 +1,93 @@ +import { AdmissionMode, AdmissionStatus, EventStatus, RsvpStatus } from "@prisma/client"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/auth", () => ({ auth: vi.fn().mockResolvedValue(null) })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +const sent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +vi.mock("@/lib/email/admission", () => ({ sendAdmissionNoticeEmail: sent })); + +import { prisma } from "@/lib/db"; +import { draftDecisions, releaseDraftBatch } from "@/lib/admission/decisions"; +import { runAdmissionMaintenance } from "@/lib/admission/maintenance"; +import { autoDecide, promoteWaitlisted } from "@/lib/admission/promotion"; +import { respondAsApplicant } from "@/lib/admission/respond"; +import { deliverAdmissionNotice } from "@/lib/outbox/admission"; +import { appOf, apply, createAdmissionEvent, decide, rsvpOf, setUpAdmission, tearDownAdmission } from "./helpers/admission"; + +/* + * A cancelled event and its applications (docs/ADMISSION.md "Offers"): no + * acceptance, no new offers from any path or the beat, queued offer emails + * dropped at delivery; decline and withdraw still work. + */ + +const cancel = (eventId: string) => prisma.event.update({ where: { id: eventId }, data: { status: EventStatus.CANCELLED } }); +const noticeRow = (applicationId: string, notice: string) => + prisma.outboxMessage.findFirstOrThrow({ where: { kind: "admission-notice", AND: [{ payload: { path: ["applicationId"], equals: applicationId } }, { payload: { path: ["notice"], equals: notice } }] } }); + +beforeAll(() => setUpAdmission("Cancelled applications")); +afterAll(() => tearDownAdmission()); +beforeEach(() => sent.mockClear()); + +describe.sequential("applications on a cancelled event", () => { + it("refuses accepting an offer once the event is cancelled, and still lets the applicant decline or withdraw", async () => { + const { event, ctx } = await createAdmissionEvent({ capacity: 10 }); + const [a, b, c] = [await apply(event, "ana"), await apply(event, "ben"), await apply(event, "cai")]; + await decide(ctx, [a.application.id, b.application.id], "ACCEPT"); + await cancel(event.id); + + expect(await respondAsApplicant(a.token, "accept")).toEqual({ ok: false, error: expect.stringContaining("cancelled") }); + expect(await appOf(a.application.id)).toMatchObject({ status: AdmissionStatus.OFFERED }); + expect((await rsvpOf(a.rsvp.id)).status).toBe(RsvpStatus.PENDING_APPROVAL); + expect(await prisma.outboxMessage.count({ where: { kind: "rsvp-status", payload: { path: ["checkInToken"], equals: a.token } } })).toBe(0); + + expect(await respondAsApplicant(b.token, "decline")).toMatchObject({ ok: true, status: AdmissionStatus.DECLINED }); + expect(await respondAsApplicant(c.token, "withdraw")).toMatchObject({ ok: true, status: AdmissionStatus.WITHDRAWN }); + }); + + it("makes no offers on a cancelled event: release, promotion, AUTO and the beat", async () => { + const { event, ctx } = await createAdmissionEvent({ offerCapacity: 1 }); + const holder = await apply(event, "hol"); + const waiting = await apply(event, "wai"); + const fresh = await apply(event, "fre"); + await decide(ctx, [holder.application.id], "ACCEPT"); + await decide(ctx, [waiting.application.id], "WAITLIST"); + // The holder's offer is due a reminder, and has ended so its place frees up. + await prisma.admissionApplication.update({ where: { id: holder.application.id }, data: { offeredAt: new Date(Date.now() - 3 * 86_400_000), respondBy: new Date(Date.now() + 3_600_000) } }); + await prisma.event.update({ where: { id: event.id }, data: { admissionMode: AdmissionMode.AUTO, admissionOfferCapacity: null } }); + await cancel(event.id); + + const drafted = await draftDecisions(ctx, { applicationIds: [fresh.application.id], outcome: "ACCEPT", overrideReason: "Testing without reviews" }); + expect(drafted.ok).toBe(true); + expect(await releaseDraftBatch(ctx)).toEqual({ ok: false, error: expect.stringContaining("cancelled") }); + expect(await promoteWaitlisted(event.id)).toBe(0); + expect(await autoDecide(event.id)).toBe(0); + + const pass = await runAdmissionMaintenance(); + expect(pass.promoted).toBe(0); + expect(await appOf(holder.application.id)).toMatchObject({ status: AdmissionStatus.OFFERED, offerReminderSentAt: null }); + expect(await appOf(waiting.application.id)).toMatchObject({ status: AdmissionStatus.WAITLISTED }); + expect(await appOf(fresh.application.id)).toMatchObject({ status: AdmissionStatus.IN_REVIEW }); + + // An ended offer still expires, which frees nothing on a cancelled event. + await prisma.admissionApplication.update({ where: { id: holder.application.id }, data: { respondBy: new Date(Date.now() - 60_000) } }); + expect(await runAdmissionMaintenance()).toMatchObject({ promoted: 0 }); + expect(await appOf(holder.application.id)).toMatchObject({ status: AdmissionStatus.EXPIRED }); + expect(await appOf(waiting.application.id)).toMatchObject({ status: AdmissionStatus.WAITLISTED }); + }); + + it("drops an offer email still queued when the event is cancelled, and sends it otherwise", async () => { + const { event, ctx } = await createAdmissionEvent({ capacity: 10 }); + const kept = await apply(event, "kep"); + const dropped = await apply(event, "dro"); + await decide(ctx, [kept.application.id, dropped.application.id], "ACCEPT"); + + const live = await noticeRow(kept.application.id, "offer"); + expect(await deliverAdmissionNotice(live.payload, live.id)).toBe("sent"); + expect(sent).toHaveBeenCalledTimes(1); + + await cancel(event.id); + const stale = await noticeRow(dropped.application.id, "offer"); + expect(await deliverAdmissionNotice(stale.payload, stale.id)).toBe("dropped"); + expect(sent).toHaveBeenCalledTimes(1); + }); +}); From fe4c1dc123647a4e946d7d2672f6911481592f94 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 22:27:04 +0530 Subject: [PATCH 39/79] fix(auth): treat a missing or malformed device id as an unknown device for alerts and audit Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- content/help/account/sign-up-and-sign-in.mdx | 2 +- docs/SECURITY.md | 2 +- lib/loginSecurity.ts | 18 +++++++++------ tests/integration/login-security.db.test.ts | 24 +++++++++++++++++++- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/content/help/account/sign-up-and-sign-in.mdx b/content/help/account/sign-up-and-sign-in.mdx index d0448c3d..be1af761 100644 --- a/content/help/account/sign-up-and-sign-in.mdx +++ b/content/help/account/sign-up-and-sign-in.mdx @@ -68,7 +68,7 @@ Only five sign-in codes can be emailed to an account each hour. ## New sign-in alerts -When someone signs in to your account with its password from a new browser or device, you get a "New sign-in to your account" email naming the browser and system. If it was you, there's nothing to do. If it wasn't, reset your password and use **Sign out everywhere**; see [Profile, appearance and security](/help/account/profile-and-security#sign-out-everywhere). New devices also appear in your **Recent security activity**. +When someone signs in to your account with its password from a new browser or device, you get a "New sign-in to your account" email naming the browser and system. If it was you, there's nothing to do. If it wasn't, reset your password and use **Sign out everywhere**; see [Profile, appearance and security](/help/account/profile-and-security#sign-out-everywhere). New devices also appear in your **Recent security activity**. A sign-in that doesn't identify its device, such as one made by a script, counts as a new device every time. ## Continue with Google diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 628ba2f1..9615e2e9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -115,7 +115,7 @@ This page documents implemented controls and security boundaries. It is not a su ## Mobile sessions - The Space Works apps authenticate as ordinary users under `/api/mobile/v1`; they are never machine clients and never receive organisation-wide credentials. A signed-in mobile request runs as an explicit request actor (`lib/requestActor.ts`): `auth()` presents the app session, and every mutation runs the matching Server Action's own body, so role checks, payment holds, refunds, capacity transactions, rate limits, webhooks and audit are shared. -- Password sign-in uses the web verifier (`lib/credentialsAuth.ts`) with every protection: address and account failure limits, the emailed code for a new install or an account under attack, the new sign-in alert, and the authenticator. Google sign-in verifies the ID token's signature against Google's JWKS, issuer, expiry, audience (the web client id or the app client ids) and `email_verified`, then applies the web linking and account-creation policy. Mobile Google sign-in stays off until app client ids are configured, and a web-audience token must name one of them as `azp`. +- Password sign-in uses the web verifier (`lib/credentialsAuth.ts`) with every protection: address and account failure limits, the emailed code for a new install or an account under attack, the new sign-in alert (a missing or malformed device id counts as an unknown device for the alert and the `SIGN_IN_NEW_DEVICE` audit; only remembering a device needs one), and the authenticator. Google sign-in verifies the ID token's signature against Google's JWKS, issuer, expiry, audience (the web client id or the app client ids) and `email_verified`, then applies the web linking and account-creation policy. Mobile Google sign-in stays off until app client ids are configured, and a web-audience token must name one of them as `azp`. - Access tokens (15 minutes) and refresh tokens (60 days) are 32 random bytes; only SHA-256 digests are stored in `MobileSession`, with a SHA-256 of the install's id. Refresh tokens rotate on every use and must come with the install id the app signed in with; replaying any of the last three rotated tokens (the newest only after a 30-second retry grace), or presenting one with another install id, revokes the session (`MOBILE_REFRESH_TOKEN_REUSED`). The install id is client-supplied, so it stops a token replayed without it, not one copied together with it. All authentication failures return one generic 401. - Every session records the user's `sessionVersion` and is rejected once it changes, so password change or reset, authenticator enable/disable, "sign out everywhere" and operator recovery revoke app sessions with web sessions; those transactions also mark the rows revoked. "Sign out everywhere" from the app needs a sign-in in the last 10 minutes. - Staff authenticator: an app session carries its own `mfaVerifiedAt` (a sign-in with `totp`, or `POST /auth/mfa`), valid for 12 hours. Organiser routes refuse with `STAFF_MFA_REQUIRED`/`STAFF_MFA_ENROLMENT_REQUIRED` until then, and `getMembership`, `canAccessEvent` and `canViewEventDashboard` fail closed for the app session as well. diff --git a/lib/loginSecurity.ts b/lib/loginSecurity.ts index 8033337d..cbc23d98 100644 --- a/lib/loginSecurity.ts +++ b/lib/loginSecurity.ts @@ -106,19 +106,23 @@ export function newSignInAdvice(email: string): string { /** * Remember the device this sign-in came from. A device the account hasn't - * used before (other than its very first) sends a "new sign-in" alert. + * used before (other than its very first) sends a "new sign-in" alert. A + * missing or malformed device id counts as an unknown device: it is audited + * and alerted every time, and only remembering it needs a usable hash. */ export async function rememberSignIn(params: { userId: string; email: string; hash: string | null; ip: string; userAgent: string; firstDevice: boolean; }) { - if (!params.hash) return; const userAgent = describeBrowser(params.userAgent); const lastIp = params.ip === "unknown" ? null : params.ip.slice(0, 64); - const existing = await prisma.userDevice.findUnique({ where: { userId_deviceHash: { userId: params.userId, deviceHash: params.hash } }, select: { id: true } }); - if (existing) { - await prisma.userDevice.update({ where: { id: existing.id }, data: { lastSeenAt: new Date(), lastIp, userAgent } }); - return; + const hash = params.hash; + if (hash) { + const existing = await prisma.userDevice.findUnique({ where: { userId_deviceHash: { userId: params.userId, deviceHash: hash } }, select: { id: true } }); + if (existing) { + await prisma.userDevice.update({ where: { id: existing.id }, data: { lastSeenAt: new Date(), lastIp, userAgent } }); + return; + } } await prisma.$transaction(async (tx) => { - await tx.userDevice.create({ data: { userId: params.userId, deviceHash: params.hash!, userAgent, lastIp } }); + if (hash) await tx.userDevice.create({ data: { userId: params.userId, deviceHash: hash, userAgent, lastIp } }); await tx.auditEvent.create({ data: { action: "SIGN_IN_NEW_DEVICE", actorUserId: params.userId, targetType: "User", targetId: params.userId } }); if (!params.firstDevice) { await enqueueOperatorNotice(tx, { diff --git a/tests/integration/login-security.db.test.ts b/tests/integration/login-security.db.test.ts index 73c69acc..15e308e3 100644 --- a/tests/integration/login-security.db.test.ts +++ b/tests/integration/login-security.db.test.ts @@ -4,12 +4,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites const session = vi.hoisted(() => ({ value: null as null | { user: { id: string; }; mfaVerified?: boolean; }, proof: undefined as string | undefined, token: undefined as string | undefined })); vi.mock("@/lib/auth", () => ({ auth: async () => session.value })); -vi.mock("next/headers", () => ({ cookies: async () => ({ +vi.mock("next/headers", () => ({ headers: async () => new Headers(), cookies: async () => ({ get: () => (session.proof ? { value: session.proof } : undefined), getAll: () => (session.token ? [{ name: "swe.session-token.v1", value: session.token }] : []), }) })); +import bcrypt from "bcryptjs"; import { prisma } from "@/lib/db"; +import { verifyPasswordSignIn } from "@/lib/credentialsAuth"; import { accountUnderAttack, consumeSignInCode, deviceHash, isAddressBlocked, knownDevice, noteFailedSignIn, rememberSignIn, sendSignInCode, } from "@/lib/loginSecurity"; @@ -64,6 +66,26 @@ describe("sign-in protections", () => { expect(device!.deviceHash).not.toContain("b".repeat(64)); }); + it("audits and alerts a password sign-in that sends no usable device id", async () => { + await prisma.user.update({ where: { id: users.attendee! }, data: { passwordHash: await bcrypt.hash("correct horse battery", 4) } }); + await rememberSignIn({ userId: users.attendee!, email: email("attendee"), hash: deviceHash("c".repeat(64)), ip: "203.0.113.5", userAgent: "Chrome/130.0", firstDevice: true }); + const audits = () => prisma.auditEvent.count({ where: { action: "SIGN_IN_NEW_DEVICE", actorUserId: users.attendee! } }); + const alerts = () => prisma.outboxMessage.count({ where: { kind: "operator-notice", payload: { path: ["to"], equals: email("attendee") } } }); + expect([await audits(), await alerts()]).toEqual([1, 0]); + + const context = { ip: "198.51.100.20", userAgent: "curl/8.0" }; + const withoutDevice = await verifyPasswordSignIn({ email: email("attendee"), password: "correct horse battery" }, context); + expect(withoutDevice.ok).toBe(true); + expect([await audits(), await alerts()]).toEqual([2, 1]); + // A malformed id is no better than none, and neither is remembered. + const malformed = await verifyPasswordSignIn({ email: email("attendee"), password: "correct horse battery", device: "not-a-device-id" }, context); + expect(malformed.ok).toBe(true); + expect([await audits(), await alerts()]).toEqual([3, 2]); + expect(await prisma.userDevice.count({ where: { userId: users.attendee! } })).toBe(1); + await prisma.userDevice.deleteMany({ where: { userId: users.attendee! } }); + await prisma.user.update({ where: { id: users.attendee! }, data: { passwordHash: null } }); + }); + it("emails a code that works once, and never stores the code itself", async () => { expect(await sendSignInCode({ id: users.person!, email: email("person") })).toBe(true); const message = await prisma.outboxMessage.findFirstOrThrow({ where: { kind: "sign-in-code", payload: { path: ["to"], equals: email("person") } }, orderBy: { createdAt: "desc" } }); From f988818fe638dabfc9b50e81defc36d027379f5b Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 22:34:14 +0530 Subject: [PATCH 40/79] fix(net): one absolute deadline for Mattermost and alert webhook posts, response destroyed after its status Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- lib/chat/send.ts | 37 ++++---------- lib/net/pinnedPost.ts | 69 ++++++++++++++++++++++++++ lib/opsWebhookAddress.ts | 47 +++--------------- tests/unit/chat-send.test.ts | 42 +++++++++++++++- tests/unit/ops-webhook-address.test.ts | 50 ++++++++++++++++++- 5 files changed, 178 insertions(+), 67 deletions(-) create mode 100644 lib/net/pinnedPost.ts diff --git a/lib/chat/send.ts b/lib/chat/send.ts index be9d9cf5..a3d88c69 100644 --- a/lib/chat/send.ts +++ b/lib/chat/send.ts @@ -1,9 +1,7 @@ import "server-only"; -import { request } from "node:https"; -import type { LookupFunction } from "node:net"; +import { beforeDeadline, DEADLINE_PASSED, postPinnedJson } from "@/lib/net/pinnedPost"; import { resolvePublicAddresses } from "@/lib/net/publicAddress"; -import { pinnedLookup } from "@/lib/webhooks/urlSafety"; import type { ChatConfig } from "./config"; import type { ChatKind } from "./events"; @@ -38,32 +36,19 @@ async function postJson(url: string, body: unknown, headers: Record((resolve, reject) => { - const req = request({ - host: target.hostname, - port: target.port || 443, - path: `${target.pathname}${target.search}`, - method: "POST", - servername: target.hostname, - lookup: pinnedLookup(addresses) as unknown as LookupFunction, - headers: { "Content-Type": "application/json", "Content-Length": payload.length }, - timeout: TIMEOUT_MS, - }, (response) => { - response.resume(); - const status = response.statusCode ?? 0; - if (status >= 200 && status < 300) resolve(); - else reject(failure(status)); - }); - req.on("timeout", () => req.destroy(new ChatDeliveryError("The chat service did not answer in time.", false))); - req.on("error", (error) => reject(error instanceof ChatDeliveryError ? error : new ChatDeliveryError("Couldn't reach the chat service.", false))); - req.end(payload); - }); + const deadline = Date.now() + TIMEOUT_MS; + const timedOut = () => new ChatDeliveryError("The chat service did not answer in time.", false); + const addresses = await beforeDeadline(resolvePublicAddresses(target.hostname), deadline); + if (addresses === DEADLINE_PASSED) throw timedOut(); + const outcome = await postPinnedJson(target, addresses, Buffer.from(JSON.stringify(body)), deadline); + if ("error" in outcome) throw outcome.error === "timeout" ? timedOut() : new ChatDeliveryError("Couldn't reach the chat service.", false); + if (outcome.status < 200 || outcome.status >= 300) throw failure(outcome.status); } /** Post one plain-text message. Throws ChatDeliveryError on failure. */ diff --git a/lib/net/pinnedPost.ts b/lib/net/pinnedPost.ts new file mode 100644 index 00000000..32b8e7da --- /dev/null +++ b/lib/net/pinnedPost.ts @@ -0,0 +1,69 @@ +import "server-only"; + +import http from "node:http"; +import https from "node:https"; +import net from "node:net"; +import { pinnedLookup, type ResolvedAddress } from "@/lib/webhooks/urlSafety"; + +/* + * A JSON POST to a server someone else runs (the alert webhook, an + * organisation's Mattermost), under one absolute deadline that the caller + * starts before its DNS check. Node's own `timeout` only measures socket + * inactivity, so a server answering a byte at a time could hold a worker for + * hours; here the deadline runs until the socket is closed, and the response + * is destroyed as soon as its status arrives, so no body is ever read. + */ + +export const DEADLINE_PASSED = Symbol("deadline passed"); + +/** `work`'s result, or DEADLINE_PASSED once the deadline (an epoch millisecond) passes first. */ +export function beforeDeadline(work: Promise, deadline: number): Promise { + let timer: NodeJS.Timeout | undefined; + const expiry = new Promise((resolve) => { + timer = setTimeout(() => resolve(DEADLINE_PASSED), Math.max(0, deadline - Date.now())); + }); + return Promise.race([work, expiry]).finally(() => clearTimeout(timer)); +} + +export type PinnedPostOutcome = { status: number; } | { error: "timeout" | "unreachable"; }; + +/** + * POST `body` to `target`, connected only to `addresses` (checked by the + * caller and pinned, so DNS can't switch them in between), with TLS checked + * against the host name and no redirects followed. + */ +export function postPinnedJson(target: URL, addresses: ResolvedAddress[], body: Buffer, deadline: number): Promise { + const hostname = target.hostname.replace(/^\[|\]$/g, ""); + const isHttps = target.protocol === "https:"; + return new Promise((resolve) => { + let settled = false; + const settle = (outcome: PinnedPostOutcome) => { + if (settled) return; + settled = true; + resolve(outcome); + }; + const request = (isHttps ? https : http).request({ + protocol: target.protocol, + hostname, + port: target.port || (isHttps ? 443 : 80), + path: `${target.pathname}${target.search}`, + method: "POST", + agent: false, + lookup: pinnedLookup(addresses) as unknown as net.LookupFunction, + ...(isHttps && !net.isIP(hostname) ? { servername: hostname } : {}), + headers: { "content-type": "application/json", "content-length": String(body.length) }, + }, (response) => { + response.on("error", () => {}); + settle({ status: response.statusCode ?? 0 }); + response.destroy(); + request.destroy(); + }); + const timer = setTimeout(() => { + settle({ error: "timeout" }); + request.destroy(); + }, Math.max(0, deadline - Date.now())); + request.on("close", () => clearTimeout(timer)); + request.on("error", () => settle({ error: "unreachable" })); + request.end(body); + }); +} diff --git a/lib/opsWebhookAddress.ts b/lib/opsWebhookAddress.ts index 762f4607..70ab01d2 100644 --- a/lib/opsWebhookAddress.ts +++ b/lib/opsWebhookAddress.ts @@ -1,10 +1,8 @@ import "server-only"; -import http from "node:http"; -import https from "node:https"; -import net from "node:net"; +import { beforeDeadline, DEADLINE_PASSED, postPinnedJson } from "@/lib/net/pinnedPost"; import { resolvePublicAddresses } from "@/lib/net/publicAddress"; -import { pinnedLookup, type ResolvedAddress } from "@/lib/webhooks/urlSafety"; +import type { ResolvedAddress } from "@/lib/webhooks/urlSafety"; export const PRIVATE_WEBHOOK_ERROR = "That webhook address points to a private or local network. Set OPS_WEBHOOK_ALLOW_PRIVATE=1 to allow one, for example a Mattermost on the same network."; @@ -35,42 +33,13 @@ export type OpsWebhookPost = { status: number; } | { error: "blocked" | "timeout * POST JSON to the alert webhook, connected only to the addresses checked a * moment before (pinned, so DNS can't switch them to a private host in * between), with TLS checked against the host name, no redirects followed - * and no response body read. + * and no response body read. One deadline covers the DNS check, the + * connection and the reply, until the socket is closed (lib/net/pinnedPost). */ export async function postOpsWebhook(url: string, json: string, timeoutMs: number): Promise { - const addresses = await vettedAddresses(url); + const deadline = Date.now() + timeoutMs; + const addresses = await beforeDeadline(vettedAddresses(url), deadline); + if (addresses === DEADLINE_PASSED) return { error: "timeout" }; if (!addresses) return { error: "blocked" }; - const target = new URL(url); - const hostname = target.hostname.replace(/^\[|\]$/g, ""); - const isHttps = target.protocol === "https:"; - const body = Buffer.from(json, "utf8"); - return new Promise((resolve) => { - let settled = false; - const finish = (outcome: OpsWebhookPost) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve(outcome); - }; - const request = (isHttps ? https : http).request({ - protocol: target.protocol, - hostname, - port: target.port || (isHttps ? 443 : 80), - path: `${target.pathname}${target.search}`, - method: "POST", - agent: false, - lookup: pinnedLookup(addresses) as unknown as net.LookupFunction, - ...(isHttps && !net.isIP(hostname) ? { servername: hostname } : {}), - headers: { "content-type": "application/json", "content-length": String(body.length) }, - }, (response) => { - response.resume(); - finish({ status: response.statusCode ?? 0 }); - }); - const timer = setTimeout(() => { - finish({ error: "timeout" }); - request.destroy(); - }, timeoutMs); - request.on("error", () => finish({ error: "unreachable" })); - request.end(body); - }); + return postPinnedJson(new URL(url), addresses, Buffer.from(json, "utf8"), deadline); } diff --git a/tests/unit/chat-send.test.ts b/tests/unit/chat-send.test.ts index 98cc983a..e8d1fe3b 100644 --- a/tests/unit/chat-send.test.ts +++ b/tests/unit/chat-send.test.ts @@ -7,7 +7,10 @@ vi.mock("@/lib/net/publicAddress", () => ({ resolvePublicAddresses: resolved.add import { describeChatTarget, parseChatConfig } from "@/lib/chat/config"; import { ChatDeliveryError, sendChatMessage } from "@/lib/chat/send"; -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); const ok = () => new Response(null, { status: 200 }); @@ -87,4 +90,41 @@ describe("sending", () => { server.close(); } }); + + it("gives up on a Mattermost host whose DNS answer never comes, at the ten-second deadline", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + resolved.addresses.mockReturnValue(new Promise(() => {})); + const sending = sendChatMessage("MATTERMOST", { url: "https://chat.example.test/hooks/abc" }, "x"); + const outcome = expect(sending).rejects.toMatchObject({ message: "The chat service did not answer in time.", permanent: false }); + await vi.advanceTimersByTimeAsync(10_000); + await outcome; + }); + + it("gives up on a Mattermost server that never completes the connection, closing the socket", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + let closed!: () => void; + let greeted!: () => void; + const socketClosed = new Promise((resolve) => { closed = resolve; }); + const handshakeStarted = new Promise((resolve) => { greeted = resolve; }); + // A plain TCP server that takes the TLS greeting and never answers it. + const server = net.createServer((socket) => { + socket.once("data", () => greeted()); + socket.on("error", () => {}); + socket.on("close", () => closed()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as net.AddressInfo; + resolved.addresses.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]); + try { + const sending = sendChatMessage("MATTERMOST", { url: `https://chat.example.test:${port}/hooks/abc` }, "x"); + const outcome = expect(sending).rejects.toMatchObject({ message: "The chat service did not answer in time." }); + await handshakeStarted; + await vi.advanceTimersByTimeAsync(10_000); + await outcome; + vi.useRealTimers(); + await socketClosed; + } finally { + server.close(); + } + }); }); diff --git a/tests/unit/ops-webhook-address.test.ts b/tests/unit/ops-webhook-address.test.ts index 7e0bb84e..9a7e4759 100644 --- a/tests/unit/ops-webhook-address.test.ts +++ b/tests/unit/ops-webhook-address.test.ts @@ -1,5 +1,5 @@ import http from "node:http"; -import type { AddressInfo } from "node:net"; +import net, { type AddressInfo } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; const dns = vi.hoisted(() => ({ lookup: vi.fn() })); @@ -32,6 +32,25 @@ async function receiver() { return { port: (server.address() as AddressInfo).port, bodies, connections: () => connections, close: () => server.close() }; } +/** + * A raw TCP receiver that answers every request with `reply`, writing + * `drip` every 25 ms forever, and reports when the client's socket closes. + */ +async function stallingReceiver(reply: string, drip: string) { + let closed!: () => void; + const socketClosed = new Promise((resolve) => { closed = resolve; }); + const server = net.createServer((socket) => { + socket.once("data", () => { + socket.write(reply); + const timer = setInterval(() => socket.write(drip), 25); + socket.on("close", () => { clearInterval(timer); closed(); }); + }); + socket.on("error", () => {}); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { port: (server.address() as AddressInfo).port, socketClosed, close: () => server.close() }; +} + describe("alert webhook address", () => { it("refuses this host, its network and cloud metadata in production", async () => { vi.stubEnv("NODE_ENV", "production"); @@ -85,6 +104,35 @@ describe("alert webhook delivery", () => { } }); + it("gives up at the deadline on a server that sends its headers a byte at a time, and closes the socket", async () => { + const slow = await stallingReceiver("HTTP/1.1 200 OK\r\n", "x"); + try { + const started = Date.now(); + await expect(postOpsWebhook(`http://127.0.0.1:${slow.port}/hooks/x`, "{}", 400)).resolves.toEqual({ error: "timeout" }); + expect(Date.now() - started).toBeLessThan(2_000); + await slow.socketClosed; + } finally { + slow.close(); + } + }); + + it("returns the status of a reply whose body never ends, and closes the socket without reading it", async () => { + const endless = await stallingReceiver("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n", "1\r\nx\r\n"); + try { + await expect(postOpsWebhook(`http://127.0.0.1:${endless.port}/hooks/x`, "{}", 30_000)).resolves.toEqual({ status: 200 }); + await endless.socketClosed; + } finally { + endless.close(); + } + }); + + it("counts a DNS answer that never comes against the same deadline", async () => { + dns.lookup.mockReturnValue(new Promise(() => {})); + const started = Date.now(); + await expect(postOpsWebhook("https://hooks.example.test/hooks/x", "{}", 300)).resolves.toEqual({ error: "timeout" }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + it("reports a refused connection as unreachable", async () => { const local = await receiver(); local.close(); From 4beef3dc5a10e912cc729a04de49cc38dcf99b33 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 22:43:43 +0530 Subject: [PATCH 41/79] fix(judging): pin one round per scoring request and re-check it under the Event lock Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/judging.ts | 10 +- .../[submissionId]/review/route.ts | 16 +-- .../[submissionId]/scores/route.ts | 17 +-- docs/MOBILE_API.md | 6 +- lib/core/judging.ts | 2 +- lib/core/judging/judgeScoring.ts | 25 +++-- lib/mobile/judgeReview.ts | 33 +++--- tests/integration/judging-scoring.db.test.ts | 4 +- .../mobile-judging-round-pin.db.test.ts | 106 ++++++++++++++++++ tests/unit/judge-review-action.test.ts | 6 +- 10 files changed, 172 insertions(+), 53 deletions(-) create mode 100644 tests/integration/mobile-judging-round-pin.db.test.ts diff --git a/app/actions/judging.ts b/app/actions/judging.ts index d06d92d4..45925b15 100644 --- a/app/actions/judging.ts +++ b/app/actions/judging.ts @@ -103,8 +103,9 @@ export async function saveJudgeScoresAction(input: unknown): Promise("POST /api/mobile/v1/orgs/:orgSlug/events/:eventId/judging/submissions/:submissionId/review", "action", async (request, { submissionId }, { judge }) => { - // Scores and reviews are written to the current round only. - const notCurrent = await refuseUnlessCurrentRound(judge, new URL(request.url).searchParams.get("roundId")); - if (notCurrent) return notCurrent; + // Scores and reviews are written to the current round only, the same one for the whole request. + const pin = await pinScoringRound(judge, new URL(request.url).searchParams.get("roundId")); + if ("response" in pin) return pin.response; const body = await parseJsonBody(request, bodySchema); if ("response" in body) return body.response; const { scores, comment } = body.data; const review = judgeReviewSchema.safeParse({ submissionId, ...(comment !== undefined ? { comment } : {}), submit: true }); if (!review.success) return mobileError("INVALID_REQUEST", "Check the highlighted fields.", { fieldErrors: zodFieldErrors(review.error) }); if (scores && scores.length > 0) { - const saved = await saveJudgeScores(judge, { submissionId, scores }); + const saved = await saveJudgeScores(judge, { submissionId, scores }, pin.roundId); if (!saved.ok) return mobileActionError(saved); } // Without a new comment, keep the saved draft comment. const existing = comment === undefined - ? (await currentRoundReview(judge, submissionId))?.comment ?? "" + ? (await pinnedRoundReview(judge, pin.roundId, submissionId))?.comment ?? "" : review.data.comment; - const submitted = await submitJudgeReview(judge, { submissionId, comment: existing, submit: true }); + const submitted = await submitJudgeReview(judge, { submissionId, comment: existing, submit: true }, pin.roundId); revalidatePath(`/dashboard/${judge.orgSlug}/event/${judge.eventId}`); if (!submitted.ok) return mobileActionError(submitted); - return judgeDetailResponse(judge, submissionId); + return judgeDetailResponse(judge, submissionId, pin.roundId ?? undefined); }, { idempotent: true }); diff --git a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/scores/route.ts b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/scores/route.ts index e993206b..3dedd5d5 100644 --- a/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/scores/route.ts +++ b/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/scores/route.ts @@ -6,7 +6,7 @@ import { mobileActionError, mobileError, mobileOk, parseJsonBody, zodFieldErrors import { judgingRoute } from "@/lib/mobile/judging"; import { judgeScoresSavedDtoSchema } from "@/lib/mobile/schemas"; import { judgeReviewSchema } from "@/lib/validators"; -import { currentRoundReview, refuseUnlessCurrentRound } from "@/lib/mobile/judgeReview"; +import { pinnedRoundReview, pinScoringRound } from "@/lib/mobile/judgeReview"; type Params = { orgSlug: string; eventId: string; submissionId: string }; @@ -19,12 +19,13 @@ const bodySchema = z.object({ * Save some or all of this judge's scores (and, optionally, the private * comment as a draft) without submitting. The core re-checks the judge, * judging status (JUDGING_CLOSED), visibility (NOT_VISIBLE) and each - * criterion's range inside its transaction. + * criterion's range inside its transaction. Both writes and the response + * read use one round, pinned at the start (JUDGING_CLOSED if it changes). */ export const PUT = judgingRoute("PUT /api/mobile/v1/orgs/:orgSlug/events/:eventId/judging/submissions/:submissionId/scores", "action", async (request, { submissionId }, { judge }) => { - // Scores and reviews are written to the current round only. - const notCurrent = await refuseUnlessCurrentRound(judge, new URL(request.url).searchParams.get("roundId")); - if (notCurrent) return notCurrent; + // Scores and reviews are written to the current round only, the same one for the whole request. + const pin = await pinScoringRound(judge, new URL(request.url).searchParams.get("roundId")); + if ("response" in pin) return pin.response; const body = await parseJsonBody(request, bodySchema); if ("response" in body) return body.response; const { scores, comment } = body.data; @@ -33,14 +34,14 @@ export const PUT = judgingRoute("PUT /api/mobile/v1/orgs/:orgSlug/events const review = judgeReviewSchema.safeParse({ submissionId, comment, submit: false }); if (!review.success) return mobileError("INVALID_REQUEST", "Check the highlighted fields.", { fieldErrors: zodFieldErrors(review.error) }); } - const saved = await saveJudgeScores(judge, { submissionId, scores }); + const saved = await saveJudgeScores(judge, { submissionId, scores }, pin.roundId); if (!saved.ok) return mobileActionError(saved); if (comment !== undefined) { - const draft = await submitJudgeReview(judge, { submissionId, comment, submit: false }); + const draft = await submitJudgeReview(judge, { submissionId, comment, submit: false }, pin.roundId); if (!draft.ok) return mobileActionError(draft); } revalidatePath(`/dashboard/${judge.orgSlug}/event/${judge.eventId}`); - const review = await currentRoundReview(judge, submissionId); + const review = await pinnedRoundReview(judge, pin.roundId, submissionId); if (!saved.data) return mobileError("INTERNAL_ERROR"); return mobileOk(judgeScoresSavedDtoSchema.parse({ scored: saved.data.scored, diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 06f13920..4107e193 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -953,7 +953,11 @@ Events judge in rounds ([Hackathon judging](HACKATHON.md) "Rounds"). Every judging read above takes an optional `?roundId=` and otherwise shows the current round (the one judges score now). Scores and reviews are written to the current round only: a `?roundId=` naming any other round answers -`JUDGING_CLOSED`. +`JUDGING_CLOSED`. A scoring write pins one round at its start (the +`?roundId=`, else the current round) and every write and read of that request +uses it; the round is compared again under the Event lock, so if an organiser +switches rounds mid-request the request answers `JUDGING_CLOSED` and nothing +is written to the new round. - `GET …/judging/rounds` → `{ "rounds": [JudgeRound], "currentRoundId", "onPanel" }`: the rounds this judge may open (rounds that have opened, and the current diff --git a/lib/core/judging.ts b/lib/core/judging.ts index a14bd9c1..7717e739 100644 --- a/lib/core/judging.ts +++ b/lib/core/judging.ts @@ -51,7 +51,7 @@ export { setJudgingStatus, setScoreNormalization, type PublishResultsOutcome, pu export { type SendResultsAnnouncementOutcome, sendResultsAnnouncement, countResultsAnnouncementRecipients } from "./judging/announcement"; export { listSubmissionsForJudge, listJudgeSubmissionsPage, loadSubmissionForJudge, type JudgeNextDto, nextSubmissionForJudge } from "./judging/judgeWorkspace"; export { type RecusedProjectDto, setJudgeRecusal } from "./judging/recusal"; -export { saveJudgeScores, submitJudgeReview } from "./judging/judgeScoring"; +export { saveJudgeScores, submitJudgeReview, type ExpectedRound } from "./judging/judgeScoring"; export { type OrganiserSubmissionsPage, listSubmissionsForOrganiser, diff --git a/lib/core/judging/judgeScoring.ts b/lib/core/judging/judgeScoring.ts index abf0e5d0..594cc439 100644 --- a/lib/core/judging/judgeScoring.ts +++ b/lib/core/judging/judgeScoring.ts @@ -13,15 +13,26 @@ import { isRoundJudge } from "./judges"; import { visibleSubmissionIds } from "./judgeVisibility"; import { type JudgingRoundScope, resolveJudgingRound } from "./rounds/scope"; +/** + * The round a request pinned before its writes (a mobile `?roundId=`, or the + * round the request's first write used): `undefined` pins nothing, `null` + * expects no round yet. Compared after the Event lock, so a round switched + * mid-request is refused rather than written to. + */ +export type ExpectedRound = string | null | undefined; + +export const ROUND_CHANGED = "Scores are saved to the current round only."; + /** * Lock the event, then confirm the current round is open and this judge may * score this submission in it. Judges only ever write to the current round: * the one OPEN round, which the event-level judging status mirrors. */ -async function lockedJudgeTarget(tx: Prisma.TransactionClient, ctx: JudgeContext, submissionId: string): Promise> { +async function lockedJudgeTarget(tx: Prisma.TransactionClient, ctx: JudgeContext, submissionId: string, expectedRoundId: ExpectedRound): Promise> { const event = await lockEventShared(tx, ctx.eventId); if (!event || event.organisationId !== ctx.organisationId || !event.submissionsEnabled) return fail(EVENT_NOT_FOUND); const round = await resolveJudgingRound(tx, event); + if (expectedRoundId !== undefined && (round?.id ?? null) !== expectedRoundId) return refuse(ROUND_CHANGED, "JUDGING_CLOSED"); if (!round || !(await isRoundJudge(tx, round, event.organisationId, ctx.userId))) return fail(NOT_FOUND); if (round.status !== JudgingRoundStatus.OPEN || !round.id) return refuse(JUDGING_NOT_OPEN, "JUDGING_CLOSED"); const visible = await visibleSubmissionIds(tx, round, ctx.userId, submissionId); @@ -34,12 +45,12 @@ async function lockedJudgeTarget(tx: Prisma.TransactionClient, ctx: JudgeContext * 0..maxScore for a criterion of the open round. Upserts on the unique * (submission, judge, criterion) key, so concurrent saves never duplicate. */ -export async function saveJudgeScores(ctx: JudgeContext, input: unknown): Promise> { +export async function saveJudgeScores(ctx: JudgeContext, input: unknown, expectedRoundId?: ExpectedRound): Promise> { const parsed = judgeScoresSchema.safeParse(input); if (!parsed.success) return invalid(parsed.error); const { submissionId, scores } = parsed.data; - return prisma.$transaction(async (tx): Promise> => { - const target = await lockedJudgeTarget(tx, ctx, submissionId); + return prisma.$transaction(async (tx): Promise> => { + const target = await lockedJudgeTarget(tx, ctx, submissionId, expectedRoundId); if (!target.ok) return target; const criteria = await criteriaFor(tx, target.data.round); const byId = new Map(criteria.map((criterion) => [criterion.id, criterion])); @@ -56,7 +67,7 @@ export async function saveJudgeScores(ctx: JudgeContext, input: unknown): Promis DO UPDATE SET "score" = EXCLUDED."score", "updatedAt" = ${SQL_UTC_NOW}`; } const scored = await tx.judgeScore.count({ where: { submissionId, judgeUserId: ctx.userId, criterionId: { in: criteria.map((c) => c.id) } } }); - return ok({ scored, total: criteria.length }); + return ok({ scored, total: criteria.length, roundId: target.data.round.id }); }); } @@ -65,12 +76,12 @@ export async function saveJudgeScores(ctx: JudgeContext, input: unknown): Promis * review complete. Submitting needs every criterion scored. Upserts on the * unique (round, submission, judge) key. */ -export async function submitJudgeReview(ctx: JudgeContext, input: unknown): Promise> { +export async function submitJudgeReview(ctx: JudgeContext, input: unknown, expectedRoundId?: ExpectedRound): Promise> { const parsed = judgeReviewSchema.safeParse(input); if (!parsed.success) return invalid(parsed.error); const { submissionId, comment, submit } = parsed.data; return prisma.$transaction(async (tx): Promise> => { - const target = await lockedJudgeTarget(tx, ctx, submissionId); + const target = await lockedJudgeTarget(tx, ctx, submissionId, expectedRoundId); if (!target.ok) return target; const { round } = target.data; if (submit) { diff --git a/lib/mobile/judgeReview.ts b/lib/mobile/judgeReview.ts index bdf4c1b9..48b44466 100644 --- a/lib/mobile/judgeReview.ts +++ b/lib/mobile/judgeReview.ts @@ -3,37 +3,32 @@ import "server-only"; import { prisma } from "@/lib/db"; import type { JudgeContext } from "@/lib/judgingAccess"; import { resolveJudgingRound } from "@/lib/core/judging/rounds/scope"; +import { ROUND_CHANGED } from "@/lib/core/judging/judgeScoring"; import { mobileError } from "@/lib/mobile/http"; /** - * `?roundId=` on a scoring write: absent or the current round goes ahead; - * any other round answers JUDGING_CLOSED, because judges write only to the - * round they score now (lib/core/judging/judgeScoring.ts). + * Pin the round a scoring write acts on, once per request: the current round + * (null before the event has any). `?roundId=` naming any other round answers + * JUDGING_CLOSED, because judges write only to the round they score now. Every + * write of the request passes the pinned round to the core, which compares it + * after the Event lock (lib/core/judging/judgeScoring.ts), so a round switched + * mid-request is refused rather than written to. */ -export async function refuseUnlessCurrentRound(judge: JudgeContext, roundId: string | null) { - if (!roundId) return null; +export async function pinScoringRound(judge: JudgeContext, roundId: string | null): Promise<{ roundId: string | null } | { response: Response }> { const event = await prisma.event.findFirst({ where: { id: judge.eventId, organisationId: judge.organisationId }, select: { id: true, judgingStatus: true, judgesSeeAll: true, reviewsPerProject: true, scoreNormalization: true }, }); const current = event ? await resolveJudgingRound(prisma, event) : null; - return current?.id === roundId ? null : mobileError("JUDGING_CLOSED", "Scores are saved to the current round only."); + if (roundId && current?.id !== roundId) return { response: mobileError("JUDGING_CLOSED", ROUND_CHANGED) }; + return { roundId: current?.id ?? null }; } -/** - * This judge's review of a project in the event's current judging round (the - * round the mobile judging routes act on), or null when there is none yet. - */ -export async function currentRoundReview(judge: JudgeContext, submissionId: string) { - const event = await prisma.event.findFirst({ - where: { id: judge.eventId, organisationId: judge.organisationId }, - select: { id: true, judgingStatus: true, judgesSeeAll: true, reviewsPerProject: true, scoreNormalization: true }, - }); - if (!event) return null; - const round = await resolveJudgingRound(prisma, event); - if (!round?.id) return null; +/** This judge's review of a project in the request's pinned round, or null when there is none yet. */ +export async function pinnedRoundReview(judge: JudgeContext, roundId: string | null, submissionId: string) { + if (!roundId) return null; return prisma.judgeReview.findUnique({ - where: { roundId_submissionId_judgeUserId: { roundId: round.id, submissionId, judgeUserId: judge.userId } }, + where: { roundId_submissionId_judgeUserId: { roundId, submissionId, judgeUserId: judge.userId } }, select: { comment: true, submittedAt: true }, }); } diff --git a/tests/integration/judging-scoring.db.test.ts b/tests/integration/judging-scoring.db.test.ts index 81d2c11d..9617d745 100644 --- a/tests/integration/judging-scoring.db.test.ts +++ b/tests/integration/judging-scoring.db.test.ts @@ -50,7 +50,7 @@ describe.sequential("hackathon judging integration", () => { expect(await score(6)).toMatchObject({ ok: false }); expect(await saveJudgeScores(judge.ctx, { submissionId, scores: [{ criterionId: c, score: -1 }] })).toMatchObject({ ok: false }); expect(await saveJudgeScores(judge.ctx, { submissionId, scores: [{ criterionId: c, score: 2.5 }] })).toMatchObject({ ok: false }); - expect(await score(3)).toEqual({ ok: true, data: { scored: 1, total: 1 } }); + expect(await score(3)).toEqual({ ok: true, data: { scored: 1, total: 1, roundId: expect.any(String) } }); // Drafts and withdrawn projects are not judged. expect(await withdrawSubmission(auth)).toMatchObject({ ok: true }); @@ -162,7 +162,7 @@ describe.sequential("hackathon judging integration", () => { // S3: J2 (5, 4) = (2 + 0.4) / 3 = 80 — a tie with S2. await review(j2.ctx, s3, 5, 4); // S4: partial scores only; a submitted review needs every criterion. - expect(await saveJudgeScores(j3.ctx, { submissionId: s4, scores: [{ criterionId: design, score: 5 }] })).toEqual({ ok: true, data: { scored: 1, total: 2 } }); + expect(await saveJudgeScores(j3.ctx, { submissionId: s4, scores: [{ criterionId: design, score: 5 }] })).toEqual({ ok: true, data: { scored: 1, total: 2, roundId: expect.any(String) } }); expect(await submitJudgeReview(j3.ctx, { submissionId: s4 })).toMatchObject({ ok: false, error: "Score every criterion before submitting your review." }); const results = await loadJudgingResults(ctx); diff --git a/tests/integration/mobile-judging-round-pin.db.test.ts b/tests/integration/mobile-judging-round-pin.db.test.ts new file mode 100644 index 00000000..1065eca2 --- /dev/null +++ b/tests/integration/mobile-judging-round-pin.db.test.ts @@ -0,0 +1,106 @@ +import { JudgingRoundStatus, JudgingStatus } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * One judging round per scoring request (docs/MOBILE_API.md "Judging"): the + * round pinned before the writes is compared after the Event lock, so an + * organiser switching rounds mid-request makes the request fail rather than + * write into the new round. + */ + +process.env.REDIS_URL = ""; +process.env.TRUSTED_PROXY_IP_HEADER = "x-real-ip"; + +vi.mock("next-auth", () => ({ default: () => ({ auth: async () => null, handlers: {}, signIn: vi.fn(), signOut: vi.fn() }), CredentialsSignin: class extends Error {} })); +vi.mock("next-auth/providers/google", () => ({ default: vi.fn() })); +vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn() })); +vi.mock("@/lib/authAdapter", () => ({ authAdapter: {} })); +vi.mock("next/headers", () => ({ headers: async () => new Headers({ "x-real-ip": `198.18.2.${Math.floor(Math.random() * 250)}` }), cookies: async () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn() }) })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); + +import { prisma } from "@/lib/db"; +import { appSession, keyed, newKey, params } from "../helpers/mobileApi"; +import { createJudgingCriterion, saveJudgeScores, setJudgingStatus, submitJudgeReview } from "@/lib/core/judging"; +import { createJudgingRound } from "@/lib/core/judgingRounds"; +import { judgingEventSelect } from "@/lib/core/judging/shared"; +import { resolveJudgingRound } from "@/lib/core/judging/rounds/scope"; +import { changeRoundStatus } from "@/lib/core/judging/rounds/status"; +import { createEvent, grantJudges, judge, must, organiser, orgSlug, project, setUpRounds, tearDownRounds, users } from "./helpers/judgingRounds"; +import { PUT as scores } from "@/app/api/mobile/v1/orgs/[orgSlug]/events/[eventId]/judging/submissions/[submissionId]/scores/route"; + +/** Round 1 open with its rubric, round 2 a DRAFT taking every project and seen by every judge. */ +async function openRoundOneEvent() { + const event = await createEvent(); + await grantJudges(event, users.j1); + const ctx = await organiser(event); + const entry = await project(event, "Pinned project"); + must(await createJudgingCriterion(ctx, { name: "Impact", weight: 1, maxScore: 10 })); + must(await setJudgingStatus(ctx, JudgingStatus.OPEN)); + const roundOne = await prisma.judgingRound.findFirstOrThrow({ where: { eventId: event.id, position: 1 } }); + const roundTwo = must(await createJudgingRound(ctx, { name: "Finals", entrySource: "ALL_SUBMITTED", judgesSeeAll: true, freshEyes: false, reviewsPerProject: "", scoreNormalization: "RAW", opensAt: "", closesAt: "" })); + must(await createJudgingCriterion(ctx, { name: "Pitch", weight: 1, maxScore: 10 }, { roundId: roundTwo.id })); + return { event, ctx, entry, roundOneId: roundOne.id, roundTwoId: roundTwo.id }; +} + +/** As the organiser in `tx` (holding the Event lock): close round 1 and open round 2. */ +async function switchRounds(tx: Parameters[0]>[0], fixture: Awaited>) { + const event = await tx.event.findUniqueOrThrow({ where: { id: fixture.event.id }, select: judgingEventSelect }); + for (const [id, next] of [[fixture.roundOneId, JudgingRoundStatus.CLOSED], [fixture.roundTwoId, JudgingRoundStatus.OPEN]] as const) { + const round = await resolveJudgingRound(tx, event, id); + must(await changeRoundStatus(tx, fixture.ctx, event, { ...round!, id }, next)); + } +} + +const waitingOnLocks = async () => (await prisma.$queryRaw>`SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'`)[0]!.n; + +describe.sequential("a scoring request keeps one judging round", () => { + beforeAll(setUpRounds); + afterAll(tearDownRounds); + + it("refuses a core write pinned to a round that is no longer current, writing nothing", async () => { + const fixture = await openRoundOneEvent(); + const ctx = await judge(fixture.event, users.j1); + await prisma.$transaction((tx) => switchRounds(tx, fixture)); + expect(await submitJudgeReview(ctx, { submissionId: fixture.entry.id, comment: "Round one notes", submit: false }, fixture.roundOneId)).toMatchObject({ ok: false, code: "JUDGING_CLOSED" }); + expect(await saveJudgeScores(ctx, { submissionId: fixture.entry.id, scores: [] }, fixture.roundOneId)).toMatchObject({ ok: false, code: "JUDGING_CLOSED" }); + expect(await prisma.judgeReview.count({ where: { submissionId: fixture.entry.id } })).toBe(0); + // Unpinned, or pinned to the current round, it goes ahead. + expect(await submitJudgeReview(ctx, { submissionId: fixture.entry.id, comment: "Finals notes", submit: false }, fixture.roundTwoId)).toMatchObject({ ok: true }); + }); + + it("refuses a mobile save whose round is switched between the precheck and the write, and writes nothing to the new round", async () => { + const fixture = await openRoundOneEvent(); + const token = await appSession(users.j1); + let locked!: () => void; + let release!: () => void; + const holding = new Promise((resolve) => { locked = resolve; }); + const gate = new Promise((resolve) => { release = resolve; }); + // The organiser holds the Event lock, then switches rounds once the judge's write is waiting on it. + const organiserSwitch = prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${fixture.event.id} FOR UPDATE`; + locked(); + await gate; + await switchRounds(tx, fixture); + }, { timeout: 30_000, maxWait: 10_000 }); + await holding; + const before = await waitingOnLocks(); + + const request = scores( + keyed(`/x?roundId=${fixture.roundOneId}`, { scores: [], comment: "Round one assessment" }, token, newKey(), "PUT"), + params({ orgSlug, eventId: fixture.event.id, submissionId: fixture.entry.id }), + ); + for (let tries = 0; (await waitingOnLocks()) <= before; tries += 1) { + if (tries > 200) throw new Error("the judge's write never waited on the Event lock"); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + release(); + await organiserSwitch; + + const response = await request; + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ error: { code: "JUDGING_CLOSED" } }); + expect(await prisma.judgeReview.count({ where: { roundId: fixture.roundTwoId } })).toBe(0); + expect(await prisma.judgeScore.count({ where: { submissionId: fixture.entry.id } })).toBe(0); + }); +}); diff --git a/tests/unit/judge-review-action.test.ts b/tests/unit/judge-review-action.test.ts index b4bed512..373ff8ec 100644 --- a/tests/unit/judge-review-action.test.ts +++ b/tests/unit/judge-review-action.test.ts @@ -31,7 +31,7 @@ beforeEach(() => { mocks.auth.mockResolvedValue({ user: { id: "judge_1" } }); mocks.rateLimit.mockResolvedValue(false); mocks.resolveJudge.mockResolvedValue({ userId: "judge_1", organisationId: "org_1", orgSlug: "org", eventId: "event_1", eventSlug: "hack", isAdmin: false }); - mocks.saveScores.mockResolvedValue({ ok: true, data: { scored: 1, total: 2 } }); + mocks.saveScores.mockResolvedValue({ ok: true, data: { scored: 1, total: 2, roundId: "round_1" } }); mocks.submitReview.mockResolvedValue({ ok: true, data: { submitted: false } }); }); @@ -44,11 +44,11 @@ describe("saveJudgeReviewAction", () => { expect(mocks.submitReview).not.toHaveBeenCalled(); }); - it("saves scores then the review when the input is valid", async () => { + it("saves scores then the review, pinned to the scores' round, when the input is valid", async () => { const result = await saveJudgeReviewAction({ ...scope, submissionId: "sub_1", scores, comment: "Good", submit: false }); expect(result).toEqual({ ok: true, data: { scored: 1, total: 2, submitted: false } }); expect(mocks.saveScores).toHaveBeenCalledOnce(); - expect(mocks.submitReview).toHaveBeenCalledWith(expect.anything(), { submissionId: "sub_1", comment: "Good", submit: false }); + expect(mocks.submitReview).toHaveBeenCalledWith(expect.anything(), { submissionId: "sub_1", comment: "Good", submit: false }, "round_1"); }); }); From f134d64607567312923bc88c0736fd533e18baaa Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 22:50:02 +0530 Subject: [PATCH 42/79] fix(storage): delete every version and delete marker of queued keys, fence completion writes by attempt Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/super-admin/operations/page.tsx | 2 +- docker/vps/storage-policy.json | 4 +- docs/CONFIGURATION.md | 5 +- docs/DEPLOYMENT_CLOUD.md | 4 +- docs/DEPLOYMENT_VPS.md | 6 +- docs/production-operations.md | 2 + lib/storage/deletionQueue.ts | 30 +++-- lib/storage/objects.ts | 111 ++++++++++++++++-- tests/helpers/fakeVersionedS3.ts | 94 +++++++++++++++ .../storage-deletion-queue.db.test.ts | 34 +++++- .../storage-deletion-versions.db.test.ts | 96 +++++++++++++++ tests/unit/storage-versions.test.ts | 110 +++++++++++++++++ 12 files changed, 466 insertions(+), 32 deletions(-) create mode 100644 tests/helpers/fakeVersionedS3.ts create mode 100644 tests/integration/storage-deletion-versions.db.test.ts create mode 100644 tests/unit/storage-versions.test.ts diff --git a/app/super-admin/operations/page.tsx b/app/super-admin/operations/page.tsx index 11a23b4c..2d3bfd2b 100644 --- a/app/super-admin/operations/page.tsx +++ b/app/super-admin/operations/page.tsx @@ -37,7 +37,7 @@ export default async function SuperAdminOperationsPage() {
    {storageDeletions.misplaced > 0 ?
  • {storageDeletions.misplaced} file{storageDeletions.misplaced === 1 ? " belongs" : "s belong"} to a storage bucket that is no longer configured. They are kept and never deleted from the current bucket; restore the earlier storage settings or remove them there by hand.
  • : null} - {storageDeletions.stillPresent > 0 ?
  • {storageDeletions.stillPresent} deleted file{storageDeletions.stillPresent === 1 ? " was" : "s were"} still there when checked twice. The private bucket may have versioning turned on, which keeps deleted files: turn it off.
  • : null} + {storageDeletions.stillPresent > 0 ?
  • {storageDeletions.stillPresent} deleted file{storageDeletions.stillPresent === 1 ? " was" : "s were"} still there when checked twice. Something keeps deleted files in the private bucket, such as an object lock or retention rule: remove it.
  • : null} {storageDeletions.failing > 0 ?
  • {storageDeletions.failing} file{storageDeletions.failing === 1 ? "" : "s"} could not be deleted after several attempts. Check the storage credentials and bucket permissions.
  • : null}
diff --git a/docker/vps/storage-policy.json b/docker/vps/storage-policy.json index 9b9635ae..f50fead9 100644 --- a/docker/vps/storage-policy.json +++ b/docker/vps/storage-policy.json @@ -1,7 +1,7 @@ { "Version": "2012-10-17", "Statement": [ - { "Effect": "Allow", "Action": ["s3:ListBucket", "s3:GetBucketLocation"], "Resource": ["arn:aws:s3:::spaceworks-events"] }, - { "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::spaceworks-events/*"] } + { "Effect": "Allow", "Action": ["s3:ListBucket", "s3:ListBucketVersions", "s3:GetBucketLocation"], "Resource": ["arn:aws:s3:::spaceworks-events"] }, + { "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:DeleteObjectVersion"], "Resource": ["arn:aws:s3:::spaceworks-events/*"] } ] } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 58b5293e..edd5929e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -128,11 +128,12 @@ Stored files are removed through a durable queue (`StorageDeletion`, worked by t - **Every upload is tracked from before its first byte.** An upload records a pending deletion for its fresh key, sends the file (refused once 30 minutes have passed since it was recorded, and aborted then), and removes the pending row only in the transaction that saves its `Asset`. A failed, abandoned or uncertain upload therefore keeps its row, and the beat deletes the file 2 hours after that deadline. Once the beat has claimed a row, that upload can no longer be saved. - **Deleting an organisation** queues every stored file it owns (images, receipts, unused uploads and every expense receipt key) in the same transaction as the deletion, plus one listing of its `organisations//` prefix for files an earlier release may have left without a record. The queue has no foreign key, so it survives the cascade. User profile images are not touched. - **Abandoned receipts:** a receipt photo no expense claim refers to after a day is removed by the hourly retention job; its file is queued in the same statement. -- **Deletion is checked.** After a delete succeeds (or finds nothing), the file is checked again 24 hours later; it is deleted again if it is still there, and the queue row goes only once the file is gone. Repeating a delete is harmless; completion is eventual. Failures are retried with back-off and recorded only as short error codes. +- **Every version goes.** The beat lists each queued file's versions and delete markers (`ListObjectVersions`) and deletes each one by version id, so a bucket with versioning (bundled Silo keeps it on) does not keep the bytes behind a delete marker; an unversioned bucket lists each file once. A legacy prefix is listed the same way, so it also finds files that only old versions or a delete marker still hold. Cloudflare R2 has no versions API: its `NotImplemented` (HTTP 501) answer is noted once per storage location, and that location uses plain deletes, `HEAD` and `ListObjectsV2`. Any other error, such as `AccessDenied`, stays a failure. +- **Deletion is checked.** After a delete succeeds (or finds nothing), the file is checked again 24 hours later; it is deleted again if any version or delete marker is left, and the queue row goes only once nothing is. Repeating a delete is harmless; completion is eventual. Failures are retried with back-off and recorded only as short error codes. Each claim is fenced by its attempt count, so a worker delayed past its 10-minute lease cannot overwrite the progress of a later one. The queue relies on this contract with the storage provider: -- **No versioning on the private bucket** (S3, Cloudflare R2, Silo/MinIO and any compatible service). A delete on a versioned bucket only adds a delete marker and keeps the bytes. **Super admin → Operations** warns when a deleted file is still there on two checks. +- **Version permissions for the application's key.** Versioning may be on or off. On Amazon S3 and other services with versions, the access key needs `s3:ListBucketVersions` on the bucket and `s3:DeleteObjectVersion` on its objects as well as list, read, write and delete (the bundled Silo policy, `docker/vps/storage-policy.json`, grants them). Without them deletions fail with `AccessDenied` and **Super admin → Operations** reports repeated failures. No object lock or retention rule may keep deleted versions: **Operations** warns when a deleted file is still there on two checks. - **One storage location.** `Asset` rows record no bucket, so changing `S3_BUCKET` or `S3_ENDPOINT` while files exist is unsupported unless every object is first copied to the new location (and the deletion queue is empty). Each queued deletion records the location (a hash of the provider, endpoint and bucket) it belongs to; the beat never deletes a row's key in another location. It keeps such rows and **Operations** reports them, so restore the earlier settings or delete those files there by hand. - **Residual risk (accepted):** an upload that the storage service applies more than 26 hours after the application aborted it would not be removed. No supported provider behaves this way. diff --git a/docs/DEPLOYMENT_CLOUD.md b/docs/DEPLOYMENT_CLOUD.md index 83e46e82..6c738289 100644 --- a/docs/DEPLOYMENT_CLOUD.md +++ b/docs/DEPLOYMENT_CLOUD.md @@ -76,8 +76,8 @@ month. 1. In the Cloudflare dashboard open **R2 Object Storage** and create a bucket, e.g. `spaceworks-events`. Leave **Public access** disabled and do not attach a custom domain: the app serves every image through its own access-checked - route. Don't turn on object versioning or keep deleted objects: the app's - deletions must really remove files + route. Don't add an object lock or retention rule that keeps deleted + objects: the app's deletions must really remove files ([storage contract](CONFIGURATION.md#storage-contract-and-the-deletion-queue)). 2. **R2 → Manage API tokens → Create API token**: permission **Object Read & Write**, **Apply to specific buckets only** → your bucket. diff --git a/docs/DEPLOYMENT_VPS.md b/docs/DEPLOYMENT_VPS.md index 25e720cf..339d0d2a 100644 --- a/docs/DEPLOYMENT_VPS.md +++ b/docs/DEPLOYMENT_VPS.md @@ -146,8 +146,10 @@ region (`auto` for R2), bucket, access key and secret (hidden). The application reads and writes the bucket itself; browsers never talk to it, so no CORS is needed. With external storage (`SWE_STORAGE='external'`) the Silo services do not run and `vps-backup.sh` copies the database and configuration only: back -the bucket up with your provider. Leave versioning off on the bucket: deleted files -must really go ([storage contract](CONFIGURATION.md#storage-contract-and-the-deletion-queue)). +the bucket up with your provider. Versioning may be on: the app deletes every version +of a deleted file, so on Amazon S3 the key also needs `s3:ListBucketVersions` and +`s3:DeleteObjectVersion`, and no object lock or retention rule may keep deleted versions +([storage contract](CONFIGURATION.md#storage-contract-and-the-deletion-queue)). To change the bucket later, copy every object to the new bucket first, then edit `S3_*` in `app.env` and recreate the app (`scripts/vps-compose.sh up -d`). diff --git a/docs/production-operations.md b/docs/production-operations.md index 1eda2afa..d63cf1a3 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -179,6 +179,8 @@ Release 0.11.0 reserves the organisation addresses `/attend`, `/crew`, `/help`, 1. Stop every instance of the previous release (web, worker and beat; this aborts their uploads in progress) **within one hour** of the migration. Docker Compose, the Helm rolling update, Render and the VPS updater all finish replacing instances within minutes; a canary or blue-green setup must not keep the old release serving longer. 2. **Never run an older image against this database again**, including for a rollback. Roll back by restoring the pre-migration backup instead. +The queue deletes every version and delete marker of a file, so versioning may stay on (bundled Silo keeps it on). Before upgrading an installation on Amazon S3 or another service with a versions API, give the application's access key `s3:ListBucketVersions` and `s3:DeleteObjectVersion` as well; without them its deletions fail with `AccessDenied` until they are granted, and **Super admin → Operations** reports them. Bundled Silo's policy is updated on the next start of the storage setup container. Cloudflare R2 needs nothing extra ([storage contract](CONFIGURATION.md#storage-contract-and-the-deletion-queue)). + The migration records when it ran (`StorageProtocol.enabledAt`). The listing of a deleted organisation's legacy prefix starts no earlier than 27 hours after that (the one-hour drain plus the 26-hour late-write bound), so an old upload still in flight during the rollout has settled before the listing looks for it. #### Lock checklist diff --git a/lib/storage/deletionQueue.ts b/lib/storage/deletionQueue.ts index bb4bc067..a518cf3e 100644 --- a/lib/storage/deletionQueue.ts +++ b/lib/storage/deletionQueue.ts @@ -3,13 +3,16 @@ import "server-only"; import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/db"; import { sqlUtcTimestamp, SQL_UTC_NOW } from "@/lib/sqlTimestamp"; -import { currentStorageLocation, deleteObjects, listObjectsPage, objectExists } from "./objects"; +import { currentStorageLocation, deleteObjects, listObjectsPage, objectRemains } from "./objects"; /* * The storage deletion queue's worker and producers (StorageDeletion in * prisma/schema.prisma; docs/CONFIGURATION.md "Object storage"). Every step is * safe to repeat: deleting an object twice is harmless, and a row goes only - * after a later HEAD finds its object gone. + * after a later check finds no version of its object left. Each claim + * increments `attempts`, and every write that finishes a claimed row requires + * that value, so a worker delayed past its lease cannot overwrite the progress + * of the worker that claimed the row after it. */ const BATCH = 100; @@ -99,7 +102,7 @@ function backoff(attempts: number) { async function retryLater(row: Row, now: Date, code: string) { await prisma.$executeRaw` UPDATE "StorageDeletion" SET "notBefore" = ${sqlUtcTimestamp(new Date(now.getTime() + backoff(row.attempts)))}, "lastErrorCode" = ${code} - WHERE "id" = ${row.id}`; + WHERE "id" = ${row.id} AND "attempts" = ${row.attempts}`; } /** @@ -136,29 +139,32 @@ async function deleteClaimed(rows: Row[], now: Date) { if (failure) await retryLater(row, now, errorCode({ Code: failure })); else await prisma.$executeRaw` UPDATE "StorageDeletion" SET "state" = 'VERIFYING', "notBefore" = ${verifyAt}, "lastErrorCode" = NULL - WHERE "id" = ${row.id} AND "state" = 'CLAIMED'`; + WHERE "id" = ${row.id} AND "state" = 'CLAIMED' AND "attempts" = ${row.attempts}`; } return rows.length - failures.size; } async function verify(row: Row, now: Date) { - if (!(await objectExists(row.key))) { - await prisma.$executeRaw`DELETE FROM "StorageDeletion" WHERE "id" = ${row.id} AND "state" = 'VERIFYING'`; + if (!(await objectRemains(row.key))) { + await prisma.$executeRaw`DELETE FROM "StorageDeletion" WHERE "id" = ${row.id} AND "state" = 'VERIFYING' AND "attempts" = ${row.attempts}`; return true; } - // Still there: a late write, or a versioned bucket keeping it. Delete again and look again later. + // Still there: a late write, or a retention setting keeping it. Delete again and look again later. const failure = (await deleteObjects([row.key])).get(row.key); const code = failure ? errorCode({ Code: failure }) : row.lastErrorCode?.startsWith("STILL_PRESENT") ? "STILL_PRESENT_REPEATED" : "STILL_PRESENT"; await prisma.$executeRaw` UPDATE "StorageDeletion" SET "notBefore" = ${sqlUtcTimestamp(new Date(now.getTime() + VERIFY_AFTER_MS))}, "lastErrorCode" = ${code} - WHERE "id" = ${row.id}`; + WHERE "id" = ${row.id} AND "attempts" = ${row.attempts}`; return false; } -/** One page of a prefix listing: its keys queued and the next token saved together. */ +/** One page of a prefix listing: its keys queued and the next token saved together, unless another worker has claimed the row since. */ async function listPrefixPage(row: Row, now: Date) { const page = await listObjectsPage(row.key, row.continuationToken); - await prisma.$transaction(async (tx) => { + return prisma.$transaction(async (tx) => { + const owned = await tx.$queryRaw` + SELECT 1 FROM "StorageDeletion" WHERE "id" = ${row.id} AND "attempts" = ${row.attempts} FOR UPDATE`; + if (owned.length === 0) return 0; if (page.keys.length > 0) { await tx.$executeRaw` INSERT INTO "StorageDeletion" ("id", "kind", "location", "key", "organisationId", "reason", "notBefore", "createdAt") @@ -171,8 +177,8 @@ async function listPrefixPage(row: Row, now: Date) { else await tx.$executeRaw` UPDATE "StorageDeletion" SET "continuationToken" = ${page.next}, "notBefore" = ${sqlUtcTimestamp(now)}, "lastErrorCode" = NULL WHERE "id" = ${row.id}`; + return page.keys.length; }); - return page.keys.length; } /** The beat job: one batch of due deletions, verifications and listing pages. */ @@ -188,7 +194,7 @@ export async function processStorageDeletions(now = new Date()) { result.misplaced += 1; await prisma.$executeRaw` UPDATE "StorageDeletion" SET "notBefore" = ${sqlUtcTimestamp(new Date(now.getTime() + VERIFY_AFTER_MS))}, "lastErrorCode" = 'LOCATION_MISMATCH' - WHERE "id" = ${row.id}`; + WHERE "id" = ${row.id} AND "attempts" = ${row.attempts}`; } else if (row.kind === "OBJECT" && row.state === "CLAIMED") { toDelete.push(row); } else { diff --git a/lib/storage/objects.ts b/lib/storage/objects.ts index dd220c84..c176cc8c 100644 --- a/lib/storage/objects.ts +++ b/lib/storage/objects.ts @@ -5,6 +5,8 @@ import { DeleteObjectsCommand, HeadObjectCommand, ListObjectsV2Command, + ListObjectVersionsCommand, + type ObjectIdentifier, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; @@ -13,6 +15,15 @@ import { * The object-storage calls the upload protocol and the deletion queue make * (lib/storage/uploads.ts, lib/storage/deletionQueue.ts), against the * configured private bucket. + * + * Deleting is version-aware: on a bucket with versioning (bundled Silo keeps + * it on) a plain delete only adds a delete marker and keeps the bytes, so + * every version and delete marker of a key is listed (ListObjectVersions) and + * deleted by version id. An unversioned bucket lists each object once, with + * the version id "null". A provider without the versions API (Cloudflare R2 + * answers NotImplemented / 501) is noted once per storage location and uses + * plain deletes, HEAD and ListObjectsV2 instead. Any other error, such as + * AccessDenied or a network failure, is thrown to the caller as a failure. */ export function storageConfigured() { @@ -50,23 +61,89 @@ export async function putObject(input: { key: string; body: Buffer; contentType: ); } -/** DELETE each key (a missing key counts as deleted). Returns the keys that failed, with the provider's error code. */ -export async function deleteObjects(keys: string[]): Promise> { - const failed = new Map(); - for (let start = 0; start < keys.length; start += 1000) { +/** Locations whose provider has no ListObjectVersions (noted the first time it answers NotImplemented / 501). */ +const unversionedLocations = new Set(); + +function versionsUnsupported(error: unknown) { + const { name, Code, $metadata } = (error ?? {}) as { name?: unknown; Code?: unknown; $metadata?: { httpStatusCode?: number } }; + return name === "NotImplemented" || Code === "NotImplemented" || $metadata?.httpStatusCode === 501; +} + +type VersionsPage = { entries: Array<{ key: string; versionId: string | undefined }>; truncated: boolean; nextKey: string | null; nextVersion: string | null }; + +/** One page of versions and delete markers under a prefix, or null when this provider has no versions API. */ +async function listVersions(prefix: string, keyMarker: string | null, versionMarker: string | null): Promise { + const location = currentStorageLocation(); + if (location && unversionedLocations.has(location)) return null; + try { + const page = await client().send(new ListObjectVersionsCommand({ + Bucket: process.env.S3_BUCKET!, + Prefix: prefix, + MaxKeys: 1000, + ...(keyMarker ? { KeyMarker: keyMarker } : {}), + ...(keyMarker && versionMarker ? { VersionIdMarker: versionMarker } : {}), + })); + const entries = [...(page.Versions ?? []), ...(page.DeleteMarkers ?? [])] + .flatMap((entry) => (entry.Key ? [{ key: entry.Key, versionId: entry.VersionId }] : [])); + return { entries, truncated: Boolean(page.IsTruncated), nextKey: page.NextKeyMarker ?? null, nextVersion: page.NextVersionIdMarker ?? null }; + } catch (error) { + if (!versionsUnsupported(error)) throw error; + if (location) unversionedLocations.add(location); + return null; + } +} + +/** + * Every version and delete marker of exactly `key`, or null without a + * versions API. The key itself is the first name under its own prefix, so the + * listing stops at the first longer name. + */ +async function keyVersions(key: string): Promise { + const found: ObjectIdentifier[] = []; + let marker: { key: string | null; version: string | null } = { key: null, version: null }; + for (;;) { + const page = await listVersions(key, marker.key, marker.version); + if (!page) return null; + for (const entry of page.entries) { + if (entry.key === key) found.push({ Key: key, VersionId: entry.versionId }); + } + if (!page.truncated || page.entries.some((entry) => entry.key !== key) || !page.nextKey) return found; + marker = { key: page.nextKey, version: page.nextVersion }; + } +} + +async function deleteIdentifiers(objects: ObjectIdentifier[], failed: Map) { + for (let start = 0; start < objects.length; start += 1000) { const result = await client().send(new DeleteObjectsCommand({ Bucket: process.env.S3_BUCKET!, - Delete: { Objects: keys.slice(start, start + 1000).map((Key) => ({ Key })), Quiet: true }, + Delete: { Objects: objects.slice(start, start + 1000), Quiet: true }, })); for (const error of result.Errors ?? []) { - if (error.Key && error.Code !== "NoSuchKey") failed.set(error.Key, error.Code ?? "DeleteFailed"); + if (error.Key && error.Code !== "NoSuchKey" && error.Code !== "NoSuchVersion") failed.set(error.Key, error.Code ?? "DeleteFailed"); } } +} + +/** + * Delete every version and delete marker of each key (a missing key counts as + * deleted). Returns the keys that failed, with the provider's error code. + */ +export async function deleteObjects(keys: string[]): Promise> { + const failed = new Map(); + const objects: ObjectIdentifier[] = []; + for (const key of keys) { + const versions = await keyVersions(key); + if (versions) objects.push(...versions); + else objects.push({ Key: key }); + } + await deleteIdentifiers(objects, failed); return failed; } -/** Whether the object exists (a HEAD request). */ -export async function objectExists(key: string): Promise { +/** Whether anything of the key is left: any version or delete marker, or (without a versions API) the object itself by HEAD. */ +export async function objectRemains(key: string): Promise { + const versions = await keyVersions(key); + if (versions) return versions.length > 0; try { await client().send(new HeadObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key })); return true; @@ -77,13 +154,27 @@ export async function objectExists(key: string): Promise { } } -/** One page of keys under a prefix, and where the next page starts (null at the end). */ +const VERSIONS_TOKEN = "versions:"; + +/** + * One page of keys under a prefix, and where the next page starts (null at + * the end). With a versions API this includes keys that only old versions or + * a delete marker still hold, which a plain listing would miss. + */ export async function listObjectsPage(prefix: string, continuationToken: string | null): Promise<{ keys: string[]; next: string | null }> { + const marker = continuationToken?.startsWith(VERSIONS_TOKEN) ? (JSON.parse(continuationToken.slice(VERSIONS_TOKEN.length)) as [string, string | null]) : null; + const versions = await listVersions(prefix, marker?.[0] ?? null, marker?.[1] ?? null); + if (versions) { + return { + keys: [...new Set(versions.entries.map((entry) => entry.key))], + next: versions.truncated && versions.nextKey ? `${VERSIONS_TOKEN}${JSON.stringify([versions.nextKey, versions.nextVersion])}` : null, + }; + } const page = await client().send(new ListObjectsV2Command({ Bucket: process.env.S3_BUCKET!, Prefix: prefix, MaxKeys: 1000, - ...(continuationToken ? { ContinuationToken: continuationToken } : {}), + ...(continuationToken && !marker ? { ContinuationToken: continuationToken } : {}), })); return { keys: (page.Contents ?? []).flatMap((object) => (object.Key ? [object.Key] : [])), diff --git a/tests/helpers/fakeVersionedS3.ts b/tests/helpers/fakeVersionedS3.ts new file mode 100644 index 00000000..c6038d4c --- /dev/null +++ b/tests/helpers/fakeVersionedS3.ts @@ -0,0 +1,94 @@ +import type * as S3 from "@aws-sdk/client-s3"; + +/* + * An in-memory S3 bucket for the storage tests, answering the commands + * lib/storage/objects.ts sends. `versioning` "on" keeps every version and + * turns a plain delete into a delete marker (bundled Silo); "off" keeps one + * "null" version per key; "unsupported" answers ListObjectVersions with + * NotImplemented / 501 like Cloudflare R2. While `failListWith` is set, + * versions listings throw it (for example AccessDenied). + */ + +type Entry = { key: string; versionId: string; marker: boolean }; + +export function fakeVersionedS3(sdk: typeof S3) { + const bucket = { + versioning: "on" as "on" | "off" | "unsupported", + entries: [] as Entry[], + pageSize: 1000, + failListWith: null as Error | null, + versionLists: 0, + sequence: 0, + }; + + const latest = (key: string) => bucket.entries.filter((entry) => entry.key === key).at(-1); + const sorted = () => bucket.entries + .map((entry, index) => ({ entry, index })) + // By key, and newest first within a key, like S3. + .sort((a, b) => (a.entry.key < b.entry.key ? -1 : a.entry.key > b.entry.key ? 1 : b.index - a.index)) + .map(({ entry }) => entry); + + function put(key: string) { + if (bucket.versioning === "on") bucket.entries.push({ key, versionId: `v${++bucket.sequence}`, marker: false }); + else bucket.entries = [...bucket.entries.filter((entry) => entry.key !== key), { key, versionId: "null", marker: false }]; + } + + function deleteKey(key: string) { + if (bucket.versioning === "on") bucket.entries.push({ key, versionId: `m${++bucket.sequence}`, marker: true }); + else bucket.entries = bucket.entries.filter((entry) => entry.key !== key); + } + + function send(command: unknown): unknown { + if (command instanceof sdk.ListObjectVersionsCommand) { + bucket.versionLists += 1; + if (bucket.versioning === "unsupported") { + throw Object.assign(new Error("Not implemented"), { name: "NotImplemented", $metadata: { httpStatusCode: 501 } }); + } + if (bucket.failListWith) throw bucket.failListWith; + const { Prefix = "", KeyMarker, VersionIdMarker } = command.input; + let all = sorted().filter((entry) => entry.key.startsWith(Prefix)); + if (KeyMarker) { + const at = VersionIdMarker ? all.findIndex((entry) => entry.key === KeyMarker && entry.versionId === VersionIdMarker) : -1; + all = at >= 0 ? all.slice(at + 1) : all.filter((entry) => entry.key > KeyMarker); + } + const page = all.slice(0, bucket.pageSize); + const last = page.at(-1); + const truncated = all.length > page.length; + return { + Versions: page.filter((entry) => !entry.marker).map((entry) => ({ Key: entry.key, VersionId: entry.versionId })), + DeleteMarkers: page.filter((entry) => entry.marker).map((entry) => ({ Key: entry.key, VersionId: entry.versionId })), + IsTruncated: truncated, + ...(truncated && last ? { NextKeyMarker: last.key, NextVersionIdMarker: last.versionId } : {}), + }; + } + if (command instanceof sdk.DeleteObjectsCommand) { + for (const { Key, VersionId } of command.input.Delete?.Objects ?? []) { + if (!Key) continue; + if (VersionId) bucket.entries = bucket.entries.filter((entry) => !(entry.key === Key && entry.versionId === VersionId)); + else deleteKey(Key); + } + return { Errors: [] }; + } + if (command instanceof sdk.HeadObjectCommand) { + const entry = latest(command.input.Key!); + if (!entry || entry.marker) throw Object.assign(new Error("Not Found"), { name: "NotFound", $metadata: { httpStatusCode: 404 } }); + return {}; + } + if (command instanceof sdk.ListObjectsV2Command) { + const { Prefix = "", ContinuationToken } = command.input; + const keys = [...new Set(sorted().map((entry) => entry.key))] + .filter((key) => key.startsWith(Prefix) && !latest(key)!.marker && (!ContinuationToken || key > ContinuationToken)); + const page = keys.slice(0, bucket.pageSize); + return { Contents: page.map((Key) => ({ Key })), IsTruncated: keys.length > page.length, NextContinuationToken: page.at(-1) }; + } + if (command instanceof sdk.PutObjectCommand) { + put(command.input.Key!); + return {}; + } + throw new Error("The fake bucket does not answer this command."); + } + + /** Every key that still has a version or a delete marker. */ + const keys = () => [...new Set(bucket.entries.map((entry) => entry.key))].sort(); + return { bucket, put, deleteKey, send, keys }; +} diff --git a/tests/integration/storage-deletion-queue.db.test.ts b/tests/integration/storage-deletion-queue.db.test.ts index ee5d083b..7fa38a6f 100644 --- a/tests/integration/storage-deletion-queue.db.test.ts +++ b/tests/integration/storage-deletion-queue.db.test.ts @@ -18,6 +18,8 @@ const store = vi.hoisted(() => ({ failAfterDelete: false, pageSize: 1000, poison: null as string | null, + /** The next listing is computed, then waits here until released (a worker stalled past its lease). */ + stall: null as null | { reached: () => void; release: Promise }, })); vi.mock("@/lib/storage/objects", async (importOriginal) => ({ @@ -39,13 +41,19 @@ vi.mock("@/lib/storage/objects", async (importOriginal) => ({ } return new Map(); }), - objectExists: vi.fn(async (key: string) => store.objects.has(key)), + objectRemains: vi.fn(async (key: string) => store.objects.has(key)), listObjectsPage: vi.fn(async (prefix: string, token: string | null) => { const all = [...store.objects].filter((key) => key.startsWith(prefix)).sort(); if (store.poison?.startsWith(prefix)) all.push(store.poison); // Like S3, a page continues after the last key returned, whatever was deleted meanwhile. const rest = token ? all.filter((key) => key > token) : all; const keys = rest.slice(0, store.pageSize); + const stall = store.stall; + store.stall = null; + if (stall) { + stall.reached(); + await stall.release; + } return { keys, next: rest.length > store.pageSize ? keys.at(-1)! : null }; }), })); @@ -236,6 +244,30 @@ describe.sequential("storage deletion queue", () => { expect(await prisma.storageDeletion.count({ where: { key: prefix } })).toBe(0); }); + it("ignores the completion of a worker delayed past its lease once another worker has claimed the row", async () => { + const prefix = `organisations/${randomUUID()}/`; + await prisma.storageDeletion.create({ data: { kind: "PREFIX", location: currentStorageLocation()!, key: prefix, reason: "LEGACY_PREFIX", notBefore: new Date() } }); + for (const name of ["a", "b", "c", "d", "e"]) store.objects.add(`${prefix}${name}.webp`); + store.pageSize = 2; + let reached!: () => void; + let release!: () => void; + const stalled = new Promise((resolve) => { reached = resolve; }); + store.stall = { reached, release: new Promise((resolve) => { release = resolve; }) }; + const start = new Date(); + const delayed = processStorageDeletions(start); // claims the row and lists the first page, then stalls + await stalled; + // The lease runs out; a second worker claims the row and gets two pages further. + const afterLease = later(start, 11 * 60_000); + await processStorageDeletions(afterLease); + await processStorageDeletions(afterLease); + const progressed = await prisma.storageDeletion.findFirstOrThrow({ where: { key: prefix } }); + expect(progressed).toMatchObject({ continuationToken: `${prefix}d.webp`, attempts: 3 }); + release(); + await delayed; + // The stalled worker's first page would have set the token back to "b". + expect(await prisma.storageDeletion.findFirstOrThrow({ where: { key: prefix } })).toMatchObject({ continuationToken: `${prefix}d.webp`, attempts: 3, notBefore: progressed.notBefore }); + }); + it("keeps and reports a row whose storage location is no longer configured", async () => { const upload = await registerUpload(coverKey(organisationId), organisationId); await putRegistered(upload, object); diff --git a/tests/integration/storage-deletion-versions.db.test.ts b/tests/integration/storage-deletion-versions.db.test.ts new file mode 100644 index 00000000..f05d4974 --- /dev/null +++ b/tests/integration/storage-deletion-versions.db.test.ts @@ -0,0 +1,96 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { fakeVersionedS3 } from "@/tests/helpers/fakeVersionedS3"; + +/* + * The storage deletion queue end to end against a fake bucket with + * versioning on (bundled Silo) and one without a versions API (Cloudflare + * R2): a deleted organisation leaves no version, delete marker or queue row. + */ +const fake = vi.hoisted(() => ({ s3: null as null | ReturnType })); +vi.mock("@aws-sdk/client-s3", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, S3Client: class { send(command: unknown) { return Promise.resolve().then(() => fake.s3!.send(command)); } } }; +}); + +import * as sdk from "@aws-sdk/client-s3"; +import { prisma } from "@/lib/db"; +import { uploadFile } from "@/lib/storage"; +import { processStorageDeletions, queueOrganisationStorage, VERIFY_AFTER_MS } from "@/lib/storage/deletionQueue"; + +const HOUR = 60 * 60_000; +const later = (from: Date, ms: number) => new Date(from.getTime() + ms); +let enabledAt: Date; +let bucketNumber = 0; + +beforeAll(async () => { + vi.stubEnv("S3_REGION", "auto"); + vi.stubEnv("S3_ENDPOINT", "https://storage.example.test"); + enabledAt = (await prisma.storageProtocol.findUniqueOrThrow({ where: { id: 1 } })).enabledAt; + // Past the legacy-prefix delay, so the prefix is listed straight away. + await prisma.storageProtocol.update({ where: { id: 1 }, data: { enabledAt: new Date(Date.now() - 48 * HOUR) } }); +}); + +beforeEach(async () => { + await prisma.storageDeletion.deleteMany({}); + vi.stubEnv("S3_BUCKET", `versions-queue-${++bucketNumber}`); + fake.s3 = fakeVersionedS3(sdk); +}); + +afterAll(async () => { + await prisma.storageProtocol.update({ where: { id: 1 }, data: { enabledAt } }); + await prisma.storageDeletion.deleteMany({}); + vi.unstubAllEnvs(); + await prisma.$disconnect(); +}); + +async function deleteOrganisationWithFiles() { + const organisationId = (await prisma.organisation.create({ data: { name: "versions", slug: `versions-${randomUUID().slice(0, 8)}` } })).id; + const cover = `organisations/${organisationId}/event-covers/${randomUUID()}.webp`; + await uploadFile({ key: cover, body: Buffer.from("webp"), contentType: "image/webp", organisationId }); + // Replaced twice: three versions under one key. + fake.s3!.put(cover); + fake.s3!.put(cover); + // Left by an earlier release without an Asset row, then deleted by key: only a delete marker shows. + const hidden = `organisations/${organisationId}/event-covers/legacy-hidden.webp`; + fake.s3!.put(hidden); + fake.s3!.deleteKey(hidden); + await prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT 1 FROM "Organisation" WHERE "id" = ${organisationId} FOR UPDATE`; + await queueOrganisationStorage(tx, organisationId); + await tx.organisation.delete({ where: { id: organisationId } }); + }); + return { cover, hidden }; +} + +async function runUntilDone() { + const now = new Date(); + for (let pass = 0; pass < 3; pass += 1) await processStorageDeletions(now); + await processStorageDeletions(later(now, VERIFY_AFTER_MS + HOUR)); +} + +describe.sequential("storage deletion queue with object versions", () => { + it("removes every version and delete marker of a deleted organisation, including keys only a marker holds", async () => { + const { cover, hidden } = await deleteOrganisationWithFiles(); + expect(fake.s3!.keys()).toEqual([cover, hidden].sort()); + await runUntilDone(); + expect(fake.s3!.bucket.entries).toEqual([]); + expect(await prisma.storageDeletion.count()).toBe(0); + }); + + it("uses plain deletes and HEAD checks on a provider without a versions API", async () => { + fake.s3!.bucket.versioning = "unsupported"; + await deleteOrganisationWithFiles(); + await runUntilDone(); + expect(fake.s3!.bucket.entries).toEqual([]); + expect(await prisma.storageDeletion.count()).toBe(0); + }); + + it("keeps the row and its error code when listing versions is denied", async () => { + const { cover } = await deleteOrganisationWithFiles(); + fake.s3!.bucket.failListWith = Object.assign(new Error("Access Denied"), { name: "AccessDenied", $metadata: { httpStatusCode: 403 } }); + await processStorageDeletions(new Date()); + expect(await prisma.storageDeletion.findFirst({ where: { key: cover } })).toMatchObject({ state: "CLAIMED", lastErrorCode: "AccessDenied" }); + expect(fake.s3!.keys()).toContain(cover); + }); +}); diff --git a/tests/unit/storage-versions.test.ts b/tests/unit/storage-versions.test.ts new file mode 100644 index 00000000..35548362 --- /dev/null +++ b/tests/unit/storage-versions.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { fakeVersionedS3 } from "@/tests/helpers/fakeVersionedS3"; + +const fake = vi.hoisted(() => ({ s3: null as null | ReturnType })); +vi.mock("@aws-sdk/client-s3", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, S3Client: class { send(command: unknown) { return Promise.resolve().then(() => fake.s3!.send(command)); } } }; +}); + +import * as sdk from "@aws-sdk/client-s3"; +import { deleteObjects, listObjectsPage, objectRemains } from "@/lib/storage/objects"; + +let bucketNumber = 0; +beforeEach(() => { + // A fresh bucket name is a fresh storage location (the versions-API note is kept per location). + vi.stubEnv("S3_BUCKET", `versions-${++bucketNumber}`); + vi.stubEnv("S3_REGION", "auto"); + vi.stubEnv("S3_ENDPOINT", "https://storage.example.test"); + fake.s3 = fakeVersionedS3(sdk); +}); +afterAll(() => vi.unstubAllEnvs()); + +async function listAll(prefix: string) { + const keys: string[] = []; + let token: string | null = null; + do { + const page: { keys: string[]; next: string | null } = await listObjectsPage(prefix, token); + keys.push(...page.keys); + token = page.next; + } while (token); + return [...new Set(keys)].sort(); +} + +describe("version-aware object deletion", () => { + it("removes every version and delete marker of a key, and only of that key", async () => { + const s3 = fake.s3!; + s3.bucket.pageSize = 2; + s3.put("organisations/o/receipt.webp"); + s3.put("organisations/o/receipt.webp"); + s3.deleteKey("organisations/o/receipt.webp"); + s3.put("organisations/o/receipt.webp"); + s3.put("organisations/o/receipt.webp.bak"); + expect(await objectRemains("organisations/o/receipt.webp")).toBe(true); + expect(await deleteObjects(["organisations/o/receipt.webp"])).toEqual(new Map()); + expect(s3.keys()).toEqual(["organisations/o/receipt.webp.bak"]); + expect(await objectRemains("organisations/o/receipt.webp")).toBe(false); + expect(await objectRemains("organisations/o/receipt.webp.bak")).toBe(true); + }); + + it("treats a key that only a delete marker holds as still present until the marker goes", async () => { + const s3 = fake.s3!; + s3.put("organisations/o/cover.webp"); + s3.deleteKey("organisations/o/cover.webp"); + // HEAD would say 404 here, yet the bytes are kept. + expect(await objectRemains("organisations/o/cover.webp")).toBe(true); + await deleteObjects(["organisations/o/cover.webp"]); + expect(s3.keys()).toEqual([]); + }); + + it("finds keys under a prefix that only old versions or a delete marker still hold, across pages", async () => { + const s3 = fake.s3!; + s3.bucket.pageSize = 2; + for (const name of ["a", "b", "c"]) s3.put(`organisations/o/${name}.webp`); + s3.put("organisations/o/b.webp"); + s3.deleteKey("organisations/o/a.webp"); + s3.deleteKey("organisations/o/c.webp"); + s3.put("organisations/other/z.webp"); + expect(await listAll("organisations/o/")).toEqual(["organisations/o/a.webp", "organisations/o/b.webp", "organisations/o/c.webp"]); + await deleteObjects(await listAll("organisations/o/")); + expect(s3.keys()).toEqual(["organisations/other/z.webp"]); + }); + + it("deletes the single version an unversioned bucket keeps", async () => { + const s3 = fake.s3!; + s3.bucket.versioning = "off"; + s3.put("organisations/o/a.webp"); + s3.put("organisations/o/a.webp"); + await deleteObjects(["organisations/o/a.webp"]); + expect(s3.keys()).toEqual([]); + expect(await objectRemains("organisations/o/a.webp")).toBe(false); + }); + + it("falls back to plain deletes, HEAD and ListObjectsV2 when the provider has no versions API, asking once per location", async () => { + const s3 = fake.s3!; + s3.bucket.versioning = "unsupported"; + s3.bucket.pageSize = 1; + s3.put("organisations/o/a.webp"); + s3.put("organisations/o/b.webp"); + expect(await listAll("organisations/o/")).toEqual(["organisations/o/a.webp", "organisations/o/b.webp"]); + expect(await objectRemains("organisations/o/a.webp")).toBe(true); + expect(await deleteObjects(["organisations/o/a.webp", "organisations/o/b.webp"])).toEqual(new Map()); + expect(await objectRemains("organisations/o/a.webp")).toBe(false); + expect(s3.keys()).toEqual([]); + expect(s3.bucket.versionLists).toBe(1); + }); + + it("keeps a permission error a failure, and does not fall back for it", async () => { + const s3 = fake.s3!; + s3.put("organisations/o/a.webp"); + s3.bucket.failListWith = Object.assign(new Error("Access Denied"), { name: "AccessDenied", $metadata: { httpStatusCode: 403 } }); + await expect(deleteObjects(["organisations/o/a.webp"])).rejects.toMatchObject({ name: "AccessDenied" }); + await expect(objectRemains("organisations/o/a.webp")).rejects.toMatchObject({ name: "AccessDenied" }); + await expect(listObjectsPage("organisations/o/", null)).rejects.toMatchObject({ name: "AccessDenied" }); + expect(s3.keys()).toEqual(["organisations/o/a.webp"]); + // Once permitted, the versions API is used again: nothing fell back. + s3.bucket.failListWith = null; + await deleteObjects(["organisations/o/a.webp"]); + expect(s3.keys()).toEqual([]); + }); +}); From a816d2a368816eb559475e4ec2a580987dba5861 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 22:52:35 +0530 Subject: [PATCH 43/79] fix(claims): withdraw only within the authorised organisation and event, or the ticket's registration Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/finance.ts | 2 +- app/actions/participant-claims.ts | 2 +- docs/FINANCE.md | 6 ++++-- lib/claims/submit.ts | 20 ++++++++++++------- tests/integration/finance.db.test.ts | 4 ++-- .../mobile-finance-claims.db.test.ts | 14 +++++++++++++ .../reimbursements-claims.db.test.ts | 2 +- 7 files changed, 36 insertions(+), 14 deletions(-) diff --git a/app/actions/finance.ts b/app/actions/finance.ts index 7217978d..3f28da8b 100644 --- a/app/actions/finance.ts +++ b/app/actions/finance.ts @@ -64,7 +64,7 @@ export async function submitStaffClaimAction(formData: FormData): Promise { const scope = await resolveScope(scopeInput, false); if ("error" in scope) return { ok: false, error: scope.error }; - return done(scope, await withdrawClaim(String(claimId ?? "").slice(0, 128), { userId: scope.userId })); + return done(scope, await withdrawClaim(String(claimId ?? "").slice(0, 128), { userId: scope.userId, organisationId: scope.organisationId, eventId: scope.eventId })); } export async function approveClaimAction(scopeInput: unknown, input: unknown): Promise { diff --git a/app/actions/participant-claims.ts b/app/actions/participant-claims.ts index 0b6b5053..3eca8fca 100644 --- a/app/actions/participant-claims.ts +++ b/app/actions/participant-claims.ts @@ -35,7 +35,7 @@ export async function withdrawParticipantClaimAction(ticketToken: unknown, claim const rsvp = await prisma.rSVP.findUnique({ where: { checkInToken: ticketToken }, select: { id: true, userId: true } }); const session = await auth(); if (!rsvp || (rsvp.userId && rsvp.userId !== session?.user?.id)) return { ok: false, error: "Claim not found." }; - return withdrawClaim(claimId.slice(0, 128), { userId: null, rsvpId: rsvp.id }); + return withdrawClaim(claimId.slice(0, 128), { rsvpId: rsvp.id }); } /** Ask for a refund outside the automatic policy, from the order page. */ diff --git a/docs/FINANCE.md b/docs/FINANCE.md index f476470a..820951c7 100644 --- a/docs/FINANCE.md +++ b/docs/FINANCE.md @@ -56,8 +56,10 @@ matter, like prize payouts and payment settings. Every step is audited. cap don't apply to them. The Finance list labels them *(crew)*. - Lifecycle: `SUBMITTED` → `APPROVED` (for the amount asked or less, with an optional note) or `REJECTED` (with a reason the claimant sees) → - `PAID`. The claimant may withdraw (`WITHDRAWN`) while it waits. Each step - locks the claim row. + `PAID`. The claimant may withdraw (`WITHDRAWN`) while it waits: staff only + their own claims on the organisation and event of the route they use + (`withdrawClaim` matches all three), a participant only claims of the + registration their ticket proves. Each step locks the claim row. - Emails (operator notices through the outbox): owners and admins when a claim arrives; the person being paid back when it is approved, rejected or paid. Chat: *Expense claims* for claims and refund requests, *Payouts* for diff --git a/lib/claims/submit.ts b/lib/claims/submit.ts index 6d53e0f6..d2893e5c 100644 --- a/lib/claims/submit.ts +++ b/lib/claims/submit.ts @@ -166,18 +166,24 @@ export async function submitParticipantClaim(holder: { ticketToken: string; acto } } +/** + * Who withdraws, and where: staff (and finance callers) as the filer, on the + * organisation and event their access was checked for; a participant by the + * registration their ticket proves. The claim must match the whole scope. + */ +export type WithdrawScope = { userId: string; organisationId: string; eventId: string; } | { rsvpId: string; }; + /** The claimant takes back a claim nobody has decided yet. */ -export async function withdrawClaim(claimId: string, claimant: { userId: string | null; rsvpId?: string | null; }): Promise> { - if (!claimant.userId && !claimant.rsvpId) return fail("Claim not found."); +export async function withdrawClaim(claimId: string, scope: WithdrawScope): Promise> { + const where = "rsvpId" in scope + ? { id: claimId, rsvpId: scope.rsvpId } + : { id: claimId, submittedByUserId: scope.userId, organisationId: scope.organisationId, eventId: scope.eventId }; const result = await prisma.$transaction(async (tx): Promise> => { - const claim = await tx.expenseClaim.findFirst({ - where: { id: claimId, OR: [...(claimant.userId ? [{ submittedByUserId: claimant.userId }] : []), ...(claimant.rsvpId ? [{ rsvpId: claimant.rsvpId }] : [])] }, - select: { id: true, organisationId: true }, - }); + const claim = await tx.expenseClaim.findFirst({ where, select: { id: true, organisationId: true } }); if (!claim) return fail("Claim not found."); const updated = await tx.expenseClaim.updateMany({ where: { id: claim.id, status: ExpenseClaimStatus.SUBMITTED }, data: { status: ExpenseClaimStatus.WITHDRAWN } }); if (updated.count !== 1) return fail("Only claims waiting for a decision can be withdrawn."); - await recordAuditEvent({ action: "EXPENSE_CLAIM_WITHDRAWN", actorUserId: claimant.userId, organisationId: claim.organisationId, targetType: "ExpenseClaim", targetId: claim.id, client: tx }); + await recordAuditEvent({ action: "EXPENSE_CLAIM_WITHDRAWN", actorUserId: "userId" in scope ? scope.userId : null, organisationId: claim.organisationId, targetType: "ExpenseClaim", targetId: claim.id, client: tx }); return ok(undefined); }); if (result.ok) emitWebhookEvent({ type: "expense_claim.status_changed", claimId, status: ExpenseClaimStatus.WITHDRAWN, previousStatus: ExpenseClaimStatus.SUBMITTED }); diff --git a/tests/integration/finance.db.test.ts b/tests/integration/finance.db.test.ts index 2e3d15cb..f8a49384 100644 --- a/tests/integration/finance.db.test.ts +++ b/tests/integration/finance.db.test.ts @@ -62,7 +62,7 @@ describe.sequential("expense claims", () => { expect(await approveClaim(admin(), { claimId: claim.id, amount: "2000" })).toMatchObject({ ok: false }); expect(await approveClaim(admin(), { claimId: claim.id, amount: "1200", note: "Standard fare" })).toEqual({ ok: true, data: undefined }); - expect(await withdrawClaim(claim.id, { userId: staff.id })).toMatchObject({ ok: false }); + expect(await withdrawClaim(claim.id, { userId: staff.id, organisationId: paid.organisationId, eventId: event.id })).toMatchObject({ ok: false }); expect(await recordClaimPayout(admin(), { claimId: claim.id, method: "UPI", reference: "UPI-1" })).toEqual({ ok: true, data: undefined }); expect(await prisma.expenseClaim.findUniqueOrThrow({ where: { id: claim.id } })).toMatchObject({ status: ExpenseClaimStatus.PAID, approvedMinor: BigInt(120000) }); const payout = await prisma.claimPayout.findFirstOrThrow({ where: { claimId: claim.id } }); @@ -88,7 +88,7 @@ describe.sequential("expense claims", () => { expect(await rejectClaim(admin(), { claimId: first.id, note: "No ticket stub" })).toEqual({ ok: true, data: undefined }); expect(await claim(rsvp.checkInToken, "1000")).toMatchObject({ ok: true }); const second = await prisma.expenseClaim.findFirstOrThrow({ where: { rsvpId: rsvp.id, status: ExpenseClaimStatus.SUBMITTED } }); - expect(await withdrawClaim(second.id, { userId: null, rsvpId: rsvp.id })).toEqual({ ok: true, data: undefined }); + expect(await withdrawClaim(second.id, { rsvpId: rsvp.id })).toEqual({ ok: true, data: undefined }); }); }); diff --git a/tests/integration/mobile-finance-claims.db.test.ts b/tests/integration/mobile-finance-claims.db.test.ts index 471f2aab..d273d5ea 100644 --- a/tests/integration/mobile-finance-claims.db.test.ts +++ b/tests/integration/mobile-finance-claims.db.test.ts @@ -91,4 +91,18 @@ describe.sequential("staff claims from isolated grants", () => { expect((await withdraw(post("/x", {}, sessions.mentor!.accessToken), at(ids.event, { claimId: claim.id }))).status).not.toBe(200); expect(await prisma.expenseClaim.findUniqueOrThrow({ where: { id: claim.id } })).toMatchObject({ status: ExpenseClaimStatus.SUBMITTED }); }); + + it("never withdraws a claim on another event through this event's route", async () => { + const judgeId = users[Object.keys(grants).indexOf("judge")]!; + const token = sessions.judge!.accessToken; + // Filed on the other event while the judge had a grant there; the grant is then removed. + const grant = await prisma.eventCollaborator.create({ data: { userId: judgeId, eventId: ids.otherEvent, permissions: [EventPermission.JUDGE] } }); + expect((await fileClaim(claimForm(token, ids.otherEvent), at(ids.otherEvent))).status).toBe(201); + await prisma.eventCollaborator.delete({ where: { id: grant.id } }); + const elsewhere = await prisma.expenseClaim.findFirstOrThrow({ where: { eventId: ids.otherEvent, submittedByUserId: judgeId, status: ExpenseClaimStatus.SUBMITTED } }); + + const refused = await withdraw(post("/x", {}, token), at(ids.event, { claimId: elsewhere.id })); + expect(refused.status).not.toBe(200); + expect(await prisma.expenseClaim.findUniqueOrThrow({ where: { id: elsewhere.id } })).toMatchObject({ status: ExpenseClaimStatus.SUBMITTED }); + }); }); diff --git a/tests/integration/reimbursements-claims.db.test.ts b/tests/integration/reimbursements-claims.db.test.ts index f6173095..10b5a360 100644 --- a/tests/integration/reimbursements-claims.db.test.ts +++ b/tests/integration/reimbursements-claims.db.test.ts @@ -52,7 +52,7 @@ describe.sequential("claims under a reimbursement policy", () => { expect(snapshot?.terms).toMatchObject({ policyId: travel, capPerPersonMinor: "100000", eligibility: "CONFIRMED_PARTICIPANT" }); expect(snapshot?.submitted).toMatchObject({ eligible: true, facts: { registrationStatus: "CONFIRMED" } }); // Withdrawn claims stop counting. - expect(await withdrawClaim(filed[1]!.id, { userId: null, rsvpId: alice.id })).toMatchObject({ ok: true }); + expect(await withdrawClaim(filed[1]!.id, { rsvpId: alice.id })).toMatchObject({ ok: true }); expect(await claim(alice.checkInToken, "400", travel)).toMatchObject({ ok: true }); }); From 33677d1064b830ec3215017b17b4c43a727b8e05 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 23:07:37 +0530 Subject: [PATCH 44/79] fix(mobile): list application-review workspaces under GET /me reviewing Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- docs/MOBILE_API.md | 10 ++++++- docs/mobile-api-fixtures/me.json | 13 ++++++++ docs/mobile-api.openapi.yaml | 3 +- lib/mobile/account.ts | 30 ++++++++++++------- lib/mobile/schemas/core.ts | 2 ++ tests/integration/mobile-me-grants.db.test.ts | 15 +++++++++- 6 files changed, 59 insertions(+), 14 deletions(-) diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 4107e193..51e01f33 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -279,7 +279,7 @@ Account creation and password reset happen on the website. ### `GET /me` (auth) -`{ "user": User, "organisations": [OrgSummary], "collaborations": [{ "organisation": { "slug", "name" }, "eventId", "eventTitle", "permissions": ["CHECK_IN", …] }], "judging": [JudgingEvent] }` +`{ "user": User, "organisations": [OrgSummary], "collaborations": [{ "organisation": { "slug", "name" }, "eventId", "eventTitle", "permissions": ["CHECK_IN", …] }], "judging": [JudgingEvent], "mentoring": [WorkspaceEvent], "reviewing": [WorkspaceEvent], "staffMfa" }` `OrgSummary = { "id", "slug", "name", "logoUrl|null", "role": "OWNER|ADMIN|MEMBER" }`. Collaborations list standalone-event grants only; `JUDGE`, `MENTOR` and @@ -291,6 +291,14 @@ events the caller judges through a `JUDGE` grant, once the event takes submissions. Organisation owners and admins judge through their role: they find their events through `myPermissions.judge` on the organiser event list. +`WorkspaceEvent = { "organisation": { "slug", "name" }, "eventId", "eventTitle", "startDateTime", "endDateTime", "timezone" }` +(`MentoringEvent` in the OpenAPI file). `mentoring`: events the caller +mentors through a `MENTOR` grant while the Mentor Desk is on. `reviewing`: +events whose applications the caller reviews through a `REVIEW_APPLICATIONS` +grant while applications are on, so a review-only collaborator can open +`…/applications/reviews` from here. Review grants still open no organiser +tools; owners and admins find these events on the organiser event list. + ### `PATCH /me` (auth) `{ "name": "…" }` → `User`. Same validation, rate limit and audit as the web diff --git a/docs/mobile-api-fixtures/me.json b/docs/mobile-api-fixtures/me.json index a77d81af..e9734716 100644 --- a/docs/mobile-api-fixtures/me.json +++ b/docs/mobile-api-fixtures/me.json @@ -58,6 +58,19 @@ "endDateTime": "2030-06-02T18:00:00.000Z", "timezone": "Europe/London" } + ], + "reviewing": [ + { + "organisation": { + "slug": "design-club", + "name": "Design Club" + }, + "eventId": "clevent000000000000000004", + "eventTitle": "Design Fellowship", + "startDateTime": "2030-09-01T09:00:00.000Z", + "endDateTime": "2030-09-01T17:00:00.000Z", + "timezone": "Europe/London" + } ] } } diff --git a/docs/mobile-api.openapi.yaml b/docs/mobile-api.openapi.yaml index 8499efc2..c150a125 100644 --- a/docs/mobile-api.openapi.yaml +++ b/docs/mobile-api.openapi.yaml @@ -2559,13 +2559,14 @@ components: Me: type: object additionalProperties: false - required: [user, organisations, collaborations, judging, mentoring, staffMfa] + required: [user, organisations, collaborations, judging, mentoring, reviewing, staffMfa] properties: user: { $ref: "#/components/schemas/User" } organisations: { type: array, items: { $ref: "#/components/schemas/OrgSummary" } } collaborations: { type: array, items: { $ref: "#/components/schemas/Collaboration" } } judging: { type: array, items: { $ref: "#/components/schemas/JudgingEvent" } } mentoring: { type: array, items: { $ref: "#/components/schemas/MentoringEvent" }, description: Events this user mentors while their Mentor Desk is on. } + reviewing: { type: array, items: { $ref: "#/components/schemas/MentoringEvent" }, description: "Events whose applications this user reviews through a REVIEW_APPLICATIONS grant, while applications are on (the workspace is under orgs/{orgSlug}/events/{eventId}/applications/reviews). Organisation owners and admins find their events on the org event list. Never listed as collaborations." } staffMfa: { type: string, enum: [ok, verify, enrol], description: "The staff authenticator rule for this app session: verify (POST /auth/mfa) or enrol (on the website). Attendees are always ok." } EventSummary: diff --git a/lib/mobile/account.ts b/lib/mobile/account.ts index 4b571d1a..6ef3e3f9 100644 --- a/lib/mobile/account.ts +++ b/lib/mobile/account.ts @@ -41,23 +41,31 @@ export async function loadMe(userId: string, baseUrl: string) { }, }), ]); - // Events this user mentors (a MENTOR grant) while their Mentor Desk is on. - const mentorGrants = await prisma.eventCollaborator.findMany({ - where: { userId, permissions: { has: EventPermission.MENTOR }, event: { is: { mentorDeskEnabled: true } } }, + // Events this user mentors (a MENTOR grant) while their Mentor Desk is on, and + // reviews applications for (a REVIEW_APPLICATIONS grant) while applications + // are on (resolveMobileReviewer's rule). Admins use the org event list. + const grantEvents = (permission: EventPermission, event: Prisma.EventWhereInput) => prisma.eventCollaborator.findMany({ + where: { userId, permissions: { has: permission }, event: { is: event } }, orderBy: { createdAt: "desc" }, take: 200, select: { event: { select: { id: true, title: true, startDateTime: true, endDateTime: true, timezone: true, organisation: { select: { slug: true, name: true } } } } }, }); + const [mentorGrants, reviewGrants] = await Promise.all([ + grantEvents(EventPermission.MENTOR, { mentorDeskEnabled: true }), + grantEvents(EventPermission.REVIEW_APPLICATIONS, { admissionEnabled: true }), + ]); + const workspaceEvent = (grant: (typeof mentorGrants)[number]) => grant.event ? [{ + organisation: { slug: grant.event.organisation.slug, name: grant.event.organisation.name }, + eventId: grant.event.id, + eventTitle: grant.event.title, + startDateTime: grant.event.startDateTime.toISOString(), + endDateTime: grant.event.endDateTime.toISOString(), + timezone: grant.event.timezone, + }] : []; return meDtoSchema.parse({ user: toUserDto(user, baseUrl), - mentoring: mentorGrants.flatMap((grant) => grant.event ? [{ - organisation: { slug: grant.event.organisation.slug, name: grant.event.organisation.name }, - eventId: grant.event.id, - eventTitle: grant.event.title, - startDateTime: grant.event.startDateTime.toISOString(), - endDateTime: grant.event.endDateTime.toISOString(), - timezone: grant.event.timezone, - }] : []), + mentoring: mentorGrants.flatMap(workspaceEvent), + reviewing: reviewGrants.flatMap(workspaceEvent), staffMfa: await staffMfaState(userId), organisations: memberships.map((membership) => ({ id: membership.organisation.id, diff --git a/lib/mobile/schemas/core.ts b/lib/mobile/schemas/core.ts index 59895dca..23d3658b 100644 --- a/lib/mobile/schemas/core.ts +++ b/lib/mobile/schemas/core.ts @@ -110,6 +110,8 @@ export const meDtoSchema = z.object({ judging: z.array(judgingEventSchema), /** Space Works: events this user mentors (a MENTOR grant, Mentor Desk on); the console is under orgs/:orgSlug/events/:eventId/mentor. */ mentoring: z.array(judgingEventSchema.omit({ judgingStatus: true }).strict()), + /** Events whose applications this user reviews (a REVIEW_APPLICATIONS grant, applications on); the workspace is under orgs/:orgSlug/events/:eventId/applications/reviews. */ + reviewing: z.array(judgingEventSchema.omit({ judgingStatus: true }).strict()), /** * The staff authenticator rule for this app session: "ok", "verify" (POST * /auth/mfa with a code) or "enrol" (set an authenticator up on the website; diff --git a/tests/integration/mobile-me-grants.db.test.ts b/tests/integration/mobile-me-grants.db.test.ts index 9c936148..841f7666 100644 --- a/tests/integration/mobile-me-grants.db.test.ts +++ b/tests/integration/mobile-me-grants.db.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { EventPermission } from "@prisma/client"; +import { EventPermission, EventPrivacyType } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; /* @@ -50,6 +50,19 @@ describe("GET /me with application-review grants", () => { const body = await response.json(); expect(response.status, JSON.stringify(body)).toBe(200); expect(body.data.collaborations).toEqual([]); + // Applications are off on that event, so there is no review workspace to open yet. + expect(body.data.reviewing).toEqual([]); + }); + + it("lists the review workspace of a review-only collaborator once applications are on, still outside organiser tools", async () => { + await prisma.event.update({ where: { id: ids.reviewOnly }, data: { privacyType: EventPrivacyType.APPROVAL_REQUIRED, admissionEnabled: true } }); + const reviewer = await signIn(email("reviewer")); + const response = await me(get("/api/mobile/v1/me", reviewer.accessToken), none); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + expect(body.data.reviewing).toEqual([expect.objectContaining({ eventId: ids.reviewOnly, organisation: { slug: `mob-review-${suffix}`, name: "Review grants" } })]); + expect(body.data.collaborations).toEqual([]); + expect(body.data.organisations).toEqual([]); }); it("lists only the organiser permissions of a mixed grant", async () => { From 8d520097533b7b6dc05b18b096bf1fc0e8526d74 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 23:10:23 +0530 Subject: [PATCH 45/79] fix(vps): refuse releases missing applied migrations, stop writers on an unsafe rollback, status without backups Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- .../installation/updates-backups-status.mdx | 9 +- docs/DEPLOYMENT_VPS.md | 17 +- docs/production-operations.md | 2 +- scripts/update-vps.sh | 33 +++- scripts/vps-deploy.sh | 16 +- scripts/vps-lib.sh | 23 +++ scripts/vps-status.sh | 4 +- tests/unit/vps-rollback.test.ts | 161 ++++++++++++++++++ 8 files changed, 252 insertions(+), 13 deletions(-) create mode 100644 tests/unit/vps-rollback.test.ts diff --git a/content/help/installation/updates-backups-status.mdx b/content/help/installation/updates-backups-status.mdx index 726ea0a3..bfbb0567 100644 --- a/content/help/installation/updates-backups-status.mdx +++ b/content/help/installation/updates-backups-status.mdx @@ -23,17 +23,20 @@ The updater stops straight away if you're already on the latest release. Otherwi 3. recreates the services, waits for them to be healthy and runs its checks; 4. keeps the three newest releases so it can go back. -It keeps every setting and secret; an update never generates new ones. **If any step after the backup fails, it puts back the previous release automatically.** +It keeps every setting and secret; an update never generates new ones. **If any step after the backup fails, it puts back the previous release automatically**, as long as that release can run on the database as it now is. -Database migrations that already ran aren't reversed by the rollback. If an older release can't run on the newer database, restore the backup the updater took just before. +Database migrations that already ran aren't reversed by the rollback. A release can only run on a database whose migrations it all knows, so: + +- an update to a release that is older than the database's migrations is refused before anything changes; +- if a failed update has already applied a migration the previous release doesn't know, the updater doesn't start that release. It stops the app, worker and scheduler, and shows the command to restore the backup it took just before. Either fix the problem and run the update again, or run that command, which puts the database back as it was and starts the previous release. ### Automatic deploys -If you answered yes to automatic deploys, or added them later with **7) Automatic deploys**, a GitHub Actions runner on the server installs each new published release by itself. It runs the same steps as a manual update: a backup, the new images, migrations, health checks, and the previous release restored if the new one is unhealthy. Nothing connects in from GitHub; the runner checks outwards. It runs under its own account with no Docker access, and only installs published releases from the main branch. +If you answered yes to automatic deploys, or added them later with **7) Automatic deploys**, a GitHub Actions runner on the server installs each new published release by itself. It runs the same steps as a manual update: a backup, the new images, migrations, health checks, and the previous release restored if the new one is unhealthy. A release that is older than the database's migrations is refused, and the running release is left as it is. Nothing connects in from GitHub; the runner checks outwards. It runs under its own account with no Docker access, and only installs published releases from the main branch. ## Backups diff --git a/docs/DEPLOYMENT_VPS.md b/docs/DEPLOYMENT_VPS.md index 339d0d2a..2177310a 100644 --- a/docs/DEPLOYMENT_VPS.md +++ b/docs/DEPLOYMENT_VPS.md @@ -354,10 +354,21 @@ Run the one-liner again and choose **1) Update to the latest release** (or pass 6. points `current` at the new release and keeps the three newest releases and their images. +A release runs only against a database whose every recorded migration (applied, +or started and failed, in `_prisma_migrations`) is in its own `prisma/migrations`. +Before step 3 the updater refuses a release that lacks one, changing nothing; an +automatic deploy makes the same check in the installed `vps-deploy.sh` before the +requested release's own updater runs, so an older release is never installed +over newer migrations. + If any step after the backup fails, it restores the previous `compose.env` and -starts the previous release's retained images. **Migrations that already ran are -not reversed**; review migrations before updating, and restore the pre-update -backup if an older release cannot run on the newer schema. For automation, unpack +starts the previous release's retained images, provided that release has every +migration now recorded. **Migrations that already ran are not reversed.** If the +update applied (or started) a migration the previous release lacks, the updater +stops the app, worker and scheduler instead, prints the restore command, and +exits with an error: either fix the problem and run the update again, or restore +the backup taken at the start of the update with the previous release's +`scripts/vps-restore.sh`, which starts that release on the database as it was. For automation, unpack the new release with its commit in a `.release` file (and, for a published release, its version in `.release-version`) and run `sudo bash /scripts/update-vps.sh --yes`; it is copied into diff --git a/docs/production-operations.md b/docs/production-operations.md index d63cf1a3..351997b9 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -177,7 +177,7 @@ Release 0.11.0 reserves the organisation addresses `/attend`, `/crew`, `/help`, `20261120010100_storage_deletion_queue` starts the stored-file deletion queue ([Object storage](CONFIGURATION.md#storage-contract-and-the-deletion-queue)): from this release on, every upload records a pending deletion before it sends a byte. Instances of an older release upload without that record, so their files can be missed when an organisation is deleted. This is a **release requirement**: 1. Stop every instance of the previous release (web, worker and beat; this aborts their uploads in progress) **within one hour** of the migration. Docker Compose, the Helm rolling update, Render and the VPS updater all finish replacing instances within minutes; a canary or blue-green setup must not keep the old release serving longer. -2. **Never run an older image against this database again**, including for a rollback. Roll back by restoring the pre-migration backup instead. +2. **Never run an older image against this database again**, including for a rollback. Roll back by restoring the pre-migration backup instead. The VPS updater and automatic deploy enforce this for every migration: they refuse a release whose `prisma/migrations` lacks one the database has recorded, and a failed update stops the app, worker and scheduler rather than restart a release that lacks the new migrations ([Upgrades and rollback](DEPLOYMENT_VPS.md#upgrades-and-rollback)). The queue deletes every version and delete marker of a file, so versioning may stay on (bundled Silo keeps it on). Before upgrading an installation on Amazon S3 or another service with a versions API, give the application's access key `s3:ListBucketVersions` and `s3:DeleteObjectVersion` as well; without them its deletions fail with `AccessDenied` until they are granted, and **Super admin → Operations** reports them. Bundled Silo's policy is updated on the next start of the storage setup container. Cloudflare R2 needs nothing extra ([storage contract](CONFIGURATION.md#storage-contract-and-the-deletion-queue)). diff --git a/scripts/update-vps.sh b/scripts/update-vps.sh index 019478ca..df2bea1e 100755 --- a/scripts/update-vps.sh +++ b/scripts/update-vps.sh @@ -3,7 +3,12 @@ # and volumes. The curl installer runs it as root from the staged new release. # It backs up the database and configuration first, downloads the release's # images (or builds them for a source install) and migrates. If the new release does not come up healthy it returns to the -# previous release's images; database migrations are never reversed. +# previous release's images; database migrations are never reversed. A release +# runs only against a database whose every recorded migration it contains +# (scripts/vps-lib.sh applied_migrations): this release is refused before +# anything changes if it lacks one, and when the update has applied migrations +# the previous release lacks, the app, worker and scheduler are stopped instead +# of restarting it, and the backup restore is printed. # --yes skip the confirmation (automation) set -euo pipefail set +x @@ -57,7 +62,7 @@ marker="$SWE_DIR/releases/$revision/.release-version" saved_marker=untouched if [[ $managed = 1 && -d "$SWE_DIR/releases/$revision" ]]; then saved_marker=$(cat "$marker" 2>/dev/null || echo none) fi -phase=preparing +phase=preparing migrating=0 applied_before='' restore_previous() { local status=$? [[ $status != 0 ]] || return 0 @@ -70,6 +75,26 @@ restore_previous() { [[ $phase = deploying ]] || exit "$status" warn "The update failed; returning to ${previous_version:-the previous release}." mv -f "$compose_env.previous" "$compose_env" + if [[ $migrating = 1 ]]; then + # Read even though the migrator may have failed: a failed migration is recorded too. + local applied missing + if ! applied=$(applied_migrations); then + missing='(the database could not be read)' + elif [[ -n "$previous_dir" ]]; then + missing=$(missing_migrations "$previous_dir" <<< "$applied") + else + # No previous source to compare: only what this update recorded is known to be new. + missing=$(grep -vxF -f <(printf '%s\n' "$applied_before") <<< "$applied") + fi + if [[ -n "$missing" ]]; then + compose stop app worker beat + warn "${previous_version:-The previous release} cannot run against this database: it lacks migrations already recorded ($(paste -sd ' ' <<< "$missing")). The app, worker and scheduler were stopped instead of restarting it." + warn "To recover, fix the problem and run the update to $version again, or restore the database from before the update, which also starts the previous release:" + warn " sudo bash ${previous_dir:-}/scripts/vps-restore.sh $(newest_backup)" + warn "That backup was taken at the start of this update (docs/DEPLOYMENT_VPS.md, Upgrades and rollback)." + exit "$status" + fi + fi if [[ -n "$previous_dir" && -f "$previous_dir/scripts/vps-compose.sh" ]]; then sh "$previous_dir/scripts/vps-compose.sh" up -d --wait --wait-timeout 300 --remove-orphans \ || warn "The previous release did not start cleanly. Inspect it with: SWE_CONFIG_DIR=$SWE_CONFIG_DIR $previous_dir/scripts/vps-compose.sh ps" @@ -83,6 +108,9 @@ trap restore_previous EXIT SWE_REPO_DIR=$source_dir [[ $managed = 0 ]] || SWE_REPO_DIR=$(promote_release "$source_dir" "$revision") cd "$SWE_REPO_DIR" +applied_before=$(applied_migrations) || die "Could not read this database's migrations. Nothing was changed." +missing=$(missing_migrations "$SWE_REPO_DIR" <<< "$applied_before") +[[ -z "$missing" ]] || die "$version cannot run against this database: it lacks migrations already applied ($(paste -sd ' ' <<< "$missing")). Nothing was changed. An older release needs the backup from before those migrations restored (docs/DEPLOYMENT_VPS.md, Upgrades and rollback)." say 'Backing up the database and configuration.' bash "$SWE_REPO_DIR/scripts/vps-backup.sh" --no-objects phase=deploying @@ -99,6 +127,7 @@ compose run --rm --no-deps ops node docker/vps/check.mjs preflight # An organisation on an address this release reserves stops the update here, # before anything is migrated, and the previous release keeps serving. compose run --rm --no-deps ops node scripts/check-reserved-slugs.mjs +migrating=1 compose run --rm migrator compose run --rm --no-deps ops node scripts/verify-production-schema.mjs compose up -d --wait --wait-timeout 300 --remove-orphans diff --git a/scripts/vps-deploy.sh b/scripts/vps-deploy.sh index 01541441..33183819 100755 --- a/scripts/vps-deploy.sh +++ b/scripts/vps-deploy.sh @@ -2,9 +2,11 @@ # Deploy a published release without questions. The Release workflow's deploy # job runs it on this server through the runner from install-runner.sh: # vps-deploy.sh v0.9.2-main.42.a1b2c3d4e5f6 -# It downloads that release's source like install.sh, then runs its updater: -# backup, new images, migrations, health checks, and the previous release -# restored automatically if the new one is unhealthy. +# It downloads that release's source like install.sh, refuses it (changing +# nothing) if it lacks a migration this database already has, then runs its +# updater: backup, new images, migrations, health checks, and the previous +# release restored automatically if the new one is unhealthy and the previous +# one can still run against the database. set -euo pipefail set +x source_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) @@ -40,4 +42,12 @@ github "https://codeload.github.com/$repository/tar.gz/$revision" | tar -xz -C " printf '%s\n' "$revision" > "$stage/source/.release" printf '%s\n' "$version" > "$stage/source/.release-version" [[ -f "$stage/source/scripts/update-vps.sh" ]] || die "Release $tag has no updater." +# Checked here, by the installed release, before the requested release's own +# (possibly older) updater runs: a release missing a migration this database +# already has is refused, and the running release is left exactly as it is. +load_config_dir +SWE_REPO_DIR=$source_dir +applied=$(applied_migrations) || die "Could not read this database's migrations; $tag was not deployed and the running release is unchanged." +missing=$(missing_migrations "$stage/source" <<< "$applied") +[[ -z "$missing" ]] || die "$tag cannot run against this database: it lacks migrations already applied ($(paste -sd ' ' <<< "$missing")). It was not deployed and the running release is unchanged. An older release needs the backup from before those migrations restored (docs/DEPLOYMENT_VPS.md, Upgrades and rollback)." SWE_STAGED=1 SWE_REPOSITORY="$repository" bash "$stage/source/scripts/update-vps.sh" --yes /dev/null | sort | tail -n 1) +# No backups directory yet just means no backup yet; any other error is shown, +# and the service checks below still run. +newest=$(newest_backup) || warn "Could not list $SWE_CONFIG_DIR/backups." say "Newest backup: ${newest:-none yet; run scripts/vps-backup.sh}" compose ps compose run --rm --no-deps ops node docker/vps/check.mjs diff --git a/tests/unit/vps-rollback.test.ts b/tests/unit/vps-rollback.test.ts new file mode 100644 index 00000000..43f27162 --- /dev/null +++ b/tests/unit/vps-rollback.test.ts @@ -0,0 +1,161 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +/* + * Scripted runs of the VPS deploy, update and status scripts with fake id, + * docker (Compose, psql, the migrator) and curl on PATH. A release runs only + * against a database whose every recorded migration it contains: the installed + * deploy entry point refuses an older release without touching the running + * one, and a failed update whose migrations the previous release lacks stops + * the writers instead of restarting it. Nothing touches the real system. + */ +const root = path.resolve(import.meta.dirname, "../.."); +const migrations = readdirSync(path.join(root, "prisma/migrations")).filter((name) => /^\d{14}_/.test(name)).sort(); +const older = migrations.slice(0, -1); +const newest = migrations.at(-1)!; +let dir = ""; +let config = ""; + +const posix = (value: string) => value.replaceAll("\\", "/").replace(/^([A-Za-z]):\//, (_, drive: string) => `/${drive.toLowerCase()}/`); +const write = (file: string, body: string, mode = 0o644) => { + mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + writeFileSync(path.join(dir, file), body, { mode }); +}; +const bin = (name: string, body: string) => write(`bin/${name}`, `#!/bin/bash\n${body}\n`, 0o755); +const bash = (script: string) => spawnSync("bash", ["-c", `PATH="${posix(dir)}/bin:$PATH"; ${script}`], { + cwd: root, encoding: "utf8", timeout: 120_000, + env: { ...process.env, FAKE: posix(dir), SWE_DIR: `${posix(dir)}/swe`, SWE_CONFIG_DIR: posix(config), SWE_STAGED: "", SWE_REPOSITORY: "" }, +}); +const log = () => (existsSync(path.join(dir, "docker.log")) ? readFileSync(path.join(dir, "docker.log"), "utf8") : ""); +const lines = (names: string[]) => names.map((name) => `${name}\n`).join(""); +/** A release's source as far as compatibility goes: its migration folders. */ +const release = (folder: string, names: string[]) => { for (const name of names) mkdirSync(path.join(dir, folder, "prisma/migrations", name), { recursive: true }); }; + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), "swe-rollback-")); + config = path.join(dir, "config"); + mkdirSync(config); + writeFileSync(path.join(config, "compose.env"), "SWE_VERSION='1.0.0'\n"); + write("applied.txt", lines(older)); + write("migrate.txt", ""); + write("migrate-status", "0\n"); + bin("id", '[ "$1" = -u ] && echo 0 || exit 1'); + // Compose: psql prints the recorded migrations; the migrator records migrate.txt (even when it then fails); `up` fails. + bin("docker", [ + 'args="$*"', + 'echo "$args" >> "$FAKE/docker.log"', + 'case "$args" in', + ' *" exec -T postgres psql "*) cat "$FAKE/applied.txt" ;;', + ' *" exec -T postgres pg_dump "*) echo dump ;;', + ' *" exec -T postgres pg_restore "*) cat > /dev/null ;;', + ' *" run -T --rm migrator"*) cat "$FAKE/migrate.txt" >> "$FAKE/applied.txt"; exit "$(cat "$FAKE/migrate-status")" ;;', + ' *" up -d --wait"*) exit 1 ;;', + "esac", + ].join("\n")); +}); + +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe("automatic deploy of a requested release", { timeout: 120_000 }, () => { + const tag = "v1.2.3-main.42.abcdef012345"; + beforeEach(() => { + write("swe/install.conf", "SWE_REPOSITORY='example/events'\n"); + write("revision", `abcdef012345${"0".repeat(28)}\n`); + write("release-src/top/scripts/update-vps.sh", 'echo ran >> "$FAKE/updater.log"\n'); + bin("curl", [ + 'url="${@: -1}"', + 'case "$url" in', + ' */commits/*) cat "$FAKE/revision" ;;', + ` */releases/tags/*) echo '{"prerelease": false, "draft": false}' ;;`, + ` */compare/*) echo ' "status": "behind",' ;;`, + ' https://codeload.github.com/*) cat "$FAKE/release.tar.gz" ;;', + " *) exit 22 ;;", + "esac", + ].join("\n")); + }); + const pack = () => expect(bash('tar -czf "$FAKE/release.tar.gz" -C "$FAKE/release-src" top').status).toBe(0); + + it("refuses a release that lacks a migration the database has, leaving the running release untouched", () => { + release("release-src/top", older.slice(0, -1)); + pack(); + const result = bash(`bash scripts/vps-deploy.sh ${tag}`); + expect(result.status).toBe(1); + expect(result.stderr).toContain(`it lacks migrations already applied (${older.at(-1)})`); + expect(result.stderr).toContain("the running release is unchanged"); + expect(existsSync(path.join(dir, "updater.log"))).toBe(false); + // Only the migrations were read: nothing stopped, started or pulled. + expect(log().trim().split("\n")).toHaveLength(1); + expect(log()).toContain("exec -T postgres psql"); + expect(readFileSync(path.join(config, "compose.env"), "utf8")).toBe("SWE_VERSION='1.0.0'\n"); + }); + + it("hands a compatible release to its own updater", () => { + release("release-src/top", older); + pack(); + const result = bash(`bash scripts/vps-deploy.sh ${tag}`); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(path.join(dir, "updater.log"), "utf8")).toBe("ran\n"); + }); +}); + +describe("a failed update", { timeout: 120_000 }, () => { + beforeEach(() => { + write("swe/current/scripts/vps-compose.sh", 'echo "previous $*" >> "$FAKE/docker.log"\n'); + }); + const update = () => bash("bash scripts/update-vps.sh --yes"); + + it("stops the writers and prints the restore when the previous release lacks a migration it recorded, even one that failed", () => { + release("swe/current", older); + write("migrate.txt", `${newest}\n`); + write("migrate-status", "1\n"); + const result = update(); + expect(result.status).not.toBe(0); + expect(log()).toMatch(/ stop app worker beat\n/); + expect(log()).not.toContain("previous up"); + expect(result.stderr).toContain(`it lacks migrations already recorded (${newest})`); + expect(result.stderr).toMatch(/swe\/current\/scripts\/vps-restore\.sh .*\/backups\/2\d+T\d+Z/); + expect(readFileSync(path.join(config, "compose.env"), "utf8")).toBe("SWE_VERSION='1.0.0'\n"); + }); + + it("returns to a previous release that has every recorded migration", () => { + release("swe/current", migrations); + write("migrate.txt", `${newest}\n`); + const result = update(); + expect(result.status).not.toBe(0); + expect(log()).toContain("previous up -d --wait"); + expect(log()).not.toMatch(/ stop app worker beat/); + }); + + it("refuses to start when this release lacks a migration the database has, before any backup", () => { + release("swe/current", migrations); + write("applied.txt", lines([...older, "20991231000000_from_a_newer_release"])); + const result = update(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("20991231000000_from_a_newer_release"); + expect(result.stderr).toContain("Nothing was changed"); + expect(log()).not.toContain("pg_dump"); + expect(existsSync(path.join(config, "backups"))).toBe(false); + }); +}); + +describe("status check", { timeout: 120_000 }, () => { + it("reports no backups yet when the backups folder does not exist, and still runs the checks", () => { + const result = bash("bash scripts/vps-status.sh"); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Newest backup: none yet"); + expect(log()).toMatch(/ ps\n/); + expect(log()).toContain("check.mjs"); + }); + + it("shows any other error listing backups, and still runs the checks", () => { + mkdirSync(path.join(config, "backups")); + bin("find", 'echo "find: cannot read the folder" >&2; exit 1'); + const result = bash("bash scripts/vps-status.sh"); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("Could not list"); + expect(log()).toContain("check.mjs"); + }); +}); From 5ceee0374f13ccfe42dbd2823a1edb9905292f35 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 23:33:21 +0530 Subject: [PATCH 46/79] fix(payments): read hold eligibility under the locks; recheck ticket types and promo codes before reviving an ended hold Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- lib/payments/operations/reaccept.ts | 5 +- lib/payments/operations/settle.ts | 20 +- lib/payments/operations/waitlist.ts | 4 +- lib/payments/promo.ts | 14 ++ lib/payments/settlement.ts | 26 ++- lib/payments/targets.ts | 47 ++++- .../payment-final-settlement.db.test.ts | 181 ++++++++++++++++++ 7 files changed, 278 insertions(+), 19 deletions(-) create mode 100644 tests/integration/payment-final-settlement.db.test.ts diff --git a/lib/payments/operations/reaccept.ts b/lib/payments/operations/reaccept.ts index 378fb73b..01cf1d30 100644 --- a/lib/payments/operations/reaccept.ts +++ b/lib/payments/operations/reaccept.ts @@ -5,6 +5,7 @@ import { prisma } from "@/lib/db"; import { recordAuditEvent } from "@/lib/audit"; import { enqueueRsvpConfirmation } from "@/lib/outbox"; import { hasFreeSeats, ORDER_SUBJECT_SELECT, orderSubject } from "../targets"; +import { promoStillFits } from "../promo"; import { COLLECTED, lockOrder, onlineAccount, PaymentOperationError } from "./shared"; import { offerInTransaction } from "./waitlist"; @@ -23,7 +24,7 @@ export async function reacceptDeclinedOrder(params: { orderId: string; organisat const order = await tx.order.findFirst({ where: { id: params.orderId, organisationId: params.organisationId }, select: { - id: true, status: true, buyerEmail: true, ...ORDER_SUBJECT_SELECT, + id: true, status: true, buyerEmail: true, promoCodeId: true, ...ORDER_SUBJECT_SELECT, rsvps: { select: { id: true, orderId: true, eventId: true, eventInstanceId: true, ticketTypeId: true, status: true, checkInToken: true } }, payments: { where: { status: { in: COLLECTED } }, select: { id: true } }, }, @@ -34,6 +35,8 @@ export async function reacceptDeclinedOrder(params: { orderId: string; organisat if (!order || !declined) throw new PaymentOperationError("Only a declined request can be re-accepted."); const subject = orderSubject(order); if (!subject || !subject.open(now)) throw new PaymentOperationError("This event is no longer open for registration."); + // Declining released its promo code's use: it comes back only if the code still has one. + if (!(await promoStillFits(tx, order, now))) throw new PaymentOperationError("The promo code on this order has been used up since it was declined, so it can't be re-accepted."); await tx.order.update({ where: { id: order.id }, data: { status: OrderStatus.REQUESTED } }); if (await hasFreeSeats(tx, order.rsvps, now)) { diff --git a/lib/payments/operations/settle.ts b/lib/payments/operations/settle.ts index 49961c58..711298a8 100644 --- a/lib/payments/operations/settle.ts +++ b/lib/payments/operations/settle.ts @@ -8,7 +8,8 @@ import { closeOpenAttempts } from "../checkout"; import { enqueueOrderEmail, enqueuePaymentEmail } from "../email"; import { isActiveHold } from "../holds"; import { effectiveOfflineRecord } from "../offlineRecord"; -import { hasFreeSeats, ORDER_SUBJECT_SELECT, orderTargetCancelled } from "../targets"; +import { hasFreeSeats, ORDER_SUBJECT_SELECT, orderTargetCancelled, ticketTypesFit } from "../targets"; +import { promoStillFits } from "../promo"; import { addOnsFit } from "../addOnStock"; import { AdmissionManagedError, attendeeEmail, isAdmissionOrder, lockOrder, PaymentOperationError, subjectTitle } from "./shared"; import { heldStatusWebhooks } from "../settlement"; @@ -49,7 +50,7 @@ export async function settleOrderByHand(params: { const order = await tx.order.findUniqueOrThrow({ where: { id: owned.id }, select: { - id: true, status: true, buyerEmail: true, buyerName: true, eventId: true, teamId: true, teamName: true, teamJoin: true, submissionId: true, ...ORDER_SUBJECT_SELECT, + id: true, status: true, buyerEmail: true, buyerName: true, eventId: true, teamId: true, teamName: true, teamJoin: true, submissionId: true, promoCodeId: true, ...ORDER_SUBJECT_SELECT, payments: { where: { status: PaymentStatus.PENDING }, select: { id: true, provider: true } }, rsvps: { select: { id: true, orderId: true, eventId: true, eventInstanceId: true, ticketTypeId: true, status: true, holdExpiresAt: true, checkInToken: true, guestEmail: true, user: { select: { email: true } } } }, }, @@ -82,12 +83,17 @@ export async function settleOrderByHand(params: { } const held = order.rsvps.filter((rsvp) => rsvp.status === RsvpStatus.PENDING_PAYMENT); if (held.length === 0) throw new PaymentOperationError("This order has no tickets left to confirm."); - const holdActive = held.every((rsvp) => isActiveHold(rsvp, now)); - // An ended hold released its extras too: they come back only if still in stock. - if (!holdActive && !(await addOnsFit(tx, order.id, now))) throw new PaymentOperationError("The hold ended and some extras on this order have sold out since."); + // Read under the locks: `now` was taken before the provider pages were closed. + const at = new Date(); + const holdActive = held.every((rsvp) => isActiveHold(rsvp, at)); + // An ended hold released its extras, ticket-type places and promo use too: + // they come back only if still there, checked before anything changes. + if (!holdActive && !(await addOnsFit(tx, order.id, at))) throw new PaymentOperationError("The hold ended and some extras on this order have sold out since."); + if (!holdActive && !(await ticketTypesFit(tx, held, at))) throw new PaymentOperationError("The hold ended and the places have gone."); + if (!holdActive && !(await promoStillFits(tx, order, at))) throw new PaymentOperationError("The hold ended and the promo code on this order has been used up since."); // TEAM events place the one registration in its team or unit (docs/TEAMS.md). - const team = held.length === 1 ? await placePaidTeamOrder(tx, order, held[0]!.id, holdActive, now) : null; - if (team === "gone" || (!team && !holdActive && !(await hasFreeSeats(tx, held, now)))) throw new PaymentOperationError("The hold ended and the places have gone."); + const team = held.length === 1 ? await placePaidTeamOrder(tx, order, held[0]!.id, holdActive, at) : null; + if (team === "gone" || (!team && !holdActive && !(await hasFreeSeats(tx, held, at)))) throw new PaymentOperationError("The hold ended and the places have gone."); const status = team?.status ?? RsvpStatus.CONFIRMED; await tx.paymentCheckoutAttempt.updateMany({ where: { paymentId: payment.id, status: CheckoutAttemptStatus.OPEN }, data: { status: CheckoutAttemptStatus.CANCELLED } }); diff --git a/lib/payments/operations/waitlist.ts b/lib/payments/operations/waitlist.ts index 47e19c90..1e848dd9 100644 --- a/lib/payments/operations/waitlist.ts +++ b/lib/payments/operations/waitlist.ts @@ -10,6 +10,7 @@ import { HOLD_EXTENSION_CAP_MS, PAYMENT_LINK_HOLD_MS } from "../holds"; import { providerMinimumMinor } from "../money"; import { hasFreeSeats, lockSeriesDates, ORDER_SUBJECT_SELECT, orderSubject, seatTargetOf, type SeatTarget } from "../targets"; import { addOnsFit } from "../addOnStock"; +import { promoStillFits } from "../promo"; import { AdmissionManagedError, attendeeEmail, isAdmissionOrder, lockOrder, onlineAccount, PaymentOperationError, subjectTitle, ZERO } from "./shared"; /** @@ -191,13 +192,14 @@ export async function sendPaymentLink(params: { orderId: string; organisationId: await lockOrder(tx, order.id); const current = await tx.order.findUniqueOrThrow({ where: { id: order.id }, - select: { status: true, totalMinor: true, currency: true, buyerEmail: true, ...ORDER_SUBJECT_SELECT, rsvps: { select: { id: true, orderId: true, eventId: true, eventInstanceId: true, ticketTypeId: true } } }, + select: { id: true, status: true, totalMinor: true, currency: true, buyerEmail: true, promoCodeId: true, ...ORDER_SUBJECT_SELECT, rsvps: { select: { id: true, orderId: true, eventId: true, eventInstanceId: true, ticketTypeId: true } } }, }); if (current.status !== OrderStatus.EXPIRED) throw new PaymentOperationError("This order was changed by someone else."); if (current.rsvps.length === 0) throw new PaymentOperationError("The buyer has registered again since this order expired."); if (current.totalMinor < providerMinimumMinor(account.provider, current.currency)) throw new PaymentOperationError("This order is below the smallest payment the provider accepts."); if (!(await hasFreeSeats(tx, current.rsvps, now))) throw new PaymentOperationError("Its places have gone since the hold ended."); if (!(await addOnsFit(tx, order.id, now))) throw new PaymentOperationError("Some extras on this order have sold out since the hold ended."); + if (!(await promoStillFits(tx, current, now))) throw new PaymentOperationError("The promo code on this order has been used up since the hold ended."); const holdExpiresAt = new Date(now.getTime() + PAYMENT_LINK_HOLD_MS); await tx.order.update({ where: { id: order.id }, data: { status: OrderStatus.PENDING, holdExpiresAt, holdCapAt: new Date(holdExpiresAt.getTime() + HOLD_EXTENSION_CAP_MS), testMode: account.testMode } }); await tx.rSVP.updateMany({ where: { orderId: order.id }, data: { status: RsvpStatus.PENDING_PAYMENT, holdExpiresAt } }); diff --git a/lib/payments/promo.ts b/lib/payments/promo.ts index 42e80e74..751160d7 100644 --- a/lib/payments/promo.ts +++ b/lib/payments/promo.ts @@ -71,3 +71,17 @@ export async function lockPromoCode(tx: Prisma.TransactionClient, params: { owne rule: { kind: locked.kind, appliesPer: locked.appliesPer, percentOff: locked.percentOff, amountOffMinor: locked.amountOffMinor, ticketTypeId: locked.ticketTypeId, minQuantity: locked.minQuantity, perOrderLimit: locked.perOrderLimit }, }; } + +/** + * Whether an order coming back after its code's use was released (an ended + * hold paid late or reopened, a declined request re-accepted) still fits the + * code's redemption limit, counting every other order using it. Read under + * the event or series lock that every order on the code takes first. + */ +export async function promoStillFits(tx: Prisma.TransactionClient, order: { id: string; promoCodeId: string | null; }, now: Date) { + if (!order.promoCodeId) return true; + const promo = await tx.promoCode.findUnique({ where: { id: order.promoCodeId }, select: { maxRedemptions: true } }); + if (promo?.maxRedemptions == null) return true; + const others = await tx.order.count({ where: { ...redeemingOrdersWhere(order.promoCodeId, now), id: { not: order.id } } }); + return others < promo.maxRedemptions; +} diff --git a/lib/payments/settlement.ts b/lib/payments/settlement.ts index 571d6346..2d298166 100644 --- a/lib/payments/settlement.ts +++ b/lib/payments/settlement.ts @@ -4,7 +4,8 @@ import { CheckoutAttemptStatus, OrderStatus, PaymentStatus, RsvpStatus, type Pri import { recordAuditEvent } from "@/lib/audit"; import { enqueueRsvpConfirmation } from "@/lib/outbox"; import { enqueueOrderEmail, enqueuePaymentEmail, organisationOwnerEmails } from "./email"; -import { hasFreeSeats, lockOrderTargets, ORDER_SUBJECT_SELECT, orderSubject, orderTargetCancelled } from "./targets"; +import { fenceOrganisation, hasFreeSeats, lockOrderTargets, ORDER_SUBJECT_SELECT, orderSubject, orderTargetCancelled, ticketTypesFit } from "./targets"; +import { promoStillFits } from "./promo"; import { addOnsFit } from "./addOnStock"; import type { OnlineProvider } from "./http"; import type { PaidNotification } from "./providers/types"; @@ -64,7 +65,11 @@ async function findPayment(tx: Prisma.TransactionClient, context: { organisation * - A payment for a cancelled event, series or date is recorded, its order * cancelled without tickets, and refunded in full automatically. * - * Lock order matches registration: the event row, the order, then the payment. + * Lock order matches registration: the organisation fence, the event row, + * the order, then the payment. `now` stamps the payment; whether the hold is + * still live, and so whether places must be counted again, is decided with + * the time read once those locks are held, so a reservation that already + * counted an ended hold's places as free is never oversold. */ export async function settlePaidNotification( tx: Prisma.TransactionClient, @@ -82,6 +87,7 @@ export async function settlePaidNotification( const located = await tx.payment.findUniqueOrThrow({ where: { id: found.paymentId }, select: { externalPaymentId: true, orderId: true, order: { select: { id: true, eventId: true, eventSeriesId: true } } } }); if (located.externalPaymentId === paid.paymentId) return { outcome: "already-settled", refundIds: [] }; + if (!(await fenceOrganisation(tx, context.organisationId))) return { outcome: "unmatched", refundIds: [] }; await lockOrderTargets(tx, located.order, now); await tx.$queryRaw`SELECT "id" FROM "Order" WHERE "id" = ${located.orderId} FOR UPDATE`; await tx.$queryRaw`SELECT "id" FROM "Payment" WHERE "id" = ${found.paymentId} FOR UPDATE`; @@ -91,7 +97,7 @@ export async function settlePaidNotification( id: true, organisationId: true, status: true, amountMinor: true, currency: true, testMode: true, description: true, externalPaymentId: true, order: { select: { - id: true, status: true, holdExpiresAt: true, buyerEmail: true, buyerName: true, eventId: true, teamId: true, teamName: true, teamJoin: true, submissionId: true, ...ORDER_SUBJECT_SELECT, + id: true, status: true, holdExpiresAt: true, buyerEmail: true, buyerName: true, eventId: true, teamId: true, teamName: true, teamJoin: true, submissionId: true, promoCodeId: true, ...ORDER_SUBJECT_SELECT, rsvps: { select: { id: true, orderId: true, eventId: true, eventInstanceId: true, ticketTypeId: true, status: true, holdExpiresAt: true, checkInToken: true, guestEmail: true, user: { select: { email: true } } } }, }, }, @@ -131,12 +137,16 @@ export async function settlePaidNotification( } const held = order.rsvps.filter((rsvp) => rsvp.status === RsvpStatus.PENDING_PAYMENT); - const holdActive = order.status === OrderStatus.PENDING && Boolean(order.holdExpiresAt && order.holdExpiresAt > now); - // After the hold ended, its extras were released too: only taken back if still in stock. - const stocked = holdActive || await addOnsFit(tx, order.id, now); + // Read under the locks: a timestamp from before them may predate the hold's end. + const at = new Date(); + const holdActive = order.status === OrderStatus.PENDING && Boolean(order.holdExpiresAt && order.holdExpiresAt > at); + // After the hold ended, its extras, its ticket types' places and its promo + // code's use were released too: taken back only if still there, checked + // before anything (a team place) changes. + const stocked = holdActive || (await addOnsFit(tx, order.id, at) && await ticketTypesFit(tx, held, at) && await promoStillFits(tx, order, at)); // TEAM events place the one registration in its team or unit (docs/TEAMS.md). - const team = stocked && held.length === 1 ? await placePaidTeamOrder(tx, order, held[0]!.id, holdActive, now) : null; - const seats = stocked && (team ? team !== "gone" : held.length > 0 && (holdActive || await hasFreeSeats(tx, held, now))); + const team = stocked && held.length === 1 ? await placePaidTeamOrder(tx, order, held[0]!.id, holdActive, at) : null; + const seats = stocked && (team ? team !== "gone" : held.length > 0 && (holdActive || await hasFreeSeats(tx, held, at))); const placed = team && team !== "gone" ? team : null; const status = placed?.status ?? RsvpStatus.CONFIRMED; await tx.payment.update({ where: { id: payment.id }, data: { status: PaymentStatus.PAID_ONLINE, externalPaymentId: paid.paymentId, paidAt: now } }); diff --git a/lib/payments/targets.ts b/lib/payments/targets.ts index 6e066b76..a47ec4c7 100644 --- a/lib/payments/targets.ts +++ b/lib/payments/targets.ts @@ -47,12 +47,35 @@ function dateLabel(date: Date, timeZone: string) { } } +/** + * The organisation fence (docs/PAYMENTS.md), the first lock of every path + * that creates an order or a payment: a key-share lock on the Organisation + * row. It never blocks other payment paths, but organisation deletion takes + * the row FOR UPDATE, so the two run one after the other instead of the + * deletion's cascade meeting an order still being written. False when the + * organisation is gone (deleted while this waited). + */ +export async function fenceOrganisation(tx: Prisma.TransactionClient, organisationId: string) { + const rows = await tx.$queryRaw>`SELECT "id" FROM "Organisation" WHERE "id" = ${organisationId} FOR KEY SHARE`; + return rows.length === 1; +} + +/** The organisation owning an event or series, read before any lock so its fence comes first. */ +async function fenceOwner(tx: Prisma.TransactionClient, owner: { eventId: string; } | { eventSeriesId: string; }) { + const row = "eventId" in owner + ? await tx.event.findUnique({ where: { id: owner.eventId }, select: { organisationId: true } }) + : await tx.eventSeries.findUnique({ where: { id: owner.eventSeriesId }, select: { organisationId: true } }); + return row != null && fenceOrganisation(tx, row.organisationId); +} + /** * Lock what the order sells, always in the same order so concurrent orders - * never deadlock: the event row; or the series row, then its dates by id. + * never deadlock: the organisation fence; then the event row, or the series + * row and its dates by id. */ export async function lockSellable(tx: Prisma.TransactionClient, target: OrderTarget, now: Date): Promise { if ("eventId" in target) { + if (!(await fenceOwner(tx, { eventId: target.eventId }))) return null; await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${target.eventId} FOR UPDATE`; const event = await tx.event.findUnique({ where: { id: target.eventId }, @@ -81,7 +104,7 @@ export async function lockSellable(tx: Prisma.TransactionClient, target: OrderTa const seriesId = "passSeriesId" in target ? target.passSeriesId : (await tx.eventInstance.findUnique({ where: { id: target.eventInstanceId }, select: { eventSeriesId: true } }))?.eventSeriesId; - if (!seriesId) return null; + if (!seriesId || !(await fenceOwner(tx, { eventSeriesId: seriesId }))) return null; await lockSeriesDates(tx, seriesId, "eventInstanceId" in target ? [target.eventInstanceId] : null, now); const series = await tx.eventSeries.findUnique({ where: { id: seriesId }, @@ -188,6 +211,26 @@ export async function hasFreeSeats(tx: Prisma.TransactionClient, rsvps: Array<{ } if (capacity != null && await tx.rSVP.count({ where: { ...target, ...others } }) + seats.length > capacity) return false; } + return ticketTypesFit(tx, rsvps, now); +} + +/** + * Whether each ticket type's limit still has room for these registrations + * (per event or date for date tickets, in passes for passes), counting + * everyone else's confirmed places and live holds. Part of hasFreeSeats, and + * checked on its own where places are counted otherwise (a TEAM event's + * units, docs/TEAMS.md). + */ +export async function ticketTypesFit(tx: Prisma.TransactionClient, rsvps: Array<{ id: string; orderId: string | null; eventId: string | null; eventInstanceId: string | null; ticketTypeId: string | null; }>, now: Date) { + const others = { id: { notIn: rsvps.map((rsvp) => rsvp.id) }, ...seatTakingWhere(now) }; + const groups = new Map(); + for (const rsvp of rsvps) { + const target = seatTargetOf(rsvp); + if (!target) continue; + const group = groups.get(targetKey(target)) ?? { target, rsvps: [] }; + group.rsvps.push(rsvp); + groups.set(targetKey(target), group); + } const typeIds = [...new Set(rsvps.flatMap((rsvp) => (rsvp.ticketTypeId ? [rsvp.ticketTypeId] : [])))]; for (const ticketTypeId of typeIds) { const type = await tx.ticketType.findUnique({ where: { id: ticketTypeId }, select: { capacity: true, kind: true, seatsPerTicket: true } }); diff --git a/tests/integration/payment-final-settlement.db.test.ts b/tests/integration/payment-final-settlement.db.test.ts new file mode 100644 index 00000000..c68b5a94 --- /dev/null +++ b/tests/integration/payment-final-settlement.db.test.ts @@ -0,0 +1,181 @@ +import { EventPrivacyType, OrderStatus, PaymentRefundStatus, RsvpStatus } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * Late and delayed settlement (docs/PAYMENTS.md "Locks and races"): whether + * a hold is still live is read under the locks, so a payment whose provider + * call outlived its hold never takes a place resold meanwhile; an ended + * hold gets back its ticket type's place and its promo code's use only if + * they are still there, checked before a team place changes; reopening or + * re-accepting an order re-checks its promo code. + */ + +vi.mock("@/lib/auth", () => ({ auth: async () => null })); + +import { prisma } from "@/lib/db"; +import { closeOpenAttempts } from "@/lib/payments/checkout"; +import { seatTakingWhere } from "@/lib/payments/holds"; +import { declineRequestedOrder, PaymentOperationError, reacceptDeclinedOrder, sendPaymentLink, settleOrderByHand } from "@/lib/payments/operations"; +import { promoCodeDigest, promoCodeHint } from "@/lib/payments/promo"; +import { submitRsvpCore } from "@/lib/rsvpCore"; +import { createTeamEvent, fake, paid, payOrder } from "./helpers/paidOrg"; +import { buyOnline, email, onlineEvent, owner, setUpPaymentAudit, tearDownPaymentAudit } from "./helpers/paymentAudit"; + +beforeAll(() => setUpPaymentAudit("Final settlement")); +afterAll(() => tearDownPaymentAudit()); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** The order's hold (and its places') ends `inMs` from now. */ +async function holdEndsIn(orderId: string, inMs: number) { + const ends = new Date(Date.now() + inMs); + await prisma.order.update({ where: { id: orderId }, data: { holdExpiresAt: ends } }); + await prisma.rSVP.updateMany({ where: { orderId }, data: { holdExpiresAt: ends } }); + return ends; +} + +/** + * Hold the next request that closes a provider page until `release` is + * called: the settlement waits there with the time it read before the call. + */ +function gatePageClose() { + let release!: () => void; + const opened = new Promise((resolve) => { release = resolve; }); + let reached!: () => void; + const waiting = new Promise((resolve) => { reached = resolve; }); + let gated = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + if (!gated && String(input).includes("/cancel")) { + gated = true; + reached(); + await opened; + } + return fake.fetch(input, init); + }); + return { waiting, release: () => { release(); vi.stubGlobal("fetch", fake.fetch); } }; +} + +const seatsTaken = (eventId: string) => prisma.rSVP.count({ where: { eventId, ...seatTakingWhere(new Date()) } }); +const confirmed = (eventId: string) => prisma.rSVP.count({ where: { eventId, status: RsvpStatus.CONFIRMED } }); +const pageOf = async (orderId: string) => (await prisma.paymentCheckoutAttempt.findFirstOrThrow({ where: { payment: { orderId } }, orderBy: { createdAt: "desc" } })).externalId!; + +describe.sequential("settling after the hold ended while the provider was asked", () => { + it("refunds a captured payment whose hold ended before its settlement got the locks", async () => { + const { event, ticket } = await onlineEvent({ capacity: 1 }); + const a = await buyOnline({ eventSlug: event.slug }, ticket.id, "late-webhook-a"); + fake.pay(await pageOf(a.id)); + const ends = await holdEndsIn(a.id, 1200); + const gate = gatePageClose(); + // A's settlement reads the time now (hold live), then waits on the provider. + const settling = closeOpenAttempts(a.id); + await gate.waiting; + await sleep(Math.max(0, ends.getTime() - Date.now()) + 100); + // B reserves after A's hold ended, before A's settlement takes the event lock. + const b = await buyOnline({ eventSlug: event.slug }, ticket.id, "late-webhook-b"); + expect(b.status).toBe(OrderStatus.PENDING); + gate.release(); + expect(await settling).toBe("settled"); + expect(await prisma.order.findUniqueOrThrow({ where: { id: a.id } })).toMatchObject({ status: OrderStatus.EXPIRED }); + expect(await prisma.paymentRefund.findFirst({ where: { payment: { orderId: a.id } } })).toMatchObject({ automatic: true, status: PaymentRefundStatus.SUCCEEDED }); + expect(await confirmed(event.id)).toBe(0); + expect(await seatsTaken(event.id)).toBe(1); + }); + + it("refuses to settle by hand a hold that ended while its page was being closed", async () => { + const { event, ticket } = await onlineEvent({ capacity: 1 }); + const a = await buyOnline({ eventSlug: event.slug }, ticket.id, "late-desk-a"); + const ends = await holdEndsIn(a.id, 1200); + const gate = gatePageClose(); + const settling = settleOrderByHand({ orderId: a.id, organisationId: paid.organisationId, actorUserId: owner.id, mode: "offline", method: "CASH" }); + const outcome = settling.then(() => "confirmed", (error: unknown) => error); + await gate.waiting; + await sleep(Math.max(0, ends.getTime() - Date.now()) + 100); + const b = await buyOnline({ eventSlug: event.slug }, ticket.id, "late-desk-b"); + expect(b.status).toBe(OrderStatus.PENDING); + gate.release(); + const result = await outcome; + expect(result).toBeInstanceOf(PaymentOperationError); + expect((result as Error).message).toBe("The hold ended and the places have gone."); + expect(await prisma.order.findUniqueOrThrow({ where: { id: a.id } })).toMatchObject({ status: OrderStatus.PENDING }); + expect(await confirmed(event.id)).toBe(0); + expect(await seatsTaken(event.id)).toBe(1); + }); +}); + +describe.sequential("a late payment on a TEAM event", () => { + const register = (eventSlug: string, who: string, ticketTypeId: string, team: { mode: "solo"; } | { mode: "create"; name: string; }) => submitRsvpCore( + { orgSlug: paid.orgSlug, eventSlug, guestEmail: email(who), name: "Team Person", answers: {}, items: [{ ticketTypeId, quantity: 1 }] }, + { userId: null, team }, + ); + const orderOf = (who: string) => prisma.order.findFirstOrThrow({ where: { buyerEmail: email(who) }, orderBy: { createdAt: "desc" } }); + + it("is refunded when its ticket type sold out meanwhile, though event units are free, and no team is confirmed", async () => { + const { event, ticket } = await createTeamEvent({ capacity: 10 }); + await prisma.ticketType.update({ where: { id: ticket.id }, data: { capacity: 1 } }); + expect(await register(event.slug, "team-late-a", ticket.id, { mode: "create", name: "Late Larks" })).toMatchObject({ ok: true }); + const a = await orderOf("team-late-a"); + await holdEndsIn(a.id, -1000); + expect(await register(event.slug, "team-late-b", ticket.id, { mode: "solo" })).toMatchObject({ ok: true }); + expect((await payOrder((await orderOf("team-late-b")).id)).outcome).toBe("confirmed"); + expect(await payOrder(a.id)).toMatchObject({ outcome: "refunding" }); + expect(await prisma.rSVP.count({ where: { ticketTypeId: ticket.id, status: RsvpStatus.CONFIRMED } })).toBe(1); + expect(await prisma.team.count({ where: { eventId: event.id, status: RsvpStatus.CONFIRMED } })).toBe(0); + expect(await prisma.order.findUniqueOrThrow({ where: { id: a.id } })).toMatchObject({ status: OrderStatus.EXPIRED }); + }); +}); + +describe.sequential("a promo code's use after it was released", () => { + let codes = 0; + async function limitedCode(eventId: string) { + codes += 1; + const code = `ONCE${codes}${paid.suffix.slice(0, 6)}`.toUpperCase(); + await prisma.promoCode.create({ data: { eventId, codeDigest: promoCodeDigest(code), hint: promoCodeHint(code), kind: "PERCENT", percentOff: 50, maxRedemptions: 1 } }); + return code; + } + async function buyWithCode(eventSlug: string, ticketTypeId: string, who: string, promoCode: string) { + const result = await submitRsvpCore({ orgSlug: paid.orgSlug, eventSlug, guestEmail: email(who), name: "Promo Buyer", answers: {}, items: [{ ticketTypeId, quantity: 1 }], promoCode }, { userId: null }); + expect(result).toMatchObject({ ok: true }); + return prisma.order.findFirstOrThrow({ where: { buyerEmail: email(who) }, orderBy: { createdAt: "desc" } }); + } + const redeemed = (promoCodeId: string) => prisma.order.count({ where: { promoCodeId, status: OrderStatus.CONFIRMED } }); + + it("refunds a late payment whose code was used up meanwhile", async () => { + const { event, ticket } = await onlineEvent(); + const code = await limitedCode(event.id); + const a = await buyWithCode(event.slug, ticket.id, "promo-late-a", code); + await holdEndsIn(a.id, -1000); + const b = await buyWithCode(event.slug, ticket.id, "promo-late-b", code); + expect((await payOrder(b.id)).outcome).toBe("confirmed"); + expect(await payOrder(a.id)).toMatchObject({ outcome: "refunding" }); + expect(await redeemed(a.promoCodeId!)).toBe(1); + }); + + it("refuses to reopen an expired order whose code was used up meanwhile", async () => { + const { event, ticket } = await onlineEvent(); + const code = await limitedCode(event.id); + const a = await buyWithCode(event.slug, ticket.id, "promo-reopen-a", code); + await holdEndsIn(a.id, -1000); + await prisma.order.update({ where: { id: a.id }, data: { status: OrderStatus.EXPIRED } }); + await prisma.payment.updateMany({ where: { orderId: a.id }, data: { status: "EXPIRED" } }); + await prisma.paymentCheckoutAttempt.updateMany({ where: { payment: { orderId: a.id } }, data: { status: "EXPIRED" } }); + await buyWithCode(event.slug, ticket.id, "promo-reopen-b", code); + await expect(sendPaymentLink({ orderId: a.id, organisationId: paid.organisationId, actorUserId: owner.id })).rejects.toThrow("used up since the hold ended"); + expect(await prisma.order.findUniqueOrThrow({ where: { id: a.id } })).toMatchObject({ status: OrderStatus.EXPIRED }); + }); + + it("refuses to re-accept a declined request whose code was used up meanwhile, changing nothing", async () => { + const { event, ticket } = await onlineEvent({ privacyType: EventPrivacyType.APPROVAL_REQUIRED }); + const code = await limitedCode(event.id); + const a = await buyWithCode(event.slug, ticket.id, "promo-reaccept-a", code); + expect(a.status).toBe(OrderStatus.REQUESTED); + await declineRequestedOrder({ orderId: a.id, organisationId: paid.organisationId, actorUserId: owner.id }); + const b = await buyWithCode(event.slug, ticket.id, "promo-reaccept-b", code); + expect(b.status).toBe(OrderStatus.REQUESTED); + await expect(reacceptDeclinedOrder({ orderId: a.id, organisationId: paid.organisationId, actorUserId: owner.id })).rejects.toThrow("used up since it was declined"); + expect(await prisma.order.findUniqueOrThrow({ where: { id: a.id } })).toMatchObject({ status: OrderStatus.CANCELLED }); + expect(await prisma.rSVP.findMany({ where: { orderId: a.id }, select: { status: true } })).toEqual([{ status: RsvpStatus.REJECTED }]); + // Once B's request goes, A can come back. + await declineRequestedOrder({ orderId: b.id, organisationId: paid.organisationId, actorUserId: owner.id }); + expect(await reacceptDeclinedOrder({ orderId: a.id, organisationId: paid.organisationId, actorUserId: owner.id })).toBe("offered"); + }); +}); From cf2011bbc57fe31956e175f95b858ea6373c7ffb Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 23:33:23 +0530 Subject: [PATCH 47/79] fix(payments): organisation fence for order and payment paths; deletion rechecks money owed under its lock; serialise membership changes Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/event/remove.ts | 22 ++- app/actions/membership.ts | 141 +++++++------ app/actions/series.ts | 17 +- lib/admission/state.ts | 9 +- lib/payments/entryFeeCheckout.ts | 4 + lib/payments/eventCancellation.ts | 13 +- lib/payments/obligations.ts | 44 +++++ lib/payments/operations/shared.ts | 14 +- .../payment-final-deletion.db.test.ts | 186 ++++++++++++++++++ 9 files changed, 365 insertions(+), 85 deletions(-) create mode 100644 lib/payments/obligations.ts create mode 100644 tests/integration/payment-final-deletion.db.test.ts diff --git a/app/actions/event/remove.ts b/app/actions/event/remove.ts index a065dbed..e2663c2d 100644 --- a/app/actions/event/remove.ts +++ b/app/actions/event/remove.ts @@ -10,7 +10,8 @@ import { flattenZodErrors } from "../utils"; import { isActionRateLimited } from "@/lib/actionRateLimit"; import { recordAuditEvent } from "@/lib/audit"; import { z } from "zod"; -import { hasPaidOrders, notifyEventCancelled, refundCancelledEvent } from "@/lib/payments/eventCancellation"; +import { hasPaidOrders, notifyEventCancelled, PAID_EVENT_REFUSAL, refundCancelledEvent } from "@/lib/payments/eventCancellation"; +import { fenceOrganisation } from "@/lib/payments/targets"; import { revalidateEventPaths, revalidatePath } from "./shared"; import { emitWebhookEvent } from "@/lib/webhooks/emit"; @@ -47,20 +48,25 @@ export async function deleteEvent(input: unknown): Promise { }); if (!event) return { ok: false, error: "Event not found." }; // Deleting would leave people who paid without their tickets: cancel instead, which refunds them. - if (await hasPaidOrders({ eventId: event.id })) { - return { ok: false, error: "People have paid for this event. Cancel it instead, which refunds them in full." }; - } + if (await hasPaidOrders({ eventId: event.id })) return { ok: false, error: PAID_EVENT_REFUSAL }; const slug = event.slug; try { - await prisma.$transaction(async (tx) => { - if (event.status === EventStatus.PUBLISHED && event.endDateTime > new Date()) await notifyEventCancelled(tx, { organisationId: org.id, eventId: event.id, title: event.title }); - // Claims outlive the event; their policy terms stay in each claim's snapshot. - // The event lock first, as when claims are filed, approved or paid (Event, then claim). + const refused = await prisma.$transaction(async (tx) => { + // The locks a reservation takes (docs/PAYMENTS.md): the organisation + // fence, then the event. A paid order committed before this is seen + // below; one that waits finds the event gone. + await fenceOrganisation(tx, org.id); await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${event.id} FOR UPDATE`; + if (await hasPaidOrders({ eventId: event.id }, tx)) return true; + if (event.status === EventStatus.PUBLISHED && event.endDateTime > new Date()) await notifyEventCancelled(tx, { organisationId: org.id, eventId: event.id, title: event.title }); + // Claims outlive the event; their policy terms stay in each claim's snapshot + // (the event lock is held, as when claims are filed, approved or paid). await tx.expenseClaim.updateMany({ where: { policy: { eventId: event.id } }, data: { policyId: null } }); await tx.event.delete({ where: { id: event.id } }); + return false; }); + if (refused) return { ok: false, error: PAID_EVENT_REFUSAL }; await recordAuditEvent({ action: "EVENT_DELETED", actorUserId: session.user.id, diff --git a/app/actions/membership.ts b/app/actions/membership.ts index 26a7e93c..e7fae3cd 100644 --- a/app/actions/membership.ts +++ b/app/actions/membership.ts @@ -19,13 +19,30 @@ import type { ActionResult } from "./org"; import { flattenZodErrors } from "./utils"; import { recordAuditEvent } from "@/lib/audit"; import { hasRecentAuthentication } from "@/lib/reauth"; -import { EventStatus, OrderStatus } from "@prisma/client"; +import { EventStatus, type Prisma } from "@prisma/client"; import { peopleWithPlaces } from "@/lib/payments/eventCancellation"; +import { organisationMoneyBlocker } from "@/lib/payments/obligations"; import { noticeEach, noticeSubject, uniqueEmails } from "@/lib/notices"; import { queueOrganisationStorage } from "@/lib/storage/deletionQueue"; const revalidatePath = trackedRevalidation(nextRevalidatePath); +/** + * Membership changes run one at a time per organisation, on its row lock (as + * leaving does), and re-read both people's roles under it: a promotion and a + * removal can't both act on what they read before the other committed. FOR + * NO KEY UPDATE serialises them without blocking the payment paths' key-share + * fence (docs/PAYMENTS.md). + */ +async function lockMembers(tx: Prisma.TransactionClient, organisationId: string, actorUserId: string, targetUserId: string) { + await tx.$queryRaw`SELECT "id" FROM "Organisation" WHERE "id" = ${organisationId} FOR NO KEY UPDATE`; + const [actor, target] = await Promise.all([ + tx.membership.findUnique({ where: { userId_organisationId: { userId: actorUserId, organisationId } }, select: { id: true, role: true } }), + tx.membership.findUnique({ where: { userId_organisationId: { userId: targetUserId, organisationId } }, select: { id: true, role: true } }), + ]); + return { actor, target }; +} + export async function updateMemberRole( input: unknown, ): Promise { @@ -57,24 +74,26 @@ export async function updateMemberRole( return { ok: false, error: "For security, sign in again before changing member roles." }; } - const target = await getMembership(targetUserId, org.id); - if (!target) return { ok: false, error: "Member not found." }; - if (target.role === "OWNER") { - return { ok: false, error: "The owner role cannot be changed here." }; - } - - await prisma.membership.update({ - where: { id: target.id }, - data: { role }, - }); - await recordAuditEvent({ - action: "MEMBERSHIP_ROLE_CHANGED", - actorUserId: session.user.id, - organisationId: org.id, - targetType: "membership", - targetId: target.id, - metadata: { role }, + const actorUserId = session.user.id; + const refusal = await prisma.$transaction(async (tx) => { + const { actor: current, target } = await lockMembers(tx, org.id, actorUserId, targetUserId); + if (current?.role !== "OWNER") return "Only the organisation owner can change roles."; + if (!target) return "Member not found."; + if (target.role === "OWNER") return "The owner role cannot be changed here."; + const updated = await tx.membership.updateMany({ where: { id: target.id, organisationId: org.id, role: { not: "OWNER" } }, data: { role } }); + if (updated.count !== 1) return "Member not found."; + await recordAuditEvent({ + action: "MEMBERSHIP_ROLE_CHANGED", + actorUserId, + organisationId: org.id, + targetType: "membership", + targetId: target.id, + metadata: { role }, + client: tx, + }); + return null; }); + if (refusal) return { ok: false, error: refusal }; revalidatePath(`/dashboard/${org.slug}/members`); revalidatePath(`/dashboard/${org.slug}`); @@ -111,23 +130,27 @@ export async function removeMember(input: unknown): Promise { return { ok: false, error: "You do not have permission to remove members." }; } - const target = await getMembership(targetUserId, org.id); - if (!target) return { ok: false, error: "Member not found." }; - if (target.role === "OWNER") { - return { ok: false, error: "Cannot remove the organisation owner." }; - } - if (actor.role === "ADMIN" && target.role !== "MEMBER") { - return { ok: false, error: "Admins can only remove members." }; - } - - await prisma.membership.delete({ where: { id: target.id } }); - await recordAuditEvent({ - action: "MEMBER_REMOVED", - actorUserId: session.user.id, - organisationId: org.id, - targetType: "membership", - targetId: target.id, + const actorUserId = session.user.id; + const refusal = await prisma.$transaction(async (tx) => { + const { actor: current, target } = await lockMembers(tx, org.id, actorUserId, targetUserId); + if (!current || !isOrgAdmin(current.role)) return "You do not have permission to remove members."; + if (!target) return "Member not found."; + if (target.role === "OWNER") return "Cannot remove the organisation owner."; + if (current.role === "ADMIN" && target.role !== "MEMBER") return "Admins can only remove members."; + // Constrained to the role checked above, so a change committed meanwhile can't be swept away. + const removed = await tx.membership.deleteMany({ where: { id: target.id, organisationId: org.id, role: current.role === "OWNER" ? { not: "OWNER" } : "MEMBER" } }); + if (removed.count !== 1) return "Member not found."; + await recordAuditEvent({ + action: "MEMBER_REMOVED", + actorUserId, + organisationId: org.id, + targetType: "membership", + targetId: target.id, + client: tx, + }); + return null; }); + if (refusal) return { ok: false, error: refusal }; revalidatePath(`/dashboard/${org.slug}/members`); revalidatePath(`/dashboard/${org.slug}`); @@ -162,24 +185,20 @@ export async function deleteOrganisation(input: unknown): Promise return { ok: false, error: "For security, sign in again before deleting an organisation." }; } - // Deleting would erase the tickets and payment records of people who paid - // for something still to come: cancelling those events refunds them first. const now = new Date(); - const paidUpcoming = await prisma.order.count({ - where: { - organisationId: org.id, - totalMinor: { gt: 0 }, - status: { in: [OrderStatus.CONFIRMED, OrderStatus.PENDING, OrderStatus.REQUESTED] }, - OR: [{ event: { endDateTime: { gt: now }, status: { not: EventStatus.CANCELLED } } }, { series: { status: { not: EventStatus.CANCELLED }, instances: { some: { endDateTime: { gt: now }, cancelledAt: null } } } }], - }, - }); - if (paidUpcoming > 0) { - return { ok: false, error: "People have paid for upcoming events. Cancel those events (or their dates) first, which refunds them." }; - } - const slug = org.slug; - // Guests of upcoming events and dates are told they won't go ahead. - await prisma.$transaction(async (tx) => { + const actorUserId = session.user.id; + // One transaction, the Organisation row first: every path that creates an + // order or a payment takes its key-share fence (docs/PAYMENTS.md), so it + // either committed before this check or waits and then finds nothing. + // Nothing is announced, audited or queued unless the check passes. + const refusal = await prisma.$transaction(async (tx) => { + const locked = await tx.$queryRaw>`SELECT "id" FROM "Organisation" WHERE "id" = ${org.id} FOR UPDATE`; + if (locked.length === 0) return "Organisation not found."; + // Deleting would erase payment records and refunds still owed or in flight. + const blocker = await organisationMoneyBlocker(tx, org.id, now); + if (blocker) return blocker; + // Guests of upcoming events and dates are told they won't go ahead. const [events, dates] = await Promise.all([ tx.event.findMany({ where: { organisationId: org.id, status: EventStatus.PUBLISHED, endDateTime: { gt: now } }, select: { id: true, title: true } }), tx.eventInstance.findMany({ where: { series: { organisationId: org.id, status: EventStatus.PUBLISHED }, endDateTime: { gt: now }, cancelledAt: null }, select: { id: true, series: { select: { title: true } } } }), @@ -191,23 +210,17 @@ export async function deleteOrganisation(input: unknown): Promise for (const [title, emails] of people) { await noticeEach(tx, emails, { subject: noticeSubject("Cancelled", title), lines: [`${title} has been cancelled: ${org.name} closed its account.`] }); } - }, { timeout: 60_000 }); - // Deliberately omit organisationId: deleting the organisation cascades its - // scoped records, while this event must survive as an instance-level record. - await recordAuditEvent({ - action: "ORGANISATION_DELETED", - actorUserId: session.user.id, - targetType: "organisation", - targetId: org.id, - }); - // Its stored files are queued in the same transaction (lib/storage/deletionQueue.ts): - // the queue has no foreign key, so it outlives the cascade, and the lock - // orders this against an upload publishing its Asset. - await prisma.$transaction(async (tx) => { - await tx.$queryRaw`SELECT 1 FROM "Organisation" WHERE "id" = ${org.id} FOR UPDATE`; + // Deliberately omit organisationId: deleting the organisation cascades its + // scoped records, while this event must survive as an instance-level record. + await recordAuditEvent({ action: "ORGANISATION_DELETED", actorUserId, targetType: "organisation", targetId: org.id, client: tx }); + // Its stored files are queued here too (lib/storage/deletionQueue.ts): the + // queue has no foreign key, so it outlives the cascade, and the row lock + // orders this against an upload publishing its Asset. await queueOrganisationStorage(tx, org.id); await tx.organisation.delete({ where: { id: org.id } }); + return null; }, { timeout: 60_000 }); + if (refusal) return { ok: false, error: refusal }; revalidatePath("/dashboard"); revalidatePath(`/${slug}`); diff --git a/app/actions/series.ts b/app/actions/series.ts index c04cb58f..aff287c2 100644 --- a/app/actions/series.ts +++ b/app/actions/series.ts @@ -15,7 +15,8 @@ import type { ActionResult } from "./org"; import { flattenZodErrors } from "./utils"; import { recordAuditEvent } from "@/lib/audit"; import { isActionRateLimited } from "@/lib/actionRateLimit"; -import { hasPaidOrders, peopleWithPlaces } from "@/lib/payments/eventCancellation"; +import { hasPaidOrders, PAID_SERIES_REFUSAL, peopleWithPlaces } from "@/lib/payments/eventCancellation"; +import { fenceOrganisation } from "@/lib/payments/targets"; import { noticeEach, noticeSubject, uniqueEmails } from "@/lib/notices"; import { cancelOccurrence, setOccurrenceCapacity } from "@/lib/payments/occurrences"; import { PaymentOperationError } from "@/lib/payments/operations"; @@ -266,16 +267,20 @@ export async function deleteEventSeries(input: unknown): Promise { // Deleting would erase the tickets of people who paid: cancel dates instead, // which refunds them. - if (await hasPaidOrders({ eventSeriesId: series.id })) { - return { ok: false, error: "People have paid for this series. Cancel its dates instead, which refunds them." }; - } + if (await hasPaidOrders({ eventSeriesId: series.id })) return { ok: false, error: PAID_SERIES_REFUSAL }; const instances = await prisma.eventInstance.findMany({ where: { eventSeriesId: series.id }, select: { id: true }, }); - await prisma.$transaction(async (tx) => { + const refused = await prisma.$transaction(async (tx) => { + // The locks a reservation takes first (docs/PAYMENTS.md): the organisation + // fence, then the series row. A paid order committed before this is seen + // here; one that waits finds the series gone. + await fenceOrganisation(tx, org.id); + await tx.$queryRaw`SELECT "id" FROM "EventSeries" WHERE "id" = ${series.id} FOR UPDATE`; + if (await hasPaidOrders({ eventSeriesId: series.id }, tx)) return true; // People booked on upcoming dates are told the series won't go ahead. if (series.status === EventStatus.PUBLISHED) { const upcoming = await tx.eventInstance.findMany({ where: { eventSeriesId: series.id, endDateTime: { gt: new Date() }, cancelledAt: null }, select: { id: true } }); @@ -283,7 +288,9 @@ export async function deleteEventSeries(input: unknown): Promise { await noticeEach(tx, people, { organisationId: org.id, subject: noticeSubject("Cancelled", series.title), lines: [`${series.title} has been cancelled by the organiser, with all its upcoming dates.`] }); } await tx.eventSeries.delete({ where: { id: series.id } }); + return false; }); + if (refused) return { ok: false, error: PAID_SERIES_REFUSAL }; await recordAuditEvent({ action: "EVENT_SERIES_DELETED", diff --git a/lib/admission/state.ts b/lib/admission/state.ts index 87f85860..025d94f2 100644 --- a/lib/admission/state.ts +++ b/lib/admission/state.ts @@ -3,6 +3,7 @@ import "server-only"; import { AdmissionStatus, OrderStatus, Prisma, RegistrationMode, RsvpStatus } from "@prisma/client"; import { countConfirmedUnits } from "@/lib/teamUnits"; import { seatTakingWhere } from "@/lib/payments/holds"; +import { fenceOrganisation } from "@/lib/payments/targets"; import type { RegistrationStatusWebhook } from "@/lib/teamUnits"; import { OFFER_HOLDING } from "./rules"; @@ -41,8 +42,14 @@ export const admissionEventSelect = { export type AdmissionEvent = Prisma.EventGetPayload<{ select: typeof admissionEventSelect }>; -/** Lock the Event row and read it; null when the event is gone or not in the organisation. */ +/** + * Lock the Event row and read it; null when the event is gone or not in the + * organisation. The organisation fence comes first, as on every path that may + * create a payment (accepting a paid offer; docs/PAYMENTS.md lock order). + */ export async function lockAdmissionEvent(tx: Prisma.TransactionClient, eventId: string, organisationId?: string): Promise { + const owner = await tx.event.findUnique({ where: { id: eventId }, select: { organisationId: true } }); + if (!owner || !(await fenceOrganisation(tx, owner.organisationId))) return null; await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${eventId} FOR UPDATE`; return tx.event.findFirst({ where: { id: eventId, ...(organisationId ? { organisationId } : {}) }, select: admissionEventSelect }); } diff --git a/lib/payments/entryFeeCheckout.ts b/lib/payments/entryFeeCheckout.ts index f032f369..f7e6951a 100644 --- a/lib/payments/entryFeeCheckout.ts +++ b/lib/payments/entryFeeCheckout.ts @@ -9,6 +9,7 @@ import { CHECKOUT_HOLD_MS, HOLD_EXTENSION_CAP_MS } from "./holds"; import { DEFAULT_CURRENCY } from "./money"; import { newOrderReference } from "./orders"; import { loadPaymentAccount } from "./settings"; +import { fenceOrganisation } from "./targets"; const UNAVAILABLE = "Entry fees can't be paid right now. Contact the organisers."; @@ -29,6 +30,8 @@ export async function openEntryFeeCheckout(params: { if (!account) return { error: UNAVAILABLE }; const now = new Date(); const orderId = await prisma.$transaction(async (tx) => { + // The organisation fence, then the event (docs/PAYMENTS.md lock order). + if (!(await fenceOrganisation(tx, event.organisationId))) return "gone" as const; await tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${params.eventId} FOR UPDATE`; if (await entryFeePaid(tx, params.submissionId)) return null; const held = await tx.order.findFirst({ where: { submissionId: params.submissionId, status: OrderStatus.PENDING, holdExpiresAt: { gt: now } }, select: { id: true } }); @@ -50,6 +53,7 @@ export async function openEntryFeeCheckout(params: { }); return order.id; }); + if (orderId === "gone") return { error: UNAVAILABLE }; if (!orderId) return { paid: true }; const checkout = await startCheckout(orderId).catch(() => null); return checkout && "url" in checkout ? { url: checkout.url } : { error: "The payment page couldn't open. Try again in a minute." }; diff --git a/lib/payments/eventCancellation.ts b/lib/payments/eventCancellation.ts index e8aaf06b..bd26dc1b 100644 --- a/lib/payments/eventCancellation.ts +++ b/lib/payments/eventCancellation.ts @@ -75,7 +75,14 @@ export async function notifyEventCancelled(tx: Prisma.TransactionClient, params: }); } -/** Whether people have paid for (or are paying for) this event, so it must be cancelled rather than deleted. */ -export async function hasPaidOrders(owner: { eventId: string; } | { eventSeriesId: string; }) { - return (await prisma.order.count({ where: { ...owner, totalMinor: { gt: 0 }, status: { in: [OrderStatus.CONFIRMED, OrderStatus.PENDING, OrderStatus.REQUESTED] } } })) > 0; +/** + * Whether people have paid for (or are paying for) this event or series, so + * it must be cancelled rather than deleted. Deletion asks again inside its + * transaction, under the locks reservations take. + */ +export async function hasPaidOrders(owner: { eventId: string; } | { eventSeriesId: string; }, client: Prisma.TransactionClient = prisma) { + return (await client.order.count({ where: { ...owner, totalMinor: { gt: 0 }, status: { in: [OrderStatus.CONFIRMED, OrderStatus.PENDING, OrderStatus.REQUESTED] } } })) > 0; } + +export const PAID_EVENT_REFUSAL = "People have paid for this event. Cancel it instead, which refunds them in full."; +export const PAID_SERIES_REFUSAL = "People have paid for this series. Cancel its dates instead, which refunds them."; diff --git a/lib/payments/obligations.ts b/lib/payments/obligations.ts new file mode 100644 index 00000000..16e29ac2 --- /dev/null +++ b/lib/payments/obligations.ts @@ -0,0 +1,44 @@ +import "server-only"; + +import { EventStatus, OrderStatus, PaymentRefundStatus, PaymentStatus, type Prisma } from "@prisma/client"; + +/* + * What still ties an organisation to money (docs/PAYMENTS.md "Deleting"): + * deleting it would erase the orders, payments and refunds that record it, + * and the payment settings that finish it. Read inside the deletion + * transaction, after the Organisation row lock that every order and payment + * path's fence waits on, so nothing new can appear between the check and the + * cascade. + */ + +const COMMITTED: PaymentRefundStatus[] = [PaymentRefundStatus.PENDING, PaymentRefundStatus.SUCCEEDED]; + +/** Why the organisation can't be deleted yet, or null when nothing is owed or in flight. */ +export async function organisationMoneyBlocker(tx: Prisma.TransactionClient, organisationId: string, now: Date): Promise { + // Tickets people paid for, for something still to come: cancelling refunds them. + const paidUpcoming = await tx.order.count({ + where: { + organisationId, + totalMinor: { gt: 0 }, + status: { in: [OrderStatus.CONFIRMED, OrderStatus.PENDING, OrderStatus.REQUESTED] }, + OR: [{ event: { endDateTime: { gt: now }, status: { not: EventStatus.CANCELLED } } }, { series: { status: { not: EventStatus.CANCELLED }, instances: { some: { endDateTime: { gt: now }, cancelledAt: null } } } }], + }, + }); + if (paidUpcoming > 0) return "People have paid for upcoming events. Cancel those events (or their dates) first, which refunds them."; + // Online payments still open: a buyer may be paying right now. + if (await tx.payment.count({ where: { organisationId, status: PaymentStatus.PENDING, provider: { not: "OFFLINE" } } }) > 0) { + return "Some buyers are still in the middle of paying. Try again once their payment pages have closed (within the hour)."; + } + // Refunds on their way, whatever the event or order: deleting would lose them. + if (await tx.paymentRefund.count({ where: { status: PaymentRefundStatus.PENDING, payment: { organisationId } } }) > 0) { + return "Refunds are still on their way to buyers. Try again once they have gone through."; + } + // Refunds the provider refused, not retried, on payments with money still owed back. + const failed = await tx.paymentRefund.findMany({ + where: { status: PaymentRefundStatus.FAILED, offline: false, retry: null, payment: { organisationId } }, + select: { payment: { select: { amountMinor: true, refunds: { where: { status: { in: COMMITTED } }, select: { amountMinor: true } } } } }, + }); + const owed = failed.some(({ payment }) => payment.amountMinor > payment.refunds.reduce((sum, refund) => sum + refund.amountMinor, BigInt(0))); + if (owed) return "Some refunds failed and are still owed to buyers. Retry them from Failed refunds (the event's Finance tab or the series page) first."; + return null; +} diff --git a/lib/payments/operations/shared.ts b/lib/payments/operations/shared.ts index f2cfcaf2..b2faa0dd 100644 --- a/lib/payments/operations/shared.ts +++ b/lib/payments/operations/shared.ts @@ -3,7 +3,7 @@ import "server-only"; import { PaymentRefundStatus, PaymentStatus, type Prisma } from "@prisma/client"; import { isPaidEventsEnabled } from "@/lib/instanceSettings"; import { loadPaymentAccount } from "../settings"; -import { lockOrderTargets, orderSubject } from "../targets"; +import { fenceOrganisation, lockOrderTargets, orderSubject } from "../targets"; /** An organiser operation was refused; the message is safe to show. */ export class PaymentOperationError extends Error {} @@ -33,10 +33,16 @@ export const ZERO = BigInt(0); export const COLLECTED: PaymentStatus[] = [PaymentStatus.PAID_ONLINE, PaymentStatus.PAID_OFFLINE]; export const COMMITTED_REFUND: PaymentRefundStatus[] = [PaymentRefundStatus.PENDING, PaymentRefundStatus.SUCCEEDED]; -/** Lock order everywhere: the event row (or the series, then its dates), then the order. */ +/** + * Lock order everywhere (docs/PAYMENTS.md): the organisation fence, the event + * row (or the series, then its dates), then the order. + */ export async function lockOrder(tx: Prisma.TransactionClient, orderId: string) { - const row = await tx.order.findUnique({ where: { id: orderId }, select: { id: true, eventId: true, eventSeriesId: true } }); - if (row) await lockOrderTargets(tx, row); + const row = await tx.order.findUnique({ where: { id: orderId }, select: { id: true, organisationId: true, eventId: true, eventSeriesId: true } }); + if (row) { + await fenceOrganisation(tx, row.organisationId); + await lockOrderTargets(tx, row); + } await tx.$queryRaw`SELECT "id" FROM "Order" WHERE "id" = ${orderId} FOR UPDATE`; } diff --git a/tests/integration/payment-final-deletion.db.test.ts b/tests/integration/payment-final-deletion.db.test.ts new file mode 100644 index 00000000..0e0a0e39 --- /dev/null +++ b/tests/integration/payment-final-deletion.db.test.ts @@ -0,0 +1,186 @@ +import { randomUUID } from "node:crypto"; +import type { AsyncLocalStorage } from "node:async_hooks"; +import { EventStatus, OrderStatus, PaymentRefundStatus, PaymentStatus, RsvpStatus, type Prisma } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/* + * Deletion against payments (docs/PAYMENTS.md "Locks and races"): every + * path that creates an order or payment takes the organisation fence first, + * and organisation deletion takes the row FOR UPDATE and checks what is + * still owed inside the same transaction, so whichever goes first, the other + * sees it: no deadlock and no payment record lost. Event deletion re-checks + * paid orders under the event lock. Membership changes serialise on the + * organisation row. + */ + +type Session = { user: { id: string; }; authenticatedAt: number; }; +const ctx = vi.hoisted(() => ({ store: null as unknown as AsyncLocalStorage })); +vi.mock("@/lib/auth", async () => { + const { AsyncLocalStorage } = await import("node:async_hooks"); + ctx.store = new AsyncLocalStorage(); + return { auth: async () => ctx.store.getStore() ?? null }; +}); +vi.mock("next/headers", () => ({ cookies: async () => ({ get: () => undefined }) })); +vi.mock("@/lib/actionRateLimit", () => ({ isActionRateLimited: async () => false })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn(), updateTag: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { deleteOrganisation, removeMember, updateMemberRole } from "@/app/actions/membership"; +import { deleteEvent } from "@/app/actions/event"; +import { submitManualTicketOrderCore } from "@/lib/rsvpCore"; +import { newOrderReference } from "@/lib/payments/orders"; +import { setUpPaymentAudit, tearDownPaymentAudit } from "./helpers/paymentAudit"; + +const suffix = randomUUID().replace(/-/g, "").slice(0, 12); +const orgIds: string[] = []; +let counter = 0; +const mail = (name: string) => `${name}-${suffix}@example.test`; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const as = (userId: string, run: () => Promise) => ctx.store.run({ user: { id: userId }, authenticatedAt: Date.now() }, run); + +beforeAll(() => setUpPaymentAudit("Final deletion")); +afterAll(async () => { + await prisma.organisation.deleteMany({ where: { id: { in: orgIds } } }); + await prisma.user.deleteMany({ where: { email: { endsWith: `${suffix}@example.test` } } }); + await tearDownPaymentAudit(); +}); + +/** An organisation of its own (these tests delete it), its owner, and an upcoming event selling a paid ticket. */ +async function organisation() { + counter += 1; + const org = await prisma.organisation.create({ data: { name: `Deleting ${counter}`, slug: `deleting-${suffix}-${counter}` } }); + orgIds.push(org.id); + const owner = await prisma.user.create({ data: { email: mail(`owner-${counter}`), name: "Owner", emailVerified: new Date(), memberships: { create: { organisationId: org.id, role: "OWNER" } } } }); + const start = new Date(Date.now() + 20 * 24 * 3_600_000); + const event = await prisma.event.create({ data: { + organisationId: org.id, title: `Paid night ${counter}`, slug: `paid-night-${suffix}-${counter}`, currency: "INR", status: EventStatus.PUBLISHED, + startDateTime: start, endDateTime: new Date(start.getTime() + 3_600_000), timezone: "UTC", page: { create: { isPublished: true } }, + } }); + const ticket = await prisma.ticketType.create({ data: { eventId: event.id, name: "Entry", priceMinor: BigInt(50000) } }); + return { org, owner, event, ticket }; +} + +/** Run a transaction holding `lock` until the returned function releases it. */ +async function holding(lock: (tx: Prisma.TransactionClient) => Promise) { + let release!: () => void; + const released = new Promise((resolve) => { release = resolve; }); + let locked!: () => void; + const isLocked = new Promise((resolve) => { locked = resolve; }); + const holder = prisma.$transaction(async (tx) => { await lock(tx); locked(); await released; }, { timeout: 20_000 }); + await isLocked; + return async () => { release(); await holder; }; +} + +const lockEvent = (eventId: string) => (tx: Prisma.TransactionClient) => tx.$queryRaw`SELECT "id" FROM "Event" WHERE "id" = ${eventId} FOR UPDATE`; +const addPaidGuest = (setup: Awaited>, who: string) => submitManualTicketOrderCore( + { eventId: setup.event.id, name: "Desk Guest", guestEmail: mail(who), ticketTypeId: setup.ticket.id, payment: "offline", method: "CASH", answers: {} }, + { organisationId: setup.org.id, actorUserId: setup.owner.id }, +); + +describe.sequential("deleting an organisation while a paid guest is added", () => { + it("refuses the deletion when the payment got there first", async () => { + const setup = await organisation(); + const release = await holding(lockEvent(setup.event.id)); + const adding = addPaidGuest(setup, "first-guest"); + await sleep(300); + const deleting = as(setup.owner.id, () => deleteOrganisation({ organisationSlug: setup.org.slug })); + await sleep(300); + await release(); + expect(await adding).toMatchObject({ ok: true }); + expect(await deleting).toMatchObject({ ok: false, error: expect.stringContaining("paid for upcoming events") }); + expect(await prisma.payment.findMany({ where: { organisationId: setup.org.id }, select: { status: true } })).toEqual([{ status: PaymentStatus.PAID_OFFLINE }]); + expect(await prisma.auditEvent.count({ where: { action: "ORGANISATION_DELETED", targetId: setup.org.id } })).toBe(0); + }); + + it("turns the guest away when the deletion got there first", async () => { + const setup = await organisation(); + const release = await holding(lockEvent(setup.event.id)); + // The deletion holds the organisation row and waits to remove the event. + const deleting = as(setup.owner.id, () => deleteOrganisation({ organisationSlug: setup.org.slug })); + await sleep(400); + const adding = addPaidGuest(setup, "second-guest"); + await sleep(300); + await release(); + expect(await deleting).toEqual({ ok: true }); + expect(await adding).toMatchObject({ ok: false }); + expect(await prisma.organisation.count({ where: { id: setup.org.id } })).toBe(0); + expect(await prisma.order.count({ where: { buyerEmail: mail("second-guest") } })).toBe(0); + }); +}); + +describe.sequential("deleting an event while a paid guest is added", () => { + it("refuses the deletion when the payment got there first", async () => { + const setup = await organisation(); + const release = await holding(lockEvent(setup.event.id)); + const adding = addPaidGuest(setup, "event-first"); + await sleep(300); + const deleting = as(setup.owner.id, () => deleteEvent({ organisationSlug: setup.org.slug, eventId: setup.event.id })); + await sleep(300); + await release(); + expect(await adding).toMatchObject({ ok: true }); + expect(await deleting).toMatchObject({ ok: false, error: expect.stringContaining("Cancel it instead") }); + expect(await prisma.rSVP.count({ where: { eventId: setup.event.id, status: RsvpStatus.CONFIRMED } })).toBe(1); + }); + + it("turns the guest away when the deletion got there first", async () => { + const setup = await organisation(); + const release = await holding(lockEvent(setup.event.id)); + const deleting = as(setup.owner.id, () => deleteEvent({ organisationSlug: setup.org.slug, eventId: setup.event.id })); + await sleep(300); + const adding = addPaidGuest(setup, "event-second"); + await sleep(300); + await release(); + expect(await deleting).toEqual({ ok: true }); + expect(await adding).toMatchObject({ ok: false }); + expect(await prisma.order.count({ where: { organisationId: setup.org.id } })).toBe(0); + }); +}); + +describe.sequential("money still owed blocks deleting an organisation", () => { + it("refuses while a cancelled event's refund is pending or failed, announcing nothing, then goes once it's paid back", async () => { + const setup = await organisation(); + await prisma.event.update({ where: { id: setup.event.id }, data: { status: EventStatus.CANCELLED } }); + // A free upcoming event whose guest would be told it's off. + const free = await prisma.event.create({ data: { + organisationId: setup.org.id, title: "Free meetup", slug: `free-${suffix}-${counter}`, status: EventStatus.PUBLISHED, + startDateTime: new Date(Date.now() + 10 * 24 * 3_600_000), endDateTime: new Date(Date.now() + 10 * 24 * 3_600_000 + 3_600_000), timezone: "UTC", + } }); + await prisma.rSVP.create({ data: { eventId: free.id, guestEmail: mail("free-guest"), guestName: "Free Guest", attendeeKey: `guest:${mail("free-guest")}`, status: RsvpStatus.CONFIRMED } }); + const order = await prisma.order.create({ data: { organisationId: setup.org.id, eventId: setup.event.id, reference: newOrderReference(), buyerEmail: mail("buyer"), buyerName: "Buyer", status: OrderStatus.CANCELLED, currency: "INR", subtotalMinor: BigInt(50000), totalMinor: BigInt(50000) } }); + const payment = await prisma.payment.create({ data: { organisationId: setup.org.id, orderId: order.id, amountMinor: BigInt(50000), currency: "INR", provider: "RAZORPAY", status: PaymentStatus.PAID_ONLINE, externalPaymentId: `pay_del${suffix}`, description: "Paid night", paidAt: new Date() } }); + const refund = await prisma.paymentRefund.create({ data: { paymentId: payment.id, amountMinor: BigInt(50000), reason: "Event cancelled" } }); + const del = () => as(setup.owner.id, () => deleteOrganisation({ organisationSlug: setup.org.slug })); + const told = () => prisma.outboxMessage.count({ where: { payload: { path: ["to"], equals: mail("free-guest") } } }); + + expect(await del()).toMatchObject({ ok: false, error: expect.stringContaining("still on their way") }); + await prisma.paymentRefund.update({ where: { id: refund.id }, data: { status: PaymentRefundStatus.FAILED, settledAt: new Date() } }); + expect(await del()).toMatchObject({ ok: false, error: expect.stringContaining("failed and are still owed") }); + expect(await told()).toBe(0); + expect(await prisma.auditEvent.count({ where: { action: "ORGANISATION_DELETED", targetId: setup.org.id } })).toBe(0); + expect(await prisma.storageDeletion.count({ where: { organisationId: setup.org.id } })).toBe(0); + + await prisma.paymentRefund.create({ data: { paymentId: payment.id, amountMinor: BigInt(50000), reason: "Retry", retryOfRefundId: refund.id, status: PaymentRefundStatus.SUCCEEDED, settledAt: new Date() } }); + expect(await del()).toEqual({ ok: true }); + expect(await told()).toBe(1); + expect(await prisma.organisation.count({ where: { id: setup.org.id } })).toBe(0); + }); +}); + +describe.sequential("membership changes", () => { + it("never lets an admin remove someone the owner promoted meanwhile", async () => { + const setup = await organisation(); + const admin = await prisma.user.create({ data: { email: mail("admin"), name: "Admin", memberships: { create: { organisationId: setup.org.id, role: "ADMIN" } } } }); + const member = await prisma.user.create({ data: { email: mail("member"), name: "Member", memberships: { create: { organisationId: setup.org.id, role: "MEMBER" } } } }); + const release = await holding((tx) => tx.$queryRaw`SELECT "id" FROM "Organisation" WHERE "id" = ${setup.org.id} FOR UPDATE`); + const promoting = as(setup.owner.id, () => updateMemberRole({ organisationSlug: setup.org.slug, targetUserId: member.id, role: "ADMIN" })); + await sleep(300); + // The admin's check reads MEMBER before the promotion commits. + const removing = as(admin.id, () => removeMember({ organisationSlug: setup.org.slug, targetUserId: member.id })); + await sleep(300); + await release(); + expect(await promoting).toEqual({ ok: true }); + expect(await removing).toMatchObject({ ok: false, error: "Admins can only remove members." }); + expect(await prisma.membership.findUnique({ where: { userId_organisationId: { userId: member.id, organisationId: setup.org.id } } })).toMatchObject({ role: "ADMIN" }); + expect(await prisma.auditEvent.count({ where: { action: "MEMBER_REMOVED", organisationId: setup.org.id } })).toBe(0); + }); +}); From 5705cb450951f4b36d3d9ffec3d1ec78b7e11c1f Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Thu, 29 Oct 2026 23:33:23 +0530 Subject: [PATCH 48/79] fix(payments): keep refunds pending on failed lookups; refund money left on ended orders; test payments out of live totals Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/actions/payment-operations.ts | 46 ++++++-- .../exports/events/[eventId]/finance/route.ts | 8 +- .../[orgSlug]/series/[seriesId]/page.tsx | 10 +- .../dashboard/org/OrgSections/insights.tsx | 7 +- .../analytics/AnalyticsSections/MoneyCard.tsx | 5 +- .../event-dashboard/panels/FinancePanel.tsx | 10 ++ components/payments/RefundableOrdersList.tsx | 66 +++++++++++ lib/eventReportPdf/sections/money.ts | 5 +- lib/finance.ts | 23 +++- lib/payments/operations.ts | 1 + lib/payments/operations/cancelledRefund.ts | 80 +++++++++++++ lib/payments/providers/stripe.ts | 27 ++++- lib/payments/refunds.ts | 18 ++- lib/payments/revenueMetrics.ts | 29 +++-- ...hboard__orgSlug__series__seriesId__page.ts | 5 +- ...__event-dashboard__panels__FinancePanel.ts | 7 +- tests/helpers/fakeProviderFetch.ts | 9 +- .../payment-final-refunds.db.test.ts | 107 ++++++++++++++++++ tests/unit/payment-revenue.test.ts | 32 +++++- tests/unit/payments-gateways.test.ts | 27 +++++ 20 files changed, 470 insertions(+), 52 deletions(-) create mode 100644 components/payments/RefundableOrdersList.tsx create mode 100644 lib/payments/operations/cancelledRefund.ts create mode 100644 tests/integration/payment-final-refunds.db.test.ts diff --git a/app/actions/payment-operations.ts b/app/actions/payment-operations.ts index a41979fd..ab0ff4e2 100644 --- a/app/actions/payment-operations.ts +++ b/app/actions/payment-operations.ts @@ -10,7 +10,7 @@ import { prisma } from "@/lib/db"; import { canAccessEvent } from "@/lib/eventAccess"; import { trackedRevalidation } from "@/lib/frontend/revalidation"; import { parseMoneyToMinor } from "@/lib/payments/money"; -import { AdmissionManagedError, amendOfflinePayment, PaymentOperationError, refundOrder, retryFailedRefund, sendPaymentLink, settleOrderByHand } from "@/lib/payments/operations"; +import { AdmissionManagedError, amendOfflinePayment, PaymentOperationError, refundEndedOrder, refundOrder, retryFailedRefund, sendPaymentLink, settleOrderByHand } from "@/lib/payments/operations"; import { getMembership } from "@/lib/permissions"; import { hasRecentAuthentication } from "@/lib/reauth"; import { submitManualTicketOrderCore } from "@/lib/rsvpCore"; @@ -91,23 +91,53 @@ export async function refundPaidOrder(input: unknown): Promise { const retrySchema = z.object({ ...target, refundId: z.string().trim().min(1).max(64) }).strict(); -/** - * Retry a refund the provider refused (docs/PAYMENTS.md). Owners and admins - * only (MANAGE_REGISTRATIONS collaborators can't), after a fresh sign-in. - */ +/** Owners and admins only (MANAGE_REGISTRATIONS collaborators can't), after a fresh sign-in. */ +async function ownerOrAdminRecently(context: { userId: string; organisation: { id: string; }; }, doing: string) { + const membership = await getMembership(context.userId, context.organisation.id); + if (membership?.role !== "OWNER" && membership?.role !== "ADMIN") return `Only the organisation's owners and admins can ${doing}.`; + if (!(await hasRecentAuthentication())) return `For security, sign in again before you ${doing}.`; + return null; +} + +/** Retry a refund the provider refused (docs/PAYMENTS.md). */ export async function retryFailedRefundAction(input: unknown): Promise { const parsed = retrySchema.safeParse(input); if (!parsed.success) return { ok: false, error: "Choose a refund." }; const context = await access(parsed.data); if ("error" in context) return { ok: false, error: context.error ?? "Event not found." }; - const membership = await getMembership(context.userId, context.organisation.id); - if (membership?.role !== "OWNER" && membership?.role !== "ADMIN") return { ok: false, error: "Only the organisation's owners and admins can retry refunds." }; - if (!(await hasRecentAuthentication())) return { ok: false, error: "For security, sign in again before retrying a refund." }; + const refused = await ownerOrAdminRecently(context, "retry refunds"); + if (refused) return { ok: false, error: refused }; const refund = await prisma.paymentRefund.findFirst({ where: { id: parsed.data.refundId, payment: { organisationId: context.organisation.id, order: context.scope } }, select: { id: true } }); if (!refund) return { ok: false, error: "Refund not found." }; return run(context.path, () => retryFailedRefund({ refundId: refund.id, organisationId: context.organisation.id, actorUserId: context.userId })); } +const endedRefundSchema = z.object({ + ...orderTarget, + /** Major units; blank refunds everything left. */ + amount: z.string().trim().max(20).default(""), + reason: z.string().trim().min(1, "Say why you're refunding.").max(500), +}).strict(); + +/** + * Refund money left on a cancelled or expired order, by order (its tickets + * may be gone): the Refundable orders list (docs/PAYMENTS.md). Owners and + * admins after a fresh sign-in, like retrying a refund. + */ +export async function refundEndedOrderAction(input: unknown): Promise { + const parsed = endedRefundSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Check the refund." }; + const context = await access(parsed.data); + if ("error" in context) return { ok: false, error: context.error ?? "Event not found." }; + const refused = await ownerOrAdminRecently(context, "refund cancelled orders"); + if (refused) return { ok: false, error: refused }; + const order = await orderOf(context.scope, parsed.data.orderId); + if (!order) return { ok: false, error: "Order not found." }; + const amountMinor = parsed.data.amount ? parseMoneyToMinor(parsed.data.amount, order.currency) : null; + if (parsed.data.amount && amountMinor == null) return { ok: false, error: `Enter the amount in ${order.currency}, for example 250.` }; + return run(context.path, () => refundEndedOrder({ orderId: order.id, organisationId: context.organisation.id, actorUserId: context.userId, amountMinor, reason: parsed.data.reason })); +} + const settleSchema = z.object({ ...orderTarget, mode: z.enum(["offline", "waive"]), diff --git a/app/api/exports/events/[eventId]/finance/route.ts b/app/api/exports/events/[eventId]/finance/route.ts index ae9580ec..87cffd42 100644 --- a/app/api/exports/events/[eventId]/finance/route.ts +++ b/app/api/exports/events/[eventId]/finance/route.ts @@ -36,7 +36,7 @@ export async function GET(_request: Request, context: RouteContext) { prisma.payment.findMany({ where: { order: { eventId }, status: { in: [PaymentStatus.PAID_ONLINE, PaymentStatus.PAID_OFFLINE] } }, select: { - amountMinor: true, currency: true, paidAt: true, createdAt: true, provider: true, offlineMethod: true, methodSummary: true, description: true, + amountMinor: true, currency: true, paidAt: true, createdAt: true, provider: true, offlineMethod: true, methodSummary: true, description: true, testMode: true, status: true, order: { select: { reference: true, buyerName: true, submissionId: true } }, refunds: { where: { status: { in: [PaymentRefundStatus.PENDING, PaymentRefundStatus.SUCCEEDED] } }, select: { amountMinor: true, settledAt: true, createdAt: true, offline: true, status: true } }, }, @@ -49,10 +49,12 @@ export async function GET(_request: Request, context: RouteContext) { for (const payment of payments) { const at = payment.paidAt ?? payment.createdAt; const method = payment.provider === "OFFLINE" || payment.offlineMethod ? offlineMethodLabel(payment.offlineMethod ?? "OTHER") : payment.methodSummary ?? payment.provider; - rows.push({ at, cells: [at.toISOString(), payment.order.submissionId ? "Entry fee" : "Ticket payment", payment.order.buyerName, payment.description, amount(payment.amountMinor, payment.currency, 1), payment.currency, method, payment.order.reference] }); + // Test-key payments moved no real money: labelled, as on the Finance tab. + const test = payment.testMode && payment.status === PaymentStatus.PAID_ONLINE ? " (test)" : ""; + rows.push({ at, cells: [at.toISOString(), `${payment.order.submissionId ? "Entry fee" : "Ticket payment"}${test}`, payment.order.buyerName, payment.description, amount(payment.amountMinor, payment.currency, 1), payment.currency, method, payment.order.reference] }); for (const refund of payment.refunds) { const refundAt = refund.settledAt ?? refund.createdAt; - rows.push({ at: refundAt, cells: [refundAt.toISOString(), refund.status === PaymentRefundStatus.PENDING ? "Refund (pending)" : "Refund", payment.order.buyerName, payment.description, amount(refund.amountMinor, payment.currency, -1), payment.currency, refund.offline ? "In person" : method, payment.order.reference] }); + rows.push({ at: refundAt, cells: [refundAt.toISOString(), `${refund.status === PaymentRefundStatus.PENDING ? "Refund (pending)" : "Refund"}${test}`, payment.order.buyerName, payment.description, amount(refund.amountMinor, payment.currency, -1), payment.currency, refund.offline ? "In person" : method, payment.order.reference] }); } } for (const payout of claimPayouts) { diff --git a/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx b/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx index e1ded04f..4dddf39a 100644 --- a/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx +++ b/app/dashboard/[orgSlug]/series/[seriesId]/page.tsx @@ -12,6 +12,7 @@ import { PromoCodesManager, RefundPolicyControl, TransfersControl } from "@/comp import { EventReportDownloadButton } from "@/components/reports/EventReportDownloadButton"; import { Card, CardHeader, PageHeader } from "@/components/ui/display"; import { FailedRefundsList } from "@/components/payments/FailedRefundsList"; +import { RefundableOrdersList } from "@/components/payments/RefundableOrdersList"; import { formatDayTime } from "@/lib/format/datetime"; import type { Metadata } from "next"; import { cachedDateTimeFormat } from "@/lib/format/intlCache"; @@ -28,7 +29,7 @@ function eventDateInput(date: Date, timeZone: string) { } export default async function SeriesManagePage({ params }: Props) { - const { series, isAdmin, organisation, canManageInvites, invites, canManageSchedule, canDownloadReports, finishedInstances, firstInstanceId, now, attendeesPage, rollup, dates, tickets, revenue, failedRefunds } = await loadSeriesManageView({ params }); + const { series, isAdmin, organisation, canManageInvites, invites, canManageSchedule, canDownloadReports, finishedInstances, firstInstanceId, now, attendeesPage, rollup, dates, tickets, revenue, failedRefunds, refundableOrders } = await loadSeriesManageView({ params }); return (
@@ -95,6 +96,13 @@ export default async function SeriesManagePage({ params }: Props) { ) : null} + {isAdmin && (tickets || refundableOrders.length > 0) ? ( + + Refundable orders} description="Cancelled or expired orders with money left on them. Confirmed orders are refunded from the guest list." /> + + + ) : null} + {canDownloadReports ? (

Reports

diff --git a/components/dashboard/org/OrgSections/insights.tsx b/components/dashboard/org/OrgSections/insights.tsx index 3337e9a6..532972c2 100644 --- a/components/dashboard/org/OrgSections/insights.tsx +++ b/components/dashboard/org/OrgSections/insights.tsx @@ -72,15 +72,16 @@ export function OrgRevenuePanel({ organisationSlug, revenue }: { organisationSlu ["Paid orders", String(summary.orders.paid)], ].map(([label, value]) => (
-
{label}{summary.testOnly ? " (test)" : ""}
+
{label}
{value}
))} - {summary.owedAtDeskMinor > 0 || summary.disputes.open > 0 ? ( + {summary.owedAtDeskMinor > 0 || summary.disputes.open > 0 || summary.test.payments > 0 ? (

{summary.owedAtDeskMinor > 0 ? `${money(summary.owedAtDeskMinor)} to collect at the desk. ` : ""} - {summary.disputes.open > 0 ? `${summary.disputes.open} open ${summary.disputes.open === 1 ? "chargeback" : "chargebacks"}.` : ""} + {summary.disputes.open > 0 ? `${summary.disputes.open} open ${summary.disputes.open === 1 ? "chargeback" : "chargebacks"}. ` : ""} + {summary.test.payments > 0 ? `${money(summary.test.collectedMinor - summary.test.refundedMinor)} in test payments, not counted above.` : ""}

) : null} {top.length > 0 ? ( diff --git a/components/event-dashboard/analytics/AnalyticsSections/MoneyCard.tsx b/components/event-dashboard/analytics/AnalyticsSections/MoneyCard.tsx index 0e8f2abc..a69e6e63 100644 --- a/components/event-dashboard/analytics/AnalyticsSections/MoneyCard.tsx +++ b/components/event-dashboard/analytics/AnalyticsSections/MoneyCard.tsx @@ -31,9 +31,9 @@ export function MoneyCard({ revenue, title = "Money", exportHref }: { revenue: R
-

{title}{revenue.testOnly ? " (test payments)" : ""}

+

{title}

- {revenue.testOnly ? "Every payment so far was a test, so no real money moved." : "Money taken for tickets, after refunds."} + Money taken for tickets, after refunds.{revenue.test.payments > 0 ? " Test payments moved no real money and are counted apart." : ""}

{exportHref ? Download orders (CSV) : null} @@ -51,6 +51,7 @@ export function MoneyCard({ revenue, title = "Money", exportHref }: { revenue: R revenue.orders.complimentary > 0 ? { label: `Complimentary (${revenue.orders.complimentary})`, value: money(revenue.complimentaryValueMinor) } : null, revenue.orders.requested > 0 ? { label: "Requests waiting", value: String(revenue.orders.requested) } : null, revenue.automaticRefundMinor > 0 ? { label: "Late or duplicate, refunded", value: money(revenue.automaticRefundMinor) } : null, + revenue.test.payments > 0 ? { label: `Test payments, not counted (${revenue.test.payments})`, value: money(revenue.test.collectedMinor - revenue.test.refundedMinor) } : null, ]} /> {revenue.disputes.open > 0 || revenue.disputes.lost > 0 ? (

diff --git a/components/event-dashboard/panels/FinancePanel.tsx b/components/event-dashboard/panels/FinancePanel.tsx index c599480d..9373cbd6 100644 --- a/components/event-dashboard/panels/FinancePanel.tsx +++ b/components/event-dashboard/panels/FinancePanel.tsx @@ -11,6 +11,7 @@ import { ReimbursementPolicies } from "@/components/event-dashboard/finance/Reim import { NoPermission } from "./shared"; import { HelpLink } from "@/components/help/HelpLink"; import { FailedRefundsList } from "@/components/payments/FailedRefundsList"; +import { RefundableOrdersList } from "@/components/payments/RefundableOrdersList"; /** * The event dashboard's Finance tab (docs/FINANCE.md): money in and out @@ -55,6 +56,11 @@ export async function FinancePanel({ ctx }: { ctx: EventDashboardContext; }) {

+ {summary.test ? ( + + {summary.test.payments} {summary.test.payments === 1 ? "payment was" : "payments were"} taken with test keys ({summary.test.amount} after refunds). No real money moved, so they are left out of income and net. + + ) : null} {summary.budget ? ( {summary.budget.committed} spent or approved; {summary.budget.over ? `over by ${summary.budget.left}` : `${summary.budget.left} left`}. @@ -78,6 +84,10 @@ export async function FinancePanel({ ctx }: { ctx: EventDashboardContext; }) { ) : null} + + Refundable orders} description="Cancelled or expired orders with money left on them. Confirmed orders are refunded from the guest list." /> + + Claim payments} description="Every payment stays on record. A mistake is reversed, never edited." /> diff --git a/components/payments/RefundableOrdersList.tsx b/components/payments/RefundableOrdersList.tsx new file mode 100644 index 00000000..cc341fdd --- /dev/null +++ b/components/payments/RefundableOrdersList.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { Undo2 } from "lucide-react"; +import { refundEndedOrderAction } from "@/app/actions/payment-operations"; +import { Button } from "@/components/ui/button"; +import { Callout, Chip } from "@/components/ui/display"; +import { Dialog, DialogBody, DialogContent, DialogHeader } from "@/components/ui/dialog"; +import { Field, Input, Textarea } from "@/components/ui/field"; +import { useSave } from "@/components/event-dashboard/finance/shared"; +import { formatDayTime } from "@/lib/format/datetime"; +import type { RefundableOrderRow } from "@/lib/payments/operations/cancelledRefund"; + +/** + * Cancelled or expired orders with money still refundable, for owners and + * admins (docs/PAYMENTS.md "Refundable orders"): a cancellation refunded in + * part, or a late payment's order. Refunded by order, so it works after the + * tickets are gone. Needs a recent sign-in. + */ +export function RefundableOrdersList({ target, orders, timezone }: { target: { organisationSlug: string; eventId?: string; seriesId?: string; }; orders: RefundableOrderRow[]; timezone: string; }) { + const [refunding, setRefunding] = useState(null); + if (orders.length === 0) return

No cancelled or expired orders have money left to refund.

; + return ( + <> +
    + {orders.map((order) => ( +
  • +
    +

    {order.buyer} · order {order.reference}

    +

    Ordered {formatDayTime(order.orderedAt, timezone)} · {order.left} left to refund

    +
    +
    + {order.status === "cancelled" ? "Cancelled" : "Expired"} + +
    +
  • + ))} +
+ {refunding ? setRefunding(null)} /> : null} + + ); +} + +function RefundDialog({ target, order, onClose }: { target: { organisationSlug: string; eventId?: string; seriesId?: string; }; order: RefundableOrderRow; onClose: () => void; }) { + const [amount, setAmount] = useState(""); + const [reason, setReason] = useState(""); + const { pending, error, save } = useSave(); + return ( + { if (!next) onClose(); }}> + + + +
{ + event.preventDefault(); + save(() => refundEndedOrderAction({ ...target, orderId: order.id, amount, reason }), "Refund sent.", onClose); + }}> + {error ? {error} : null} + setAmount(event.target.value)} maxLength={20} /> +