@7365admin1/core 3.63.0 → 3.64.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.63.0",
4
+ "version": "3.64.1",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -13,7 +13,7 @@
13
13
  "build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
14
14
  "release": "yarn run build && changeset publish",
15
15
  "lint": "tsc",
16
- "test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
16
+ "test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
17
17
  "test:camera": "yarn test",
18
18
  "test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
19
19
  },
@@ -131,9 +131,28 @@ function handler(name) {
131
131
  test("setting a client's modules takes the CONSOLE gate", () => {
132
132
  const body = handler("updateModules");
133
133
  assert.match(body, /await requireConsolePermission\(req, "organizations"\)/);
134
- // The write must name only `modules`. Anything else and this becomes a second
135
- // way to edit an organisation, with a different gate from the first.
136
- assert.match(body, /_update\(orgId, \{ modules \}\)/);
134
+ /*
135
+ * The write must name ONLY entitlement fields. Anything else and this
136
+ * becomes a second way to edit an organisation, with a different gate from
137
+ * the first.
138
+ *
139
+ * There are two now: `modules` (the catalogue) and `residentAppModules`
140
+ * (R4's resident-app ceiling). Both are the same decision — what did this
141
+ * client buy — taken by the same actor on the same console screen, which is
142
+ * why they share an endpoint rather than getting one each.
143
+ */
144
+ const write = body.slice(body.indexOf("await _update(orgId, {"));
145
+ assert.match(write, /modules,/);
146
+ assert.match(
147
+ write,
148
+ /\.\.\.\(residentAppModules === undefined \? \{\} : \{ residentAppModules \}\),/,
149
+ );
150
+ // Nothing from the request body reaches the write except those two.
151
+ const spread = write.slice(0, write.indexOf("});"));
152
+ assert.ok(
153
+ !/req\.body\.(?!residentAppModules|modules)/.test(spread),
154
+ "an unexpected request field reaches the organisation write",
155
+ );
137
156
  });
138
157
 
139
158
  test("the CLIENT-reachable update still cannot set modules", () => {
@@ -0,0 +1,197 @@
1
+ /*
2
+ * THE RESIDENT-APP MODULE CASCADE — owner requirement R4, 2026-09-10.
3
+ *
4
+ * Seven365 -> organizations.residentAppModules (tier one, this adds it)
5
+ * the org -> sites.metadata.residentAppModules (tier two, already shipped
6
+ * and written by property-management's Site Settings panel)
7
+ *
8
+ * The whole risk is the DEFAULT. No organisation has the new field and no site
9
+ * has to have the old one, so a reading of "absent means nothing" empties the
10
+ * home screen of every resident in the estate the day it deploys. Most of what
11
+ * follows is about the ways this could fail closed when it must fail open.
12
+ */
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { readFileSync } from "node:fs";
16
+
17
+ import {
18
+ effectiveResidentAppModules,
19
+ residentAppModulesAboveCeiling,
20
+ residentAppModulesAboveCeilingMessage,
21
+ } from "./.build/utils/resident-app-modules.util.mjs";
22
+
23
+ const KEYS = [
24
+ "feedback",
25
+ "bulletin",
26
+ "bulletinVideos",
27
+ "inviteVisitors",
28
+ "myVisitors",
29
+ "billingAndSoa",
30
+ "access",
31
+ "facilityBooking",
32
+ "onlineForm",
33
+ "emergencyContact",
34
+ ];
35
+
36
+ const allOn = () => Object.fromEntries(KEYS.map((key) => [key, true]));
37
+
38
+ /* ── reading: it must never take a module away by accident ─────────────── */
39
+
40
+ test("absent at BOTH tiers means every module is ON", () => {
41
+ for (const org of [undefined, null, {}, [], "junk", 0, true]) {
42
+ for (const site of [undefined, null, {}, [], "junk", 0, true]) {
43
+ assert.deepEqual(
44
+ effectiveResidentAppModules(org, site),
45
+ allOn(),
46
+ `${JSON.stringify(org)} / ${JSON.stringify(site)}`,
47
+ );
48
+ }
49
+ }
50
+ });
51
+
52
+ test("ONLY an explicit false disables — nothing else does", () => {
53
+ for (const value of [undefined, null, 0, "", "false", NaN, true, {}]) {
54
+ const result = effectiveResidentAppModules({ feedback: value }, undefined);
55
+ assert.equal(result.feedback, true, String(value));
56
+ }
57
+ // CONTROL: the one value that does disable it.
58
+ assert.equal(
59
+ effectiveResidentAppModules({ feedback: false }, undefined).feedback,
60
+ false,
61
+ );
62
+ });
63
+
64
+ test("a site's false turns its own module off", () => {
65
+ const result = effectiveResidentAppModules(undefined, { bulletin: false });
66
+ assert.equal(result.bulletin, false);
67
+ assert.equal(result.feedback, true, "it must not take anything else with it");
68
+ });
69
+
70
+ test("Seven365's false WINS over a site's true — that is the ceiling", () => {
71
+ /*
72
+ * And it keeps winning if Seven365 turns a module off AFTER a site had
73
+ * already switched it on, which a write-time refusal alone cannot do.
74
+ */
75
+ const result = effectiveResidentAppModules(
76
+ { access: false },
77
+ { access: true },
78
+ );
79
+ assert.equal(result.access, false);
80
+ });
81
+
82
+ test("an unknown key at either tier changes nothing", () => {
83
+ const result = effectiveResidentAppModules(
84
+ { notAModule: false },
85
+ { alsoNot: false },
86
+ );
87
+ assert.deepEqual(result, allOn());
88
+ });
89
+
90
+ test("every one of the ten keys is always answered", () => {
91
+ // A missing key reads as `undefined`, and the app's own predicate treats
92
+ // undefined as ON - but a caller comparing keys would see a shorter object.
93
+ assert.deepEqual(
94
+ Object.keys(effectiveResidentAppModules({ feedback: false }, undefined)).sort(),
95
+ [...KEYS].sort(),
96
+ );
97
+ });
98
+
99
+ /* ── writing: a site cannot switch on what the client did not buy ──────── */
100
+
101
+ test("turning ON a module Seven365 turned OFF is refused, by name", () => {
102
+ assert.deepEqual(
103
+ residentAppModulesAboveCeiling({ access: false }, { access: true }),
104
+ ["access"],
105
+ );
106
+ assert.deepEqual(
107
+ residentAppModulesAboveCeiling(
108
+ { access: false, onlineForm: false },
109
+ { access: true, onlineForm: true, feedback: true },
110
+ ),
111
+ ["access", "onlineForm"],
112
+ );
113
+ });
114
+
115
+ test("an organisation with no ceiling refuses nothing", () => {
116
+ // Every live organisation is in this state today. If this refused, the Site
117
+ // Settings panel would stop saving for everybody on deploy.
118
+ for (const org of [undefined, null, {}, "junk"]) {
119
+ assert.deepEqual(residentAppModulesAboveCeiling(org, allOn()), []);
120
+ }
121
+ });
122
+
123
+ test("turning a module OFF is never refused, whatever the ceiling says", () => {
124
+ // Switching off is how a site opts out, and it can never exceed a ceiling.
125
+ assert.deepEqual(
126
+ residentAppModulesAboveCeiling({ access: false }, { access: false }),
127
+ [],
128
+ );
129
+ assert.deepEqual(
130
+ residentAppModulesAboveCeiling({ access: false }, {}),
131
+ [],
132
+ );
133
+ });
134
+
135
+ test("a module the ceiling ALLOWS can still be switched on", () => {
136
+ assert.deepEqual(
137
+ residentAppModulesAboveCeiling({ access: true }, { access: true }),
138
+ [],
139
+ );
140
+ });
141
+
142
+ test("the refusal names the switches and says what to do", () => {
143
+ const message = residentAppModulesAboveCeilingMessage(["access", "onlineForm"]);
144
+ assert.match(message, /access/);
145
+ assert.match(message, /onlineForm/);
146
+ assert.match(message, /Ask Seven365/);
147
+ });
148
+
149
+ /* ── the endpoints ─────────────────────────────────────────────────────── */
150
+
151
+ const SITE_CONTROLLER = readFileSync(
152
+ new URL("../src/controllers/site.controller.ts", import.meta.url),
153
+ "utf8",
154
+ );
155
+ const ORG_CONTROLLER = readFileSync(
156
+ new URL("../src/controllers/organization.controller.ts", import.meta.url),
157
+ "utf8",
158
+ );
159
+
160
+ test("the site read returns the EFFECTIVE set as a second field", () => {
161
+ /*
162
+ * A second field, not a narrowing of the saved one. The Site Settings panel
163
+ * reads back what it writes: narrowing in place would show a property
164
+ * manager a switch coming back off after they saved it on, which is
165
+ * indistinguishable from a failed save.
166
+ */
167
+ assert.match(SITE_CONTROLLER, /residentAppModulesEffective: effectiveResidentAppModules\(/);
168
+ assert.match(SITE_CONTROLLER, /res\.json\(await withResidentAppCeiling\(site\)\)/);
169
+ // CONTROL: the saved field is still returned untouched alongside it.
170
+ assert.match(SITE_CONTROLLER, /\.\.\.\(site\.metadata \?\? \{\}\)/);
171
+ });
172
+
173
+ test("the site read fails open — an unreadable organisation cannot blank the app", () => {
174
+ const start = SITE_CONTROLLER.indexOf("async function withResidentAppCeiling");
175
+ const body = SITE_CONTROLLER.slice(start, start + 1200);
176
+ assert.match(body, /\.catch\(\(\) => null\)/);
177
+ assert.match(body, /catch \{\s*return site;/);
178
+ });
179
+
180
+ test("the site WRITE refuses only when it actually carries the switches", () => {
181
+ // Checking unconditionally would cost every site edit an org lookup, and
182
+ // would refuse a write that never mentioned the resident app.
183
+ assert.match(SITE_CONTROLLER, /if \(rest\?\.metadata\?\.residentAppModules\) \{/);
184
+ assert.match(SITE_CONTROLLER, /residentAppModulesAboveCeiling\(/);
185
+ });
186
+
187
+ test("Seven365 sets the ceiling on the console's own endpoint, and it is optional", () => {
188
+ // Optional so the existing console screen, which sends only `modules`, keeps
189
+ // working and leaves the resident-app ceiling exactly as it was.
190
+ assert.match(ORG_CONTROLLER, /residentAppModules: residentAppModulesSchema\.optional\(\)/);
191
+ assert.match(
192
+ ORG_CONTROLLER,
193
+ /\.\.\.\(residentAppModules === undefined \? \{\} : \{ residentAppModules \}\)/,
194
+ );
195
+ // CONTROL: still behind the console permission, not reachable by a client.
196
+ assert.match(ORG_CONTROLLER, /requireConsolePermission\(req, "organizations"\)/);
197
+ });
@@ -112,6 +112,32 @@ test("/api/roles/v2 is gated too — the same hole with /v2 on the URL", () => {
112
112
  );
113
113
  });
114
114
 
115
+ test("/api/roles/v2 cannot mint a role holding the CONSOLE's own resources", () => {
116
+ /*
117
+ * `role.controller createRole` has carried `requireNoPlatformGrant` for a
118
+ * while; this mount did not. It is a separate controller behind a separate
119
+ * route and takes any string at all, so the hole was reachable by adding
120
+ * `/v2` to the URL - the same shape as the two gates above it.
121
+ *
122
+ * MEASURED BEFORE ADDING, on production, read-only: NO site-scoped role holds
123
+ * a platform-only string (6 site-scoped roles read; the 11 roles that do hold
124
+ * one are org-scoped and already covered by the v1 gate). This mount REQUIRES
125
+ * a `site`, so the check refuses nothing that exists today.
126
+ */
127
+ const body = fn(controllerV2, "createRole");
128
+ assert.match(body, /requireNoPlatformGrant\(/);
129
+
130
+ // It must run BEFORE the write, or it decides nothing.
131
+ const guard = body.indexOf("requireNoPlatformGrant(");
132
+ const write = body.indexOf("_createRole(");
133
+ assert.ok(guard !== -1 && write !== -1);
134
+ assert.ok(guard < write, "the role is created before the platform check");
135
+
136
+ // CONTROL: v1 still has it, so the assertion above is a real comparison and
137
+ // not a rule that only exists on one side.
138
+ assert.match(fn(controller, "createRole"), /requireNoPlatformGrant\(/);
139
+ });
140
+
115
141
  /**
116
142
  * The four true WRITES now go through `requireRoleWrite`, which is
117
143
  * `requireRoleOrg` plus one refusal: a role with no `org` and a non-admin type
@@ -30,6 +30,8 @@ import {
30
30
  effectiveSiteModules,
31
31
  rejectedSiteModules,
32
32
  rejectedSiteModulesMessage,
33
+ permissionsOutsideModules,
34
+ requireWithinModules,
33
35
  } from "./.build/utils/site-modules.util.mjs";
34
36
 
35
37
  /* ── reading: it must never return "nothing" ───────────────────────────── */
@@ -147,3 +149,152 @@ test("metadata is MERGED, never replaced", () => {
147
149
  const body = handler("updateSiteModules");
148
150
  assert.match(body, /metadata: \{ \.\.\.\(site\.metadata \?\? \{\}\), modules \}/);
149
151
  });
152
+
153
+ /* ── TIER THREE: the permissions a role may be GIVEN ───────────────────── */
154
+
155
+ /*
156
+ * R1's third clause: whatever Seven365 gives an organisation is the only set
157
+ * that organisation can give "to its Site Team member". A permission is
158
+ * `"resource:action"` and the resource IS the module key, so the server refuses
159
+ * exactly what the editor declines to offer.
160
+ */
161
+
162
+ test("no module list means EVERYTHING, so nothing is ever refused", () => {
163
+ // Every one of the 168 live organisations is in this state today.
164
+ for (const ceiling of [undefined, null, [], "junk", 0]) {
165
+ assert.deepEqual(
166
+ permissionsOutsideModules(ceiling, ["anything:at-all", "console:whatever"]),
167
+ [],
168
+ String(ceiling),
169
+ );
170
+ }
171
+ });
172
+
173
+ test("a grant outside the ceiling is refused, by name", () => {
174
+ assert.deepEqual(
175
+ permissionsOutsideModules(
176
+ ["visitor-mgmt"],
177
+ ["visitor-mgmt:add-visitor", "nfc-patrol:see-all-patrols"],
178
+ ),
179
+ ["nfc-patrol:see-all-patrols"],
180
+ );
181
+ });
182
+
183
+ test("a grant inside the ceiling is allowed", () => {
184
+ assert.deepEqual(
185
+ permissionsOutsideModules(
186
+ ["visitor-mgmt", "nfc-patrol"],
187
+ ["visitor-mgmt:add-visitor", "nfc-patrol:see-all-patrols"],
188
+ ),
189
+ [],
190
+ );
191
+ });
192
+
193
+ test("ONLY what the write ADDS is tested - a role keeps what it already holds", () => {
194
+ /*
195
+ * This is what makes the guard safe. A role granted `nfc-patrol` before the
196
+ * module list existed can still be renamed, and can still have a DIFFERENT
197
+ * permission removed, without this refusing the write. Refusing those would
198
+ * turn a narrowing into an outage for every role that predates it - the
199
+ * 2026-09-07 shape, designed out.
200
+ */
201
+ const previous = ["nfc-patrol:see-all-patrols"];
202
+ assert.deepEqual(
203
+ permissionsOutsideModules(["visitor-mgmt"], previous, previous),
204
+ [],
205
+ );
206
+ // Removing something it holds is still allowed.
207
+ assert.deepEqual(
208
+ permissionsOutsideModules(["visitor-mgmt"], [], previous),
209
+ [],
210
+ );
211
+ // CONTROL: adding a NEW out-of-ceiling string alongside is still refused.
212
+ assert.deepEqual(
213
+ permissionsOutsideModules(
214
+ ["visitor-mgmt"],
215
+ [...previous, "pool-mgmt:see-all"],
216
+ previous,
217
+ ),
218
+ ["pool-mgmt:see-all"],
219
+ );
220
+ });
221
+
222
+ test("both spellings of EVERYTHING are never refused", () => {
223
+ // An empty list and `["*"]` both mean everything and cannot name a module.
224
+ assert.deepEqual(permissionsOutsideModules(["visitor-mgmt"], []), []);
225
+ assert.deepEqual(permissionsOutsideModules(["visitor-mgmt"], ["*"]), []);
226
+ assert.deepEqual(
227
+ permissionsOutsideModules(["visitor-mgmt"], ["*", "pool-mgmt:see-all"]),
228
+ [],
229
+ );
230
+ });
231
+
232
+ test("a string with no readable resource fails OPEN", () => {
233
+ // Four spellings of "work orders" are live. It is not this guard's job to
234
+ // reject a spelling, and a role write that refuses one is an outage.
235
+ assert.deepEqual(
236
+ permissionsOutsideModules(["visitor-mgmt"], [":no-resource", "", " "]),
237
+ [],
238
+ );
239
+ for (const junk of [null, undefined, 7, {}]) {
240
+ assert.deepEqual(permissionsOutsideModules(["visitor-mgmt"], [junk]), [], String(junk));
241
+ }
242
+ });
243
+
244
+ test("the refusal throws, names the strings, and says what to do", () => {
245
+ assert.doesNotThrow(() =>
246
+ requireWithinModules(["visitor-mgmt"], ["visitor-mgmt:add-visitor"]),
247
+ );
248
+
249
+ let thrown;
250
+ try {
251
+ requireWithinModules(["visitor-mgmt"], ["pool-mgmt:see-all"]);
252
+ } catch (error) {
253
+ thrown = error;
254
+ }
255
+ assert.ok(thrown, "an out-of-ceiling grant was allowed through");
256
+ assert.match(thrown.message, /pool-mgmt:see-all/);
257
+ assert.match(thrown.message, /Ask Seven365/);
258
+ });
259
+
260
+ /* ── the four write paths ──────────────────────────────────────────────── */
261
+
262
+ const ROLE_CONTROLLER = readFileSync(
263
+ new URL("../src/controllers/role.controller.ts", import.meta.url),
264
+ "utf8",
265
+ );
266
+ const ROLE_V2_CONTROLLER = readFileSync(
267
+ new URL("../src/controllers/role-v2.controller.ts", import.meta.url),
268
+ "utf8",
269
+ );
270
+
271
+ test("every role write path takes the ceiling, /v2 included", () => {
272
+ /*
273
+ * `/api/roles/v2` is a separate mount onto a separate controller, so gating
274
+ * one and not the other leaves the rule reachable by adding `/v2` to the URL
275
+ * - the reasoning already written down for the platform-grant gate in that
276
+ * same file. And v2 is the path this requirement is really about: it REQUIRES
277
+ * a site, so it is where site roles are created.
278
+ */
279
+ assert.equal(
280
+ (ROLE_CONTROLLER.match(/requireWithinModules\(/g) ?? []).length,
281
+ 3,
282
+ "role.controller has three write paths: create, update, updatePermissionsById",
283
+ );
284
+ assert.equal((ROLE_V2_CONTROLLER.match(/requireWithinModules\(/g) ?? []).length, 1);
285
+
286
+ // CONTROL: the established platform gate is still on all three, so the count
287
+ // above is a real number and not a coincidence.
288
+ assert.equal((ROLE_CONTROLLER.match(/requireNoPlatformGrant\(/g) ?? []).length, 3);
289
+ });
290
+
291
+ test("the two EDIT paths pass the existing permissions, so only additions are tested", () => {
292
+ // Without the third argument every legacy grant would be re-tested on every
293
+ // edit, and a rename would start failing.
294
+ const flat = ROLE_CONTROLLER.replace(/\s+/g, " ");
295
+ const CALL =
296
+ "requireWithinModules( await moduleCeiling((existing as any)?.org, " +
297
+ "(existing as any)?.site), permissions, (existing as any)?.permissions, );";
298
+
299
+ assert.equal(flat.split(CALL).length - 1, 2);
300
+ });