Ownership was checked ad hoc: some handlers loaded-and-compared
ownerId, some lib mutators enforced it in WHERE clauses and silently
no-op'd for non-owners, and nothing tied the two together. Two real
authorization bugs hid in the gaps: linkActivityToRoute ignored its
ownerId parameter entirely (any logged-in user could relink any
activity), and createRouteFromActivity loaded any activity without an
ownership check (a non-owner could copy a private activity's GPX into
their own route). PUT /api/v1/routes/:id also returned ok:true for
non-owners without updating anything.
ownership.server.ts is now the single enforcement point:
- loadOwnedRoute / loadOwnedActivity (non-throwing, for callers with
their own error vocabulary) and requireOwnedRoute /
requireOwnedActivity (throwing data() 404/403 for web handlers; 404
by default so guessed ids don't leak existence)
- the returned entities carry an Owned<> brand; mutators (updateRoute,
deleteRoute, deleteActivity, updateActivityVisibility,
linkActivityToRoute, createRouteFromActivity) now require an
OwnedRef, so skipping the check is a compile error
- vouchOwnership is the explicit, greppable escape hatch for the one
non-session authorization path (the Planner JWT callback)
- WHERE ownerId clauses stay in the mutators as defense in depth
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>