Compare commits

...

12 Commits

Author SHA1 Message Date
Jeff Emmett fab58aead0 fix: correct networking imports and API response format
- Fix useSession → useAuth import (matches actual export)
- Fix GraphEdge properties: source/target instead of fromUserId/toUserId
- Add missing trustLevel, effectiveTrustLevel to edge response
- Add myConnections to NetworkGraph type
- Prefix unused myConnections param with underscore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 22:51:58 -08:00
Jeff Emmett b95eb6dc01 Update task task-041 2025-12-05 22:46:57 -08:00
Jeff Emmett eb7498157f Create task task-042 2025-12-05 22:46:50 -08:00
Jeff Emmett 30608dfdc8 feat: integrate read-only mode for board permissions
- Add permission fetching and state management in Board.tsx
- Fetch user's permission level when board loads
- Set tldraw to read-only mode when user has 'view' permission
- Show AnonymousViewerBanner for unauthenticated users
- Banner prompts CryptID sign-up with your specified messaging
- Update permission state when user authenticates
- Wire up permission API routes in worker/worker.ts
  - GET /boards/:boardId/permission
  - GET /boards/:boardId/permissions (admin)
  - POST /boards/:boardId/permissions (admin)
  - DELETE /boards/:boardId/permissions/:userId (admin)
  - PATCH /boards/:boardId (admin)
- Add X-CryptID-PublicKey to CORS allowed headers
- Add PUT, PATCH, DELETE to CORS allowed methods

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 22:45:31 -08:00
Jeff Emmett 9f5befc729 Update task task-024 2025-12-05 22:40:20 -08:00
Jeff Emmett fe0a96ddad Update task task-018 2025-12-05 22:39:25 -08:00
Jeff Emmett e11eccd34c Update task task-041 2025-12-05 22:38:33 -08:00
Jeff Emmett c00106e2b7 feat: implement user permissions system (view/edit/admin)
Phase 1 of user permissions feature:
- Add board permissions schema to D1 database
  - boards table with owner, default_permission, is_public
  - board_permissions table for per-user permissions
- Add permission types (PermissionLevel) to worker and client
- Implement permission API handlers in worker/boardPermissions.ts
  - GET /boards/:boardId/permission - check user's permission
  - GET /boards/:boardId/permissions - list all (admin only)
  - POST /boards/:boardId/permissions - grant permission (admin)
  - DELETE /boards/:boardId/permissions/:userId - revoke (admin)
  - PATCH /boards/:boardId - update board settings (admin)
- Update AuthContext with permission fetching and caching
  - fetchBoardPermission() - fetch and cache permission for a board
  - canEdit() - check if user can edit current board
  - isAdmin() - check if user is admin for current board
- Create AnonymousViewerBanner component with CryptID signup prompt
- Add CSS styles for anonymous viewer banner
- Fix automerge sync manager to flush saves on peer disconnect

Permission levels:
- view: Read-only, cannot create/edit/delete shapes
- edit: Can modify board contents
- admin: Full access + permission management

Next steps:
- Integrate with Board component for read-only mode
- Wire up permission checking in Automerge sync
- Add permission management UI for admins

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 22:27:12 -08:00
Jeff Emmett 037e232b85 Update task task-041 2025-12-05 22:24:37 -08:00
Jeff Emmett 14bf688b60 Create task task-041 2025-12-05 22:17:54 -08:00
Jeff Emmett af6666bf72 Update task task-027 2025-12-05 14:05:24 -08:00
Jeff Emmett d4df704c86 Create task task-040 2025-12-05 13:58:56 -08:00
31 changed files with 4941 additions and 62 deletions

View File

@ -1,10 +1,10 @@
---
id: task-018
title: Create Cloudflare D1 cryptid-auth database
status: To Do
status: Done
assignee: []
created_date: '2025-12-04 12:02'
updated_date: '2025-12-04 12:27'
updated_date: '2025-12-06 06:39'
labels:
- infrastructure
- cloudflare
@ -108,4 +108,11 @@ git commit -m "chore: add D1 database IDs for cryptid-auth"
Feature branch: `feature/cryptid-email-recovery`
Code is ready - waiting for D1 database creation
Schema deployed to production D1 (35fbe755-0e7c-4b9a-a454-34f945e5f7cc)
Tables created:
- users, device_keys, verification_tokens (CryptID auth)
- boards, board_permissions (permissions system)
- user_profiles, user_connections, connection_metadata (social graph)
<!-- SECTION:NOTES:END -->

View File

@ -4,7 +4,7 @@ title: 'Open Mapping: Collaborative Route Planning Module'
status: In Progress
assignee: []
created_date: '2025-12-04 14:30'
updated_date: '2025-12-05 05:35'
updated_date: '2025-12-06 06:40'
labels:
- feature
- mapping
@ -129,4 +129,10 @@ Enhanced MapShapeUtil (1326 lines) with:
- Pulse animation on user GPS marker
- "Fit All" button to zoom to all GPS users
- Route info badge when panel is closed
Fixed persistence issue with two changes:
1. Server-side: handlePeerDisconnect now flushes pending saves immediately (prevents data loss on page close)
2. Client-side: Changed merge strategy from 'local takes precedence' to 'server takes precedence' for initial load
<!-- SECTION:NOTES:END -->

View File

