From 426b66828f4286e316e876aa626e26cedbf95901 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Sun, 12 Apr 2026 22:11:47 +0200 Subject: [PATCH] deploy 2/2 : image server / api url fix - responsive images --- .../supabase/zitadel-service-connection.md | 293 ++++++++++++++++++ packages/ui/src/components/PhotoCard.tsx | 1 + .../ui/src/components/ResponsiveImage.tsx | 76 +++-- packages/ui/src/hooks/useResponsiveImage.ts | 81 ++--- packages/ui/src/lib/db.ts | 81 ++++- .../modules/storage/hooks/useVfsAdapter.ts | 11 +- packages/ui/src/playground/auth.tsx | 63 ++-- 7 files changed, 495 insertions(+), 111 deletions(-) create mode 100644 packages/ui/docs/supabase/zitadel-service-connection.md diff --git a/packages/ui/docs/supabase/zitadel-service-connection.md b/packages/ui/docs/supabase/zitadel-service-connection.md new file mode 100644 index 00000000..938bdc62 --- /dev/null +++ b/packages/ui/docs/supabase/zitadel-service-connection.md @@ -0,0 +1,293 @@ +# Zitadel Service Connection (M2M) + +How the **pm-pics server** authenticates to Zitadel as a machine principal — for background jobs, management-API calls, and server-side E2E tests — without a browser or a human user. + +For the human OIDC/PKCE flow (SPA login, `id_token`) see [`auth-zitadel.md`](./auth-zitadel.md). +For the `ZITADEL_SERVER_ACCESS_TOKEN` quick-reference see [`server/docs/zitadel-server-token.md`](../../server/docs/zitadel-server-token.md). + +--- + +## How it works + +``` +Server Zitadel (auth.polymech.info) + | | + |-- JWT assertion ----------------->| POST /oauth/v2/token + | (signed with service key) | grant_type=jwt-bearer + |<-- access_token (JWT) -----------| + | + | OR simply: + | ZITADEL_SERVER_ACCESS_TOKEN (PAT pre-issued in Zitadel console) + | + |-- Bearer -------->| JWKS verification (same as human user) +``` + +There are **two ways** to get a service JWT. Priority order in `getServiceAccessToken()`: + +| Priority | Method | Set up via | Token lifetime | +|---|---|---|---| +| **1 — PAT** | Personal Access Token from Zitadel console | `ZITADEL_SERVER_ACCESS_TOKEN` | Days–months (Zitadel policy) | +| **2 — JWT assertion** | Server signs a JWT with its private key; Zitadel exchanges it (RFC 7523) | `ZITADEL_SERVICE_KEY_JSON` | ~36 h per exchange; key valid until `expirationDate` | + +The resulting access token is a **Zitadel-issued JWT** — it JWKS-verifies exactly like a human user token, so `getUserCached()` and all downstream middleware work identically. + +--- + +## Registered service identity + +| Field | Value | +|---|---| +| Zitadel machine user `sub` | `368155730978537473` | +| Zitadel key ID (`kid`) | `368190655136006145` | +| Key expiry | `2026-06-11` | +| DB `profiles.user_id` | `fe6c94a9-aeec-40db-be63-07618e502585` | +| DB role | `admin` (`public.user_roles`) | +| `aud` in issued access tokens | `["service"]` | + +--- + +## Environment variables + +All live in `server/.env.zitadel.local` (git-ignored; loaded with `override: true` in vitest so it wins over `.env`). + +```env +# ── Service identity (required for test:zitadel and test:zitadel:server) ─────── +# The same token is used by both test suites. Refresh with: +# npm run zitadel:write-service-token +ZITADEL_SERVER_ACCESS_TOKEN='eyJ...' + +# ── Option B: JWT assertion key (used by zitadel:write-service-token itself) ─── +# Download: Zitadel console → Service Users → → Keys → Download JSON +# The public key must be uploaded to Zitadel before this works. +# Verify: npm run zitadel:key-inspect +ZITADEL_SERVICE_KEY_JSON='{"type":"serviceaccount","keyId":"368190655136006145","key":"-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n","expirationDate":"2026-06-11T22:00:00Z","userId":"368155730978537473"}' + +# ── Human PKCE flow (optional, for testing with a human user token) ───────────── +# Set only when running npm run zitadel:token (browser OIDC login). +# zitadel.e2e.test.ts falls back to this when ZITADEL_SERVER_ACCESS_TOKEN is absent. +ZITADEL_TEST_ID_TOKEN='eyJ...' +``` + +Persistent values in `server/.env`: + +```env +ZITADEL_ISSUER=https://auth.polymech.info +# ZITADEL_AUDIENCE intentionally unset — service token aud ["service"] is accepted +# because verifyZitadelAccessToken() skips audience validation when not configured +``` + +> **`ZITADEL_TEST_ACCESS_TOKEN` is removed.** It was a redundant copy of `ZITADEL_SERVER_ACCESS_TOKEN` written by the old `zitadel:write-service-token` script. Both test suites now read `ZITADEL_SERVER_ACCESS_TOKEN` directly. + +--- + +## Production CLI (no `tsx` required) + +The webpack bundle (`dist/server.js`) doubles as a CLI. Any recognised sub-command runs the operation and exits; no sub-command starts the HTTP server as normal. + +```bash +# Dev (tsx source) +npm run cli:register-service-admin -- --sub 368155730978537473 +npm run cli:refresh-service-token +npm run cli:verify-admin + +# Production bundle (systemd / Docker exec / any shell with node) +node dist/server.js register-service-admin --sub 368155730978537473 +node dist/server.js refresh-service-token +node dist/server.js verify-admin +node dist/server.js --help # list all commands +``` + +| Command | What it does | +|---|---| +| `register-service-admin --sub [--role admin]` | Upserts `profiles.zitadel_sub` row + grants role in `user_roles`. Idempotent. | +| `refresh-service-token` | Runs JWT-assertion exchange, prints claims (`sub`, `iss`, `aud`, `exp`). No file write. | +| `verify-admin` | Reads `ZITADEL_SERVER_ACCESS_TOKEN`, verifies via JWKS, asserts `isAdmin()` in Postgres. | + +All commands share the same env-load order as the HTTP server (`.env` → `.env.zitadel.local` → `.env-db`), so no extra setup is needed. + +--- + +## Scripts + +```bash +# ── Token refresh (run whenever service tokens expire, ~36 h) ───────────────── +npm run zitadel:write-service-token +# Uses ZITADEL_SERVICE_KEY_JSON → JWT assertion → fresh access token → +# writes ZITADEL_SERVER_ACCESS_TOKEN into .env.zitadel.local (merges, preserves other keys). + +npm run zitadel:m2m +# Same exchange but prints token + decoded payload to stdout only (no file write). +# Use to inspect a fresh token without committing it anywhere. + +# ── Diagnostics ─────────────────────────────────────────────────────────────── +npm run zitadel:key-inspect +# Extracts public key PEM from ZITADEL_SERVICE_KEY_JSON, runs a live token +# exchange, and prints the result. "500 Errors.Internal" = key not registered. +# Also prints step-by-step Zitadel console upload instructions. + +npm run verify:admin +# Reads ZITADEL_SERVER_ACCESS_TOKEN from .env.zitadel.local, verifies the JWT +# via JWKS, then checks isAdmin() against DATABASE_URL. Quick sanity check that +# the service identity is correctly wired end-to-end. + +# ── One-time DB setup ───────────────────────────────────────────────────────── +npx tsx scripts/register-service-admin.ts --sub 368155730978537473 +# Creates profiles row + admin user_roles entry for the machine user. Idempotent. + +# ── Human PKCE flow (optional) ──────────────────────────────────────────────── +npm run zitadel:token +# Opens browser → OIDC PKCE login → merges ZITADEL_TEST_ID_TOKEN into .env.zitadel.local. + +npm run zitadel:token:password +# Password grant (no browser) → same file write. Requires ROPC enabled on the OIDC app. +``` + +--- + +## E2E test suites + +```bash +npm run test:zitadel:server # zitadel-service.e2e.test.ts — service JWT + DB admin +npm run test:zitadel # zitadel.e2e.test.ts — same service JWT + DB admin +``` + +Both suites read `ZITADEL_SERVER_ACCESS_TOKEN`. `test:zitadel` falls back to `ZITADEL_TEST_ID_TOKEN` when the service token is absent (human PKCE flow). + +**Expected result when everything is set up:** + +``` +Test Files 2 passed (2) + Tests 18 passed | 1 skipped (19) +``` + +The skipped test is the password-grant test (requires `ZITADEL_ENABLE_PASSWORD_GRANT_TEST=1`). + +### `test:zitadel:server` + +Reads `ZITADEL_SERVER_ACCESS_TOKEN`. If absent, `beforeAll` calls `getServiceAccessToken()` via `ZITADEL_SERVICE_KEY_JSON` as a fallback. Tests: + +1. `getUserCached(serviceJwt)` → non-null user (JWKS verification) +2. Tampered JWT is rejected +3. `GET /api/admin/authz/debug` with Bearer → `{ verified: true, sub: "..." }` +4. `getUserCached` + `isAdmin` → `true` (service user is admin in DB) +5. JWT payload has `sub`, `iss`, `aud` +6. `jwtVerify` (jose) against JWKS passes + +### `test:zitadel` + +Reads `ZITADEL_SERVER_ACCESS_TOKEN` (preferred) or `ZITADEL_TEST_ID_TOKEN`. Tests JWKS reachability, `getUserCached`, `authz/debug`, and `isAdmin`. + +--- + +## How JWT assertion works internally + +When `ZITADEL_SERVER_ACCESS_TOKEN` is absent or expired, `getServiceAccessToken()` does RFC 7523: + +``` +1. Parse ZITADEL_SERVICE_KEY_JSON + clientId = keyConfig.userId ("368155730978537473") + keyId = keyConfig.keyId ("368190655136006145") + privateKey = RSA PEM + +2. Sign an assertion JWT: + Header: { alg: "RS256", kid: keyId } + Payload: { iss: clientId, sub: clientId, aud: ZITADEL_ISSUER, + iat: now, exp: now+3600, jti: random } + +3. POST https://auth.polymech.info/oauth/v2/token + grant_type = urn:ietf:params:oauth:grant-type:jwt-bearer + assertion = + scope = openid profile email + +4. Zitadel verifies signature against the registered public key → + returns { access_token (JWT), expires_in, token_type: "Bearer" } + +5. Token is cached with a 1-min safety buffer before exp +``` + +> **Why `500 Errors.Internal`?** This is Zitadel's response when the `kid` doesn't match any key registered for that user. The JSON file alone is not enough — the **public key must be uploaded to Zitadel first**. Run `npm run zitadel:key-inspect` to get the PEM and exact console steps. + +--- + +## Service token payload + +Machine user tokens have `aud: ["service"]` and **no `email` claim**: + +```json +{ + "iss": "https://auth.polymech.info", + "sub": "368155730978537473", + "aud": ["service"], + "exp": 1776086012, + "iat": 1775956412, + "client_id": "service" +} +``` + +Since `ZITADEL_AUDIENCE` is unset, `verifyZitadelAccessToken()` skips audience validation and the token verifies cleanly via JWKS. `claimsToUser()` sets `email: ''`. `isAdmin` resolves correctly via the app UUID (`fe6c94a9-...`) which has an `admin` row in `user_roles`. + +--- + +## One-time DB registration + +```bash +npx tsx scripts/register-service-admin.ts --sub 368155730978537473 +``` + +What it does: +1. Checks `profiles.zitadel_sub = '368155730978537473'` — creates row if missing (`gen_random_uuid()` for `user_id`) +2. Grants `role = 'admin'` in `user_roles` if not already present + +After registration: `resolveAppUserId('368155730978537473', null)` → `fe6c94a9-...` → `isAdmin(uuid, '')` → `true`. + +--- + +## One-time key registration in Zitadel + +If you have `ZITADEL_SERVICE_KEY_JSON` but get `500 Errors.Internal`: + +```bash +npm run zitadel:key-inspect # prints public key PEM + exact console steps +``` + +Manual steps: +1. Open `https://auth.polymech.info/ui/console` +2. Service Users → user ID `368155730978537473` +3. Keys → Add Key → paste the public key PEM → confirm key ID is `368190655136006145` + +--- + +## Day-to-day workflow + +``` +First time only (dev): + npm run cli:register-service-admin -- --sub 368155730978537473 + npm run zitadel:key-inspect # verify key is registered in Zitadel + +First time only (production): + node dist/server.js register-service-admin --sub 368155730978537473 + +When tokens expire (~36 h) — dev: + npm run zitadel:write-service-token + npm run verify:admin # quick sanity check: JWKS + isAdmin + +When tokens expire (~36 h) — production: + node dist/server.js refresh-service-token # confirm exchange works + node dist/server.js verify-admin # JWKS + isAdmin + +Run tests: + npm run test:zitadel:server + npm run test:zitadel +``` + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `500 Errors.Internal` at token endpoint | Public key not registered in Zitadel | `npm run zitadel:key-inspect` → upload PEM | +| `getUserCached` returns null | Token expired | `npm run zitadel:write-service-token` | +| `isAdmin` returns false | Service user not in DB | `npx tsx scripts/register-service-admin.ts --sub 368155730978537473` | +| `test:zitadel` shows "token expired" despite fresh `.env.zitadel.local` | Expired token in `.env` wins (no override) | `.env.zitadel.local` uses `override: true` in vitest — make sure the key exists in the local file, not just `.env` | +| `zitadel:write-service-token` fails "credentials missing" | `ZITADEL_SERVICE_KEY_JSON` not in env | Restore `.env.zitadel.local` with the service key JSON | diff --git a/packages/ui/src/components/PhotoCard.tsx b/packages/ui/src/components/PhotoCard.tsx index 2791848e..676dab9e 100644 --- a/packages/ui/src/components/PhotoCard.tsx +++ b/packages/ui/src/components/PhotoCard.tsx @@ -312,6 +312,7 @@ const PhotoCard = ({ responsiveSizes={variant === 'grid' ? [320, 640, 1024, 1280] : [640, 1024, 1280, 1920]} // 1920 added data={responsive} apiUrl={apiUrl} + rootMargin={variant === 'feed' ? '160px 0px' : '200px 0px'} /> {/* Helper Badge for External Images */} {isExternal && ( diff --git a/packages/ui/src/components/ResponsiveImage.tsx b/packages/ui/src/components/ResponsiveImage.tsx index a221956a..789ca76c 100644 --- a/packages/ui/src/components/ResponsiveImage.tsx +++ b/packages/ui/src/components/ResponsiveImage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react'; import { useResponsiveImage } from '@/hooks/useResponsiveImage'; interface ResponsiveImageProps extends Omit, 'src'> { @@ -41,7 +41,7 @@ const ResponsiveImage: React.FC = React.memo(({ formats = DEFAULT_FORMATS, alt, onDataLoaded, - rootMargin = '800px', + rootMargin = '240px', onLoad, onError, data: providedData, @@ -51,33 +51,36 @@ const ResponsiveImage: React.FC = React.memo(({ // Lazy load logic const [isInView, setIsInView] = useState(props.loading === 'eager'); const [imgLoaded, setImgLoaded] = useState(false); - const ref = React.useRef(null); - const imgRef = React.useRef(null); + /** When render URLs fail, show original src (e.g. VFS) — same as opening in a new tab */ + const [fallbackOriginal, setFallbackOriginal] = useState(false); + const observerRef = useRef(null); + const imgRef = useRef(null); + const placeholderRef = useCallback( + (node: HTMLSpanElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + if (!node || props.loading === 'eager') return; - useEffect(() => { - if (props.loading === 'eager' || providedData) { - setIsInView(true); - return; - } + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + setIsInView(true); + observer.disconnect(); + } + }, { rootMargin }); - const observer = new IntersectionObserver(([entry]) => { - if (entry.isIntersecting) { - setIsInView(true); - observer.disconnect(); - } - }, { - rootMargin - }); - - if (ref.current) { - observer.observe(ref.current); - } + observer.observe(node); + observerRef.current = observer; + }, + [props.loading, rootMargin], + ); + useLayoutEffect(() => { return () => { - observer.disconnect(); + observerRef.current?.disconnect(); + observerRef.current = null; }; - }, [props.loading, rootMargin, providedData]); + }, []); const hookResult = useResponsiveImage({ src: src as string | File, @@ -100,6 +103,7 @@ const ResponsiveImage: React.FC = React.memo(({ // Reset loaded state when src changes useEffect(() => { setImgLoaded(false); + setFallbackOriginal(false); }, [src]); // Check if image is already loaded (from cache) @@ -118,7 +122,7 @@ const ResponsiveImage: React.FC = React.memo(({ if (!isInView || isLoadingOrPending) { // Use className for wrapper if provided, otherwise generic // We attach the ref here to detect when this placeholder comes into view - return ; + return ; } if (error || !data) { @@ -129,6 +133,22 @@ const ResponsiveImage: React.FC = React.memo(({ return Failed to load image; } + if (fallbackOriginal && typeof src === 'string') { + return ( + + {alt} + + ); + } + return ( @@ -149,6 +169,10 @@ const ResponsiveImage: React.FC = React.memo(({ onLoad?.(e); }} onError={(e) => { + if (typeof src === 'string') { + setFallbackOriginal(true); + return; + } setImgLoaded(true); onError?.(e); }} @@ -174,7 +198,9 @@ const ResponsiveImage: React.FC = React.memo(({ prev.loading === next.loading && prev.data === next.data && prev.responsiveSizes === next.responsiveSizes && - prev.formats === next.formats; + prev.formats === next.formats && + prev.rootMargin === next.rootMargin && + prev.apiUrl === next.apiUrl; }); export default ResponsiveImage; diff --git a/packages/ui/src/hooks/useResponsiveImage.ts b/packages/ui/src/hooks/useResponsiveImage.ts index 75501591..788741ab 100644 --- a/packages/ui/src/hooks/useResponsiveImage.ts +++ b/packages/ui/src/hooks/useResponsiveImage.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; import { ResponsiveData } from '@/components/ResponsiveImage'; -import { serverUrl } from '@/lib/db'; +import { serverUrl, apiClient } from '@/lib/db'; interface UseResponsiveImageProps { src: string | File | null; @@ -18,6 +18,27 @@ const DEFAULT_FORMATS = ['avif', 'webp', 'jpeg']; // Key: stringified request params, Value: Promise const requestCache = new Map>(); +// Concurrency limiter — at most N responsive-image requests in flight at once +const MAX_CONCURRENT = 3; +let _activeSlots = 0; +const _slotQueue: (() => void)[] = []; + +function _acquireSlot(): Promise { + if (_activeSlots < MAX_CONCURRENT) { + _activeSlots++; + return Promise.resolve(); + } + return new Promise(resolve => { _slotQueue.push(resolve); }); +} + +function _releaseSlot(): void { + if (_slotQueue.length > 0) { + _slotQueue.shift()!(); + } else { + _activeSlots--; + } +} + export const useResponsiveImage = ({ src, responsiveSizes = DEFAULT_SIZES, @@ -49,47 +70,38 @@ export const useResponsiveImage = ({ return; } + // Data URIs bypass the concurrency limiter (no network) + if (typeof src === 'string' && src.startsWith('data:')) { + if (isMounted) { + setData({ + img: { src, width: 0, height: 0, format: 'unknown' }, + sources: [] + }); + setLoading(false); + } + return; + } + + await _acquireSlot(); + if (!isMounted) { _releaseSlot(); return; } + if (isMounted) { setLoading(true); setError(null); } try { - // Only cache string URLs (remote images) - // File objects are harder to cache reliably and usually come from user input anyway if (typeof src === 'string') { - // Check for Data URI - if (src.startsWith('data:')) { - if (isMounted) { - setData({ - img: { - src: src, - width: 0, // Unknown dimensions without parsing - height: 0, - format: 'unknown' - }, - sources: [] // No alternative sources for raw data URI - }); - setLoading(false); - } - return; - } - const cacheKey = JSON.stringify({ src, sizes: responsiveSizes, formats, apiUrl }); if (!requestCache.has(cacheKey)) { const requestPromise = (async () => { const serverBase = apiUrl; - // Resolve relative URLs to absolute so the server-side API can fetch them let resolvedSrc = src; if (src.startsWith('/')) { - resolvedSrc = `${window.location.origin}${src}`; - } else if (src.startsWith('./')) { - // For files like ./image033.jpg, resolve relative to current path or origin - // Standard URL constructor resolves './' against the base URL - resolvedSrc = new URL(src, window.location.href).href; + resolvedSrc = `${serverBase}${src}`; } else if (!src.startsWith('http://') && !src.startsWith('https://')) { - resolvedSrc = new URL(src, window.location.href).href; + resolvedSrc = new URL(src, serverBase).href; } const params = new URLSearchParams({ url: resolvedSrc, @@ -97,16 +109,13 @@ export const useResponsiveImage = ({ formats: JSON.stringify(formats), }); - const response = await fetch(`${serverBase}/api/images/responsive?${params.toString()}`); - - if (!response.ok) { - const txt = await response.text(); - // Remove from cache on error so it can be retried + const responsiveUrl = `${serverBase.replace(/\/$/, '')}/api/images/responsive?${params.toString()}`; + try { + return await apiClient(responsiveUrl); + } catch (e) { requestCache.delete(cacheKey); - throw new Error(txt || `Failed to generate responsive images for ${src}`); + throw e; } - - return response.json(); })(); requestCache.set(cacheKey, requestPromise); @@ -117,7 +126,6 @@ export const useResponsiveImage = ({ setData(result); } } else { - // Handle File objects (no caching) const formData = new FormData(); formData.append('file', src); formData.append('sizes', JSON.stringify(responsiveSizes)); @@ -144,6 +152,7 @@ export const useResponsiveImage = ({ setError(err.message); } } finally { + _releaseSlot(); if (isMounted) { setLoading(false); } diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index 610dfc23..45a209b7 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -77,7 +77,10 @@ export const getAuthToken = async (): Promise => { /** Helper function to get authorization headers */ export const getAuthHeaders = async (): Promise => { const token = await getAuthToken(); - const headers: HeadersInit = { 'Content-Type': 'application/json' }; + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; if (token) headers['Authorization'] = `Bearer ${token}`; return headers; }; @@ -97,23 +100,76 @@ async function apiResponseJson(response: Response, endpoint: string): Promise throw new Error(`API Error on ${endpoint}: ${msg}`); } const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { + if (contentType?.includes('text/html')) { + throw new Error( + `API Error on ${endpoint}: HTTP ${response.status} returned text/html instead of JSON — SPA fallback or API upstream unavailable`, + ); + } + if (contentType?.includes('application/json')) { return response.json(); } return response.text() as unknown as T; } +/** True when the edge/proxy likely returned the SPA shell or a transient upstream error instead of the API. */ +function isTransientApiFailure(response: Response): boolean { + if ([502, 503, 504].includes(response.status)) return true; + if (!response.ok) return false; + const ct = response.headers.get('content-type') || ''; + return ct.includes('text/html'); +} + +const API_CLIENT_MAX_RETRIES = 2; +const API_CLIENT_RETRY_BASE_MS = 350; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function isLikelyNetworkError(err: Error): boolean { + const m = err.message; + return m.includes('fetch') || m.includes('network') || m.includes('Failed to fetch'); +} + +/** + * Retries when the edge returns the SPA index.html (200 + text/html), 502/503/504, + * or a thrown fetch — common during Node restarts or mis-timed proxy static fallback. + */ +async function fetchWithApiRetries(url: string, init: RequestInit, labelForLog: string): Promise { + let lastErr: Error | null = null; + for (let attempt = 0; attempt <= API_CLIENT_MAX_RETRIES; attempt++) { + try { + const response = await fetch(url, init); + if (isTransientApiFailure(response) && attempt < API_CLIENT_MAX_RETRIES) { + const ct = response.headers.get('content-type') || ''; + console.warn( + `[apiClient] transient (${response.status}${ct ? ` ${ct.split(';')[0]}` : ''}) on ${labelForLog} — retry ${attempt + 1}/${API_CLIENT_MAX_RETRIES}`, + ); + await sleep(API_CLIENT_RETRY_BASE_MS * (attempt + 1)); + continue; + } + return response; + } catch (e) { + lastErr = e instanceof Error ? e : new Error(String(e)); + if (isLikelyNetworkError(lastErr) && attempt < API_CLIENT_MAX_RETRIES) { + console.warn(`[apiClient] network error on ${labelForLog} — retry ${attempt + 1}/${API_CLIENT_MAX_RETRIES}`); + await sleep(API_CLIENT_RETRY_BASE_MS * (attempt + 1)); + continue; + } + throw lastErr; + } + } + throw lastErr ?? new Error(`fetch failed after retries: ${labelForLog}`); +} + export async function apiClient(endpoint: string, options: RequestInit = {}): Promise { const headers = await getAuthHeaders(); const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://'); const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`; - const response = await fetch(url, { + const response = await fetchWithApiRetries(url, { ...options, - headers: { - ...headers, - ...options.headers, - }, - }); + headers: { ...headers, ...options.headers }, + }, endpoint); return apiResponseJson(response, endpoint); } @@ -121,13 +177,10 @@ export async function apiClientOr404(endpoint: string, options: RequestInit = const headers = await getAuthHeaders(); const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://'); const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`; - const response = await fetch(url, { + const response = await fetchWithApiRetries(url, { ...options, - headers: { - ...headers, - ...options.headers, - }, - }); + headers: { ...headers, ...options.headers }, + }, endpoint); if (response.status === 404) return null; return apiResponseJson(response, endpoint); } diff --git a/packages/ui/src/modules/storage/hooks/useVfsAdapter.ts b/packages/ui/src/modules/storage/hooks/useVfsAdapter.ts index 1d3a2b76..e9332d9b 100644 --- a/packages/ui/src/modules/storage/hooks/useVfsAdapter.ts +++ b/packages/ui/src/modules/storage/hooks/useVfsAdapter.ts @@ -69,12 +69,14 @@ export function useVfsAdapter({ const clean = dirPath.replace(/^\/+/, ''); if (isSearchMode) { const data = await fetchVfsSearch(mount, clean, searchQuery, { accessToken }); - setNodes(data.results || []); - if (onFetchedRef.current) onFetchedRef.current(data.results || [], true); + const results = Array.isArray(data?.results) ? data.results : []; + setNodes(results); + if (onFetchedRef.current) onFetchedRef.current(results, true); } else { const data = await fetchVfsDirectory(mount, clean, { accessToken, includeSize }); - setNodes(data); - if (onFetchedRef.current) onFetchedRef.current(data, false); + const results = Array.isArray(data) ? data : []; + setNodes(results); + if (onFetchedRef.current) onFetchedRef.current(results, false); } } catch (e: any) { setError(e.message || 'Failed to load directory'); @@ -120,6 +122,7 @@ export function useVfsAdapter({ }, [currentPath, jail, jailRoot]); const filteredNodes = useMemo(() => { + if (!Array.isArray(nodes)) return []; const regex = globToRegex(currentGlob); return nodes.filter(n => { const isDir = n.type === 'dir' || n.mime === 'inode/directory'; diff --git a/packages/ui/src/playground/auth.tsx b/packages/ui/src/playground/auth.tsx index e7f2f3c3..7f41c75c 100644 --- a/packages/ui/src/playground/auth.tsx +++ b/packages/ui/src/playground/auth.tsx @@ -15,7 +15,7 @@ type AuthzDebugResponse = { appAdmin?: boolean; resolvedUserId?: string | null; userSecrets?: unknown; - dbUsersNext?: { poolOk: boolean; database: string; profileCount: number }; + dbPing?: { poolOk: boolean; database: string; profileCount: number }; error?: string; hint?: string; }; @@ -103,7 +103,7 @@ function Row({ } /** - * Dev playground: exercises the new Zitadel/JWKS path (`commons/zitadel.ts` + `DATABASE_URL_NEXT` pool). + * Dev playground: exercises the Zitadel/JWKS auth path (`commons/zitadel.ts` + Postgres pool). * Calls like `GET /api/admin/authz/debug` and `GET /api/admin/authz/experiment/users` are for * integration testing only — not production policy. */ @@ -114,10 +114,10 @@ export default function PlaygroundAuth() { const [last, setLast] = useState(null); const [fetchError, setFetchError] = useState(null); const [lastHttp, setLastHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null); - const [lastDbNext, setLastDbNext] = useState(null); - const [lastDbNextHttp, setLastDbNextHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null); - const [loadingDbNext, setLoadingDbNext] = useState(false); - const [fetchErrorDbNext, setFetchErrorDbNext] = useState(null); + const [lastDbPing, setLastDbPing] = useState(null); + const [lastDbPingHttp, setLastDbPingHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null); + const [loadingDbPing, setLoadingDbPing] = useState(false); + const [fetchErrorDbPing, setFetchErrorDbPing] = useState(null); /** `/api/admin/authz/experiment/users` — admin-gated user list for new-auth experiments only */ const [loadingExpUsers, setLoadingExpUsers] = useState(false); const [lastExpUsers, setLastExpUsers] = useState(null); @@ -189,36 +189,36 @@ export default function PlaygroundAuth() { } }, [auth.user, base]); - /** `GET /api/admin/authz/db-next` — same JWT + `authzDbNextPing()` via `pool` (`DATABASE_URL_NEXT`, `db-users-next.ts`). */ - const runDbNextCheck = useCallback(async () => { + /** `GET /api/admin/authz/db-ping` — same JWT + Postgres pool ping (`DATABASE_URL`). */ + const runDbPingCheck = useCallback(async () => { const picked = pickBearerJwtForApi(auth.user ?? undefined); if (!picked) { - setLastDbNext({ + setLastDbPing({ verified: false, error: 'opaque_or_non_jwt_token', hint: 'Need a JWT id_token (or JWT access_token).', }); - setFetchErrorDbNext(null); - setLastDbNextHttp(null); + setFetchErrorDbPing(null); + setLastDbPingHttp(null); return; } - setLoadingDbNext(true); - setFetchErrorDbNext(null); - const url = `${base.replace(/\/$/, '')}/api/admin/authz/db-next`; + setLoadingDbPing(true); + setFetchErrorDbPing(null); + const url = `${base.replace(/\/$/, '')}/api/admin/authz/db-ping`; try { const res = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${picked.token}` }, }); - setLastDbNextHttp({ status: res.status, ok: res.ok, url }); + setLastDbPingHttp({ status: res.status, ok: res.ok, url }); const body = (await res.json()) as AuthzDebugResponse; - setLastDbNext(body); + setLastDbPing(body); } catch (e) { - setLastDbNextHttp(null); - setFetchErrorDbNext(e instanceof Error ? e.message : String(e)); - setLastDbNext(null); + setLastDbPingHttp(null); + setFetchErrorDbPing(e instanceof Error ? e.message : String(e)); + setLastDbPing(null); } finally { - setLoadingDbNext(false); + setLoadingDbPing(false); } }, [auth.user, base]); @@ -296,11 +296,11 @@ export default function PlaygroundAuth() { {/* New-auth experiment only — see server `authzExperimentUsersRoute` */}