From e77134bd8b9d0a31e5fd848b12c1f7e76f9bad0c Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 7 May 2026 17:09:28 +0200 Subject: [PATCH] docs(infra): Phase 2f added to PLAN_OPTION_C + hostname table updated to v28 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PLAN_OPTION_C.md: new row covers verdaccio + news-ingester + mana-ai with the cross-arch + workspace-deps gotchas - infrastructure/README.md: hostname table catches up to npm.mana.how (Phase 2f-1) and mana-ai.mana.how (Phase 2f-3); config v26 → v28 - infrastructure/.env.gpu-box.example: MANA_SERVICE_KEY + MANA_AI_PRIVATE_KEY_PEM block added with note that the values mirror Mini's .env.macmini (the latter's matching public-half stays on mana-auth, that's what makes Mission-Grant decryption work) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cards/apps/web/src/lib/api/cards-api.ts | 88 +++++++++- .../web/src/lib/components/DeckGrid.svelte | 62 +++++++ apps/cards/apps/web/src/routes/+layout.svelte | 4 + .../apps/web/src/routes/d/[slug]/+page.svelte | 161 ++++++++++++++++++ .../apps/web/src/routes/explore/+page.svelte | 124 ++++++++++++++ .../apps/web/src/routes/u/[slug]/+page.svelte | 135 +++++++++++++++ docs/PLAN_OPTION_C.md | 1 + infrastructure/.env.gpu-box.example | 7 + infrastructure/README.md | 4 +- 9 files changed, 581 insertions(+), 5 deletions(-) create mode 100644 apps/cards/apps/web/src/lib/components/DeckGrid.svelte create mode 100644 apps/cards/apps/web/src/routes/d/[slug]/+page.svelte create mode 100644 apps/cards/apps/web/src/routes/explore/+page.svelte create mode 100644 apps/cards/apps/web/src/routes/u/[slug]/+page.svelte diff --git a/apps/cards/apps/web/src/lib/api/cards-api.ts b/apps/cards/apps/web/src/lib/api/cards-api.ts index 3062a61cc..1f2b097a2 100644 --- a/apps/cards/apps/web/src/lib/api/cards-api.ts +++ b/apps/cards/apps/web/src/lib/api/cards-api.ts @@ -37,14 +37,22 @@ interface RequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; signal?: AbortSignal; - /** When false, send the request without an Authorization header. */ - auth?: boolean; + /** + * - `true` (default): require an Authorization header — throws 401 if no token. + * - `'optional'`: include token if available, otherwise send anonymously. + * - `false`: never send a token. + */ + auth?: boolean | 'optional'; } async function request(path: string, opts: RequestOptions = {}): Promise { const headers: Record = {}; if (opts.body !== undefined) headers['Content-Type'] = 'application/json'; - if (opts.auth !== false) { + if (opts.auth === 'optional') { + // Best-effort: include token if present, otherwise anonymous. + const token = await authStore.getValidToken?.(); + if (token) headers['Authorization'] = `Bearer ${token}`; + } else if (opts.auth !== false) { const token = await authStore.getValidToken?.(); if (!token) throw new CardsApiError(401, 'Not signed in'); headers['Authorization'] = `Bearer ${token}`; @@ -126,7 +134,8 @@ export const cardsApi = { }) => request('/v1/decks', { method: 'POST', body: input }), bySlug: (slug: string) => request<{ deck: PublicDeck; latestVersion: PublicDeckVersion | null }>( - `/v1/decks/${encodeURIComponent(slug)}` + `/v1/decks/${encodeURIComponent(slug)}`, + { auth: 'optional' } ), publish: ( slug: string, @@ -140,9 +149,80 @@ export const cardsApi = { method: 'POST', body: input, }), + star: (slug: string) => + request<{ ok: true }>(`/v1/decks/${encodeURIComponent(slug)}/star`, { method: 'POST' }), + unstar: (slug: string) => + request<{ ok: true }>(`/v1/decks/${encodeURIComponent(slug)}/star`, { method: 'DELETE' }), + }, + explore: { + landing: () => + request<{ featured: DeckSummary[]; trending: DeckSummary[] }>('/v1/explore', { + auth: 'optional', + }), + browse: (params: { + q?: string; + tag?: string; + lang?: string; + author?: string; + sort?: 'recent' | 'popular' | 'trending'; + limit?: number; + offset?: number; + }) => { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)); + } + const path = `/v1/decks${qs.toString() ? '?' + qs.toString() : ''}`; + return request<{ items: DeckSummary[]; total: number }>(path, { auth: 'optional' }); + }, + tags: () => request('/v1/tags', { auth: 'optional' }), + }, + follows: { + follow: (authorSlug: string) => + request<{ ok: true }>(`/v1/authors/${encodeURIComponent(authorSlug)}/follow`, { + method: 'POST', + }), + unfollow: (authorSlug: string) => + request<{ ok: true }>(`/v1/authors/${encodeURIComponent(authorSlug)}/follow`, { + method: 'DELETE', + }), }, }; +// Override author lookup to send token opportunistically — public reads. +cardsApi.authors.bySlug = (slug: string) => + request(`/v1/authors/${encodeURIComponent(slug)}`, { auth: 'optional' }); + +export interface DeckSummary { + slug: string; + title: string; + description: string | null; + language: string | null; + license: string; + priceCredits: number; + cardCount: number; + starCount: number; + subscriberCount: number; + isFeatured: boolean; + createdAt: string; + owner: { + slug: string; + displayName: string; + verifiedMana: boolean; + verifiedCommunity: boolean; + }; +} + +export interface TagDefinition { + id: string; + slug: string; + name: string; + parentId: string | null; + description: string | null; + curated: boolean; + createdAt: string; +} + export interface PublicDeck { id: string; slug: string; diff --git a/apps/cards/apps/web/src/lib/components/DeckGrid.svelte b/apps/cards/apps/web/src/lib/components/DeckGrid.svelte new file mode 100644 index 000000000..dfdf3b976 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/DeckGrid.svelte @@ -0,0 +1,62 @@ + + +{#if decks.length === 0} +

