Yesterday's prod incident exposed postgres on 0.0.0.0:5432 with the
default 'postgres:postgres' credentials. A scanner ransomware bot
brute-forced it and dropped the database (left a readme_to_recover
note). We restored from a pre-incident dump and the user data is back,
but the underlying weakness was in this docker-compose.yml.
Changes:
- Remove `ports: "5432:5432"` from the postgres service entirely.
Postgres is reachable only via the internal docker network. For
ad-hoc admin access, use an SSH tunnel:
`ssh -L 5432:localhost:5432 deploy@<host>`
- POSTGRES_PASSWORD now reads from `${POSTGRES_PASSWORD:-postgres}`
via env interpolation. Prod `.env` (not in repo) provides the real
value; local dev gets the `postgres` fallback so `docker compose up`
still works without setup.
- Remove the no-longer-needed DATABASE_URL override in the app service
`environment:` — `env_file: .env` already supplies it.
After this lands, the deploy pipeline will rsync the new compose.yml,
recreate containers with no exposed pg port, and substitute the strong
password from .env at container start. Volumes persist, data intact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wave 8 closes the high-value gaps from the original spec while staying
inside MVP scope (encounters/weapons/items still future work).
Spell generator (mirrors the NPC pattern end-to-end)
- src/lib/ai/types.ts: spellParamsSchema, spellResultSchema, Open5eSpell
- src/lib/ai/openrouter.ts: generateSpell using llama-3.3-70b:free for FREE
- src/lib/ai/kimi.ts: generateSpell using moonshot-v1-8k with json_schema
response_format for PRO
- src/pages/api/dm/ai/generate-spell.ts: auth → rate-limit → optional
/spells/ Open5e reference for balance → AI generate → Zod validate →
increment counter. Spells are returned to client and not persisted
(spec only requires NPC persistence for PRO).
- src/components/dm/AiSpellForm.astro: form (level, school, classes,
tone, suggested name) + inline result card rendering with copy-JSON
- src/components/dm/AiKindSwitcher.astro: segmented NPC | Spell control
wrapping both forms; default tab is NPC
Open5e Reference: equipment + magic items tabs
- src/lib/open5e/client.ts: searchEquipment, getEquipmentItem,
searchMagicItems, getMagicItem (plus EquipmentItem / MagicItem types)
- src/lib/client/open5e-ui.ts: Tab type extended to four, search() and
selectItem() dispatch all four
- src/components/dm/Open5eReference.astro: two more tab buttons; new
renderEquipmentCard/Detail and renderMagicItemCard/Detail; filter
dropdown hidden for new tabs (search-by-name only)
- src/lib/client/translation.ts: TranslationType union now includes
"equipment" | "magicitem"
- src/lib/ai/openrouter.ts: translateOpen5eContent type widened (export
Open5eContentType)
- src/pages/api/dm/translate.ts: ALLOWED_TYPES adds equipment/magicitem
and dispatches to the right Open5e fetcher
UX polish
- src/components/dm/AiQuotaExhausted.astro: dedicated state shown to
authenticated FREE users with remaining=0 — replaces the form with
reset-time message and Boosty upgrade hint
- src/pages/{dm,ru/dm}/index.astro: render AiQuotaExhausted when the
guard passes; otherwise render the kind switcher
- src/middleware/index.ts: remove [Middleware] debug console.logs that
were noisy in prod logs (one line per request)
Verified locally: tsc 0 errors, lint 0 errors, vitest 339/339 passing,
npm run build succeeds without auth env vars.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The deploy pipeline was building the app image with only `dist/` inside
and rsync'ing only `dist package*.json docker-compose.yml Dockerfile`
to the server. The drizzle migrations folder never reached prod, so
new schema changes (Wave 1 added `tier`, `boosty_verified_at`, and 5
tables) silently went missing. App requests then failed with
"column does not exist" errors at runtime.
Wave-1 CI step `npm run db:migrate` ran against the in-job postgres
service, not against prod — so it never helped.
Changes:
- src/db/migrate.ts -> src/db/migrate.mjs: plain JS so the prod image
can run it via `node` without devDependencies (no tsx needed).
- Dockerfile: COPY drizzle and src/db/migrate.mjs into the image,
prepend `node ./src/db/migrate.mjs &&` to the CMD. Container fails
to start if migrations fail — better than serving with stale schema.
- .github/workflows/deploy.yml: rsync now also sends `drizzle` and
`src` so the build context on the server has what the Dockerfile
COPYs reference.
- package.json: `db:migrate` script switched to `node src/db/migrate.mjs`.
- eslint.config.mjs: enable node globals for the migrator script.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`validateAuthEnv()` was running at module-load time, which fired during
`astro build` while constructing the route manifest — before any actual
request needed the secrets. CI build failed even though build-time code
doesn't use JWT_SECRET / OAuth secrets.
- src/lib/auth/env.ts: wrap authEnv in a Proxy that runs validation
on first property read; cache the validated object after.
- src/lib/auth/jwt.ts: defer `new TextEncoder().encode(...)` of the
secret behind a memoised getSecret() helper.
- src/lib/auth/oauth.ts: same for the JWT secret; vkOAuthConfig and
yandexOAuthConfig switched to getter-based properties so credential
access is also lazy.
- src/lib/auth/auth.test.ts: 3 tests previously asserted "throws at
module load"; updated to assert "throws on first access" — semantic
guarantee (invalid env throws) is preserved.
Runtime fail-fast is intact: any auth route reading authEnv.X will
throw with the same descriptive Zod error if a var is missing. Build
just no longer crashes when secrets aren't in env (e.g. CI deploy
pipeline running tsc/lint/build without secrets).
Verified: npm run build succeeds with no auth env vars set; tsc 0,
lint 0, vitest 339/339.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 6 API/CORS test files mock the Astro APIContext by spreading
`{} as any` to satisfy fields not under test (cookies, redirect,
clientAddress, etc.). Replacing each cast individually would require
either full typed contexts (obscures tests) or 70+ inline disables.
Add one file-level `eslint-disable @typescript-eslint/no-explicit-any`
with a comment explaining the rationale to each affected file.
Production code remains lint-clean. Verified: tsc 0, lint 0,
vitest 339/339.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an opt-in modal that prompts a PRO user with local data to migrate
their localStorage notes and sessionStorage initiative into the cloud.
- src/lib/client/import.ts: pure helpers (hasLocalData, shouldShowImport)
- src/components/dm/ImportModal.astro: modal UI + import orchestration
with conflict handling for existing "DM Notes" record
- src/i18n/dm-translations.ts: 14 new keys for the import flow
- Wired into both EN and RU dm/index.astro
- tests/import-flow.test.ts: 18 unit tests on the helpers
Local storage is preserved as backup; only a "dm-import-asked" flag is
set after the user makes a decision (import or skip).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Fix auth anti-patterns (JWT, OAuth, logout, middleware, url.origin)
- Extend DB schema with tier, npcs, counters, translations, notes, initiative
- Expand Open5e TypeScript interfaces (Monster, Spell) to full V2
- Build OpenRouter API client for FREE tier
- Build Kimi API client for PRO tier
- Add comprehensive tests for all modules
- Fix typo "combatантов" -> "участников боя" in dm-translations
- Replace hardcoded English "Result", "Rolls:", "Invalid notation"
in DiceRoller with translation keys
- Translate English aria-labels ("Quick dice selectors", "Roll history",
"Combatants") to Russian
- Move InitiativeTracker hardcoded strings ("Итого", "Инициатива",
"Порядок хода", help text) into the translation file
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bump container max-width and right context column at xl/2xl so the
reference and notes panels get noticeably more room on larger monitors.
Mobile and lg layouts are unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pages /dm/ and /ru/dm/ were static (prerendered), so middleware
never ran and Astro.locals.user was always null. Adding
prerender = false allows middleware to authenticate the user
before page render.
- Change COOKIE_NAME from 'session' to 'auth_token' in oauth.ts
- Update logout.ts to use Headers.append() and SameSite=Lax
- Fixes mismatch where callback set 'session' but middleware read 'auth_token'
Browser cannot parse multiple cookies from a single Set-Cookie header
joined by comma (RFC 6265). Use Headers.append() to send each cookie
in its own Set-Cookie header. Fixes state/verifier cookie mismatch.