diff --git a/billing-client/package.json b/billing-client/package.json index 589e8987f..f840602d8 100644 --- a/billing-client/package.json +++ b/billing-client/package.json @@ -2,13 +2,13 @@ "name": "@fuzefront/billing-client", "version": "1.0.0", "description": "Typed HTTP client for the FuzeFront billing-service REST API", - "main": "dist/index.js", + "main": "dist/index.cjs", "types": "dist/index.d.ts", "files": [ "dist" ], "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsup", "type-check": "tsc --noEmit", "test": "jest", "clean": "rimraf dist", @@ -38,6 +38,15 @@ "openapi-typescript": "^7.4.0", "rimraf": "^5.0.1", "ts-jest": "29.1.1", - "typescript": "5.1.6" + "typescript": "5.1.6", + "tsup": "^8.0.2" + }, + "module": "dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } } } diff --git a/billing-client/tsconfig.json b/billing-client/tsconfig.json index 8692d47a4..337b0b886 100644 --- a/billing-client/tsconfig.json +++ b/billing-client/tsconfig.json @@ -2,7 +2,9 @@ "compilerOptions": { "target": "ES2020", "module": "commonjs", - "lib": ["ES2020"], + "lib": [ + "ES2020" + ], "outDir": "./dist", "rootDir": "./src", "declaration": true, @@ -11,8 +13,15 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "ignoreDeprecations": "6.0" }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests"] + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "tests" + ] } diff --git a/billing-client/tsup.config.ts b/billing-client/tsup.config.ts new file mode 100644 index 000000000..666abc6dd --- /dev/null +++ b/billing-client/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsup'; + +// Dual build: ESM (.js) + CJS (.cjs) + .d.ts. The ESM output keeps `export *` +// re-exports STATIC so the host bundler (vite/rollup, which bundles billing-ui +// with billing-client external) can resolve named exports. Plain tsc emitted CJS +// __exportStar(require()) → "X is not exported" at host build time (same fix as +// @fuzefront/chat-client). +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + outExtension({ format }) { + return { js: format === 'cjs' ? '.cjs' : '.js' }; + }, +}); diff --git a/deploy/contabo/sealed/billing-secrets.yaml b/deploy/contabo/sealed/billing-secrets.yaml new file mode 100644 index 000000000..7f0a519c0 --- /dev/null +++ b/deploy/contabo/sealed/billing-secrets.yaml @@ -0,0 +1,36 @@ +# ============================================================================= +# SealedSecret: billing-secrets (namespace: fuzefront) +# ----------------------------------------------------------------------------- +# Per-service secret for billing-service ONLY (least privilege). Holds just the +# three billing-specific keys. Shared platform creds (DB_PASSWORD, PERMIT_API_KEY) +# stay in fuzefront-secrets — billing-service reads those from there. A compromised +# billing pod therefore can't read Authentik/SMTP/OAuth/LLM secrets. +# +# Because this secret is isolated to billing (which is enabled: false until these +# are populated), committing/syncing it empty is SAFE — it can't clobber any other +# service's keys (unlike the shared fuzefront-secrets). +# +# Populate (credential-free; --merge-into preserves the others): +# deploy/scripts/seal-secret.sh STRIPE_SECRET_KEY +# deploy/scripts/seal-secret.sh STRIPE_WEBHOOK_SECRET +# deploy/scripts/seal-secret.sh BILLING_INTERNAL_TOKEN --in ~/.fuzefront-secrets/billing-internal-token.txt +# deploy/scripts/seal-secret.sh BILLING_DB_PASSWORD --in ~/.fuzefront-secrets/billing-db-password.txt +# (the script defaults to scope fuzefront/billing-secrets). Then commit + push -> +# Argo (FuzeInfra-operated) decrypts it in-cluster. +# +# Keys: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, BILLING_INTERNAL_TOKEN, BILLING_DB_PASSWORD +# (BILLING_DB_PASSWORD = the billing_svc role's password, created by the +# billing-db-bootstrap Job; generated to ~/.fuzefront-secrets/billing-db-password.txt) +# ============================================================================= +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: billing-secrets + namespace: fuzefront +spec: + encryptedData: {} # populate via deploy/scripts/seal-secret.sh + template: + metadata: + name: billing-secrets + namespace: fuzefront + type: Opaque diff --git a/deploy/helm/fuzefront/templates/billing-db-bootstrap-job.yaml b/deploy/helm/fuzefront/templates/billing-db-bootstrap-job.yaml new file mode 100644 index 000000000..d9fe1b742 --- /dev/null +++ b/deploy/helm/fuzefront/templates/billing-db-bootstrap-job.yaml @@ -0,0 +1,76 @@ +{{- if and .Values.billingService.enabled .Values.billingService.dbBootstrap.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: fuzefront-billing-db-bootstrap + labels: + {{- include "fuzefront.labels" . | nindent 4 }} + app.kubernetes.io/component: billing-db-bootstrap + annotations: + # Idempotent, privileged provisioning of the least-privilege billing_svc role + # and the `billing` schema it owns. Runs BEFORE billing-service starts (which + # self-migrates its schema on boot as billing_svc). Runs as the FuzeInfra + # Postgres superuser — the only place that needs CREATEROLE. + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-4" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + backoffLimit: 3 + ttlSecondsAfterFinished: 600 + template: + metadata: + labels: + app.kubernetes.io/part-of: fuzefront + app.kubernetes.io/component: billing-db-bootstrap + spec: + restartPolicy: Never + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: billing-db-bootstrap + image: postgres:15 + command: ["/bin/sh", "-ec"] + args: + - | + export PGPASSWORD="$DB_SUPERUSER_PASSWORD" + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_SUPERUSER" -d "$DB_NAME" \ + -v ON_ERROR_STOP=1 -v billing_pw="$BILLING_DB_PASSWORD" <<'SQL' + DO $do$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'billing_svc') THEN + CREATE ROLE billing_svc LOGIN PASSWORD :'billing_pw'; + ELSE + ALTER ROLE billing_svc WITH LOGIN PASSWORD :'billing_pw'; + END IF; + END + $do$; + CREATE SCHEMA IF NOT EXISTS billing AUTHORIZATION billing_svc; + GRANT USAGE ON SCHEMA public TO billing_svc; + SQL + echo "billing_svc role + billing schema ensured" + env: + - name: DB_HOST + value: {{ .Values.fuzeinfra.postgres.host | quote }} + - name: DB_PORT + value: {{ .Values.fuzeinfra.postgres.port | quote }} + - name: DB_NAME + value: {{ .Values.database.name | quote }} + - name: DB_SUPERUSER + value: {{ .Values.database.bootstrap.superuser.username | quote }} + - name: DB_SUPERUSER_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.database.bootstrap.superuser.secretName | default (include "fuzefront.secretName" .) }} + key: {{ .Values.database.bootstrap.superuser.secretKey | quote }} + # billing_svc's own password — lives in the per-service billing-secrets. + - name: BILLING_DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.billingService.secretName | default "billing-secrets" }} + key: BILLING_DB_PASSWORD + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { cpu: 200m, memory: 128Mi } +{{- end }} diff --git a/deploy/helm/fuzefront/templates/billing-service.yaml b/deploy/helm/fuzefront/templates/billing-service.yaml index e0ade6150..140c05d57 100644 --- a/deploy/helm/fuzefront/templates/billing-service.yaml +++ b/deploy/helm/fuzefront/templates/billing-service.yaml @@ -42,31 +42,28 @@ spec: - name: DB_PASSWORD valueFrom: secretKeyRef: - name: {{ include "fuzefront.secretName" . }} - key: DB_PASSWORD + name: {{ .Values.billingService.secretName | default "billing-secrets" }} + key: BILLING_DB_PASSWORD - name: DATABASE_URL value: "postgresql://{{ .Values.billingService.dbUser }}:$(DB_PASSWORD)@{{ .Values.fuzeinfra.postgres.host }}:{{ .Values.fuzeinfra.postgres.port }}/{{ .Values.database.name }}" - {{- if .Values.secret.stripeSecretKey }} + # Always rendered when billing is enabled — the per-service billing-secrets + # SealedSecret provides these at runtime (the pod stays pending until the + # keys are sealed). No Helm-values gate (values are empty under SealedSecrets). - name: STRIPE_SECRET_KEY valueFrom: secretKeyRef: - name: {{ include "fuzefront.secretName" . }} + name: {{ .Values.billingService.secretName | default "billing-secrets" }} key: STRIPE_SECRET_KEY - {{- end }} - {{- if .Values.secret.stripeWebhookSecret }} - name: STRIPE_WEBHOOK_SECRET valueFrom: secretKeyRef: - name: {{ include "fuzefront.secretName" . }} + name: {{ .Values.billingService.secretName | default "billing-secrets" }} key: STRIPE_WEBHOOK_SECRET - {{- end }} - {{- if .Values.secret.billingInternalToken }} - name: BILLING_INTERNAL_TOKEN valueFrom: secretKeyRef: - name: {{ include "fuzefront.secretName" . }} + name: {{ .Values.billingService.secretName | default "billing-secrets" }} key: BILLING_INTERNAL_TOKEN - {{- end }} - name: PERMIT_API_KEY valueFrom: secretKeyRef: diff --git a/deploy/helm/fuzefront/templates/ingress.yaml b/deploy/helm/fuzefront/templates/ingress.yaml index 1468ccebf..c94430bad 100644 --- a/deploy/helm/fuzefront/templates/ingress.yaml +++ b/deploy/helm/fuzefront/templates/ingress.yaml @@ -57,6 +57,19 @@ spec: port: number: {{ .Values.applicationsService.port }} {{- end }} + {{- if .Values.billingService.enabled }} + # Billing webhook carve-out: the Stripe webhook is the ONLY publicly + # reachable billing path (Stripe-signature verified via STRIPE_WEBHOOK_SECRET). + # Exact match so the rest of /api/v1/billing/* stays internal (no ingress) — + # internal callers reach it in-cluster with BILLING_INTERNAL_TOKEN. + - path: /api/v1/billing/webhooks/stripe + pathType: Exact + backend: + service: + name: fuzefront-billing-service + port: + number: {{ .Values.billingService.port }} + {{- end }} {{- if or .Values.securityService.enabled .Values.applicationsService.enabled }} # Thin backend: health + any remaining /api/* (still dual-serving the # domain routes during the soak; this is the catch-all under the diff --git a/deploy/helm/fuzefront/values-prod.yaml b/deploy/helm/fuzefront/values-prod.yaml index 8ba6635c3..d80881614 100644 --- a/deploy/helm/fuzefront/values-prod.yaml +++ b/deploy/helm/fuzefront/values-prod.yaml @@ -74,15 +74,28 @@ provisioningService: tag: "" # CI's release sed writes the real image SHA here # Billing-service (Stripe). Independently-lifecycled — synced by its own Argo -# Application (deploy/argocd/applications/billing.yaml). Keep enabled: false in -# prod until the SealedSecret `fuzefront-secrets` holds STRIPE_SECRET_KEY, -# STRIPE_WEBHOOK_SECRET, and BILLING_INTERNAL_TOKEN, AND the billing_svc DB role -# exists (follow-up). NEVER hand-deploy — flip via GitOps. +# Application (deploy/argocd/applications/billing.yaml). Enabled — but it only +# actually serves once the per-service `billing-secrets` SealedSecret holds +# STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, BILLING_INTERNAL_TOKEN, BILLING_DB_PASSWORD +# (seal them with deploy/scripts/seal-secret.sh). The billing_svc role + `billing` +# schema are provisioned by the pre-install billing-db-bootstrap Job. Until the +# secret keys exist the pod stays pending on them (it will NOT crash other services, +# since billing has its own isolated secret). NEVER hand-deploy — flip via GitOps. billingService: - enabled: false + enabled: true image: repository: ghcr.io/izzywdev/fuzefront-billing-service tag: "" # CI's release sed writes the real image SHA here + # Steer onto node-2 (keep LLM/chat/billing load off the DB-heavy node-1). + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + preference: + matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: ["fuzefront-node-2"] permit: enabled: true @@ -183,13 +196,3 @@ chatService: operator: In values: ["fuzefront-node-2"] -billingService: - affinity: - nodeAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - weight: 100 - preference: - matchExpressions: - - key: kubernetes.io/hostname - operator: In - values: ["fuzefront-node-2"] diff --git a/deploy/helm/fuzefront/values.yaml b/deploy/helm/fuzefront/values.yaml index 86666eed7..1606f2577 100644 --- a/deploy/helm/fuzefront/values.yaml +++ b/deploy/helm/fuzefront/values.yaml @@ -360,6 +360,18 @@ billingService: tag: local port: 3006 replicas: 1 + # Dedicated least-privilege DB role (created by the billing-db-bootstrap Job), + # NOT the shared fuzefront_user. Its password is BILLING_DB_PASSWORD in the + # per-service billing-secrets SealedSecret. + dbUser: billing_svc + # Per-service secret (billing-only keys: STRIPE_*, BILLING_INTERNAL_TOKEN, + # BILLING_DB_PASSWORD). Shared creds (PERMIT_API_KEY) stay in fuzefront-secrets. + secretName: billing-secrets + meterFlushIntervalSec: 60 + permitPdpUrl: "http://fuzefront-permit-pdp:7000" + # Pre-install Job that creates the billing_svc role + `billing` schema + grants. + dbBootstrap: + enabled: true kafka: brokers: "kafka.fuzeinfra.svc.cluster.local:9092" clientId: "billing-service" diff --git a/deploy/scripts/seal-secret.sh b/deploy/scripts/seal-secret.sh new file mode 100755 index 000000000..c5dbbbb8a --- /dev/null +++ b/deploy/scripts/seal-secret.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# seal-secret.sh — seal ONE secret value into this repo's SealedSecret, offline, +# credential-free. No kubeconfig, no cluster access: FuzeInfra holds the private +# (decrypt) key; we only need the cluster's PUBLIC cert, which FuzeInfra publishes +# at a stable URL. We fetch the CURRENT public cert at seal time (so sealed-secrets +# key rotation just works), seal with kubeseal, and merge the result into the +# SealedSecret manifest IN PLACE — preserving every other key. +# +# Usage: +# deploy/scripts/seal-secret.sh STRIPE_SECRET_KEY # hidden prompt, paste value +# deploy/scripts/seal-secret.sh BILLING_INTERNAL_TOKEN --in ~/.fuzefront-secrets/billing-internal-token.txt +# deploy/scripts/seal-secret.sh STRIPE_SECRET_KEY --cert ./pub.pem # offline: use a local cert +# deploy/scripts/seal-secret.sh SOME_KEY --scope other-ns/other-secret # override the hard-coded scope +# +# Then: git add the manifest, commit, push. Argo (FuzeInfra-operated) syncs it and +# the in-cluster controller decrypts it into a real Secret. Plaintext NEVER touches +# git, chat, or shell history. +# NOTE: this is an INTERIM, vendored copy. The CANONICAL seal-secret.sh + the +# secrets-management methodology + the published public-cert URL are owned by +# FuzeInfra (it runs the controller and holds the decrypt key). Tracked for +# migration to the fuzeone onboarding toolkit. Until then this copy unblocks +# FuzeFront sealing. +set -euo pipefail + +# ---- per-repo defaults (this is the FuzeFront repo) ---------------------------- +NS="fuzefront" +NAME="billing-secrets" +# FuzeInfra publishes the sealed-secrets public cert here (single source of truth, +# always current). Override via env if the URL differs. +CERT_URL="${FUZEINFRA_SEALED_CERT_URL:-https://sealed-secrets.fuzeinfra.fuzefront.com/v1/cert.pem}" + +CERT_OVERRIDE=""; INFILE=""; KEY=""; MANIFEST="" +while [ $# -gt 0 ]; do + case "$1" in + --scope) NS="${2%%/*}"; NAME="${2##*/}"; shift 2 ;; + --cert) CERT_OVERRIDE="$2"; shift 2 ;; + --in) INFILE="$2"; shift 2 ;; + --manifest) MANIFEST="$2"; shift 2 ;; + -h|--help) + grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) echo "unknown flag: $1" >&2; exit 2 ;; + *) KEY="$1"; shift ;; + esac +done +[ -n "$KEY" ] || { echo "usage: seal-secret.sh [--in FILE] [--scope ns/name] [--cert PUBCERT] [--manifest PATH]" >&2; exit 2; } +MANIFEST="${MANIFEST:-deploy/contabo/sealed/${NAME}.yaml}" + +command -v kubeseal >/dev/null || { echo "kubeseal not found (scoop install kubeseal / brew install kubeseal)" >&2; exit 1; } +command -v kubectl >/dev/null || { echo "kubectl not found" >&2; exit 1; } + +CERT="$(mktemp)"; VAL="$(mktemp)"; chmod 600 "$VAL" +trap 'rm -f "$CERT" "$VAL"' EXIT + +# ---- get the public cert (fetch current, or use the offline override) ---------- +if [ -n "$CERT_OVERRIDE" ]; then + cp "$CERT_OVERRIDE" "$CERT" +else + curl -fsSL "$CERT_URL" -o "$CERT" \ + || { echo "Could not fetch public cert from $CERT_URL — pass --cert for offline use." >&2; exit 1; } +fi + +# ---- get the value (hidden prompt, or from a file) ----------------------------- +if [ -n "$INFILE" ]; then + tr -d '\r\n' < "$INFILE" > "$VAL" +else + printf 'Paste value for %s (hidden, will not echo): ' "$KEY" >&2 + read -rs _V; echo >&2 + printf '%s' "$_V" > "$VAL"; unset _V +fi +[ -s "$VAL" ] || { echo "empty value — aborting" >&2; exit 1; } + +# ---- seal + merge into the manifest in place (preserves other keys) ------------ +mkdir -p "$(dirname "$MANIFEST")" +mkseal() { kubectl create secret generic "$NAME" -n "$NS" --from-file="$KEY=$VAL" --dry-run=client -o yaml | kubeseal --cert "$CERT" -o yaml; } +if [ -f "$MANIFEST" ]; then + kubectl create secret generic "$NAME" -n "$NS" --from-file="$KEY=$VAL" --dry-run=client -o yaml \ + | kubeseal --cert "$CERT" --merge-into "$MANIFEST" +else + mkseal > "$MANIFEST" +fi + +echo "✓ sealed '$KEY' into $MANIFEST (namespace=$NS, name=$NAME)" >&2 +echo " next: git add $MANIFEST && git commit && git push → Argo decrypts in-cluster" >&2 diff --git a/docs/deployment/SERVICE_URL_CONVENTION.md b/docs/deployment/SERVICE_URL_CONVENTION.md new file mode 100644 index 000000000..e0cb07f94 --- /dev/null +++ b/docs/deployment/SERVICE_URL_CONVENTION.md @@ -0,0 +1,52 @@ +# Service URL convention (FuzeFront prod) + +**Decision (2026-06-22): path-based routing under one app host. NOT per-service subdomains.** + +All FuzeFront microservices are addressed as **paths under the single app host**: + +``` +https://app.fuzefront.com/api/v1//... +``` + +e.g. `https://app.fuzefront.com/api/v1/billing/...`, `/api/auth`, `/api/apps`. + +We deliberately do **not** use `billing.app.fuzefront.com` / `.prod.fuzefront.com` +subdomains. + +## Why path-based wins on ops + +| Concern | Path-based (`/api/v1/billing`) | Subdomain (`billing.app.…`) | +|---|---|---| +| TLS | One wildcard/app cert, already issued | New cert (or wildcard mgmt) per service | +| DNS | Zero new records | New record per service | +| CORS | Same-origin — none needed | Cross-origin preflight + allow-list upkeep | +| Cookies/session | Shared on the apex automatically | SameSite/domain juggling | +| Ingress | One Ingress, longest-prefix rules | One Ingress/host block per service | +| Browser → service | Just `fetch('/api/v1/billing/...')` | Must know each service's FQDN | + +## Default posture: internal-only, carve out the public bits + +A service's routes are **cluster-internal by default** (reachable pod-to-pod via +Service DNS `fuzefront-:`, guarded by the service's internal token). +The ingress exposes **only** the specific public paths a service needs. + +Billing is the canonical example: +- **Public (ingress carve-out):** `POST /api/v1/billing/webhooks/stripe` only + (Stripe-signature verified via `STRIPE_WEBHOOK_SECRET`). `pathType: Exact`. +- **Everything else** under `/api/v1/billing/*` stays internal. Browsers reach it + **through the host backend**, which proxies same-origin `/api/v1/billing/*` + to `fuzefront-billing-service:3006` adding `BILLING_INTERNAL_TOKEN`. + +So the browser only ever talks to `app.fuzefront.com` (same origin); the host +backend is the trust boundary that holds the internal service token. + +## Adding a new service + +1. Pick its path prefix: `/api/v1/`. +2. Keep it internal; expose public paths via an `{{- if .Values..enabled }}` + block in `deploy/helm/fuzefront/templates/ingress.yaml` (longest-prefix wins, + so service-specific prefixes sit above the `/api` catch-all). +3. For browser-facing-but-not-public APIs, add a host-backend proxy route + (`browser → backend → fuzefront-` with the service's internal token). +4. Generate the typed client (`@fuzefront/-client`) from the service's + OpenAPI spec; UI imports it and calls same-origin paths. diff --git a/package-lock.json b/package-lock.json index 846c02dc6..2dc077503 100644 --- a/package-lock.json +++ b/package-lock.json @@ -425,6 +425,7 @@ "openapi-typescript": "^7.4.0", "rimraf": "^5.0.1", "ts-jest": "29.1.1", + "tsup": "^8.0.2", "typescript": "5.1.6" } },