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>
119 lines
5.1 KiB
YAML
119 lines
5.1 KiB
YAML
# Staging / PR-preview compose file.
|
|
#
|
|
# Used for both the persistent staging stack and ephemeral PR previews.
|
|
# The cd-staging.yml workflow picks the project name and fills in the host
|
|
# ports + DOMAIN + STAGING_DATABASE from the PR number (or "staging" for the
|
|
# persistent stack):
|
|
#
|
|
# # Persistent staging
|
|
# PROJECT=trails-staging
|
|
# JOURNAL_HOST_PORT=3100 PLANNER_HOST_PORT=3101 \
|
|
# DOMAIN=staging.trails.cool STAGING_DATABASE=trails_staging \
|
|
# docker compose -f docker-compose.staging.yml -p $PROJECT up -d
|
|
#
|
|
# # PR 123 preview (port = 3200 + 2N for journal, 3201 + 2N for planner)
|
|
# PROJECT=trails-pr-123
|
|
# JOURNAL_HOST_PORT=3446 PLANNER_HOST_PORT=3447 \
|
|
# DOMAIN=pr-123.staging.trails.cool STAGING_DATABASE=trails_pr_123 \
|
|
# JOURNAL_IMAGE_TAG=pr-123 PLANNER_IMAGE_TAG=pr-123 \
|
|
# docker compose -f docker-compose.staging.yml -p $PROJECT up -d
|
|
#
|
|
# `trails-shared` is created by the production compose file (see
|
|
# docker-compose.yml) so staging/preview containers can reach the production
|
|
# `postgres` host. BRouter lives on a separate host and is reached over
|
|
# vSwitch using the production-shared BROUTER_URL / BROUTER_AUTH_TOKEN.
|
|
#
|
|
# Container names are not pinned — Docker Compose generates them from the
|
|
# project name (e.g. `trails-pr-123-journal-1`), which keeps each preview
|
|
# isolated without manual naming.
|
|
|
|
services:
|
|
journal:
|
|
image: ghcr.io/trails-cool/journal:${JOURNAL_IMAGE_TAG:-latest}
|
|
restart: unless-stopped
|
|
# Published on 0.0.0.0 (not 127.0.0.1) so the production Caddy container
|
|
# can reach it via host.docker.internal — Docker's host-gateway resolves
|
|
# to the bridge IP, not loopback. The Hetzner Cloud firewall blocks all
|
|
# inbound on ports 3000+ from the public internet, so this is effectively
|
|
# internal-only despite the bind address.
|
|
ports:
|
|
- "${JOURNAL_HOST_PORT:?JOURNAL_HOST_PORT must be set}:3000"
|
|
networks:
|
|
- trails-shared
|
|
environment:
|
|
DOMAIN: ${DOMAIN:?DOMAIN must be set}
|
|
ORIGIN: https://${DOMAIN}
|
|
# PR previews override this to point at the persistent staging
|
|
# planner (planner.staging.trails.cool). Persistent staging defaults
|
|
# to its own planner subdomain.
|
|
PLANNER_URL: ${PLANNER_URL:-https://planner.${DOMAIN}}
|
|
IS_FLAGSHIP: ""
|
|
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@postgres:5432/${STAGING_DATABASE:?STAGING_DATABASE must be set}
|
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set}
|
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
|
NODE_ENV: production
|
|
PORT: 3000
|
|
SENTRY_DSN: ${SENTRY_DSN_JOURNAL:-}
|
|
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
|
|
SMTP_URL: ""
|
|
SMTP_FROM: trails.cool staging <noreply@staging.trails.cool>
|
|
WAHOO_CLIENT_ID: ""
|
|
WAHOO_CLIENT_SECRET: ""
|
|
WAHOO_WEBHOOK_TOKEN: ""
|
|
GARMIN_CLIENT_ID: ${GARMIN_CLIENT_ID:-}
|
|
GARMIN_CLIENT_SECRET: ${GARMIN_CLIENT_SECRET:-}
|
|
DEMO_BOT_ENABLED: ""
|
|
# Federation (social-federation rollout). Enabled by cd-staging.yml
|
|
# for persistent staging (12.2) and for PR previews (12.4 — every
|
|
# preview is a second live instance staging can follow across).
|
|
FEDERATION_ENABLED: ${FEDERATION_ENABLED:-}
|
|
FEDERATION_KEY_ENCRYPTION_KEY: ${FEDERATION_KEY_ENCRYPTION_KEY:-}
|
|
FEDERATION_LOG_LEVEL: ${FEDERATION_LOG_LEVEL:-}
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 3
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
memory: 256M
|
|
|
|
planner:
|
|
image: ghcr.io/trails-cool/planner:${PLANNER_IMAGE_TAG:-latest}
|
|
# Only the persistent staging stack runs a planner. PR previews are
|
|
# journal-only and point their PLANNER_URL at the persistent
|
|
# planner.staging.trails.cool. Saves ~256MB per active preview.
|
|
profiles: ["persistent"]
|
|
restart: unless-stopped
|
|
# Same rationale as the journal port — see the comment there.
|
|
ports:
|
|
- "${PLANNER_HOST_PORT:-3101}:3001"
|
|
networks:
|
|
- trails-shared
|
|
environment:
|
|
BROUTER_URL: ${BROUTER_URL:?BROUTER_URL must be set}
|
|
BROUTER_AUTH_TOKEN: ${BROUTER_AUTH_TOKEN:?BROUTER_AUTH_TOKEN must be set}
|
|
OVERPASS_URLS: ${OVERPASS_URLS:-https://lz4.overpass-api.de/api/interpreter,https://overpass-api.de/api/interpreter}
|
|
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@postgres:5432/${STAGING_DATABASE}
|
|
NODE_ENV: production
|
|
PORT: 3001
|
|
SENTRY_DSN: ${SENTRY_DSN_PLANNER:-}
|
|
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 3
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
memory: 256M
|
|
|
|
networks:
|
|
# Staging/preview services attach only to trails-shared — created (and
|
|
# IPv6-enabled) by the production compose project. There is no per-project
|
|
# default network here, so v6 egress comes from trails-shared itself.
|
|
trails-shared:
|
|
external: true
|
|
name: trails-shared
|