diff --git a/.planning/phases/32-setup-sharing-system/32-RESEARCH.md b/.planning/phases/32-setup-sharing-system/32-RESEARCH.md new file mode 100644 index 0000000..6ca93b8 --- /dev/null +++ b/.planning/phases/32-setup-sharing-system/32-RESEARCH.md @@ -0,0 +1,190 @@ +# 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 diff --git a/.planning/phases/32-setup-sharing-system/32-VALIDATION.md b/.planning/phases/32-setup-sharing-system/32-VALIDATION.md new file mode 100644 index 0000000..acab699 --- /dev/null +++ b/.planning/phases/32-setup-sharing-system/32-VALIDATION.md @@ -0,0 +1,81 @@ +--- +phase: 32 +slug: setup-sharing-system +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-04-13 +--- + +# Phase 32 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Bun test runner | +| **Config file** | bunfig.toml | +| **Quick run command** | `bun test tests/services/share.service.test.ts tests/services/setup.service.test.ts` | +| **Full suite command** | `bun test` | +| **Estimated runtime** | ~8 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `bun test tests/services/share.service.test.ts tests/services/setup.service.test.ts` +- **After every plan wave:** Run `bun test` +- **Before `/gsd-verify-work`:** Full suite must be green +- **Max feedback latency:** 10 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 32-01-01 | 01 | 1 | D-02 | — | N/A | migration | `bun run db:generate` | ✅ | ⬜ pending | +| 32-01-02 | 01 | 1 | D-10 | — | N/A | migration | `bun run db:generate` | ✅ | ⬜ pending | +| 32-02-01 | 02 | 1 | D-05 | T-32-01 | Token is 128-bit random, URL-safe | unit | `bun test tests/services/share.service.test.ts` | ❌ W0 | ⬜ pending | +| 32-02-02 | 02 | 1 | D-06,D-07,D-08 | — | N/A | unit | `bun test tests/services/share.service.test.ts` | ❌ W0 | ⬜ pending | +| 32-03-01 | 03 | 2 | D-01,D-03 | — | N/A | unit | `bun test tests/services/setup.service.test.ts` | ✅ | ⬜ pending | +| 32-03-02 | 03 | 2 | D-19 | — | N/A | unit | `bun test tests/services/discovery.service.test.ts` | ✅ | ⬜ pending | +| 32-04-01 | 04 | 2 | D-13,D-14,D-15 | — | N/A | e2e | `bun run test:e2e` | ❌ W0 | ⬜ pending | +| 32-05-01 | 05 | 3 | D-06,D-17 | T-32-02 | Invalid/expired tokens return 404 | route | `bun test tests/routes/setups.test.ts` | ✅ | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/services/share.service.test.ts` — stubs for share link CRUD and token validation +- [ ] Existing `tests/services/setup.service.test.ts` — extend with visibility tests + +*Existing infrastructure covers framework and fixture needs.* + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Share modal responsive layout | D-16 | Visual layout verification | Open share modal on mobile viewport, verify all controls accessible | +| Copy-to-clipboard works | D-15 | Browser clipboard API | Click copy button on share link, paste in new tab | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 10s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending