nucleus-core-ts 0.9.751 → 0.9.752

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ export { createPaymentRoutes } from './routes/payment';
16
16
  export { createMarketplaceRoutes, type MarketplaceRouteConfig } from './routes/payment/marketplace';
17
17
  export type { ClientManager, PubSubRouteConfig } from './routes/pubsub';
18
18
  export { createPubSubRoutes } from './routes/pubsub';
19
- export { createCdnRoutes, mergeCdnConfig, mergeStorageConfig, } from './routes/storage';
19
+ export { createCdnRoutes, mergeCdnConfig, mergeStorageConfig, mergeTransformConfig, mergeVideoConfig, } from './routes/storage';
20
20
  export type { ProvisionTenantBody, ProvisionTenantResponse, TenantRouteConfig, } from './routes/tenant';
21
21
  export { createTenantRoutes } from './routes/tenant';
22
22
  export { createSwaggerPlugin, type NucleusSwaggerConfig } from './swagger';
@@ -3,6 +3,7 @@ import type { ChatConfig, ChatService, RealtimeBroadcaster } from 'src/Services/
3
3
  import type { Logger } from 'src/Services/Logger';
4
4
  import type { TenantRegistry } from 'src/Services/Tenant';
5
5
  import type { StorageConfig } from '../storage';
6
+ import type { CdnMediaConfig } from '../storage/mediaPostProcess';
6
7
  /** Public configuration accepted by {@link createChatRoutes}. */
