fix(wordeck): pre-existing test drifts + L-2 cleanup vor Deploy
Some checks are pending
CI / validate (push) Waiting to run

- tests cards→wordeck rebrand-drift: app-name in health/search/tools/dsgvo,
  envelope to.app + service-key env-var WORDECK_DSGVO_SERVICE_KEY
  (war: CARDS_*). Test-Suite jetzt 83/83 grün.
- dsgvo.ts: ENV-Name auf WORDECK_DSGVO_SERVICE_KEY (war CARDS_*) — passt
  zum Test-Setup + wordeck-Branding
- decks.ts (web): generateDeckFromImage routet URL-only-Pfad auf
  generateDeck, File-Upload-Pfad wirft klaren Fehler (Server-Route
  existiert nicht). UI-Komponenten unverändert
- migrate-db-to-events.ts: Stub als „nicht benötigt" markiert.
  Wordeck-Production hat keine User-Daten in den obsoleten Tabellen;
  Marketplace-Decks (cardecky-User) leben in eigenem pgSchema und
  sind vom Cutover nicht betroffen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-20 21:56:42 +02:00
parent 375a6af86e
commit cba37c3c37
8 changed files with 52 additions and 50 deletions

View file

@ -1,42 +1,35 @@
/**
* Migration-Skript für den Big-Bang-Cutover L-2 (2026-05-20).
* Migration-Skript-Stub für DBEvent-Sync (L-2, 2026-05-20).
*
* Liest pro User alle Decks + Cards + Reviews aus der Wordeck-DB und
* publiziert sie als event-sourced Append-Sequence an
* sync2.mana.how/sync/wordeck. Idempotent über `idempotencyKey =
* 'migration:<row-id>:<event-type>'`.
* **Aktueller Status (2026-05-20): NICHT BENÖTIGT.**
*
* Verwendung (immer dry-run probieren bevor echter Run):
* Wordeck-Production hat keine User-Daten in den (jetzt obsoleten)
* wordeck.decks/cards/reviews-Tabellen. Die einzigen aktiven Daten
* sind die öffentlichen Marketplace-Decks des cardecky-Plattform-Users,
* die in `marketplace.public_decks` + `marketplace.deck_versions` leben
* und vom Big-Bang nicht betroffen sind.
*
* Dieses Skript bleibt als Bauplan stehen, falls künftig vor einem
* weiteren Cutover (z.B. Schema-V2-Migration) ein DBEvents-Lift
* gebraucht wird. **Bei Reaktivierung dringend Code-Review +
* Dry-Run-Tests, das Skript ist heute ungetestet.**
*
* Offene Implementation-Punkte falls Reaktivierung kommt:
* - `mintToken()` braucht entweder Service-Key-Token-Mint in mana-auth
* (gibt's heute nicht) oder Service-Key-Auth-Mode in mana-sync
* (gibt's heute nicht) beides Plattform-Arbeit
* - Encryption: Plaintext-Migration ist akzeptabel solange Trust-
* Domain stimmt; Re-Encrypt nach User-Login als Folge-Task
*
* Verwendung (sobald aktiviert):
*
* pnpm tsx scripts/migrate-db-to-events.ts --dry-run [--user-id <id>]
* pnpm tsx scripts/migrate-db-to-events.ts --commit [--user-id <id>]
*
* Ohne --user-id wird über alle User iteriert. --dry-run gibt Counts
* aus ohne POST. --commit POSTet tatsächlich.
*
* Voraussetzungen:
* - DATABASE_URL gesetzt (Wordeck-DB)
* - MANA_SYNC_URL (default https://sync2.mana.how)
* - MANA_SERVICE_KEY (für service-side User-JWT-Mint oder per-User-Token)
* - User-JWTs: Skript ruft mana-auth-`POST /api/v1/service/mint-token`
* mit Service-Key + user_id für temporäre JWT (Service-Key-Pattern,
* siehe shared-auth)
*
* Encryption: Daten werden im **Plaintext** an sync2 gesendet wir
* haben keinen User-Master-Key zur Migration-Zeit. Server speichert
* sie als wire-format string (NoOp-kompatibel). Wenn der User sich
* später einloggt + Vault-Key bootstrappt, kann ein Re-Encrypt-Pass
* folgen. Für den Big-Bang akzeptieren wir Plaintext-Migration, weil:
* - die Daten waren vorher schon in Postgres plaintext
* - der Sync-Server liegt in derselben Trust-Domain (Mac Mini)
* - User kann nach Login encrypted-Versionen drüberspielen
*
* **Status: Skript-Stub, ungetestet.** Vor echtem Run:
* 1. Code-Review durch Till
* 2. Snapshot von mana_sync_v2.wordeck.* + Wordeck-DB
* 3. Dry-run für 1-2 User
* 4. Manuelle Verifikation des sync2-States
* 5. Erst dann --commit
* - MANA_SERVICE_KEY (für Token-Mint)
*/
import { eq, sql } from 'drizzle-orm';

View file

@ -100,7 +100,7 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
// IP-Rate-Limit als Defense-in-Depth — der Service-Key ist ohnehin
// Pflicht, aber 10/min stoppt einen brute-force-Versuch auf den Key.
r.use('*', rateLimit({ scope: 'dsgvo', windowMs: 60_000, max: 10, keyOf: ipKey }));
r.use('*', serviceKeyAuth({ envVar: 'CARDS_DSGVO_SERVICE_KEY' }));
r.use('*', serviceKeyAuth({ envVar: 'WORDECK_DSGVO_SERVICE_KEY' }));
/**
* Voll-Export aller Cards-Daten eines Users. Liefert serialisier-

View file

@ -70,7 +70,7 @@ describe('dsgvoRouter — Service-Key-Gate', () => {
data: { decks: unknown[]; cards: unknown[]; reviews: unknown[] };
};
expect(body.user_id).toBe('u-1');
expect(body.app).toBe('cards');
expect(body.app).toBe('wordeck');
expect(Array.isArray(body.data.decks)).toBe(true);
expect(Array.isArray(body.data.cards)).toBe(true);
expect(Array.isArray(body.data.reviews)).toBe(true);

View file

@ -17,7 +17,7 @@ describe('health routes', () => {
const res = await app.request('/version');
expect(res.status).toBe(200);
const body = (await res.json()) as { app: string; version: string; build: string };
expect(body.app).toBe('cards');
expect(body.app).toBe('wordeck');
expect(body.version).toBeTruthy();
expect(body.build).toBeTruthy();
});

View file

@ -60,7 +60,7 @@ describe('searchRouter — Query-Validation', () => {
};
expect(body.envelope_version).toBe('0.1');
expect(body.query).toBe('Konfuzius');
expect(body.app).toBe('cards');
expect(body.app).toBe('wordeck');
expect(Array.isArray(body.results)).toBe(true);
expect(body.partial).toBe(false);
});

View file

@ -25,7 +25,7 @@ function envelope(overrides: Record<string, unknown> = {}) {
timestamp: new Date().toISOString(),
},
to: {
app: 'cards',
app: 'wordeck',
user_id: userA,
},
type: 'mana/quote',
@ -61,7 +61,7 @@ describe('shareRouter — Envelope-Validation', () => {
it('Cross-User-Share ist 422 (envelope_invalid)', async () => {
const { app } = buildApp();
const env = envelope({
to: { app: 'cards', user_id: userB }, // anderer User → Cross-User
to: { app: 'wordeck', user_id: userB }, // anderer User → Cross-User
});
const res = await app.request('/api/v1/share/receive', {
method: 'POST',
@ -77,7 +77,7 @@ describe('shareRouter — Envelope-Validation', () => {
const { app } = buildApp();
const env = envelope({
from: { ...envelope().from, user_id: userB },
to: { app: 'cards', user_id: userB },
to: { app: 'wordeck', user_id: userB },
});
const res = await app.request('/api/v1/share/receive', {
method: 'POST',

View file

@ -91,6 +91,6 @@ describe('toolsRouter — Tool-Dispatch', () => {
const body = (await res.json()) as { query: string; results: unknown[]; app: string };
expect(body.query).toBe('Konfuzius');
expect(Array.isArray(body.results)).toBe(true);
expect(body.app).toBe('cards');
expect(body.app).toBe('wordeck');
});
});

View file

@ -13,7 +13,7 @@ import type { Deck, DeckCreate, DeckUpdate } from '@wordeck/domain';
import type { WordeckDeckState as DeckProjection } from '@mana/shared-schemas';
import { ulid } from 'ulid';
import { api, apiForm } from './client.ts';
import { api } from './client.ts';
import { deckAggregateId, deckStateToRow } from './event-adapters.ts';
import { emitEvent } from './event-builder.ts';
import { getSync } from '../sync.svelte.ts';
@ -188,21 +188,30 @@ export function fetchDistractors(
return api<{ distractors: string[] }>(`/api/v1/decks/${deckId}/distractors${qs}`);
}
export function generateDeckFromImage(
/**
* generateDeckFromImage Stub seit 2026-05-20 (L-2g).
*
* Die Server-Route `/api/v1/decks/from-image` existiert nicht und wurde
* nie ausgerollt. UI nutzt diese Funktion für File-Upload + URL-only-Pfad.
* Wir routen URL-only-Aufrufe transparent auf `generateDeck` (das den
* URL-Kontext bereits unterstützt). File-Uploads werfen einen klaren
* Fehler bis Server-Implementation kommt.
*/
export async function generateDeckFromImage(
files: File | File[],
opts: { language?: string; count?: number; url?: string },
) {
): Promise<{ deck: Deck; cards_created: number }> {
const arr = Array.isArray(files) ? files : [files];
if (arr.length === 0) {
return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', {
method: 'POST',
body: { language: opts.language, count: opts.count, url: opts.url },
if (arr.length === 0 && opts.url) {
// URL-only: über generateDeck routen (Server hat URL-Support)
return generateDeck({
prompt: opts.url,
language: opts.language,
count: opts.count,
url: opts.url,
});
}
const form = new FormData();
for (const f of arr) form.append('file', f);
if (opts.language) form.append('language', opts.language);
if (opts.count != null) form.append('count', String(opts.count));
if (opts.url) form.append('url', opts.url);
return apiForm<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', form);
throw new Error(
'Bild-zu-Karten ist nach dem event-sync-Cutover noch nicht verfügbar. Nutze die URL-Eingabe oder erstelle Karten manuell.',
);
}