perf(core): response compression, single-flight role-state load, PermissionGuard from the attached state, /public max-age

Merged
EV
10d ago
+849-18

Four per-request levers left over from #10976 (the "payload / caching levers" in the handover), each small and gated by environment where behaviour is visible.

1. Response compression (new: API_COMPRESSION, default on)

Nothing compressed the API's responses — not the API, and not the ingress in front of it — so every JSON body crossed the network as it was: /role-permissions/me is ~164 KB per login, pagination pages tens of KB. The compression middleware (already resolved in yarn.lock; added to @gauzy/core's dependencies, no lockfile change) now negotiates gzip, or Brotli where the client accepts br, per request. JSON compresses 8–15× and the transfer is what the browser waits for after the API has answered; the encoding runs on zlib's thread pool, not the event loop.

  • API_COMPRESSION=false|0|no|off turns it off; API_COMPRESSION_LEVEL (zlib 0–9, default 6) and API_COMPRESSION_THRESHOLD (bytes, default 1024) tune it.
  • text/event-stream responses are never compressed (events must reach the client as they are written), nor a response to a request carrying X-No-Compression; the middleware's own filter (compressible types, no-transform) applies otherwise.
  • packages/core/src/lib/bootstrap/response-compression.ts (+ spec with a real Express server: gzip round-trip, threshold, identity, event stream, opt-out header), compression.d.ts (the package ships no typings; @types/compression is not in the lockfile).

2. One load per role when its cached authorization state expires

RoleAuthorizationService.getAuthorizationState (every authenticated request, via JwtStrategy) caches a role's state for 60 s. When it expired, every request of that role arriving together missed at once and each ran the role + role-permissions query. Concurrent misses of one role now share one load and the cache is written once; roles are independent; a failed load rejects all waiters and clears, so the next call loads again. Spec: 3 new tests (5 concurrent callers → 1 query, 1 cache write; per-role independence; shared failure).

3. PermissionGuard decides from the role state already on the request

JwtStrategy pins the caller's role and its database-fresh permissions onto request.user (RoleAuthorizationService, the same predicate as checkRolePermission(tenantId, roleId, permissions, true): enabled, active, not archived, the role's own rows). PermissionGuard still did a cache read — and on a miss the query and a cache write — on every guarded request. It now answers from the attached state when it can (attachedRolePermissionVerdict), which also means a demotion is denied at once rather than after the 5-minute cache, and keeps the cache + query path for a request without that state: another authentication path, a role of another tenant, a role attached as a bare name (RegisterAuthorizationGuard). TenantPermissionGuard is unchanged on purpose: its query is tenant-wide (includeRole=false), a different question. Specs: attached-role-permissions.spec.ts (7) and a new permission.guard.spec.ts (6: no metadata, grant/deny from the attached state without cache or query, unresolved role denied, fallback path with its cache key, other-tenant role left to the query).

4. /public/ assets get a max-age (new: PUBLIC_ASSETS_MAX_AGE_SECONDS, default 86400)

Uploaded files (avatars, logos, attachments; written once under generated names) were served with no max-age, so every page load revalidated each of them. They are now sent with Cache-Control: private, max-age=86400 — private, so only the viewer's browser keeps a file and a CDN or proxy in front of the API can never go on serving a deleted screenshot or attachment (CodeRabbit security review); 0 restores serve-static's own public, max-age=0. The nosniff and sandbox-CSP headers from GHSA-p334-cm7f-php5 are untouched; helper.serve-static.spec.ts asserts the header.

Also from review: X-No-Compression is in the CORS allowedHeaders, and attachedRolePermissionVerdict never grants from a permission set that has no role tied to the request's tenant (defence in depth; no current path produces that shape).

Verification

  • Specs: see the last comment on this PR for the batch run (role-authorization, permission guard, attached-role-permissions, tenant-permission guard, every bootstrap/*.spec.ts, public-assets-cache).
  • nx run core:build — see the same comment.
  • Not re-benchmarked: 1 and 4 change what the client receives (bytes on the wire, cache hits), which the earlier latency harness does not measure; 2 and 3 remove work on paths the harness already showed to be dominated by other costs. No response body changes.

Per the owner's rule for Gauzy API/UI: develop → stage only.

🤖 Generated with Claude Code