Compare commits

..

No commits in common. "cd58b1c1cd389516d15128f6b6fd886bd5ec65ee" and "abb80c05d8bcb779dc4902dae134be5fffdb67a6" have entirely different histories.

10 changed files with 59 additions and 281 deletions

View File

@ -1,53 +0,0 @@
---
id: task-015
title: Set up Cloudflare D1 email-collector database for cross-site subscriptions
status: To Do
assignee: []
created_date: '2025-12-04 12:00'
labels:
- infrastructure
- cloudflare
- d1
- email
- cross-site
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Create a standalone Cloudflare D1 database for collecting email subscriptions across all websites (mycofi.earth, canvas.jeffemmett.com, decolonizeti.me, etc.) with easy export capabilities.
**Purpose:**
- Unified email collection from all sites
- Page-separated lists (e.g., /newsletter, /waitlist, /landing)
- Simple CSV/JSON export for email campaigns
- GDPR-compliant with unsubscribe tracking
**Sites to integrate:**
- mycofi.earth
- canvas.jeffemmett.com
- decolonizeti.me
- games.jeffemmett.com
- Future sites
**Key Features:**
- Double opt-in verification
- Source tracking (which site, which page)
- Export in multiple formats (CSV, JSON, Mailchimp)
- Basic admin dashboard or CLI for exports
- Rate limiting to prevent abuse
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 D1 database 'email-collector' created on Cloudflare
- [ ] #2 Schema deployed with subscribers, verification_tokens tables
- [ ] #3 POST /api/subscribe endpoint accepts email + source_site + source_page
- [ ] #4 Email verification flow with token-based double opt-in
- [ ] #5 GET /api/emails/export returns CSV with filters (site, date, verified)
- [ ] #6 Unsubscribe endpoint and tracking
- [ ] #7 Rate limiting prevents spam submissions
- [ ] #8 At least one site integrated and collecting emails
<!-- AC:END -->

View File

@ -1,56 +0,0 @@
---
id: task-016
title: Add encryption for CryptID emails at rest
status: To Do
assignee: []
created_date: '2025-12-04 12:01'
labels:
- security
- cryptid
- encryption
- privacy
- d1
dependencies:
- task-017
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Enhance CryptID security by encrypting email addresses stored in D1 database. This protects user privacy even if the database is compromised.
**Encryption Strategy:**
- Encrypt email addresses before storing in D1
- Use Cloudflare Workers KV or environment secret for encryption key
- Store encrypted email + hash for lookups
- Decrypt only when needed (sending emails, display)
**Implementation Options:**
1. **AES-GCM encryption** with key in Worker secret
2. **Deterministic encryption** for email lookups (hash-based)
3. **Hybrid approach**: Hash for lookup index, AES for actual email
**Schema Changes:**
```sql
ALTER TABLE users ADD COLUMN email_encrypted TEXT;
ALTER TABLE users ADD COLUMN email_hash TEXT; -- For lookups
-- Migrate existing emails, then drop plaintext column
```
**Considerations:**
- Key rotation strategy
- Performance impact on lookups
- Backup/recovery implications
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Encryption key securely stored in Worker secrets
- [ ] #2 Emails encrypted before D1 insert
- [ ] #3 Email lookup works via hash index
- [ ] #4 Decryption works for email display and sending
- [ ] #5 Existing emails migrated to encrypted format
- [ ] #6 Key rotation procedure documented
- [ ] #7 No plaintext emails in database
<!-- AC:END -->

View File

