Compare commits

...

23 Commits

Author SHA1 Message Date
1f48712e42 Tenant naming logic adjustments
All checks were successful
Build and Push / build (push) Successful in 1m22s
2026-04-26 18:47:50 +02:00
0bf4c6cf4c Debug pipeline 2026-04-25 22:48:05 +02:00
4296a70d51 Debug pipeline 2026-04-25 22:34:48 +02:00
c41145bae7 Debug pipeline 2026-04-25 22:29:42 +02:00
d6a6150a7f Debug pipeline 2026-04-25 22:22:39 +02:00
f69a2d4fa2 Debug pipeline 2026-04-25 22:17:10 +02:00
c2ac8b4401 Debug pipeline 2026-04-25 22:10:46 +02:00
3ce3ba0649 Debug pipeline 2026-04-25 22:08:46 +02:00
5b27f54eb3 Debug pipeline 2026-04-25 20:15:30 +02:00
cc5806f031 Debug pipeline 2026-04-25 20:12:22 +02:00
dab18bb9e6 Debug pipeline 2026-04-25 20:10:36 +02:00
de4ff5ebaf Debug pipeline 2026-04-25 20:09:17 +02:00
f3a1ae0267 Debug pipeline 2026-04-25 20:04:55 +02:00
e7d3fa3873 Debug pipeline 2026-04-25 19:30:48 +02:00
baa0e2b597 Add docker env 2026-04-25 19:21:53 +02:00
935dfb8abc Add docker env 2026-04-25 19:20:54 +02:00
65d8a2e2ff Add docker env 2026-04-25 19:07:16 +02:00
d62684dec7 Add docker env 2026-04-25 19:04:42 +02:00
709588302c ci: add Gitea Actions workflows 2026-04-25 18:20:14 +02:00
b9654d7a7c Timestamp and registration checking 2026-04-25 18:09:02 +02:00
f550b3400f Frontend adjustments 2026-04-14 20:45:58 +02:00
f0eca1959b fix(portal): security hardening for pilot readiness
- C1: Rewrite /api/usage to resolve teamId server-side from tenant CR;
  customers can no longer pass arbitrary teamId (IDOR fix)
- C2: Remove POST /api/tenants — tenants are only created via admin
  approval flow
- H1: Validate packages against catalog, workspaceFiles against allowlist,
  and field lengths in PATCH /api/tenants/[name]
- H2: Remove full ZITADEL profile claims logging from JWT callback
- H3: Add safeError() utility; sanitize all error responses to clients,
  toggle raw errors via PORTAL_DEBUG_ERRORS=true
- H4/H5: Escape HTML entities in all email templates (contactName,
  companyName, adminNotes)
2026-04-14 20:20:04 +02:00
6f9f46b2d0 Ratelimit 2026-04-12 18:13:26 +02:00
36 changed files with 1815 additions and 127 deletions

106
.gitea/workflows/build.yml Normal file
View File

@@ -0,0 +1,106 @@
name: Build and Push
on:
push:
branches: [main]
# Don't rebuild on doc-only or CI-config-only changes
paths-ignore:
- 'README.md'
- '.gitea/**'
- 'deploy/**'
workflow_dispatch:
env:
REGISTRY: registry.c5ai.ch
IMAGE: pieced/pieced-portal
jobs:
build:
# 'self-hosted' matches the label our act_runner registers with.
# 'ubuntu-latest' would work too because we configure both labels in the
# runner config, but self-hosted makes intent explicit.
runs-on: ubuntu-latest
env:
DOCKER_HOST: tcp://172.17.0.1:2375
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine next patch version
id: version
# Reads tags from the registry's OCI Distribution v2 API, filters to
# strict semver (skips 'latest', 'dev', '<sha>-dirty', etc.), picks the
# highest with version-sort, and bumps the patch component. If nothing
# numeric exists yet (fresh registry), starts at 0.1.0.
env:
REG_USER: ${{ secrets.REGISTRY_USERNAME }}
REG_PASS: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
tags_json=$(curl -sf -u "$REG_USER:$REG_PASS" \
"https://${REGISTRY}/v2/${IMAGE}/tags/list")
highest=$(echo "$tags_json" \
| jq -r '.tags // [] | .[]' \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V \
| tail -n1 || true)
if [ -z "$highest" ]; then
next="0.1.0"
echo "No semver tags found — starting at $next"
else
major=$(echo "$highest" | cut -d. -f1)
minor=$(echo "$highest" | cut -d. -f2)
patch=$(echo "$highest" | cut -d. -f3)
next="${major}.${minor}.$((patch + 1))"
echo "Highest existing: $highest → next: $next"
fi
echo "version=${next}" >> "$GITHUB_OUTPUT"
- name: Build and push image
env:
REG_USER: ${{ secrets.REGISTRY_USERNAME }}
REG_PASS: ${{ secrets.REGISTRY_PASSWORD }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
printf '%s' "$REG_PASS" \
| docker login "${REGISTRY}" -u "$REG_USER" --password-stdin
docker build \
--pull \
-t "${REGISTRY}/${IMAGE}:${VERSION}" \
-t "${REGISTRY}/${IMAGE}:latest" \
.
docker push "${REGISTRY}/${IMAGE}:${VERSION}"
docker push "${REGISTRY}/${IMAGE}:latest"
- name: Tag git commit with version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
git config user.name "pieced-ci"
git config user.email "ci@pieced.ch"
git tag -a "v${VERSION}" -m "Release ${VERSION}"
# Use CI_TOKEN explicitly so we can push a tag (the workflow's
# default token may or may not have push scope depending on Gitea
# actions config — explicit token avoids ambiguity).
git push \
"https://oauth2:${{ secrets.CI_TOKEN }}@git.c5ai.ch/pieced/pieced-portal.git" \
"v${VERSION}"
- name: Summary
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
{
echo "## Build complete: ${VERSION}"
echo
echo "**Image:** \`${REGISTRY}/${IMAGE}:${VERSION}\`"
echo
echo "Run the **Deploy to GitOps** workflow to roll this version out."
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -0,0 +1,98 @@
name: Deploy to GitOps
# Manually triggered. Bumps the image tag in pieced-gitops so ArgoCD rolls
# the new version out. Does not build anything itself — the build workflow
# is the only thing that creates and pushes images.
on:
workflow_dispatch:
inputs:
version:
description: 'Version to deploy (e.g. 0.1.5). Must already exist in the registry.'
required: true
type: string
env:
REGISTRY: registry.c5ai.ch
IMAGE: pieced/pieced-portal
GITOPS_REPO: admin/pieced-gitops
GITOPS_FILE: apps/portal/deployment.yaml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Verify image exists in registry
# Fail fast if the user typed a version that was never built. Catches
# typos before we touch the gitops repo. Uses env-var pattern for
# credentials to avoid shell interpolation mangling special characters.
env:
REG_USER: ${{ secrets.REGISTRY_USERNAME }}
REG_PASS: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
status=$(curl -sf -o /dev/null -w '%{http_code}' \
-u "$REG_USER:$REG_PASS" \
"https://${REGISTRY}/v2/${IMAGE}/manifests/${{ inputs.version }}" \
|| true)
if [ "$status" != "200" ]; then
echo "::error::Image ${REGISTRY}/${IMAGE}:${{ inputs.version }} not found (HTTP $status)"
exit 1
fi
echo "Confirmed: ${REGISTRY}/${IMAGE}:${{ inputs.version }} exists."
- name: Checkout pieced-gitops
uses: actions/checkout@v4
with:
repository: ${{ env.GITOPS_REPO }}
token: ${{ secrets.CI_TOKEN }}
path: gitops
# We need history to commit + push back; default fetch-depth: 1 is fine
# for a single commit but force a clean shallow clone:
fetch-depth: 1
- name: Update image tag
working-directory: gitops
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
file="${GITOPS_FILE}"
if [ ! -f "$file" ]; then
echo "::error::$file not found in gitops repo"
exit 1
fi
# Anchored to the full image path to avoid accidentally rewriting
# any unrelated 'image:' line that might appear later.
sed -i -E \
"s|(image: ${REGISTRY}/${IMAGE}:)[^[:space:]]+|\1${VERSION}|" \
"$file"
echo "--- diff ---"
git --no-pager diff "$file" || true
- name: Commit and push
working-directory: gitops
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if git diff --quiet; then
echo "No changes — image tag was already ${VERSION}."
exit 0
fi
git config user.name "pieced-ci"
git config user.email "ci@pieced.ch"
git add "${GITOPS_FILE}"
git commit -m "Bump pieced-portal to ${VERSION}"
git push
- name: Summary
env:
VERSION: ${{ inputs.version }}
run: |
{
echo "## Deployed: pieced-portal ${VERSION}"
echo
echo "ArgoCD will sync within its refresh interval."
echo "Watch with: \`kubectl get app -n argocd portal -w\`"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,5 +1,25 @@
npm install #!/usr/bin/env bash
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.4 . set -euo pipefail
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.4
kubectl rollout restart deployment pieced-portal -n pieced-system
VERSION="${1:-}"
REGISTRY="registry.c5ai.ch/pieced/pieced-portal"
if [[ -z "$VERSION" ]]; then
echo "Usage: ./buildanddeploy.sh <version>"
echo "Example: ./buildanddeploy.sh 0.2.0"
exit 1
fi
echo "Building pieced-portal:${VERSION}..."
npm install
docker build -t "${REGISTRY}:${VERSION}" .
docker push "${REGISTRY}:${VERSION}"
echo ""
echo "✓ Pushed ${REGISTRY}:${VERSION}"
echo ""
echo "Next steps:"
echo " 1. Update image tag in pieced-gitops/apps/portal/deployment.yaml:"
echo " image: ${REGISTRY}:${VERSION}"
echo " 2. git commit + push to pieced-gitops"
echo " 3. ArgoCD syncs automatically"

View File

@@ -0,0 +1,4 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|

View File

@@ -0,0 +1,37 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
| Cross-tenant: alpha→testfirma:18789 | tenant-alpha | openclaw.tenant-testfirma:18789 | block | blocked | ✅ PASS |
| Cross-tenant: testfirma→alpha:18789 | tenant-testfirma | openclaw.tenant-alpha:18789 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:18793 | tenant-alpha | openclaw.tenant-testfirma:18793 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:9090 | tenant-alpha | openclaw.tenant-testfirma:9090 | block | blocked | ✅ PASS |
| Tenant→OpenBao | tenant-alpha | openbao:8200 | block | blocked | ✅ PASS |
| Tenant→ZITADEL (svc) | tenant-alpha | zitadel:8080 | block | blocked | ✅ PASS |
| Tenant→Portal | tenant-alpha | pieced-portal:3000 | block | blocked | ✅ PASS |
| Tenant→Portal DB | tenant-alpha | portal-db-rw:5432 | block | blocked | ✅ PASS |
| Tenant→ArgoCD | tenant-alpha | argocd-server:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-alpha | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-testfirma | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→DNS | tenant-alpha | kube-dns | allow | allowed | ✅ PASS |
| Tenant→LiteLLM | tenant-alpha | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Tenant→world:443 | tenant-alpha | httpbin.org:443 | allow | allowed | ✅ PASS |
| Platform→OpenBao | pieced-system | openbao:8200 | allow | **BLOCKED** | ❌ FAIL |
| Platform→ZITADEL | pieced-system | zitadel:8080 | allow | allowed | ✅ PASS |
| Platform→K8s API | pieced-system | kubernetes.default:443 | allow | allowed | ✅ PASS |
| Platform→LiteLLM | pieced-system | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Platform→Portal DB | pieced-system | portal-db-rw:5432 | allow | **BLOCKED** | ❌ FAIL |
| Tenant→Operator | tenant-alpha | pieced-operator:8080 | block | blocked | ✅ PASS |
| Tenant→metadata endpoint | tenant-alpha | 169.254.169.254 | block | blocked | ✅ PASS |
## Summary
- **Passed**: 19
- **Failed**: 2
- **Date**: 2026-04-12 15:09:45 UTC
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume `litellm.inference.svc:4000`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.

View File

@@ -0,0 +1,37 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
| Cross-tenant: alpha→testfirma:18789 | tenant-alpha | openclaw.tenant-testfirma:18789 | block | blocked | ✅ PASS |
| Cross-tenant: testfirma→alpha:18789 | tenant-testfirma | openclaw.tenant-alpha:18789 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:18793 | tenant-alpha | openclaw.tenant-testfirma:18793 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:9090 | tenant-alpha | openclaw.tenant-testfirma:9090 | block | blocked | ✅ PASS |
| Tenant→OpenBao | tenant-alpha | openbao:8200 | block | blocked | ✅ PASS |
| Tenant→ZITADEL (svc) | tenant-alpha | zitadel:8080 | block | blocked | ✅ PASS |
| Tenant→Portal | tenant-alpha | pieced-portal:3000 | block | blocked | ✅ PASS |
| Tenant→Portal DB | tenant-alpha | portal-db-rw:5432 | block | blocked | ✅ PASS |
| Tenant→ArgoCD | tenant-alpha | argocd-server:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-alpha | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-testfirma | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→DNS | tenant-alpha | kube-dns | allow | allowed | ✅ PASS |
| Tenant→LiteLLM | tenant-alpha | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Tenant→world:443 | tenant-alpha | httpbin.org:443 | allow | allowed | ✅ PASS |
| Platform→OpenBao | pieced-system | openbao:8200 | allow | allowed | ✅ PASS |
| Platform→ZITADEL | pieced-system | zitadel:8080 | allow | allowed | ✅ PASS |
| Platform→K8s API | pieced-system | kubernetes.default:443 | allow | allowed | ✅ PASS |
| Platform→LiteLLM | pieced-system | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Platform→Portal DB | pieced-system | portal-db-rw:5432 | allow | **BLOCKED** | ❌ FAIL |
| Tenant→Operator | tenant-alpha | pieced-operator:8080 | block | blocked | ✅ PASS |
| Tenant→metadata endpoint | tenant-alpha | 169.254.169.254 | block | blocked | ✅ PASS |
## Summary
- **Passed**: 20
- **Failed**: 1
- **Date**: 2026-04-12 15:16:10 UTC
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume `litellm.inference.svc:4000`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.

View File

@@ -0,0 +1,37 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
| Cross-tenant: alpha→testfirma:18789 | tenant-alpha | openclaw.tenant-testfirma:18789 | block | blocked | ✅ PASS |
| Cross-tenant: testfirma→alpha:18789 | tenant-testfirma | openclaw.tenant-alpha:18789 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:18793 | tenant-alpha | openclaw.tenant-testfirma:18793 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:9090 | tenant-alpha | openclaw.tenant-testfirma:9090 | block | blocked | ✅ PASS |
| Tenant→OpenBao | tenant-alpha | openbao:8200 | block | blocked | ✅ PASS |
| Tenant→ZITADEL (svc) | tenant-alpha | zitadel:8080 | block | blocked | ✅ PASS |
| Tenant→Portal | tenant-alpha | pieced-portal:3000 | block | blocked | ✅ PASS |
| Tenant→Portal DB | tenant-alpha | portal-db-rw:5432 | block | blocked | ✅ PASS |
| Tenant→ArgoCD | tenant-alpha | argocd-server:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-alpha | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-testfirma | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→DNS | tenant-alpha | kube-dns | allow | allowed | ✅ PASS |
| Tenant→LiteLLM | tenant-alpha | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Tenant→world:443 | tenant-alpha | httpbin.org:443 | allow | allowed | ✅ PASS |
| Platform→OpenBao | pieced-system | openbao:8200 | allow | allowed | ✅ PASS |
| Platform→ZITADEL | pieced-system | zitadel:8080 | allow | allowed | ✅ PASS |
| Platform→K8s API | pieced-system | kubernetes.default:443 | allow | allowed | ✅ PASS |
| Platform→LiteLLM | pieced-system | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Platform→Portal DB | pieced-system | portal-db-rw:5432 | allow | **BLOCKED** | ❌ FAIL |
| Tenant→Operator | tenant-alpha | pieced-operator:8080 | block | blocked | ✅ PASS |
| Tenant→metadata endpoint | tenant-alpha | 169.254.169.254 | block | blocked | ✅ PASS |
## Summary
- **Passed**: 20
- **Failed**: 1
- **Date**: 2026-04-12 15:19:15 UTC
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume `litellm.inference.svc:4000`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.

283
scripts/cilium-audit.sh Normal file
View File

@@ -0,0 +1,283 @@
#!/usr/bin/env bash
# ============================================================================
# PieCed IT — Session 7.1: Cilium Network Policy Audit
# ============================================================================
#
# Prerequisites:
# - kubectl configured for the cluster
# - Existing pods:
# tenant-alpha/openclaw-0 (3 containers)
# tenant-testfirma/openclaw-0 (3 containers)
# pieced-system/pieced-portal-* (1 container)
#
# This script deploys temporary netshoot pods (they have curl, nslookup, etc.)
# into each namespace, runs the tests, then cleans up.
#
# Usage:
# chmod +x cilium-audit.sh
# ./cilium-audit.sh
# ============================================================================
set -euo pipefail
RED='\033[0;31m'
GRN='\033[0;32m'
YLW='\033[1;33m'
RST='\033[0m'
PASS=0
FAIL=0
WARN=0
# Results file
RESULTS_FILE="cilium-audit-results-$(date +%Y%m%d-%H%M%S).md"
log_header() {
echo ""
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
echo -e "${YLW} $1${RST}"
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
}
log_result() {
local test_name="$1"
local from_ns="$2"
local to_target="$3"
local expected="$4" # "block" or "allow"
local actual="$5" # exit code from curl/nslookup: 0=success, non-0=fail
if [[ "$expected" == "block" ]]; then
if [[ "$actual" -ne 0 ]]; then
echo -e " ${GRN}✓ PASS${RST} [$from_ns$to_target] $test_name (blocked as expected)"
PASS=$((PASS + 1))
echo "| $test_name | $from_ns | $to_target | block | blocked | ✅ PASS |" >> "$RESULTS_FILE"
else
echo -e " ${RED}✗ FAIL${RST} [$from_ns$to_target] $test_name (SHOULD BE BLOCKED but succeeded!)"
FAIL=$((FAIL + 1))
echo "| $test_name | $from_ns | $to_target | block | **ALLOWED** | ❌ FAIL |" >> "$RESULTS_FILE"
fi
else
if [[ "$actual" -eq 0 ]]; then
echo -e " ${GRN}✓ PASS${RST} [$from_ns$to_target] $test_name (allowed as expected)"
PASS=$((PASS + 1))
echo "| $test_name | $from_ns | $to_target | allow | allowed | ✅ PASS |" >> "$RESULTS_FILE"
else
echo -e " ${RED}✗ FAIL${RST} [$from_ns$to_target] $test_name (SHOULD BE ALLOWED but blocked!)"
FAIL=$((FAIL + 1))
echo "| $test_name | $from_ns | $to_target | allow | **BLOCKED** | ❌ FAIL |" >> "$RESULTS_FILE"
fi
fi
}
# ----------------------------------------------------------------------------
# Deploy netshoot pods
# ----------------------------------------------------------------------------
deploy_netshoot() {
local ns="$1"
local name="netshoot-audit"
echo " Deploying netshoot in $ns..."
kubectl run "$name" -n "$ns" \
--image=nicolaka/netshoot \
--restart=Never \
--labels="app=netshoot-audit" \
--command -- sleep 600 2>/dev/null || true
kubectl wait --for=condition=Ready pod/"$name" -n "$ns" --timeout=60s
}
cleanup_netshoot() {
echo ""
echo "Cleaning up netshoot pods..."
for ns in tenant-alpha tenant-testfirma pieced-system; do
kubectl delete pod netshoot-audit -n "$ns" --ignore-not-found --wait=false 2>/dev/null || true
done
echo "Done."
}
# Clean up on exit
trap cleanup_netshoot EXIT
# Run a command in netshoot pod, return exit code
# Uses --connect-timeout 5 for curl, timeout 5 for nslookup
run_in() {
local ns="$1"
shift
kubectl exec -n "$ns" netshoot-audit -- "$@" >/dev/null 2>&1
return $?
}
# ============================================================================
# Start
# ============================================================================
echo ""
echo "PieCed IT — Cilium Network Policy Audit"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo ""
# Initialize results markdown
cat > "$RESULTS_FILE" <<'EOF'
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
EOF
# Deploy netshoot pods
log_header "Deploying audit pods"
deploy_netshoot tenant-alpha
deploy_netshoot tenant-testfirma
deploy_netshoot pieced-system
# ============================================================================
# SECTION 1: Tenant-to-Tenant Isolation
# ============================================================================
log_header "1. Tenant-to-Tenant Isolation"
# tenant-alpha → tenant-testfirma OpenClaw (port 18789)
run_in tenant-alpha curl -s --connect-timeout 5 http://openclaw.tenant-testfirma.svc:18789 && rc=0 || rc=$?
log_result "Cross-tenant: alpha→testfirma:18789" "tenant-alpha" "openclaw.tenant-testfirma:18789" "block" "$rc"
# tenant-testfirma → tenant-alpha OpenClaw (port 18789)
run_in tenant-testfirma curl -s --connect-timeout 5 http://openclaw.tenant-alpha.svc:18789 && rc=0 || rc=$?
log_result "Cross-tenant: testfirma→alpha:18789" "tenant-testfirma" "openclaw.tenant-alpha:18789" "block" "$rc"
# Cross-tenant on other OpenClaw ports
run_in tenant-alpha curl -s --connect-timeout 5 http://openclaw.tenant-testfirma.svc:18793 && rc=0 || rc=$?
log_result "Cross-tenant: alpha→testfirma:18793" "tenant-alpha" "openclaw.tenant-testfirma:18793" "block" "$rc"
run_in tenant-alpha curl -s --connect-timeout 5 http://openclaw.tenant-testfirma.svc:9090 && rc=0 || rc=$?
log_result "Cross-tenant: alpha→testfirma:9090" "tenant-alpha" "openclaw.tenant-testfirma:9090" "block" "$rc"
# ============================================================================
# SECTION 2: Tenant → Platform Services (must be blocked except LiteLLM)
# ============================================================================
log_header "2. Tenant → Platform Services"
# OpenBao
run_in tenant-alpha curl -s --connect-timeout 5 http://openbao.openbao-system.svc:8200/v1/sys/health && rc=0 || rc=$?
log_result "Tenant→OpenBao" "tenant-alpha" "openbao:8200" "block" "$rc"
# ZITADEL (direct svc, not via ingress)
run_in tenant-alpha curl -s --connect-timeout 5 http://zitadel.zitadel.svc:8080/debug/healthz && rc=0 || rc=$?
log_result "Tenant→ZITADEL (svc)" "tenant-alpha" "zitadel:8080" "block" "$rc"
# Portal
run_in tenant-alpha curl -s --connect-timeout 5 http://pieced-portal.pieced-system.svc:3000 && rc=0 || rc=$?
log_result "Tenant→Portal" "tenant-alpha" "pieced-portal:3000" "block" "$rc"
# Portal DB
run_in tenant-alpha curl -s --connect-timeout 5 http://portal-db-rw.pieced-system.svc:5432 && rc=0 || rc=$?
log_result "Tenant→Portal DB" "tenant-alpha" "portal-db-rw:5432" "block" "$rc"
# ArgoCD
run_in tenant-alpha curl -sk --connect-timeout 5 https://argocd-server.argocd.svc:443 && rc=0 || rc=$?
log_result "Tenant→ArgoCD" "tenant-alpha" "argocd-server:443" "block" "$rc"
# ============================================================================
# SECTION 3: Tenant → K8s API Server (must be blocked)
# ============================================================================
log_header "3. Tenant → K8s API Server"
run_in tenant-alpha curl -sk --connect-timeout 5 https://kubernetes.default.svc:443/version && rc=0 || rc=$?
log_result "Tenant→K8s API" "tenant-alpha" "kubernetes.default:443" "block" "$rc"
# Also test from the other tenant
run_in tenant-testfirma curl -sk --connect-timeout 5 https://kubernetes.default.svc:443/version && rc=0 || rc=$?
log_result "Tenant→K8s API" "tenant-testfirma" "kubernetes.default:443" "block" "$rc"
# ============================================================================
# SECTION 4: Tenant → Allowed Paths (must succeed)
# ============================================================================
log_header "4. Tenant → Allowed Paths"
# DNS resolution
run_in tenant-alpha nslookup -timeout=5 google.com && rc=0 || rc=$?
log_result "Tenant→DNS" "tenant-alpha" "kube-dns" "allow" "$rc"
# LiteLLM (adjust namespace if different — check your actual LiteLLM svc namespace)
# Based on .env.example: LITELLM_INTERNAL_URL=http://litellm.inference.svc:4000
run_in tenant-alpha curl -s --connect-timeout 5 http://litellm.inference.svc:4000/health && rc=0 || rc=$?
log_result "Tenant→LiteLLM" "tenant-alpha" "litellm.inference:4000" "allow" "$rc"
# External HTTPS (world:443)
run_in tenant-alpha curl -s --connect-timeout 5 https://httpbin.org/status/200 && rc=0 || rc=$?
log_result "Tenant→world:443" "tenant-alpha" "httpbin.org:443" "allow" "$rc"
# ============================================================================
# SECTION 5: Platform Pod → Platform Services (must succeed)
# ============================================================================
log_header "5. Platform → Platform Services"
# Platform → OpenBao
run_in pieced-system curl -s --connect-timeout 5 http://openbao.openbao.svc:8200/v1/sys/health && rc=0 || rc=$?
log_result "Platform→OpenBao" "pieced-system" "openbao:8200" "allow" "$rc"
# Platform → ZITADEL
run_in pieced-system curl -s --connect-timeout 5 http://zitadel.zitadel.svc:8080/debug/healthz && rc=0 || rc=$?
log_result "Platform→ZITADEL" "pieced-system" "zitadel:8080" "allow" "$rc"
# Platform → K8s API
run_in pieced-system curl -sk --connect-timeout 5 https://kubernetes.default.svc:443/version && rc=0 || rc=$?
log_result "Platform→K8s API" "pieced-system" "kubernetes.default:443" "allow" "$rc"
# Platform → LiteLLM
run_in pieced-system curl -s --connect-timeout 5 http://litellm.inference.svc:4000/health && rc=0 || rc=$?
log_result "Platform→LiteLLM" "pieced-system" "litellm.inference:4000" "allow" "$rc"
# Platform → Portal DB (internal connectivity)
run_in pieced-system curl -s --connect-timeout 5 http://portal-db-rw.pieced-system.svc:5432 && rc=0 || rc=$?
log_result "Platform→Portal DB" "pieced-system" "portal-db-rw:5432" "allow" "$rc"
# ============================================================================
# SECTION 6: Reverse — Tenant → Platform Pod (must be blocked)
# ============================================================================
log_header "6. Tenant → Platform Pods (reverse check)"
# Tenant → operator
run_in tenant-alpha curl -s --connect-timeout 5 http://pieced-operator.pieced-system.svc:8080 && rc=0 || rc=$?
log_result "Tenant→Operator" "tenant-alpha" "pieced-operator:8080" "block" "$rc"
# ============================================================================
# SECTION 7: Metadata / Edge Cases
# ============================================================================
log_header "7. Edge Cases"
# Cloud metadata endpoint (should be unreachable on bare metal, but verify)
run_in tenant-alpha curl -s --connect-timeout 3 http://169.254.169.254/latest/meta-data/ && rc=0 || rc=$?
log_result "Tenant→metadata endpoint" "tenant-alpha" "169.254.169.254" "block" "$rc"
# ============================================================================
# Summary
# ============================================================================
echo ""
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
echo -e "${YLW} SUMMARY${RST}"
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
echo ""
echo -e " ${GRN}Passed: $PASS${RST}"
echo -e " ${RED}Failed: $FAIL${RST}"
echo ""
# Append summary to results file
cat >> "$RESULTS_FILE" <<EOF
## Summary
- **Passed**: $PASS
- **Failed**: $FAIL
- **Date**: $(date -u '+%Y-%m-%d %H:%M:%S UTC')
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume \`litellm.inference.svc:4000\`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.
EOF
echo "Full results written to: $RESULTS_FILE"
if [[ $FAIL -gt 0 ]]; then
echo ""
echo -e "${RED}$FAIL test(s) failed — review results and fix network policies.${RST}"
exit 1
fi

View File

@@ -0,0 +1,97 @@
// Standalone JS port of deriveTenantName for offline verification.
// Mirror lib/tenant-naming.ts byte-for-byte logic.
const MAX_NAMESPACE_LEN = 63;
const NAMESPACE_PREFIX = "tenant-";
const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length;
const SUFFIX_HEX_LEN = 8;
const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1;
const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN;
function slugify(input) {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function requestIdSuffix(requestId) {
const hex = requestId.replace(/-/g, "").toLowerCase();
if (!/^[0-9a-f]{8}/.test(hex)) {
throw new Error(`Invalid request id: ${requestId}`);
}
return hex.slice(0, SUFFIX_HEX_LEN);
}
function deriveTenantName(kind, companyName, requestId) {
const suffix = requestIdSuffix(requestId);
if (kind === "personal") return `p-${suffix}`;
const rawSlug = slugify(companyName);
const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, "");
if (!slug) return `t-${suffix}`;
return `${slug}-${suffix}`;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const cases = [
// [kind, companyName, requestId, expected, note]
["company", "Acme GmbH", "abc12345-1234-1234-1234-123456789abc", "acme-gmbh-abc12345", "basic company"],
["company", "Müller AG", "abc12345-aaaa", "m-ller-ag-abc12345", "umlaut → '-'"],
["company", "!!!", "abc12345-aaaa", "t-abc12345", "no alnum → 't-' fallback"],
["personal", "irrelevant", "abc12345-aaaa", "p-abc12345", "personal ignores companyName"],
["personal", "", "abc12345-aaaa", "p-abc12345", "personal with empty companyName"],
["company", " Trim Me ", "abc12345-aaaa", "trim-me-abc12345", "leading/trailing whitespace"],
["company", "Foo---Bar", "abc12345-aaaa", "foo-bar-abc12345", "consecutive hyphens collapse"],
["company", "A very long company name that absolutely will exceed the slug limit easily", "abc12345-aaaa", null, "must be <= 56 chars"],
["company", "----", "abc12345-aaaa", "t-abc12345", "all-hyphen → fallback"],
["company", "ACME", "ABCDEF12-...", "acme-abcdef12", "uppercase UUID is lowercased"],
];
let pass = 0, fail = 0;
for (const [kind, name, id, expected, note] of cases) {
let got;
let err = null;
try {
got = deriveTenantName(kind, name, id);
} catch (e) {
err = e.message;
}
// Special length-only cases
if (expected === null) {
const ok = got && got.length <= 56;
console.log(`${ok ? "PASS" : "FAIL"} len(${got}) = ${got?.length} [${note}]`);
if (ok) pass++; else fail++;
continue;
}
if (err) {
console.log(`THROW ${err} [${note}]`);
if (expected === "throw") pass++; else fail++;
continue;
}
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
if (ok) pass++; else fail++;
}
// Should-throw cases
console.log("\nThrow cases:");
const throwCases = [
["company", "Acme", "", "empty requestId"],
["company", "Acme", "xyz", "non-hex requestId"],
["company", "Acme", "1234567", "too short (7 chars)"],
];
for (const [kind, name, id, note] of throwCases) {
let threw = false;
try { deriveTenantName(kind, name, id); } catch { threw = true; }
console.log(`${threw ? "PASS" : "FAIL"} threw=${threw} [${note}]`);
if (threw) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

View File

@@ -1,5 +1,5 @@
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { getTranslations } from "next-intl/server"; import { getTranslations, getFormatter } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s"; import { listTenants } from "@/lib/k8s";
import { getTenantRequestByOrgId } from "@/lib/db"; import { getTenantRequestByOrgId } from "@/lib/db";
@@ -7,6 +7,7 @@ import { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display"; import { UsageDisplay } from "@/components/dashboard/usage-display";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { formatDateTime } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -15,6 +16,7 @@ export default async function DashboardPage() {
const t = await getTranslations("dashboard"); const t = await getTranslations("dashboard");
const tAdmin = await getTranslations("admin"); const tAdmin = await getTranslations("admin");
const f = await getFormatter();
const allTenants = await listTenants(); const allTenants = await listTenants();
@@ -110,9 +112,7 @@ export default async function DashboardPage() {
{tenant.spec.packages?.join(", ") || "—"} {tenant.spec.packages?.join(", ") || "—"}
</td> </td>
<td className="px-5 py-3 text-xs text-text-muted tabular-nums"> <td className="px-5 py-3 text-xs text-text-muted tabular-nums">
{tenant.metadata.creationTimestamp {formatDateTime(tenant.metadata.creationTimestamp, f)}
? new Date(tenant.metadata.creationTimestamp).toLocaleDateString()
: "—"}
</td> </td>
<td className="px-5 py-3 text-right"> <td className="px-5 py-3 text-right">
<Link <Link
@@ -169,7 +169,6 @@ export default async function DashboardPage() {
} }
const tenantName = myTenant.metadata.name; const tenantName = myTenant.metadata.name;
const teamId = myTenant.status?.litellmTeamId || tenantName;
return ( return (
<div> <div>
@@ -209,12 +208,12 @@ export default async function DashboardPage() {
</Card> </Card>
</div> </div>
{/* Usage */} {/* Usage — no teamId passed, backend resolves from session */}
<div className="mb-6 animate-in animate-in-delay-2"> <div className="mb-6 animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")} {t("usage")}
</h2> </h2>
<UsageDisplay teamId={myTenant.status?.litellmTeamId || teamId} /> <UsageDisplay />
</div> </div>
{/* Link to tenant detail */} {/* Link to tenant detail */}

View File

@@ -44,6 +44,12 @@ export default function RegisterPage() {
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
// Localize known structured codes; fall back to server-supplied
// English message for everything else (validation, ZITADEL errors,
// generic 500s).
if (data.code === "duplicate_domain" && data.domain) {
throw new Error(t("duplicateDomain", { domain: data.domain }));
}
throw new Error(data.error || "Registration failed"); throw new Error(data.error || "Registration failed");
} }

View File

@@ -1,5 +1,5 @@
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { getTranslations } from "next-intl/server"; import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation"; import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s"; import { getTenant } from "@/lib/k8s";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
@@ -7,6 +7,7 @@ import { UsageDisplay } from "@/components/dashboard/usage-display";
import { PackageList } from "@/components/packages/package-list"; import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor"; import { WorkspaceEditor } from "@/components/packages/workspace-editor";
import { ChannelUsers } from "@/components/channel-users/channel-users"; import { ChannelUsers } from "@/components/channel-users/channel-users";
import { formatDateTime, formatRelative } from "@/lib/format";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"]; const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
@@ -20,6 +21,7 @@ export default async function TenantDetailPage({
const { name } = await params; const { name } = await params;
const t = await getTranslations("tenantDetail"); const t = await getTranslations("tenantDetail");
const f = await getFormatter();
const tenant = await getTenant(name); const tenant = await getTenant(name);
if (!tenant) notFound(); if (!tenant) notFound();
@@ -39,6 +41,12 @@ export default async function TenantDetailPage({
); );
const channelUsers = tenant.spec.channelUsers || {}; const channelUsers = tenant.spec.channelUsers || {};
// Admins inspecting another tenant's usage: pass teamId explicitly.
// Customers viewing their own: no teamId, backend resolves from session.
const usageTeamId = user.isPlatform
? tenant.status?.litellmTeamId || undefined
: undefined;
return ( return (
<div> <div>
{/* Header */} {/* Header */}
@@ -54,6 +62,18 @@ export default async function TenantDetailPage({
{t("agent")}: {tenant.spec.agentName} {t("agent")}: {tenant.spec.agentName}
</p> </p>
)} )}
{tenant.metadata.creationTimestamp && (
<p
className="text-xs text-text-muted mt-1"
title={formatDateTime(tenant.metadata.creationTimestamp, f)}
>
{t("provisioned")}{" "}
{formatRelative(tenant.metadata.creationTimestamp, f)}{" "}
<span className="text-text-muted/60">
({formatDateTime(tenant.metadata.creationTimestamp, f)})
</span>
</p>
)}
</div> </div>
{/* Usage */} {/* Usage */}
@@ -61,7 +81,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")} {t("usage")}
</h2> </h2>
<UsageDisplay teamId={tenant.status?.litellmTeamId || name} /> <UsageDisplay teamId={usageTeamId} />
</section> </section>
{/* Packages */} {/* Packages */}
@@ -96,4 +116,4 @@ export default async function TenantDetailPage({
</section> </section>
</div> </div>
); );
} }

View File

@@ -14,6 +14,8 @@ import {
getDefaultAgentsMd, getDefaultAgentsMd,
generateToolsMd, generateToolsMd,
} from "@/lib/workspace-defaults"; } from "@/lib/workspace-defaults";
import { deriveTenantName } from "@/lib/tenant-naming";
import { safeError } from "@/lib/errors";
/** /**
* POST /api/admin/requests/[id]/approve * POST /api/admin/requests/[id]/approve
@@ -60,13 +62,14 @@ export async function POST(
const isReApproval = tenantRequest.status === "rejected"; const isReApproval = tenantRequest.status === "rejected";
// Derive tenant name from company name: lowercase, alphanumeric + hyphens // Build the CR name: see `lib/tenant-naming.ts` for the format spec.
const tenantName = // For now all approvals are kind="company" — the personal branch is
tenantRequest.companyName // wired but unused until Slice 4 introduces the `is_personal` column.
.toLowerCase() const tenantName = deriveTenantName(
.replace(/[^a-z0-9]+/g, "-") "company",
.replace(/^-|-$/g, "") tenantRequest.companyName,
.slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`; tenantRequest.id
);
try { try {
// Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard) // Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard)
@@ -133,7 +136,7 @@ export async function POST(
} catch (e: any) { } catch (e: any) {
console.error("Failed to create tenant:", e); console.error("Failed to create tenant:", e);
return NextResponse.json( return NextResponse.json(
{ error: `Failed to create tenant: ${e.message}` }, { error: safeError(e, "Failed to create tenant") },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { getTenant, deleteTenant } from "@/lib/k8s"; import { getTenant, deleteTenant } from "@/lib/k8s";
import { markTenantRequestDeletedByTenantName } from "@/lib/db"; import { markTenantRequestDeletedByTenantName } from "@/lib/db";
import { safeError } from "@/lib/errors";
/** /**
* POST /api/admin/tenants/[name]/delete * POST /api/admin/tenants/[name]/delete
@@ -42,7 +43,7 @@ export async function POST(
} catch (e: any) { } catch (e: any) {
console.error("Failed to delete tenant:", e); console.error("Failed to delete tenant:", e);
return NextResponse.json( return NextResponse.json(
{ error: `Failed to delete tenant: ${e.message}` }, { error: safeError(e, "Failed to delete tenant") },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s"; import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { safeError } from "@/lib/errors";
/** /**
* POST /api/admin/tenants/[name]/suspend * POST /api/admin/tenants/[name]/suspend
@@ -35,7 +36,7 @@ export async function POST(
} catch (e: any) { } catch (e: any) {
console.error("Failed to update tenant suspend state:", e); console.error("Failed to update tenant suspend state:", e);
return NextResponse.json( return NextResponse.json(
{ error: `Failed to update tenant: ${e.message}` }, { error: safeError(e, "Failed to update tenant") },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { registerCustomer } from "@/lib/zitadel"; import { registerCustomer } from "@/lib/zitadel";
import { rateLimit } from "@/lib/rate-limit";
import { checkDuplicateDomain } from "@/lib/db";
import type { RegistrationInput } from "@/types"; import type { RegistrationInput } from "@/types";
import { z } from "zod"; import { z } from "zod";
@@ -11,7 +13,34 @@ const registrationSchema = z.object({
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(), preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(),
}); });
export async function POST(request: Request) { /** 3 registrations per IP per hour */
const RATE_LIMIT = 3;
const RATE_WINDOW_MS = 3_600_000; // 1 hour
export async function POST(request: NextRequest) {
// --- Rate limiting ---
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"unknown";
const rl = rateLimit(`register:${ip}`, RATE_LIMIT, RATE_WINDOW_MS);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many registration attempts. Please try again later." },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rl.resetMs / 1000)),
"X-RateLimit-Limit": String(RATE_LIMIT),
"X-RateLimit-Remaining": "0",
},
},
);
}
// --- Validation ---
try { try {
const body = await request.json(); const body = await request.json();
const parsed = registrationSchema.safeParse(body); const parsed = registrationSchema.safeParse(body);
@@ -19,12 +48,34 @@ export async function POST(request: Request) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() }, { error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 } { status: 400 },
); );
} }
const input: RegistrationInput = parsed.data; const input: RegistrationInput = parsed.data;
// --- Duplicate-domain check ---
//
// Block if another active tenant_request or ZITADEL org already exists
// for this corporate email domain. Public domains (gmail, gmx, etc.)
// are exempted by checkDuplicateDomain.
//
// We return a structured `code: "duplicate_domain"` with the matched
// domain so the client can render the localized message via
// register.duplicateDomain (with {domain} interpolation). The fallback
// English string is included for non-i18n clients (curl, monitoring).
const dup = await checkDuplicateDomain(input.email);
if (dup.blocked && dup.domain) {
return NextResponse.json(
{
error: `An account for the email domain ${dup.domain} is already registered. Please contact your company administrator or PieCed IT support.`,
code: "duplicate_domain",
domain: dup.domain,
},
{ status: 409 },
);
}
const result = await registerCustomer({ const result = await registerCustomer({
companyName: input.companyName, companyName: input.companyName,
email: input.email, email: input.email,
@@ -37,9 +88,16 @@ export async function POST(request: Request) {
{ {
orgId: result.orgId, orgId: result.orgId,
userId: result.userId, userId: result.userId,
message: "Registration successful. You will receive an invitation email to set your password.", message:
"Registration successful. You will receive an invitation email to set your password.",
},
{
status: 201,
headers: {
"X-RateLimit-Limit": String(RATE_LIMIT),
"X-RateLimit-Remaining": String(rl.remaining),
},
}, },
{ status: 201 }
); );
} catch (e: any) { } catch (e: any) {
console.error("Registration failed:", e); console.error("Registration failed:", e);
@@ -48,14 +106,14 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ error: zitadelMessage || "Registration failed. Please try again." }, { error: zitadelMessage || "Registration failed. Please try again." },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 },
); );
} }
} }
/** /**
* ZITADEL errors come as: * ZITADEL errors come as:
* "ZITADEL POST /path: 400 {"code":3, "message":"..."}" * "ZITADEL POST /path: 400 {"code":3, "message":"..."}"
* Extract the human-readable "message" field. * Extract the human-readable "message" field.
*/ */
function extractZitadelMessage(errorMsg: string): string | null { function extractZitadelMessage(errorMsg: string): string | null {

View File

@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s"; import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { safeError } from "@/lib/errors";
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
const MAX_WORKSPACE_FILE_SIZE = 10_000;
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
@@ -27,7 +32,7 @@ export async function GET(
return NextResponse.json(tenant); return NextResponse.json(tenant);
} catch (e: any) { } catch (e: any) {
return NextResponse.json( return NextResponse.json(
{ error: e.message }, { error: safeError(e, "Failed to fetch tenant") },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 }
); );
} }
@@ -61,20 +66,130 @@ export async function PATCH(
} }
const specPatch: Record<string, any> = {}; const specPatch: Record<string, any> = {};
if (body.packages !== undefined) specPatch.packages = body.packages;
if (body.workspaceFiles !== undefined) // ── Validate packages against catalog ──
if (body.packages !== undefined) {
if (!Array.isArray(body.packages) || body.packages.length > 10) {
return NextResponse.json(
{ error: "Invalid packages: must be an array of at most 10 items" },
{ status: 400 }
);
}
for (const pkg of body.packages) {
if (typeof pkg !== "string" || !getPackageDef(pkg)) {
return NextResponse.json(
{ error: `Unknown package: ${pkg}` },
{ status: 400 }
);
}
}
specPatch.packages = body.packages;
}
// ── Validate workspaceFiles ──
if (body.workspaceFiles !== undefined) {
if (
typeof body.workspaceFiles !== "object" ||
body.workspaceFiles === null ||
Array.isArray(body.workspaceFiles)
) {
return NextResponse.json(
{ error: "Invalid workspaceFiles: must be an object" },
{ status: 400 }
);
}
for (const [key, value] of Object.entries(body.workspaceFiles)) {
if (!ALLOWED_WORKSPACE_FILES.includes(key)) {
return NextResponse.json(
{
error: `Invalid workspace file: ${key}. Allowed: ${ALLOWED_WORKSPACE_FILES.join(", ")}`,
},
{ status: 400 }
);
}
if (
typeof value !== "string" ||
value.length > MAX_WORKSPACE_FILE_SIZE
) {
return NextResponse.json(
{
error: `Workspace file ${key} must be a string of at most ${MAX_WORKSPACE_FILE_SIZE} characters`,
},
{ status: 400 }
);
}
}
specPatch.workspaceFiles = body.workspaceFiles; specPatch.workspaceFiles = body.workspaceFiles;
if (body.displayName !== undefined) }
// ── Simple string fields ──
if (body.displayName !== undefined) {
if (
typeof body.displayName !== "string" ||
body.displayName.length < 1 ||
body.displayName.length > 100
) {
return NextResponse.json(
{ error: "displayName must be 1-100 characters" },
{ status: 400 }
);
}
specPatch.displayName = body.displayName; specPatch.displayName = body.displayName;
if (body.agentName !== undefined) specPatch.agentName = body.agentName; }
if (body.channelUsers !== undefined)
if (body.agentName !== undefined) {
if (
typeof body.agentName !== "string" ||
body.agentName.length < 1 ||
body.agentName.length > 50
) {
return NextResponse.json(
{ error: "agentName must be 1-50 characters" },
{ status: 400 }
);
}
specPatch.agentName = body.agentName;
}
// ── channelUsers (basic shape validation) ──
if (body.channelUsers !== undefined) {
if (
typeof body.channelUsers !== "object" ||
body.channelUsers === null ||
Array.isArray(body.channelUsers)
) {
return NextResponse.json(
{ error: "Invalid channelUsers: must be an object" },
{ status: 400 }
);
}
for (const [channel, users] of Object.entries(body.channelUsers)) {
if (typeof channel !== "string" || channel.length > 50) {
return NextResponse.json(
{ error: `Invalid channel name: ${channel}` },
{ status: 400 }
);
}
if (
!Array.isArray(users) ||
(users as any[]).some(
(u: any) => typeof u !== "string" || u.length > 100
)
) {
return NextResponse.json(
{ error: `Invalid user IDs for channel ${channel}` },
{ status: 400 }
);
}
}
specPatch.channelUsers = body.channelUsers; specPatch.channelUsers = body.channelUsers;
}
const updated = await patchTenantSpec(name, specPatch); const updated = await patchTenantSpec(name, specPatch);
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (e: any) { } catch (e: any) {
return NextResponse.json( return NextResponse.json(
{ error: e.message }, { error: safeError(e, "Failed to update tenant") },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 }
); );
} }

View File

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { listTenants, getTenant, createTenant } from "@/lib/k8s"; import { listTenants } from "@/lib/k8s";
import type { PiecedTenantSpec } from "@/types";
export async function GET() { export async function GET() {
const user = await getSessionUser(); const user = await getSessionUser();
@@ -20,37 +19,3 @@ export async function GET() {
); );
return NextResponse.json(own); return NextResponse.json(own);
} }
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = (await request.json()) as {
name: string;
spec: PiecedTenantSpec;
};
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(body.name) || body.name.length > 63) {
return NextResponse.json(
{ error: "Invalid tenant name: lowercase alphanumeric and hyphens, 2-63 chars" },
{ status: 400 }
);
}
const existing = await getTenant(body.name);
if (existing) {
return NextResponse.json(
{ error: "Tenant already exists" },
{ status: 409 }
);
}
const tenant = await createTenant(body.name, body.spec, {
"pieced.ch/zitadel-org-id": user.orgId,
});
return NextResponse.json(tenant, { status: 201 });
}

View File

@@ -1,20 +1,49 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm"; import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
* GET /api/usage
*
* Customers: teamId is resolved server-side from the tenant matching the
* user's orgId. No client-supplied teamId accepted.
* Platform admins: may pass ?teamId=... to inspect any tenant's usage.
*/
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const teamId = req.nextUrl.searchParams.get("teamId"); let teamId: string | null = null;
if (!teamId)
return NextResponse.json({ error: "teamId required" }, { status: 400 }); if (user.isPlatform) {
// Admins may pass a specific teamId to inspect any tenant
teamId = req.nextUrl.searchParams.get("teamId") ?? null;
}
// For customers (or admins without explicit teamId): resolve from their tenant
if (!teamId) {
const tenants = await listTenants();
const orgTenant = tenants.find(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (!orgTenant?.status?.litellmTeamId) {
return NextResponse.json(
{ error: "No active tenant found for your organization" },
{ status: 404 }
);
}
teamId = orgTenant.status.litellmTeamId;
}
// Month param: YYYY-MM, defaults to current month // Month param: YYYY-MM, defaults to current month
const now = new Date(); const now = new Date();
const monthParam = req.nextUrl.searchParams.get("month") const monthParam =
|| `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; req.nextUrl.searchParams.get("month") ||
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const [year, month] = monthParam.split("-").map(Number); const [year, month] = monthParam.split("-").map(Number);
const startDate = new Date(year, month - 1, 1); const startDate = new Date(year, month - 1, 1);
@@ -30,18 +59,28 @@ export async function GET(req: NextRequest) {
const allRequests: any[] = []; const allRequests: any[] = [];
let page = 1; let page = 1;
while (true) { while (true) {
const result = await getTeamSpendLogsV2(teamId, startStr, endStr, page, 100); const result = await getTeamSpendLogsV2(
teamId,
startStr,
endStr,
page,
100
);
allRequests.push(...(result.data || [])); allRequests.push(...(result.data || []));
if (page >= (result.total_pages || 1)) break; if (page >= (result.total_pages || 1)) break;
page++; page++;
} }
// Aggregate by day // Aggregate by day
const byDay: Record<string, { inputTokens: number; outputTokens: number; spend: number }> = {}; const byDay: Record<
string,
{ inputTokens: number; outputTokens: number; spend: number }
> = {};
for (const r of allRequests) { for (const r of allRequests) {
const day = (r.startTime || r.endTime || "").slice(0, 10); const day = (r.startTime || r.endTime || "").slice(0, 10);
if (!day) continue; if (!day) continue;
if (!byDay[day]) byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 }; if (!byDay[day])
byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 };
byDay[day].inputTokens += r.prompt_tokens || 0; byDay[day].inputTokens += r.prompt_tokens || 0;
byDay[day].outputTokens += r.completion_tokens || 0; byDay[day].outputTokens += r.completion_tokens || 0;
byDay[day].spend += r.spend || 0; byDay[day].spend += r.spend || 0;
@@ -51,8 +90,14 @@ export async function GET(req: NextRequest) {
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]) => ({ date, ...d })); .map(([date, d]) => ({ date, ...d }));
const totalInput = allRequests.reduce((s, r) => s + (r.prompt_tokens || 0), 0); const totalInput = allRequests.reduce(
const totalOutput = allRequests.reduce((s, r) => s + (r.completion_tokens || 0), 0); (s, r) => s + (r.prompt_tokens || 0),
0
);
const totalOutput = allRequests.reduce(
(s, r) => s + (r.completion_tokens || 0),
0
);
const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0); const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0);
return NextResponse.json({ return NextResponse.json({
@@ -79,6 +124,9 @@ export async function GET(req: NextRequest) {
}); });
} catch (e: any) { } catch (e: any) {
console.error("Usage fetch error:", e.message); console.error("Usage fetch error:", e.message);
return NextResponse.json({ error: "Failed to fetch usage" }, { status: 500 }); return NextResponse.json(
{ error: safeError(e, "Failed to fetch usage") },
{ status: 500 }
);
} }
} }

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { getPackageDef } from "@/lib/packages";
import { import {
getDefaultSoulMd, getDefaultSoulMd,
getDefaultAgentsMd, getDefaultAgentsMd,
@@ -7,9 +8,12 @@ import {
} from "@/lib/workspace-defaults"; } from "@/lib/workspace-defaults";
/** /**
* GET /api/workspace-defaults?orgName=...&packages=telegram,web-search * GET /api/workspace-defaults?packages=telegram,web-search
* Returns default content for SOUL.md, AGENTS.md, and TOOLS.md. * Returns default content for SOUL.md, AGENTS.md, and TOOLS.md.
* Used by the onboarding wizard to pre-fill textareas. * Used by the onboarding wizard to pre-fill textareas.
*
* orgName is always resolved from the authenticated session — never
* accepted as a query parameter.
*/ */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getSessionUser(); const user = await getSessionUser();
@@ -17,10 +21,13 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
const orgName = // Always use the session org name — not a client-supplied parameter
req.nextUrl.searchParams.get("orgName") || user.orgName || "Your Company"; const orgName = user.orgName || "Your Company";
const packagesParam = req.nextUrl.searchParams.get("packages") || ""; const packagesParam = req.nextUrl.searchParams.get("packages") || "";
const packages = packagesParam ? packagesParam.split(",").filter(Boolean) : []; const packages = packagesParam
? packagesParam.split(",").filter((id) => id && getPackageDef(id))
: [];
const [soulMd, agentsMd, toolsMd] = await Promise.all([ const [soulMd, agentsMd, toolsMd] = await Promise.all([
getDefaultSoulMd(orgName), getDefaultSoulMd(orgName),

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import type { PiecedTenant, TenantRequest } from "@/types"; import type { PiecedTenant, TenantRequest } from "@/types";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
type Tab = "requests" | "tenants" | "health"; type Tab = "requests" | "tenants" | "health";
@@ -24,6 +25,7 @@ interface AdminPanelProps {
export function AdminPanel({ initialTenants }: AdminPanelProps) { export function AdminPanel({ initialTenants }: AdminPanelProps) {
const t = useTranslations("admin"); const t = useTranslations("admin");
const f = useFormatter();
const [tab, setTab] = useState<Tab>("requests"); const [tab, setTab] = useState<Tab>("requests");
// Requests state // Requests state
@@ -369,7 +371,19 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<RequestStatusBadge status={req.status} /> <RequestStatusBadge status={req.status} />
</td> </td>
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell"> <td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
{new Date(req.createdAt).toLocaleDateString()} <div
title={`${t("submitted")}: ${formatDateTime(req.createdAt, f)}${
req.updatedAt && req.updatedAt !== req.createdAt
? `\n${t("updated")}: ${formatDateTime(req.updatedAt, f)}`
: ""
}`}
className="leading-tight"
>
<div>{formatDateTime(req.createdAt, f)}</div>
<div className="text-[10px] text-text-muted/70">
{formatRelative(req.createdAt, f)}
</div>
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
@@ -536,11 +550,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
: "—"} : "—"}
</td> </td>
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell"> <td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
{tenant.metadata.creationTimestamp <div
? new Date( title={formatDateTime(
tenant.metadata.creationTimestamp tenant.metadata.creationTimestamp,
).toLocaleDateString() f
: "—"} )}
className="leading-tight"
>
<div>
{formatDateTime(
tenant.metadata.creationTimestamp,
f
)}
</div>
<div className="text-[10px] text-text-muted/70">
{formatRelative(
tenant.metadata.creationTimestamp,
f
)}
</div>
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex gap-1.5 flex-wrap"> <div className="flex gap-1.5 flex-wrap">

View File

@@ -91,7 +91,13 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
); );
} }
export function UsageDisplay({ teamId }: { teamId: string | null }) { /**
* Usage display widget.
*
* - Customers: don't pass teamId — the backend resolves it from the session.
* - Admins inspecting a specific tenant: pass teamId to override.
*/
export function UsageDisplay({ teamId }: { teamId?: string | null }) {
const t = useTranslations("usage"); const t = useTranslations("usage");
const [month, setMonth] = useState(getCurrentMonth); const [month, setMonth] = useState(getCurrentMonth);
const [data, setData] = useState<UsageData | null>(null); const [data, setData] = useState<UsageData | null>(null);
@@ -101,10 +107,15 @@ export function UsageDisplay({ teamId }: { teamId: string | null }) {
const isCurrentMonth = month === getCurrentMonth(); const isCurrentMonth = month === getCurrentMonth();
const fetchUsage = useCallback(() => { const fetchUsage = useCallback(() => {
if (!teamId) { setLoading(false); return; }
setLoading(true); setLoading(true);
setError(null); setError(null);
fetch(`/api/usage?teamId=${encodeURIComponent(teamId)}&month=${month}`)
const params = new URLSearchParams({ month });
if (teamId) {
params.set("teamId", teamId);
}
fetch(`/api/usage?${params}`)
.then((res) => { if (!res.ok) throw new Error(`${res.status}`); return res.json(); }) .then((res) => { if (!res.ok) throw new Error(`${res.status}`); return res.json(); })
.then(setData) .then(setData)
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
@@ -113,8 +124,6 @@ export function UsageDisplay({ teamId }: { teamId: string | null }) {
useEffect(() => { fetchUsage(); }, [fetchUsage]); useEffect(() => { fetchUsage(); }, [fetchUsage]);
if (!teamId) return null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Month selector */} {/* Month selector */}
@@ -182,4 +191,4 @@ function StatCard({ label, value, accent }: { label: string; value: string; acce
<div className={`font-display text-lg font-semibold tabular-nums ${accent ? "text-accent" : "text-text-primary"}`}>{value}</div> <div className={`font-display text-lg font-semibold tabular-nums ${accent ? "text-accent" : "text-text-primary"}`}>{value}</div>
</div> </div>
); );
} }

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
interface OnboardingState { interface OnboardingState {
state: string; state: string;
@@ -13,6 +14,7 @@ interface OnboardingState {
companyName: string; companyName: string;
agentName: string; agentName: string;
adminNotes?: string; adminNotes?: string;
createdAt?: string;
}; };
tenant?: { tenant?: {
name: string; name: string;
@@ -30,6 +32,7 @@ interface OnboardingState {
export function ProvisioningStatus() { export function ProvisioningStatus() {
const t = useTranslations("onboarding"); const t = useTranslations("onboarding");
const f = useFormatter();
const [data, setData] = useState<OnboardingState | null>(null); const [data, setData] = useState<OnboardingState | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -107,6 +110,20 @@ export function ProvisioningStatus() {
<p className="text-sm text-text-secondary max-w-sm mx-auto"> <p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")} {t("pendingDescription")}
</p> </p>
{data.request?.createdAt && (
<p
className="text-xs text-text-muted mt-4"
title={formatDateTime(data.request.createdAt, f)}
>
{t("submittedAt")}{" "}
<span className="text-text-secondary">
{formatRelative(data.request.createdAt, f)}
</span>{" "}
<span className="text-text-muted/60">
({formatDateTime(data.request.createdAt, f)})
</span>
</p>
)}
</div> </div>
</Card> </Card>
); );

View File

@@ -90,7 +90,7 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
// Fetch DB-stored defaults on mount // Fetch DB-stored defaults on mount
useEffect(() => { useEffect(() => {
fetch(`/api/workspace-defaults?orgName=${encodeURIComponent(orgName)}`) fetch("/api/workspace-defaults")
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((data) => { .then((data) => {
if (data) { if (data) {
@@ -106,7 +106,8 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
.catch(() => { .catch(() => {
/* use inline fallbacks */ /* use inline fallbacks */
}); });
}, [orgName]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-fetch TOOLS.md preview when packages change // Re-fetch TOOLS.md preview when packages change
const packagesKey = config.packages.sort().join(","); const packagesKey = config.packages.sort().join(",");
@@ -115,14 +116,14 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
if (prevPackagesKey.current === packagesKey && defaultsLoaded) return; if (prevPackagesKey.current === packagesKey && defaultsLoaded) return;
prevPackagesKey.current = packagesKey; prevPackagesKey.current = packagesKey;
fetch( fetch(
`/api/workspace-defaults?orgName=${encodeURIComponent(orgName)}&packages=${encodeURIComponent(packagesKey)}` `/api/workspace-defaults?packages=${encodeURIComponent(packagesKey)}`
) )
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((data) => { .then((data) => {
if (data?.toolsMd) setToolsMdPreview(data.toolsMd); if (data?.toolsMd) setToolsMdPreview(data.toolsMd);
}) })
.catch(() => {}); .catch(() => {});
}, [packagesKey, orgName, defaultsLoaded]); }, [packagesKey, defaultsLoaded]);
const stepIndex = STEPS.indexOf(step); const stepIndex = STEPS.indexOf(step);

View File

@@ -39,7 +39,6 @@ export const authConfig: NextAuthConfig = {
callbacks: { callbacks: {
async jwt({ token, account, profile }) { async jwt({ token, account, profile }) {
if (account && profile) { if (account && profile) {
console.log("ZITADEL profile claims:", JSON.stringify(profile, null, 2));
const claims = profile as unknown as ZitadelClaims; const claims = profile as unknown as ZitadelClaims;
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"]; token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"]; token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];

View File

@@ -238,6 +238,17 @@ export async function clearEncryptedSecrets(requestId: string): Promise<void> {
); );
} }
/**
* Wrapper around domain-check.ts that injects the portal's connection pool.
* Kept here so route handlers don't need direct access to the pool.
*/
export async function checkDuplicateDomain(email: string) {
await ensureSchema();
// Lazy import to keep db.ts free of fetch/AbortSignal at module load time.
const { checkRegistrationDomain } = await import("./domain-check");
return checkRegistrationDomain(getPool(), email);
}
/** /**
* Mark a tenant request as "deleted" when the associated tenant CR is deleted. * Mark a tenant request as "deleted" when the associated tenant CR is deleted.
* This allows the customer to re-submit the onboarding wizard. * This allows the customer to re-submit the onboarding wizard.

266
src/lib/domain-check.ts Normal file
View File

@@ -0,0 +1,266 @@
/**
* Domain-uniqueness check for company registration.
*
* Goal: prevent two people from the same company creating two separate
* ZITADEL orgs. If alice@acme.ch registers Acme GmbH, then later
* bob@acme.ch tries to register Acme Holding AG, we should block bob and
* tell him to ask alice for an invite.
*
* Strategy:
* 1. Extract the domain from the submitted email address.
* 2. If the domain is in PUBLIC_EMAIL_DOMAINS, skip the check entirely
* (gmail/outlook/etc. are not company identifiers — many independent
* personal/sole-proprietor registrations may share gmail.com).
* 3. Otherwise, look up tenant_requests with status NOT IN
* ('rejected', 'deleted'). A domain is "in use" if any active row's
* contact_email shares that domain.
* 4. As a secondary check, query ZITADEL for orgs whose primary verified
* domain matches. This catches orgs created outside the portal flow
* (manually in ZITADEL console, or by an earlier bootstrap script).
* The primary-domain check is BEST-EFFORT — if ZITADEL is unreachable
* or returns an unexpected shape, we log and skip. The DB check is
* authoritative for portal-created orgs and that's what matters most.
*
* Returns the matching domain (lowercased) if a duplicate is found, else
* null. The caller turns that into a 409 response with a localized error.
*/
import { Pool } from "pg";
// ---------------------------------------------------------------------------
// Public email-provider blocklist
// ---------------------------------------------------------------------------
/**
* Domains where personal accounts dominate. Registrations from these are
* allowed to coexist independently — we don't treat "two gmail.com users"
* as the same company.
*
* Conservative list focused on Switzerland + major international providers.
* Adding to this list reduces false positives; removing increases them.
* Anything not on this list is treated as a corporate domain.
*/
export const PUBLIC_EMAIL_DOMAINS: ReadonlySet<string> = new Set([
// Global
"gmail.com",
"googlemail.com",
"outlook.com",
"outlook.de",
"hotmail.com",
"hotmail.de",
"hotmail.fr",
"hotmail.it",
"live.com",
"msn.com",
"yahoo.com",
"yahoo.de",
"yahoo.fr",
"yahoo.it",
"icloud.com",
"me.com",
"mac.com",
"proton.me",
"protonmail.com",
"pm.me",
"tutanota.com",
"tutanota.de",
"tuta.io",
"fastmail.com",
"zoho.com",
"aol.com",
// Switzerland
"bluewin.ch",
"gmx.ch",
"gmx.com",
"gmx.net",
"gmx.de",
"gmx.at",
"hispeed.ch",
"sunrise.ch",
"swissonline.ch",
"vtxnet.ch",
"vtx.ch",
"tele2.ch",
"freesurf.ch",
"bluemail.ch",
"hotmail.ch",
"yahoo.ch",
"mail.ch",
// Germany / Austria (common in DACH region)
"web.de",
"t-online.de",
"freenet.de",
"1und1.de",
"aon.at",
// France / Italy
"orange.fr",
"free.fr",
"laposte.net",
"wanadoo.fr",
"sfr.fr",
"libero.it",
"tiscali.it",
"alice.it",
"virgilio.it",
]);
// ---------------------------------------------------------------------------
// Domain extraction
// ---------------------------------------------------------------------------
/**
* Extract the lowercased domain from an email address. Returns null if the
* input is not a well-formed email (defense in depth — Zod already validates
* the format upstream).
*/
export function extractEmailDomain(email: string): string | null {
const at = email.lastIndexOf("@");
if (at === -1 || at === email.length - 1) return null;
const domain = email.slice(at + 1).trim().toLowerCase();
if (!domain || !domain.includes(".")) return null;
return domain;
}
/**
* True if the domain belongs to a public email provider where multiple
* independent registrations should be allowed.
*/
export function isPublicEmailDomain(domain: string): boolean {
return PUBLIC_EMAIL_DOMAINS.has(domain.toLowerCase());
}
// ---------------------------------------------------------------------------
// Database check
// ---------------------------------------------------------------------------
/**
* Look up active tenant_requests whose contact_email shares the given domain.
* Active = status NOT IN ('rejected', 'deleted').
*
* Uses LOWER() on both sides to handle any historical case inconsistency in
* stored emails. The pattern '%@<domain>' is anchored so 'acme.ch' does not
* match 'notacme.ch' or 'acme.ch.evil.com'.
*/
async function findDuplicateInDb(
pool: Pool,
domain: string
): Promise<boolean> {
const result = await pool.query<{ count: string }>(
`SELECT COUNT(*) AS count FROM tenant_requests
WHERE LOWER(contact_email) LIKE $1
AND status NOT IN ('rejected', 'deleted')`,
[`%@${domain.toLowerCase()}`]
);
return Number(result.rows[0]?.count ?? 0) > 0;
}
// ---------------------------------------------------------------------------
// ZITADEL check (secondary, best-effort)
// ---------------------------------------------------------------------------
/**
* Search ZITADEL orgs by primary verified domain.
*
* Uses the v2 OrganizationService.ListOrganizations API:
* POST {ZITADEL_URL}/v2/organizations/_search
*
* Filter shape (per ZITADEL v2 API): an `organizationDomain` query that
* matches against verified domain. Method is EQUALS and case-insensitive.
*
* Returns true if at least one org matches. Returns false on any error
* (network, auth, schema mismatch) — we log and let the DB check be
* authoritative. The portal must not block legitimate registrations because
* ZITADEL had a hiccup.
*/
async function findDuplicateInZitadel(domain: string): Promise<boolean> {
const ZITADEL_URL = process.env.ZITADEL_ISSUER;
const ZITADEL_SA_PAT = process.env.ZITADEL_SA_PAT;
if (!ZITADEL_URL || !ZITADEL_SA_PAT) {
console.warn("ZITADEL env not configured, skipping org-domain check");
return false;
}
try {
const res = await fetch(`${ZITADEL_URL}/v2/organizations/_search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${ZITADEL_SA_PAT}`,
},
body: JSON.stringify({
queries: [
{
organizationDomain: {
domain,
method: "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE",
},
},
],
// Limit + sort: we only need to know whether ANY org has this domain
pagination: { limit: 1 },
}),
// Belt: hard timeout so a hung ZITADEL doesn't stall registration
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
console.warn(
`ZITADEL org-domain search returned ${res.status}, skipping check`
);
return false;
}
const data = (await res.json()) as {
result?: Array<{ id?: string; name?: string }>;
};
return Array.isArray(data.result) && data.result.length > 0;
} catch (err) {
console.warn("ZITADEL org-domain search failed, skipping check:", err);
return false;
}
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
export interface DuplicateCheckResult {
/** True if registration must be blocked. */
blocked: boolean;
/** The domain that was matched (lowercased). Set when blocked = true. */
domain?: string;
}
/**
* Run the full duplicate-domain check for a registration request.
*
* Order:
* - Parse domain. Invalid → not blocked (Zod already failed if so;
* this is just defensive).
* - Public domain → not blocked.
* - DB hit → blocked.
* - ZITADEL hit → blocked.
* - Otherwise → not blocked.
*/
export async function checkRegistrationDomain(
pool: Pool,
email: string
): Promise<DuplicateCheckResult> {
const domain = extractEmailDomain(email);
if (!domain) return { blocked: false };
if (isPublicEmailDomain(domain)) return { blocked: false };
if (await findDuplicateInDb(pool, domain)) {
return { blocked: true, domain };
}
if (await findDuplicateInZitadel(domain)) {
return { blocked: true, domain };
}
return { blocked: false };
}

View File

@@ -42,11 +42,26 @@ function getFrom(): string {
); );
} }
/**
* Escape HTML entities to prevent injection in HTML emails.
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export async function sendApprovalEmail( export async function sendApprovalEmail(
to: string, to: string,
contactName: string, contactName: string,
companyName: string companyName: string
): Promise<void> { ): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
try { try {
await getTransporter().sendMail({ await getTransporter().sendMail({
from: getFrom(), from: getFrom(),
@@ -68,8 +83,8 @@ export async function sendApprovalEmail(
html: ` html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Your AI assistant is being set up</h2> <h2 style="color: #ffffff; margin-top: 0;">Your AI assistant is being set up</h2>
<p>Hello ${contactName},</p> <p>Hello ${safeName},</p>
<p>Great news! Your onboarding request for <strong>${companyName}</strong> has been approved.</p> <p>Great news! Your onboarding request for <strong>${safeCompany}</strong> has been approved.</p>
<p>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p> <p>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p>
<p> <p>
<a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;"> <a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
@@ -95,14 +110,18 @@ export async function sendRejectionEmail(
companyName: string, companyName: string,
adminNotes?: string adminNotes?: string
): Promise<void> { ): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
try { try {
const notesBlock = adminNotes const notesBlock = adminNotes
? `\nNote from our team:\n${adminNotes}\n` ? `\nNote from our team:\n${adminNotes}\n`
: ""; : "";
const notesHtml = adminNotes const notesHtml = safeNotes
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;"> ? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
<p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p> <p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p>
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${adminNotes}</p> <p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${safeNotes}</p>
</div>` </div>`
: ""; : "";
@@ -123,8 +142,8 @@ export async function sendRejectionEmail(
html: ` html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2> <h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2>
<p>Hello ${contactName},</p> <p>Hello ${safeName},</p>
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${companyName}</strong> at this time.</p> <p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${safeCompany}</strong> at this time.</p>
${notesHtml} ${notesHtml}
<p>If you have questions or would like to discuss this further, please reply to this email.</p> <p>If you have questions or would like to discuss this further, please reply to this email.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" /> <hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
@@ -145,6 +164,10 @@ export async function sendAdminNotificationEmail(
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL; const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) return; if (!adminEmail) return;
const safeCompany = escapeHtml(companyName);
const safeName = escapeHtml(contactName);
const safeEmail = escapeHtml(contactEmail);
try { try {
await getTransporter().sendMail({ await getTransporter().sendMail({
from: getFrom(), from: getFrom(),
@@ -158,6 +181,23 @@ export async function sendAdminNotificationEmail(
"", "",
`Review it at https://app.pieced.ch/admin`, `Review it at https://app.pieced.ch/admin`,
].join("\n"), ].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">New onboarding request</h2>
<p>A new onboarding request has been submitted.</p>
<table style="color: #ccc; font-size: 14px; margin: 16px 0;">
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Company:</td><td>${safeCompany}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Contact:</td><td>${safeName} (${safeEmail})</td></tr>
</table>
<p>
<a href="https://app.pieced.ch/admin" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
Review Request
</a>
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
}); });
} catch (err) { } catch (err) {
console.error("Failed to send admin notification email:", err); console.error("Failed to send admin notification email:", err);

37
src/lib/errors.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Error sanitization for API responses.
*
* By default, returns a generic message to the client and logs the full
* error server-side. Set PORTAL_DEBUG_ERRORS=true to return the raw
* error message to the client (useful during development/debugging).
*/
const DEBUG_ERRORS = process.env.PORTAL_DEBUG_ERRORS === "true";
/**
* Returns a safe error string for API responses.
*
* - In debug mode (PORTAL_DEBUG_ERRORS=true): returns the raw e.message
* - In production mode: returns the fallback string and logs the real error
*
* Recognises common HTTP status codes from k8s/vault errors and returns
* appropriate short messages even in production mode.
*/
export function safeError(e: unknown, fallback: string): string {
const err = e instanceof Error ? e : new Error(String(e));
const statusCode = (err as any).statusCode as number | undefined;
if (DEBUG_ERRORS) {
return err.message;
}
// Map well-known status codes to safe messages
if (statusCode === 404) return "Not found";
if (statusCode === 403) return "Forbidden";
if (statusCode === 409) return "Conflict";
if (statusCode === 401) return "Unauthorized";
// Log full error server-side, return generic to client
console.error(`${fallback}:`, err.message);
return fallback;
}

118
src/lib/format.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* Locale-aware date/time formatting helpers.
*
* Built on top of next-intl's format API, which wraps Intl.DateTimeFormat /
* Intl.RelativeTimeFormat using the active request locale. These helpers add
* three things on top of raw next-intl:
*
* 1. Tolerant input — accepts string | Date | null | undefined and returns
* a stable em-dash for missing values, so call sites don't need to
* conditionally render.
* 2. Two presets used everywhere in the portal (`dateTime`, `dateOnly`)
* so the four locales render consistently. German/French/Italian use
* 24h DD.MM.YYYY HH:mm; English uses 12h MMM D, YYYY h:mm a.
* 3. A `relative` helper that auto-picks the right unit (minute/hour/day/
* week/month) based on the elapsed delta.
*
* Usage in client components:
*
* import { useFormatter } from "next-intl";
* import { formatDateTime, formatRelative } from "@/lib/format";
*
* const f = useFormatter();
* <span>{formatDateTime(req.createdAt, f)}</span>
* <span title={formatDateTime(req.createdAt, f)}>
* {formatRelative(req.createdAt, f)}
* </span>
*
* Usage in server components:
*
* import { getFormatter } from "next-intl/server";
* const f = await getFormatter();
* ...same calls...
*/
// next-intl's `useFormatter()` (client) and `getFormatter()` (server) return
// the same shape. We derive the type from useFormatter's return so we stay
// in sync with next-intl version bumps without hand-maintaining a mirror.
import type { useFormatter } from "next-intl";
type Formatter = ReturnType<typeof useFormatter>;
const FALLBACK = "—";
function toDate(value: string | Date | null | undefined): Date | null {
if (!value) return null;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Full date+time, locale-formatted. Returns "—" if the value is missing.
*
* de: 25.04.2026, 14:30
* en: Apr 25, 2026, 2:30 PM
* fr: 25 avr. 2026, 14:30
* it: 25 apr 2026, 14:30
*/
export function formatDateTime(
value: string | Date | null | undefined,
formatter: Formatter
): string {
const d = toDate(value);
if (!d) return FALLBACK;
return formatter.dateTime(d, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Date only, locale-formatted. Use in dense table cells.
*/
export function formatDateOnly(
value: string | Date | null | undefined,
formatter: Formatter
): string {
const d = toDate(value);
if (!d) return FALLBACK;
return formatter.dateTime(d, {
year: "numeric",
month: "short",
day: "numeric",
});
}
/**
* Relative time ("2 hours ago", "vor 2 Stunden", etc.).
* Picks the unit automatically based on the magnitude of the delta.
* Returns "—" if the value is missing.
*
* Anchors against `now` (defaults to current time) so SSR and client
* render the same string when called within a single request.
*/
export function formatRelative(
value: string | Date | null | undefined,
formatter: Formatter,
now: Date = new Date()
): string {
const d = toDate(value);
if (!d) return FALLBACK;
const diffMs = d.getTime() - now.getTime();
const absSeconds = Math.abs(diffMs) / 1000;
let unit: Intl.RelativeTimeFormatUnit;
if (absSeconds < 60) unit = "second";
else if (absSeconds < 3_600) unit = "minute";
else if (absSeconds < 86_400) unit = "hour";
else if (absSeconds < 604_800) unit = "day";
else if (absSeconds < 2_592_000) unit = "week";
else if (absSeconds < 31_536_000) unit = "month";
else unit = "year";
return formatter.relativeTime(d, { now, unit });
}

71
src/lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* In-memory sliding-window rate limiter.
*
* Suitable for single-node deployments (pilot scale).
* For multi-replica, replace with Redis-backed store.
*/
interface RateLimitEntry {
timestamps: number[];
}
const store = new Map<string, RateLimitEntry>();
// Cleanup stale entries every 10 minutes
if (typeof globalThis !== "undefined") {
// Use globalThis to survive HMR in dev — only one interval
const key = "__rateLimitCleanup";
if (!(globalThis as any)[key]) {
(globalThis as any)[key] = setInterval(() => {
const now = Date.now();
for (const [k, entry] of store) {
entry.timestamps = entry.timestamps.filter((t) => now - t < 3_600_000);
if (entry.timestamps.length === 0) store.delete(k);
}
}, 600_000);
}
}
export interface RateLimitResult {
allowed: boolean;
remaining: number;
/** Milliseconds until the oldest request in the window expires */
resetMs: number;
}
/**
* Check and record a rate-limited action.
*
* @param key - Unique key, e.g. `register:${ip}`
* @param limit - Max allowed actions in the window
* @param windowMs - Window size in milliseconds
*/
export function rateLimit(
key: string,
limit: number,
windowMs: number,
): RateLimitResult {
const now = Date.now();
const entry = store.get(key) ?? { timestamps: [] };
// Prune expired timestamps
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
if (entry.timestamps.length >= limit) {
const oldest = entry.timestamps[0];
return {
allowed: false,
remaining: 0,
resetMs: oldest + windowMs - now,
};
}
entry.timestamps.push(now);
store.set(key, entry);
return {
allowed: true,
remaining: limit - entry.timestamps.length,
resetMs: entry.timestamps[0] + windowMs - now,
};
}

132
src/lib/tenant-naming.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* Deterministic tenant-name derivation for PiecedTenant CRs.
*
* Background
* ----------
* Every PiecedTenant CR's `metadata.name` becomes part of the tenant
* namespace, which the operator builds as `tenant-{name}` (see
* `pieced-operator/api/v1alpha1/piecedtenant_types.go::NamespaceName`).
* Kubernetes namespace names follow the RFC 1123 DNS *label* spec:
* lowercased alphanumeric + hyphens, must start and end with alnum,
* and **max 63 characters**.
*
* That gives us 63 - len("tenant-") = 56 chars to play with for the CR
* name itself. Anything longer is rejected at apply time, so we cap
* here.
*
* Format
* ------
* kind=company → {slug}-{requestIdHex8} e.g. "acme-gmbh-abc12345"
* kind=personal → p-{requestIdHex8} e.g. "p-abc12345"
*
* The 8-hex-char suffix is taken from `tenant_requests.id` (a Postgres
* `gen_random_uuid()` value, set at row insert). Two motivations:
*
* 1. Uniqueness — multiple requests for the same company name no longer
* collide (this is what unblocks Slice 3, multi-tenant per org).
* 2. Stability — the suffix is known at approval time and never changes,
* so the operator and portal agree without coordination. We use the
* request UUID rather than the eventual LiteLLM virtual-key UUID
* because the latter doesn't exist until the operator runs.
*
* 8 hex chars = 32 bits of entropy. Collision probability with 100 active
* tenants per company prefix is ~1e-6; for our pilot scale that's fine.
*
* Limits
* ------
* Suffix is always 8 + 1 (hyphen) = 9 chars. Slug therefore caps at
* 56 - 9 = 47 chars, then we strip any trailing hyphens left by the cut.
*
* Examples
* --------
* deriveTenantName("company", "Acme GmbH", "abc12345-...") = "acme-gmbh-abc12345"
* deriveTenantName("company", "Müller AG", "abc12345-...") = "m-ller-ag-abc12345" (umlaut → "-")
* deriveTenantName("company", "!!!", "abc12345-...") = "t-abc12345" (slug empty → "t-")
* deriveTenantName("personal", "", "abc12345-...") = "p-abc12345"
*/
export type TenantKind = "company" | "personal";
const MAX_NAMESPACE_LEN = 63;
const NAMESPACE_PREFIX = "tenant-";
const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length; // 56
const SUFFIX_HEX_LEN = 8;
const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1; // including the joining "-"
const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN; // 47
export class InvalidRequestIdError extends Error {
constructor(requestId: string) {
super(
`Cannot derive tenant name: requestId "${requestId}" does not contain ${SUFFIX_HEX_LEN} hex characters`
);
this.name = "InvalidRequestIdError";
}
}
/**
* Reduce an arbitrary string to a DNS-label-safe slug. Non-alnum runs
* collapse to a single "-"; leading/trailing hyphens are stripped.
*
* Note this does not transliterate Unicode — "Müller" becomes "m-ller",
* not "mueller". That's deliberate: transliteration introduces locale
* dependencies (de-DE vs de-CH vs sv-SE all disagree on ä→a/ä→ae) and
* we'd rather have a stable, ugly slug than a pretty one that changes
* if we touch the locale config later. Customers see the human-readable
* `displayName`, not the slug.
*/
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Extract the first 8 hex chars of a UUID string. Strips hyphens and
* lowercases first so callers can pass either "abc12345-..." or
* "ABC12345..." form. Postgres `gen_random_uuid()` already emits the
* canonical lowercase-hyphenated form, so this is just defense in depth
* against any hand-inserted IDs.
*/
function requestIdSuffix(requestId: string): string {
const hex = requestId.replace(/-/g, "").toLowerCase();
if (!/^[0-9a-f]{8}/.test(hex)) {
throw new InvalidRequestIdError(requestId);
}
return hex.slice(0, SUFFIX_HEX_LEN);
}
/**
* Build the PiecedTenant CR `metadata.name` for an approved tenant request.
*
* @param kind "company" for normal customer accounts; "personal"
* for individual accounts (Slice 4 — `is_personal=true`).
* @param companyName Raw display name from the registration. Ignored when
* kind="personal".
* @param requestId `tenant_requests.id` (Postgres UUID).
* @returns A K8s-safe CR name, ≤ 56 chars, with an 8-hex suffix.
*/
export function deriveTenantName(
kind: TenantKind,
companyName: string,
requestId: string
): string {
const suffix = requestIdSuffix(requestId);
if (kind === "personal") {
return `p-${suffix}`;
}
// Company branch: slug-{suffix}, with empty-slug fallback.
const rawSlug = slugify(companyName);
// Cap then re-trim — slicing might leave a dangling hyphen if a non-alnum
// run sat right at the boundary (e.g. "acme-foo-bar-..." cut to "acme-foo-").
const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, "");
if (!slug) {
return `t-${suffix}`;
}
return `${slug}-${suffix}`;
}

View File

@@ -34,7 +34,8 @@
"footer": "Ihre Daten werden ausschliesslich On-Premises in der Schweiz gehostet.", "footer": "Ihre Daten werden ausschliesslich On-Premises in der Schweiz gehostet.",
"successTitle": "Registrierung eingegangen", "successTitle": "Registrierung eingegangen",
"successDescription": "Sie erhalten eine Einladungs-E-Mail mit einem Link, um Ihr Passwort festzulegen und Ihre E-Mail-Adresse zu bestätigen. Danach können Sie sich anmelden und Ihren KI-Assistenten einrichten.", "successDescription": "Sie erhalten eine Einladungs-E-Mail mit einem Link, um Ihr Passwort festzulegen und Ihre E-Mail-Adresse zu bestätigen. Danach können Sie sich anmelden und Ihren KI-Assistenten einrichten.",
"goToLogin": "Zur Anmeldung" "goToLogin": "Zur Anmeldung",
"duplicateDomain": "Für die E-Mail-Domain {domain} ist bereits ein Konto registriert. Bitte wenden Sie sich an Ihren Firmenadministrator, um eingeladen zu werden, oder kontaktieren Sie den PieCed-IT-Support, falls dies ein Fehler ist."
}, },
"onboarding": { "onboarding": {
"loading": "Status wird geladen…", "loading": "Status wird geladen…",
@@ -81,7 +82,8 @@
"phase": "Phase", "phase": "Phase",
"readyTitle": "Ihr Assistent ist bereit!", "readyTitle": "Ihr Assistent ist bereit!",
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.", "readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.",
"goToDashboard": "Zum Dashboard" "goToDashboard": "Zum Dashboard",
"submittedAt": "Eingereicht"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -99,7 +101,8 @@
"packages": "Pakete", "packages": "Pakete",
"workspaceFiles": "Workspace-Dateien", "workspaceFiles": "Workspace-Dateien",
"notFound": "Tenant nicht gefunden.", "notFound": "Tenant nicht gefunden.",
"usage": "Nutzung & Kosten" "usage": "Nutzung & Kosten",
"provisioned": "Bereitgestellt"
}, },
"usage": { "usage": {
"inputTokens": "Input-Tokens", "inputTokens": "Input-Tokens",
@@ -191,6 +194,7 @@
"agentName": "Agent", "agentName": "Agent",
"status": "Status", "status": "Status",
"submitted": "Eingereicht", "submitted": "Eingereicht",
"updated": "Aktualisiert",
"actions": "Aktionen", "actions": "Aktionen",
"noRequests": "Keine Anfragen gefunden.", "noRequests": "Keine Anfragen gefunden.",
"loadingRequests": "Anfragen werden geladen…", "loadingRequests": "Anfragen werden geladen…",

View File

@@ -34,7 +34,8 @@
"footer": "Your data is hosted exclusively on-premises in Switzerland.", "footer": "Your data is hosted exclusively on-premises in Switzerland.",
"successTitle": "Registration received", "successTitle": "Registration received",
"successDescription": "You will receive an invitation email with a link to set your password and verify your email address. Once completed, you can sign in to set up your AI assistant.", "successDescription": "You will receive an invitation email with a link to set your password and verify your email address. Once completed, you can sign in to set up your AI assistant.",
"goToLogin": "Go to Sign In" "goToLogin": "Go to Sign In",
"duplicateDomain": "An account for the email domain {domain} is already registered. Please contact your company administrator to be invited, or reach out to PieCed IT support if you believe this is in error."
}, },
"onboarding": { "onboarding": {
"loading": "Loading status…", "loading": "Loading status…",
@@ -81,7 +82,8 @@
"phase": "Phase", "phase": "Phase",
"readyTitle": "Your assistant is ready!", "readyTitle": "Your assistant is ready!",
"readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.", "readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.",
"goToDashboard": "Go to Dashboard" "goToDashboard": "Go to Dashboard",
"submittedAt": "Submitted"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -99,7 +101,8 @@
"packages": "Packages", "packages": "Packages",
"workspaceFiles": "Workspace Files", "workspaceFiles": "Workspace Files",
"notFound": "Tenant not found.", "notFound": "Tenant not found.",
"usage": "Usage & Spend" "usage": "Usage & Spend",
"provisioned": "Provisioned"
}, },
"usage": { "usage": {
"inputTokens": "Input Tokens", "inputTokens": "Input Tokens",
@@ -191,6 +194,7 @@
"agentName": "Agent", "agentName": "Agent",
"status": "Status", "status": "Status",
"submitted": "Submitted", "submitted": "Submitted",
"updated": "Updated",
"actions": "Actions", "actions": "Actions",
"noRequests": "No requests found.", "noRequests": "No requests found.",
"loadingRequests": "Loading requests…", "loadingRequests": "Loading requests…",

View File

@@ -34,7 +34,8 @@
"footer": "Vos données sont hébergées exclusivement on-premises en Suisse.", "footer": "Vos données sont hébergées exclusivement on-premises en Suisse.",
"successTitle": "Inscription reçue", "successTitle": "Inscription reçue",
"successDescription": "Vous recevrez un e-mail d'invitation avec un lien pour définir votre mot de passe et vérifier votre adresse e-mail. Ensuite, vous pourrez vous connecter et configurer votre assistant IA.", "successDescription": "Vous recevrez un e-mail d'invitation avec un lien pour définir votre mot de passe et vérifier votre adresse e-mail. Ensuite, vous pourrez vous connecter et configurer votre assistant IA.",
"goToLogin": "Aller à la connexion" "goToLogin": "Aller à la connexion",
"duplicateDomain": "Un compte pour le domaine de courriel {domain} est déjà enregistré. Veuillez contacter l'administrateur de votre entreprise pour être invité, ou contactez le support PieCed IT si vous pensez qu'il s'agit d'une erreur."
}, },
"onboarding": { "onboarding": {
"loading": "Chargement du statut…", "loading": "Chargement du statut…",
@@ -81,7 +82,8 @@
"phase": "Phase", "phase": "Phase",
"readyTitle": "Votre assistant est prêt !", "readyTitle": "Votre assistant est prêt !",
"readyDescription": "Votre assistant IA a été mis en service et est actif. Vous pouvez maintenant le gérer depuis le tableau de bord.", "readyDescription": "Votre assistant IA a été mis en service et est actif. Vous pouvez maintenant le gérer depuis le tableau de bord.",
"goToDashboard": "Aller au tableau de bord" "goToDashboard": "Aller au tableau de bord",
"submittedAt": "Soumis"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -99,7 +101,8 @@
"packages": "Paquets", "packages": "Paquets",
"workspaceFiles": "Fichiers workspace", "workspaceFiles": "Fichiers workspace",
"notFound": "Locataire non trouvé.", "notFound": "Locataire non trouvé.",
"usage": "Utilisation et coûts" "usage": "Utilisation et coûts",
"provisioned": "Provisionné"
}, },
"usage": { "usage": {
"inputTokens": "Tokens d'entrée", "inputTokens": "Tokens d'entrée",
@@ -191,6 +194,7 @@
"agentName": "Agent", "agentName": "Agent",
"status": "Statut", "status": "Statut",
"submitted": "Soumis", "submitted": "Soumis",
"updated": "Mis à jour",
"actions": "Actions", "actions": "Actions",
"noRequests": "Aucune demande trouvée.", "noRequests": "Aucune demande trouvée.",
"loadingRequests": "Chargement des demandes…", "loadingRequests": "Chargement des demandes…",

View File

@@ -34,7 +34,8 @@
"footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.", "footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.",
"successTitle": "Registrazione ricevuta", "successTitle": "Registrazione ricevuta",
"successDescription": "Riceverai un'e-mail di invito con un link per impostare la password e verificare il tuo indirizzo e-mail. Dopodiché potrai accedere e configurare il tuo assistente IA.", "successDescription": "Riceverai un'e-mail di invito con un link per impostare la password e verificare il tuo indirizzo e-mail. Dopodiché potrai accedere e configurare il tuo assistente IA.",
"goToLogin": "Vai all'accesso" "goToLogin": "Vai all'accesso",
"duplicateDomain": "Un account per il dominio e-mail {domain} è già registrato. Contatta l'amministratore della tua azienda per essere invitato, oppure contatta il supporto PieCed IT se ritieni che si tratti di un errore."
}, },
"onboarding": { "onboarding": {
"loading": "Caricamento stato…", "loading": "Caricamento stato…",
@@ -81,7 +82,8 @@
"phase": "Fase", "phase": "Fase",
"readyTitle": "Il tuo assistente è pronto!", "readyTitle": "Il tuo assistente è pronto!",
"readyDescription": "Il tuo assistente IA è stato attivato ed è operativo. Ora puoi gestirlo dalla dashboard.", "readyDescription": "Il tuo assistente IA è stato attivato ed è operativo. Ora puoi gestirlo dalla dashboard.",
"goToDashboard": "Vai alla dashboard" "goToDashboard": "Vai alla dashboard",
"submittedAt": "Inviato"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -99,7 +101,8 @@
"packages": "Pacchetti", "packages": "Pacchetti",
"workspaceFiles": "File workspace", "workspaceFiles": "File workspace",
"notFound": "Tenant non trovato.", "notFound": "Tenant non trovato.",
"usage": "Utilizzo e costi" "usage": "Utilizzo e costi",
"provisioned": "Attivato"
}, },
"usage": { "usage": {
"inputTokens": "Token di input", "inputTokens": "Token di input",
@@ -191,6 +194,7 @@
"agentName": "Agente", "agentName": "Agente",
"status": "Stato", "status": "Stato",
"submitted": "Inviato", "submitted": "Inviato",
"updated": "Aggiornato",
"actions": "Azioni", "actions": "Azioni",
"noRequests": "Nessuna richiesta trovata.", "noRequests": "Nessuna richiesta trovata.",
"loadingRequests": "Caricamento richieste…", "loadingRequests": "Caricamento richieste…",