mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 01:26:41 +02:00
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:
parent
ce923bbdc7
commit
db1dc9a738
5 changed files with 157 additions and 0 deletions
68
apps/api/src/lib/mana-me.ts
Normal file
68
apps/api/src/lib/mana-me.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue