Files
GearBox/.planning/phases/32-setup-sharing-system/32-02-PLAN.md
Jean-Luc Makiola 81a654085d docs(32): create phase plans for setup sharing system
4 plans in 3 waves:
- Wave 1: Schema migration (isPublic→visibility) + shares table
- Wave 2: Share link service + API routes
- Wave 3: Share modal UI + shared setup viewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:05:36 +02:00

338 lines
15 KiB
Markdown

---
phase: 32-setup-sharing-system
plan: 02
type: execute
wave: 2
depends_on: [01]
files_modified:
- src/server/services/share.service.ts
- src/server/routes/shares.ts
- src/server/index.ts
- src/shared/schemas.ts
- tests/services/share.service.test.ts
autonomous: true
requirements:
- TBD
must_haves:
truths:
- "Owner can create a share link for their setup with a specified expiration"
- "Owner can list all share links for their setup"
- "Owner can revoke a specific share link"
- "Share links generate unique URL-safe tokens with 128-bit entropy"
- "Expired share tokens are rejected"
- "Revoked share tokens are rejected"
- "Changing visibility to private deactivates all share links"
- "Changing visibility back to link reactivates deactivated links"
artifacts:
- path: "src/server/services/share.service.ts"
provides: "Share link CRUD, token validation, visibility transition side effects"
exports: ["createShareLink", "getShareLinks", "revokeShareLink", "validateShareToken", "deactivateShareLinks", "reactivateShareLinks"]
- path: "src/server/routes/shares.ts"
provides: "Share link API endpoints nested under /api/setups/:id/shares"
- path: "tests/services/share.service.test.ts"
provides: "Full service test coverage for share link operations"
key_links:
- from: "src/server/services/share.service.ts"
to: "src/db/schema.ts"
via: "shares table CRUD operations"
pattern: "shares.*insert|shares.*select|shares.*update"
- from: "src/server/routes/shares.ts"
to: "src/server/services/share.service.ts"
via: "service function calls"
pattern: "createShareLink|getShareLinks|revokeShareLink"
- from: "src/server/services/setup.service.ts"
to: "src/server/services/share.service.ts"
via: "visibility change triggers link deactivation/reactivation"
pattern: "deactivateShareLinks|reactivateShareLinks"
---
<objective>
Create the share link service and API routes for managing setup share links — creating links with configurable expiration, listing active links, revoking links, and validating share tokens.
Purpose: This is the backend for the share modal UI (Plan 03) and the shared setup viewer (Plan 04). Implements D-04 through D-12 share link mechanics.
Output: New share service, API routes, and comprehensive tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/32-setup-sharing-system/32-CONTEXT.md
@.planning/phases/32-setup-sharing-system/32-RESEARCH.md
@.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md
<interfaces>
<!-- Key types and contracts from Plan 01 output. -->
From src/db/schema.ts (after Plan 01):
```typescript
export const shares = pgTable("shares", {
id: serial("id").primaryKey(),
setupId: integer("setup_id").notNull().references(() => setups.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
permission: text("permission").notNull().default("read"),
expiresAt: timestamp("expires_at"),
userId: integer("user_id").references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
revokedAt: timestamp("revoked_at"),
});
```
Existing service pattern (from src/server/services/setup.service.ts):
```typescript
type Db = typeof prodDb;
export async function createSetup(db: Db, userId: number, data: CreateSetup) { ... }
```
Existing route pattern (from src/server/routes/setups.ts):
```typescript
type Env = { Variables: { db?: any; userId?: number } };
const app = new Hono<Env>();
app.post("/", zValidator("json", schema), async (c) => { ... });
```
Token generation pattern (from src/server/services/auth.service.ts):
```typescript
import { randomBytes } from "node:crypto";
const rawKey = randomBytes(32).toString("hex");
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create share service with token generation, CRUD, and visibility transitions</name>
<files>src/server/services/share.service.ts, src/server/services/setup.service.ts, src/shared/schemas.ts, tests/services/share.service.test.ts</files>
<read_first>src/server/services/setup.service.ts, src/server/services/auth.service.ts, src/shared/schemas.ts, tests/services/setup.service.test.ts, tests/helpers/db.ts</read_first>
<behavior>
- createShareLink: generates a 128-bit random base64url token, inserts share row, returns share with full URL
- createShareLink with expiresInDays=7: sets expiresAt to 7 days from now
- createShareLink with expiresInDays=null: sets expiresAt to null (infinite)
- createShareLink for non-owned setup: returns null
- getShareLinks: returns all shares for a setup owned by the user, ordered by createdAt desc
- revokeShareLink: sets revokedAt to now, returns updated share
- revokeShareLink for non-owned share: returns null
- validateShareToken with valid token: returns setupId
- validateShareToken with expired token: returns null
- validateShareToken with revoked token: returns null
- validateShareToken with nonexistent token: returns null
- deactivateShareLinks: sets revokedAt on all non-manually-revoked links for a setup
- reactivateShareLinks: clears revokedAt on visibility-deactivated links only
</behavior>
<action>
**Add share Zod schemas to `src/shared/schemas.ts`:**
```typescript
export const createShareLinkSchema = z.object({
expiresInDays: z.union([z.literal(7), z.literal(14), z.literal(30), z.null()]).default(14),
});
```
**Create `src/server/services/share.service.ts`** following existing service patterns (db as first param, no HTTP awareness):
```typescript
import { randomBytes } from "node:crypto";
import { and, eq, isNull, sql } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts";
import { shares, setups } from "../../db/schema.ts";
type Db = typeof prodDb;
export async function createShareLink(
db: Db,
userId: number,
setupId: number,
options: { expiresInDays: number | null },
) { ... }
```
Functions to implement:
- `createShareLink(db, userId, setupId, { expiresInDays })`:
1. Verify setup belongs to userId
2. Generate token: `randomBytes(16).toString("base64url")` (22 chars, URL-safe, 128 bits — per D-04)
3. Calculate expiresAt: `new Date(Date.now() + days * 86400000)` or null (per D-07)
4. Insert into shares table with permission='read' (per D-09)
5. Return the created share row
- `getShareLinks(db, userId, setupId)`:
1. Verify setup belongs to userId
2. Return all shares for setupId ordered by createdAt desc (per D-05, D-08)
- `revokeShareLink(db, userId, shareId)`:
1. Join shares with setups to verify ownership
2. Set revokedAt = new Date() (per D-08)
3. Return updated share
- `validateShareToken(db, token)`:
1. Find share by token where revokedAt IS NULL
2. Check expiresAt IS NULL OR expiresAt > NOW()
3. Return { setupId, permission } or null
- `deactivateShareLinks(db, setupId)`:
1. Set revokedAt on all shares where revokedAt IS NULL (per D-03)
2. Mark these with a sentinel: use current timestamp (distinguishable from manual revokes by exact timestamp match)
- `reactivateShareLinks(db, setupId)`:
1. Clear revokedAt on all shares that were deactivated (where revokedAt IS NOT NULL and the share was not manually revoked before deactivation)
2. Simple approach per D-03: clear revokedAt on ALL non-expired shares for the setup. This reactivates everything, including manually revoked links — acceptable UX since user explicitly chose to re-enable sharing.
**Update `src/server/services/setup.service.ts` `updateSetup` function:**
- After updating visibility, check transitions:
- If new visibility is "private" and old was not: call `deactivateShareLinks(db, setupId)`
- If new visibility is "link" or "public" and old was "private": call `reactivateShareLinks(db, setupId)`
- To detect the transition, read the current setup before update.
**Create `tests/services/share.service.test.ts`:**
- Use `createTestDb()` from tests/helpers/db.ts
- Seed a user, category, and setup
- Test all behaviors listed above
</action>
<verify>
<automated>bun test tests/services/share.service.test.ts</automated>
</verify>
<acceptance_criteria>
- `src/server/services/share.service.ts` exports: createShareLink, getShareLinks, revokeShareLink, validateShareToken, deactivateShareLinks, reactivateShareLinks
- Token generation uses `randomBytes(16).toString("base64url")` (128-bit entropy)
- `tests/services/share.service.test.ts` has tests for all 12 behaviors above
- All tests pass: `bun test tests/services/share.service.test.ts` exits 0
- updateSetup in setup.service.ts calls deactivateShareLinks when visibility transitions to private
</acceptance_criteria>
<done>Share service with full CRUD, token validation, visibility transitions, and tests</done>
</task>
<task type="auto">
<name>Task 2: Create share link API routes and register in server</name>
<files>src/server/routes/shares.ts, src/server/index.ts</files>
<read_first>src/server/routes/setups.ts, src/server/index.ts</read_first>
<action>
**Create `src/server/routes/shares.ts`** following existing route patterns:
```typescript
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { createShareLinkSchema } from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts";
import {
createShareLink,
getShareLinks,
revokeShareLink,
validateShareToken,
} from "../services/share.service.ts";
import { getSetupWithItems } from "../services/setup.service.ts";
import { withImageUrls } from "../services/storage.service.ts";
type Env = { Variables: { db?: any; userId?: number } };
const app = new Hono<Env>();
```
Endpoints:
1. `POST /api/setups/:id/shares` — Create share link (auth required)
- Validate body with `createShareLinkSchema`
- Call `createShareLink(db, userId, setupId, data)`
- Return 201 with share object
2. `GET /api/setups/:id/shares` — List share links (auth required)
- Call `getShareLinks(db, userId, setupId)`
- Return array of shares
3. `DELETE /api/setups/:id/shares/:shareId` — Revoke share link (auth required)
- Call `revokeShareLink(db, userId, shareId)`
- Return 200 with updated share or 404
4. `GET /api/shared/:token` — Access setup via share token (NO auth required)
- Call `validateShareToken(db, token)`
- If null: return 404 `{ error: "Not found" }` (per research: return 404, not 403, to prevent token enumeration)
- If valid: call `getSetupWithItems` (need to add a version that fetches by setupId without userId check) or query directly
- Return setup with items (same format as public view)
**For the shared access endpoint**, add a new function to setup.service.ts or use the existing `getPublicSetupWithItems` from profile.service.ts but modify it to not check isPublic/visibility (since the share token already authorizes access). Create `getSetupWithItemsById(db, setupId)` that returns setup+items without user/visibility checks.
**Register routes in `src/server/index.ts`:**
- Add `import { shareRoutes } from "./routes/shares.ts";`
- Register: `app.route("/api/setups", shareRoutes)` — but since setup routes are already on `/api/setups`, either:
a. Add the share sub-routes directly to `src/server/routes/setups.ts` (simpler, keeps all setup routes together)
b. Or create nested route registration
**Recommended approach:** Add share endpoints directly to `src/server/routes/setups.ts` rather than a separate file, since they are nested under `/api/setups/:id/shares`. Add the shared access route as a separate top-level route registered at `/api/shared`.
**Also add the short URL redirect route to `src/server/index.ts`:**
```typescript
// Short share URL redirect — before SPA catch-all
app.get("/s/:token", async (c) => {
const db = c.get("db");
const token = c.req.param("token");
const result = await validateShareToken(db, token);
if (!result) return c.redirect("/", 302);
return c.redirect(`/setups/${result.setupId}?share=${token}`, 302);
});
```
Register this BEFORE the SPA catch-all route (per D-06).
</action>
<verify>
<automated>bun run lint && bun test</automated>
</verify>
<acceptance_criteria>
- `POST /api/setups/:id/shares` creates a share link and returns 201
- `GET /api/setups/:id/shares` returns array of shares for the setup
- `DELETE /api/setups/:id/shares/:shareId` sets revokedAt and returns updated share
- `GET /api/shared/:token` returns setup with items for valid token, 404 for invalid/expired/revoked
- `GET /s/:token` redirects to `/setups/{setupId}?share={token}` for valid tokens, redirects to `/` for invalid
- Share endpoints under `/api/setups/:id/shares` require authentication
- `GET /api/shared/:token` does NOT require authentication
- `bun run lint` passes
- `bun test` passes
</acceptance_criteria>
<done>Share link API routes registered and functional, short URL redirect works</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client->API (share CRUD) | Authenticated user creating/revoking share links |
| anonymous->API (token validation) | Unauthenticated access via share token |
| anonymous->short URL | Unauthenticated redirect via /s/:token |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-32-03 | Spoofing | /api/shared/:token | mitigate | Token is 128-bit random (base64url) — brute force infeasible. Rate limiting from existing middleware applies. |
| T-32-04 | Information Disclosure | /api/shared/:token | mitigate | Return 404 for ALL invalid tokens (expired, revoked, nonexistent) — no distinction reveals token validity |
| T-32-05 | Elevation of Privilege | share CRUD endpoints | mitigate | All share mutations verify setup ownership (userId check before any write) |
| T-32-06 | Tampering | createShareLink | mitigate | expiresInDays validated by Zod enum (7/14/30/null) — cannot set arbitrary expiration |
| T-32-07 | Denial of Service | createShareLink | accept | No per-setup share limit enforced. Low risk for single-user app. Monitor if needed. |
</threat_model>
<verification>
1. `bun test tests/services/share.service.test.ts` — all service tests pass
2. `bun run lint` — no lint errors
3. `bun test` — full test suite passes
4. Manual: `curl -X POST http://localhost:3000/api/setups/1/shares` returns 201 with token
5. Manual: `curl http://localhost:3000/api/shared/{token}` returns setup data
6. Manual: `curl -I http://localhost:3000/s/{token}` returns 302 redirect
</verification>
<success_criteria>
- Share links can be created, listed, and revoked via API
- Share tokens validate correctly (valid, expired, revoked, nonexistent)
- Visibility transitions correctly deactivate/reactivate share links
- Short URL /s/:token redirects correctly
- No token enumeration possible (all failures return 404)
</success_criteria>
<output>
After completion, create `.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md`
</output>