@ -4,7 +4,7 @@ title: Implement proper Automerge CRDT sync for offline-first support
status: In Progress
assignee: []
created_date: '2025-12-04 21:06'
updated_date: '2025-12-05 03:53'
updated_date: '2025-12-05 22:05'
labels:
- offline-sync
- crdt
@ -63,4 +63,24 @@ Solution: Use Automerge's native binary sync protocol with proper CRDT merge sem
- WASM initialization, sync manager, R2 storage
- Integration fixes for getDocument(), handleBinaryMessage(), schedulePersistToR2()
- Ready for production testing
### 2025-12-05: Data Safety Mitigations Added
Added safety mitigations for Automerge format conversion (commit f8092d8 on feature/google-export):
**Pre-conversion backups:**
- Before any format migration, raw document backed up to R2
- Location: `pre-conversion-backups/{roomId}/{timestamp}_{formatType}.json`
**Conversion threshold guards:**
- 10% loss threshold: Conversion aborts if too many records would be lost
- 5% shape loss warning: Emits warning if shapes are lost
**Unknown format handling:**
- Unknown formats backed up before creating empty document
- Raw document keys logged for investigation
**Also fixed:**
- Keyboard shortcuts dialog error (tldraw i18n objects)
- Google Workspace integration now first in Settings > Integrations
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,39 @@
---
id: task-040
title: 'Open-Mapping Production Ready: Fix TypeScript, Enable Build, Polish UI'
status: In Progress
assignee: []
created_date: '2025-12-05 21:58'
labels:
- feature
- mapping
- typescript
- build
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make the open-mapping module production-ready by fixing TypeScript errors, re-enabling it in the build, and polishing the UI components.
Currently the open-mapping directory is excluded from tsconfig due to TypeScript errors. This task covers:
1. Fix TypeScript errors in src/open-mapping/**
2. Re-enable in tsconfig.json
3. Add NODE_OPTIONS for build memory
4. Polish MapShapeUtil UI (multi-route, layer panel)
5. Test collaboration features
6. Deploy to staging
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 open-mapping included in tsconfig without errors
- [ ] #2 npm run build succeeds
- [ ] #3 MapShapeUtil renders and functions correctly
- [ ] #4 Routing via OSRM works
- [ ] #5 GPS sharing works between clients
- [ ] #6 Layer switching works
- [ ] #7 Search with autocomplete works
<!-- AC:END -->

View File

@ -0,0 +1,91 @@
---
id: task-041
title: User Networking & Social Graph Visualization
status: Done
assignee: []
created_date: '2025-12-06 06:17'
updated_date: '2025-12-06 06:46'
labels:
- feature
- social
- visualization
- networking
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Build a social networking layer on the canvas that allows users to:
1. Tag other users as "connected" to them
2. Search by username to add connections
3. Track connected network of CryptIDs
4. Replace top-right presence icons with bottom-right graph visualization
5. Create 3D interactive graph at graph.jeffemmett.com
Key Components:
- Connection storage (extend trust circles in D1/Automerge)
- User search API
- 2D mini-graph in bottom-right (like minimap)
- 3D force-graph visualization (Three.js/react-force-graph-3d)
- Edge metadata (relationship types, clickable edges)
Architecture: Extends existing presence system in open-mapping/presence/ and trust circles in privacy/trustCircles.ts
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Users can search and add connections to other CryptIDs
- [x] #2 Connections persist across sessions in D1 database
- [x] #3 Bottom-right graph visualization shows room users and connections
- [ ] #4 3D graph at graph.jeffemmett.com is interactive (spin, zoom, click)
- [ ] #5 Clicking edges allows defining relationship metadata
- [x] #6 Real-time updates when connections change
- [x] #7 Privacy-respecting (honors trust circle permissions)
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Design decisions made:
- Binary connections only: 'connected' or 'not connected'
- All usernames publicly searchable
- One-way following allowed (no acceptance required)
- Graph scope: full network in grey, room participants colored by presence
- Edge metadata private to the two connected parties
Implementation complete:
**Files Created:**
- worker/schema.sql: Added user_profiles, user_connections, connection_metadata tables
- worker/types.ts: Added TrustLevel, UserConnection, GraphEdge, NetworkGraph types
- worker/networkingApi.ts: Full API implementation for connections, search, graph
- src/lib/networking/types.ts: Client-side types with trust levels
- src/lib/networking/connectionService.ts: API client
- src/lib/networking/index.ts: Module exports
- src/components/networking/useNetworkGraph.ts: React hook for graph state
- src/components/networking/UserSearchModal.tsx: User search UI
- src/components/networking/NetworkGraphMinimap.tsx: 2D force graph with d3
- src/components/networking/NetworkGraphPanel.tsx: Tldraw integration wrapper
- src/components/networking/index.ts: Component exports
**Modified Files:**
- worker/worker.ts: Added networking API routes
- src/ui/components.tsx: Added NetworkGraphPanel to InFrontOfCanvas
**Trust Levels:**
- unconnected (grey): No permissions
- connected (yellow): View permission
- trusted (green): Edit permission
**Features:**
- One-way following (no acceptance required)
- Trust level upgrade/downgrade
- Edge metadata (private labels, notes, colors)
- Room participants highlighted with presence colors
- Full network shown in grey, room subset colored
- Expandable to 3D view (future: graph.jeffemmett.com)
2D implementation complete. Follow-up task-042 created for 3D graph and edge metadata editor modal.
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,52 @@
---
id: task-042
title: 3D Network Graph Visualization & Edge Metadata Editor
status: To Do
assignee: []
created_date: '2025-12-06 06:46'
labels:
- feature
- visualization
- 3d
- networking
dependencies:
- task-041
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Build the 3D interactive network visualization at graph.jeffemmett.com and implement the edge metadata editor modal. This extends the 2D minimap created in task-041.
Key Features:
1. **3D Force Graph** at graph.jeffemmett.com
- Three.js / react-force-graph-3d visualization
- Full-screen, interactive (spin, zoom, pan)
- Click nodes to view user profiles
- Click edges to edit metadata
- Same trust level coloring (grey/yellow/green)
- Real-time presence sync with canvas rooms
2. **Edge Metadata Editor Modal**
- Opens on edge click in 2D minimap or 3D view
- Edit: label, notes, color, strength (1-10)
- Private to each party on the edge
- Bidirectional - each user has their own metadata view
3. **Expand Button Integration**
- 2D minimap expand button opens 3D view
- URL sharing for specific graph views
- Optional: embed 3D graph back in canvas as iframe
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 3D force graph at graph.jeffemmett.com renders user network
- [ ] #2 Graph is interactive: spin, zoom, pan, click nodes/edges
- [ ] #3 Edge metadata editor modal allows editing label, notes, color, strength
- [ ] #4 Edge metadata persists to D1 and is private per-user
- [ ] #5 Expand button in 2D minimap opens 3D view
- [ ] #6 Real-time updates when connections change
- [ ] #7 Trust level colors match 2D minimap (grey/yellow/green)
<!-- AC:END -->

View File

@ -0,0 +1,79 @@
---
id: task-042
title: User Permissions - View, Edit, Admin Levels
status: In Progress
assignee: [@claude]
created_date: '2025-12-05 14:00'
updated_date: '2025-12-05 14:00'
labels:
- feature
- auth
- permissions
- cryptid
- security
dependencies:
- task-018
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement a three-tier permission system for canvas boards:
**Permission Levels:**
1. **View** - Can see board contents, cannot edit. Default for anonymous/unauthenticated users.
2. **Edit** - Can see and modify board contents. Requires CryptID authentication.
3. **Admin** - Full access + can manage board settings and user permissions. Board owner by default.
**Key Features:**
- Anonymous users can view any shared board but cannot edit
- Creating a CryptID (username only, no password) grants edit access
- CryptID uses WebCrypto API for browser-based cryptographic keys (W3C standard)
- Session state encrypted and stored offline for authenticated users
- Admins can invite users with specific permission levels
**Anonymous User Banner:**
Display a banner for unauthenticated users:
> "If you want to edit this board, just sign in by creating a username as your CryptID - no password required! Your CryptID is secured with encrypted keys, right in your browser, by a W3C standard algorithm. As a bonus, your session will be stored for offline access, encrypted in your browser storage by the same key, allowing you to use it securely any time you like, with full data portability."
**Technical Foundation:**
- Builds on existing CryptID WebCrypto authentication (`auth-webcrypto` branch)
- Extends D1 database schema for board-level permissions
- Read-only mode in tldraw editor for view-only users
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Anonymous users can view any shared board content
- [ ] #2 Anonymous users cannot create, edit, or delete shapes
- [ ] #3 Anonymous users see a dismissible banner prompting CryptID sign-up
- [ ] #4 Creating a CryptID grants immediate edit access to current board
- [ ] #5 Board creator automatically becomes admin
- [ ] #6 Admins can view and manage board permissions
- [ ] #7 Permission levels enforced on both client and server (worker)
- [ ] #8 Authenticated user sessions stored encrypted in browser storage
- [ ] #9 Read-only toolbar/UI state for view-only users
- [ ] #10 Permission state syncs correctly across devices via CryptID
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
**Branch:** `feature/user-permissions`
**Completed:**
- [x] Database schema for boards and board_permissions tables
- [x] Permission types (PermissionLevel) in worker and client
- [x] Permission API handlers (boardPermissions.ts)
- [x] AuthContext updated with permission fetching/caching
- [x] AnonymousViewerBanner component with CryptID signup
**In Progress:**
- [ ] Board component read-only mode integration
- [ ] Automerge sync permission checking
**Dependencies:**
- `task-018` - D1 database creation (blocking for production)
- `auth-webcrypto` branch - WebCrypto authentication (merged)
<!-- SECTION:NOTES:END -->

293
package-lock.json generated
View File

@ -24,6 +24,7 @@
"@tldraw/assets": "^3.15.4",
"@tldraw/tldraw": "^3.15.4",
"@tldraw/tlschema": "^3.15.4",
"@types/d3": "^7.4.3",
"@types/markdown-it": "^14.1.1",
"@types/marked": "^5.0.2",
"@uiw/react-md-editor": "^4.0.5",
@ -34,6 +35,7 @@
"ajv": "^8.17.1",
"cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7",
"d3": "^7.9.0",
"fathom-typescript": "^0.0.36",
"gray-matter": "^4.0.3",
"gun": "^0.2020.1241",
@ -6770,6 +6772,259 @@
"@types/node": "*"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -8310,7 +8565,6 @@
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 10"
}
@ -8595,7 +8849,6 @@
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
@ -8637,7 +8890,6 @@
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"optional": true,
"dependencies": {
"internmap": "1 - 2"
},
@ -8650,7 +8902,6 @@
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8660,7 +8911,6 @@
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
@ -8677,7 +8927,6 @@
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-path": "1 - 3"
},
@ -8690,7 +8939,6 @@
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8700,7 +8948,6 @@
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-array": "^3.2.0"
},
@ -8713,7 +8960,6 @@
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"optional": true,
"dependencies": {
"delaunator": "5"
},
@ -8726,7 +8972,6 @@
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8736,7 +8981,6 @@
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
@ -8750,7 +8994,6 @@
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"optional": true,
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
@ -8776,7 +9019,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@ -8789,7 +9031,6 @@
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8799,7 +9040,6 @@
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-dsv": "1 - 3"
},
@ -8812,7 +9052,6 @@
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
@ -8827,7 +9066,6 @@
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8837,7 +9075,6 @@
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-array": "2.5.0 - 3"
},
@ -8850,7 +9087,6 @@
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8860,7 +9096,6 @@
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-color": "1 - 3"
},
@ -8873,7 +9108,6 @@
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8883,7 +9117,6 @@
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8893,7 +9126,6 @@
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8903,7 +9135,6 @@
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8913,7 +9144,6 @@
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
@ -8930,7 +9160,6 @@
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
@ -8944,7 +9173,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -8954,7 +9182,6 @@
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-path": "^3.1.0"
},
@ -8967,7 +9194,6 @@
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-array": "2 - 3"
},
@ -8980,7 +9206,6 @@
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-time": "1 - 3"
},
@ -8993,7 +9218,6 @@
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -9003,7 +9227,6 @@
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
@ -9023,7 +9246,6 @@
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"optional": true,
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
@ -9256,7 +9478,6 @@
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"optional": true,
"dependencies": {
"robust-predicates": "^3.0.2"
}
@ -11170,7 +11391,6 @@
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@ -15581,8 +15801,7 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense",
"optional": true
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.53.3",

View File

@ -41,6 +41,7 @@
"@tldraw/assets": "^3.15.4",
"@tldraw/tldraw": "^3.15.4",
"@tldraw/tlschema": "^3.15.4",
"@types/d3": "^7.4.3",
"@types/markdown-it": "^14.1.1",
"@types/marked": "^5.0.2",
"@uiw/react-md-editor": "^4.0.5",
@ -51,6 +52,7 @@
"ajv": "^8.17.1",
"cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7",
"d3": "^7.9.0",
"fathom-typescript": "^0.0.36",
"gray-matter": "^4.0.3",
"gun": "^0.2020.1241",

View File

@ -417,21 +417,40 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
// Merge server data with local data
// Automerge handles conflict resolution automatically via CRDT
// Strategy:
// 1. If local is EMPTY, use server data (bootstrap from R2)
// 2. If local HAS data, only add server records that don't exist locally
// (preserve offline changes, let Automerge CRDT sync handle conflicts)
if (serverDoc.store && serverRecordCount > 0) {
handle.change((doc: any) => {
// Initialize store if it doesn't exist
if (!doc.store) {
doc.store = {}
}
// Merge server records - Automerge will handle conflicts
const localIsEmpty = Object.keys(doc.store).length === 0
let addedFromServer = 0
let skippedExisting = 0
Object.entries(serverDoc.store).forEach(([id, record]) => {
// Only add if not already present locally (local changes take precedence)
// This is a simple merge strategy - Automerge's CRDT will handle deeper conflicts
if (!doc.store[id]) {
if (localIsEmpty) {
// Local is empty - bootstrap everything from server
doc.store[id] = record
addedFromServer++
} else if (!doc.store[id]) {
// Local has data but missing this record - add from server
// This handles: shapes created on another device and synced to R2
doc.store[id] = record
addedFromServer++
} else {
// Record exists locally - preserve local version
// The Automerge binary sync will handle merging conflicts via CRDT
// This preserves offline edits to existing shapes
skippedExisting++
}
})
console.log(`📥 Merge strategy: local was ${localIsEmpty ? 'EMPTY' : 'populated'}, added ${addedFromServer} from server, preserved ${skippedExisting} local records`)
})
const finalDoc = handle.doc()

View File

@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
import CryptID from './CryptID';
import '../../css/anonymous-banner.css';
interface AnonymousViewerBannerProps {
/** Callback when user successfully signs up/logs in */
onAuthenticated?: () => void;
/** Whether the banner was triggered by an edit attempt */
triggeredByEdit?: boolean;
}
/**
* Banner shown to anonymous (unauthenticated) users viewing a board.
* Explains CryptID and provides a smooth sign-up flow.
*/
const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
onAuthenticated,
triggeredByEdit = false
}) => {
const { session } = useAuth();
const [isDismissed, setIsDismissed] = useState(false);
const [showSignUp, setShowSignUp] = useState(false);
const [isExpanded, setIsExpanded] = useState(triggeredByEdit);
// Check if banner was previously dismissed this session
useEffect(() => {
const dismissed = sessionStorage.getItem('anonymousBannerDismissed');
if (dismissed && !triggeredByEdit) {
setIsDismissed(true);
}
}, [triggeredByEdit]);
// If user is authenticated, don't show banner
if (session.authed) {
return null;
}
// If dismissed and not triggered by edit, don't show
if (isDismissed && !triggeredByEdit) {
return null;
}
const handleDismiss = () => {
sessionStorage.setItem('anonymousBannerDismissed', 'true');
setIsDismissed(true);
};
const handleSignUpClick = () => {
setShowSignUp(true);
};
const handleSignUpSuccess = () => {
setShowSignUp(false);
if (onAuthenticated) {
onAuthenticated();
}
};
const handleSignUpCancel = () => {
setShowSignUp(false);
};
// Show CryptID modal when sign up is clicked
if (showSignUp) {
return (
<div className="anonymous-banner-modal-overlay">
<div className="anonymous-banner-modal">
<CryptID
onSuccess={handleSignUpSuccess}
onCancel={handleSignUpCancel}
/>
</div>
</div>
);
}
return (
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''} ${isExpanded ? 'expanded' : ''}`}>
<div className="banner-content">
<div className="banner-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
</svg>
</div>
<div className="banner-text">
{triggeredByEdit ? (
<p className="banner-headline">
<strong>Want to edit this board?</strong>
</p>
) : (
<p className="banner-headline">
<strong>You're viewing this board anonymously</strong>
</p>
)}
{isExpanded ? (
<div className="banner-details">
<p>
Sign in by creating a username as your <strong>CryptID</strong> &mdash; no password required!
</p>
<ul className="cryptid-benefits">
<li>
<span className="benefit-icon">&#x1F512;</span>
<span>Secured with encrypted keys, right in your browser, by a <a href="https://www.w3.org/TR/WebCryptoAPI/" target="_blank" rel="noopener noreferrer">W3C standard</a> algorithm</span>
</li>
<li>
<span className="benefit-icon">&#x1F4BE;</span>
<span>Your session is stored for offline access, encrypted in browser storage by the same key</span>
</li>
<li>
<span className="benefit-icon">&#x1F4E6;</span>
<span>Full data portability &mdash; use your canvas securely any time you like</span>
</li>
</ul>
</div>
) : (
<p className="banner-summary">
Create a free CryptID to edit this board &mdash; no password needed!
</p>
)}
</div>
<div className="banner-actions">
<button
className="banner-signup-btn"
onClick={handleSignUpClick}
>
Create CryptID
</button>
{!triggeredByEdit && (
<button
className="banner-dismiss-btn"
onClick={handleDismiss}
title="Dismiss"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
</svg>
</button>
)}
{!isExpanded && (
<button
className="banner-expand-btn"
onClick={() => setIsExpanded(true)}
title="Learn more"
>
Learn more
</button>
)}
</div>
</div>
{triggeredByEdit && (
<div className="banner-edit-notice">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" fill="currentColor"/>
</svg>
<span>This board is in read-only mode for anonymous viewers</span>
</div>
)}
</div>
);
};
export default AnonymousViewerBanner;

View File

@ -0,0 +1,442 @@
/**
* NetworkGraphMinimap Component
*
* A 2D force-directed graph visualization in the bottom-right corner.
* Shows:
* - User's full network in grey
* - Room participants in their presence colors
* - Connections as edges between nodes
* - Mutual connections as thicker lines
*
* Features:
* - Click node to view profile / connect
* - Click edge to edit metadata
* - Hover for tooltips
* - Expand button to open full 3D view
*/
import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as d3 from 'd3';
import { type GraphNode, type GraphEdge, type TrustLevel, TRUST_LEVEL_COLORS } from '../../lib/networking';
import { UserSearchModal } from './UserSearchModal';
// =============================================================================
// Types
// =============================================================================
interface NetworkGraphMinimapProps {
nodes: GraphNode[];
edges: GraphEdge[];
myConnections: string[];
currentUserId?: string;
onConnect: (userId: string) => Promise<void>;
onDisconnect?: (connectionId: string) => Promise<void>;
onNodeClick?: (node: GraphNode) => void;
onEdgeClick?: (edge: GraphEdge) => void;
onExpandClick?: () => void;
width?: number;
height?: number;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
}
interface SimulationNode extends d3.SimulationNodeDatum, GraphNode {}
interface SimulationLink extends d3.SimulationLinkDatum<SimulationNode> {
id: string;
isMutual: boolean;
}
// =============================================================================
// Styles
// =============================================================================
const styles = {
container: {
position: 'fixed' as const,
bottom: '60px',
right: '10px',
zIndex: 1000,
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'flex-end',
gap: '8px',
},
panel: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: '12px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.15)',
overflow: 'hidden',
transition: 'all 0.2s ease',
},
panelCollapsed: {
width: '48px',
height: '48px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: '1px solid rgba(0, 0, 0, 0.1)',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
},
title: {
fontSize: '12px',
fontWeight: 600,
color: '#1a1a2e',
margin: 0,
},
headerButtons: {
display: 'flex',
gap: '4px',
},
iconButton: {
width: '28px',
height: '28px',
border: 'none',
background: 'none',
borderRadius: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
color: '#666',
transition: 'background-color 0.15s',
},
canvas: {
display: 'block',
},
tooltip: {
position: 'absolute' as const,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: '#fff',
padding: '6px 10px',
borderRadius: '6px',
fontSize: '12px',
pointerEvents: 'none' as const,
whiteSpace: 'nowrap' as const,
zIndex: 1001,
transform: 'translate(-50%, -100%)',
marginTop: '-8px',
},
collapsedIcon: {
fontSize: '20px',
},
stats: {
display: 'flex',
gap: '12px',
padding: '6px 12px',
borderTop: '1px solid rgba(0, 0, 0, 0.1)',
fontSize: '11px',
color: '#666',
},
stat: {
display: 'flex',
alignItems: 'center',
gap: '4px',
},
statDot: {
width: '8px',
height: '8px',
borderRadius: '50%',
},
};
// =============================================================================
// Component
// =============================================================================
export function NetworkGraphMinimap({
nodes,
edges,
myConnections: _myConnections,
currentUserId,
onConnect,
onDisconnect,
onNodeClick,
onEdgeClick,
onExpandClick,
width = 240,
height = 180,
isCollapsed = false,
onToggleCollapse,
}: NetworkGraphMinimapProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
// Count stats
const inRoomCount = nodes.filter(n => n.isInRoom).length;
const trustedCount = nodes.filter(n => n.trustLevelTo === 'trusted').length;
const connectedCount = nodes.filter(n => n.trustLevelTo === 'connected').length;
const unconnectedCount = nodes.filter(n => !n.trustLevelTo && !n.isCurrentUser).length;
// Initialize and update the D3 simulation
useEffect(() => {
if (!svgRef.current || isCollapsed || nodes.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
// Create simulation nodes and links
const simNodes: SimulationNode[] = nodes.map(n => ({ ...n }));
const nodeMap = new Map(simNodes.map(n => [n.id, n]));
const simLinks: SimulationLink[] = edges
.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
.map(e => ({
source: nodeMap.get(e.source)!,
target: nodeMap.get(e.target)!,
id: e.id,
isMutual: e.isMutual,
}));
// Create the simulation
const simulation = d3.forceSimulation<SimulationNode>(simNodes)
.force('link', d3.forceLink<SimulationNode, SimulationLink>(simLinks)
.id(d => d.id)
.distance(40))
.force('charge', d3.forceManyBody().strength(-80))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(12));
simulationRef.current = simulation;
// Create container group
const g = svg.append('g');
// Helper to get edge color based on trust level
const getEdgeColor = (d: SimulationLink) => {
const edge = edges.find(e => e.id === d.id);
if (!edge) return 'rgba(0, 0, 0, 0.15)';
// Use effective trust level for mutual connections, otherwise the edge's trust level
const level = edge.effectiveTrustLevel || edge.trustLevel;
if (level === 'trusted') {
return 'rgba(34, 197, 94, 0.6)'; // green
} else if (level === 'connected') {
return 'rgba(234, 179, 8, 0.6)'; // yellow
}
return 'rgba(0, 0, 0, 0.15)';
};
// Create edges
const link = g.append('g')
.attr('class', 'links')
.selectAll('line')
.data(simLinks)
.join('line')
.attr('stroke', d => getEdgeColor(d))
.attr('stroke-width', d => d.isMutual ? 2.5 : 1.5)
.style('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation();
const edge = edges.find(e => e.id === d.id);
if (edge && onEdgeClick) {
onEdgeClick(edge);
}
});
// Helper to get node color based on trust level and room status
const getNodeColor = (d: SimulationNode) => {
if (d.isCurrentUser) {
return '#4f46e5'; // Current user is always purple
}
// If in room, use presence color
if (d.isInRoom && d.roomPresenceColor) {
return d.roomPresenceColor;
}
// Otherwise use trust level color
if (d.trustLevelTo) {
return TRUST_LEVEL_COLORS[d.trustLevelTo];
}
// Unconnected
return TRUST_LEVEL_COLORS.unconnected;
};
// Create nodes
const node = g.append('g')
.attr('class', 'nodes')
.selectAll('circle')
.data(simNodes)
.join('circle')
.attr('r', d => d.isCurrentUser ? 8 : 6)
.attr('fill', d => getNodeColor(d))
.attr('stroke', d => d.isCurrentUser ? '#fff' : 'none')
.attr('stroke-width', d => d.isCurrentUser ? 2 : 0)
.style('cursor', 'pointer')
.on('mouseenter', (event, d) => {
const rect = svgRef.current!.getBoundingClientRect();
setTooltip({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
text: d.displayName || d.username,
});
})
.on('mouseleave', () => {
setTooltip(null);
})
.on('click', (event, d) => {
event.stopPropagation();
if (onNodeClick) {
onNodeClick(d);
}
})
.call(d3.drag<SVGCircleElement, SimulationNode>()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}) as any);
// Update positions on tick
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as SimulationNode).x!)
.attr('y1', d => (d.source as SimulationNode).y!)
.attr('x2', d => (d.target as SimulationNode).x!)
.attr('y2', d => (d.target as SimulationNode).y!);
node
.attr('cx', d => Math.max(8, Math.min(width - 8, d.x!)))
.attr('cy', d => Math.max(8, Math.min(height - 8, d.y!)));
});
return () => {
simulation.stop();
};
}, [nodes, edges, width, height, isCollapsed, onNodeClick, onEdgeClick]);
// Handle collapsed state click
const handleCollapsedClick = useCallback(() => {
if (onToggleCollapse) {
onToggleCollapse();
}
}, [onToggleCollapse]);
if (isCollapsed) {
return (
<div style={styles.container}>
<div
style={{ ...styles.panel, ...styles.panelCollapsed }}
onClick={handleCollapsedClick}
title="Show network graph"
>
<span style={styles.collapsedIcon}>🕸</span>
</div>
</div>
);
}
return (
<div style={styles.container}>
<div style={styles.panel}>
<div style={styles.header}>
<h3 style={styles.title}>Network</h3>
<div style={styles.headerButtons}>
<button
style={styles.iconButton}
onClick={() => setIsSearchOpen(true)}
title="Find people"
>
🔍
</button>
{onExpandClick && (
<button
style={styles.iconButton}
onClick={onExpandClick}
title="Open full view"
>
</button>
)}
{onToggleCollapse && (
<button
style={styles.iconButton}
onClick={onToggleCollapse}
title="Collapse"
>
</button>
)}
</div>
</div>
<div style={{ position: 'relative' }}>
<svg
ref={svgRef}
width={width}
height={height}
style={styles.canvas}
/>
{tooltip && (
<div
style={{
...styles.tooltip,
left: tooltip.x,
top: tooltip.y,
}}
>
{tooltip.text}
</div>
)}
</div>
<div style={styles.stats}>
<div style={styles.stat} title="Users in this room">
<div style={{ ...styles.statDot, backgroundColor: '#4f46e5' }} />
<span>{inRoomCount}</span>
</div>
<div style={styles.stat} title="Trusted (edit access)">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.trusted }} />
<span>{trustedCount}</span>
</div>
<div style={styles.stat} title="Connected (view access)">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.connected }} />
<span>{connectedCount}</span>
</div>
<div style={styles.stat} title="Unconnected (no access)">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.unconnected }} />
<span>{unconnectedCount}</span>
</div>
</div>
</div>
<UserSearchModal
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
onConnect={onConnect}
onDisconnect={onDisconnect ? (userId) => {
// Find the connection ID for this user
const edge = edges.find(e =>
(e.source === currentUserId && e.target === userId) ||
(e.target === currentUserId && e.source === userId)
);
if (edge && onDisconnect) {
return onDisconnect(edge.id);
}
return Promise.resolve();
} : undefined}
currentUserId={currentUserId}
/>
</div>
);
}
export default NetworkGraphMinimap;

View File

@ -0,0 +1,154 @@
/**
* NetworkGraphPanel Component
*
* Wrapper that integrates the NetworkGraphMinimap with tldraw.
* Extracts room participants from the editor and provides connection actions.
*/
import React, { useState, useCallback, useMemo } from 'react';
import { useEditor, useValue } from 'tldraw';
import { NetworkGraphMinimap } from './NetworkGraphMinimap';
import { useNetworkGraph } from './useNetworkGraph';
import { useAuth } from '../../context/AuthContext';
import type { GraphEdge, TrustLevel } from '../../lib/networking';
// =============================================================================
// Types
// =============================================================================
interface NetworkGraphPanelProps {
onExpand?: () => void;
}
// =============================================================================
// Component
// =============================================================================
export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
const editor = useEditor();
const { session } = useAuth();
const [isCollapsed, setIsCollapsed] = useState(false);
const [selectedEdge, setSelectedEdge] = useState<GraphEdge | null>(null);
// Get collaborators from tldraw
const collaborators = useValue(
'collaborators',
() => editor.getCollaborators(),
[editor]
);
const myColor = useValue('myColor', () => editor.user.getColor(), [editor]);
const myName = useValue('myName', () => editor.user.getName() || 'Anonymous', [editor]);
// Convert collaborators to room participants format
const roomParticipants = useMemo(() => {
// Add current user
const participants = [
{
id: session.username || 'me', // Use CryptID username if available
username: myName,
color: myColor,
},
];
// Add collaborators
collaborators.forEach((c: any) => {
participants.push({
id: c.id || c.userId || c.instanceId,
username: c.userName || 'Anonymous',
color: c.color,
});
});
return participants;
}, [session.username, myName, myColor, collaborators]);
// Use the network graph hook
const {
nodes,
edges,
myConnections,
isLoading,
error,
connect,
disconnect,
} = useNetworkGraph({
roomParticipants,
refreshInterval: 30000, // Refresh every 30 seconds
useCache: true,
});
// Handle connect with default trust level
const handleConnect = useCallback(async (userId: string) => {
await connect(userId);
}, [connect]);
// Handle disconnect
const handleDisconnect = useCallback(async (connectionId: string) => {
await disconnect(connectionId);
}, [disconnect]);
// Handle node click
const handleNodeClick = useCallback((node: any) => {
// Could open a profile modal or navigate to user
console.log('Node clicked:', node);
}, []);
// Handle edge click
const handleEdgeClick = useCallback((edge: GraphEdge) => {
setSelectedEdge(edge);
// Could open an edge metadata editor modal
console.log('Edge clicked:', edge);
}, []);
// Handle expand to full 3D view
const handleExpand = useCallback(() => {
if (onExpand) {
onExpand();
} else {
// Default: open in new tab
window.open('/graph', '_blank');
}
}, [onExpand]);
// Don't render if not authenticated
if (!session.authed) {
return null;
}
// Show loading state briefly
if (isLoading && nodes.length === 0) {
return (
<div style={{
position: 'fixed',
bottom: '60px',
right: '10px',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.15)',
}}>
Loading network...
</div>
);
}
return (
<NetworkGraphMinimap
nodes={nodes}
edges={edges}
myConnections={myConnections}
currentUserId={session.username}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onExpandClick={handleExpand}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
/>
);
}
export default NetworkGraphPanel;

View File

@ -0,0 +1,374 @@
/**
* UserSearchModal Component
*
* Modal for searching and connecting with other users.
* Features:
* - Fuzzy search by username/display name
* - Shows connection status
* - One-click connect/disconnect
* - Shows mutual connections count
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { searchUsers, type UserSearchResult } from '../../lib/networking';
// =============================================================================
// Types
// =============================================================================
interface UserSearchModalProps {
isOpen: boolean;
onClose: () => void;
onConnect: (userId: string) => Promise<void>;
onDisconnect?: (userId: string) => Promise<void>;
currentUserId?: string;
}
// =============================================================================
// Styles
// =============================================================================
const styles = {
overlay: {
position: 'fixed' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
},
modal: {
backgroundColor: 'var(--color-background, #fff)',
borderRadius: '12px',
width: '90%',
maxWidth: '480px',
maxHeight: '70vh',
display: 'flex',
flexDirection: 'column' as const,
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.2)',
overflow: 'hidden',
},
header: {
padding: '16px 20px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: '18px',
fontWeight: 600,
margin: 0,
color: 'var(--color-text, #1a1a2e)',
},
closeButton: {
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: 'var(--color-text-secondary, #666)',
padding: '4px',
lineHeight: 1,
},
searchContainer: {
padding: '16px 20px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
},
searchInput: {
width: '100%',
padding: '12px 16px',
fontSize: '16px',
border: '1px solid var(--color-border, #e0e0e0)',
borderRadius: '8px',
outline: 'none',
backgroundColor: 'var(--color-surface, #f5f5f5)',
color: 'var(--color-text, #1a1a2e)',
},
results: {
flex: 1,
overflowY: 'auto' as const,
padding: '8px 0',
},
resultItem: {
display: 'flex',
alignItems: 'center',
padding: '12px 20px',
gap: '12px',
cursor: 'pointer',
transition: 'background-color 0.15s',
},
resultItemHover: {
backgroundColor: 'var(--color-surface, #f5f5f5)',
},
avatar: {
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '16px',
fontWeight: 600,
color: '#fff',
flexShrink: 0,
},
userInfo: {
flex: 1,
minWidth: 0,
},
username: {
fontSize: '15px',
fontWeight: 500,
color: 'var(--color-text, #1a1a2e)',
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
displayName: {
fontSize: '13px',
color: 'var(--color-text-secondary, #666)',
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
mutualBadge: {
fontSize: '11px',
color: 'var(--color-text-tertiary, #999)',
marginTop: '2px',
},
connectButton: {
padding: '8px 16px',
fontSize: '13px',
fontWeight: 500,
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s',
flexShrink: 0,
},
connectButtonConnect: {
backgroundColor: 'var(--color-primary, #4f46e5)',
color: '#fff',
},
connectButtonConnected: {
backgroundColor: 'var(--color-success, #22c55e)',
color: '#fff',
},
connectButtonMutual: {
backgroundColor: 'var(--color-accent, #8b5cf6)',
color: '#fff',
},
emptyState: {
padding: '40px 20px',
textAlign: 'center' as const,
color: 'var(--color-text-secondary, #666)',
},
loadingState: {
padding: '40px 20px',
textAlign: 'center' as const,
color: 'var(--color-text-secondary, #666)',
},
};
// =============================================================================
// Component
// =============================================================================
export function UserSearchModal({
isOpen,
onClose,
onConnect,
onDisconnect,
currentUserId,
}: UserSearchModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<UserSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [connectingId, setConnectingId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Focus input when modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
// Debounced search
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (query.length < 2) {
setResults([]);
return;
}
searchTimeoutRef.current = setTimeout(async () => {
setIsLoading(true);
try {
const users = await searchUsers(query);
// Filter out current user
setResults(users.filter(u => u.id !== currentUserId));
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [query, currentUserId]);
// Handle connect/disconnect
const handleConnect = useCallback(async (user: UserSearchResult) => {
setConnectingId(user.id);
try {
if (user.isConnected && onDisconnect) {
await onDisconnect(user.id);
// Update local state
setResults(prev => prev.map(u =>
u.id === user.id ? { ...u, isConnected: false } : u
));
} else {
await onConnect(user.id);
// Update local state
setResults(prev => prev.map(u =>
u.id === user.id ? { ...u, isConnected: true } : u
));
}
} catch (error) {
console.error('Connection action failed:', error);
} finally {
setConnectingId(null);
}
}, [onConnect, onDisconnect]);
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
const getButtonStyle = (user: UserSearchResult) => {
if (user.isConnected && user.isConnectedBack) {
return { ...styles.connectButton, ...styles.connectButtonMutual };
}
if (user.isConnected) {
return { ...styles.connectButton, ...styles.connectButtonConnected };
}
return { ...styles.connectButton, ...styles.connectButtonConnect };
};
const getButtonText = (user: UserSearchResult) => {
if (connectingId === user.id) return '...';
if (user.isConnected && user.isConnectedBack) return 'Mutual';
if (user.isConnected) return 'Connected';
return 'Connect';
};
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.header}>
<h2 style={styles.title}>Find People</h2>
<button style={styles.closeButton} onClick={onClose}>
&times;
</button>
</div>
<div style={styles.searchContainer}>
<input
ref={inputRef}
type="text"
placeholder="Search by username..."
value={query}
onChange={e => setQuery(e.target.value)}
style={styles.searchInput}
/>
</div>
<div style={styles.results}>
{isLoading ? (
<div style={styles.loadingState}>Searching...</div>
) : results.length === 0 ? (
<div style={styles.emptyState}>
{query.length < 2
? 'Type at least 2 characters to search'
: 'No users found'
}
</div>
) : (
results.map(user => (
<div
key={user.id}
style={{
...styles.resultItem,
...(hoveredId === user.id ? styles.resultItemHover : {}),
}}
onMouseEnter={() => setHoveredId(user.id)}
onMouseLeave={() => setHoveredId(null)}
>
<div
style={{
...styles.avatar,
backgroundColor: user.avatarColor || '#6366f1',
}}
>
{(user.displayName || user.username).charAt(0).toUpperCase()}
</div>
<div style={styles.userInfo}>
<div style={styles.username}>@{user.username}</div>
{user.displayName && user.displayName !== user.username && (
<div style={styles.displayName}>{user.displayName}</div>
)}
{user.isConnectedBack && !user.isConnected && (
<div style={styles.mutualBadge}>Follows you</div>
)}
{user.mutualConnections > 0 && (
<div style={styles.mutualBadge}>
{user.mutualConnections} mutual connection{user.mutualConnections !== 1 ? 's' : ''}
</div>
)}
</div>
<button
style={getButtonStyle(user)}
onClick={() => handleConnect(user)}
disabled={connectingId === user.id}
>
{getButtonText(user)}
</button>
</div>
))
)}
</div>
</div>
</div>
);
}
export default UserSearchModal;

View File

@ -0,0 +1,11 @@
/**
* Networking Components
*
* UI components for user networking and social graph visualization.
*/
export { NetworkGraphMinimap } from './NetworkGraphMinimap';
export { NetworkGraphPanel } from './NetworkGraphPanel';
export { UserSearchModal } from './UserSearchModal';
export { useNetworkGraph, useRoomParticipantsFromEditor } from './useNetworkGraph';
export type { RoomParticipant, NetworkGraphState, UseNetworkGraphOptions, UseNetworkGraphReturn } from './useNetworkGraph';

View File

@ -0,0 +1,321 @@
/**
* useNetworkGraph Hook
*
* Manages the network graph state for visualization:
* - Fetches user's network from the API
* - Integrates with room presence to mark active participants
* - Provides real-time updates when connections change
* - Caches graph for fast loading
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useAuth } from '../../context/AuthContext';
import {
getMyNetworkGraph,
getRoomNetworkGraph,
createConnection,
removeConnection,
getCachedGraph,
setCachedGraph,
clearGraphCache,
type NetworkGraph,
type GraphNode,
type GraphEdge,
} from '../../lib/networking';
// =============================================================================
// Types
// =============================================================================
export interface RoomParticipant {
id: string;
username: string;
color: string; // Presence color from tldraw
}
export interface NetworkGraphState {
nodes: GraphNode[];
edges: GraphEdge[];
myConnections: string[];
isLoading: boolean;
error: string | null;
}
export interface UseNetworkGraphOptions {
// Room participants to highlight (from tldraw presence)
roomParticipants?: RoomParticipant[];
// Auto-refresh interval (ms), 0 to disable
refreshInterval?: number;
// Whether to use cached data initially
useCache?: boolean;
}
export interface UseNetworkGraphReturn extends NetworkGraphState {
// Refresh the graph from the server
refresh: () => Promise<void>;
// Connect to a user
connect: (userId: string) => Promise<void>;
// Disconnect from a user
disconnect: (connectionId: string) => Promise<void>;
// Check if connected to a user
isConnectedTo: (userId: string) => boolean;
// Get node by ID
getNode: (userId: string) => GraphNode | undefined;
// Get edges for a node
getEdgesForNode: (userId: string) => GraphEdge[];
// Nodes that are in the current room
roomNodes: GraphNode[];
// Nodes that are not in the room (shown in grey)
networkNodes: GraphNode[];
}
// =============================================================================
// Hook Implementation
// =============================================================================
export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetworkGraphReturn {
const {
roomParticipants = [],
refreshInterval = 0,
useCache = true,
} = options;
const { session } = useAuth();
const [state, setState] = useState<NetworkGraphState>({
nodes: [],
edges: [],
myConnections: [],
isLoading: true,
error: null,
});
// Create a map of room participant IDs to their colors
const participantColorMap = useMemo(() => {
const map = new Map<string, string>();
roomParticipants.forEach(p => map.set(p.id, p.color));
return map;
}, [roomParticipants]);
const participantIds = useMemo(() =>
roomParticipants.map(p => p.id),
[roomParticipants]
);
// Fetch the network graph
const fetchGraph = useCallback(async (skipCache = false) => {
if (!session.authed || !session.username) {
setState(prev => ({
...prev,
isLoading: false,
error: 'Not authenticated',
}));
return;
}
// Try cache first
if (useCache && !skipCache) {
const cached = getCachedGraph();
if (cached) {
setState(prev => ({
...prev,
nodes: cached.nodes.map(n => ({
...n,
isInRoom: participantIds.includes(n.id),
roomPresenceColor: participantColorMap.get(n.id),
isCurrentUser: n.username === session.username,
})),
edges: cached.edges,
myConnections: (cached as any).myConnections || [],
isLoading: false,
error: null,
}));
// Still fetch in background to update
}
}
try {
setState(prev => ({ ...prev, isLoading: !prev.nodes.length }));
// Fetch graph, optionally scoped to room
let graph: NetworkGraph;
if (participantIds.length > 0) {
graph = await getRoomNetworkGraph(participantIds);
} else {
graph = await getMyNetworkGraph();
}
// Enrich nodes with room status and current user flag
const enrichedNodes = graph.nodes.map(node => ({
...node,
isInRoom: participantIds.includes(node.id),
roomPresenceColor: participantColorMap.get(node.id),
isCurrentUser: node.username === session.username,
}));
setState({
nodes: enrichedNodes,
edges: graph.edges,
myConnections: graph.myConnections,
isLoading: false,
error: null,
});
// Cache the result
setCachedGraph(graph);
} catch (error) {
setState(prev => ({
...prev,
isLoading: false,
error: (error as Error).message,
}));
}
}, [session.authed, session.username, participantIds, participantColorMap, useCache]);
// Initial fetch
useEffect(() => {
fetchGraph();
}, [fetchGraph]);
// Refresh interval
useEffect(() => {
if (refreshInterval > 0) {
const interval = setInterval(() => fetchGraph(true), refreshInterval);
return () => clearInterval(interval);
}
}, [refreshInterval, fetchGraph]);
// Update room status when participants change
useEffect(() => {
setState(prev => ({
...prev,
nodes: prev.nodes.map(node => ({
...node,
isInRoom: participantIds.includes(node.id),
roomPresenceColor: participantColorMap.get(node.id),
})),
}));
}, [participantIds, participantColorMap]);
// Connect to a user
const connect = useCallback(async (userId: string) => {
try {
await createConnection(userId);
// Refresh the graph to get updated state
await fetchGraph(true);
clearGraphCache();
} catch (error) {
setState(prev => ({
...prev,
error: (error as Error).message,
}));
throw error;
}
}, [fetchGraph]);
// Disconnect from a user
const disconnect = useCallback(async (connectionId: string) => {
try {
await removeConnection(connectionId);
// Refresh the graph to get updated state
await fetchGraph(true);
clearGraphCache();
} catch (error) {
setState(prev => ({
...prev,
error: (error as Error).message,
}));
throw error;
}
}, [fetchGraph]);
// Check if connected to a user
const isConnectedTo = useCallback((userId: string) => {
return state.myConnections.includes(userId);
}, [state.myConnections]);
// Get node by ID
const getNode = useCallback((userId: string) => {
return state.nodes.find(n => n.id === userId);
}, [state.nodes]);
// Get edges for a node
const getEdgesForNode = useCallback((userId: string) => {
return state.edges.filter(e => e.source === userId || e.target === userId);
}, [state.edges]);
// Split nodes into room vs network
const roomNodes = useMemo(() =>
state.nodes.filter(n => n.isInRoom),
[state.nodes]
);
const networkNodes = useMemo(() =>
state.nodes.filter(n => !n.isInRoom),
[state.nodes]
);
return {
...state,
refresh: () => fetchGraph(true),
connect,
disconnect,
isConnectedTo,
getNode,
getEdgesForNode,
roomNodes,
networkNodes,
};
}
// =============================================================================
// Helper Hook: Extract room participants from tldraw editor
// =============================================================================
/**
* Extract room participants from tldraw collaborators
* Use this to get the roomParticipants for useNetworkGraph
*/
export function useRoomParticipantsFromEditor(editor: any): RoomParticipant[] {
const [participants, setParticipants] = useState<RoomParticipant[]>([]);
useEffect(() => {
if (!editor) return;
const updateParticipants = () => {
try {
const collaborators = editor.getCollaborators();
const currentUser = editor.user;
const ps: RoomParticipant[] = [
// Add current user
{
id: currentUser.getId(),
username: currentUser.getName(),
color: currentUser.getColor(),
},
// Add collaborators
...collaborators.map((c: any) => ({
id: c.id || c.instanceId,
username: c.userName || 'Anonymous',
color: c.color,
})),
];
setParticipants(ps);
} catch (e) {
console.warn('Failed to get collaborators:', e);
}
};
// Initial update
updateParticipants();
// Listen for changes
// Note: tldraw doesn't have a great event for this, so we poll
const interval = setInterval(updateParticipants, 2000);
return () => clearInterval(interval);
}, [editor]);
return participants;
}

View File

@ -1,7 +1,9 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
import { Session, SessionError } from '../lib/auth/types';
import { Session, SessionError, PermissionLevel } from '../lib/auth/types';
import { AuthService } from '../lib/auth/authService';
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
import { WORKER_URL } from '../constants/workerUrl';
import * as crypto from '../lib/auth/crypto';
interface AuthContextType {
session: Session;
@ -12,6 +14,12 @@ interface AuthContextType {
login: (username: string) => Promise<boolean>;
register: (username: string) => Promise<boolean>;
logout: () => Promise<void>;
/** Fetch and cache the user's permission level for a specific board */
fetchBoardPermission: (boardId: string) => Promise<PermissionLevel>;
/** Check if user can edit the current board */
canEdit: () => boolean;
/** Check if user is admin for the current board */
isAdmin: () => boolean;
}
const initialSession: Session = {
@ -167,6 +175,82 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
}, [clearSession]);
/**
* Fetch and cache the user's permission level for a specific board
*/
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
// Check cache first
if (session.boardPermissions?.[boardId]) {
return session.boardPermissions[boardId];
}
try {
// Get public key for auth header if user is authenticated
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (session.authed && session.username) {
const publicKey = crypto.getPublicKey(session.username);
if (publicKey) {
headers['X-CryptID-PublicKey'] = publicKey;
}
}
const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, {
method: 'GET',
headers,
});
if (!response.ok) {
console.error('Failed to fetch board permission:', response.status);
// Default to 'view' for unauthenticated, 'edit' for authenticated
return session.authed ? 'edit' : 'view';
}
const data = await response.json() as {
permission: PermissionLevel;
isOwner: boolean;
boardExists: boolean;
};
// Cache the permission
setSessionState(prev => ({
...prev,
currentBoardPermission: data.permission,
boardPermissions: {
...prev.boardPermissions,
[boardId]: data.permission,
},
}));
return data.permission;
} catch (error) {
console.error('Error fetching board permission:', error);
// Default to 'view' for unauthenticated, 'edit' for authenticated
return session.authed ? 'edit' : 'view';
}
}, [session.authed, session.username, session.boardPermissions]);
/**
* Check if user can edit the current board
*/
const canEdit = useCallback((): boolean => {
const permission = session.currentBoardPermission;
if (!permission) {
// If no permission set, default based on auth status
return session.authed;
}
return permission === 'edit' || permission === 'admin';
}, [session.currentBoardPermission, session.authed]);
/**
* Check if user is admin for the current board
*/
const isAdmin = useCallback((): boolean => {
return session.currentBoardPermission === 'admin';
}, [session.currentBoardPermission]);
// Initialize on mount
useEffect(() => {
try {
@ -190,8 +274,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
initialize,
login,
register,
logout
}), [session, setSession, clearSession, initialize, login, register, logout]);
logout,
fetchBoardPermission,
canEdit,
isAdmin,
}), [session, setSession, clearSession, initialize, login, register, logout, fetchBoardPermission, canEdit, isAdmin]);
return (
<AuthContext.Provider value={contextValue}>

View File

@ -0,0 +1,323 @@
/* Anonymous Viewer Banner Styles */
.anonymous-viewer-banner {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
max-width: 600px;
width: calc(100% - 40px);
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 16px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
0 0 40px rgba(139, 92, 246, 0.15);
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.anonymous-viewer-banner.edit-triggered {
background: linear-gradient(135deg, #2d1f3d 0%, #3d2d54 100%);
border-color: rgba(236, 72, 153, 0.4);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
0 0 40px rgba(236, 72, 153, 0.2);
}
.banner-content {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px;
}
.banner-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.edit-triggered .banner-icon {
background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%);
}
.banner-text {
flex: 1;
min-width: 0;
}
.banner-headline {
margin: 0 0 8px 0;
font-size: 16px;
color: #f0f0f0;
line-height: 1.4;
}
.banner-headline strong {
color: white;
}
.banner-summary {
margin: 0;
font-size: 14px;
color: #a0a0b0;
line-height: 1.5;
}
.banner-details {
margin-top: 8px;
}
.banner-details p {
margin: 0 0 12px 0;
font-size: 14px;
color: #c0c0d0;
line-height: 1.5;
}
.banner-details strong {
color: #8b5cf6;
}
.cryptid-benefits {
margin: 0;
padding: 0;
list-style: none;
}
.cryptid-benefits li {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 8px;
font-size: 13px;
color: #a0a0b0;
line-height: 1.4;
}
.cryptid-benefits li:last-child {
margin-bottom: 0;
}
.benefit-icon {
flex-shrink: 0;
font-size: 14px;
}
.cryptid-benefits a {
color: #8b5cf6;
text-decoration: none;
}
.cryptid-benefits a:hover {
text-decoration: underline;
}
.banner-actions {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.banner-signup-btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.banner-signup-btn:hover {
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4);
transform: translateY(-1px);
}
.edit-triggered .banner-signup-btn {
background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%);
}
.edit-triggered .banner-signup-btn:hover {
background: linear-gradient(135deg, #db2777 0%, #9333ea 100%);
box-shadow: 0 4px 20px rgba(236, 72, 153, 0.4);
}
.banner-dismiss-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: #808090;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.banner-dismiss-btn:hover {
color: #f0f0f0;
background: rgba(255, 255, 255, 0.1);
}
.banner-expand-btn {
padding: 6px 12px;
font-size: 12px;
color: #8b5cf6;
background: transparent;
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.banner-expand-btn:hover {
background: rgba(139, 92, 246, 0.1);
border-color: rgba(139, 92, 246, 0.5);
}
.banner-edit-notice {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: rgba(236, 72, 153, 0.1);
border-top: 1px solid rgba(236, 72, 153, 0.2);
border-radius: 0 0 16px 16px;
font-size: 13px;
color: #f472b6;
}
/* Modal overlay for CryptID sign-up */
.anonymous-banner-modal-overlay {
position: fixed;
inset: 0;
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.anonymous-banner-modal {
max-width: 420px;
width: calc(100% - 40px);
max-height: calc(100vh - 80px);
overflow-y: auto;
background: #1e1e2e;
border-radius: 16px;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.5);
animation: scaleIn 0.3s ease-out;
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Dark mode is default, light mode adjustments */
@media (prefers-color-scheme: light) {
.anonymous-viewer-banner {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-color: rgba(139, 92, 246, 0.2);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.1),
0 0 40px rgba(139, 92, 246, 0.1);
}
.banner-headline {
color: #2d2d44;
}
.banner-headline strong {
color: #1e1e2e;
}
.banner-summary,
.banner-details p,
.cryptid-benefits li {
color: #606080;
}
.banner-dismiss-btn {
color: #606080;
}
.banner-dismiss-btn:hover {
color: #2d2d44;
background: rgba(0, 0, 0, 0.05);
}
}
/* Responsive adjustments */
@media (max-width: 640px) {
.anonymous-viewer-banner {
bottom: 10px;
max-width: none;
width: calc(100% - 20px);
border-radius: 12px;
}
.banner-content {
flex-direction: column;
padding: 16px;
}
.banner-icon {
width: 40px;
height: 40px;
}
.banner-actions {
flex-direction: row;
width: 100%;
margin-top: 12px;
}
.banner-signup-btn {
flex: 1;
}
}

View File

@ -1,3 +1,11 @@
/**
* Permission levels for board access:
* - 'view': Read-only access, cannot create/edit/delete shapes
* - 'edit': Can create, edit, and delete shapes
* - 'admin': Full access including permission management and board settings
*/
export type PermissionLevel = 'view' | 'edit' | 'admin';
export interface Session {
username: string;
authed: boolean;
@ -6,6 +14,10 @@ export interface Session {
obsidianVaultPath?: string;
obsidianVaultName?: string;
error?: string;
// Board permission for current board (populated when viewing a board)
currentBoardPermission?: PermissionLevel;
// Cache of board permissions by board ID
boardPermissions?: Record<string, PermissionLevel>;
}
export enum SessionError {

View File

@ -0,0 +1,336 @@
/**
* Connection Service
*
* Client-side API for user networking features:
* - User search
* - Connection management (follow/unfollow)
* - Edge metadata (labels, notes, colors)
* - Network graph retrieval
*/
import type {
UserProfile,
UserSearchResult,
Connection,
ConnectionWithMetadata,
EdgeMetadata,
NetworkGraph,
GraphNode,
GraphEdge,
TrustLevel,
} from './types';
// =============================================================================
// Configuration
// =============================================================================
const API_BASE = '/api/networking';
// =============================================================================
// Helper Functions
// =============================================================================
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText })) as { message?: string };
throw new Error(errorData.message || `HTTP ${response.status}`);
}
return response.json();
}
function generateId(): string {
return crypto.randomUUID();
}
// =============================================================================
// User Search
// =============================================================================
/**
* Search for users by username or display name
*/
export async function searchUsers(
query: string,
limit: number = 20
): Promise<UserSearchResult[]> {
const params = new URLSearchParams({ q: query, limit: String(limit) });
return fetchJson<UserSearchResult[]>(`${API_BASE}/users/search?${params}`);
}
/**
* Get a user's public profile
*/
export async function getUserProfile(userId: string): Promise<UserProfile | null> {
try {
return await fetchJson<UserProfile>(`${API_BASE}/users/${userId}`);
} catch (error) {
if ((error as Error).message.includes('404')) {
return null;
}
throw error;
}
}
/**
* Update current user's profile
*/
export async function updateMyProfile(updates: Partial<{
displayName: string;
bio: string;
avatarColor: string;
}>): Promise<UserProfile> {
return fetchJson<UserProfile>(`${API_BASE}/users/me`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
// =============================================================================
// Connection Management
// =============================================================================
/**
* Create a connection (follow a user)
* @param toUserId - The user to connect to
* @param trustLevel - 'connected' (yellow, view) or 'trusted' (green, edit)
*/
export async function createConnection(
toUserId: string,
trustLevel: TrustLevel = 'connected'
): Promise<Connection> {
return fetchJson<Connection>(`${API_BASE}/connections`, {
method: 'POST',
body: JSON.stringify({ toUserId, trustLevel }),
});
}
/**
* Update trust level for an existing connection
*/
export async function updateTrustLevel(
connectionId: string,
trustLevel: TrustLevel
): Promise<Connection> {
return fetchJson<Connection>(`${API_BASE}/connections/${connectionId}/trust`, {
method: 'PUT',
body: JSON.stringify({ trustLevel }),
});
}
/**
* Remove a connection (unfollow a user)
*/
export async function removeConnection(connectionId: string): Promise<void> {
await fetch(`${API_BASE}/connections/${connectionId}`, {
method: 'DELETE',
});
}
/**
* Get a specific connection by ID
*/
export async function getConnection(connectionId: string): Promise<ConnectionWithMetadata | null> {
try {
return await fetchJson<ConnectionWithMetadata>(`${API_BASE}/connections/${connectionId}`);
} catch (error) {
if ((error as Error).message.includes('404')) {
return null;
}
throw error;
}
}
/**
* Get all connections for current user
*/
export async function getMyConnections(): Promise<ConnectionWithMetadata[]> {
return fetchJson<ConnectionWithMetadata[]>(`${API_BASE}/connections`);
}
/**
* Get users who are connected to current user (followers)
*/
export async function getMyFollowers(): Promise<Connection[]> {
return fetchJson<Connection[]>(`${API_BASE}/connections/followers`);
}
/**
* Check if current user is connected to a specific user
*/
export async function isConnectedTo(userId: string): Promise<boolean> {
try {
const result = await fetchJson<{ connected: boolean }>(
`${API_BASE}/connections/check/${userId}`
);
return result.connected;
} catch {
return false;
}
}
// =============================================================================
// Edge Metadata
// =============================================================================
/**
* Update metadata for a connection edge
*/
export async function updateEdgeMetadata(
connectionId: string,
metadata: Partial<EdgeMetadata>
): Promise<EdgeMetadata> {
return fetchJson<EdgeMetadata>(`${API_BASE}/connections/${connectionId}/metadata`, {
method: 'PUT',
body: JSON.stringify(metadata),
});
}
/**
* Get metadata for a connection edge
*/
export async function getEdgeMetadata(connectionId: string): Promise<EdgeMetadata | null> {
try {
return await fetchJson<EdgeMetadata>(`${API_BASE}/connections/${connectionId}/metadata`);
} catch (error) {
if ((error as Error).message.includes('404')) {
return null;
}
throw error;
}
}
// =============================================================================
// Network Graph
// =============================================================================
/**
* Get the full network graph for current user
*/
export async function getMyNetworkGraph(): Promise<NetworkGraph> {
return fetchJson<NetworkGraph>(`${API_BASE}/graph`);
}
/**
* Get network graph scoped to room participants
* Returns full network in grey, room participants colored
*/
export async function getRoomNetworkGraph(
roomParticipants: string[]
): Promise<NetworkGraph> {
return fetchJson<NetworkGraph>(`${API_BASE}/graph/room`, {
method: 'POST',
body: JSON.stringify({ participants: roomParticipants }),
});
}
/**
* Get mutual connections between current user and another user
*/
export async function getMutualConnections(userId: string): Promise<UserProfile[]> {
return fetchJson<UserProfile[]>(`${API_BASE}/connections/mutual/${userId}`);
}
// =============================================================================
// Graph Building Helpers (Client-side)
// =============================================================================
/**
* Build a GraphNode from a UserProfile and room state
*/
export function buildGraphNode(
profile: UserProfile,
options: {
isInRoom: boolean;
roomPresenceColor?: string;
isCurrentUser: boolean;
}
): GraphNode {
return {
id: profile.id,
username: profile.username,
displayName: profile.displayName,
avatarColor: profile.avatarColor,
isInRoom: options.isInRoom,
roomPresenceColor: options.roomPresenceColor,
isCurrentUser: options.isCurrentUser,
};
}
/**
* Build a GraphEdge from a Connection
*/
export function buildGraphEdge(
connection: ConnectionWithMetadata,
currentUserId: string
): GraphEdge {
const isOnEdge = connection.fromUserId === currentUserId || connection.toUserId === currentUserId;
return {
id: connection.id,
source: connection.fromUserId,
target: connection.toUserId,
trustLevel: connection.trustLevel,
effectiveTrustLevel: connection.effectiveTrustLevel,
isMutual: connection.isMutual,
metadata: isOnEdge ? connection.metadata : undefined,
isVisible: true,
};
}
// =============================================================================
// Local Storage Cache (for offline/fast loading)
// =============================================================================
const CACHE_KEY = 'network_graph_cache';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
interface CachedGraph {
graph: NetworkGraph;
timestamp: number;
}
export function getCachedGraph(): NetworkGraph | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const { graph, timestamp }: CachedGraph = JSON.parse(cached);
if (Date.now() - timestamp > CACHE_TTL) {
localStorage.removeItem(CACHE_KEY);
return null;
}
return graph;
} catch {
return null;
}
}
export function setCachedGraph(graph: NetworkGraph): void {
try {
const cached: CachedGraph = {
graph,
timestamp: Date.now(),
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
} catch {
// Ignore storage errors
}
}
export function clearGraphCache(): void {
try {
localStorage.removeItem(CACHE_KEY);
} catch {
// Ignore
}
}

View File

@ -0,0 +1,58 @@
/**
* User Networking Module
*
* Provides social graph functionality for the canvas:
* - User search by username
* - One-way connections (following)
* - Private edge metadata (labels, notes, colors)
* - Network graph visualization
*/
// Types
export type {
UserProfile,
UserSearchResult,
Connection,
ConnectionWithMetadata,
EdgeMetadata,
GraphNode,
GraphEdge,
NetworkGraph,
RoomNetworkGraph,
NetworkEvent,
NetworkEventType,
TrustLevel,
} from './types';
// Constants
export { TRUST_LEVEL_COLORS, TRUST_LEVEL_PERMISSIONS } from './types';
// Connection Service API
export {
// User search
searchUsers,
getUserProfile,
updateMyProfile,
// Connection management
createConnection,
updateTrustLevel,
removeConnection,
getConnection,
getMyConnections,
getMyFollowers,
isConnectedTo,
// Edge metadata
updateEdgeMetadata,
getEdgeMetadata,
// Network graph
getMyNetworkGraph,
getRoomNetworkGraph,
getMutualConnections,
// Graph building helpers
buildGraphNode,
buildGraphEdge,
// Cache
getCachedGraph,
setCachedGraph,
clearGraphCache,
} from './connectionService';

235
src/lib/networking/types.ts Normal file
View File

@ -0,0 +1,235 @@
/**
* User Networking / Social Graph Types
*
* These types are used for the client-side networking features:
* - User search and profiles
* - One-way connections (following)
* - Edge metadata (private labels/notes on connections)
* - Network graph visualization
*/
// =============================================================================
// User Profile Types
// =============================================================================
/**
* Public user profile for search results and graph nodes
*/
export interface UserProfile {
id: string;
username: string;
displayName: string | null;
avatarColor: string | null;
bio: string | null;
}
/**
* Extended profile with connection status (for search results)
*/
export interface UserSearchResult extends UserProfile {
isConnected: boolean; // Am I following them?
isConnectedBack: boolean; // Are they following me?
mutualConnections: number; // Count of shared connections
}
// =============================================================================
// Trust Levels
// =============================================================================
/**
* Trust levels for connections:
* - 'connected': Yellow, grants view permission on shared data
* - 'trusted': Green, grants edit permission on shared data
*
* Unconnected users (grey) have no permissions.
* The user themselves has admin access.
*/
export type TrustLevel = 'connected' | 'trusted';
/**
* Color mapping for trust levels
*/
export const TRUST_LEVEL_COLORS: Record<TrustLevel | 'unconnected', string> = {
unconnected: '#9ca3af', // grey
connected: '#eab308', // yellow
trusted: '#22c55e', // green
};
/**
* Permission mapping for trust levels
*/
export const TRUST_LEVEL_PERMISSIONS: Record<TrustLevel | 'unconnected', 'none' | 'view' | 'edit'> = {
unconnected: 'none',
connected: 'view',
trusted: 'edit',
};
// =============================================================================
// Connection Types
// =============================================================================
/**
* A one-way connection from one user to another
*/
export interface Connection {
id: string;
fromUserId: string;
toUserId: string;
trustLevel: TrustLevel;
createdAt: string;
isMutual: boolean; // True if both users follow each other
// The highest trust level between both directions (if mutual)
effectiveTrustLevel: TrustLevel | null;
}
/**
* Private metadata for a connection edge
* Each party on an edge can have their own metadata
*/
export interface EdgeMetadata {
label: string | null; // Short label (e.g., "Met at ETHDenver")
notes: string | null; // Private notes
color: string | null; // Custom edge color (hex)
strength: number; // 1-10 connection strength
}
/**
* Full connection with optional metadata
*/
export interface ConnectionWithMetadata extends Connection {
metadata?: EdgeMetadata;
}
// =============================================================================
// Graph Types (for visualization)
// =============================================================================
/**
* Node in the network graph
*/
export interface GraphNode {
id: string;
username: string;
displayName: string | null;
avatarColor: string | null;
// Connection state (from current user's perspective)
trustLevelTo?: TrustLevel; // Trust level I've granted to this user
trustLevelFrom?: TrustLevel; // Trust level they've granted to me
// Visualization state
isInRoom: boolean; // Currently in the same room
roomPresenceColor?: string; // Color from room presence (if in room)
isCurrentUser: boolean; // Is this the logged-in user
}
/**
* Edge in the network graph
*/
export interface GraphEdge {
id: string;
source: string; // from user ID
target: string; // to user ID
trustLevel: TrustLevel; // Trust level of this direction
isMutual: boolean; // Both directions exist
// The highest trust level between both directions (if mutual)
effectiveTrustLevel: TrustLevel | null;
// Only included if current user is on this edge
metadata?: EdgeMetadata;
// Visualization state
isVisible: boolean; // Should be rendered (based on privacy)
}
/**
* Complete network graph for visualization
*/
export interface NetworkGraph {
nodes: GraphNode[];
edges: GraphEdge[];
myConnections: string[]; // User IDs the current user is connected to
}
/**
* Room-scoped graph (subset of network that's in current room)
*/
export interface RoomNetworkGraph extends NetworkGraph {
roomId: string;
// All room participants (even if not in your network)
roomParticipants: string[];
}
// =============================================================================
// API Request/Response Types
// =============================================================================
export interface SearchUsersRequest {
query: string;
limit?: number;
}
export interface SearchUsersResponse {
users: UserSearchResult[];
total: number;
}
export interface CreateConnectionRequest {
toUserId: string;
}
export interface CreateConnectionResponse {
connection: Connection;
}
export interface UpdateEdgeMetadataRequest {
connectionId: string;
metadata: Partial<EdgeMetadata>;
}
export interface GetNetworkGraphRequest {
userId?: string; // If not provided, returns current user's network
roomParticipants?: string[]; // If provided, marks which nodes are in room
}
export interface GetNetworkGraphResponse {
graph: NetworkGraph;
myConnections: string[]; // User IDs I'm connected to
}
// =============================================================================
// Real-time Events
// =============================================================================
export type NetworkEventType =
| 'connection:created'
| 'connection:removed'
| 'metadata:updated'
| 'user:joined_room'
| 'user:left_room';
export interface NetworkEvent {
type: NetworkEventType;
payload: unknown;
timestamp: number;
}
export interface ConnectionCreatedEvent {
type: 'connection:created';
payload: {
connection: Connection;
};
}
export interface ConnectionRemovedEvent {
type: 'connection:removed';
payload: {
connectionId: string;
fromUserId: string;
toUserId: string;
};
}
export interface MetadataUpdatedEvent {
type: 'metadata:updated';
payload: {
connectionId: string;
userId: string; // Who updated their metadata
};
}

View File

@ -72,7 +72,9 @@ import { GestureTool } from "@/GestureTool"
import { CmdK } from "@/CmdK"
import { setupMultiPasteHandler } from "@/utils/multiPasteHandler"
import { ConnectionStatusIndicator } from "@/components/ConnectionStatusIndicator"
import AnonymousViewerBanner from "@/components/auth/AnonymousViewerBanner"
import { PermissionLevel } from "@/lib/auth/types"
import "@/css/anonymous-banner.css"
import "react-cmdk/dist/cmdk.css"
import "@/css/style.css"
@ -272,7 +274,62 @@ export function Board() {
}
}, [])
const roomId = slug || "mycofi33"
const { session } = useAuth()
const { session, fetchBoardPermission, canEdit } = useAuth()
// Permission state
const [permission, setPermission] = useState<PermissionLevel | null>(null)
const [permissionLoading, setPermissionLoading] = useState(true)
const [showEditPrompt, setShowEditPrompt] = useState(false)
// Fetch permission when board loads
useEffect(() => {
let mounted = true
const loadPermission = async () => {
setPermissionLoading(true)
try {
const perm = await fetchBoardPermission(roomId)
if (mounted) {
setPermission(perm)
}
} catch (error) {
console.error('Failed to fetch permission:', error)
// Default to view for unauthenticated, edit for authenticated
if (mounted) {
setPermission(session.authed ? 'edit' : 'view')
}
} finally {
if (mounted) {
setPermissionLoading(false)
}
}
}
loadPermission()
return () => {
mounted = false
}
}, [roomId, fetchBoardPermission, session.authed])
// Check if user can edit (either has edit/admin permission, or is authenticated with default edit access)
const isReadOnly = permission === 'view' || (!session.authed && !permission)
// Handler for when user tries to edit in read-only mode
const handleEditAttempt = () => {
if (isReadOnly) {
setShowEditPrompt(true)
}
}
// Handler for successful authentication from banner
const handleAuthenticated = () => {
setShowEditPrompt(false)
// Re-fetch permission after authentication
fetchBoardPermission(roomId).then(perm => {
setPermission(perm)
})
}
// Store roomId in localStorage for VideoChatShapeUtil to access
useEffect(() => {
@ -396,6 +453,19 @@ export function Board() {
const { connectionState, isNetworkOnline } = storeWithHandle
const [editor, setEditor] = useState<Editor | null>(null)
// Update read-only state when permission changes after editor is mounted
useEffect(() => {
if (!editor) return
if (isReadOnly) {
editor.updateInstanceState({ isReadonly: true })
console.log('🔒 Permission changed: Board is now read-only')
} else {
editor.updateInstanceState({ isReadonly: false })
console.log('🔓 Permission changed: Board is now editable')
}
}, [editor, isReadOnly])
useEffect(() => {
const value = localStorage.getItem("makereal_settings_2")
if (value) {
@ -1114,6 +1184,12 @@ export function Board() {
// Note: User presence is configured through the useAutomergeSync hook above
// The authenticated username should appear in the people section
// MycelialIntelligence is now a permanent UI bar - no shape creation needed
// Set read-only mode based on permission
if (isReadOnly) {
editor.updateInstanceState({ isReadonly: true })
console.log('🔒 Board is in read-only mode for this user')
}
}}
>
<CmdK />
@ -1124,6 +1200,13 @@ export function Board() {
connectionState={connectionState}
isNetworkOnline={isNetworkOnline}
/>
{/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */}
{(!session.authed || showEditPrompt) && (
<AnonymousViewerBanner
onAuthenticated={handleAuthenticated}
triggeredByEdit={showEditPrompt}
/>
)}
</div>
</AutomergeHandleProvider>
)

View File

@ -7,6 +7,7 @@ import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
import { CommandPalette } from "./CommandPalette"
import { UserSettingsModal } from "./UserSettingsModal"
import { GoogleExportBrowser } from "../components/GoogleExportBrowser"
import { NetworkGraphPanel } from "../components/networking"
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
@ -635,6 +636,7 @@ function CustomInFrontOfCanvas() {
<MycelialIntelligenceBar />
<FocusLockIndicator />
<CommandPalette />
<NetworkGraphPanel />
</>
)
}

View File

@ -283,9 +283,11 @@ export class AutomergeDurableObject {
serverWebSocket.addEventListener("close", (event) => {
console.log(`🔌 AutomergeDurableObject: Client disconnected: ${sessionId}, code: ${event.code}, reason: ${event.reason}`)
this.clients.delete(sessionId)
// Clean up sync manager state for this peer
// Clean up sync manager state for this peer and flush pending saves
if (this.syncManager) {
this.syncManager.handlePeerDisconnect(sessionId)
this.syncManager.handlePeerDisconnect(sessionId).catch((error) => {
console.error(`❌ Error handling peer disconnect:`, error)
})
}
})

View File

@ -262,12 +262,18 @@ export class AutomergeSyncManager {
/**
* Handle peer disconnection
* Clean up sync state but don't lose any data
* Clean up sync state and flush any pending saves
*/
handlePeerDisconnect(peerId: string): void {
async handlePeerDisconnect(peerId: string): Promise<void> {
if (this.peerSyncStates.has(peerId)) {
this.peerSyncStates.delete(peerId)
console.log(`👋 Peer disconnected: ${peerId}`)
// If there's a pending save, flush it immediately to prevent data loss
if (this.pendingSave) {
console.log(`💾 Flushing pending save on peer disconnect`)
await this.forceSave()
}
}
}

581
worker/boardPermissions.ts Normal file
View File

@ -0,0 +1,581 @@
import { Environment, Board, BoardPermission, PermissionLevel, PermissionCheckResult, User } from './types';
// Generate a UUID v4
function generateUUID(): string {
return crypto.randomUUID();
}
/**
* Get a user's effective permission for a board
* Priority: explicit permission > board owner (admin) > default permission
*/
export async function getEffectivePermission(
db: D1Database,
boardId: string,
userId: string | null
): Promise<PermissionCheckResult> {
// Check if board exists
const board = await db.prepare(
'SELECT * FROM boards WHERE id = ?'
).bind(boardId).first<Board>();
// Board doesn't exist - treat as new board, anyone authenticated can create
if (!board) {
// If user is authenticated, they can create the board (will become owner)
// If not authenticated, view-only (they'll see empty canvas but can't edit)
return {
permission: userId ? 'edit' : 'view',
isOwner: false,
boardExists: false
};
}
// If user is not authenticated, return default permission
if (!userId) {
return {
permission: board.default_permission as PermissionLevel,
isOwner: false,
boardExists: true
};
}
// Check if user is the board owner (always admin)
if (board.owner_id === userId) {
return {
permission: 'admin',
isOwner: true,
boardExists: true
};
}
// Check for explicit permission
const explicitPerm = await db.prepare(
'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?'
).bind(boardId, userId).first<BoardPermission>();
if (explicitPerm) {
return {
permission: explicitPerm.permission,
isOwner: false,
boardExists: true
};
}
// Fall back to default permission, but authenticated users get at least 'edit'
// (unless board explicitly restricts to view-only)
const defaultPerm = board.default_permission as PermissionLevel;
// For most boards, authenticated users can edit
// Board owners can set default_permission to 'view' to restrict this
return {
permission: defaultPerm === 'view' ? 'view' : 'edit',
isOwner: false,
boardExists: true
};
}
/**
* Create a board and assign owner
* Called when a new board is first accessed by an authenticated user
*/
export async function createBoard(
db: D1Database,
boardId: string,
ownerId: string,
name?: string
): Promise<Board> {
const id = boardId;
await db.prepare(`
INSERT INTO boards (id, owner_id, name, default_permission, is_public)
VALUES (?, ?, ?, 'edit', 1)
ON CONFLICT(id) DO NOTHING
`).bind(id, ownerId, name || null).run();
const board = await db.prepare(
'SELECT * FROM boards WHERE id = ?'
).bind(id).first<Board>();
if (!board) {
throw new Error('Failed to create board');
}
return board;
}
/**
* Ensure a board exists, creating it if necessary
* Called on first edit by authenticated user
*/
export async function ensureBoardExists(
db: D1Database,
boardId: string,
userId: string
): Promise<Board> {
let board = await db.prepare(
'SELECT * FROM boards WHERE id = ?'
).bind(boardId).first<Board>();
if (!board) {
// Create the board with this user as owner
board = await createBoard(db, boardId, userId);
}
return board;
}
/**
* GET /boards/:boardId/permission
* Get current user's permission for a board
*/
export async function handleGetPermission(
boardId: string,
request: Request,
env: Environment
): Promise<Response> {
try {
const db = env.CRYPTID_DB;
if (!db) {
// No database - default to edit for backwards compatibility
return new Response(JSON.stringify({
permission: 'edit',
isOwner: false,
boardExists: false,
message: 'Permission system not configured'
}), {
headers: { 'Content-Type': 'application/json' },
});
}
// Get user ID from public key if provided
let userId: string | null = null;
const publicKey = request.headers.get('X-CryptID-PublicKey');
if (publicKey) {
const deviceKey = await db.prepare(
'SELECT user_id FROM device_keys WHERE public_key = ?'
).bind(publicKey).first<{ user_id: string }>();
if (deviceKey) {
userId = deviceKey.user_id;
}
}
const result = await getEffectivePermission(db, boardId, userId);
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Get permission error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* GET /boards/:boardId/permissions
* List all permissions for a board (admin only)
*/
export async function handleListPermissions(
boardId: string,
request: Request,
env: Environment
): Promise<Response> {
try {
const db = env.CRYPTID_DB;
if (!db) {
return new Response(JSON.stringify({ error: 'Database not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
// Authenticate user
const publicKey = request.headers.get('X-CryptID-PublicKey');
if (!publicKey) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const deviceKey = await db.prepare(
'SELECT user_id FROM device_keys WHERE public_key = ?'
).bind(publicKey).first<{ user_id: string }>();
if (!deviceKey) {
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Check if user is admin
const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id);
if (permCheck.permission !== 'admin') {
return new Response(JSON.stringify({ error: 'Admin access required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
// Get all permissions with user info
const permissions = await db.prepare(`
SELECT bp.*, u.cryptid_username, u.email
FROM board_permissions bp
JOIN users u ON bp.user_id = u.id
WHERE bp.board_id = ?
ORDER BY bp.granted_at DESC
`).bind(boardId).all<BoardPermission & { cryptid_username: string; email: string }>();
// Get board info
const board = await db.prepare(
'SELECT * FROM boards WHERE id = ?'
).bind(boardId).first<Board>();
// Get owner info if exists
let owner = null;
if (board?.owner_id) {
owner = await db.prepare(
'SELECT id, cryptid_username, email FROM users WHERE id = ?'
).bind(board.owner_id).first<{ id: string; cryptid_username: string; email: string }>();
}
return new Response(JSON.stringify({
board: board ? {
id: board.id,
name: board.name,
defaultPermission: board.default_permission,
isPublic: board.is_public === 1,
createdAt: board.created_at
} : null,
owner,
permissions: permissions.results || []
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('List permissions error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* POST /boards/:boardId/permissions
* Grant permission to a user (admin only)
* Body: { userId, permission, username? }
*/
export async function handleGrantPermission(
boardId: string,
request: Request,
env: Environment
): Promise<Response> {
try {
const db = env.CRYPTID_DB;
if (!db) {
return new Response(JSON.stringify({ error: 'Database not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
// Authenticate user
const publicKey = request.headers.get('X-CryptID-PublicKey');
if (!publicKey) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const adminDeviceKey = await db.prepare(
'SELECT user_id FROM device_keys WHERE public_key = ?'
).bind(publicKey).first<{ user_id: string }>();
if (!adminDeviceKey) {
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Check if user is admin
const permCheck = await getEffectivePermission(db, boardId, adminDeviceKey.user_id);
if (permCheck.permission !== 'admin') {
return new Response(JSON.stringify({ error: 'Admin access required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const body = await request.json() as {
userId?: string;
username?: string;
permission: PermissionLevel;
};
const { userId, username, permission } = body;
if (!permission || !['view', 'edit', 'admin'].includes(permission)) {
return new Response(JSON.stringify({ error: 'Invalid permission level' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Find target user
let targetUserId = userId;
if (!targetUserId && username) {
const user = await db.prepare(
'SELECT id FROM users WHERE cryptid_username = ?'
).bind(username).first<{ id: string }>();
if (!user) {
return new Response(JSON.stringify({ error: 'User not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
targetUserId = user.id;
}
if (!targetUserId) {
return new Response(JSON.stringify({ error: 'userId or username required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Ensure board exists
await ensureBoardExists(db, boardId, adminDeviceKey.user_id);
// Upsert permission
await db.prepare(`
INSERT INTO board_permissions (id, board_id, user_id, permission, granted_by)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(board_id, user_id) DO UPDATE SET
permission = excluded.permission,
granted_by = excluded.granted_by,
granted_at = datetime('now')
`).bind(generateUUID(), boardId, targetUserId, permission, adminDeviceKey.user_id).run();
return new Response(JSON.stringify({
success: true,
message: `Permission '${permission}' granted to user`
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Grant permission error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* DELETE /boards/:boardId/permissions/:userId
* Revoke a user's permission (admin only)
*/
export async function handleRevokePermission(
boardId: string,
targetUserId: string,
request: Request,
env: Environment
): Promise<Response> {
try {
const db = env.CRYPTID_DB;
if (!db) {
return new Response(JSON.stringify({ error: 'Database not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
// Authenticate user
const publicKey = request.headers.get('X-CryptID-PublicKey');
if (!publicKey) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const adminDeviceKey = await db.prepare(
'SELECT user_id FROM device_keys WHERE public_key = ?'
).bind(publicKey).first<{ user_id: string }>();
if (!adminDeviceKey) {
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Check if user is admin
const permCheck = await getEffectivePermission(db, boardId, adminDeviceKey.user_id);
if (permCheck.permission !== 'admin') {
return new Response(JSON.stringify({ error: 'Admin access required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
// Can't revoke from board owner
const board = await db.prepare(
'SELECT owner_id FROM boards WHERE id = ?'
).bind(boardId).first<{ owner_id: string }>();
if (board?.owner_id === targetUserId) {
return new Response(JSON.stringify({ error: 'Cannot revoke permission from board owner' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Delete permission
await db.prepare(
'DELETE FROM board_permissions WHERE board_id = ? AND user_id = ?'
).bind(boardId, targetUserId).run();
return new Response(JSON.stringify({
success: true,
message: 'Permission revoked'
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Revoke permission error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* PATCH /boards/:boardId
* Update board settings (admin only)
* Body: { name?, defaultPermission?, isPublic? }
*/
export async function handleUpdateBoard(
boardId: string,
request: Request,
env: Environment
): Promise<Response> {
try {
const db = env.CRYPTID_DB;
if (!db) {
return new Response(JSON.stringify({ error: 'Database not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
// Authenticate user
const publicKey = request.headers.get('X-CryptID-PublicKey');
if (!publicKey) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const deviceKey = await db.prepare(
'SELECT user_id FROM device_keys WHERE public_key = ?'
).bind(publicKey).first<{ user_id: string }>();
if (!deviceKey) {
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Check if user is admin
const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id);
if (permCheck.permission !== 'admin') {
return new Response(JSON.stringify({ error: 'Admin access required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const body = await request.json() as {
name?: string;
defaultPermission?: 'view' | 'edit';
isPublic?: boolean;
};
const updates: string[] = [];
const values: any[] = [];
if (body.name !== undefined) {
updates.push('name = ?');
values.push(body.name);
}
if (body.defaultPermission !== undefined) {
if (!['view', 'edit'].includes(body.defaultPermission)) {
return new Response(JSON.stringify({ error: 'Invalid default permission' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
updates.push('default_permission = ?');
values.push(body.defaultPermission);
}
if (body.isPublic !== undefined) {
updates.push('is_public = ?');
values.push(body.isPublic ? 1 : 0);
}
if (updates.length === 0) {
return new Response(JSON.stringify({ error: 'No updates provided' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
updates.push("updated_at = datetime('now')");
values.push(boardId);
await db.prepare(`
UPDATE boards SET ${updates.join(', ')} WHERE id = ?
`).bind(...values).run();
const updatedBoard = await db.prepare(
'SELECT * FROM boards WHERE id = ?'
).bind(boardId).first<Board>();
return new Response(JSON.stringify({
success: true,
board: updatedBoard ? {
id: updatedBoard.id,
name: updatedBoard.name,
defaultPermission: updatedBoard.default_permission,
isPublic: updatedBoard.is_public === 1,
updatedAt: updatedBoard.updated_at
} : null
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Update board error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

834
worker/networkingApi.ts Normal file
View File

@ -0,0 +1,834 @@
/**
* User Networking API Routes
*
* Handles:
* - User search by username
* - Connection management (follow/unfollow)
* - Edge metadata (labels, notes, colors)
* - Network graph retrieval
*/
import { IRequest } from 'itty-router';
import { Environment, UserProfile, UserConnection, ConnectionMetadata, UserNode, GraphEdge, NetworkGraph } from './types';
// =============================================================================
// Helper Functions
// =============================================================================
function generateId(): string {
return crypto.randomUUID();
}
function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function errorResponse(message: string, status = 400): Response {
return jsonResponse({ error: message }, status);
}
// Extract user ID from request (from auth header or session)
// For now, we'll use a simple header-based approach
function getUserIdFromRequest(request: IRequest): string | null {
// Check for X-User-Id header (set by client after CryptID auth)
const userId = request.headers.get('X-User-Id');
return userId;
}
// =============================================================================
// User Search Routes
// =============================================================================
/**
* GET /api/networking/users/search?q=query&limit=20
* Search users by username or display name
*/
export async function searchUsers(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const url = new URL(request.url);
const query = url.searchParams.get('q') || '';
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
if (!query || query.length < 2) {
return errorResponse('Query must be at least 2 characters');
}
const currentUserId = getUserIdFromRequest(request);
try {
// Search users by username or display name
const searchPattern = `%${query}%`;
const users = await db.prepare(`
SELECT
u.id,
u.cryptid_username as username,
COALESCE(p.display_name, u.cryptid_username) as displayName,
p.avatar_color as avatarColor,
p.bio
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE (
u.cryptid_username LIKE ?1
OR p.display_name LIKE ?1
)
AND (p.is_searchable = 1 OR p.is_searchable IS NULL)
LIMIT ?2
`).bind(searchPattern, limit).all();
// If we have a current user, add connection status
let results = users.results || [];
if (currentUserId && results.length > 0) {
const userIds = results.map((u: any) => u.id);
// Get connections from current user
const myConnections = await db.prepare(`
SELECT to_user_id FROM user_connections WHERE from_user_id = ?
`).bind(currentUserId).all();
const connectedIds = new Set((myConnections.results || []).map((c: any) => c.to_user_id));
// Get connections to current user
const theirConnections = await db.prepare(`
SELECT from_user_id FROM user_connections WHERE to_user_id = ?
`).bind(currentUserId).all();
const connectedBackIds = new Set((theirConnections.results || []).map((c: any) => c.from_user_id));
results = results.map((user: any) => ({
...user,
isConnected: connectedIds.has(user.id),
isConnectedBack: connectedBackIds.has(user.id),
mutualConnections: 0, // TODO: Calculate mutual connections
}));
}
return jsonResponse(results);
} catch (error) {
console.error('User search error:', error);
return errorResponse('Search failed', 500);
}
}
/**
* GET /api/networking/users/:userId
* Get a user's public profile
*/
export async function getUserProfile(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const { userId } = request.params;
try {
const result = await db.prepare(`
SELECT
u.id,
u.cryptid_username as username,
COALESCE(p.display_name, u.cryptid_username) as displayName,
p.avatar_color as avatarColor,
p.bio
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE u.id = ?
`).bind(userId).first();
if (!result) {
return errorResponse('User not found', 404);
}
return jsonResponse(result);
} catch (error) {
console.error('Get profile error:', error);
return errorResponse('Failed to get profile', 500);
}
}
/**
* PUT /api/networking/users/me
* Update current user's profile
*/
export async function updateMyProfile(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
const body = await request.json() as {
displayName?: string;
bio?: string;
avatarColor?: string;
};
// Upsert profile
await db.prepare(`
INSERT INTO user_profiles (user_id, display_name, bio, avatar_color, updated_at)
VALUES (?1, ?2, ?3, ?4, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
display_name = COALESCE(?2, display_name),
bio = COALESCE(?3, bio),
avatar_color = COALESCE(?4, avatar_color),
updated_at = datetime('now')
`).bind(userId, body.displayName || null, body.bio || null, body.avatarColor || null).run();
// Return updated profile
return getUserProfile({ ...request, params: { userId } } as unknown as IRequest, env);
} catch (error) {
console.error('Update profile error:', error);
return errorResponse('Failed to update profile', 500);
}
}
// =============================================================================
// Connection Management Routes
// =============================================================================
/**
* POST /api/networking/connections
* Create a connection (follow a user)
* Body: { toUserId: string, trustLevel?: 'connected' | 'trusted' }
*/
export async function createConnection(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const fromUserId = getUserIdFromRequest(request);
if (!fromUserId) {
return errorResponse('Unauthorized', 401);
}
try {
const body = await request.json() as { toUserId: string; trustLevel?: 'connected' | 'trusted' };
const { toUserId, trustLevel = 'connected' } = body;
if (!toUserId) {
return errorResponse('toUserId is required');
}
if (fromUserId === toUserId) {
return errorResponse('Cannot connect to yourself');
}
if (trustLevel !== 'connected' && trustLevel !== 'trusted') {
return errorResponse('trustLevel must be "connected" or "trusted"');
}
// Check if target user exists
const targetUser = await db.prepare('SELECT id FROM users WHERE id = ?').bind(toUserId).first();
if (!targetUser) {
return errorResponse('User not found', 404);
}
// Check if connection already exists
const existing = await db.prepare(`
SELECT id FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
`).bind(fromUserId, toUserId).first();
if (existing) {
return errorResponse('Already connected');
}
// Create connection
const connectionId = generateId();
await db.prepare(`
INSERT INTO user_connections (id, from_user_id, to_user_id, trust_level)
VALUES (?, ?, ?, ?)
`).bind(connectionId, fromUserId, toUserId, trustLevel).run();
// Check if mutual and get their trust level
const reverseConnection = await db.prepare(`
SELECT id, trust_level FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
`).bind(toUserId, fromUserId).first() as { id: string; trust_level: string } | null;
// Calculate effective trust level (highest of both directions)
let effectiveTrustLevel = null;
if (reverseConnection) {
const theirLevel = reverseConnection.trust_level;
effectiveTrustLevel = (trustLevel === 'trusted' || theirLevel === 'trusted') ? 'trusted' : 'connected';
}
const connection = {
id: connectionId,
fromUserId,
toUserId,
trustLevel,
createdAt: new Date().toISOString(),
isMutual: !!reverseConnection,
effectiveTrustLevel,
};
return jsonResponse(connection, 201);
} catch (error) {
console.error('Create connection error:', error);
return errorResponse('Failed to create connection', 500);
}
}
/**
* PUT /api/networking/connections/:connectionId/trust
* Update trust level for a connection
*/
export async function updateConnectionTrust(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
const { connectionId } = request.params;
try {
// Verify ownership
const connection = await db.prepare(`
SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ? AND from_user_id = ?
`).bind(connectionId, userId).first() as { id: string; from_user_id: string; to_user_id: string } | null;
if (!connection) {
return errorResponse('Connection not found or not owned by you', 404);
}
const body = await request.json() as { trustLevel: 'connected' | 'trusted' };
const { trustLevel } = body;
if (trustLevel !== 'connected' && trustLevel !== 'trusted') {
return errorResponse('trustLevel must be "connected" or "trusted"');
}
// Update trust level
await db.prepare(`
UPDATE user_connections SET trust_level = ?, updated_at = datetime('now') WHERE id = ?
`).bind(trustLevel, connectionId).run();
// Check if mutual and get their trust level
const reverseConnection = await db.prepare(`
SELECT trust_level FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
`).bind(connection.to_user_id, connection.from_user_id).first() as { trust_level: string } | null;
let effectiveTrustLevel = null;
if (reverseConnection) {
const theirLevel = reverseConnection.trust_level;
effectiveTrustLevel = (trustLevel === 'trusted' || theirLevel === 'trusted') ? 'trusted' : 'connected';
}
return jsonResponse({
id: connectionId,
fromUserId: connection.from_user_id,
toUserId: connection.to_user_id,
trustLevel,
isMutual: !!reverseConnection,
effectiveTrustLevel,
});
} catch (error) {
console.error('Update trust level error:', error);
return errorResponse('Failed to update trust level', 500);
}
}
/**
* DELETE /api/networking/connections/:connectionId
* Remove a connection (unfollow)
*/
export async function removeConnection(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
const { connectionId } = request.params;
try {
// Verify ownership
const connection = await db.prepare(`
SELECT id FROM user_connections WHERE id = ? AND from_user_id = ?
`).bind(connectionId, userId).first();
if (!connection) {
return errorResponse('Connection not found or not owned by you', 404);
}
// Delete connection and its metadata
await db.prepare('DELETE FROM connection_metadata WHERE connection_id = ?').bind(connectionId).run();
await db.prepare('DELETE FROM user_connections WHERE id = ?').bind(connectionId).run();
return new Response(null, { status: 204 });
} catch (error) {
console.error('Remove connection error:', error);
return errorResponse('Failed to remove connection', 500);
}
}
/**
* GET /api/networking/connections
* Get current user's connections (people they follow)
*/
export async function getMyConnections(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
const connections = await db.prepare(`
SELECT
c.id,
c.from_user_id as fromUserId,
c.to_user_id as toUserId,
c.created_at as createdAt,
m.label,
m.notes,
m.color,
m.strength,
EXISTS(
SELECT 1 FROM user_connections r
WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id
) as isMutual
FROM user_connections c
LEFT JOIN connection_metadata m ON c.id = m.connection_id AND m.user_id = ?
WHERE c.from_user_id = ?
`).bind(userId, userId).all();
const results = (connections.results || []).map((c: any) => ({
id: c.id,
fromUserId: c.fromUserId,
toUserId: c.toUserId,
createdAt: c.createdAt,
isMutual: !!c.isMutual,
metadata: c.label || c.notes || c.color || c.strength ? {
label: c.label,
notes: c.notes,
color: c.color,
strength: c.strength || 5,
} : undefined,
}));
return jsonResponse(results);
} catch (error) {
console.error('Get connections error:', error);
return errorResponse('Failed to get connections', 500);
}
}
/**
* GET /api/networking/connections/followers
* Get users who follow the current user
*/
export async function getMyFollowers(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
const connections = await db.prepare(`
SELECT
c.id,
c.from_user_id as fromUserId,
c.to_user_id as toUserId,
c.created_at as createdAt,
EXISTS(
SELECT 1 FROM user_connections r
WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id
) as isMutual
FROM user_connections c
WHERE c.to_user_id = ?
`).bind(userId).all();
const results = (connections.results || []).map((c: any) => ({
id: c.id,
fromUserId: c.fromUserId,
toUserId: c.toUserId,
createdAt: c.createdAt,
isMutual: !!c.isMutual,
}));
return jsonResponse(results);
} catch (error) {
console.error('Get followers error:', error);
return errorResponse('Failed to get followers', 500);
}
}
/**
* GET /api/networking/connections/check/:userId
* Check if current user is connected to a specific user
*/
export async function checkConnection(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const currentUserId = getUserIdFromRequest(request);
if (!currentUserId) {
return errorResponse('Unauthorized', 401);
}
const { userId } = request.params;
try {
const connection = await db.prepare(`
SELECT id FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
`).bind(currentUserId, userId).first();
return jsonResponse({ connected: !!connection });
} catch (error) {
console.error('Check connection error:', error);
return errorResponse('Failed to check connection', 500);
}
}
// =============================================================================
// Edge Metadata Routes
// =============================================================================
/**
* PUT /api/networking/connections/:connectionId/metadata
* Update edge metadata for a connection
*/
export async function updateEdgeMetadata(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
const { connectionId } = request.params;
try {
// Verify user is on this connection
const connection = await db.prepare(`
SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ?
`).bind(connectionId).first() as { id: string; from_user_id: string; to_user_id: string } | null;
if (!connection) {
return errorResponse('Connection not found', 404);
}
if (connection.from_user_id !== userId && connection.to_user_id !== userId) {
return errorResponse('Not authorized to edit this connection', 403);
}
const body = await request.json() as {
label?: string;
notes?: string;
color?: string;
strength?: number;
};
// Validate strength
if (body.strength !== undefined && (body.strength < 1 || body.strength > 10)) {
return errorResponse('Strength must be between 1 and 10');
}
// Upsert metadata
const metadataId = generateId();
await db.prepare(`
INSERT INTO connection_metadata (id, connection_id, user_id, label, notes, color, strength, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))
ON CONFLICT(connection_id, user_id) DO UPDATE SET
label = COALESCE(?4, label),
notes = COALESCE(?5, notes),
color = COALESCE(?6, color),
strength = COALESCE(?7, strength),
updated_at = datetime('now')
`).bind(
metadataId,
connectionId,
userId,
body.label || null,
body.notes || null,
body.color || null,
body.strength || null
).run();
// Return updated metadata
const metadata = await db.prepare(`
SELECT label, notes, color, strength FROM connection_metadata
WHERE connection_id = ? AND user_id = ?
`).bind(connectionId, userId).first();
return jsonResponse(metadata || { label: null, notes: null, color: null, strength: 5 });
} catch (error) {
console.error('Update metadata error:', error);
return errorResponse('Failed to update metadata', 500);
}
}
/**
* GET /api/networking/connections/:connectionId/metadata
* Get edge metadata for a connection
*/
export async function getEdgeMetadata(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
const { connectionId } = request.params;
try {
// Verify user is on this connection
const connection = await db.prepare(`
SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ?
`).bind(connectionId).first() as { id: string; from_user_id: string; to_user_id: string } | null;
if (!connection) {
return errorResponse('Connection not found', 404);
}
if (connection.from_user_id !== userId && connection.to_user_id !== userId) {
return errorResponse('Not authorized to view this connection', 403);
}
const metadata = await db.prepare(`
SELECT label, notes, color, strength FROM connection_metadata
WHERE connection_id = ? AND user_id = ?
`).bind(connectionId, userId).first();
return jsonResponse(metadata || { label: null, notes: null, color: null, strength: 5 });
} catch (error) {
console.error('Get metadata error:', error);
return errorResponse('Failed to get metadata', 500);
}
}
// =============================================================================
// Network Graph Routes
// =============================================================================
/**
* GET /api/networking/graph
* Get the full network graph for current user
*/
export async function getNetworkGraph(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
// Get all users connected to/from current user
const connections = await db.prepare(`
SELECT DISTINCT user_id FROM (
SELECT to_user_id as user_id FROM user_connections WHERE from_user_id = ?
UNION
SELECT from_user_id as user_id FROM user_connections WHERE to_user_id = ?
)
`).bind(userId, userId).all();
const connectedUserIds = (connections.results || []).map((c: any) => c.user_id);
connectedUserIds.push(userId); // Include self
// Get user profiles for all connected users
const placeholders = connectedUserIds.map(() => '?').join(',');
const users = await db.prepare(`
SELECT
u.id,
u.cryptid_username as username,
COALESCE(p.display_name, u.cryptid_username) as displayName,
p.avatar_color as avatarColor,
p.bio
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE u.id IN (${placeholders})
`).bind(...connectedUserIds).all();
// Build nodes
const nodes: UserNode[] = (users.results || []).map((u: any) => ({
id: u.id,
username: u.username,
displayName: u.displayName,
avatarColor: u.avatarColor,
bio: u.bio,
}));
// Get all edges between these users
const edges = await db.prepare(`
SELECT
c.id,
c.from_user_id as fromUserId,
c.to_user_id as toUserId,
c.created_at as createdAt,
m.label,
m.notes,
m.color,
m.strength,
EXISTS(
SELECT 1 FROM user_connections r
WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id
) as isMutual
FROM user_connections c
LEFT JOIN connection_metadata m ON c.id = m.connection_id AND m.user_id = ?
WHERE c.from_user_id IN (${placeholders}) AND c.to_user_id IN (${placeholders})
`).bind(userId, ...connectedUserIds, ...connectedUserIds).all();
const graphEdges: GraphEdge[] = (edges.results || []).map((e: any) => ({
id: e.id,
source: e.fromUserId,
target: e.toUserId,
trustLevel: e.trustLevel || 'connected',
effectiveTrustLevel: e.isMutual ? (e.trustLevel || 'connected') : null,
isMutual: !!e.isMutual,
isVisible: true,
metadata: (e.fromUserId === userId || e.toUserId === userId) && (e.label || e.notes || e.color || e.strength) ? {
label: e.label,
notes: e.notes,
color: e.color,
strength: e.strength || 5,
} : undefined,
}));
// Get list of users current user is connected to
const myConnections = await db.prepare(`
SELECT to_user_id FROM user_connections WHERE from_user_id = ?
`).bind(userId).all();
const graph: NetworkGraph = {
nodes,
edges: graphEdges,
myConnections: (myConnections.results || []).map((c: any) => c.to_user_id),
};
return jsonResponse(graph);
} catch (error) {
console.error('Get network graph error:', error);
return errorResponse('Failed to get network graph', 500);
}
}
/**
* POST /api/networking/graph/room
* Get network graph scoped to room participants
* Body: { participants: string[] }
*/
export async function getRoomNetworkGraph(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
const body = await request.json() as { participants: string[] };
const { participants } = body;
if (!participants || !Array.isArray(participants)) {
return errorResponse('participants array is required');
}
// First get the full network graph
const graphResponse = await getNetworkGraph(request, env);
const graph = await graphResponse.json() as NetworkGraph;
// Mark which nodes are in the room
const participantSet = new Set(participants);
const nodesWithRoomStatus = graph.nodes.map(node => ({
...node,
isInRoom: participantSet.has(node.id),
}));
return jsonResponse({
...graph,
nodes: nodesWithRoomStatus,
roomParticipants: participants,
});
} catch (error) {
console.error('Get room network graph error:', error);
return errorResponse('Failed to get room network graph', 500);
}
}
/**
* GET /api/networking/connections/mutual/:userId
* Get mutual connections between current user and another user
*/
export async function getMutualConnections(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const currentUserId = getUserIdFromRequest(request);
if (!currentUserId) {
return errorResponse('Unauthorized', 401);
}
const { userId } = request.params;
try {
// Find users that both current user and target user are connected to
const mutuals = await db.prepare(`
SELECT
u.id,
u.cryptid_username as username,
COALESCE(p.display_name, u.cryptid_username) as displayName,
p.avatar_color as avatarColor,
p.bio
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE u.id IN (
SELECT c1.to_user_id
FROM user_connections c1
INNER JOIN user_connections c2 ON c1.to_user_id = c2.to_user_id
WHERE c1.from_user_id = ? AND c2.from_user_id = ?
)
`).bind(currentUserId, userId).all();
return jsonResponse(mutuals.results || []);
} catch (error) {
console.error('Get mutual connections error:', error);
return errorResponse('Failed to get mutual connections', 500);
}
}

View File

@ -45,3 +45,101 @@ CREATE INDEX IF NOT EXISTS idx_device_keys_pubkey ON device_keys(public_key);
CREATE INDEX IF NOT EXISTS idx_tokens_token ON verification_tokens(token);
CREATE INDEX IF NOT EXISTS idx_tokens_email ON verification_tokens(email);
CREATE INDEX IF NOT EXISTS idx_tokens_expires ON verification_tokens(expires_at);
-- =============================================================================
-- Board Permissions System
-- =============================================================================
-- Board ownership and default permissions
-- Each board has an owner (admin) and a default permission level for new visitors
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY, -- board slug/room ID (e.g., "mycofi33")
owner_id TEXT, -- user ID of creator (NULL for legacy boards)
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
-- Default permission for unauthenticated users: 'view' (read-only) or 'edit' (open)
default_permission TEXT DEFAULT 'view' CHECK (default_permission IN ('view', 'edit')),
-- Board metadata
name TEXT, -- Optional display name
description TEXT, -- Optional description
is_public INTEGER DEFAULT 1, -- 1 = anyone with link can view, 0 = invite only
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL
);
-- Per-user board permissions
-- Overrides the board's default permission for specific users
CREATE TABLE IF NOT EXISTS board_permissions (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
user_id TEXT NOT NULL,
-- Permission levels: 'view' (read-only), 'edit' (can modify), 'admin' (full access)
permission TEXT NOT NULL CHECK (permission IN ('view', 'edit', 'admin')),
granted_by TEXT, -- user ID who granted permission (NULL for owner)
granted_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE(board_id, user_id)
);
-- Board permission indexes
CREATE INDEX IF NOT EXISTS idx_boards_owner ON boards(owner_id);
CREATE INDEX IF NOT EXISTS idx_board_perms_board ON board_permissions(board_id);
CREATE INDEX IF NOT EXISTS idx_board_perms_user ON board_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_board_perms_board_user ON board_permissions(board_id, user_id);
-- =============================================================================
-- User Networking / Social Graph System
-- =============================================================================
-- User profiles with searchable usernames and display info
-- Extends the users table with public profile data
CREATE TABLE IF NOT EXISTS user_profiles (
user_id TEXT PRIMARY KEY, -- References users.id
display_name TEXT, -- Optional display name (defaults to username)
bio TEXT, -- Short bio
avatar_color TEXT, -- Hex color for avatar
is_searchable INTEGER DEFAULT 1, -- 1 = appears in search results
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- User connections (one-way following with trust levels)
-- from_user follows to_user (asymmetric)
-- Trust levels: 'connected' (yellow, view) or 'trusted' (green, edit)
CREATE TABLE IF NOT EXISTS user_connections (
id TEXT PRIMARY KEY,
from_user_id TEXT NOT NULL, -- User who initiated the connection
to_user_id TEXT NOT NULL, -- User being connected to
trust_level TEXT DEFAULT 'connected' CHECK (trust_level IN ('connected', 'trusted')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(from_user_id, to_user_id) -- Can only connect once
);
-- Edge metadata (private notes/labels on connections)
-- Each user can have their own metadata for a connection edge
CREATE TABLE IF NOT EXISTS connection_metadata (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL, -- References user_connections.id
user_id TEXT NOT NULL, -- Which party owns this metadata
label TEXT, -- Short label (e.g., "Met at ETHDenver")
notes TEXT, -- Private notes about the connection
color TEXT, -- Custom edge color (hex)
strength INTEGER DEFAULT 5 CHECK (strength >= 1 AND strength <= 10), -- 1-10 connection strength
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (connection_id) REFERENCES user_connections(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(connection_id, user_id) -- One metadata entry per user per connection
);
-- User networking indexes
CREATE INDEX IF NOT EXISTS idx_profiles_searchable ON user_profiles(is_searchable);
CREATE INDEX IF NOT EXISTS idx_connections_from ON user_connections(from_user_id);
CREATE INDEX IF NOT EXISTS idx_connections_to ON user_connections(to_user_id);
CREATE INDEX IF NOT EXISTS idx_connections_both ON user_connections(from_user_id, to_user_id);
CREATE INDEX IF NOT EXISTS idx_conn_meta_connection ON connection_metadata(connection_id);
CREATE INDEX IF NOT EXISTS idx_conn_meta_user ON connection_metadata(user_id);

View File

@ -48,4 +48,144 @@ export interface VerificationToken {
public_key?: string;
device_name?: string;
user_agent?: string;
}
// =============================================================================
// Board Permission Types
// =============================================================================
/**
* Permission levels for board access:
* - 'view': Read-only access, cannot create/edit/delete shapes
* - 'edit': Can create, edit, and delete shapes
* - 'admin': Full access including permission management and board settings
*/
export type PermissionLevel = 'view' | 'edit' | 'admin';
/**
* Board record in the database
*/
export interface Board {
id: string; // board slug/room ID
owner_id: string | null; // user ID of creator (NULL for legacy boards)
created_at: string;
updated_at: string;
default_permission: 'view' | 'edit';
name: string | null;
description: string | null;
is_public: number; // SQLite boolean (0 or 1)
}
/**
* Board permission record for a specific user
*/
export interface BoardPermission {
id: string;
board_id: string;
user_id: string;
permission: PermissionLevel;
granted_by: string | null;
granted_at: string;
}
/**
* Response when checking a user's permission for a board
*/
export interface PermissionCheckResult {
permission: PermissionLevel;
isOwner: boolean;
boardExists: boolean;
}
// =============================================================================
// User Networking / Social Graph Types
// =============================================================================
/**
* User profile record in the database
*/
export interface UserProfile {
user_id: string;
display_name: string | null;
bio: string | null;
avatar_color: string | null;
is_searchable: number; // SQLite boolean (0 or 1)
created_at: string;
updated_at: string;
}
/**
* Trust levels for connections:
* - 'connected': Yellow, grants view permission on shared data
* - 'trusted': Green, grants edit permission on shared data
*/
export type TrustLevel = 'connected' | 'trusted';
/**
* User connection record (one-way follow with trust level)
*/
export interface UserConnection {
id: string;
from_user_id: string;
to_user_id: string;
trust_level: TrustLevel;
created_at: string;
updated_at: string;
}
/**
* Edge metadata for a connection (private to each party)
*/
export interface ConnectionMetadata {
id: string;
connection_id: string;
user_id: string;
label: string | null;
notes: string | null;
color: string | null;
strength: number; // 1-10
updated_at: string;
}
/**
* Combined user info for search results and graph nodes
*/
export interface UserNode {
id: string;
username: string;
displayName: string | null;
avatarColor: string | null;
bio: string | null;
}
/**
* Graph edge with connection and optional metadata
*/
export interface GraphEdge {
id: string;
fromUserId: string;
toUserId: string;
trustLevel: TrustLevel;
createdAt: string;
// Metadata is only included for the requesting user's edges
metadata?: {
label: string | null;
notes: string | null;
color: string | null;
strength: number;
};
// Indicates if this is a mutual connection (both follow each other)
isMutual: boolean;
// The highest trust level between both directions (if mutual)
effectiveTrustLevel: TrustLevel | null;
}
/**
* Full network graph response
*/
export interface NetworkGraph {
nodes: UserNode[];
edges: GraphEdge[];
// Current user's connections (for filtering)
myConnections: string[]; // User IDs I'm connected to
}

View File

@ -1,6 +1,29 @@
import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from "./types"
import {
searchUsers,
getUserProfile,
updateMyProfile,
createConnection,
updateConnectionTrust,
removeConnection,
getMyConnections,
getMyFollowers,
checkConnection,
updateEdgeMetadata,
getEdgeMetadata,
getNetworkGraph,
getRoomNetworkGraph,
getMutualConnections,
} from "./networkingApi"
import {
handleGetPermission,
handleListPermissions,
handleGrantPermission,
handleRevokePermission,
handleUpdateBoard,
} from "./boardPermissions"
// make sure our sync durable objects are made available to cloudflare
export { AutomergeDurableObject } from "./AutomergeDurableObject"
@ -81,7 +104,7 @@ const { preflight, corsify } = cors({
// If no match found, return * to allow all origins
return "*"
},
allowMethods: ["GET", "POST", "HEAD", "OPTIONS", "UPGRADE"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "UPGRADE"],
allowHeaders: [
"Content-Type",
"Authorization",
@ -96,6 +119,9 @@ const { preflight, corsify } = cors({
"Range",
"If-None-Match",
"If-Modified-Since",
"X-CryptID-PublicKey", // CryptID authentication header
"X-User-Id", // User ID header for networking API
"X-Api-Key", // API key header for external services
"*"
],
maxAge: 86400,
@ -761,10 +787,10 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
.post("/fathom/webhook", async (req) => {
try {
const body = await req.json()
// Log the webhook for debugging
console.log('Fathom webhook received:', JSON.stringify(body, null, 2))
// TODO: Verify webhook signature for security
// For now, we'll accept all webhooks. In production, you should:
// 1. Get the webhook secret from Fathom
@ -777,17 +803,17 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
// headers: { 'Content-Type': 'application/json' }
// })
// }
// Process the meeting data
const meetingData = body as any
// Store meeting data for later retrieval
// This could be stored in R2 or Durable Object storage
console.log('Processing meeting:', meetingData.meeting_id)
// TODO: Store meeting data in R2 or send to connected clients
// For now, just log it
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' }
})
@ -800,6 +826,57 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
}
})
// =============================================================================
// User Networking / Social Graph API
// =============================================================================
// User search and profiles
.get("/api/networking/users/search", searchUsers)
.get("/api/networking/users/me", (req, env) => getUserProfile({ ...req, params: { userId: req.headers.get('X-User-Id') || '' } } as unknown as IRequest, env))
.put("/api/networking/users/me", updateMyProfile)
.get("/api/networking/users/:userId", getUserProfile)
// Connection management
.post("/api/networking/connections", createConnection)
.get("/api/networking/connections", getMyConnections)
.get("/api/networking/connections/followers", getMyFollowers)
.get("/api/networking/connections/check/:userId", checkConnection)
.get("/api/networking/connections/mutual/:userId", getMutualConnections)
.put("/api/networking/connections/:connectionId/trust", updateConnectionTrust)
.delete("/api/networking/connections/:connectionId", removeConnection)
// Edge metadata
.put("/api/networking/connections/:connectionId/metadata", updateEdgeMetadata)
.get("/api/networking/connections/:connectionId/metadata", getEdgeMetadata)
// Network graph
.get("/api/networking/graph", getNetworkGraph)
.post("/api/networking/graph/room", getRoomNetworkGraph)
// =============================================================================
// Board Permissions API
// =============================================================================
// Get current user's permission for a board
.get("/boards/:boardId/permission", (req, env) =>
handleGetPermission(req.params.boardId, req, env))
// List all permissions for a board (admin only)
.get("/boards/:boardId/permissions", (req, env) =>
handleListPermissions(req.params.boardId, req, env))
// Grant permission to a user (admin only)
.post("/boards/:boardId/permissions", (req, env) =>
handleGrantPermission(req.params.boardId, req, env))
// Revoke a user's permission (admin only)
.delete("/boards/:boardId/permissions/:userId", (req, env) =>
handleRevokePermission(req.params.boardId, req.params.userId, req, env))
// Update board settings (admin only)
.patch("/boards/:boardId", (req, env) =>
handleUpdateBoard(req.params.boardId, req, env))
async function backupAllBoards(env: Environment) {
try {
// List all room files from TLDRAW_BUCKET