bhooai-nexus 2.0.11 → 2.0.13

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.
Files changed (124) hide show
  1. package/docs/ARCHITECTURE.md +3 -1
  2. package/docs/CONVENTIONS.md +60 -10
  3. package/package.json +4 -1
  4. package/packages/nexus-admin/package.json +1 -1
  5. package/packages/nexus-admin/src/App.tsx +128 -3
  6. package/packages/nexus-ads/package.json +1 -1
  7. package/packages/nexus-ai-client/package.json +1 -1
  8. package/packages/nexus-auth/package.json +1 -1
  9. package/packages/nexus-auth/src/csrf.ts +23 -0
  10. package/packages/nexus-cache/package.json +1 -1
  11. package/packages/nexus-cli/package.json +1 -1
  12. package/packages/nexus-cli/src/commands/add.ts +1 -0
  13. package/packages/nexus-cli/src/commands/init.ts +96 -51
  14. package/packages/nexus-cli/src/devPanel.ts +275 -121
  15. package/packages/nexus-cli/src/devServiceManager.ts +26 -12
  16. package/packages/nexus-cli/src/launcher.ts +11 -10
  17. package/packages/nexus-cli/src/layout.ts +45 -5
  18. package/packages/nexus-cli/src/make/generators.ts +125 -9
  19. package/packages/nexus-cli/src/tui.ts +23 -1
  20. package/packages/nexus-cli/src/wizard.ts +32 -35
  21. package/packages/nexus-cli/templates/base/apps/admin/nginx.conf.ejs +5 -0
  22. package/packages/nexus-cli/templates/base/apps/admin/vite.config.ts.ejs +4 -0
  23. package/packages/nexus-cli/templates/base/apps/frontend/nginx.conf.ejs +5 -0
  24. package/packages/nexus-cli/templates/base/apps/frontend/vite.config.ts.ejs +27 -1
  25. package/packages/nexus-cli/templates/base/nexus.config.ts.ejs +19 -16
  26. package/packages/nexus-core/package.json +1 -1
  27. package/packages/nexus-core/src/app/createNexusApp.ts +224 -21
  28. package/packages/nexus-core/src/app/discover.ts +39 -8
  29. package/packages/nexus-core/src/config/defaults.ts +4 -0
  30. package/packages/nexus-core/src/config/runtimeJson.ts +6 -0
  31. package/packages/nexus-core/src/config/schema.ts +4 -0
  32. package/packages/nexus-core/src/config/types.ts +8 -0
  33. package/packages/nexus-crypto/package.json +1 -1
  34. package/packages/nexus-data/package.json +1 -1
  35. package/packages/nexus-email/package.json +1 -1
  36. package/packages/nexus-examples/examples/ai-chat/apps/backend/src/handlers/chat.handler.ts +27 -0
  37. package/packages/nexus-examples/examples/ai-chat/apps/backend/src/middleware/validateChat.ts +3 -0
  38. package/packages/nexus-examples/examples/ai-chat/apps/backend/src/routes/chat.ts +5 -38
  39. package/packages/nexus-examples/examples/ai-chat/apps/backend/src/types/ai.chat.types.ts +7 -0
  40. package/packages/nexus-examples/examples/blog-crud/README.md +8 -6
  41. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/graphql/app.graph.ts +89 -0
  42. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/handlers/blog.handler.ts +63 -0
  43. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/middleware/validatePost.ts +15 -0
  44. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/repositories/PostRepository.ts +20 -0
  45. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/services/PostService.ts +41 -0
  46. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/types/blog.author.types.ts +16 -0
  47. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/types/blog.comment.types.ts +15 -0
  48. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/types/blog.post.types.ts +39 -0
  49. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/validators/post.schema.ts +9 -0
  50. package/packages/nexus-examples/examples/blog-crud/apps/frontend/src/main.tsx +45 -16
  51. package/packages/nexus-examples/examples/chat/apps/backend/src/graphql/app.graph.ts +94 -0
  52. package/packages/nexus-examples/examples/chat/apps/backend/src/handlers/chat.handler.ts +22 -0
  53. package/packages/nexus-examples/examples/chat/apps/backend/src/middleware/validateMessage.ts +11 -0
  54. package/packages/nexus-examples/examples/chat/apps/backend/src/routes/index.ts +11 -0
  55. package/packages/nexus-examples/examples/chat/apps/backend/src/services/ChatService.ts +14 -0
  56. package/packages/nexus-examples/examples/chat/apps/backend/src/types/chat.message.types.ts +23 -0
  57. package/packages/nexus-examples/examples/chat/apps/backend/src/ws/chat.room.ts +18 -55
  58. package/packages/nexus-examples/examples/chat/apps/frontend/src/main.tsx +192 -70
  59. package/packages/nexus-examples/examples/checkout/apps/backend/src/graphql/app.graph.ts +90 -0
  60. package/packages/nexus-examples/examples/checkout/apps/backend/src/handlers/checkout.handler.ts +36 -0
  61. package/packages/nexus-examples/examples/checkout/apps/backend/src/main.ts +28 -0
  62. package/packages/nexus-examples/examples/checkout/apps/backend/src/middleware/validateOrder.ts +3 -0
  63. package/packages/nexus-examples/examples/checkout/apps/backend/src/routes/checkout.ts +5 -70
  64. package/packages/nexus-examples/examples/checkout/apps/backend/src/routes/index.ts +30 -0
  65. package/packages/nexus-examples/examples/checkout/apps/backend/src/types/checkout.order.types.ts +7 -0
  66. package/packages/nexus-examples/examples/checkout/apps/frontend/index.html +13 -0
  67. package/packages/nexus-examples/examples/checkout/apps/frontend/src/main.tsx +190 -18
  68. package/packages/nexus-examples/examples/dashboard/README.md +3 -2
  69. package/packages/nexus-examples/examples/dashboard/apps/backend/src/graphql/app.graph.ts +38 -0
  70. package/packages/nexus-examples/examples/dashboard/apps/backend/src/handlers/metrics.handler.ts +42 -0
  71. package/packages/nexus-examples/examples/dashboard/apps/backend/src/middleware/validateMetric.ts +3 -0
  72. package/packages/nexus-examples/examples/dashboard/apps/backend/src/routes/metrics.ts +6 -70
  73. package/packages/nexus-examples/examples/dashboard/apps/backend/src/types/dashboard.metric.types.ts +7 -0
  74. package/packages/nexus-examples/examples/dashboard/apps/frontend/src/main.tsx +8 -2
  75. package/packages/nexus-examples/examples/file-storage/README.md +4 -2
  76. package/packages/nexus-examples/examples/file-storage/apps/backend/src/graphql/app.graph.ts +46 -0
  77. package/packages/nexus-examples/examples/file-storage/apps/backend/src/handlers/file.handler.ts +45 -0
  78. package/packages/nexus-examples/examples/file-storage/apps/backend/src/middleware/validateFile.ts +3 -0
  79. package/packages/nexus-examples/examples/file-storage/apps/backend/src/routes/files.ts +8 -82
  80. package/packages/nexus-examples/examples/file-storage/apps/backend/src/types/file.file.types.ts +7 -0
  81. package/packages/nexus-examples/examples/file-storage/apps/frontend/src/main.tsx +24 -7
  82. package/packages/nexus-examples/examples/livestream/apps/backend/src/middleware/validateStream.ts +3 -0
  83. package/packages/nexus-examples/examples/livestream/apps/backend/src/types/live.stream.types.ts +7 -0
  84. package/packages/nexus-examples/examples/multi-app/apps/frontend-shop/src/main.tsx +2 -1
  85. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/events/TeamCreated.ts +1 -7
  86. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/events/UserRegistered.ts +1 -6
  87. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/graphql/app.graph.ts +110 -0
  88. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/handlers/project.handler.ts +32 -0
  89. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/handlers/team.handler.ts +75 -0
  90. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/listeners/OnTeamCreated.ts +3 -6
  91. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/listeners/OnUserRegistered.ts +4 -5
  92. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/middleware/auth.ts +2 -5
  93. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/models/Membership.ts +6 -9
  94. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/models/User.ts +0 -2
  95. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/policies/TeamPolicy.ts +0 -9
  96. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/services/TeamService.ts +19 -0
  97. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/types/saas.team.types.ts +10 -0
  98. package/packages/nexus-examples/examples/saas-starter/apps/frontend/src/main.tsx +18 -13
  99. package/packages/nexus-examples/examples/video-call/apps/backend/src/middleware/validateCall.ts +3 -0
  100. package/packages/nexus-examples/examples/video-call/apps/backend/src/types/video.call.types.ts +7 -0
  101. package/packages/nexus-examples/package.json +1 -1
  102. package/packages/nexus-graphql/package.json +6 -1
  103. package/packages/nexus-graphql/src/builtin/helloSubgraph.ts +38 -0
  104. package/packages/nexus-graphql/src/builtin/index.ts +3 -0
  105. package/packages/nexus-graphql/src/explorer/explorerHtml.ts +1642 -0
  106. package/packages/nexus-graphql/src/federation/createFederatedGateway.ts +6 -1
  107. package/packages/nexus-graphql/src/federation/executor.ts +81 -6
  108. package/packages/nexus-graphql/src/federation/parseMetadata.ts +13 -11
  109. package/packages/nexus-graphql/src/gateway/graphqlHttpHandler.ts +31 -4
  110. package/packages/nexus-graphql/src/index.ts +4 -1
  111. package/packages/nexus-graphql/src/mutationCsrf.ts +25 -0
  112. package/packages/nexus-payments/package.json +1 -1
  113. package/packages/nexus-payments/src/providers/PayUProvider.ts +1 -0
  114. package/packages/nexus-plugins/package.json +1 -1
  115. package/packages/nexus-postcss/package.json +1 -1
  116. package/packages/nexus-realtime/package.json +1 -1
  117. package/packages/nexus-safe-goto/package.json +1 -1
  118. package/packages/nexus-safe-goto/src/index.tsx +1 -1
  119. package/packages/nexus-telemetry/package.json +1 -1
  120. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/graphql/post.graph.ts +0 -99
  121. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/routes/posts.ts +0 -119
  122. package/packages/nexus-examples/examples/chat/apps/backend/src/routes/chat.ts +0 -27
  123. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/routes/projects.ts +0 -52
  124. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/routes/teams.ts +0 -112
