@7365admin1/core 3.61.0 → 3.62.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.
@@ -0,0 +1,331 @@
1
+ /*
2
+ * The 2026-09-07 repair tool -- what it will and will not touch.
3
+ *
4
+ * "Org Owner" is org-less and `type: "organization"`, so every client invited
5
+ * with it shares the one document. A console save wrote the 34 PLATFORM
6
+ * permission strings onto it and not one client module. An empty list means
7
+ * everything, so it granted everything right up to that save and nothing
8
+ * afterwards -- 123 live members and 322 invitations, all at once.
9
+ *
10
+ * The cause is closed at both ends. The document was never put back. This tool
11
+ * puts it back, and these tests are the whole of its judgement: `planRepair` is
12
+ * pure, so what it decides is exactly what is asserted here.
13
+ *
14
+ * The danger in a repair tool is not that it fails; it is that it repairs
15
+ * something it should not have. Most of what follows is about what it REFUSES.
16
+ */
17
+ import test from "node:test";
18
+ import assert from "node:assert/strict";
19
+
20
+ import {
21
+ planRepair,
22
+ isPlatformOnly,
23
+ PLATFORM_ONLY_RESOURCES,
24
+ } from "../tools/repair-client-template-permissions/repair.mjs";
25
+
26
+ // The multi-entry tsup build nests by source folder and emits `.mjs`, so this
27
+ // path is `./.build/utils/…`, not `./.build/…`. Getting it wrong fails to
28
+ // RESOLVE, which reads exactly like the assertion failing.
29
+ import { PLATFORM_ONLY_PERMISSIONS } from "./.build/utils/role-scope.util.mjs";
30
+
31
+ const ID = "69df7c8034293c971075ab97"; // the real "Org Owner" on staging
32
+
33
+ /** The 34 strings the incident actually wrote, as measured on staging. */
34
+ const PLATFORM_34 = [
35
+ "organizations:see-all-organizations",
36
+ "organizations:see-organization-details",
37
+ "promo-codes:create-promo-code",
38
+ "promo-codes:see-promo-code-details",
39
+ "promo-codes:edit-promo-code-details",
40
+ "promo-codes:change-promo-code-status",
41
+ "promo-codes:delete-promo-code",
42
+ "users:see-all-users",
43
+ "users:see-user-details",
44
+ "invitations:create-invitation",
45
+ "invitations:view-invitations",
46
+ "invitations:cancel-invitation",
47
+ "subscriptions:see-all-subscriptions",
48
+ "subscriptions:see-subscription-details",
49
+ "subscriptions:manage-subscription",
50
+ "sp-approvals:see-all-sp-approvals",
51
+ "sp-approvals:approve-sp",
52
+ "sp-approvals:reject-sp",
53
+ "sp-approvals:delete-sp-approval",
54
+ "marketplace-vendors:see-all-marketplace-vendors",
55
+ "marketplace-vendors:see-marketplace-vendor-details",
56
+ "platform-terms:see-platform-terms",
57
+ "platform-terms:edit-platform-terms",
58
+ "activity-history:see-activity-history",
59
+ "members:view-members",
60
+ "members:assign-member-role",
61
+ "members:suspend-member",
62
+ "members:activate-member",
63
+ "members:delete-member",
64
+ "roles-and-permissions:add-role",
65
+ "roles-and-permissions:see-all-roles",
66
+ "roles-and-permissions:see-role-details",
67
+ "roles-and-permissions:delete-role",
68
+ "roles-and-permissions:update-role",
69
+ ];
70
+
71
+ /* ── the drift guard ──────────────────────────────────────────────────── */
72
+
73
+ test("the tool's platform-only resources match role-scope.util.ts exactly", () => {
74
+ /*
75
+ * The tool duplicates the list so it runs with no build step. If the two ever
76
+ * disagree, the tool either misses a corrupt role or -- much worse -- judges
77
+ * an ordinary client role corrupt and rewrites it. This is the test that
78
+ * stops that, and it reads the SHIPPED source of truth, not a copy.
79
+ */
80
+ const fromSource = new Set(
81
+ [...PLATFORM_ONLY_PERMISSIONS].map((p) => String(p).split(":")[0]),
82
+ );
83
+ assert.deepEqual(
84
+ [...PLATFORM_ONLY_RESOURCES].sort(),
85
+ [...fromSource].sort(),
86
+ "the tool's resource list has drifted from role-scope.util.ts",
87
+ );
88
+ });
89
+
90
+ test("the four shared resource names are NOT treated as platform-only", () => {
91
+ // `users`, `invitations`, `members` and `roles-and-permissions` exist in BOTH
92
+ // catalogues. Treating them as platform-only would classify a perfectly
93
+ // ordinary client role as corrupt and rewrite it. This is the single most
94
+ // dangerous mistake this tool could make.
95
+ for (const shared of ["users", "invitations", "members", "roles-and-permissions"]) {
96
+ assert.equal(
97
+ isPlatformOnly(`${shared}:anything`),
98
+ false,
99
+ `${shared} must not count as platform-only`,
100
+ );
101
+ }
102
+ });
103
+
104
+ /* ── what it repairs ──────────────────────────────────────────────────── */
105
+
106
+ test("the real Org Owner document is REPORTED, never repaired unasked", () => {
107
+ // 19 of its 34 strings are platform-only; the other 15 are on the four SHARED
108
+ // resource names. Either way it is not repaired without a person naming it.
109
+ const plan = planRepair({
110
+ _id: ID,
111
+ name: "Org Owner",
112
+ type: "organization",
113
+ org: "",
114
+ permissions: PLATFORM_34,
115
+ });
116
+
117
+ assert.equal(plan.payload, undefined, "a mixed list must never be rewritten silently");
118
+ assert.equal(plan.review, true, "it must be raised for a human");
119
+ assert.match(plan.skip, /19 platform string\(s\) of 34/);
120
+ });
121
+
122
+ test("even an ENTIRELY-platform list is not repaired on the tool's own judgement", () => {
123
+ /*
124
+ * This is the correction the staging dry run forced. The first version
125
+ * repaired this case automatically, and over the real 462 roles that rule
126
+ * wanted to hand "Sub Member" -- one platform string, no holders -- the run
127
+ * of the whole platform. Restoring to `["*"]` asserts the role granted
128
+ * everything BEFORE the save, and no document says that. A person asserts it.
129
+ */
130
+ const purePlatform = PLATFORM_34.filter((p) => isPlatformOnly(p));
131
+ assert.equal(purePlatform.length, 19, "control: the fixture must be non-trivial");
132
+
133
+ const plan = planRepair({
134
+ _id: ID, name: "Sub Member", type: "organization", org: "",
135
+ permissions: purePlatform,
136
+ });
137
+
138
+ assert.equal(plan.payload, undefined);
139
+ assert.equal(plan.review, true);
140
+ });
141
+
142
+ /* ── what it refuses ──────────────────────────────────────────────────── */
143
+
144
+ test("it never touches a role that belongs to one organisation", () => {
145
+ const plan = planRepair({
146
+ _id: ID,
147
+ type: "organization",
148
+ org: "692132394150ca6a69b9f298",
149
+ permissions: PLATFORM_34.filter(isPlatformOnly),
150
+ });
151
+ assert.match(plan.skip, /belongs to one organisation/);
152
+ });
153
+
154
+ test("it never touches a Seven365 staff role - those SHOULD hold platform strings", () => {
155
+ const plan = planRepair({
156
+ _id: ID,
157
+ type: "admin",
158
+ org: "",
159
+ permissions: PLATFORM_34.filter(isPlatformOnly),
160
+ });
161
+ assert.match(plan.skip, /platform staff role/);
162
+ });
163
+
164
+ test("a role already granting everything is left alone, both spellings", () => {
165
+ const empty = planRepair({ _id: ID, type: "organization", org: "", permissions: [] });
166
+ assert.match(empty.skip, /empty list/);
167
+
168
+ const star = planRepair({ _id: ID, type: "organization", org: "", permissions: ["*"] });
169
+ assert.match(star.skip, /wildcard/);
170
+ });
171
+
172
+ test("an ordinary client template is left alone", () => {
173
+ const plan = planRepair({
174
+ _id: ID,
175
+ name: "Site Member",
176
+ type: "organization",
177
+ org: "",
178
+ permissions: ["visitor-mgmt:see-all-visitor", "work-order:see-all-work-orders"],
179
+ });
180
+ assert.match(plan.skip, /holds no platform strings/);
181
+ });
182
+
183
+ test("it fails closed on anything it cannot read", () => {
184
+ assert.match(planRepair(null).skip, /no usable id/);
185
+ assert.match(planRepair({ _id: "not-an-id" }).skip, /no usable id/);
186
+ assert.match(
187
+ planRepair({ _id: ID, type: "organization", org: "", permissions: null }).skip,
188
+ /unreadable/,
189
+ );
190
+ });
191
+
192
+ test("the repair is idempotent - a repaired role plans nothing on a second run", () => {
193
+ const role = {
194
+ _id: ID,
195
+ type: "organization",
196
+ org: "",
197
+ permissions: PLATFORM_34.filter(isPlatformOnly),
198
+ };
199
+ const first = planRepair(role, [ID]);
200
+ assert.ok(first.payload);
201
+
202
+ const after = { ...role, permissions: first.payload.permissions };
203
+ assert.equal(planRepair(after, [ID]).payload, undefined);
204
+ assert.match(planRepair(after, [ID]).skip, /wildcard/);
205
+ });
206
+
207
+ /* ── the human opt-in ─────────────────────────────────────────────────── */
208
+
209
+ test("a role is repaired only when a human names that exact id", () => {
210
+ /*
211
+ * This is how the real "Org Owner" gets repaired. Nothing in the document
212
+ * proves what it held before the save, so the tool will not decide -- but a
213
+ * person who has read it can, and naming the id is that decision recorded on
214
+ * the command line rather than assumed in code.
215
+ */
216
+ const role = {
217
+ _id: ID,
218
+ name: "Org Owner",
219
+ type: "organization",
220
+ org: "",
221
+ permissions: PLATFORM_34,
222
+ };
223
+
224
+ assert.equal(planRepair(role).payload, undefined, "unnamed: refused");
225
+
226
+ const named = planRepair(role, [ID]);
227
+ assert.deepEqual(named.payload, { permissions: ["*"] });
228
+ assert.equal(named.named, true);
229
+ assert.equal(named.removing, 34);
230
+ });
231
+
232
+ test("naming an id does NOT unlock the refusals that protect other roles", () => {
233
+ // The opt-in must widen exactly one thing: "this role may be restored".
234
+ // Every other guard has to survive it, or "--repair-role" becomes a way to
235
+ // damage a role by typing its id.
236
+ const org = planRepair(
237
+ { _id: ID, type: "organization", org: "692132394150ca6a69b9f298", permissions: PLATFORM_34 },
238
+ [ID],
239
+ );
240
+ assert.match(org.skip, /belongs to one organisation/);
241
+
242
+ const staff = planRepair({ _id: ID, type: "admin", org: "", permissions: PLATFORM_34 }, [ID]);
243
+ assert.match(staff.skip, /platform staff role/);
244
+
245
+ const ordinary = planRepair(
246
+ { _id: ID, type: "organization", org: "", permissions: ["visitor-mgmt:see-all-visitor"] },
247
+ [ID],
248
+ );
249
+ assert.match(ordinary.skip, /holds no platform strings/);
250
+ });
251
+
252
+ test("naming a DIFFERENT role leaves this one refused", () => {
253
+ const other = "690000000000000000000001";
254
+ const plan = planRepair(
255
+ { _id: ID, type: "organization", org: "", permissions: PLATFORM_34 },
256
+ [other],
257
+ );
258
+ assert.equal(plan.payload, undefined);
259
+ assert.equal(plan.review, true);
260
+ });
261
+
262
+ /* ── the browser snippet must decide identically ──────────────────────── */
263
+
264
+ /*
265
+ * `devtools-snippet.js` carries its own copy of the decision, because it runs
266
+ * pasted into a DevTools console with nothing to import. A copy that drifts
267
+ * from `planRepair` is the whole risk of having one, so the copy is lifted OUT
268
+ * of the shipped snippet here and run against the same fixtures.
269
+ */
270
+ async function snippetPlanRepair() {
271
+ const { readFileSync, writeFileSync, mkdirSync } = await import("node:fs");
272
+ const { join } = await import("node:path");
273
+ const { pathToFileURL } = await import("node:url");
274
+
275
+ const src = readFileSync(
276
+ new URL("../tools/repair-client-template-permissions/devtools-snippet.js", import.meta.url),
277
+ "utf8",
278
+ );
279
+
280
+ const consts = src.match(/const PLATFORM_ONLY = \[[\s\S]*?\];/);
281
+ const isPlat = src.match(/const isPlatformOnly = [^\n]+\n/);
282
+ const hex = src.match(/const HEX24 = [^\n]+\n/);
283
+ const fn = src.match(/function planRepair\(role, named = \[\]\) \{[\s\S]*?\n \}/);
284
+ assert.ok(consts && isPlat && hex && fn, "the snippet's decision block has moved");
285
+
286
+ const out = join(new URL("..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1"), "test", ".build");
287
+ mkdirSync(out, { recursive: true });
288
+ const file = join(out, `snippet-plan-${Date.now()}.mjs`);
289
+ writeFileSync(
290
+ file,
291
+ [consts[0], isPlat[0], hex[0], fn[0], "export { planRepair, PLATFORM_ONLY };"].join("\n"),
292
+ );
293
+ return import(pathToFileURL(file).href);
294
+ }
295
+
296
+ test("the DevTools snippet's platform list matches the source of truth", async () => {
297
+ const snippet = await snippetPlanRepair();
298
+ const fromSource = new Set(
299
+ [...PLATFORM_ONLY_PERMISSIONS].map((p) => String(p).split(":")[0]),
300
+ );
301
+ assert.deepEqual([...snippet.PLATFORM_ONLY].sort(), [...fromSource].sort());
302
+ });
303
+
304
+ test("the DevTools snippet decides exactly as planRepair does", async () => {
305
+ const snippet = await snippetPlanRepair();
306
+
307
+ const cases = [
308
+ ["the real Org Owner, unnamed", { _id: ID, type: "organization", org: "", permissions: PLATFORM_34 }, []],
309
+ ["the real Org Owner, named", { _id: ID, type: "organization", org: "", permissions: PLATFORM_34 }, [ID]],
310
+ ["all-platform, unnamed", { _id: ID, type: "organization", org: "", permissions: PLATFORM_34.filter(isPlatformOnly) }, []],
311
+ ["a staff role", { _id: ID, type: "admin", org: "", permissions: PLATFORM_34 }, [ID]],
312
+ ["an org-scoped role", { _id: ID, type: "organization", org: "692132394150ca6a69b9f298", permissions: PLATFORM_34 }, [ID]],
313
+ ["an ordinary template", { _id: ID, type: "organization", org: "", permissions: ["visitor-mgmt:see-all-visitor"] }, [ID]],
314
+ ["already wildcard", { _id: ID, type: "organization", org: "", permissions: ["*"] }, [ID]],
315
+ ["already empty", { _id: ID, type: "organization", org: "", permissions: [] }, [ID]],
316
+ ["a bad id", { _id: "nope", type: "organization", org: "", permissions: PLATFORM_34 }, []],
317
+ ];
318
+
319
+ for (const [label, role, named] of cases) {
320
+ const mine = planRepair(role, named);
321
+ const theirs = snippet.planRepair(role, named);
322
+ assert.deepEqual(
323
+ { payload: theirs.payload, review: theirs.review ?? undefined },
324
+ { payload: mine.payload, review: mine.review ?? undefined },
325
+ `${label}: the snippet and the tool disagree`,
326
+ );
327
+ }
328
+
329
+ // CONTROL: the comparison can fail — one of these really does plan a write.
330
+ assert.ok(snippet.planRepair({ _id: ID, type: "organization", org: "", permissions: PLATFORM_34 }, [ID]).payload);
331
+ });
@@ -108,6 +108,18 @@ const GUARDED = [
108
108
  "requirePlatformOwner",
109
109
  "_updateStatusById(",
110
110
  ],
111
+ // Setting a client's modules is the commercial decision Seven365 makes ABOUT
112
+ // a client, so it takes the STAFF gate plus the `organizations` module — not
113
+ // the client-reachable `update`, whose `requireOrgReach` would otherwise let
114
+ // a client decide its own entitlements by editing its own organisation.
115
+ // Owner decision 9 keeps the OWNER tier for suspend and Terms; choosing
116
+ // modules is "set up subscriptions" work, which is ordinary staff work.
117
+ [
118
+ "organization.controller.ts",
119
+ "updateModules",
120
+ "requireConsolePermission",
121
+ "_update(",
122
+ ],
111
123
  ["organization-v2.controller.ts", "getAll", "requireConsolePermission", "_getAll("],
112
124
  [
113
125
  "organization-v2.controller.ts",
@@ -167,7 +179,10 @@ const GUARDED = [
167
179
  "requireConsolePermission",
168
180
  "_softDeleteById(",
169
181
  ],
170
- ["platform-terms.controller.ts", "add", "requireConsolePermission", "_add("],
182
+ // Owner decision 9 (2026-08-20): only the OWNER may publish the platform
183
+ // Terms and Privacy Policy. Staff identity is not enough here, and the
184
+ // empty-permission-list rule meant an unticked staff role could do it.
185
+ ["platform-terms.controller.ts", "add", "requirePlatformOwner", "_add("],
171
186
  // The acceptance record as a list - every account on the platform and
172
187
  // whether it has consented. The fourth cross-tenant read, gated like the
173
188
  // others; the per-user `getStatus` below cannot answer it.
@@ -310,8 +325,13 @@ test("platform Terms are signed by the session, never by the request body", () =
310
325
  "the author is back to being whatever the caller typed",
311
326
  );
312
327
 
313
- const guard = b.search(/await requireConsolePermission\(req/);
328
+ // Owner decision 9: publishing platform Terms is OWNER-only, not staff, so
329
+ // the gate here is `requirePlatformOwner`. It still names the module as a
330
+ // second, narrowing condition -- the owner tier is checked first.
331
+ const guard = b.search(/await requirePlatformOwner\(req/);
314
332
  assert.ok(guard !== -1 && guard < b.indexOf("createdBy:"));
333
+ assert.match(b, /resource: "platform-terms"/);
334
+ assert.match(b, /action: "edit-platform-terms"/);
315
335
  });
316
336
 
317
337
  test("a promo code is removed, never deleted", () => {
@@ -102,10 +102,40 @@ export function planForOrg(org, existingRoleCount) {
102
102
  // accept (see the test, which pins this delta on purpose).
103
103
  const owner = { org: id, name: "owner", permissions: ["*"] };
104
104
 
105
- const payloads = [{ ...owner, type: "organization" }];
105
+ // Kept in step with `ADMINISTRATOR_PERMISSIONS` in
106
+ // `src/utils/org-default-roles.util.ts` -- duplicated so this tool needs no
107
+ // build step, and pinned by `test/backfill-org-owner-roles.test.mjs`, which
108
+ // compares these payloads against `defaultOwnerRoles` string for string.
109
+ const administrator = {
110
+ org: id,
111
+ name: "Administrator",
112
+ permissions: [
113
+ "members:view-members",
114
+ "members:assign-member-role",
115
+ "members:suspend-member",
116
+ "members:activate-member",
117
+ "members:delete-member",
118
+ "invitations:create-invitation",
119
+ "invitations:view-invitations",
120
+ "invitations:cancel-invitation",
121
+ "roles:see-all-roles",
122
+ "roles:see-role-details",
123
+ "roles:add-role",
124
+ "roles:update-role",
125
+ "roles:delete-role",
126
+ ],
127
+ };
128
+
129
+ const payloads = [
130
+ { ...owner, type: "organization" },
131
+ { ...administrator, type: "organization" },
132
+ ];
106
133
 
107
134
  const nature = org?.nature ?? "";
108
- if (APP_SERVICE_TYPES.includes(nature)) payloads.push({ ...owner, type: nature });
135
+ if (APP_SERVICE_TYPES.includes(nature)) {
136
+ payloads.push({ ...owner, type: nature });
137
+ payloads.push({ ...administrator, type: nature });
138
+ }
109
139
 
110
140
  return { payloads };
111
141
  }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * THE 2026-09-07 REPAIR, RUN FROM A BROWSER — paste into the DevTools console of
3
+ * a SIGNED-IN Seven365 platform-staff session on https://org.app.iservice365.org
4
+ *
5
+ * This is the same decision as `repair.mjs` (`planRepair`, tested in
6
+ * `test/repair-client-template-permissions.test.mjs`), in the one place that
7
+ * already holds a production session. The node tool needs a `SEVEN365_SID`;
8
+ * your browser has one, so nobody has to move a credential anywhere to run this.
9
+ *
10
+ * It sends no Authorization header and puts no secret in a URL: the session
11
+ * rides as the `sid` cookie the app already set, and the app's own Nitro proxy
12
+ * forwards `/api/**` to API-core. That is exactly how the 2026-09-09
13
+ * owner-membership repair was run.
14
+ *
15
+ * ## READ THIS BEFORE THE SECOND STEP
16
+ *
17
+ * DRY RUN unless you set `APPLY` to true AND name the role ids in `REPAIR_IDS`.
18
+ * Two separate switches, on purpose:
19
+ *
20
+ * - it repairs NOTHING on its own judgement. Restoring a role to `["*"]`
21
+ * asserts it granted EVERYTHING before the save, and no stored document
22
+ * says what it held before. Over the real 462 staging roles the earlier
23
+ * "repair anything that is all-platform" rule wanted to hand "Sub Member" —
24
+ * one platform string, no holders — the run of the whole platform.
25
+ * - so a person reads the table, decides, and names the id. That decision is
26
+ * the point, and it is what the 2026-09-07 incident did not have.
27
+ *
28
+ * Run it once with both switches untouched. Read the table. Only then come back.
29
+ */
30
+ (async () => {
31
+ /* ─── the two switches ─────────────────────────────────────────────── */
32
+
33
+ const APPLY = false;
34
+
35
+ /**
36
+ * The role ids you have decided to restore, as 24-hex strings.
37
+ *
38
+ * "Org Owner" on staging is `69df7c8034293c971075ab97`. PRODUCTION WILL HAVE A
39
+ * DIFFERENT ID — take it from the table this prints, never from here.
40
+ */
41
+ const REPAIR_IDS = [];
42
+
43
+ /* ─── the decision, identical to planRepair ────────────────────────── */
44
+
45
+ // The same seven resources as `src/utils/role-scope.util.ts`. `users`,
46
+ // `invitations`, `members` and `roles-and-permissions` are deliberately NOT
47
+ // here: those four names exist in BOTH catalogues, so treating them as
48
+ // platform-only would call an ordinary client role corrupt and rewrite it.
49
+ const PLATFORM_ONLY = [
50
+ "organizations", "promo-codes", "subscriptions", "sp-approvals",
51
+ "marketplace-vendors", "platform-terms", "activity-history",
52
+ ];
53
+ const isPlatformOnly = (p) => PLATFORM_ONLY.includes(String(p ?? "").split(":")[0]);
54
+ const HEX24 = /^[0-9a-fA-F]{24}$/;
55
+
56
+ function planRepair(role, named = []) {
57
+ const id = String(role?._id ?? "");
58
+ if (!HEX24.test(id)) return { skip: "role has no usable id" };
59
+ if ((role?.type ?? "") === "admin") return { skip: "platform staff role" };
60
+ if (role?.org) return { skip: "role belongs to one organisation" };
61
+
62
+ const held = Array.isArray(role?.permissions) ? role.permissions : null;
63
+ if (held === null) return { skip: "permission list unreadable" };
64
+ if (held.length === 0) return { skip: "already grants everything (empty list)" };
65
+ if (held.includes("*")) return { skip: "already grants everything (wildcard)" };
66
+
67
+ const platform = held.filter(isPlatformOnly);
68
+ if (platform.length === 0) {
69
+ return { skip: "holds no platform strings - an ordinary client template" };
70
+ }
71
+ if (!named.includes(id)) {
72
+ return {
73
+ skip: `holds ${platform.length} platform string(s) of ${held.length} - needs a human to name it`,
74
+ review: true,
75
+ };
76
+ }
77
+ return { payload: { permissions: ["*"] }, removing: held.length };
78
+ }
79
+
80
+ /* ─── read ─────────────────────────────────────────────────────────── */
81
+
82
+ const call = async (path, init) => {
83
+ const res = await fetch(path, {
84
+ credentials: "include",
85
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
86
+ ...init,
87
+ });
88
+ const text = await res.text();
89
+ let body = null;
90
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
91
+ return { ok: res.ok, status: res.status, body };
92
+ };
93
+
94
+ console.log(APPLY ? "MODE: APPLY (will write)" : "MODE: DRY RUN (writes nothing)");
95
+
96
+ // No `org` parameter, which is the ORG-LESS set — the shared client templates.
97
+ const listed = await call("/api/roles?limit=100");
98
+ if (!listed.ok) {
99
+ console.error(`GET /api/roles -> ${listed.status}. Are you signed in as Seven365 staff?`, listed.body);
100
+ return;
101
+ }
102
+
103
+ const roles = listed.body?.items ?? [];
104
+ const rows = roles.map((role) => {
105
+ const plan = planRepair(role, REPAIR_IDS);
106
+ return {
107
+ name: role.name,
108
+ id: String(role._id),
109
+ type: role.type,
110
+ permissions: Array.isArray(role.permissions) ? role.permissions.length : "?",
111
+ decision: plan.payload
112
+ ? `REPAIR -> ["*"] (removing ${plan.removing})`
113
+ : plan.review
114
+ ? `REVIEW: ${plan.skip}`
115
+ : `skip: ${plan.skip}`,
116
+ };
117
+ });
118
+
119
+ console.log(`org-less roles returned: ${roles.length}`);
120
+ console.table(rows.filter((r) => !r.decision.startsWith("skip")));
121
+ console.log(
122
+ `repairs planned: ${rows.filter((r) => r.decision.startsWith("REPAIR")).length}` +
123
+ ` | flagged for a human: ${rows.filter((r) => r.decision.startsWith("REVIEW")).length}` +
124
+ ` | untouched: ${rows.filter((r) => r.decision.startsWith("skip")).length}`,
125
+ );
126
+
127
+ // How many people are actually behind a flagged role — the number that decides
128
+ // whether this is urgent. Counted per role so nothing is inferred.
129
+ for (const row of rows.filter((r) => !r.decision.startsWith("skip"))) {
130
+ const members = await call(`/api/members?role=${row.id}&limit=1`);
131
+ const total = Number(String(members.body?.pageRange ?? "").split(" of ").pop());
132
+ console.log(` ${row.name} (${row.id}) — live members: ${Number.isFinite(total) ? total : "unknown"}`);
133
+ }
134
+
135
+ if (!APPLY || !REPAIR_IDS.length) {
136
+ console.log(
137
+ "\nDRY RUN — nothing was written." +
138
+ "\nTo repair: copy the id of the role you have decided about into REPAIR_IDS," +
139
+ " set APPLY = true, and paste the whole snippet again.",
140
+ );
141
+ return;
142
+ }
143
+
144
+ /* ─── write ────────────────────────────────────────────────────────── */
145
+
146
+ for (const row of rows.filter((r) => r.decision.startsWith("REPAIR"))) {
147
+ // Re-read immediately before writing, so a role somebody repaired a second
148
+ // ago is skipped rather than overwritten.
149
+ const fresh = await call(`/api/roles/id/${row.id}`);
150
+ if (!fresh.ok) { console.log(` SKIP ${row.name}: re-read ${fresh.status}`); continue; }
151
+
152
+ const recheck = planRepair(fresh.body, REPAIR_IDS);
153
+ if (!recheck.payload) { console.log(` SKIP ${row.name}: ${recheck.skip} (changed since the listing)`); continue; }
154
+
155
+ const res = await call(`/api/roles/permissions/id/${row.id}`, {
156
+ method: "PATCH",
157
+ body: JSON.stringify({ permissions: ["*"] }),
158
+ });
159
+ console.log(res.ok ? ` OK ${row.name} -> ["*"]` : ` FAIL ${row.name} -> ${res.status}`, res.ok ? "" : res.body);
160
+ }
161
+
162
+ console.log("\nDone. Re-run with APPLY = false to confirm the table is now clean.");
163
+ })();