@flowerforce/flowerbase 1.8.1 → 1.8.2

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 1.8.2 (2026-03-31)
2
+
3
+
4
+ ### 🩹 Fixes
5
+
6
+ - apply_when ([f2bcef2](https://github.com/flowerforce/flowerbase/commit/f2bcef2))
7
+
8
+ - forwarded host ([#52](https://github.com/flowerforce/flowerbase/pull/52))
9
+
1
10
  ## 1.8.1 (2026-03-27)
2
11
 
3
12
 
package/README.md CHANGED
@@ -557,6 +557,45 @@ MONIT_ALLOW_EDIT=true
557
557
  - If `MONIT_ALLOWED_IPS` is set, only those IPs can reach `/monit` (ensure `req.ip` reflects your proxy setup / `trustProxy`).
558
558
  - You can disable **invoke** or **edit** with `MONIT_ALLOW_INVOKE=false` and/or `MONIT_ALLOW_EDIT=false`.
559
559
 
560
+ ### 🔐 Reverse Proxy and SSL Termination
561
+
562
+ When Flowerbase runs behind Nginx or HAProxy with SSL termination, make sure the proxy forwards the public scheme/host/port headers.
563
+
564
+ Flowerbase uses these headers for `GET /api/client/<version>/app/:appId/location`, and Realm-style clients use that response to build auth URLs (including `/login`).
565
+
566
+ - Header priority for host and scheme is: `Forwarded` first, then `X-Forwarded-*`, then `Host`.
567
+ - If `X-Forwarded-Port` is set and host has no explicit port, Flowerbase appends it.
568
+
569
+ #### Nginx
570
+
571
+ ```nginx
572
+ location / {
573
+ proxy_pass http://flowerbase_upstream;
574
+ proxy_set_header Host $http_host;
575
+ proxy_set_header X-Forwarded-Proto $scheme;
576
+ proxy_set_header X-Forwarded-Host $http_host;
577
+ proxy_set_header X-Forwarded-Port $server_port;
578
+ }
579
+ ```
580
+
581
+ #### HAProxy
582
+
583
+ ```haproxy
584
+ frontend fe_https
585
+ bind *:443 ssl crt /etc/haproxy/certs/site.pem
586
+ mode http
587
+ default_backend be_flowerbase
588
+
589
+ backend be_flowerbase
590
+ mode http
591
+ server app1 127.0.0.1:3000 check
592
+ http-request set-header Host %[req.hdr(host)]
593
+ http-request set-header X-Forwarded-Proto https if { ssl_fc }
594
+ http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
595
+ http-request set-header X-Forwarded-Host %[req.hdr(host)]
596
+ http-request set-header X-Forwarded-Port %[dst_port]
597
+ ```
598
+
560
599
 
561
600
  <a id="build"></a>
562
601
  ## 🚀 Build & Deploy the Server
@@ -1 +1 @@
1
- {"version":3,"file":"exposeRoutes.d.ts","sourceRoot":"","sources":["../../../src/utils/initializer/exposeRoutes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAOzC;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAU,SAAS,eAAe,kBAqF1D,CAAA"}
1
+ {"version":3,"file":"exposeRoutes.d.ts","sourceRoot":"","sources":["../../../src/utils/initializer/exposeRoutes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAmCzC;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAU,SAAS,eAAe,kBA2F1D,CAAA"}
@@ -15,6 +15,36 @@ const utils_1 = require("../../auth/utils");
15
15
  const constants_1 = require("../../constants");
16
16
  const handleUserRegistration_model_1 = require("../../shared/models/handleUserRegistration.model");
17
17
  const crypto_1 = require("../crypto");
18
+ const parseFirstHeaderValue = (header) => {
19
+ var _a;
20
+ if (!header)
21
+ return undefined;
22
+ const raw = Array.isArray(header) ? header[0] : header;
23
+ return ((_a = raw === null || raw === void 0 ? void 0 : raw.split(',')[0]) === null || _a === void 0 ? void 0 : _a.trim()) || undefined;
24
+ };
25
+ const parseForwardedHeader = (header) => {
26
+ var _a;
27
+ if (!header)
28
+ return {};
29
+ const segment = (_a = header.split(',')[0]) === null || _a === void 0 ? void 0 : _a.trim();
30
+ if (!segment)
31
+ return {};
32
+ const tokens = segment.split(';').map((item) => item.trim());
33
+ const protoToken = tokens.find((item) => item.toLowerCase().startsWith('proto='));
34
+ const hostToken = tokens.find((item) => item.toLowerCase().startsWith('host='));
35
+ const clean = (value) => value === null || value === void 0 ? void 0 : value.replace(/^"|"$/g, '');
36
+ return {
37
+ proto: clean(protoToken === null || protoToken === void 0 ? void 0 : protoToken.split('=')[1]),
38
+ host: clean(hostToken === null || hostToken === void 0 ? void 0 : hostToken.split('=')[1])
39
+ };
40
+ };
41
+ const hasExplicitPort = (host) => {
42
+ if (/^\[[^\]]+]\:\d+$/.test(host))
43
+ return true;
44
+ if (/^[^:]+\:\d+$/.test(host))
45
+ return true;
46
+ return false;
47
+ };
18
48
  /**
19
49
  * > Used to expose all app routes
20
50
  * @param fastify -> the fastify instance
@@ -27,12 +57,16 @@ const exposeRoutes = (fastify) => __awaiter(void 0, void 0, void 0, function* ()
27
57
  tags: ['System']
28
58
  }
29
59
  }, (req) => __awaiter(void 0, void 0, void 0, function* () {
30
- var _a, _b, _c;
31
- const schema = (_a = constants_1.DEFAULT_CONFIG === null || constants_1.DEFAULT_CONFIG === void 0 ? void 0 : constants_1.DEFAULT_CONFIG.HTTPS_SCHEMA) !== null && _a !== void 0 ? _a : 'http';
32
- const headerHost = (_b = req.headers.host) !== null && _b !== void 0 ? _b : 'localhost:3000';
33
- const hostname = headerHost.split(':')[0];
34
- const port = (_c = constants_1.DEFAULT_CONFIG === null || constants_1.DEFAULT_CONFIG === void 0 ? void 0 : constants_1.DEFAULT_CONFIG.PORT) !== null && _c !== void 0 ? _c : 3000;
35
- const host = process.env.NODE_ENV === "production" ? hostname : `${hostname}:${port}`;
60
+ var _a, _b, _c, _d, _e, _f, _g;
61
+ const forwarded = parseForwardedHeader(parseFirstHeaderValue(req.headers.forwarded));
62
+ const forwardedProto = parseFirstHeaderValue(req.headers['x-forwarded-proto']);
63
+ const forwardedHost = parseFirstHeaderValue(req.headers['x-forwarded-host']);
64
+ const forwardedPort = parseFirstHeaderValue(req.headers['x-forwarded-port']);
65
+ const schema = (_c = (_b = (_a = forwarded.proto) !== null && _a !== void 0 ? _a : forwardedProto) !== null && _b !== void 0 ? _b : constants_1.DEFAULT_CONFIG === null || constants_1.DEFAULT_CONFIG === void 0 ? void 0 : constants_1.DEFAULT_CONFIG.HTTPS_SCHEMA) !== null && _c !== void 0 ? _c : 'http';
66
+ let host = (_f = (_e = (_d = forwarded.host) !== null && _d !== void 0 ? _d : forwardedHost) !== null && _e !== void 0 ? _e : req.headers.host) !== null && _f !== void 0 ? _f : `localhost:${(_g = constants_1.DEFAULT_CONFIG === null || constants_1.DEFAULT_CONFIG === void 0 ? void 0 : constants_1.DEFAULT_CONFIG.PORT) !== null && _g !== void 0 ? _g : 3000}`;
67
+ if (forwardedPort && !hasExplicitPort(host)) {
68
+ host = `${host}:${forwardedPort}`;
69
+ }
36
70
  const wsSchema = 'wss';
37
71
  return {
38
72
  deployment_model: 'LOCAL',
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../../src/utils/roles/machines/write/B/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAA;AAGxC,eAAO,MAAM,aAAa,EAAE,MAgE3B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../../src/utils/roles/machines/write/B/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAA;AAGxC,eAAO,MAAM,aAAa,EAAE,MAmE3B,CAAA"}
@@ -44,6 +44,9 @@ exports.STEP_B_STATES = {
44
44
  });
45
45
  const check = yield (0, commonValidators_1.evaluateTopLevelPermissionsFn)(context, 'write');
46
46
  if (check) {
47
+ if (context.params.type === 'write') {
48
+ return endValidation({ success: true });
49
+ }
47
50
  return next('evaluateTopLevelInsert');
48
51
  }
49
52
  if (check === false) {
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/utils/rules-matcher/utils.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAe,MAAM,aAAa,CAAA;AAmEvE;;GAEG;AACH,QAAA,MAAM,iBAAiB,EAAE,iBAoNxB,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,SAAS,EAAE,SAyDvB,CAAA;AAID,eAAe,iBAAiB,CAAA"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/utils/rules-matcher/utils.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAe,MAAM,aAAa,CAAA;AAmEvE;;GAEG;AACH,QAAA,MAAM,iBAAiB,EAAE,iBAkOxB,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,SAAS,EAAE,SAyDvB,CAAA;AAID,eAAe,iBAAiB,CAAA"}
@@ -212,6 +212,13 @@ const rulesMatcherUtils = {
212
212
  return true;
213
213
  return block['$or'].some((item) => rulesMatcherUtils.checkRule(item, data, options));
214
214
  }
215
+ if (!Array.isArray(block) && block && typeof block === 'object') {
216
+ const keys = Object.keys(block);
217
+ const hasLogicalOperators = keys.some((key) => key === '$and' || key === '$or');
218
+ if (!hasLogicalOperators && keys.length > 1) {
219
+ return keys.every((key) => rulesMatcherUtils.checkRule({ [key]: block[key] }, data, options));
220
+ }
221
+ }
215
222
  const res = rulesMatcherUtils.rule(block, data, options);
216
223
  return res.valid;
217
224
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowerforce/flowerbase",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -77,9 +77,9 @@ describe('WRITE STEP_B_STATES', () => {
77
77
  expect(endValidation).toHaveBeenCalledWith({ success: false })
78
78
  })
79
79
 
80
- it('routes to insert check when write=true', async () => {
80
+ it('ends validation when write=true on update requests', async () => {
81
81
  ;(evaluateTopLevelPermissionsFn as jest.Mock).mockResolvedValueOnce(true)
82
- const context = {} as MachineContext
82
+ const context = { params: { type: 'write' } } as MachineContext
83
83
  await evaluateTopLevelWrite({
84
84
  context,
85
85
  endValidation,
@@ -87,13 +87,12 @@ describe('WRITE STEP_B_STATES', () => {
87
87
  next,
88
88
  initialStep: null
89
89
  })
90
- expect(next).toHaveBeenCalledWith('evaluateTopLevelInsert')
90
+ expect(endValidation).toHaveBeenCalledWith({ success: true })
91
91
  })
92
92
 
93
- it('routes to insert check when write=true and field-level rules exist', async () => {
93
+ it('routes to insert check when write=true on insert requests', async () => {
94
94
  ;(evaluateTopLevelPermissionsFn as jest.Mock).mockResolvedValueOnce(true)
95
- ;(checkFieldsPropertyExists as jest.Mock).mockReturnValue(true)
96
- const context = {} as MachineContext
95
+ const context = { params: { type: 'insert' } } as MachineContext
97
96
  await evaluateTopLevelWrite({
98
97
  context,
99
98
  endValidation,
@@ -38,7 +38,10 @@ describe('exposeRoutes', () => {
38
38
  const appId = 'it'
39
39
  const response = await config.app!.inject({
40
40
  method: 'GET',
41
- url: `${API_VERSION}/app/${appId}/location`
41
+ url: `${API_VERSION}/app/${appId}/location`,
42
+ headers: {
43
+ host: 'localhost:3000'
44
+ }
42
45
  })
43
46
 
44
47
  expect(response.statusCode).toBe(200)
@@ -50,6 +53,28 @@ describe('exposeRoutes', () => {
50
53
  })
51
54
  })
52
55
 
56
+ it('GET location should prioritize forwarded host and port', async () => {
57
+ const appId = 'it'
58
+ const response = await config.app!.inject({
59
+ method: 'GET',
60
+ url: `${API_VERSION}/app/${appId}/location`,
61
+ headers: {
62
+ host: 'internal-flowerbase:3000',
63
+ 'x-forwarded-proto': 'https',
64
+ 'x-forwarded-host': 'api.example.com',
65
+ 'x-forwarded-port': '8443'
66
+ }
67
+ })
68
+
69
+ expect(response.statusCode).toBe(200)
70
+ expect(JSON.parse(response.body)).toEqual({
71
+ deployment_model: 'LOCAL',
72
+ location: 'IE',
73
+ hostname: 'https://api.example.com:8443',
74
+ ws_hostname: 'wss://api.example.com:8443'
75
+ })
76
+ })
77
+
53
78
  it('exposeRoutes should handle errors in the catch block', async () => {
54
79
  const mockedApp = Fastify()
55
80
  // Forced fail on get method
@@ -9,7 +9,8 @@ const {
9
9
  getPath,
10
10
  forceNumber,
11
11
  getDefaultStringValue,
12
- getTypeOf
12
+ getTypeOf,
13
+ checkRule
13
14
  } = rulesMatcherUtils
14
15
 
15
16
  describe('rulesMatcherUtils', () => {
@@ -64,4 +65,33 @@ describe('rulesMatcherUtils', () => {
64
65
  expect(getPath('^data.name')).toBe('data.name')
65
66
  expect(getPath('$data.name')).toBe('$data.name')
66
67
  })
68
+
69
+ it('evaluates plain objects with multiple keys as AND conditions', () => {
70
+ const data = {
71
+ '%%root': { accountId: 'acc-1' },
72
+ '%%user': { custom_data: { accountId: 'acc-1', role: 'admin' } }
73
+ }
74
+
75
+ expect(
76
+ checkRule(
77
+ {
78
+ '%%root.accountId': '$ref:%%user.custom_data.accountId',
79
+ '%%user.custom_data.role': 'admin'
80
+ } as any,
81
+ data,
82
+ {}
83
+ )
84
+ ).toBe(true)
85
+
86
+ expect(
87
+ checkRule(
88
+ {
89
+ '%%root.accountId': '$ref:%%user.custom_data.accountId',
90
+ '%%user.custom_data.role': 'staff'
91
+ } as any,
92
+ data,
93
+ {}
94
+ )
95
+ ).toBe(false)
96
+ })
67
97
  })
@@ -6,6 +6,34 @@ import { API_VERSION, AUTH_CONFIG, AUTH_DB_NAME, DEFAULT_CONFIG } from '../../co
6
6
  import { PROVIDER } from '../../shared/models/handleUserRegistration.model'
7
7
  import { hashPassword } from '../crypto'
8
8
 
9
+ const parseFirstHeaderValue = (header: string | string[] | undefined): string | undefined => {
10
+ if (!header) return undefined
11
+ const raw = Array.isArray(header) ? header[0] : header
12
+ return raw?.split(',')[0]?.trim() || undefined
13
+ }
14
+
15
+ const parseForwardedHeader = (header: string | undefined): { proto?: string; host?: string } => {
16
+ if (!header) return {}
17
+ const segment = header.split(',')[0]?.trim()
18
+ if (!segment) return {}
19
+
20
+ const tokens = segment.split(';').map((item) => item.trim())
21
+ const protoToken = tokens.find((item) => item.toLowerCase().startsWith('proto='))
22
+ const hostToken = tokens.find((item) => item.toLowerCase().startsWith('host='))
23
+ const clean = (value?: string) => value?.replace(/^"|"$/g, '')
24
+
25
+ return {
26
+ proto: clean(protoToken?.split('=')[1]),
27
+ host: clean(hostToken?.split('=')[1])
28
+ }
29
+ }
30
+
31
+ const hasExplicitPort = (host: string): boolean => {
32
+ if (/^\[[^\]]+]\:\d+$/.test(host)) return true
33
+ if (/^[^:]+\:\d+$/.test(host)) return true
34
+ return false
35
+ }
36
+
9
37
  /**
10
38
  * > Used to expose all app routes
11
39
  * @param fastify -> the fastify instance
@@ -18,11 +46,17 @@ export const exposeRoutes = async (fastify: FastifyInstance) => {
18
46
  tags: ['System']
19
47
  }
20
48
  }, async (req) => {
21
- const schema = DEFAULT_CONFIG?.HTTPS_SCHEMA ?? 'http'
22
- const headerHost = req.headers.host ?? 'localhost:3000'
23
- const hostname = headerHost.split(':')[0]
24
- const port = DEFAULT_CONFIG?.PORT ?? 3000
25
- const host = process.env.NODE_ENV === "production" ? hostname : `${hostname}:${port}`
49
+ const forwarded = parseForwardedHeader(parseFirstHeaderValue(req.headers.forwarded))
50
+ const forwardedProto = parseFirstHeaderValue(req.headers['x-forwarded-proto'])
51
+ const forwardedHost = parseFirstHeaderValue(req.headers['x-forwarded-host'])
52
+ const forwardedPort = parseFirstHeaderValue(req.headers['x-forwarded-port'])
53
+ const schema = forwarded.proto ?? forwardedProto ?? DEFAULT_CONFIG?.HTTPS_SCHEMA ?? 'http'
54
+ let host = forwarded.host ?? forwardedHost ?? req.headers.host ?? `localhost:${DEFAULT_CONFIG?.PORT ?? 3000}`
55
+
56
+ if (forwardedPort && !hasExplicitPort(host)) {
57
+ host = `${host}:${forwardedPort}`
58
+ }
59
+
26
60
  const wsSchema = 'wss'
27
61
 
28
62
  return {
@@ -37,6 +37,9 @@ export const STEP_B_STATES: States = {
37
37
  })
38
38
  const check = await evaluateTopLevelPermissionsFn(context, 'write')
39
39
  if (check) {
40
+ if (context.params.type === 'write') {
41
+ return endValidation({ success: true })
42
+ }
40
43
  return next('evaluateTopLevelInsert')
41
44
  }
42
45
  if (check === false) {
@@ -267,6 +267,20 @@ const rulesMatcherUtils: RulesMatcherUtils = {
267
267
  )
268
268
  }
269
269
 
270
+ if (!Array.isArray(block) && block && typeof block === 'object') {
271
+ const keys = Object.keys(block)
272
+ const hasLogicalOperators = keys.some((key) => key === '$and' || key === '$or')
273
+ if (!hasLogicalOperators && keys.length > 1) {
274
+ return keys.every((key) =>
275
+ rulesMatcherUtils.checkRule(
276
+ { [key]: (block as Record<string, unknown>)[key] } as any,
277
+ data,
278
+ options
279
+ )
280
+ )
281
+ }
282
+ }
283
+
270
284
  const res = rulesMatcherUtils.rule(block, data, options)
271
285
  return res.valid
272
286
  },