diff --git a/apps/mana/apps/web/src/lib/modules/picture/stores/images.svelte.ts b/apps/mana/apps/web/src/lib/modules/picture/stores/images.svelte.ts index 32de43fa2..409b9cacf 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/stores/images.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/stores/images.svelte.ts @@ -7,6 +7,7 @@ */ import { db } from '$lib/data/database'; +import { encryptRecord } from '$lib/data/crypto'; import { createArchiveOps, toggleField } from '@mana/shared-stores'; import { PictureEvents, trackEvent } from '@mana/shared-utils/analytics'; import type { LocalImage } from '../types'; @@ -58,4 +59,35 @@ export const imagesStore = { await imageArchive.softDelete(id); trackEvent('image_deleted', { module: 'picture' }); }, + + /** + * Insert a freshly-generated image. This is the canonical path for + * any future image-generation flow (currently the + * /picture/generate route is a stub) — it wraps the user-typed + * `prompt` and `negativePrompt` fields via encryptRecord so the + * generated image lands in IndexedDB with the same encryption + * envelope as locally-edited rows. + * + * Why this is the only safe way to insert an image: server-side + * code (the eventual image-gen API + sync push) cannot encrypt + * under the user's master key — it lives in the browser. So the + * generation flow MUST round-trip through the client store, even + * if the actual AI call happens server-side. The pattern is: + * + * 1. Client posts { prompt, negativePrompt, ... } to image-gen API + * 2. Server returns { storagePath, generationId, dimensions, ... } + * 3. Client calls imagesStore.insert(...) with both halves + * 4. encryptRecord seals the prompt fields before the IndexedDB + * write; sync pushes the encrypted row to the backend + * + * The mixed-state guarantee from picture/queries.ts already covers + * the migration window where some images came in via legacy + * server-side push and others through this path — decryptRecord + * passes plaintext through and unwraps ciphertext blobs. + */ + async insert(image: LocalImage) { + await encryptRecord('images', image); + await imageTable().add(image); + trackEvent('image_created', { module: 'picture' }); + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/storage/stores/files.svelte.ts b/apps/mana/apps/web/src/lib/modules/storage/stores/files.svelte.ts index 44ae90464..1b2dcbfc3 100644 --- a/apps/mana/apps/web/src/lib/modules/storage/stores/files.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/storage/stores/files.svelte.ts @@ -229,4 +229,34 @@ export const filesStore = { selectedFolderIds = new Set(); return count; }, + + /** + * Insert a freshly-uploaded file. Canonical path for any future + * upload flow (none exists in the unified mana app yet — uploads + * land via legacy mana-sync push from the standalone storage + * server). When the new in-app upload UI is built it MUST go + * through here so the user-typed `name` + `originalName` fields + * get sealed via encryptRecord before they hit IndexedDB. + * + * The contract is the same as imagesStore.insert(): server-side + * code can't encrypt under the user's master key, so the upload + * round-trips through the client. Server returns the structural + * metadata (storageKey, mimeType, size, checksum, …) and the + * client calls insert() with both halves. The plaintext name + * fields then get encrypted before the local write. + * + * NOTE on the file *bytes*: client-side bytes-encryption (so the + * actual content in S3 is also opaque to the provider) is a + * separate concern that needs streaming AES-GCM and is out of + * scope for this commit. This insert helper only protects the + * filename metadata. The bytes-on-S3 problem will need its own + * milestone — track it as backlog #4b. + */ + async insert(file: LocalFile) { + await encryptRecord('files', file); + await fileTable.add(file); + // No StorageEvents.fileUploaded() yet — analytics doesn't have + // an upload-side event because the upload UI is unbuilt. Add + // the analytic when the corresponding UI lands. + }, };