diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 5fd4f60..853c8b6 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -173,7 +173,13 @@ Plans: **Requirements**: TBD (discuss phase) **Success Criteria** (what must be TRUE): TBD (discuss phase) -**Plans**: TBD +**Plans**: 4 plans + +Plans: +- [ ] 32-01-PLAN.md — Schema migration (isPublic to visibility) + shares table + full-stack update +- [ ] 32-02-PLAN.md — Share link service, API routes, and short URL redirect +- [ ] 32-03-PLAN.md — Share modal UI component with visibility picker and link management +- [ ] 32-04-PLAN.md — Shared setup viewer with token detection and read-only mode **UI hint**: yes ### Phase 33: Currency System @@ -227,7 +233,7 @@ Plans: | 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 | | 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 | | 31. Mobile Polish | v2.2 | 2/2 | Complete | 2026-04-12 | -| 32. Setup Sharing System | v2.3 | TBD | Pending | — | +| 32. Setup Sharing System | v2.3 | 0/4 | Planned | — | | 33. Currency System | v2.3 | TBD | Pending | — | | 34. i18n Foundation | v2.3 | TBD | Pending | — | diff --git a/.planning/phases/32-setup-sharing-system/32-01-PLAN.md b/.planning/phases/32-setup-sharing-system/32-01-PLAN.md new file mode 100644 index 0000000..4bac933 --- /dev/null +++ b/.planning/phases/32-setup-sharing-system/32-01-PLAN.md @@ -0,0 +1,298 @@ +--- +phase: 32-setup-sharing-system +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/db/schema.ts + - src/server/services/setup.service.ts + - src/server/services/discovery.service.ts + - src/server/services/profile.service.ts + - src/server/routes/setups.ts + - src/shared/schemas.ts + - src/shared/types.ts + - src/client/hooks/useSetups.ts + - src/client/components/SetupCard.tsx + - src/client/components/SetupsView.tsx + - src/client/routes/setups/$setupId.tsx + - tests/services/setup.service.test.ts + - tests/services/discovery.service.test.ts + - tests/services/profile.service.test.ts +autonomous: true +requirements: + - TBD + +must_haves: + truths: + - "setups table has visibility text column with values private/link/public instead of isPublic boolean" + - "shares table exists with id, setupId, token, permission, expiresAt, userId, createdAt, revokedAt columns" + - "Discovery feed returns only setups with visibility='public'" + - "Public profile returns only setups with visibility='public'" + - "All existing isPublic=true setups migrated to visibility='public'" + - "All existing isPublic=false setups migrated to visibility='private'" + artifacts: + - path: "src/db/schema.ts" + provides: "Updated setups table with visibility column, new shares table" + contains: "visibility.*text.*notNull.*default.*private" + - path: "drizzle/" + provides: "Migration SQL for visibility column and shares table" + key_links: + - from: "src/server/services/discovery.service.ts" + to: "src/db/schema.ts" + via: "visibility column filter" + pattern: "visibility.*public" + - from: "src/server/services/profile.service.ts" + to: "src/db/schema.ts" + via: "visibility column filter" + pattern: "visibility.*public" +--- + + +Migrate the setups visibility model from boolean isPublic to three-tier visibility (private/link/public), add shares table to schema, and update all services, routes, schemas, and client code that reference isPublic. + +Purpose: This is the foundational schema change required by all other plans. Every service, route, and component that references isPublic must be updated atomically to prevent broken queries. + +Output: Updated schema with visibility column and shares table, migrated data, updated services/routes/schemas/hooks/components. + + + +@$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 + + + + +From src/db/schema.ts (current setups table, line 118-127): +```typescript +export const setups = pgTable("setups", { + id: serial("id").primaryKey(), + name: text("name").notNull(), + userId: integer("user_id").notNull().references(() => users.id), + isPublic: boolean("is_public").notNull().default(false), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); +``` + +From src/shared/schemas.ts (setup schemas, lines 86-98): +```typescript +export const createSetupSchema = z.object({ + name: z.string().min(1, "Setup name is required"), + isPublic: z.boolean().optional().default(false), +}); +export const updateSetupSchema = z.object({ + name: z.string().min(1, "Setup name is required"), + isPublic: z.boolean().optional(), +}); +``` + +From src/server/services/setup.service.ts (createSetup uses isPublic): +```typescript +export async function createSetup(db: Db, userId: number, data: CreateSetup) { + const [row] = await db.insert(setups) + .values({ name: data.name, userId, isPublic: data.isPublic ?? false }) + .returning(); + return row; +} +``` + +From src/server/services/discovery.service.ts (line 53): +```typescript +.where(eq(setups.isPublic, true)) +``` + +From src/server/services/profile.service.ts (lines 82, 91): +```typescript +.where(and(eq(setups.userId, userId), eq(setups.isPublic, true))); +// and: +.where(and(eq(setups.id, setupId), eq(setups.isPublic, true))); +``` + + + + + + + Task 1: Update schema — add visibility column, shares table, generate migration + src/db/schema.ts + src/db/schema.ts + +1. In `src/db/schema.ts`, modify the `setups` table: + - Remove `isPublic: boolean("is_public").notNull().default(false)` (per D-02) + - Add `visibility: text("visibility").notNull().default("private")` (per D-01, D-02) + +2. Add new `shares` table after `setupItems` (per D-10, D-11, D-12): +```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"), +}); +``` + +3. Run `bun run db:generate` to generate the Drizzle migration. + +4. The generated migration will likely create a new column and drop the old one. Edit the migration SQL to include data migration: + - After `ALTER TABLE setups ADD COLUMN visibility text NOT NULL DEFAULT 'private'`, add: + - `UPDATE setups SET visibility = 'public' WHERE is_public = true;` + - Then the `ALTER TABLE setups DROP COLUMN is_public` statement. + +5. Run `bun run db:push` to apply the migration. + + + grep -q "visibility" src/db/schema.ts && grep -q "shares" src/db/schema.ts && echo "PASS" || echo "FAIL" + + + - `src/db/schema.ts` contains `visibility: text("visibility").notNull().default("private")` in setups table + - `src/db/schema.ts` contains `shares` table with columns: id, setupId, token, permission, expiresAt, userId, createdAt, revokedAt + - `src/db/schema.ts` does NOT contain `isPublic` or `is_public` + - A new migration file exists in `drizzle/` directory + - `bun run db:push` succeeds without error + + Schema updated, migration generated and applied, isPublic replaced with visibility, shares table created + + + + Task 2: Update all services, routes, schemas, and client code from isPublic to visibility + src/server/services/setup.service.ts, src/server/services/discovery.service.ts, src/server/services/profile.service.ts, src/server/routes/setups.ts, src/shared/schemas.ts, src/shared/types.ts, src/client/hooks/useSetups.ts, src/client/components/SetupCard.tsx, src/client/components/SetupsView.tsx, src/client/routes/setups/$setupId.tsx + src/server/services/setup.service.ts, src/server/services/discovery.service.ts, src/server/services/profile.service.ts, src/shared/schemas.ts, src/client/hooks/useSetups.ts, src/client/routes/setups/$setupId.tsx, src/client/components/SetupCard.tsx, src/client/components/SetupsView.tsx + +**Shared schemas (`src/shared/schemas.ts`):** +- Replace `createSetupSchema`: change `isPublic: z.boolean().optional().default(false)` to `visibility: z.enum(["private", "link", "public"]).optional().default("private")` +- Replace `updateSetupSchema`: change `isPublic: z.boolean().optional()` to `visibility: z.enum(["private", "link", "public"]).optional()` + +**Setup service (`src/server/services/setup.service.ts`):** +- `createSetup`: change `isPublic: data.isPublic ?? false` to `visibility: data.visibility ?? "private"` (per D-01) +- `getAllSetups`: change `isPublic: setups.isPublic` in select to `visibility: setups.visibility` +- `updateSetup`: change `data.isPublic` handling to `data.visibility` — set `updateData.visibility = data.visibility` when defined + +**Discovery service (`src/server/services/discovery.service.ts`):** +- `getPopularSetups`: change `.where(eq(setups.isPublic, true))` to `.where(eq(setups.visibility, "public"))` (per D-19) + +**Profile service (`src/server/services/profile.service.ts`):** +- `getPublicProfile`: change `eq(setups.isPublic, true)` to `eq(setups.visibility, "public")` (per D-19) +- `getPublicSetupWithItems`: change `eq(setups.isPublic, true)` to `eq(setups.visibility, "public")` (per D-19) + +**Setup routes (`src/server/routes/setups.ts`):** +- No route changes needed — routes use service functions and Zod schemas + +**Client hooks (`src/client/hooks/useSetups.ts`):** +- `useUpdateSetup` mutation body: replace any `isPublic` references with `visibility` +- All query return types will auto-update via TypeScript inference + +**Client components:** +- `SetupCard.tsx`: replace any `isPublic` references with `visibility` checks (e.g., `setup.visibility === "public"` instead of `setup.isPublic`) +- `SetupsView.tsx`: replace any `isPublic` references with `visibility` +- `setups/$setupId.tsx`: Replace the globe toggle button (lines 177-203) with a temporary visibility indicator. For now, just show the current visibility state as a read-only badge (the full share modal comes in Plan 03). Replace: + - `onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })}` + - With a static badge showing visibility icon per 32-UI-SPEC.md color table: + - private: lock icon, gray-500/gray-50 + - link: link icon, blue-600/blue-50 + - public: globe icon, green-700/green-50 + - This button will be upgraded to open the share modal in Plan 03. + +**Also check and update:** +- `src/server/routes/account.ts` if it references isPublic +- `src/db/dev-seed.ts` and `src/db/dev-seed-data.ts` — update seed data to use `visibility` instead of `isPublic` +- `src/client/routes/__root.tsx` if it references isPublic +- Any MCP tool definitions that reference isPublic + + + grep -r "isPublic" src/ --include="*.ts" --include="*.tsx" | grep -v node_modules | grep -v ".gen.ts" | wc -l | xargs -I{} test {} -eq 0 && echo "PASS" || echo "FAIL: isPublic references remain" + + + - Zero occurrences of `isPublic` or `is_public` in `src/` directory (excluding node_modules and generated files) + - `src/shared/schemas.ts` contains `visibility: z.enum(["private", "link", "public"])` + - `src/server/services/discovery.service.ts` contains `eq(setups.visibility, "public")` + - `src/server/services/profile.service.ts` contains `eq(setups.visibility, "public")` (two occurrences) + - `src/server/services/setup.service.ts` contains `visibility: data.visibility` + - `src/client/routes/setups/$setupId.tsx` shows visibility badge with lock/link/globe icons + - `bun run lint` passes + - `bun test` passes (existing tests may need updating in Task 3) + + All isPublic references replaced with visibility across the full stack + + + + Task 3: Update existing tests for visibility column + tests/services/setup.service.test.ts, tests/services/discovery.service.test.ts, tests/services/profile.service.test.ts, tests/routes/discovery.test.ts, tests/routes/profiles.test.ts + tests/services/setup.service.test.ts, tests/services/discovery.service.test.ts, tests/services/profile.service.test.ts + +Update all existing tests that reference `isPublic` to use `visibility` instead: + +1. **`tests/services/setup.service.test.ts`**: Replace `isPublic: true` with `visibility: "public"`, `isPublic: false` with `visibility: "private"` in all test fixtures and assertions. + +2. **`tests/services/discovery.service.test.ts`**: Replace `isPublic: true` with `visibility: "public"` in setup creation for discovery feed tests. + +3. **`tests/services/profile.service.test.ts`**: Replace `isPublic: true` with `visibility: "public"` in setup creation for public profile tests. + +4. **`tests/routes/discovery.test.ts`**: Update route test fixtures. + +5. **`tests/routes/profiles.test.ts`**: Update route test fixtures. + +6. **`tests/helpers/db.ts`**: If createTestDb seeds any setup data with isPublic, update to visibility. + +Run `bun test` to verify all tests pass after changes. + + + bun test + + + - Zero occurrences of `isPublic` in `tests/` directory + - `bun test` exits with code 0 (all tests pass) + - Discovery feed tests verify `visibility: "public"` setups appear + - Profile tests verify only `visibility: "public"` setups are returned + + All existing tests pass with the visibility column changes + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client->API | Visibility enum value from untrusted client input | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-32-01 | Tampering | updateSetup endpoint | mitigate | Zod enum validation ensures only "private"/"link"/"public" accepted — `z.enum(["private", "link", "public"])` at route entry | +| T-32-02 | Information Disclosure | getAllSetups | accept | getAllSetups is already scoped to authenticated userId — no cross-user visibility leak | + + + +1. `bun run lint` passes +2. `bun test` passes — all existing tests updated for visibility +3. No `isPublic` references remain in `src/` or `tests/` +4. Schema migration applied successfully + + + +- isPublic column fully replaced by visibility column across entire codebase +- shares table exists in schema (ready for Plan 02) +- Discovery feed shows only visibility='public' setups (identical behavior to before) +- All existing tests pass with visibility column + + + +After completion, create `.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md` + diff --git a/.planning/phases/32-setup-sharing-system/32-02-PLAN.md b/.planning/phases/32-setup-sharing-system/32-02-PLAN.md new file mode 100644 index 0000000..40ddd0f --- /dev/null +++ b/.planning/phases/32-setup-sharing-system/32-02-PLAN.md @@ -0,0 +1,337 @@ +--- +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` + diff --git a/.planning/phases/32-setup-sharing-system/32-03-PLAN.md b/.planning/phases/32-setup-sharing-system/32-03-PLAN.md new file mode 100644 index 0000000..60870fb --- /dev/null +++ b/.planning/phases/32-setup-sharing-system/32-03-PLAN.md @@ -0,0 +1,338 @@ +--- +phase: 32-setup-sharing-system +plan: 03 +type: execute +wave: 3 +depends_on: [01, 02] +files_modified: + - src/client/components/ShareModal.tsx + - src/client/hooks/useShares.ts + - src/client/routes/setups/$setupId.tsx +autonomous: true +requirements: + - TBD + +must_haves: + truths: + - "Share button on setup detail page reflects current visibility state (lock/link/globe icon with state color)" + - "Clicking share button opens the share modal" + - "Share modal shows visibility picker with three options (private/link/public)" + - "Changing visibility in modal immediately updates via API" + - "Share modal shows create link form when visibility is link or public" + - "Creating a share link auto-copies to clipboard and shows in links list" + - "Each share link has copy and revoke actions" + - "Switching to private shows deactivation warning" + - "Share modal works on both desktop and mobile" + artifacts: + - path: "src/client/components/ShareModal.tsx" + provides: "Share modal with visibility picker, link creation, and link management" + exports: ["ShareModal"] + - path: "src/client/hooks/useShares.ts" + provides: "React Query hooks for share link CRUD" + exports: ["useShareLinks", "useCreateShareLink", "useRevokeShareLink"] + key_links: + - from: "src/client/components/ShareModal.tsx" + to: "src/client/hooks/useShares.ts" + via: "Share CRUD mutations" + pattern: "useShareLinks|useCreateShareLink|useRevokeShareLink" + - from: "src/client/routes/setups/$setupId.tsx" + to: "src/client/components/ShareModal.tsx" + via: "Modal open state and render" + pattern: "ShareModal|shareModalOpen" +--- + + +Create the share modal component and wire it into the setup detail page, replacing the temporary visibility badge from Plan 01 with a full share button that opens a Google Docs-style share dialog. + +Purpose: This implements the primary user-facing share UX (D-13 through D-16). Users manage visibility and share links from a single modal. + +Output: ShareModal component, share hooks, and updated setup detail page. + + + +@$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-UI-SPEC.md +@.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md +@.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md + + + + +Share API endpoints (from Plan 02): +``` +POST /api/setups/:id/shares → { id, setupId, token, permission, expiresAt, createdAt, revokedAt } +GET /api/setups/:id/shares → Array +DELETE /api/setups/:id/shares/:shareId → { id, setupId, token, ..., revokedAt } +``` + +Setup update endpoint (from Plan 01): +``` +PUT /api/setups/:id → accepts { name, visibility } → returns updated setup +``` + +From src/client/lib/api.ts: +```typescript +export function apiGet(url: string): Promise; +export function apiPost(url: string, body: unknown): Promise; +export function apiDelete(url: string): Promise; +``` + +From src/client/lib/iconData.tsx: +```typescript +export function LucideIcon({ name, size, className }: { name: string; size?: number; className?: string }): JSX.Element; +// Available icons: lock, link, globe, copy, check, x, alert-triangle, share-2, plus +``` + +From src/client/hooks/useSetups.ts: +```typescript +export function useUpdateSetup(setupId: number): UseMutationResult; +``` + + + + + + + Task 1: Create share hooks for React Query + src/client/hooks/useShares.ts + src/client/hooks/useSetups.ts, src/client/lib/api.ts + +Create `src/client/hooks/useShares.ts` following existing hook patterns in `useSetups.ts`: + +```typescript +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiDelete, apiGet, apiPost } from "../lib/api"; + +interface ShareLink { + id: number; + setupId: number; + token: string; + permission: string; + expiresAt: string | null; + createdAt: string; + revokedAt: string | null; +} + +export function useShareLinks(setupId: number | null) { + return useQuery({ + queryKey: ["shares", setupId], + queryFn: () => apiGet(`/api/setups/${setupId}/shares`), + enabled: !!setupId, + }); +} + +export function useCreateShareLink(setupId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { expiresInDays: number | null }) => + apiPost(`/api/setups/${setupId}/shares`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shares", setupId] }); + }, + }); +} + +export function useRevokeShareLink(setupId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (shareId: number) => + apiDelete(`/api/setups/${setupId}/shares/${shareId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shares", setupId] }); + }, + }); +} +``` + + + grep -q "useShareLinks" src/client/hooks/useShares.ts && grep -q "useCreateShareLink" src/client/hooks/useShares.ts && grep -q "useRevokeShareLink" src/client/hooks/useShares.ts && echo "PASS" || echo "FAIL" + + + - `src/client/hooks/useShares.ts` exports `useShareLinks`, `useCreateShareLink`, `useRevokeShareLink` + - Hooks follow same patterns as `useSetups.ts` (React Query, apiGet/apiPost/apiDelete) + - Query invalidation on mutations targets `["shares", setupId]` key + + Share hooks created with query and mutation patterns + + + + Task 2: Create ShareModal component and wire into setup detail page + src/client/components/ShareModal.tsx, src/client/routes/setups/$setupId.tsx + src/client/routes/setups/$setupId.tsx, src/client/components/ConfirmDialog.tsx, src/client/components/CreateThreadModal.tsx, src/client/hooks/useSetups.ts, .planning/phases/32-setup-sharing-system/32-UI-SPEC.md + +**Create `src/client/components/ShareModal.tsx`** following the 32-UI-SPEC.md contract exactly: + +Props: +```typescript +interface ShareModalProps { + isOpen: boolean; + onClose: () => void; + setupId: number; + currentVisibility: "private" | "link" | "public"; + onVisibilityChange: (visibility: "private" | "link" | "public") => void; +} +``` + +Component structure (all per 32-UI-SPEC.md): + +1. **Overlay:** `fixed inset-0 z-50 bg-black/50 flex items-center justify-center`. Click overlay to close. Listen for Escape key. + +2. **Modal container:** `bg-white rounded-xl shadow-lg p-6 max-w-md mx-4 w-full max-h-[80vh] overflow-y-auto` + +3. **Header:** "Share Setup" in `text-lg font-semibold text-gray-900`, close X button top-right. + +4. **Visibility Picker:** Three radio-style buttons in vertical stack with `gap-2`: + - Each: `flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors` + - Unselected: `border-gray-200 hover:border-gray-300` + - Selected: `border-{state-color}-200 bg-{state-color}-50` + - Private: lock icon (gray-500), "Private", "Only you can access" + - Link: link icon (blue-600), "Link sharing", "Anyone with the link" + - Public: globe icon (green-700), "Public", "Visible on your profile" + - On click: call `onVisibilityChange(newVisibility)` (immediate API call) + +5. **Deactivation warning** (show when selecting private while links exist): + - `flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg mt-2` + - alert-triangle icon in text-amber-500 + - "Switching to private will deactivate all share links. They can be reactivated by switching back." + +6. **Share Links Section** (visible when visibility is "link" or "public"): + - Divider: `border-t border-gray-100 pt-4 mt-4` + - Label: "Share Links" in `text-sm font-medium text-gray-700 mb-3` + - Create row: `flex items-center gap-2` + - Expiration select: `px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white` + - Options: "7 days", "14 days" (default selected), "30 days", "No expiration" + - Create button: `px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg` + - Text: "Create Link" + - On click: call `createShareLink.mutate({ expiresInDays })`, on success copy the generated URL to clipboard + +7. **Active Links List:** For each non-revoked share from `useShareLinks`: + - `flex items-center gap-2 p-3 bg-gray-50 rounded-lg mb-2` + - URL display: `text-sm text-gray-600 truncate flex-1` showing short URL `{origin}/s/{token.slice(0,8)}...` + - Expiration badge: `text-xs text-gray-400` — "Expires {formatted date}" or "No expiration" + - Copy button: `p-1.5 text-gray-400 hover:text-gray-600 rounded` with copy icon (16px) + - On click: copy full share URL to clipboard, swap icon to check (green-500) for 2 seconds + - Revoke button: `p-1.5 text-gray-400 hover:text-red-500 rounded` with x icon (16px) + - On click: call `revokeShareLink.mutate(shareId)` + +8. **Empty state** (no active links): "No share links yet" in `text-sm text-gray-400 text-center py-4` + +**Clipboard helper:** Use `navigator.clipboard.writeText(url)`. Construct full URL as `${window.location.origin}/s/${share.token}`. + +**Update `src/client/routes/setups/$setupId.tsx`:** + +1. Add import: `import { ShareModal } from "../../components/ShareModal";` +2. Add state: `const [shareModalOpen, setShareModalOpen] = useState(false);` +3. Replace the temporary visibility badge (from Plan 01) with the share button per UI-SPEC: + +**Desktop variant:** +```tsx + +``` + +**Mobile variant:** +```tsx + +``` + +4. Render ShareModal: +```tsx +{shareModalOpen && ( + setShareModalOpen(false)} + setupId={numericId} + currentVisibility={setup.visibility} + onVisibilityChange={(v) => updateSetup.mutate({ visibility: v })} + /> +)} +``` + +5. Only show share button when `isAuthenticated` (same guard as current toggle). + + + grep -q "ShareModal" src/client/components/ShareModal.tsx && grep -q "ShareModal" src/client/routes/setups/\$setupId.tsx && bun run lint && echo "PASS" || echo "FAIL" + + + - `src/client/components/ShareModal.tsx` renders: visibility picker with 3 options, create link form, active links list + - Visibility picker options use correct icons: lock (private), link (link), globe (public) + - Visibility picker colors match UI-SPEC: gray-500/gray-50, blue-600/blue-50, green-700/green-50 + - Create link form has expiration dropdown with options: 7 days, 14 days, 30 days, No expiration + - Copy button copies `${origin}/s/${token}` to clipboard and shows check icon for 2s + - Revoke button calls delete mutation + - Deactivation warning shows when selecting private with active links + - `src/client/routes/setups/$setupId.tsx` renders share button with visibility-state icon/color + - Share button opens ShareModal on click + - `bun run lint` passes + + Share modal fully functional with visibility management, link creation, copy, and revoke + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client->clipboard | Share URL written to system clipboard | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-32-08 | Information Disclosure | clipboard copy | accept | Share URLs are intentionally shareable — copying to clipboard is the feature's purpose | + + + +1. `bun run lint` passes +2. Share button visible on setup detail page with correct icon/color per visibility state +3. Modal opens, visibility picker works, create link generates copyable URL +4. Revoking a link removes it from the list +5. Switching to private shows warning and deactivates links + + + +- Share modal is the single UI for managing visibility and share links (per D-13) +- Share icon button replaces old globe toggle (per D-14) +- Modal contains visibility picker, create link, and active links list (per D-15) +- Works on both desktop and mobile (per D-16) + + + +After completion, create `.planning/phases/32-setup-sharing-system/32-03-SUMMARY.md` + diff --git a/.planning/phases/32-setup-sharing-system/32-04-PLAN.md b/.planning/phases/32-setup-sharing-system/32-04-PLAN.md new file mode 100644 index 0000000..b6612a7 --- /dev/null +++ b/.planning/phases/32-setup-sharing-system/32-04-PLAN.md @@ -0,0 +1,231 @@ +--- +phase: 32-setup-sharing-system +plan: 04 +type: execute +wave: 3 +depends_on: [01, 02] +files_modified: + - src/client/routes/setups/$setupId.tsx + - src/client/hooks/useSetups.ts +autonomous: true +requirements: + - TBD + +must_haves: + truths: + - "Anonymous user visiting /setups/:id?share=token sees the shared setup with items" + - "Shared setup viewer shows a 'Shared setup' banner at the top" + - "Invalid or expired share tokens show an error message" + - "Short URL /s/:token redirects to /setups/:id?share=token" + - "Shared viewer is read-only — no edit buttons, no share button, no delete button" + artifacts: + - path: "src/client/routes/setups/$setupId.tsx" + provides: "Enhanced setup detail page with share token detection and shared view mode" + - path: "src/client/hooks/useSetups.ts" + provides: "useSharedSetup hook for fetching shared setup data" + exports: ["useSharedSetup"] + key_links: + - from: "src/client/routes/setups/$setupId.tsx" + to: "src/client/hooks/useSetups.ts" + via: "useSharedSetup hook for share token access" + pattern: "useSharedSetup" + - from: "src/client/routes/setups/$setupId.tsx" + to: "/api/shared/:token" + via: "API fetch for shared setup data" + pattern: "api/shared" +--- + + +Add shared setup viewer functionality to the existing setup detail page — detect share token in URL, fetch via shared endpoint, and display read-only view with shared banner. + +Purpose: This completes the user-facing share flow (D-06, D-17). When someone receives a share link, they can view the setup without authentication. + +Output: Updated setup detail page with share token detection and shared viewing mode. + + + +@$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-UI-SPEC.md +@.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md +@.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md + + + + +Shared access API endpoint (from Plan 02): +``` +GET /api/shared/:token → Setup object with items array (same format as public view) +Returns 404 for invalid/expired/revoked tokens +``` + +Short URL redirect (from Plan 02): +``` +GET /s/:token → 302 redirect to /setups/:setupId?share=:token +``` + +From src/client/routes/setups/$setupId.tsx (current structure): +```typescript +// Three-way data source: private (auth), public (no auth), shared (token) +const { data: auth } = useAuth(); +const isAuthenticated = !!auth?.user; +const privateSetup = useSetup(isAuthenticated ? numericId : null); +const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null); +``` + +From @tanstack/react-router: +```typescript +// URL search params access +const search = Route.useSearch(); // needs searchSchema defined on route +``` + + + + + + + Task 1: Add useSharedSetup hook and share token detection to setup detail page + src/client/hooks/useSetups.ts, src/client/routes/setups/$setupId.tsx + src/client/hooks/useSetups.ts, src/client/routes/setups/$setupId.tsx, src/client/lib/api.ts + +**Add `useSharedSetup` hook to `src/client/hooks/useSetups.ts`:** + +```typescript +export function useSharedSetup(token: string | null) { + return useQuery({ + queryKey: ["shared-setup", token], + queryFn: () => apiGet(`/api/shared/${token}`), + enabled: !!token, + retry: false, // Don't retry on 404 + }); +} +``` + +Use the same `SetupWithItems` type used by `useSetup` and `usePublicSetup`. + +**Update `src/client/routes/setups/$setupId.tsx`:** + +1. Add search params validation to the route definition to capture the `share` query param: +```typescript +import { z } from "zod"; + +export const Route = createFileRoute("/setups/$setupId")({ + component: SetupDetailPage, + validateSearch: z.object({ + share: z.string().optional(), + }), +}); +``` + +2. In `SetupDetailPage`, detect the share token: +```typescript +const { share: shareToken } = Route.useSearch(); +``` + +3. Update the three-way data source logic: +```typescript +const { data: auth } = useAuth(); +const isAuthenticated = !!auth?.user; + +// Priority: share token > authenticated owner > public viewer +const sharedSetup = useSharedSetup(shareToken ?? null); +const privateSetup = useSetup(!shareToken && isAuthenticated ? numericId : null); +const publicSetup = usePublicSetup(!shareToken && !isAuthenticated ? numericId : null); + +const isSharedView = !!shareToken; +const { data: setup, isLoading, isError } = isSharedView + ? sharedSetup + : isAuthenticated + ? privateSetup + : publicSetup; +``` + +4. Add shared banner (per 32-UI-SPEC.md) — render above the header bar when `isSharedView`: +```tsx +{isSharedView && setup && ( +
+ + Shared setup +
+)} +``` + +5. Add error state for invalid/expired share tokens: +```tsx +{isSharedView && isError && ( +
+ +

Link not available

+

This share link has expired or is no longer valid.

+
+)} +``` + +6. Hide owner-only controls when in shared view — conditionally hide these elements when `isSharedView` is true: + - Add Items button (both desktop and mobile variants) + - Share button (both desktop and mobile variants) + - Delete Setup button (both desktop and mobile variants) + - Classification dropdowns on items + - Remove item buttons + + Wrap each with: `{!isSharedView && isAuthenticated && ( ... )}` + +7. The shared view shows the same read-only content as the public view: item list grouped by category, weight summary card, setup name header. +
+ + grep -q "useSharedSetup" src/client/hooks/useSetups.ts && grep -q "shareToken\|share:" src/client/routes/setups/\$setupId.tsx && grep -q "Shared setup" src/client/routes/setups/\$setupId.tsx && bun run lint && echo "PASS" || echo "FAIL" + + + - `src/client/hooks/useSetups.ts` exports `useSharedSetup(token)` that fetches `/api/shared/:token` + - `src/client/routes/setups/$setupId.tsx` validates `share` search param via Zod + - When `?share=token` is present, setup data is fetched via shared endpoint (not owner or public) + - Shared banner (`Shared setup` with link icon in blue-50) appears at top of page when share token present + - Invalid/expired token shows error state with "Link not available" message + - Owner-only controls (add items, share, delete, classification, remove item) are hidden in shared view + - `bun run lint` passes + + Shared setup viewer with token detection, shared banner, error handling, and read-only mode +
+ +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| URL search params | Share token from URL — untrusted user input | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-32-09 | Spoofing | share token in URL | mitigate | Token validated server-side by /api/shared/:token — client only passes through, no client-side authorization decisions | +| T-32-10 | Information Disclosure | shared view content | accept | Shared setup data is intentionally visible to anyone with the token — this is the feature | + + + +1. `bun run lint` passes +2. Visit `/setups/1?share=valid-token` — shows setup with shared banner, no edit controls +3. Visit `/setups/1?share=invalid-token` — shows error state +4. Visit `/s/valid-token` — redirects to `/setups/:id?share=token`, displays shared view +5. Owner visiting their own setup normally (no share param) — sees all controls as before + + + +- Share links use `/s/{token}` short URL AND `/setups/:id?share={token}` (per D-06) +- Shared setup viewer works for anonymous users (per D-17) +- No owner-only actions visible in shared view +- No changes to discovery feed or profile page (per D-18) + + + +After completion, create `.planning/phases/32-setup-sharing-system/32-04-SUMMARY.md` +