diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e0c1e3d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +node_modules +.next +.env +.env.* +!.env.example +agents +*.log +*.pem +**/*.env +**/*.env.local +**/*.dump +**/*.key +**/.vk-smoke.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b148550 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npx prisma generate && npm run build + +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3100 BIND_ADDRESS=0.0.0.0 +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=build --chown=node:node /app /app +USER node +EXPOSE 3100 +CMD ["node", "tools/serve-production.cjs"] diff --git a/agents/codex/done/TASK-2026-10-19-coder2-review.md b/agents/codex/done/TASK-2026-10-19-coder2-review.md new file mode 100644 index 0000000..7c3fce7 --- /dev/null +++ b/agents/codex/done/TASK-2026-10-19-coder2-review.md @@ -0,0 +1,61 @@ +# Result — coder2 review and scoped fixes + +Base SHA: `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60`. +Reviewed/result SHA: `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60` (no commit; shared working tree remains uncommitted). + +## Scope reviewed + +Reviewed the full stage-one working diff and added deployment/R3 files against the stage-one manifest and R1–R5 assignments. Focused on production ingress trust, auth profiles, Compose isolation, backup/restore and migration bootstrap, VK incomplete-data handling, the A–H smoke script, and runbook correctness. Preserved the existing local `AGENTS.md` modification and the owner-authorized four seed-length assertion changes. No production host, firewall, WireGuard, tunnel, or Docker state was changed by this review. + +## Fixes made + +- Restored the original manual smoke follow-ups I–M for snapshot, draw, public audit, refresh, and logout. The script continues to report only its A–H automation and never claims these follow-ups passed. +- Changed the local smoke example from `127.0.0.1` to canonical `localhost`, matching the production profile and callback origin. +- Documented the production entrypoint's socket-peer sanitization and the distinction from generic `TRUST_PROXY=true` behavior in `docs/PRODUCTION_GUARDS.md`. +- Made the smoke script stop with a `FAIL setup` result if `mktemp` cannot create its private directory, avoiding accidental writes to root paths. +- Returned the internally generated incomplete-data reason to the UI instead of replacing it with a generic message; added a route regression assertion and confirmed the prior participant list remains unchanged on failure. +- Changed backup-directory setup in the runbook to create it with the operator's ownership and mode `0700`. + +## Files changed by this review + +- `agents/codex/inbox/TASK-2026-10-19-coder2-review.md` +- `docs/PRODUCTION_GUARDS.md` +- `docs/VK_REAL_SMOKE_TEST.md` +- `docs/STAGE1_DEPLOYMENT_RUNBOOK.md` +- `tools/vk-smoke.sh` +- `src/core/errors/http-errors.ts` +- `tests/vk-r3-safety.test.ts` +- `agents/codex/done/TASK-2026-10-19-coder2-review.md` + +## Commands actually run + +- `USE_VK_MOCK=true npx vitest run tests/vk-r3-safety.test.ts tests/deployment-profile.test.ts tests/production-transport.test.ts` — exit 0; 3 files, 26 tests passed. +- `bash -n tools/vk-smoke.sh tools/db-backup.sh tools/db-backup-daily.sh tools/db-restore-check.sh` — exit 0. +- `git diff --check` — exit 0. +- `git rev-parse HEAD` — `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60`. + +The full test/lint/build/audit gates and local Docker/restore acceptance were not rerun by this reviewer; coordinator owns the final shared gates and Docker smoke. No live VK account, production host, or VPS was used. + +## Findings + +- CRITICAL: none found in reviewed scope. +- HIGH: none found in reviewed scope. +- MEDIUM: none found in reviewed scope. +- LOW: fixed in the working diff — smoke temporary-directory creation lacked a failure guard; previous checklist I–M had been dropped from the updated runbook; local smoke example used a host different from the configured canonical origin; incomplete-data UI response did not expose its safe cause; backup directory creation did not establish the service account's ownership. + +These are coder2 review results, not an independent security sign-off. Auth, proxy trust, migrations, and the production Compose boundary still require the requested independent audits. + +## UNVERIFIED + +- Live VK ID redirect acceptance for localhost, consent, token refresh, and real API behavior/scopes/limits. +- Whether Docker ingress on the target host preserves the actual VPS socket peer `10.77.0.1`; the transport tests cover forged headers and an actual loopback socket, not the VPS path. +- VPS identity, existing web server, DNS, certificate, firewall persistence, domain login, and network isolation acceptance. +- The actual Caddy version/directive compatibility on the operator's VPS; deployment snippets must be validated against the installed server. + +## Remaining blockers + +Live VK credentials/consent and operator confirmation/actions for R5 remain external prerequisites. Coordinator-reported local Docker backup-restore success is not independently reproduced in this review. The phase remains open for the two independent auditors and coordinator's final gates. + +## Non-blocking tech debt / environment notes + +`MEMORY_POLICY.md` is at `/srv/projects/AI-Memory/00_SYSTEM/MEMORY_POLICY.md` alongside `AGENT_PROTOCOL.md`; it was read. Per coordinator direction, only an individual worklog is being added; shared project-state files are left untouched. diff --git a/agents/codex/done/TASK-2026-10-19-r1-r3.md b/agents/codex/done/TASK-2026-10-19-r1-r3.md new file mode 100644 index 0000000..e173a83 --- /dev/null +++ b/agents/codex/done/TASK-2026-10-19-r1-r3.md @@ -0,0 +1,50 @@ +# Result — R1 dependencies and R3 VK safety + +Base SHA: `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60`. +Result SHA: `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60` (HEAD unchanged; edits remain uncommitted in the shared stage1 branch per coordinator instruction). + +## Changes + +R1 updated the lock and package ranges to next `16.3.8`, sharp `0.35.5`, and source-map-js `1.2.2`; `eslint-config-next` was aligned to `16.3.8`. Before/after licenses: next MIT→MIT, sharp Apache-2.0→Apache-2.0, source-map-js BSD-3-Clause→BSD-3-Clause. + +R3 now rejects incomplete or inconsistent pagination and subscription checks with a safe API error. An import that receives incomplete data does not replace the prior saved participant list. Unsupported repost and manager exclusion rules carry explicit reasons in the UI and are rejected by server capability validation. The live smoke script covers the automatable A-H checks, marks browser consent as MANUAL, stores session/proxy credentials only in a mode-0600 file, limits origins to loopback HTTP or the approved domain, and suppresses response bodies/headers. + +Files changed by this assignment: + +- `package.json` +- `package-lock.json` +- `src/integrations/vk/vk-errors.ts` +- `src/integrations/vk/vk-client.ts` +- `src/providers/vk/vk-provider.ts` +- `src/providers/vk/vk-capabilities.ts` +- `src/core/errors/http-errors.ts` +- `src/core/validation/giveaway-schemas.ts` +- `src/app/giveaways/new/page.tsx` +- `tests/vk-r3-safety.test.ts` (new; existing tests untouched) +- `tools/vk-smoke.sh` (new) +- `docs/VK_REAL_SMOKE_TEST.md` +- `agents/codex/inbox/TASK-2026-10-19-r1-dependencies.md` +- `agents/codex/inbox/TASK-2026-10-19-r3-vk-safety.md` +- `agents/codex/notes/TASK-2026-10-19-r1-r3-notes.md` +- `agents/codex/done/TASK-2026-10-19-r1-r3.md` + +## Commands actually run + +- `npm ci` — exit 0; installed 449 packages. npm reported 19 vulnerabilities in the full dependency tree. Production-only audit below is clean. +- `npx prisma generate` — exit 0; generated Prisma Client v5.22.0. +- `USE_VK_MOCK=true npm test` — exit 0; 62 files, 372 tests passed. +- `npx tsc --noEmit` — exit 0. +- `npm run lint` — exit 0; 0 errors, 6 existing `no-img-element` warnings. +- `npm run build` — exit 0; Next.js 16.3.8 production build completed. It printed the existing in-memory limiter/idempotency deployment warnings. +- `npm audit --omit=dev` — exit 0; `found 0 vulnerabilities`. +- `bash -n tools/vk-smoke.sh` — exit 0. +- `shellcheck tools/vk-smoke.sh` — not run; shellcheck is unavailable. +- `./tools/vk-smoke.sh agents/codex/notes/vk-smoke-fake.env` against a temporary local HTTP fixture — exit 0; A and C-H PASS, B MANUAL. The fake env used a marker cookie and was removed. No real VK account was used. + +## Findings, UNVERIFIED, blockers + +- Production dependency audit has no remaining vulnerabilities. `npm ci` still reports vulnerabilities in the full dependency tree, which includes dev dependencies; production-only audit is the R1 criterion. +- No live VK smoke was run. Current official method behavior for manager enumeration, private-profile reposts, execute limits, and stable offset pagination remains `UNVERIFIED`; the app fails closed where data completeness is uncertain and currently does not use `execute`. +- The owner must provide authorized VK credentials and perform browser consent for a live smoke run. Domain/proxy smoke was not run against the live host. +- Independent coder2 review and the two requested auditors remain pending. This report does not declare the stage or phase closed. +- No review severity findings are claimed; this is an implementation report, not an independent review. diff --git a/agents/codex/inbox/2026-10-19-seed-owner-authorization.md b/agents/codex/inbox/2026-10-19-seed-owner-authorization.md new file mode 100644 index 0000000..cb2f086 --- /dev/null +++ b/agents/codex/inbox/2026-10-19-seed-owner-authorization.md @@ -0,0 +1,10 @@ +# Scope amendment — explicit owner authorization +Base SHA: 6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60 +Date: 2026-10-19 +Owner answer in chat: «Разрешаю увеличить seed до 32 байт». +Scope: generateCryptoSecureSeed uses 32 random bytes (64 hex characters), preserving deterministic proof format, snapshot hash and HMAC_SHA256_FY_V1 algorithms. No existing tests may be changed. +Reason: existing PostgreSQL integration seed contract expects 64 hex characters, actual generator used 16 bytes (32 hex). +Required verification: existing unit/integration suites, tsc/lint/build, independent review. + +Second explicit owner answer: «Разрешаю обновить assertions seed на 64 hex». +Narrow exception to the no-existing-test-edits rule: update seed-length assertions only in tests/randomizer.test.ts and tests/seed-precommit-gate.test.ts (four assertions across the two files; the clarification's count of three omitted the additional precommit assertion). No weakening or unrelated test changes. diff --git a/agents/codex/inbox/2026-10-19-stage1-r2-assignment.md b/agents/codex/inbox/2026-10-19-stage1-r2-assignment.md new file mode 100644 index 0000000..f4d6241 --- /dev/null +++ b/agents/codex/inbox/2026-10-19-stage1-r2-assignment.md @@ -0,0 +1,6 @@ +# Task — coordinator R2 +Base SHA: 6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60 +Scope: isolated PostgreSQL 16 compose, clean migration path, existing integration suite and fixes needed to pass. +Do not change: existing tests, Randomizer/AuditProof/hash/algorithm, local AGENTS.md. +Definition of Done: migrate deploy on empty database; integration suite 0 failures; report evidence and remaining blockers. +Required verification: compose up --wait; prisma migrate deploy; npm run test:integration; final shared gates. diff --git a/agents/codex/inbox/2026-10-19-stage1-r4-r5-assignment.md b/agents/codex/inbox/2026-10-19-stage1-r4-r5-assignment.md new file mode 100644 index 0000000..421a4df --- /dev/null +++ b/agents/codex/inbox/2026-10-19-stage1-r4-r5-assignment.md @@ -0,0 +1,6 @@ +# Task — coordinator R4/R5 preparation +Base SHA: 6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60 +Scope: explicit loopback production profile, strict domain profile, actual socket peer proxy boundary, isolated deploy stack, backup/restore scripts, two-host executable runbook. Actual firewall/WireGuard/VPS changes remain operator work per original manifest. +Do not change: protected draw/proof/hash algorithm, existing tests, pre-existing AGENTS.md. +Definition of Done: local production smoke and clean backup restore; adversarial origin/proxy tests; clear external blockers. +Required verification: all original gates, new profile/transport tests, stack startup and restore drill. diff --git a/agents/codex/inbox/TASK-2026-10-19-coder2-review.md b/agents/codex/inbox/TASK-2026-10-19-coder2-review.md new file mode 100644 index 0000000..9ee1fb0 --- /dev/null +++ b/agents/codex/inbox/TASK-2026-10-19-coder2-review.md @@ -0,0 +1,6 @@ +# Task +Base SHA: 6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60 +Scope: Review the complete shared working diff and added files for the stage-one task, focusing on production ingress trust, strict/local auth profiles, isolated compose, backup/restore/runbook correctness, R3 failure behavior and no-partial-state, truthful A-H smoke checks, and safe migration bootstrap. Fix concrete defects and missing tests/docs within R1-R5 authorized scope. +Do not change: Randomizer, AuditProof, deterministic proof format, snapshot hash algorithm, HMAC_SHA256_FY_V1; existing tests except the four specifically authorized seed-length assertions; preserve the pre-existing AGENTS.md user modification; no production host/firewall/WireGuard changes; no commit or push. +Definition of Done: Concrete scoped defects fixed, meaningful checks run, report includes exact base and current HEAD, changed files, commands/results, findings, UNVERIFIED claims, blockers, and non-blocking tech debt. +Required verification: Inspect whole diff/new files; relevant R3/R4/R5 adversarial tests and scripts; record exact commands and outcomes. Coordinator owns local Docker smoke and shared gates. diff --git a/agents/codex/inbox/TASK-2026-10-19-r1-dependencies.md b/agents/codex/inbox/TASK-2026-10-19-r1-dependencies.md new file mode 100644 index 0000000..700753a --- /dev/null +++ b/agents/codex/inbox/TASK-2026-10-19-r1-dependencies.md @@ -0,0 +1,6 @@ +# Task +Base SHA: 6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60 +Scope: Complete stage-one R1: minimally update vulnerable next, sharp, source-map-js within same major; record license before/after; achieve npm audit --omit=dev with no critical/high; run npm ci, Prisma generate, npm test, TypeScript, lint, build. Continue R3 only after R1, as directed by coordinator. +Do not change: existing tests; Randomizer; AuditProof; deterministic proof/snapshot hash/HMAC algorithm; user changes in AGENTS.md; R2/R4/R5 assigned to coordinator; no commit/push. +Definition of Done: exact vulnerable dependency versions replaced with same-major fixed versions, documented licenses, executed gates recorded; then implement assigned R3 while preserving above boundaries. +Required verification: npm ci; npx prisma generate; npm test; tsc; lint; build; npm audit --omit=dev. R3 tests and smoke checks per task manifest. diff --git a/agents/codex/inbox/TASK-2026-10-19-r3-vk-safety.md b/agents/codex/inbox/TASK-2026-10-19-r3-vk-safety.md new file mode 100644 index 0000000..dc0b302 --- /dev/null +++ b/agents/codex/inbox/TASK-2026-10-19-r3-vk-safety.md @@ -0,0 +1,6 @@ +# Task +Base SHA: 6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60 +Scope: R3 from stage-one manifest, per coordinator follow-up: safe handling for VK unknown/unsupported manager listing, private-profile reposts, pagination limits, execute limits; preserve participants and communicate capability/failure status in UI and participant state; add mock-provider regression tests; add `tools/vk-smoke.(ts|sh)` covering automatable A-H checks with truthful PASS/FAIL and manual browser-consent steps. +Do not change: existing tests; Randomizer, AuditProof, deterministic proof format, snapshot hashing, HMAC algorithm; package files; auth/profile files; R2/R4/R5 and coordinator-owned files; no real credentials; no commit/push. +Definition of Done: failed or unknown VK behavior cannot silently produce a complete participant list or exclude participants without an explicit status; tests cover each requested failure; smoke tool does not print secrets or claim unperformed checks pass; document UNVERIFIED VK contract claims. +Required verification: add/run new R3 regression tests; tsc, relevant test/lint/build gates as practical; all commands and outcomes in report. diff --git a/agents/codex/notes/.gitignore b/agents/codex/notes/.gitignore new file mode 100644 index 0000000..7e8e0df --- /dev/null +++ b/agents/codex/notes/.gitignore @@ -0,0 +1,8 @@ +# Local verification outputs: retain on disk, never package credentials or dumps. +*.env.local +*.dump +*.partial +*.log +stage1-baseline-schema.prisma + +backups/ diff --git a/agents/codex/notes/2026-10-19-stage1-baseline.md b/agents/codex/notes/2026-10-19-stage1-baseline.md new file mode 100644 index 0000000..728561d --- /dev/null +++ b/agents/codex/notes/2026-10-19-stage1-baseline.md @@ -0,0 +1,33 @@ +# Stage 1 baseline + +User supplied task: agents/codex/inbox/2026-10-19-stage1-completion.md (retrieved by git fetch forgejo main). +Initial local HEAD: b625d70e8eb5bfb3eb2b6461fe58b08f4b13b1ab. +Task base SHA: 6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60 (origin/main = forgejo/main). +Branch: codex/stage1-completion. Owner merges PR; agent does not merge. +Pre-existing local modification: AGENTS.md, preserve and exclude from commits. +Delegation requested by manifest: cheap coder 1, coder 2, two independent auditors, coordinator verification. +No real VK credentials used. No existing service, firewall, WireGuard, or external VPS configuration to be changed here. + +R2 evidence: +- docker compose up -d --wait: PostgreSQL 16 healthy at 127.0.0.1:55439. +- Initial prisma migrate deploy: P3018 / 42P01 missing Giveaway; log r2-initial-migrate.log. +- Add 20260818000000_initial generated from historical pre-ownership/pre-session schema; existing migrations unchanged. +- Recreated only disposable randomayzer_test database; migrate deploy: 3 migrations applied. +- Initial integration: 10 passed / 2 failed. Fix duplicate-draw error type at Prisma repository: 11 passed / 1 seed-contract failure. +- Owner authorized 32-byte generator and corresponding existing seed assertions; integration 12/12. +- prisma migrate diff --from-url disposable DB --to-schema-datamodel prisma/schema.prisma --exit-code: No difference detected, exit 0. + +R4 evidence before coder2: +- Docker build app completed, Next.js 16.3.8 production build inside image. +- compose.production.yml postgres/app up --wait: both healthy, fake credentials only in ignored 0600 local-stack.env.local. +- Production prisma migrate deploy applied all 3 migrations on empty randomayzer DB. +- Actual port: ss shows 127.0.0.1:3100 only; DB HostConfig.PortBindings={}; app USER=node, PG USER=postgres. +- Actual limits: app1073741824 bytes/1.5 CPUs, PG536870912 bytes/1CPU. +- HTTP /api/auth/me returned unauthenticated safe JSON. Fake VK start returned correct localhost callback and PKCE despite forged Host/X-Forwarded-* headers. +- db-backup-daily.sh restored custom dump to a clean temporary DB, showed 3 migrations / 0 giveaways, printed both PASS messages; repeat with one marker giveaway pending. +- No real VK authorization, actual VPS TLS/basic auth or WG firewall acceptance claimed. +- Repeated backup/restore with one fake giveaway row: clean restored DB reported 3 migration rows and 1 giveaway; both PASS messages. Removed only our marker giveaway/user after drill. + +Gate caveat: +- One raw npm test run timed out in existing tests/security.test.ts unmocked live-network preview case (368/369); a focused unchanged rerun passed7/7, network case725ms. +- USE_VK_MOCK=true full suite later passed372/372. No test timeout or assertions were weakened; final raw run pending. diff --git a/agents/codex/notes/TASK-2026-10-19-r1-r3-notes.md b/agents/codex/notes/TASK-2026-10-19-r1-r3-notes.md new file mode 100644 index 0000000..aa0f177 --- /dev/null +++ b/agents/codex/notes/TASK-2026-10-19-r1-r3-notes.md @@ -0,0 +1,10 @@ +# Working notes — R1 / R3 + +- Base SHA: `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60`. +- npm lock before changes: `next@16.3.2` (MIT), `sharp@0.35.3` (Apache-2.0), `source-map-js@1.2.1` (BSD-3-Clause). +- Minimal same-major patched versions selected from npm registry metadata: `next@16.3.8` (MIT), `sharp@0.35.5` (Apache-2.0), `source-map-js@1.2.2` (BSD-3-Clause). `eslint-config-next` moved 16.3.2→16.3.8 to match Next. +- Production audit before update found next Critical, sharp High, source-map-js High; after update `npm audit --omit=dev` reported zero vulnerabilities. +- Pagination must be considered incomplete when reported totals are absent/invalid/change mid-read, pages end early, page sizes are invalid, or the safety ceiling is reached. Do not return partial participants for persistence. +- `groups.isMember` only produces an accepted status when every requested ID appears exactly once with `member` exactly 0 or 1. Missing/invalid data aborts the import before repository save. +- Provider currently does not use `execute`; observed execute method limits and live VK method contracts are `UNVERIFIED`. +- A-H smoke was exercised against a fake localhost HTTP server. A, C-H emitted PASS and B emitted MANUAL; no real VK credentials/session were used. diff --git a/agents/codex/notes/stage1-pr-body.md b/agents/codex/notes/stage1-pr-body.md new file mode 100644 index 0000000..47835e6 --- /dev/null +++ b/agents/codex/notes/stage1-pr-body.md @@ -0,0 +1,7 @@ +Stage1 R1–R5 implementation from the owner-supplied inbox manifest. Updates vulnerable production dependencies, adds a clean PostgreSQL bootstrap path and fixes repository duplicate-draw conflict handling, rejects incomplete VK participant/subscription data, and provides a private live-smoke script plus explicit loopback/domain deployment profiles. + +The owner explicitly authorized 32-byte random seeds and the narrow seed assertion changes. Deterministic proof/hash/HMAC algorithms remain unchanged. + +Local production stack and clean backup restore have been exercised with fake marker credentials. Real VK consent/scopes and VPS DNS/TLS/basic-auth/WireGuard/firewall acceptance require the operator; they are not claimed as complete. Main is not merged by this PR author. + +Final validation and independent review evidence will be recorded in agents/codex/done/2026-10-19-stage1-completion.md. diff --git a/compose.domain.yml b/compose.domain.yml new file mode 100644 index 0000000..e7edf43 --- /dev/null +++ b/compose.domain.yml @@ -0,0 +1,10 @@ +# Opt-in after VPS identity, DNS, firewall and proxy are verified by the operator. +services: + app: + environment: + DEPLOYMENT_PROFILE: strict + APP_BASE_URL: https://random.ochenstarik.ru + VK_REDIRECT_URI: https://random.ochenstarik.ru/api/auth/vk/callback + TRUSTED_PROXY_IP: 10.77.0.1 + ports: + - "10.77.0.3:${RANDOMAYZER_PORT:-3100}:3100" diff --git a/compose.production.yml b/compose.production.yml new file mode 100644 index 0000000..74abd19 --- /dev/null +++ b/compose.production.yml @@ -0,0 +1,66 @@ +name: randomayzer-production +services: + postgres: + image: postgres:16-alpine + container_name: randomayzer-postgres + user: postgres + restart: unless-stopped + environment: + POSTGRES_USER: randomayzer + POSTGRES_DB: randomayzer + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in the 0600 environment file} + volumes: + - database:/var/lib/postgresql/data + networks: [database] + mem_limit: 512m + cpus: 1.0 + security_opt: [no-new-privileges:true] + healthcheck: + test: [CMD-SHELL, pg_isready -U randomayzer -d randomayzer] + interval: 5s + timeout: 3s + retries: 30 + app: + image: ${RANDOMAYZER_IMAGE:-randomayzer-app:stage1} + build: . + container_name: randomayzer-app + user: node + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + env_file: ${RANDOMAYZER_ENV_FILE:?Set absolute path to a 0600 environment file} + environment: + NODE_ENV: production + STORAGE_DRIVER: prisma + DATABASE_URL: postgresql://randomayzer:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/randomayzer + DEPLOYMENT_PROFILE: local-single-user + APP_BASE_URL: http://localhost:3100 + VK_REDIRECT_URI: http://localhost:3100/api/auth/vk/callback + TRUSTED_PROXY_IP: "" + ALLOW_MEMORY_IDEMPOTENCY: "true" + ALLOW_MEMORY_RATE_LIMITER: "true" + PORT: "3100" + BIND_ADDRESS: 0.0.0.0 + ports: + - "127.0.0.1:${RANDOMAYZER_PORT:-3100}:3100" + networks: [database, egress] + mem_limit: 1g + cpus: 1.5 + pids_limit: 256 + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + healthcheck: + test: [CMD, node, -e, "fetch('http://127.0.0.1:3100/api/auth/me').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 12 +volumes: + database: + name: randomayzer-database +networks: + database: + name: randomayzer-database + internal: true + egress: + name: randomayzer-egress diff --git a/deploy/randomayzer-backup.service b/deploy/randomayzer-backup.service new file mode 100644 index 0000000..3a4f30a --- /dev/null +++ b/deploy/randomayzer-backup.service @@ -0,0 +1,11 @@ +[Unit] +Description=Randomayzer backup and clean restore drill +Requires=docker.service +After=docker.service +[Service] +Type=oneshot +User=RANDOMAYZER_OPERATOR +Environment=RANDOMAYZER_ENV_FILE=/etc/randomayzer/production.env +WorkingDirectory=/opt/randomayzer +ExecStart=/opt/randomayzer/tools/db-backup-daily.sh /var/backups/randomayzer +UMask=0077 diff --git a/deploy/randomayzer-backup.timer b/deploy/randomayzer-backup.timer new file mode 100644 index 0000000..2d6fce3 --- /dev/null +++ b/deploy/randomayzer-backup.timer @@ -0,0 +1,8 @@ +[Unit] +Description=Daily verified Randomayzer backup +[Timer] +OnCalendar=*-*-* 04:15:00 +Persistent=true +RandomizedDelaySec=15m +[Install] +WantedBy=timers.target diff --git a/deploy/randomayzer.Caddyfile b/deploy/randomayzer.Caddyfile new file mode 100644 index 0000000..f26d55e --- /dev/null +++ b/deploy/randomayzer.Caddyfile @@ -0,0 +1,29 @@ +# Add this site only to an existing Caddy. Replace BASIC_AUTH_HASH with `caddy hash-password` output. +random.ochenstarik.ru { + tls { + issuer acme { + disable_tlsalpn_challenge + } + } + basic_auth { + owner BASIC_AUTH_HASH + } + request_body { + max_size 1MB + } + # No access log: OAuth callback query values are sensitive. + reverse_proxy 10.77.0.3:3100 { + header_up Host random.ochenstarik.ru + header_up X-Forwarded-Host random.ochenstarik.ru + header_up X-Forwarded-Proto https + header_up X-Forwarded-For {remote_host} + header_up -X-Real-IP + header_up -Forwarded + header_up -CF-Connecting-IP + header_up -Authorization + transport http { + dial_timeout 10s + response_header_timeout 120s + } + } +} diff --git a/deploy/randomayzer.nginx.conf b/deploy/randomayzer.nginx.conf new file mode 100644 index 0000000..92cb4ba --- /dev/null +++ b/deploy/randomayzer.nginx.conf @@ -0,0 +1,37 @@ +# Include in the existing nginx HTTP context. Install ONLY if nginx already owns 80/443. +server { + listen 80; + server_name random.ochenstarik.ru; + location ^~ /.well-known/acme-challenge/ { root /var/www/randomayzer-acme; } + location / { return 301 https://random.ochenstarik.ru$request_uri; } +} +server { + listen 443 ssl; + server_name random.ochenstarik.ru; + ssl_certificate /etc/letsencrypt/live/random.ochenstarik.ru/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/random.ochenstarik.ru/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + auth_basic "Randomayzer owner"; + auth_basic_user_file /etc/nginx/randomayzer.htpasswd; + client_max_body_size 1m; + client_body_timeout 20s; + # OAuth callback has query credentials: never log request paths/query strings. + access_log off; + error_log /var/log/nginx/randomayzer-error.log crit; + location / { + proxy_pass http://10.77.0.3:3100; + proxy_bind 10.77.0.1; + proxy_set_header Host random.ochenstarik.ru; + proxy_set_header X-Forwarded-Host random.ochenstarik.ru; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP ""; + proxy_set_header Forwarded ""; + proxy_set_header CF-Connecting-IP ""; + # Basic credentials are for nginx only, never forward them to the application. + proxy_set_header Authorization ""; + proxy_connect_timeout 10s; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index fcb4cf9..3cb0ad2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,30 @@ -version: '3.8' - +# Disposable integration database only. Production uses compose.production.yml. +name: randomayzer-integration services: postgres: image: postgres:16-alpine - container_name: randomayzer-postgres - restart: unless-stopped + container_name: randomayzer-integration-postgres + restart: "no" + user: postgres environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: randomayzer + POSTGRES_PASSWORD: randomayzer-integration-only + POSTGRES_DB: randomayzer_test ports: - - "5432:5432" + - "127.0.0.1:${RANDOMAYZER_TEST_DB_PORT:-55439}:5432" volumes: - pgdata:/var/lib/postgresql/data - + networks: [database] + mem_limit: 512m + cpus: 1.0 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d randomayzer_test"] + interval: 2s + timeout: 3s + retries: 30 volumes: pgdata: + name: randomayzer-integration-pgdata +networks: + database: + name: randomayzer-integration-database diff --git a/docs/PRODUCTION_GUARDS.md b/docs/PRODUCTION_GUARDS.md index 3db167a..668e418 100644 --- a/docs/PRODUCTION_GUARDS.md +++ b/docs/PRODUCTION_GUARDS.md @@ -53,6 +53,22 @@ The API supports the standard `Idempotency-Key` HTTP header on state-mutating en - Normalizes IPv4, IPv6 (including IPv4-mapped IPv6 `::ffff:192.0.2.1` and loopbacks `::1`). - Validates IP syntax against IPv4/IPv6 standards. +### Supported production ingress profiles + +The stage-one Docker deployment uses `tools/serve-production.cjs` as its production entry point. It checks the +actual Node socket peer before forwarding a request to Next, removes client-supplied proxy identity headers, and +rebuilds `Host` and `X-Forwarded-*` from the configured canonical origin. In the strict domain profile, forwarded +client IP and HTTPS scheme are accepted only when `socket.remoteAddress` is the configured VPS peer +`10.77.0.1`; the reverse proxy must overwrite those headers. The local `local-single-user` profile accepts only +HTTP loopback origins and emits a non-Secure session cookie only for that explicit profile. All other production +profiles still require HTTPS and Secure cookies. + +This socket-aware boundary is specific to `tools/serve-production.cjs` and the supplied Compose profiles. The +generic `TRUST_PROXY=true` behavior described above parses forwarded chains and is safe only when an upstream +proxy already sanitizes them. Do not run `next start` with `TRUST_PROXY=true` as a substitute for this entry point, +and do not weaken the HTTPS or cookie checks globally. A deployment using another entry point must separately +establish and test its trusted socket boundary. + ### Memory Limiter vs Multi-Instance Deployments - The built-in `SlidingWindowRateLimiter` is optimized for single-instance, serverless dev, and test environments. - In multi-instance or horizontal cluster deployments, rate limiting must be offloaded to an edge layer (e.g., Cloudflare Rate Limiting, Nginx limit_req) or a shared distributed cache (Redis/Valkey). diff --git a/docs/STAGE1_DEPLOYMENT_RUNBOOK.md b/docs/STAGE1_DEPLOYMENT_RUNBOOK.md new file mode 100644 index 0000000..507dfd5 --- /dev/null +++ b/docs/STAGE1_DEPLOYMENT_RUNBOOK.md @@ -0,0 +1,273 @@ +# Randomayzer: операторский runbook R2–R5 + +База задания: `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60`. Команды выполняет владелец/Claude. +Ниже два альтернативных профиля: R4 только loopback; R5 строго HTTPS через существующий VPS. +Реальный вход VK, актуальное согласие на localhost redirect и возможности token scopes остаются **UNVERIFIED** до smoke. +Не менять существующие Apache/Express/Forgejo/WireGuard. Не устанавливать второй сервер на 80/443. + +## 1. Предварительная проверка на домашнем сервере + +```bash +cd /opt/randomayzer # замените фактическим checkout проекта +ss -ltn '( sport = :3100 or sport = :55439 )' +df -h . /var/lib/docker +docker info --format '{{.ServerVersion}}' +docker compose version +``` + +Порты должны быть свободны. Если 3100 занят — выбрать другой свободный непривилегированный порт и +одновременно изменить mapping, APP_BASE_URL/VK_REDIRECT_URI в compose, proxy upstream и команды ниже. +`RANDOMAYZER_PORT` меняет только host mapping; canonical origin обязательно согласовать отдельно. +Проверить запас диска для Node image, PostgreSQL и 14 бэкапов; при нехватке остановиться, чужие images/volumes не удалять. + +Создать вне checkout файл `/etc/randomayzer/production.env`, доступ только оператору, mode **0600**. +Не использовать `set -x`, `cat` файла, вывод `docker compose config`, `docker inspect` environment, +`docker compose logs` OAuth-запросов или `source` файла. Compose читает данные, не shell-код. +Обязательные имена переменных: + +```text +POSTGRES_PASSWORD= +AUTH_SECRET=<отдельный случайный ключ> +TOKEN_ENCRYPTION_KEY=<отдельный случайный ключ> +VK_APP_ID=<идентификатор приложения> +VK_CLIENT_SECRET=<секрет приложения> +VK_SERVICE_TOKEN=<сервисный токен> +``` + +Для локальной проверки без VK используются очевидные marker values; это не доказательство живого VK. +AUTH_SECRET/TOKEN_ENCRYPTION_KEY — разные криптографически случайные значения. + +```bash +export RANDOMAYZER_ENV_FILE=/etc/randomayzer/production.env +test "$(stat -c '%a' "$RANDOMAYZER_ENV_FILE")" = 600 +# В каждой новой shell определите функцию заново. +ry() { docker compose --env-file "$RANDOMAYZER_ENV_FILE" -f compose.production.yml "$@"; } +ry config --quiet +ry build app +ry up -d --wait postgres +ry run --rm --no-deps app npx prisma migrate deploy +ry up -d --wait app +ry ps +curl --fail --silent http://127.0.0.1:3100/api/auth/me +``` + +Ожидается: 3 миграции применены, оба сервиса healthy; `/api/auth/me` возвращает JSON `authenticated: false`, +`user: null` до входа. PostgreSQL **не имеет host ports**. Приложение на хосте доступно только на 127.0.0.1:3100. +Внутренний контейнерный listener — 0.0.0.0:3100, необходимый для Docker port forwarding; на хосте wildcard запрещён. +Оба контейнера непривилегированные, отдельные randomayzer-сети/том, CPU/RAM limits заданы в Compose. + +Для уже существующей БД: сначала бэкап. Новая `20260818000000_initial` описывает историческую схему до ownership/auth-store +миграций. **Не применять CREATE TABLE поверх существующих таблиц.** Сверить `prisma migrate status`, схему и историю: +если исходные таблицы уже созданы через `db push`, после проверки соответствия оператор выполняет +`ry run --rm --no-deps app npx prisma migrate resolve --applied 20260818000000_initial`, затем deploy. +Не отмечать старые ownership/auth миграции applied без проверки их SQL и фактических ограничений. +NULL organizerId по-прежнему вызывает отказ ownership migration; автоматической перепривязки нет. + +## 2. Доступ владельца / R4 + +На сервере открыть `http://localhost:3100`. Через SSH с компьютера владельца: + +```bash +ssh -N -L 3100:127.0.0.1:3100 OPERATOR@HOME_SERVER +``` + +Открыть `http://localhost:3100` на этом компьютере. В VK ID указать буквально +`http://localhost:3100/api/auth/vk/callback`; поддержку этого URI данным VK-приложением проверить вручную. +`local-single-user` допускает HTTP только для localhost/127.0.0.1/[::1], отключает Secure только в этом профиле. +Cookie остаётся HttpOnly + SameSite=Lax, CSRF проверяет canonical origin. Профиль нельзя использовать с LAN/public hostname. +Не публиковать домен, TLS/firewall/роутер ради R4. + +## 3. Ежедневный бэкап и проверка восстановления + +```bash +sudo install -d -m 700 -o OPERATOR -g OPERATOR /var/backups/randomayzer +tools/db-backup-daily.sh /var/backups/randomayzer +``` + +Replace both `OPERATOR` placeholders with the service account and its primary group. If backups are on a mounted +separate disk, mount it first and verify that this account can write there; keep the directory mode at `0700`. + +Ожидается `PASS backup restored to clean drill database` и `PASS daily backup and restore verification`. +Скрипт создаёт pg_dump custom archive c mode 0600, восстанавливает в новую временную БД `randomayzer_restore_`, +останавливается при первой SQL-ошибке, проверяет таблицы миграций/Giveaway, удаляет только временную БД. +Production DB не перезаписывается. Retention 14 дней применяется только после успешного drill. +Бэкап содержит private данные/зашифрованные credentials: не коммитить, не прикладывать к review. + +Для расписания заменить `RANDOMAYZER_OPERATOR` в `deploy/randomayzer-backup.service` именем оператора; +исправить WorkingDirectory/ExecStart на фактический checkout. Оператор должен иметь доступ к Docker и env-файлу. + +```bash +sudo install -m 644 deploy/randomayzer-backup.service deploy/randomayzer-backup.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now randomayzer-backup.timer +systemctl list-timers randomayzer-backup.timer +sudo systemctl start randomayzer-backup.service +systemctl status randomayzer-backup.service --no-pager +``` + +Ожидается успешный oneshot и следующее ежедневное срабатывание. Проверять failed state ежедневно; +не считать наличие dump доказательством восстановления. + +## 4. Обновление и откат + +До обновления записать полный `git rev-parse HEAD`, сохранить текущий image под отдельным именем и выполнить backup/drill. + +```bash +git rev-parse HEAD +docker tag randomayzer-app:stage1 randomayzer-app:rollback +# Переключить checkout на явно выбранный reviewed SHA, не git pull поверх локальных изменений. +ry build app +ry run --rm --no-deps app npx prisma migrate deploy +ry up -d --wait app +curl --fail --silent http://127.0.0.1:3100/api/auth/me +``` + +Откат приложения: `RANDOMAYZER_IMAGE=randomayzer-app:rollback ry up -d --no-build --wait app`. +Если миграция несовместима с предыдущим приложением: остановить **только** `ry stop app`, восстановить dump +в новую БД, проверить её, затем оператор переключает DATABASE_URL/POSTGRES_DB на восстановленную БД. +Не откатывать production данные автоматически и не удалять текущий том. Для полной остановки приложения +`ry stop app`; `ry down` сохраняет volume. Никогда не использовать `down -v` на production. + +## 5. R2: отдельная уничтожаемая тестовая БД + +```bash +docker compose -f docker-compose.yml up -d --wait +DATABASE_URL=postgresql://postgres:randomayzer-integration-only@127.0.0.1:55439/randomayzer_test npx prisma migrate deploy +DATABASE_URL=postgresql://postgres:randomayzer-integration-only@127.0.0.1:55439/randomayzer_test npm run test:integration +docker compose -f docker-compose.yml down +``` + +Integration tests очищают все данные: **никогда не подставлять production URL**. Имена и volume отделены от production. + +## 6. R5: предварительно на VPS и домашнем сервере + +**BLOCKED до подтверждения владельца:** A-record `random.ochenstarik.ru → 178.212.13.102`, +peer `10.77.0.1` — именно этот VPS, какой существующий веб-сервер владеет 80/443. +Здесь не предполагается, что DNS/сертификат/вход уже проверены. + +На VPS (без изменений): + +```bash +ss -ltn '( sport = :80 or sport = :443 )' +systemctl is-active nginx caddy apache2 +ip route get 10.77.0.3 +getent ahostsv4 random.ochenstarik.ru +curl --connect-timeout 5 http://10.77.0.3:3100/api/auth/me +``` + +До включения domain overlay последний curl должен быть недоступен. Маршрут к `10.77.0.3` должен иметь +`src 10.77.0.1`; иначе остановиться, текущий WireGuard не менять. +Если 443 принадлежит Apache или контейнеру неизвестной конфигурации, эти nginx/Caddy snippets не применять: +оператор сначала определяет точку подключения отдельного virtual host. Чужой vhost не заменять. + +### Домашний сервер: firewall раньше публикации WG-порта + +```bash +ip -brief address show krona-wg +ip route get 10.77.0.1 +sudo iptables -S DOCKER-USER +sudo iptables -S INPUT +``` + +Ожидается 10.77.0.3 на существующем интерфейсе и Docker iptables backend. Если используется nftables native, +firewalld/ufw или другая система управления — остановиться и поручить владельцу эквивалентные правила в его +существующем manager; не смешивать managers. Для iptables Docker backend следующий отдельный chain +ограничивает только original destination 10.77.0.3:3100. Docker DNAT может обходить INPUT, поэтому нужен +DOCKER-USER. Дополнительный INPUT rule покрывает host/userland-proxy путь. + +```bash +sudo iptables -N RANDOMAYZER-WG +sudo iptables -A RANDOMAYZER-WG -s 10.77.0.1 -j RETURN +sudo iptables -A RANDOMAYZER-WG -j DROP +sudo iptables -I DOCKER-USER 1 -p tcp -m conntrack --ctorigdst 10.77.0.3 --ctorigdstport 3100 -j RANDOMAYZER-WG +sudo iptables -I INPUT 1 -p tcp -d 10.77.0.3 --dport 3100 -j RANDOMAYZER-WG +``` + +Выполнять один раз, не дублировать chain/rules. После тестов оператор сохраняет **только добавленные правила** +через существующий менеджер firewall. При отсутствии подтверждённой persistence R5 не завершён. +Не открывать 3100 на 0.0.0.0, LAN или роутере. IPv6 WG-порт не публикуется. + +```bash +ryd() { docker compose --env-file "$RANDOMAYZER_ENV_FILE" -f compose.production.yml -f compose.domain.yml "$@"; } +ryd config --quiet +ryd up -d --wait app +ryd ps +ss -ltn '( sport = :3100 )' +``` + +Ожидаются host bindings только 127.0.0.1:3100 и 10.77.0.3:3100. Domain profile использует HTTPS canonical origin, +Secure cookie и VK redirect `https://random.ochenstarik.ru/api/auth/vk/callback`. Loopback URL более не подходит +для пользовательского login в строгом профиле. В VK ID оператор меняет зарегистрированный redirect на HTTPS. +На VPS повторить curl к `10.77.0.3:3100/api/auth/me`: ожидается JSON. Подтвердить сохранение socket source +10.77.0.1 при Docker DNAT, например tcpdump метаданными `-q`, **без `-A`/payload/logging OAuth**. +При source NAT на gateway приложение игнорирует proxy headers; менять trust на всю Docker subnet запрещено. + +### VPS: добавить ровно один virtual host + +**nginx (только если он уже обслуживает 443).** Сохранить текущий конфиг владельцем. Сначала добавить только +HTTP server block из `deploy/randomayzer.nginx.conf` в подключаемый sites directory, создать +`/var/www/randomayzer-acme`, проверить `sudo nginx -t` и выполнить `sudo systemctl reload nginx`. + +```bash +sudo certbot certonly --webroot -w /var/www/randomayzer-acme -d random.ochenstarik.ru +sudo htpasswd -c /etc/nginx/randomayzer.htpasswd owner +sudo chmod 640 /etc/nginx/randomayzer.htpasswd +# Владелец/группа файла должны позволять только nginx worker читать hash. +``` + +Пароль вводится интерактивно, не через аргументы/логи. Certbot должен сообщить успешную выдачу. +Теперь добавить HTTPS server block из snippet. `sudo nginx -t`, затем `sudo systemctl reload nginx`. +Проверить существующий certbot renewal timer и `sudo certbot renew --dry-run` (не перезапускать Forgejo). +Basic auth применяется также к callback; браузер владельца уже авторизован на том же origin. + +**Caddy (только если он уже обслуживает 443).** Выполнить `caddy hash-password` интерактивно, вставить hash +в новый отдельный site block `deploy/randomayzer.Caddyfile`, хранить конфиг с доступом только оператора/Caddy. +Добавить block в существующий Caddyfile, сохранить старую копию; выполнить +`sudo caddy validate --config /etc/caddy/Caddyfile`, затем `sudo systemctl reload caddy`. +TLS-ALPN отключён в этом site; ACME использует HTTP-01. Проверить автоматическое renewal в существующем Caddy. +Фактическая версия Caddy и конфиг валидируются на VPS; если validate отвергает директиву — не reload. + +Оба snippets переписывают Host/X-Forwarded-* (не дописывают к client-supplied chain), убирают Basic Authorization +перед upstream, задают 1MB body limit и таймауты. Не включать access log с callback query/cookie. +Приложение запускается **только** через `tools/serve-production.cjs`: он проверяет socket.remoteAddress, +доверяет forwarded IP/proto только `10.77.0.1`, удаляет все входящие proxy headers остальных клиентов. +Обычный `next start` не является production entry point этих compose-профилей. + +### Приёмка и откат R5 + +С внешней машины: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://random.ochenstarik.ru/ +# ожидается 401 без basic auth +curl -u owner --fail --silent https://random.ochenstarik.ru/api/auth/me +# пароль вводится curl интерактивно; ожидается JSON user:null +curl --connect-timeout 5 http://178.212.13.102:3100/ +# timeout/refused; порт НЕ должен отвечать +``` + +С LAN другой машины `curl --connect-timeout 5 http://HOME_LAN_IP:3100/` — refused/timeout. +С другого WG peer запрос к 10.77.0.3:3100 — timeout. С VPS запрос к нему — успешен. +Браузером проверить сертификат, basic auth, VK login, `/api/auth/me`, Secure/HttpOnly cookie, A–H smoke. +Записать фактические результаты/дату/SHA в `agents/claude/done/`, без секретных ответов. +До этого сертификат, 401 на публичном домене, live VK и изоляция на стенде — **UNVERIFIED**. + +Откат: удалить только добавленный vhost, проверить существующий конфиг и reload соответствующего сервера. +На домашнем сервере `ry up -d --wait app` вернёт loopback-only профиль. Сначала проверить исчезновение WG binding, +затем удалить только наши firewall entries: + +```bash +sudo iptables -D DOCKER-USER -p tcp -m conntrack --ctorigdst 10.77.0.3 --ctorigdstport 3100 -j RANDOMAYZER-WG +sudo iptables -D INPUT -p tcp -d 10.77.0.3 --dport 3100 -j RANDOMAYZER-WG +sudo iptables -F RANDOMAYZER-WG +sudo iptables -X RANDOMAYZER-WG +``` + +Владелец также убирает эти entries из persistence своего firewall manager. DB volume, чужие vhosts и tunnel сохраняются. + +## Источники конфигурации + +- [Next.js custom server](https://nextjs.org/docs/app/guides/custom-server) — socket-aware ingress перед Next. +- [Docker Compose services](https://docs.docker.com/reference/compose-file/services/) — exact host port binding и resource limits. +- [nginx proxy module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html) — proxy_bind и overwrite headers. +- [Caddy reverse_proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy), [TLS](https://caddyserver.com/docs/caddyfile/directives/tls). diff --git a/docs/VK_REAL_SMOKE_TEST.md b/docs/VK_REAL_SMOKE_TEST.md index 76a3beb..097c8f3 100644 --- a/docs/VK_REAL_SMOKE_TEST.md +++ b/docs/VK_REAL_SMOKE_TEST.md @@ -1,43 +1,69 @@ -# Real VK ID & API Live Smoke Test Runbook +# VK live smoke test -This runbook outlines the live verification steps for testing VK ID OAuth 2.1 and authenticated VK operations without committing credentials into version control or CI. +The smoke tool checks the configured application and VK account without placing tokens or cookies in command arguments or output. It only accepts loopback HTTP or the approved `https://random.ochenstarik.ru` origin and a regular environment file with mode `0600`. The domain run requires proxy Basic Auth in that file. ---- +## Prepare the local run -## 1. Local Environment Preparation +1. Start the app with its normal local credentials, then log in from a browser at the configured loopback URL. +2. Complete the browser consent flow described in **B** below. +3. Create a file outside the repository, for example `~/.vk-smoke.env`, and restrict it to the owner: -Set in your `.env.local` file: -```bash -# VK ID Web Application Credentials -VK_APP_ID="" -VK_CLIENT_SECRET="" + ```sh + umask 077 + touch ~/.vk-smoke.env + chmod 600 ~/.vk-smoke.env + ``` -# Service Token for Public Operations -VK_SERVICE_TOKEN="" +4. Add one unquoted `KEY=value` per line. Do not add VK access tokens or client secrets. The session cookie is sensitive and must remain in this `0600` file. -# Canonical Local Configuration -APP_BASE_URL="http://localhost:3000" -VK_REDIRECT_URI="http://localhost:3000/api/auth/vk/callback" + ```text + VK_SMOKE_BASE_URL=http://localhost:3100 + VK_SMOKE_SESSION_COOKIE=randomayzer_session= + VK_SMOKE_PROXY_AUTH=: + VK_SMOKE_PUBLIC_POST_URL=https://vk.com/wall-_ + VK_SMOKE_PRIVATE_POST_URL=https://vk.com/wall-_ + VK_SMOKE_GROUP_ID= + VK_SMOKE_CREATE_GIVEAWAY=YES + ``` -# Cryptographic Keys (min 32 chars) -AUTH_SECRET="" -TOKEN_ENCRYPTION_KEY="" -``` + For the domain run, set `VK_SMOKE_BASE_URL=https://random.ochenstarik.ru` and provide `VK_SMOKE_PROXY_AUTH`. Proxy credentials are used only by curl from the protected temporary config. `VK_SMOKE_PRIVATE_POST_URL` and `VK_SMOKE_GROUP_ID` are optional when those checks are not part of the run. Remove their lines to skip the related checks. Set `VK_SMOKE_CREATE_GIVEAWAY=YES` only when a smoke-test giveaway may be created. ---- +5. Run: -## 2. Verification Checklist + ```sh + ./tools/vk-smoke.sh ~/.vk-smoke.env + ``` -- [ ] **A. OAuth Login Start**: Visit `/api/auth/vk/start` $\rightarrow$ Redirects to `https://id.vk.com/authorize` with PKCE `code_challenge` (S256). -- [ ] **B. OAuth Callback**: Authorize on VK screen $\rightarrow$ Redirected to `/api/auth/vk/callback`, sets HttpOnly cookie `randomayzer_session`. -- [ ] **C. Session Inspection**: Visit `/api/auth/me` $\rightarrow$ Returns authenticated user profile (name, avatar). -- [ ] **D. Public Post Preview**: Paste public VK post URL in `/giveaways/new` $\rightarrow$ Preview loads with `accessMode: "PUBLIC_SERVICE"`. -- [ ] **E. Private/Restricted Post Preview**: Paste post URL from closed group where organizer is member $\rightarrow$ Resolver falls back to `ORGANIZER_USER`. -- [ ] **F. Create Giveaway**: Submit giveaway form $\rightarrow$ Giveaway created with `organizerId: sessionUser.id`. -- [ ] **G. Import Participants**: Click Import Participants $\rightarrow$ Likes and comments fetched via `VkAuthContextResolver`. -- [ ] **H. Subscription Verification**: Run community subscription filter $\rightarrow$ Batch `groups.isMember` executed successfully. -- [ ] **I. Snapshot Locking**: Lock snapshot $\rightarrow$ Canonical hashes computed. -- [ ] **J. Deterministic Draw**: Execute draw $\rightarrow$ Winner selected via unbiased CSPRNG rejection sampling. -- [ ] **K. Public Audit**: Open `/api/giveaways/[id]/verify` in incognito window $\rightarrow$ Audit passes without authentication. -- [ ] **L. Token Expiry & Refresh**: Wait for access token expiry or simulate $\rightarrow$ Next API request automatically triggers server-side refresh without user interruption. -- [ ] **M. Logout**: Click logout $\rightarrow$ Session terminated, cookie destroyed. +The script prints `PASS`, `FAIL`, `SKIP`, or `MANUAL` for checks A–H and exits nonzero when any executed check fails. It keeps response bodies and HTTP headers in a private temporary directory and removes that directory on exit. It does not print profile or participant data. A successful run of F creates a real giveaway record; G imports participants and H reimports them with the subscription condition. Choose a known test post and community. + +## Checks + +- **A. OAuth start:** verifies that `/api/auth/vk/start` redirects to VK ID and requests PKCE S256. +- **B. Browser consent:** manual. Open `/api/auth/vk/start`, approve the consent screen, and confirm the browser returns to `/api/auth/vk/callback`. Confirm the app session is authenticated before copying the session cookie into the protected smoke file. The script never treats the browser step as passed. +- **C. Session:** confirms `/api/auth/me` returns an authenticated profile. +- **D. Public post:** checks authenticated preview for the configured public post and requires `effectiveCapabilities.accessMode` to equal `PUBLIC_SERVICE`. +- **E. Restricted post:** checks preview for the configured restricted post and requires the resolved auth mode to be `USER`. The chosen post must be visible to the organizer. +- **F. Giveaway creation:** creates a test giveaway from the successful public preview when explicitly enabled. +- **G. Participant import:** checks that likes and comments import successfully. +- **H. Subscription check:** reimports participants with `groups.isMember` for the configured group and accepts only a complete successful response. + +## Follow-up checks retained from the original checklist + +These checks are intentionally manual. A–H are the script's current automation boundary; later checks are never +reported as passed by the script. + +- **I. Snapshot locking:** lock the imported participant snapshot and verify the returned counts and hashes are present. Do not change or infer the deterministic proof/hash contract from this smoke run. +- **J. Deterministic draw:** run one draw from the locked snapshot and confirm the result is returned. The implementation's algorithm is outside this smoke checklist; do not use this as evidence to alter or revalidate the protected proof format. +- **K. Public audit:** open `/api/giveaways//verify` in a private browser window without a session and confirm the public verification response succeeds without exposing organizer or participant-private data. +- **L. Token refresh:** use an authorized test account after its access token expires, or a controlled test credential with a known expiry, and confirm the next authorized VK request either refreshes successfully or reports reauthentication. Do not force expiry by editing stored credentials on a live database. +- **M. Logout:** log out, confirm the session cookie is cleared, then confirm `/api/auth/me` returns unauthenticated. + +## Safe behavior for unsupported and unknown VK behavior + +- Community manager detection is disabled unless a complete, supported check is implemented. Choosing a manager-exclusion rule is rejected with an explanation; the app does not mark unknown users as non-managers. +- Repost eligibility remains unsupported. The app rejects a repost requirement and explains that it cannot verify it for private profiles. +- Pagination fails the import when an empty or short page arrives before VK's reported total, when the total is missing, or when the page limit is reached before the total. The import route saves participants only after all fetch and enrichment work succeeds, so a failed import does not replace the previous participant list. +- The current provider does not call VK `execute`; it fetches pages with ordinary methods. Its batching limits and behavior remain **UNVERIFIED** and are not inferred from third-party examples. +- VK can change data while an offset-based import is running. Completeness checks detect short or missing pages against the returned total but cannot prove a stable point-in-time set. That behavior remains **UNVERIFIED** pending official contract confirmation or a live test. + +No test credentials, cookies, or tokens belong in the repository. Use only a throwaway session cookie in the protected local file and delete that file after testing. diff --git a/package-lock.json b/package-lock.json index 937a63c..0aa17d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@prisma/client": "^5.20.0", "clsx": "^2.1.1", "lucide-react": "^0.453.0", - "next": "^16.3.2", + "next": "^16.3.8", "react": "^19.2.8", "react-dom": "^19.2.8", "tailwind-merge": "^2.5.4", @@ -23,7 +23,7 @@ "@types/react-dom": "^19.2.4", "autoprefixer": "^10.4.20", "eslint": "^9.20.0", - "eslint-config-next": "^16.3.2", + "eslint-config-next": "^16.3.8", "postcss": "^8.5.26", "prisma": "^5.20.0", "tailwindcss": "^3.4.13", @@ -988,9 +988,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -1006,13 +1006,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -1028,20 +1028,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1051,9 +1051,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -1067,9 +1067,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -1083,15 +1083,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1102,15 +1099,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1121,15 +1115,12 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1140,15 +1131,12 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1159,15 +1147,12 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1178,15 +1163,12 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1197,15 +1179,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1216,15 +1195,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1235,15 +1211,12 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1256,19 +1229,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1281,19 +1251,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1306,19 +1273,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1331,19 +1295,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1356,19 +1317,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1381,19 +1339,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1406,19 +1361,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1431,17 +1383,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1451,16 +1403,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1470,9 +1422,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1489,9 +1441,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1508,9 +1460,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1584,9 +1536,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1616,15 +1565,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.2.tgz", - "integrity": "sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.2.tgz", - "integrity": "sha512-z+HW1cZgt8QhByw8p2EbxF94AImgsKIYUbtSkA7Zld2T9yrKAlys4jNOcAOCtv6csX2CoA/5qCVyesL5pHmJ0A==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz", + "integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==", "dev": true, "license": "MIT", "dependencies": { @@ -1682,9 +1631,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.2.tgz", - "integrity": "sha512-ib5Llm93YCKoKWDh6ZaHq6QWTuOZ2bRkSnUwMmX8dsRIOkBNL1vVlSiUKSfixPL9SSh9pvukzqajk/klkn5vqg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "cpu": [ "arm64" ], @@ -1698,9 +1647,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.2.tgz", - "integrity": "sha512-qd98fX2+I5nYJDioW2o7nSjoxM5KvWdeDefM80igia4+C/qSIEhH4MhTE+hO/7qKM7W37/Mq+dOWp8UePSyLHw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "cpu": [ "x64" ], @@ -1714,15 +1663,12 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.2.tgz", - "integrity": "sha512-vqsgb6FAOzcrCccsLXiKtAy5t8EzO+uOazuFaSkQxeY0tNONG3vpHYy8pyBafcI5SNFPTeyard6yTr6SzNGo2A==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1733,15 +1679,12 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.2.tgz", - "integrity": "sha512-xIe1eujfHUB2XcxHGddxJyu6TJRPjC5NpIkQYB/32ESkt5VkQyIAjmLRS38c+s6QY+qjtY/4KarVDzXRuD7lZQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1752,15 +1695,12 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.2.tgz", - "integrity": "sha512-Fe0SA2j8X0kmc3aveuHD7UktO3AE2+mH3LguP60vGbz7u0z+MrDXbeb5iZFYAwR7EzzzXJ2Yk966w9mGTFMqfA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1771,15 +1711,12 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.2.tgz", - "integrity": "sha512-TFBipb+gyesI/2Ve4zVu7kGltBWN/R466G5/1gtt2lECfc22G1pjkTxu68Q9aFcOaXiRGTQfvDbQQFe7mYgxiQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1790,9 +1727,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.2.tgz", - "integrity": "sha512-rVtmnNpBYIosDnKD/96dKxFsJnwnn1WRGG/HioSe8XCm2ksSHNrd2R6+hSjvTBxeMNhJ9pYeu/90cWB1nQLuNA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "cpu": [ "arm64" ], @@ -1806,9 +1743,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.2.tgz", - "integrity": "sha512-H4Y2o2/JcHu8LtwzD5CXfHhwxwz8gfsx2HXDEw46Mtev5xHnEmB7HNtZtmriw5ReUOjRtcDqo7XSbU01FT9NlA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "cpu": [ "x64" ], @@ -2029,9 +1966,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2046,9 +1980,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2063,9 +1994,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2080,9 +2008,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2097,9 +2022,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2114,9 +2036,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2131,9 +2050,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2148,9 +2064,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2165,9 +2078,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2182,9 +2092,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2199,9 +2106,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2216,9 +2120,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2233,9 +2134,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2766,9 +2664,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2783,9 +2678,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2800,9 +2692,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2817,9 +2706,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2834,9 +2720,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2851,9 +2734,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2868,9 +2748,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2885,9 +2762,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2902,9 +2776,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2919,9 +2790,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4387,13 +4255,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.2.tgz", - "integrity": "sha512-gTABOJmyc6pEgSX1Z1VOjBxkSmo5Hkdj+ePclDf8HLGTsnVWzgtDdrOeQrAnUkf0JNJJhwPuXrsIwmFZRKJLoQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz", + "integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.2", + "@next/eslint-plugin-next": "16.3.8", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -5034,6 +4902,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6233,12 +6102,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.2.tgz", - "integrity": "sha512-/ZCaubUy17Lld1SiPWxuPbCk2ihqAxF2QNQaPZeEaEb7t1I58qhsJN187D7AfpapHAqUPXH0f/thtdW9dWgWFg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.2", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -6252,15 +6121,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.2", - "@next/swc-darwin-x64": "16.3.2", - "@next/swc-linux-arm64-gnu": "16.3.2", - "@next/swc-linux-arm64-musl": "16.3.2", - "@next/swc-linux-x64-gnu": "16.3.2", - "@next/swc-linux-x64-musl": "16.3.2", - "@next/swc-win32-arm64-msvc": "16.3.2", - "@next/swc-win32-x64-msvc": "16.3.2", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -7235,9 +7104,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -7252,31 +7121,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -7404,9 +7273,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 219c662..e861a53 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@prisma/client": "^5.20.0", "clsx": "^2.1.1", "lucide-react": "^0.453.0", - "next": "^16.3.2", + "next": "^16.3.8", "react": "^19.2.8", "react-dom": "^19.2.8", "tailwind-merge": "^2.5.4", @@ -31,7 +31,7 @@ "@types/react-dom": "^19.2.4", "autoprefixer": "^10.4.20", "eslint": "^9.20.0", - "eslint-config-next": "^16.3.2", + "eslint-config-next": "^16.3.8", "postcss": "^8.5.26", "prisma": "^5.20.0", "tailwindcss": "^3.4.13", diff --git a/prisma/migrations/20260818000000_initial/migration.sql b/prisma/migrations/20260818000000_initial/migration.sql new file mode 100644 index 0000000..ba314e5 --- /dev/null +++ b/prisma/migrations/20260818000000_initial/migration.sql @@ -0,0 +1,201 @@ +-- CreateEnum +CREATE TYPE "Platform" AS ENUM ('VK', 'TELEGRAM', 'YOUTUBE'); + +-- CreateEnum +CREATE TYPE "GiveawayStatus" AS ENUM ('DRAFT', 'FETCHING', 'READY', 'SNAPSHOT_LOCKED', 'DRAWN', 'PUBLISHED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "ParticipantSource" AS ENUM ('LIKES', 'COMMENTS', 'REPOSTS', 'COMBINED'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "vkUserId" TEXT NOT NULL, + "firstName" TEXT, + "lastName" TEXT, + "username" TEXT, + "avatarUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserCredential" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "encryptedAccessToken" TEXT NOT NULL, + "encryptedRefreshToken" TEXT, + "expiresAt" TIMESTAMP(3), + "scope" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserCredential_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Giveaway" ( + "id" TEXT NOT NULL, + "platform" "Platform" NOT NULL DEFAULT 'VK', + "sourceUrl" TEXT NOT NULL, + "platformOwnerId" TEXT NOT NULL, + "platformPostId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "postImageUrl" TEXT, + "postLikesCount" INTEGER NOT NULL DEFAULT 0, + "postCommentsCount" INTEGER NOT NULL DEFAULT 0, + "postRepostsCount" INTEGER NOT NULL DEFAULT 0, + "status" "GiveawayStatus" NOT NULL DEFAULT 'DRAFT', + "filterRules" JSONB NOT NULL, + "winnersCount" INTEGER NOT NULL DEFAULT 1, + "reserveWinnersCount" INTEGER NOT NULL DEFAULT 0, + "seed" TEXT, + "organizerId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "drawnAt" TIMESTAMP(3), + + CONSTRAINT "Giveaway_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Participant" ( + "id" TEXT NOT NULL, + "giveawayId" TEXT NOT NULL, + "platformUserId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "username" TEXT, + "avatarUrl" TEXT, + "source" "ParticipantSource" NOT NULL DEFAULT 'LIKES', + "liked" BOOLEAN NOT NULL DEFAULT false, + "commented" BOOLEAN NOT NULL DEFAULT false, + "commentsCount" INTEGER NOT NULL DEFAULT 0, + "reposted" BOOLEAN NOT NULL DEFAULT false, + "subscribed" BOOLEAN NOT NULL DEFAULT false, + "eligible" BOOLEAN NOT NULL DEFAULT true, + "exclusionReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Participant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ParticipantSnapshot" ( + "id" TEXT NOT NULL, + "giveawayId" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "eligibleParticipants" JSONB NOT NULL, + "filterRulesSnapshot" JSONB NOT NULL, + "participantCount" INTEGER NOT NULL, + "participantsSnapshotHash" TEXT NOT NULL, + "conditionsHash" TEXT NOT NULL, + + CONSTRAINT "ParticipantSnapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DrawResult" ( + "id" TEXT NOT NULL, + "drawId" TEXT NOT NULL, + "giveawayId" TEXT NOT NULL, + "snapshotId" TEXT NOT NULL, + "winners" JSONB NOT NULL, + "reserveWinners" JSONB NOT NULL, + "winnerIds" JSONB NOT NULL, + "reserveWinnerIds" JSONB NOT NULL, + "totalEligibleCount" INTEGER NOT NULL, + "totalLoadedCount" INTEGER NOT NULL, + "seedUsed" TEXT NOT NULL, + "algorithmVersion" TEXT NOT NULL DEFAULT 'HMAC_SHA256_FY_V1', + "deterministicProofHash" TEXT NOT NULL, + "auditEventHash" TEXT NOT NULL, + "drawnAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DrawResult_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditRecord" ( + "id" TEXT NOT NULL, + "giveawayId" TEXT NOT NULL, + "snapshotId" TEXT NOT NULL, + "algorithmVersion" TEXT NOT NULL DEFAULT 'HMAC_SHA256_FY_V1', + "seed" TEXT NOT NULL, + "participantsSnapshotHash" TEXT NOT NULL, + "conditionsHash" TEXT NOT NULL, + "deterministicProofHash" TEXT NOT NULL, + "auditEventHash" TEXT NOT NULL, + "winnerIds" JSONB NOT NULL, + "reserveWinnerIds" JSONB NOT NULL, + "eligibleCount" INTEGER NOT NULL, + "drawId" TEXT NOT NULL, + "drawnAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_vkUserId_key" ON "User"("vkUserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserCredential_userId_key" ON "UserCredential"("userId"); + +-- CreateIndex +CREATE INDEX "Giveaway_platform_platformOwnerId_platformPostId_idx" ON "Giveaway"("platform", "platformOwnerId", "platformPostId"); + +-- CreateIndex +CREATE INDEX "Giveaway_organizerId_idx" ON "Giveaway"("organizerId"); + +-- CreateIndex +CREATE INDEX "Participant_giveawayId_eligible_idx" ON "Participant"("giveawayId", "eligible"); + +-- CreateIndex +CREATE UNIQUE INDEX "Participant_giveawayId_platformUserId_key" ON "Participant"("giveawayId", "platformUserId"); + +-- CreateIndex +CREATE INDEX "ParticipantSnapshot_giveawayId_idx" ON "ParticipantSnapshot"("giveawayId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ParticipantSnapshot_giveawayId_version_key" ON "ParticipantSnapshot"("giveawayId", "version"); + +-- CreateIndex +CREATE UNIQUE INDEX "DrawResult_giveawayId_key" ON "DrawResult"("giveawayId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DrawResult_snapshotId_key" ON "DrawResult"("snapshotId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuditRecord_giveawayId_key" ON "AuditRecord"("giveawayId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuditRecord_snapshotId_key" ON "AuditRecord"("snapshotId"); + +-- AddForeignKey +ALTER TABLE "UserCredential" ADD CONSTRAINT "UserCredential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Giveaway" ADD CONSTRAINT "Giveaway_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Participant" ADD CONSTRAINT "Participant_giveawayId_fkey" FOREIGN KEY ("giveawayId") REFERENCES "Giveaway"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ParticipantSnapshot" ADD CONSTRAINT "ParticipantSnapshot_giveawayId_fkey" FOREIGN KEY ("giveawayId") REFERENCES "Giveaway"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DrawResult" ADD CONSTRAINT "DrawResult_giveawayId_fkey" FOREIGN KEY ("giveawayId") REFERENCES "Giveaway"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DrawResult" ADD CONSTRAINT "DrawResult_snapshotId_fkey" FOREIGN KEY ("snapshotId") REFERENCES "ParticipantSnapshot"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditRecord" ADD CONSTRAINT "AuditRecord_giveawayId_fkey" FOREIGN KEY ("giveawayId") REFERENCES "Giveaway"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditRecord" ADD CONSTRAINT "AuditRecord_snapshotId_fkey" FOREIGN KEY ("snapshotId") REFERENCES "ParticipantSnapshot"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/src/app/giveaways/new/page.tsx b/src/app/giveaways/new/page.tsx index fb0f1db..3ea0287 100644 --- a/src/app/giveaways/new/page.tsx +++ b/src/app/giveaways/new/page.tsx @@ -597,7 +597,7 @@ export default function NewGiveawayWizardPage() {

