deploy 2/2 : image server / api url fix - responsive images

This commit is contained in:
lovebird 2026-04-12 22:11:47 +02:00
parent b5dbf55c71
commit 426b66828f
7 changed files with 495 additions and 111 deletions

View File

@ -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 <access_token> -------->| 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` | Daysmonths (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 → <user> → 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 <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 = <signed JWT>
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 |

View File

@ -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 && (

View File

@ -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<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> {
@ -41,7 +41,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
formats = DEFAULT_FORMATS,
alt,
onDataLoaded,
rootMargin = '800px',
rootMargin = '240px',
onLoad,
onError,
data: providedData,
@ -51,33 +51,36 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
// Lazy load logic
const [isInView, setIsInView] = useState(props.loading === 'eager');
const [imgLoaded, setImgLoaded] = useState(false);
const ref = React.useRef<HTMLSpanElement>(null);
const imgRef = React.useRef<HTMLImageElement>(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<IntersectionObserver | null>(null);
const imgRef = useRef<HTMLImageElement>(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<ResponsiveImageProps> = 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<ResponsiveImageProps> = 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 <span ref={ref} className={`block animate-pulse dark:bg-gray-800 bg-gray-200 w-full h-full ${className || ''}`} />;
return <span ref={placeholderRef} className={`block animate-pulse dark:bg-gray-800 bg-gray-200 w-full h-full ${className || ''}`} />;
}
if (error || !data) {
@ -129,6 +133,22 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
return <span className="block text-red-500 text-xs">Failed to load image</span>;
}
if (fallbackOriginal && typeof src === 'string') {
return (
<span className={`relative block w-full h-full overflow-hidden ${className || ''}`}>
<img
ref={imgRef}
src={src}
alt={alt}
className={`${imgClassName || ''} transition-opacity duration-300 opacity-100`}
loading={props.loading || 'lazy'}
decoding="async"
{...props}
/>
</span>
);
}
return (
<span className={`relative block w-full h-full overflow-hidden ${className || ''}`}>
<picture>
@ -149,6 +169,10 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = 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<ResponsiveImageProps> = 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;

View File

@ -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<ResponsiveData>
const requestCache = new Map<string, Promise<ResponsiveData>>();
// 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<void> {
if (_activeSlots < MAX_CONCURRENT) {
_activeSlots++;
return Promise.resolve();
}
return new Promise<void>(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<ResponsiveData>(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);
}

View File

@ -77,7 +77,10 @@ export const getAuthToken = async (): Promise<string | undefined> => {
/** Helper function to get authorization headers */
export const getAuthHeaders = async (): Promise<HeadersInit> => {
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<T>(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<void> {
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<Response> {
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
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<T>(response, endpoint);
}
@ -121,13 +177,10 @@ export async function apiClientOr404<T>(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<T>(response, endpoint);
}

View File

@ -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';

View File

@ -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<AuthzDebugResponse | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const [lastHttp, setLastHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null);
const [lastDbNext, setLastDbNext] = useState<AuthzDebugResponse | null>(null);
const [lastDbNextHttp, setLastDbNextHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null);
const [loadingDbNext, setLoadingDbNext] = useState(false);
const [fetchErrorDbNext, setFetchErrorDbNext] = useState<string | null>(null);
const [lastDbPing, setLastDbPing] = useState<AuthzDebugResponse | null>(null);
const [lastDbPingHttp, setLastDbPingHttp] = useState<{ status: number; ok: boolean; url: string } | null>(null);
const [loadingDbPing, setLoadingDbPing] = useState(false);
const [fetchErrorDbPing, setFetchErrorDbPing] = useState<string | null>(null);
/** `/api/admin/authz/experiment/users` — admin-gated user list for new-auth experiments only */
const [loadingExpUsers, setLoadingExpUsers] = useState(false);
const [lastExpUsers, setLastExpUsers] = useState<unknown>(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() {
<Button
variant="secondary"
size="sm"
onClick={() => void runDbNextCheck()}
disabled={loadingDbNext || !base}
onClick={() => void runDbPingCheck()}
disabled={loadingDbPing || !base}
>
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loadingDbNext ? 'animate-spin' : ''}`} />
{loadingDbNext ? 'Pool…' : 'db-users-next (pool)'}
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loadingDbPing ? 'animate-spin' : ''}`} />
{loadingDbPing ? 'Pinging…' : 'DB ping (pool)'}
</Button>
{/* New-auth experiment only — see server `authzExperimentUsersRoute` */}
<Button
@ -462,24 +462,23 @@ export default function PlaygroundAuth() {
</section>
)}
{fetchErrorDbNext && (
{fetchErrorDbPing && (
<pre className="text-xs bg-destructive/10 text-destructive rounded-md p-3 overflow-x-auto">
{fetchErrorDbNext}
{fetchErrorDbPing}
</pre>
)}
{lastDbNext && (
{lastDbPing && (
<section className="space-y-2">
<h3 className="text-sm font-semibold">
API response (/api/admin/authz/db-next) <code className="text-xs">DATABASE_URL_NEXT</code> via{' '}
<code className="text-xs">db-users-next.ts</code>
API response (/api/admin/authz/db-ping) Postgres pool ping
</h3>
<Row label="request">{lastDbNextHttp?.url ?? '—'}</Row>
<Row label="request">{lastDbPingHttp?.url ?? '—'}</Row>
<Row label="status">
{lastDbNextHttp ? `${lastDbNextHttp.status} ${lastDbNextHttp.ok ? 'OK' : ''}` : '—'}
{lastDbPingHttp ? `${lastDbPingHttp.status} ${lastDbPingHttp.ok ? 'OK' : ''}` : '—'}
</Row>
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-[360px]">
{JSON.stringify(lastDbNext, null, 2)}
{JSON.stringify(lastDbPing, null, 2)}
</pre>
</section>
)}