7
8
  export type ChatRoutesConfig = {
8
9
  db: NodePgDatabase;
@@ -14,6 +15,8 @@ export type ChatRoutesConfig = {
14
15
  storageConfig: StorageConfig;
15
16
  attachmentsAvailable: boolean;
16
17
  cdnBasePath: string;
18
+ /** Resolved CDN transform/video config so uploaded media can be warmed/transcoded (fire-and-forget). */
19
+ cdnMedia?: CdnMediaConfig;
17
20
  };
18
21
  /** Per-request resolved service plus the tenant schema tables it was built on. */
19
22
  export type ResolvedChatService = {
@@ -28,4 +31,5 @@ export type ChatRouteDeps = {
28
31
  storageConfig: StorageConfig;
29
32
  attachmentsAvailable: boolean;
30
33
  getService: (request: Request) => ResolvedChatService;
34
+ cdnMedia?: CdnMediaConfig;
31
35
  };
@@ -1,5 +1,7 @@
1
1
  import Elysia from 'elysia';
2
2
  import type { Logger } from '../../../Services/Logger';
3
+ import { type TransformConfig, type TransformConfigInput } from './imageTransform';
4
+ import { type VideoConfig } from './videoTransform';
3
5
  export interface CdnConfig {
4
6
  enabled: boolean;
5
7
  basePath: string;
@@ -7,6 +9,19 @@ export interface CdnConfig {
7
9
  enableRangeRequests: boolean;
8
10
  enableEtag: boolean;
9
11
  corsOrigins: string[];
12
+ transform: TransformConfig;
13
+ video: VideoConfig;
14
+ }
15
+ /** Raw (deep-partial) shape accepted from config.storage.cdn. */
16
+ export interface CdnConfigInput {
17
+ enabled?: boolean;
18
+ basePath?: string;
19
+ cacheMaxAge?: number;
20
+ enableRangeRequests?: boolean;
21
+ enableEtag?: boolean;
22
+ corsOrigins?: string[];
23
+ transform?: TransformConfigInput;
24
+ video?: Partial<VideoConfig>;
10
25
  }
11
26
  export interface CdnRoutesConfig {
12
27
  cdn: CdnConfig;
@@ -19,7 +34,7 @@ export interface CdnRoutesConfig {
19
34
  mime_type: string;
20
35
  } | null>;
21
36
  }
22
- export declare function mergeCdnConfig(config?: Partial<CdnConfig>): CdnConfig;
37
+ export declare function mergeCdnConfig(config?: CdnConfigInput): CdnConfig;
23
38
  export declare function createCdnRoutes(config: CdnRoutesConfig): Elysia<string, {
24
39
  decorator: {};
25
40
  store: {};
@@ -0,0 +1,75 @@
1
+ import type { Logger } from '../../../Services/Logger';
2
+ /** Formats a client may request via `?format=`. */
3
+ export type TransformFormat = 'webp' | 'avif' | 'jpeg' | 'png';
4
+ export interface PregenerateConfig {
5
+ enabled: boolean;
6
+ widths: number[];
7
+ formats: TransformFormat[];
8
+ quality?: number;
9
+ }
10
+ export interface TransformConfig {
11
+ enabled: boolean;
12
+ maxWidth: number;
13
+ maxHeight: number;
14
+ defaultQuality: number;
15
+ allowedFormats: TransformFormat[];
16
+ cacheSubdir: string;
17
+ maxInputPixels: number;
18
+ pregenerate: PregenerateConfig;
19
+ }
20
+ /** Raw (deep-partial) shape accepted from config.storage.cdn.transform. */
21
+ export type TransformConfigInput = Partial<Omit<TransformConfig, 'pregenerate'>> & {
22
+ pregenerate?: Partial<PregenerateConfig>;
23
+ };
24
+ export declare function mergeTransformConfig(config?: TransformConfigInput | undefined): TransformConfig;
25
+ export interface TransformParams {
26
+ width?: number;
27
+ height?: number;
28
+ quality: number;
29
+ /** Requested output format, or undefined to keep the source format. */
30
+ format?: TransformFormat;
31
+ }
32
+ /**
33
+ * True only for raster images we can safely transform. SVG is deliberately excluded — it is served
34
+ * as a download (stored-XSS defence) and rasterizing untrusted SVG is an SSRF/bomb vector.
35
+ */
36
+ export declare function isTransformableImage(mimeType: string): boolean;
37
+ /**
38
+ * Parse `?w=&h=&q=&format=` into a normalized, CLAMPED transform request. Returns null when no
39
+ * transform is requested (no w/h/q/format), so the caller serves the original bytes untouched.
40
+ */
41
+ export declare function parseTransformParams(query: Record<string, string | undefined>, config: TransformConfig): TransformParams | null;
42
+ /** Short, filesystem-safe cache key folding in every input that changes the output. */
43
+ export declare function deriveCacheKey(mtimeMs: number, params: TransformParams, outputFormat: TransformFormat): string;
44
+ export interface EnsureDerivativeArgs {
45
+ sourcePath: string;
46
+ mimeType: string;
47
+ params: TransformParams;
48
+ storagePath: string;
49
+ config: TransformConfig;
50
+ logger: Logger;
51
+ }
52
+ /**
53
+ * Produce (and cache on disk) a transformed image derivative and return its path + content-type +
54
+ * etag, so the caller can stream it exactly like an original file. Returns null on ANY failure
55
+ * (sharp missing, non-transformable type, decode error) so the caller falls back to the original —
56
+ * a broken transform must never break file serving.
57
+ */
58
+ export declare function ensureDerivative(args: EnsureDerivativeArgs): Promise<{
59
+ path: string;
60
+ contentType: string;
61
+ etag: string;
62
+ } | null>;
63
+ /**
64
+ * Pre-generate the configured image variants into the SAME derivative cache the on-the-fly path reads,
65
+ * so the first `?w=&format=` request is already a cache hit. Best-effort: each variant is independent and
66
+ * any failure is swallowed (logged inside ensureDerivative). Intended to be called fire-and-forget right
67
+ * after an image is persisted on upload. Returns the number of variants successfully generated/present.
68
+ */
69
+ export declare function pregenerateImageVariants(args: {
70
+ sourcePath: string;
71
+ mimeType: string;
72
+ storagePath: string;
73
+ config: TransformConfig;
74
+ logger: Logger;
75
+ }): Promise<number>;
@@ -1,3 +1,6 @@
1
1
  export { type CdnConfig, type CdnRoutesConfig, createCdnRoutes, mergeCdnConfig, } from './cdn';
2
2
  export { buildFileRecordPayload, type PersistedFile, persistUploadedFiles, } from './file-record';
3
+ export { type CdnMediaConfig, scheduleUploadMediaProcessing, } from './mediaPostProcess';
4
+ export { mergeTransformConfig, type TransformConfig, } from './imageTransform';
5
+ export { mergeVideoConfig, type VideoConfig, } from './videoTransform';
3
6
  export { deleteFile, type FileUploadResult, type FormDataParseResult, mergeStorageConfig, parseFormDataBody, type StorageConfig, type StorageConfigInput, uploadFile, uploadFiles, validateFile, } from './helpers';
@@ -0,0 +1,20 @@
1
+ import type { Logger } from '../../../Services/Logger';
2
+ import type { PersistedFile } from './file-record';
3
+ import { type TransformConfig } from './imageTransform';
4
+ import { type VideoConfig } from './videoTransform';
5
+ /** The resolved CDN media config needed to warm/transcode uploads. */
6
+ export interface CdnMediaConfig {
7
+ transform: TransformConfig;
8
+ video: VideoConfig;
9
+ }
10
+ /**
11
+ * Schedule post-upload media processing for freshly-persisted files — FIRE-AND-FORGET. For each record:
12
+ * pre-generate the configured image variants (into the on-the-fly derivative cache) and/or kick off an
13
+ * ffmpeg video transcode + poster. Never awaited by the caller, never throws — a failed or slow transform
14
+ * must never affect the upload response. Both steps are individually gated by their config `enabled` flags.
15
+ */
16
+ export declare function scheduleUploadMediaProcessing(records: PersistedFile[], opts: {
17
+ storagePath: string;
18
+ media: CdnMediaConfig;
19
+ logger: Logger;
20
+ }): void;
@@ -0,0 +1,39 @@
1
+ import type { Logger } from '../../../Services/Logger';
2
+ export interface VideoConfig {
3
+ enabled: boolean;
4
+ transcode: boolean;
5
+ poster: boolean;
6
+ posterFormat: 'jpeg' | 'webp';
7
+ crf: number;
8
+ maxWidth: number;
9
+ ffmpegPath: string;
10
+ }
11
+ export declare function mergeVideoConfig(config?: Partial<VideoConfig> | undefined): VideoConfig;
12
+ export declare function isTransformableVideo(mimeType: string): boolean;
13
+ /**
14
+ * Serve-side: return the ready web-optimized MP4 (or poster) derivative for a source video, or null if
15
+ * it hasn't been generated yet (→ the caller serves the original). `kind: 'poster'` returns the poster.
16
+ */
17
+ export declare function resolveVideoVariant(args: {
18
+ sourcePath: string;
19
+ storagePath: string;
20
+ cacheSubdir: string;
21
+ posterFormat: VideoConfig['posterFormat'];
22
+ kind: 'web' | 'poster';
23
+ }): Promise<{
24
+ path: string;
25
+ contentType: string;
26
+ } | null>;
27
+ /**
28
+ * Upload-side: transcode a just-uploaded video to a web-optimized MP4 (+ poster frame) via ffmpeg.
29
+ * Best-effort and NON-BLOCKING — intended to be fired-and-forgotten (the caller does not await). All
30
+ * failures are swallowed/logged; a missing ffmpeg just leaves the original to be served as-is.
31
+ */
32
+ export declare function processVideoOnUpload(args: {
33
+ sourcePath: string;
34
+ mimeType: string;
35
+ storagePath: string;
36
+ cacheSubdir: string;
37
+ config: VideoConfig;
38
+ logger: Logger;
39
+ }): Promise<void>;
@@ -1098,6 +1098,69 @@ export interface NucleusConfigOptions {
1098
1098
  enableRangeRequests?: boolean;
1099
1099
  enableEtag?: boolean;
1100
1100
  corsOrigins?: string[];
1101
+ /**
1102
+ * On-the-fly IMAGE resize/compress/re-encode at the serve endpoint, so a frontend can point
1103
+ * `<img>` / `next/image` (with its own optimizer disabled) at `/cdn/{id}?w=800&q=80&format=webp`
1104
+ * and get a resized, compressed, browser-cached derivative. Requires the optional `sharp` peer
1105
+ * dependency (`npm i sharp`); when enabled but sharp is missing, the CDN logs an error and serves
1106
+ * the ORIGINAL bytes (never 500s). Only raster images are transformed — SVG (served as a download),
1107
+ * video, and other types are streamed untouched.
1108
+ */
1109
+ transform?: {
1110
+ /** Enable on-the-fly image transforms. Default false (opt-in — needs `sharp`). */
1111
+ enabled?: boolean;
1112
+ /** Clamp requested width/height so a caller can't force a huge allocation. Defaults 3840. */
1113
+ maxWidth?: number;
1114
+ maxHeight?: number;
1115
+ /** Output quality (1-100) applied when `?q=` is omitted. Default 80. */
1116
+ defaultQuality?: number;
1117
+ /**
1118
+ * Formats a client may request via `?format=`. When `?format=` is omitted the source format is
1119
+ * kept (still resized/re-compressed). Default ['webp', 'avif', 'jpeg', 'png'].
1120
+ */
1121
+ allowedFormats?: ('webp' | 'avif' | 'jpeg' | 'png')[];
1122
+ /** Subdirectory under storage.basePath where generated derivatives are cached. Default '.cache/cdn'. */
1123
+ cacheSubdir?: string;
1124
+ /** Max source pixels sharp will decode — decompression-bomb guard. Default 100000000 (100 MP). */
1125
+ maxInputPixels?: number;
1126
+ /**
1127
+ * On upload, PRE-GENERATE these image variants (into the same derivative cache the on-the-fly
1128
+ * path reads) so the first request is already fast. Best-effort + non-blocking.
1129
+ */
1130
+ pregenerate?: {
1131
+ /** Default false. Requires transform.enabled + `sharp`. */
1132
+ enabled?: boolean;
1133
+ /** Widths to pre-render, e.g. [400, 800, 1200]. Default []. */
1134
+ widths?: number[];
1135
+ /** Formats to pre-render, e.g. ['webp']. Default ['webp']. */
1136
+ formats?: ('webp' | 'avif' | 'jpeg' | 'png')[];
1137
+ /** Encode quality for the pre-rendered variants. Default = transform.defaultQuality. */
1138
+ quality?: number;
1139
+ };
1140
+ };
1141
+ /**
1142
+ * VIDEO optimization on upload (opt-in; requires an `ffmpeg` binary on PATH). On upload of a video the
1143
+ * CDN spawns ffmpeg (fire-and-forget) to produce a web-optimized MP4 (H.264 + faststart, optionally
1144
+ * downscaled) and/or a poster frame, cached beside the image derivatives. The transcoded MP4 is then
1145
+ * served at `/cdn/{id}` (range-streamed) once ready; until then the ORIGINAL is served. The poster is
1146
+ * served at `/cdn/{id}?poster=1`. Per-request transcoding is NOT done (too heavy) — only on upload.
1147
+ */
1148
+ video?: {
1149
+ /** Default false. Requires an ffmpeg binary. */
1150
+ enabled?: boolean;
1151
+ /** Transcode uploaded videos to a web-optimized MP4 (H.264 + +faststart). Default true when video.enabled. */
1152
+ transcode?: boolean;
1153
+ /** Extract a poster frame served at ?poster=1. Default true when video.enabled. */
1154
+ poster?: boolean;
1155
+ /** Poster image format. Default 'jpeg'. */
1156
+ posterFormat?: 'jpeg' | 'webp';
1157
+ /** H.264 CRF (0-51, lower = better/bigger). Default 23. */
1158
+ crf?: number;
1159
+ /** Downscale the transcoded video so its width never exceeds this. Default 1920. */
1160
+ maxWidth?: number;
1161
+ /** ffmpeg executable (name on PATH or absolute path). Default 'ffmpeg'. */
1162
+ ffmpegPath?: string;
1163
+ };
1101
1164
  };
1102
1165
  formData?: {
1103
1166
  filesField?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.751",
3
+ "version": "0.9.752",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -51,7 +51,7 @@
51
51
  "test": "bun test src",
52
52
  "build": "bun run scripts/build.ts",
53
53
  "build:quick": "bun run build:js && bun run build:types",
54
- "build:js": "bun build ./index.ts ./client.ts ./fe/index.ts ./src/Client/Proxy/index.ts --outdir=dist --target=bun --format=esm --splitting --minify --external react --external react-dom --external gsap --external @gsap/react --external h-state --external three --external @react-three/fiber --external elysia --external drizzle-orm --external drizzle-kit --external ioredis --external googleapis --external @dapr/dapr --external pg --external @xyflow/react --external @xyflow/system --external @azure/communication-email --external @azure/identity --external stripe",
54
+ "build:js": "bun build ./index.ts ./client.ts ./fe/index.ts ./src/Client/Proxy/index.ts --outdir=dist --target=bun --format=esm --splitting --minify --external react --external react-dom --external gsap --external @gsap/react --external h-state --external three --external @react-three/fiber --external elysia --external drizzle-orm --external drizzle-kit --external ioredis --external googleapis --external @dapr/dapr --external pg --external @xyflow/react --external @xyflow/system --external @azure/communication-email --external @azure/identity --external stripe --external sharp",
55
55
  "build:types": "tsc --declaration --emitDeclarationOnly --outDir dist --skipLibCheck",
56
56
  "version:patch": "bun run scripts/version.ts patch",
57
57
  "version:minor": "bun run scripts/version.ts minor",
@@ -110,6 +110,7 @@
110
110
  },
111
111
  "optionalDependencies": {
112
112
  "@azure/communication-email": "latest",
113
+ "sharp": "^0.33.0",
113
114
  "stripe": "latest"
114
115
  }
115
116
  }
@@ -504,6 +504,49 @@
504
504
  },
505
505
  "approvalRedirectUrl": {
506
506
  "type": "string"
507
+ },
508
+ "deviceTrust": {
509
+ "type": "object",
510
+ "properties": {
511
+ "enabled": {
512
+ "type": "boolean"
513
+ },
514
+ "tokenExpiresIn": {
515
+ "type": "string"
516
+ },
517
+ "cookieName": {
518
+ "type": "string"
519
+ },
520
+ "cookiePrefix": {
521
+ "type": "string",
522
+ "enum": [
523
+ "",
524
+ "__Secure-",
525
+ "__Host-"
526
+ ]
527
+ },
528
+ "sameSite": {
529
+ "type": "string",
530
+ "enum": [
531
+ "Strict",
532
+ "Lax"
533
+ ]
534
+ },
535
+ "onUnidentifiable": {
536
+ "type": "string",
537
+ "enum": [
538
+ "require_approval",
539
+ "deny"
540
+ ]
541
+ },
542
+ "approvalMethods": {
543
+ "type": "array",
544
+ "items": {
545
+ "type": "string"
546
+ }
547
+ }
548
+ },
549
+ "description": "Remembered-device trust (opt-in). Server-issued hashed device token replaces the spoofable UA fingerprint as the \"known device\" anchor for new-device approval. Never replaces authentication. See docs/superpowers/specs/2026-07-07-device-trust-design.md."
507
550
  }
508
551
  },
509
552
  "required": [
@@ -1132,7 +1175,8 @@
1132
1175
  "type": "array",
1133
1176
  "items": {
1134
1177
  "type": "string"
1135
- }
1178
+ },
1179
+ "description": "Use `publicPaths` to make a path public. Kept for config back-compat only."
1136
1180
  },
