Files
ChatApp/docs/superpowers/plans/2026-05-16-phase4c-soundboard-cloud-sync.md
T

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 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:

-- 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 — 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):

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 — 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:

// 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)

  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.