Skip to content

Commit 9e48b48

Browse files
committed
2 parents dce582b + f9c8817 commit 9e48b48

68 files changed

Lines changed: 2620 additions & 251 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ KIND_FWD_BACKEND_PORT ?= $(shell echo $$((12000 + $(KIND_PORT_OFFSET))))
101101
KIND_FWD_BACKEND_PORT := $(KIND_FWD_BACKEND_PORT)
102102
KIND_FWD_API_SERVER_PORT ?= $(shell echo $$((13000 + $(KIND_PORT_OFFSET))))
103103
KIND_FWD_API_SERVER_PORT := $(KIND_FWD_API_SERVER_PORT)
104+
KIND_FWD_AMBIENT_UI_PORT ?= $(shell echo $$((14000 + $(KIND_PORT_OFFSET))))
105+
KIND_FWD_AMBIENT_UI_PORT := $(KIND_FWD_AMBIENT_UI_PORT)
106+
KIND_FWD_KEYCLOAK_PORT ?= $(shell echo $$((18000 + $(KIND_PORT_OFFSET))))
107+
KIND_FWD_KEYCLOAK_PORT := $(KIND_FWD_KEYCLOAK_PORT)
104108
# Remote kind host — set to Tailscale IP/hostname of the Linux build machine.
105109
# When set, kubeconfig is rewritten so kubectl/port-forward work from Mac.
106110
KIND_HOST ?=
@@ -675,9 +679,9 @@ preflight: preflight-cluster ## Validate dev environment (cluster tools + option
675679
p=$$(echo "$$piece" | sed 's/^[[:space:]]*//;s/[[:space:]]*$$//'); \
676680
[ -z "$$p" ] && continue; \
677681
case "$$p" in \
678-
frontend) NEED_NODE=1 ;; \
682+
frontend|ambient-ui) NEED_NODE=1 ;; \
679683
backend) NEED_GO=1 ;; \
680-
*) echo "$(COLOR_RED)$(COLOR_RESET) Unknown COMPONENT: $$p (use frontend, backend, or frontend,backend)"; FAILED=1 ;; \
684+
*) echo "$(COLOR_RED)$(COLOR_RESET) Unknown COMPONENT: $$p (use frontend, backend, ambient-ui, or comma-separated)"; FAILED=1 ;; \
681685
esac; \
682686
done; \
683687
fi; \
@@ -753,7 +757,30 @@ dev-env: check-kubectl check-local-context ## Generate components/frontend/.env.
753757
echo "$(COLOR_GREEN)$(COLOR_RESET) Wrote $$ENV_FILE"; \
754758
fi
755759

756-
dev: ## Local dev: preflight, cluster, dev-env, port-forwards; COMPONENT=frontend|backend|frontend,backend for hot-reload
760+
dev-env-ambient-ui: check-kubectl ## Generate components/ambient-ui/.env.local from cluster state
761+
@kubectl config use-context kind-$(KIND_CLUSTER_NAME) >/dev/null 2>&1 || true
762+
@set -e; \
763+
SESSION_SECRET=$$(printf '%s-ambient-ui' '$(KIND_CLUSTER_NAME)' | sha256sum | cut -c1-32); \
764+
ENV_FILE="components/ambient-ui/.env.local"; \
765+
{ \
766+
echo "# Generated by make dev-env-ambient-ui — do not commit"; \
767+
echo "API_SERVER_URL=http://localhost:$(KIND_FWD_API_SERVER_PORT)"; \
768+
echo "NEXT_PUBLIC_PREVIEW_ALLOWED_HOSTS=localhost:*,127.0.0.1:*"; \
769+
echo "SSO_ISSUER_URL=http://localhost:$(KIND_FWD_KEYCLOAK_PORT)/realms/ambient-code"; \
770+
echo "SSO_CLIENT_ID=ambient-frontend"; \
771+
echo "SSO_CLIENT_SECRET=dev-secret-do-not-use-in-prod"; \
772+
echo "SSO_REDIRECT_URI=http://localhost:3001/api/auth/sso/callback"; \
773+
echo "SESSION_SECRET=$$SESSION_SECRET"; \
774+
} > "$$ENV_FILE.tmp"; \
775+
if [ -f "$$ENV_FILE" ] && cmp -s "$$ENV_FILE.tmp" "$$ENV_FILE"; then \
776+
rm -f "$$ENV_FILE.tmp"; \
777+
echo "$(COLOR_GREEN)$(COLOR_RESET) $$ENV_FILE unchanged"; \
778+
else \
779+
mv "$$ENV_FILE.tmp" "$$ENV_FILE"; \
780+
echo "$(COLOR_GREEN)$(COLOR_RESET) Wrote $$ENV_FILE"; \
781+
fi
782+
783+
dev: ## Local dev: preflight, cluster, dev-env, port-forwards; COMPONENT=frontend|backend|ambient-ui for hot-reload
757784
@if [ -z "$(COMPONENT)" ]; then $(MAKE) --no-print-directory preflight-cluster; else $(MAKE) --no-print-directory preflight; fi
758785
@set -e; \
759786
if [ "$(CONTAINER_ENGINE)" = "podman" ]; then export KIND_EXPERIMENTAL_PROVIDER=podman; fi; \
@@ -781,10 +808,10 @@ dev: ## Local dev: preflight, cluster, dev-env, port-forwards; COMPONENT=fronten
781808
kubectl config use-context kind-$(KIND_CLUSTER_NAME); \
782809
fi; \
783810
COMP="$(COMPONENT)"; \
784-
HAS_FRONT=0; HAS_BACK=0; \
811+
HAS_FRONT=0; HAS_BACK=0; HAS_AUI=0; \
785812
for piece in $$(echo "$$COMP" | tr ',' ' '); do \
786813
p=$$(echo "$$piece" | sed 's/^[[:space:]]*//;s/[[:space:]]*$$//'); \
787-
case "$$p" in frontend) HAS_FRONT=1 ;; backend) HAS_BACK=1 ;; esac; \
814+
case "$$p" in frontend) HAS_FRONT=1 ;; backend) HAS_BACK=1 ;; ambient-ui) HAS_AUI=1 ;; esac; \
788815
done; \
789816
DEV_LOCAL=0; \
790817
if [ "$$HAS_FRONT" -eq 1 ] && [ "$$HAS_BACK" -eq 1 ]; then DEV_LOCAL=1; \
@@ -829,6 +856,33 @@ dev: ## Local dev: preflight, cluster, dev-env, port-forwards; COMPONENT=fronten
829856
(cd components/frontend && npm run dev) & NPM_PID=$$!; \
830857
trap 'kill $$GO_PID $$NPM_PID 2>/dev/null; cleanup' INT TERM; \
831858
wait $$GO_PID $$NPM_PID; \
859+
elif [ "$$HAS_AUI" -eq 1 ]; then \
860+
echo "$(COLOR_BLUE)$(COLOR_RESET) ambient-ui dev: setting up API server + Keycloak..."; \
861+
pkill -f "port-forward.*ambient-api-server-service" 2>/dev/null || true; \
862+
pkill -f "port-forward.*keycloak-service" 2>/dev/null || true; \
863+
WANT_KC="http://localhost:$(KIND_FWD_KEYCLOAK_PORT)"; \
864+
CUR_KC=$$(kubectl get deployment keycloak -n $(NAMESPACE) -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="KC_HOSTNAME")].value}' 2>/dev/null); \
865+
if [ "$$CUR_KC" != "$$WANT_KC" ]; then \
866+
echo "$(COLOR_BLUE)$(COLOR_RESET) Patching Keycloak hostname: $$CUR_KC → $$WANT_KC"; \
867+
kubectl set env deployment/keycloak -n $(NAMESPACE) KC_HOSTNAME="$$WANT_KC" >/dev/null 2>&1; \
868+
kubectl rollout status deployment/keycloak -n $(NAMESPACE) --timeout=120s >/dev/null 2>&1 || true; \
869+
echo "$(COLOR_GREEN)$(COLOR_RESET) Keycloak hostname patched"; \
870+
else \
871+
echo "$(COLOR_GREEN)$(COLOR_RESET) Keycloak hostname already correct"; \
872+
fi; \
873+
kubectl port-forward -n $(NAMESPACE) svc/ambient-api-server-service $(KIND_FWD_API_SERVER_PORT):8000 >/tmp/acp-dev-pf-api.log 2>&1 & PF_PIDS="$$PF_PIDS $$!"; \
874+
kubectl port-forward -n $(NAMESPACE) svc/keycloak-service $(KIND_FWD_KEYCLOAK_PORT):8080 >/tmp/acp-dev-pf-keycloak.log 2>&1 & PF_PIDS="$$PF_PIDS $$!"; \
875+
sleep 2; \
876+
echo "$(COLOR_GREEN)$(COLOR_RESET) API server → http://localhost:$(KIND_FWD_API_SERVER_PORT)"; \
877+
echo "$(COLOR_GREEN)$(COLOR_RESET) Keycloak → http://localhost:$(KIND_FWD_KEYCLOAK_PORT)"; \
878+
$(MAKE) --no-print-directory dev-env-ambient-ui; \
879+
echo ""; \
880+
echo "$(COLOR_BOLD)Access:$(COLOR_RESET)"; \
881+
echo " Ambient UI: $(COLOR_BLUE)http://localhost:3001$(COLOR_RESET)"; \
882+
echo " API server: http://localhost:$(KIND_FWD_API_SERVER_PORT)"; \
883+
echo " Keycloak: http://localhost:$(KIND_FWD_KEYCLOAK_PORT)"; \
884+
echo ""; \
885+
cd components/ambient-ui && npm run dev; \
832886
fi
833887

834888
##@ Benchmarking
@@ -969,14 +1023,18 @@ kind-login: check-kubectl check-local-context ## Set kubectl context, port-forwa
9691023
kind-port-forward: check-kubectl check-local-context ## Port-forward kind services (for remote Podman)
9701024
@echo "$(COLOR_BOLD)Port forwarding kind services ($(KIND_CLUSTER_NAME))$(COLOR_RESET)"
9711025
@echo ""
972-
@echo " Frontend: http://localhost:$(KIND_FWD_FRONTEND_PORT)"
973-
@echo " Backend: http://localhost:$(KIND_FWD_BACKEND_PORT)"
1026+
@echo " Frontend: http://localhost:$(KIND_FWD_FRONTEND_PORT)"
1027+
@echo " Backend: http://localhost:$(KIND_FWD_BACKEND_PORT)"
1028+
@echo " Ambient UI: http://localhost:$(KIND_FWD_AMBIENT_UI_PORT)"
1029+
@echo " Keycloak: http://localhost:$(KIND_FWD_KEYCLOAK_PORT)"
9741030
@echo ""
9751031
@echo "$(COLOR_YELLOW)Press Ctrl+C to stop$(COLOR_RESET)"
9761032
@echo ""
9771033
@trap 'echo ""; echo "$(COLOR_GREEN)✓$(COLOR_RESET) Port forwarding stopped"; exit 0' INT; \
9781034
(kubectl port-forward -n ambient-code svc/frontend-service $(KIND_FWD_FRONTEND_PORT):3000 >/dev/null 2>&1 &); \
9791035
(kubectl port-forward -n ambient-code svc/backend-service $(KIND_FWD_BACKEND_PORT):8080 >/dev/null 2>&1 &); \
1036+
(kubectl port-forward -n ambient-code svc/ambient-ui-service $(KIND_FWD_AMBIENT_UI_PORT):3000 >/dev/null 2>&1 &); \
1037+
(kubectl port-forward -n ambient-code svc/keycloak-service $(KIND_FWD_KEYCLOAK_PORT):8080 >/dev/null 2>&1 &); \
9801038
wait
9811039

9821040
dev-bootstrap: check-kubectl check-local-context ## Bootstrap developer workspace with API key and integrations
@@ -1169,7 +1227,7 @@ kind-status: check-kind ## Show all kind clusters and their port assignments
11691227
@echo " Cluster: $(KIND_CLUSTER_NAME)"
11701228
@if [ -n "$(KIND_HOST)" ]; then echo " Host: $(KIND_HOST) (remote)"; else echo " Host: localhost"; fi
11711229
@echo " NodePort: $(KIND_HTTP_PORT) (HTTP) / $(KIND_HTTPS_PORT) (HTTPS)"
1172-
@echo " Forward: $(KIND_FWD_FRONTEND_PORT) (frontend) / $(KIND_FWD_BACKEND_PORT) (backend)"
1230+
@echo " Forward: $(KIND_FWD_FRONTEND_PORT) (frontend) / $(KIND_FWD_BACKEND_PORT) (backend) / $(KIND_FWD_KEYCLOAK_PORT) (keycloak)"
11731231
@echo ""
11741232
@CLUSTERS=$$($(if $(filter podman,$(CONTAINER_ENGINE)),KIND_EXPERIMENTAL_PROVIDER=podman) kind get clusters 2>/dev/null); \
11751233
if [ -z "$$CLUSTERS" ]; then \

components/ambient-control-plane/cmd/ambient-control-plane/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ func runKubeMode(ctx context.Context, cfg *config.ControlPlaneConfig) error {
156156
HTTPSProxy: cfg.HTTPSProxy,
157157
NoProxy: cfg.NoProxy,
158158
ImagePullSecret: cfg.ImagePullSecret,
159+
PlatformMode: cfg.PlatformMode,
160+
MPPConfigNamespace: cfg.MPPConfigNamespace,
159161
}
160162

161163
conn, err := grpc.NewClient(cfg.GRPCServerAddr, grpc.WithTransportCredentials(grpcCredentials(cfg.GRPCUseTLS)))
@@ -196,7 +198,7 @@ func runKubeMode(ctx context.Context, cfg *config.ControlPlaneConfig) error {
196198
inf.RegisterHandler("sessions", sessionRec.Reconcile)
197199
}
198200

199-
podSyncer := reconciler.NewPodStatusSyncer(factory, provisionerKube, log.Logger)
201+
podSyncer := reconciler.NewPodStatusSyncer(factory, provisionerKube, cfg.PlatformMode, cfg.MPPConfigNamespace, log.Logger)
200202

201203
tsErrCh := make(chan error, 1)
202204
go func() {

components/ambient-control-plane/internal/kubeclient/kubeclient.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,19 @@ func (kc *KubeClient) UpdateNetworkPolicy(ctx context.Context, obj *unstructured
314314
return kc.dynamic.Resource(NetworkPolicyGVR).Namespace(obj.GetNamespace()).Update(ctx, obj, metav1.UpdateOptions{})
315315
}
316316

317+
func (kc *KubeClient) ListTenantNamespaces(ctx context.Context, namespace, labelSelector string) (*unstructured.UnstructuredList, error) {
318+
gvr := schema.GroupVersionResource{
319+
Group: "tenant.paas.redhat.com",
320+
Version: "v1alpha1",
321+
Resource: "tenantnamespaces",
322+
}
323+
opts := metav1.ListOptions{}
324+
if labelSelector != "" {
325+
opts.LabelSelector = labelSelector
326+
}
327+
return kc.dynamic.Resource(gvr).Namespace(namespace).List(ctx, opts)
328+
}
329+
317330
func (kc *KubeClient) GetResource(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) {
318331
return kc.dynamic.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
319332
}

components/ambient-control-plane/internal/reconciler/kube_reconciler.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ type KubeReconcilerConfig struct {
7272
HTTPSProxy string
7373
NoProxy string
7474
ImagePullSecret string
75+
PlatformMode string
76+
MPPConfigNamespace string
7577
}
7678

7779
type SimpleKubeReconciler struct {
@@ -513,7 +515,7 @@ func (r *SimpleKubeReconciler) ensurePod(ctx context.Context, namespace string,
513515
"resources": map[string]interface{}{
514516
"requests": map[string]interface{}{
515517
"cpu": "500m",
516-
"memory": "512Mi",
518+
"memory": "1Gi",
517519
},
518520
"limits": map[string]interface{}{
519521
"cpu": "2000m",
@@ -1008,6 +1010,12 @@ func (r *SimpleKubeReconciler) buildCredentialSidecars(sessionID string, namespa
10081010
if r.cfg.NoProxy != "" {
10091011
env = append(env, envVar("NO_PROXY", r.cfg.NoProxy))
10101012
}
1013+
if r.cfg.PlatformMode != "" {
1014+
env = append(env, envVar("PLATFORM_MODE", r.cfg.PlatformMode))
1015+
}
1016+
if r.cfg.MPPConfigNamespace != "" {
1017+
env = append(env, envVar("MPP_CONFIG_NAMESPACE", r.cfg.MPPConfigNamespace))
1018+
}
10111019

10121020
sidecar := map[string]interface{}{
10131021
"name": spec.Name,

components/ambient-control-plane/internal/reconciler/pod_sync.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,20 @@ const (
1818
)
1919

2020
type PodStatusSyncer struct {
21-
factory *SDKClientFactory
22-
kube *kubeclient.KubeClient
23-
logger zerolog.Logger
21+
factory *SDKClientFactory
22+
kube *kubeclient.KubeClient
23+
platformMode string
24+
mppConfigNamespace string
25+
logger zerolog.Logger
2426
}
2527

26-
func NewPodStatusSyncer(factory *SDKClientFactory, kube *kubeclient.KubeClient, logger zerolog.Logger) *PodStatusSyncer {
28+
func NewPodStatusSyncer(factory *SDKClientFactory, kube *kubeclient.KubeClient, platformMode, mppConfigNamespace string, logger zerolog.Logger) *PodStatusSyncer {
2729
return &PodStatusSyncer{
28-
factory: factory,
29-
kube: kube,
30-
logger: logger.With().Str("component", "pod-status-syncer").Logger(),
30+
factory: factory,
31+
kube: kube,
32+
platformMode: platformMode,
33+
mppConfigNamespace: mppConfigNamespace,
34+
logger: logger.With().Str("component", "pod-status-syncer").Logger(),
3135
}
3236
}
3337

@@ -60,6 +64,9 @@ func (s *PodStatusSyncer) syncOnce(ctx context.Context) {
6064
}
6165

6266
func (s *PodStatusSyncer) listManagedNamespaces(ctx context.Context) ([]string, error) {
67+
if s.platformMode == "mpp" {
68+
return s.listMPPManagedNamespaces(ctx)
69+
}
6370
nsList, err := s.kube.ListNamespacesByLabel(ctx, managedLabelFilter)
6471
if err != nil {
6572
return nil, fmt.Errorf("listing managed namespaces: %w", err)
@@ -72,6 +79,19 @@ func (s *PodStatusSyncer) listManagedNamespaces(ctx context.Context) ([]string,
7279
return names, nil
7380
}
7481

82+
func (s *PodStatusSyncer) listMPPManagedNamespaces(ctx context.Context) ([]string, error) {
83+
tnList, err := s.kube.ListTenantNamespaces(ctx, s.mppConfigNamespace, managedLabelFilter)
84+
if err != nil {
85+
return nil, fmt.Errorf("listing managed TenantNamespaces in %s: %w", s.mppConfigNamespace, err)
86+
}
87+
88+
var names []string
89+
for _, tn := range tnList.Items {
90+
names = append(names, "ambient-code--"+tn.GetName())
91+
}
92+
return names, nil
93+
}
94+
7595
func (s *PodStatusSyncer) syncNamespace(ctx context.Context, namespace string) {
7696
pods, err := s.kube.ListPodsByLabel(ctx, namespace, managedLabelFilter)
7797
if err != nil {
@@ -168,6 +188,14 @@ func (s *PodStatusSyncer) hasContainerCrashLoop(pod *unstructured.Unstructured)
168188
return true
169189
}
170190
}
191+
name, _, _ := unstructured.NestedString(csMap, "name")
192+
terminated, found, _ := unstructured.NestedMap(csMap, "state", "terminated")
193+
if found && name == "ambient-code-runner" {
194+
reason, _, _ := unstructured.NestedString(terminated, "reason")
195+
if reason == "OOMKilled" || reason == "Error" {
196+
return true
197+
}
198+
}
171199
}
172200

173201
initStatuses, found, _ := unstructured.NestedSlice(pod.Object, "status", "initContainerStatuses")

components/ambient-ui/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Ambient UI
2+
3+
Operations console for the Ambient Code Platform. Next.js BFF with OIDC authentication (Keycloak), shadcn/ui components, and Red Hat design system.
4+
5+
## Local Development
6+
7+
Prerequisites: Kind cluster running (`make kind-up`).
8+
9+
```bash
10+
make dev COMPONENT=ambient-ui
11+
```
12+
13+
This single command:
14+
1. Sets kubectl context to the correct Kind cluster
15+
2. Port-forwards the API server and Keycloak
16+
3. Patches Keycloak's `KC_HOSTNAME` so OIDC redirects work locally
17+
4. Generates `.env.local` with correct SSO config
18+
5. Starts the Next.js dev server on `http://localhost:3001`
19+
20+
Login with the Keycloak admin credentials (`admin` / `admin`) or any user configured in the `ambient-code` realm.
21+
22+
### Other useful commands
23+
24+
```bash
25+
make dev-env-ambient-ui # Regenerate .env.local without starting the dev server
26+
make kind-reload-ambient-ui # Rebuild image and redeploy to Kind
27+
make kind-status # Show cluster ports (including Keycloak)
28+
```
29+
30+
## Testing
31+
32+
```bash
33+
npm test # Unit tests (vitest, ~3s)
34+
npm run test:watch # Watch mode
35+
npm run test:coverage # With coverage
36+
37+
# E2E (requires: npm install && npx playwright install chromium)
38+
npm run test:e2e
39+
```
40+
41+
## Architecture
42+
43+
- **BFF pattern**: Next.js server handles OIDC auth, proxies API calls with JWT injection. Browser never sees raw tokens.
44+
- **Port/Adapter**: Domain types in `src/domain/`, ports in `src/ports/`, SDK adapters in `src/adapters/`. Components consume ports, never SDK types.
45+
- **iron-session**: Per-user encrypted cookie sessions for connection context and auth state.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('Authentication endpoints', () => {
4+
test('GET /api/me returns unauthenticated when no session cookie', async ({
5+
request,
6+
}) => {
7+
const response = await request.get('/api/me')
8+
9+
expect(response.status()).toBe(200)
10+
const body = await response.json()
11+
expect(body.authenticated).toBe(false)
12+
})
13+
14+
test('GET /api/config returns config shape', async ({ request }) => {
15+
const response = await request.get('/api/config')
16+
17+
expect(response.status()).toBe(200)
18+
const body = await response.json()
19+
expect(body).toHaveProperty('apiServerUrl')
20+
expect(body).toHaveProperty('isCustomContext')
21+
expect(typeof body.apiServerUrl).toBe('string')
22+
expect(typeof body.isCustomContext).toBe('boolean')
23+
})
24+
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('Health check endpoint', () => {
4+
test('GET /api/healthz returns 200 with status ok', async ({ request }) => {
5+
const response = await request.get('/api/healthz')
6+
7+
expect(response.status()).toBe(200)
8+
const body = await response.json()
9+
expect(body).toEqual({ status: 'ok' })
10+
})
11+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('BFF proxy endpoint', () => {
4+
test('GET /api/ambient/v1/sessions without auth returns 401 or 502', async ({
5+
request,
6+
}) => {
7+
const response = await request.get('/api/ambient/v1/sessions', {
8+
failOnStatusCode: false,
9+
})
10+
11+
// Without auth: 401 (no token). Without backend: 502 (upstream unreachable).
12+
// 404/405 would indicate a broken route — reject those.
13+
expect([401, 502]).toContain(response.status())
14+
})
15+
})

0 commit comments

Comments
 (0)