@ -1,41 +0,0 @@
---
id: task-016
title: Configure CryptID secrets and environment variables
status: To Do
assignee: []
created_date: '2025-12-04 12:00'
labels:
- infrastructure
- cloudflare
- cryptid
- secrets
- email
dependencies:
- task-015
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Set up the required secrets and environment variables for CryptID email functionality on Cloudflare Workers.
**Required Secrets:**
- SENDGRID_API_KEY - For sending verification emails
- CRYPTID_EMAIL_FROM - Sender email address (e.g., auth@jeffemmett.com)
- APP_URL - Base URL for verification links (e.g., https://canvas.jeffemmett.com)
**Configuration:**
- Secrets set for both production and dev environments
- SendGrid account configured with verified sender domain
- Email templates tested
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 SENDGRID_API_KEY secret set via wrangler secret put
- [ ] #2 CRYPTID_EMAIL_FROM secret configured
- [ ] #3 APP_URL environment variable set in wrangler.toml
- [ ] #4 SendGrid sender domain verified (jeffemmett.com or subdomain)
- [ ] #5 Test email sends successfully from Worker
<!-- AC:END -->

View File

@ -1,43 +0,0 @@
---
id: task-016
title: Create Cloudflare D1 cryptid-auth database
status: To Do
assignee: []
created_date: '2025-12-04 12:00'
labels:
- infrastructure
- cloudflare
- d1
- cryptid
- auth
- security
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Create the D1 database on Cloudflare for CryptID authentication system. This is the first step before deploying the email recovery feature.
**Database Purpose:**
- Store user accounts linked to CryptID usernames
- Store device public keys for multi-device auth
- Store verification tokens for email/device linking
- Enable account recovery via verified email
**Security Considerations:**
- Emails should be encrypted at rest (Phase 2)
- Public keys are safe to store (not secrets)
- Tokens are time-limited and single-use
- No passwords stored (WebCrypto key-based auth)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 D1 database 'cryptid-auth' created via wrangler d1 create
- [ ] #2 D1 database 'cryptid-auth-dev' created for dev environment
- [ ] #3 Database IDs added to wrangler.toml (replacing placeholders)
- [ ] #4 Schema from worker/schema.sql deployed to both databases
- [ ] #5 Verified tables exist: users, device_keys, verification_tokens
<!-- AC:END -->

View File

@ -1,52 +0,0 @@
---
id: task-017
title: Deploy CryptID email recovery to dev branch and test
status: To Do
assignee: []
created_date: '2025-12-04 12:00'
labels:
- feature
- cryptid
- auth
- testing
- dev-branch
dependencies:
- task-015
- task-016
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Push the existing CryptID email recovery code changes to dev branch and test the full flow before merging to main.
**Code Changes Ready:**
- src/App.tsx - Routes for /verify-email, /link-device
- src/components/auth/CryptID.tsx - Email linking flow
- src/components/auth/Profile.tsx - Email management UI, device list
- src/css/crypto-auth.css - Styling for email/device modals
- worker/types.ts - Updated D1 types
- worker/worker.ts - Auth API routes
- worker/cryptidAuth.ts - Auth handlers (already committed)
**Test Scenarios:**
1. Link email to existing CryptID account
2. Verify email via link
3. Request device link from new device
4. Approve device link via email
5. View and revoke linked devices
6. Recover account on new device via email
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All CryptID changes committed to dev branch
- [ ] #2 Worker deployed to dev environment
- [ ] #3 Link email flow works end-to-end
- [ ] #4 Email verification completes successfully
- [ ] #5 Device linking via email works
- [ ] #6 Device revocation works
- [ ] #7 Profile shows linked email and devices
- [ ] #8 No console errors in happy path
<!-- AC:END -->

View File

@ -11,23 +11,39 @@ function isValidIndexKey(index: string): boolean {
return false return false
} }
// tldraw uses fractional indexing where: // The first character indicates the integer part length:
// - First character is a lowercase letter indicating integer part length (a=1, b=2, c=3, etc.) // 'a' = 1 digit, 'b' = 2 digits, etc. for positive integers
// - Followed by alphanumeric characters for the value and optional jitter // 'Z' = 1 digit, 'Y' = 2 digits, etc. for negative integers
// Examples: "a0", "a1", "b10", "b99", "c100", "a1V4rr", "b10Lz" // But for normal shapes, 'a' followed by a digit is the most common pattern
//
// Also uppercase letters for negative indices (Z=1, Y=2, etc.)
// Valid fractional index: lowercase letter followed by alphanumeric characters // Simple invalid patterns that are definitely wrong:
if (/^[a-z][a-zA-Z0-9]+$/.test(index)) { // - Just a number like "1", "2"
// - Old format like "b1", "c1" (letter + single digit that's not a valid fractional index)
// - Empty or whitespace
// Valid fractional indices from tldraw start with 'a' for small positive numbers
// and follow with digits + optional alphanumeric jitter
// Pattern: starts with 'a', followed by at least one digit, then optional alphanumeric chars
// Simple patterns that are DEFINITELY invalid for tldraw:
// "b1", "c1", "d1" etc - these are old non-fractional indices
if (/^[b-z]\d$/i.test(index)) {
return false
}
// Valid tldraw indices should start with lowercase 'a' followed by digits
// and optionally more alphanumeric characters for the fractional part
// Examples from actual tldraw: "a0", "a1", "a24sT", "a1V4rr"
if (/^a\d/.test(index)) {
return true return true
} }
// Also allow uppercase prefix for negative/very high indices // Also allow 'Z' prefix for very high indices (though rare)
if (/^[A-Z][a-zA-Z0-9]+$/.test(index)) { if (/^Z[a-z]/i.test(index)) {
return true return true
} }
// If none of the above, it's likely invalid
return false return false
} }