- Не поддерживается VK API для закрытых профилей сторонними приложениями + Проверка не подключена. Репосты закрытых профилей нельзя подтвердить, поэтому это условие недоступно.

@@ -619,7 +619,7 @@ export default function NewGiveawayWizardPage() {

- Требует авторизации организатора через VK ID для доступа к списку контактов + Список руководителей сообщества не загружается. Администраторы не исключаются автоматически.

diff --git a/src/core/errors/http-errors.ts b/src/core/errors/http-errors.ts index 8bd9d13..5447743 100644 --- a/src/core/errors/http-errors.ts +++ b/src/core/errors/http-errors.ts @@ -209,6 +209,19 @@ export function handleApiError(error: unknown): NextResponse { { status: 504 } ); + case 'INCOMPLETE_DATA': + case 'PAGINATION_LIMIT_REACHED': + return NextResponse.json( + { + success: false, + error: { + code: 'VK_INCOMPLETE_DATA', + message: error.message, + }, + }, + { status: 502 } + ); + default: return NextResponse.json( { diff --git a/src/core/randomizer/hasher.ts b/src/core/randomizer/hasher.ts index 2acb1cd..255ce49 100644 --- a/src/core/randomizer/hasher.ts +++ b/src/core/randomizer/hasher.ts @@ -15,11 +15,11 @@ export { }; /** - * Generates a cryptographically secure random seed (128-bit / 32 hex chars) using CSPRNG. + * Generates a cryptographically secure random seed (256-bit / 64 hex chars) using CSPRNG. * Math.random() is strictly forbidden in security-sensitive giveaway workflows. */ export function generateCryptoSecureSeed(): string { - return randomBytes(16).toString('hex'); + return randomBytes(32).toString('hex'); } /** diff --git a/src/core/validation/giveaway-schemas.ts b/src/core/validation/giveaway-schemas.ts index 2816da0..f2993fb 100644 --- a/src/core/validation/giveaway-schemas.ts +++ b/src/core/validation/giveaway-schemas.ts @@ -74,7 +74,7 @@ export function validateProviderCapabilities( ): void { if (rules.requireRepost && !capabilities.reposts) { throw new ValidationError( - 'Repost verification is not supported due to VK API privacy limitations', + `Repost verification is unavailable: ${capabilities.repostsNote || 'the requirement cannot be evaluated safely.'}`, { condition: 'requireRepost' } ); } @@ -88,7 +88,7 @@ export function validateProviderCapabilities( if (rules.excludeAdmins && !capabilities.adminDetection) { throw new ValidationError( - 'Admin detection requires VK ID organizer authorization', + `Admin detection is unavailable: ${capabilities.adminDetectionNote || 'community managers cannot be excluded safely.'}`, { condition: 'excludeAdmins' } ); } diff --git a/src/integrations/vk/vk-client.ts b/src/integrations/vk/vk-client.ts index 93df967..63aac5c 100644 --- a/src/integrations/vk/vk-client.ts +++ b/src/integrations/vk/vk-client.ts @@ -11,7 +11,8 @@ import { VkCancelledError, VkNetworkError, VkValidationError, - VkPaginationLimitError + VkPaginationLimitError, + VkIncompleteDataError } from './vk-errors'; import { validateAuthContext } from './vk-auth'; import { IVkRateLimiter, defaultVkRateLimiter } from './vk-rate-limit'; @@ -219,7 +220,6 @@ export async function fetchPaginatedVk( ): Promise { const pageSize = options.pageSize ?? 100; const maxPages = options.maxPages ?? 10000; - const throwOnTruncation = options.throwOnTruncation ?? true; const allItems: TItem[] = []; let offset = 0; let page = 0; @@ -232,10 +232,38 @@ export async function fetchPaginatedVk( const { items, totalCount } = await options.fetchPage(offset, pageSize, options.signal); if (totalCount !== undefined) { + if (!Number.isSafeInteger(totalCount) || totalCount < 0) { + throw new VkIncompleteDataError('VK returned an invalid pagination total.', { + details: { loaded: allItems.length, page }, + }); + } + if (recordedTotalCount !== undefined && totalCount !== recordedTotalCount) { + throw new VkIncompleteDataError( + `VK pagination total changed during import (${recordedTotalCount} to ${totalCount}); no complete result can be confirmed.`, + { details: { loaded: allItems.length, previousTotal: recordedTotalCount, total: totalCount, page } } + ); + } recordedTotalCount = totalCount; } + if (!Array.isArray(items) || items.length > pageSize) { + throw new VkIncompleteDataError('VK returned an invalid page size; no complete result can be confirmed.', { + details: { loaded: allItems.length, received: Array.isArray(items) ? items.length : null, pageSize, page }, + }); + } + if (recordedTotalCount !== undefined && allItems.length + items.length > recordedTotalCount) { + throw new VkIncompleteDataError('VK returned more items than its reported pagination total.', { + details: { loaded: allItems.length, received: items.length, total: recordedTotalCount, page }, + }); + } + if (!items || items.length === 0) { + if (recordedTotalCount === undefined || allItems.length < recordedTotalCount) { + throw new VkIncompleteDataError( + `VK pagination ended at ${allItems.length} items before a complete result could be confirmed.`, + { details: { loaded: allItems.length, total: recordedTotalCount ?? null, page } } + ); + } break; } @@ -252,22 +280,22 @@ export async function fetchPaginatedVk( } if (items.length < pageSize) { + if (recordedTotalCount === undefined || allItems.length < recordedTotalCount) { + throw new VkIncompleteDataError( + `VK returned a short page at ${allItems.length} items before its reported total was reached.`, + { details: { loaded: allItems.length, total: recordedTotalCount ?? null, page } } + ); + } break; } } // Safety check against silent truncation - if ( - page >= maxPages && - recordedTotalCount !== undefined && - allItems.length < recordedTotalCount - ) { - if (throwOnTruncation) { - throw new VkPaginationLimitError( - `VK pagination reached maxPages safety ceiling (${maxPages} pages) with only ${allItems.length}/${recordedTotalCount} items loaded. Truncation detected.`, - { details: { loaded: allItems.length, total: recordedTotalCount, maxPages } } - ); - } + if (page >= maxPages && (recordedTotalCount === undefined || allItems.length < recordedTotalCount)) { + throw new VkPaginationLimitError( + `VK pagination reached maxPages safety ceiling (${maxPages}) before a complete result was confirmed.`, + { details: { loaded: allItems.length, total: recordedTotalCount ?? null, maxPages } } + ); } return allItems; diff --git a/src/integrations/vk/vk-errors.ts b/src/integrations/vk/vk-errors.ts index b430f89..499f36c 100644 --- a/src/integrations/vk/vk-errors.ts +++ b/src/integrations/vk/vk-errors.ts @@ -117,6 +117,12 @@ export class VkPaginationLimitError extends VkClientError { readonly category = 'PAGINATION_LIMIT_REACHED'; } +/** VK stopped returning pages before its reported total was collected. */ +export class VkIncompleteDataError extends VkClientError { + readonly isRetryable = false; + readonly category = 'INCOMPLETE_DATA'; +} + // Alias for backwards compatibility export const VkPaginationTruncatedError = VkPaginationLimitError; diff --git a/src/lib/auth/app-config.ts b/src/lib/auth/app-config.ts index 1a9a5de..8443fb7 100644 --- a/src/lib/auth/app-config.ts +++ b/src/lib/auth/app-config.ts @@ -1,55 +1,60 @@ -/** - * Application environment configuration and trusted origin resolver. - * Enforces strict fail-fast validation in production for OAuth base URLs. - */ +/** Canonical origins are server configuration; request headers never choose them. */ +function parseConfiguredUrl(value: string, name: string): URL { + let url: URL; + try { url = new URL(value); } catch { throw new Error(`FATAL CONFIGURATION ERROR: ${name} must be a valid URL.`); } + if (url.username || url.password || url.search || url.hash) { + throw new Error(`FATAL CONFIGURATION ERROR: ${name} cannot contain credentials, query or fragment.`); + } + return url; +} + +export function isLocalSingleUserProfile(): boolean { + if (process.env.DEPLOYMENT_PROFILE !== 'local-single-user') return false; + const value = process.env.APP_BASE_URL?.trim(); + if (!value) throw new Error('FATAL CONFIGURATION ERROR: APP_BASE_URL is required for local-single-user.'); + const url = parseConfiguredUrl(value, 'APP_BASE_URL'); + if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.protocol !== 'http:' || url.pathname !== '/') { + throw new Error('FATAL CONFIGURATION ERROR: local-single-user requires an HTTP loopback origin.'); + } + return true; +} export function getAppBaseUrl(): string { const envUrl = process.env.APP_BASE_URL?.trim(); - if (process.env.NODE_ENV === 'production') { - if (!envUrl) { - throw new Error( - 'FATAL CONFIGURATION ERROR: APP_BASE_URL environment variable is strictly required in production.' - ); + if (!envUrl) throw new Error('FATAL CONFIGURATION ERROR: APP_BASE_URL environment variable is strictly required in production.'); + const url = parseConfiguredUrl(envUrl, 'APP_BASE_URL'); + if (url.pathname !== '/' || (url.protocol !== 'https:' && !isLocalSingleUserProfile())) { + throw new Error('FATAL CONFIGURATION ERROR: APP_BASE_URL must be a valid HTTPS URL in production.'); } - if (!envUrl.startsWith('https://')) { - throw new Error( - 'FATAL CONFIGURATION ERROR: APP_BASE_URL must be a valid HTTPS URL in production.' - ); - } - return envUrl.replace(/\/+$/, ''); + // Validate explicitly selected local profile even when URL happens to use HTTPS. + if (process.env.DEPLOYMENT_PROFILE === 'local-single-user') isLocalSingleUserProfile(); + return url.origin; } - - // Development/Test environment fallback return (envUrl || 'http://localhost:3000').replace(/\/+$/, ''); } export function getVkRedirectUri(): string { const envUri = process.env.VK_REDIRECT_URI?.trim(); - if (process.env.NODE_ENV === 'production') { - if (!envUri) { - throw new Error( - 'FATAL CONFIGURATION ERROR: VK_REDIRECT_URI environment variable is strictly required in production.' - ); + if (!envUri) throw new Error('FATAL CONFIGURATION ERROR: VK_REDIRECT_URI environment variable is strictly required in production.'); + const url = parseConfiguredUrl(envUri, 'VK_REDIRECT_URI'); + const base = getAppBaseUrl(); + if (url.origin !== base || url.pathname !== '/api/auth/vk/callback') { + throw new Error('FATAL CONFIGURATION ERROR: VK_REDIRECT_URI must match the canonical origin and callback path.'); } - if (!envUri.startsWith('https://')) { - throw new Error( - 'FATAL CONFIGURATION ERROR: VK_REDIRECT_URI must be a valid HTTPS URL in production.' - ); - } - return envUri; + return url.href; } - - // Development/Test environment fallback return envUri || `${getAppBaseUrl()}/api/auth/vk/callback`; } -export function getTrustedHost(): string { - try { - const url = new URL(getAppBaseUrl()); - return url.host.toLowerCase(); - } catch { - return 'localhost:3000'; - } +export function getSecureSessionCookieFlag(): boolean { + if (process.env.NODE_ENV !== 'production') return false; + if (process.env.DEPLOYMENT_PROFILE === 'local-single-user') return !isLocalSingleUserProfile(); + return true; +} + +export function getTrustedHost(): string { + // Production configuration failures must never fall back to a different trust boundary. + return new URL(getAppBaseUrl()).host.toLowerCase(); } diff --git a/src/lib/auth/csrf-guard.ts b/src/lib/auth/csrf-guard.ts index e084482..dcabe6b 100644 --- a/src/lib/auth/csrf-guard.ts +++ b/src/lib/auth/csrf-guard.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; import { ForbiddenError } from '@/core/errors/http-errors'; -import { getTrustedHost } from './app-config'; +import { getAppBaseUrl, getTrustedHost } from './app-config'; /** * Validates Origin and Referer headers for cookie-authenticated mutating requests (POST/PUT/DELETE/PATCH) @@ -30,7 +30,8 @@ export function validateCsrfOrigin(req: NextRequest): void { if (origin) { try { const originUrl = new URL(origin); - if (originUrl.host.toLowerCase() !== trustedHost.toLowerCase()) { + if (originUrl.host.toLowerCase() !== trustedHost.toLowerCase() || + (process.env.NODE_ENV === 'production' && originUrl.origin !== getAppBaseUrl())) { throw new ForbiddenError(`CSRF origin mismatch: origin "${originUrl.host}" does not match trusted host "${trustedHost}"`); } } catch (e: any) { @@ -43,7 +44,8 @@ export function validateCsrfOrigin(req: NextRequest): void { if (referer) { try { const refererUrl = new URL(referer); - if (refererUrl.host.toLowerCase() !== trustedHost.toLowerCase()) { + if (refererUrl.host.toLowerCase() !== trustedHost.toLowerCase() || + (process.env.NODE_ENV === 'production' && refererUrl.origin !== getAppBaseUrl())) { throw new ForbiddenError(`CSRF referer mismatch: referer "${refererUrl.host}" does not match trusted host "${trustedHost}"`); } } catch (e: any) { diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 00a0663..fc02770 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { randomBytes } from 'crypto'; import { prisma } from '@/lib/prisma'; +import { getSecureSessionCookieFlag } from './app-config'; export const SESSION_COOKIE_NAME = 'randomayzer_session'; export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days @@ -269,12 +270,11 @@ export async function getSessionFromRequest( * Sets session cookie on a NextResponse */ export function setSessionCookie(res: NextResponse, sessionId: string): void { - const isProd = process.env.NODE_ENV === 'production'; res.cookies.set({ name: SESSION_COOKIE_NAME, value: sessionId, httpOnly: true, - secure: isProd, + secure: getSecureSessionCookieFlag(), sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE_SECONDS, @@ -289,7 +289,7 @@ export function clearSessionCookie(res: NextResponse): void { name: SESSION_COOKIE_NAME, value: '', httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: getSecureSessionCookieFlag(), sameSite: 'lax', path: '/', maxAge: 0, diff --git a/src/lib/repository/prisma-repository.ts b/src/lib/repository/prisma-repository.ts index 9f28bd5..6148996 100644 --- a/src/lib/repository/prisma-repository.ts +++ b/src/lib/repository/prisma-repository.ts @@ -536,6 +536,9 @@ export class PrismaGiveawayRepository implements IGiveawayRepository { const current = await this.getGiveawayById(id); if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`); + if (current.status === 'DRAWN' || current.status === 'PUBLISHED') { + throw new ConflictError('Giveaway has already been drawn. Duplicate draw is strictly forbidden.'); + } GiveawayFSM.assertCanDraw(current.status); try { diff --git a/src/providers/vk/vk-capabilities.ts b/src/providers/vk/vk-capabilities.ts index 73a1405..49296ce 100644 --- a/src/providers/vk/vk-capabilities.ts +++ b/src/providers/vk/vk-capabilities.ts @@ -13,10 +13,10 @@ export const STATIC_VK_CAPABILITIES: ProviderCapabilities = { likes: true, comments: true, reposts: false, - repostsNote: 'Сбор репостов ограничен политикой приватности VK для закрытых профилей', + repostsNote: 'Проверка репостов не подключена; данные о репостах закрытых профилей нельзя подтвердить.', subscriptions: true, adminDetection: false, - adminDetectionNote: 'Требует расширенных прав администратора сообщества', + adminDetectionNote: 'Список руководителей сообщества не загружается; администраторы не исключаются из розыгрыша.', }; /** @@ -39,7 +39,7 @@ export function resolveEffectiveCapabilities( adminDetection: isCommunityAdmin, adminDetectionNote: isCommunityAdmin ? undefined - : 'Требует прямого подключения токена сообщества с правами администратора', + : 'Список руководителей сообщества не загружается без отдельного доступа.', accessMode, ...(credentialStatus ? { credentialStatus } : {}), }; diff --git a/src/providers/vk/vk-provider.ts b/src/providers/vk/vk-provider.ts index 246f78f..b1bf383 100644 --- a/src/providers/vk/vk-provider.ts +++ b/src/providers/vk/vk-provider.ts @@ -17,7 +17,8 @@ import { VkNotFoundError, VkAuthError, VkPrivateResourceError, - VkPermissionError + VkPermissionError, + VkIncompleteDataError } from '@/integrations/vk/vk-errors'; import { STATIC_VK_CAPABILITIES } from './vk-capabilities'; import { VkAuthContextResolver, defaultVkAuthContextResolver } from '@/integrations/vk/vk-auth-resolver'; @@ -381,8 +382,31 @@ export class VkProvider implements SocialMediaProvider { activeAuth ); - for (const item of res || []) { - resultMap.set(String(item.user_id), item.member === 1); + if (!Array.isArray(res)) { + throw new VkIncompleteDataError( + 'VK subscription check returned an invalid response shape. No result was saved.', + { method: 'groups.isMember', details: { requested: chunk.length } } + ); + } + const chunkStatuses = new Set(); + for (const item of res) { + const userId = String(item?.user_id ?? ''); + if (!/^\d+$/.test(userId) || !chunk.includes(userId) || chunkStatuses.has(userId) || (item.member !== 0 && item.member !== 1)) { + throw new VkIncompleteDataError( + 'VK subscription check returned an invalid or unexpected user status. No result was saved.', + { method: 'groups.isMember', details: { requested: chunk.length, received: chunkStatuses.size } } + ); + } + chunkStatuses.add(userId); + resultMap.set(userId, item.member === 1); + } + + const missingStatuses = chunk.filter(userId => !chunkStatuses.has(String(userId))); + if (missingStatuses.length > 0) { + throw new VkIncompleteDataError( + `VK subscription check returned status for ${chunk.length - missingStatuses.length}/${chunk.length} users. No result was saved.`, + { method: 'groups.isMember', details: { requested: chunk.length, received: chunk.length - missingStatuses.length } } + ); } } diff --git a/tests/deployment-profile.test.ts b/tests/deployment-profile.test.ts new file mode 100644 index 0000000..0085ecd --- /dev/null +++ b/tests/deployment-profile.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest, NextResponse } from 'next/server'; +import { getAppBaseUrl, getVkRedirectUri } from '../src/lib/auth/app-config'; +import { setSessionCookie, clearSessionCookie } from '../src/lib/auth/session'; +import { validateCsrfOrigin } from '../src/lib/auth/csrf-guard'; + +afterEach(() => vi.unstubAllEnvs()); +function config(base: string, profile = 'local-single-user') { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('DEPLOYMENT_PROFILE', profile); + vi.stubEnv('APP_BASE_URL', base); + vi.stubEnv('VK_REDIRECT_URI', `${base}/api/auth/vk/callback`); +} +describe('production deployment profiles', () => { + it('allows HTTP only in the explicit loopback profile and uses HttpOnly Lax cookies', () => { + config('http://localhost:3100'); + expect(getAppBaseUrl()).toBe('http://localhost:3100'); + expect(getVkRedirectUri()).toBe('http://localhost:3100/api/auth/vk/callback'); + const res = NextResponse.json({}); + setSessionCookie(res, 'fake-session'); + expect(res.headers.get('set-cookie')).toContain('HttpOnly'); + expect(res.headers.get('set-cookie')).toContain('SameSite=lax'); + expect(res.headers.get('set-cookie')).not.toContain('Secure'); + clearSessionCookie(res); + expect(res.headers.get('set-cookie')).not.toContain('Secure'); + }); + it.each(['http://evil.test', 'http://localhost.evil.test', 'http://192.168.1.81:3100', 'https://localhost:3100', 'http://localhost:3100/path'])('rejects invalid local origin %s', (base) => { + config(base); + expect(() => getAppBaseUrl()).toThrow(); + }); + it('keeps strict HTTPS and Secure cookies outside the local profile', () => { + config('http://localhost:3100', 'strict'); + expect(() => getAppBaseUrl()).toThrow(); + config('https://random.ochenstarik.ru', 'strict'); + expect(getAppBaseUrl()).toBe('https://random.ochenstarik.ru'); + const res = NextResponse.json({}); + setSessionCookie(res, 'fake-session'); + expect(res.headers.get('set-cookie')).toContain('Secure'); + }); + it('rejects redirect origin and path mismatch', () => { + config('http://localhost:3100'); + vi.stubEnv('VK_REDIRECT_URI', 'http://localhost:3101/api/auth/vk/callback'); + expect(() => getVkRedirectUri()).toThrow(); + vi.stubEnv('VK_REDIRECT_URI', 'http://localhost:3100/other'); + expect(() => getVkRedirectUri()).toThrow(); + }); + it('ignores forged Host/forwarded headers and rejects scheme downgrade for CSRF', () => { + config('https://random.ochenstarik.ru', 'strict'); + const request = (origin: string) => new NextRequest('https://random.ochenstarik.ru/api/giveaways', { + method: 'POST', headers: { origin, host: 'evil.test', 'x-forwarded-host': 'evil.test' }, + }); + expect(() => validateCsrfOrigin(request('https://evil.test'))).toThrow(); + expect(() => validateCsrfOrigin(request('http://random.ochenstarik.ru'))).toThrow(); + expect(() => validateCsrfOrigin(request('https://random.ochenstarik.ru'))).not.toThrow(); + }); +}); diff --git a/tests/production-transport.test.ts b/tests/production-transport.test.ts new file mode 100644 index 0000000..b6dd0e1 --- /dev/null +++ b/tests/production-transport.test.ts @@ -0,0 +1,54 @@ +import { createRequire } from 'node:module'; +import { createServer, request } from 'node:http'; +import { describe, expect, it } from 'vitest'; +const require = createRequire(import.meta.url); +const { sanitizeProxyHeaders } = require('../tools/production-transport.cjs'); +const origin = new URL('https://random.ochenstarik.ru'); +function fake(peer: string) { + return { socket: { remoteAddress: peer }, headers: { + host: 'evil.test', 'x-forwarded-for': '203.0.113.8', 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'evil.test', 'x-real-ip': '203.0.113.9', forwarded: 'for=evil', + 'cf-connecting-ip': '203.0.113.10', 'x-forwarded-unknown': 'evil', + } as Record }; +} +describe('production socket trust boundary', () => { + it.each(['192.168.1.2', '10.77.0.2', '127.0.0.1', '::ffff:192.168.1.2'])('ignores all forwarded headers from foreign peer %s', (peer) => { + const req = fake(peer); + sanitizeProxyHeaders(req, { trustedProxyIp: '10.77.0.1', origin }); + expect(req.headers['x-forwarded-for']).toBe(peer.replace('::ffff:', '')); + expect(req.headers['x-forwarded-proto']).toBe('http'); + expect(req.headers.host).toBe(origin.host); + expect(req.headers.forwarded).toBeUndefined(); + expect(req.headers['x-real-ip']).toBeUndefined(); + expect(req.headers['cf-connecting-ip']).toBeUndefined(); + expect(req.headers['x-forwarded-unknown']).toBeUndefined(); + }); + it('accepts one overwritten client IP only from the VPS socket peer', () => { + const req = fake('::ffff:10.77.0.1'); + sanitizeProxyHeaders(req, { trustedProxyIp: '10.77.0.1', origin }); + expect(req.headers['x-forwarded-for']).toBe('203.0.113.8'); + expect(req.headers['x-forwarded-proto']).toBe('https'); + req.headers['x-forwarded-for'] = '203.0.113.8, 192.0.2.3'; + sanitizeProxyHeaders(req, { trustedProxyIp: '10.77.0.1', origin }); + expect(req.headers['x-forwarded-for']).toBe('10.77.0.1'); + }); + it('sanitizes a real loopback HTTP connection before the downstream handler', async () => { + const server = createServer((req, res) => { + sanitizeProxyHeaders(req, { trustedProxyIp: '10.77.0.1', origin }); + res.end(JSON.stringify(req.headers)); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address() as { port: number }; + const body = await new Promise((resolve, reject) => { + const req = request({ host: '127.0.0.1', port: address.port, headers: fake('ignored').headers }, res => { + let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => resolve(data)); + }); req.on('error', reject); req.end(); + }); + const headers = JSON.parse(body); + expect(headers['x-forwarded-for']).toBe('127.0.0.1'); + expect(headers['x-forwarded-proto']).toBe('http'); + expect(headers['x-forwarded-host']).toBe(origin.host); + } finally { await new Promise((resolve, reject) => server.close(err => err ? reject(err) : resolve())); } + }); +}); diff --git a/tests/randomizer.test.ts b/tests/randomizer.test.ts index 2afde63..8a530d7 100644 --- a/tests/randomizer.test.ts +++ b/tests/randomizer.test.ts @@ -41,10 +41,10 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => { const seed1 = generateCryptoSecureSeed(); const seed2 = generateCryptoSecureSeed(); - expect(seed1).toHaveLength(32); // 16 bytes in hex = 32 chars - expect(seed2).toHaveLength(32); + expect(seed1).toHaveLength(64); // 32 bytes in hex = 64 chars + expect(seed2).toHaveLength(64); expect(seed1).not.toBe(seed2); - expect(/^[0-9a-f]{32}$/.test(seed1)).toBe(true); + expect(/^[0-9a-f]{64}$/.test(seed1)).toBe(true); }); it('should guarantee deterministic replay given the same snapshot and seed', () => { diff --git a/tests/seed-precommit-gate.test.ts b/tests/seed-precommit-gate.test.ts index c96900d..faf0040 100644 --- a/tests/seed-precommit-gate.test.ts +++ b/tests/seed-precommit-gate.test.ts @@ -316,7 +316,7 @@ describe('Phase 2.4 — Seed Pre-Commit Gate (Seed Grinding Elimination)', () => const lockedGw = await repo.getGiveawayById(gw.id); expect(lockedGw?.status).toBe('SNAPSHOT_LOCKED'); expect(lockedGw?.seed).toBeDefined(); - expect(lockedGw?.seed).toHaveLength(32); + expect(lockedGw?.seed).toHaveLength(64); expect(lockedGw?.seedCommitment).toBe(computeSeedCommitment(lockedGw!.seed!)); expect(seedCommitment).toBe(lockedGw?.seedCommitment); }); diff --git a/tests/vk-r3-safety.test.ts b/tests/vk-r3-safety.test.ts new file mode 100644 index 0000000..610c5d0 --- /dev/null +++ b/tests/vk-r3-safety.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { fetchPaginatedVk } from '../src/integrations/vk/vk-client'; +import { VkIncompleteDataError } from '../src/integrations/vk/vk-errors'; +import { VkProvider } from '../src/providers/vk/vk-provider'; +import { STATIC_VK_CAPABILITIES } from '../src/providers/vk/vk-capabilities'; +import { IVkClient } from '../src/integrations/vk/vk-client'; +import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; +import { validateProviderCapabilities } from '../src/core/validation/giveaway-schemas'; +import { POST as importParticipants } from '../src/app/api/giveaways/[id]/participants/route'; +import { GiveawayStore } from '../src/lib/giveaway-store'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; +import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session'; +import { ProviderFactory } from '../src/providers/factory'; +import { generalApiRateLimiter, expensiveApiRateLimiter } from '../src/lib/rate-limiter'; + +describe('R3 VK incomplete and unsupported behavior', () => { + it('rejects an empty page before the reported total instead of returning a partial list', async () => { + await expect(fetchPaginatedVk({ + pageSize: 2, + fetchPage: vi.fn().mockResolvedValue({ items: [], totalCount: 2 }), + })).rejects.toBeInstanceOf(VkIncompleteDataError); + }); + + it('rejects a short page before the reported total instead of treating it as completion', async () => { + await expect(fetchPaginatedVk({ + pageSize: 3, + fetchPage: vi.fn().mockResolvedValue({ items: [1, 2], totalCount: 4 }), + })).rejects.toBeInstanceOf(VkIncompleteDataError); + }); + + it('rejects an empty result without a reported total because completeness is unknown', async () => { + await expect(fetchPaginatedVk({ + pageSize: 3, + fetchPage: vi.fn().mockResolvedValue({ items: [] }), + })).rejects.toBeInstanceOf(VkIncompleteDataError); + }); + + it('accepts a complete short final page when it reaches the reported total', async () => { + const result = await fetchPaginatedVk({ + pageSize: 3, + fetchPage: vi.fn() + .mockResolvedValueOnce({ items: [1, 2, 3], totalCount: 5 }) + .mockResolvedValueOnce({ items: [4, 5], totalCount: 5 }), + }); + expect(result).toEqual([1, 2, 3, 4, 5]); + }); + + it('rejects invalid totals, totals that change, and oversized pages', async () => { + await expect(fetchPaginatedVk({ + pageSize: 2, + fetchPage: vi.fn().mockResolvedValue({ items: [], totalCount: -1 }), + })).rejects.toBeInstanceOf(VkIncompleteDataError); + await expect(fetchPaginatedVk({ + pageSize: 2, + fetchPage: vi.fn() + .mockResolvedValueOnce({ items: [1, 2], totalCount: 4 }) + .mockResolvedValueOnce({ items: [3], totalCount: 5 }), + })).rejects.toBeInstanceOf(VkIncompleteDataError); + await expect(fetchPaginatedVk({ + pageSize: 2, + fetchPage: vi.fn().mockResolvedValue({ items: [1, 2, 3], totalCount: 3 }), + })).rejects.toBeInstanceOf(VkIncompleteDataError); + }); + + it('fails subscription enrichment when VK omits any requested user status', async () => { + const client = { + call: vi.fn().mockResolvedValue([{ user_id: 101, member: 1 }]), + } as unknown as IVkClient; + const provider = new VkProvider('fake-service-token-r3', client); + + await expect(provider.checkSubscription(['101', '202'], '42')) + .rejects.toBeInstanceOf(VkIncompleteDataError); + }); + + it('fails subscription enrichment for malformed or unexpected VK statuses', async () => { + for (const response of [ + { items: [{ user_id: 101, member: 1 }] }, + [{ user_id: 101, member: 2 }], + [{ user_id: 999, member: 1 }], + [{ user_id: 101, member: 1 }, { user_id: 101, member: 0 }], + ]) { + const client = { call: vi.fn().mockResolvedValue(response) } as unknown as IVkClient; + const provider = new VkProvider('fake-service-token-r3', client); + await expect(provider.checkSubscription(['101'], '42')).rejects.toBeInstanceOf(VkIncompleteDataError); + } + }); + + it('keeps unsupported repost and manager checks explicit', () => { + expect(STATIC_VK_CAPABILITIES.reposts).toBe(false); + expect(STATIC_VK_CAPABILITIES.repostsNote).toMatch(/не подключена/i); + expect(STATIC_VK_CAPABILITIES.adminDetection).toBe(false); + expect(STATIC_VK_CAPABILITIES.adminDetectionNote).toMatch(/не загружается/i); + }); + + it('rejects repost and manager rules with clear capability reasons', () => { + expect(() => validateProviderCapabilities( + { ...DEFAULT_FILTER_RULES, requireRepost: true }, + STATIC_VK_CAPABILITIES + )).toThrow(/репост/i); + expect(() => validateProviderCapabilities( + { ...DEFAULT_FILTER_RULES, excludeAdmins: true }, + STATIC_VK_CAPABILITIES + )).toThrow(/руководителей/i); + }); + + it('uses ordinary page requests and never attempts execute batching', async () => { + const calls: string[] = []; + const client = { + call: vi.fn(async (method: string) => { + calls.push(method); + return { count: 0, items: [] }; + }), + } as unknown as IVkClient; + const provider = new VkProvider('fake-service-token-r3', client); + + await provider.fetchParticipants({ ownerId: '-42', postId: '7', includeLikes: true, includeComments: true }); + + expect(calls).toEqual(['likes.getList', 'wall.getComments']); + expect(calls).not.toContain('execute'); + }); +}); + +describe('R3 import route persistence on incomplete VK data', () => { + const user = { id: 'usr_r3_safety', vkUserId: '101' }; + let sessionId = ''; + + beforeEach(async () => { + GiveawayStore.setRepository(new MemoryGiveawayRepository()); + defaultSessionStore.clear(); + generalApiRateLimiter.reset(); + expensiveApiRateLimiter.reset(); + sessionId = await defaultSessionStore.createSession(user); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('returns an explicit error and preserves the previous participant list when membership status is incomplete', async () => { + const giveaway = await GiveawayStore.create({ + sourceUrl: 'https://vk.com/wall-42_7', + post: { + platform: 'VK', ownerId: '-42', postId: '7', sourceUrl: 'https://vk.com/wall-42_7', + title: 'R3 test', text: '', likesCount: 1, commentsCount: 0, repostsCount: 0, + }, + filterRules: { ...DEFAULT_FILTER_RULES }, + winnersCount: 1, + reserveWinnersCount: 0, + organizerId: user.id, + }); + const previousParticipants = [{ + platformUserId: 'old-participant', firstName: 'Existing', lastName: 'Entry', + source: 'LIKES' as const, liked: true, commented: false, commentsCount: 0, + reposted: false, subscribed: false, eligible: true, exclusionReason: null, + }]; + await GiveawayStore.updateParticipants(giveaway.id, previousParticipants); + + const client = { + call: vi.fn(async (method: string) => { + if (method === 'likes.getList') { + return { count: 2, items: [ + { id: 101, first_name: 'One', last_name: 'User' }, + { id: 202, first_name: 'Two', last_name: 'User' }, + ] }; + } + if (method === 'wall.getComments') return { count: 0, items: [] }; + if (method === 'groups.isMember') return [{ user_id: 101, member: 1 }]; + throw new Error(`Unexpected VK method: ${method}`); + }), + } as unknown as IVkClient; + vi.spyOn(ProviderFactory, 'getVkProvider').mockReturnValue( + new VkProvider('fake-service-token-r3-route', client) + ); + + const req = new NextRequest(`http://localhost/api/giveaways/${giveaway.id}/participants`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `${SESSION_COOKIE_NAME}=${sessionId}`, + }, + body: JSON.stringify({ + filterRules: { + ...DEFAULT_FILTER_RULES, + requireLike: true, + requireComment: true, + requireSubscription: true, + targetGroupId: '42', + }, + }), + }); + const response = await importParticipants(req, { params: { id: giveaway.id } }); + const body = await response.json(); + + expect(response.status).toBe(502); + expect(body.error.code).toBe('VK_INCOMPLETE_DATA'); + expect(body.error.message).toMatch(/subscription check returned status/i); + const stored = await GiveawayStore.getParticipantsPaginated(giveaway.id, 1, 50); + expect(stored.participants).toEqual(previousParticipants); + }); +}); diff --git a/tools/db-backup-daily.sh b/tools/db-backup-daily.sh new file mode 100755 index 0000000..3fd5ded --- /dev/null +++ b/tools/db-backup-daily.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 +[[ $# == 1 && -d $1 ]] || { echo 'Usage: db-backup-daily.sh /absolute/backup-directory' >&2; exit 1; } +root=$(cd "$(dirname "$0")/.." && pwd) +# Serialize daily runs to bound space usage. On failure retain dump for diagnosis. +exec 9>"$1/.randomayzer-backup.lock" +flock -n 9 || exit 0 +backup=$("$root/tools/db-backup.sh" "$1") +"$root/tools/db-restore-check.sh" "$backup" +# Retain fourteen daily backups; only this application's named dumps are eligible. +find "$1" -maxdepth 1 -type f -name 'randomayzer-*.dump' -mtime +14 -delete +echo 'PASS daily backup and restore verification' diff --git a/tools/db-backup.sh b/tools/db-backup.sh new file mode 100755 index 0000000..5c229ce --- /dev/null +++ b/tools/db-backup.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 +# Environment file is read by Docker Compose, never sourced as executable shell code. +: "${RANDOMAYZER_ENV_FILE:?Set absolute 0600 env file path}" +[[ $(stat -c '%a' "$RANDOMAYZER_ENV_FILE") == 600 ]] || { echo 'Environment file must have mode 0600' >&2; exit 1; } +[[ $# == 1 && -d $1 ]] || { echo 'Usage: tools/db-backup.sh /absolute/backup-directory' >&2; exit 1; } +root=$(cd "$(dirname "$0")/.." && pwd) +backup_dir=$(realpath "$1") +file="$backup_dir/randomayzer-$(date -u +%Y%m%dT%H%M%S)-$$.dump" +args=(--env-file "$RANDOMAYZER_ENV_FILE" -f "$root/compose.production.yml") +export RANDOMAYZER_ENV_FILE +trap 'rm -f "$file.partial"' EXIT +docker compose "${args[@]}" exec -T postgres pg_dump -U randomayzer -d randomayzer -Fc > "$file.partial" +# Validate archive readability; full restore drill is a separate mandatory daily step. +docker compose "${args[@]}" exec -T postgres pg_restore --list < "$file.partial" > /dev/null +mv "$file.partial" "$file" +echo "$file" diff --git a/tools/db-restore-check.sh b/tools/db-restore-check.sh new file mode 100755 index 0000000..c1198a6 --- /dev/null +++ b/tools/db-restore-check.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${RANDOMAYZER_ENV_FILE:?Set absolute 0600 env file path}" +[[ $(stat -c '%a' "$RANDOMAYZER_ENV_FILE") == 600 ]] || { echo 'Environment file must have mode 0600' >&2; exit 1; } +[[ $# == 1 && -f $1 ]] || { echo 'Usage: tools/db-restore-check.sh backup.dump' >&2; exit 1; } +root=$(cd "$(dirname "$0")/.." && pwd) +args=(--env-file "$RANDOMAYZER_ENV_FILE" -f "$root/compose.production.yml") +export RANDOMAYZER_ENV_FILE +# A fresh temporary DB, never the production database. Avoid concurrent drill collisions. +drill="randomayzer_restore_${$}" +cleanup() { docker compose "${args[@]}" exec -T postgres dropdb -U randomayzer --if-exists "$drill" > /dev/null; } +trap cleanup EXIT +docker compose "${args[@]}" exec -T postgres createdb -U randomayzer "$drill" +docker compose "${args[@]}" exec -T postgres pg_restore -U randomayzer -d "$drill" --exit-on-error --no-owner --no-privileges < "$1" +docker compose "${args[@]}" exec -T postgres psql -U randomayzer -d "$drill" -v ON_ERROR_STOP=1 -c 'SELECT COUNT(*) FROM "_prisma_migrations";' -c 'SELECT COUNT(*) FROM "Giveaway";' +echo 'PASS backup restored to clean drill database' diff --git a/tools/production-transport.cjs b/tools/production-transport.cjs new file mode 100644 index 0000000..8e0c45a --- /dev/null +++ b/tools/production-transport.cjs @@ -0,0 +1,21 @@ +'use strict'; +const { isIP } = require('node:net'); +function normalizePeer(ip) { return (ip || '').replace(/^::ffff:/, ''); } +/** Run before handing the raw HTTP request to Next. Never use a header as peer identity. */ +function sanitizeProxyHeaders(req, { trustedProxyIp, origin }) { + const peer = normalizePeer(req.socket.remoteAddress); + const trusted = !!trustedProxyIp && peer === trustedProxyIp; + const forwarded = req.headers['x-forwarded-for']; + const proto = req.headers['x-forwarded-proto']; + for (const key of Object.keys(req.headers)) { + if (key.startsWith('x-forwarded-') || ['forwarded', 'x-real-ip', 'cf-connecting-ip'].includes(key)) delete req.headers[key]; + } + // Our VPS overwrites this with one client address; chains are deliberately unsupported. + const client = trusted && typeof forwarded === 'string' && isIP(forwarded) ? forwarded : peer; + req.headers['x-forwarded-for'] = isIP(client) ? client : 'unknown-client'; + req.headers['x-forwarded-proto'] = trusted && proto === 'https' ? 'https' : 'http'; + req.headers['x-forwarded-host'] = origin.host; + req.headers['x-forwarded-port'] = origin.port || (origin.protocol === 'https:' ? '443' : '80'); + req.headers.host = origin.host; +} +module.exports = { sanitizeProxyHeaders }; diff --git a/tools/serve-production.cjs b/tools/serve-production.cjs new file mode 100644 index 0000000..85cfb62 --- /dev/null +++ b/tools/serve-production.cjs @@ -0,0 +1,29 @@ +'use strict'; +const http = require('node:http'); +const next = require('next'); +const { sanitizeProxyHeaders } = require('./production-transport.cjs'); +async function main() { + if (process.env.NODE_ENV !== 'production') throw new Error('Production server requires NODE_ENV=production'); + const origin = new URL(process.env.APP_BASE_URL); + const local = process.env.DEPLOYMENT_PROFILE === 'local-single-user'; + if (origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new Error('Invalid canonical origin'); + if (local ? !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)) : origin.protocol !== 'https:') throw new Error('Invalid production origin/profile'); + if (process.env.VK_REDIRECT_URI !== `${origin.origin}/api/auth/vk/callback`) throw new Error('Invalid VK redirect URI'); + const trustedProxyIp = process.env.TRUSTED_PROXY_IP || ''; + if (trustedProxyIp && (local || trustedProxyIp !== '10.77.0.1')) throw new Error('Only the configured VPS peer 10.77.0.1 is supported'); + // TRUST_PROXY is safe only after this socket-aware ingress sanitizes the request. + process.env.TRUST_PROXY = 'true'; + const app = next({ dev: false }); + await app.prepare(); + const handle = app.getRequestHandler(); + const server = http.createServer({ maxHeaderSize: 16384 }, (req, res) => { + sanitizeProxyHeaders(req, { trustedProxyIp, origin }); + handle(req, res).catch(() => { if (!res.headersSent) res.writeHead(500); res.end('Request failed'); }); + }); + server.requestTimeout = 120000; + server.headersTimeout = 20000; + // Container binds internally; Compose publishes only the exact host loopback/WG addresses. + server.listen(Number(process.env.PORT || 3100), process.env.BIND_ADDRESS || '127.0.0.1', () => console.log('Randomayzer production listener ready')); + for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => server.close(() => process.exit(0))); +} +main().catch(() => { console.error('Production startup failed: check canonical origin, redirect, profile and required environment'); process.exitCode = 1; }); diff --git a/tools/vk-smoke.sh b/tools/vk-smoke.sh new file mode 100755 index 0000000..9995b78 --- /dev/null +++ b/tools/vk-smoke.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -u -o pipefail + +ENV_FILE="${1:-.vk-smoke.env}" +failures=0 +skips=0 +TMP_DIR="" + +result() { + local name="$1" status="$2" detail="${3:-}" + printf '%s %s' "$status" "$name" + [[ -z "$detail" ]] || printf ' — %s' "$detail" + printf '\n' + [[ "$status" != FAIL ]] || failures=$((failures + 1)) + [[ "$status" != SKIP && "$status" != MANUAL ]] || skips=$((skips + 1)) +} + +if [[ ! -f "$ENV_FILE" || -L "$ENV_FILE" ]]; then + printf 'FAIL config — expected a regular, non-symlink 0600 file: %s\n' "$ENV_FILE" + exit 2 +fi +mode="$(stat -c '%a' -- "$ENV_FILE" 2>/dev/null || true)" +if [[ "$mode" != 600 ]]; then + printf 'FAIL config — permissions must be 0600 (found %s)\n' "${mode:-unknown}" + exit 2 +fi + +declare -A config=() +while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue + [[ "$line" == *=* ]] || { printf 'FAIL config — malformed line\n'; exit 2; } + key="${line%%=*}" + value="${line#*=}" + case "$key" in + VK_SMOKE_BASE_URL|VK_SMOKE_SESSION_COOKIE|VK_SMOKE_PROXY_AUTH|VK_SMOKE_PUBLIC_POST_URL|VK_SMOKE_PRIVATE_POST_URL|VK_SMOKE_GROUP_ID|VK_SMOKE_CREATE_GIVEAWAY) ;; + *) printf 'FAIL config — unsupported key %s\n' "$key"; exit 2 ;; + esac + [[ -z "${config[$key]+x}" ]] || { printf 'FAIL config — duplicate key %s\n' "$key"; exit 2; } + [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || { printf 'FAIL config — multiline values are forbidden\n'; exit 2; } + config[$key]="$value" +done < "$ENV_FILE" + +BASE_URL="${config[VK_SMOKE_BASE_URL]:-}" +SESSION_COOKIE="${config[VK_SMOKE_SESSION_COOKIE]:-}" +PUBLIC_POST_URL="${config[VK_SMOKE_PUBLIC_POST_URL]:-}" +PRIVATE_POST_URL="${config[VK_SMOKE_PRIVATE_POST_URL]:-}" +GROUP_ID="${config[VK_SMOKE_GROUP_ID]:-}" +ALLOW_CREATE="${config[VK_SMOKE_CREATE_GIVEAWAY]:-NO}" + +if [[ ! "$BASE_URL" =~ ^http://(localhost|127\.0\.0\.1)(:[0-9]+)?$ && "$BASE_URL" != 'https://random.ochenstarik.ru' ]]; then + printf 'FAIL config — base URL must be loopback HTTP or https://random.ochenstarik.ru\n' + exit 2 +fi +if [[ -n "$SESSION_COOKIE" && ! "$SESSION_COOKIE" =~ ^randomayzer_session=[A-Za-z0-9._~-]+$ ]]; then + printf 'FAIL config — session cookie format is invalid\n' + exit 2 +fi +PROXY_AUTH="${config[VK_SMOKE_PROXY_AUTH]:-}" +if [[ -n "$PROXY_AUTH" && ! "$PROXY_AUTH" =~ ^[A-Za-z0-9._~-]+:[A-Za-z0-9._~-]+$ ]]; then + printf 'FAIL config — proxy auth must use safe username:password characters\n' + exit 2 +fi +if [[ "$BASE_URL" == 'https://random.ochenstarik.ru' && -z "$PROXY_AUTH" ]]; then + printf 'FAIL config — VK_SMOKE_PROXY_AUTH is required for the domain smoke test\n' + exit 2 +fi + +umask 077 +TMP_DIR="$(mktemp -d)" || { + printf 'FAIL setup — could not create a private temporary directory\n' + exit 2 +} +trap 'rm -rf -- "$TMP_DIR"' EXIT +BODY="$TMP_DIR/body.json" +HEADERS="$TMP_DIR/headers.txt" +CURL_CONFIG="$TMP_DIR/curl.cfg" +printf 'silent\nshow-error\n' > "$CURL_CONFIG" +[[ -z "$SESSION_COOKIE" ]] || printf 'header = "Cookie: %s"\n' "$SESSION_COOKIE" >> "$CURL_CONFIG" +[[ -z "$PROXY_AUTH" ]] || printf 'user = "%s"\n' "$PROXY_AUTH" >> "$CURL_CONFIG" + +request() { + local method="$1" path="$2" payload="${3:-}" + local args=(--config "$CURL_CONFIG" --connect-timeout 5 --max-time 120 --request "$method" --output "$BODY" --dump-header "$HEADERS" --write-out '%{http_code}') + if [[ "$method" != GET ]]; then + args+=(--header "Origin: $BASE_URL" --header 'Content-Type: application/json' --data "$payload") + fi + HTTP_CODE="$(curl "${args[@]}" "$BASE_URL$path" 2>/dev/null || true)" +} + +json_success() { + node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.success === true ? 0 : 1)' "$BODY" >/dev/null 2>&1 +} + +# A: OAuth start creates a VK ID authorize redirect with PKCE S256. +request GET /api/auth/vk/start +if [[ "$HTTP_CODE" =~ ^30[1278]$ ]] && grep -Eiq '^location: https://id\.vk\.com/authorize[^\r]*code_challenge_method=S256' "$HEADERS"; then + result A PASS 'VK ID authorize redirect and PKCE S256 found' +else + result A FAIL "expected VK ID 3xx authorize redirect with PKCE S256 (HTTP ${HTTP_CODE:-curl-error})" +fi + +# B: consent, redirect completion, and cookie delivery require a human in a browser. +result B MANUAL 'open /api/auth/vk/start, approve consent in VK, and confirm callback returns to the app with a session cookie' + +# C: authenticated session lookup. No response body or cookie is printed. +if [[ -z "$SESSION_COOKIE" ]]; then + result C SKIP 'set VK_SMOKE_SESSION_COOKIE after completing manual consent' +else + request GET /api/auth/me + if [[ "$HTTP_CODE" == 200 ]] && node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.authenticated === true ? 0 : 1)' "$BODY" >/dev/null 2>&1; then result C PASS 'authenticated session endpoint returned success'; else result C FAIL "session endpoint returned HTTP ${HTTP_CODE:-curl-error}"; fi +fi + +POST_JSON="" +GIVEAWAY_ID="" +if [[ -z "$SESSION_COOKIE" || -z "$PUBLIC_POST_URL" ]]; then + result D SKIP 'authenticated session and VK_SMOKE_PUBLIC_POST_URL are required' +else + payload="$(node -e 'process.stdout.write(JSON.stringify({url:process.argv[1],platform:"VK"}))' "$PUBLIC_POST_URL")" + request POST /api/posts/preview "$payload" + if [[ "$HTTP_CODE" == 200 ]] && json_success; then + access_mode="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.effectiveCapabilities?.accessMode||"")' "$BODY")" + if [[ "$access_mode" != PUBLIC_SERVICE ]]; then + result D FAIL 'public post preview did not report PUBLIC_SERVICE access mode' + else + result D PASS 'public VK post preview succeeded with PUBLIC_SERVICE access mode' + POST_JSON="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(JSON.stringify(d.post))' "$BODY")" + fi + else result D FAIL "public post preview returned HTTP ${HTTP_CODE:-curl-error}"; fi +fi + +if [[ -z "$SESSION_COOKIE" || -z "$PRIVATE_POST_URL" ]]; then + result E SKIP 'authenticated session and VK_SMOKE_PRIVATE_POST_URL are required' +else + payload="$(node -e 'process.stdout.write(JSON.stringify({url:process.argv[1],platform:"VK"}))' "$PRIVATE_POST_URL")" + request POST /api/posts/preview "$payload" + if [[ "$HTTP_CODE" == 200 ]] && json_success; then + mode="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.post?.resolvedAuthType||"")' "$BODY")" + if [[ "$mode" == USER ]]; then result E PASS 'restricted post preview used organizer authorization'; else result E FAIL 'restricted post did not resolve with USER authorization'; fi + else result E FAIL "restricted post preview returned HTTP ${HTTP_CODE:-curl-error}"; fi +fi + +if [[ "$ALLOW_CREATE" != YES ]]; then + result F SKIP 'set VK_SMOKE_CREATE_GIVEAWAY=YES to create a test giveaway' + result G SKIP 'requires a giveaway created by check F' + result H SKIP 'requires a giveaway and VK_SMOKE_GROUP_ID' +elif [[ -z "$SESSION_COOKIE" || -z "$POST_JSON" ]]; then + result F FAIL 'cannot create without a successful public preview and authenticated session' + result G SKIP 'requires a giveaway created by check F' + result H SKIP 'requires a giveaway and VK_SMOKE_GROUP_ID' +else + create_payload="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(JSON.stringify({sourceUrl:p.sourceUrl,post:p,filterRules:{requireLike:true,requireComment:false,requireRepost:false,requireSubscription:false,excludeAdmins:false,excludeBlacklistedIds:[],minEligibleParticipants:1}}))' "$POST_JSON")" + request POST /api/giveaways "$create_payload" + if [[ "$HTTP_CODE" == 201 ]] && json_success; then + GIVEAWAY_ID="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.giveaway?.id||"")' "$BODY")" + if [[ -n "$GIVEAWAY_ID" ]]; then result F PASS 'test giveaway created'; else result F FAIL 'create response omitted giveaway id'; fi + else result F FAIL "giveaway create returned HTTP ${HTTP_CODE:-curl-error}"; fi + + if [[ -n "$GIVEAWAY_ID" ]]; then + import_payload='{"filterRules":{"requireLike":true,"requireComment":true,"requireRepost":false,"requireSubscription":false,"excludeAdmins":false,"excludeBlacklistedIds":[],"minEligibleParticipants":1}}' + request POST "/api/giveaways/$GIVEAWAY_ID/participants" "$import_payload" + if [[ "$HTTP_CODE" == 200 ]] && json_success; then result G PASS 'participant import succeeded'; else result G FAIL "participant import returned HTTP ${HTTP_CODE:-curl-error}"; fi + + if [[ -z "$GROUP_ID" ]]; then + result H SKIP 'set VK_SMOKE_GROUP_ID to run subscription verification' + else + subscription_payload="$(node -e 'const p=JSON.parse(process.argv[1]); p.filterRules.requireSubscription=true; p.filterRules.targetGroupId=process.argv[2]; process.stdout.write(JSON.stringify(p))' "$import_payload" "$GROUP_ID")" + request POST "/api/giveaways/$GIVEAWAY_ID/participants" "$subscription_payload" + if [[ "$HTTP_CODE" == 200 ]] && json_success; then result H PASS 'subscription verification returned a complete result'; else result H FAIL "subscription verification returned HTTP ${HTTP_CODE:-curl-error}"; fi + fi + else + result G SKIP 'requires a giveaway created by check F' + result H SKIP 'requires a giveaway and VK_SMOKE_GROUP_ID' + fi +fi + +printf 'Smoke checks: %d failure(s), %d skipped/manual check(s).\n' "$failures" "$skips" +[[ "$failures" -eq 0 ]]