1137
1181
  "publicPaths": {
1138
1182
  "type": "array",
@@ -1224,6 +1268,92 @@
1224
1268
  }
1225
1269
  },
1226
1270
  "description": "Project-specific seed data for roles, claims, and role-claim assignments. Runs AFTER autoSeedClaims (entity-based) and godmin setup. Idempotent — existing items are skipped, only missing ones are created/assigned."
1271
+ },
1272
+ "endpointDiscovery": {
1273
+ "type": "object",
1274
+ "properties": {
1275
+ "enabled": {
1276
+ "type": "boolean"
1277
+ },
1278
+ "runOnBoot": {
1279
+ "type": "boolean",
1280
+ "description": "Run discovery at boot (default true when enabled)."
1281
+ },
1282
+ "services": {
1283
+ "type": "array",
1284
+ "items": {
1285
+ "type": "object",
1286
+ "properties": {
1287
+ "id": {
1288
+ "type": "string",
1289
+ "description": "Stable prefix for this service's claim actions (e.g. \"agent\")."
1290
+ },
1291
+ "baseUrl": {
1292
+ "type": "string",
1293
+ "description": "Base URL reachable from the IDP (e.g. \"http://vorion-agent:8000\")."
1294
+ },
1295
+ "openapi": {
1296
+ "type": "string",
1297
+ "description": "OpenAPI document path (default \"/openapi.json\")."
1298
+ },
1299
+ "exclude": {
1300
+ "type": "array",
1301
+ "items": {
1302
+ "type": "string"
1303
+ },
1304
+ "description": "Path globs to skip, e.g. [\"/health\",\"/api/v1/internal/*\"]."
1305
+ }
1306
+ },
1307
+ "required": [
1308
+ "id",
1309
+ "baseUrl"
1310
+ ]
1311
+ }
1312
+ },
1313
+ "trigger": {
1314
+ "type": "object",
1315
+ "properties": {
1316
+ "enabled": {
1317
+ "type": "boolean"
1318
+ },
1319
+ "basePath": {
1320
+ "type": "string"
1321
+ }
1322
+ },
1323
+ "description": "godmin-only on-demand re-discovery endpoint."
1324
+ },
1325
+ "token": {
1326
+ "type": "string",
1327
+ "description": "Shared secret that lets the reverse proxy fetch `/authorization/route-manifest` without a user session (presented as the `x-discovery-token` header). Godmin callers are always allowed. Falls back to the `NUCLEUS_DISCOVERY_TOKEN` env."
1328
+ }
1329
+ },
1330
+ "description": "Auto-discover the endpoints of EXTERNAL (non-nucleus) services from their OpenAPI documents and seed a claim per endpoint, so a nucleus IDP can protect 500+ routes across other services (e.g. FastAPI) with the same role/claim model without hand-writing claims. Full (IDP) mode only. Discovered claim actions are `{serviceId}.{tag}.{operationId}` (idempotent on action; removed endpoints are DEACTIVATED, not deleted). Enforcement happens at the reverse proxy via `HttpProxyTarget.authorize` (see nucleus-core-ts/proxy) consuming the `/authorization/route-manifest` this produces. `serviceId` must not be an HTTP-method word (get/post/put/…)."
1331
+ },
1332
+ "claimGuards": {
1333
+ "type": "array",
1334
+ "items": {
1335
+ "type": "object",
1336
+ "properties": {
1337
+ "name": {
1338
+ "type": "string",
1339
+ "description": "Key template, e.g. `token_spent_today:{userId}` or a global `feature_flag_x`."
1340
+ },
1341
+ "value": {
1342
+ "type": "string",
1343
+ "enum": [
1344
+ "int",
1345
+ "boolean",
1346
+ "string"
1347
+ ],
1348
+ "description": "Declared value type: drives parsing, valid operators, and threshold parsing."
1349
+ }
1350
+ },
1351
+ "required": [
1352
+ "name",
1353
+ "value"
1354
+ ]
1355
+ },
1356
+ "description": "ClaimGuards — generic, config-driven claim invalidation. Each entry defines a comparable key in the shared Redis/Dapr state and seeds a claim (action = the name up to the first `{`). At assignment time godmin attaches a condition to the guard-claim and selects the claims to invalidate when it holds (`role_claims.scope` JSON `{op,value,invalidates}`); at runtime nucleus reads the key, evaluates the condition, and drops the listed claims. The APP writes the key values. nucleus names no metric/endpoint/window — in Vorion this surfaces as a quota. See docs/specs/2026-07-04-claim-guards-design.md."
1227
1357
  }
