Files
2026-04-13 16:59:54 +02:00

9.8 KiB

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:

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.

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