docs(32): add research and validation strategy for setup sharing system

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 16:59:54 +02:00
parent cb0c1e8c9a
commit 9965e356de
2 changed files with 271 additions and 0 deletions

View File

@@ -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

View File

@@ -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 `<automated>` 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