@7365admin1/core 3.55.0 → 3.56.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.55.0",
4
+ "version": "3.56.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/invite-role-scope.util.ts src/models/personal-emergency-chain.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/invite-role-scope.util.ts src/models/personal-emergency-chain.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
  },
@@ -97,9 +97,11 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
97
97
  ownerSid = await h.login(OWNER, PASSWORD);
98
98
 
99
99
  assert.equal(
100
- await h.db.collection("members").countDocuments({ user: id.owner }),
100
+ await h.db
101
+ .collection("members")
102
+ .countDocuments({ user: id.owner, org: id.ownOrg }),
101
103
  0,
102
- "the owner must hold no membership — reach is by `createdBy` (DV-0248)",
104
+ "the owner must hold no membership of THIS organisation — reach is by `createdBy` (DV-0248)",
103
105
  );
104
106
  assert.equal((await ownOrg()).name, "Ben Org");
105
107
  });
@@ -160,6 +162,49 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
160
162
  assert.equal(saved.contact, "+63 9569346664");
161
163
  });
162
164
 
165
+ // ---- the second control: the legacy document shape is NOT the cause -------
166
+
167
+ record("5. control — the legacy production document shape saves unchanged", async () => {
168
+ // The 16 rows a lead read out of the production `organizations` collection
169
+ // carry the LEGACY `organization-api` field set — `contactNumber`, `sites`,
170
+ // `logo`, `createdBySpId` — and `createdAt`, `updatedAt` and `createdBy`
171
+ // are EMPTY STRINGS, not dates and not ObjectIds. The obvious suspicion is
172
+ // that saving over a row like that throws on a type: `createdAt: ""` where
173
+ // a Date is expected, `createdBy: ""` where an ObjectId is.
174
+ //
175
+ // It cannot. This repository talks to the raw `mongodb` driver — there is
176
+ // no Mongoose, no model layer and no cast step between `update()` and the
177
+ // server. `$set` names `name`, `email`, `contact` and `updatedAt` and
178
+ // nothing else, and MongoDB replaces a field wholesale whatever BSON type
179
+ // it held before. The legacy fields are never read, never re-validated and
180
+ // never rewritten.
181
+ //
182
+ // This case is the proof: same modal body, same route, a row in exactly
183
+ // that shape, no unique index on it — 200, and the legacy fields survive
184
+ // untouched.
185
+ const res = await h.api(`/organizations/${id.legacyOrg}`, {
186
+ method: "PUT",
187
+ sid: ownerSid,
188
+ body: modalBody("Legacy Shaped Org Renamed"),
189
+ });
190
+
191
+ assert.equal(res.status, 200, JSON.stringify(res.body));
192
+
193
+ const saved = await h.db
194
+ .collection("organizations")
195
+ .findOne({ _id: id.legacyOrg });
196
+
197
+ assert.equal(saved.name, "Legacy Shaped Org Renamed");
198
+ assert.equal(saved.contact, "+63 9569346664");
199
+ assert.ok(saved.updatedAt instanceof Date, "updatedAt is replaced by a Date");
200
+ // untouched, still exactly as the legacy service left them
201
+ assert.equal(saved.createdAt, "");
202
+ assert.equal(saved.createdBy, "");
203
+ assert.equal(saved.contactNumber, "+65 61234567");
204
+ assert.deepEqual(saved.sites, []);
205
+ assert.equal(saved.createdBySpId, "");
206
+ });
207
+
163
208
  record("4. and the guard is unchanged — no session is still refused", async () => {
164
209
  const res = await h.api(`/organizations/${id.ownOrg}`, {
165
210
  method: "PUT",
@@ -175,6 +220,7 @@ async function seed(h) {
175
220
  const now = new Date().toISOString();
176
221
  const otherOrg = new ObjectId();
177
222
  const ownOrg = new ObjectId();
223
+ const legacyOrg = new ObjectId();
178
224
 
179
225
  const orgs = h.db.collection("organizations");
180
226
 
@@ -202,6 +248,24 @@ async function seed(h) {
202
248
  contact: "+65 00000000",
203
249
  createdAt: now,
204
250
  },
251
+ // A row in the exact shape a lead read out of production: the legacy
252
+ // `organization-api` field set, with the three timestamps/ids as EMPTY
253
+ // STRINGS. Deliberately NOT covered by the unique index above — its name is
254
+ // its own — so case 5 measures the document shape and nothing else.
255
+ {
256
+ _id: legacyOrg,
257
+ name: "Legacy Shaped Org",
258
+ email: "os-legacy@e2e.example.com",
259
+ contactNumber: "+65 61234567",
260
+ description: "",
261
+ sites: [],
262
+ status: "active",
263
+ logo: [],
264
+ createdBySpId: "",
265
+ createdAt: "",
266
+ updatedAt: "",
267
+ createdBy: "",
268
+ },
205
269
  ]);
206
270
 
207
271
  const users = await h.db.collection("users").insertMany([
@@ -216,5 +280,14 @@ async function seed(h) {
216
280
  // holds at step 1.
217
281
  await orgs.updateOne({ _id: ownOrg }, { $set: { createdBy: owner } });
218
282
 
219
- return { owner, ownOrg, otherOrg };
283
+ // The legacy row's `createdBy` is "" — that road cannot reach it, so the
284
+ // owner reaches it the ordinary way, by membership.
285
+ await h.db.collection("members").insertOne({
286
+ user: owner,
287
+ org: legacyOrg,
288
+ type: "organization",
289
+ status: "active",
290
+ });
291
+
292
+ return { owner, ownOrg, otherOrg, legacyOrg };
220
293
  }
@@ -0,0 +1,195 @@
1
+ import { strict as assert } from "node:assert";
2
+ import test from "node:test";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ import {
6
+ PLATFORM_ONLY_PERMISSIONS,
7
+ platformPermissionsAdded,
8
+ refuseSharedClientTemplate,
9
+ requireNoPlatformGrant,
10
+ roleScope,
11
+ } from "./.build/utils/role-scope.util.mjs";
12
+
13
+ /*
14
+ * The 2026-09-07 outage, reproduced as data.
15
+ *
16
+ * "Org Owner": `type: "organization"`, `org: ""`, 117 live members, 321
17
+ * invitations. The console editor pre-ticks its own catalogue for a
18
+ * `default: true` role and saves it -- 34 platform strings, no client module.
19
+ */
20
+ const ORG_OWNER = { type: "organization", org: "", permissions: ["*"] };
21
+
22
+ /** Exactly what `useAdminPermission` expands to: 24 + members 5 + roles 5. */
23
+ const CONSOLE_SAVE = [
24
+ "organizations:see-all-organizations",
25
+ "organizations:see-organization-details",
26
+ "promo-codes:create-promo-code",
27
+ "promo-codes:see-promo-code-details",
28
+ "promo-codes:edit-promo-code-details",
29
+ "promo-codes:change-promo-code-status",
30
+ "promo-codes:delete-promo-code",
31
+ "users:see-all-users",
32
+ "users:see-user-details",
33
+ "invitations:create-invitation",
34
+ "invitations:view-invitations",
35
+ "invitations:cancel-invitation",
36
+ "subscriptions:see-all-subscriptions",
37
+ "subscriptions:see-subscription-details",
38
+ "subscriptions:manage-subscription",
39
+ "sp-approvals:see-all-sp-approvals",
40
+ "sp-approvals:approve-sp",
41
+ "sp-approvals:reject-sp",
42
+ "sp-approvals:delete-sp-approval",
43
+ "marketplace-vendors:see-all-marketplace-vendors",
44
+ "marketplace-vendors:see-marketplace-vendor-details",
45
+ "platform-terms:see-platform-terms",
46
+ "platform-terms:edit-platform-terms",
47
+ "activity-history:see-activity-history",
48
+ "members:view-members",
49
+ "members:assign-member-role",
50
+ "members:suspend-member",
51
+ "members:activate-member",
52
+ "members:delete-member",
53
+ "roles-and-permissions:add-role",
54
+ "roles-and-permissions:see-all-roles",
55
+ "roles-and-permissions:see-role-details",
56
+ "roles-and-permissions:update-role",
57
+ "roles-and-permissions:delete-role",
58
+ ];
59
+
60
+ test("the save that caused the outage is 34 strings", () => {
61
+ assert.equal(CONSOLE_SAVE.length, 34);
62
+ });
63
+
64
+ test("a role's scope is read off type and org, and nothing else", () => {
65
+ assert.equal(roleScope({ type: "admin", org: "" }), "platform");
66
+ assert.equal(roleScope({ type: "admin", org: "6512ab" }), "platform");
67
+ assert.equal(roleScope(ORG_OWNER), "client-template");
68
+ assert.equal(roleScope({ type: "security_agency", org: "" }), "client-template");
69
+ assert.equal(roleScope({ type: "organization", org: "6512ab" }), "client");
70
+ assert.equal(roleScope({ type: "", org: "6512ab" }), "client");
71
+ // ObjectId, not a string
72
+ assert.equal(
73
+ roleScope({ type: "organization", org: { toString: () => "6512ab" } }),
74
+ "client",
75
+ );
76
+ });
77
+
78
+ test("THE OUTAGE: a console save cannot strip a shared template", () => {
79
+ assert.throws(
80
+ () => refuseSharedClientTemplate(ORG_OWNER),
81
+ /shared by every client/,
82
+ );
83
+ });
84
+
85
+ test("POSITIVE CONTROL: the writes that must still work are not refused", () => {
86
+ // a real platform staff role -- the console's own
87
+ assert.doesNotThrow(() =>
88
+ refuseSharedClientTemplate({ type: "admin", org: "", permissions: [] }),
89
+ );
90
+ // a client's own role, in its own org
91
+ assert.doesNotThrow(() =>
92
+ refuseSharedClientTemplate({ type: "organization", org: "6512ab" }),
93
+ );
94
+ assert.doesNotThrow(() =>
95
+ refuseSharedClientTemplate({ type: "security_agency", org: "6512ab" }),
96
+ );
97
+ });
98
+
99
+ test("a client editor cannot grant a platform resource", () => {
100
+ assert.throws(
101
+ () =>
102
+ requireNoPlatformGrant({ type: "organization", org: "6512ab", permissions: [] }, [
103
+ "members:view-members",
104
+ "organizations:see-all-organizations",
105
+ ]),
106
+ /Seven365 console permissions/,
107
+ );
108
+ });
109
+
110
+ test("the console CAN still grant them on a platform role", () => {
111
+ assert.doesNotThrow(() =>
112
+ requireNoPlatformGrant({ type: "admin", org: "", permissions: [] }, CONSOLE_SAVE),
113
+ );
114
+ });
115
+
116
+ test("NEVER refuses a save that works today: held strings pass through", () => {
117
+ // 114 org-owned roles hold a `subscriptions:*` string. Re-saving one is not
118
+ // an addition, so it is allowed even though the resource name collides.
119
+ const held = ["subscriptions:see-all-subscriptions", "members:view-members"];
120
+ assert.doesNotThrow(() =>
121
+ requireNoPlatformGrant({ type: "organization", org: "6512ab", permissions: held }, held),
122
+ );
123
+ assert.deepEqual(platformPermissionsAdded(held, held), []);
124
+ });
125
+
126
+ test("the ORG spelling of subscriptions is not a platform grant", () => {
127
+ // `useOrgPermission` spells them differently. Refusing the bare resource
128
+ // would have locked 114 roles out of their own editor.
129
+ const org = [
130
+ "subscriptions:management-subscription",
131
+ "subscriptions:view-subscription-details",
132
+ ];
133
+ assert.deepEqual(platformPermissionsAdded(org, []), []);
134
+ assert.doesNotThrow(() =>
135
+ requireNoPlatformGrant({ type: "organization", org: "6512ab", permissions: [] }, org),
136
+ );
137
+ });
138
+
139
+ test("the four shared resource names are deliberately NOT refused", () => {
140
+ for (const permission of [
141
+ "users:see-all-users",
142
+ "invitations:view-invitations",
143
+ "members:view-members",
144
+ "roles-and-permissions:add-role",
145
+ "roles:add-role",
146
+ ]) {
147
+ assert.equal(
148
+ PLATFORM_ONLY_PERMISSIONS.has(permission),
149
+ false,
150
+ permission + " must stay grantable on a client role",
151
+ );
152
+ }
153
+ });
154
+
155
+ test("POSITIVE CONTROL: the refusal set is not empty and does bite", () => {
156
+ // 19, not the console catalogue's 34: `users` (2), `invitations` (3),
157
+ // `members` (5) and `roles-and-permissions` (5) are shared resource names and
158
+ // are deliberately left grantable on a client role.
159
+ assert.equal(PLATFORM_ONLY_PERMISSIONS.size, 19);
160
+ const added = platformPermissionsAdded(CONSOLE_SAVE, []);
161
+ assert.equal(added.length, 19);
162
+ });
163
+
164
+ test("an empty or absent permission list is never a platform grant", () => {
165
+ assert.doesNotThrow(() =>
166
+ requireNoPlatformGrant({ type: "organization", org: "6512ab" }, []),
167
+ );
168
+ assert.doesNotThrow(() =>
169
+ requireNoPlatformGrant({ type: "organization", org: "6512ab" }, null),
170
+ );
171
+ assert.doesNotThrow(() =>
172
+ requireNoPlatformGrant({ type: "organization", org: "6512ab" }, ["*"]),
173
+ );
174
+ });
175
+
176
+ test("the lockout check's copy of the platform list cannot drift", () => {
177
+ /*
178
+ * `tools/role-scope-lockout-check/check.js` is plain CommonJS run against a
179
+ * live database, so it cannot import the TypeScript guard. It reads a JSON
180
+ * copy instead. Two copies of a permission catalogue is what drifted last
181
+ * time (`useAdminPermission` was 107 lines apart between two apps), so the
182
+ * copy is asserted here rather than trusted.
183
+ */
184
+ const onDisk = JSON.parse(
185
+ readFileSync(
186
+ new URL(
187
+ "../tools/role-scope-lockout-check/platform-only-permissions.json",
188
+ import.meta.url,
189
+ ),
190
+ "utf8",
191
+ ),
192
+ );
193
+
194
+ assert.deepEqual(onDisk, [...PLATFORM_ONLY_PERMISSIONS].sort());
195
+ });
@@ -112,6 +112,21 @@ test("/api/roles/v2 is gated too — the same hole with /v2 on the URL", () => {
112
112
  );
113
113
  });
114
114
 
115
+ /**
116
+ * The four true WRITES now go through `requireRoleWrite`, which is
117
+ * `requireRoleOrg` plus one refusal: a role with no `org` and a non-admin type
118
+ * is a template every client shares, and the console must not rewrite it. That
119
+ * is the 2026-09-07 outage. `getDeletionPreview` is a read and deliberately
120
+ * stays on `requireRoleOrg` — see `role-scope-separation.test.mjs`.
121
+ */
122
+ const WRITE_GATE = {
123
+ updateRole: /await requireRoleWrite\(req, existing/,
124
+ updatePermissionsById: /await requireRoleWrite\(req, existing/,
125
+ deleteRole: /await requireRoleWrite\(req, existing/,
126
+ deleteWithReassignments: /await requireRoleWrite\(req, existing/,
127
+ getDeletionPreview: /await requireRoleOrg\(req, existing\.org\)/,
128
+ };
129
+
115
130
  test("every role write scopes on the STORED role's organisation", () => {
116
131
  for (const name of MUST_SCOPE) {
117
132
  const body = fn(controller, name);
@@ -123,14 +138,14 @@ test("every role write scopes on the STORED role's organisation", () => {
123
138
  );
124
139
  assert.match(
125
140
  body,
126
- /await requireRoleOrg\(req, existing\.org\)/,
141
+ WRITE_GATE[name],
127
142
  `${name} must scope on the stored role's org`,
128
143
  );
129
144
 
130
145
  // and it must not take the organisation from the caller instead
131
146
  assert.doesNotMatch(
132
147
  body,
133
- /requireRoleOrg\(req, (req\.body|req\.query|req\.params)/,
148
+ /requireRole(Org|Write)\(req, (req\.body|req\.query|req\.params)/,
134
149
  `${name} must not scope on an org the caller supplied`,
135
150
  );
136
151
  }
@@ -147,7 +162,7 @@ test("the gate runs before the write, not after it", () => {
147
162
 
148
163
  for (const [name, call] of Object.entries(writes)) {
149
164
  const body = fn(controller, name);
150
- const gate = body.indexOf("requireRoleOrg");
165
+ const gate = body.search(/requireRole(Org|Write)\(req/);
151
166
  const write = body.indexOf(call);
152
167
  assert.ok(gate !== -1 && write !== -1 && gate < write,
153
168
  `${name}: the gate must run before ${call}`);
@@ -163,6 +178,43 @@ test("an org-less role is platform-staff only", () => {
163
178
  assert.match(helper.slice(0, 500), /requireOrgAccess\(req, orgId\)/);
164
179
  });
165
180
 
181
+ test("the write gate refuses a shared client template before anything else", () => {
182
+ // The whole of the 2026-09-07 fix. `requireRoleWrite` must refuse a template
183
+ // FIRST; if it fell through to `requireRoleOrg` an org-less role would reach
184
+ // the staff console gate again, which is what let the console rewrite it.
185
+ const helper = controller.slice(
186
+ controller.indexOf("async function requireRoleWrite"),
187
+ );
188
+ const body = helper.slice(0, 500);
189
+
190
+ const refuse = body.indexOf("refuseSharedClientTemplate(role)");
191
+ const fallthrough = body.indexOf("requireRoleOrg(req");
192
+
193
+ assert.ok(refuse !== -1, "requireRoleWrite must refuse shared templates");
194
+ assert.ok(fallthrough !== -1, "and must still apply the org rule to the rest");
195
+ assert.ok(refuse < fallthrough, "the refusal must come first");
196
+ });
197
+
198
+ test("the permission-writing paths refuse a platform grant", () => {
199
+ for (const name of ["createRole", "updateRole", "updatePermissionsById"]) {
200
+ assert.match(
201
+ fn(controller, name),
202
+ /requireNoPlatformGrant\(/,
203
+ `${name} must refuse console permissions on a client role`,
204
+ );
205
+ }
206
+ });
207
+
208
+ test("READS are NOT routed through the write gate", () => {
209
+ // 117 live members read the org-less "Org Owner" on every session. Refusing
210
+ // them is the same outage from the other direction.
211
+ const helper = controller.slice(
212
+ controller.indexOf("async function requireRoleRead"),
213
+ );
214
+ assert.doesNotMatch(helper.slice(0, 400), /requireRoleWrite|refuseSharedClientTemplate/);
215
+ assert.match(helper.slice(0, 400), /holdsRole\(callerId\(req\), roleId\)/);
216
+ });
217
+
166
218
  test("no new handler arrives on this controller without a decision", () => {
167
219
  const known = new Set([...MUST_SCOPE, ...OPEN_BY_DESIGN]);
168
220
  const unknown = exported(controller).filter((name) => !known.has(name));