@bhooai/nexus-core 2.0.17 → 2.0.19

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.
@@ -47,6 +47,23 @@ export async function loadConfig(opts: LoadOptions = {}): Promise<NexusConfig> {
47
47
  merged = deepMerge(merged, defaultConfig);
48
48
  }
49
49
 
50
+ // 1c. project identity — resolve `app.name` from every higher layer, then
51
+ // derive auth identity, redis prefix and mail `from` from it. This layer
52
+ // sits above the framework default but below the user config, so an
53
+ // explicit value anywhere in nexus.config.ts / runtime.json / env still
54
+ // wins while a project only has to declare its name once.
55
+ const runtimePath = opts.runtimePath ?? resolve(root, 'nexus.runtime.json');
56
+ const runtime = readRuntimeObject(runtimePath);
57
+ const envLayer = configFromEnv(env);
58
+ const appName = resolveAppName({
59
+ userConfig: opts.userConfig,
60
+ runtime,
61
+ env: envLayer,
62
+ cli: opts.cli,
63
+ root,
64
+ });
65
+ merged = deepMerge(merged, deriveAppIdentity(appName));
66
+
50
67
  // 2. user config (nexus.config.ts / nexus.config.js)
51
68
  let userConfig = opts.userConfig;
52
69
  if (!userConfig && opts.userConfigPath) {
@@ -55,18 +72,10 @@ export async function loadConfig(opts: LoadOptions = {}): Promise<NexusConfig> {
55
72
  if (userConfig) merged = deepMerge(merged, userConfig);
56
73
 
57
74
  // 3. runtime.json (admin write-back, gitignored)
58
- const runtimePath = opts.runtimePath ?? resolve(root, 'nexus.runtime.json');
59
- if (existsSync(runtimePath)) {
60
- try {
61
- const runtime = JSON.parse(readFileSync(runtimePath, 'utf8')) as DeepPartial<NexusConfig>;
62
- merged = deepMerge(merged, runtime);
63
- } catch {
64
- // A corrupt runtime file must not crash boot; ignore it.
65
- }
66
- }
75
+ if (runtime) merged = deepMerge(merged, runtime);
67
76
 
68
77
  // 4. env
69
- merged = deepMerge(merged, configFromEnv(env));
78
+ merged = deepMerge(merged, envLayer);
70
79
 
71
80
  // 5. cli flags (highest)
72
81
  if (opts.cli) merged = deepMerge(merged, opts.cli);
@@ -76,6 +85,65 @@ export async function loadConfig(opts: LoadOptions = {}): Promise<NexusConfig> {
76
85
  return Object.freeze(parsed) as NexusConfig;
77
86
  }
78
87
 
88
+ /** Read nexus.runtime.json (DeepPartial) or an empty object when absent/corrupt. */
89
+ function readRuntimeObject(runtimePath: string): DeepPartial<NexusConfig> {
90
+ if (!existsSync(runtimePath)) return {};
91
+ try {
92
+ return JSON.parse(readFileSync(runtimePath, 'utf8')) as DeepPartial<NexusConfig>;
93
+ } catch {
94
+ // A corrupt runtime file must not crash boot; ignore it.
95
+ return {};
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Resolve the project slug used to derive identity/namespaces: an explicit
101
+ * `app.name` from the highest-precedence layer wins, else package.json `name`
102
+ * (scope stripped), else the project folder name.
103
+ */
104
+ function resolveAppName(layers: {
105
+ userConfig?: UserNexusConfig;
106
+ runtime?: DeepPartial<NexusConfig>;
107
+ env?: DeepPartial<NexusConfig>;
108
+ cli?: DeepPartial<NexusConfig>;
109
+ root: string;
110
+ }): string {
111
+ const explicit =
112
+ layers.cli?.app?.name ??
113
+ layers.env?.app?.name ??
114
+ layers.runtime?.app?.name ??
115
+ layers.userConfig?.app?.name;
116
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
117
+
118
+ try {
119
+ const pkg = JSON.parse(readFileSync(join(layers.root, 'package.json'), 'utf8')) as { name?: string };
120
+ if (pkg.name) return pkg.name.replace(/^@[^/]+\//, '').trim();
121
+ } catch {
122
+ // no package.json
123
+ }
124
+ return layers.root.split(/[\\/]/).filter(Boolean).pop() ?? 'nexus';
125
+ }
126
+
127
+ /**
128
+ * Derive the per-project identity fields from `app.name`. Values are only
129
+ * applied as a base layer — an explicit value in a higher config layer wins
130
+ * (including `NEXUS_DB_URI`, which maps to `db.mongodb.uri` via the legacy
131
+ * flat shape).
132
+ */
133
+ function deriveAppIdentity(appName: string): DeepPartial<NexusConfig> {
134
+ return {
135
+ app: { name: appName },
136
+ auth: {
137
+ jwt: { issuer: appName, audience: `${appName}-client` },
138
+ cookieName: `${appName}_sid`,
139
+ refreshCookieName: `${appName}_rid`,
140
+ },
141
+ redis: { keyPrefix: `${appName}:` },
142
+ db: { mongodb: { uri: `mongodb://localhost:27017/${appName}` } },
143
+ email: { from: `no-reply@${appName}.local` },
144
+ };
145
+ }
146
+
79
147
  async function importUserConfig(absPath: string): Promise<UserNexusConfig> {
80
148
  const url = pathToFileURL(absPath).href;
81
149
  const mod = (await import(url)) as Record<string, unknown>;
@@ -155,7 +223,13 @@ export async function loadConfigAuto(
155
223
  });
156
224
  }
157
225
 
158
- /** Read-only accessor for tests / bootstrap that don't need file loading. */
226
+ /**
227
+ * Read-only accessor for tests / bootstrap that don't need file loading.
228
+ * When any source declares `app.name`, the derived identity fields are applied
229
+ * as a base layer (an explicit value in a later source still wins).
230
+ */
159
231
  export function mergeConfig(...sources: DeepPartial<NexusConfig>[]): NexusConfig {
160
- return Object.freeze(nexusConfigSchema.parse(deepMerge(defaults, ...sources))) as NexusConfig;
232
+ const explicitName = [...sources].reverse().find((s) => s?.app?.name)?.app?.name;
233
+ const base = explicitName ? deepMerge(defaults, deriveAppIdentity(explicitName)) : defaults;
234
+ return Object.freeze(nexusConfigSchema.parse(deepMerge(base, ...sources))) as NexusConfig;
161
235
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Process-wide accessor for the loaded config.
3
+ *
4
+ * `createNexusApp()` registers the merged config here at boot so code paths
5
+ * that are not handed a `ctx` (subgraphs, services, listeners, jobs) can read
6
+ * the same identity/namespace values instead of hardcoding them.
7
+ *
8
+ * This is a convenience fallback, not a replacement for DI — prefer the
9
+ * `ctx.config` / resolver-context value when a request context is available.
10
+ */
11
+ import type { NexusConfig } from './types.js';
12
+
13
+ let current: NexusConfig | null = null;
14
+
15
+ /** Register the config for this process (called by createNexusApp at boot). */
16
+ export function setNexusConfig(config: NexusConfig): void {
17
+ current = config;
18
+ }
19
+
20
+ /** The config registered for this process, or null before boot. */
21
+ export function getNexusConfig(): NexusConfig | null {
22
+ return current;
23
+ }
24
+
25
+ /** Reset the process config (tests / hot reload). */
26
+ export function resetNexusConfig(): void {
27
+ current = null;
28
+ }
@@ -24,3 +24,24 @@ export function fusionEnabled(config: NexusConfig): boolean {
24
24
  export function fusionDir(config: NexusConfig): string {
25
25
  return config.db.fusion.dir;
26
26
  }
27
+
28
+ /** True when the backend delegates to a standalone Fusion server over HTTP
29
+ * (no in-process engine). Triggered by `db.fusion.remote` or a set `url`. */
30
+ export function fusionRemote(config: NexusConfig): boolean {
31
+ return config.db.fusion.remote === true || !!config.db.fusion.url;
32
+ }
33
+
34
+ /** Standalone Fusion server base URL (empty when embedded). */
35
+ export function fusionUrl(config: NexusConfig): string {
36
+ return config.db.fusion.url ?? '';
37
+ }
38
+
39
+ /** Which backend serves cache duties — 'fusion' (default) or 'redis'. */
40
+ export function cacheActive(config: NexusConfig): 'fusion' | 'redis' {
41
+ return config.cache?.active ?? 'fusion';
42
+ }
43
+
44
+ /** True when the app uses an external Redis server for cache. */
45
+ export function redisCacheEnabled(config: NexusConfig): boolean {
46
+ return cacheActive(config) === 'redis';
47
+ }
@@ -6,6 +6,11 @@ import type { NexusConfig } from './types.js';
6
6
  */
7
7
  export const defaults: NexusConfig = {
8
8
  env: 'development',
9
+ app: {
10
+ // Overridden by ConfigLoader with the project slug (package.json name or
11
+ // folder name) when not set explicitly in nexus.config.ts.
12
+ name: 'nexus',
13
+ },
9
14
  server: {
10
15
  port: 4000,
11
16
  host: 'localhost',
@@ -21,7 +26,7 @@ export const defaults: NexusConfig = {
21
26
  allowedTypes: [],
22
27
  },
23
28
  db: {
24
- active: 'mongodb',
29
+ active: 'fusion',
25
30
  mongodb: {
26
31
  enabled: true,
27
32
  uri: 'mongodb://localhost:27017/nexus',
@@ -29,7 +34,9 @@ export const defaults: NexusConfig = {
29
34
  autoIndex: true,
30
35
  },
31
36
  fusion: {
32
- enabled: false,
37
+ enabled: true,
38
+ remote: false,
39
+ url: '',
33
40
  dir: 'storage/fusion',
34
41
  persist: true,
35
42
  cacheCapacity: 10_000,
@@ -87,11 +94,10 @@ export const defaults: NexusConfig = {
87
94
  },
88
95
  ads: {
89
96
  enabled: false,
90
- developerToken: '',
97
+ publisherId: '',
91
98
  clientId: '',
92
99
  clientSecret: '',
93
100
  refreshToken: '',
94
- customerId: '',
95
101
  },
96
102
  webrtc: {
97
103
  rtcMinPort: 40000,
@@ -131,7 +137,7 @@ export const defaults: NexusConfig = {
131
137
  level: 'info',
132
138
  format: 'pretty',
133
139
  console: true,
134
- dir: 'logs',
140
+ dir: 'storage/logs',
135
141
  maxFileSize: 10 * 1024 * 1024, // 10 MiB
136
142
  maxFiles: 7,
137
143
  },
@@ -149,14 +155,8 @@ export const defaults: NexusConfig = {
149
155
  host: 'localhost',
150
156
  enabled: true,
151
157
  },
152
- fusion: {
153
- dir: 'storage/fusion',
154
- persist: true,
155
- cacheCapacity: 10_000,
156
- cacheTtlMs: 5_000,
157
- walCompactThreshold: 1_000,
158
- },
159
158
  cache: {
159
+ active: 'fusion',
160
160
  dir: 'storage/fusion/cache',
161
161
  persist: true,
162
162
  cacheCapacity: 10_000,
package/src/config/env.ts CHANGED
@@ -50,6 +50,26 @@ function coerce(value: string): string | number | boolean {
50
50
  return v;
51
51
  }
52
52
 
53
+ /**
54
+ * Runtime-only `NEXUS_*` variables that are NOT config keys.
55
+ *
56
+ * These are process controls injected by the CLI / supervisor (`nexus dev` sets
57
+ * NEXUS_PORT and NEXUS_PORT_STEP on every child) or read directly by the
58
+ * framework at boot. Mapping them into the config tree corrupts it — e.g.
59
+ * NEXUS_PORT creates `{ port: 4000 }` and NEXUS_PORT_STEP then tries to write
60
+ * `port.step` onto that number, throwing `Cannot create property 'step' on
61
+ * number` and killing backend boot whenever ports step up.
62
+ */
63
+ const RUNTIME_ENV_VARS = new Set([
64
+ 'NEXUS_PORT', // per-service resolved port (read by createNexusApp)
65
+ 'NEXUS_PORT_STEP', // coordinated step-up delta (read by the CLI)
66
+ 'NEXUS_HOST', // bind host injected by the supervisor
67
+ 'NEXUS_PROJECT_ROOT', // license/config resolution root injected by the CLI
68
+ 'NEXUS_TUI_NO_MOUSE', // dev panel control
69
+ 'NEXUS_TUI_DEBUG', // dev panel control
70
+ 'NEXUS_AI_FIX', // `nexus dev --ai-fix` toggle
71
+ ]);
72
+
53
73
  /**
54
74
  * Two `_`-separated env segments that form ONE camelCase config field.
55
75
  * Generic splitting would turn `KEY_ID` into `key.id` (a nested object) —
@@ -69,6 +89,8 @@ const PAIR_FIELDS: Record<string, string> = {
69
89
  'webhook_secret': 'webhookSecret',
70
90
  'customer_id': 'customerId',
71
91
  'developer_token': 'developerToken',
92
+ 'publisher_id': 'publisherId',
93
+ 'account_id': 'accountId',
72
94
  'refresh_token': 'refreshToken',
73
95
  'access_ttl': 'accessTtl',
74
96
  'refresh_ttl': 'refreshTtl',
@@ -91,6 +113,7 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): DeepPartial
91
113
  const out: Record<string, unknown> = {};
92
114
  for (const [rawKey, rawValue] of Object.entries(env)) {
93
115
  if (!rawKey.startsWith('NEXUS_') || rawValue === undefined) continue;
116
+ if (RUNTIME_ENV_VARS.has(rawKey)) continue;
94
117
  const path = rawKey.slice('NEXUS_'.length).toLowerCase().split('_');
95
118
  const last = path.length - 1;
96
119
  if (last >= 1) {
@@ -100,15 +123,37 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): DeepPartial
100
123
  }
101
124
  }
102
125
  let node: Record<string, unknown> = out;
126
+ let conflict = false;
103
127
  for (let i = 0; i < path.length; i++) {
104
128
  const segment = path[i]!;
105
129
  if (i === path.length - 1) {
106
130
  node[segment] = coerce(rawValue);
107
131
  } else {
108
- node[segment] = (node[segment] as Record<string, unknown>) ?? {};
109
- node = node[segment] as Record<string, unknown>;
132
+ const existing = node[segment];
133
+ // A scalar here means two env vars disagree about the shape (e.g.
134
+ // NEXUS_SERVER_PORT=4000 plus NEXUS_SERVER_PORT_X). Overwriting would
135
+ // either crash (writing a property onto a primitive) or silently drop
136
+ // config, so skip the offending var and keep the first value.
137
+ if (existing !== undefined && (typeof existing !== 'object' || existing === null)) {
138
+ conflict = true;
139
+ break;
140
+ }
141
+ const child = existing ?? {};
142
+ node[segment] = child;
143
+ node = child as Record<string, unknown>;
110
144
  }
111
145
  }
146
+ if (conflict && !isKnownRuntimeVar(rawKey)) {
147
+ // Real config vars would silently vanish — make that visible.
148
+ console.warn(
149
+ `[config] ignoring ${rawKey}: its path collides with a scalar already set by another NEXUS_* var`,
150
+ );
151
+ }
112
152
  }
113
153
  return out as DeepPartial<NexusConfig>;
154
+ }
155
+
156
+ /** True for vars the framework consumes at runtime rather than as config. */
157
+ function isKnownRuntimeVar(key: string): boolean {
158
+ return RUNTIME_ENV_VARS.has(key);
114
159
  }
@@ -4,4 +4,5 @@ export * from './merge.js';
4
4
  export * from './env.js';
5
5
  export * from './schema.js';
6
6
  export * from './dbAccess.js';
7
- export * from './ConfigLoader.js';
7
+ export * from './ConfigLoader.js';
8
+ export * from './current.js';
@@ -11,6 +11,9 @@ const providerConfig = z
11
11
 
12
12
  export const nexusConfigSchema = z.object({
13
13
  env: z.enum(['development', 'production', 'test']).default('development'),
14
+ app: z.object({
15
+ name: z.string().min(1).default('nexus'),
16
+ }).default({ name: 'nexus' }),
14
17
  server: z.object({
15
18
  port: z.number().int().min(1).max(65535),
16
19
  host: z.string().min(1),
@@ -29,7 +32,7 @@ export const nexusConfigSchema = z.object({
29
32
  }),
30
33
  db: z
31
34
  .object({
32
- active: z.enum(['mongodb', 'fusion']).default('mongodb'),
35
+ active: z.enum(['mongodb', 'fusion']).default('fusion'),
33
36
  mongodb: z
34
37
  .object({
35
38
  enabled: z.boolean().default(true),
@@ -46,7 +49,9 @@ export const nexusConfigSchema = z.object({
46
49
  }),
47
50
  fusion: z
48
51
  .object({
49
- enabled: z.boolean().default(false),
52
+ enabled: z.boolean().default(true),
53
+ remote: z.boolean().default(false),
54
+ url: z.string().default(''),
50
55
  dir: z.string().min(1),
51
56
  persist: z.boolean().default(true),
52
57
  cacheCapacity: z.number().int().positive(),
@@ -54,7 +59,9 @@ export const nexusConfigSchema = z.object({
54
59
  walCompactThreshold: z.number().int().positive(),
55
60
  })
56
61
  .default({
57
- enabled: false,
62
+ enabled: true,
63
+ remote: false,
64
+ url: '',
58
65
  dir: 'storage/fusion',
59
66
  persist: true,
60
67
  cacheCapacity: 10_000,
@@ -102,18 +109,21 @@ export const nexusConfigSchema = z.object({
102
109
  }),
103
110
  cookieName: z.string(),
104
111
  refreshCookieName: z.string(),
112
+ // NOTE: clientId/clientSecret use z.coerce.string() — env coercion turns
113
+ // all-digit values (e.g. numeric Meta App IDs) into numbers, and OAuth
114
+ // credential fields must accept them rather than crash the boot.
105
115
  google: z
106
116
  .object({
107
- clientId: z.string(),
108
- clientSecret: z.string(),
117
+ clientId: z.coerce.string(),
118
+ clientSecret: z.coerce.string(),
109
119
  callbackPath: z.string(),
110
120
  scope: z.string(),
111
121
  })
112
122
  .optional(),
113
123
  facebook: z
114
124
  .object({
115
- clientId: z.string(),
116
- clientSecret: z.string(),
125
+ clientId: z.coerce.string(),
126
+ clientSecret: z.coerce.string(),
117
127
  callbackPath: z.string(),
118
128
  scope: z.string(),
119
129
  })
@@ -151,11 +161,11 @@ export const nexusConfigSchema = z.object({
151
161
  }),
152
162
  ads: z.object({
153
163
  enabled: z.boolean(),
154
- developerToken: z.string(),
164
+ publisherId: z.string(),
155
165
  clientId: z.string(),
156
166
  clientSecret: z.string(),
157
167
  refreshToken: z.string(),
158
- customerId: z.string(),
168
+ accountId: z.string().optional(),
159
169
  }),
160
170
  webrtc: z.object({
161
171
  rtcMinPort: z.number().int().min(1).max(65535),
@@ -201,14 +211,8 @@ export const nexusConfigSchema = z.object({
201
211
  host: z.string().min(1),
202
212
  enabled: z.boolean(),
203
213
  }),
204
- fusion: z.object({
205
- dir: z.string().min(1).default('storage/fusion'),
206
- persist: z.boolean().default(true),
207
- cacheCapacity: z.number().int().positive().default(10_000),
208
- cacheTtlMs: z.number().int().positive().default(5_000),
209
- walCompactThreshold: z.number().int().positive().default(1_000),
210
- }),
211
214
  cache: z.object({
215
+ active: z.enum(['fusion', 'redis']).default('fusion'),
212
216
  dir: z.string().min(1).default('storage/fusion/cache'),
213
217
  persist: z.boolean().default(true),
214
218
  cacheCapacity: z.number().int().positive().default(10_000),
@@ -8,6 +8,16 @@
8
8
 
9
9
  export type Env = 'development' | 'production' | 'test';
10
10
 
11
+ /**
12
+ * Project identity. `app.name` is the single source of truth for the project
13
+ * slug; cookie names, JWT issuer/audience, redis key prefix and mail `from`
14
+ * are derived from it when not set explicitly.
15
+ */
16
+ export interface AppConfig {
17
+ /** Project slug, e.g. 'nexus-bhooai-com'. Derives auth identity + namespaces. */
18
+ name: string;
19
+ }
20
+
11
21
  export interface ServerConfig {
12
22
  port: number;
13
23
  host: string;
@@ -23,7 +33,8 @@ export interface ServerConfig {
23
33
  }
24
34
 
25
35
  export interface UploadsConfig {
26
- /** Directory for persisted files, relative to the project root. */
36
+ /** Directory for persisted files, relative to the storage root
37
+ * (`createNexusApp`'s `storageRoot`, default = project root). */
27
38
  dir: string;
28
39
  /** Public URL path for upload and download requests. */
29
40
  path: string;
@@ -50,8 +61,19 @@ export interface MongoDbConfig {
50
61
  }
51
62
 
52
63
  export interface FusionDbConfig {
53
- /** Open the Fusion engine at boot when true. Default false. */
64
+ /** Open the Fusion engine at boot when true. Default true. */
54
65
  enabled: boolean;
66
+ /**
67
+ * Delegate to a **standalone Fusion server** instead of opening the engine
68
+ * in-process. When true (or when `url` is non-empty) the backend no longer
69
+ * owns `storage/fusion/fusion.wal`; every read/write goes over HTTP to the
70
+ * `fusion` service started by `nexus dev`. This lets several apps share one
71
+ * Fusion database and keeps the backend process free of the Rust engine.
72
+ */
73
+ remote: boolean;
74
+ /** Standalone Fusion server base URL, e.g. `http://127.0.0.1:5000`.
75
+ * Empty (and `remote: false`) keeps the embedded in-process engine. */
76
+ url: string;
55
77
  /** Durable storage dir, relative to the project root. Omit/empty for in-memory. */
56
78
  dir: string;
57
79
  /** Persist the WAL to `dir` (persist=false runs purely in-memory). */
@@ -65,7 +87,7 @@ export interface FusionDbConfig {
65
87
  }
66
88
 
67
89
  export interface DbConfig {
68
- /** Which database backs the user store + ODM models. Default 'mongodb'. */
90
+ /** Which database backs the user store + ODM models. Default 'fusion'. */
69
91
  active: ActiveDatabase;
70
92
  mongodb: MongoDbConfig;
71
93
  fusion: FusionDbConfig;
@@ -165,11 +187,11 @@ export interface CertsConfig {
165
187
 
166
188
  export interface AdsConfig {
167
189
  enabled: boolean;
168
- developerToken: string;
190
+ publisherId: string;
169
191
  clientId: string;
170
192
  clientSecret: string;
171
193
  refreshToken: string;
172
- customerId: string;
194
+ accountId?: string;
173
195
  }
174
196
 
175
197
  export interface WebRtcConfig {
@@ -303,22 +325,14 @@ export interface AppPortConfig {
303
325
  enabled?: boolean;
304
326
  }
305
327
 
306
- export interface FusionConfig {
307
- /** Durable storage dir, resolved against the backend's projectRoot. Empty or
308
- * persist=false runs the engine purely in-memory. Default 'storage/fusion'. */
309
- dir: string;
310
- /** When false, ignore `dir` and run purely in-memory. Default true. */
311
- persist: boolean;
312
- /** L1 cache capacity (entries). Default 10_000. */
313
- cacheCapacity: number;
314
- /** L1 cache TTL in ms. Default 5_000. */
315
- cacheTtlMs: number;
316
- /** WAL compaction threshold (records). Default 1_000. */
317
- walCompactThreshold: number;
318
- }
319
-
320
328
  /** Redis-like FusionCache settings (`@bhooai/nexus-fusion/cache`). */
321
329
  export interface CacheConfig {
330
+ /** Which backend serves cache duties — 'fusion' (embedded, zero-dep,
331
+ * default) or 'redis' (external server at `redis.url`). Override with
332
+ * NEXUS_CACHE_ACTIVE. Single-instance apps are fully served by fusion
333
+ * (strings/hashes/lists/sets + TTL); pick 'redis' for multi-instance
334
+ * pub/sub fan-out. */
335
+ active: 'fusion' | 'redis';
322
336
  /** Durable storage dir, resolved against the backend's projectRoot. Cache
323
337
  * lives in its own subdir (default 'storage/fusion/cache') so it never
324
338
  * shares a WAL with the main fusion engine. */
@@ -339,6 +353,7 @@ export interface CacheConfig {
339
353
 
340
354
  export interface NexusConfig {
341
355
  env: Env;
356
+ app: AppConfig;
342
357
  server: ServerConfig;
343
358
  uploads: UploadsConfig;
344
359
  db: DbConfig;
@@ -356,7 +371,6 @@ export interface NexusConfig {
356
371
  plugins: PluginsConfig;
357
372
  frontend: FrontendConfig;
358
373
  admin: AdminConfig;
359
- fusion: FusionConfig;
360
374
  cache: CacheConfig;
361
375
  /** Multi-app port layout. Empty = every service uses its section default. */
362
376
  apps: AppPortConfig[];
@@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto';
5
5
  import type { Middleware, RequestContext } from './context.js';
6
6
  import { createContext } from './context.js';
7
7
  import type { Router } from './Router.js';
8
+ import type { NexusConfig } from '../config/types.js';
8
9
  import { NexusError, toNexusError } from '../errors.js';
9
10
 
10
11
  /** Generate a request id (overridable for tests/injection). */
@@ -33,6 +34,8 @@ export interface ServerOptions {
33
34
  renderError?: (err: unknown, ctx: RequestContext) => Promise<void> | void;
34
35
  /** Custom 404 renderer for unmatched routes. Falls back to JSON. */
35
36
  renderNotFound?: (ctx: RequestContext) => Promise<void> | void;
37
+ /** Loaded project config, exposed to handlers as `ctx.config`. */
38
+ config?: NexusConfig;
36
39
  }
37
40
 
38
41
  /**
@@ -87,6 +90,7 @@ export class NexusServer {
87
90
  const requestId = (req.headers['x-request-id'] as string) ?? newRequestId();
88
91
  res.setHeader('x-request-id', requestId);
89
92
  const ctx = createContext(req, res, requestId);
93
+ ctx.config = this.opts.config ?? null;
90
94
 
91
95
  try {
92
96
  await this.runPipeline(ctx);
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { Socket } from 'node:net';
3
+ import type { NexusConfig } from '../config/types.js';
3
4
 
4
5
  /** Route path parameters extracted from the URL. */
5
6
  export type Params = Record<string, string>;
@@ -26,6 +27,11 @@ export interface RequestContext {
26
27
  body: unknown;
27
28
  /** Per-request state. */
28
29
  state: State;
30
+ /**
31
+ * The loaded project config. Injected by NexusServer at request time; may be
32
+ * null when a context is built standalone (e.g. in tests).
33
+ */
34
+ config?: NexusConfig | null;
29
35
  /** Request id (also in headers as x-request-id). */
30
36
  requestId: string;
31
37
  /** The matched route pattern, e.g. "/users/:id". */
@@ -25,6 +25,57 @@ describe('configFromEnv', () => {
25
25
  expect((cfg as any).ai?.serverUrl).toBe('http://ai:8000');
26
26
  expect((cfg as any).ai?.server?.url).toBeUndefined();
27
27
  });
28
+
29
+ it('accepts a numeric Meta App ID for auth.facebook.clientId', () => {
30
+ // Env coercion turns all-digit values into numbers; the schema must
31
+ // coerce them back so boot does not crash (ZodError on auth.facebook.clientId).
32
+ const partial = configFromEnv({ NEXUS_AUTH_FACEBOOK_CLIENT_ID: '123456789012345' });
33
+ expect((partial as any).auth?.facebook?.clientId).toBe(123456789012345);
34
+ const cfg = mergeConfig({
35
+ auth: {
36
+ facebook: {
37
+ clientId: (partial as any).auth.facebook.clientId,
38
+ clientSecret: 's3cret',
39
+ callbackPath: '/auth/facebook/callback',
40
+ scope: 'email',
41
+ },
42
+ },
43
+ });
44
+ expect(cfg.auth.facebook?.clientId).toBe('123456789012345');
45
+ });
46
+ });
47
+
48
+ describe('app identity derivation', () => {
49
+ it('derives auth/redis/mongo/email identity from app.name', () => {
50
+ const cfg = mergeConfig({ app: { name: 'my-app' } });
51
+ expect(cfg.app.name).toBe('my-app');
52
+ expect(cfg.auth.jwt.issuer).toBe('my-app');
53
+ expect(cfg.auth.jwt.audience).toBe('my-app-client');
54
+ expect(cfg.auth.cookieName).toBe('my-app_sid');
55
+ expect(cfg.auth.refreshCookieName).toBe('my-app_rid');
56
+ expect(cfg.redis.keyPrefix).toBe('my-app:');
57
+ expect(cfg.db.mongodb.uri).toBe('mongodb://localhost:27017/my-app');
58
+ expect(cfg.email.from).toBe('no-reply@my-app.local');
59
+ });
60
+
61
+ it('lets an explicit mongo uri override the derived one', () => {
62
+ const cfg = mergeConfig({
63
+ app: { name: 'my-app' },
64
+ db: { mongodb: { uri: 'mongodb://remote:27017/custom' } },
65
+ });
66
+ expect(cfg.db.mongodb.uri).toBe('mongodb://remote:27017/custom');
67
+ });
68
+
69
+ it('lets an explicit value override the derived default', () => {
70
+ const cfg = mergeConfig({ app: { name: 'my-app' }, auth: { cookieName: 'custom_sid' } });
71
+ expect(cfg.auth.cookieName).toBe('custom_sid');
72
+ expect(cfg.auth.jwt.issuer).toBe('my-app');
73
+ });
74
+
75
+ it('defaults app.name to nexus when unset', () => {
76
+ const cfg = mergeConfig({});
77
+ expect(cfg.app.name).toBe('nexus');
78
+ });
28
79
  });
29
80
 
30
81
  describe('mergeConfig', () => {