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.
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:
- Look up the share record by token
- Verify: not revoked (
revokedAt IS NULL), not expired (expiresAt IS NULL OR expiresAt > NOW()) - 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 allisPublicreferences withvisibility. UpdatecreateSetup,updateSetup,getAllSetupsto use visibility. AddupdateVisibility(db, userId, setupId, visibility)function. -
discovery.service.ts: Change.where(eq(setups.isPublic, true))to.where(eq(setups.visibility, "public"))ingetPopularSetups. -
profile.service.ts: Changeeq(setups.isPublic, true)toeq(setups.visibility, "public")in bothgetPublicProfileandgetPublicSetupWithItems. AddgetSharedSetupWithItems(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 recordrevokeShareLink(db, userId, shareId)— setsrevokedAtgetShareLinks(db, userId, setupId)— returns all shares for a setupvalidateShareToken(db, token)— checks token validity, returns setup data if validdeactivateShareLinks(db, setupId)— bulk set revokedAt when visibility goes to privatereactivateShareLinks(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 handlevisibilityinstead ofisPublicGET /api/setups/:id/public— update to checkvisibility = '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: ReplaceisPublic: z.boolean()withvisibility: 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/:setupIdwith?share=tokenquery 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
useUpdateSetupto 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:
- Add
visibilitycolumn with default'private' - Migrate data:
UPDATE setups SET visibility = 'public' WHERE is_public = true - Drop
isPubliccolumn - 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
isPublicparameter withvisibilityin tool schemas - Add share link tools if desired (optional for this phase)
Validation Architecture
Critical Paths
- Share token generation and validation (security-critical)
- Visibility state transitions with link deactivation/reactivation
- Migration from
isPublictovisibilitywithout data loss - 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 |