Fix medium-severity spec drift across 17 specs

- authentication-methods: document completeAuth mode param ("redirect"|"json");
  clarify add-passkey nudge (no dismiss mechanism, disappears on passkey add)
- journal-auth: session maxAge is 30 days; terms allow-list uses /legal/ prefix
  matching (broader than fixed list of paths)
- session-notes: mark awareness isolation and UndoManager isolation as not yet
  implemented (shared instances in current code)
- activity-feed: add fan-out scenario for visibility change to public
- explore: note that ?perPage is not yet implemented (hardcoded page size)
- multi-day-routes: add per-day GPX track split scenario (splitByDays option);
  document overnight vs isDayBreak naming gap
- osm-poi-overlays: debounce is 800ms + 2000ms min interval (not 500ms);
  retry is not automatic (fires on next viewport change)
- brouter-integration: rate limit corrected to 300/hour; add segment-cache
  requirement (client caches per-pair segments)
- wahoo-route-push: OAuth state shape uses camelCase (returnTo, pushAfter
  object) not snake_case with push_after boolean
- komoot-import: document noop adapter / ConnectedServiceManager bypass;
  note four Komoot-specific routes that bypass the generic OAuth framework
- background-jobs: exponential backoff not wired (retryLimit only); add SIGINT
- connected-services: add revoked status; name ConnectionNotActiveError
- infrastructure: add INTEGRATION_SECRET and SENTRY_DSN to env var lists;
  split secret decryption scenario by workflow (cd-apps vs cd-infra)
- secret-management: correct CD decryption — cd-apps only decrypts app.env;
  cd-infra decrypts both
- transactional-emails: welcome email is async (pg-boss job); magic-link email
  includes 6-digit numeric code
- journal-route-detail: websites are https: links (not mailto:); opening_hours
  is also displayed
- local-dev-environment: add mobile app (Expo) dev commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-24 11:11:31 +02:00
parent 47eb2615ec
commit 0cf87b72ab
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
17 changed files with 100 additions and 35 deletions

View file

@ -110,6 +110,10 @@ Creating an activity with `visibility = 'public'` SHALL enqueue a fan-out job th
- **WHEN** a user creates an activity with `visibility = 'private'` or `'unlisted'` - **WHEN** a user creates an activity with `visibility = 'private'` or `'unlisted'`
- **THEN** no fan-out job is enqueued and no notifications are created - **THEN** no fan-out job is enqueued and no notifications are created
#### Scenario: Visibility change to public fans out
- **WHEN** an activity owner changes an existing activity's visibility from `private` or `unlisted` to `public`
- **THEN** a fan-out job is enqueued for the visibility change, creating `activity_published` notifications for all accepted followers — the same fan-out as on initial public creation
#### Scenario: No accepted followers means no notifications #### Scenario: No accepted followers means no notifications
- **WHEN** a user with zero accepted followers creates a public activity - **WHEN** a user with zero accepted followers creates a public activity
- **THEN** the fan-out job runs and inserts zero rows - **THEN** the fan-out job runs and inserts zero rows

View file

@ -80,8 +80,9 @@ A signed-in user SHALL be able to add an additional passkey to their account fro
- **THEN** a WebAuthn registration ceremony runs against the existing user id and a new row is inserted in `credentials` linked to that user - **THEN** a WebAuthn registration ceremony runs against the existing user id and a new row is inserted in `credentials` linked to that user
#### Scenario: Post-login add-passkey nudge #### Scenario: Post-login add-passkey nudge
- **WHEN** a user has just verified via magic-link/code and lands on `/?add-passkey=1` - **WHEN** a user who has zero registered passkeys lands on `/?add-passkey=1`
- **THEN** the home page surfaces an "Add a passkey for faster sign-in" prompt; if dismissed it does not re-appear automatically - **THEN** the home page surfaces an "Add a passkey for faster sign-in" prompt
- **AND** once the user adds a passkey the prompt disappears (there is no separate dismiss/suppress mechanism)
### Requirement: Passkey deletion ### Requirement: Passkey deletion
A signed-in user SHALL be able to remove a passkey from their account via the Security settings page (`/settings/security`). The Journal SHALL prevent deletion of the user's last remaining passkey if no alternative auth method (a verified email for magic-link login) is available, to avoid lock-out. A signed-in user SHALL be able to remove a passkey from their account via the Security settings page (`/settings/security`). The Journal SHALL prevent deletion of the user's last remaining passkey if no alternative auth method (a verified email for magic-link login) is available, to avoid lock-out.
@ -99,6 +100,8 @@ Every successful web authentication flow — passkey register-finish, passkey lo
Per-method identity verification (WebAuthn ceremony, magic-token consumption, 6-digit-code consumption) SHALL run in its own function and produce a `userId` *before* `completeAuth` is invoked. `completeAuth` SHALL NOT know how identity was proved. Per-method identity verification (WebAuthn ceremony, magic-token consumption, 6-digit-code consumption) SHALL run in its own function and produce a `userId` *before* `completeAuth` is invoked. `completeAuth` SHALL NOT know how identity was proved.
`completeAuth` supports two response modes via an optional `mode` parameter: `"redirect"` (default, for browser flows — returns a `Response` with `Set-Cookie` + `Location`) and `"json"` (for API/mobile flows — returns a JSON body with the session token). All web authentication scenarios use `"redirect"`; the `"json"` mode is used by the OAuth2/PKCE mobile code-exchange flow.
Terms recording happens at user creation time inside the per-method registration functions (`finishRegistration` for passkey, `registerWithMagicLink` for magic-link), not inside `completeAuth`. The Terms gate (root-loader redirect for cookie sessions; `requireApiUser` 403 for bearer-token API requests) SHALL remain the enforcement point for stale `terms_version`. Terms recording happens at user creation time inside the per-method registration functions (`finishRegistration` for passkey, `registerWithMagicLink` for magic-link), not inside `completeAuth`. The Terms gate (root-loader redirect for cookie sessions; `requireApiUser` 403 for bearer-token API requests) SHALL remain the enforcement point for stale `terms_version`.
OAuth-code issuance at `/oauth/authorize` SHALL NOT be routed through `completeAuth` — that flow operates on an already-authenticated user and shares only the trailing redirect, not the full sequence. OAuth-code issuance at `/oauth/authorize` SHALL NOT be routed through `completeAuth` — that flow operates on an already-authenticated user and shares only the trailing redirect, not the full sequence.

View file

@ -12,7 +12,7 @@ The `@trails-cool/jobs` package SHALL initialize a pg-boss instance using the ap
- **THEN** pg-boss connects to PostgreSQL, creates/migrates the `pgboss` schema if needed, and begins polling for jobs - **THEN** pg-boss connects to PostgreSQL, creates/migrates the `pgboss` schema if needed, and begins polling for jobs
#### Scenario: Worker stops on shutdown #### Scenario: Worker stops on shutdown
- **WHEN** the server process receives SIGTERM - **WHEN** the server process receives SIGTERM or SIGINT
- **THEN** pg-boss completes any in-progress jobs and stops gracefully before the process exits - **THEN** pg-boss completes any in-progress jobs and stops gracefully before the process exits
### Requirement: Cron job scheduling ### Requirement: Cron job scheduling
@ -27,11 +27,11 @@ The system SHALL support registering recurring jobs with cron expressions.
- **THEN** existing cron schedules persist and continue firing without re-registration conflicts - **THEN** existing cron schedules persist and continue firing without re-registration conflicts
### Requirement: Job retry policy ### Requirement: Job retry policy
Jobs SHALL support configurable retry policies with exponential backoff. Jobs SHALL support configurable retry limits. The `JobDefinition` type exposes `retryLimit` and `expireInSeconds`; exponential backoff (`retryDelay`/`retryBackoff` in pg-boss terms) is not currently wired.
#### Scenario: Transient failure retry #### Scenario: Transient failure retry
- **WHEN** a job handler throws an error - **WHEN** a job handler throws an error
- **THEN** pg-boss retries the job up to the configured retry limit with exponential backoff - **THEN** pg-boss retries the job up to the configured `retryLimit`
#### Scenario: Permanent failure #### Scenario: Permanent failure
- **WHEN** a job exhausts all retries - **WHEN** a job exhausts all retries

