mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 02:06:42 +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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue