docs(infra): Phase 2f added to PLAN_OPTION_C + hostname table updated to v28

- 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 17:09:28 +02:00
parent a8cce79e4c
commit e77134bd8b
9 changed files with 581 additions and 5 deletions

View file

@ -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<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {};
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<PublicDeck>('/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<TagDefinition[]>('/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<PublicAuthor>(`/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;

View file

@ -0,0 +1,62 @@
<script lang="ts">
import type { DeckSummary } from '$lib/api/cards-api';
interface Props {
decks: DeckSummary[];
emptyText?: string;
}
let { decks, emptyText = 'Noch keine Decks.' }: Props = $props();
function badgeClass(d: DeckSummary): string {
if (d.owner.verifiedMana) return 'bg-emerald-500/15 text-emerald-300';
if (d.owner.verifiedCommunity) return 'bg-amber-500/15 text-amber-300';
return '';
}
function badgeText(d: DeckSummary): string {
if (d.owner.verifiedMana) return '🛡️';
if (d.owner.verifiedCommunity) return '⭐';
return '';
}
</script>
{#if decks.length === 0}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-400">
{emptyText}
</p>
{:else}
<ul class="grid gap-3 sm:grid-cols-2">
{#each decks as deck (deck.slug)}
<li>
<a
href={`/d/${deck.slug}`}
class="block rounded-xl border border-neutral-800 bg-neutral-900 p-4 transition-colors hover:border-neutral-700 hover:bg-neutral-800"
>
<div class="mb-1 flex items-start justify-between gap-3">
<h3 class="font-semibold leading-tight">{deck.title}</h3>
{#if deck.priceCredits > 0}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
{deck.priceCredits} 💎
</span>
{/if}
</div>
{#if deck.description}
<p class="mb-2 line-clamp-2 text-xs text-neutral-400">{deck.description}</p>
{/if}
<div class="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
<!-- Author shows as text inside the deck-link; the deck card
navigates to the deck page, the author profile is one
hop further from there. Keeps HTML valid (no nested <a>). -->
<span class="text-neutral-300">{deck.owner.displayName}</span>
{#if badgeText(deck)}
<span class="rounded-full px-1.5 py-0.5 {badgeClass(deck)}">{badgeText(deck)}</span>
{/if}
<span>· {deck.cardCount} Karten</span>
<span>· ⭐ {deck.starCount}</span>
{#if deck.language}<span>· {deck.language.toUpperCase()}</span>{/if}
</div>
</a>
</li>
{/each}
</ul>
{/if}

View file

@ -48,6 +48,10 @@
<a href="/" class="flex items-center gap-2 text-sm font-semibold tracking-tight">
<span class="text-base">🃏</span> Cards
</a>
<nav class="flex items-center gap-4 text-xs text-neutral-400">
<a href="/" class="hover:text-neutral-100">Meine Decks</a>
<a href="/explore" class="hover:text-neutral-100">Entdecken</a>
</nav>
<div class="flex items-center gap-3 text-xs text-neutral-500">
{#if streak > 0}
<span

View file

@ -0,0 +1,161 @@
<script lang="ts">
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<PublicDeck | null>(null);
let version = $state<PublicDeckVersion | null>(null);
let author = $state<PublicAuthor | null>(null);
let starred = $state(false);
let error = $state<string | null>(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/<slug>.
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;
</script>
<svelte:head>
<title>{deck?.title ?? slug} — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
{#if stage === 'loading'}
<p class="py-12 text-center text-sm text-neutral-400">Lade Deck…</p>
{:else if stage === 'not-found'}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-400">
Deck <code class="rounded bg-neutral-800 px-1">{slug}</code> existiert nicht.
</p>
{:else if stage === 'error'}
<p class="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
{error}
</p>
{:else if deck}
<article>
<header class="mb-6">
<h1 class="text-3xl font-semibold tracking-tight">{deck.title}</h1>
{#if deck.description}
<p class="mt-2 text-sm text-neutral-400">{deck.description}</p>
{/if}
</header>
<div class="mb-6 flex flex-wrap items-center gap-3 text-sm">
{#if version}
<span class="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
v{version.semver}
</span>
<span class="text-neutral-400">{version.cardCount} Karten</span>
{/if}
<span class="text-neutral-400">{deck.license}</span>
{#if deck.language}
<span class="text-neutral-400">{deck.language.toUpperCase()}</span>
{/if}
{#if deck.priceCredits > 0}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
{deck.priceCredits} 💎
</span>
{/if}
</div>
{#if version?.changelog}
<section class="mb-6 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 class="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-500">
Changelog v{version.semver}
</h2>
<p class="whitespace-pre-line text-sm text-neutral-300">{version.changelog}</p>
</section>
{/if}
<div class="flex flex-wrap items-center gap-2">
{#if authStore.isAuthenticated}
<button
class="rounded-lg border border-indigo-500/40 px-4 py-2 text-sm text-indigo-300 hover:bg-indigo-500/10 disabled:opacity-50"
onclick={toggleStar}
disabled={busy}
>
{starred ? '★ Markiert' : '☆ Merken'}
</button>
<button
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
disabled
title="Subscribe + Smart-Merge folgt in Phase δ"
>
Abonnieren · Phase δ
</button>
{:else}
<a
href="/login"
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
>
Anmelden um zu merken
</a>
{/if}
</div>
<p class="mt-10 text-xs text-neutral-500">
Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}
</p>
</article>
{/if}
<p class="mt-12 text-center text-xs text-neutral-600">
<a href="/explore" class="hover:text-neutral-300">← Marktplatz</a>
</p>
</main>

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { cardsApi, type DeckSummary } from '$lib/api/cards-api';
import DeckGrid from '$lib/components/DeckGrid.svelte';
let stage = $state<'loading' | 'landing' | 'search' | 'error'>('loading');
let featured = $state<DeckSummary[]>([]);
let trending = $state<DeckSummary[]>([]);
let searchQuery = $state('');
let searchResults = $state<DeckSummary[]>([]);
let searchTotal = $state(0);
let searchBusy = $state(false);
let error = $state<string | null>(null);
onMount(loadLanding);
async function loadLanding() {
stage = 'loading';
try {
const r = await cardsApi.explore.landing();
featured = r.featured;
trending = r.trending;
stage = 'landing';
} catch (e) {
error = (e as Error).message;
stage = 'error';
}
}
async function runSearch() {
const q = searchQuery.trim();
if (!q) {
loadLanding();
return;
}
searchBusy = true;
try {
const r = await cardsApi.explore.browse({ q, sort: 'popular', limit: 30 });
searchResults = r.items;
searchTotal = r.total;
stage = 'search';
} catch (e) {
error = (e as Error).message;
stage = 'error';
} finally {
searchBusy = false;
}
}
</script>
<svelte:head>
<title>Entdecken — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
<header class="mb-6">
<h1 class="text-3xl font-semibold tracking-tight">Entdecken</h1>
<p class="text-sm text-neutral-400">
Decks aus dem Cards-Marktplatz — kostenlos lernen oder eigene veröffentlichen.
</p>
</header>
<form
class="mb-6 flex gap-2"
onsubmit={(e) => {
e.preventDefault();
runSearch();
}}
>
<input
type="search"
bind:value={searchQuery}
placeholder="Suche nach Titel oder Beschreibung…"
class="flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
<button
type="submit"
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
disabled={searchBusy}
>
{searchBusy ? 'Suche…' : 'Suchen'}
</button>
</form>
{#if stage === 'loading'}
<p class="py-12 text-center text-sm text-neutral-400">Lade Marktplatz…</p>
{:else if stage === 'error'}
<p class="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
{error}
<button class="ml-2 underline" onclick={loadLanding}>Erneut versuchen</button>
</p>
{:else if stage === 'search'}
<section>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-medium text-neutral-300">
{searchTotal} Treffer für „{searchQuery}"
</h2>
<button class="text-xs text-neutral-500 hover:text-neutral-200" onclick={loadLanding}>
Zurück
</button>
</div>
<DeckGrid decks={searchResults} emptyText="Keine Decks gefunden." />
</section>
{:else if stage === 'landing'}
{#if featured.length > 0}
<section class="mb-8">
<h2 class="mb-3 text-sm font-medium text-neutral-300">
🛡️ Featured · vom Mana-Verein empfohlen
</h2>
<DeckGrid decks={featured} />
</section>
{/if}
<section>
<h2 class="mb-3 text-sm font-medium text-neutral-300">📈 Trending · letzte 7 Tage</h2>
<DeckGrid decks={trending} emptyText="Noch keine Trends sei der/die Erste mit einem Public-Deck." />
</section>
{/if}
<p class="mt-12 text-center text-xs text-neutral-600">
<a href="/" class="hover:text-neutral-300">← Eigene Decks</a>
</p>
</main>

View file

@ -0,0 +1,135 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { authStore } from '$lib/stores/auth.svelte';
import { cardsApi, CardsApiError, type PublicAuthor, type DeckSummary } from '$lib/api/cards-api';
import DeckGrid from '$lib/components/DeckGrid.svelte';
const slug = $derived(page.params.slug as string);
let stage = $state<'loading' | 'ok' | 'not-found' | 'error'>('loading');
let author = $state<PublicAuthor | null>(null);
let decks = $state<DeckSummary[]>([]);
let following = $state(false);
let error = $state<string | null>(null);
let busy = $state(false);
$effect(() => {
if (!slug) return;
load();
});
async function load() {
stage = 'loading';
try {
const [a, d] = await Promise.all([
cardsApi.authors.bySlug(slug),
cardsApi.explore.browse({ author: slug, sort: 'recent', limit: 50 }),
]);
author = a;
decks = d.items;
stage = 'ok';
} catch (e) {
if (e instanceof CardsApiError && e.status === 404) {
stage = 'not-found';
return;
}
error = (e as Error).message;
stage = 'error';
}
}
async function toggleFollow() {
if (busy) return;
busy = true;
try {
if (following) {
await cardsApi.follows.unfollow(slug);
following = false;
} else {
await cardsApi.follows.follow(slug);
following = true;
}
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>{author?.displayName ?? '@' + slug} — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
{#if stage === 'loading'}
<p class="py-12 text-center text-sm text-neutral-400">Lade Profil…</p>
{:else if stage === 'not-found'}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-8 text-center text-sm text-neutral-400">
Profil <code class="rounded bg-neutral-800 px-1">@{slug}</code> existiert nicht.
</p>
{:else if stage === 'error'}
<p class="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
{error}
</p>
{:else if author}
<header class="mb-6 flex items-start gap-4">
{#if author.avatarUrl}
<img
src={author.avatarUrl}
alt=""
class="h-16 w-16 rounded-full border border-neutral-800 object-cover"
/>
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded-full border border-neutral-800 bg-neutral-900 text-xl font-semibold text-neutral-400"
>
{author.displayName.slice(0, 1).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<div class="flex flex-wrap items-center gap-2">
<h1 class="text-2xl font-semibold">{author.displayName}</h1>
{#if author.verifiedMana}
<span class="rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs text-emerald-300">
🛡️ Mana
</span>
{/if}
{#if author.verifiedCommunity}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
⭐ Community
</span>
{/if}
</div>
<p class="text-xs text-neutral-500">
@{author.slug} · seit {new Date(author.joinedAt).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
})}
</p>
{#if author.bio}
<p class="mt-2 text-sm text-neutral-300">{author.bio}</p>
{/if}
</div>
{#if authStore.isAuthenticated}
<button
class="rounded-lg border border-indigo-500/40 px-3 py-1.5 text-sm text-indigo-300 hover:bg-indigo-500/10 disabled:opacity-50"
onclick={toggleFollow}
disabled={busy}
>
{following ? 'Entfolgen' : 'Folgen'}
</button>
{/if}
</header>
<h2 class="mb-3 text-sm font-medium text-neutral-300">
{decks.length} {decks.length === 1 ? 'Deck' : 'Decks'}
</h2>
<DeckGrid {decks} emptyText="Dieser Author hat noch keine Decks veröffentlicht." />
{/if}
<p class="mt-12 text-center text-xs text-neutral-600">
<a href="/explore" class="hover:text-neutral-300">← Marktplatz</a>
</p>
</main>

View file

@ -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) |

View file

@ -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=

View file

@ -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):