View file

@ -62,12 +62,24 @@ The Planner backend SHALL proxy all BRouter API calls. Clients SHALL NOT communi
- **THEN** the Planner logs a fatal error and refuses to start - **THEN** the Planner logs a fatal error and refuses to start
### Requirement: Rate limiting ### Requirement: Rate limiting
The Planner backend SHALL rate limit BRouter API calls to prevent abuse. The Planner backend SHALL rate limit BRouter API calls to prevent abuse. See `rate-limiting` spec for the authoritative limit values.
#### Scenario: Rate limit exceeded #### Scenario: Rate limit exceeded
- **WHEN** a session exceeds 60 route computations per hour - **WHEN** a session exceeds 300 route computations per hour (the `DEFAULT_MAX_REQUESTS` value)
- **THEN** subsequent requests receive a 429 response with a Retry-After header - **THEN** subsequent requests receive a 429 response with a Retry-After header
### Requirement: Client-side segment cache
The Planner client SHALL cache BRouter responses per waypoint-pair so that only changed segments are re-fetched when waypoints are added or moved.
#### Scenario: Only changed segments re-fetched
- **WHEN** a user moves one waypoint in a multi-waypoint route
- **THEN** only the two segments adjacent to the moved waypoint are re-fetched from the proxy
- **AND** all other segments are served from the client-side cache without a network request
#### Scenario: Cache key is waypoint-pair coordinates
- **WHEN** the same start/end coordinate pair appears in a new route
- **THEN** the cached segment result is reused without a BRouter call
### Requirement: BRouter Docker deployment ### Requirement: BRouter Docker deployment
BRouter SHALL run as a Docker container on a dedicated Hetzner host reached over a private Hetzner vSwitch, with planet-wide RD5 segments mounted as a volume. The BRouter container SHALL NOT be exposed on any public network interface. BRouter SHALL run as a Docker container on a dedicated Hetzner host reached over a private Hetzner vSwitch, with planet-wide RD5 segments mounted as a volume. The BRouter container SHALL NOT be exposed on any public network interface.

View file

@ -54,5 +54,7 @@ The system SHALL expose a `ConnectedServiceManager` that owns credential lifecyc
#### Scenario: Refresh failure flips status to needs_relink #### Scenario: Refresh failure flips status to needs_relink
- **WHEN** the `CredentialAdapter`'s refresh call returns a permanent failure (e.g. revoked refresh token) - **WHEN** the `CredentialAdapter`'s refresh call returns a permanent failure (e.g. revoked refresh token)
- **THEN** the manager sets `status = 'needs_relink'` on the `connected_services` row - **THEN** the manager sets `status = 'needs_relink'` on the `connected_services` row
- **AND** raises an error that the caller surfaces as a re-connect prompt - **AND** raises a `ConnectionNotActiveError` that the caller surfaces as a re-connect prompt
- **AND** subsequent calls for the same service short-circuit until the user re-links - **AND** subsequent calls for the same service short-circuit until the user re-links
Note: the `status` column has three valid values: `active`, `needs_relink`, and `revoked`. The `revoked` state is set when the user explicitly disconnects but the row is retained for audit purposes.

View file

