From cb9a9bb42edb7f4aef42a91fd92d81e8d5578198 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 18:09:57 +0200 Subject: [PATCH] refactor(profile,tool-registry): flip meImages from user-scoped to space-scoped (v40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips `meImages` out of USER_LEVEL_TABLES so it lives under the same tenancy model as every other data table (tags, scenes, tasks, …). Precursor to the Wardrobe module, which is space-scoped across all six space types — leaving meImages user-global would leave an inconsistency where the Wardrobe catalog is per-space but its reference input is cross-space, plus a latent privacy leak in shared spaces (agents in a brand-space would see the owner's entire pool). Plan: docs/plans/me-images-space-scope-migration.md. Key decisions: - Strict scope, no cross-space fallback. Switching into a brand-space with no uploaded face shows an empty state and links back to /profile/me-images; it does not quietly reach into the personal- space pool. Keeps the mental model clean. - auth.users.image remains pinned to personal-space primary-avatar. Only a primary change inside personal space triggers the Better Auth sync; brand/club/family/team/practice primaries stay local. - Single Dexie v40 upgrade: stamps `spaceId=_personal:` sentinel, `authorId=`, `visibility='space'` on every existing row and drops the legacy `userId` column. Dexie upgrades block app startup, so by the time the new code's scopedForModule reads run, every row is already space-stamped. reconcileSentinels() on the next active-space bootstrap rewrites `_personal:` to the real personal-space id, same path v28 used. - Legacy-avatar migration (M2.5) now pins its row to `_personal:` explicitly — the legacy avatar is the user's global SSO identity and belongs in the personal space even if the migration happens to fire while the user is in a brand space. Code changes: - types.ts: LocalMeImage gains spaceId/authorId/visibility (all optional — stamped by hook). Public MeImage exposes spaceId for queries that want to branch on space type. - database.ts: meImages out of USER_LEVEL_TABLES; new v40 upgrade block that stamps sentinels + drops userId in one pass. - queries.ts: all four hooks (useAllMeImages, useMeImagesByKind, useReferenceImages, useImageByPrimary) read via scopedForModule. Scope-switch triggers automatic re-render via the existing scopedTable filter path. - stores/me-images.svelte.ts: setPrimaryInTx uses scopedForModule so a setPrimary in Brand-space never clears Personal-space's holder. syncAvatarToAuth gates on activeSpace.type==='personal' so non- personal primary changes don't leak into Better Auth. createMeImage accepts optional spaceId override — the legacy- avatar migration uses it, regular uploads let the hook stamp the active space. - migration/legacy-avatar.ts: explicitly passes spaceId=_personal: to pin the legacy row into personal space. - MeImagesView.svelte: subtle badge in the intro card shows the active space ("Persönlich" for personal, space name otherwise) so users notice when the pool changes on space switch. - packages/mana-tool-registry/src/modules/me.ts: me.listReferenceImages filters pulled rows by row.spaceId === ctx.spaceId. mana-sync returns all spaces the user belongs to; the tool only wants the active space's subset. No schema/index change on meImages (non-indexed fields, pool size small enough for in-memory scopedTable filter). If perf matters later, adding [spaceId+kind] is a 5-minute follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mana/apps/web/src/lib/data/database.ts | 45 ++++++++++- .../lib/modules/profile/MeImagesView.svelte | 22 ++++- .../profile/migration/legacy-avatar.ts | 8 ++ .../web/src/lib/modules/profile/queries.ts | 80 +++++++++++-------- .../profile/stores/me-images.svelte.ts | 48 ++++++++--- .../apps/web/src/lib/modules/profile/types.ts | 10 +++ docs/plans/me-images-space-scope-migration.md | 66 +++++++++++++++ packages/mana-tool-registry/src/modules/me.ts | 12 ++- 8 files changed, 245 insertions(+), 46 deletions(-) create mode 100644 docs/plans/me-images-space-scope-migration.md diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index ee8a9538d..21e22dea5 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -932,6 +932,48 @@ db.version(38).stores({ meImages: 'id, kind, primaryFor, createdAt', }); +// v40 — Flip meImages from USER_LEVEL_TABLES to space-scoped data +// (docs/plans/me-images-space-scope-migration.md). +// +// Why: after Wardrobe's decision to be space-scoped across all six +// space types, leaving meImages user-scoped creates a split-brain +// model (space-scoped catalog, user-global input). Unification also +// closes a latent privacy leak in shared spaces — an MCP agent in a +// brand-space would otherwise see the owner's entire private pool. +// +// What the upgrade does to existing rows, in one pass: +// 1. stamps `spaceId = _personal:` sentinel (reconcileSentinels +// rewrites it to the real personal-space id on next Better Auth +// membership load — same path as v28's sentinel population) +// 2. stamps `authorId = userId` +// 3. stamps `visibility = 'space'` +// 4. drops `userId` (meImages is a data-table now, attribution lives +// on the Actor fields + tenancy on spaceId — same sweep as v35 +// did for the other data-tables, just scoped to this one) +// +// No schema/index change: `spaceId`, `authorId`, `visibility` are +// non-indexed fields, scopedTable filters in-memory. Tiny pool per +// space (typ. 2-10 rows), no compound index warranted. +db.version(40).upgrade(async (tx) => { + await tx + .table('meImages') + .toCollection() + .modify((record: Record) => { + const ownerId = + typeof record.userId === 'string' && record.userId ? record.userId : GUEST_USER_ID; + if (record.spaceId === undefined || record.spaceId === null) { + record.spaceId = `_personal:${ownerId}`; + } + if (record.authorId === undefined || record.authorId === null) { + record.authorId = ownerId; + } + if (record.visibility === undefined || record.visibility === null) { + record.visibility = 'space'; + } + if ('userId' in record) delete record.userId; + }); +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module @@ -1114,7 +1156,8 @@ const USER_LEVEL_TABLES: ReadonlySet = new Set([ 'broadcastSettings', 'wetterSettings', 'userTagPresets', - 'meImages', + // meImages removed in v40 — now space-scoped like every other + // data-table. See docs/plans/me-images-space-scope-migration.md. ]); for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { diff --git a/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte b/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte index 889f2ddfc..d67557d7d 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte @@ -16,6 +16,7 @@