@flowerforce/flowerbase 1.8.1 → 1.8.3

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,19 @@
1
+ ## 1.8.3 (2026-04-02)
2
+
3
+
4
+ ### 🩹 Fixes
5
+
6
+ - updateOne options ([5b16467](https://github.com/flowerforce/flowerbase/commit/5b16467))
7
+
8
+ ## 1.8.2 (2026-03-31)
9
+
10
+
11
+ ### 🩹 Fixes
12
+
13
+ - apply_when ([f2bcef2](https://github.com/flowerforce/flowerbase/commit/f2bcef2))
14
+
15
+ - forwarded host ([#52](https://github.com/flowerforce/flowerbase/pull/52))
16
+
1
17
  ## 1.8.1 (2026-03-27)
2
18
 
3
19
 
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
@@ -577,35 +616,80 @@ Once deployed, you'll receive a public URL (e.g. https://your-app-name.up.exampl
577
616
 
578
617
  >This URL should be used as the base URL in your frontend application, as explained in the next section.
579
618
 
580
- ## 🌐 Frontend Setup – Realm SDK in React (Example)
619
+ ## 🌐 Frontend Setup – `@flowerforce/flowerbase-client` (Recommended)
581
620
 
582
- You can use the official `realm-web` SDK to integrate MongoDB Realm into a React application.
583
- This serves as a sample setup — similar logic can be applied using other official Realm SDKs **(e.g. React Native, Node, or Flutter)**.
584
-
585
- ### 📦 Install Realm SDK
621
+ For frontend and mobile projects, you can use the dedicated Flowerbase client:
586
622
 
587
623
  ```bash
588
- npm install realm-web
624
+ npm install @flowerforce/flowerbase-client
589
625
  ```
590
626
 
591
- ### ⚙️ Configure Realm in React
592
-
593
- Create a file to initialize and export the Realm App instance:
627
+ ### ⚙️ Configure client app
594
628
 
595
629
  ```ts
596
- // src/realm/realmApp.ts
630
+ import { App, Credentials } from '@flowerforce/flowerbase-client'
597
631
 
598
- import * as Realm from "realm-web";
632
+ const app = new App({
633
+ id: 'your-app-id',
634
+ baseUrl: 'https://your-deployed-backend-url.com',
635
+ timeout: 10000
636
+ })
599
637
 
600
- // Replace with your actual Realm App ID and your deployed backend URL
601
- const app = new Realm.App({
602
- id: "your-realm-app-id", // e.g., my-app-abcde
603
- baseUrl: "https://your-deployed-backend-url.com" // e.g., https://your-app-name.up.example.app
604
- });
638
+ await app.logIn(Credentials.emailPassword('user@example.com', 'secret'))
639
+ ```
640
+
641
+ ### 📦 Common client operations
642
+
643
+ ```ts
644
+ const user = app.currentUser
645
+ if (!user) throw new Error('User not logged in')
646
+
647
+ const profile = await user.functions.getProfile()
605
648
 
606
- export default app;
649
+ const todos = user.mongoClient('mongodb-atlas')
650
+ .db('my-db')
651
+ .collection('todos')
607
652
 
653
+ await todos.insertOne({ title: 'Ship docs update', done: false })
654
+ const openTodos = await todos.find({ done: false })
608
655
  ```
609
656
 
610
- >🔗 The baseUrl should point to the backend URL you deployed earlier using Flowerbase.
611
- This tells the frontend SDK where to send authentication and data requests.
657
+ `@flowerforce/flowerbase-client` supports:
658
+ - local-userpass / anon-user / custom-function authentication
659
+ - function calls (`user.functions.<name>(...)`)
660
+ - MongoDB operations via `user.mongoClient('mongodb-atlas')`
661
+ - change streams with `watch()` async iterator
662
+ - BSON/EJSON interoperability (`ObjectId`, `Date`, etc.)
663
+
664
+ ## 💡 Use Cases by Feature
665
+
666
+ ### 🔐 Authentication
667
+ - Registration and login flows for SaaS dashboards using `local-userpass`.
668
+ - Guest sessions for trial users with `anon-user`, then account upgrade with full registration.
669
+ - Delegated enterprise login with `custom-function` auth when credentials must be validated by external identity logic.
670
+
671
+ ### 🔒 Rules
672
+ - Multi-tenant isolation where each user can only read/write documents of their own workspace.
673
+ - Field-level protection to hide private fields (for example billing or internal notes) from non-admin users.
674
+
675
+ ### ⚙️ Functions
676
+ - Centralized business logic (pricing, counters, workflows) called from web and mobile clients.
677
+ - Privileged server-side tasks invoked with `run_as_system` to perform safe internal operations.
678
+
679
+ ### 🔔 Triggers
680
+ - Audit logging on insert/update/delete events into an activity collection.
681
+ - Scheduled jobs (for example nightly cleanup, reminder generation, data aggregation).
682
+ - Auth lifecycle reactions (welcome email on user creation, cleanup on user deletion).
683
+
684
+ ### 🌐 HTTP Endpoints
685
+ - Public webhook ingestion from third-party systems.
686
+ - Protected custom APIs for backoffice actions not exposed as direct database operations.
687
+
688
+ ### 📡 `flowerbase-client`
689
+ - Real-time UI updates in task boards using `collection.watch()` change streams.
690
+ - Frontend data access with Realm-style API surface to minimize integration complexity.
691
+ - Shared client usage across web and React Native projects with consistent auth/session behavior.
692
+
693
+ ### 🖥 Monitoring UI
694
+ - Live inspection of function invocations, endpoint calls, and trigger executions in staging/production.
695
+ - Fast troubleshooting with event stream filters and user/session search tools.
@@ -18,7 +18,7 @@ export declare const executeQuery: ({ currentMethod, query, update, filter, proj
18
18
  countDocuments: () => Promise<number>;
19
19
  deleteOne: () => Promise<import("mongodb").DeleteResult>;
20
20
  insertOne: () => Promise<import("mongodb").InsertOneResult<Document>>;
21
- updateOne: () => Promise<unknown> | import("mongodb").FindCursor<any> | import("mongodb").ChangeStream<Document, Document> | import("mongodb").AggregationCursor<Document>;
21
+ updateOne: () => Promise<import("mongodb").UpdateResult<Document>>;
22
22
  findOneAndUpdate: () => Promise<Document | null>;
23
23
  aggregate: () => Promise<Document[]>;
24
24
  insertMany: () => Promise<import("mongodb").InsertManyResult<Document>>;
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/features/functions/utils.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAGlC,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAE3D;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAU,gBAAuB,KAAG,OAAO,CAAC,SAAS,CAwB9E,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAU,2HAYhC,kBAAkB;;;;;;;;;;;;;EAiGpB,CAAA"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/features/functions/utils.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAGlC,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAE3D;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAU,gBAAuB,KAAG,OAAO,CAAC,SAAS,CAwB9E,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAU,2HAYhC,kBAAkB;;;;;;;;;;;;;EAsGpB,CAAA"}
@@ -100,7 +100,7 @@ const executeQuery = (_a) => __awaiter(void 0, [_a], void 0, function* ({ curren
100
100
  countDocuments: () => currentMethod(bson_1.EJSON.deserialize(resolvedQuery), parsedOptions),
101
101
  deleteOne: () => currentMethod(bson_1.EJSON.deserialize(resolvedQuery), parsedOptions),
102
102
  insertOne: () => currentMethod(bson_1.EJSON.deserialize(document)),
103
- updateOne: () => currentMethod(bson_1.EJSON.deserialize(resolvedQuery), bson_1.EJSON.deserialize(resolvedUpdate)),
103
+ updateOne: () => currentMethod(bson_1.EJSON.deserialize(resolvedQuery), bson_1.EJSON.deserialize(resolvedUpdate), parsedOptions),
104
104
  findOneAndUpdate: () => currentMethod(bson_1.EJSON.deserialize(resolvedQuery), bson_1.EJSON.deserialize(resolvedUpdate), parsedOptions),
105
105
  aggregate: () => __awaiter(void 0, void 0, void 0, function* () {
106
106
  return (yield currentMethod(bson_1.EJSON.deserialize(pipeline), {}, // TODO -> ADD OPTIONS
@@ -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.3",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,33 @@
1
+ import { executeQuery } from '../utils'
2
+
3
+ describe('executeQuery', () => {
4
+ it('passes parsed options to updateOne', async () => {
5
+ const currentMethod = jest.fn().mockResolvedValue({
6
+ acknowledged: true,
7
+ matchedCount: 0,
8
+ modifiedCount: 0,
9
+ upsertedCount: 1
10
+ })
11
+
12
+ const operators = await executeQuery({
13
+ currentMethod,
14
+ query: { ownerUserId: 'user-1' },
15
+ update: {
16
+ $set: { locale: 'it-IT' },
17
+ $setOnInsert: { scope: 'workspace' }
18
+ },
19
+ options: { upsert: true }
20
+ } as any)
21
+
22
+ await operators.updateOne()
23
+
24
+ expect(currentMethod).toHaveBeenCalledWith(
25
+ { ownerUserId: 'user-1' },
26
+ {
27
+ $set: { locale: 'it-IT' },
28
+ $setOnInsert: { scope: 'workspace' }
29
+ },
30
+ { upsert: true }
31
+ )
32
+ })
33
+ })
@@ -122,7 +122,12 @@ export const executeQuery = async ({
122
122
  (currentMethod as ReturnType<GetOperatorsFunction>['insertOne'])(
123
123
  EJSON.deserialize(document)
124
124
  ),
125
- updateOne: () => currentMethod(EJSON.deserialize(resolvedQuery), EJSON.deserialize(resolvedUpdate)),
125
+ updateOne: () =>
126
+ (currentMethod as ReturnType<GetOperatorsFunction>['updateOne'])(
127
+ EJSON.deserialize(resolvedQuery),
128
+ EJSON.deserialize(resolvedUpdate),
129
+ parsedOptions
130
+ ),
126
131
  findOneAndUpdate: () =>
127
132
  (currentMethod as ReturnType<GetOperatorsFunction>['findOneAndUpdate'])(
128
133
  EJSON.deserialize(resolvedQuery),
@@ -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
  },