From f68e0c8b766dc0735f7b9e9aafa5dc832f612079 Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 21:55:08 +0300 Subject: [PATCH 1/6] feat(secrets): credential-free seal-secret.sh + fuzefront-secrets SealedSecret scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seal-secret.sh : hidden-prompt → fetch current public cert from FuzeInfra (stable URL; handles key rotation) → kubeseal --cert --merge-into the manifest IN PLACE (preserves other keys). Per-repo scope hard-coded (fuzefront/ fuzefront-secrets), --scope/--cert/--in/--manifest overrides. No kubeconfig: only the public cert is needed to seal; FuzeInfra holds the decrypt key. Manifest is the SHARED app secret (Authentik/Permit/DB/OAuth/messaging/LLM + billing) — documented full key inventory + loud warning never to sync it empty (would clobber live keys). Populate every key via the script before Argo-wiring. Co-Authored-By: Claude Opus 4.8 --- deploy/contabo/sealed/fuzefront-secrets.yaml | 48 ++++++++++++ deploy/scripts/seal-secret.sh | 79 ++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 deploy/contabo/sealed/fuzefront-secrets.yaml create mode 100755 deploy/scripts/seal-secret.sh diff --git a/deploy/contabo/sealed/fuzefront-secrets.yaml b/deploy/contabo/sealed/fuzefront-secrets.yaml new file mode 100644 index 000000000..7961103e9 --- /dev/null +++ b/deploy/contabo/sealed/fuzefront-secrets.yaml @@ -0,0 +1,48 @@ +# ============================================================================= +# SealedSecret: fuzefront-secrets (namespace: fuzefront) +# ----------------------------------------------------------------------------- +# This is the SHARED application secret. MANY running services read keys from it +# (Authentik, Permit, Postgres, OAuth, messaging, LLM, and now billing). Each +# value below is encrypted against the cluster's sealed-secrets PUBLIC key and is +# safe to commit — only the in-cluster controller (FuzeInfra) can decrypt it. +# +# ⚠️ DANGER — DO NOT apply/sync this file with `encryptedData: {}` (empty) or +# with only a subset of keys. Argo would create an EMPTY/partial Secret and +# CLOBBER the live one → Authentik/Permit/DB/etc. break. This file must hold +# EVERY key below before it is wired into Argo. +# +# HOW TO POPULATE (credential-free — no kubeconfig, FuzeInfra holds the decrypt key): +# Seal one key at a time; `--merge-into` preserves the others: +# deploy/scripts/seal-secret.sh STRIPE_SECRET_KEY # hidden prompt +# deploy/scripts/seal-secret.sh STRIPE_WEBHOOK_SECRET +# deploy/scripts/seal-secret.sh BILLING_INTERNAL_TOKEN --in ~/.fuzefront-secrets/billing-internal-token.txt +# ...and the same for every key in the inventory below. +# The script fetches the current public cert from FuzeInfra and runs +# `kubeseal --cert … --merge-into` against THIS file. +# +# KEY INVENTORY (group → keys): +# core : JWT_SECRET, SESSION_SECRET, DB_PASSWORD, DB_SUPERUSER_PASSWORD, +# INTERNAL_PROVISION_SECRET +# authz : PERMIT_API_KEY +# authentik : AUTHENTIK_SECRET_KEY, AUTHENTIK_BOOTSTRAP_PASSWORD, +# AUTHENTIK_BOOTSTRAP_TOKEN, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET +# oauth : GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET +# messaging : SENDGRID_API_KEY, TWILIO_AUTH_TOKEN, SMS_AUTH_SECRET, SMTP_PASSWORD +# llm : ANTHROPIC_API_KEY, OPENAI_API_KEY, LITELLM_MASTER_KEY +# billing : STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, BILLING_INTERNAL_TOKEN +# +# If you already maintain a populated fuzefront-secrets SealedSecret elsewhere, +# run seal-secret.sh --merge-into against THAT file instead and ignore this stub. +# ============================================================================= +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: fuzefront-secrets + namespace: fuzefront +spec: + encryptedData: {} # populate via deploy/scripts/seal-secret.sh (do NOT sync empty) + template: + metadata: + name: fuzefront-secrets + namespace: fuzefront + type: Opaque diff --git a/deploy/scripts/seal-secret.sh b/deploy/scripts/seal-secret.sh new file mode 100755 index 000000000..e97d4c646 --- /dev/null +++ b/deploy/scripts/seal-secret.sh @@ -0,0 +1,79 @@ +#!/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. +set -euo pipefail + +# ---- per-repo defaults (this is the FuzeFront repo) ---------------------------- +NS="fuzefront" +NAME="fuzefront-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 From c1f530697615f2032f8b790ff3a1f7d4834340f2 Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 21:56:11 +0300 Subject: [PATCH 2/6] chore(secrets): ASCII-only comments in sealed manifest (avoid cp1252/tool encoding issues) --- deploy/contabo/sealed/fuzefront-secrets.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/deploy/contabo/sealed/fuzefront-secrets.yaml b/deploy/contabo/sealed/fuzefront-secrets.yaml index 7961103e9..9c1bb999f 100644 --- a/deploy/contabo/sealed/fuzefront-secrets.yaml +++ b/deploy/contabo/sealed/fuzefront-secrets.yaml @@ -6,9 +6,9 @@ # value below is encrypted against the cluster's sealed-secrets PUBLIC key and is # safe to commit — only the in-cluster controller (FuzeInfra) can decrypt it. # -# ⚠️ DANGER — DO NOT apply/sync this file with `encryptedData: {}` (empty) or +# DANGER — DO NOT apply/sync this file with `encryptedData: {}` (empty) or # with only a subset of keys. Argo would create an EMPTY/partial Secret and -# CLOBBER the live one → Authentik/Permit/DB/etc. break. This file must hold +# CLOBBER the live one -> Authentik/Permit/DB/etc. break. This file must hold # EVERY key below before it is wired into Argo. # # HOW TO POPULATE (credential-free — no kubeconfig, FuzeInfra holds the decrypt key): @@ -18,9 +18,9 @@ # deploy/scripts/seal-secret.sh BILLING_INTERNAL_TOKEN --in ~/.fuzefront-secrets/billing-internal-token.txt # ...and the same for every key in the inventory below. # The script fetches the current public cert from FuzeInfra and runs -# `kubeseal --cert … --merge-into` against THIS file. +# `kubeseal --cert ... --merge-into` against THIS file. # -# KEY INVENTORY (group → keys): +# KEY INVENTORY (group -> keys): # core : JWT_SECRET, SESSION_SECRET, DB_PASSWORD, DB_SUPERUSER_PASSWORD, # INTERNAL_PROVISION_SECRET # authz : PERMIT_API_KEY From 3a9f0c00538b36c3858bd1dac3cb430bd2633953 Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 22:07:32 +0300 Subject: [PATCH 3/6] refactor(secrets): per-service billing-secrets (least privilege) instead of shared blob Billing's 3 sensitive keys (STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET/ BILLING_INTERNAL_TOKEN) now live in their OWN SealedSecret 'billing-secrets'; billing-service Deployment references it (DB_PASSWORD/PERMIT_API_KEY stay shared in fuzefront-secrets). A compromised billing pod can't read Authentik/SMTP/OAuth/LLM secrets, and sealing/rotating billing keys can't clobber other services. Dropped the shared-secret scaffold (operator-owned). seal-secret.sh defaults to scope fuzefront/billing-secrets and is marked an interim copy (canonical tool + cert URL + methodology owned by FuzeInfra, delegated separately). --- deploy/contabo/sealed/billing-secrets.yaml | 33 +++++++++++++ deploy/contabo/sealed/fuzefront-secrets.yaml | 48 ------------------- .../fuzefront/templates/billing-service.yaml | 6 +-- deploy/scripts/seal-secret.sh | 7 ++- 4 files changed, 42 insertions(+), 52 deletions(-) create mode 100644 deploy/contabo/sealed/billing-secrets.yaml delete mode 100644 deploy/contabo/sealed/fuzefront-secrets.yaml diff --git a/deploy/contabo/sealed/billing-secrets.yaml b/deploy/contabo/sealed/billing-secrets.yaml new file mode 100644 index 000000000..4c0be0315 --- /dev/null +++ b/deploy/contabo/sealed/billing-secrets.yaml @@ -0,0 +1,33 @@ +# ============================================================================= +# 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 +# (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 +# ============================================================================= +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/contabo/sealed/fuzefront-secrets.yaml b/deploy/contabo/sealed/fuzefront-secrets.yaml deleted file mode 100644 index 9c1bb999f..000000000 --- a/deploy/contabo/sealed/fuzefront-secrets.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# ============================================================================= -# SealedSecret: fuzefront-secrets (namespace: fuzefront) -# ----------------------------------------------------------------------------- -# This is the SHARED application secret. MANY running services read keys from it -# (Authentik, Permit, Postgres, OAuth, messaging, LLM, and now billing). Each -# value below is encrypted against the cluster's sealed-secrets PUBLIC key and is -# safe to commit — only the in-cluster controller (FuzeInfra) can decrypt it. -# -# DANGER — DO NOT apply/sync this file with `encryptedData: {}` (empty) or -# with only a subset of keys. Argo would create an EMPTY/partial Secret and -# CLOBBER the live one -> Authentik/Permit/DB/etc. break. This file must hold -# EVERY key below before it is wired into Argo. -# -# HOW TO POPULATE (credential-free — no kubeconfig, FuzeInfra holds the decrypt key): -# Seal one key at a time; `--merge-into` preserves the others: -# deploy/scripts/seal-secret.sh STRIPE_SECRET_KEY # hidden prompt -# deploy/scripts/seal-secret.sh STRIPE_WEBHOOK_SECRET -# deploy/scripts/seal-secret.sh BILLING_INTERNAL_TOKEN --in ~/.fuzefront-secrets/billing-internal-token.txt -# ...and the same for every key in the inventory below. -# The script fetches the current public cert from FuzeInfra and runs -# `kubeseal --cert ... --merge-into` against THIS file. -# -# KEY INVENTORY (group -> keys): -# core : JWT_SECRET, SESSION_SECRET, DB_PASSWORD, DB_SUPERUSER_PASSWORD, -# INTERNAL_PROVISION_SECRET -# authz : PERMIT_API_KEY -# authentik : AUTHENTIK_SECRET_KEY, AUTHENTIK_BOOTSTRAP_PASSWORD, -# AUTHENTIK_BOOTSTRAP_TOKEN, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET -# oauth : GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET -# messaging : SENDGRID_API_KEY, TWILIO_AUTH_TOKEN, SMS_AUTH_SECRET, SMTP_PASSWORD -# llm : ANTHROPIC_API_KEY, OPENAI_API_KEY, LITELLM_MASTER_KEY -# billing : STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, BILLING_INTERNAL_TOKEN -# -# If you already maintain a populated fuzefront-secrets SealedSecret elsewhere, -# run seal-secret.sh --merge-into against THAT file instead and ignore this stub. -# ============================================================================= -apiVersion: bitnami.com/v1alpha1 -kind: SealedSecret -metadata: - name: fuzefront-secrets - namespace: fuzefront -spec: - encryptedData: {} # populate via deploy/scripts/seal-secret.sh (do NOT sync empty) - template: - metadata: - name: fuzefront-secrets - namespace: fuzefront - type: Opaque diff --git a/deploy/helm/fuzefront/templates/billing-service.yaml b/deploy/helm/fuzefront/templates/billing-service.yaml index e0ade6150..d8523a66c 100644 --- a/deploy/helm/fuzefront/templates/billing-service.yaml +++ b/deploy/helm/fuzefront/templates/billing-service.yaml @@ -50,21 +50,21 @@ spec: - 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 diff --git a/deploy/scripts/seal-secret.sh b/deploy/scripts/seal-secret.sh index e97d4c646..c5dbbbb8a 100755 --- a/deploy/scripts/seal-secret.sh +++ b/deploy/scripts/seal-secret.sh @@ -16,11 +16,16 @@ # 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="fuzefront-secrets" +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}" From 6b3e70ca8140345a7e955182844d596045646483 Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 22:41:56 +0300 Subject: [PATCH 4/6] feat(billing): enable + complete billing deploy (ingress carve-out, billing_svc role, DB, secrets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - values: complete billingService (dbUser=billing_svc, secretName=billing-secrets, meterFlushIntervalSec, permitPdpUrl, dbBootstrap) — template referenced these but they were undefined (billing never actually deployed). - billing-db-bootstrap Job (pre-install, idempotent): creates least-privilege billing_svc role + 'billing' schema + grants via psql as the superuser. billing- service self-migrates its schema on boot as billing_svc. - ingress carve-out: ONLY /api/v1/billing/webhooks/stripe is public (Exact match, Stripe-signature verified); rest of /api/v1/billing stays internal. - billing-secrets gains BILLING_DB_PASSWORD (billing_svc's password); STRIPE/BILLING env always render (dropped the Helm-values guards — SealedSecret provides them). - values-prod: billingService.enabled=true (fixed a duplicate-key bug that clobbered it — billingService was declared twice; merged node-2 affinity into one block). - Verified: helm lint clean + helm template renders all billing artifacts. Co-Authored-By: Claude Opus 4.8 --- deploy/contabo/sealed/billing-secrets.yaml | 5 +- .../templates/billing-db-bootstrap-job.yaml | 76 +++++++++++++++++++ .../fuzefront/templates/billing-service.yaml | 13 ++-- deploy/helm/fuzefront/templates/ingress.yaml | 13 ++++ deploy/helm/fuzefront/values-prod.yaml | 33 ++++---- deploy/helm/fuzefront/values.yaml | 12 +++ 6 files changed, 128 insertions(+), 24 deletions(-) create mode 100644 deploy/helm/fuzefront/templates/billing-db-bootstrap-job.yaml diff --git a/deploy/contabo/sealed/billing-secrets.yaml b/deploy/contabo/sealed/billing-secrets.yaml index 4c0be0315..7f0a519c0 100644 --- a/deploy/contabo/sealed/billing-secrets.yaml +++ b/deploy/contabo/sealed/billing-secrets.yaml @@ -14,10 +14,13 @@ # 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 +# 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 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 d8523a66c..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: {{ .Values.billingService.secretName | default "billing-secrets" }} key: STRIPE_SECRET_KEY - {{- end }} - {{- if .Values.secret.stripeWebhookSecret }} - name: STRIPE_WEBHOOK_SECRET valueFrom: secretKeyRef: name: {{ .Values.billingService.secretName | default "billing-secrets" }} key: STRIPE_WEBHOOK_SECRET - {{- end }} - {{- if .Values.secret.billingInternalToken }} - name: BILLING_INTERNAL_TOKEN valueFrom: secretKeyRef: 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" From 785c7272bb2d3a3dd055084aa0a1e84cbfc882c4 Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 23:10:17 +0300 Subject: [PATCH 5/6] build(billing-client): tsup dual-build (ESM+CJS) for host static export resolution Mirror the proven @fuzefront/chat-client fix (#90): billing-ui keeps @fuzefront/billing-client external, so the host vite/rollup bundle must statically resolve its named exports. Plain tsc emitted CJS __exportStar(require()) -> 'X is not exported' at host build. tsup emits static ESM re-exports + .d.ts + .cjs. Adds module/exports map + tsup devDep; tsconfig ignoreDeprecations '6.0' (TS 6.0.3 baseUrl escalation). Co-Authored-By: Claude Opus 4.8 --- billing-client/package.json | 15 ++++++++++++--- billing-client/tsconfig.json | 17 +++++++++++++---- billing-client/tsup.config.ts | 18 ++++++++++++++++++ package-lock.json | 1 + 4 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 billing-client/tsup.config.ts 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/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" } }, From 6ec4216d470f3b0c9b8b654bc61b94804fbcdd26 Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 23:13:28 +0300 Subject: [PATCH 6/6] docs(deploy): lock path-based service URL convention (/api/v1/, internal-by-default) Decision: path-based routing under the single app host, not per-service subdomains (one cert, no DNS churn, same-origin/no-CORS, shared cookies). Services internal-only by default; ingress carves out only public paths (billing's Stripe webhook); browser-facing APIs go through host-backend proxy with the service's internal token. Co-Authored-By: Claude Opus 4.8 --- docs/deployment/SERVICE_URL_CONVENTION.md | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/deployment/SERVICE_URL_CONVENTION.md 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.