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:
190
.planning/phases/32-setup-sharing-system/32-RESEARCH.md
Normal file
190
.planning/phases/32-setup-sharing-system/32-RESEARCH.md
Normal 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
|
||||
81
.planning/phases/32-setup-sharing-system/32-VALIDATION.md
Normal file
81
.planning/phases/32-setup-sharing-system/32-VALIDATION.md
Normal 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
|
||||
Reference in New Issue
Block a user