Initial: RAPPORT-HOST Iteration 1 (proprietär)

Kommerzielle Hosting-/Abo-Plattform für Rapport-Instanzen.

- React-Frontend (Vite/JSX): Landing, Register, Login, Plans, Dashboard
- Node/Express-Backend: Auth (bcrypt+JWT), Stripe-Billing, Provisioning
- HOST-Postgres-Schema: accounts, subscriptions, instances
- Provisioning-Interface + Modell-A-Adapter (Studio im geteilten Stack)
- MOCK-Modus: voller End-to-End-Flow ohne Stripe/Rapport-Stack testbar
- Idempotentes Fulfillment (Upsert auf stripe_subscription_id)
- docker-compose für lokale host-db; identisch auf Hetzner deploybar

E2E lokal verifiziert: Register -> Checkout(mock) -> Instanz -> Idempotenz.

Lizenz: proprietär (kein AGPL-Code eingebunden, nur Netzwerk-API zur Familie).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 15:35:47 +02:00
commit 6290475ea3
34 changed files with 4115 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
// Kunden-Dashboard-Daten: Konto + aktuelles Abo + bereitgestellte Instanz.
import { Router } from "express";
import { one } from "../db.js";
import { requireAuth } from "../auth.js";
import { publicPlans } from "../plans.js";
export const accountRouter = Router();
accountRouter.get("/me", requireAuth, async (req, res) => {
const subscription = await one(
`select plan, status, current_period_end, stripe_subscription_id
from subscriptions where account_id = $1 order by created_at desc limit 1`,
[req.account.id]
);
const instance = await one(
`select studio_slug, instance_url, status, created_at
from instances where account_id = $1 order by created_at desc limit 1`,
[req.account.id]
);
res.json({
account: req.account,
subscription: subscription || null,
instance: instance || null,
plans: publicPlans(),
});
});
+34
View File
@@ -0,0 +1,34 @@
// HOST-Konten: Registrierung + Login. Gibt ein JWT zurück.
import { Router } from "express";
import { one } from "../db.js";
import { hashPassword, verifyPassword, signToken } from "../auth.js";
export const authRouter = Router();
const isEmail = (s) => /.+@.+\..+/.test(s || "");
authRouter.post("/register", async (req, res) => {
const { email, password } = req.body || {};
if (!isEmail(email)) return res.status(400).json({ error: "Ungültige Email." });
if (!password || password.length < 8) return res.status(400).json({ error: "Passwort min. 8 Zeichen." });
const existing = await one("select id from accounts where email = $1", [email.toLowerCase()]);
if (existing) return res.status(409).json({ error: "Konto existiert bereits." });
const account = await one(
"insert into accounts (email, password_hash) values ($1, $2) returning id, email",
[email.toLowerCase(), await hashPassword(password)]
);
res.json({ token: signToken(account), account: { id: account.id, email: account.email } });
});
authRouter.post("/login", async (req, res) => {
const { email, password } = req.body || {};
const account = await one("select id, email, password_hash from accounts where email = $1", [
(email || "").toLowerCase(),
]);
if (!account || !(await verifyPassword(password || "", account.password_hash))) {
return res.status(401).json({ error: "Email oder Passwort falsch." });
}
res.json({ token: signToken(account), account: { id: account.id, email: account.email } });
});
+117
View File
@@ -0,0 +1,117 @@
// Billing: Plan wählen → Stripe-Checkout → (Webhook) → Instanz freischalten.
//
// Stripe-Modus: /checkout erstellt eine Checkout-Session, Stripe leitet den
// Kunden weiter; nach Zahlung ruft Stripe /webhook auf, das die
// Instanz provisioniert.
// MOCK-Modus: ohne Stripe-Key provisioniert /checkout sofort selbst (so ist
// der ganze Flow lokal ohne Stripe testbar) und gibt eine
// interne Erfolgs-URL zurück.
import { Router } from "express";
import { one, query } from "../db.js";
import { requireAuth } from "../auth.js";
import { getPlan, publicPlans } from "../plans.js";
import { env, stripeEnabled } from "../env.js";
import { stripe } from "../stripe.js";
import { provision } from "../provisioning/index.js";
export const billingRouter = Router();
billingRouter.get("/plans", (_req, res) => res.json({ plans: publicPlans() }));
// Gemeinsame Fulfillment-Logik: Abo speichern + Instanz provisionieren.
// Idempotent über stripe_subscription_id (bzw. Mock-Kennung).
async function fulfill({ accountId, plan, stripeCustomerId, stripeSubscriptionId, status, periodEnd }) {
const account = await one("select id, email from accounts where id = $1", [accountId]);
if (!account) throw new Error("Konto nicht gefunden: " + accountId);
await query(
`insert into subscriptions
(account_id, plan, status, stripe_customer_id, stripe_subscription_id, current_period_end)
values ($1,$2,$3,$4,$5,$6)
on conflict (stripe_subscription_id) do update
set status = excluded.status, current_period_end = excluded.current_period_end`,
[accountId, plan.id, status, stripeCustomerId, stripeSubscriptionId, periodEnd]
);
// Nur provisionieren, wenn für dieses Konto noch keine aktive Instanz da ist.
const existing = await one("select id from instances where account_id = $1 limit 1", [accountId]);
if (existing) return;
const result = await provision({ account, plan });
await query(
`insert into instances (account_id, studio_id, studio_slug, instance_url, status)
values ($1,$2,$3,$4,'active')`,
[accountId, result.studioId, result.slug, result.instanceUrl]
);
console.log(`✓ Instanz freigeschaltet für ${account.email}: ${result.instanceUrl}`);
}
billingRouter.post("/checkout", requireAuth, async (req, res) => {
const plan = getPlan(req.body?.planId);
if (!plan) return res.status(400).json({ error: "Unbekannter Plan." });
// ── MOCK: sofort freischalten ──────────────────────────────────────────────
if (!stripeEnabled) {
await fulfill({
accountId: req.account.id,
plan,
stripeCustomerId: "mock_cus_" + req.account.id,
stripeSubscriptionId: "mock_sub_" + req.account.id + "_" + plan.id,
status: "active",
periodEnd: new Date(Date.now() + 30 * 864e5),
});
return res.json({ mock: true, url: `${env.publicBaseUrl}/dashboard?provisioned=1` });
}
// ── Stripe-Checkout-Session ────────────────────────────────────────────────
if (!plan.stripePriceId) return res.status(500).json({ error: "Kein Stripe-Price für diesen Plan konfiguriert." });
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: plan.stripePriceId, quantity: 1 }],
customer_email: req.account.email,
client_reference_id: req.account.id,
metadata: { accountId: req.account.id, planId: plan.id },
success_url: `${env.publicBaseUrl}/dashboard?provisioned=1`,
cancel_url: `${env.publicBaseUrl}/plans?canceled=1`,
});
res.json({ url: session.url });
});
// Stripe-Webhook. Braucht RAW Body (Signaturprüfung) — in index.js so verdrahtet.
billingRouter.post("/webhook", async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], env.stripe.webhookSecret);
} catch (err) {
console.error("Webhook-Signatur ungültig:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
try {
if (event.type === "checkout.session.completed") {
const s = event.data.object;
const plan = getPlan(s.metadata?.planId);
const sub = await stripe.subscriptions.retrieve(s.subscription);
await fulfill({
accountId: s.metadata?.accountId || s.client_reference_id,
plan,
stripeCustomerId: s.customer,
stripeSubscriptionId: s.subscription,
status: sub.status,
periodEnd: new Date(sub.current_period_end * 1000),
});
} else if (event.type === "customer.subscription.deleted") {
const sub = event.data.object;
await query("update subscriptions set status = 'canceled' where stripe_subscription_id = $1", [sub.id]);
await query(
`update instances set status = 'suspended'
where account_id = (select account_id from subscriptions where stripe_subscription_id = $1)`,
[sub.id]
);
}
res.json({ received: true });
} catch (err) {
console.error("Webhook-Verarbeitung fehlgeschlagen:", err);
res.status(500).json({ error: "fulfillment failed" });
}
});