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:
parent
47eb2615ec
commit
0cf87b72ab
17 changed files with 100 additions and 35 deletions
|
|
@ -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'`
|
||||
- **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
|
||||
- **WHEN** a user with zero accepted followers creates a public activity
|
||||
- **THEN** the fan-out job runs and inserts zero rows
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
#### Scenario: Post-login add-passkey nudge
|
||||
- **WHEN** a user has just verified via magic-link/code and 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
|
||||
- **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
|
||||
- **AND** once the user adds a passkey the prompt disappears (there is no separate dismiss/suppress mechanism)
|
||||
|
||||
### 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.
|
||||
|
|
@ -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.
|
||||
|
||||
`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`.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
#### 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
- **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
|
||||
- **WHEN** a job exhausts all retries
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
### 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
|
||||
- **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
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -54,5 +54,7 @@ The system SHALL expose a `ConnectedServiceManager` that owns credential lifecyc
|
|||
#### Scenario: Refresh failure flips status to needs_relink
|
||||
- **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
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
- **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
|
||||
- **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
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ Each service SHALL be configured via environment variables defined in Docker Com
|
|||
|
||||
#### Scenario: Journal configuration
|
||||
- **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
|
||||
- **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
|
||||
- **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/`
|
||||
|
||||
#### Scenario: Secret decryption at deploy time
|
||||
- **WHEN** any CD workflow runs
|
||||
- **THEN** the SOPS-encrypted secrets file is decrypted and provided to docker-compose as an env file
|
||||
- **WHEN** `cd-apps.yml` runs
|
||||
- **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
|
||||
- **WHEN** a PR is opened
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Session management and the terms-of-service consent gate for the Journal app. Th
|
|||
## Requirements
|
||||
|
||||
### 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
|
||||
- **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.
|
||||
|
||||
#### 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>`
|
||||
|
||||
#### 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
|
||||
|
||||
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
|
||||
- **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 `/`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- **THEN** the waypoint displays the POI name, icon, and category alongside its coordinates
|
||||
|
||||
#### Scenario: Phone, address, and website shown
|
||||
- **WHEN** a waypoint has POI metadata including phone, address, or website
|
||||
- **THEN** those details are shown in the waypoint detail section with appropriate links (tel: for phone, mailto: or https: for website)
|
||||
#### Scenario: Phone, address, website, and opening hours shown
|
||||
- **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, 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
|
||||
- **WHEN** a waypoint on the route detail page has no associated POI metadata
|
||||
|
|
|
|||
|
|
@ -80,12 +80,14 @@ Each import run SHALL be tracked as a batch with status and statistics.
|
|||
- **WHEN** an import completes
|
||||
- **THEN** the batch shows: totalFound, importedCount, duplicateCount, and duration
|
||||
|
||||
### Requirement: Credential encryption (authenticated mode)
|
||||
Komoot credentials SHALL be encrypted at rest using AES-256-GCM.
|
||||
### Requirement: Credential storage
|
||||
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
|
||||
- **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
|
||||
The Komoot integration SHALL be documented in the privacy manifest.
|
||||
|
|
|
|||
|
|
@ -79,3 +79,16 @@ The dev environment SHALL provide a command to tear down and recreate the local
|
|||
#### Scenario: Reset dev environment
|
||||
- **WHEN** a developer runs `pnpm dev:reset`
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- **THEN** overnight waypoints include a `<type>overnight</type>` element in the GPX
|
||||
- **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`.
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ The Planner SHALL load POIs only within the current map viewport, refreshing whe
|
|||
|
||||
#### Scenario: Load POIs on viewport
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **WHEN** the Overpass API is unreachable
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
- **THEN** the file is decrypted in a temporary editor, and re-encrypted on save
|
||||
|
||||
#### Scenario: CD decryption
|
||||
- **WHEN** the CD workflow 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
|
||||
#### Scenario: App deploy decryption
|
||||
- **WHEN** `cd-apps.yml` runs
|
||||
- **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
|
||||
- **WHEN** a secret is changed
|
||||
|
|
|
|||
|
|
@ -27,13 +27,17 @@ Planner sessions SHALL have a shared text editor for participants to write notes
|
|||
### Requirement: Editor implementation
|
||||
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
|
||||
- **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
|
||||
- **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
|
||||
Notes SHALL be included in GPX exports as `<metadata><desc>`.
|
||||
|
|
|
|||
|
|
@ -24,14 +24,14 @@ The system SHALL send an email containing the magic link when a user requests pa
|
|||
|
||||
#### Scenario: Email content
|
||||
- **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
|
||||
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
|
||||
- **WHEN** a user completes passkey registration
|
||||
- **THEN** a welcome email is sent to their registered email address
|
||||
- **WHEN** a user completes registration (passkey or magic-link)
|
||||
- **THEN** a `send-welcome-email` job is enqueued and the email is delivered asynchronously to their registered email address
|
||||
|
||||
### Requirement: Email templates
|
||||
Each email type SHALL have an HTML template with a plain-text fallback.
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ The Journal SHALL detect when a connected Wahoo account lacks the `routes_write`
|
|||
#### Scenario: Existing connection lacks routes_write
|
||||
- **WHEN** the route owner clicks "Send to Wahoo"
|
||||
- **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
|
||||
|
||||
#### Scenario: Push completes after re-auth
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue