feat(picture,storage): pre-wired insert helpers for future generate/upload flows

Closes backlog #3+4 from the Phase 9 audit. The original framing —
"server-pushed records bypass client-side encryption" — turned out
to overstate the problem after a code audit:

  - apps/mana/apps/web/src/routes/(app)/picture/generate/+page.svelte
    is currently a TODO stub. The handleGenerate() function returns
    "requires connection to Picture-Server (port 3006)" without
    inserting anything.
  - There is no fileTable.add() call site anywhere in the unified
    mana app. File uploads still happen via the standalone storage
    server in apps/storage and arrive via legacy mana-sync push.

So the production code path that would write plaintext images or
files to the user's IndexedDB doesn't yet exist. The risk only
materialises when someone wires up the in-app generate / upload
UI in the unified app.

The right action is to leave behind a clearly-labelled, encryption-
aware insert() helper on each store so the future implementation
has an obvious "do the right thing" path to call. This commit does
exactly that.

picture/stores/images.svelte.ts
-------------------------------
New imagesStore.insert(image: LocalImage) method:
  - Calls encryptRecord('images', image) to seal `prompt` +
    `negativePrompt` (the two registered encrypted fields)
  - Calls imageTable().add(image)
  - Fires the PictureEvents.imageCreated analytic (replaces the
    old plain-table-add path)

A long doc comment on the method explains the architectural
reasoning: the server cannot encrypt under the user's master key
(the key only lives in the browser), so the generation flow MUST
round-trip through the client store even if the AI call itself
happens server-side. The pattern is documented as:

  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

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.

storage/stores/files.svelte.ts
------------------------------
New filesStore.insert(file: LocalFile) method:
  - Calls encryptRecord('files', file) to seal `name` +
    `originalName`
  - Calls fileTable.add(file)

Same architectural reasoning applies. The doc comment also flags a
SEPARATE concern that this commit does NOT address: encrypting the
actual file *bytes* on S3 (so the storage provider can't read the
content) needs streaming AES-GCM and is a much bigger lift. Tracked
as "backlog #4b" in the comment for whoever picks it up next.

(No analytic call yet on the storage side because StorageEvents
doesn't have a fileUploaded() event — the upload UI is unbuilt, so
adding the analytic event is up to whoever lands the UI.)

Pre-existing TS error on line 46 of images.svelte.ts (the
`toggleField(imageTable(), ...)` Drizzle/Dexie type variance bug)
is unchanged — it predates Phase 9 and is not introduced by this
commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 23:52:20 +02:00
parent 05ae348b12
commit 109de61e21
2 changed files with 62 additions and 0 deletions

View file

@ -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' });
},
};

View file

@ -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.
},
};