fix: close release preflight gaps
CI and Deploy / test (pull_request) Failing after 1m22s
CI and Deploy / deploy (pull_request) Has been skipped

Route public health checks to the API, backfill and synchronize job opportunities, stabilize SPA smoke tests, and document operator-only production steps.
This commit is contained in:
cesnimda
2026-07-31 20:18:30 +02:00
parent ce76046a29
commit 955182b7c2
18 changed files with 350 additions and 52 deletions
@@ -0,0 +1,75 @@
# Production DataProtection key rotation
The repository history contains one DataProtection key:
- exposed key id: `9a89a42c-d2bd-4770-83fb-5930685432db`
- historical path: `JobTrackerApi/keys/key-9a89a42c-d2bd-4770-83fb-5930685432db.xml`
Do not rotate blindly. Production stores its key ring in `/data/keys` inside the backend volume. First
prove whether production ever used the exposed key.
## 1. Non-destructive fingerprint check
From `/opt/job-tracker/app` on the production host:
```bash
docker compose exec -T backend sh -lc \
'for key in /data/keys/key-*.xml; do [ -f "$key" ] && basename "$key"; done'
```
- If the exposed key id is **absent**, save the command output as release evidence and close the
rotation blocker. Production keys were not the committed key; deleting a healthy ring would only
cause an outage.
- If the exposed key id is **present**, continue below. Do not delete or move anything yet.
## 2. Back up before a confirmed rotation
First complete the database backup and scratch-restore checklist in `docs/deployment/backup-restore.md`.
Then take a restricted backup of the key ring:
```bash
cd /opt/job-tracker/app
umask 077
docker compose exec -T backend tar -C /data -czf - keys \
> "/opt/job-tracker/backups/dataprotection-keys-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
```
Store that archive offline with production secrets. It contains decryption keys and must not enter
Git, chat, CI artifacts, or ordinary application logs.
## 3. Inventory the impact
This ring protects more than browser cookies. Before revoking an exposed key, count affected records
without printing their encrypted values:
- users with `TotpSecretEncrypted` or `TotpPendingSecretEncrypted`;
- Gmail connections with encrypted access/refresh tokens;
- Microsoft Graph connections with encrypted access/refresh tokens;
- IMAP connections with encrypted passwords;
- encrypted application-export files that still need to remain restorable.
Password-reset/email-confirmation tokens and active authentication cookies will also stop validating.
## 4. Choose the cutover
### No protected records
Use a short maintenance window, replace the `/data/keys` ring, restart the backend, then verify login,
password reset, email confirmation, and a new encrypted backup. Existing sessions will be signed out.
### Protected records exist
Do not simply delete the old ring: that would strand TOTP secrets and mail credentials. Keep the old
ring available only for a one-time re-protection operation, create a new active key, re-protect every
persistent secret, verify the affected integrations, then remove the exposed key and restart. The
repository does not yet include that production-only migration because it is unnecessary unless the
fingerprint check proves the exposed key is active.
## Completion evidence
- production key-id listing captured without key contents;
- database scratch restore passed;
- key-ring backup stored outside the repository;
- affected-record counts recorded without encrypted values;
- login, password reset, TOTP, Gmail/Graph/IMAP, and encrypted backup behavior verified as applicable;
- exposed key id absent from the active `/data/keys` directory.
@@ -0,0 +1,52 @@
# Job opportunity cutover
The release-readiness build closes the write-side gap:
- normal application creation and Gmail suggested-job creation both create a linked `Job`;
- edits synchronize opportunity fields through `JobOpportunitySync`;
- startup idempotently creates one `Job` for every legacy `JobApplication` whose `JobId` is null.
The startup backfill is additive and runs after the deployment backup. It does not drop or overwrite
legacy application columns.
## Production validation
Run these read-only checks after deployment. Expected result for every count is `0`:
```sql
SELECT COUNT(*) AS missing_job
FROM JobApplications
WHERE JobId IS NULL;
SELECT COUNT(*) AS missing_target
FROM JobApplications a
LEFT JOIN Jobs j ON j.Id = a.JobId
WHERE a.JobId IS NOT NULL AND j.Id IS NULL;
SELECT COUNT(*) AS owner_mismatch
FROM JobApplications a
JOIN Jobs j ON j.Id = a.JobId
WHERE NOT (a.OwnerUserId <=> j.OwnerUserId);
SELECT COUNT(*) AS field_mismatch
FROM JobApplications a
JOIN Jobs j ON j.Id = a.JobId
WHERE NOT (a.CompanyId <=> j.CompanyId)
OR NOT (a.JobTitle <=> j.JobTitle)
OR NOT (a.Location <=> j.Location)
OR NOT (a.JobUrl <=> j.JobUrl)
OR NOT (a.Description <=> j.Description)
OR NOT (a.Salary <=> j.Salary);
```
`<=>` is MariaDB/MySQL's null-safe equality operator. Do not use these statements as an update script.
## Expand/contract release order
1. Deploy the additive backfill and synchronized writers.
2. Capture the validation counts above and observe one release.
3. Change reads to use `Job`; keep compatibility columns during that release.
4. Re-run validation against a fresh backup restore.
5. Only then make `JobId` required and drop duplicated opportunity columns in a later migration.
Do not combine the destructive column drop with the first production backfill.
+60
View File
@@ -0,0 +1,60 @@
# Stripe activation
The application code is complete. Activate it in Stripe test mode first; test and live mode have
different API keys, price ids, webhook endpoints/signing secrets, and customer data.
Official references:
- https://docs.stripe.com/subscriptions
- https://docs.stripe.com/customer-management/integrate-customer-portal
- https://docs.stripe.com/webhooks?lang=dotnet
## Test-mode setup
1. Create one recurring monthly Premium product/price.
2. Enable the customer portal for subscription management and cancellation.
3. Register `https://jobs.cesnimda.uk/api/billing/webhook` as an HTTPS webhook endpoint for:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
4. Add the test-mode values to `/opt/job-tracker/shared/.env`:
```text
STRIPE_SECRET_KEY=<test secret key>
STRIPE_PRICE_PREMIUM=<test recurring price id>
STRIPE_WEBHOOK_SECRET=<test endpoint signing secret>
```
5. Restart/redeploy the backend without printing those values.
Never put the values in Git, chat, screenshots, issue text, or CI logs. Stripe requires the raw
request body for signature verification; the implemented endpoint reads it before constructing the
signed event.
## Acceptance check
Use a disposable, authenticated Jobbjakt account:
1. Account settings reports billing enabled and offers Upgrade.
2. Upgrade opens Stripe-hosted Checkout for the configured monthly price.
3. Completing a Stripe test subscription returns to `/settings?billing=success`.
4. The signed subscription webhook stores the Stripe customer/subscription/status and grants the
`Premium` role.
5. `/api/auth/me` exposes Premium entitlements and the UI unlocks Premium capabilities.
6. Manage billing opens the Stripe customer portal.
7. Cancel in the portal and confirm an updated/deleted subscription webhook removes Premium when the
subscription is no longer active or trialing.
8. Replay one event from Stripe Workbench and confirm role/state remain idempotent.
## Live-mode cutover
Repeat the product/price, portal, and webhook setup in live mode. Replace all three environment values
as one change; never mix a test price or endpoint secret with a live API key. Complete one real
low-risk subscription and cancellation, confirm the webhook delivery log is green, then remove the
disposable account if it is no longer needed.
## Rollback
Removing all three environment values disables new Checkout/portal sessions without deleting billing
history. Do not manually remove Premium roles while Stripe still reports an active subscription; fix
webhook delivery and replay the authoritative subscription event instead.