# Phase 32: Setup Sharing System - Research **Researched:** 2026-04-13 **Status:** Complete ## Executive Summary This phase adds a three-tier visibility model (private/link/public) to setups and introduces share links with secret tokens. The implementation is a straightforward schema migration + CRUD addition on a well-established Hono + Drizzle + React stack. No external libraries or unfamiliar integrations are needed. ## Key Technical Findings ### 1. Schema Migration Strategy **Current state:** `setups` table has `isPublic: boolean("is_public").notNull().default(false)` (schema.ts line 124). **Migration approach:** Add `visibility: text("visibility").notNull().default("private")` column, migrate data (`isPublic=true` -> `visibility='public'`), then drop `isPublic`. Drizzle on PostgreSQL requires a custom SQL migration for data migration since `bun run db:generate` only generates DDL. **New `shares` table:** ```sql CREATE TABLE shares ( id SERIAL PRIMARY KEY, setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, permission TEXT NOT NULL DEFAULT 'read', expires_at TIMESTAMP, user_id INTEGER REFERENCES users(id), created_at TIMESTAMP NOT NULL DEFAULT NOW(), revoked_at TIMESTAMP ); CREATE INDEX idx_shares_token ON shares(token); CREATE INDEX idx_shares_setup_id ON shares(setup_id); ``` **Token generation:** Use `randomBytes(16).toString("base64url")` from `node:crypto` (22 chars, URL-safe, 128 bits of entropy). This matches the pattern already used in `auth.service.ts` and `oauth.service.ts`. ### 2. Access Control for Share Links **Current auth pattern:** `src/server/index.ts` has middleware that protects POST/PUT/DELETE on `/api/*`. GET endpoints are public. The `/:id/public` route in `setups.ts` already bypasses auth for public viewing. **Share link access:** A new route `GET /api/setups/:id/shared?token=xxx` (or a short-URL route `GET /s/:token`) needs to: 1. Look up the share record by token 2. Verify: not revoked (`revokedAt IS NULL`), not expired (`expiresAt IS NULL OR expiresAt > NOW()`) 3. Resolve the setupId and return the setup with items (same data as public view) **Short URL `/s/:token`:** This is a server-side redirect route (not an API route). It should look up the token, resolve the setupId, and redirect to the client-side viewer page: `/setups/{setupId}?share={token}`. Register as `app.get("/s/:token", ...)` in `src/server/index.ts` before the SPA catch-all. ### 3. Service Layer Changes **Existing services to modify:** - `setup.service.ts`: Replace all `isPublic` references with `visibility`. Update `createSetup`, `updateSetup`, `getAllSetups` to use visibility. Add `updateVisibility(db, userId, setupId, visibility)` function. - `discovery.service.ts`: Change `.where(eq(setups.isPublic, true))` to `.where(eq(setups.visibility, "public"))` in `getPopularSetups`. - `profile.service.ts`: Change `eq(setups.isPublic, true)` to `eq(setups.visibility, "public")` in both `getPublicProfile` and `getPublicSetupWithItems`. Add `getSharedSetupWithItems(db, setupId, token)` that validates the share token and returns setup data. **New service: `share.service.ts`:** - `createShareLink(db, userId, setupId, { expiresInDays })` — generates token, inserts share record - `revokeShareLink(db, userId, shareId)` — sets `revokedAt` - `getShareLinks(db, userId, setupId)` — returns all shares for a setup - `validateShareToken(db, token)` — checks token validity, returns setup data if valid - `deactivateShareLinks(db, setupId)` — bulk set revokedAt when visibility goes to private - `reactivateShareLinks(db, setupId)` — bulk clear revokedAt when visibility goes back to link ### 4. API Routes **Modified routes in `setups.ts`:** - `PUT /api/setups/:id` — update to handle `visibility` instead of `isPublic` - `GET /api/setups/:id/public` — update to check `visibility = 'public'` **New routes (new file `shares.ts` or added to `setups.ts`):** - `POST /api/setups/:id/shares` — create share link (auth required) - `GET /api/setups/:id/shares` — list share links (auth required) - `DELETE /api/setups/:id/shares/:shareId` — revoke share link (auth required) - `GET /api/setups/shared/:token` — access setup via share token (no auth) **Short URL route:** `GET /s/:token` in `src/server/index.ts` — redirects to client page. ### 5. Client-Side Changes **Schema/type changes:** - `schemas.ts`: Replace `isPublic: z.boolean()` with `visibility: z.enum(["private", "link", "public"])` in setup schemas. Add share link schemas. - `types.ts`: Types auto-inferred from Drizzle + Zod — will update automatically. **Setup detail page (`setups/$setupId.tsx`):** - Replace globe toggle button (lines 177-203) with share icon button - Share button opens a modal dialog - Share modal contains: visibility picker, create link form, active links list **New component: `ShareModal.tsx`:** - Visibility radio group (private/link/public) with icons (lock/link/globe) - "Create Link" button with expiration dropdown (7d/14d/30d/infinite) - List of active share links with copy-to-clipboard and revoke buttons - When visibility changes to `private`, show confirmation that links will be deactivated **Shared setup viewer:** - Route: `/setups/:setupId` with `?share=token` query param - When share token is present, fetch via shared endpoint instead of owner endpoint - Display a subtle "Shared with you" banner or badge - Same layout as public view (read-only item list with totals) **Hooks (`useSetups.ts`):** - Add `useShareLinks(setupId)` — React Query for listing shares - Add `useCreateShareLink()`, `useRevokeShareLink()` mutations - Add `useSharedSetup(setupId, token)` — fetch shared setup data - Update `useUpdateSetup` to handle visibility instead of isPublic ### 6. Visibility State Transitions ``` private ──→ link : No side effects (links remain inactive until created) private ──→ public : No side effects link ──→ private: Deactivate all share links (set revokedAt) link ──→ public : No side effects (links still work) public ──→ private: Deactivate all share links public ──→ link : No side effects * ──→ link : Reactivate previously-deactivated links (clear revokedAt where revokedAt was set by visibility change, not manual revoke) ``` **Implementation note:** To distinguish manual revokes from visibility-deactivated links, add a `deactivatedByVisibility: boolean` column or use a sentinel value in `revokedAt`. Simpler approach: track `deactivationReason: text` ("manual" | "visibility"). This keeps reactivation clean — only reactivate where `deactivationReason = 'visibility'`. Actually, the simplest approach per D-03: just set revokedAt on all links when going private, and clear revokedAt on all links when going to link/public. Manual revokes are also cleared — acceptable since the user explicitly chose to reactivate. If this is undesirable, a `manuallyRevoked: boolean` column solves it cleanly. ### 7. Testing Strategy **Service tests (extend `tests/services/setup.service.test.ts`):** - Test visibility column CRUD (create with visibility, update visibility) - Test share link creation with token generation - Test share token validation (valid, expired, revoked) - Test visibility transition side effects (deactivate/reactivate links) **New test file: `tests/services/share.service.test.ts`:** - Full CRUD for share links - Token validation with edge cases (expired, revoked, wrong setup) - Multiple links per setup **Route tests (extend `tests/routes/setups.test.ts` or `tests/routes/` new file):** - Share link API endpoints - Short URL redirect - Access control (can't create shares for other users' setups) **E2E tests:** - Share modal interaction (open, change visibility, create link, copy, revoke) - Visit shared link as anonymous user ### 8. Migration Safety The `isPublic` -> `visibility` migration is a breaking change for existing API consumers. Migration steps: 1. Add `visibility` column with default `'private'` 2. Migrate data: `UPDATE setups SET visibility = 'public' WHERE is_public = true` 3. Drop `isPublic` column 4. Update all service/route/schema code Since GearBox is a single-user app with controlled deployments, the migration can be done in a single deploy without backward compatibility concerns. The Drizzle migration file handles steps 1-3 atomically. ### 9. MCP Server Updates The MCP tools `create_setup`, `update_setup`, `get_setup`, `list_setups` need updates: - Replace `isPublic` parameter with `visibility` in tool schemas - Add share link tools if desired (optional for this phase) ## Validation Architecture ### Critical Paths 1. Share token generation and validation (security-critical) 2. Visibility state transitions with link deactivation/reactivation 3. Migration from `isPublic` to `visibility` without data loss 4. Short URL redirect resolution ### Verification Points - Token uniqueness enforced by database unique constraint - Expired/revoked tokens return 404 (not 403, to avoid token enumeration) - Visibility changes correctly cascade to share link states - Discovery feed query produces identical results before/after migration - Public setup view works identically before/after migration ## Dependencies - **Phase 28 (profiles):** Required — profiles must be working for public setup attribution - **No external dependencies:** All functionality implemented with existing stack (Drizzle, Hono, React Query, Tailwind) ## Risks and Mitigations | Risk | Likelihood | Impact | Mitigation | |------|-----------|--------|------------| | Token enumeration on share endpoints | Low | Medium | Return 404 for invalid/expired/revoked tokens (no distinction) | | Migration breaks existing public setups | Low | High | Test migration on dev DB first, verify discovery feed still works | | Share modal complexity on mobile | Medium | Low | Reuse existing modal patterns, test responsive behavior | ## RESEARCH COMPLETE