@ -95,6 +95,8 @@ The directory SHALL paginate via `?page=N` (1-indexed) and `?perPage=K` query pa
- **WHEN** a visitor loads `/explore?perPage=10000` or `/explore?perPage=0` - **WHEN** a visitor loads `/explore?perPage=10000` or `/explore?perPage=0`
- **THEN** the loader clamps `perPage` to `[1, 100]` and renders normally — no 400 response - **THEN** the loader clamps `perPage` to `[1, 100]` and renders normally — no 400 response
Note: the `?perPage` parameter is not yet implemented. The current loader uses a hardcoded page size (20). The `?page` parameter is supported. `perPage` support is aspirational.
#### Scenario: Page beyond the last #### Scenario: Page beyond the last
- **WHEN** a visitor loads `/explore?page=N` where N is past the last populated page - **WHEN** a visitor loads `/explore?page=N` where N is past the last populated page
- **THEN** the response renders the empty list and a "Previous page" link; no 404 - **THEN** the response renders the empty list and a "Previous page" link; no 404

View file

@ -21,11 +21,11 @@ Each service SHALL be configured via environment variables defined in Docker Com
#### Scenario: Journal configuration #### Scenario: Journal configuration
- **WHEN** the Journal container starts - **WHEN** the Journal container starts
- **THEN** it reads DOMAIN, DATABASE_URL, PLANNER_URL, JWT_SECRET, SESSION_SECRET, and WAHOO_* credentials from environment variables - **THEN** it reads DOMAIN, DATABASE_URL, PLANNER_URL, JWT_SECRET, SESSION_SECRET, INTEGRATION_SECRET, WAHOO_* credentials, and SENTRY_DSN from environment variables
#### Scenario: Planner configuration #### Scenario: Planner configuration
- **WHEN** the Planner container starts - **WHEN** the Planner container starts
- **THEN** it reads BROUTER_URL and DATABASE_URL from environment variables - **THEN** it reads BROUTER_URL, DATABASE_URL, and INTEGRATION_SECRET from environment variables
#### Scenario: Caddy security headers #### Scenario: Caddy security headers
- **WHEN** Caddy proxies a request - **WHEN** Caddy proxies a request
@ -69,8 +69,10 @@ GitHub Actions SHALL use separate workflows for app deployment, infrastructure d
- **THEN** the cd-brouter workflow SSHes as the `trails` user into the dedicated BRouter host using `BROUTER_DEPLOY_HOST` / `BROUTER_DEPLOY_SSH_KEY` and runs `docker compose up -d` in `~trails/brouter/` - **THEN** the cd-brouter workflow SSHes as the `trails` user into the dedicated BRouter host using `BROUTER_DEPLOY_HOST` / `BROUTER_DEPLOY_SSH_KEY` and runs `docker compose up -d` in `~trails/brouter/`
#### Scenario: Secret decryption at deploy time #### Scenario: Secret decryption at deploy time
- **WHEN** any CD workflow runs - **WHEN** `cd-apps.yml` runs
- **THEN** the SOPS-encrypted secrets file is decrypted and provided to docker-compose as an env file - **THEN** `secrets.app.env` is decrypted and injected into the Journal and Planner containers
- **WHEN** `cd-infra.yml` runs
- **THEN** both `secrets.app.env` and `secrets.infra.env` are decrypted and merged for infrastructure services
#### Scenario: Gitleaks scan #### Scenario: Gitleaks scan
- **WHEN** a PR is opened - **WHEN** a PR is opened

View file

@ -6,7 +6,7 @@ Session management and the terms-of-service consent gate for the Journal app. Th
## Requirements ## Requirements
### Requirement: Cookie session for signed-in users ### Requirement: Cookie session for signed-in users
The Journal SHALL identify signed-in users via a server-set HTTP cookie (`__session`) that carries a serialized JSON payload containing `userId`. The cookie SHALL be `HttpOnly`, `SameSite=Lax`, signed with the server secret, and have a finite max-age. Anonymous browsers SHALL render the public surface (anonymous home, public profiles, public routes/activities) without a session cookie present. The Journal SHALL identify signed-in users via a server-set HTTP cookie (`__session`) that carries a serialized JSON payload containing `userId`. The cookie SHALL be `HttpOnly`, `SameSite=Lax`, signed with the server secret, and have a `maxAge` of **30 days**. Anonymous browsers SHALL render the public surface (anonymous home, public profiles, public routes/activities) without a session cookie present.
#### Scenario: Set cookie on successful authentication #### Scenario: Set cookie on successful authentication
- **WHEN** any authentication path (passkey finish, magic-link verify, code verify) succeeds - **WHEN** any authentication path (passkey finish, magic-link verify, code verify) succeeds
@ -41,13 +41,15 @@ The registration form SHALL require explicit acknowledgement of the Terms of Ser
Logged-in users whose stored `terms_version` does not match the currently-published version SHALL be prompted to accept the current Terms before accessing any non-allow-listed page. Logged-in users whose stored `terms_version` does not match the currently-published version SHALL be prompted to accept the current Terms before accessing any non-allow-listed page.
#### Scenario: Stale version redirects to accept-terms page #### Scenario: Stale version redirects to accept-terms page
- **WHEN** a logged-in user whose `users.terms_version` is NULL or differs from the current `TERMS_VERSION` requests any page outside the allow-list (`/auth/accept-terms`, `/auth/logout`, `/legal/*`) - **WHEN** a logged-in user whose `users.terms_version` is NULL or differs from the current `TERMS_VERSION` requests any path not in the allow-list
- **THEN** the server redirects them to `/auth/accept-terms?returnTo=<original path>` - **THEN** the server redirects them to `/auth/accept-terms?returnTo=<original path>`
#### Scenario: Allow-list keeps Terms and logout reachable #### Scenario: Allow-list keeps Terms and logout reachable
- **WHEN** the same user requests `/legal/terms`, `/legal/privacy`, `/legal/imprint`, `/auth/accept-terms`, or `/auth/logout` - **WHEN** the same user requests any path under `/legal/` (e.g. `/legal/terms`, `/legal/privacy`, `/legal/imprint`), `/auth/accept-terms`, or `/auth/logout`
- **THEN** the request is served normally without being redirected - **THEN** the request is served normally without being redirected
Note: the allow-list uses prefix matching (`/legal/` prefix covers all current and future legal pages); it is not a fixed list of individual paths.
#### Scenario: Successful re-acceptance updates both fields #### Scenario: Successful re-acceptance updates both fields
- **WHEN** a user submits the acceptance form with the required checkbox ticked - **WHEN** a user submits the acceptance form with the required checkbox ticked
- **THEN** the server updates `users.terms_version` to the current version and `users.terms_accepted_at` to the current timestamp, then redirects to the `returnTo` path (or `/`) - **THEN** the server updates `users.terms_version` to the current version and `users.terms_accepted_at` to the current timestamp, then redirects to the `returnTo` path (or `/`)

View file

