191 lines
9.8 KiB
Markdown
191 lines
9.8 KiB
Markdown
# 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
|