trails/openspec/specs/account-management/spec.md
Ullrich Schäfer acb92b32fa Spec drift catch-up: URLs, settings split, navbar shape
Outcome of today's `/spec-drift-review`. Concentrated on the
high-and-medium-severity items; low-severity wording left for the
next per-feature change to pick up naturally.

High-severity URL fixes (specs were actively misleading):

- account-management — email-change verification URL was specced as
  `/auth/verify-email-change?token=...`; the actual route is
  `/auth/verify?email-change=1&token=...` (see auth.verify.tsx:8).
  A reader implementing against the old wording would build a link
  that 404s.
- observability — "Both apps SHALL expose a /metrics endpoint" was
  half right: the planner is at /metrics, but the journal exposes
  /api/metrics. The infrastructure spec already had the right URLs;
  the observability spec disagreed with itself. Now both correct,
  with a one-line note explaining the per-app split.

Medium-severity wording drift (Stream E aftermath):

- profile-settings, account-management, connected-services — all
  three said "the settings page SHALL include a [...] section",
  which described the old single-scrollable settings page. Stream E
  (PR #323) split /settings into four sub-pages
  (/settings/{profile,account,security,connections}); rewording each
  spec to point at its specific sub-page. The API endpoints
  (/api/settings/*) and behavior are unchanged.
- authentication-methods — passkey add/delete previously said "via
  the settings page"; now specifically /settings/security.

Code drift fixed inline:

- apps/journal/app/routes/auth.verify.tsx — after a successful
  email change, the redirect was going to `/settings#account` (an
  anchor on the OLD single-scrollable settings page). Stream E
  retired that page; the right destination now is
  `/settings/account`. Without this the user would land on
  /settings/profile (which is what /settings redirects to) instead
  of the page that just changed.

sse-broker spec wording:

- The "no buffering tweaks in the Caddy reverse_proxy block"
  scenario asserted the entry was "a plain `reverse_proxy
  journal:3000`". After PR #329 the journal block has
  `lb_try_duration 30s` / `lb_try_interval 250ms` — neither affects
  streaming, so SSE still works, but the spec's "plain" language
  was no longer literally true. Reworded to forbid only
  buffering-related directives; explicitly call out that
  retry-on-restart directives like lb_try_duration are fine.

Navbar consolidation (journal-landing):

- The shipped navbar's full shape was scattered: notifications said
  "navbar has a bell," explore said "navbar has an Explore entry,"
  but no spec described the avatar dropdown, the primary-nav
  cluster (Feed/Routes/Activities), or the mobile drawer (Stream
  C / PR #324). Added a "Top navbar shape" requirement to
  journal-landing covering all of it — anonymous vs signed-in,
  desktop vs mobile, dropdown contents, drawer behavior. The
  per-feature specs (notifications, explore) still own their own
  badges/entries; this requirement just says what the whole
  cluster looks like.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:19:02 +02:00

4.1 KiB

account-management Specification

Purpose

Lifecycle operations on a user's account: changing the registered email address (with re-verification) and deleting the account. Authentication-method specifics (passkeys, magic links) live in authentication-methods; profile-editing UX lives in profile-settings. This spec covers the irreversible / verification-gated operations exposed from the Journal's settings page.

Requirements

Requirement: Email change with verification

The Account settings page (/settings/account) SHALL include an "Email" section where the signed-in user can request a new email address via POST /api/settings/email. The change SHALL NOT take effect until the user clicks a verification link delivered to the new email; the original address remains active until verification completes.

Scenario: Initiate email change

  • WHEN a signed-in user submits a new email address that is not already in use by another account
  • THEN the server creates a verification token (purpose = email-change) tied to the user's id and the proposed address, sends it to the new address, and the page renders a "check your inbox" confirmation. users.email is unchanged at this point.

Scenario: Reject duplicate email

  • WHEN the submitted new email is already registered to another user
  • THEN the server responds with a validation error and the verification email is not sent
  • WHEN the user follows the verification link (/auth/verify?email-change=1&token=...)
  • THEN the server validates the token (matching purpose, not expired, not used), updates users.email, marks the token used, signs the user back in if necessary, and redirects to /settings/account
  • WHEN the verification link is more than 15 minutes old or has already been used
  • THEN the page shows an expired/used message and the email is unchanged

Requirement: Account deletion is irreversible and owner-bound

The Account settings page (/settings/account) SHALL include a "Delete account" section behind a confirmation step. Deletion SHALL be irreversible and SHALL cascade to all rows owned by the user (per the existing FK ON DELETE CASCADE rules: routes, activities, follows, notifications, magic tokens, sync connections, oauth tokens). Pending follow requests targeting the deleted user SHALL also be cleared.

Scenario: Authenticated user deletes their account

  • WHEN a signed-in user POSTs to /api/settings/delete-account with the confirmation step satisfied
  • THEN the server deletes users row for that user (cascading per schema), invalidates the user's session, and redirects to a logged-out goodbye page

Scenario: Anonymous request is rejected

  • WHEN an unauthenticated request hits /api/settings/delete-account
  • THEN the server responds with HTTP 401 and no rows are deleted

Scenario: A deleted user's authored notifications survive (with actor set null)

  • WHEN a user is deleted who has previously emitted notifications (e.g. follows, activity_published)
  • THEN the recipient's notification rows remain, with actor_user_id set to NULL via ON DELETE SET NULL — so historical context is preserved as "someone followed you" rather than dropping the row

Requirement: Terms re-acceptance gate (cross-cutting)

Settings pages SHALL be reachable while the user has a stale terms_version so they can read the current Terms or sign out, but action endpoints behind settings SHALL NOT execute side effects until the user has re-accepted the current Terms version. The cross-cutting gate that enforces this lives in journal-auth's "Re-accept updated Terms on next visit" requirement; this spec only documents the dependency so settings UX is read in context.

Scenario: Stale-terms user is redirected before reaching settings

  • WHEN a user with a stale terms_version navigates to /settings (or any sub-page like /settings/account)
  • THEN the root loader's Terms gate redirects them to /auth/accept-terms first (per journal-auth); after re-acceptance they return to the settings page and side effects work normally