Compare commits

..

No commits in common. "2967f1a5af7d0045269fd99099b7e1a6e02e9a31" and "951dd8d26b612c7bc24ce88d0e11e8ba8086ae9c" have entirely different histories.

34 changed files with 183 additions and 775 deletions

View file

@ -55,7 +55,7 @@ BANANAS_PUBLIC_BASE_URL=http://localhost:8080
# The development realm. A production deployment points this at its own provider; # The development realm. A production deployment points this at its own provider;
# only the issuer, client credentials and group mapping are provider-specific. # only the issuer, client credentials and group mapping are provider-specific.
BANANAS_OIDC_ISSUER=https://auth.lcl-urpi.be/realms/urpi BANANAS_OIDC_ISSUER=https://auth-dev.urpi.be/realms/urpi
BANANAS_OIDC_CLIENT_ID=go-bananas BANANAS_OIDC_CLIENT_ID=go-bananas
# BANANAS_OIDC_CLIENT_SECRET=read-from-a-secret # BANANAS_OIDC_CLIENT_SECRET=read-from-a-secret
@ -163,4 +163,4 @@ BANANAS_ENABLE_PROFILER=false
# Passed by docker-bake and goreleaser rather than set by hand. # Passed by docker-bake and goreleaser rather than set by hand.
# VERSION=dev # VERSION=dev
# COMMIT=unknown # COMMIT=unknown
# BUILD_DATE=unknown # BUILD_DATE=unknown

View file

@ -1,175 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
NAMESPACE=go-insane-prd
REGISTRY=nexus.urpi.be
CERT_MANAGER_VERSION=v1.19.1
LOCAL_PATH_VERSION=v0.0.37
LOCK_NAME=forgejo-prd-deploy-lock
if [[ $# -lt 4 ]]; then
echo "Usage: $0 <chart> <release> <image-name> <image-tag> [helm args...]" >&2
exit 2
fi
CHART=$1
RELEASE=$2
IMAGE_NAME=$3
IMAGE_TAG=$4
shift 4
: "${KUBECONFIG_PRD:?Set the Forgejo secret KUBECONFIG_PRD to the PRD kubeconfig contents}"
: "${NEXUS_USERNAME:?Set the Forgejo secret NEXUS_USERNAME}"
: "${NEXUS_PASSWORD:?Set the Forgejo secret NEXUS_PASSWORD}"
KUBECONFIG="${RUNNER_TEMP:-/tmp}/kubeconfig-prd-${GITHUB_RUN_ID:-$$}"
umask 077
printf '%s\n' "$KUBECONFIG_PRD" > "$KUBECONFIG"
export KUBECONFIG
LOCK_ACQUIRED=false
cleanup() {
if [[ "$LOCK_ACQUIRED" == true ]]; then kubectl -n "$NAMESPACE" delete configmap "$LOCK_NAME" --ignore-not-found --wait=false >/dev/null 2>&1 || true; fi
rm -f "$KUBECONFIG"
}
trap cleanup EXIT
secret_value() {
local name=$1 key=$2 encoded
encoded="$(kubectl -n "$NAMESPACE" get secret "$name" -o go-template="{{index .data \"${key}\"}}" 2>/dev/null || true)"
[[ -z "$encoded" || "$encoded" == "<no value>" ]] && return 0
printf '%s' "$encoded" | base64 -d 2>/dev/null || true
}
kubectl get --raw=/readyz >/dev/null
# Create the workload namespace and its Nexus pull credential idempotently.
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
for attempt in {1..360}; do
if kubectl -n "$NAMESPACE" create configmap "$LOCK_NAME" --from-literal=owner="${GITHUB_REPOSITORY:-local}:${GITHUB_RUN_ID:-$$}" >/dev/null 2>&1; then LOCK_ACQUIRED=true; break; fi
sleep 5
done
if [[ "$LOCK_ACQUIRED" != true ]]; then echo "Timed out waiting for another go-insane-prd deploy to release its lock." >&2; exit 1; fi
kubectl -n "$NAMESPACE" create secret docker-registry nexus-registry \
--docker-server="$REGISTRY" \
--docker-username="$NEXUS_USERNAME" \
--docker-password="$NEXUS_PASSWORD" \
--dry-run=client -o yaml | kubectl apply -f -
# Stateful charts use the cluster default StorageClass. Install the Rancher local
# path provisioner only when the cluster has no default class at all.
DEFAULT_STORAGE_CLASS="$(kubectl get storageclass -o=jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}{"\n"}{end}' | awk '$2 == "true" { print $1; exit }')"
if [[ -z "$DEFAULT_STORAGE_CLASS" ]]; then
if ! kubectl get storageclass local-path >/dev/null 2>&1 || ! kubectl -n local-path-storage get deployment local-path-provisioner >/dev/null 2>&1; then
kubectl apply -f "https://raw.githubusercontent.com/rancher/local-path-provisioner/${LOCAL_PATH_VERSION}/deploy/local-path-storage.yaml"
kubectl -n local-path-storage rollout status deployment/local-path-provisioner --timeout=5m
fi
kubectl annotate storageclass local-path storageclass.kubernetes.io/is-default-class=true --overwrite
fi
# Applications' production charts request nginx ingress and Let's Encrypt TLS.
# Install only missing cluster prerequisites; leave existing installations intact.
if ! kubectl get ingressclass nginx >/dev/null 2>&1; then
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx --force-update
helm repo update ingress-nginx
helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.service.type=NodePort \
--set controller.hostPort.enabled=true \
--set controller.kind=DaemonSet \
--set controller.admissionWebhooks.enabled=false \
--wait --timeout 10m
fi
if ! kubectl get crd clusterissuers.cert-manager.io >/dev/null 2>&1 || ! kubectl -n cert-manager get deployment cert-manager >/dev/null 2>&1; then
kubectl apply --validate=false -f "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
kubectl -n cert-manager rollout status deployment/cert-manager --timeout=5m
kubectl -n cert-manager rollout status deployment/cert-manager-cainjector --timeout=5m
kubectl -n cert-manager rollout status deployment/cert-manager-webhook --timeout=5m
fi
if ! kubectl get clusterissuer letsencrypt-prod >/dev/null 2>&1; then
kubectl apply -f - <<'ISSUER'
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@urpi.be
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
ISSUER
fi
kubectl wait --for=condition=Ready clusterissuer/letsencrypt-prod --timeout=2m
if [[ ! -d "$CHART" ]]; then
echo "Helm chart not found: $CHART" >&2
exit 1
fi
if [[ -f "$CHART/Chart.lock" ]]; then helm dependency build "$CHART"; fi
HELM_ARGS=(
--namespace "$NAMESPACE"
--create-namespace
)
if [[ "$RELEASE" != go-guess ]]; then
HELM_ARGS+=(--set-string "image.repository=${REGISTRY}/docker-hosted/${IMAGE_NAME}" --set-string "image.tag=${IMAGE_TAG}")
fi
if [[ -f "$CHART/values-prd.yaml" ]]; then
HELM_ARGS+=(--values "$CHART/values-prd.yaml")
fi
case "$RELEASE" in
go-loose)
if ! kubectl -n "$NAMESPACE" get secret go-loose-runtime >/dev/null 2>&1; then
db_password="$(secret_value go-loose-postgresql postgres-password)"
if [[ -z "$db_password" ]] && kubectl -n "$NAMESPACE" get pvc -o name | grep -Eq 'go-loose.*postgresql|data-go-loose'; then
echo "Existing go-loose database storage has no recoverable password secret; refusing to rotate its credentials." >&2
exit 1
fi
[[ -n "$db_password" ]] || db_password="$(openssl rand -hex 24)"
session_secret="$(secret_value go-loose-env GO_LOOSE_SESSION_SECRET)"
[[ -n "$session_secret" ]] || session_secret="$(openssl rand -hex 32)"
kubectl -n "$NAMESPACE" create secret generic go-loose-runtime --from-literal=postgresql-password="$db_password" --from-literal=session-secret="$session_secret"
fi
db_password="$(secret_value go-loose-runtime postgresql-password)"
session_secret="$(secret_value go-loose-runtime session-secret)"
database_url="postgres://goloose:${db_password}@go-loose-postgresql:5432/goloose?sslmode=disable"
HELM_ARGS+=(--set-string "postgresql.auth.password=${db_password}" --set-string "envSecrets.GO_LOOSE_DATABASE_URL=${database_url}" --set-string "envSecrets.GO_LOOSE_SESSION_SECRET=${session_secret}")
;;
go-mad)
if ! kubectl -n "$NAMESPACE" get secret go-mad-runtime >/dev/null 2>&1; then
db_password="$(secret_value go-mad-postgresql password)"
[[ -n "$db_password" ]] || db_password="$(secret_value go-mad-postgresql postgres-password)"
if [[ -z "$db_password" ]] && kubectl -n "$NAMESPACE" get pvc -o name | grep -Eq 'go-mad.*postgresql|data-go-mad'; then
echo "Existing go-mad database storage has no recoverable password secret; refusing to rotate its credentials." >&2
exit 1
fi
[[ -n "$db_password" ]] || db_password="$(openssl rand -hex 24)"
kubectl -n "$NAMESPACE" create secret generic go-mad-runtime --from-literal=postgresql-password="$db_password" --from-literal=jwt-secret="$(openssl rand -hex 32)"
fi
db_password="$(secret_value go-mad-runtime postgresql-password)"
jwt_secret="$(secret_value go-mad-runtime jwt-secret)"
HELM_ARGS+=(--set-string "postgresql.auth.password=${db_password}" --set-string "go-mad.jwtSecret=${jwt_secret}")
;;
go-first)
if ! kubectl -n "$NAMESPACE" get secret go-first-runtime >/dev/null 2>&1; then
kubectl -n "$NAMESPACE" create secret generic go-first-runtime --from-literal=jwt-secret="$(openssl rand -hex 32)"
fi
;;
go-places)
: "${GO_LOOSE_JWT_SECRET:?Set Forgejo secret GO_LOOSE_JWT_SECRET to the same signing key used by go-loose}"
kubectl -n "$NAMESPACE" create secret generic go-loose-jwt --from-literal=secret="$GO_LOOSE_JWT_SECRET" --dry-run=client -o yaml | kubectl apply -f -
;;
go-bananas)
: "${GO_BANANAS_OIDC_CLIENT_SECRET:?Set Forgejo secret GO_BANANAS_OIDC_CLIENT_SECRET}"
session_secret="$(secret_value go-bananas-auth session-secret)"
if [[ -z "$session_secret" ]]; then session_secret="$(openssl rand -hex 32)"; fi
kubectl -n "$NAMESPACE" create secret generic go-bananas-auth --from-literal=session-secret="$session_secret" --from-literal=oidc-client-secret="$GO_BANANAS_OIDC_CLIENT_SECRET" --dry-run=client -o yaml | kubectl apply -f -
;;
esac
helm upgrade --install "$RELEASE" "$CHART" "${HELM_ARGS[@]}" "$@" --wait --timeout 15m
kubectl -n "$NAMESPACE" get deployments,statefulsets,pods,services,ingresses

