# 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