@@ -60,7 +60,7 @@ the registry via `lookupPort()` at scaffold time and bake the port into
60
60
 
61
61
  1. `loadConfigAuto()` — config stack: code defaults < nexus.config.ts <
62
62
  nexus.runtime.json < NEXUS_* env vars < CLI flags
63
- 2. `discoverBackend(srcRoot)` — walks 14 folder patterns:
63
+ 2. `discoverBackend(srcRoot)` — walks 14 flat folder patterns:
64
64
 
65
65
  | Folder | Pattern | Used as |
66
66
  |-----------------|----------------------------------|---------|
@@ -81,6 +81,8 @@ the registry via `lookupPort()` at scaffold time and bake the port into
81
81
  | plugins/<n>/ | `index.ts` | Local plugins |
82
82
  | config/ | `*.ts` | Merged into app config |
83
83
 
84
+ Flat workflow with dot-namespaced types: `src/types/blog.post.types.ts`, `src/middleware/validatePost.ts`, `src/models/Post.ts`, `src/services/PostService.ts`. Example: `blog-crud` uses `src/types/blog.post.types.ts` + `src/middleware/validatePost.ts` + `src/routes/posts.ts` (thin → `PostService`). `discover` also still scans legacy `src/modules/*/…` via `collectWithModules` for backward compat.
85
+
84
86
  3. Configures cross-cutting services:
85
87
  - `configureStorageFromEnv()` — Storage facade with `local` + `s3` disks
86
88
  - `configureQueue()` — Job queue adapter (in-memory default, Redis-ready)
@@ -49,6 +49,26 @@ export const prefix = '/api/v2';
49
49
 
50
50
  Optional per-route fields: `middleware`, `upload`, `rateLimit`, `public`, `request`.
51
51
 
52
+ Routes are **thin** — handlers live in `src/handlers/`:
53
+
54
+ ```ts
55
+ // handlers/chat.handler.ts
56
+ import type { RequestContext } from '@bhooai/nexus-core';
57
+ import { Message } from '../models/Message.js';
58
+ export async function listMessages(ctx: RequestContext) { /* ... */ }
59
+ export async function sendMessage(ctx: RequestContext) { /* ... */ }
60
+
61
+ // routes/chat.ts
62
+ import { defineRoutes } from '@bhooai/nexus-core';
63
+ import { validateMessage } from '../middleware/validateMessage.js';
64
+ import { listMessages, sendMessage } from '../handlers/chat.handler.js';
65
+ export default defineRoutes([
66
+ { method:'GET', path:'/api/chat/rooms/:roomId/messages', handler: listMessages },
67
+ { method:'POST', path:'/api/chat/rooms/:roomId/messages', middleware:[validateMessage], handler: sendMessage },
68
+ ]);
69
+ ```
70
+ Generate with `nexus make:handler chat` → `src/handlers/chat.handler.ts` (`handlers/<name>.handler.ts` — not auto-discovered, imported by routes).
71
+
52
72
  ## graphql/
53
73
 
54
74
  Files matching `*.graph.ts` export typeDefs + resolvers:
@@ -155,21 +175,51 @@ Run with `nexus queue:work --queue mail`.
155
175
  `register(container)` wires DI singletons; `boot(container)` runs async init
156
176
  after all providers are registered.
157
177
 
158
- ## models/, services/, repositories/, controllers/, middleware/, validators/, requests/, resources/
178
+ ## handlers/ — route handlers (separate folder)
159
179
 
160
- These are NOT auto-wired — import them yourself from routes/jobs/listeners.
161
- They exist so your routes stay thin:
180
+ `src/handlers/<name>.handler.ts` exports plain `Handler` functions (`(ctx: RequestContext)=>...`). **Not auto-discovered** — routes import them. Prefer one file per domain (`chat.handler.ts` with `listMessages, sendMessage`, `blog.handler.ts` with `listPosts, createPost, ...`). Keeps `routes/` declarative (10–20 LOC) and testable.
162
181
 
163
182
  ```
164
- routes/users.ts → controllers/UserController.ts (route action)
165
- → services/UserService.ts (business rules)
166
- → repositories/UserRepository.ts (Mongo queries)
167
- → models/User.ts (ODM model)
168
- → validators/user.schema.ts (zod)
169
- → requests/CreateUserRequest.ts (zod + authorize)
170
- → resources/UserResource.ts (response shape)
183
+ src/handlers/
184
+ chat.handler.ts # listMessages, sendMessage
185
+ blog.handler.ts # listPosts, createPost, getPost, updatePost, deletePost, createComment
186
+ checkout.handler.ts # createOrder, webhook
187
+ file.handler.ts # listFiles, uploadPublic, …
171
188
  ```
172
189
 
190
+ ## models/, services/, repositories/, controllers/, middleware/, validators/, requests/, resources/, types/ — flat src/<folder> (canonical)
191
+
192
+ These are **not auto-wired** — import them yourself from `routes/handlers/jobs/listeners`. They exist so routes+handlers stay thin. Use **flat** `src/<folder>` with **dot-namespaced types** for domain separation (no extra `modules/` folder):
193
+
194
+ ```
195
+ src/
196
+ models/{Post.ts, User.ts, Team.ts} # ODM — model('posts', schema)
197
+ types/blog.post.types.ts # DTOs + Zod (PostDto, CreatePostInput) — dot namespaced
198
+ types/blog.author.types.ts
199
+ types/chat.message.types.ts
200
+ types/saas.team.types.ts
201
+ types/checkout.order.types.ts
202
+ routes/posts.ts # defineRoutes (thin → services)
203
+ services/PostService.ts # business logic
204
+ repositories/PostRepository.ts # Mongo queries (Db)
205
+ middleware/validatePost.ts # Middleware (ctx,next)
206
+ middleware/validateMessage.ts
207
+ middleware/auth.ts
208
+ validators/post.schema.ts # Zod
209
+ graphql/post.graph.ts # defineSubgraph (imports ../models/Post.js)
210
+ ws/chat.room.ts
211
+
212
+ routes/posts.ts → services/PostService.ts
213
+ → repositories/PostRepository.ts
214
+ → models/Post.ts
215
+ → types/blog.post.types.ts # `import { CreatePostInput } from '../types/blog.post.types.js'`
216
+ → validators/post.schema.ts
217
+ → middleware/validatePost.ts # `import { validatePost } from '../middleware/validatePost.js'`
218
+ → resources/PostResource.ts
219
+ ```
220
+
221
+ Generate with flat commands: `nexus make:model Post`, `nexus make:types blog.post`, `nexus make:middleware validatePost`, `nexus make:route posts`, `nexus make:service PostService`. No `--module` flag. Legacy `src/modules/*` is still discovered via `collectWithModules` (`discover.ts:115`) for backward compat but not recommended.
222
+
173
223
  ## database/
174
224
 
175
225
  - `seeds/*Seeder.ts` — classes extending `Seeder`. `nexus db:seed`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bhooai-nexus",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "BhooAI Nexus - a Node-based full-stack framework (websockets, GraphQL federation, inbuilt security, custom ODM, OAuth, payments, WebRTC, AI, plugins). v2: multi-app, Laravel-style folders, Docker-based scaling.",
5
5
  "license": "MIT",
6
6
  "author": "BhooAI",
@@ -56,6 +56,9 @@
56
56
  "./postcss": "./packages/nexus-postcss/src/index.js",
57
57
  "./postcss/theme.css": "./packages/nexus-postcss/src/theme.css",
58
58
  "./examples": "./packages/nexus-examples/src/index.ts",
59
+ "./graphql": "./packages/nexus-graphql/src/index.ts",
60
+ "./graphql/hello": "./packages/nexus-graphql/src/builtin/helloSubgraph.ts",
61
+ "./graphql/builtin": "./packages/nexus-graphql/src/builtin/index.ts",
59
62
  "./package.json": "./package.json"
60
63
  },
61
64
  "files": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-admin",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
4
4
  "description": "BhooAI Nexus v2 admin SPA — Apps-tab-first, live registry/health/logs/config/users/payments/AI",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@ import {
19
19
  } from './api.js';
20
20
 
21
21
 
22
- type Tab = 'overview' | 'processes' | 'logs' | 'config' | 'env' | 'plugins' | 'users' | 'monitoring' | 'payments' | 'databases' | 'schema' | 'ai' | 'theme';
22
+ type Tab = 'overview' | 'processes' | 'logs' | 'config' | 'env' | 'plugins' | 'users' | 'monitoring' | 'payments' | 'databases' | 'schema' | 'ai' | 'graphql' | 'theme';
23
23
  type AdminTheme = 'aurora' | 'midnight' | 'violet';
24
24
  type UiMode = 'workspace' | 'compact' | 'focus';
25
25
  type ToggleStyle = 'chip' | 'neon' | 'icon' | 'orb' | 'track';
@@ -459,6 +459,7 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
459
459
  { id: 'databases', label: 'Databases', icon: 'database', keywords: ['mongo', 'mongodb', 'collections', 'data', 'tables', 'docs'], desc: 'Databases, collections and their documents.' },
460
460
  { id: 'schema', label: 'AI Schema', icon: 'sparkles', keywords: ['generate', 'ai', 'model', 'json schema', 'validator'], desc: 'Generate MongoDB schemas from plain English.' },
461
461
  { id: 'ai', label: 'AI Agents', icon: 'bot', keywords: ['providers', 'chat', 'playground', 'ollama', 'openai', 'models', 'settings'], desc: 'AI settings, providers and chat playground.' },
462
+ { id: 'graphql', label: 'GraphQL', icon: 'gitFork', keywords: ['graphiql', 'explorer', 'schema', 'playground', 'graphql', 'api'], desc: 'GraphQL explorer and access control.' },
462
463
  { id: 'theme', label: 'Theme Centre', icon: 'palette', keywords: ['appearance', 'colors', 'palette', 'ui', 'customize', 'surface'], desc: 'Curated themes, palettes and surface tuning.' },
463
464
  ];
464
465
  const searchResults = useMemo(() => {
@@ -645,6 +646,7 @@ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; o
645
646
  {tab === 'databases' && <Databases />}
646
647
  {tab === 'schema' && <Schema />}
647
648
  {tab === 'ai' && <AiAgents toggle={customTheme.toggle} />}
649
+ {tab === 'graphql' && <GraphQLSettings onNavigate={setTab} />}
648
650
  {tab === 'theme' && <ThemeCentre theme={theme} onThemeChange={(value) => { setTheme(value); window.localStorage.setItem('nexus-admin-theme', value); }} uiMode={uiMode} onUiModeChange={(value) => { setUiMode(value); window.localStorage.setItem('nexus-admin-ui', value); }} custom={customTheme} onCustom={updateCustomTheme} particles={particles} onParticles={updateParticles} />}
649
651
  </main>
650
652
  <aside className="admin-inspector">
@@ -2173,7 +2175,7 @@ function AiProviders({ toggle }: { toggle: ToggleStyle }) {
2173
2175
  const [showAdd, setShowAdd] = useState(false);
2174
2176
  const [newProvider, setNewProvider] = useState({ id: '', label: '', baseUrl: '', apiKey: '', defaultModel: '' });
2175
2177
  const [query, setQuery] = useState('');
2176
- const [filter, setFilter] = useState<ProviderFilter>('enabled');
2178
+ const [filter, setFilter] = useState<ProviderFilter>('all');
2177
2179
  const [testingId, setTestingId] = useState<string | null>(null);
2178
2180
  const [testResults, setTestResults] = useState<Record<string, AiProviderTestResult>>({});
2179
2181
  const [keyEditorProvider, setKeyEditorProvider] = useState<AiProviderView | null>(null);
@@ -2185,7 +2187,14 @@ function AiProviders({ toggle }: { toggle: ToggleStyle }) {
2185
2187
  const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
2186
2188
 
2187
2189
  const loadProviders = async () => {
2188
- try { const r = await getAiProviders(); setProviders(r.providers ?? []); } catch { /* non-fatal */ }
2190
+ try {
2191
+ const r = await getAiProviders();
2192
+ setProviders(r.providers ?? []);
2193
+ if (!r.providers?.length) setMsg('No providers returned — is the backend running and are you logged in as admin?');
2194
+ else setMsg('');
2195
+ } catch (e: any) {
2196
+ setMsg(`Failed to load providers: ${String(e?.message ?? e)} — ensure you are logged in as admin (403 = not admin, 401 = not logged in).`);
2197
+ }
2189
2198
  };
2190
2199
 
2191
2200
  const persistenceNote = (persistence?: AiProviderPersistence): string => {
@@ -2349,6 +2358,11 @@ function AiProviders({ toggle }: { toggle: ToggleStyle }) {
2349
2358
  <button onClick={() => setShowAdd(true)} disabled={busy} className="glass-chip-btn">+ add provider</button>
2350
2359
  </div>
2351
2360
  </div>
2361
+ {msg && (
2362
+ <div role="status" className={`rounded-lg border px-3 py-2 text-xs ${msg.includes('Failed') || msg.includes('No providers') ? 'border-amber-500/30 bg-amber-500/10 text-amber-300' : 'border-sky-400/20 bg-sky-400/10 text-sky-300'}`}>
2363
+ {msg}
2364
+ </div>
2365
+ )}
2352
2366
 
2353
2367
  {/* Filter bar */}
2354
2368
  <div className="provider-filter-bar">
@@ -3229,6 +3243,117 @@ function Plugins() {
3229
3243
  );
3230
3244
  }
3231
3245
 
3246
+ function GraphQLSettings({ onNavigate }: { onNavigate?: (tab: Tab) => void }) {
3247
+ const [cfg, setCfg] = useState<AdminConfig | null>(null);
3248
+ const [explorer, setExplorer] = useState(true);
3249
+ const [requireAuth, setRequireAuth] = useState(false);
3250
+ const [msg, setMsg] = useAdminAlert('graphql');
3251
+ const [busy, setBusy] = useState(false);
3252
+ const [restartDialog, setRestartDialog] = useState(false);
3253
+
3254
+ const load = async () => {
3255
+ try {
3256
+ const c = await getAdminConfig();
3257
+ setCfg(c);
3258
+ const g = (c.config?.graphql ?? {}) as Record<string, unknown>;
3259
+ setExplorer(g.explorer !== false);
3260
+ setRequireAuth(!!g.explorerRequireAuth);
3261
+ } catch (e: any) { setMsg(String(e?.message ?? e)); }
3262
+ };
3263
+ useEffect(() => { load(); }, []);
3264
+
3265
+ const save = async () => {
3266
+ setBusy(true); setMsg('');
3267
+ try {
3268
+ await putAdminConfig({ graphql: { explorer, explorerRequireAuth: requireAuth } });
3269
+ setMsg('Saved to nexus.runtime.json. Restart services to apply changes.');
3270
+ setRestartDialog(true);
3271
+ } catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); }
3272
+ finally { setBusy(false); }
3273
+ };
3274
+
3275
+ const g = (cfg?.config?.graphql ?? {}) as Record<string, unknown>;
3276
+ const path = (g.path as string) ?? '/graphql';
3277
+ const introspection = g.introspection !== false;
3278
+
3279
+ // Build a directly-openable explorer URL (backend origin + path). The admin
3280
+ // and backend share a host in local dev, so use the browser origin + the
3281
+ // backend port from the merged config.
3282
+ const serverCfg = (cfg?.config?.server ?? {}) as { port?: number };
3283
+ const backendPort = serverCfg.port ?? 4000;
3284
+ const explorerUrl = `${window.location.protocol}//${window.location.hostname}:${backendPort}/graphiql`;
3285
+
3286
+ return (
3287
+ <div className="space-y-6">
3288
+ <PageHead title="GraphQL" />
3289
+ <PageHero kicker="GRAPHQL WORKSPACE" title={<>Explore your <em>schema.</em></>} desc="Control the GraphiQL explorer and gate it behind an admin access token." glyph="gitFork" art="grid" />
3290
+
3291
+ <section className="glass-card space-y-3">
3292
+ <h3 className="glass-h3">Open the explorer</h3>
3293
+ <p className="text-xs text-slate-500">Jump straight into the interactive GraphiQL sandbox on the backend.</p>
3294
+ <div className="flex flex-wrap items-center gap-3">
3295
+ <a
3296
+ href={explorerUrl}
3297
+ target="_blank"
3298
+ rel="noreferrer"
3299
+ className="glass-chip-btn-primary inline-flex items-center gap-2 px-4 py-2 text-sm"
3300
+ >
3301
+ <Icon name="gitFork" size={14} /> Open GraphQL explorer <Icon name="externalLink" size={12} />
3302
+ </a>
3303
+ <code className="font-mono text-xs text-slate-400">{explorerUrl}</code>
3304
+ </div>
3305
+ </section>
3306
+
3307
+ <section className="glass-card space-y-3">
3308
+ <h3 className="glass-h3">GraphiQL explorer</h3>
3309
+ <p className="text-xs text-slate-500">The interactive sandbox is served at <code className="font-mono text-slate-300">{path}</code> (page) and <code className="font-mono text-slate-300">/graphiql</code>. The <code className="font-mono text-slate-300">/graphql</code> JSON endpoint is always public.</p>
3310
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
3311
+ <div className="glass flex items-center justify-between rounded-xl px-3 py-2">
3312
+ <div>
3313
+ <span className="admin-overline">EXPLORER</span>
3314
+ <div className="text-sm text-slate-200">Enable GraphiQL</div>
3315
+ </div>
3316
+ <button onClick={() => setExplorer(!explorer)} className={`glass-chip-btn ${explorer ? 'is-on' : ''}`}>{explorer ? 'On' : 'Off'}</button>
3317
+ </div>
3318
+ <div className="glass flex items-center justify-between rounded-xl px-3 py-2">
3319
+ <div>
3320
+ <span className="admin-overline">ACCESS</span>
3321
+ <div className="text-sm text-slate-200">Require admin token</div>
3322
+ </div>
3323
+ <button onClick={() => setRequireAuth(!requireAuth)} className={`glass-chip-btn ${requireAuth ? 'is-on' : ''}`}>{requireAuth ? 'On' : 'Off'}</button>
3324
+ </div>
3325
+ </div>
3326
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
3327
+ {([
3328
+ ['Endpoint', path],
3329
+ ['Introspection', introspection ? 'enabled' : 'disabled'],
3330
+ ] as const).map(([label, value]) => (
3331
+ <div key={label} className="glass rounded-xl px-3 py-2">
3332
+ <span className="admin-overline">{label}</span>
3333
+ <code className="block mt-1 truncate font-mono text-sm text-slate-200">{value}</code>
3334
+ </div>
3335
+ ))}
3336
+ </div>
3337
+ {requireAuth && <p className="text-xs text-amber-400">When enabled, opening /graphiql requires an active admin session (same guard as /admin/*).</p>}
3338
+ {msg && <p className="text-xs text-slate-400">{msg}</p>}
3339
+ <div className="flex items-center justify-end gap-2">
3340
+ <button onClick={load} className="glass-chip-btn"><Icon name="refresh" size={12} /> Reload</button>
3341
+ <button onClick={save} disabled={busy} className="glass-chip-btn-primary shrink-0">{busy ? 'Saving…' : 'Save'} <span>→</span></button>
3342
+ </div>
3343
+ </section>
3344
+
3345
+ <ConfirmDialog
3346
+ open={restartDialog}
3347
+ title="Restart required"
3348
+ message="Your GraphQL settings have been saved. Restart the backend services to apply the new configuration."
3349
+ confirmLabel="Open services"
3350
+ onConfirm={() => { setRestartDialog(false); onNavigate?.('processes'); }}
3351
+ onCancel={() => setRestartDialog(false)}
3352
+ />
3353
+ </div>
3354
+ );
3355
+ }
3356
+
3232
3357
  function Users({ currentUserEmail }: { currentUserEmail?: string }) {
3233
3358
  const [data, setData] = useState<{ users: UserRecord[] } | null>(null);
3234
3359
  const [roles, setRoles] = useState<RoleDefinition[]>([]);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-ads",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-ai-client",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-auth",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -68,6 +68,29 @@ export function issueCsrfToken(ctx: RequestContext, options: CsrfOptions = {}):
68
68
  return issueToken(ctx, options.cookieName ?? 'nexus_csrf', options.cookie ?? { path: '/', sameSite: 'lax', secure: false });
69
69
  }
70
70
 
71
+ /**
72
+ * Read the existing double-submit cookie token WITHOUT rotating it. Returns the
73
+ * current `nexus_csrf` value, or undefined when no cookie is present. Use this
74
+ * (not `issueCsrfToken`) when you only need to expose the token, so the cookie
75
+ * stays stable and previously-issued tokens keep matching.
76
+ */
77
+ export function getCsrfToken(ctx: RequestContext, options: CsrfOptions = {}): string | undefined {
78
+ return readCookie(ctx, options.cookieName ?? 'nexus_csrf');
79
+ }
80
+
81
+ /**
82
+ * Verify a mutation's CSRF token against the double-submit cookie. The token may
83
+ * be carried either as an argument (`csrf`) or in the `X-CSRF-Token` header; only
84
+ * one needs to match the cookie (never both). Both channels stay cookie-bound:
85
+ * the provided token must equal the `nexus_csrf` cookie value.
86
+ */
87
+ export function verifyMutationCsrf(ctx: RequestContext, argToken: string | undefined, headerToken?: string | undefined, options: CsrfOptions = {}): boolean {
88
+ const cookieToken = readCookie(ctx, options.cookieName ?? 'nexus_csrf');
89
+ if (!cookieToken) return false;
90
+ const provided = argToken ?? headerToken;
91
+ return !!provided && timingSafeEqual(cookieToken, provided);
92
+ }
93
+
71
94
  /** CSRF + origin check for the WebSocket upgrade request (no CORS preflight). */
72
95
  export function checkWsUpgrade(headers: Record<string, string | string[] | undefined>, options: CsrfOptions = {}): void {
73
96
  const trusted = options.trustedOrigins ?? [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-cache",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-cli",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
4
4
  "description": "BhooAI Nexus v2 CLI — init, dev, build, test, scaffolding, plugins, queue, db.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -205,6 +205,7 @@ export default defineConfig({
205
205
  proxy: {
206
206
  '/api': { target: \`http://\${backendHost}:${backendPort}\`, changeOrigin: true },
207
207
  '/uploads': { target: \`http://\${backendHost}:${backendPort}\`, changeOrigin: true },
208
+ '/files': { target: \`http://\${backendHost}:${backendPort}\`, changeOrigin: true },
208
209
  '/ws': { target: \`ws://\${backendHost}:${backendPort}\`, ws: true },
209
210
  },
210
211
  },
@@ -8,10 +8,11 @@
8
8
  * Always interactive (the wizard). Requires a TTY; in a non-interactive
9
9
  * context it prints a message and exits non-zero. `--no-install` skips the
10
10
  * dependency install, `--force` overwrites an existing target directory.
11
- * `--dev` rewrites bhooai-nexus + @bhooai/* to file:../../PACKAGES/bhooai-nexus
11
+ * `--dev` rewrites bhooai-nexus + @bhooai/* (incl. the admin app's
12
+ * @bhooai/nexus-admin + @bhooai/nexus-safe-goto) to file:../../PACKAGES/bhooai-nexus
12
13
  * (relative) and runs `npm link` for live symlink.
13
14
  */
14
- import { mkdir, writeFile, readFile } from 'node:fs/promises';
15
+ import { mkdir, writeFile, readFile, readdir } from 'node:fs/promises';
15
16
  import { existsSync } from 'node:fs';
16
17
  import { spawn } from 'node:child_process';
17
18
  import { join, resolve, dirname, relative, sep } from 'node:path';
@@ -204,7 +205,7 @@ export async function run(ctx: CommandContext): Promise<void> {
204
205
  if (devMode) {
205
206
  console.log(`\n[dev] Linking local packages for live symlink...\n`);
206
207
  const linkCode = await runNpmLinkForDev(targetDir);
207
- if (linkCode === 0) console.log(`\n[dev] Live symlink ready — edits in ${resolveLocalBhooaiNexusRoot() ?? 'PACKAGES/bhooai-nexus'} reflect instantly.`);
208
+ if (linkCode === 0) console.log(`\n[dev] Live symlink ready — edits in ${resolveLocalBhooaiNexusRoot() ?? 'PACKAGES/bhooai-nexus'} (incl. admin) reflect instantly.`);
208
209
  else console.warn(`\n[dev] npm link failed (code ${linkCode}) — fallback to file: copy (re-run npm install after edits).`);
209
210
  }
210
211
  }
@@ -212,7 +213,7 @@ export async function run(ctx: CommandContext): Promise<void> {
212
213
 
213
214
  // 6. Summary
214
215
  console.log(`
215
- ✓ Project scaffolded at ${targetDir}
216
+ ✓ Project scaffolded at ${targetDir}
216
217
 
217
218
  Next steps:
218
219
  cd ${name}
@@ -223,9 +224,10 @@ Ports allocated:
223
224
  Frontend : ${includeFrontend ? vars.frontendPort : '(not included)'}
224
225
  Admin : ${includeAdmin ? vars.adminPort : '(not included)'}
225
226
  AI server : ${includeAi ? vars.aiPort : '(not included)'}
226
- `);
227
-
227
+ `);
228
228
 
229
+ // Ensure stdin is paused so Node can drain naturally after TUI exit (even on npm failure → exit to shell).
230
+ try { process.stdin.pause(); } catch {}
229
231
  }
230
232
 
231
233
  /** Run `npm install` in a directory; returns the exit code. */
@@ -259,47 +261,79 @@ function resolveLocalBhooaiNexusRoot(): string | null {
259
261
  return null;
260
262
  }
261
263
 
262
- async function rewriteToLocalFileDeps(targetDir: string): Promise<{ count: number; root: string; entries: Array<{ name: string; spec: string }> }> {
263
- const localRoot = resolveLocalBhooaiNexusRoot();
264
+ /**
265
+ * Rewrite bhooai-nexus + @bhooai/* deps to local `file:` specs in the root
266
+ * package.json AND every app workspace package.json (notably apps/admin, which
267
+ * depends on @bhooai/nexus-admin + @bhooai/nexus-safe-goto). This ensures a
268
+ * `--dev` scaffold serves the local admin source, not the published npm copy.
269
+ *
270
+ * `localRootOverride` lets tests pin the local repo root; when omitted it is
271
+ * resolved automatically.
272
+ */
273
+ export async function rewriteToLocalFileDeps(
274
+ targetDir: string,
275
+ localRootOverride?: string,
276
+ ): Promise<{ count: number; root: string; entries: Array<{ name: string; spec: string }> }> {
277
+ const localRoot = localRootOverride ?? resolveLocalBhooaiNexusRoot();
264
278
  if (!localRoot) return { count: 0, root: '', entries: [] };
265
- const pkgPath = join(targetDir, 'package.json');
266
- let raw: string;
267
- try { raw = await readFile(pkgPath, 'utf8'); } catch { return { count: 0, root: localRoot, entries: [] }; }
268
- let pkg: any;
269
- try { pkg = JSON.parse(raw); } catch { return { count: 0, root: localRoot, entries: [] }; }
270
279
  const entries: Array<{ name: string; spec: string }> = [];
271
- const toFileSpec = (abs: string) => {
272
- let rel = relative(targetDir, abs).replace(/\\/g, '/');
273
- if (!rel.startsWith('.')) rel = './' + rel;
274
- // Use forward slashes for file: spec (npm supports both, but posix is more portable)
275
- return `file:${rel}`;
276
- };
277
- const maybeRewrite = (obj: Record<string, string> | undefined) => {
278
- if (!obj) return;
279
- for (const name of Object.keys(obj)) {
280
- if (name === 'bhooai-nexus') {
281
- const spec = toFileSpec(localRoot);
282
- obj[name] = spec;
283
- entries.push({ name, spec });
284
- } else if (name.startsWith('@bhooai/')) {
285
- const short = name.replace('@bhooai/', '');
286
- // Map @bhooai/nexus-* -> packages/nexus-<short> ; @bhooai/nexus -> packages/nexus-core? but we keep generic
287
- const pkgDir = join(localRoot, 'packages', short === 'nexus' ? 'nexus-core' : short.startsWith('nexus-') ? short : `nexus-${short}`);
288
- // Fallback: try packages/<name without @bhooai/> and packages/nexus-<name>
289
- const candidates = [join(localRoot, 'packages', name.replace('@bhooai/', '')), join(localRoot, 'packages', `nexus-${short}`), pkgDir];
290
- let found: string | null = null;
291
- for (const c of candidates) if (existsSync(join(c, 'package.json'))) { found = c; break; }
292
- if (found) {
293
- const spec = toFileSpec(found);
280
+
281
+ const rewritePackageJson = async (pkgPath: string): Promise<void> => {
282
+ const appDir = dirname(pkgPath);
283
+ let raw: string;
284
+ try { raw = await readFile(pkgPath, 'utf8'); } catch { return; }
285
+ let pkg: any;
286
+ try { pkg = JSON.parse(raw); } catch { return; }
287
+ const toFileSpec = (abs: string) => {
288
+ let rel = relative(appDir, abs).replace(/\\/g, '/');
289
+ if (!rel.startsWith('.')) rel = './' + rel;
290
+ // Use forward slashes for file: spec (npm supports both, but posix is more portable)
291
+ return `file:${rel}`;
292
+ };
293
+ let changed = false;
294
+ const maybeRewrite = (obj: Record<string, string> | undefined) => {
295
+ if (!obj) return;
296
+ for (const name of Object.keys(obj)) {
297
+ if (name === 'bhooai-nexus') {
298
+ const spec = toFileSpec(localRoot);
294
299
  obj[name] = spec;
295
300
  entries.push({ name, spec });
301
+ changed = true;
302
+ } else if (name.startsWith('@bhooai/')) {
303
+ const short = name.replace('@bhooai/', '');
304
+ // Map @bhooai/nexus-* -> packages/nexus-<short> ; @bhooai/nexus -> packages/nexus-core
305
+ const pkgDir = join(localRoot, 'packages', short === 'nexus' ? 'nexus-core' : short.startsWith('nexus-') ? short : `nexus-${short}`);
306
+ // Fallback: try packages/<name without @bhooai/> and packages/nexus-<name>
307
+ const candidates = [join(localRoot, 'packages', name.replace('@bhooai/', '')), join(localRoot, 'packages', `nexus-${short}`), pkgDir];
308
+ let found: string | null = null;
309
+ for (const c of candidates) if (existsSync(join(c, 'package.json'))) { found = c; break; }
310
+ if (found) {
311
+ const spec = toFileSpec(found);
312
+ obj[name] = spec;
313
+ entries.push({ name, spec });
314
+ changed = true;
315
+ }
296
316
  }
297
317
  }
298
- }
318
+ };
319
+ maybeRewrite(pkg.dependencies);
320
+ maybeRewrite(pkg.devDependencies);
321
+ if (changed) await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
299
322
  };
300
- maybeRewrite(pkg.dependencies);
301
- maybeRewrite(pkg.devDependencies);
302
- if (entries.length) await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
323
+
324
+ // Root package.json first, then every app workspace package.json.
325
+ await rewritePackageJson(join(targetDir, 'package.json'));
326
+ const appsDir = join(targetDir, 'apps');
327
+ if (existsSync(appsDir)) {
328
+ let appEntries: import('node:fs').Dirent[] = [];
329
+ try { appEntries = await readdir(appsDir, { withFileTypes: true }); } catch {}
330
+ for (const e of appEntries) {
331
+ if (!e.isDirectory()) continue;
332
+ const appPkg = join(appsDir, e.name, 'package.json');
333
+ if (existsSync(appPkg)) await rewritePackageJson(appPkg);
334
+ }
335
+ }
336
+
303
337
  return { count: entries.length, root: localRoot, entries };
304
338
  }
305
339
 
@@ -319,18 +353,29 @@ async function runNpmLinkForDev(targetDir: string): Promise<number> {
319
353
  c.on('error', () => resolve(1));
320
354
  c.on('exit', (code) => resolve(code ?? 1));
321
355
  });
322
- // Also link each @bhooai/* that we rewrote to file: (best-effort, ignore failures)
323
- try {
324
- const pkgRaw = await readFile(join(targetDir, 'package.json'), 'utf8');
325
- const pkg = JSON.parse(pkgRaw);
326
- const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
327
- for (const name of Object.keys(allDeps)) if (name.startsWith('@bhooai/')) {
328
- await new Promise<void>((r) => {
329
- const c = spawn('npm', ['link', name], { cwd: targetDir, stdio: 'inherit', shell: process.platform === 'win32' });
330
- c.on('exit', () => r()); c.on('error', () => r());
331
- });
356
+ // Also link each @bhooai/* that we rewrote to file: in the root AND app
357
+ // workspace package.json (e.g. apps/admin's @bhooai/nexus-admin) — best-effort.
358
+ const collectBhooaiDeps = async (): Promise<string[]> => {
359
+ const names = new Set<string>();
360
+ const roots = [join(targetDir, 'package.json'), join(targetDir, 'apps', 'admin', 'package.json')];
361
+ for (const p of roots) {
362
+ try {
363
+ const raw = await readFile(p, 'utf8');
364
+ const pkg = JSON.parse(raw);
365
+ for (const obj of [pkg.dependencies ?? {}, pkg.devDependencies ?? {}]) {
366
+ for (const name of Object.keys(obj)) if (name.startsWith('@bhooai/')) names.add(name);
367
+ }
368
+ } catch {}
332
369
  }
333
- } catch {}
370
+ return [...names];
371
+ };
372
+ const bhooaiDeps = await collectBhooaiDeps();
373
+ for (const name of bhooaiDeps) {
374
+ await new Promise<void>((r) => {
375
+ const c = spawn('npm', ['link', name], { cwd: targetDir, stdio: 'inherit', shell: process.platform === 'win32' });
376
+ c.on('exit', () => r()); c.on('error', () => r());
377
+ });
378
+ }
334
379
  return linkTarget;
335
380
  }
336
381