35 KiB
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 bucketsoundboards(private, owner-only RLS). The table is added tosupabase_realtimeso 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 existingencryptFor/decryptFromhelpers inpackages/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 existingsubscribeSoundboardChangespubsub) debounced 500ms uploads the changed row; (c) realtime subscription on ownuser_soundboardsrows triggers a targeted re-pull when another device of the user edits. LWW byupdated_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:
-- Phase 4C: cloud-synced soundboard.
--
-- user_soundboards: one row per cloud-known sound. The audio payload itself
-- lives in the `soundboards` storage bucket at `<user_id>/<sound_id>.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
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— adduser_soundboardsto 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):
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:
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<RemoteSound[]> {
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<RemoteSound> {
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<void> {
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<Uint8Array> {
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<Uint8Array> {
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<void> {
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<Uint8Array> {
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:
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:
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
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— addputRawStoredSound/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:
// 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<void> {
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored));
}
export async function deleteRawStoredSound(id: string): Promise<void> {
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:
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<string, SyncBadge>;
initialPullDone: boolean;
} {
const { session } = useAuth();
const userId = session?.user.id ?? null;
const [badges, setBadges] = useState<Map<string, SyncBadge>>(new Map());
const [initialPullDone, setInitialPullDone] = useState(false);
const debounceRef = useRef<number | null>(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<void> {
const [localList, remoteList] = await Promise.all([
listSounds(),
listOwnSounds(supabase),
]);
const remoteById = new Map<string, RemoteSound>();
for (const r of remoteList) remoteById.set(r.id, r);
const localById = new Map<string, SoundboardEntry>();
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<void> {
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<void> {
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<void> {
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
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
<span>group that shows name/category). -
The current delete-button onClick handler.
-
Step 2: Mount the hook + render the badge
Add imports:
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):
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):
<span
title={badgeTitle(badges.get(s.id))}
aria-label={badgeTitle(badges.get(s.id))}
className="ml-2 inline-flex items-center text-[10px] font-medium text-fg-muted"
>
{badgeGlyph(badges.get(s.id))}
</span>
Add at the bottom of the file (outside the component):
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:
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
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)
-
Spec coverage (
docs/superpowers/specs/2026-05-16-fifteen-features-design.mdlines 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/<user_id>/<sound_id>.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 viaencryptFor/decryptFrom - "Local IndexedDB stays the working store; sync is bidirectional" → T3
runDiffhandles both directions - "Sync engine: on app start + on every local edit (debounced 500ms)" → T3 init +
subscribeSoundboardChangesdebounce - "Realtime subscription on own
user_soundboardsrows" → T3 channel - "Conflict resolution: last-write-wins by
updated_at" → T3local.updatedAt > Date.parse(remote.updatedAt)comparisons - "UI shows '☁ Sync OK / ↑ Hochladen… / ⚠ Konflikt' badge per sound" → T4 (
Konfliktreplaced with genericerrorsince LWW means real conflicts auto-resolve)
- "New table
-
Placeholders: none.
-
Type consistency:
RemoteSoundcamelCase ↔ DB snake_case: isolated inmapRow; tested in T2.SoundboardEntry.order(local) ↔RemoteSound.sortOrder(remote) ↔sort_order(DB): consistently translated at the wrapper boundary.SoundboardEntry.createdAt/updatedAtare JS ms;RemoteSound.createdAt/updatedAtare ISO strings. T3 callsDate.parse(...)for comparisons andnew Date(...).toISOString()for outbound.SyncBadgedefined T3, consumed T4.
-
Feedback-loop guard:
putRawStoredSound/deleteRawStoredSounddeliberately bypassnotifyChange(). Without that, every realtime-pulled row would re-triggersubscribeSoundboardChanges→ debounced push → upload → realtime → infinite loop. -
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.
-
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.