From f981904e7f9b5c3fbbc95e4c20325ea0720f959b Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 20:53:48 +0200 Subject: [PATCH] feat(P4C.T2): shared soundboards wrappers + sealed-blob crypto + tests --- packages/db-types/src/index.ts | 44 +++++ packages/shared/src/chat/index.ts | 1 + packages/shared/src/chat/soundboards.test.ts | 134 +++++++++++++ packages/shared/src/chat/soundboards.ts | 189 +++++++++++++++++++ 4 files changed, 368 insertions(+) create mode 100644 packages/shared/src/chat/soundboards.test.ts create mode 100644 packages/shared/src/chat/soundboards.ts diff --git a/packages/db-types/src/index.ts b/packages/db-types/src/index.ts index b238a4f..13e50bb 100644 --- a/packages/db-types/src/index.ts +++ b/packages/db-types/src/index.ts @@ -538,6 +538,50 @@ export type Database = { }, ] } + 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: [] + } } Views: { [_ in never]: never diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index cbaa0b1..1bedd9f 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -11,6 +11,7 @@ export * from './pinnedMessages'; export * from './mentions'; export * from './viewOnceAttachments'; export * from './whiteboards'; +export * from './soundboards'; // ----- RPC wrappers --------------------------------------------------------- diff --git a/packages/shared/src/chat/soundboards.test.ts b/packages/shared/src/chat/soundboards.test.ts new file mode 100644 index 0000000..928968d --- /dev/null +++ b/packages/shared/src/chat/soundboards.test.ts @@ -0,0 +1,134 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +import { getCryptoBackend, setCryptoBackend } from '../crypto/backend'; +import { makeWasmTestBackend } from '../crypto/testBackend'; +import { + decryptSoundEnvelope, + encryptSoundBlob, + listOwnSounds, + upsertSound, +} from './soundboards'; + +beforeAll(async () => { + setCryptoBackend(await makeWasmTestBackend()); +}); + +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'); + }); +}); diff --git a/packages/shared/src/chat/soundboards.ts b/packages/shared/src/chat/soundboards.ts new file mode 100644 index 0000000..2cb00a3 --- /dev/null +++ b/packages/shared/src/chat/soundboards.ts @@ -0,0 +1,189 @@ +import { decryptFrom, encryptFor } from '../crypto/box'; +import type { AppSupabaseClient } from '../supabase/client'; + +export const SOUNDBOARDS_BUCKET = 'soundboards'; + +export interface RemoteSound { + id: string; + userId: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + sortOrder: number; + storagePath: string; + createdAt: string; + updatedAt: string; +} + +export interface UpsertSoundInput { + id: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + sortOrder: number; + storagePath: string; + updatedAtIso: string; +} + +export async function listOwnSounds(client: AppSupabaseClient): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const { data, error } = await client + .from('user_soundboards') + .select( + 'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at', + ) + .eq('user_id', session.user.id) + .order('updated_at', { ascending: false }); + if (error) throw error; + return data.map(mapRow); +} + +export async function upsertSound( + client: AppSupabaseClient, + input: UpsertSoundInput, +): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const { data, error } = await client + .from('user_soundboards') + .upsert( + { + id: input.id, + user_id: session.user.id, + name: input.name, + mime: input.mime, + size: input.size, + category: input.category, + hotkey: input.hotkey, + gain: input.gain, + sort_order: input.sortOrder, + storage_path: input.storagePath, + updated_at: input.updatedAtIso, + }, + { onConflict: 'id' }, + ) + .select( + 'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at', + ) + .single(); + if (error) throw error; + return mapRow(data); +} + +export async function deleteSound( + client: AppSupabaseClient, + soundId: string, +): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const { data: row } = await client + .from('user_soundboards') + .select('storage_path') + .eq('id', soundId) + .eq('user_id', session.user.id) + .maybeSingle(); + if (row?.storage_path) { + const { error: storageErr } = await client.storage + .from(SOUNDBOARDS_BUCKET) + .remove([row.storage_path]); + if (storageErr && !/not found/i.test(storageErr.message)) throw storageErr; + } + const { error } = await client + .from('user_soundboards') + .delete() + .eq('id', soundId) + .eq('user_id', session.user.id); + if (error) throw error; +} + +// Sealed-to-self envelope: nonce || ciphertext. encryptFor's sender and +// recipient are both the current user, equivalent to crypto_box_seal but +// reuses the existing helper (no new backend method). +export async function encryptSoundBlob( + blob: Blob, + myPublicKey: Uint8Array, + myPrivateKey: Uint8Array, +): Promise { + const bytes = new Uint8Array(await blob.arrayBuffer()); + const { ciphertext, nonce } = await encryptFor(bytes, myPublicKey, myPrivateKey); + const out = new Uint8Array(nonce.length + ciphertext.length); + out.set(nonce, 0); + out.set(ciphertext, nonce.length); + return out; +} + +export async function decryptSoundEnvelope( + envelope: Uint8Array, + myPublicKey: Uint8Array, + myPrivateKey: Uint8Array, +): Promise { + const NONCE_LEN = 24; + if (envelope.length < NONCE_LEN + 16) { + throw new Error('sound_envelope_too_short'); + } + const nonce = envelope.slice(0, NONCE_LEN); + const ciphertext = envelope.slice(NONCE_LEN); + return decryptFrom(ciphertext, nonce, myPublicKey, myPrivateKey); +} + +export async function uploadSoundCiphertext( + client: AppSupabaseClient, + storagePath: string, + ciphertext: Uint8Array, +): Promise { + const { error } = await client.storage + .from(SOUNDBOARDS_BUCKET) + .upload(storagePath, ciphertext, { + contentType: 'application/octet-stream', + upsert: true, + }); + if (error) throw error; +} + +export async function downloadSoundCiphertext( + client: AppSupabaseClient, + storagePath: string, +): Promise { + const { data, error } = await client.storage + .from(SOUNDBOARDS_BUCKET) + .download(storagePath); + if (error) throw error; + return new Uint8Array(await data.arrayBuffer()); +} + +function mapRow(row: { + id: string; + user_id: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + sort_order: number; + storage_path: string; + created_at: string; + updated_at: string; +}): RemoteSound { + return { + id: row.id, + userId: row.user_id, + name: row.name, + mime: row.mime, + size: row.size, + category: row.category, + hotkey: row.hotkey, + gain: row.gain, + sortOrder: row.sort_order, + storagePath: row.storage_path, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +}