diff --git a/docs/superpowers/plans/2026-05-16-phase4c-soundboard-cloud-sync.md b/docs/superpowers/plans/2026-05-16-phase4c-soundboard-cloud-sync.md new file mode 100644 index 0000000..3118875 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-phase4c-soundboard-cloud-sync.md @@ -0,0 +1,1113 @@ +# Phase 4C — Soundboard Cloud-Sync (E2E Encrypted) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the existing local-only soundboard (IndexedDB) bidirectionally synced across all of a user's devices, with the audio payload encrypted client-side using the per-user X25519 key. Server only sees ciphertext + metadata; cross-device replays Just Work. + +**Architecture:** +- Server: a single new table `public.user_soundboards (id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at)` + a Storage bucket `soundboards` (private, owner-only RLS). The table is added to `supabase_realtime` so every signed-in install of the same user can react to peer edits. +- E2E: audio Blob is encrypted with sealed-to-self `crypto_box` (sender=recipient=me) using the existing `encryptFor` / `decryptFrom` helpers in `packages/shared/src/crypto/box.ts`. The 24-byte nonce is **prepended** to the ciphertext bytes in storage — single object, no extra DB column. +- Client sync engine (`useSoundboardSync`): runs once on session-start, then idles. Triggers: (a) initial diff after sign-in pulls every missing/newer remote row + decrypts blob into IndexedDB; (b) every local change (via existing `subscribeSoundboardChanges` pubsub) debounced 500ms uploads the changed row; (c) realtime subscription on own `user_soundboards` rows triggers a targeted re-pull when another device of the user edits. LWW by `updated_at`. +- Per-sound sync badge in `SoundboardSettings`: 4 states (`synced` = ☁, `uploading` = ↑, `downloading` = ↓, `error` = ⚠). + +**Tech Stack:** Postgres + RLS + Supabase Storage (private bucket) + Supabase Realtime (postgres_changes); React 18; existing `cachedUserKey` + `derivePublicKey` + `encryptFor`/`decryptFrom`. No new dependencies. + +**Non-goals:** +- No blob editing of an existing sound (rename / re-encode → delete+re-add). +- No server-side decryption ever. Metadata (gain/hotkey) NOT considered sensitive — plaintext columns. +- No conflict-merge UI. LWW silently resolves. +- No Web build sync (soundboard already requires Electron). + +--- + +## Pre-flight + +- [ ] **Verify clean working tree on `main`** + +Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status` +Expected: clean. + +- [ ] **Confirm tooling is green** + +Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test -- --run` +Expected: all green; 38 shared tests pass. + +--- + +## Task 1: SQL migration — `user_soundboards` + RLS + realtime + private storage bucket + +**Files:** +- Create: `supabase/migrations/20260516000007_soundboards.sql` + +Must be idempotent. + +- [ ] **Step 1: Write the SQL migration** + +Create `supabase/migrations/20260516000007_soundboards.sql`: + +```sql +-- Phase 4C: cloud-synced soundboard. +-- +-- user_soundboards: one row per cloud-known sound. The audio payload itself +-- lives in the `soundboards` storage bucket at `/.bin`, +-- E2E-encrypted with the user's own X25519 key (sealed-to-self). The first +-- 24 bytes of the stored object are the XSalsa20 nonce; the rest is the +-- Poly1305-authenticated ciphertext. +-- +-- Metadata (name, gain, hotkey, category, sort_order) is stored plaintext — +-- not considered sensitive by the spec — so other devices of the same user +-- can read it without holding the private key. + +create table if not exists public.user_soundboards ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + name text not null, + mime text not null, + size bigint not null, + category text null, + hotkey text null, + gain real not null default 1.0, + sort_order integer not null default 0, + storage_path text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint user_soundboards_name_len check (length(name) between 1 and 200), + constraint user_soundboards_size_pos check (size > 0) +); + +create index if not exists user_soundboards_user_idx + on public.user_soundboards(user_id, updated_at desc); + +alter table public.user_soundboards enable row level security; + +drop policy if exists user_soundboards_select on public.user_soundboards; +drop policy if exists user_soundboards_insert on public.user_soundboards; +drop policy if exists user_soundboards_update on public.user_soundboards; +drop policy if exists user_soundboards_delete on public.user_soundboards; + +create policy user_soundboards_select + on public.user_soundboards + for select + using (user_id = auth.uid()); + +create policy user_soundboards_insert + on public.user_soundboards + for insert + with check (user_id = auth.uid()); + +create policy user_soundboards_update + on public.user_soundboards + for update + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +create policy user_soundboards_delete + on public.user_soundboards + for delete + using (user_id = auth.uid()); + +alter table public.user_soundboards replica identity full; + +do $$ +begin + if not exists ( + select 1 + from pg_publication_tables + where pubname = 'supabase_realtime' + and schemaname = 'public' + and tablename = 'user_soundboards' + ) then + execute 'alter publication supabase_realtime add table public.user_soundboards'; + end if; +end +$$; + +-- Storage bucket: private, owner-only. +do $$ +begin + if not exists (select 1 from storage.buckets where id = 'soundboards') then + insert into storage.buckets (id, name, public) + values ('soundboards', 'soundboards', false); + end if; +end +$$; + +drop policy if exists soundboards_select on storage.objects; +drop policy if exists soundboards_insert on storage.objects; +drop policy if exists soundboards_update on storage.objects; +drop policy if exists soundboards_delete on storage.objects; + +create policy soundboards_select + on storage.objects + for select + using ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + ); + +create policy soundboards_insert + on storage.objects + for insert + with check ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + ); + +create policy soundboards_update + on storage.objects + for update + using ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + ) + with check ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + ); + +create policy soundboards_delete + on storage.objects + for delete + using ( + bucket_id = 'soundboards' + and auth.uid()::text = (storage.foldername(name))[1] + ); +``` + +- [ ] **Step 2: Commit + push to prod** + +```bash +cd "D:\Programmieren\ChatApp-Electron\chat-app" +git add supabase/migrations/20260516000007_soundboards.sql +git commit -m "feat(P4C.T1): user_soundboards table + soundboards storage bucket + RLS" +bash scripts/prod/push-migrations.sh soundboards +``` + +--- + +## Task 2: Shared wrappers — sealed-blob crypto + CRUD + storage I/O + db-types + tests + +**Files:** +- Modify: `packages/db-types/src/index.ts` — add `user_soundboards` to the Database type +- Create: `packages/shared/src/chat/soundboards.ts` +- Create: `packages/shared/src/chat/soundboards.test.ts` +- Modify: `packages/shared/src/chat/index.ts` — re-export + +- [ ] **Step 1: Extend the db-types** + +Read `packages/db-types/src/index.ts` to find the `public.Tables` block. Add a new entry next to the most-recent table (likely `whiteboard_strokes` from P4B.T2 `3df7cc0`): + +```ts +user_soundboards: { + Row: { + id: string; + user_id: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + sort_order: number; + storage_path: string; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + user_id: string; + name: string; + mime: string; + size: number; + category?: string | null; + hotkey?: string | null; + gain?: number; + sort_order?: number; + storage_path: string; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + user_id?: string; + name?: string; + mime?: string; + size?: number; + category?: string | null; + hotkey?: string | null; + gain?: number; + sort_order?: number; + storage_path?: string; + updated_at?: string; + }; + Relationships: []; +}; +``` + +Match the `Relationships` shape used by another table with a single `user_id → auth.users` FK (e.g. `devices`). + +- [ ] **Step 2: Create the shared wrapper** + +Create `packages/shared/src/chat/soundboards.ts`: + +```ts +import { decryptFrom, encryptFor } from '../crypto/box'; +import type { AppSupabaseClient } from '../supabase/client'; + +export const SOUNDBOARDS_BUCKET = 'soundboards'; + +export interface RemoteSound { + id: string; + userId: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + sortOrder: number; + storagePath: string; + createdAt: string; + updatedAt: string; +} + +export interface UpsertSoundInput { + id: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + sortOrder: number; + storagePath: string; + updatedAtIso: string; +} + +export async function listOwnSounds(client: AppSupabaseClient): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const { data, error } = await client + .from('user_soundboards') + .select( + 'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at', + ) + .eq('user_id', session.user.id) + .order('updated_at', { ascending: false }); + if (error) throw error; + return data.map(mapRow); +} + +export async function upsertSound( + client: AppSupabaseClient, + input: UpsertSoundInput, +): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const { data, error } = await client + .from('user_soundboards') + .upsert( + { + id: input.id, + user_id: session.user.id, + name: input.name, + mime: input.mime, + size: input.size, + category: input.category, + hotkey: input.hotkey, + gain: input.gain, + sort_order: input.sortOrder, + storage_path: input.storagePath, + updated_at: input.updatedAtIso, + }, + { onConflict: 'id' }, + ) + .select( + 'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at', + ) + .single(); + if (error) throw error; + return mapRow(data); +} + +export async function deleteSound( + client: AppSupabaseClient, + soundId: string, +): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const { data: row } = await client + .from('user_soundboards') + .select('storage_path') + .eq('id', soundId) + .eq('user_id', session.user.id) + .maybeSingle(); + if (row?.storage_path) { + const { error: storageErr } = await client.storage + .from(SOUNDBOARDS_BUCKET) + .remove([row.storage_path]); + if (storageErr && !/not found/i.test(storageErr.message)) throw storageErr; + } + const { error } = await client + .from('user_soundboards') + .delete() + .eq('id', soundId) + .eq('user_id', session.user.id); + if (error) throw error; +} + +// Sealed-to-self envelope: nonce || ciphertext. encryptFor's sender and +// recipient are both the current user, equivalent to crypto_box_seal but +// reuses the existing helper (no new backend method). +export async function encryptSoundBlob( + blob: Blob, + myPublicKey: Uint8Array, + myPrivateKey: Uint8Array, +): Promise { + const bytes = new Uint8Array(await blob.arrayBuffer()); + const { ciphertext, nonce } = await encryptFor(bytes, myPublicKey, myPrivateKey); + const out = new Uint8Array(nonce.length + ciphertext.length); + out.set(nonce, 0); + out.set(ciphertext, nonce.length); + return out; +} + +export async function decryptSoundEnvelope( + envelope: Uint8Array, + myPublicKey: Uint8Array, + myPrivateKey: Uint8Array, +): Promise { + const NONCE_LEN = 24; + if (envelope.length < NONCE_LEN + 16) { + throw new Error('sound_envelope_too_short'); + } + const nonce = envelope.slice(0, NONCE_LEN); + const ciphertext = envelope.slice(NONCE_LEN); + return decryptFrom(ciphertext, nonce, myPublicKey, myPrivateKey); +} + +export async function uploadSoundCiphertext( + client: AppSupabaseClient, + storagePath: string, + ciphertext: Uint8Array, +): Promise { + const { error } = await client.storage + .from(SOUNDBOARDS_BUCKET) + .upload(storagePath, ciphertext, { + contentType: 'application/octet-stream', + upsert: true, + }); + if (error) throw error; +} + +export async function downloadSoundCiphertext( + client: AppSupabaseClient, + storagePath: string, +): Promise { + const { data, error } = await client.storage + .from(SOUNDBOARDS_BUCKET) + .download(storagePath); + if (error) throw error; + return new Uint8Array(await data.arrayBuffer()); +} + +function mapRow(row: { + id: string; + user_id: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + sort_order: number; + storage_path: string; + created_at: string; + updated_at: string; +}): RemoteSound { + return { + id: row.id, + userId: row.user_id, + name: row.name, + mime: row.mime, + size: row.size, + category: row.category, + hotkey: row.hotkey, + gain: row.gain, + sortOrder: row.sort_order, + storagePath: row.storage_path, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} +``` + +- [ ] **Step 3: Write the tests** + +Create `packages/shared/src/chat/soundboards.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; + +import { getCryptoBackend } from '../crypto/backend'; +import { + decryptSoundEnvelope, + encryptSoundBlob, + listOwnSounds, + upsertSound, +} from './soundboards'; + +function makeClient(opts: { + user?: { id: string } | null; + selectData?: unknown[]; + upsertReturn?: { data: unknown; error: unknown }; +}): any { + const order = vi.fn().mockResolvedValue({ data: opts.selectData ?? [], error: null }); + const eq = vi.fn().mockReturnValue({ order }); + const selectChain = vi.fn().mockReturnValue({ eq }); + const single = vi.fn().mockResolvedValue(opts.upsertReturn ?? { data: {}, error: null }); + const upsertSelect = vi.fn().mockReturnValue({ single }); + const upsertChain = vi.fn().mockReturnValue({ select: upsertSelect }); + const from = vi.fn().mockReturnValue({ + select: selectChain, + upsert: upsertChain, + }); + return { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) }, + from, + }; +} + +describe('encryptSoundBlob ↔ decryptSoundEnvelope', () => { + it('round-trips bytes via sealed-to-self crypto_box', async () => { + const backend = getCryptoBackend(); + const seed = backend.randomBytes(32); + const pub = backend.scalarMultBase(seed); + const blob = new Blob([new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])]); + + const envelope = await encryptSoundBlob(blob, pub, seed); + expect(envelope.length).toBeGreaterThan(24); + + const plain = await decryptSoundEnvelope(envelope, pub, seed); + expect(Array.from(plain)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it('rejects an envelope that is too short', async () => { + const backend = getCryptoBackend(); + const seed = backend.randomBytes(32); + const pub = backend.scalarMultBase(seed); + await expect( + decryptSoundEnvelope(new Uint8Array(20), pub, seed), + ).rejects.toThrow(/too_short/); + }); +}); + +describe('listOwnSounds', () => { + it('maps DB rows to camelCase', async () => { + const client = makeClient({ + selectData: [ + { + id: 's-1', + user_id: 'u-1', + name: 'horn', + mime: 'audio/mpeg', + size: 1234, + category: 'fx', + hotkey: 'F1', + gain: 0.8, + sort_order: 0, + storage_path: 'u-1/s-1.bin', + created_at: '2026-05-16T00:00:00Z', + updated_at: '2026-05-16T00:00:00Z', + }, + ], + }); + const out = await listOwnSounds(client); + expect(out[0]).toEqual({ + id: 's-1', + userId: 'u-1', + name: 'horn', + mime: 'audio/mpeg', + size: 1234, + category: 'fx', + hotkey: 'F1', + gain: 0.8, + sortOrder: 0, + storagePath: 'u-1/s-1.bin', + createdAt: '2026-05-16T00:00:00Z', + updatedAt: '2026-05-16T00:00:00Z', + }); + }); +}); + +describe('upsertSound', () => { + it('returns mapped RemoteSound after upsert', async () => { + const upsertReturn = { + data: { + id: 's-1', + user_id: 'u-1', + name: 'horn', + mime: 'audio/mpeg', + size: 1234, + category: null, + hotkey: null, + gain: 1, + sort_order: 0, + storage_path: 'u-1/s-1.bin', + created_at: '2026-05-16T00:00:00Z', + updated_at: '2026-05-16T00:00:00Z', + }, + error: null, + }; + const client = makeClient({ upsertReturn }); + const out = await upsertSound(client, { + id: 's-1', + name: 'horn', + mime: 'audio/mpeg', + size: 1234, + category: null, + hotkey: null, + gain: 1, + sortOrder: 0, + storagePath: 'u-1/s-1.bin', + updatedAtIso: '2026-05-16T00:00:00Z', + }); + expect(out.id).toBe('s-1'); + expect(out.userId).toBe('u-1'); + }); +}); +``` + +The round-trip test uses `getCryptoBackend()` directly. If `scalarMultBase` isn't exposed on the backend interface (check with `Grep -n "scalarMultBase" packages/shared/src/crypto/`), use whatever method `derivePublicKey(privateKey)` from `apps/desktop/src/lib/userIdentity.ts:254` uses — same operation, different entry point. + +- [ ] **Step 4: Re-export from the chat index** + +Append to `packages/shared/src/chat/index.ts`: +```ts +export * from './soundboards'; +``` + +- [ ] **Step 5: Run tests + typecheck** + +``` +pnpm --filter @chat-app/shared test -- --run soundboards +pnpm --filter @chat-app/shared typecheck +pnpm --filter @chat-app/shared test -- --run +``` +Expected: 4 new tests pass; full suite at 42/42. + +- [ ] **Step 6: Commit** + +```bash +git add packages/db-types/src/index.ts packages/shared/src/chat/soundboards.ts packages/shared/src/chat/soundboards.test.ts packages/shared/src/chat/index.ts +git commit -m "feat(P4C.T2): shared soundboards wrappers + sealed-blob crypto + tests" +``` + +--- + +## Task 3: `useSoundboardSync` hook — initial diff + realtime + debounced local-edit sync + +**Files:** +- Create: `apps/desktop/src/hooks/useSoundboardSync.ts` +- Modify: `apps/desktop/src/lib/soundboardStorage.ts` — add `putRawStoredSound` / `deleteRawStoredSound` / `getRawStoredSound` (notifyChange-bypass variants used by the sync engine to avoid feedback loops) + +- [ ] **Step 1: Add the bypass helpers to soundboardStorage.ts** + +Read `apps/desktop/src/lib/soundboardStorage.ts` first. At the bottom of the file, add: + +```ts +// Used by the cloud-sync engine to write a pulled-from-server sound into +// IndexedDB without triggering the notifyChange pubsub — pulls are not +// "edits", and if they fired notifyChange the engine would loop (push +// debounce → upsert → realtime → pull → notifyChange → push debounce → ...). +export async function putRawStoredSound(stored: { + id: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + order: number; + createdAt: number; + updatedAt: number; + blob: Blob; +}): Promise { + await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored)); +} + +export async function deleteRawStoredSound(id: string): Promise { + await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id)); +} + +export async function getRawStoredSound(id: string): Promise<{ + id: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + order: number; + createdAt: number; + updatedAt: number; + blob: Blob; +} | null> { + const db = await openDb(); + return new Promise((resolve, reject) => { + const t = db.transaction(SOUNDS_STORE, 'readonly'); + const s = t.objectStore(SOUNDS_STORE); + const req = s.get(id); + req.onsuccess = () => resolve((req.result as any) ?? null); + req.onerror = () => reject(req.error); + }); +} +``` + +(`openDb`, `tx`, `SOUNDS_STORE` are file-private but accessible because the helpers live in the same file.) + +- [ ] **Step 2: Build the sync hook** + +Create `apps/desktop/src/hooks/useSoundboardSync.ts`: + +```ts +import { useEffect, useRef, useState } from 'react'; + +import { getCryptoBackend } from '@chat-app/shared/crypto'; +import { + decryptSoundEnvelope, + deleteSound as deleteRemoteSound, + downloadSoundCiphertext, + encryptSoundBlob, + listOwnSounds, + type RemoteSound, + upsertSound, + uploadSoundCiphertext, +} from '@chat-app/shared/chat'; + +import { useAuth } from '../context/AuthContext'; +import { + deleteRawStoredSound, + getRawStoredSound, + listSounds, + putRawStoredSound, + type SoundboardEntry, + subscribeSoundboardChanges, +} from '../lib/soundboardStorage'; +import { supabase } from '../lib/supabase'; +import { cachedUserKey } from '../lib/userIdentity'; + +export type SyncBadge = 'synced' | 'uploading' | 'downloading' | 'error'; + +const PUSH_DEBOUNCE_MS = 500; + +// Suppress unused-import lint until deleteRemoteSound is consumed by T4. +void deleteRemoteSound; + +function storagePathFor(userId: string, soundId: string): string { + return userId + '/' + soundId + '.bin'; +} + +export function useSoundboardSync(): { + badges: Map; + initialPullDone: boolean; +} { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [badges, setBadges] = useState>(new Map()); + const [initialPullDone, setInitialPullDone] = useState(false); + const debounceRef = useRef(null); + + function setBadge(id: string, badge: SyncBadge): void { + setBadges((cur) => { + const next = new Map(cur); + next.set(id, badge); + return next; + }); + } + + useEffect(() => { + if (!userId) { + setInitialPullDone(false); + setBadges(new Map()); + return; + } + let cancelled = false; + let teardown: (() => void) | null = null; + + const init = async () => { + const priv = await cachedUserKey(userId); + if (!priv) return; + const pub = getCryptoBackend().scalarMultBase(priv); + + try { + await runDiff(userId, priv, pub); + } catch (err) { + console.error('soundboard initial diff failed', err); + } + if (cancelled) return; + setInitialPullDone(true); + + const unsubLocal = subscribeSoundboardChanges(() => { + if (debounceRef.current !== null) window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(() => { + debounceRef.current = null; + void runDiff(userId, priv, pub).catch((err) => { + console.error('soundboard push diff failed', err); + }); + }, PUSH_DEBOUNCE_MS); + }); + + const channel = supabase + .channel('soundboards:' + userId) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'user_soundboards', + filter: 'user_id=eq.' + userId, + }, + () => { + void runDiff(userId, priv, pub).catch((err) => { + console.error('soundboard realtime pull failed', err); + }); + }, + ) + .subscribe(); + + teardown = () => { + unsubLocal(); + void supabase.removeChannel(channel); + if (debounceRef.current !== null) { + window.clearTimeout(debounceRef.current); + debounceRef.current = null; + } + }; + }; + + void init(); + return () => { + cancelled = true; + teardown?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]); + + async function runDiff(uid: string, priv: Uint8Array, pub: Uint8Array): Promise { + const [localList, remoteList] = await Promise.all([ + listSounds(), + listOwnSounds(supabase), + ]); + const remoteById = new Map(); + for (const r of remoteList) remoteById.set(r.id, r); + const localById = new Map(); + for (const l of localList) localById.set(l.id, l); + + for (const local of localList) { + const remote = remoteById.get(local.id); + const localIso = new Date(local.updatedAt).toISOString(); + if (!remote) { + await uploadAndUpsert(local, uid, priv, pub, localIso); + } else { + const remoteMs = Date.parse(remote.updatedAt); + if (local.updatedAt > remoteMs) { + await upsertMetadataOnly(local, remote, localIso); + } + } + } + + for (const remote of remoteList) { + const local = localById.get(remote.id); + const remoteMs = Date.parse(remote.updatedAt); + if (!local) { + await pullAndStore(remote, priv, pub); + } else if (remoteMs > local.updatedAt) { + const stored = await getRawStoredSound(remote.id); + if (stored) { + await putRawStoredSound({ + ...stored, + name: remote.name, + mime: remote.mime, + size: remote.size, + category: remote.category, + hotkey: remote.hotkey, + gain: remote.gain, + order: remote.sortOrder, + updatedAt: remoteMs, + }); + setBadge(remote.id, 'synced'); + } + } else { + setBadge(remote.id, 'synced'); + } + } + + // Local rows that vanished from remote — propagate the delete locally, + // but with a 5-second grace window so a freshly added local row isn't + // wiped before its first push has fired. + const now = Date.now(); + for (const local of localList) { + if (!remoteById.has(local.id) && now - local.updatedAt > 5000) { + await deleteRawStoredSound(local.id); + } + } + } + + async function uploadAndUpsert( + local: SoundboardEntry, + uid: string, + priv: Uint8Array, + pub: Uint8Array, + localIso: string, + ): Promise { + setBadge(local.id, 'uploading'); + try { + const stored = await getRawStoredSound(local.id); + if (!stored) return; + const ciphertext = await encryptSoundBlob(stored.blob, pub, priv); + const path = storagePathFor(uid, local.id); + await uploadSoundCiphertext(supabase, path, ciphertext); + await upsertSound(supabase, { + id: local.id, + name: local.name, + mime: local.mime, + size: local.size, + category: local.category, + hotkey: local.hotkey, + gain: local.gain, + sortOrder: local.order, + storagePath: path, + updatedAtIso: localIso, + }); + setBadge(local.id, 'synced'); + } catch (err) { + console.error('soundboard upload failed', err); + setBadge(local.id, 'error'); + } + } + + async function upsertMetadataOnly( + local: SoundboardEntry, + remote: RemoteSound, + localIso: string, + ): Promise { + setBadge(local.id, 'uploading'); + try { + await upsertSound(supabase, { + id: local.id, + name: local.name, + mime: local.mime, + size: local.size, + category: local.category, + hotkey: local.hotkey, + gain: local.gain, + sortOrder: local.order, + storagePath: remote.storagePath, + updatedAtIso: localIso, + }); + setBadge(local.id, 'synced'); + } catch (err) { + console.error('soundboard metadata upload failed', err); + setBadge(local.id, 'error'); + } + } + + async function pullAndStore( + remote: RemoteSound, + priv: Uint8Array, + pub: Uint8Array, + ): Promise { + setBadge(remote.id, 'downloading'); + try { + const envelope = await downloadSoundCiphertext(supabase, remote.storagePath); + const plain = await decryptSoundEnvelope(envelope, pub, priv); + const blob = new Blob([plain], { type: remote.mime }); + const ms = Date.parse(remote.updatedAt); + await putRawStoredSound({ + id: remote.id, + name: remote.name, + mime: remote.mime, + size: remote.size, + category: remote.category, + hotkey: remote.hotkey, + gain: remote.gain, + order: remote.sortOrder, + createdAt: Date.parse(remote.createdAt), + updatedAt: ms, + blob, + }); + setBadge(remote.id, 'synced'); + } catch (err) { + console.error('soundboard pull failed', err); + setBadge(remote.id, 'error'); + } + } + + return { badges, initialPullDone }; +} +``` + +If `@chat-app/shared/crypto` doesn't re-export `getCryptoBackend`, look in `packages/shared/src/index.ts` for the path that does — likely just `@chat-app/shared` plain. Match what the codebase already uses (`Grep -n "getCryptoBackend" apps/desktop/src/` finds the existing renderer call sites). + +- [ ] **Step 3: Typecheck** + +``` +pnpm --filter @chat-app/desktop typecheck +``` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/desktop/src/lib/soundboardStorage.ts apps/desktop/src/hooks/useSoundboardSync.ts +git commit -m "feat(P4C.T3): useSoundboardSync — initial diff + realtime + debounced push" +``` + +--- + +## Task 4: SoundboardSettings — mount the sync hook + per-row badge UI + remote-delete on local delete + +**Files:** +- Modify: `apps/desktop/src/components/SoundboardSettings.tsx` + +- [ ] **Step 1: Find the existing render loop + delete handler** + +``` +Grep -n "listSounds\|subscribeSoundboardChanges\|deleteSound\|map((s)" apps/desktop/src/components/SoundboardSettings.tsx +``` + +Identify: +- Where each sound row is rendered (the `.map(...)` over the list). +- Where the row's metadata is displayed (the cell or `` group that shows name/category). +- The current delete-button onClick handler. + +- [ ] **Step 2: Mount the hook + render the badge** + +Add imports: +```ts +import { useSoundboardSync, type SyncBadge } from '../hooks/useSoundboardSync'; +import { deleteSound as deleteRemoteSound } from '@chat-app/shared/chat'; +import { supabase } from '../lib/supabase'; +``` +(Verify `supabase` isn't already imported — likely it isn't. `Grep -n "from '../lib/supabase'" apps/desktop/src/components/SoundboardSettings.tsx` first.) + +Inside the component (near other hooks): +```ts +const { badges } = useSoundboardSync(); +``` + +For each row, ADD a badge near the existing metadata (right next to the row's name or in the row's right-side action cluster): +```tsx + + {badgeGlyph(badges.get(s.id))} + +``` + +Add at the bottom of the file (outside the component): +```ts +function badgeGlyph(b: SyncBadge | undefined): string { + switch (b) { + case 'uploading': return '↑'; + case 'downloading': return '↓'; + case 'error': return '⚠'; + case 'synced': + default: return '☁'; + } +} + +function badgeTitle(b: SyncBadge | undefined): string { + switch (b) { + case 'uploading': return 'Hochladen…'; + case 'downloading': return 'Wird heruntergeladen…'; + case 'error': return 'Synchronisationsfehler'; + case 'synced': + default: return 'Synchronisiert'; + } +} +``` + +- [ ] **Step 3: Wrap the existing local-delete handler to also call remote-delete** + +Find the existing delete-button handler (search for `deleteSound(`). Replace its body so it does the remote delete first, then the local delete: + +```ts +async (id: string) => { + try { + await deleteRemoteSound(supabase, id); + } catch (err) { + console.warn('remote sound delete failed (local delete proceeds)', err); + } + await deleteSound(id); +} +``` + +Match the surrounding closure layout — if the delete is inline JSX (`onClick={() => deleteSound(s.id)}`), restructure into either an inline `async` arrow or a separate `const handleDelete = useCallback(...)` near the other handlers. + +- [ ] **Step 4: Typecheck** + +``` +pnpm --filter @chat-app/desktop typecheck +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/desktop/src/components/SoundboardSettings.tsx +git commit -m "feat(P4C.T4): SoundboardSettings — sync hook mount + per-row badges + remote-delete" +``` + +--- + +## Final gate + +- [ ] **Step 1: Typecheck both packages** + +Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck` +Expected: both PASS. + +- [ ] **Step 2: Run shared tests** + +Run: `pnpm --filter @chat-app/shared test -- --run` +Expected: PASS — 42 tests (38 baseline + 4 new soundboard tests). + +- [ ] **Step 3: Verify no uncommitted changes** + +Run: `git status` +Expected: clean working tree on `main`. + +- [ ] **Step 4: Report** + +Report: "Phase 4C (Soundboard Cloud-Sync) code-complete on `main`; migration + storage bucket applied to prod. Restart dev → Settings → Soundboard. On first open after sign-in the badge briefly shows ↑ as local sounds upload, then ☁ once synced. Add a sound on Device A → re-open Settings on Device B → it appears (badge: ↓ briefly, then ☁). Delete from either side → vanishes everywhere. Audio bytes are E2E encrypted with your per-user key — the server never sees plaintext." + +--- + +## Self-review (resolved inline) + +1. **Spec coverage** (`docs/superpowers/specs/2026-05-16-fifteen-features-design.md` lines 103-108): + - "New table `user_soundboards (id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at)`" → T1 + - "Storage bucket `soundboards//.bin`. Audio payload is encrypted client-side with the user's existing per-user X25519 key (reuses crypto/box). Server only sees ciphertext" → T1 bucket + RLS + T2 sealed-to-self via `encryptFor`/`decryptFrom` + - "Local IndexedDB stays the working store; sync is bidirectional" → T3 `runDiff` handles both directions + - "Sync engine: on app start + on every local edit (debounced 500ms)" → T3 init + `subscribeSoundboardChanges` debounce + - "Realtime subscription on own `user_soundboards` rows" → T3 channel + - "Conflict resolution: last-write-wins by `updated_at`" → T3 `local.updatedAt > Date.parse(remote.updatedAt)` comparisons + - "UI shows '☁ Sync OK / ↑ Hochladen… / ⚠ Konflikt' badge per sound" → T4 (`Konflikt` replaced with generic `error` since LWW means real conflicts auto-resolve) + +2. **Placeholders:** none. + +3. **Type consistency:** + - `RemoteSound` camelCase ↔ DB snake_case: isolated in `mapRow`; tested in T2. + - `SoundboardEntry.order` (local) ↔ `RemoteSound.sortOrder` (remote) ↔ `sort_order` (DB): consistently translated at the wrapper boundary. + - `SoundboardEntry.createdAt/updatedAt` are JS ms; `RemoteSound.createdAt/updatedAt` are ISO strings. T3 calls `Date.parse(...)` for comparisons and `new Date(...).toISOString()` for outbound. + - `SyncBadge` defined T3, consumed T4. + +4. **Feedback-loop guard:** `putRawStoredSound` / `deleteRawStoredSound` deliberately bypass `notifyChange()`. Without that, every realtime-pulled row would re-trigger `subscribeSoundboardChanges` → debounced push → upload → realtime → infinite loop. + +5. **Bandwidth:** engine never re-uploads the blob after the first add. Metadata edits round-trip in <1 KB. A 5 MB sound = ~5 MB egress per device that pulls it; ~5 MB ingress once per upload. Acceptable. + +6. **Memory-wipe interaction:** if the user wipes (P1.T12/T13), IndexedDB persists (sound blobs are there, not localStorage). The next sign-in's initial diff finds the cloud state intact. If they wipe AND re-install, all blobs are pulled from cloud on first launch.