@7365admin1/core 3.69.0 → 3.71.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.69.0",
4
+ "version": "3.71.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -43,6 +43,7 @@
43
43
  "exceljs": "^4.4.0",
44
44
  "express": "^4.21.2",
45
45
  "fast-levenshtein": "^3.0.0",
46
+ "handlebars": "^4.7.8",
46
47
  "iservice365-expo-notifications": "^1.0.0",
47
48
  "joi": "^17.13.3",
48
49
  "jsonwebtoken": "^9.0.2",
@@ -62,5 +63,6 @@
62
63
  "xlsx": "^0.18.5",
63
64
  "xml2js": "^0.6.2",
64
65
  "zod": "^3.24.2"
65
- }
66
+ },
67
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
66
68
  }
@@ -319,6 +319,7 @@ function buildApp(nsu, core) {
319
319
  updateRoleById,
320
320
  updateSiteById,
321
321
  completeOnboarding,
322
+ getModulesByUserIdType,
322
323
  } = core.useMemberController();
323
324
 
324
325
  members.post("/verification/:id", requireAuth, createMember);
@@ -332,6 +333,7 @@ function buildApp(nsu, core) {
332
333
  members.put("/site", requireAuth, updateSiteById);
333
334
  members.put("/:id/onboarding/complete", requireAuth, completeOnboarding);
334
335
  members.get("/user/:id/app/:type/org", requireAuth, getByUserIdTypeOrg);
336
+ members.get("/user/:id/app/:type/modules", requireAuth, getModulesByUserIdType);
335
337
  }
336
338
 
337
339
  // mirrors iservice365-API-core/src/routes/organization-v2.route.ts
@@ -596,6 +598,8 @@ function buildApp(nsu, core) {
596
598
  updateGuardPostsById,
597
599
  siteInformation,
598
600
  getAllSitesForResidentCreation,
601
+ updateSiteModules,
602
+ previewSiteModules,
599
603
  } = core.useSiteController();
600
604
 
601
605
  sites.post("/", requireAuth, createSite);
@@ -604,6 +608,8 @@ function buildApp(nsu, core) {
604
608
  sites.put("/:id/block", requireAuth, updateSiteBlock);
605
609
  sites.delete("/:id", requireAuth, deleteSite);
606
610
  sites.patch("/id/:id", requireAuth, updateById);
611
+ sites.patch("/:id/modules", requireAuth, updateSiteModules);
612
+ sites.post("/:id/modules/preview", requireAuth, previewSiteModules);
607
613
  sites.patch("/guard-post/id/:id", requireAuth, updateGuardPostsById);
608
614
  sites.patch("/information/id/:id", requireAuth, siteInformation);
609
615
  sites.get("/site/resident", getAllSitesForResidentCreation); // no requireAuth — as shipped
@@ -0,0 +1,111 @@
1
+ // GET /api/members?org=: belonging to the organisation is not belonging to every
2
+ // site in it. A guard pinned to site A sees site A's members only; a request for
3
+ // site B is refused; the organisation's own admin and Seven365 staff see all;
4
+ // a resident sees none.
5
+ //
6
+ // In-process MongoDB, loopback Redis and a loopback mail sink only (harness.mjs).
7
+ //
8
+ // Run with: yarn test:e2e
9
+
10
+ import { after, before, describe, it } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { ObjectId } from "mongodb";
13
+
14
+ import { startHarness } from "./harness.mjs";
15
+
16
+ const PASSWORD = "MemberListScope-Passw0rd!";
17
+ const ADMIN = "admin-mls@e2e.example.com";
18
+ const GUARD_A = "guard-a-mls@e2e.example.com";
19
+ const GUARD_B = "guard-b-mls@e2e.example.com";
20
+ const RESIDENT = "resident-mls@e2e.example.com";
21
+ const STAFF = "staff-mls@e2e.example.com";
22
+
23
+ describe("Member lists follow the caller's sites", { concurrency: 1 }, () => {
24
+ let h;
25
+ const id = {};
26
+ const sid = {};
27
+
28
+ before(async () => {
29
+ h = await startHarness();
30
+ Object.assign(id, await seed(h));
31
+ for (const email of [ADMIN, GUARD_A, GUARD_B, RESIDENT, STAFF]) sid[email] = await h.login(email, PASSWORD);
32
+ }, { timeout: 300000 });
33
+
34
+ after(async () => {
35
+ if (h) await h.stop();
36
+ });
37
+
38
+ const list = (who, extra = "") =>
39
+ h.api(`/members?org=${id.org}&type=security_agency&status=active&limit=50${extra}`, { sid: sid[who] });
40
+ const emails = (res) => (res.body?.items ?? []).map((row) => row.email).sort();
41
+
42
+ it("a guard pinned to site A lists only site A's members", async () => {
43
+ const res = await list(GUARD_A);
44
+ assert.equal(res.status, 200, JSON.stringify(res.body));
45
+ assert.deepEqual(emails(res), [GUARD_A]);
46
+ });
47
+
48
+ it("a guard asking for a site they are not pinned to is refused", async () => {
49
+ const res = await list(GUARD_A, `&siteId=${id.siteB}`);
50
+ assert.equal(res.status, 401, JSON.stringify(res.body));
51
+ const own = await list(GUARD_A, `&siteId=${id.siteA}`);
52
+ assert.equal(own.status, 200, JSON.stringify(own.body));
53
+ assert.deepEqual(emails(own), [GUARD_A]);
54
+ });
55
+
56
+ it("CONTROL: the organisation's admin and Seven365 staff list every site", async () => {
57
+ for (const who of [ADMIN, STAFF]) {
58
+ const res = await list(who);
59
+ assert.equal(res.status, 200, `${who}: ${JSON.stringify(res.body)}`);
60
+ assert.deepEqual(emails(res), [GUARD_A, GUARD_B].sort(), who);
61
+ }
62
+ });
63
+
64
+ it("a resident of the organisation lists no staff", async () => {
65
+ const res = await list(RESIDENT);
66
+ assert.equal(res.status, 200, JSON.stringify(res.body));
67
+ assert.deepEqual(emails(res), []);
68
+ });
69
+ });
70
+
71
+ async function seed(h) {
72
+ const now = new Date().toISOString();
73
+ const org = new ObjectId();
74
+ const siteA = new ObjectId();
75
+ const siteB = new ObjectId();
76
+ const adminRole = new ObjectId();
77
+ const guardRoleA = new ObjectId();
78
+ const guardRoleB = new ObjectId();
79
+ const residentRole = new ObjectId();
80
+ const staffRole = new ObjectId();
81
+
82
+ await h.db.collection("organizations").insertOne({ _id: org, name: "MLS Security", email: "org-mls@e2e.example.com", type: "org", nature: "security_agency", status: "active", createdAt: now });
83
+ await h.db.collection("sites").insertMany([
84
+ { _id: siteA, name: "MLS A", orgId: org, status: "active", createdAt: now },
85
+ { _id: siteB, name: "MLS B", orgId: org, status: "active", createdAt: now },
86
+ ]);
87
+ await h.db.collection("roles").insertMany([
88
+ { _id: adminRole, name: "owner", type: "organization", org, permissions: ["*"], status: "active" },
89
+ { _id: guardRoleA, name: "Guard A", type: "security_agency", org, site: siteA, permissions: ["*"], status: "active" },
90
+ { _id: guardRoleB, name: "Guard B", type: "security_agency", org, site: siteB, permissions: ["*"], status: "active" },
91
+ { _id: residentRole, name: "Resident", type: "resident", org, site: siteA, permissions: ["*"], status: "active" },
92
+ { _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
93
+ ]);
94
+
95
+ const users = await h.db.collection("users").insertMany(
96
+ [ADMIN, GUARD_A, GUARD_B, RESIDENT, STAFF].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
97
+ );
98
+ const hashed = await h.hashPassword(PASSWORD);
99
+ await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
100
+ const [admin, guardA, guardB, resident, staff] = Object.values(users.insertedIds);
101
+ const member = (user, role, type, email, siteId = "") => ({ user, org, role, type, siteId, siteName: "", name: email, email, status: "active", deletedAt: "", createdAt: now });
102
+ await h.db.collection("members").insertMany([
103
+ member(admin, adminRole, "organization", ADMIN),
104
+ member(guardA, guardRoleA, "security_agency", GUARD_A, siteA),
105
+ member(guardB, guardRoleB, "security_agency", GUARD_B, siteB),
106
+ member(resident, residentRole, "resident", RESIDENT, siteA),
107
+ { user: staff, type: "admin", role: staffRole, status: "active", name: STAFF, email: STAFF, createdAt: now },
108
+ ]);
109
+
110
+ return { org, siteA, siteB };
111
+ }
@@ -0,0 +1,139 @@
1
+ // Per-site modules, set by the client's own owner/admin (owner, 2026-09-14):
2
+ // Seven365 gives the organisation its modules; the organisation's owner/admin
3
+ // decides which of those each site gets; the site's staff get only that.
4
+ //
5
+ // - `PATCH /api/sites/:id/modules` and its preview need Seven365 staff or the
6
+ // caller's ORGANISATION membership with `*` or `sites:manage-site-modules`.
7
+ // Reaching the site (a guard pinned to it) is not enough.
8
+ // - A key the organisation was not given is refused.
9
+ // - An acknowledged save by the client admin is ENFORCED: the site's staff are
10
+ // denied what the site list leaves out.
11
+ //
12
+ // In-process MongoDB, loopback Redis and a loopback mail sink only (harness.mjs).
13
+ //
14
+ // Run with: yarn test:e2e
15
+
16
+ import { after, before, describe, it } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { createHash } from "node:crypto";
19
+ import { ObjectId } from "mongodb";
20
+
21
+ import { startHarness } from "./harness.mjs";
22
+
23
+ const PASSWORD = "SiteModules-Passw0rd!";
24
+ const OWNER = "owner-smca@e2e.example.com";
25
+ const ADMIN_WITH = "admin-with-smca@e2e.example.com";
26
+ const ADMIN_WITHOUT = "admin-without-smca@e2e.example.com";
27
+ const GUARD = "guard-smca@e2e.example.com";
28
+
29
+ const ORG_MODULES = ["visitor-mgmt", "work_orders", "members", "roles", "invitations", "sites"];
30
+ const SITE_LIST = ["visitor-mgmt"];
31
+
32
+ const listHash = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex");
33
+
34
+ describe("A client's owner/admin sets which modules each site gets", { concurrency: 1 }, () => {
35
+ let h;
36
+ const id = {};
37
+ const sid = {};
38
+
39
+ before(async () => {
40
+ h = await startHarness();
41
+ Object.assign(id, await seed(h));
42
+ for (const email of [OWNER, ADMIN_WITH, ADMIN_WITHOUT, GUARD]) sid[email] = await h.login(email, PASSWORD);
43
+ }, { timeout: 300000 });
44
+
45
+ after(async () => {
46
+ if (h) await h.stop();
47
+ });
48
+
49
+ const save = (who, body) =>
50
+ h.api(`/sites/${id.site}/modules`, { method: "PATCH", sid: sid[who], body });
51
+ const preview = (who, body) =>
52
+ h.api(`/sites/${id.site}/modules/preview`, { method: "POST", sid: sid[who], body });
53
+ const stored = async () => (await h.db.collection("sites").findOne({ _id: id.site }))?.metadata?.modules;
54
+
55
+ it("a guard at the site, or an admin without the permission, is refused and nothing is written", async () => {
56
+ for (const who of [GUARD, ADMIN_WITHOUT]) {
57
+ const res = await save(who, { modules: SITE_LIST, acknowledgeImpact: true });
58
+ assert.equal(res.status, 401, `${who}: ${JSON.stringify(res.body)}`);
59
+ const peek = await preview(who, { modules: SITE_LIST });
60
+ assert.equal(peek.status, 401, `${who} preview: ${JSON.stringify(peek.body)}`);
61
+ }
62
+ assert.equal(await stored(), undefined);
63
+ });
64
+
65
+ it("a module the organisation was not given is refused", async () => {
66
+ const res = await save(OWNER, { modules: [...SITE_LIST, "cleaning-schedule"], acknowledgeImpact: true });
67
+ assert.equal(res.status, 400, JSON.stringify(res.body));
68
+ assert.equal(await stored(), undefined);
69
+ });
70
+
71
+ it("the admin holding the permission can preview", async () => {
72
+ const res = await preview(ADMIN_WITH, { modules: SITE_LIST });
73
+ assert.equal(res.status, 200, JSON.stringify(res.body));
74
+ });
75
+
76
+ it("the owner's narrowing save needs acknowledging, then is enforced for the site's staff", async () => {
77
+ const before = await h.api(`/members/user/${id.guardUser}/app/security_agency/modules?site=${id.site}`, { sid: sid[GUARD] });
78
+ assert.equal(before.status, 200, JSON.stringify(before.body));
79
+ assert.ok(!JSON.stringify(before.body).includes("work_orders"), "CONTROL: nothing denied before the save");
80
+
81
+ const unacknowledged = await save(OWNER, { modules: SITE_LIST });
82
+ assert.equal(unacknowledged.status, 409, JSON.stringify(unacknowledged.body));
83
+ assert.equal(await stored(), undefined, "a refused save writes nothing");
84
+
85
+ const ok = await save(OWNER, { modules: SITE_LIST, acknowledgeImpact: true });
86
+ assert.equal(ok.status, 200, JSON.stringify(ok.body));
87
+ assert.deepEqual(await stored(), SITE_LIST);
88
+
89
+ const row = await h.db
90
+ .collection("console-audit")
91
+ .findOne({ action: "site.modules-changed", target: id.site.toString() }, { sort: { createdAt: -1 } });
92
+ assert.equal(row?.after?.gateListHash, listHash(SITE_LIST), "the client admin's save is the enforcement marker");
93
+
94
+ const after = await h.api(`/members/user/${id.guardUser}/app/security_agency/modules?site=${id.site}`, { sid: sid[GUARD] });
95
+ assert.equal(after.status, 200, JSON.stringify(after.body));
96
+ assert.ok(JSON.stringify(after.body).includes("work_orders"), `the guard loses what the site left out: ${JSON.stringify(after.body)}`);
97
+ });
98
+ });
99
+
100
+ async function seed(h) {
101
+ const now = new Date().toISOString();
102
+ const org = new ObjectId();
103
+ const site = new ObjectId();
104
+ const ownerRole = new ObjectId();
105
+ const withRole = new ObjectId();
106
+ const withoutRole = new ObjectId();
107
+ const guardRole = new ObjectId();
108
+
109
+ await h.db.collection("organizations").insertOne({
110
+ _id: org, name: "SMCA Security", email: "org-smca@e2e.example.com", type: "org", nature: "security_agency",
111
+ status: "active", modules: ORG_MODULES, createdAt: now,
112
+ });
113
+ await h.db.collection("sites").insertOne({ _id: site, name: "SMCA Tower", orgId: org, status: "active", createdAt: now });
114
+ await h.db.collection("roles").insertMany([
115
+ { _id: ownerRole, name: "owner", type: "organization", org, permissions: ["*"], status: "active" },
116
+ { _id: withRole, name: "Site modules admin", type: "organization", org, permissions: ["sites:manage-site-modules"], status: "active" },
117
+ { _id: withoutRole, name: "Sites viewer", type: "organization", org, permissions: ["sites:see-all-sites"], status: "active" },
118
+ { _id: guardRole, name: "Guard", type: "security_agency", org, site, permissions: ["*"], status: "active" },
119
+ ]);
120
+
121
+ const users = await h.db.collection("users").insertMany(
122
+ [OWNER, ADMIN_WITH, ADMIN_WITHOUT, GUARD].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
123
+ );
124
+ const hashed = await h.hashPassword(PASSWORD);
125
+ await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
126
+
127
+ const [owner, withUser, withoutUser, guard] = Object.values(users.insertedIds);
128
+ const member = (user, role, type, siteId = "") => ({
129
+ user, org, role, type, siteId, siteName: "", name: "", email: "", status: "active", deletedAt: "", createdAt: now,
130
+ });
131
+ await h.db.collection("members").insertMany([
132
+ member(owner, ownerRole, "organization"),
133
+ member(withUser, withRole, "organization"),
134
+ member(withoutUser, withoutRole, "organization"),
135
+ member(guard, guardRole, "security_agency", site),
136
+ ]);
137
+
138
+ return { org, site, guardUser: guard };
139
+ }
@@ -0,0 +1,30 @@
1
+ // GET /api/members?org=: a caller pinned to sites lists only those sites' members
2
+ // (owner policy: agency staff see only their assigned site).
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+
6
+ import { memberListSiteScope } from "./.build/utils/member-lookup.util.mjs";
7
+
8
+ const ORG = "aaaaaaaaaaaaaaaaaaaaaaaa";
9
+ const OTHER = "bbbbbbbbbbbbbbbbbbbbbbbb";
10
+ const A = "111111111111111111111111";
11
+ const B = "222222222222222222222222";
12
+ const m = (org, siteId, type = "security_agency", orgLevelRole = false) => ({ org, siteId, type, orgLevelRole });
13
+
14
+ test("an organisation-wide membership, or an organisation-level role, lists every site", () => {
15
+ assert.equal(memberListSiteScope([m(ORG, "", "organization")], ORG), null);
16
+ assert.equal(memberListSiteScope([m(ORG, A, "security_agency", true)], ORG), null);
17
+ });
18
+
19
+ test("a caller pinned to sites lists only those sites", () => {
20
+ assert.deepEqual(memberListSiteScope([m(ORG, A), m(ORG, B), m(ORG, A, "cleaning_services")], ORG), [A, B]);
21
+ });
22
+
23
+ test("memberships in another organisation do not widen this one", () => {
24
+ assert.deepEqual(memberListSiteScope([m(ORG, A), m(OTHER, "", "organization")], ORG), [A]);
25
+ });
26
+
27
+ test("a resident-only caller lists nothing; an owner or invitee with no membership keeps today's answer", () => {
28
+ assert.deepEqual(memberListSiteScope([m(ORG, A, "resident")], ORG), []);
29
+ assert.equal(memberListSiteScope([], ORG), null);
30
+ });
@@ -362,10 +362,13 @@ const RESIDENT = {
362
362
  memberships: [{ org: ORG, siteId: SITE, orgLevelRole: false, type: "resident" }],
363
363
  };
364
364
  const STAFF = { ...ESTATE, isSuperAdmin: true, staffPermissions: [], orgIds: [], memberships: [] };
365
+ // The client's owner/admin: the estate member whose organisation row holds the permission.
366
+ const CLIENT_ADMIN = { ...ESTATE, id: "client-admin" };
365
367
 
366
368
  function siteHandlers(d, actor) {
367
369
  return siteModulesHandlers({
368
370
  ...d.shared,
371
+ canManageSiteModules: async (who) => (d.log.push("manage"), who === STAFF || who === CLIENT_ADMIN),
369
372
  requireSiteWrite: async () => {
370
373
  d.log.push("guard");
371
374
  if (!actor) throw refused();
@@ -438,18 +441,30 @@ test("updateSiteModules: Seven365 staff's narrowing save gets 409 before any wri
438
441
  assert.equal(d.audit[0].after[HASH].length, 64);
439
442
  });
440
443
 
441
- test("updateSiteModules: anyone but staff (estate member, resident, engaged provider) still saves, but writes no marker", async () => {
444
+ test("updateSiteModules: an estate member without the permission, a resident or an engaged provider is refused and nothing is written", async () => {
442
445
  for (const [who, actor] of [["estate member", ESTATE], ["resident", RESIDENT], ["provider", VENDOR]]) {
443
446
  const d = deps({ stored: undefined });
444
447
  const { updateSiteModules } = siteHandlers(d, actor);
445
448
  const out = await call(updateSiteModules, { params: { id: SITE }, body: { modules: NARROW, acknowledgeImpact: true } });
446
- assert.equal(out.error, undefined, `${who}: the existing save is not narrowed`);
447
- assert.deepEqual(d.writes(), ["write", "audit"], who);
448
- assert.equal(d.audit[0].after[HASH], undefined, `${who}: the list can never be enforced`);
449
+ assert.equal(out.error?.statusCode, 401, who);
450
+ assert.deepEqual(d.writes(), [], `${who}: nothing written`);
449
451
  assert.equal(d.calls.staffMembers ?? 0, 0, `${who}: the estate's staff are never read for it`);
450
452
  }
451
453
  });
452
454
 
455
+ test("updateSiteModules: the client's owner/admin gets the same 409-then-marker as staff (owner rule 2026-09-14)", async () => {
456
+ const d = deps({ stored: undefined });
457
+ const { updateSiteModules } = siteHandlers(d, CLIENT_ADMIN);
458
+ const out = await call(updateSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
459
+ assert.equal(out.error?.statusCode, 409);
460
+ assert.deepEqual(d.writes(), []);
461
+
462
+ const ok = await call(updateSiteModules, { params: { id: SITE }, body: { modules: NARROW, acknowledgeImpact: true } });
463
+ assert.equal(ok.error, undefined);
464
+ assert.deepEqual(d.writes(), ["write", "audit"]);
465
+ assert.equal(d.audit[0].after[HASH].length, 64, "the client admin's save enforces the list");
466
+ });
467
+
453
468
  test("updateSiteModules: re-saving the stored site list unchanged needs no acknowledgement and writes no marker", async () => {
454
469
  const d = deps({ stored: NARROW });
455
470
  const out = await call(siteHandlers(d, STAFF).updateSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
@@ -457,20 +472,20 @@ test("updateSiteModules: re-saving the stored site list unchanged needs no ackno
457
472
  assert.equal(d.audit[0].after[HASH], undefined);
458
473
  });
459
474
 
460
- test("previewSiteModules: an engagement-only provider or a resident is refused before any member is read", async () => {
461
- for (const [who, actor] of [["provider", VENDOR], ["resident", RESIDENT]]) {
475
+ test("previewSiteModules: anyone who may not manage the site's modules is refused before any member is read", async () => {
476
+ for (const [who, actor] of [["provider", VENDOR], ["resident", RESIDENT], ["estate member without the permission", ESTATE]]) {
462
477
  const d = deps({ stored: undefined });
463
478
  const out = await call(siteHandlers(d, actor).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
464
479
  assert.equal(out.error?.statusCode, 401, who);
465
480
  assert.equal(out.json, undefined, `${who}: no staff names or roles leave`);
466
- assert.deepEqual(d.log, ["guard", "read"], who);
481
+ assert.deepEqual(d.log, ["guard", "read", "manage"], who);
467
482
  assert.deepEqual(d.calls, {}, who);
468
483
  }
469
484
 
470
485
  // A missing site answers as missing, like the rest of the site routes.
471
486
  const gone = deps();
472
487
  gone.missing = true;
473
- const missing = await call(siteHandlers(gone, ESTATE).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
488
+ const missing = await call(siteHandlers(gone, CLIENT_ADMIN).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
474
489
  assert.equal(missing.error?.statusCode, 404);
475
490
 
476
491
  // A caller the site guard refuses reads nothing at all.
@@ -479,7 +494,7 @@ test("previewSiteModules: an engagement-only provider or a resident is refused b
479
494
  assert.deepEqual(none.log, ["guard"]);
480
495
 
481
496
  // CONTROL: the estate's own member gets the preview, and nothing is written.
482
- for (const actor of [ESTATE, STAFF]) {
497
+ for (const actor of [CLIENT_ADMIN, STAFF]) {
483
498
  const ok = deps({ stored: undefined });
484
499
  const answer = await call(siteHandlers(ok, actor).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
485
500
  assert.ok(answer.json.impact.memberCount > 0);