Spec catchup: drift fixes, account-settings split, notifications archive
Drift (specs aligned to shipped code): - social-follows: locked-account access rule for /users/:u/followers and /users/:u/following (owner + accepted-follower see; non-followers of private get 404). Adds the follow→notification lifecycle requirement. Fills the placeholder Purpose. - public-profiles: counts degrade to plain text (not anchors) for viewers who can't see the lists. Cross-references social-follows. Fills the placeholder Purpose. - journal-auth slimmed to cookie session + Terms gate. Auth methods moved out (see authentication-methods). Splits: - account-settings (14-line stub) deleted, content split into: - profile-settings (display name, bio, profile_visibility) - account-management (email change with verification, account deletion) - connected-services (Wahoo + future external integrations) - authentication-methods split out of journal-auth: passkeys (register/login/add/delete), magic links, 6-digit codes (login + register), method toggle on register/login forms, dev-console fallback. New specs: - sse-broker: /api/events, in-process broker, useUnreadNotifications hook, Caddy passthrough, multi-process forward-compat contract. Archived: notifications change → openspec/changes/archive/2026-04-26-notifications. Promoted the four delta spec files into top-level specs: - specs/notifications/ (new capability) - specs/activity-feed/ (added: public activity fan-out) - specs/journal-landing/ (added: Notifications navbar entry) - specs/social-follows/ (added: follow→notification lifecycle) Added openspec/CAPABILITIES.md grouped index covering all 40 specs with a Conventions section explaining cross-references, naming, and the catch-up-vs-change rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0530dd3e59
commit
37073eafd7
21 changed files with 604 additions and 29 deletions
82
openspec/CAPABILITIES.md
Normal file
82
openspec/CAPABILITIES.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Capabilities Index
|
||||
|
||||
Top-level index of every spec under `openspec/specs/`, grouped by area. Each spec describes a capability the platform exposes (or supports internally). Use this as the entry point when navigating the spec library — the alphabetical directory listing is fine for grep, but groupings make it easier to spot drift, overlap, and coverage gaps.
|
||||
|
||||
When adding a new spec, slot it into the most relevant group below and update this index in the same change. When merging or splitting specs, update both the directory and this index in the same change.
|
||||
|
||||
## Identity & access
|
||||
|
||||
- [`journal-auth`](specs/journal-auth/spec.md) — cookie session, terms-of-service consent gate (initial accept + re-accept on version bump).
|
||||
- [`authentication-methods`](specs/authentication-methods/spec.md) — passkeys (WebAuthn), magic links, 6-digit codes, the method toggle on register/login forms.
|
||||
- [`security-hardening`](specs/security-hardening/spec.md) — cross-cutting hardening (CSP, headers, rate-limit-adjacent concerns).
|
||||
- [`rate-limiting`](specs/rate-limiting/spec.md) — per-route rate-limit policy.
|
||||
|
||||
## Profile & settings
|
||||
|
||||
- [`profile-settings`](specs/profile-settings/spec.md) — display name, bio, profile-visibility editing UX.
|
||||
- [`account-management`](specs/account-management/spec.md) — email change with re-verification, account deletion (irreversible, cascades).
|
||||
- [`connected-services`](specs/connected-services/spec.md) — third-party integrations (Wahoo today; Strava, Garmin later) connected from settings.
|
||||
- [`public-profiles`](specs/public-profiles/spec.md) — `/users/:username` page (full vs. locked stub), open-graph metadata.
|
||||
|
||||
## Social
|
||||
|
||||
- [`social-follows`](specs/social-follows/spec.md) — follow API, follower/following collections (with locked-account access rules), Pending request lifecycle, `/feed`.
|
||||
- [`activity-feed`](specs/activity-feed/spec.md) — the `/feed` aggregation behavior (note: also referenced from `social-follows`; this spec covers feed-specific concerns).
|
||||
|
||||
## Notifications & realtime
|
||||
|
||||
- [`notifications`](specs/notifications/spec.md) — `notifications` table, four event types (follow_request_received / follow_request_approved / follow_received / activity_published), generation hooks, fan-out, `/notifications` page with cursor pagination, mark-read APIs, 90-day retention.
|
||||
- [`sse-broker`](specs/sse-broker/spec.md) — `/api/events` endpoint, in-process pub/sub registry, client `EventSource` hook, Caddy passthrough. Transport-only; specific event payloads are owned by the capability that emits them (notifications today).
|
||||
|
||||
## Routes (Planner core)
|
||||
|
||||
- [`planner-session`](specs/planner-session/spec.md) — anonymous Yjs session lifecycle.
|
||||
- [`session-notes`](specs/session-notes/spec.md) — per-session shared notes.
|
||||
- [`route-management`](specs/route-management/spec.md) — CRUD around saved routes.
|
||||
- [`route-preview`](specs/route-preview/spec.md) — preview rendering of saved routes.
|
||||
- [`route-drag-reshape`](specs/route-drag-reshape/spec.md) — drag-to-reshape interaction.
|
||||
- [`route-splitting`](specs/route-splitting/spec.md) — split routes at points.
|
||||
- [`route-coloring`](specs/route-coloring/spec.md) — generic line color modes.
|
||||
- [`road-type-coloring`](specs/road-type-coloring/spec.md) — road-type-specific color mode.
|
||||
- [`elevation-map-interaction`](specs/elevation-map-interaction/spec.md) — hover-to-locate between elevation chart and map.
|
||||
- [`multi-day-routes`](specs/multi-day-routes/spec.md) — overnight markers + per-day export.
|
||||
- [`no-go-areas`](specs/no-go-areas/spec.md) — drawn polygons that re-route avoidance.
|
||||
- [`crash-recovery`](specs/crash-recovery/spec.md) — recovering an interrupted session.
|
||||
- [`planner-journal-handoff`](specs/planner-journal-handoff/spec.md) — JWT-callback save flow back to Journal.
|
||||
|
||||
## Routing engine
|
||||
|
||||
- [`brouter-integration`](specs/brouter-integration/spec.md) — BRouter container + routing-host election + the API the client expects.
|
||||
|
||||
## Map / overlays
|
||||
|
||||
- [`map-core`](specs/map-core/spec.md) — shared Leaflet wrappers in `@trails-cool/map`.
|
||||
- [`map-display`](specs/map-display/spec.md) — base-tile selection and rendering.
|
||||
- [`osm-tile-overlays`](specs/osm-tile-overlays/spec.md) — OSM raster overlays.
|
||||
- [`osm-poi-overlays`](specs/osm-poi-overlays/spec.md) — POI overlay layer.
|
||||
|
||||
## Imports
|
||||
|
||||
- [`gpx-import`](specs/gpx-import/spec.md) — GPX file parsing and route ingestion.
|
||||
- [`wahoo-import`](specs/wahoo-import/spec.md) — Wahoo activity sync rules.
|
||||
|
||||
## Journal landing & content
|
||||
|
||||
- [`journal-landing`](specs/journal-landing/spec.md) — anonymous landing + signed-in personal dashboard.
|
||||
- [`legal-disclaimers`](specs/legal-disclaimers/spec.md) — Terms / Privacy / Imprint pages.
|
||||
- [`transactional-emails`](specs/transactional-emails/spec.md) — magic-link, welcome, etc. email templates.
|
||||
|
||||
## Infrastructure & ops
|
||||
|
||||
- [`infrastructure`](specs/infrastructure/spec.md) — Hetzner host topology, Caddy front, vSwitch.
|
||||
- [`local-dev-environment`](specs/local-dev-environment/spec.md) — `pnpm dev` orchestration, Docker services.
|
||||
- [`secret-management`](specs/secret-management/spec.md) — SOPS-encrypted env files, key rotation.
|
||||
- [`observability`](specs/observability/spec.md) — Prometheus, Loki, Grafana dashboards.
|
||||
- [`shared-packages`](specs/shared-packages/spec.md) — workspace package boundaries (`@trails-cool/types`, `@trails-cool/map`, etc.).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Spec filenames use kebab-case capability names (`profile-settings`, `social-follows`, `sse-broker`).
|
||||
- Cross-spec references should link to the other spec's path rather than duplicate requirements (see `public-profiles` linking to `social-follows` for the followers/following list access rule, and `account-management` linking to `journal-auth` for the Terms gate).
|
||||
- New behavior lands through `openspec/changes/<name>/` with a delta in `specs/<capability>/spec.md`. The delta is promoted to the top-level spec when the change is archived (via `/opsx:archive`).
|
||||
- Drift catch-up — i.e. updating a spec to match shipped code without changing behavior — can be edited directly without a change, since there's no behavioral payload to propose. Always update `CAPABILITIES.md` in the same edit if a spec is added, removed, or renamed.
|
||||
47
openspec/specs/account-management/spec.md
Normal file
47
openspec/specs/account-management/spec.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# 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 settings page 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
|
||||
|
||||
#### Scenario: Verification link applies the change
|
||||
- **WHEN** the user follows the verification link (`/auth/verify-email-change?token=...`)
|
||||
- **THEN** the server validates the token (matching purpose, not expired, not used), updates `users.email`, marks the token used, and signs the user back in if necessary
|
||||
|
||||
#### Scenario: Expired verification link
|
||||
- **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 settings page 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 can read settings but is blocked from saving
|
||||
- **WHEN** a user with a stale `terms_version` navigates to `/settings`
|
||||
- **THEN** the loader-level 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
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
## Purpose
|
||||
|
||||
User account settings page with connected services management, profile editing, and security controls.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Connected Services section
|
||||
The settings page SHALL include a "Connected Services" section for managing external integrations.
|
||||
|
||||
#### Scenario: Wahoo connection status
|
||||
- **WHEN** a user views the settings page
|
||||
- **THEN** a "Connected Services" section shows Wahoo as connected or disconnected
|
||||
- **AND** connected state shows "Disconnect" button
|
||||
- **AND** disconnected state shows "Connect Wahoo" button
|
||||
|
|
@ -94,3 +94,18 @@ The Journal's own-activities feed SHALL show the owner everything regardless of
|
|||
- **WHEN** a visitor loads `/users/:username`
|
||||
- **THEN** the rendered list of activities includes only the user's `public` activities; `unlisted` and `private` activities are omitted
|
||||
|
||||
|
||||
### Requirement: Public activity creation fans out notifications
|
||||
Creating an activity with `visibility = 'public'` SHALL enqueue a fan-out job that inserts an `activity_published` notification for every accepted follower of the activity owner. The fan-out SHALL run asynchronously so the activity-creation request returns immediately. See `notifications` spec for the notification row shape and the idempotency rules.
|
||||
|
||||
#### Scenario: Public activity fans out
|
||||
- **WHEN** a user with N accepted followers creates an activity with `visibility = 'public'`
|
||||
- **THEN** a pg-boss job is enqueued, and on completion N notifications exist with `type = 'activity_published'`, `recipient_user_id` ∈ accepted-followers, `actor_user_id` = activity owner, `subject_id` = activity id
|
||||
|
||||
#### Scenario: Private or unlisted activity does not fan out
|
||||
- **WHEN** a user creates an activity with `visibility = 'private'` or `'unlisted'`
|
||||
- **THEN** no fan-out job is enqueued and no notifications are created
|
||||
|
||||
#### 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
|
||||
|
|
|
|||
95
openspec/specs/authentication-methods/spec.md
Normal file
95
openspec/specs/authentication-methods/spec.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# authentication-methods Specification
|
||||
|
||||
## Purpose
|
||||
The credentials a user can authenticate with on the Journal: passkeys (WebAuthn) and email magic links / 6-digit codes. Covers registration, login, adding a passkey to an existing account, and the UX toggle on the register/login forms that lets users pick the method that works in their browser. Session management, terms acceptance, and the cookie session cookie itself live in `journal-auth` — this spec only covers what proves identity.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Passkey-based registration
|
||||
The Journal SHALL support WebAuthn passkey registration as the primary auth method when the browser advertises `browserSupportsWebAuthn()`. Registration SHALL create the user row, generate a credential, and start a session in one HTTP round-trip pair (`step: "start"` followed by `step: "finish"`).
|
||||
|
||||
#### Scenario: Browser supports WebAuthn
|
||||
- **WHEN** a visitor completes the Register form with a browser that supports WebAuthn
|
||||
- **THEN** the form prompts for a passkey, the credential is stored in `credentials`, the `users` row is created with `terms_version` and `terms_accepted_at`, and a session cookie is issued
|
||||
|
||||
#### Scenario: Browser does not support WebAuthn
|
||||
- **WHEN** the browser reports `browserSupportsWebAuthn() === false`
|
||||
- **THEN** the Register form auto-switches to the magic-link/code path; the passkey button is not rendered
|
||||
|
||||
### Requirement: Magic link + 6-digit code registration
|
||||
The Journal SHALL support a passwordless registration alternative: the user submits email + username, the server creates the account and a 15-minute magic token (with both a click-through link and a 6-digit code), and the user verifies through either path. Used in production over real email; in development the link/code is logged to the server console (`[Register Magic Link] ...`) so the dev can test without a mail transport.
|
||||
|
||||
#### Scenario: Production register-magic-link flow
|
||||
- **WHEN** a visitor submits the Register form with `step: "register-magic-link"` in production
|
||||
- **THEN** the server inserts the user row, creates a `magic_tokens` row with both `token` and `code`, sends the email (link + 6-digit code), and the form renders a "check your email" confirmation
|
||||
|
||||
#### Scenario: Dev register-magic-link flow
|
||||
- **WHEN** the same flow runs with `NODE_ENV !== "production"`
|
||||
- **THEN** the server logs `[Register Magic Link] <email>: <link> (code: <code>)` to stdout instead of sending email, and the API response includes both `devLink` and `code` so the dev can paste either
|
||||
|
||||
#### Scenario: Verify via 6-digit code on registration
|
||||
- **WHEN** the new user enters the 6-digit code on the post-submit form
|
||||
- **THEN** `POST /api/auth/login { step: "verify-code", email, code }` validates against the same `magic_tokens` row (default `purpose = "login"`), marks it used, and starts a session — registration and first login share the verify-code endpoint
|
||||
|
||||
#### Scenario: Verify via click-through link
|
||||
- **WHEN** the user clicks the link delivered to their inbox (or to the dev console)
|
||||
- **THEN** `/auth/verify?token=...` validates the same row, marks it used, and starts a session
|
||||
|
||||
### Requirement: Passkey login
|
||||
The Journal SHALL support WebAuthn passkey login on `/auth/login`. The page SHALL default to the passkey method when `browserSupportsWebAuthn()` returns true; otherwise it SHALL pre-select the magic-link method.
|
||||
|
||||
#### Scenario: Passkey login on a browser with a registered credential
|
||||
- **WHEN** a returning user clicks "Sign in with passkey" on a browser that already holds the credential
|
||||
- **THEN** WebAuthn authentication completes, the matching `credentials` row is verified, and a session cookie is issued
|
||||
|
||||
#### Scenario: Passkey not found locally
|
||||
- **WHEN** the browser does not present any matching credential during the WebAuthn ceremony
|
||||
- **THEN** the login page surfaces the `auth.passkeyNotFound` error and the user can switch to the magic-link/code flow
|
||||
|
||||
### Requirement: Magic link + code login
|
||||
The Journal SHALL support email-based login as the universal fallback. The login form SHALL include a "Use magic link instead" toggle (for users with WebAuthn but no local credential), which surfaces an email-only form; submitting it sends a magic link (production) or surfaces `devLink` + `code` (dev), and a 6-digit code form lets the user paste the code instead of clicking the link.
|
||||
|
||||
#### Scenario: Send magic link
|
||||
- **WHEN** a registered user submits their email under `step: "magic-link"`
|
||||
- **THEN** a `magic_tokens` row is created with both `token` and `code`, and either the email is sent (prod) or `devLink` + `code` are returned (dev) plus `[Magic Link] <email>: <link> (code: <code>)` is logged to stdout
|
||||
|
||||
#### Scenario: Verify via 6-digit code
|
||||
- **WHEN** the user submits `step: "verify-code"` with the email and the 6-digit code
|
||||
- **THEN** the matching `magic_tokens` row is validated (not expired, not used), marked used, and a session cookie is issued
|
||||
|
||||
#### Scenario: Magic-link-only browser routes through magic-link mode
|
||||
- **WHEN** the login page detects `browserSupportsWebAuthn() === false`
|
||||
- **THEN** the page auto-selects magic-link mode and does not render the passkey button
|
||||
|
||||
### Requirement: Method-toggle UX on register and login
|
||||
The register form and the login form SHALL both render a small text toggle that lets the user manually switch between passkey and magic-link mode when both are available, mirroring the same UX on both surfaces.
|
||||
|
||||
#### Scenario: Toggle on register
|
||||
- **WHEN** a passkey-capable browser visits `/auth/register`
|
||||
- **THEN** the form starts in passkey mode and renders a "Use magic link instead" link below the submit button; clicking it switches the form to the magic-link path
|
||||
|
||||
#### Scenario: Toggle on login
|
||||
- **WHEN** a passkey-capable browser visits `/auth/login`
|
||||
- **THEN** the form starts in passkey mode and renders the same toggle to switch to magic-link mode
|
||||
|
||||
### Requirement: Add passkey to an existing account
|
||||
A signed-in user SHALL be able to add an additional passkey to their account from the settings page (or from the post-login `/?add-passkey=1` prompt). The flow SHALL reuse the WebAuthn registration ceremony but bind the credential to the existing `users.id` rather than creating a new account.
|
||||
|
||||
#### Scenario: Add passkey from settings
|
||||
- **WHEN** a signed-in user clicks "Add passkey" in settings
|
||||
- **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
|
||||
|
||||
### Requirement: Passkey deletion
|
||||
A signed-in user SHALL be able to remove a passkey from their account via the settings page. 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.
|
||||
|
||||
#### Scenario: Delete one of multiple passkeys
|
||||
- **WHEN** a user with 2+ passkeys removes one via `/api/settings/passkey/delete`
|
||||
- **THEN** the matching `credentials` row is deleted; remaining passkeys stay valid
|
||||
|
||||
#### Scenario: Last-passkey safety net (verified email is the fallback)
|
||||
- **WHEN** a user attempts to delete their only remaining passkey
|
||||
- **THEN** the action proceeds because magic-link login by email is always available — passkey deletion does not lock the user out
|
||||
30
openspec/specs/connected-services/spec.md
Normal file
30
openspec/specs/connected-services/spec.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# connected-services Specification
|
||||
|
||||
## Purpose
|
||||
Third-party service connections (Wahoo today; future Strava, Garmin, etc.) that the user opts into from the Journal's settings page. Covers OAuth-based connect / disconnect flows and the storage layout for tokens. Token refresh behavior, webhook ingestion, and the per-service import rules live with each service's own change (e.g. `wahoo-import`).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Connected Services section on the settings page
|
||||
The settings page SHALL include a "Connected Services" section listing each external integration the Journal supports, each row showing the connection state (Connect / Disconnect) and the link to start the OAuth flow when not connected.
|
||||
|
||||
#### Scenario: Wahoo connection status renders both states
|
||||
- **WHEN** a user views the settings page
|
||||
- **THEN** a "Connected Services" section shows Wahoo as connected or disconnected
|
||||
- **AND** connected state shows a "Disconnect" button that POSTs to `/api/sync/disconnect/<provider>`
|
||||
- **AND** disconnected state shows a "Connect Wahoo" button that begins the OAuth handshake
|
||||
|
||||
### Requirement: OAuth token storage in `sync_connections`
|
||||
External-service OAuth tokens SHALL be stored in the `journal.sync_connections` table keyed by `(user_id, provider)`. Each row SHALL persist `access_token`, `refresh_token`, `expires_at`, and the provider-side user id (`provider_user_id`). Disconnecting SHALL delete the row, severing the user's link to the external service without affecting any imported activities.
|
||||
|
||||
#### Scenario: Wahoo connect persists tokens
|
||||
- **WHEN** a user completes the Wahoo OAuth flow
|
||||
- **THEN** a `sync_connections` row is upserted with `provider = 'wahoo'`, the access/refresh tokens, the provider user id, and `expires_at`
|
||||
|
||||
#### Scenario: Disconnect removes the row but keeps imports
|
||||
- **WHEN** a user clicks "Disconnect" on a Wahoo connection
|
||||
- **THEN** the matching `sync_connections` row is deleted; previously imported activities are not deleted (they remain owned by the user, just no longer auto-syncing)
|
||||
|
||||
#### Scenario: Each user has at most one row per provider
|
||||
- **WHEN** a user reconnects an already-connected provider
|
||||
- **THEN** the existing `sync_connections` row is updated in place with the fresh tokens; no duplicate row is created
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
## Purpose
|
||||
# journal-auth Specification
|
||||
|
||||
Authentication for the Journal app, including OAuth token storage for external services in the sync_connections table.
|
||||
## Purpose
|
||||
Session management and the terms-of-service consent gate for the Journal app. The credentials a user authenticates with (passkeys, magic links, magic codes) and the registration UX live in `authentication-methods`; OAuth tokens for third-party services (Wahoo etc.) live in `connected-services`. This spec is the cross-cutting layer: cookie sessions, the Terms-version gate that wraps every authenticated request, and the rules for safely returning users to where they came from.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Store external service tokens
|
||||
The journal auth system SHALL store OAuth tokens for external services alongside user credentials.
|
||||
### 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.
|
||||
|
||||
#### Scenario: Wahoo token storage
|
||||
- **WHEN** a user connects their Wahoo account
|
||||
- **THEN** access token, refresh token, expiry time, and Wahoo user ID are stored in the `wahoo_tokens` table
|
||||
- **AND** tokens are associated with the journal user ID
|
||||
#### Scenario: Set cookie on successful authentication
|
||||
- **WHEN** any authentication path (passkey finish, magic-link verify, code verify) succeeds
|
||||
- **THEN** the response carries a `Set-Cookie: __session=...` header binding the resulting `userId` to the browser
|
||||
|
||||
#### Scenario: Anonymous request renders public surface
|
||||
- **WHEN** a request arrives without `__session` (or with one that fails to verify)
|
||||
- **THEN** loaders treat the request as anonymous; routes that require auth either redirect to `/auth/login` or render the public layout per their own spec
|
||||
|
||||
### Requirement: Terms acknowledgement at signup
|
||||
The registration form SHALL require explicit acknowledgement of the Terms of Service before an account can be created.
|
||||
|
|
|
|||
|
|
@ -64,3 +64,14 @@ For signed-in users, the personal dashboard SHALL include a prominent link to th
|
|||
- **WHEN** an unauthenticated visitor loads `/`
|
||||
- **THEN** the visitor-home layout does not expose a link to `/feed` (the route requires authentication)
|
||||
|
||||
|
||||
### Requirement: Notifications entry in the navbar
|
||||
The navbar SHALL render a "Notifications" entry for signed-in users, linking to `/notifications`. It SHALL render an unread count badge when the user has at least one unread notification, and no badge when the count is zero. This entry SHALL be distinct from the existing "Follow requests" entry — they cover different categories (informational vs. actionable). The badge live-updates via `sse-broker`; the loader-driven count is the SSR baseline.
|
||||
|
||||
#### Scenario: Signed-in user sees the entry
|
||||
- **WHEN** a signed-in user loads any page
|
||||
- **THEN** the navbar includes a "Notifications" link to `/notifications`, and a count badge if the user has unread rows
|
||||
|
||||
#### Scenario: Anonymous user does not see the entry
|
||||
- **WHEN** an unauthenticated visitor loads any page
|
||||
- **THEN** the navbar does not include the Notifications entry (the route requires authentication anyway)
|
||||
|
|
|
|||
145
openspec/specs/notifications/spec.md
Normal file
145
openspec/specs/notifications/spec.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# notifications Specification
|
||||
|
||||
## Purpose
|
||||
In-app notifications for things that happened to a user that they didn't trigger themselves: their follow request was approved, someone followed them, a friend posted a public activity. Distinct from `/follows/requests` (which is the actionable surface for *incoming* Pending requests) and `/feed` (which is content from people you follow). Includes the `notifications` table, generation hooks, the `/notifications` page, mark-read APIs, retention, and the SSE-driven live unread badge (the SSE transport itself lives in `sse-broker`).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Notification rows for follow + activity events
|
||||
The Journal SHALL maintain a `notifications` table where each row records a single event the recipient should be informed about. Four event types SHALL be produced in v1: `follow_request_received`, `follow_request_approved`, `follow_received`, and `activity_published`. Each row SHALL include the recipient user, the actor (the user who caused the event, nullable for system or deleted-actor cases), a `subject_id` whose meaning depends on type (follow row id for follow events; activity id for activity events), a `payload` JSONB snapshot of the renderer-friendly fields at create time, and a `payload_version` (INT, default 1) that documents the per-type payload schema version. Rows SHALL track read state via a nullable `read_at` timestamp.
|
||||
|
||||
#### Scenario: Pending follow request emits a notification to the target
|
||||
- **WHEN** `followUser` creates a Pending row against a private target
|
||||
- **THEN** a `follow_request_received` notification is created with `recipient_user_id` = the followed user, `actor_user_id` = the requester, `subject_id` = the follow row id
|
||||
|
||||
#### Scenario: Approval emits a notification to the requester
|
||||
- **WHEN** `approveFollowRequest` succeeds against a Pending row
|
||||
- **THEN** a `follow_request_approved` notification is created with `recipient_user_id` = the follower, `actor_user_id` = the followed user, `subject_id` = the follow row id
|
||||
|
||||
#### Scenario: Auto-accepted public follow emits a notification to the target
|
||||
- **WHEN** `followUser` creates an accepted row against a public target (auto-accept path)
|
||||
- **THEN** a `follow_received` notification is created with `recipient_user_id` = the followed user, `actor_user_id` = the follower, `subject_id` = the follow row id
|
||||
|
||||
#### Scenario: Public activity creation fans out notifications to followers
|
||||
- **WHEN** a user creates an activity with `visibility = 'public'`
|
||||
- **THEN** a pg-boss job is enqueued that inserts an `activity_published` notification per accepted follower with `recipient_user_id` = follower, `actor_user_id` = activity owner, `subject_id` = activity id
|
||||
|
||||
#### Scenario: Re-following does not duplicate the notification
|
||||
- **WHEN** a user calls `followUser` against a target they already follow (returns existing state without creating a new row)
|
||||
- **THEN** no new `follow_received` or `follow_request_received` notification is created
|
||||
|
||||
#### Scenario: follow_request_received survives request resolution
|
||||
- **WHEN** a target approves, rejects, or the requester cancels a Pending follow that previously emitted `follow_request_received`
|
||||
- **THEN** the `follow_request_received` notification row stays in the database with its existing read-state intact (the event happened; the historical record is preserved independently of the request's eventual outcome)
|
||||
|
||||
#### Scenario: follow_request_received card links to /follows/requests, not inline Approve/Reject
|
||||
- **WHEN** a recipient renders a `follow_request_received` notification on `/notifications`
|
||||
- **THEN** the card shows a "Review request" link that navigates to `/follows/requests`; Approve / Reject buttons are NOT rendered on `/notifications`
|
||||
|
||||
#### Scenario: Read-state is independent from request resolution
|
||||
- **WHEN** a recipient marks a `follow_request_received` notification read OR approves/rejects the request
|
||||
- **THEN** the OTHER surface is unaffected — marking read does not approve/reject; approve/reject does not mark the notification read
|
||||
|
||||
### Requirement: Versioned payload snapshot for offline renderers
|
||||
Each notification row SHALL include a `payload` (JSONB) capturing the denormalized fields needed to render the row without a live DB lookup, and a `payload_version` (INT) recording the per-type schema version of `payload`. The web renderer SHALL prefer live data when the subject is still reachable and SHALL fall back to the payload when the subject has been deleted, gone private, or is otherwise unreachable. Future renderers (mobile push, email) SHALL read the payload directly.
|
||||
|
||||
#### Scenario: follow notifications snapshot the follower / target
|
||||
- **WHEN** a `follow_request_received`, `follow_received`, or `follow_request_approved` notification is created
|
||||
- **THEN** the row's `payload` records the relevant party's `username` and `displayName`, and `payload_version = 1`
|
||||
|
||||
#### Scenario: activity_published snapshots the activity + owner
|
||||
- **WHEN** an `activity_published` notification is created
|
||||
- **THEN** the row's `payload` records `activityId`, `activityName`, `ownerUsername`, and `ownerDisplayName`, and `payload_version = 1`
|
||||
|
||||
#### Scenario: Web renderer falls back to snapshot if subject is unreachable
|
||||
- **WHEN** a recipient renders a notification whose subject (e.g. activity) has been deleted or made private since creation
|
||||
- **THEN** the renderer uses the `payload` fields (e.g. activity name from the snapshot) so the row still has meaningful copy; click-through degrades gracefully (the link target may 404, which the user sees once they click)
|
||||
|
||||
### Requirement: Single `linkFor` helper produces per-platform deep links
|
||||
The Journal SHALL expose a single server-side helper `linkFor(notification)` returning a `LinkBundle` with at minimum a `web` path and (for forward-compat) `mobile` and `email` URL variants. Every renderer (web loader, future mobile push formatter, future email formatter) SHALL use this helper so the type-to-URL mapping lives in exactly one file.
|
||||
|
||||
#### Scenario: Web loader resolves a click-through link
|
||||
- **WHEN** the `/notifications` loader prepares a row for render
|
||||
- **THEN** the row carries `linkFor(row).web` so the card's anchor can navigate to the right page (`/activities/...`, `/users/...`, or `/follows/requests`)
|
||||
|
||||
#### Scenario: Helper builds links from subject_id with payload fallback
|
||||
- **WHEN** `linkFor` is invoked on a notification whose `subject_id` is non-null
|
||||
- **THEN** the path is built from `subject_id`; if `subject_id` is null, the helper falls back to the relevant `payload` field (e.g. `payload.activityId`)
|
||||
|
||||
#### Scenario: Helper outputs a `trails://` mobile scheme
|
||||
- **WHEN** `linkFor` is invoked for any v1 notification type
|
||||
- **THEN** the returned `LinkBundle.mobile` is a `trails://` URL pointing at the same logical destination as `web`
|
||||
|
||||
#### Scenario: Activity changing to private after publish
|
||||
- **WHEN** an activity that already triggered fan-out notifications is later changed from `public` to `private`
|
||||
- **THEN** the existing notification rows remain in the database but are filtered out of the recipient's `/notifications` listing (the renderer skips rows whose subject the recipient can no longer see)
|
||||
|
||||
### Requirement: Notifications page and unread count
|
||||
The Journal SHALL expose `/notifications` to signed-in users only. The page SHALL list the user's notifications reverse-chronological, with each row showing the actor's display name + handle, a type-specific summary line, the timestamp, and a clickable link to the relevant subject. The page SHALL paginate via opaque cursor (a `before` query parameter); when more rows exist past the current page, the page SHALL surface a "Load older" affordance that fetches the next page using the cursor. Page size defaults to 50 and is capped at 100. The navbar SHALL surface an unread count badge linking to `/notifications`; the count is `notifications.read_at IS NULL` for the current user. Logged-out visitors requesting `/notifications` SHALL be redirected to `/auth/login`. The live-update transport for the unread badge is `sse-broker` (`/api/events`); this spec only requires the badge to reflect the count.
|
||||
|
||||
#### Scenario: Logged-in user with notifications
|
||||
- **WHEN** a signed-in user with N notifications (N ≤ page size) loads `/notifications`
|
||||
- **THEN** the page lists all N rows reverse-chronological by `created_at`, with unread rows visually distinct from read rows
|
||||
|
||||
#### Scenario: Logged-in user with no notifications
|
||||
- **WHEN** a signed-in user with zero notifications loads `/notifications`
|
||||
- **THEN** the page renders an empty-state message
|
||||
|
||||
#### Scenario: Anonymous request
|
||||
- **WHEN** an unauthenticated visitor requests `/notifications`
|
||||
- **THEN** they are redirected to `/auth/login`
|
||||
|
||||
#### Scenario: Navbar badge reflects unread count
|
||||
- **WHEN** a signed-in user has K > 0 unread notifications
|
||||
- **THEN** the "Notifications" navbar entry renders with a count badge showing K
|
||||
- **AND** when K = 0, the entry renders without a badge
|
||||
|
||||
#### Scenario: Paginates older notifications via cursor
|
||||
- **WHEN** a signed-in user has more notifications than fit in one page and clicks "Load older"
|
||||
- **THEN** the next request includes the previous response's cursor as `?before=<cursor>` and the page renders the next batch, strictly older than the cursor's `(created_at, id)` position
|
||||
- **AND** when the final page has been reached the response no longer surfaces "Load older"
|
||||
|
||||
#### Scenario: Cursor is stable across rows with identical timestamps
|
||||
- **WHEN** two notification rows share the exact same `created_at`
|
||||
- **THEN** pagination orders them deterministically by `id` (descending) as a tiebreaker, so neither page omits nor duplicates them
|
||||
|
||||
#### Scenario: Malformed cursor is ignored, not surfaced as an error
|
||||
- **WHEN** a request reaches `/notifications` with a `before` value that doesn't decode to a valid cursor
|
||||
- **THEN** the page renders from the top (newest rows) instead of returning a 400, since the cursor is opaque and clients should not need to validate it
|
||||
|
||||
### Requirement: Mark-as-read controls
|
||||
A signed-in user SHALL be able to mark an individual notification as read via `POST /api/notifications/:id/read`, and to mark all of their unread notifications as read via `POST /api/notifications/read-all`. Both endpoints are owner-bound: only the recipient may mark their own notifications.
|
||||
|
||||
#### Scenario: Click-through marks the row read and navigates to the subject
|
||||
- **WHEN** a user clicks an unread notification on `/notifications`
|
||||
- **THEN** the row's `read_at` is set to `now()` and the user is navigated to the subject page (e.g., the activity, or the follower's profile)
|
||||
|
||||
#### Scenario: Mark-all-read clears the unread badge
|
||||
- **WHEN** a user invokes "Mark all read"
|
||||
- **THEN** every notification belonging to that user with `read_at IS NULL` is updated to `read_at = now()`, and the navbar unread count drops to 0
|
||||
|
||||
#### Scenario: Owner-bound enforcement
|
||||
- **WHEN** a user attempts to mark another user's notification read via the API
|
||||
- **THEN** the API returns 404 (no leak of recipient identity), and the row is unchanged
|
||||
|
||||
### Requirement: Retention of read notifications
|
||||
The Journal SHALL retain unread notifications indefinitely and SHALL delete read notifications older than 90 days via a daily pg-boss job, to bound table growth without losing actionable history.
|
||||
|
||||
#### Scenario: Read notification past retention window
|
||||
- **WHEN** the retention job runs and finds notifications with `read_at IS NOT NULL` AND `read_at < now() - interval '90 days'`
|
||||
- **THEN** those rows are deleted
|
||||
|
||||
#### Scenario: Long-unread notification is preserved
|
||||
- **WHEN** a notification has `read_at IS NULL` for more than 90 days
|
||||
- **THEN** the retention job does NOT delete it (only read notifications expire)
|
||||
|
||||
### Requirement: Cascade on user deletion
|
||||
Deleting a user account SHALL cascade-delete all notifications where they are the recipient. Notifications where they are the `actor_user_id` SHALL set `actor_user_id` to NULL and remain so the recipient's history is preserved with a generic actor reference.
|
||||
|
||||
#### Scenario: Recipient deletes account
|
||||
- **WHEN** a user deletes their own account
|
||||
- **THEN** every notification with `recipient_user_id = that user` is deleted
|
||||
|
||||
#### Scenario: Actor deletes account
|
||||
- **WHEN** a user who has caused notifications deletes their account
|
||||
- **THEN** the recipient's notifications remain, with `actor_user_id` set to NULL; the renderer surfaces a generic "Someone" attribution
|
||||
32
openspec/specs/profile-settings/spec.md
Normal file
32
openspec/specs/profile-settings/spec.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# profile-settings Specification
|
||||
|
||||
## Purpose
|
||||
The user-facing profile editing surface — display name, bio, and `profile_visibility` — exposed through the Journal's settings page. The locked-account semantics that `profile_visibility` controls live in `public-profiles` and `social-follows`; this spec only covers the editing UX and API.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Profile section on the settings page
|
||||
The settings page SHALL include a "Profile" section where the signed-in user can edit their display name, bio, and profile visibility, and save the changes through `POST /api/settings/profile`. Save SHALL be optimistic via a fetcher; success SHALL be confirmed with a visible "Profile saved." line and the form SHALL re-render with the persisted values.
|
||||
|
||||
#### Scenario: Edit display name and bio
|
||||
- **WHEN** a signed-in user changes the display name and/or bio fields and clicks Save
|
||||
- **THEN** the API persists the new values on `users.display_name` / `users.bio` and the page renders "Profile saved." and the new values
|
||||
|
||||
#### Scenario: Validation error renders inline
|
||||
- **WHEN** the API rejects the submission (e.g. display name too long)
|
||||
- **THEN** the form renders the error inline and the values are not persisted
|
||||
|
||||
### Requirement: Profile visibility toggle
|
||||
The Profile section SHALL include a `profileVisibility` radio group with `public` and `private` options. New accounts default to `private` (locked-account model). Changing the value and saving SHALL update `users.profile_visibility` and take effect on the next page render across the site (counts, profile-route gating, follow-button state).
|
||||
|
||||
#### Scenario: Toggle to private
|
||||
- **WHEN** a public-profile user selects "private" and saves
|
||||
- **THEN** `users.profile_visibility` is set to `private`; subsequent visitors to the profile see the locked stub per `public-profiles`; existing accepted follows are unaffected; new follow requests land Pending
|
||||
|
||||
#### Scenario: Toggle to public
|
||||
- **WHEN** a private-profile user selects "public" and saves
|
||||
- **THEN** `users.profile_visibility` is set to `public`; future incoming follows auto-accept; previously-Pending follows remain Pending until explicitly approved or rejected
|
||||
|
||||
#### Scenario: Radio is targeted by name + value, not label text
|
||||
- **WHEN** end-to-end tests interact with the visibility radio
|
||||
- **THEN** the test selector is `input[type=radio][name=profileVisibility][value=public|private]`, because the help-text label of one radio mentions the other's word and a label-based selector would collide
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
# public-profiles Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change public-content-visibility. Update Purpose after archive.
|
||||
Profile pages at `/users/:username`, including the locked-account model (private profiles render a stub for non-followers), per-route visibility (`public` / `unlisted` / `private`) and the social-share metadata (Open Graph) that public pages emit. The drilldown collection routes (`/users/:username/followers` and `/users/:username/following`) and the access rule that gates them live in `social-follows` — this spec links to them but does not duplicate the rule.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Public profile page
|
||||
The Journal SHALL serve a profile page at `/users/:username` for any user who exists. The render SHALL depend on the viewer relationship to the profile owner:
|
||||
|
|
@ -9,7 +10,7 @@ The Journal SHALL serve a profile page at `/users/:username` for any user who ex
|
|||
- If the owner's `profile_visibility = 'public'`, render the full profile (display name, handle, follower/following counts, public routes, public activities) regardless of who is viewing.
|
||||
- If the owner's `profile_visibility = 'private'`, render a stub page (header + handle + counts + lock badge) for non-owner viewers who do NOT have an accepted follow relation. Render the full profile for the owner themselves and for accepted followers.
|
||||
|
||||
The page SHALL display follower and following counts (always, regardless of stub vs. full). For signed-in viewers other than the owner, the page SHALL render a Follow button whose label depends on the relation: "Follow" against a public profile with no relation, "Request to follow" against a private profile with no relation, "Requested" (cancellable) when a Pending request exists, "Unfollow" when an accepted relation exists.
|
||||
The page SHALL display follower and following counts (always, regardless of stub vs. full). The counts SHALL link into `/users/:username/followers` and `/users/:username/following` only when the viewer is permitted to see the underlying lists (per `social-follows`); otherwise they render as plain text. For signed-in viewers other than the owner, the page SHALL render a Follow button whose label depends on the relation: "Follow" against a public profile with no relation, "Request to follow" against a private profile with no relation, "Requested" (cancellable) when a Pending request exists, "Unfollow" when an accepted relation exists.
|
||||
|
||||
#### Scenario: Logged-out visitor views a public profile
|
||||
- **WHEN** an unauthenticated visitor navigates to `/users/:username` for a user whose `profile_visibility` is `public`
|
||||
|
|
@ -39,6 +40,10 @@ The page SHALL display follower and following counts (always, regardless of stub
|
|||
- **WHEN** any visitor loads a populated `/users/:username` (full or stub)
|
||||
- **THEN** the page emits Open Graph tags (`og:title`, `og:site_name`, `og:type="profile"`) so links shared on social platforms render a meaningful preview
|
||||
|
||||
#### Scenario: Counts degrade to plain text for viewers who can't see the lists
|
||||
- **WHEN** a viewer who is not permitted to see the followers/following lists per `social-follows` (e.g. an anonymous visitor or non-follower of a private profile) loads any profile page
|
||||
- **THEN** the follower and following counts render as plain text rather than as anchors, so the visible count doesn't surface a clickable link to a route that would 404
|
||||
|
||||
### Requirement: Profile visibility setting (locked accounts)
|
||||
Every user SHALL have an explicit `profile_visibility` of `public` or `private`. New accounts SHALL default to `private` (locked: profile is reachable but content is gated behind follow approval). Users SHALL be able to change their profile visibility from account settings at any time. `private` is functionally Mastodon's "locked" / "manually approves followers" — visitors see a stub with a Request-to-follow button, follows land in a Pending state, and content is only revealed to accepted followers.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# social-follows Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change social-feed. Update Purpose after archive.
|
||||
Local social follow relationships between Journal users — the follow API, follower/following collections (with locked-account access rules), the Pending request lifecycle for private profiles, and the activity feed driven by accepted follows. Federation of follows across instances is out of scope here and lives in `social-federation`.
|
||||
## Requirements
|
||||
### Requirement: Follow another user
|
||||
A signed-in user SHALL be able to follow another local user from a profile page. Public targets auto-accept (`accepted_at = now()`); private (locked) targets land in a Pending state (`accepted_at = NULL`) until the target approves the request from `/follows/requests`. Following remote ActivityPub actors is out of scope here and tracked in the `social-federation` change.
|
||||
|
|
@ -35,14 +35,31 @@ A signed-in user SHALL be able to follow another local user from a profile page.
|
|||
- **THEN** the API returns 404 (no leak of who the row targets) and the row state is unchanged
|
||||
|
||||
### Requirement: Follower and following collections
|
||||
Every local user SHALL expose follower and following counts on their profile and paginated collection pages, listing only **accepted** relations. Pending requests do not count toward the public tallies.
|
||||
Every local user SHALL expose follower and following counts on their profile and paginated collection pages, listing only **accepted** relations. Pending requests do not count toward the public tallies. The collection pages themselves SHALL be access-gated under the locked-account model: only the owner, accepted followers, and viewers of public profiles can drill into the list. Counts remain visible to everyone (Mastodon-equivalent surface), but the underlying list of usernames is gated.
|
||||
|
||||
#### Scenario: Follower count on profile
|
||||
#### Scenario: Follower count on profile (always visible)
|
||||
- **WHEN** any visitor loads a profile (public or private stub)
|
||||
- **THEN** the page displays the follower and following counts of accepted relations, linking to paginated `/users/:username/followers` and `/users/:username/following` pages
|
||||
- **THEN** the page displays the follower and following counts of accepted relations
|
||||
- **AND** the counts link to the paginated `/users/:username/followers` and `/users/:username/following` pages when the viewer is permitted to see the lists; otherwise the counts render as plain text without links
|
||||
|
||||
#### Scenario: Owner sees their own list
|
||||
- **WHEN** the profile owner loads `/users/:username/followers` or `/users/:username/following` for their own username
|
||||
- **THEN** the page renders the list regardless of their `profile_visibility` setting
|
||||
|
||||
#### Scenario: Public profile list is reachable by anyone
|
||||
- **WHEN** any visitor (anonymous or signed-in) loads `/users/:username/followers` or `/users/:username/following` for a user with `profile_visibility = 'public'`
|
||||
- **THEN** the page renders the list
|
||||
|
||||
#### Scenario: Private profile list visible to accepted followers
|
||||
- **WHEN** a signed-in user with an accepted follow relation against a private user loads that user's `/followers` or `/following` page
|
||||
- **THEN** the page renders the list
|
||||
|
||||
#### Scenario: Private profile list is 404 for non-followers
|
||||
- **WHEN** a visitor (anonymous, or signed-in but with no follow row, or with a Pending follow row) loads `/users/:username/followers` or `/users/:username/following` for a private user
|
||||
- **THEN** the server responds with HTTP 404 — the same opaque response a stranger would see, with no leak of the user's existence beyond what the profile route already exposes
|
||||
|
||||
#### Scenario: Collection pagination
|
||||
- **WHEN** a visitor loads `/users/:username/followers` or `/users/:username/following`
|
||||
- **WHEN** a visitor permitted to see the list loads `/users/:username/followers` or `/users/:username/following`
|
||||
- **THEN** the page lists the accepted relations in reverse-chronological order of acceptance, 50 per page
|
||||
|
||||
### Requirement: Pending follow request management
|
||||
|
|
@ -94,3 +111,31 @@ The `follows` table SHALL key the followed side by an `actor_iri TEXT` column (n
|
|||
- **WHEN** any local follow row is created against a private target in this change
|
||||
- **THEN** `accepted_at` is set to `NULL`; against a public target it is set to `now()`. Federation's remote-Pending state lands here without schema change
|
||||
|
||||
|
||||
### Requirement: Follow lifecycle emits notifications
|
||||
The follow lifecycle SHALL produce notifications for the recipient of the social event (see `notifications` spec for the row shape):
|
||||
|
||||
- Auto-accepted public follow → `follow_received` to the followed user.
|
||||
- Pending follow against a private profile → `follow_request_received` to the followed user.
|
||||
- Approved Pending request → `follow_request_approved` to the follower (now accepted).
|
||||
- Reject and unfollow do NOT produce notifications.
|
||||
|
||||
#### Scenario: Public auto-accept notifies the target
|
||||
- **WHEN** a user follows another user whose `profile_visibility = 'public'` (auto-accept path)
|
||||
- **THEN** a `follow_received` notification is created for the followed user
|
||||
|
||||
#### Scenario: Pending request notifies the target
|
||||
- **WHEN** a user requests to follow another user whose `profile_visibility = 'private'`
|
||||
- **THEN** a `follow_request_received` notification is created for the followed user (the historical record persists even if the request is later rejected or canceled)
|
||||
|
||||
#### Scenario: Approval notifies the requester
|
||||
- **WHEN** a private user approves a Pending follow request
|
||||
- **THEN** a `follow_request_approved` notification is created for the follower
|
||||
|
||||
#### Scenario: Reject does not notify
|
||||
- **WHEN** a private user rejects a Pending follow request
|
||||
- **THEN** no notification is created (silent rejection — the follower can re-request later if they want)
|
||||
|
||||
#### Scenario: Unfollow does not notify
|
||||
- **WHEN** a follower unfollows or cancels a Pending request
|
||||
- **THEN** no notification is created on the followed side
|
||||
|
|
|
|||
78
openspec/specs/sse-broker/spec.md
Normal file
78
openspec/specs/sse-broker/spec.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# sse-broker Specification
|
||||
|
||||
## Purpose
|
||||
Server-Sent Events (SSE) infrastructure that pushes one-way notifications from the Journal server to the user's open browser tabs in between page navigations. This is the transport layer; specific event payloads (e.g. `notifications.unread`) are owned by the capability that emits them. The interface is intentionally narrow so it can be swapped from the in-process registry to a Redis pub/sub adapter when the Journal goes multi-process, without caller changes.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: SSE endpoint exposes a session-bound event stream
|
||||
The Journal SHALL expose `GET /api/events` returning a streaming `text/event-stream` response that delivers per-user events to a signed-in browser tab. The endpoint SHALL be authenticated via the user's session cookie; anonymous requests SHALL receive HTTP 401 with no event stream. The response SHALL include `Cache-Control: no-cache` and `X-Accel-Buffering: no` so intermediate proxies do not buffer the stream.
|
||||
|
||||
#### Scenario: Authenticated client receives an event stream
|
||||
- **WHEN** a signed-in user opens an `EventSource("/api/events")` connection
|
||||
- **THEN** the server registers the connection in the broker keyed by the user's id, sends an SSE preamble (including a `retry: 5000` reconnect hint with 0–2 s server-side jitter), and holds the response open until the client disconnects
|
||||
|
||||
#### Scenario: Anonymous client is rejected
|
||||
- **WHEN** an unauthenticated request hits `/api/events`
|
||||
- **THEN** the server responds with HTTP 401 and does not start a stream
|
||||
|
||||
#### Scenario: Heartbeat keeps idle connections alive
|
||||
- **WHEN** a connection has been open for ≥25 s with no events
|
||||
- **THEN** the server writes an SSE comment frame (`: ping\n\n`) so intermediate proxies do not idle-timeout the connection
|
||||
- **AND** if the heartbeat write fails (broken pipe), the connection is removed from the broker registry
|
||||
|
||||
#### Scenario: Cleanup on client teardown
|
||||
- **WHEN** the client closes the EventSource (tab closed, navigation, network drop)
|
||||
- **THEN** the server removes the connection from the broker on the request's abort signal so subsequent emits to that user don't write to a dead socket
|
||||
|
||||
### Requirement: Per-user broker registry with simple emit interface
|
||||
The Journal SHALL provide a server-side `events.server.ts` module exposing two functions: `register(userId, conn)` returning an unregister callback, and `emitTo(userId, event, data)` that delivers a serialized SSE event to every connection registered for that user. The interface SHALL be the only call site for multi-process swap (in-memory `Map<userId, Set<Connection>>` today; Redis pub/sub later). Generation hooks (e.g. `notifications.server.ts`'s `emitUnreadCount`) SHALL call `emitTo` after their underlying state change commits, never before.
|
||||
|
||||
#### Scenario: Emit delivers to all of a user's open connections
|
||||
- **WHEN** `emitTo("user-1", "notifications.unread", { count: 5 })` is called and "user-1" has two open SSE connections
|
||||
- **THEN** both connections receive the same SSE frame (`event: notifications.unread\ndata: {"count":5}\n\n`)
|
||||
|
||||
#### Scenario: Emit to a user with no open connections is a no-op
|
||||
- **WHEN** `emitTo("user-2", "notifications.unread", { count: 1 })` is called and "user-2" has zero open connections
|
||||
- **THEN** the call returns silently without error and without queueing — events are best-effort, the loader-driven count is the source of truth on next render
|
||||
|
||||
#### Scenario: Broken connections are pruned defensively
|
||||
- **WHEN** `emitTo` writes to a connection whose `send` throws (broken pipe)
|
||||
- **THEN** the broker calls `close()` on that connection so it cannot leak open file descriptors
|
||||
|
||||
#### Scenario: Emit happens after the underlying state change commits
|
||||
- **WHEN** a generation hook (e.g. `createNotification`, `markRead`, `markAllRead`) updates the database
|
||||
- **THEN** `emitTo` is called only after the transaction commits, so a client receiving the event observes a consistent count when its next request reaches the database
|
||||
|
||||
### Requirement: Client hook seeds from loader, then live-updates via SSE
|
||||
The Journal SHALL provide a React client hook `useUnreadNotifications(initialCount, signedIn)` that opens an `EventSource("/api/events")` when the user is signed in, listens for `notifications.unread` events, and returns the live `count`. The loader-supplied `initialCount` SHALL be the SSR baseline so the badge renders correctly before the first SSE event arrives. The browser's native `EventSource` reconnect behavior SHALL be relied on for transient disconnects; no manual reconnect loop is required.
|
||||
|
||||
#### Scenario: Loader baseline rendered on first paint
|
||||
- **WHEN** a signed-in user navigates to any page
|
||||
- **THEN** the navbar bell badge initially displays the count returned by the root loader, not zero, even before SSE has handshaked
|
||||
|
||||
#### Scenario: Live event overrides the loader baseline
|
||||
- **WHEN** the server emits `notifications.unread { count: 7 }` to the user's open tab via SSE
|
||||
- **THEN** the navbar bell badge updates to `7` without a navigation
|
||||
|
||||
#### Scenario: Anonymous tab does not open an SSE connection
|
||||
- **WHEN** a logged-out visitor renders a page
|
||||
- **THEN** the hook does not call `EventSource("/api/events")` (since the endpoint would 401 anyway)
|
||||
|
||||
### Requirement: Caddy reverse-proxy passes SSE through unmodified
|
||||
The reverse proxy in front of the Journal (Caddy v2) SHALL pass `text/event-stream` responses through without buffering. No special directive is required because Caddy v2 auto-detects the content type and disables response buffering automatically; this requirement exists so future infrastructure changes do not silently re-introduce buffering.
|
||||
|
||||
#### Scenario: Caddy reverse_proxy block carries no buffering tweaks
|
||||
- **WHEN** the Journal upstream is added to `infrastructure/Caddyfile`
|
||||
- **THEN** the entry is a plain `reverse_proxy journal:3000` (no `flush_interval` or buffering directive needed)
|
||||
|
||||
### Requirement: In-process today, Redis-pub/sub when multi-process
|
||||
The broker SHALL be implemented as an in-process `Map` for the single-process deployment shipped today. The same `register` / `emitTo` interface SHALL be the swap point for a future Redis pub/sub adapter when the Journal scales to multiple processes; callers SHALL NOT need to change.
|
||||
|
||||
#### Scenario: Single-process deployment
|
||||
- **WHEN** the Journal runs as one process behind Caddy
|
||||
- **THEN** the broker is the in-process `Map<userId, Set<Connection>>` and emits resolve synchronously within the same Node process
|
||||
|
||||
#### Scenario: Future multi-process swap (forward-compat)
|
||||
- **WHEN** the Journal is split into multiple processes (e.g., behind a load balancer with sticky sessions or random routing)
|
||||
- **THEN** `events.server.ts` is rewritten as a Redis pub/sub adapter — `register` subscribes to a per-user channel, `emitTo` publishes — without any change at the call sites
|
||||
Loading…
Add table
Add a link
Reference in a new issue