diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index fb5f7365..b0836d3a 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -19,3 +19,8 @@ ADMIN_EMAILS= EPAY_CONFIG_ENCRYPTION_KEY= EPAY_CHAT_ENABLED=false JYOTISH_DYNAMIC_RECTIFICATION_TOKEN= + +# Personal reports are staging-only in this rollout. The web service receives +# these server-side values through env_file; they are not NEXT_PUBLIC values. +PERSONAL_REPORT_ENABLED=true +PERSONAL_REPORT_DAILY_LIMIT=5 diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index 807af650..13bef651 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -106,4 +106,22 @@ fi require_selector EPAY_CHAT_ENABLED false require_literal JYOTISH_DYNAMIC_RECTIFICATION_TOKEN 32 +personal_report_enabled_count="$(grep -Ec '^PERSONAL_REPORT_ENABLED=' "$ENV_FILE" || true)" +personal_report_enabled="$(grep -E '^PERSONAL_REPORT_ENABLED=' "$ENV_FILE" || true)" +personal_report_enabled="${personal_report_enabled#*=}" +if [ "$personal_report_enabled_count" -ne 1 ] || + [[ "$personal_report_enabled" != "true" && "$personal_report_enabled" != "false" ]]; then + echo "invalid staging personal report setting: PERSONAL_REPORT_ENABLED" >&2 + exit 1 +fi + +personal_report_daily_limit_count="$(grep -Ec '^PERSONAL_REPORT_DAILY_LIMIT=' "$ENV_FILE" || true)" +personal_report_daily_limit="$(grep -E '^PERSONAL_REPORT_DAILY_LIMIT=' "$ENV_FILE" || true)" +personal_report_daily_limit="${personal_report_daily_limit#*=}" +if [ "$personal_report_daily_limit_count" -ne 1 ] || + [[ ! "$personal_report_daily_limit" =~ ^[1-9][0-9]*$ ]]; then + echo "invalid staging personal report setting: PERSONAL_REPORT_DAILY_LIMIT" >&2 + exit 1 +fi + echo "staging environment selectors: valid" diff --git a/docs/operations/personal-report-staging.md b/docs/operations/personal-report-staging.md new file mode 100644 index 00000000..1d29724e --- /dev/null +++ b/docs/operations/personal-report-staging.md @@ -0,0 +1,168 @@ +# Personal Report Staging Acceptance and Rollback + +This runbook covers the staging-only rollout of `ReportDocument v1`, personal report generation, the report reader, D1 SVG rendering, and browser print-to-PDF. It does **not** authorize a production migration or deployment. + +## Release identity + +- Pre-change application baseline: `49da8f916960030d5760d8dedf4e77820732a527` +- Read-only upstream Skill source commit: `unknown` (the local source has no usable Git metadata) +- Read-only upstream source tree SHA-256: `9034e1967032d09c7fbae83fc2205f7e75e8ad482c5f9eba1bf309fe30aef5bb` +- Packaged `SKILL.md` SHA-256: read from `references/upstream/yinduzhanxing/source-manifest.json` +- Deployment SHA: use the exact full SHA shared by `main` and `staging`; never substitute a short SHA or mutable image tag. +- Application rollback target: `49da8f916960030d5760d8dedf4e77820732a527`, subject to successful gate artifact retention. + +## Staging configuration + +The server-owned `/opt/jyotisha-staging/.env.staging` must remain mode `0600`. Do not print or copy the file. In addition to the existing self-hosted identity settings, configure: + +```dotenv +PERSONAL_REPORT_ENABLED=true +PERSONAL_REPORT_DAILY_LIMIT=5 +``` + +`PERSONAL_REPORT_ALLOWED_ORIGINS` is normally omitted because browser requests are same-origin. Add it only for an explicitly reviewed origin. `JYOTISH_SKILL_SNAPSHOT_SHA256` and `JYOTISH_SKILL_SOURCE_COMMIT` are optional deployment pins; without a valid override, the web bundle uses the statically packaged, validated source manifest and fails closed if its SHA is invalid. + +Validate without logging values: + +```bash +cd /opt/jyotisha-staging +./deploy/validate-staging-env.sh .env.staging +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet +``` + +The report API reads these values server-side through Compose `env_file`. They must not be changed to `NEXT_PUBLIC_*` build arguments. + +## Exact-SHA release sequence + +Follow `deploy/README.md`; the short form is: + +1. Confirm the reviewed `main` full SHA and fast-forward `staging` to the same SHA. +2. Wait for `Staging Backend Quality Gate` on the `staging` push to succeed and publish the immutable API/web digest manifest. +3. Let the automatic read-only deployment check stop on pending migrations. +4. Run `Migrate Staging Database` manually from the `main` controller with the exact full SHA. +5. Confirm the migration ledger contains `20260806000000_personal_reports.sql`. +6. Run `Deploy staging` manually with that same SHA and `allow_rollback=false`. +7. Verify `https://staging.jyotisha.chat/api/health` reports that exact SHA and both private services healthy. + +Do not deploy the application before the additive migration. Do not trigger production workflows. + +## Automated acceptance + +Before pushing, record the literal command and outcome for: + +```bash +python3 -m pytest -q \ + tests/test_cross_project_contract.py \ + tests/test_cross_project_sync_status.py \ + tests/test_import_yinduzhanxing.py + +python3 -m pytest -q \ + tests/test_unified_consultation_orchestrator.py \ + tests/test_report_orchestrator_reader_contract.py \ + tests/test_consultation_consumer_context.py \ + tests/test_personal_report_contract.py \ + tests/test_supabase_migration_versions.py + +npm test --prefix frontend +npm run lint --prefix frontend +npm run build --prefix frontend +``` + +If the complete frontend suite has environment-only failures, preserve their exact count and cause; do not report the suite as green. The personal-report-focused test set, TypeScript check, Python contract test, migration contract, production build, and `git diff --check` must pass before release. + +## Synthetic browser acceptance + +Use only synthetic accounts and synthetic birth facts. Never paste real birth data into tickets, logs, screenshots, or this document. + +Prepare these profiles: + +1. `confirmed` with an exact synthetic active time. +2. `accepted` with a user-adopted synthetic candidate time. +3. A report whose evidence contains blocked/conflicting techniques. +4. A second synthetic owner account for isolation checks. + +For each supported owner profile: + +1. Sign in and complete a personal consultation with workflow evidence. +2. Confirm the “生成个人报告” CTA is visible only for `accepted` or `confirmed` active time. It must be hidden for `reported` and unaccepted `candidate` states. +3. Generate one report. Confirm the button prevents duplicate in-flight clicks and the app navigates to `/reports/`. +4. Confirm content order is summary, thematic narrative, then evidence appendix. +5. Confirm D1 renders from real document facts. D9/D10 must be absent when their complete facts are unavailable. +6. Confirm blocked claims use uncertainty language and contain no deterministic prediction. +7. Reload the report URL and confirm the same validated document is returned. +8. Attempt the report URL as the second synthetic owner; expect `404`/not found and no document data. +9. Delete as the owner and confirm subsequent retrieval is not found. +10. Generate with the same request ID and payload to confirm replay behavior; a different payload under the same ID must return `409 report_request_conflict`. + +PDF/browser matrix: + +| Client | Required check | Status before user handoff | +| --- | --- | --- | +| Desktop Chrome | Print → Save as PDF; SVG sharp; Chinese text intact; tables not clipped | pending manual | +| macOS Safari | Print → Save as PDF; pagination and fonts | pending manual | +| iPhone Safari | Share/Print flow and mobile hint | pending manual | +| WeChat in-app browser | Export guidance is understandable | pending manual | + +For a long synthetic document, target 20–40 printed pages and verify that print CSS expands the evidence appendix as designed. The report action must only wait for `document.fonts.ready` and call `window.print()`. + +## Security and server assertions + +Verify logged-out and cross-owner behavior without recording tokens: + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' https://staging.jyotisha.chat/api/account +curl -sS -o /dev/null -w '%{http_code}\n' https://staging.jyotisha.chat/api/reports/00000000-0000-4000-8000-000000000000 +``` + +Expected logged-out status is `401`. Authenticated owner/non-owner checks must be performed in the browser or with ephemeral local credentials that are never pasted into logs. + +On the host, confirm no product PDF browser runtime exists: + +```bash +cd /opt/jyotisha-staging +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml exec -T web \ + sh -lc '! command -v chromium && ! command -v chromium-browser && ! command -v google-chrome' +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml exec -T web \ + sh -lc '! ps aux | grep -E "[c]hromium|[p]laywright|[p]uppeteer"' +``` + +Inspect logs only for stable codes and operational state. Do not log or copy report text, prompts, birth facts, JWTs, cookies, model keys, exception stacks, or the staging env file. + +## Resource evidence + +Record before, during one generation, and during two synthetic users attempting concurrent generation: + +```bash +cd /opt/jyotisha-staging +docker stats --no-stream +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml ps +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml logs --tail=200 api web caddy +``` + +Capture only aggregate CPU, memory, container restart count, request duration, stable failure state, and serialized report byte size. Confirm: + +- one generation per user at a time; +- a second concurrent request for the same user returns the stable in-progress response; +- serialized `ReportDocument` is at most 1.5 MiB; +- no OOM or container restart; +- no Chromium/Playwright server process. + +One successful sample does not establish high-concurrency capacity. + +## Rollback + +### Application + +Run `Deploy staging` from the trusted `main` controller using the previous known-good full SHA and `allow_rollback=true`. The selected SHA must still have a successful quality-gate artifact. Do not use a mutable tag or manually overwrite the server tree. + +### Database + +The personal report migration is additive. Application rollback intentionally leaves `public.personal_reports`, its grants, constraints, indexes, and RLS policies in place. Do not run a destructive down migration. Delete only explicitly identified synthetic test rows if cleanup is required. + +### Feature pause + +If generation must stop while the current application remains deployed, set `PERSONAL_REPORT_ENABLED=false` in the server-owned staging env under the deployment lock and recreate only the web service after validation. This is an operational containment action, not a substitute for an application rollback. Restore `true` only after the incident is resolved. + +### Skill + +Revert the commercial repository import/semantic-merge commits as reviewed. Do not modify the read-only upstream source directory. Preserve the import manifest and record that the target revision was rolled back; never rewrite provenance history. diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index aea3b45f..abeb0690 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -249,6 +249,8 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi "EPAY_CONFIG_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "EPAY_CHAT_ENABLED=false", "JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=dynamic-token-that-is-at-least-32-bytes", + "PERSONAL_REPORT_ENABLED=true", + "PERSONAL_REPORT_DAILY_LIMIT=5", ]; const run = () => spawnSync("bash", [validator, envFile], { encoding: "utf8" }); @@ -276,6 +278,14 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi assert.notEqual(run().status, 0); writeEnv(validSelectors.map((line) => line.startsWith("EPAY_CHAT_ENABLED=") ? "EPAY_CHAT_ENABLED=true" : line)); assert.notEqual(run().status, 0); + writeEnv(validSelectors.map((line) => line.startsWith("PERSONAL_REPORT_ENABLED=") ? "PERSONAL_REPORT_ENABLED=false" : line)); + assert.equal(run().status, 0); + writeEnv(validSelectors.map((line) => line.startsWith("PERSONAL_REPORT_ENABLED=") ? "PERSONAL_REPORT_ENABLED=TRUE" : line)); + assert.notEqual(run().status, 0); + writeEnv(validSelectors.map((line) => line.startsWith("PERSONAL_REPORT_DAILY_LIMIT=") ? "PERSONAL_REPORT_DAILY_LIMIT=0" : line)); + assert.notEqual(run().status, 0); + writeEnv(validSelectors.map((line) => line.startsWith("PERSONAL_REPORT_DAILY_LIMIT=") ? "PERSONAL_REPORT_DAILY_LIMIT=5x" : line)); + assert.notEqual(run().status, 0); writeEnv(validSelectors); const shellOverride = spawnSync( diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index c17df1ee..40bd2abe 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -799,6 +799,8 @@ test("public rectification rollout enables the semantic agent and recreates web "EPAY_CONFIG_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "EPAY_CHAT_ENABLED=false", `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=${"t".repeat(32)}`, + "PERSONAL_REPORT_ENABLED=true", + "PERSONAL_REPORT_DAILY_LIMIT=5", "KEEP_ME=unchanged", "RECTIFICATION_V3_CREATE_ENABLED=false", "RECTIFICATION_V3_MIGRATIONS_READY=false",