feat(profile,infra): mana-me Write-Through-Brücke + me.mana.how-Route

Phase 1 des managarten me-images → mana-me Cutovers
(siehe mana/docs/USER_CONTEXT_STRATEGY.md). Spiegelt Primary-Slot-
Wechsel (face-ref, body-ref) in die Plattform-Service mana-me, damit
cross-app-Konsumenten (Werdrobe, Memoro) den aktuellen Face/Body
des Users ohne managarten-DB-Abhängigkeit bekommen.

- apps/api/src/lib/mana-me.ts: Service-to-Service-Client (X-Service-Key)
  mit slot→kind-Mapping (face-ref→face, body-ref→fullbody).
- apps/api/.../profile/routes.ts: POST /me-images/sync-primary (JWT)
  ruft den Client; best-effort, blockt setPrimary nicht.
- web stores/me-images.svelte.ts: setPrimary('face-ref'|'body-ref')
  triggert die Brücke via api/me-images.ts.

cloudflared: me.mana.how → localhost:3078 in der Plattform-Sektion
(neben share/mcp).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-21 17:07:32 +02:00
parent ce923bbdc7
commit db1dc9a738
5 changed files with 157 additions and 0 deletions

View file

@ -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<boolean> {
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;
}
}

View file

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

View file

@ -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<void> {
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<UploadMeImageResult> {
// Fail-fast wenn der Auth-Store keinen Token liefern kann. Vorher
// schickten wir die Anfrage trotzdem ohne `Authorization`-Header

View file

@ -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<void> {

View file

@ -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