diff --git a/docs/reviews/README.md b/docs/reviews/README.md new file mode 100644 index 0000000..088c7b9 --- /dev/null +++ b/docs/reviews/README.md @@ -0,0 +1,17 @@ +# Reviews + +Point-in-time review decks. Self-contained HTML — open directly in a +browser (arrow keys / click to navigate). Each is a snapshot of its +date; findings were triaged and tracked through PRs, so the deck reads +as the "why" behind a batch of follow-up work rather than a live +checklist. + +- **`architecture-review-2026-06-10.html`** — deepening opportunities + (shallow modules, implicit interfaces, duplicated choreography). The + 10 candidates were implemented across PRs that reference them as + "candidate N from the 2026-06-10 review". + +- **`security-review-2026-06-10.html`** — defensive review of the auth, + federation, secrets, and injection surfaces. Severity-calibrated + (several scanner false-positives were verified and dropped). All + actionable findings were fixed in follow-up PRs. diff --git a/docs/reviews/architecture-review-2026-06-10.html b/docs/reviews/architecture-review-2026-06-10.html new file mode 100644 index 0000000..e3d25b5 --- /dev/null +++ b/docs/reviews/architecture-review-2026-06-10.html @@ -0,0 +1,586 @@ + + +
+ + +A broad repo scan for architectural friction — shallow modules, implicit interfaces, duplicated choreography — with sketches for turning them into deep modules.
+2026-06-10 · four parallel codebase surveys (Journal, Planner, packages, cross-cutting) · key claims verified against source · checked against CONTEXT.md and ADR-0001…0006
+Imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
+SyncProvider (ADR-0002)AuthMethod polymorphism (ADR-0005)completeAuth, ConnectedServiceManager, capability seams (Importer / RoutePusher / WebhookReceiver).unknown end-to-endmap, uiapps/journal/app/routes/api.sync.komoot.import.ts:34 apps/journal/app/jobs/komoot-bulk-import.ts
Verified: the route enqueues creds: service.credentials — the raw JSONB — in the pg-boss payload. This bypasses withFreshCredentials, the ConnectedServiceManager's whole reason to exist:
markNeedsRelink never fires on failure.The Wahoo path goes through the manager correctly; Komoot is the one defector from the seam.
+Jobs carry only the serviceId. The handler resolves fresh credentials through ConnectedServiceManager at execution time — identical to every other capability caller.
unknown end-to-end high leveragepackages/jobs/src/types.ts apps/journal/app/jobs/ (~14 handlers) · ~7 enqueue sites
Verified: JobDefinition.handler is (jobs: Job<unknown>[]) => Promise<unknown>.
item.data as SomePayload; every enqueue passes a bare string queue name + unchecked object.unknown exists for a documented contravariance reason — but that's an implementation constraint leaking into the interface.enqueueOptional swallows queue failures: "saved but fan-out never queued" looks like success.A JobPayloads map type ('notifications-fanout': { activityId: string }, …) with typed enqueue<K> and typed handler registration. The contravariance cast happens once, inside the package — not fourteen times at the edges.
apps/planner/app/lib/use-yjs.ts use-routing.ts use-waypoint-manager.ts use-elevation-data.ts + components (SessionView, ProfileSelector, ElevationChart, …)
Verified: ~15 string-keyed entries (geojson, coordinates, segmentBoundaries, surfaces, highways, maxspeeds, …) read at ~30 raw call sites.
routeData.get("surfaces") as string | undefined and re-parse JSON.parseJsonArray copy-pasted in two hooks with different signatures; waypoint extraction written four times.The document schema is the Planner's most important interface — enforced by convention only.
+A routeData schema module: typed read/write of the whole document state, JSON encoding internal — analogous to what waypoint-ymap.ts already does well for waypoints. Hooks and components consume a RouteDataState, never raw Y.Map keys.
apps/planner/app/components/SaveToJournalButton.tsx apps/planner/app/components/ExportButton.tsx
One buildGpxData(yjs) module — naturally layered on candidate 3 — consumed by both buttons and any future export path.
createRoute / createActivity duplicate the GPX choreography high leverageapps/journal/app/lib/routes.server.ts (~24–78) apps/journal/app/lib/activities.server.ts (~68–122) apps/journal/app/lib/gpx-save.server.ts
Deepen the gpx-save module to own the full validate-and-derive step: one function returning parsed GPX + stats (accepting precomputed stats), leaving callers with only entity-specific row shapes. Extends ADR-0006 rather than contradicting it.
+Worth grilling: should basic validation move down into packages/gpx, which already owns parsing and stat computation?
+ packages/types/src/index.ts packages/api/src/routes.ts (Zod) packages/db/src/schema/journal.ts (Drizzle)
Route exists three times: hand-written interface (types), Zod RouteSummary/RouteDetail (api), Drizzle columns (db) — each with different fields and nullability.api.v1.routes._index.ts constructs response JSON manually, not through the schema).RouteListResponse.Pick one canonical source — likely derive TS types from the Drizzle schema, have the api Zod schemas reference them — and validate responses against the contract at the seam (typed response helpers, or validation in the v1 handlers).
+packages/api start guarding real traffic instead of just the schemas themselves.routes.$id.edit.server.ts push-action.server.ts:54 activities.server.ts (mutators that assume the caller checked) · others
route.ownerId !== user.id is re-checked in some loaders, re-checked in some lib functions, absent in others where the precondition is implicit.A loadOwnedRoute(routeId, userId) (and activity twin) as the single enforcement point, returning a branded type that mutators require — making bypass a compile error rather than a code-review catch.
completeAuth gave session minting.api.sync.connect.$provider.ts api.sync.callback.$provider.ts api.sync.push.$provider.$routeId.ts oauth-state.server.ts
An OAuth flow module owning initiate (returns redirect with cookies set) and complete (consumes request, returns exchange result + decoded state). Routes shrink to thin adapters.
+map and ui cleanuppackages/map (79 lines: re-exports + a 35-line MapView) packages/ui (162 lines)
Both fail the deletion test in the telling direction — deleting them moves almost nothing:
+map adds an import-ambiguity tax: apps import from map and map-core inconsistently (~21 vs ~19 sites).@trails-cool/ui is imported only by the two apps' root.tsx — for the stylesheet. Button/Input/Card have zero consumers; both apps roll their own buttons inline.map the only import surface for apps.ui or commit to it as a real design system — the half-state is the worst option.e2e/ — seed-route boilerplate in 5+ files · auth helpers defined inside auth.test.ts · http://localhost:3000/3001 hardcoded in 10+ files
A real e2e/lib/ fixtures module: seedRoute(), registerAndLogin(), base-URL constants, authenticator setup — imported by every spec.
Result<T,E>-everywhere error-handling rewrite — too sweeping; the narrower wins live inside candidates 5 and 7.| # | Candidate | Why it's first-tier |
|---|---|---|
| 1 | Komoot credentials bypass | Closest to a real defect — credentials at rest, no refresh, seam violated |
| 3 | Planner routeData schema module | Highest-leverage refactor — 30 call sites, ~7-file blast radius per attribute |
| 2 | Typed job seam | Best testability-per-effort — one registry, fourteen handlers gain types |
| 7 | Branded ownership loading | Best safety-per-effort — bypass becomes a compile error |
Then: 5 & 6 (one source of truth for GPX stats and domain types), 8 (before the next OAuth provider lands), 4 / 9 / 10 as opportunistic cleanups.
+Defensive review of the auth, federation, secret-management, and injection surfaces across both apps and the infrastructure — findings verified against source and severity-calibrated.
+2026-06-10 · four parallel review sweeps (auth/session/JWT · federation/SSRF · secrets/crypto · injection/uploads/infra) · every load-bearing claim re-checked against the code before inclusion
+The automated sweeps over-flagged. I verified each high-severity claim against the source and downgraded or dropped the ones that didn't hold up. That recalibration is itself a finding: don't action the raw scanner output.
+jose with a symmetric key already rejects alg:none and asymmetric algs. Explicit allow-list is hardening only. → Info.env is gitignored and never appears in history; values are dev creds. → InfoHigh Medium Low Info
+0.0.0.0 Lowapps/planner/app/routes/api.sessions.ts · apps/planner/app/routes/api.save-to-journal.ts · helper apps/planner/app/lib/url-validation.server.ts (exists, unused here)
Verified: POST /api/sessions is anonymous (the Planner is stateless) and stores the caller-supplied callbackUrl with no validation. On save, the Planner server fetches it:
resp = await fetchWithTimeout(session.callbackUrl, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${session.callbackToken}` },
+ ...
+ Anyone can make the Planner backend issue POSTs to arbitrary hosts — internal services, 169.254.169.254, localhost. A SAFE-scheme/host validator (validateFetchUrl) already exists in the repo but is not applied on this path.
Validate callbackUrl at session-create against the existing allowlist helper, rejecting non-HTTP(S) schemes and private/loopback/link-local resolutions:
const v = validateFetchUrl(callbackUrl, {
+ allowedHosts: getCallbackAllowedHosts(),
+});
+if (!v.ok) return data(
+ { error: "Invalid callback URL" },
+ { status: 400 },
+);
+ Re-validate at fetch time too (defends against DNS rebinding between create and save).
+apps/journal/app/routes/api.v1.uploads.ts
Verified: the presigned-upload endpoint builds the S3 key from the raw client filename and accepts any content-type:
+const key =
+ `${resourceType}/${resourceId}/${randomUUID()}-${filename}`;
+ filename in the key lets a caller shape arbitrary key prefixes (the UUID prevents collision/overwrite, and S3 keys aren't a filesystem, so this is shaping not traversal).resourceId ownership is checked so a user can't mint upload URLs under another user's resource path.contentType (e.g. image/jpeg, image/png, application/gpx+xml) and bind it into the presign so the upload can't differ.filename to [A-Za-z0-9._-] or drop it from the key entirely (the UUID is enough).Content-Disposition: attachment / from a separate origin, and verify resourceId belongs to the caller.apps/journal/app/lib/connected-services/oauth-flow.server.ts (~line 113) · pattern also in manager.ts (markNeedsRelink reason)
Verified: the code-exchange failure path logs the whole error object:
+} catch (e) {
+ console.error(
+ `OAuth callback failed for ${manifest.id}:`, e);
+ If a provider's error response embeds the authorization code, a token, or other sensitive context in the thrown error, it lands in server logs and Sentry. The blast radius is small (only the failure branch, and the affected user is the token's owner), hence Low — but credentials in logs are worth closing.
+console.error(
+ `OAuth callback failed for ${manifest.id}:`,
+ e instanceof Error ? e.message : String(e),
+);
+ Log a redacted shape (name + truncated message), never the raw object. Apply the same to markNeedsRelink's provider-supplied reason.
0.0.0.0:2019 Lowinfrastructure/Caddyfile (line 3)
Verified: admin 0.0.0.0:2019. The port isn't published to the host, so it's not internet-reachable — but it is reachable by every container on the Docker network. The Caddy admin API can rewrite routes and reverse-proxy targets, so an RCE in the journal or planner container becomes "redirect all traffic" with no extra auth.
admin localhost:2019
+ Bind admin to loopback inside the Caddy container (or disable it with admin off if no live-reload is needed). Turns a one-step lateral move into a non-path.
apps/journal/app/lib/auth.server.ts (register + magic-link create paths)
Verified: the API returns distinguishable messages — "Email already in use" vs "Username already taken", and magic-link create throws "No account found for this email". An attacker can probe which emails/usernames are registered.
Calibrated to Low: username availability is intentionally visible at registration anyway, and this is a privacy/info-leak issue, not an account-takeover one.
+apps/journal/app/lib/federation*.server.ts · apps/journal/app/jobs/poll-remote-*.ts
Verified: Fedify verifies HTTP Signatures on inbox listeners by default; allowPrivateAddress is gated behind an env flag used only in e2e; private profiles don't federate (checked in every dispatcher); inbox replay is guarded via the Postgres KV store. The "unsigned activities accepted" alarm does not hold.
/users/:u/inbox is rejected — so a future config change can't silently disable verification.X-Forwarded-For Lowapps/journal/app/lib/rate-limit.server.ts
Map. Correct and safe for the single-instance flagship; horizontal scaling silently weakens every limit (each instance has its own counters).clientIp() trusts the first X-Forwarded-For hop. Safe behind Caddy today (the app ports aren't published); becomes spoofable the moment the container is exposed directly.Both are documented assumptions in the code, not oversights — recorded here so the assumption stays visible when the topology changes.
+XFF[0].apps/journal/app/lib/crypto.server.ts · connected-services/providers/komoot/* · api.sync.komoot.connect.ts
Verified: the Komoot web-login password is encrypted at rest with AES-256-GCM, random 12-byte IV per encryption, scrypt-derived key. It must be reversibly stored because re-login replays it — that's inherent to web-login providers (ADR-recorded), not a flaw. The cipher usage is correct.
+scrypt cost params (N, r, p) so a future Node default change can't alter derivation.Credentials type off the Record<string, unknown> catch-all so a future kind can't accidentally store a secret in the clear.sql.identifier().safeReturnTo() blocks open redirects (local paths only, no //).requireSecret() refuses to boot prod on a dev-fallback secret..env gitignored and never in history.sendDefaultPii: false, no session replay.consumed_jwt_jti, atomic ON CONFLICT) and single-use magic tokens (atomic UPDATE … RETURNING) — both race-proof.E2E env, never set in prod compose.unsafe-eval); Garmin webhook host allowlist; app ports unpublished behind Caddy; deploy secrets SOPS-encrypted, not echoed.| # | Finding | Sev | Do |
|---|---|---|---|
| 1 | SSRF via Planner session callback URL | High | Apply validateFetchUrl at create + fetch |
| 2 | Upload content-type / filename / ownership | Medium | Allowlist type, sanitize key, check owner |
| 3 | OAuth callback logs raw exception | Low | Log redacted message only |
| 4 | Caddy admin on 0.0.0.0:2019 | Low | Bind to localhost / admin off |
| 5 | User enumeration via auth errors | Low | Generic responses on the email paths |
| 6 | Federation tests + fetch caps | Low | Add signature-rejection test, size cap |
| 7 | In-process rate limit / XFF trust | Low | Shared store before scaling out |
| 8 | Komoot credential edges | Info | Encrypt email, pin scrypt params |
No Critical issues. One High, externally-reachable and unauthenticated — fix first. Everything else is hardening on an already-solid base. Four scanner "Critical/High/Medium" alarms were verified false and dropped; trust the code, not the raw sweep.
+