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