@bhooai/nexus-core 2.0.13 → 2.0.15

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.
@@ -25,6 +25,14 @@ export interface ServerOptions {
25
25
  keyFile?: string;
26
26
  /** Called for each error to produce a JSON error body. */
27
27
  onError?: (err: NexusError, ctx: RequestContext) => unknown;
28
+ /**
29
+ * Full custom error renderer (e.g. content-negotiated HTML via
30
+ * ErrorHandler.render). When provided it takes precedence over `onError`.
31
+ * If it leaves the response unwritten, the legacy JSON body is used.
32
+ */
33
+ renderError?: (err: unknown, ctx: RequestContext) => Promise<void> | void;
34
+ /** Custom 404 renderer for unmatched routes. Falls back to JSON. */
35
+ renderNotFound?: (ctx: RequestContext) => Promise<void> | void;
28
36
  }
29
37
 
30
38
  /**
@@ -56,7 +64,7 @@ export class NexusServer {
56
64
  return this;
57
65
  }
58
66
 
59
- listen(port: number, host: string = '0.0.0.0'): Promise<void> {
67
+ listen(port: number, host: string = 'localhost'): Promise<void> {
60
68
  return new Promise((resolve) => {
61
69
  this.server.listen(port, host, () => resolve());
62
70
  });
@@ -83,7 +91,7 @@ export class NexusServer {
83
91
  try {
84
92
  await this.runPipeline(ctx);
85
93
  } catch (err) {
86
- this.handleError(err, ctx);
94
+ await this.handleError(err, ctx);
87
95
  }
88
96
  }
89
97
 
@@ -97,6 +105,14 @@ export class NexusServer {
97
105
  await next();
98
106
 
99
107
  if (!ctx.res.writableEnded) {
108
+ if (this.opts.renderNotFound) {
109
+ try {
110
+ await this.opts.renderNotFound(ctx);
111
+ } catch (err) {
112
+ await this.handleError(err, ctx);
113
+ }
114
+ if (ctx.res.writableEnded) return;
115
+ }
100
116
  ctx.json({ error: { code: 'NOT_FOUND', message: `No route for ${ctx.method} ${ctx.path}` } }, 404);
101
117
  }
102
118
  }
@@ -122,8 +138,16 @@ export class NexusServer {
122
138
  };
123
139
  }
124
140
 
125
- private handleError(err: unknown, ctx: RequestContext): void {
141
+ private async handleError(err: unknown, ctx: RequestContext): Promise<void> {
126
142
  if (ctx.res.writableEnded) return;
143
+ if (this.opts.renderError) {
144
+ try {
145
+ await this.opts.renderError(err, ctx);
146
+ } catch {
147
+ // Fall through to the legacy JSON body below.
148
+ }
149
+ if (ctx.res.writableEnded) return;
150
+ }
127
151
  const ne = toNexusError(err);
128
152
  const body = this.opts.onError ? this.opts.onError(ne, ctx) : defaultErrorBody(ne);
129
153
  if (!ctx.res.headersSent) {
@@ -38,7 +38,7 @@ export function serveStatic(root: string, options: { index?: string; prefix?: st
38
38
  return;
39
39
  }
40
40
  const requestPath = prefix ? ctx.path.slice(prefix.length) || '/' : ctx.path;
41
- const safe = normalize(requestPath).replace(/^(\.\.[/\\])+/, '');
41
+ const safe = normalize(requestPath).replace(/^(\.\.[/\\])+/, '').replace(/^[/\\]+/, '');
42
42
  let filePath = join(root, safe);
43
43
  if (isOutside(root, filePath)) {
44
44
  throw new NotFoundError();
@@ -28,10 +28,33 @@ describe('configFromEnv', () => {
28
28
  });
29
29
 
30
30
  describe('mergeConfig', () => {
31
+ it('defaults apps[] to an empty array', () => {
32
+ const cfg = mergeConfig({ auth: { jwt: { secret: 'supersecret' } } });
33
+ expect(cfg.apps).toEqual([]);
34
+ });
35
+
36
+ it('accepts an apps[] layout and validates entries', () => {
37
+ const cfg = mergeConfig({
38
+ apps: [
39
+ { name: 'backend', port: 4000 },
40
+ { name: 'backend-shop', port: 4001, enabled: false },
41
+ ],
42
+ auth: { jwt: { secret: 'supersecret' } },
43
+ });
44
+ expect(cfg.apps).toEqual([
45
+ { name: 'backend', port: 4000, enabled: undefined },
46
+ { name: 'backend-shop', port: 4001, enabled: false },
47
+ ]);
48
+ });
49
+
50
+ it('rejects an invalid apps[] port', () => {
51
+ expect(() => mergeConfig({ apps: [{ name: 'a', port: 0 }], auth: { jwt: { secret: 'supersecret' } } })).toThrow();
52
+ });
53
+
31
54
  it('applies user overrides over defaults and validates', () => {
32
55
  const cfg = mergeConfig({ server: { port: 5000 }, auth: { jwt: { secret: 'supersecret' } } });
33
56
  expect(cfg.server.port).toBe(5000);
34
- expect(cfg.server.host).toBe('127.0.0.1'); // default retained
57
+ expect(cfg.server.host).toBe('localhost'); // default retained
35
58
  expect(cfg.auth.jwt.secret).toBe('supersecret');
36
59
  });
37
60
 
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { fusion } from '../../nexus-fusion/src/index.js';
3
+ import { FusionUserStore } from '../src/app/userStore.js';
4
+
5
+ describe('FusionUserStore', () => {
6
+ const dbs: Array<{ close(): void }> = [];
7
+ afterEach(() => {
8
+ for (const db of dbs.splice(0)) db.close();
9
+ });
10
+
11
+ function freshStore() {
12
+ const db = fusion('main', 'prod'); // in-memory (no dir)
13
+ dbs.push(db);
14
+ return new FusionUserStore(db);
15
+ }
16
+
17
+ it('reads empty collections without throwing', async () => {
18
+ const store = freshStore();
19
+ expect(await store.count()).toBe(0);
20
+ expect(await store.countAdmins()).toBe(0);
21
+ expect(await store.list(10)).toEqual([]);
22
+ expect(await store.findByEmail('nobody@x.com')).toBeNull();
23
+ expect(await store.findById('nope')).toBeNull();
24
+ expect(await store.findOAuthUser('google', 'g9')).toBeNull();
25
+ });
26
+
27
+ it('creates + finds by email (lowercased) and id', async () => {
28
+ const store = freshStore();
29
+ const created = await store.create({ email: 'Alice@Example.com', name: 'Alice', roles: ['user'] });
30
+ expect(created.email).toBe('alice@example.com');
31
+ expect(await store.findByEmail('ALICE@example.com')).toMatchObject({ email: 'alice@example.com' });
32
+ expect(await store.findById(String(created._id))).toMatchObject({ name: 'Alice' });
33
+ expect(await store.findForLogin('alice@example.com')).toMatchObject({ name: 'Alice' });
34
+ });
35
+
36
+ it('rejects duplicate emails', async () => {
37
+ const store = freshStore();
38
+ await store.create({ email: 'dup@x.com', roles: ['user'] });
39
+ await expect(store.create({ email: 'dup@x.com', roles: ['user'] })).rejects.toThrow();
40
+ });
41
+
42
+ it('updates name/password/roles and counts admins', async () => {
43
+ const store = freshStore();
44
+ const a = await store.create({ email: 'a@x.com', roles: ['admin'] });
45
+ await store.create({ email: 'b@x.com', roles: ['user'] });
46
+ expect(await store.count()).toBe(2);
47
+ expect(await store.countAdmins()).toBe(1);
48
+ await store.setName(String(a._id), 'A2');
49
+ expect((await store.findById(String(a._id)))?.name).toBe('A2');
50
+ await store.setPassword(String(a._id), 'hash123');
51
+ expect(await store.findPasswordHash(String(a._id))).toBe('hash123');
52
+ await store.setRoles(String(a._id), ['user']);
53
+ expect(await store.countAdmins()).toBe(0);
54
+ const listed = await store.list(10);
55
+ expect(listed).toHaveLength(2);
56
+ });
57
+
58
+ it('links + finds OAuth identities', async () => {
59
+ const store = freshStore();
60
+ const u = await store.create({ email: 'o@x.com', roles: ['user'] });
61
+ expect(await store.findOAuthUser('google', 'g1')).toBeNull();
62
+ await store.linkOAuth(String(u._id), 'google', 'g1');
63
+ expect(await store.findOAuthUser('google', 'g1')).toMatchObject({ email: 'o@x.com' });
64
+ // Idempotent re-link.
65
+ await store.linkOAuth(String(u._id), 'google', 'g1');
66
+ expect((await store.findById(String(u._id)))?.oauthAccounts).toHaveLength(1);
67
+ });
68
+ });