1228
1358
  }
1229
1359
  },
@@ -1782,10 +1912,18 @@
1782
1912
  "type": "string",
1783
1913
  "description": "Dapr pubsub component name (default: \"pubsub-redis\")"
1784
1914
  },
1915
+ "daprApiToken": {
1916
+ "type": "string",
1917
+ "description": "Shared secret Dapr presents as the `dapr-api-token` header when POSTing a delivered message to the subscription endpoint. REQUIRED to secure that endpoint — without it (and without the APP_API_TOKEN/DAPR_API_TOKEN env fallback) any caller reaching the route can inject realtime events to any user. Falls back to env APP_API_TOKEN, then DAPR_API_TOKEN."
1918
+ },
1785
1919
  "maxClientsPerUser": {
1786
1920
  "type": "number",
1787
1921
  "description": "Max clients per user (default: 10)"
1788
1922
  },
1923
+ "maxTopicsPerClient": {
1924
+ "type": "number",
1925
+ "description": "Max distinct topics a single client may subscribe to (default: 64). Bounds the in-memory subscription Set against an attacker-controlled `topics` array (memory-exhaustion DoS)."
1926
+ },
1789
1927
  "wsIdleTimeout": {
1790
1928
  "type": "number",
1791
1929
  "description": "WebSocket idle timeout in seconds (default: 120)"
@@ -1877,6 +2015,120 @@
1877
2015
  "items": {
1878
2016
  "type": "string"
1879
2017
  }
2018
+ },
2019
+ "transform": {
2020
+ "type": "object",
2021
+ "properties": {
2022
+ "enabled": {
2023
+ "type": "boolean",
2024
+ "description": "Enable on-the-fly image transforms. Default false (opt-in — needs `sharp`)."
2025
+ },
2026
+ "maxWidth": {
2027
+ "type": "number",
2028
+ "description": "Clamp requested width/height so a caller can't force a huge allocation. Defaults 3840."
2029
+ },
2030
+ "maxHeight": {
2031
+ "type": "number"
2032
+ },
2033
+ "defaultQuality": {
2034
+ "type": "number",
2035
+ "description": "Output quality (1-100) applied when `?q=` is omitted. Default 80."
2036
+ },
2037
+ "allowedFormats": {
2038
+ "type": "array",
2039
+ "items": {
2040
+ "type": "string",
2041
+ "enum": [
2042
+ "webp",
2043
+ "avif",
2044
+ "jpeg",
2045
+ "png"
2046
+ ]
2047
+ },
2048
+ "description": "Formats a client may request via `?format=`. When `?format=` is omitted the source format is kept (still resized/re-compressed). Default ['webp', 'avif', 'jpeg', 'png']."
2049
+ },
2050
+ "cacheSubdir": {
2051
+ "type": "string",
2052
+ "description": "Subdirectory under storage.basePath where generated derivatives are cached. Default '.cache/cdn'."
2053
+ },
2054
+ "maxInputPixels": {
2055
+ "type": "number",
2056
+ "description": "Max source pixels sharp will decode — decompression-bomb guard. Default 100000000 (100 MP)."
2057
+ },
2058
+ "pregenerate": {
2059
+ "type": "object",
2060
+ "properties": {
2061
+ "enabled": {
2062
+ "type": "boolean",
2063
+ "description": "Default false. Requires transform.enabled + `sharp`."
2064
+ },
2065
+ "widths": {
2066
+ "type": "array",
2067
+ "items": {
2068
+ "type": "number"
2069
+ },
2070
+ "description": "Widths to pre-render, e.g. [400, 800, 1200]. Default []."
2071
+ },
2072
+ "formats": {
2073
+ "type": "array",
2074
+ "items": {
2075
+ "type": "string",
2076
+ "enum": [
2077
+ "webp",
2078
+ "avif",
2079
+ "jpeg",
2080
+ "png"
2081
+ ]
2082
+ },
2083
+ "description": "Formats to pre-render, e.g. ['webp']. Default ['webp']."
2084
+ },
2085
+ "quality": {
2086
+ "type": "number",
2087
+ "description": "Encode quality for the pre-rendered variants. Default = transform.defaultQuality."
2088
+ }
2089
+ },
2090
+ "description": "On upload, PRE-GENERATE these image variants (into the same derivative cache the on-the-fly path reads) so the first request is already fast. Best-effort + non-blocking."
2091
+ }
2092
+ },
2093
+ "description": "On-the-fly IMAGE resize/compress/re-encode at the serve endpoint, so a frontend can point `<img>` / `next/image` (with its own optimizer disabled) at `/cdn/{id}?w=800&q=80&format=webp` and get a resized, compressed, browser-cached derivative. Requires the optional `sharp` peer dependency (`npm i sharp`); when enabled but sharp is missing, the CDN logs an error and serves the ORIGINAL bytes (never 500s). Only raster images are transformed — SVG (served as a download), video, and other types are streamed untouched."
2094
+ },
2095
+ "video": {
2096
+ "type": "object",
2097
+ "properties": {
2098
+ "enabled": {
2099
+ "type": "boolean",
2100
+ "description": "Default false. Requires an ffmpeg binary."
2101
+ },
2102
+ "transcode": {
2103
+ "type": "boolean",
2104
+ "description": "Transcode uploaded videos to a web-optimized MP4 (H.264 + +faststart). Default true when video.enabled."
2105
+ },
2106
+ "poster": {
2107
+ "type": "boolean",
2108
+ "description": "Extract a poster frame served at ?poster=1. Default true when video.enabled."
2109
+ },
2110
+ "posterFormat": {
2111
+ "type": "string",
2112
+ "enum": [
2113
+ "jpeg",
2114
+ "webp"
2115
+ ],
2116
+ "description": "Poster image format. Default 'jpeg'."
2117
+ },
2118
+ "crf": {
2119
+ "type": "number",
2120
+ "description": "H.264 CRF (0-51, lower = better/bigger). Default 23."
2121
+ },
2122
+ "maxWidth": {
2123
+ "type": "number",
2124
+ "description": "Downscale the transcoded video so its width never exceeds this. Default 1920."
2125
+ },
2126
+ "ffmpegPath": {
2127
+ "type": "string",
2128
+ "description": "ffmpeg executable (name on PATH or absolute path). Default 'ffmpeg'."
2129
+ }
2130
+ },
2131
+ "description": "VIDEO optimization on upload (opt-in; requires an `ffmpeg` binary on PATH). On upload of a video the CDN spawns ffmpeg (fire-and-forget) to produce a web-optimized MP4 (H.264 + faststart, optionally downscaled) and/or a poster frame, cached beside the image derivatives. The transcoded MP4 is then served at `/cdn/{id}` (range-streamed) once ready; until then the ORIGINAL is served. The poster is served at `/cdn/{id}?poster=1`. Per-request transcoding is NOT done (too heavy) — only on upload."
1880
2132
  }
1881
2133
  }
1882
2134
  },