Files
GearBox/.planning/phases/32-setup-sharing-system/32-02-PLAN.md
Jean-Luc Makiola 81a654085d 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>
2026-04-13 17:05:36 +02:00

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
01
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
true
TBD
truths artifacts key_links
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
path provides exports
src/server/services/share.service.ts Share link CRUD, token validation, visibility transition side effects
createShareLink
getShareLinks
revokeShareLink
validateShareToken
deactivateShareLinks
reactivateShareLinks
path provides
src/server/routes/shares.ts Share link API endpoints nested under /api/setups/:id/shares
path provides
tests/services/share.service.test.ts Full service test coverage for share link operations
from to via pattern
src/server/services/share.service.ts src/db/schema.ts shares table CRUD operations shares.*insert|shares.*select|shares.*update
from to via pattern
src/server/routes/shares.ts src/server/services/share.service.ts service function calls createShareLink|getShareLinks|revokeShareLink
from to via pattern
src/server/services/setup.service.ts src/server/services/share.service.ts visibility change triggers link deactivation/reactivation deactivateShareLinks|reactivateShareLinks
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.

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

From 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");
Task 1: Create share service with token generation, CRUD, and visibility transitions src/server/services/share.service.ts, src/server/services/setup.service.ts, src/shared/schemas.ts, tests/services/share.service.test.ts 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 - 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 **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):

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 bun test tests/services/share.service.test.ts <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> Share service with full CRUD, token validation, visibility transitions, and tests
Task 2: Create share link API routes and register in server src/server/routes/shares.ts, src/server/index.ts src/server/routes/setups.ts, src/server/index.ts **Create `src/server/routes/shares.ts`** following existing route patterns:
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:

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

<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>
After completion, create `.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md`