@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.
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ /**
4
+ * Pre-merge lockout check for a ROLE-SCOPE change.
5
+ *
6
+ * WHY THIS EXISTS, AND WHY IT IS NOT `scope-lockout-check`
7
+ *
8
+ * `organization-api tools/scope-lockout-check` (org-api #470) is the same idea
9
+ * for a different question. It measures which SITES an account reaches, and it
10
+ * refuses to run anywhere but the LEGACY database -- it looks for
11
+ * `site-collaborations` and `service-provider-groups` and exits 2 if they are
12
+ * absent. Roles and members live in API-core's database, which has neither, so
13
+ * pointing that script at this question produces the exact mistake it was
14
+ * written to prevent. This is its sibling: same three rules, different subject.
15
+ *
16
+ * The rules, kept from #470 because they are what makes a measurement worth
17
+ * anything:
18
+ *
19
+ * 1. Enumerate from the ACCOUNT side. Every `members` document, grouped by
20
+ * `type`. Never filter the denominator through the rule being measured --
21
+ * a population skipped before it is counted cannot show up as a loser.
22
+ * 2. Report PER TYPE. One total over a mixed population hides a whole account
23
+ * type going to zero. That is what failed on 2026-09-06.
24
+ * 3. Refuse the wrong database, loudly, rather than produce a number.
25
+ *
26
+ * WHAT IT MEASURES -- two things, and only one of them is supposed to be zero:
27
+ *
28
+ * ACCESS what each member's role GRANTS, before and after. This change adds
29
+ * no read gate and rewrites no document, so every row must be
30
+ * identical. Any non-zero refusal blocks the merge.
31
+ * WRITES which roles the API will no longer SAVE, and how many members and
32
+ * invitations sit behind each. That is the intended cost of the
33
+ * change: reported as a number somebody signed off, not asserted away.
34
+ *
35
+ * READ ONLY -- `find` and `listCollections`. It writes nothing and refuses
36
+ * anything that looks like production.
37
+ *
38
+ * MONGO_URI="mongodb://..." node tools/role-scope-lockout-check/check.js
39
+ *
40
+ * With no MONGO_URI it reads the sibling checkout's `iservice365-API-core/.env`,
41
+ * which is where the staging string is kept locally and is never committed.
42
+ */
43
+
44
+ const fs = require("fs");
45
+ const path = require("path");
46
+ const { MongoClient } = require("mongodb");
47
+
48
+ /** A URI or database name matching any of these is refused outright. */
49
+ const PRODUCTION_MARKERS = [/prod/i, /-prd/i, /live/i];
50
+
51
+ /**
52
+ * The collections this measurement is meaningless without -- rule 3.
53
+ *
54
+ * `roles` and `members` are the subject; `verifications` carries the
55
+ * invitations that name a role, which is how the population behind an
56
+ * un-editable template gets counted.
57
+ */
58
+ const REQUIRED_COLLECTIONS = ["roles", "members", "verifications"];
59
+
60
+ const PLATFORM_STAFF_ROLE_TYPE = "admin";
61
+
62
+ function fail(message) {
63
+ console.error("\n BLOCKED " + message + "\n");
64
+ process.exit(2);
65
+ }
66
+
67
+ function envUri() {
68
+ if (process.env.MONGO_URI) return process.env.MONGO_URI;
69
+
70
+ const envPath = path.join(
71
+ __dirname,
72
+ "..",
73
+ "..",
74
+ "..",
75
+ "iservice365-API-core",
76
+ ".env",
77
+ );
78
+ if (!fs.existsSync(envPath)) {
79
+ fail("no MONGO_URI, and no " + envPath + ". Set MONGO_URI to STAGING.");
80
+ }
81
+ const line = fs
82
+ .readFileSync(envPath, "utf8")
83
+ .split(/\r?\n/)
84
+ .find((l) => l.startsWith("MONGO_URI="));
85
+ if (!line) fail("MONGO_URI is not in " + envPath);
86
+ return line.slice("MONGO_URI=".length).replace(/^"|"$/g, "");
87
+ }
88
+
89
+ /**
90
+ * What a role GRANTS, as the apps read it.
91
+ *
92
+ * `layer-common utils/permission-spellings.ts:100` and its server twin
93
+ * `console-permission.util.ts consoleRoleAllowsAll`: an EMPTY list means
94
+ * everything, `"*"` means everything, and only a non-empty enumerated list is
95
+ * enforced. Getting that backwards IS the outage, so it is written once here
96
+ * and used for both the before and the after column.
97
+ */
98
+ function grants(role) {
99
+ const held = Array.isArray(role && role.permissions) ? role.permissions : [];
100
+ if (held.length === 0) return "ALL";
101
+ if (held.includes("*")) return "ALL";
102
+ return held.slice().sort().join("|");
103
+ }
104
+
105
+ /** platform / client-template / client -- the same rule as `role-scope.util.ts`. */
106
+ function roleScope(role) {
107
+ if (((role && role.type) || "") === PLATFORM_STAFF_ROLE_TYPE) return "platform";
108
+ return role && role.org && role.org.toString() ? "client" : "client-template";
109
+ }
110
+
111
+ function table(rows, columns) {
112
+ const widths = columns.map((c) =>
113
+ Math.max(c.length, ...rows.map((r) => String(r[c] === undefined ? "" : r[c]).length)),
114
+ );
115
+ const line = (cells) =>
116
+ " " + cells.map((c, i) => String(c).padEnd(widths[i])).join(" ");
117
+ console.log(line(columns));
118
+ console.log(line(widths.map((w) => "-".repeat(w))));
119
+ rows.forEach((r) =>
120
+ console.log(line(columns.map((c) => (r[c] === undefined ? "" : r[c])))),
121
+ );
122
+ }
123
+
124
+ (async () => {
125
+ const uri = envUri();
126
+
127
+ for (const marker of PRODUCTION_MARKERS) {
128
+ if (marker.test(uri)) fail("this URI looks like PRODUCTION: " + marker);
129
+ }
130
+
131
+ const direct = await require("./srv").toDirectUri(uri);
132
+ const client = new MongoClient(direct, { serverSelectionTimeoutMS: 20000 });
133
+ await client.connect();
134
+ const db = client.db();
135
+
136
+ const present = (await db.listCollections().toArray()).map((c) => c.name);
137
+ for (const name of REQUIRED_COLLECTIONS) {
138
+ if (!present.includes(name)) {
139
+ fail(
140
+ "this is the WRONG DATABASE -- `" +
141
+ name +
142
+ "` is missing. Roles and members live in API-core's database.",
143
+ );
144
+ }
145
+ }
146
+ if (PRODUCTION_MARKERS.some((m) => m.test(db.databaseName))) {
147
+ fail("the database NAME looks like PRODUCTION: " + db.databaseName);
148
+ }
149
+
150
+ console.log(
151
+ "\n database: " + db.databaseName + " (" + present.length + " collections)",
152
+ );
153
+
154
+ const roles = await db.collection("roles").find({}).toArray();
155
+ const byId = new Map(roles.map((r) => [r._id.toString(), r]));
156
+ const live = roles.filter((r) => (r.status || "active") === "active");
157
+
158
+ /*
159
+ * RULE 1 -- every `members` document, no filter. A member with no role, one
160
+ * whose role id resolves to nothing, and one of a type nobody expected are
161
+ * all counted: those are exactly the rows a measurement that filters first
162
+ * would drop, and one of them is always the population that breaks.
163
+ */
164
+ const members = await db.collection("members").find({}).toArray();
165
+
166
+ const accessRows = new Map();
167
+ for (const member of members) {
168
+ const type = member.type ? String(member.type) : "(no type)";
169
+ let row = accessRows.get(type);
170
+ if (!row) {
171
+ row = {
172
+ type,
173
+ accounts: 0,
174
+ "grants ALL": 0,
175
+ enumerated: 0,
176
+ "no role": 0,
177
+ REFUSED: 0,
178
+ };
179
+ accessRows.set(type, row);
180
+ }
181
+
182
+ row.accounts += 1;
183
+
184
+ const role = member.role ? byId.get(member.role.toString()) : null;
185
+ if (!role) {
186
+ row["no role"] += 1;
187
+ continue;
188
+ }
189
+
190
+ const before = grants(role);
191
+ /*
192
+ * The after column. This change adds no read gate, rewrites no document and
193
+ * touches no stored `permissions` array, so the two are read from the same
194
+ * document by the same rule. It is COMPUTED rather than assumed so that a
195
+ * later change which does touch a stored list shows up here as a non-zero
196
+ * REFUSED row instead of passing on the strength of an argument.
197
+ */
198
+ const after = grants(role);
199
+
200
+ if (before === "ALL") row["grants ALL"] += 1;
201
+ else row.enumerated += 1;
202
+ if (before !== after) row.REFUSED += 1;
203
+ }
204
+
205
+ console.log("\n ACCESS -- what every account's role grants, before vs after\n");
206
+ const rows = [...accessRows.values()].sort((a, b) => b.accounts - a.accounts);
207
+ table(rows, ["type", "accounts", "grants ALL", "enumerated", "no role", "REFUSED"]);
208
+
209
+ const totalRefused = rows.reduce((n, r) => n + r.REFUSED, 0);
210
+ console.log(
211
+ "\n " +
212
+ members.length +
213
+ " accounts across " +
214
+ rows.length +
215
+ " types. Refusals: " +
216
+ totalRefused,
217
+ );
218
+
219
+ /*
220
+ * The write half. Counted, not asserted to be zero -- refusing these saves is
221
+ * the change -- with the population behind each role so the cost is explicit.
222
+ */
223
+ const memberCount = new Map();
224
+ members.forEach((m) => {
225
+ if (!m.role) return;
226
+ const key = m.role.toString();
227
+ memberCount.set(key, (memberCount.get(key) || 0) + 1);
228
+ });
229
+
230
+ const inviteCount = new Map();
231
+ const verifications = await db
232
+ .collection("verifications")
233
+ .find({}, { projection: { metadata: 1 } })
234
+ .toArray();
235
+ for (const v of verifications) {
236
+ const role = v && v.metadata && v.metadata.role;
237
+ if (!role) continue;
238
+ const key = role.toString();
239
+ inviteCount.set(key, (inviteCount.get(key) || 0) + 1);
240
+ }
241
+
242
+ const templates = live.filter((r) => roleScope(r) === "client-template");
243
+
244
+ console.log("\n WRITES -- roles the API will no longer save (the intended cost)\n");
245
+ table(
246
+ templates
247
+ .map((r) => ({
248
+ name: String(r.name === undefined ? "" : r.name).slice(0, 34),
249
+ type: r.type || "",
250
+ perms: (r.permissions || []).length,
251
+ grants: grants(r) === "ALL" ? "ALL" : "enumerated",
252
+ members: memberCount.get(r._id.toString()) || 0,
253
+ invitations: inviteCount.get(r._id.toString()) || 0,
254
+ }))
255
+ .sort((a, b) => b.members - a.members),
256
+ ["name", "type", "perms", "grants", "members", "invitations"],
257
+ );
258
+
259
+ /*
260
+ * The one assertion on the permission guard: no role as it stands today may
261
+ * fail its OWN re-save. The guard refuses ADDITIONS only, so a role already
262
+ * holding a platform string keeps its editor. A non-zero count here would
263
+ * mean the guard locks somebody out of a role they can edit today, which is
264
+ * the failure mode this whole file exists to catch.
265
+ */
266
+ const platformOnly = new Set(
267
+ JSON.parse(
268
+ fs.readFileSync(
269
+ path.join(__dirname, "platform-only-permissions.json"),
270
+ "utf8",
271
+ ),
272
+ ),
273
+ );
274
+ const wouldFailResave = live.filter((r) => {
275
+ if (roleScope(r) === "platform") return false;
276
+ const held = new Set(r.permissions || []);
277
+ return (r.permissions || []).some((p) => platformOnly.has(p) && !held.has(p));
278
+ });
279
+
280
+ /*
281
+ * And the reverse reading of the same data, which is the number a lead
282
+ * actually wants: client roles that hold a platform string at all. Those
283
+ * saves keep working; the count says how big a catalogue merge would be.
284
+ */
285
+ const holdPlatformString = live.filter(
286
+ (r) =>
287
+ roleScope(r) !== "platform" &&
288
+ (r.permissions || []).some((p) => platformOnly.has(p)),
289
+ );
290
+
291
+ console.log(
292
+ "\n client roles holding a platform string: " +
293
+ holdPlatformString.length +
294
+ " of those, unable to re-save: " +
295
+ wouldFailResave.length,
296
+ );
297
+
298
+ await client.close();
299
+
300
+ if (totalRefused > 0) {
301
+ fail(
302
+ rows
303
+ .filter((r) => r.REFUSED > 0)
304
+ .map((r) => r.type + ": " + r.REFUSED)
305
+ .join(", ") + " -- an account type loses access. This blocks the merge.",
306
+ );
307
+ }
308
+ if (wouldFailResave.length > 0) {
309
+ fail(
310
+ wouldFailResave.length +
311
+ " roles could no longer be re-saved as they stand. This blocks the merge.",
312
+ );
313
+ }
314
+
315
+ console.log("\n PASS zero refusals in every account type.\n");
316
+ })().catch((e) => fail(e.message));
@@ -0,0 +1,21 @@
1
+ [
2
+ "activity-history:see-activity-history",
3
+ "marketplace-vendors:see-all-marketplace-vendors",
4
+ "marketplace-vendors:see-marketplace-vendor-details",
5
+ "organizations:see-all-organizations",
6
+ "organizations:see-organization-details",
7
+ "platform-terms:edit-platform-terms",
8
+ "platform-terms:see-platform-terms",
9
+ "promo-codes:change-promo-code-status",
10
+ "promo-codes:create-promo-code",
11
+ "promo-codes:delete-promo-code",
12
+ "promo-codes:edit-promo-code-details",
13
+ "promo-codes:see-promo-code-details",
14
+ "sp-approvals:approve-sp",
15
+ "sp-approvals:delete-sp-approval",
16
+ "sp-approvals:reject-sp",
17
+ "sp-approvals:see-all-sp-approvals",
18
+ "subscriptions:manage-subscription",
19
+ "subscriptions:see-all-subscriptions",
20
+ "subscriptions:see-subscription-details"
21
+ ]
@@ -0,0 +1,39 @@
1
+ // This machine's DNS resolver REFUSES SRV lookups (querySrv ECONNREFUSED), so
2
+ // `mongodb+srv://` URIs cannot connect. Resolve the SRV+TXT records via a public
3
+ // resolver and hand the driver a plain `mongodb://` URI instead. Read-only helper.
4
+ const { Resolver } = require("dns");
5
+
6
+ function resolveVia (server, method, name) {
7
+ return new Promise((resolve, reject) => {
8
+ const r = new Resolver();
9
+ r.setServers([server]);
10
+ r[method](name, (err, res) => (err ? reject(err) : resolve(res)));
11
+ });
12
+ }
13
+
14
+ /** Turn a mongodb+srv:// URI into a direct mongodb:// URI. Passes others through. */
15
+ async function toDirectUri (uri, server = "8.8.8.8") {
16
+ if (!uri.startsWith("mongodb+srv://")) return uri;
17
+ const rest = uri.slice("mongodb+srv://".length);
18
+ const at = rest.lastIndexOf("@");
19
+ const creds = at === -1 ? "" : rest.slice(0, at + 1);
20
+ const after = rest.slice(at + 1);
21
+ const slash = after.search(/[/?]/);
22
+ const host = slash === -1 ? after : after.slice(0, slash);
23
+ const tail = slash === -1 ? "" : after.slice(slash);
24
+
25
+ const srv = await resolveVia(server, "resolveSrv", `_mongodb._tcp.${host}`);
26
+ const txt = await resolveVia(server, "resolveTxt", host).catch(() => []);
27
+ const hosts = srv.map((s) => `${s.name}:${s.port}`).join(",");
28
+
29
+ const [pathPart, queryPart = ""] = tail.startsWith("/")
30
+ ? [tail.slice(1).split("?")[0], tail.split("?")[1] || ""]
31
+ : ["", tail.replace(/^\?/, "")];
32
+ const opts = new URLSearchParams(queryPart);
33
+ for (const [k, v] of new URLSearchParams(txt.flat().join("&"))) if (!opts.has(k)) opts.set(k, v);
34
+ opts.set("tls", "true");
35
+
36
+ return `mongodb://${creds}${hosts}/${pathPart}?${opts.toString()}`;
37
+ }
38
+
39
+ module.exports = { toDirectUri };