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