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>
This commit is contained in:
2026-04-13 17:05:36 +02:00
parent 9965e356de
commit 81a654085d
5 changed files with 1212 additions and 2 deletions

View File

@@ -173,7 +173,13 @@ Plans:
**Requirements**: TBD (discuss phase) **Requirements**: TBD (discuss phase)
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
TBD (discuss phase) 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 **UI hint**: yes
### Phase 33: Currency System ### Phase 33: Currency System
@@ -227,7 +233,7 @@ Plans:
| 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 | | 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 |
| 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 | | 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 |
| 31. Mobile Polish | v2.2 | 2/2 | 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 | — | | 33. Currency System | v2.3 | TBD | Pending | — |
| 34. i18n Foundation | v2.3 | TBD | Pending | — | | 34. i18n Foundation | v2.3 | TBD | Pending | — |

View File

@@ -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"
---
<objective>
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.
</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
<interfaces>
<!-- Key types and contracts the executor needs. -->
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)));
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Update schema — add visibility column, shares table, generate migration</name>
<files>src/db/schema.ts</files>
<read_first>src/db/schema.ts</read_first>
<action>
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.
</action>
<verify>
<automated>grep -q "visibility" src/db/schema.ts && grep -q "shares" src/db/schema.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Schema updated, migration generated and applied, isPublic replaced with visibility, shares table created</done>
</task>
<task type="auto">
<name>Task 2: Update all services, routes, schemas, and client code from isPublic to visibility</name>
<files>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</files>
<read_first>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</read_first>
<action>
**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
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>All isPublic references replaced with visibility across the full stack</done>
</task>
<task type="auto">
<name>Task 3: Update existing tests for visibility column</name>
<files>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</files>
<read_first>tests/services/setup.service.test.ts, tests/services/discovery.service.test.ts, tests/services/profile.service.test.ts</read_first>
<action>
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.
</action>
<verify>
<automated>bun test</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>All existing tests pass with the visibility column changes</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md`
</output>

View File

@@ -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"
---
<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>

View File

@@ -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"
---
<objective>
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.
</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-UI-SPEC.md
@.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md
@.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md
<interfaces>
<!-- Key types and contracts from Plans 01 and 02. -->
Share API endpoints (from Plan 02):
```
POST /api/setups/:id/shares → { id, setupId, token, permission, expiresAt, createdAt, revokedAt }
GET /api/setups/:id/shares → Array<Share>
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<T>(url: string): Promise<T>;
export function apiPost<T>(url: string, body: unknown): Promise<T>;
export function apiDelete<T>(url: string): Promise<T>;
```
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;
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create share hooks for React Query</name>
<files>src/client/hooks/useShares.ts</files>
<read_first>src/client/hooks/useSetups.ts, src/client/lib/api.ts</read_first>
<action>
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<ShareLink[]>(`/api/setups/${setupId}/shares`),
enabled: !!setupId,
});
}
export function useCreateShareLink(setupId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { expiresInDays: number | null }) =>
apiPost<ShareLink>(`/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<ShareLink>(`/api/setups/${setupId}/shares/${shareId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shares", setupId] });
},
});
}
```
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Share hooks created with query and mutation patterns</done>
</task>
<task type="auto">
<name>Task 2: Create ShareModal component and wire into setup detail page</name>
<files>src/client/components/ShareModal.tsx, src/client/routes/setups/$setupId.tsx</files>
<read_first>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</read_first>
<action>
**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
<button
type="button"
onClick={() => setShareModalOpen(true)}
className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
setup.visibility === "public"
? "text-green-700 bg-green-50 hover:bg-green-100"
: setup.visibility === "link"
? "text-blue-600 bg-blue-50 hover:bg-blue-100"
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
}`}
>
<LucideIcon name={setup.visibility === "public" ? "globe" : setup.visibility === "link" ? "link" : "lock"} size={16} />
Share
</button>
```
**Mobile variant:**
```tsx
<button
type="button"
onClick={() => setShareModalOpen(true)}
className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg transition-colors ${
setup.visibility === "public"
? "text-green-700 bg-green-50 hover:bg-green-100"
: setup.visibility === "link"
? "text-blue-600 bg-blue-50 hover:bg-blue-100"
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
}`}
aria-label="Share settings"
title="Share settings"
>
<LucideIcon name={setup.visibility === "public" ? "globe" : setup.visibility === "link" ? "link" : "lock"} size={16} />
</button>
```
4. Render ShareModal:
```tsx
{shareModalOpen && (
<ShareModal
isOpen={shareModalOpen}
onClose={() => 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).
</action>
<verify>
<automated>grep -q "ShareModal" src/client/components/ShareModal.tsx && grep -q "ShareModal" src/client/routes/setups/\$setupId.tsx && bun run lint && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Share modal fully functional with visibility management, link creation, copy, and revoke</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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)
</success_criteria>
<output>
After completion, create `.planning/phases/32-setup-sharing-system/32-03-SUMMARY.md`
</output>

View File

@@ -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"
---
<objective>
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.
</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-UI-SPEC.md
@.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md
@.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md
<interfaces>
<!-- Key types and contracts from Plans 01 and 02. -->
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
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add useSharedSetup hook and share token detection to setup detail page</name>
<files>src/client/hooks/useSetups.ts, src/client/routes/setups/$setupId.tsx</files>
<read_first>src/client/hooks/useSetups.ts, src/client/routes/setups/$setupId.tsx, src/client/lib/api.ts</read_first>
<action>
**Add `useSharedSetup` hook to `src/client/hooks/useSetups.ts`:**
```typescript
export function useSharedSetup(token: string | null) {
return useQuery({
queryKey: ["shared-setup", token],
queryFn: () => apiGet<SetupWithItems>(`/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 && (
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-100">
<LucideIcon name="link" size={16} className="text-blue-500" />
<span className="text-sm text-blue-700">Shared setup</span>
</div>
)}
```
5. Add error state for invalid/expired share tokens:
```tsx
{isSharedView && isError && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
<LucideIcon name="link" size={48} className="text-gray-300 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Link not available</h2>
<p className="text-sm text-gray-500">This share link has expired or is no longer valid.</p>
</div>
)}
```
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.
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Shared setup viewer with token detection, shared banner, error handling, and read-only mode</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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)
</success_criteria>
<output>
After completion, create `.planning/phases/32-setup-sharing-system/32-04-SUMMARY.md`
</output>