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:
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>
|
||||
Reference in New Issue
Block a user