trails/openspec/changes/route-sharing/design.md
Ullrich Schäfer 0a330e4466
Break up route-features into focused specs, add new changes
Archive the monolithic route-features spec and replace with 9 focused
OpenSpec changes: multi-day-routes, waypoint-notes (with POI snapping),
undo-redo, local-dev-stack, route-sharing, route-discovery,
activity-photos, osm-overlays, plus the existing changelog and
komoot-import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:53:41 +02:00

255 lines
10 KiB
Markdown

## Context
The Journal stores routes in `journal.routes` with an `ownerId` foreign key to
`journal.users`. Route versions live in `journal.route_versions` with a
`routeId` reference. Activities live in `journal.activities` with an `ownerId`.
Currently, all route access is implicitly owner-only: the route detail loader
returns the route regardless of who requests it, but the route list only shows
the current user's routes. There is no visibility column, no sharing table, and
no permission checks beyond ownership.
The architecture doc defines a permission matrix with private/public/shared
visibility, view/edit permission levels, and forking. The original
`route-features` spec designed these alongside spatial search, multi-day routes,
and photos. This design covers only the sharing, permissions, and forking scope.
## Decisions
### D1: Visibility enum -- private and public
Add a `visibility` column to `journal.routes` and `journal.activities` using a
PostgreSQL enum type `journal.visibility_enum` with values `private` and
`public`. Default is `private`.
```sql
CREATE TYPE journal.visibility_enum AS ENUM ('private', 'public');
ALTER TABLE journal.routes ADD COLUMN visibility journal.visibility_enum NOT NULL DEFAULT 'private';
ALTER TABLE journal.activities ADD COLUMN visibility journal.visibility_enum NOT NULL DEFAULT 'private';
```
In Drizzle:
```typescript
import { pgEnum } from "drizzle-orm/pg-core";
export const visibilityEnum = journalSchema.enum("visibility_enum", ["private", "public"]);
// On routes table:
visibility: visibilityEnum("visibility").notNull().default("private"),
// On activities table:
visibility: visibilityEnum("visibility").notNull().default("private"),
```
Only two values for now. A `followers_only` value can be appended to the enum
later when the follow system exists. Starting minimal avoids unused code paths
and UI states.
### D2: Route shares table
Create `journal.route_shares` to store per-user sharing permissions:
```typescript
export const routeSharePermission = journalSchema.enum(
"route_share_permission",
["view", "edit"],
);
export const routeShares = journalSchema.table("route_shares", {
id: text("id").primaryKey(),
routeId: text("route_id")
.notNull()
.references(() => routes.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: routeSharePermission("permission").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
```
A unique constraint on `(routeId, userId)` prevents duplicate shares. If the
owner updates a share, the existing row is replaced (upsert). Deleting a share
revokes access immediately.
The table is intentionally simple: one row per user per route. No expiry, no
"pending" state. The owner shares, the recipient sees it. This matches the
architecture doc's "simple permissions" decision.
### D3: Permission functions
Create `apps/journal/app/lib/permissions.server.ts` with two core functions:
```typescript
async function canView(routeId: string, userId: string | null): Promise<boolean>;
async function canEdit(routeId: string, userId: string | null): Promise<boolean>;
```
Logic for `canView`:
1. If user is the route owner: **yes**
2. If route visibility is `public`: **yes** (even if userId is null)
3. If user has a route_share with `view` or `edit` permission: **yes**
4. Otherwise: **no**
Logic for `canEdit`:
1. If user is the route owner: **yes**
2. If user has a route_share with `edit` permission: **yes**
3. Otherwise: **no**
Note: public visibility grants view access but never edit access. Edit access
requires either ownership or an explicit `edit` share.
These functions are used in:
- **Route detail loader**: Replace the current unconditional fetch with a
`canView` check. Return 404 (not 403) for unauthorized access to avoid
revealing route existence.
- **Route list loader**: Query own routes + routes shared with the user.
Public routes are not shown in the list (they are discoverable via the
explore page in a future change).
- **Route edit action**: Check `canEdit` before allowing updates.
- **Edit-in-Planner API**: Check `canEdit` before generating a Planner session.
- **GPX download API**: Check `canView` before serving GPX.
### D4: Forking
Forking copies a route and its latest GPX into the current user's collection.
Only public routes can be forked (shared routes cannot -- the owner explicitly
chose to share, not to allow copying).
Schema addition on `journal.routes`:
```typescript
forkedFromId: text("forked_from_id").references(() => routes.id, { onDelete: "set null" }),
```
`onDelete: "set null"` means if the original route is deleted, the fork remains
but loses the provenance link. This is correct -- the fork is an independent
copy.
Fork API route (`api.routes.$id.fork.ts`):
1. Verify user is logged in
2. Load the source route, check visibility is `public`
3. Create a new route with:
- `ownerId`: current user
- `name`: same as original (user can rename later)
- `description`: same as original
- `gpx`: copy of current GPX
- `geom`: copy of current geometry
- `routingProfile`, `distance`, `elevationGain`, `elevationLoss`: copied
- `forkedFromId`: source route ID
- `visibility`: `private` (forks start private)
4. Create a v1 route version with the copied GPX
5. Redirect to the new route's detail page
UI: A "Fork" button appears on the route detail page when:
- The route is public
- The viewer is logged in
- The viewer is not the owner
The route detail page shows "Forked from [Original Route Name]" with a link
when `forkedFromId` is set and the original route still exists and is viewable.
### D5: Contributor tracking
Add a `contributors` text array column to `journal.route_versions`:
```typescript
contributors: jsonb("contributors").$type<string[]>(),
```
This stores user IDs (not usernames) of people who contributed to this version.
For now this is populated from the JWT callback: the Planner session callback
includes the JWT which identifies the route and the user who initiated the
session. When the callback saves a new version, the contributor is the user
associated with the JWT token.
In the callback handler (`api.routes.$id.callback.ts`):
1. Decode the JWT to get the user identity (already done for auth)
2. Pass the contributor user ID to the version creation function
3. Store it in the `contributors` array on the new version row
For self-saves (owner editing via Planner), the contributor is the owner. For
cross-instance edits (future federation), the contributor would be the remote
user's ActivityPub URI.
Display on the route detail page: each version in the version history shows
contributor names/usernames. The route header shows a combined list of all
unique contributors across versions.
### D6: Share dialog
The share dialog is a modal accessible from the route detail page (owner only).
It provides:
1. **User search**: A text input that searches users by username or display name.
Uses an API endpoint (`api.users.search.ts`) that returns matching users
(excluding the route owner). Search is debounced (300ms) and returns at most
10 results.
2. **Permission selector**: For each user in the share list, a dropdown with
`view` or `edit`. Defaults to `view`.
3. **Current shares**: Shows users the route is already shared with, their
permission level, and a remove button.
4. **Actions**: "Share" button adds the selected user with the chosen permission.
"Remove" button revokes a share.
The dialog uses form submissions (React Router actions) for mutations, keeping
it server-rendered and progressive. The user search uses a client-side fetch to
the search API for responsiveness.
### D7: Visibility toggle
A simple toggle on the route detail page (owner only) that switches between
private and public. Implemented as a form with a hidden intent field:
```html
<form method="post">
<input type="hidden" name="intent" value="toggleVisibility" />
<button type="submit">Make Public / Make Private</button>
</form>
```
The toggle shows the current state clearly: "This route is private" with a
"Make public" button, or "This route is public" with a "Make private" button.
Making a route private does not revoke existing shares -- shared users retain
their access. This is intentional: visibility controls anonymous access, shares
control named-user access.
The same pattern applies to activities: a visibility toggle on the activity
detail page.
### D8: Privacy manifest update
The privacy page (`apps/journal/app/routes/privacy.tsx`) must document:
- **Route visibility**: Routes and activities have a visibility setting
(private or public). Public routes are viewable by anyone with the link.
- **Route sharing**: Owners can share routes with specific users. Shared users
can see the route name, description, and GPX data according to their
permission level.
- **Forking**: Public routes can be forked (copied) by other users. The fork
is an independent copy -- changes to the fork do not affect the original and
vice versa.
- **Contributor tracking**: When you edit someone's route via the Planner, your
user ID is recorded as a contributor on that version.
## Risks / Trade-offs
- **No followers-only visibility**: Starting with just private/public is
simpler but means there is no middle ground. Users who want to share with
followers but not the world must use per-user shares. This is acceptable
until the follow system exists.
- **Fork divergence**: Forks are fully independent copies. There is no mechanism
to sync upstream changes or notify fork owners of updates. This is by design
-- forks are for adaptation, not tracking.
- **Share revocation is immediate**: Removing a share immediately revokes access.
If a shared user has the route open in a Planner session, they can finish
their current session (the JWT is still valid) but cannot start a new one.
- **User search exposes usernames**: The user search API returns usernames and
display names. This is necessary for sharing to work. Rate limiting on the
search endpoint mitigates enumeration concerns.
- **404 vs 403**: Returning 404 for unauthorized access prevents route existence
leaks but makes debugging harder for shared users who lost access. This is the
standard privacy-first approach.