Commit graph

21 commits

Author SHA1 Message Date
Ullrich Schäfer
881991ca18
feat(journal): federation protocol doc + delivery observability
Task group 4 of federation-hardening.

4.1 — FEDERATION.md at the repo root: actor discovery (WebFinger, actor,
NodeInfo), object/activity types with real JSON examples (Note, Create,
Delete, the narrow follow-graph inbox), addressing, HTTP-Signature
expectations, the two-layer dedup contract, durable delivery/retry
policy, and blocklist moderation semantics — precise enough for another
implementation to interoperate. Linked from README and docs/architecture.

4.2 — three prom-client metrics + a journal dashboard row:
- `federation_delivery_total{outcome}` — incremented in deliver-activity
  (delivered/skipped/failed).
- `federation_inbox_dropped_total{reason}` — incremented at every inbox
  drop (duplicate | blocked); this is the counter deferred from task 3.2.
- `federation_queue_depth` — gauge sampled at scrape time in
  /api/metrics from PgBossMessageQueue.getDepth(); the restart-loss
  regression detector.
Grafana journal.json gains a Federation row (delivery rate, queue depth,
inbox drops); the logs panels shift down to make room.

Verified: dashboard JSON valid; journal typecheck + lint clean; unit
suite 357 passing (route-template guard unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:11:43 +02:00
Ullrich Schäfer
105659df7c
feat(journal): inbound federation replay defense
Task group 2 of federation-hardening. The narrow inbox
(Follow/Undo/Accept/Reject) had no replay protection — only Create(Note)
did, via the activities.remote_origin_iri unique constraint. A remote
redelivering a signed follow-graph activity would re-run its side
effects.

- New `federation_processed_activities` table (activity IRI PK,
  received_at + index). Additive, so drizzle-kit push creates it; no
  hand-written migration needed.
- `federation-replay.server.ts`: `markInboundActivityProcessed` does an
  insert-or-drop (ON CONFLICT DO NOTHING RETURNING) and reports whether
  the IRI is fresh; `sweepProcessedActivities` deletes rows > 30 days old
  (signature date-freshness already rejects older replays).
- Each inbox listener drops a duplicate before side effects. The
  follow-graph handlers are idempotent, so a handler failure whose retry
  is later dropped as a duplicate can't corrupt state.
- `federation-dedup-sweep` job (daily 04:30 UTC) runs the TTL sweep.

Verified: db + journal typecheck + lint clean; drizzle-kit push creates
the table; replay integration test (fresh-vs-duplicate + 30-day sweep)
green against real Postgres; journal unit suite 355 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:07:30 +02:00
Ullrich Schäfer
279734c607
route-surface-breakdown (Phase 2): async Overpass backfill + SSE
Covers routes/activities that enter the journal without BRouter waytags
(imports, uploads, pre-existing rows):

- overpass-ways.server.ts: server-side Overpass client (way[highway] + geom in
  a bbox, configurable OVERPASS_URLS, timeout + oversized-bbox guard).
- surface-match.server.ts: pure nearest-way map-matcher → per-segment
  surface/highway (unmatched → unknown). Unit-tested.
- surface-backfill pg-boss job: load geom → skip if breakdown exists → Overpass
  → match → computeSurfaceBreakdown → store → emit `surface_breakdown` SSE to
  the owner. Idempotent, retry-safe, best-effort; registered in server.ts.
- Enqueued from createActivity (imports/uploads) + owner-on-open in the route &
  activity detail loaders (non-Planner routes, old rows), deduped via singletonKey.
- useSurfaceBackfillUpdates: detail pages subscribe to /api/events and
  revalidate() when their row's backfill lands (live bars, no reload).
- Privacy manifest updated (DE + EN) for the Overpass bbox lookup.

Tests: matchSurfaces unit; surface-backfill job (mocked Overpass/db/events:
store + emit, skip-if-present, skip-if-no-ways). Verified the real
Overpass→match→breakdown pipeline against a live Berlin bbox (509 ways →
plausible asphalt/paving_stones/footway mix). typecheck + lint + unit
(journal 333) green.

Note: the worker runs via server.ts (prod/staging), not `react-router dev`, so
the live job + SSE exercise on a deployed instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:18:03 +02:00
Ullrich Schäfer
855244747c
journal: typed job seam + Komoot credentials through the manager
Two related fixes from the architecture review:

Typed job seam. Job payloads were `unknown` end-to-end: every handler
opened with `job.data as SomePayload`, every enqueue site passed a bare
string queue name and an unchecked object, and a typo meant a runtime
failure in a background worker. packages/jobs gains defineJob(), which
keeps the payload typed inside the handler and performs the
contravariance cast once inside the package. The journal declares all
14 queues and their payload shapes in app/jobs/payloads.ts; enqueue()/
enqueueOptional() and defineJournalJob() key off that map, so enqueue
sites and handlers cannot drift and queue names are compile-checked.

Komoot credential bypass. The bulk-import route enqueued the raw
credentials JSONB in the pg-boss payload, skipping withFreshCredentials
entirely: credentials sat at rest in the job table, were never
refreshed if stale, and markNeedsRelink never fired. The payload now
carries only the serviceId; the handler resolves fresh credentials
through the ConnectedServiceManager at execution time, and marks the
import batch failed (instead of leaving it pending forever) when
credential resolution itself fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:05:24 +02:00
Ullrich Schäfer
0360757ae8 feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)
Garmin Connect as the third connected-services provider (spec:
garmin-import). The interesting parts:

- Push-first ingestion: Garmin has no list endpoint. The webhook
  normalizes ping (callbackURL) and push (inline) notification batches
  into events; the slow work (authorized FIT download, FIT→GPX via the
  shared converter, activity creation) runs in a garmin-import-activity
  pg-boss job so the webhook answers fast. Callback URLs are validated
  against Garmin's API host before any fetch (SSRF guard).
- History via backfill requests: /sync/import/garmin is a date-range
  requester with honest async progress (no pick list — the concept
  doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap;
  overlaps are free via sync_imports dedupe. Requests persist in
  import_batches via two new nullable columns (range_start/range_end).
- OAuth2 + PKCE on the existing oauth credential kind. Design
  correction from apply: the verifier rides a short-lived httpOnly
  cookie scoped to the callback path — the state param is visible in
  redirect URLs and must never carry it. Manifests opt in via pkce:true.
- Deregistration notifications flip the connection to 'revoked'
  (row kept for audit, imports retained, re-connect prompt shown).
- Framework evolutions, all additive: parseWebhook returns
  WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains
  configured()/importUrl/pkce, importActivity accepts summary stats
  for FIT-less imports, manager gains markRevoked.
- Env-gated: no GARMIN_CLIENT_ID → provider hidden on
  /settings/connections. Privacy manifest entry (DE+EN). i18n en+de.

Rollout (§6) stays gated on the Garmin Developer Program application
(submitted 2026-06-07). Fixtures are doc-shaped; the staging soak
swaps in recorded payloads if shapes differ.

Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known
flakes green isolated ✓ openspec validate ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:47:22 +02:00
Ullrich Schäfer
bf2d8bfbd4 feat(journal): audience-aware social feed with remote activities (§8+§9)
8.1/8.2: listSocialFeed is now a UNION ALL of local and remote
branches, sorted on COALESCE(remote_published_at, created_at):
- local rows: visibility='public' from accepted local follows — and
  the previously missing accepted_at filter is added (the spec's
  'Pending follows contribute nothing' scenario)
- remote rows: gated structurally by joining the viewer's OWN accepted
  follow against the originating actor — which is exactly the
  followers-only audience rule (a row reaches only viewers whose
  follow brought it in); attribution from the remote_actors cache;
  cards link outward to the canonical origin page (no local detail
  page for remote rows)

9.1: annotated — local Pending button shipped with locked accounts;
remote Pending lives on /follows/outgoing.
9.2: already enforced + tested since §3 (actor/webfinger 404).
9.3 hardening: deliver-activity now re-checks the OWNER's profile
visibility at send time, closing the enqueue→delivery flip window
(enqueue-side and inbound-side gates already existed).

Integration tests: the §8 audience-leak guard (A sees followers-only,
B does not), public remote attribution + outward link, pending
contributes nothing, mixed local/remote COALESCE ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:32:11 +02:00
Ullrich Schäfer
b96bef91a9 feat(journal): outbox-poll ingestion of remote trails activities (§7)
Tasks 7.1–7.3, 7.5 (+7.4 pacing; Retry-After-duration backoff noted as
remaining). Resolves the activities.owner_id open question.

Schema (design decision from the open question):
- activities.owner_id nullable + check constraint enforcing exactly
  one of (owner_id, remote_actor_iri) — the follows pattern again.
- remote_published_at carries the origin's publish time for the §8
  feed sort; index on remote_actor_iri for the feed join.
- Compiler-audited fallout: notification fan-out, the Note object
  dispatcher, and the activity detail loader explicitly skip/404
  remote rows (their canonical page is the origin; feed links there).
  Every other surface joins users on owner_id and excludes remote rows
  structurally.

Ingestion:
- federation-ingest.server.ts: parseOutboxItem targets exactly our own
  outgoing Create(Note) shape (PropertyValue stats, first-paragraph
  name, audience from to/cc); unknown items skipped, never fatal.
- ingestRemoteActivities: replay-safe via unique remote_origin_iri,
  conflict-streak early exit (outboxes are newest-first).
- pollRemoteActor: signed fetch (Authorized Fetch via a local
  follower's key), outbox resolution via remote_actors cache with
  actor-doc fallback (which refreshes the cache), 1 req/5 s per-host
  pacing, last_polled_at stamping. Network + pacing injectable.

Jobs: poll-remote-actor (per-actor; the §4 Accept listener already
enqueues it as the first poll) + poll-remote-outboxes (5-min cron
sweep over accepted remote follows not polled within the hour).

Tests: parse-shape units; integration suite for ingestion provenance,
audience→visibility mirroring, replay safety, the DB author invariant,
poll flow (actor-doc → collection → page), signer requirement, and
due-polling selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:27:10 +02:00
Ullrich Schäfer
bc233e03e5 feat(journal): federation outbox + push delivery to remote followers
social-federation tasks 5.1–5.6. Completes the inbound-federation
story: a Mastodon follower now receives a trails user's new public
activities in their home timeline.

Outbox (5.1/5.2):
- /users/:username/outbox — paginated OrderedCollection of public
  activities as Create(Note), newest first; unlisted/private never
  federate. Private-user 404 enforced at the route layer because
  Fedify builds collection-level responses from counter/cursors
  without consulting the page dispatcher.
- Note shape: HTML content (escaped name/description/stats + link to
  the activity page) with structured PropertyValue attachments
  (distance-m, elevation-gain-m, duration-s) — Mastodon renders the
  text, trails consumers read the structured fields. Resolves the
  design open question toward Create(Note).
- Authorized Fetch: signed and unsigned outbox fetches deliberately
  see the same (public-only) content until locked accounts exist.

Push delivery (5.3–5.6):
- createActivity / updateActivityVisibility(→public) enqueue one
  deliver-activity job per accepted remote follower; flips away from
  public and hard deletes enqueue Delete(Tombstone) retractions
  (enqueued before the row disappears).
- deliver-activity job: re-reads the row at delivery time (skips if
  gone or no longer public), resolves the recipient inbox via the
  remote_actors cache with actor-document fetch fallback (priming the
  cache), HTTP-signs via the owner's key, and POSTs. retryLimit 8 +
  exponential backoff at enqueue time; outbound paced at 1 req/s per
  remote host.
- Actor objects now advertise the outbox IRI.
- @js-temporal/polyfill added (same range Fedify uses) for published
  timestamps; Fedify's types want the global esnext.temporal namespace,
  bridged with a documented cast.

Tests: 9 unit tests for the AS mapping (escaping, stats, attachments,
published fallback, stable ids, tombstones), 4 outbox integration
tests (collection count, page shape/visibility filtering, private-404,
delivery audience query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:32:52 +02:00
Ullrich Schäfer
b17685d58c feat(journal): federation inbox — Mastodon follows land here
social-federation tasks 3.1–3.4, 4.1–4.8. With this, a Mastodon user
can follow a public trails user: WebFinger → actor fetch → signed
Follow → recorded + Accept(Follow) pushed back.

Identity surface (section 3):
- Actor objects now carry the user's public key (publicKey +
  assertionMethods via Fedify key pairs dispatcher; keys generated
  lazily as a fallback to the backfill) and an inbox IRI; url uses
  localActorIri (3.1).
- Software discovery shipped as standard NodeInfo
  (/.well-known/nodeinfo + /nodeinfo/2.1, software.name trails-cool)
  instead of the originally-sketched custom AS actor field — Fedify's
  typed vocab can't emit arbitrary actor props and NodeInfo is what
  the fediverse reads. Artifacts updated accordingly (3.4).

Inbox (section 4):
- /users/:username/inbox resource route; HTTP Signatures verified by
  Fedify before any listener runs (4.1). Rate-limited 60 req/5 min per
  source instance (host from Signature keyId) BEFORE verification so
  hostile instances can't burn CPU on key fetches (4.8).
- Listeners: Follow → auto-accept for public profiles + Accept pushed
  back (4.2); Undo(Follow) → row removed (4.3); Accept(Follow) →
  Pending settled + first outbox poll enqueued (4.4); Reject(Follow) →
  Pending dropped (4.5; UI notice deferred to 6.6). Unhandled types
  are acknowledged + dropped by Fedify (4.6).
- Replay protection via Fedify's KvStore, now Postgres-backed
  (journal.federation_kv + daily sweep job) so dedupe survives
  restarts (4.7).

Schema (discovered requirement):
- follows.follower_id relaxed to nullable + follows.follower_actor_iri
  for inbound remote followers — the proposal's 'follows is already
  federation-ready' only held for outbound. Check constraint enforces
  exactly one follower identity; partial unique index dedupes remote
  follows. Notification fan-out + approve flow now filter local
  followers explicitly. Design/proposal updated.

Tests: 7 inbox integration tests (accept/refuse/idempotence/undo/
settle/reject/check-constraint), 6 KvStore integration tests, 2 unit
tests for source-host extraction. All against real Postgres, gated on
FEDERATION_INTEGRATION=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:33:43 +02:00
Ullrich Schäfer
9a90b6db55 feat(journal): federation schema + per-user signing keypairs
social-federation tasks 2.1–2.5:

- users.public_key / users.private_key_encrypted (TEXT NULL): RSA 2048
  keypairs as JWK JSON; private key AES-256-GCM encrypted at rest with
  FEDERATION_KEY_ENCRYPTION_KEY.
- remote_actors table: cache of remote AP actors (display fields,
  inbox/outbox URLs, public key, software discovery field, poll cursor).
- activities.remote_origin_iri (UNIQUE) / remote_actor_iri / audience:
  provenance + audience tagging for rows ingested from remote outboxes.
  Replay-safe ingestion keys off the unique origin IRI.
- crypto.server.ts: generalized into createAesCipher(envVar, salt)
  factory; existing INTEGRATION_SECRET surface unchanged.
- federation-keys.server.ts: generate/ensure/load keypairs.
  RSASSA-PKCS1-v1_5 because that's what Mastodon interops on.
- backfill-user-keypairs pg-boss job: enqueued once per server startup
  when FEDERATION_ENABLED=true; only touches users with NULL keys, so
  re-runs are no-ops. New users get keys at registration (both passkey
  and magic-link paths), best-effort with the backfill as safety net.
- design.md: noted open question — activities.owner_id is NOT NULL but
  remote-ingested rows have no local owner; decide in task 7.2.

Schema is additive; zero behavior change while FEDERATION_ENABLED is
off. db:push verified clean against local Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:15:51 +02:00
Ullrich Schäfer
b0b58d36fb
chore(ts): eliminate the remaining 4 \as any\ casts in production code
Followup to #447. The audit ran on \`as unknown as\` first; this PR
closes out \`as any\` separately. After this, \`grep -rn ' as any\\b'
apps/ packages/\` returns 0 (excluding tests and node_modules).

## Sites fixed

**\`apps/journal/server.ts\`** + **\`packages/jobs/src/types.ts\`** —
\`komootBulkImportJob as any\`. The job had a typed payload
(\`JobDefinition<KomootBulkImportData>\`) but the worker's
\`JobDefinition[]\` array forced a contravariance cast at every site
that mixed typed and untyped jobs. Dropped the generic from
\`JobDefinition\` entirely; handlers narrow their own \`job.data\`. Only
one job (komoot-bulk-import) used the generic, so the surface is tiny.

**\`apps/journal/app/lib/connected-services/fit.ts\`** + same pattern in
\`routes/sync.import.\$provider.server.ts\` — \`parser.parse(buffer as any,
(err: unknown, d: any) => ...)\`. fit-file-parser's TypeScript types
require \`Buffer<ArrayBuffer>\` specifically; a generic Node \`Buffer\`
is structurally \`Buffer<ArrayBufferLike>\` (which includes
SharedArrayBuffer). The runtime accepts either, so narrowed the cast
to \`as Buffer<ArrayBuffer>\` — still a cast, but precise about what
we're asserting and why. Removed the \`(err, d: any) => …\` ad-hoc
callback typings; the library exports a proper \`FitParserCallback\`.

**\`apps/mobile/lib/editor/RouteMap.tsx\`** — \`onLongPress\` handler
took \`(event: any)\`. maplibre-react-native v11 generates the event
type via React Native codegen but doesn't re-export it as a public
TypeScript type. Replaced with a local structural slice of the parts
we actually read.

Net: 4 \`as any\` sites in prod code → 0. \`pnpm typecheck\` / \`lint\` /
\`test\` all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:11:02 +02:00
Ullrich Schäfer
b4c64a40e7
fix(journal): single-use JWT enforcement for route callback tokens (#2 Phase B)
After Phase A (#442) moved the journal callback token off the browser,
the token was still replayable on the wire until \`exp\` (7 days). This
PR makes each token strictly single-use.

Changes:

- **\`journal.consumed_jwt_jti\` table** — \`jti TEXT PRIMARY KEY,
  consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ\`. Picked up by
  drizzle-kit push on deploy.
- **\`createRouteToken\` now sets a \`jti\` claim** (\`randomUUID()\`).
- **\`verifyRouteToken\` atomically consumes the jti** via
  \`INSERT … ON CONFLICT DO NOTHING RETURNING jti\`. Postgres
  serializes the insert, so exactly one concurrent caller wins; the
  rest see an empty result and throw \`TokenAlreadyConsumedError\`.
  Tokens without a \`jti\` claim (i.e. minted before this PR) are
  also rejected — the right call: any in-flight legacy token sitting
  in a planner session is replayable, and we'd rather fail-loud than
  silently grandfather them in.
- **\`consumed-jti-sweep\` job** — daily 03:45 UTC cron that
  \`DELETE WHERE expires_at < now()\`. Keeps the table tiny; offset
  from the other purge jobs to spread load.
- **e2e replay test** — \`integration.test.ts\` now exercises a
  same-token double-submit and asserts the second returns 401 with
  \`/consumed|already/i\`.

UX implication worth flagging: a user who clicks \"Save\" twice (or whose
network retries a failed POST) sees an error on the second attempt.
They go back to the journal for a fresh \"Edit in Planner\" link.

Full repo: pnpm typecheck / lint / test all green (177 + 31 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:38:08 +02:00
Ullrich Schäfer
4de6c86d41
fix(journal): architectural audit omnibus
Addresses 8 issues from the Journal architecture audit:

1. DB indexes on routes.ownerId + activities.ownerId. Listing queries on
   these tables were full table scans; adds composite indexes matching
   the order-by columns (updatedAt/startedAt/createdAt).
2. Zod validation on /api/auth/register body. Previously the action
   destructured request.json() with zero schema validation.
3. N+1 GeoJSON batch fetch collapsed to a single ANY($1::text[]) query
   in both routes.server and activities.server.
4. Webhook envelope validation in /api/sync/webhook/:provider.
5. AbortSignal.timeout(30s) on all external fetches (Komoot, Wahoo) via
   a new fetchWithTimeout helper in lib/http.server.ts.
6. .limit(100) on listPublicRoutesForOwner / listPublicActivitiesForOwner.
9. Welcome email moved off fire-and-forget onto a pg-boss job with
   retryLimit: 3 (send-welcome-email).
10. process.env.ORIGIN ?? "http://localhost:3000" centralized into
    lib/config.server.ts::getOrigin() across 14 call sites.

Issues 7 (centralized apiError/auth guards across 60+ route files) and
8 (split .server.ts boundaries across 20+ route files) intentionally
deferred — both are pure refactors that would balloon this PR past
reviewability and warrant their own focused PRs.

Tests added:
- lib/config.server.test.ts (2 cases)
- lib/http.server.test.ts (3 cases — timeout abort, success passthrough,
  caller-signal composition)
- routes/api.sync.webhook.$provider.test.ts (6 cases)
- routes/api.auth.register.test.ts (7 cases — schema rejection paths +
  the new welcome-email enqueue assertion)

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:28:33 +02:00
Ullrich Schäfer
ec371ac400
Simplify: typed status, skip-write guard, remove redundant comment
- Use ImportBatchStatus type instead of raw string literals in sweep job
- Add a COUNT pre-check so the sweep UPDATE is skipped when no stale
  batches exist (avoids an unconditional write every minute)
- Remove comment in disconnect route that explained what the code does

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:49:09 +02:00
Ullrich Schäfer
a181dfe6b8
Add cron job to sweep stale import batches every minute
Batches stuck in pending or running for more than 10 minutes (server
restart mid-import, pg-boss job dropped) are marked failed with a
user-visible message. Runs every minute via pg-boss cron with a 55s
expiry so overlapping runs are dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:08:49 +02:00
Ullrich Schäfer
5ff7af8a81
Add background bulk import for Komoot
Replace the per-tour import UI with a fire-and-forget background job:

- Add `import_batches` DB table tracking status, found/imported/duplicate counts
- Add `runKomootBulkImport` server function that pages all Komoot tours,
  fetches GPX, creates activities, and deduplicates via sync_imports
- Add `komoot-bulk-import` pg-boss job registered at server startup
- Add POST /api/sync/komoot/import to enqueue the job
- Add GET /api/sync/komoot/import-status to return the latest batch
- Replace the Komoot import page with a progress UI that polls every 2s
  while a batch is running and shows found/imported/skipped counts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:19:10 +02:00
Ullrich Schäfer
b20c8cca39 Cursor-based pagination for /notifications
Switches `listForUser` from page-offset to cursor (`before` param,
opaque base64 of `{ts, id}`) ordered by `(created_at DESC, id DESC)`
for stable pagination even with simultaneous fan-out inserts.

Returns `{ rows, nextCursor }` instead of a bare array. Loader surfaces
`?before=<cursor>` on a "Load older" link at the bottom of the list,
shown only while `nextCursor !== null`. Default page size 50, capped
at 100. Malformed cursors fall back to "start from top" rather than
400ing — opaque cursors should not be a client validation surface.

Spec drift: delta spec adds three pagination scenarios (cursor pages
forward, tie-stable on identical `created_at`, malformed-cursor
graceful fallback). Design doc gets a new decision section explaining
the cursor choice over page-offset and why we don't compute totals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:42:32 +02:00
Ullrich Schäfer
e61179ab27 Implement notifications + supporting fixes
Adds the notifications system end-to-end (4 types, payload-versioned
JSONB, SSE-based live unread badge, /notifications page, mark-read API,
fan-out job for activity_published, daily 90-day retention purge).
Bell icon in the navbar with unread badge.

Side-findings from exercising the change:
- Add 6-digit magic code to registration (mirrors login UX, mobile
  paste-friendly), with `[Register Magic Link]` console line in dev so
  the code is reachable without a real email transport.
- Manual passkey/magic-link toggle on the register form (login already
  had it).
- Restrict ALPN to http/1.1 in HTTPS dev so React Router's
  singleFetchAction CSRF check (Origin vs. Host) passes — Node doesn't
  synthesize Host from h2's :authority. Plain HTTP dev unaffected.
- Followers/Following routes now use the locked-account rule from the
  profile route (owner + accepted followers see the list; others 404).
  Profile page renders the count chips as plain spans for viewers who
  can't see the lists, so private profiles don't surface dead links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:28:55 +02:00
Ullrich Schäfer
c2e8461f9c
Upgrade pg-boss from 10 to 12
Supersedes dependabot #264 — this one involves code changes that PR
couldn't make.

## Breaking changes in our usage surface

### v12: default export removed → named `PgBoss` export

Affects every file that imports the SDK:
- `packages/jobs/src/boss.ts`: `import PgBoss` → `import { PgBoss }`
- `packages/jobs/src/worker.ts`: same for the type import
- `packages/jobs/src/types.ts`: `PgBoss.Job<T>` → `Job<T>` (types.ts
  re-exports `Job` at the package root in v12)

### v11: queue names restricted to `[A-Za-z0-9_.-]`

Colon `:` is no longer allowed. Renamed the two journal queues that
had it:
- `demo-bot:generate` → `demo-bot-generate`
- `demo-bot:prune`    → `demo-bot-prune`

The planner's `expire-sessions` was already valid.

### v12: minimum Node 22.12

Not a code change for us — journal + planner Dockerfiles are on
`node:25-slim`, CI runners on 24.

## Regression guard

Added `assertValidJobName()` in `@trails-cool/jobs`, called by
`startWorker()` before any side effects. Pg-boss v11+ silently accepts
an invalid name then rejects the underlying SQL call later — we fail
loudly at boot instead. Unit tests cover the character-class rule
and exercise the exact old-name (`demo-bot:generate`) as a
regression fence.

## Prod rollout note

Pg-boss v11 dropped the auto-migration path from v10. On deploy, the
live `pgboss` schema from v10 won't migrate cleanly. Simplest path:
`DROP SCHEMA pgboss CASCADE` before the first v12 worker starts —
our jobs are all cron-scheduled and will re-register themselves on
boot, so there's nothing durable to preserve in the queue.

## Verified

- `pnpm typecheck` / `pnpm lint` / `pnpm test` — all clean
- `pnpm exec playwright test --workers=2` — 50/50 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:01:11 +02:00
Ullrich Schäfer
df6074b0b5
Fix demo-bot cron + cadence math
`*/90 * * * *` is invalid cron (minute field is 0–59) and degrades
to hourly, producing half the intended bot cadence. Switch to
`0,30 * * * *` (every 30 min) and lower the Bernoulli gate from
0.12 to 0.09 so we still hit ~2–3 walks/day during the 07–21
Berlin window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:25:06 +02:00
Ullrich Schäfer
ba4c4e1b4f
Apply demo-activity-bot: Bruno, the synthetic public-walk generator
Adds a background demo user (`bruno`) plus two pg-boss jobs that generate
public trekking routes and activities in inner Berlin and prune them
after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED`
only in prod.

- Schema: `synthetic` boolean on routes + activities
- `ensureDemoUser()` idempotent insert + badge on /users/bruno
- Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d
- Initial 4-walk backfill on first enable; retention via daily prune
- Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total`
- Unit + DB-gated integration + E2E tests

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