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>
15 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 32-setup-sharing-system | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom src/db/schema.ts (after Plan 01):
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):
type Db = typeof prodDb;
export async function createSetup(db: Db, userId: number, data: CreateSetup) { ... }
Existing route pattern (from src/server/routes/setups.ts):
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):
import { randomBytes } from "node:crypto";
const rawKey = randomBytes(32).toString("hex");
Create src/server/services/share.service.ts following existing service patterns (db as first param, no HTTP awareness):
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 }):- Verify setup belongs to userId
- Generate token:
randomBytes(16).toString("base64url")(22 chars, URL-safe, 128 bits — per D-04) - Calculate expiresAt:
new Date(Date.now() + days * 86400000)or null (per D-07) - Insert into shares table with permission='read' (per D-09)
- Return the created share row
-
getShareLinks(db, userId, setupId):- Verify setup belongs to userId
- Return all shares for setupId ordered by createdAt desc (per D-05, D-08)
-
revokeShareLink(db, userId, shareId):- Join shares with setups to verify ownership
- Set revokedAt = new Date() (per D-08)
- Return updated share
-
validateShareToken(db, token):- Find share by token where revokedAt IS NULL
- Check expiresAt IS NULL OR expiresAt > NOW()
- Return { setupId, permission } or null
-
deactivateShareLinks(db, setupId):- Set revokedAt on all shares where revokedAt IS NULL (per D-03)
- Mark these with a sentinel: use current timestamp (distinguishable from manual revokes by exact timestamp match)
-
reactivateShareLinks(db, setupId):- Clear revokedAt on all shares that were deactivated (where revokedAt IS NOT NULL and the share was not manually revoked before deactivation)
- 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)
- If new visibility is "private" and old was not: call
- To detect the transition, read the current setup before update.
Create tests/services/share.service.test.ts:
- Use
createTestDb()from tests/helpers/db.ts - Seed a user, category, and setup
- Test all behaviors listed above
bun test tests/services/share.service.test.ts
<acceptance_criteria>
src/server/services/share.service.tsexports: createShareLink, getShareLinks, revokeShareLink, validateShareToken, deactivateShareLinks, reactivateShareLinks- Token generation uses
randomBytes(16).toString("base64url")(128-bit entropy) tests/services/share.service.test.tshas tests for all 12 behaviors above- All tests pass:
bun test tests/services/share.service.test.tsexits 0 - updateSetup in setup.service.ts calls deactivateShareLinks when visibility transitions to private </acceptance_criteria> Share service with full CRUD, token validation, visibility transitions, and tests
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:
-
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
- Validate body with
-
GET /api/setups/:id/shares— List share links (auth required)- Call
getShareLinks(db, userId, setupId) - Return array of shares
- Call
-
DELETE /api/setups/:id/shares/:shareId— Revoke share link (auth required)- Call
revokeShareLink(db, userId, shareId) - Return 200 with updated share or 404
- Call
-
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)
- Call
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 tosrc/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:
// Short share URL redirect — before SPA catch-all
app.get("/s/:token", async (c) => {
const db = c.get("db");
const token = c.req.param("token");
const result = await validateShareToken(db, token);
if (!result) return c.redirect("/", 302);
return c.redirect(`/setups/${result.setupId}?share=${token}`, 302);
});
Register this BEFORE the SPA catch-all route (per D-06).
bun run lint && bun test
<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>
Share link API routes registered and functional, short URL redirect works
<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> |
<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>