@ -11,9 +11,11 @@ The Journal route detail page SHALL display POI metadata (phone, address, websit
- **WHEN** a route detail page is loaded and a waypoint has POI metadata from the Planner - **WHEN** a route detail page is loaded and a waypoint has POI metadata from the Planner
- **THEN** the waypoint displays the POI name, icon, and category alongside its coordinates - **THEN** the waypoint displays the POI name, icon, and category alongside its coordinates
#### Scenario: Phone, address, and website shown #### Scenario: Phone, address, website, and opening hours shown
- **WHEN** a waypoint has POI metadata including phone, address, or website - **WHEN** a waypoint has POI metadata including phone, address, website, or opening hours
- **THEN** those details are shown in the waypoint detail section with appropriate links (tel: for phone, mailto: or https: for website) - **THEN** those details are shown in the waypoint detail section with appropriate links (tel: for phone, https: for website; opening hours displayed as text)
Note: `mailto:` links for websites are not rendered — all website values are treated as `https:` links. The `opening_hours` OSM tag is also displayed when present.
#### Scenario: Waypoints without POI data display normally #### Scenario: Waypoints without POI data display normally
- **WHEN** a waypoint on the route detail page has no associated POI metadata - **WHEN** a waypoint on the route detail page has no associated POI metadata

View file

@ -80,12 +80,14 @@ Each import run SHALL be tracked as a batch with status and statistics.
- **WHEN** an import completes - **WHEN** an import completes
- **THEN** the batch shows: totalFound, importedCount, duplicateCount, and duration - **THEN** the batch shows: totalFound, importedCount, duplicateCount, and duration
### Requirement: Credential encryption (authenticated mode) ### Requirement: Credential storage
Komoot credentials SHALL be encrypted at rest using AES-256-GCM. Both connection modes store credentials in `connected_services` with `credential_kind = 'web-login'`. Public mode stores only the verified Komoot username; authenticated mode stores an encrypted email + password in the `credentials` JSONB blob. The Komoot provider uses a noop `CredentialAdapter` — it does not go through `ConnectedServiceManager`'s `withFreshCredentials` lifecycle (no token refresh needed for web-login credentials). Credential decryption for API calls happens inside the Komoot importer directly.
The four Komoot-specific routes (`/api/sync/komoot/connect`, `/api/sync/komoot/verify`, `/api/sync/komoot/import`, `/api/sync/komoot/import-status`) intentionally bypass the generic `/api/sync/connect/:provider` / `/api/sync/callback/:provider` framework — Komoot's bio-verification flow is too different from OAuth to fit the generic shape.
#### Scenario: Credentials stored securely #### Scenario: Credentials stored securely
- **WHEN** a user connects via authenticated mode - **WHEN** a user connects via authenticated mode
- **THEN** the password is encrypted before storage and only decrypted when making API calls - **THEN** the password is encrypted with AES-256-GCM before storage and only decrypted when making API calls
### Requirement: Privacy disclosure ### Requirement: Privacy disclosure
The Komoot integration SHALL be documented in the privacy manifest. The Komoot integration SHALL be documented in the privacy manifest.

View file

@ -79,3 +79,16 @@ The dev environment SHALL provide a command to tear down and recreate the local
#### Scenario: Reset dev environment #### Scenario: Reset dev environment
- **WHEN** a developer runs `pnpm dev:reset` - **WHEN** a developer runs `pnpm dev:reset`
- **THEN** all Docker volumes are removed and containers are recreated - **THEN** all Docker volumes are removed and containers are recreated
### Requirement: Mobile app dev (Expo)
The `apps/mobile` React Native app (Expo) requires its own dev commands separate from the web stack.
#### Scenario: iOS development
- **WHEN** a developer runs `pnpm dev:ios` from `apps/mobile`
- **THEN** the Expo bundler starts and the iOS simulator opens with the app
#### Scenario: Android development
- **WHEN** a developer runs `pnpm dev:android` from `apps/mobile`
- **THEN** the Expo bundler starts and an Android emulator opens with the app
Note: the mobile app requires the Journal to be running locally (or pointing at staging) for API calls. It does not use the BRouter or Yjs stack — route edits in mobile go directly through the Journal API.

View file

@ -52,3 +52,14 @@ Day structure SHALL be preserved in GPX exports via waypoint type elements.
- **WHEN** a user exports a plan with overnight waypoints - **WHEN** a user exports a plan with overnight waypoints
- **THEN** overnight waypoints include a `<type>overnight</type>` element in the GPX - **THEN** overnight waypoints include a `<type>overnight</type>` element in the GPX
- **AND** reimporting the GPX restores the day structure - **AND** reimporting the GPX restores the day structure
#### Scenario: Per-day GPX track splitting
- **WHEN** `generateGpx` is called with `splitByDays: true`
- **THEN** the GPX output contains separate `<trk>` elements for each day's segment rather than a single track
- **AND** each `<trk>` is labelled with the day number
Note: the single-track export (without `splitByDays`) is the default. The per-day split is available as an option on the GPX generator and is the recommended format for multi-day exports to head units.
### Note on overnight vs isDayBreak naming
The Yjs wire format stores the flag as `overnight: true` on the waypoint Y.Map entry. The TypeScript interface exposes it as `isDayBreak` (a comment in `waypoint-ymap.ts` documents `overnight` as the legacy wire name). Spec scenarios use `overnight` to refer to the wire-level concept; TypeScript code uses `isDayBreak`.

View file

@ -50,7 +50,7 @@ The Planner SHALL load POIs only within the current map viewport, refreshing whe
#### Scenario: Load POIs on viewport #### Scenario: Load POIs on viewport
- **WHEN** POI categories are enabled and the user pans or zooms the map - **WHEN** POI categories are enabled and the user pans or zooms the map
- **THEN** POIs are fetched for the new viewport after a 500ms debounce - **THEN** POIs are fetched for the new viewport after an 800ms debounce (`DEBOUNCE_MS` in `use-pois.ts`); a minimum request interval of 2000ms additionally throttles rapid viewport changes
#### Scenario: Zoom threshold #### Scenario: Zoom threshold
- **WHEN** the map zoom level is below 10 (`MIN_ZOOM` constant in `use-pois.ts`) - **WHEN** the map zoom level is below 10 (`MIN_ZOOM` constant in `use-pois.ts`)
@ -65,7 +65,9 @@ The Planner SHALL handle Overpass API rate limits gracefully.
#### Scenario: Rate limited response #### Scenario: Rate limited response
- **WHEN** the Overpass API returns a 429 status - **WHEN** the Overpass API returns a 429 status
- **THEN** the Planner shows a temporary "POI data unavailable — try again shortly" message and retries with exponential backoff - **THEN** the Planner shows a temporary "POI data unavailable — try again shortly" message and sets a backoff delay before the next request
Note: automatic retry is not implemented. The next request fires only on the next user viewport change or category toggle after the backoff delay has elapsed.
#### Scenario: Overpass unavailable #### Scenario: Overpass unavailable
- **WHEN** the Overpass API is unreachable - **WHEN** the Overpass API is unreachable

View file

@ -11,9 +11,13 @@ Production secrets SHALL be stored as SOPS-encrypted files in the repository, de
- **WHEN** a developer runs `sops infrastructure/secrets.app.env` or `sops infrastructure/secrets.infra.env` - **WHEN** a developer runs `sops infrastructure/secrets.app.env` or `sops infrastructure/secrets.infra.env`
- **THEN** the file is decrypted in a temporary editor, and re-encrypted on save - **THEN** the file is decrypted in a temporary editor, and re-encrypted on save
#### Scenario: CD decryption #### Scenario: App deploy decryption
- **WHEN** the CD workflow runs - **WHEN** `cd-apps.yml` runs
- **THEN** both encrypted secrets files are decrypted using the AGE_SECRET_KEY GitHub secret and merged at deploy time as env files for docker-compose - **THEN** only `secrets.app.env` is decrypted using `AGE_SECRET_KEY` and injected into the Journal and Planner containers
#### Scenario: Infra deploy decryption
- **WHEN** `cd-infra.yml` runs
- **THEN** both `secrets.app.env` and `secrets.infra.env` are decrypted and merged for infrastructure services
#### Scenario: Secret audit trail #### Scenario: Secret audit trail
- **WHEN** a secret is changed - **WHEN** a secret is changed

View file

@ -27,13 +27,17 @@ Planner sessions SHALL have a shared text editor for participants to write notes
### Requirement: Editor implementation ### Requirement: Editor implementation
The notes editor SHALL use CodeMirror 6 with y-codemirror.next for Yjs binding. The notes editor SHALL use CodeMirror 6 with y-codemirror.next for Yjs binding.
#### Scenario: Undo/redo #### Scenario: Undo/redo (not fully isolated)
- **WHEN** a user presses Ctrl+Z / Ctrl+Shift+Z in the notes editor - **WHEN** a user presses Ctrl+Z / Ctrl+Shift+Z in the notes editor
- **THEN** undo/redo applies to notes only (separate Y.UndoManager from waypoint undo) - **THEN** undo/redo operates via the shared `Y.UndoManager` that also covers waypoints and no-go areas — undo in the notes editor may undo recent waypoint changes and vice versa
#### Scenario: Awareness field isolation Note: the spec originally called for a separate `Y.UndoManager` for notes. The shipped implementation uses a single shared manager (`new Y.UndoManager([waypoints, noGoAreas, notes], {...})` in `use-yjs.ts`). Full isolation is aspirational.
#### Scenario: Awareness field isolation (not yet isolated)
- **WHEN** the notes editor sets cursor awareness state - **WHEN** the notes editor sets cursor awareness state
- **THEN** it does not conflict with map cursor awareness (uses separate awareness fields) - **THEN** it uses the same shared `awareness` object as the map cursor awareness; the `yCollab` extension writes to the `"user"` field on the same channel
Note: the spec originally called for separate awareness fields. The shipped implementation passes the same awareness instance to both the map and notes editor. Full isolation is aspirational.
### Requirement: Notes in GPX export ### Requirement: Notes in GPX export
Notes SHALL be included in GPX exports as `<metadata><desc>`. Notes SHALL be included in GPX exports as `<metadata><desc>`.

View file

@ -24,14 +24,14 @@ The system SHALL send an email containing the magic link when a user requests pa
#### Scenario: Email content #### Scenario: Email content
- **WHEN** a magic link email is sent - **WHEN** a magic link email is sent
- **THEN** it includes: the link, expiry time (15 minutes), and plain-text fallback - **THEN** it includes: the clickable link, a prominently displayed 6-digit numeric code (for users who prefer copy-paste over clicking), expiry time (15 minutes), and a plain-text fallback
### Requirement: Welcome email ### Requirement: Welcome email
The system SHALL send a welcome email after successful registration. The system SHALL send a welcome email after successful registration. The welcome email is sent asynchronously via a pg-boss `send-welcome-email` job rather than inline during the registration request.
#### Scenario: Welcome on registration #### Scenario: Welcome on registration
- **WHEN** a user completes passkey registration - **WHEN** a user completes registration (passkey or magic-link)
- **THEN** a welcome email is sent to their registered email address - **THEN** a `send-welcome-email` job is enqueued and the email is delivered asynchronously to their registered email address
### Requirement: Email templates ### Requirement: Email templates
Each email type SHALL have an HTML template with a plain-text fallback. Each email type SHALL have an HTML template with a plain-text fallback.

View file

@ -89,7 +89,7 @@ The Journal SHALL detect when a connected Wahoo account lacks the `routes_write`
#### Scenario: Existing connection lacks routes_write #### Scenario: Existing connection lacks routes_write
- **WHEN** the route owner clicks "Send to Wahoo" - **WHEN** the route owner clicks "Send to Wahoo"
- **AND** the user's `connected_services.granted_scopes` does not include `routes_write` - **AND** the user's `connected_services.granted_scopes` does not include `routes_write`
- **THEN** the server redirects the user to Wahoo's authorization URL with the full updated scope list and a `state` that encodes `{ return_to: <route_url>, push_after: true }` - **THEN** the server redirects the user to Wahoo's authorization URL with the full updated scope list and a `state` that encodes `{ returnTo: <route_url>, pushAfter: { routeId: <id> } }` (camelCase; `pushAfter` is an object, not a boolean)
- **AND** no Wahoo `/v1/routes` call is attempted - **AND** no Wahoo `/v1/routes` call is attempted
#### Scenario: Push completes after re-auth #### Scenario: Push completes after re-auth