@7365admin1/core 3.66.0 → 3.68.0

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.66.0",
4
+ "version": "3.68.0",
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-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 src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts src/utils/org-owner-repair.util.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 src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts src/utils/org-owner-repair.util.ts src/utils/socket-session.util.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
  },
@@ -126,3 +126,30 @@ test("a site document passed in is used as is", async () => {
126
126
  assert.deepEqual(result, { refuse: true, denied: true });
127
127
  assert.equal(calls.site ?? 0, 0);
128
128
  });
129
+
130
+ test("nothing enforced: no membership or role read at all", async () => {
131
+ // This runs on every governed request. While no client has an enforced list
132
+ // there is nothing to decide, and `members.user` has no index to use, so the
133
+ // read is pure cost on every request for every client.
134
+ const { data, calls } = fakeGate({
135
+ rows: [member()],
136
+ orgs: { [ORG]: { _id: ORG } },
137
+ sites: { [SITE]: SITE_DOC },
138
+ });
139
+
140
+ assert.deepEqual(await withEnv({ MODULE_LIST_GATE: undefined }, () => check(data)), ALLOW);
141
+ assert.equal(calls.membershipsOf, undefined, "no memberships read while nothing is enforced");
142
+ assert.equal(calls.roles, undefined, "no roles read while nothing is enforced");
143
+ });
144
+
145
+ test("an ENFORCED list still reads what it needs, and still decides", async () => {
146
+ // The control for the test above: the early return must not have turned the
147
+ // gate off for a client that really has a list.
148
+ const { data, calls } = enforced();
149
+
150
+ assert.deepEqual(await withEnv({ MODULE_LIST_GATE: undefined }, () => check(data)), {
151
+ refuse: false,
152
+ denied: true,
153
+ });
154
+ assert.ok(calls.membershipsOf >= 1, "an enforced client is still measured");
155
+ });
@@ -0,0 +1,48 @@
1
+ /*
2
+ * MODULE_LIST_GATE=off must mean OFF.
3
+ *
4
+ * Every other part of the module gate checks the master switch. The
5
+ * acknowledge-before-save path did not, so turning the switch off still left a
6
+ * narrowing save refused with a 409 — the one lever a lead would reach for in an
7
+ * emergency, not working for the one path that blocks a save from going through.
8
+ */
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+
12
+ import { mustAcknowledge } from "./.build/utils/module-gate.util.mjs";
13
+ import { withEnv } from "./module-gate.fixtures.mjs";
14
+
15
+ /** A save that newly denies something, and somebody really loses it. */
16
+ const narrowing = {
17
+ newlyDenied: ["work_orders"],
18
+ impact: { memberCount: 3, modules: [{ module: "work_orders", memberCount: 3 }] },
19
+ };
20
+
21
+ test("by default a narrowing save still needs acknowledgement", async () => {
22
+ // The control. If this ever goes false, the 409 has been switched off for
23
+ // everyone, which is the opposite of the intent.
24
+ await withEnv({ MODULE_LIST_GATE: undefined }, async () => {
25
+ assert.equal(mustAcknowledge(narrowing, undefined), true);
26
+ });
27
+ });
28
+
29
+ test("MODULE_LIST_GATE=off stops the 409 as well", async () => {
30
+ await withEnv({ MODULE_LIST_GATE: "off" }, async () => {
31
+ assert.equal(mustAcknowledge(narrowing, undefined), false);
32
+ });
33
+ });
34
+
35
+ test("with the gate off, the save goes through however the impact reads", async () => {
36
+ await withEnv({ MODULE_LIST_GATE: "off" }, async () => {
37
+ // Unknown impact is treated as a loss everywhere else, so it is the case
38
+ // most likely to keep refusing by accident.
39
+ assert.equal(mustAcknowledge({ newlyDenied: ["work_orders"], impact: null }, undefined), false);
40
+ });
41
+ });
42
+
43
+ test("acknowledging still works, and a save that denies nothing new never needed it", async () => {
44
+ await withEnv({ MODULE_LIST_GATE: undefined }, async () => {
45
+ assert.equal(mustAcknowledge(narrowing, true), false);
46
+ assert.equal(mustAcknowledge({ newlyDenied: [], impact: null }, undefined), false);
47
+ });
48
+ });
@@ -41,21 +41,50 @@ const enforced = (over = {}) =>
41
41
  const WO = { category: "workOrder", siteId: SITE };
42
42
  const on = (fn) => withEnv({ NOTIFY_ACCESS_FILTER: "on", MODULE_LIST_GATE: undefined }, fn);
43
43
 
44
- test("the code default is LOG; anything unrecognised is log", () => {
45
- assert.equal(notifyAccessMode({}), "log");
46
- assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "garbage" }), "log");
47
- assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "ON" }), "on");
44
+ test("the code default is ON; anything unrecognised falls back to that default", () => {
45
+ // Owner, 2026-09-13: turn the filter on. Unset env => the code default.
46
+ assert.equal(notifyAccessMode({}), "on");
47
+ assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "garbage" }), "on");
48
+ assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "LOG" }), "log");
48
49
  assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "off" }), "off");
49
50
  });
50
51
 
51
- test("LOG never drops: the denied recipient is counted and still sent to", async () => {
52
+ test("the DEFAULT drops, with no env var set at all", async () => {
53
+ // The flip itself: this is the test that fails if the default goes back to
54
+ // `log`. `undefined` means "nothing set on the server", i.e. production.
55
+ const { data } = enforced({ rows: [member()] });
56
+ const sent = await withEnv({ NOTIFY_ACCESS_FILTER: undefined, MODULE_LIST_GATE: undefined }, () =>
57
+ filterByAccess([USER], WO, data),
58
+ );
59
+ assert.deepEqual(sent, [], "the denied recipient is dropped by the default alone");
60
+ });
61
+
62
+ test("LOG still never drops: the denied recipient is counted and still sent to", async () => {
52
63
  const { data } = enforced({ rows: [member()] });
53
64
  const report = await notificationAccessReport([USER], WO, data);
54
65
  assert.deepEqual(report.dropByModule, [USER], "control: the list does deny work orders");
55
- const sent = await withEnv({ NOTIFY_ACCESS_FILTER: undefined }, () => filterByAccess([USER], WO, data));
66
+ // A lead putting it back to `log` must still send to everyone, with no deploy.
67
+ const sent = await withEnv({ NOTIFY_ACCESS_FILTER: "log" }, () => filterByAccess([USER], WO, data));
56
68
  assert.deepEqual(sent, [USER]);
57
69
  });
58
70
 
71
+ test("a client with NO enforced list is untouched by the default being on", async () => {
72
+ // Why the flip is safe on the day it ships: no organisation and no site had an
73
+ // enforced list, so the filter returns early and reads nothing.
74
+ const { data, calls } = fakeGate({
75
+ orgs: { [ORG]: { _id: ORG } },
76
+ sites: { [SITE]: SITE_DOC },
77
+ roles: { r1: { permissions: ["*"] } },
78
+ rows: [member()],
79
+ });
80
+ assert.deepEqual(await filterByAccess([USER], WO, data), [USER]);
81
+ // It does read the org and the site to find out whether a list is enforced.
82
+ // What it must not do is the expensive part: `members.user` has no index to
83
+ // use, so a members/roles read on every send is the cost worth avoiding.
84
+ assert.equal(calls.members, undefined, "no members read while nothing is enforced");
85
+ assert.equal(calls.roles, undefined, "no roles read while nothing is enforced");
86
+ });
87
+
59
88
  test("ON drops only the member whose client list denies the module", async () => {
60
89
  const other = member({ user: USER_2, org: "9".repeat(24), siteId: SITE }); // a provider: own org, no list
61
90
  const { data } = enforced({ rows: [member(), other] });
@@ -0,0 +1,119 @@
1
+ /*
2
+ * Proving who is on the other end of a socket.
3
+ *
4
+ * `/manpower` streams every client site's live staffing level — how many guards
5
+ * are actually on shift against how many were promised — to anyone who connects,
6
+ * with no credential of any kind. The authenticated twin `/manpower-live` uses
7
+ * this check; the old namespace is deliberately left open, because the guard app
8
+ * installed on phones today connects to it.
9
+ *
10
+ * Every refusal must look identical. A socket refused because its session
11
+ * expired must not be distinguishable from one refused for sending nothing, or
12
+ * the difference becomes an oracle.
13
+ */
14
+ import test from "node:test";
15
+ import assert from "node:assert/strict";
16
+
17
+ import {
18
+ MANPOWER_LIVE_NAMESPACE,
19
+ authoriseSocketSession,
20
+ sidFromHandshake,
21
+ userIdOfSession,
22
+ } from "./.build/utils/socket-session.util.mjs";
23
+
24
+ const SID = "a-live-session-id";
25
+ const USER = "64b7f0000000000000000007";
26
+
27
+ /** A session store holding exactly one live session. */
28
+ const store = async (sid) => (sid === SID ? { user: USER } : null);
29
+
30
+ test("the authenticated namespace is a NEW one, not the open feed", () => {
31
+ // If this ever became "/manpower", the guard handsets in the field would be
32
+ // cut off the moment it deployed.
33
+ assert.equal(MANPOWER_LIVE_NAMESPACE, "/manpower-live");
34
+ assert.notEqual(MANPOWER_LIVE_NAMESPACE, "/manpower");
35
+ });
36
+
37
+ test("the session id is read from every shape the clients send", () => {
38
+ assert.equal(sidFromHandshake({ headers: { cookie: `a=1; sid=${SID}; b=2` } }), SID);
39
+ assert.equal(sidFromHandshake({ auth: { sid: SID } }), SID);
40
+ assert.equal(sidFromHandshake({ auth: { token: SID } }), SID);
41
+ assert.equal(sidFromHandshake({ headers: { authorization: `Bearer ${SID}` } }), SID);
42
+ assert.equal(sidFromHandshake({ headers: { authorization: SID } }), SID);
43
+ });
44
+
45
+ test("the cookie wins, and an empty one does not mask the auth payload", () => {
46
+ assert.equal(
47
+ sidFromHandshake({ headers: { cookie: `sid=${SID}` }, auth: { sid: "other" } }),
48
+ SID,
49
+ );
50
+ assert.equal(sidFromHandshake({ headers: { cookie: "sid=" }, auth: { sid: SID } }), SID);
51
+ });
52
+
53
+ test("an empty handshake presents nothing", () => {
54
+ assert.equal(sidFromHandshake({}), "");
55
+ assert.equal(sidFromHandshake({ headers: {}, auth: {} }), "");
56
+ assert.equal(sidFromHandshake({ auth: { sid: " " } }), "");
57
+ });
58
+
59
+ test("a session's user id is read under either spelling", () => {
60
+ assert.equal(userIdOfSession({ user: USER }), USER);
61
+ assert.equal(userIdOfSession({ _id: USER }), USER);
62
+ assert.equal(userIdOfSession(null), "");
63
+ assert.equal(userIdOfSession({}), "");
64
+ });
65
+
66
+ test("a live session is admitted", async () => {
67
+ assert.equal(await authoriseSocketSession({ auth: { sid: SID } }, store), USER);
68
+ });
69
+
70
+ test("an anonymous socket is refused - the whole point of the new namespace", async () => {
71
+ await assert.rejects(() => authoriseSocketSession({}, store), /Unauthorized/);
72
+ });
73
+
74
+ test("an expired or invented session id is refused", async () => {
75
+ await assert.rejects(
76
+ () => authoriseSocketSession({ auth: { sid: "not-real" } }, store),
77
+ /Unauthorized/,
78
+ );
79
+ });
80
+
81
+ test("a session that resolves to nobody is refused", async () => {
82
+ await assert.rejects(
83
+ () => authoriseSocketSession({ auth: { sid: SID } }, async () => ({})),
84
+ /Unauthorized/,
85
+ );
86
+ });
87
+
88
+ test("a session store that is DOWN refuses, it does not let everyone in", async () => {
89
+ // Fail closed here: this is an authentication decision, not a feature flag.
90
+ await assert.rejects(
91
+ () =>
92
+ authoriseSocketSession({ auth: { sid: SID } }, async () => {
93
+ throw new Error("redis is unreachable");
94
+ }),
95
+ /Unauthorized/,
96
+ );
97
+ });
98
+
99
+ test("every refusal carries the SAME message", async () => {
100
+ const messages = [];
101
+ for (const [handshake, read] of [
102
+ [{}, store],
103
+ [{ auth: { sid: "not-real" } }, store],
104
+ [{ auth: { sid: SID } }, async () => ({})],
105
+ [
106
+ { auth: { sid: SID } },
107
+ async () => {
108
+ throw new Error("redis is unreachable");
109
+ },
110
+ ],
111
+ ]) {
112
+ await authoriseSocketSession(handshake, read).catch((error) =>
113
+ messages.push(error.message),
114
+ );
115
+ }
116
+
117
+ assert.equal(messages.length, 4);
118
+ assert.deepEqual([...new Set(messages)], ["Unauthorized"]);
119
+ });