View file

@ -1,101 +0,0 @@
name: Build, test, publish, deploy
on:
push:
pull_request:
workflow_dispatch:
env:
GO_VERSION: "1.27.1"
NODE_VERSION: "24"
REGISTRY: nexus.urpi.be
IMAGE_NAME: docker-hosted/go-bananas
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: |
web/package-lock.json
sdk/package-lock.json
- name: Build and test Go server
run: |
go vet ./...
make test-go
make build-go
- name: Build and test admin UI
run: |
npm ci --no-audit --no-fund
npm run lint
npm test
npm run build
working-directory: web
- name: Build and test SDK
run: |
npm ci --no-audit --no-fund
npm run typecheck
npm test
npm run build
working-directory: sdk
- uses: azure/setup-helm@v4
with:
version: v3.18.6
- name: Validate production chart
run: helm lint --strict deploy/helm/go-bananas -f deploy/helm/go-bananas/values-prd.yaml
publish:
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
needs: build-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.NEXUS_USERNAME }}
password: ${{ secrets.NEXUS_PASSWORD }}
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
deploy-prd:
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
needs: publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
with:
version: v3.18.6
- uses: azure/setup-kubectl@v4
with:
version: v1.32.2
- name: Bootstrap resources and deploy go-bananas
env:
KUBECONFIG_PRD: ${{ secrets.KUBECONFIG_PRD }}
NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
GO_BANANAS_OIDC_CLIENT_SECRET: ${{ secrets.GO_BANANAS_OIDC_CLIENT_SECRET }}
run: |
set -Eeuo pipefail
: "${GO_BANANAS_OIDC_CLIENT_SECRET:?Add Forgejo secret GO_BANANAS_OIDC_CLIENT_SECRET}"
bash .forgejo/scripts/deploy-prd.sh deploy/helm/go-bananas go-bananas go-bananas "${{ github.sha }}" \
--set-string identity.oidc.issuer=https://auth.urpi.be/realms/urpi \
--set-string identity.oidc.clientId=go-bananas \
--set-string identity.oidc.existingSecret=go-bananas-auth \
--set identity.oidc.generateSecret=false

View file

@ -32,7 +32,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
REGISTRY ?= nexus.urpi.be/docker-hosted REGISTRY ?= nexus.urpi.be
IMAGE_NAME ?= $(REGISTRY)/$(BINARY) IMAGE_NAME ?= $(REGISTRY)/$(BINARY)
IMAGE_TAG ?= $(VERSION) IMAGE_TAG ?= $(VERSION)
PLATFORM ?= linux/amd64,linux/arm64 PLATFORM ?= linux/amd64,linux/arm64
@ -127,13 +127,12 @@ dev: ## Run the API with hot reload (Ctrl-C to stop)
air -c .air.toml air -c .air.toml
.PHONY: dev-api .PHONY: dev-api
dev-api: ## Run the API (no SPA); Vite serves the UI, this serves the runtime SDK dev-api: ## Run only the API server, no frontend
@BANANAS_ENVIRONMENT=development \ @BANANAS_ENVIRONMENT=development \
BANANAS_AUTH_MODE=dev \ BANANAS_AUTH_MODE=dev \
BANANAS_HTTP_ADDR=:8080 \ BANANAS_HTTP_ADDR=:8080 \
BANANAS_DATABASE_PATH=./data/go-bananas.db \ BANANAS_DATABASE_PATH=./data/go-bananas.db \
BANANAS_STATIC_DIR= \ BANANAS_STATIC_DIR= BANANAS_SDK_DIR= \
BANANAS_SDK_DIR=$(SDK_DIR)/dist \
$(GO) run -ldflags "$(LDFLAGS)" $(CMD) $(GO) run -ldflags "$(LDFLAGS)" $(CMD)
.PHONY: dev-web .PHONY: dev-web
@ -141,7 +140,7 @@ dev-web: ## Run the Vite dev server for the admin UI, proxying the API
cd $(WEB_DIR) && $(NPM) run dev cd $(WEB_DIR) && $(NPM) run dev
.PHONY: dev-all .PHONY: dev-all
dev-all: build-sdk ## Run the API and the frontend dev server together dev-all: ## Run the API and the frontend dev server together
@echo "API on http://localhost:8080, admin UI on http://localhost:5173" @echo "API on http://localhost:8080, admin UI on http://localhost:5173"
@trap 'kill 0' EXIT; \ @trap 'kill 0' EXIT; \
$(MAKE) dev-api & \ $(MAKE) dev-api & \
@ -376,7 +375,7 @@ helm-template: ## Render the chart with the default values
helm-template-auth: ## Render the chart configured for OIDC helm-template-auth: ## Render the chart configured for OIDC
$(HELM) template go-bananas deploy/helm/go-bananas \ $(HELM) template go-bananas deploy/helm/go-bananas \
--set identity.mode=oidc \ --set identity.mode=oidc \
--set identity.oidc.issuer=https://auth.lcl-urpi.be/realms/urpi \ --set identity.oidc.issuer=https://auth-dev.urpi.be/realms/urpi \
--set identity.oidc.clientId=go-bananas \ --set identity.oidc.clientId=go-bananas \
--set identity.oidc.generateSecret=false \ --set identity.oidc.generateSecret=false \
--set identity.oidc.existingSecret=go-bananas-oidc \ --set identity.oidc.existingSecret=go-bananas-oidc \
@ -393,61 +392,6 @@ helm-install: ## Install the chart into the current kube context ##@destructive
helm-uninstall: ## Remove the release ##@destructive helm-uninstall: ## Remove the release ##@destructive
$(HELM) uninstall go-bananas --namespace go-bananas $(HELM) uninstall go-bananas --namespace go-bananas
##@ Local (lcl)
# The lcl environment is the developer's local k3s cluster with a registry at
# localhost:5000 and a TLS edge serving *.intern-urpi.be. go-* apps deploy into the
# shared `go-insane-lcl` namespace. These targets use values-lcl.yaml.
#
# KUBECONFIG — the lcl kubeconfig. On this box the `local` kubecontext goes through a
# Rancher proxy that can 502; the direct k3s config is /etc/rancher/k3s/k3s.yaml.
# Set it to deploy without depending on the proxy: KUBECONFIG=/etc/rancher/k3s/k3s.yaml make deploy-lcl
# LCL_TAG — image tag. Defaults to a timestamp so a rebuild is always re-pulled;
# `IfNotPresent` would otherwise serve a stale `0.0.1` from the node cache.
LCL_REGISTRY ?= localhost:5000
LCL_REPO ?= go-bananas
LCL_TAG ?= $(shell date -u +%Y%m%d%H%M%S)
LCL_NAMESPACE ?= go-insane-lcl
LCL_VALUES = deploy/helm/go-bananas/values-lcl.yaml
.PHONY: image-lcl
image-lcl: ## Build and push the image to the local registry ##@destructive
$(DOCKER) buildx build \
--build-arg VERSION=$(VERSION) \
--build-arg COMMIT=$(COMMIT) \
--build-arg BUILD_DATE=$(BUILD_DATE) \
--tag $(LCL_REGISTRY)/$(LCL_REPO):$(LCL_TAG) \
--push \
.
.PHONY: helm-template-lcl
helm-template-lcl: ## Render the lcl Helm manifest
$(HELM) template go-bananas deploy/helm/go-bananas \
--namespace $(LCL_NAMESPACE) \
--values $(LCL_VALUES) \
--set-string image.tag=$(LCL_TAG)
.PHONY: helm-install-lcl
helm-install-lcl: ## Deploy go-bananas to the local (lcl) environment ##@destructive
$(HELM) upgrade --install go-bananas deploy/helm/go-bananas \
--namespace $(LCL_NAMESPACE) --create-namespace \
--values $(LCL_VALUES) \
--set-string image.tag=$(LCL_TAG) \
--wait --timeout 5m
.PHONY: helm-uninstall-lcl
helm-uninstall-lcl: ## Remove the lcl release ##@destructive
$(HELM) uninstall go-bananas --namespace $(LCL_NAMESPACE)
.PHONY: deploy-lcl
deploy-lcl: image-lcl helm-install-lcl ## Build, push, deploy to lcl, then verify ##@destructive
@echo "waiting for rollout ($(LCL_NAMESPACE)/go-bananas)..."
@kubectl -n $(LCL_NAMESPACE) rollout status deploy/go-bananas --timeout=120s
@echo "=== runtime manifest through the TLS edge ==="
@curl -sk https://bananas-lcl.intern-urpi.be/rt/v1/manifest.json -o /dev/null -w 'manifest.json -> %{http_code}\n' || true
@echo "deployed image: $(LCL_REGISTRY)/$(LCL_REPO):$(LCL_TAG)"
##@ Docs ##@ Docs
.PHONY: docs-serve .PHONY: docs-serve