View File

@ -23,15 +23,13 @@ function minimalSanitizeRecord(record: any): any {
// NOTE: Index assignment is handled by assignSequentialIndices() during format conversion // NOTE: Index assignment is handled by assignSequentialIndices() during format conversion
// Here we only ensure index exists with a valid format, not strictly validate // Here we only ensure index exists with a valid format, not strictly validate
// This preserves layer order that was established during conversion // This preserves layer order that was established during conversion
// tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc. // Valid formats: a1, a2, a10, a1V, a1Lz, etc. (fractional indexing)
// - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.)
// - Uppercase (A-Z) for negative/special indices
if (!sanitized.index || typeof sanitized.index !== 'string' || sanitized.index.length === 0) { if (!sanitized.index || typeof sanitized.index !== 'string' || sanitized.index.length === 0) {
// Only assign default if truly missing // Only assign default if truly missing
sanitized.index = 'a1' sanitized.index = 'a1'
} else if (!/^[a-zA-Z][a-zA-Z0-9]+$/.test(sanitized.index)) { } else if (!/^a\d/.test(sanitized.index) && !/^Z[a-z]/i.test(sanitized.index)) {
// Accept any letter followed by alphanumeric characters // Accept any index starting with 'a' + digit, or 'Z' prefix
// Only reset clearly invalid formats (e.g., numbers, empty, single char) // Only reset clearly invalid formats
console.warn(`⚠️ MinimalSanitization: Invalid index format "${sanitized.index}" for shape ${sanitized.id}`) console.warn(`⚠️ MinimalSanitization: Invalid index format "${sanitized.index}" for shape ${sanitized.id}`)
sanitized.index = 'a1' sanitized.index = 'a1'
} }

View File

@ -20,23 +20,31 @@ import { getDocumentId, saveDocumentId } from "./documentIdMapping"
function isValidTldrawIndex(index: string): boolean { function isValidTldrawIndex(index: string): boolean {
if (!index || typeof index !== 'string' || index.length === 0) return false if (!index || typeof index !== 'string' || index.length === 0) return false
// tldraw uses fractional indexing where: // The first character indicates the integer part length:
// - First character is a lowercase letter indicating integer part length (a=1, b=2, c=3, etc.) // 'a' = 1 digit, 'b' = 2 digits, etc. for positive integers
// - Followed by alphanumeric characters for the value and optional jitter // 'Z' = 1 digit, 'Y' = 2 digits, etc. for negative integers
// Examples: "a0", "a1", "b10", "b99", "c100", "a1V4rr", "b10Lz" // But for normal shapes, 'a' followed by a digit is the most common pattern
//
// Also uppercase letters for negative indices (Z=1, Y=2, etc.)
// Valid fractional index: lowercase letter followed by alphanumeric characters // Simple patterns that are DEFINITELY invalid for tldraw:
if (/^[a-z][a-zA-Z0-9]+$/.test(index)) { // "b1", "c1", "d1" etc - these are old non-fractional indices (single letter + single digit)
// These were used before tldraw switched to fractional indexing
if (/^[b-z]\d$/i.test(index)) {
return false
}
// Valid tldraw indices should start with lowercase 'a' followed by digits
// and optionally more alphanumeric characters for the fractional/jitter part
// Examples from actual tldraw: "a0", "a1", "a24sT", "a1V4rr"
if (/^a\d/.test(index)) {
return true return true
} }
// Also allow uppercase prefix for negative/very high indices // Also allow 'Z' prefix for very high indices (though rare)
if (/^[A-Z][a-zA-Z0-9]+$/.test(index)) { if (/^Z[a-z]/i.test(index)) {
return true return true
} }
// If none of the above, it's likely invalid
return false return false
} }

View File

@ -68,18 +68,20 @@ import "@/css/style.css"
import "@/css/obsidian-browser.css" import "@/css/obsidian-browser.css"
// Helper to validate and fix tldraw IndexKey format // Helper to validate and fix tldraw IndexKey format
// tldraw uses fractional indexing: a0, a1, b10, c100, a1V4rr, etc. // Valid: "a0", "a1", "a24sT", "a1V4rr" - Invalid: "b1", "c1" (old format)
// - First letter (a-z) indicates integer part length (a=1 digit, b=2 digits, etc.)
// - Uppercase (A-Z) for negative/special indices
function sanitizeIndex(index: any): IndexKey { function sanitizeIndex(index: any): IndexKey {
if (!index || typeof index !== 'string' || index.length === 0) { if (!index || typeof index !== 'string' || index.length === 0) {
return 'a1' as IndexKey return 'a1' as IndexKey
} }
// Valid: letter followed by alphanumeric characters // Old format "b1", "c1" etc are invalid (single letter + single digit)
if (/^[a-zA-Z][a-zA-Z0-9]+$/.test(index)) { if (/^[b-z]\d$/i.test(index)) {
return 'a1' as IndexKey
}
// Valid: starts with 'a' followed by at least one digit
if (/^a\d/.test(index)) {
return index as IndexKey return index as IndexKey
} }
// Fallback for invalid formats // Fallback
return 'a1' as IndexKey return 'a1' as IndexKey
} }

View File

@ -747,12 +747,11 @@ export class AutomergeDurableObject {
shapesNeedingIndex.sort((a, b) => a.originalIndex - b.originalIndex) shapesNeedingIndex.sort((a, b) => a.originalIndex - b.originalIndex)
// Check if shapes already have valid indices we should preserve // Check if shapes already have valid indices we should preserve
// Valid tldraw fractional index: starts with a lowercase letter followed by alphanumeric characters // Valid index: starts with 'a' followed by digits, optionally followed by alphanumeric jitter
// Examples: a1, a2, b1, c10, a1V, a1Lz, etc. (the letter increments as indices grow)
const isValidIndex = (idx: any): boolean => { const isValidIndex = (idx: any): boolean => {
if (!idx || typeof idx !== 'string' || idx.length === 0) return false if (!idx || typeof idx !== 'string' || idx.length === 0) return false
// Valid fractional index format: lowercase letter followed by alphanumeric (a1, b1, c10, a1V, etc.) // Valid fractional index format: a1, a2, a1V, a10, a1Lz, etc.
if (/^[a-z][a-zA-Z0-9]+$/.test(idx)) return true if (/^a\d/.test(idx)) return true
// Also allow 'Z' prefix for very high indices // Also allow 'Z' prefix for very high indices
if (/^Z[a-z]/i.test(idx)) return true if (/^Z[a-z]/i.test(idx)) return true
return false return false