diff --git a/apps/api/src/lib/mana-me.ts b/apps/api/src/lib/mana-me.ts new file mode 100644 index 000000000..c7c181f60 --- /dev/null +++ b/apps/api/src/lib/mana-me.ts @@ -0,0 +1,68 @@ +/** + * Service-to-service client for mana-me. + * + * managarten's Dexie-local-first me-images module stays the user's + * curated, encrypted, per-space collection (with `aiReference`-opt-in + * etc.) — but when the user sets the primary slot for face-ref / + * body-ref, that decision is the *platform-truth* every other app + * needs ("which photo is currently this user's face"). This client + * mirrors that single bit into mana-me via its internal write-through + * endpoint, so consumers like Werdrobe and Memoro don't depend on the + * managarten DB. + * + * Best-effort: mana-me being unreachable does not fail the user-facing + * write. The managarten row stays authoritative locally; the next + * primary-change reconciles. We log + return false instead of throwing. + */ + +const MANA_ME_URL = process.env.MANA_ME_URL || 'http://localhost:3078'; +const MANA_SERVICE_KEY = process.env.MANA_SERVICE_KEY || 'dev-service-key'; + +export type ManaMeKind = 'face' | 'fullbody' | 'halfbody' | 'hands' | 'reference'; + +/** + * Map managarten's `primaryFor` slot to mana-me's `kind`. We only + * mirror the two slots that have cross-app meaning — `avatar` is + * already handled by the existing auth.users.image sync hook in + * `me-images.svelte.ts` and doesn't need a mana-me entry. + */ +export function slotToManaMeKind(slot: 'avatar' | 'face-ref' | 'body-ref'): ManaMeKind | null { + if (slot === 'face-ref') return 'face'; + if (slot === 'body-ref') return 'fullbody'; + return null; +} + +export interface MeImageWriteThrough { + userId: string; + kind: ManaMeKind; + mediaId: string; + makePrimary: boolean; +} + +export async function mirrorMeImageToManaMe(input: MeImageWriteThrough): Promise { + const url = `${MANA_ME_URL}/api/v1/internal/me/${encodeURIComponent(input.userId)}/images`; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Service-Key': MANA_SERVICE_KEY, + }, + body: JSON.stringify({ + kind: input.kind, + mediaId: input.mediaId, + makePrimary: input.makePrimary, + }), + }); + if (!res.ok) { + console.warn( + `[mana-me] mirror failed for user=${input.userId} kind=${input.kind}: ${res.status}` + ); + return false; + } + return true; + } catch (err) { + console.warn(`[mana-me] mirror network error for user=${input.userId}:`, err); + return false; + } +} diff --git a/apps/api/src/modules/profile/routes.ts b/apps/api/src/modules/profile/routes.ts index d0ecea937..e1a216274 100644 --- a/apps/api/src/modules/profile/routes.ts +++ b/apps/api/src/modules/profile/routes.ts @@ -7,10 +7,17 @@ * in mana-media (CAS + thumbnails) so the Picture generator can pull * the original bytes by `mediaId` for the eventual /v1/images/edits * call (M3) without the client needing to re-upload each time. + * + * `POST /me-images/sync-primary` mirrors a primary-slot change into + * mana-me so cross-app consumers (Werdrobe, Memoro, …) get a + * managarten-DB-independent source for the user's current face/body. + * See `mana/docs/USER_CONTEXT_STRATEGY.md` for the rationale. */ import { Hono } from 'hono'; import type { AuthVariables } from '@mana/shared-hono'; +import { z } from 'zod'; +import { mirrorMeImageToManaMe, slotToManaMeKind } from '../../lib/mana-me'; const routes = new Hono<{ Variables: AuthVariables }>(); @@ -18,6 +25,11 @@ const routes = new Hono<{ Variables: AuthVariables }>(); // real-world phone-camera PNG range, same mana-media pipeline downstream. const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; +const syncPrimarySchema = z.object({ + mediaId: z.string().min(1), + slot: z.enum(['face-ref', 'body-ref']), +}); + routes.post('/me-images/upload', async (c) => { const userId = c.get('userId'); const formData = await c.req.formData(); @@ -51,4 +63,34 @@ routes.post('/me-images/upload', async (c) => { } }); +/** + * Mirror a primary-slot change into mana-me. Client calls this whenever + * the managarten Dexie store flips `primaryFor='face-ref' | 'body-ref'` + * onto a different image. Best-effort — failures don't block the + * user-facing write (managarten Dexie remains authoritative locally). + * + * The mana-me side does its own mana-media ownership check on the + * mediaId, but we additionally validate here that the caller's userId + * matches the JWT — the X-Service-Key path on mana-me trusts us as a + * platform service, so this is the gate that ties it back to the user. + */ +routes.post('/me-images/sync-primary', async (c) => { + const userId = c.get('userId'); + const body = await c.req.json().catch(() => ({})); + const parsed = syncPrimarySchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'invalid_body', detail: parsed.error.flatten() }, 400); + } + const kind = slotToManaMeKind(parsed.data.slot); + if (!kind) return c.json({ error: 'unsupported_slot' }, 400); + + const ok = await mirrorMeImageToManaMe({ + userId, + mediaId: parsed.data.mediaId, + kind, + makePrimary: true, + }); + return c.json({ mirrored: ok }); +}); + export { routes as profileRoutes }; diff --git a/apps/mana/apps/web/src/lib/modules/profile/api/me-images.ts b/apps/mana/apps/web/src/lib/modules/profile/api/me-images.ts index 43ff8a0c4..439248ef6 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/api/me-images.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/api/me-images.ts @@ -20,6 +20,36 @@ export interface UploadMeImageResult { thumbnailUrl?: string; } +/** + * Mirror a primary-slot change into the platform's mana-me service. + * The managarten Dexie row stays authoritative locally; mana-me is the + * cross-app source for "which photo is the user's current face/body". + * Best-effort — a failure does not block the user-facing setPrimary. + * + * Only fires for `face-ref` and `body-ref`. The legacy `avatar` slot is + * synced separately via `syncAvatarToAuth` and doesn't need a mana-me + * mirror. + */ +export async function syncMeImagePrimaryToPlatform( + mediaId: string, + slot: 'face-ref' | 'body-ref' +): Promise { + try { + const token = await authStore.getValidToken(); + if (!token) return; + await fetch(`${getManaApiUrl()}/api/v1/profile/me-images/sync-primary`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ mediaId, slot }), + }); + } catch (err) { + console.warn('[profile] mana-me primary sync failed', err); + } +} + export async function uploadMeImageFile(file: File): Promise { // Fail-fast wenn der Auth-Store keinen Token liefern kann. Vorher // schickten wir die Anfrage trotzdem ohne `Authorization`-Header diff --git a/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts b/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts index 3cf530764..cf5a37caf 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts @@ -13,6 +13,7 @@ import { emitDomainEvent } from '$lib/data/events'; import { scopedForModule, getActiveSpace } from '$lib/data/scope'; import { profileService } from '$lib/api/profile'; import { meImagesTable } from '../collections'; +import { syncMeImagePrimaryToPlatform } from '../api/me-images'; import { toMeImage } from '../types'; import type { LocalMeImage, @@ -234,6 +235,18 @@ export const meImagesStore = { if (slot === 'avatar' || slot === 'face-ref') { await syncAvatarToAuth(); } + // Mirror face-ref / body-ref into mana-me so cross-app + // consumers (Werdrobe, Memoro, …) get the user's current + // face/body without depending on the managarten DB. Skipped + // for the legacy `avatar` slot because `syncAvatarToAuth` + // already covers it via Better Auth. Best-effort — failures + // don't roll back the local primary swap. + if (slot === 'face-ref' || slot === 'body-ref') { + const row = await meImagesTable.get(id); + if (row?.mediaId) { + void syncMeImagePrimaryToPlatform(row.mediaId, slot); + } + } }, async deleteMeImage(id: string): Promise { diff --git a/cloudflared-config.yml b/cloudflared-config.yml index 27abaee3f..6ccdd725b 100644 --- a/cloudflared-config.yml +++ b/cloudflared-config.yml @@ -195,6 +195,10 @@ ingress: service: http://localhost:3072 - hostname: mcp.mana.how service: http://localhost:3069 + # mana-me — User-globale Identitäts-Refs (Code/mana/services/mana-me). + # Erst-Konsument: werdrobe Try-On. Strategy: USER_CONTEXT_STRATEGY.md. + - hostname: me.mana.how + service: http://localhost:3078 - hostname: cardecky-api.mana.how service: http://localhost:3191