1114 lines
35 KiB
Markdown
1114 lines
35 KiB
Markdown
# 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 `<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**
|
|
|
|
```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<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`:
|
|
|
|
```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<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`:
|
|
|
|
```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**
|
|
|
|
```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 `<span>` 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
|
|
<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):
|
|
```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/<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 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.
|