feat(wordeck): L-1f Phase C — public-feed-Endpoint + DSE für lokale Speicherung
Some checks are pending
CI / validate (push) Waiting to run

apps/api:
- neue Route GET /api/v1/public/feed?cursor=<opaque>&limit=20
- Plattform-Konvention aus LOCAL_FIRST_LOGIN_OPTIONAL.md §3 D
- Shape: { items: [{ id, type:'deck', slug, title, excerpt, author,
  publishedAt, stats }], nextCursor: <opaque> | null }
- Quelle: marketplace.public_decks (is_takedown=false), sortiert
  nach createdAt desc — Aggregatoren wie hub.mana.how/timeline
  konsumieren genau diese Shape

apps/web/datenschutz:
- neue Sektion 7a „Lokale Daten + anonyme Nutzung" mit Hinweisen auf
  IndexedDB-DB-Name manasync_wordeck, anonyme Geräte-ID anon:<ulid>,
  Sign-In-Lift mit AES-GCM-256-Vault-Verschlüsselung, Browser-Reset-
  Risiko bei anonymer Nutzung
- alter sql-wasm-Hinweis raus (Wordeck nutzt jetzt event-sync, kein
  sql-wasm mehr)

Verbleibend für Live-Smoke (Till manuell):
- wordeck.com lädt → /decks ohne Login (anonymous-Mode)
- Deck anlegen → IndexedDB manasync_wordeck zeigt Event
- Login → Re-Tagging-Sweep + Push zu sync2.mana.how
- /api/v1/public/feed Smoke (oder über hub.mana.how)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-20 21:40:49 +02:00
parent e0605b2f2e
commit bebd182540
3 changed files with 150 additions and 2 deletions

View file

@ -16,6 +16,7 @@ import { decksGenerateRouter } from './routes/decks-generate.ts';
import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts';
import { marketplaceDecksRouter } from './routes/marketplace/decks.ts';
import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/explore.ts';
import { publicFeedRouter } from './routes/public-feed.ts';
import { engagementRouter as marketplaceEngagementRouter } from './routes/marketplace/engagement.ts';
import { subscriptionsRouter as marketplaceSubscriptionsRouter } from './routes/marketplace/subscriptions.ts';
import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts';
@ -90,6 +91,10 @@ app.route('/api/v1/marketplace/me', marketplaceMeRouter());
app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter());
app.route('/api/v1/marketplace/decks', marketplaceDecksRouter());
// Plattform-Public-Feed-Konvention (LOCAL_FIRST_LOGIN_OPTIONAL.md §3 D).
// hub.mana.how/timeline aggregiert über /api/v1/public/feed aller Apps.
app.route('/api/v1/public', publicFeedRouter());
app.get('/', (c) =>
c.json({
app: 'wordeck',

View file

@ -0,0 +1,118 @@
/**
* Public-Feed-Endpoint nach Plattform-Konvention
* (siehe `mana/docs/playbooks/LOCAL_FIRST_LOGIN_OPTIONAL.md` Konvention D).
*
* Shape:
* GET /api/v1/public/feed?cursor=<opaque>&limit=20
* { items: [{ id, type, slug, title, excerpt, author, publishedAt, ... }],
* nextCursor: <opaque> | null }
*
* Wordecks Public-Items sind die published Marketplace-Decks. Wir wrappen
* den Marketplace-Browse mit dem Konventions-Output. Aggregator-Apps wie
* `hub.mana.how/timeline` konsumieren genau diese Shape.
*
* Keine Auth pure public read.
*/
import { Hono } from 'hono';
import { authors, publicDecks } from '../db/schema/index.ts';
import { count, eq, and, desc, sql } from 'drizzle-orm';
import { getDb, type CardsDb } from '../db/connection.ts';
interface FeedItem {
id: string;
type: 'deck';
slug: string;
title: string;
excerpt: string | null;
author: {
slug: string;
display_name: string;
};
publishedAt: string;
stats: {
card_count: number;
star_count: number;
};
}
function encodeCursor(offset: number): string {
return Buffer.from(JSON.stringify({ offset })).toString('base64url');
}
function decodeCursor(cursor: string): number {
try {
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
return typeof parsed.offset === 'number' ? parsed.offset : 0;
} catch {
return 0;
}
}
async function loadPage(
db: CardsDb,
offset: number,
limit: number,
): Promise<{ items: FeedItem[]; total: number }> {
const starCount = sql<number>`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id})`;
const cardCountExpr = sql<number>`COALESCE((SELECT v.card_count FROM marketplace.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`;
const rows = await db
.select({
id: publicDecks.id,
slug: publicDecks.slug,
title: publicDecks.title,
description: publicDecks.description,
createdAt: publicDecks.createdAt,
cardCount: cardCountExpr,
starCount,
ownerSlug: authors.slug,
ownerDisplayName: authors.displayName,
})
.from(publicDecks)
.innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
.where(and(eq(publicDecks.isTakedown, false)))
.orderBy(desc(publicDecks.createdAt))
.limit(limit)
.offset(offset);
const totalRow = await db
.select({ value: count() })
.from(publicDecks)
.where(and(eq(publicDecks.isTakedown, false)));
return {
items: rows.map(
(r): FeedItem => ({
id: r.id,
type: 'deck',
slug: r.slug,
title: r.title,
excerpt: r.description,
author: { slug: r.ownerSlug, display_name: r.ownerDisplayName },
publishedAt: r.createdAt.toISOString(),
stats: { card_count: Number(r.cardCount), star_count: Number(r.starCount) },
}),
),
total: totalRow[0]?.value ?? 0,
};
}
export function publicFeedRouter() {
const r = new Hono();
r.get('/feed', async (c) => {
const url = new URL(c.req.url);
const limitRaw = url.searchParams.get('limit');
const cursor = url.searchParams.get('cursor');
const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '20', 10) || 20), 100);
const offset = cursor ? decodeCursor(cursor) : 0;
const { items, total } = await loadPage(getDb(), offset, limit);
const nextOffset = offset + items.length;
const nextCursor = nextOffset < total ? encodeCursor(nextOffset) : null;
return c.json({ items, nextCursor });
});
return r;
}

View file

@ -214,8 +214,33 @@
</p>
<p>
Lokal speichert die App im Browser-localStorage technisch notwendige
Daten (Auth-Token, Spracheinstellung, sql-wasm-Cache für Offline-
Study). Wir nutzen keine Tracking-Cookies.
Daten (Auth-Token, Spracheinstellung). Wir nutzen keine
Tracking-Cookies.
</p>
<h3 class="mt-4 text-base font-semibold">7a. Lokale Daten + anonyme Nutzung</h3>
<p>
Seit Mai 2026 läuft Wordeck <strong>local-first</strong>: Decks,
Karten und Lern-Reviews werden zuerst in Ihrer Browser-IndexedDB
gespeichert (Datenbank-Name <code>manasync_wordeck</code>), nicht
direkt auf unseren Servern. Sie können die App komplett ohne
Konto nutzen — eine anonyme Geräte-ID
(<code>anon:&lt;ulid&gt;</code>) wird lokal erzeugt und verlässt
Ihr Gerät nicht.
</p>
<p>
Sobald Sie sich anmelden, werden alle bisher anonym gesammelten
Daten dem Konto zugeordnet und an unseren Sync-Server
<code>sync2.mana.how</code> übertragen — verschlüsselt mit einem
User-eigenen Master-Key (AES-GCM-256), der über
<code>auth.mana.how</code> in Ihrem Vault liegt. Inhalte
(Karteninhalte, Notizen) sind serverseitig nicht im Klartext
lesbar.
</p>
<p>
Sie können jederzeit ohne Anmeldung weiter lokal arbeiten —
diese Daten bleiben dann auf Ihrem Gerät. Bei Browser-Reset oder
manueller Löschung der IndexedDB sind sie verloren, sofern Sie
vorher kein Konto angelegt haben.
</p>
<h2 class="mt-6 text-xl font-semibold">8. Ihre Rechte</h2>