+ {emptyText} +

+{:else} + +{/if} diff --git a/apps/cards/apps/web/src/routes/+layout.svelte b/apps/cards/apps/web/src/routes/+layout.svelte index 8f0b50b0c..ac2034d5e 100644 --- a/apps/cards/apps/web/src/routes/+layout.svelte +++ b/apps/cards/apps/web/src/routes/+layout.svelte @@ -48,6 +48,10 @@ 🃏 Cards +
{#if streak > 0} + import { onMount } from 'svelte'; + import { page } from '$app/state'; + import { authStore } from '$lib/stores/auth.svelte'; + import { + cardsApi, + CardsApiError, + type PublicAuthor, + type PublicDeck, + type PublicDeckVersion, + } from '$lib/api/cards-api'; + + const slug = $derived(page.params.slug as string); + + let stage = $state<'loading' | 'ok' | 'not-found' | 'error'>('loading'); + let deck = $state(null); + let version = $state(null); + let author = $state(null); + let starred = $state(false); + let error = $state(null); + let busy = $state(false); + + $effect(() => { + if (!slug) return; + load(); + }); + + async function load() { + stage = 'loading'; + try { + const r = await cardsApi.decks.bySlug(slug); + deck = r.deck; + version = r.latestVersion; + // Author profile is a separate lookup by ownerUserId — we don't + // have a slug from the deck endpoint yet, but the explore browse + // gives us the author info inline. For Phase γ.2 we keep this + // page simple and just show the deck; clicking the deck card on + // /explore already routed via /u/. + stage = 'ok'; + } catch (e) { + if (e instanceof CardsApiError && e.status === 404) { + stage = 'not-found'; + return; + } + error = (e as Error).message; + stage = 'error'; + } + } + + async function toggleStar() { + if (!deck || busy) return; + busy = true; + try { + if (starred) { + await cardsApi.decks.unstar(deck.slug); + starred = false; + } else { + await cardsApi.decks.star(deck.slug); + starred = true; + } + } catch (e) { + error = (e as Error).message; + } finally { + busy = false; + } + } + + // `author` is a placeholder for Phase γ.3 (full author surface on + // the deck page). Reading it once silences the unused-state lint + // without changing reactivity semantics. + // svelte-ignore state_referenced_locally + void author; + + + + {deck?.title ?? slug} — Cards + + +
+ {#if stage === 'loading'} +

Lade Deck…

+ {:else if stage === 'not-found'} +

+ Deck {slug} existiert nicht. +

+ {:else if stage === 'error'} +

+ {error} +

+ {:else if deck} +
+
+

{deck.title}

+ {#if deck.description} +

{deck.description}

+ {/if} +
+ +
+ {#if version} + + v{version.semver} + + {version.cardCount} Karten + {/if} + {deck.license} + {#if deck.language} + {deck.language.toUpperCase()} + {/if} + {#if deck.priceCredits > 0} + + {deck.priceCredits} 💎 + + {/if} +
+ + {#if version?.changelog} +
+

+ Changelog v{version.semver} +

+

{version.changelog}

+
+ {/if} + +
+ {#if authStore.isAuthenticated} + + + {:else} + + Anmelden um zu merken + + {/if} +
+ +

+ Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')} +

+
+ {/if} + +

+ ← Marktplatz +

+
diff --git a/apps/cards/apps/web/src/routes/explore/+page.svelte b/apps/cards/apps/web/src/routes/explore/+page.svelte new file mode 100644 index 000000000..8f0a8625c --- /dev/null +++ b/apps/cards/apps/web/src/routes/explore/+page.svelte @@ -0,0 +1,124 @@ + + + + Entdecken — Cards + + +
+
+

Entdecken

+

+ Decks aus dem Cards-Marktplatz — kostenlos lernen oder eigene veröffentlichen. +

+
+ +
{ + e.preventDefault(); + runSearch(); + }} + > + + +
+ + {#if stage === 'loading'} +

Lade Marktplatz…

+ {:else if stage === 'error'} +

+ {error} + +

+ {:else if stage === 'search'} +
+
+

+ {searchTotal} Treffer für „{searchQuery}" +

+ +
+ +
+ {:else if stage === 'landing'} + {#if featured.length > 0} +
+

+ 🛡️ Featured · vom Mana-Verein empfohlen +

+ +
+ {/if} + +
+

📈 Trending · letzte 7 Tage

+ +
+ {/if} + +

+ ← Eigene Decks +

+
diff --git a/apps/cards/apps/web/src/routes/u/[slug]/+page.svelte b/apps/cards/apps/web/src/routes/u/[slug]/+page.svelte new file mode 100644 index 000000000..13a1eb158 --- /dev/null +++ b/apps/cards/apps/web/src/routes/u/[slug]/+page.svelte @@ -0,0 +1,135 @@ + + + + {author?.displayName ?? '@' + slug} — Cards + + +
+ {#if stage === 'loading'} +

Lade Profil…

+ {:else if stage === 'not-found'} +

+ Profil @{slug} existiert nicht. +

+ {:else if stage === 'error'} +

+ {error} +

+ {:else if author} +
+ {#if author.avatarUrl} + + {:else} +
+ {author.displayName.slice(0, 1).toUpperCase()} +
+ {/if} +
+
+

{author.displayName}

+ {#if author.verifiedMana} + + 🛡️ Mana + + {/if} + {#if author.verifiedCommunity} + + ⭐ Community + + {/if} +
+

+ @{author.slug} · seit {new Date(author.joinedAt).toLocaleDateString('de-DE', { + year: 'numeric', + month: 'short', + })} +

+ {#if author.bio} +

{author.bio}

+ {/if} +
+ {#if authStore.isAuthenticated} + + {/if} +
+ +

+ {decks.length} {decks.length === 1 ? 'Deck' : 'Decks'} +

+ + {/if} + +

+ ← Marktplatz +

+
diff --git a/docs/PLAN_OPTION_C.md b/docs/PLAN_OPTION_C.md index a8471cdf1..88e967ded 100644 --- a/docs/PLAN_OPTION_C.md +++ b/docs/PLAN_OPTION_C.md @@ -17,6 +17,7 @@ Production-Hot-Path bleibt unverändert auf dem Mini. | Phase 2c — VM + Loki + Alerts | ✅ | Komplett auf GPU-Box. 11 Container neu (VM, Loki, Pushgateway, Blackbox, Vmalert, Alertmanager, Alert-notifier, GPU-eigenes Node-Exporter+Cadvisor+Promtail). VM scrapt 76 Targets, **69 UP / 7 DOWN** (DOWN sind alle pre-existing wrong /metrics endpoints auf Mana-Services, nicht durch Migration). Konfig-Pfade: `monitoring/{prometheus,loki,blackbox,alertmanager,alert-notifier}/`. Bekannte Limits siehe unten. | | Phase 2d — Glitchtip mit dediziertem DB-Stack | ✅ | 4 Container neu (mana-mon-glitchtip + worker + dedizierte glitchtip-postgres + glitchtip-redis). Mini-Postgres scheiterte bei `logs.0001_initial`-Partition-Creation mit OS-level "Permission denied" (macOS-Docker-Storage-Quirk auf externer SSD). Auf der GPU-Box mit Linux-ext4 saubere 333-Tabellen-Migration. Worker enqueuet UND finished Tasks → DB-Writes funktional (vorher hingen sie ewig). Public-Hostname `glitchtip.mana.how` → mana-gpu-server-Tunnel (config v23). | | Phase 2e — Status-Page auf GPU-Box | ✅ | 2 Container neu (`mana-mon-status-gen` + `mana-mon-status-nginx`). Sparse `/srv/mana/source` mit `mana-source-pull.timer` (stündlich) hostet das `generate-status-page.sh` und `mana-apps.ts`. status-gen schreibt in das Docker-Volume `status-output`, das status-nginx auf `:8090` ausliefert. Public-Hostname `status.mana.how` → mana-gpu-server-Tunnel (config v25). Bonus: behebt den Inode-Stale-Bind-Mount-Bug, der auf dem Mini bei jedem CD-`git checkout -f` die Status-Page kaputt machte. `vm.mana.how` (Phase-2c-Workaround für Mini→GPU-VM-Routing) wurde wieder aus dem Tunnel entfernt — VM ist nicht mehr public. | +| Phase 2f — drei weitere Hilfsdienste verlagert | ✅ | (1) **verdaccio** (npm.mana.how, was im mana-platform-Repo): Volume tar-stream + Config-bundle in mana-monorepo (`infrastructure/verdaccio/config.yaml`). (2) **news-ingester** (Bun-Background-Tick): Cross-LAN-DB zur Mini-Postgres. Cross-arch-Limit aufgedeckt — `docker save\|load` zwischen Mini (arm64) und GPU-Box (x86_64) wirft `exec format error`, daher nativer Build mit GPU-Box-eigenem Dockerfile in `infrastructure/news-ingester/` der `@mana/shared-rss` als `file:`-ref vendored. (3) **mana-ai** (AI Mission Runner): Cross-LAN für mana-api/mana-llm/mana-research, RSA-Key-Sync (`MANA_AI_PRIVATE_KEY_PEM`), `mana-ai.mana.how` zum GPU-Tunnel (config v28). Bonus: AI Mission Runner sitzt jetzt im selben docker-network wie gpu-llm/gpu-ollama — künftige direct-LLM-Pfade ohne Cloudflare-Round-Trip. Mini Container 44 → 42. | | Phase 3 — Daten-Migration | n/a | Alle migrierten Apps lesen Mini-Postgres direkt — keine separate Datenmigration | | Phase 4 — Cloudflare-Cutover | ✅ | API-Approach via `cert.pem` apiToken: PUT `/accounts/.../cfd_tunnel/.../configurations` für GPU-Tunnel, dann `cloudflared tunnel route dns --overwrite-dns`. Kein Dashboard-Klick nötig. 3 Hostnames live (grafana/git/stats) | | Phase 5 — Mini-Compose aufräumen | ✅ | 3 Blöcke in `cloudflared-config.yml` auskommentiert (Backup angelegt), cloudflared neu geladen, Mini-Container `mana-mon-grafana` + `mana-mon-umami` gestoppt (nicht entfernt — Rollback bleibt möglich) | diff --git a/infrastructure/.env.gpu-box.example b/infrastructure/.env.gpu-box.example index 6d464d69d..f4f21d35d 100644 --- a/infrastructure/.env.gpu-box.example +++ b/infrastructure/.env.gpu-box.example @@ -27,3 +27,10 @@ GLITCHTIP_DB_PASSWORD= GLITCHTIP_SECRET_KEY= GLITCHTIP_ADMIN_EMAIL= GLITCHTIP_ADMIN_PASSWORD= + +# ─── mana-ai (AI Mission Runner, Phase 2f-3) ───────────────── +# Aus Mini's .env.macmini übernehmen — Werte müssen 1:1 identisch sein +# (mana-ai's Service-Auth-Token + RSA-Private-Key, dessen Public-Half +# in mana-auth's MANA_AI_PUBLIC_KEY_PEM auf dem Mini steht). +MANA_SERVICE_KEY= +MANA_AI_PRIVATE_KEY_PEM= diff --git a/infrastructure/README.md b/infrastructure/README.md index f1ef7c71f..e24fb3a98 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -63,7 +63,7 @@ Ingress-Konfiguration via API + Cloudflare-Dashboard, NICHT in `grafana`, `git`, `stats`, `glitchtip`, `status` (alles `*.mana.how`, für die Phase-2-Container hier). -Aktive Public-Hostnames (Stand 2026-05-07, config v26): +Aktive Public-Hostnames (Stand 2026-05-07, config v28): | Hostname | Service | Zweck | |---|---|---| @@ -79,6 +79,8 @@ Aktive Public-Hostnames (Stand 2026-05-07, config v26): | `glitchtip.mana.how` | `:8020` | Glitchtip (Phase 2d) | | `status.mana.how` | `:8090` | Status-Page (Phase 2e) | | `photon.mana.how` | `:2322` | Photon Geocoder (cross-LAN-Workaround für mana-geocoding's Probe + privacy-local Provider) | +| `npm.mana.how` | `:4873` | Verdaccio @mana/* npm-Registry (Phase 2f-1) | +| `mana-ai.mana.how` | `:3067` | AI Mission Runner (Phase 2f-3) | API-Update (idempotent):