group conversations with models and local files
0

Configure Feed

Select the types of activity you want to include in your feed.

push-project: v1 SQLite → v2 Automerge via URL fragment

The v1 push-project pointed at a server endpoint that no longer
exists. The v2 path can't send content to the server, but it can
ship content to a logged-in browser via a URL fragment, same trust
model as invite links: K_proj plaintext + Automerge doc bytes ride
in `#import-project=`. The fragment never leaves the local
browser, gets scrubbed on load, gets wrapped and POST'd to
/api/projects after auth.

- scripts/push-project.mjs: rewritten to build an Automerge doc
from a local v1 SQLite (conversations + turns + text files;
binary files skipped — v2 hasn't wired blob transport yet) and
open the import URL.
- src/main.tsx: handle `#import-project=` by stashing the raw
payload in sessionStorage and scrubbing the hash, then mount.
- src/App.tsx: after auth, consume the stash. Derive wrap key from
PRF, wrap K_proj, POST /api/projects to mint the row, persist
the Automerge doc bytes under the server-assigned project id.
Cleared on success or alert-and-clear on failure.

dietrich ayala (May 26, 2026, 1:49 PM +0200) cbb3f995 09f97468

+235 -48
+146 -34
scripts/push-project.mjs
··· 1 1 #!/usr/bin/env node 2 - // Push a locally-imported project SQLite to a remote Hezo instance. 3 - // Pairs with `import-icloud`: that script materializes a legacy Automerge 4 - // project locally; this one ships the resulting SQLite to prod. 2 + // Migrate a v1 (Centrosome-era) per-project SQLite into a v2 Hezo 3 + // instance. v2 doesn't have a server-side import endpoint anymore 4 + // — content lives in browser CRDTs — so this script builds an 5 + // Automerge ProjectDoc locally, then opens a browser URL with the 6 + // project in a `#import-project` fragment. The browser handles 7 + // account creation / login, key wrap, and IndexedDB write. 5 8 // 6 9 // Usage: 7 10 // node scripts/push-project.mjs <localProjectId> <email> 8 11 // 9 12 // Env: 10 - // BASE_URL default https://hezo-production.up.railway.app 11 - // HEZO_DATA default ./data (same as the server uses locally) 13 + // HEZO_URL default https://hezo.app 14 + // HEZO_DATA default ./data 12 15 // 13 - // Logs in as <email> (bootstrap or existing user), POSTs the SQLite as 14 - // base64. Does NOT ship binary blobs — TODO. 16 + // Notes: 17 + // - Binary files are skipped (v2 hasn't wired binary blobs through 18 + // the outbox yet). 19 + // - The URL fragment is never sent to the network, but it does sit 20 + // in the URL bar / history briefly. main.tsx scrubs it on load. 21 + // K_proj is in the fragment in plaintext, same trust model as the 22 + // invite URL. 15 23 24 + import * as Automerge from '@automerge/automerge' 25 + import Database from 'better-sqlite3' 26 + import { randomBytes } from 'node:crypto' 27 + import { execFileSync } from 'node:child_process' 16 28 import * as fs from 'node:fs' 17 29 import * as path from 'node:path' 18 30 ··· 23 35 process.exit(1) 24 36 } 25 37 26 - const BASE = process.env.BASE_URL ?? 'https://hezo.app' 38 + const BASE = process.env.HEZO_URL ?? 'https://hezo.app' 27 39 const DATA = process.env.HEZO_DATA ?? path.resolve('data') 28 40 29 41 const src = path.join(DATA, 'projects', `${localId}.sqlite`) ··· 32 44 process.exit(1) 33 45 } 34 46 35 - console.log(`logging in to ${BASE} as ${email}`) 36 - const loginRes = await fetch(`${BASE}/api/auth/login`, { 37 - method: 'POST', 38 - headers: { 'Content-Type': 'application/json' }, 39 - body: JSON.stringify({ email }), 40 - }) 41 - if (!loginRes.ok) { 42 - console.error(`login failed: ${loginRes.status} ${await loginRes.text()}`) 43 - process.exit(1) 47 + console.log(`reading v1 SQLite: ${src}`) 48 + const db = new Database(src, { readonly: true }) 49 + 50 + // --- read v1 rows ----------------------------------------------------------- 51 + 52 + const meta = Object.fromEntries( 53 + db.prepare('SELECT k, v FROM meta').all().map((r) => [r.k, r.v]), 54 + ) 55 + const projectName = meta.name ?? 'Imported project' 56 + 57 + const convRows = db 58 + .prepare('SELECT id, title, archived, created_at, created_by FROM conversations') 59 + .all() 60 + 61 + const turnRows = db 62 + .prepare( 63 + `SELECT id, conv_id, parent_id, author, role, created_at, text, 64 + status, model_id, input_tokens, output_tokens, paid_by 65 + FROM turns 66 + ORDER BY created_at ASC`, 67 + ) 68 + .all() 69 + 70 + const fileRows = db 71 + .prepare( 72 + `SELECT id, name, content, binary_sha256, binary_mime, binary_size, 73 + created_at, created_by, updated_at, updated_by 74 + FROM files`, 75 + ) 76 + .all() 77 + 78 + db.close() 79 + 80 + const textFiles = fileRows.filter((f) => !f.binary_sha256) 81 + const skippedBinary = fileRows.length - textFiles.length 82 + 83 + console.log( 84 + ` project "${projectName}": ${convRows.length} conversations, ${turnRows.length} turns, ${textFiles.length} text files` + 85 + (skippedBinary > 0 ? ` (${skippedBinary} binary files skipped)` : ''), 86 + ) 87 + 88 + // --- build v2 Automerge doc ------------------------------------------------- 89 + 90 + const initial = { 91 + meta: { description: meta.description ?? '' }, 92 + bots: {}, 93 + conversations: Object.fromEntries( 94 + convRows.map((c) => [ 95 + c.id, 96 + { 97 + id: c.id, 98 + title: c.title || 'Untitled', 99 + createdBy: c.created_by, 100 + createdAt: c.created_at, 101 + }, 102 + ]), 103 + ), 104 + turns: Object.fromEntries( 105 + turnRows.map((t) => { 106 + const turn = { 107 + id: t.id, 108 + convId: t.conv_id, 109 + author: t.author, 110 + role: t.role, 111 + text: t.text ?? '', 112 + createdAt: t.created_at, 113 + } 114 + if (t.status) turn.status = t.status 115 + if (t.model_id) turn.modelId = t.model_id 116 + if (t.paid_by) turn.paidBy = t.paid_by 117 + return [t.id, turn] 118 + }), 119 + ), 120 + files: Object.fromEntries( 121 + textFiles.map((f) => [ 122 + f.id, 123 + { 124 + id: f.id, 125 + name: f.name, 126 + content: f.content ?? '', 127 + createdBy: f.created_by, 128 + createdAt: f.created_at, 129 + updatedBy: f.updated_by, 130 + updatedAt: f.updated_at, 131 + }, 132 + ]), 133 + ), 44 134 } 45 - const cookie = (loginRes.headers.get('set-cookie') ?? '').split(';')[0] 46 - if (!cookie.startsWith('cs=')) { 47 - console.error('no session cookie in login response') 48 - process.exit(1) 135 + 136 + const doc = Automerge.from(initial) 137 + const docBytes = Automerge.save(doc) 138 + const projectKey = new Uint8Array(randomBytes(32)) 139 + 140 + console.log(` Automerge doc: ${docBytes.length} bytes`) 141 + 142 + // --- encode + open ---------------------------------------------------------- 143 + 144 + function b64u(bytes) { 145 + return Buffer.from(bytes).toString('base64url') 49 146 } 50 147 51 - const bytes = fs.readFileSync(src) 52 - console.log(`uploading ${src} (${(bytes.length / 1024).toFixed(1)} KB)`) 53 - const res = await fetch(`${BASE}/api/projects/import`, { 54 - method: 'POST', 55 - headers: { 'Content-Type': 'application/json', Cookie: cookie }, 56 - body: JSON.stringify({ sqliteBase64: bytes.toString('base64') }), 57 - }) 58 - const body = await res.json().catch(() => ({})) 59 - if (!res.ok) { 60 - console.error(`import failed: ${res.status} ${JSON.stringify(body)}`) 61 - process.exit(1) 148 + const payload = { 149 + name: projectName, 150 + projectKey: b64u(projectKey), 151 + doc: b64u(docBytes), 152 + } 153 + const fragment = Buffer.from(JSON.stringify(payload)).toString('base64url') 154 + 155 + const url = `${BASE}/?email=${encodeURIComponent(email)}#import-project=${fragment}` 156 + 157 + console.log(` URL fragment size: ${fragment.length} bytes`) 158 + if (fragment.length > 100_000) { 159 + console.warn( 160 + ' WARNING: URL fragment is over 100KB; some browsers may reject it. Safari is the tightest.', 161 + ) 162 + } 163 + 164 + console.log('') 165 + console.log('Opening browser to import project…') 166 + console.log(url.length > 500 ? `${url.slice(0, 500)}… (${url.length} chars)` : url) 167 + console.log('') 168 + console.log('Steps in the page that opens:') 169 + console.log(' 1. Sign in with your passkey (or create account if new device)') 170 + console.log(' 2. Project should appear in the project list immediately') 171 + 172 + try { 173 + execFileSync('open', [url]) 174 + } catch { 175 + console.log('(Could not auto-open; paste the URL above into your browser.)') 62 176 } 63 - console.log(`✓ imported as ${body.project?.name} (id=${body.project?.id})`) 64 - console.log(` open: ${BASE}`)
+47
src/App.tsx
··· 26 26 } from './lib/crypto' 27 27 import { OutboxClient } from './lib/outbox' 28 28 import { ProjectStore } from './lib/store' 29 + import { saveDoc as persistDoc } from './lib/persistence' 30 + import { PROJECT_IMPORT_STASH_KEY } from './main' 29 31 import { 30 32 loadKeys, 31 33 saveKeys, ··· 147 149 .then(setProviderKeys) 148 150 .catch((err) => console.warn('[keys] load failed:', err)) 149 151 }, [phase]) 152 + 153 + // Consume any pending project-import payload stashed by main.tsx. 154 + // The script-side build of v1-data → Automerge → URL fragment is in 155 + // scripts/push-project.mjs. We can only do the server POST + wrap 156 + // here, where PRF is in scope. 157 + useEffect(() => { 158 + if (phase !== 'ready' || !prf) return 159 + const raw = sessionStorage.getItem(PROJECT_IMPORT_STASH_KEY) 160 + if (!raw) return 161 + sessionStorage.removeItem(PROJECT_IMPORT_STASH_KEY) 162 + ;(async () => { 163 + try { 164 + const padded = raw + '='.repeat((4 - (raw.length % 4)) % 4) 165 + const json = atob( 166 + padded.replaceAll('-', '+').replaceAll('_', '/'), 167 + ) 168 + const payload = JSON.parse(json) as { 169 + name: string 170 + projectKey: string // base64url, 32 bytes raw K_proj 171 + doc: string // base64url Automerge.save bytes 172 + } 173 + const projectKeyRaw = b64uDecode(payload.projectKey) 174 + const docBytes = b64uDecode(payload.doc) 175 + // Wrap K_proj for ourselves via PRF-derived AES key, same as 176 + // a fresh project would. 177 + const wrapKey = await deriveWrapKey(prf) 178 + const wrapped = await wrapProjectKey(wrapKey, projectKeyRaw) 179 + const project = await projectsApi.create(payload.name, wrapped) 180 + // Drop the imported Automerge state into IndexedDB under the 181 + // server-assigned project id. Next time the user opens the 182 + // project, the store loads from here. 183 + await persistDoc(project.id, docBytes) 184 + console.log( 185 + `[import-project] imported "${payload.name}" as ${project.id}`, 186 + ) 187 + } catch (err) { 188 + console.warn('[import-project] failed:', err) 189 + window.alert( 190 + `Project import failed: ${ 191 + err instanceof Error ? err.message : String(err) 192 + }`, 193 + ) 194 + } 195 + })() 196 + }, [phase, prf]) 150 197 151 198 const finishAuth = useCallback( 152 199 async (id: Identity, p: PrfSecret) => {
+42 -14
src/main.tsx
··· 3 3 import { App, ErrorBoundary } from './App' 4 4 import { saveKeys, loadKeys, type ProviderKeys } from './lib/keystore' 5 5 6 - // One-shot key import via URL fragment. The import-keychain script 7 - // builds a URL of the form `hezo.app/#import-keys=<base64url-json>`, 8 - // where the JSON is a partial ProviderKeys object. We merge into the 9 - // existing per-device keystore so a repeat run doesn't clobber other 10 - // providers, then scrub the fragment so the key isn't sitting in the 11 - // URL bar or history. Done before App mounts so even an unauthenticated 12 - // visit (about to register) gets the key staged. 6 + // One-shot URL-fragment handlers. The fragment never traverses the 7 + // network, so it's a safe channel for key material + content that 8 + // shouldn't end up in our server logs. Both handlers run before App 9 + // mounts, so the rest of the app sees the persisted state directly. 10 + 13 11 async function handleImportKeys(): Promise<void> { 14 12 const hash = window.location.hash 15 13 const prefix = '#import-keys=' ··· 24 22 } catch (err) { 25 23 console.warn('[import-keys] failed:', err) 26 24 } finally { 27 - history.replaceState( 28 - {}, 29 - '', 30 - window.location.pathname + window.location.search, 31 - ) 25 + scrubHash() 32 26 } 33 27 } 34 28 35 - void handleImportKeys().then(() => { 29 + // Project migration from v1. Stashes the encoded payload in 30 + // sessionStorage and lets App consume it post-auth (needs PRF to wrap 31 + // K_proj before POSTing to /api/projects). Stash key is a const that 32 + // App imports — see PROJECT_IMPORT_STASH_KEY below. 33 + export const PROJECT_IMPORT_STASH_KEY = 'hz.pendingProjectImport' 34 + 35 + function handleImportProject(): void { 36 + const hash = window.location.hash 37 + const prefix = '#import-project=' 38 + if (!hash.startsWith(prefix)) return 39 + const raw = hash.slice(prefix.length) 40 + // Don't decode here — just stash for App to consume after auth. 41 + // The payload contains the project key in plaintext; keep it in 42 + // sessionStorage (cleared on tab close) rather than localStorage. 43 + try { 44 + sessionStorage.setItem(PROJECT_IMPORT_STASH_KEY, raw) 45 + } catch (err) { 46 + console.warn('[import-project] could not stash:', err) 47 + } 48 + scrubHash() 49 + } 50 + 51 + function scrubHash(): void { 52 + history.replaceState( 53 + {}, 54 + '', 55 + window.location.pathname + window.location.search, 56 + ) 57 + } 58 + 59 + async function boot(): Promise<void> { 60 + await handleImportKeys() 61 + handleImportProject() 36 62 createRoot(document.getElementById('root')!).render( 37 63 <React.StrictMode> 38 64 <ErrorBoundary> ··· 40 66 </ErrorBoundary> 41 67 </React.StrictMode>, 42 68 ) 43 - }) 69 + } 70 + 71 + void boot()