LastVet — Bitwarden Hygiene Plan (R940 Migration)¶
Date: 2026-08-10
Companion: BITWARDEN_ENV_AUDIT.md
Status: Proposal only. Do not execute renames until Ryan approves this plan.
Executive summary¶
| Item | Count |
|---|---|
Live BWS secrets in lastvet project |
32 |
| Hygiene issues flagged | 28 |
| Secrets recommended rename | 12 |
| Secrets recommended keep as-is (after note fix) | 8 |
| Secrets recommended delete (after grace period) | 4 |
| New secrets to create before R940 deploy | ~25 |
Issue breakdown:
| Category | Count | Examples |
|---|---|---|
| Legacy pointer (Railway hosts) | 6 | DATABASE_APP_URL_STAGING, MIGRATION_DATABASE_URL_PRODUCTION |
| AWS-specific / ambiguous target | 8 | LASTVET_APP_PASSWORD_PRODUCTION, undifferentiated crypto keys |
| Ambiguous scope (env not in name) | 6 | VETERAN_JWT_SECRET, LAST1_TOKEN_ENCRYPTION_KEY |
| Orphaned (no API code path) | 5 | VA_FORMS_API_KEY, E2E_OTP_IMAP_* |
| Duplicated value | 2 | App password stored in URL + LASTVET_APP_PASSWORD_* |
| Missing from BWS (AWS SM only) | ~30 keys | SendGrid, Stripe, Google OAuth, portal URLs |
| Stale notes | 1 | OAUTH_JWT_SECRET veteran-fallback note |
Goals¶
- Parallel run: AWS ECS and R940 VM can hold different connection strings and credentials without name collisions.
- Greppable:
bws secret list | grep R940returns every R940-scoped operator secret. - Runtime clarity: Deployment scripts map BWS storage names → canonical
process.envnames (which never change in application code during migration). - No blind sync: AWS SM remains authoritative for ECS until R940 cutover; Bitwarden is never pushed into AWS without verification.
Naming convention¶
Pattern¶
| Segment | Values | Meaning |
|---|---|---|
LASTVET |
Fixed prefix | Product scope (contrast with LAST1_ for cross-product platform secrets) |
{TARGET} |
AWS, R940, RAILWAY, EDGE, IOS, SHARED |
Where the value is consumed |
{ENV} |
STAGING, PROD, omit for global |
Logical environment lane |
{PURPOSE} |
Slug | Describes role; for API secrets, suffix with runtime var name when helpful |
Cross-product secrets¶
| Scope | Pattern | Example |
|---|---|---|
| LastVet-only | LASTVET_{TARGET}_{ENV}_… |
LASTVET_R940_STAGING_VETERAN_JWT_SECRET |
| Shared Last1 platform | LAST1_{PURPOSE} or live in owning project |
LAST1_ONSET_IDENTITY_ASSERTION_SECRET |
| Non-secret config | LASTVET_{TARGET}_{ENV}_… or plain env in systemd |
LASTVET_R940_STAGING_OAUTH_ISSUER |
Rule: If a secret is shared with another repo (iOS, Onset, last1.id), the BWS note must name the peer and the canonical writer. One writer, many readers.
Runtime mapping (deploy time)¶
The API always reads DATABASE_APP_URL, not DATABASE_APP_URL_R940_STAGING. A systemd drop-in or restore-api-env-from-bws.sh performs:
# Example — not implemented yet
export DATABASE_APP_URL="$(bws secret get LASTVET_R940_STAGING_DATABASE_APP_URL)"
export MIGRATION_DATABASE_URL="$(compose from LASTVET_R940_STAGING_POSTGRES_MASTER_PASSWORD ...)"
Store both AWS and R940 URLs in BWS during parallel run; the deploy target selects which pair to inject.
grep cheatsheet¶
bws secret list | jq -r '.[].key' | grep LASTVET_R940 # all R940
bws secret list | jq -r '.[].key' | grep LASTVET_AWS # all AWS operator copies
bws secret list | jq -r '.[].key' | grep LASTVET_RAILWAY # rollback net only
bws secret list | jq -r '.[].key' | grep LASTVET_EDGE # cloudflared
bws secret list | jq -r '.[].key' | grep LASTVET_IOS # iOS-bound
Migration table — all 32 live BWS secrets¶
| Current BWS key | Issue | Proposed BWS key | Action | Rationale |
|---|---|---|---|---|
DATABASE_APP_URL_STAGING |
Legacy Railway host | LASTVET_RAILWAY_STAGING_DATABASE_APP_URL |
Rename | Target + env explicit; Railway rollback only |
DATABASE_APP_URL_PRODUCTION |
Legacy Railway host | LASTVET_RAILWAY_PROD_DATABASE_APP_URL |
Rename | Same |
MIGRATION_DATABASE_URL_STAGING |
Legacy Railway host | LASTVET_RAILWAY_STAGING_MIGRATION_DATABASE_URL |
Rename | Same |
MIGRATION_DATABASE_URL_PRODUCTION |
Legacy Railway host | LASTVET_RAILWAY_PROD_MIGRATION_DATABASE_URL |
Rename | Same |
LASTVET_APP_PASSWORD_STAGING |
Ambiguous; duplicates URL | LASTVET_RAILWAY_STAGING_POSTGRES_APP_PASSWORD |
Rename | Matches R940 password naming |
LASTVET_APP_PASSWORD_PRODUCTION |
Ambiguous; duplicates URL | LASTVET_RAILWAY_PROD_POSTGRES_APP_PASSWORD |
Rename | Same |
DATABASE_APP_URL_R940_STAGING |
OK intent; non-standard prefix | LASTVET_R940_STAGING_DATABASE_APP_URL |
Rename | Consistent LASTVET_R940_* grep |
DATABASE_APP_URL_R940_PROD |
OK intent | LASTVET_R940_PROD_DATABASE_APP_URL |
Rename | Same |
LASTVET_R940_POSTGRES_APP_PASSWORD |
OK | Keep (already prefixed) | Keep | Add note linking to app URL |
LASTVET_R940_POSTGRES_MASTER_PASSWORD |
Missing migration URL companion | Keep + create LASTVET_R940_STAGING_MIGRATION_DATABASE_URL and …_PROD_… |
Create | Week 2 migrate script needs owner URL |
LASTVET_R940_MINIO_ROOT_USER |
Infra-only | Keep | Keep | Not API env until MinIO wired |
LASTVET_R940_MINIO_ROOT_PASSWORD |
Infra-only | Keep | Keep | Same |
LASTVET_R940_ZFS_IMAGING_KEY |
Infra-only | Keep | Keep | ZFS recovery doc references this |
LASTVET_R940_CLOUDFLARED_TUNNEL_TOKEN |
Edge, not API | LASTVET_EDGE_CLOUDFLARED_TUNNEL_TOKEN |
Rename optional | EDGE clearer than R940 for tunnel |
LASTVET_R940_CLOUDFLARED_ORIGIN_CERT |
Edge | LASTVET_EDGE_CLOUDFLARED_ORIGIN_CERT |
Rename optional | Same |
VETERAN_JWT_SECRET |
Ambiguous env; note says prod only | LASTVET_AWS_PROD_VETERAN_JWT_SECRET + create …_AWS_STAGING_… + …_R940_* |
Split + rename | Distinct per lane; Ryan decides rotation |
SECURE_LINK_HMAC_SECRET |
Ambiguous env | LASTVET_AWS_PROD_SECURE_LINK_HMAC_SECRET + staging + R940 copies |
Split + rename | Same |
OAUTH_JWT_SECRET |
Stale note (veteran fallback) | LASTVET_AWS_STAGING_OAUTH_JWT_SECRET + prod + R940 |
Split + rename + fix note | Onset OAuth only |
PROVIDER_JWT_SECRET |
Ambiguous env | LASTVET_AWS_STAGING_PROVIDER_JWT_SECRET + prod + R940 |
Split + rename | Per-env distinct |
LAST1_TOKEN_ENCRYPTION_KEY |
Ambiguous scope | Ryan decision → either LASTVET_* per env or LAST1_TOKEN_ENCRYPTION_KEY in Last1 project |
Rename or move | See decisions below |
LAST1_WEBHOOK_SECRET |
Ambiguous env | LASTVET_AWS_STAGING_LAST1_WEBHOOK_SECRET + prod + R940 |
Split + rename | |
LAST1_PROOF_DRAINER_SECRET |
Ambiguous env | LASTVET_AWS_STAGING_LAST1_PROOF_DRAINER_SECRET + prod + R940 |
Split + rename | |
LASTVET_INTERNAL_ADMIN_SECRET |
Ambiguous env | LASTVET_AWS_STAGING_INTERNAL_ADMIN_SECRET + prod + R940 |
Split + rename | |
LASTVET_APP_SECRET |
Shared with last1.id | LASTVET_AWS_STAGING_APP_SECRET + prod (+ same value on last1.id) |
Split + rename | last1.id is peer writer for app registration |
ONSET_IDENTITY_ASSERTION_SECRET |
Cross-project | LAST1_ONSET_IDENTITY_ASSERTION_SECRET in Last1 or Onset project |
Move | Single canonical home |
VETERAN_SESSION_EXCHANGE_SECRET |
iOS + API shared | LASTVET_IOS_SHARED_VETERAN_SESSION_EXCHANGE_SECRET |
Rename | iOS xcconfig + API inject same value |
VA_OPEN_DATA_API_KEY |
iOS only | LASTVET_IOS_SHARED_VA_OPEN_DATA_API_KEY |
Rename | Not API |
VA_FORMS_API_KEY |
Orphan / parked | Keep or delete after iOS wiring | Delete candidate | Not consumed anywhere yet |
E2E_OTP_IMAP_HOST |
Test harness | LASTVET_TEST_E2E_OTP_IMAP_HOST |
Rename | Move to test project long-term |
E2E_OTP_IMAP_USER |
Test harness | LASTVET_TEST_E2E_OTP_IMAP_USER |
Rename | Same |
E2E_OTP_IMAP_PASSWORD |
Test harness | LASTVET_TEST_E2E_OTP_IMAP_PASSWORD |
Rename | Same |
CLOUDFLARE_ACCOUNT_ID |
Infra | LAST1_SHARED_CLOUDFLARE_ACCOUNT_ID |
Move optional | Shared across Last1 web projects |
New secrets to create (not in BWS today)¶
Populate from AWS SM (lastvet-staging/app, lastvet-prod/app) as operator backups before R940 Week 2 deploy. Use LASTVET_AWS_{ENV}_{RUNTIME_VAR} naming.
Priority batch (required for functional R940 staging API):
LASTVET_AWS_STAGING_*andLASTVET_R940_STAGING_*for all Terraformapp_secret_keys(40 keys)LASTVET_R940_STAGING_MIGRATION_DATABASE_URL(compose from master password + host)LASTVET_R940_PROD_MIGRATION_DATABASE_URL- Plain config:
LASTVET_R940_STAGING_OAUTH_ISSUER,…_PROVIDER_PORTAL_URL,…_NODE_ENV,…_UPLOAD_DIR,…_DATABASE_SSL
Second batch (feature parity):
- SendGrid block (5), Stripe block (6), Google OAuth (3), APNS (3)
Delete vs rename vs keep¶
| Action | Secrets |
|---|---|
| Keep as-is (short term) | LASTVET_R940_POSTGRES_*, MinIO, ZFS imaging key until infra scripts reference new names |
| Rename first | All Railway DATABASE_*, ambiguous crypto keys (create new name, copy value, deprecate old) |
| Delete only after | Old names once (1) R940 deploy script uses new names, (2) AWS parallel run documented, (3) 30-day grace |
| Delete candidates | VA_FORMS_API_KEY if iOS still unused at Gate 1; Railway URLs once rollback net torn down |
| Never delete | Railway URLs until Railway teardown per invariants |
Execution order (operations)¶
Phase 0 — This PR (done when merged)
Document only. No BWS writes.
Phase 1 — Create before rename (no deletes)
1. Export AWS SM lastvet-staging/app and lastvet-prod/app to local secure store.
2. Create all LASTVET_AWS_{STAGING,PROD}_{KEY} entries in BWS from SM (operator backup).
3. Create LASTVET_R940_STAGING_MIGRATION_DATABASE_URL / …_PROD_….
4. Create LASTVET_R940_{STAGING,PROD}_* copies for crypto keys (Ryan decides: rotate vs copy AWS staging).
Phase 2 — Rename with duplication
5. For each row in migration table: create proposed name with same value.
6. Update bitwarden/secrets-manifest.json and restore-*-from-bws.sh scripts to read new names.
7. Add deprecated note on old keys: DEPRECATED → use LASTVET_RAILWAY_STAGING_….
Phase 3 — R940 deploy wiring (Week 2 Task 1)
8. Implement restore-api-env-from-bws.sh on lastvet-api VM mapping LASTVET_R940_STAGING_* → /etc/lastvet/api.env.
9. Smoke test: /health, DB connect, provider JWT, veteran exchange (staging).
10. Parallel run: AWS ECS unchanged; cloudflared tunnel to R940 for soak only.
Phase 4 — Cleanup (post-cutover)
11. Verify no script references old BWS names (rg DEPRECATED keys).
12. Delete deprecated Railway keys after Railway teardown.
13. Delete duplicate AWS backup keys if SM + R940 are authoritative.
14. Fix OAUTH_JWT_SECRET stale notes everywhere.
Never: Rename/delete a BWS key that is the sole copy of a production credential without verifying AWS SM or R940 runtime first.
Top 3 decisions for Ryan (block Week 2 Task 1)¶
1. R940 staging crypto: rotate or copy AWS staging?¶
Creating LASTVET_R940_STAGING_VETERAN_JWT_SECRET (etc.) requires choosing:
- Copy AWS staging values: iOS + provider portal can hit either AWS or R940 staging interchangeably during soak.
- Rotate new values: Stronger isolation; iOS xcconfig and last1.id callbacks must be updated for R940-only testing.
2. Canonical home for cross-product secrets¶
| Secret | Options |
|---|---|
LAST1_TOKEN_ENCRYPTION_KEY |
(A) Per-env under LASTVET_* — implies LastVet-owned keys. (B) LAST1_* in a shared Last1 BWS project — implies platform key. |
ONSET_IDENTITY_ASSERTION_SECRET |
(A) Keep in lastvet. (B) Move to onset project; lastvet reads via sync. (C) LAST1_* shared. |
Until decided, do not rename these two.
3. R940 imaging env strategy¶
Code today expects IMAGING_S3_BUCKET + IMAGING_KMS_KEY_ARN (AWS). BWS already has MinIO + ZFS envelope material. Pick one before Week 2:
- A) Week 2 deploy without imaging routes (cap disabled).
- B) Add MinIO env vars + code adapter (scope creep — needs explicit gate approval).
- C) Point R940 staging at AWS S3 temporarily (violates Case B intent).
Manifest reconciliation¶
bitwarden/secrets-manifest.json is stale relative to live BWS:
| Manifest | Live BWS |
|---|---|
| Lists SendGrid, Stripe, Railway DB keys | Missing SendGrid, Stripe, Google |
| Missing all R940 keys | Has R940 DB, MinIO, cloudflared, ZFS |
Missing SECURE_LINK_HMAC_SECRET, VA_FORMS_API_KEY |
Present in BWS |
Follow-up task (separate PR): Regenerate manifest from this plan after Phase 2 renames.
Sub-task B — Issue detail (cross-reference)¶
| # | Secret(s) | Category | Detail |
|---|---|---|---|
| 1–4 | DATABASE_APP_URL_{STAGING,PRODUCTION}, MIGRATION_* |
Legacy pointer | *.railway.internal hosts |
| 5–6 | LASTVET_APP_PASSWORD_* |
Legacy + ambiguous | Railway-era naming; no RAILWAY in key |
| 7–8 | DATABASE_APP_URL_R940_* |
Ambiguous scope | Good values; inconsistent prefix order |
| 9–14 | JWT/HMAC/crypto keys (single undifferentiated copy) | Ambiguous scope | BWS holds one value; AWS has per-env SM |
| 15–16 | LASTVET_APP_PASSWORD_* + Railway URLs |
Duplicated | Password embedded in URL string |
| 17 | OAUTH_JWT_SECRET note |
Stale metadata | Claims veteran JWT fallback (removed) |
| 18–20 | VA_FORMS_API_KEY, VA_OPEN_DATA_API_KEY |
Orphan (API) | iOS / parked |
| 21–23 | E2E_OTP_IMAP_* |
Orphan (API) | Test harness only |
| 24 | CLOUDFLARE_ACCOUNT_ID |
Orphan (API) | CI/operator |
| 25–28 | R940 MinIO/ZFS/cloudflared | Infra-only | Correct in BWS; not API env vars yet |
| 29+ | ~30 AWS SM keys | Missing from BWS | Operator cannot reconstruct ECS env from BWS |
References¶
BITWARDEN_ENV_AUDIT.md— full runtime var catalogbitwarden/secrets-manifest.json— intended sync mappingdocs/architecture/LASTVET_MIGRATION_PLAN.md— Case B / R940 timelinelast.vet-api/infra/modules/compute/main.tf— ECSapp_secret_keyslast.vet-api/docs/deployment/ENVIRONMENT_TOPOLOGY.md— AWS authority rules