View file

@ -82,7 +82,7 @@ helm upgrade --install go-bananas oci://ghcr.io/urpi/charts/go-bananas \
--set identity.oidc.existingSecret=go-bananas-auth --set identity.oidc.existingSecret=go-bananas-auth
``` ```
The chart defaults to the shared development realm at `auth.lcl-urpi.be`. See The chart defaults to the shared development realm at `auth-dev.urpi.be`. See
[docs/deployment.md](docs/deployment.md) for the full production checklist. [docs/deployment.md](docs/deployment.md) for the full production checklist.
--- ---
@ -462,9 +462,8 @@ Requires Go 1.27 and Node 22 or later. `make tools` installs `air`,
| [docs/operations.md](docs/operations.md) | Monitoring, backup, runbook | | [docs/operations.md](docs/operations.md) | Monitoring, backup, runbook |
| [docs/security.md](docs/security.md) | Threat model and hardening | | [docs/security.md](docs/security.md) | Threat model and hardening |
| [docs/styling-contract.md](docs/styling-contract.md) | The shared styling contract, and how an application adopts it | | [docs/styling-contract.md](docs/styling-contract.md) | The shared styling contract, and how an application adopts it |
| [docs/dev-infrastructure.md](docs/dev-infrastructure.md) | **This machine's dev infrastructure: audit, port map, fixes, and the issue log to append to** |
| [docs/adr/](docs/adr/) | Architecture decision records | | [docs/adr/](docs/adr/) | Architecture decision records |
## Licence ## Licence
MIT. MIT.

View file

@ -6,7 +6,7 @@
# #
# The `oidc` profile adds a mock OpenID Connect provider, so the authentication # The `oidc` profile adds a mock OpenID Connect provider, so the authentication
# flow can be exercised end to end without pointing at a real realm. The `oidc` # flow can be exercised end to end without pointing at a real realm. The `oidc`
# profile uses the mock because auth.lcl-urpi.be is a shared development realm and a # profile uses the mock because auth-dev.urpi.be is a shared development realm and a
# local stack should not depend on it being reachable. # local stack should not depend on it being reachable.
# #
# Usage: # Usage:

View file

@ -21,8 +21,12 @@
# reproducible rather than "whatever worked when it was deployed". # reproducible rather than "whatever worked when it was deployed".
image: image:
registry: nexus.urpi.be # The development cluster pulls from a private registry whose certificate is valid
repository: docker-hosted/go-bananas # for a different name, so the host and the port have to stay in one piece: putting
# the port in `repository` renders an image reference without it, and the kubelet
# then resolves the registry over https on port 443.
registry: batty1039.startdedicated.net:5000
repository: go-bananas
# The development registry is tagged `latest` rather than pinned to a version, to # The development registry is tagged `latest` rather than pinned to a version, to
# match every other release in this namespace. Consequently the pull policy must be # match every other release in this namespace. Consequently the pull policy must be
# Always: with IfNotPresent a node keeps serving the image it already has, and a # Always: with IfNotPresent a node keeps serving the image it already has, and a
@ -35,7 +39,7 @@ config:
# refuses both of these outside a development environment, so this pair cannot be # refuses both of these outside a development environment, so this pair cannot be
# promoted to production by accident. # promoted to production by accident.
environment: development environment: development
publicBaseUrl: https://bananas.dev-urpi.be publicBaseUrl: https://bananas-dev.urpi.be
identity: identity:
# dev, not oidc. # dev, not oidc.
@ -55,11 +59,11 @@ identity:
# helm upgrade ... --set identity.mode=oidc \ # helm upgrade ... --set identity.mode=oidc \
# --set identity.oidc.generateSecret=false \ # --set identity.oidc.generateSecret=false \
# --set identity.oidc.existingSecret=go-bananas-auth \ # --set identity.oidc.existingSecret=go-bananas-auth \
# --set identity.oidc.issuer=https://auth.dev-urpi.be \ # --set identity.oidc.issuer=https://auth-dev.urpi.be \
# --set identity.oidc.clientId=go-bananas # --set identity.oidc.clientId=go-bananas
mode: dev mode: dev
oidc: oidc:
issuer: https://auth.dev-urpi.be issuer: https://auth-dev.urpi.be
clientId: go-bananas clientId: go-bananas
existingSecret: go-bananas-auth existingSecret: go-bananas-auth
generateSecret: false generateSecret: false
@ -70,17 +74,17 @@ ingress:
cert-manager.io/cluster-issuer: letsencrypt-staging cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
hosts: hosts:
- host: bananas.dev-urpi.be - host: bananas-dev.urpi.be
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
tls: tls:
- secretName: go-bananas-tls - secretName: go-bananas-tls
hosts: hosts:
- bananas.dev-urpi.be - bananas-dev.urpi.be
deployment: deployment:
# One replica. The volume is ReadWriteOnce, so a second replica cannot mount it and # One replica. The volume is ReadWriteOnce, so a second replica cannot mount it and
# would sit in ImagePullBackOff-equivalent limbo forever. Scaling needs either this # would sit in ImagePullBackOff-equivalent limbo forever. Scaling needs either this
# changed to 1 or a ReadWriteMany filesystem. # changed to 1 or a ReadWriteMany filesystem.
replicaCount: 1 replicaCount: 1

View file

@ -60,7 +60,7 @@ Authentication is set to "{{ .Values.identity.mode }}". Every request is current
treated as an administrator. Before exposing this to a network, set: treated as an administrator. Before exposing this to a network, set:
identity.mode=oidc identity.mode=oidc
identity.oidc.issuer=https://auth.dev-urpi.be/realms/<realm> identity.oidc.issuer=https://auth-dev.urpi.be/realms/<realm>
identity.oidc.clientId=go-bananas identity.oidc.clientId=go-bananas
identity.oidc.existingSecret=<secret-name> identity.oidc.existingSecret=<secret-name>
{{- end }} {{- end }}

View file

@ -167,7 +167,7 @@ CrashLoopBackOff to diagnose. Each check names the value to change.
{{- if eq $mode "oidc" -}} {{- if eq $mode "oidc" -}}
{{- if not .Values.identity.oidc.issuer -}} {{- if not .Values.identity.oidc.issuer -}}
{{- fail "identity.oidc.issuer is required when identity.mode is oidc. For the development realm this is https://auth.dev-urpi.be/realms/<realm>." -}} {{- fail "identity.oidc.issuer is required when identity.mode is oidc. For the development realm this is https://auth-dev.urpi.be/realms/<realm>." -}}
{{- end -}} {{- end -}}
{{- if not (regexMatch "^https?://" .Values.identity.oidc.issuer) -}} {{- if not (regexMatch "^https?://" .Values.identity.oidc.issuer) -}}
{{- fail (printf "identity.oidc.issuer %q is not a URL. It must include the scheme." .Values.identity.oidc.issuer) -}} {{- fail (printf "identity.oidc.issuer %q is not a URL. It must include the scheme." .Values.identity.oidc.issuer) -}}
@ -253,4 +253,4 @@ The data volume name, whether it is a PVC or an emptyDir.
{{- else -}} {{- else -}}
{{- printf "%s-data" (include "go-bananas.fullname" .) -}} {{- printf "%s-data" (include "go-bananas.fullname" .) -}}
{{- end -}} {{- end -}}
{{- end }} {{- end }}

View file

