--- 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" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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 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(); 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"); ``` Task 1: Create share service with token generation, CRUD, and visibility transitions src/server/services/share.service.ts, src/server/services/setup.service.ts, src/shared/schemas.ts, tests/services/share.service.test.ts 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 - 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 **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 bun test tests/services/share.service.test.ts - `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 Share service with full CRUD, token validation, visibility transitions, and tests Task 2: Create share link API routes and register in server src/server/routes/shares.ts, src/server/index.ts src/server/routes/setups.ts, src/server/index.ts **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(); ``` 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). bun run lint && bun test - `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 Share link API routes registered and functional, short URL redirect works ## 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. | 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 - 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) After completion, create `.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md`