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:
298
.planning/phases/32-setup-sharing-system/32-01-PLAN.md
Normal file
298
.planning/phases/32-setup-sharing-system/32-01-PLAN.md
Normal 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>
|
||||
337
.planning/phases/32-setup-sharing-system/32-02-PLAN.md
Normal file
337
.planning/phases/32-setup-sharing-system/32-02-PLAN.md
Normal 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>
|
||||
338
.planning/phases/32-setup-sharing-system/32-03-PLAN.md
Normal file
338
.planning/phases/32-setup-sharing-system/32-03-PLAN.md
Normal 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>
|
||||
231
.planning/phases/32-setup-sharing-system/32-04-PLAN.md
Normal file
231
.planning/phases/32-setup-sharing-system/32-04-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user