Three Low/Info hardening items from the 2026-06-10 security review.
OAuth/credential log redaction:
- oauth-flow.server.ts logged the raw exception on code-exchange
failure; a provider error can embed the auth code / token response,
which would land in logs + Sentry. Log only e.message now.
- manager.markNeedsRelink bounded the provider-supplied reason string
to 200 chars before logging.
Magic-link account enumeration:
- createMagicToken now returns null (instead of throwing "No account
found for this email") when no account matches; the login route
always responds { step: "magic-link-sent" }, minting a token and
sending mail only for a real account. The public login form can no
longer be used to probe which emails are registered. Registration's
email/username "already in use/taken" messages are intentionally
unchanged — standard signup UX, and the passkey ceremony can't be
made to fake-succeed.
Federation remote-document size cap:
- assertRemoteDocSize rejects an actor/outbox document over 4 MB once
serialized, applied in the ingest fetchJson seam. Fedify owns the
transfer (with its own SSRF + redirect limits) and our poll uses the
authenticated loader for secure-mode instances, so this is a
downstream guard on iteration/persistence, complementing the existing
per-poll item cap.
Tests: assertRemoteDocSize unit cases; a gated (FEDERATION_INTEGRATION=1)
integration test asserting an unsigned POST to /users/:u/inbox is
rejected with 401 — a regression guard for Fedify's signature
verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
social-federation tasks 2.1–2.5:
- users.public_key / users.private_key_encrypted (TEXT NULL): RSA 2048
keypairs as JWK JSON; private key AES-256-GCM encrypted at rest with
FEDERATION_KEY_ENCRYPTION_KEY.
- remote_actors table: cache of remote AP actors (display fields,
inbox/outbox URLs, public key, software discovery field, poll cursor).
- activities.remote_origin_iri (UNIQUE) / remote_actor_iri / audience:
provenance + audience tagging for rows ingested from remote outboxes.
Replay-safe ingestion keys off the unique origin IRI.
- crypto.server.ts: generalized into createAesCipher(envVar, salt)
factory; existing INTEGRATION_SECRET surface unchanged.
- federation-keys.server.ts: generate/ensure/load keypairs.
RSASSA-PKCS1-v1_5 because that's what Mastodon interops on.
- backfill-user-keypairs pg-boss job: enqueued once per server startup
when FEDERATION_ENABLED=true; only touches users with NULL keys, so
re-runs are no-ops. New users get keys at registration (both passkey
and magic-link paths), best-effort with the backfill as safety net.
- design.md: noted open question — activities.owner_id is NOT NULL but
remote-ingested rows have no local owner; decide in task 7.2.
Schema is additive; zero behavior change while FEDERATION_ENABLED is
off. db:push verified clean against local Postgres.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
verifyLoginCode, verifyMagicToken, and verifyEmailChange previously did
SELECT WHERE used_at IS NULL → UPDATE … SET used_at = now. Two concurrent
verifications could both pass the SELECT and both succeed, accepting the
same single-use token twice.
Collapsed each to a single UPDATE … WHERE … RETURNING * statement.
Postgres serializes row-level locks within an UPDATE, so exactly one
concurrent caller observes a returned row; the rest see an empty array
and get \"Invalid or expired\". The token is also marked used as part of
the same statement — no second write needed.
verifyEmailChange's tertiary email-availability check now runs *after*
the consume; we keep the original semantics where the token is burned
on a clash (the previous code explicitly did the same with a separate
UPDATE).
No behavior change on the happy path. Closes a credential-reuse
window that mattered most for the 6-digit login codes (small search
space, more likely to race).
Full repo: pnpm typecheck, pnpm lint, pnpm test all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses 8 issues from the Journal architecture audit:
1. DB indexes on routes.ownerId + activities.ownerId. Listing queries on
these tables were full table scans; adds composite indexes matching
the order-by columns (updatedAt/startedAt/createdAt).
2. Zod validation on /api/auth/register body. Previously the action
destructured request.json() with zero schema validation.
3. N+1 GeoJSON batch fetch collapsed to a single ANY($1::text[]) query
in both routes.server and activities.server.
4. Webhook envelope validation in /api/sync/webhook/:provider.
5. AbortSignal.timeout(30s) on all external fetches (Komoot, Wahoo) via
a new fetchWithTimeout helper in lib/http.server.ts.
6. .limit(100) on listPublicRoutesForOwner / listPublicActivitiesForOwner.
9. Welcome email moved off fire-and-forget onto a pg-boss job with
retryLimit: 3 (send-welcome-email).
10. process.env.ORIGIN ?? "http://localhost:3000" centralized into
lib/config.server.ts::getOrigin() across 14 call sites.
Issues 7 (centralized apiError/auth guards across 60+ route files) and
8 (split .server.ts boundaries across 20+ route files) intentionally
deferred — both are pure refactors that would balloon this PR past
reviewability and warrant their own focused PRs.
Tests added:
- lib/config.server.test.ts (2 cases)
- lib/http.server.test.ts (3 cases — timeout abort, success passthrough,
caller-signal composition)
- routes/api.sync.webhook.$provider.test.ts (6 cases)
- routes/api.auth.register.test.ts (7 cases — schema rejection paths +
the new welcome-email enqueue assertion)
Full repo: pnpm typecheck, pnpm lint, pnpm test all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cleanups in one pass:
1. Update import paths app-wide from `~/lib/auth.server` to
`~/lib/auth/session.server` for the four session helpers
(sessionStorage, createSession, getSessionUser, destroySession).
~40 files: 33 simple path swaps where the file imported only session
symbols, 5 splits where it also imported per-method auth functions
(auth.verify.tsx, api.settings.email.ts, activities.\$id.tsx,
routes.\$id.tsx, auth.accept-terms.tsx) — those keep one import
from auth.server (for verifyMagicToken, canView, recordTermsAcceptance,
etc.) and gain a second import from auth/session.server.
Two more files used relative paths and were missed by the first
grep pass (lib/oauth.server.ts and routes/oauth.authorize.tsx) —
migrated too.
The @deprecated re-exports block in auth.server.ts is gone.
2. Rename the new auth files to follow the project's `.server.ts`
convention so Vite/React Router treat them as server-only (they
read process.env.SESSION_SECRET, hit the DB, etc. — must NOT enter
the client bundle):
- auth/session.ts → auth/session.server.ts
- auth/completion.ts → auth/completion.server.ts
- auth/completion.test.ts → auth/completion.server.test.ts
Done with `git mv` so blame is preserved.
Verified: typecheck + lint green; 126 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the notifications system end-to-end (4 types, payload-versioned
JSONB, SSE-based live unread badge, /notifications page, mark-read API,
fan-out job for activity_published, daily 90-day retention purge).
Bell icon in the navbar with unread badge.
Side-findings from exercising the change:
- Add 6-digit magic code to registration (mirrors login UX, mobile
paste-friendly), with `[Register Magic Link]` console line in dev so
the code is reachable without a real email transport.
- Manual passkey/magic-link toggle on the register form (login already
had it).
- Restrict ALPN to http/1.1 in HTTPS dev so React Router's
singleFetchAction CSRF check (Origin vs. Host) passes — Node doesn't
synthesize Host from h2's :authority. Plain HTTP dev unaffected.
- Followers/Following routes now use the locked-account rule from the
profile route (owner + accepted followers see the list; others 404).
Profile page renders the count chips as plain spans for viewers who
can't see the lists, so private profiles don't surface dead links.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the public-content-visibility OpenSpec change. Adds the
smallest social surface that lets us demo the product to logged-out
visitors without user signup.
Schema:
- `visibility text NOT NULL DEFAULT 'private'` on routes + activities.
- Shared Visibility type exported from the schema module.
Access:
- New canView(content, viewer, { asDirectLink }) helper in auth.server.ts
centralises the rule: public → anyone; unlisted → anyone on direct
link; private → owner only.
- routes.$id and activities.$id loaders return 404 (not 403) when
canView rejects, so existence of private content isn't leaked.
- Detail pages emit Open Graph + Twitter Card meta on public/unlisted
content only.
Editing:
- Visibility <select> on routes/:id/edit with owner-only access.
- Activity detail page gets a small visibility form + set-visibility
action intent (no separate activity-edit page needed).
- EN + DE i18n under routes.visibility.* and activities.visibility.*.
Listings:
- Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner
for cross-user queries. Existing owner-scoped listRoutes/listActivities
stay — owners see their own content regardless of visibility.
Public profile:
- /users/:username is now truly public. Renders the user's public
routes + activities, 404s when no public content exists AND viewer
isn't the owner (prevents account enumeration).
- Owner sees a short "this is your profile" note linking to settings.
- Open Graph meta (og:type=profile) for shareable preview.
Privacy manifest:
- Added a bullet noting public content is world-visible on profile
and indexable by search engines.
- Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive
snapshot.
Tests:
- 13 unit tests for canView covering the full matrix.
- 6 E2E tests in e2e/public-content.test.ts covering:
- Private route → 404 for logged-out visitor
- Public route → reachable + OG tags present (og:title, og:type,
og:site_name)
- Owner still sees own private content
- Profile 404 when no public content
- Profile renders when at least one public route exists
- Unlisted route reachable via direct URL but hidden from profile
- Test file runs serially (describe.configure mode=serial) to avoid
WebAuthn virtual-authenticator races under Playwright's default
parallel workers.
- New public-content Playwright project added to config.
Rollout safety: every existing row in prod keeps visibility='private'
by default — nothing becomes visible to outsiders until an owner
explicitly opts in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three pre-legal-disclaimer users (ullrich, pistazie, nelli) have
NULL terms_version, and any future Terms update would leave every
existing user in the same state. Close the loop now that we have
version storage by redirecting any logged-in user whose
users.terms_version doesn't match the currently-published
TERMS_VERSION to a dedicated acceptance page.
Changes:
- auth.server: new recordTermsAcceptance(userId, version) helper that
writes both terms_accepted_at and terms_version.
- root loader: if the session user has a stale or NULL terms_version,
throw redirect("/auth/accept-terms?returnTo=<pathname>") unless the
request is already on an allow-listed path
(/auth/accept-terms, /auth/logout, /legal/*) so Terms are reachable
and logout works.
- New route /auth/accept-terms (GET renders the prompt, POST records
acceptance and bounces to a sanitised returnTo). Same-origin check
on returnTo to avoid open-redirect abuse. Logout button is provided
as an escape hatch.
- i18n: new auth.reaccept.* keys for EN and DE.
- Spec: new Requirement + five scenarios (redirect, allow-list,
successful re-accept, missing consent, returnTo sanitisation).
No action on the three legacy users is required beyond what they'll
experience on their next visit — the gate takes care of it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer's follow-up: it's not enough to record when a user accepted
the Terms; we also need to record which version of the text they saw.
Changes:
- journal.users gains a nullable `terms_version` text column (nullable
so the three pre-existing users without a version are kept as-is).
- New apps/journal/app/lib/legal.ts exports TERMS_VERSION as a single
source of truth, reused by the legal pages' "Last updated" header
and by the registration flow as the value to send/store.
- Registration form posts `termsVersion` alongside `termsAccepted` on
all three relevant steps (start, finish, register-magic-link).
- API route validates that `termsVersion` is a non-empty string on
any step that requires terms, and forwards it to the auth server.
- auth.server finishRegistration and registerWithMagicLink now take
`termsVersion` and persist it on the users row.
- journal-auth spec gets a new scenario for version storage and a
rejection scenario for missing version.
PRIVACY_LAST_UPDATED is also exported from the same module and used
by the Privacy page header, keeping both pages on a single legal.ts
source of truth for "last updated" labels. Privacy is not per-user
stored — it's informational, not contract.
Existing users have NULL terms_version; if we ever prompt them to
re-accept updated Terms, we can backfill with the version they
re-accept at that point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Journal: Impressum (§5 TMG), Terms of Service, GDPR privacy page
- Registration flow: required ToS checkbox, termsAcceptedAt persisted
- Alpha banner on Journal (always visible); alpha badge on Planner hero
- Footer with legal links + GitHub on both apps
- Reduce PII: sendDefaultPii=false, Sentry init only after login (Journal)
- Drop Sentry from Planner (no login, no consent possible)
- Drop localStorage caching from i18n client detection
- Sync SSR i18n resources each request so locale edits HMR without restart
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Magic links open in the device browser, not the OAuth in-app browser,
so the session doesn't carry over. A login code lets mobile users
type a 6-digit code from their email into the login page instead.
- Generate 6-digit numeric code alongside magic link token
- Store code in magic_tokens table
- Add verify-code step to login API endpoint
- Show code input UI after magic link is sent
- Include code in email template
- i18n keys for code UI (en + de)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add `purpose` column (default: 'login') to distinguish login tokens
from email-change tokens
- verifyMagicToken only matches purpose='login'
- verifyEmailChange only matches purpose='email-change'
- Re-check email availability at verification time, not just initiation
— prevents race where someone registers with the new email between
request and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- /settings with profile, security, and account sections
- Profile: edit display name and bio
- Security: list passkeys with device labels, add/delete with
last-passkey warning
- Account: email change with magic link verification, account
deletion with username confirmation
- Settings link in nav for authenticated users
- E2E tests: auth guard, profile editing, passkey add/delete,
last-passkey warning, account deletion, nav visibility
- i18n: full en + de translations for all settings UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Login: hide passkey button when browser lacks WebAuthn, default to
magic link mode, hide "back to passkey" link
- Register: detect passkey support and offer magic link registration as
fallback. New server-side registerWithMagicLink() creates account
without credential and sends verification email.
- Home: show passkey count for logged-in users, show amber unsupported
message instead of hiding the add-passkey prompt entirely
- i18n: add passkeyNotSupported, passkeyNotSupportedRegister,
registerWithMagicLink, passkeyStatus keys (en + de)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bug: @simplewebauthn v13 changed credential.id from Uint8Array to
base64url string. Buffer.from(string) without encoding stored the
ASCII text instead of decoded bytes. Authentication then failed to
match because it correctly decoded with "base64url".
Fix: Buffer.from(credential.id, "base64url") in both registration
and add-passkey flows.
Also: catch WebAuthn "not allowed" errors and show a friendly message
instead of the raw browser error.
Existing passkeys must be re-registered after deploy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Node's --experimental-strip-types requires explicit .ts extensions on
relative imports. Add allowImportingTsExtensions to both app tsconfigs
and update server-side imports in planner and journal for consistency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add passkey prompt after magic link login (/?add-passkey=1)
- addPasskeyStart/addPasskeyFinish for existing users
- Dev mode: magic link auto-redirects (returns devLink in response)
- Fix username validation: strip invalid chars instead of HTML pattern
- Separate API routes from page routes for JSON fetch compatibility
All 10 Group 7 tasks complete.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>