feat(P4C.T2): shared soundboards wrappers + sealed-blob crypto + tests
This commit is contained in:
@@ -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: {
|
Views: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export * from './pinnedMessages';
|
|||||||
export * from './mentions';
|
export * from './mentions';
|
||||||
export * from './viewOnceAttachments';
|
export * from './viewOnceAttachments';
|
||||||
export * from './whiteboards';
|
export * from './whiteboards';
|
||||||
|
export * from './soundboards';
|
||||||
|
|
||||||
// ----- RPC wrappers ---------------------------------------------------------
|
// ----- RPC wrappers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user