@ -52,6 +52,9 @@ spec:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
spec: spec:
{{- with .Values.image.pullPolicy }}
imagePullPolicy: {{ . }}
{{- end }}
serviceAccountName: {{ include "go-bananas.serviceAccountName" . }} serviceAccountName: {{ include "go-bananas.serviceAccountName" . }}
automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }}
{{- with .Values.serviceAccount.annotations }} {{- with .Values.serviceAccount.annotations }}
@ -72,20 +75,19 @@ spec:
securityContext: securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }} {{- toYaml .Values.podSecurityContext | nindent 8 }}
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }} terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
{{- if .Values.deployment.lifecycle.preStop.enabled }}
# A preStop sleep gives the Service endpoints controller time to remove this
# pod from the endpoint list before the process starts refusing connections.
# Without it, a rolling update can drop requests that were already routed.
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep {{ .Values.deployment.lifecycle.preStop.sleepSeconds }}"]
{{- end }}
containers: containers:
- name: go-bananas - name: go-bananas
image: {{ include "go-bananas.image" . }} image: {{ include "go-bananas.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }} imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if .Values.deployment.lifecycle.preStop.enabled }}
# A preStop sleep gives the Service endpoints controller time to remove this
# pod from the endpoint list before the process starts refusing connections.
# Without it, a rolling update can drop requests that were already routed.
# lifecycle is a container field, not a podSpec field.
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep {{ .Values.deployment.lifecycle.preStop.sleepSeconds }}"]
{{- end }}
{{- with .Values.deployment.extraArgs }} {{- with .Values.deployment.extraArgs }}
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}

View file

@ -2,8 +2,7 @@
nameOverride: "" nameOverride: ""
fullnameOverride: "" fullnameOverride: ""
deployment: replicaCount: 1
replicaCount: 1
image: image:
repository: nexus.urpi.be/docker-hosted/go-bananas repository: nexus.urpi.be/docker-hosted/go-bananas
@ -14,16 +13,6 @@ service:
type: ClusterIP type: ClusterIP
port: 80 port: 80
# Spelled out here rather than inherited from values.yaml so the development
# issuer is stated where it is used.
identity:
mode: oidc
oidc:
issuer: https://auth.dev-urpi.be/realms/urpi
clientId: go-bananas
existingSecret: go-bananas-auth
generateSecret: false
ingress: ingress:
enabled: true enabled: true
className: nginx className: nginx
@ -31,14 +20,20 @@ ingress:
cert-manager.io/cluster-issuer: letsencrypt-staging cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
hosts: hosts:
- host: bananas.dev-urpi.be - host: bananas-dev.intern-urpi.be
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
tls: tls:
- secretName: go-bananas-tls - secretName: go-bananas-tls
hosts: hosts:
- bananas.dev-urpi.be - bananas-dev.intern-urpi.be
certManager:
enabled: true
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
resources: resources:
limits: limits:

View file

@ -1,5 +1,5 @@
# Local (LCL) values for go-bananas. # Local (LCL) values for go-bananas.
# Served at https://bananas.lcl-urpi.be by the docker TLS edge. # Served at https://bananas-lcl.intern-urpi.be by the docker TLS edge.
# #
# NOTE: this chart validates values against values.schema.json, which allows # NOTE: this chart validates values against values.schema.json, which allows
# only the keys defined there -- no certManager and no replicaCount here # only the keys defined there -- no certManager and no replicaCount here
@ -18,12 +18,12 @@ service:
port: 80 port: 80
# Local environment: development unlocks the local authentication bypass, so # Local environment: development unlocks the local authentication bypass, so
# the deployment works without the shared Keycloak realm at auth.dev-urpi.be # the deployment works without the shared Keycloak realm at auth-dev.urpi.be
# (go-loose does not serve /realms/... endpoints, so the oidc default cannot # (go-loose does not serve /realms/... endpoints, so the oidc default cannot
# be pointed at it either). # be pointed at it either).
config: config:
environment: development environment: development
publicBaseUrl: "https://bananas.lcl-urpi.be" publicBaseUrl: "https://bananas-lcl.intern-urpi.be"
identity: identity:
mode: dev mode: dev
@ -33,14 +33,14 @@ ingress:
className: traefik className: traefik
annotations: {} annotations: {}
hosts: hosts:
- host: bananas.lcl-urpi.be - host: bananas-lcl.intern-urpi.be
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
tls: tls:
- secretName: local-dev-cert - secretName: local-dev-cert
hosts: hosts:
- bananas.lcl-urpi.be - bananas-lcl.intern-urpi.be
resources: resources:
limits: limits:

View file

@ -1,31 +1,20 @@
# Production values for go-bananas. Secret values remain in the Kubernetes Secret # PRD values for go-bananas
# created by the Forgejo deployment job. nameOverride: ""
global: fullnameOverride: ""
imagePullSecrets:
- name: nexus-registry
deployment: replicaCount: 1
replicaCount: 1
imagePullSecrets:
- name: nexus-registry
image: image:
registry: nexus.urpi.be repository: nexus.urpi.be/docker-hosted/go-bananas
repository: docker-hosted/go-bananas tag: "0.0.1"
tag: latest
pullPolicy: Always pullPolicy: Always
config:
environment: production
publicBaseUrl: https://bananas.urpi.be
persistence:
enabled: true
type: persistentVolumeClaim
size: 2Gi
service: service:
type: ClusterIP type: ClusterIP
port: 80 port: 80
sessionSecure: true
ingress: ingress:
enabled: true enabled: true
@ -43,17 +32,16 @@ ingress:
hosts: hosts:
- bananas.urpi.be - bananas.urpi.be
identity: certManager:
mode: oidc enabled: true
oidc: issuerRef:
issuer: https://auth.urpi.be/realms/urpi name: letsencrypt-prod
clientId: go-bananas kind: ClusterIssuer
existingSecret: go-bananas-auth
generateSecret: false
resources: resources:
limits:
cpu: 500m
memory: 512Mi
requests: requests:
cpu: 100m cpu: 100m
memory: 128Mi memory: 256Mi
limits:
memory: 512Mi

View file

@ -2,8 +2,7 @@
nameOverride: "" nameOverride: ""
fullnameOverride: "" fullnameOverride: ""
deployment: replicaCount: 1
replicaCount: 1
image: image:
repository: nexus.urpi.be/docker-hosted/go-bananas repository: nexus.urpi.be/docker-hosted/go-bananas
@ -14,17 +13,6 @@ service:
type: ClusterIP type: ClusterIP
port: 80 port: 80
# Spelled out here rather than inherited from values.yaml, whose default issuer
# points at the development realm: a missing key would otherwise make the
# testing release validate tokens against the development identity provider.
identity:
mode: oidc
oidc:
issuer: https://auth.tst-urpi.be/realms/urpi
clientId: go-bananas
existingSecret: go-bananas-auth
generateSecret: false
ingress: ingress:
enabled: true enabled: true
className: nginx className: nginx
@ -32,14 +20,20 @@ ingress:
cert-manager.io/cluster-issuer: letsencrypt-staging cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
hosts: hosts:
- host: bananas.tst-urpi.be - host: bananas-tst.intern-urpi.be
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
tls: tls:
- secretName: go-bananas-tls - secretName: go-bananas-tls
hosts: hosts:
- bananas.tst-urpi.be - bananas-tst.intern-urpi.be
certManager:
enabled: true
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
resources: resources:
limits: limits:

View file

@ -197,7 +197,7 @@ config:
# replicaCount: 1, or a filesystem that permits multiple mounts. # replicaCount: 1, or a filesystem that permits multiple mounts.
persistence: persistence:
enabled: false enabled: true
type: persistentVolumeClaim type: persistentVolumeClaim
accessMode: ReadWriteOnce accessMode: ReadWriteOnce
size: 2Gi size: 2Gi
@ -288,7 +288,7 @@ identity:
# issuer: this string must match the `iss` claim in the ID token exactly, # issuer: this string must match the `iss` claim in the ID token exactly,
# including a trailing slash if the provider emits one. A mismatch here is the # including a trailing slash if the provider emits one. A mismatch here is the
# single most common cause of "invalid issuer" at the callback. # single most common cause of "invalid issuer" at the callback.
issuer: "https://auth.dev-urpi.be/realms/urpi" issuer: "https://auth-dev.urpi.be/realms/urpi"
# clientId is the client identifier registered with the provider. # clientId is the client identifier registered with the provider.
clientId: "go-bananas" clientId: "go-bananas"

View file

@ -10,7 +10,6 @@ Running go-bananas, from a laptop to a production cluster.
- [Configuration](#configuration) - [Configuration](#configuration)
- [Authentication](#authentication) - [Authentication](#authentication)
- [Helm](#helm) - [Helm](#helm)
- [Local deployment (lcl)](#local-deployment-lcl)
- [The production checklist](#the-production-checklist) - [The production checklist](#the-production-checklist)
- [Scaling](#scaling) - [Scaling](#scaling)
- [Backup and restore](#backup-and-restore) - [Backup and restore](#backup-and-restore)
@ -128,7 +127,7 @@ is made impossible to construct.
### The shared development realm ### The shared development realm
The chart defaults to `https://auth.lcl-urpi.be/realms/urpi`, so a development cluster The chart defaults to `https://auth-dev.urpi.be/realms/urpi`, so a development cluster
works with no identity configuration. works with no identity configuration.
The realm path is part of the issuer. The value must match the `iss` claim in the ID The realm path is part of the issuer. The value must match the `iss` claim in the ID
@ -261,77 +260,6 @@ helm lint deploy/helm/go-bananas --strict
--- ---
## Local deployment (lcl)
The `lcl` environment is the developer's own single-node **k3s** cluster used for
local integration testing. It is defined by `deploy/helm/go-bananas/values-lcl.yaml`:
- `config.environment: development` so the local auth bypass is allowed.
- `identity.mode: dev` so you can sign in without an OIDC provider.
- `image.registry: localhost:5000` so the cluster pulls from a local registry.
- Ingress host `bananas-lcl.intern-urpi.be`, terminated by the local TLS edge.
All the `go-*` applications share the **`go-insane-lcl`** namespace; go-bananas is a
Helm release named `go-bananas` inside it.
### Prerequisites
1. A running local k3s cluster and a registry at `localhost:5000`.
2. `bananas-lcl.intern-urpi.be` resolving to the node (`/etc/hosts`) behind the TLS edge.
3. `docker buildx`, `helm` and `kubectl`.
> **Kubeconfig caveat.** On this machine the default `local` context goes through a
> Rancher proxy (`https://rancher.urpi.be/k8s/clusters/local`) which can 502. When it
> does, talk to k3s directly instead: `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml`.
> Everything below respects that variable.
### One-shot build + deploy + verify
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml ./hack/deploy-lcl.sh
# or, equivalently:
KUBECONFIG=/etc/rancher/k3s/k3s.yaml make deploy-lcl
```
This builds a fresh image (with the web admin UI and runtime SDK compiled in), pushes
it to the local registry, runs `helm upgrade --install`, waits for the rollout, and
curls the runtime manifest through the TLS edge. The image tag defaults to a build
timestamp so a rebuild is always re-pulled (`IfNotPresent` would otherwise serve a
stale cached tag). Override it with `LCL_TAG=...`.
### Step by step
```bash
make install # Go and Node dependencies
make image-lcl # build + push to localhost:5000/go-bananas:<LCL_TAG>
make helm-template-lcl # sanity-check the rendered manifest
make helm-install-lcl # helm upgrade --install into go-insane-lcl
```
`LCL_TAG`, `LCL_REGISTRY` and `LCL_NAMESPACE` are overridable:
```bash
make deploy-lcl LCL_TAG=v1 LCL_REGISTRY=registry:5000 LCL_NAMESPACE=go-insane-lcl
```
### Sign in and verify
The release uses `dev` authentication, so any user is a local administrator. Open
`https://bananas-lcl.intern-urpi.be/?dev-user=you@example.test`, then:
```bash
curl -sk https://bananas-lcl.intern-urpi.be/readyz
curl -sk https://bananas-lcl.intern-urpi.be/rt/v1/manifest.json | jq '.themes[].slug'
```
### Remove
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml make helm-uninstall-lcl
```
---
## The production checklist ## The production checklist
- [ ] `config.environment: production` - [ ] `config.environment: production`
@ -616,4 +544,4 @@ applications. A CDN or browser holding an old version explains it; the runtime
manifest's `baseUrl` will show whether a CDN is in the path. manifest's `baseUrl` will show whether a CDN is in the path.
**The stylesheet is stale after a publish.** Expected within one `s-maxage` window **The stylesheet is stale after a publish.** Expected within one `s-maxage` window
(300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN. (300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN.

View file

@ -26,7 +26,7 @@ make tools # air, golangci-lint, sqlc
## Running ## Running
```bash ```bash
make dev-all # API on :8080, admin UI on :5173 with a proxy (builds the SDK first) make dev-all # API on :8080, admin UI on :5173 with a proxy
``` ```
Open <http://localhost:5173>. The Vite dev server proxies `/api`, `/rt` and `/auth` Open <http://localhost:5173>. The Vite dev server proxies `/api`, `/rt` and `/auth`
@ -34,18 +34,11 @@ to the Go process, so the session cookie works exactly as it will in production.
Same-origin matters: a cross-origin setup would not authenticate at all, and the Same-origin matters: a cross-origin setup would not authenticate at all, and the
problem would look like a bug rather than a topology difference. problem would look like a bug rather than a topology difference.
The admin UI is itself a consumer of the runtime: the header theme selector and the
light/dark toggle are the shipped controls, loaded from `/rt/v1/components.js`, which
the **API** serves from `sdk/dist` (Vite proxies `/rt` to it). `make dev-all` builds
the SDK up front for exactly this reason — an unbuilt `sdk/dist` means those controls
never define and the switcher silently falls back.
Other entry points: Other entry points:
```bash ```bash
make dev # the API with hot reload (air); serves web/dist and sdk/dist, so `make build` first make dev-api # the API alone, with hot reload
make dev-api # the API alone, no hot reload; serves the runtime SDK, Vite serves the UI make dev-web # the admin UI alone
make dev-web # the admin UI alone (its /api and /rt proxy to :8080)
make run # build everything, run the real binary make run # build everything, run the real binary
``` ```

View file

@ -22,7 +22,7 @@ import (
) )
// OIDCProvider implements Resolver against any OpenID Connect provider, // OIDCProvider implements Resolver against any OpenID Connect provider,
// including the auth.lcl-urpi.be realm used for local development. // including the auth-dev.urpi.be realm used for local development.
// //
// Everything provider-specific comes from the issuer's discovery document, so // Everything provider-specific comes from the issuer's discovery document, so
// the same code works against Keycloak, Authentik, Zitadel or Google without a // the same code works against Keycloak, Authentik, Zitadel or Google without a

View file

@ -71,7 +71,7 @@ type Config struct {
// AuthMode is oidc, dev, or none. // AuthMode is oidc, dev, or none.
AuthMode string AuthMode string
// OIDCIssuer is the issuer URL, e.g. https://auth.lcl-urpi.be/realms/urpi. // OIDCIssuer is the issuer URL, e.g. https://auth-dev.urpi.be/realms/urpi.
OIDCIssuer string OIDCIssuer string
// OIDCClientID is the client identifier registered with the provider. // OIDCClientID is the client identifier registered with the provider.
OIDCClientID string OIDCClientID string

View file

@ -70,7 +70,7 @@ func newUnauthenticatedRouter(t *testing.T) http.Handler {
t.Helper() t.Helper()
cfg := testConfig(t) cfg := testConfig(t)
cfg.AuthMode = config.AuthModeOIDC cfg.AuthMode = config.AuthModeOIDC
cfg.OIDCIssuer = "https://auth.lcl-urpi.be/realms/urpi" cfg.OIDCIssuer = "https://auth-dev.urpi.be/realms/urpi"
cfg.OIDCClientID = "go-bananas" cfg.OIDCClientID = "go-bananas"
cfg.OIDCClientSecret = "test-client-secret" cfg.OIDCClientSecret = "test-client-secret"
cfg.SessionSecure = false cfg.SessionSecure = false
@ -278,7 +278,7 @@ func TestRuntimeIsReadableCrossOrigin(t *testing.T) {
// not testing the policy — it is testing the same-origin path under a // not testing the policy — it is testing the same-origin path under a
// misleading name. // misleading name.
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil) req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
req.Header.Set("Origin", "https://tell.lcl-urpi.be") req.Header.Set("Origin", "https://tell-dev.urpi.be")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@ -313,7 +313,7 @@ func TestAdminAPIIsNotOpenedByTheRuntimePolicy(t *testing.T) {
for _, path := range []string{"/api/v1/session", "/api/v1/themes"} { for _, path := range []string{"/api/v1/session", "/api/v1/themes"} {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
req.Header.Set("Origin", "https://tell.lcl-urpi.be") req.Header.Set("Origin", "https://tell-dev.urpi.be")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@ -357,7 +357,7 @@ func TestAdminAPIAllowsAConfiguredOrigin(t *testing.T) {
func TestRuntimeAnswersPreflight(t *testing.T) { func TestRuntimeAnswersPreflight(t *testing.T) {
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), http.MethodOptions, "/rt/v1/contract.json", nil) context.Background(), http.MethodOptions, "/rt/v1/contract.json", nil)
req.Header.Set("Origin", "https://tell.lcl-urpi.be") req.Header.Set("Origin", "https://tell-dev.urpi.be")
// A real preflight names the method it wants to use. Without this header the // A real preflight names the method it wants to use. Without this header the
// request is not a preflight, and asserting 204 on it would be asserting on a // request is not a preflight, and asserting 204 on it would be asserting on a
// case no browser ever sends — which is how this test passed against a 405. // case no browser ever sends — which is how this test passed against a 405.

View file

@ -207,8 +207,8 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) ([]domain.Aud
args []any args []any
) )
if filter.Actor != "" { if filter.Actor != "" {
where = append(where, `actor_email LIKE ?`) where = append(where, `actor = ?`)
args = append(args, "%"+filter.Actor+"%") args = append(args, filter.Actor)
} }
if filter.Action != "" { if filter.Action != "" {
where = append(where, `action = ?`) where = append(where, `action = ?`)

View file

@ -23,7 +23,6 @@
}, },
"include": [ "include": [
"src/**/*.js", "src/**/*.js",
"src/**/*.d.ts",
"vite.config.js", "vite.config.js",
"vitest.config.js" "vitest.config.js"
] ]

View file

@ -28,11 +28,7 @@ export default tseslint.config(
{ {
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
// The app build project excludes test files (so the production image build projectService: true,
// never compiles the SDK-coupled integration test). ESLint still needs type
// info for every file it lints, so point it at BOTH projects explicitly
// rather than relying on projectService to discover the test project.
project: ['./tsconfig.app.json', './tsconfig.test.json'],
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
}, },
globals: { ...globals.browser, ...globals.es2022 }, globals: { ...globals.browser, ...globals.es2022 },

View file

@ -7,11 +7,11 @@
"license": "SEE LICENSE IN ../LICENSE", "license": "SEE LICENSE IN ../LICENSE",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -p tsconfig.app.json && vite build", "build": "tsc --build && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.test.json --noEmit", "typecheck": "tsc --build --force",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
@ -56,4 +56,4 @@
"engines": { "engines": {
"node": ">=22" "node": ">=22"
} }
} }

View file

@ -16,11 +16,6 @@ export type AdminThemeValue = {
previewSlug: string | null previewSlug: string | null
setMode: (mode: AdminThemeMode) => void setMode: (mode: AdminThemeMode) => void
setPreviewSlug: (slug: string | null) => void setPreviewSlug: (slug: string | null) => void
/**
* Sets mode and preview slug atomically. Used by the shipped controls so that
* picking a theme does not race with picking its default appearance.
*/
choose: (choice: { mode: AdminThemeMode; slug: string | null }) => void
/** The slugs available to preview, from the runtime manifest. */ /** The slugs available to preview, from the runtime manifest. */
available: string[] available: string[]
/** The runtime base URL, for building preview URLs. */ /** The runtime base URL, for building preview URLs. */

View file

@ -21,27 +21,11 @@ vi.mock('../lib/api', () => ({
resetPreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })), resetPreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
})) }))
// Mock the control loader so the origin it is asked to load from can be asserted
// directly, rather than depending on the loader's module-level singleton promise.
vi.mock('../lib/runtime-controls', () => ({
loadRuntimeControls: vi.fn(() => new Promise<void>(() => undefined)),
}))
const api = await import('../lib/api') const api = await import('../lib/api')
const runtimeControls = await import('../lib/runtime-controls')
const THEME_ATTRIBUTE = 'data-bananas-theme' const THEME_ATTRIBUTE = 'data-bananas-theme'
const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance' const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance'
const FULL_ENTRY = {
slug: 'midnight',
name: 'Midnight',
defaultAppearance: 'dark',
version: 1,
css: '/rt/v1/theme.css?slug=midnight',
tokens: '/rt/v1/theme.json?slug=midnight',
} as const
/** A probe that renders the current choice and exposes the setters. */ /** A probe that renders the current choice and exposes the setters. */
function Probe() { function Probe() {
const { mode, previewSlug, available, setMode, setPreviewSlug } = useAdminTheme() const { mode, previewSlug, available, setMode, setPreviewSlug } = useAdminTheme()
@ -126,26 +110,20 @@ describe('with a stored preference', () => {
// knows that its own chrome is styled by the full variant of a theme. // knows that its own chrome is styled by the full variant of a theme.
await waitFor(() => await waitFor(() =>
expect( expect(
document document.querySelector('link[data-bananas-admin="bananas-admin-preview"]')?.getAttribute('href'),
.querySelector('link[data-bananas-admin="bananas-admin-preview"]')
?.getAttribute('href'),
).toBe('/rt/v1/theme.css?slug=midnight&variant=full'), ).toBe('/rt/v1/theme.css?slug=midnight&variant=full'),
) )
}) })
// While the shipped controls are not ready — in this file loadRuntimeControls is // The document attributes belong to the shipped control, so that there is one writer
// mocked and never resolves — the provider is the only writer, and the preview // for them rather than two that can disagree. This test pins that division of
// stylesheet is scoped to exactly these attributes. If the provider did not apply // responsibility: if the provider starts writing them again, it will fail.
// them, the fallback switcher would change state but repaint nothing, which is the it('leaves the document attributes to the shipped controls', async () => {
// "I can't select a theme / light-dark does nothing" bug. (The single-writer handoff,
// where the controls take over once loaded, is covered by the real-controls
// integration test.)
it('applies the document attributes itself while the controls are not ready', async () => {
renderProvider() renderProvider()
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark')) await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark'))
await waitFor(() => expect(screen.getByTestId('slug')).toHaveTextContent('midnight')) await waitFor(() => expect(screen.getByTestId('slug')).toHaveTextContent('midnight'))
expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight') expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false)
expect(document.documentElement.getAttribute(APPEARANCE_ATTRIBUTE)).toBe('dark') expect(document.documentElement.hasAttribute(APPEARANCE_ATTRIBUTE)).toBe(false)
}) })
it('writes the choice back to the API when the mode changes', async () => { it('writes the choice back to the API when the mode changes', async () => {
@ -208,41 +186,3 @@ describe('when persisting fails', () => {
expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark') expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark')
}) })
}) })
// TestTheAdminConsumesItsRuntimeSameOrigin: a manifest (or config) that advertises a
// DIFFERENT absolute origin than the page itself — a CDN in front, a public host that
// is not the one you opened, `localhost` vs `127.0.0.1` — must not make the admin
// fetch its own controls and preview stylesheet from that other origin. The page's CSP
// is `script-src 'self'`, so a cross-origin `<script type=module>` and the module's
// dynamic `import()` of its chunk are blocked, `controlsReady` never becomes true, and
// the switcher silently falls back with no theme selectable. The admin must therefore
// always load `/rt/v1` from its own origin.
describe('the runtime is consumed same-origin', () => {
it('ignores the absolute public base when loading controls and the preview', async () => {
vi.mocked(api.getManifest).mockResolvedValue({
apiVersion: '1',
// An origin that is deliberately not the page's own.
baseUrl: 'https://themes.example.com/rt/v1',
themes: [FULL_ENTRY],
})
vi.mocked(api.getSession).mockResolvedValue({
authenticated: true,
mode: 'dev',
preferences: { selectedTheme: 'midnight', selectedAppearance: 'dark' },
} as unknown as Awaited<ReturnType<typeof api.getSession>>)
renderProvider()
// The shipped controls are requested from the admin's own origin, not the CDN.
await waitFor(() => expect(runtimeControls.loadRuntimeControls).toHaveBeenCalledWith('/rt/v1'))
// The previewed palette is loaded from the admin's own origin too.
await waitFor(() =>
expect(
document
.querySelector('link[data-bananas-admin="bananas-admin-preview"]')
?.getAttribute('href'),
).toBe('/rt/v1/theme.css?slug=midnight&variant=full'),
)
})
})

View file

@ -29,6 +29,7 @@ import { loadRuntimeControls } from '../lib/runtime-controls'
/** Which visual mode the admin chrome is in. */ /** Which visual mode the admin chrome is in. */
export type AdminThemeMode = 'admin' | 'theme-light' | 'theme-dark' export type AdminThemeMode = 'admin' | 'theme-light' | 'theme-dark'
/** The value used when a previewed theme has not been chosen yet. */ /** The value used when a previewed theme has not been chosen yet. */
const DEFAULT_PREVIEW = 'midnight' const DEFAULT_PREVIEW = 'midnight'
@ -102,30 +103,7 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
const available = useMemo(() => manifest?.themes.map((theme) => theme.slug) ?? [], [manifest]) const available = useMemo(() => manifest?.themes.map((theme) => theme.slug) ?? [], [manifest])
// The default appearance of each published theme, so that picking a theme in the const runtimeBase = manifest?.baseUrl ?? '/rt/v1'
// fallback switcher previews it in the scheme its author chose — exactly what the
// shipped controls do. Without this, choosing a theme while in the admin chrome would
// set the slug but leave `mode: 'admin'`, so nothing repaints and the dropdown looks
// dead: the "I select a theme and nothing happens" symptom.
const defaultAppearanceOf = useMemo(() => {
const map = new Map<string, 'light' | 'dark'>()
for (const theme of manifest?.themes ?? []) {
map.set(theme.slug, theme.defaultAppearance === 'light' ? 'light' : 'dark')
}
return map
}, [manifest])
// The admin loads the shipped controls and the preview stylesheet from THIS
// origin, not from manifest.baseUrl / config.runtimeBase. Those are the absolute
// *public* origin (or the CDN) meant for consumers pasting a snippet; using them
// here would make the module script and its contract.json fetch cross-origin
// whenever the browser's origin differs from the configured public base — a
// localhost/127.0.0.1 mismatch, a Vite dev proxy, or a CDN in front — and the
// page's CSP (script-src 'self') would then block the controls from ever
// defining, which surfaces as a switcher that cannot pick a theme. apiBase()
// already derives the same-origin path from the optional base-path marker; do the
// same for /rt/v1 so a sub-path deployment resolves correctly too.
const runtimeBase = sameOriginRuntimeBase()
// Fetch the shipped controls from this service. A failure is not worth surfacing: // Fetch the shipped controls from this service. A failure is not worth surfacing:
// the switcher keeps its previous control, and nothing else depends on this. // the switcher keeps its previous control, and nothing else depends on this.
@ -162,25 +140,26 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
writeStored(STORAGE_KEY, next.mode) writeStored(STORAGE_KEY, next.mode)
if (next.slug) { if (next.slug) {
writeStored(PREVIEW_KEY, next.slug) writeStored(PREVIEW_KEY, next.slug)
persist(next.slug, next.mode === 'theme-dark' ? 'dark' : 'light')
} else { } else {
window.localStorage?.removeItem(PREVIEW_KEY) window.localStorage?.removeItem(PREVIEW_KEY)
} }
// The admin chrome is the absence of a theme, so returning to it forgets the
// preference rather than recording the slug that is still selected in the
// dropdown. Persisting the slug here would make the next browser open on a
// theme this person had just switched away from.
if (next.mode === 'admin') {
void resetPreferences().catch(() => undefined)
} else if (next.slug) {
persist(next.slug, next.mode === 'theme-dark' ? 'dark' : 'light')
}
}, },
[persist], [persist],
) )
const setMode = useCallback( const setMode = useCallback(
(next: AdminThemeMode) => { (next: AdminThemeMode) => {
// The admin chrome is the absence of a theme, so returning to it forgets the
// preference rather than recording the slug that is still selected in the
// dropdown. Persisting the slug here would make the next browser open on a
// theme this person had just switched away from.
if (next === 'admin') {
setChoice({ mode: 'admin', slug: previewSlug })
writeStored(STORAGE_KEY, 'admin')
void resetPreferences().catch(() => undefined)
return
}
choose({ mode: next, slug: previewSlug }) choose({ mode: next, slug: previewSlug })
}, },
[choose, previewSlug], [choose, previewSlug],
@ -188,48 +167,36 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
const setPreviewSlug = useCallback( const setPreviewSlug = useCallback(
(slug: string | null) => { (slug: string | null) => {
// Choosing a concrete theme previews it; choosing none returns to the chrome. choose({ mode, slug })
// Used by the fallback switcher's dropdown, which has no separate appearance
// click, so it applies the theme's own default scheme the way the shipped
// controls do.
if (!slug) {
choose({ mode, slug: null })
return
}
const nextMode: AdminThemeMode =
defaultAppearanceOf.get(slug) === 'light' ? 'theme-light' : 'theme-dark'
choose({ mode: nextMode, slug })
}, },
[choose, mode, defaultAppearanceOf], [choose, mode],
) )
// The document attributes are owned by the shipped controls once they are ready, // The document attributes are no longer written from here. The shipped controls
// so there is one writer and the two can never disagree. BUT until the module has // write them, including the prefixed names this stylesheet reads, so there is one
// upgraded them — and in the fallback case where it never does — nothing else writes // writer and no chance of the two disagreeing. The admin state drives the elements
// them. Because the preview stylesheet is scoped to exactly these attributes, leaving // instead; see the shell for the binding.
// them unset means picking a theme or toggling light/dark changes state but repaints //
// nothing. So while the controls are not ready the provider must apply them itself; // The `go-bananas-admin` sentinel is gone with it. It was read by nothing at all:
// once they are, it steps back and lets the controls own the attributes. // no rule in the stylesheet matched it and no component inspected it. "Admin
// chrome" is the state with no previewed palette applied, which is exactly what the
// appearance toggle's admin state produces.
// The injected stylesheet must follow the mode too, because a theme's light and
// dark schemes are two rules under the same scoped selector and the attribute
// decides which wins.
useEffect(() => { useEffect(() => {
if (controlsReady) return
const root = document.documentElement const root = document.documentElement
if (mode === 'admin') { if (mode === 'admin') {
root.removeAttribute('data-bananas-theme') root.removeAttribute('data-bananas-theme')
root.removeAttribute('data-bananas-appearance') root.removeAttribute('data-bananas-appearance')
return return
} }
const slug = previewSlug ?? DEFAULT_PREVIEW const slug = previewSlug ?? DEFAULT_PREVIEW
root.setAttribute('data-bananas-theme', slug) root.setAttribute('data-bananas-theme', slug)
root.setAttribute('data-bananas-appearance', mode === 'theme-dark' ? 'dark' : 'light') root.setAttribute('data-bananas-appearance', mode === 'theme-dark' ? 'dark' : 'light')
}, [mode, previewSlug, controlsReady])
// The preview stylesheet is the one thing only this service knows it needs:
// the full variant of a published theme, scoped to the prefixed attributes the
// runtime emits.
useEffect(() => {
if (mode === 'admin') return
const slug = previewSlug ?? DEFAULT_PREVIEW
const id = 'bananas-admin-preview' const id = 'bananas-admin-preview'
let link = document.querySelector<HTMLLinkElement>(`link[data-bananas-admin="${id}"]`) let link = document.querySelector<HTMLLinkElement>(`link[data-bananas-admin="${id}"]`)
if (!link) { if (!link) {
@ -250,36 +217,21 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
}, [mode]) }, [mode])
const value = useMemo<AdminThemeValue>( const value = useMemo<AdminThemeValue>(
() => ({ () => ({ mode, previewSlug, setMode, setPreviewSlug, available, runtimeBase, controlsReady }),
[
mode, mode,
previewSlug, previewSlug,
setMode, setMode,
setPreviewSlug, setPreviewSlug,
choose,
available, available,
runtimeBase, runtimeBase,
controlsReady, controlsReady,
}), ],
[mode, previewSlug, setMode, setPreviewSlug, choose, available, runtimeBase, controlsReady],
) )
return <AdminThemeContext.Provider value={value}>{children}</AdminThemeContext.Provider> return <AdminThemeContext.Provider value={value}>{children}</AdminThemeContext.Provider>
} }
/**
* The same-origin runtime base the admin consumes for its own controls and preview.
*
* It mirrors `apiBase()` in `lib/api.ts`: both resolve against the page's own origin
* and honour the optional `data-bananas-base-path` marker, so a sub-path deployment
* prefixes `/rt/v1` the same way it prefixes `/api/v1`. Nothing here may point at the
* absolute public/CDN origin — that is cross-origin under CSP `script-src 'self'`.
*/
function sameOriginRuntimeBase(): string {
const configured = document.documentElement.getAttribute('data-bananas-base-path') ?? ''
const prefix = configured.replace(/\/+$/, '')
return `${prefix}/rt/v1`
}
/** /**
* Reads the persisted choice, tolerating storage being unavailable. * Reads the persisted choice, tolerating storage being unavailable.
* *

View file

@ -40,11 +40,7 @@ export function AppShell() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [navOpen, setNavOpen] = useState(false) const [navOpen, setNavOpen] = useState(false)
const { const { data: session, isLoading, error } = useQuery({
data: session,
isLoading,
error,
} = useQuery({
queryKey: ['session'], queryKey: ['session'],
queryFn: ({ signal }) => getSession(signal), queryFn: ({ signal }) => getSession(signal),
staleTime: 60_000, staleTime: 60_000,
@ -121,7 +117,7 @@ export function AppShell() {
onClick={() => setNavOpen(false)} onClick={() => setNavOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'rounded-control flex items-center gap-2.5 px-2.5 py-2 text-sm transition-colors', 'flex items-center gap-2.5 rounded-control px-2.5 py-2 text-sm transition-colors',
isActive isActive
? 'bg-brand-subtle text-brand font-medium' ? 'bg-brand-subtle text-brand font-medium'
: 'text-ink-muted hover:bg-raised hover:text-ink', : 'text-ink-muted hover:bg-raised hover:text-ink',
@ -163,7 +159,7 @@ export function AppShell() {
<button <button
type="button" type="button"
aria-label="Close navigation" aria-label="Close navigation"
className="fixed inset-0 z-30 bg-black/60 lg:hidden" className="bg-black/60 fixed inset-0 z-30 lg:hidden"
onClick={() => setNavOpen(false)} onClick={() => setNavOpen(false)}
/> />
) : null} ) : null}
@ -227,19 +223,15 @@ export function AppShell() {
/** The banana mark. Drawn inline so the shell needs no image asset. */ /** The banana mark. Drawn inline so the shell needs no image asset. */
function BananasMark() { function BananasMark() {
return ( return (
<svg viewBox="0 0 24 24" className="text-brand size-5 shrink-0" fill="currentColor" aria-hidden> <svg
<path viewBox="0 0 24 24"
d="M4 8c0-1.1.9-2 2-2h3c.6 0 1.1.2 1.5.6l7.2 6.4c.2.2.3.5.3.8V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8Z" className="text-brand size-5 shrink-0"
opacity=".9" fill="currentColor"
/> aria-hidden
<path >
d="M20 8c0-1.1-.9-2-2-2h-1.2c-.6 0-1.1.2-1.5.6L8.1 13c-.2.2-.3.5-.3.8V20a2 2 0 0 0 2 2H18a2 2 0 0 0 2-2V8Z" <path d="M4 8c0-1.1.9-2 2-2h3c.6 0 1.1.2 1.5.6l7.2 6.4c.2.2.3.5.3.8V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8Z" opacity=".9" />
opacity=".55" <path d="M20 8c0-1.1-.9-2-2-2h-1.2c-.6 0-1.1.2-1.5.6L8.1 13c-.2.2-.3.5-.3.8V20a2 2 0 0 0 2 2H18a2 2 0 0 0 2-2V8Z" opacity=".55" />
/> <path d="M8 5c0-.6.4-1 1-1h1.5c.3 0 .6.1.8.4l.5.5c.2.2.2.5 0 .7-.2.2-.5.2-.7 0l-.4-.4H9.5a.5.5 0 0 1-.5-.5V5Z" opacity=".7" />
<path
d="M8 5c0-.6.4-1 1-1h1.5c.3 0 .6.1.8.4l.5.5c.2.2.2.5 0 .7-.2.2-.5.2-.7 0l-.4-.4H9.5a.5.5 0 0 1-.5-.5V5Z"
opacity=".7"
/>
</svg> </svg>
) )
} }
@ -288,7 +280,7 @@ const routeLabels: Record<string, string> = {
function usePathSegments(): { label: string; to: string }[] { function usePathSegments(): { label: string; to: string }[] {
// useLocation is imported lazily through the module scope; declaring the hook // useLocation is imported lazily through the module scope; declaring the hook
// here keeps Breadcrumbs readable. // here keeps Breadcrumbs readable.
return useLocationSegments() return useLocationSegments()
} }
@ -305,18 +297,9 @@ function usePathSegments(): { label: string; to: string }[] {
* moving between machines expects to see the same preview. And the element persists * moving between machines expects to see the same preview. And the element persists
* locally under its own key; letting it win would silently outrank the account. * locally under its own key; letting it win would silently outrank the account.
*/ */
/** Exported for integration tests. */ function ThemeSwitcher() {
export function ThemeSwitcher() { const { mode, previewSlug, setMode, setPreviewSlug, available, runtimeBase, controlsReady } =
const { useAdminThemeSafe()
mode,
previewSlug,
setMode,
setPreviewSlug,
choose,
available,
runtimeBase,
controlsReady,
} = useAdminThemeSafe()
const selector = useRef<BananasElement>(null) const selector = useRef<BananasElement>(null)
const toggle = useRef<BananasElement>(null) const toggle = useRef<BananasElement>(null)
@ -331,18 +314,16 @@ export function ThemeSwitcher() {
const slug = detail?.slug const slug = detail?.slug
// An empty slug is the control's "no theme" entry, which in the admin means // An empty slug is the control's "no theme" entry, which in the admin means
// the chrome, since that is what an unset preview already maps to. // the chrome, since that is what an unset preview already maps to.
setPreviewSlug(slug || null)
// Also apply the theme's default appearance mode so selecting a theme
// immediately shows it rather than leaving the chrome in 'admin' mode.
if (slug && detail?.mode) { if (slug && detail?.mode) {
choose({ setMode(detail.mode === 'dark' ? 'theme-dark' : 'theme-light')
slug,
mode: detail.mode === 'dark' ? 'theme-dark' : 'theme-light',
})
} else {
choose({ slug: null, mode: 'admin' })
} }
} }
el.addEventListener('bananas:theme', handler) el.addEventListener('bananas:theme', handler)
return () => el.removeEventListener('bananas:theme', handler) return () => el.removeEventListener('bananas:theme', handler)
}, [choose, controlsReady]) }, [setPreviewSlug, setMode, controlsReady])
useEffect(() => { useEffect(() => {
const el = toggle.current const el = toggle.current
@ -423,21 +404,23 @@ function LegacyThemeSwitcher({
}) { }) {
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<select {mode !== 'admin' ? (
aria-label="Preview theme" <select
value={previewSlug ?? ''} aria-label="Preview theme"
onChange={(event) => setPreviewSlug(event.target.value)} value={previewSlug ?? ''}
className="border-line bg-canvas text-ink-muted rounded-control h-8 max-w-[10rem] border px-2 text-xs" onChange={(event) => setPreviewSlug(event.target.value)}
> className="border-line bg-canvas text-ink-muted h-8 max-w-[10rem] rounded-control border px-2 text-xs"
{available.length === 0 ? <option value="">no published themes</option> : null} >
{available.map((slug) => ( {available.length === 0 ? <option value="">no published themes</option> : null}
<option key={slug} value={slug}> {available.map((slug) => (
{slug} <option key={slug} value={slug}>
</option> {slug}
))} </option>
</select> ))}
</select>
) : null}
<div <div
className="border-line bg-canvas rounded-control flex border p-0.5" className="border-line bg-canvas flex rounded-control border p-0.5"
role="group" role="group"
aria-label="Interface theme" aria-label="Interface theme"
> >
@ -519,7 +502,6 @@ function useAdminThemeSafe() {
setMode: () => {}, setMode: () => {},
previewSlug: null, previewSlug: null,
setPreviewSlug: () => {}, setPreviewSlug: () => {},
choose: () => {},
available: [] as string[], available: [] as string[],
runtimeBase: '/rt/v1', runtimeBase: '/rt/v1',
// Without a provider the module is never asked for, so the fallback control // Without a provider the module is never asked for, so the fallback control
@ -541,4 +523,4 @@ function useLocationSegments(): { label: string; to: string }[] {
out.push({ label: routeLabels[part] ?? part, to: accumulated }) out.push({ label: routeLabels[part] ?? part, to: accumulated })
} }
return out return out
} }

12
web/src/global.d.ts vendored
View file

@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
/** /**
* Global type augmentations for bananas runtime controls. * Global type augmentations for bananas runtime controls.
* This file must not have any imports/exports to be treated as a global script. * This file must not have any imports/exports to be treated as a global script.
@ -8,19 +6,13 @@
declare global { declare global {
namespace JSX { namespace JSX {
interface IntrinsicElements { interface IntrinsicElements {
'bananas-theme-selector': React.DetailedHTMLProps< 'bananas-theme-selector': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
React.HTMLAttributes<HTMLElement>,
HTMLElement
> & {
base?: string base?: string
label?: string label?: string
compact?: boolean compact?: boolean
'no-preview'?: boolean 'no-preview'?: boolean
} }
'bananas-appearance-toggle': React.DetailedHTMLProps< 'bananas-appearance-toggle': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
React.HTMLAttributes<HTMLElement>,
HTMLElement
> & {
label?: string label?: string
'show-labels'?: boolean 'show-labels'?: boolean
} }

View file

@ -71,7 +71,7 @@ export default function DashboardRoute() {
) : null} ) : null}
{/* Counters. */} {/* Counters. */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Stat <Stat
label="Themes" label="Themes"
icon={<Palette className="size-4" aria-hidden />} icon={<Palette className="size-4" aria-hidden />}

View file

@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/** /**

View file

@ -27,11 +27,5 @@
"types": ["vitest/globals", "@testing-library/jest-dom"], "types": ["vitest/globals", "@testing-library/jest-dom"],
"paths": { "@/*": ["./src/*"] } "paths": { "@/*": ["./src/*"] }
}, },
// The production build must not compile test files. One test imports the shipped "include": ["src"]
// SDK source across packages (`sdk/src/*.js`) to drive the real custom elements; that
// coupling belongs to the test project (tsconfig.test.json), not to the app image
// build, where the SDK may not even be on disk yet. `tsc -p tsconfig.app.json` is what
// the Dockerfile runs, so tests are simply out of this project's file set.
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
} }

View file

@ -36,11 +36,11 @@ export default defineConfig({
port: 5173, port: 5173,
strictPort: true, strictPort: true,
proxy: { proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true }, '/api': { target: 'http://localhost:8081', changeOrigin: true },
'/rt': { target: 'http://localhost:8080', changeOrigin: true }, '/rt': { target: 'http://localhost:8081', changeOrigin: true },
'/auth': { target: 'http://localhost:8080', changeOrigin: true }, '/auth': { target: 'http://localhost:8081', changeOrigin: true },
'/healthz': { target: 'http://localhost:8080', changeOrigin: true }, '/healthz': { target: 'http://localhost:8081', changeOrigin: true },
'/readyz': { target: 'http://localhost:8080', changeOrigin: true }, '/readyz': { target: 'http://localhost:8081', changeOrigin: true },
}, },
}, },
test: { test: {

View file

@ -1,5 +1,3 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
/** /**
@ -9,15 +7,6 @@ import { defineConfig } from 'vitest/config'
* errors about the `test` key. * errors about the `test` key.
*/ */
export default defineConfig({ export default defineConfig({
// The integration tests import the actual shipped SDK modules (sdk/src/*) to drive
// the real custom elements. Those live one directory up, outside the web package
// root, so Vite's default filesystem allow-list has to be widened to the repository
// root for the cross-package import to resolve.
server: {
fs: {
allow: [fileURLToPath(new URL('..', import.meta.url))],
},
},
test: { test: {
// The admin interface is a browser application: components touch the DOM, // The admin interface is a browser application: components touch the DOM,
// localStorage and matchMedia, so jsdom is required rather than merely useful. // localStorage and matchMedia, so jsdom is required rather than merely useful.