clearotron 0.3.3 → 0.4.0-beta.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/INSTALL.md +1 -14
- package/bin/brandowner.mjs +5 -5
- package/bin/connect.mjs +4 -4
- package/bin/onboard.mjs +5 -7
- package/bin/start.mjs +20 -5
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +18 -0
- package/driver/company-bundle.mjs +11 -18
- package/driver/door-call-verdict.mjs +27 -0
- package/driver/driver.config.mjs +1 -2
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +14 -5
- package/driver/portal-mcp-client.mjs +1 -1
- package/driver/portal-request-origin.mjs +79 -0
- package/driver/portal-service.mjs +43 -15
- package/driver/profile-page.html +9 -13
- package/driver/profile-service.mjs +25 -13
- package/driver/profiles.mjs +17 -4
- package/driver/publish/index.mjs +13 -1
- package/driver/publish/render.mjs +13 -2
- package/driver/publish/search-depth.mjs +4 -2
- package/driver/run-economics.mjs +7 -18
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +61 -31
- package/driver/tokens.mjs +18 -10
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/audit.mjs +9 -2
- package/mcp-server/lib/http-handler.mjs +7 -3
- package/mcp-server/mint-token.mjs +8 -6
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +10 -0
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-DVtz44vH.js} +57 -8
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/freeze-example-run.mjs +1 -1
- package/scripts/live-surface-check.mjs +11 -2
- package/scripts/release-entry-catch-up.mjs +142 -0
- package/shared/client-door.mjs +15 -8
- package/shared/scope.mjs +25 -10
- package/shared/store-in-repo.mjs +38 -17
package/INSTALL.md
CHANGED
|
@@ -647,24 +647,11 @@ matches, the neutral Generic default applies.
|
|
|
647
647
|
config store and the engine loads *those* companies instead. **Same engine, different config path** —
|
|
648
648
|
the code carries no company identities.
|
|
649
649
|
|
|
650
|
-
|
|
650
|
+
One thing goes with it, and it is a refusal rather than a preference:
|
|
651
651
|
|
|
652
652
|
- **`PROFILE_REPO_ROOT` moves too.** The company directory has to sit inside the repository that
|
|
653
653
|
variable names, because editing a profile is a commit. Point one somewhere new and leave the other
|
|
654
654
|
behind and the portal and the profile service both refuse to start, naming both variables.
|
|
655
|
-
- **That repository needs a `user.name` and a `user.email` of its own.** Saves are committed under
|
|
656
|
-
the name of whoever asked for them, but git also records who *made* the commit, and it will not
|
|
657
|
-
commit at all without one. A service account usually has no global git identity, so a store created
|
|
658
|
-
by hand needs its own:
|
|
659
|
-
|
|
660
|
-
```bash
|
|
661
|
-
git init -b main /srv/clearotron-store
|
|
662
|
-
git -C /srv/clearotron-store config user.name "clearotron local install"
|
|
663
|
-
git -C /srv/clearotron-store config user.email "clearotron@example.com"
|
|
664
|
-
```
|
|
665
|
-
|
|
666
|
-
`clearotron start` does this for the store it creates. A store you make yourself does not get it,
|
|
667
|
-
and the symptom is the first save failing at a commit rather than anything about profiles.
|
|
668
655
|
- **Run data is external too.** Published reports, audits, and per-run state go to the archive pool at
|
|
669
656
|
`CLEAROTRON_REPORTS_DIR`. Nothing company-specific is committed to the repository.
|
|
670
657
|
|
package/bin/brandowner.mjs
CHANGED
|
@@ -160,11 +160,10 @@ export async function add(argv, {
|
|
|
160
160
|
contextPack = readFileSync(packFile, "utf8");
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
//
|
|
164
|
-
// candidate is still judged as a member of the roster and not alone — see assertRosterAccepts.
|
|
163
|
+
// The candidate is judged as a member of the roster and not alone — see assertRosterAccepts.
|
|
165
164
|
const { loadProfiles } = await import("../driver/profiles.mjs");
|
|
166
165
|
const load = loadProfilesFn ?? loadProfiles;
|
|
167
|
-
const platforms = resolvePlatforms(args.platforms
|
|
166
|
+
const platforms = resolvePlatforms(args.platforms);
|
|
168
167
|
|
|
169
168
|
const profile = buildProfile({
|
|
170
169
|
key: args.key, name: args.name.trim(), domains: args.domains,
|
|
@@ -180,10 +179,11 @@ export async function add(argv, {
|
|
|
180
179
|
: `framework: ${framework.path} — THE GENERIC DEFAULT, applied because none was supplied. `
|
|
181
180
|
+ `Their matters will be rated under it until they give us theirs.`;
|
|
182
181
|
|
|
182
|
+
// NONE, when none was supplied (the owner's ruling of 2026-09-23): the brand owner starts with no
|
|
183
|
+
// marketplaces, and the line says what their searches still cover.
|
|
183
184
|
const platformsLine = platforms.source === "supplied"
|
|
184
185
|
? `platforms: ${platforms.platforms.join(", ")} — as supplied`
|
|
185
|
-
: `platforms:
|
|
186
|
-
+ `Their searches cover these marketplaces until someone changes them in the portal.`;
|
|
186
|
+
: `platforms: none — searches use the general web, plus any stores chosen for each matter.`;
|
|
187
187
|
|
|
188
188
|
if (args.dryRun) {
|
|
189
189
|
out(`would create ${join(store, `${args.key}.json`)}`);
|
package/bin/connect.mjs
CHANGED
|
@@ -135,10 +135,10 @@ export function systemdFailure(e, { step, unit = null } = {}) {
|
|
|
135
135
|
const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
|
|
136
136
|
const ENV_PATH = join(homedir(), ".env");
|
|
137
137
|
// Where the revocation list lives when this install has never named one — created by connect so the
|
|
138
|
-
// door is BORN consulting it (; measured: no denylist is configured on production
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
138
|
+
// door is BORN consulting it (; measured: no denylist is configured on production).
|
|
139
|
+
// `isRevoked()` now reads this same default when the setting is unset, and an absent default reads as
|
|
140
|
+
// "nothing revoked"; a list that is there and unreadable refuses. Naming the path is still what makes
|
|
141
|
+
// every door, whatever home it runs under, read the file a revocation is written to.
|
|
142
142
|
// — one owner for this path. It was written out here, in disconnect and twice
|
|
143
143
|
// in start; `start` named it and created nothing, which is how a revoked key kept answering 200.
|
|
144
144
|
const DENYLIST_PATH = defaultDenylistPath(homedir());
|
package/bin/onboard.mjs
CHANGED
|
@@ -3453,10 +3453,8 @@ export async function runCheck() {
|
|
|
3453
3453
|
if (r.state === "valid") ok(line);
|
|
3454
3454
|
else info(line);
|
|
3455
3455
|
}
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
+ `— \`${invoke("disconnect")}\` could not actually revoke it. \`${invoke("connect")}\` arms one; set the variable or reconnect.`);
|
|
3459
|
-
}
|
|
3456
|
+
// No "revocation list not configured" problem any more: with TRADEMARK_MCP_TOKEN_DENYLIST unset, every
|
|
3457
|
+
// door reads the install's default list (isRevoked, shared/scope.mjs), which is where disconnect writes.
|
|
3460
3458
|
// THE PUBLISHED ADDRESS, AND WHETHER IT ANSWERS (acceptance 2). Reported here
|
|
3461
3459
|
// rather than beside the unit, because the unit running and the address being reachable are
|
|
3462
3460
|
// different facts and the second is the one a client depends on.
|
|
@@ -4496,9 +4494,9 @@ try {
|
|
|
4496
4494
|
candidate.PROFILE_REPO_ROOT = cfg; // no alias row — this name is current
|
|
4497
4495
|
for (const k of ["CLEAROTRON_CUSTOMERS_DIR", "PROFILE_REPO_ROOT"]) ok(`${k}=${candidate[k]}`);
|
|
4498
4496
|
// A REPOSITORY ALREADY THERE IS ASKED NOW, while the operator is still here, whether it can record a
|
|
4499
|
-
// save
|
|
4500
|
-
//
|
|
4501
|
-
//
|
|
4497
|
+
// save: one this account cannot use refuses the first company created in the portal. Said here, with
|
|
4498
|
+
// the command, rather than discovered on that first company. A missing git identity is not asked
|
|
4499
|
+
// about, because every save supplies the product's own committer.
|
|
4502
4500
|
if (existsSync(join(cfgAbs, ".git"))) {
|
|
4503
4501
|
const cannot = storeCommitRefusal(cfgAbs);
|
|
4504
4502
|
if (cannot) warn(`${cannot.message}. Until then, creating a company in the portal is refused.`);
|
package/bin/start.mjs
CHANGED
|
@@ -111,7 +111,7 @@ export async function runTables() {
|
|
|
111
111
|
return { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: RUN_DEFAULT_ENGINE, resolveEngine: resolveEngineProgram };
|
|
112
112
|
}
|
|
113
113
|
import { spawn, spawnSync, execFileSync } from "node:child_process";
|
|
114
|
-
import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal } from "../shared/store-in-repo.mjs"; //
|
|
114
|
+
import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal, storeCommitEnv } from "../shared/store-in-repo.mjs"; //
|
|
115
115
|
import { stdioConnectOffer } from "../shared/stdio-connect.mjs";
|
|
116
116
|
import { wslTarget } from "../shared/wsl.mjs"; // — and which distribution a row should start the server in
|
|
117
117
|
import { ensureDemoProgram } from "../shared/permanent-install.mjs"; // — a demo from npx keeps its own copy
|
|
@@ -1531,9 +1531,9 @@ if (isMain) {
|
|
|
1531
1531
|
err(` WARNING: could not initialise the saved-search store at ${paths.configStore} (${String(e?.message ?? e)}). Searches will list and run; SAVING one will fail until this is a git repository.`);
|
|
1532
1532
|
}
|
|
1533
1533
|
} else {
|
|
1534
|
-
// AN ADOPTED STORE IS ASKED WHETHER IT CAN RECORD A SAVE, HERE, not at the first save
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1534
|
+
// AN ADOPTED STORE IS ASKED WHETHER IT CAN RECORD A SAVE, HERE, not at the first save: a directory
|
|
1535
|
+
// that is not a repository this account can use refuses every company created in the portal. A store
|
|
1536
|
+
// with no git identity is not refused; every save supplies the product's own committer.
|
|
1537
1537
|
const cannot = storeCommitRefusal(paths.configStore);
|
|
1538
1538
|
if (cannot) err(` WARNING: ${cannot.message}. Until then, creating a company or saving a search is refused.`);
|
|
1539
1539
|
}
|
|
@@ -1556,7 +1556,7 @@ if (isMain) {
|
|
|
1556
1556
|
// and the first save commits them with its own change.
|
|
1557
1557
|
try {
|
|
1558
1558
|
execFileSync("git", ["-C", paths.configStore, "add", "-A", "--", "profiles"], { stdio: "ignore" });
|
|
1559
|
-
execFileSync("git", ["-C", paths.configStore, "commit", "-q", "-m", "the demo's company"], { stdio: "ignore" });
|
|
1559
|
+
execFileSync("git", ["-C", paths.configStore, "commit", "-q", "-m", "the demo's company"], { stdio: "ignore", env: storeCommitEnv(paths.configStore) });
|
|
1560
1560
|
} catch { /* see above */ }
|
|
1561
1561
|
say(` demo store ${paths.profiles} — ${copied.join(", ")}`);
|
|
1562
1562
|
}
|
|
@@ -2298,6 +2298,7 @@ if (isMain) {
|
|
|
2298
2298
|
// the `stopping` flag is what stops an orderly stop being reported as a crash.
|
|
2299
2299
|
process.on("SIGINT", () => { void shutdown(0); });
|
|
2300
2300
|
process.on("SIGTERM", () => { void shutdown(0); });
|
|
2301
|
+
for (const sig of windowCloseSignals()) process.on(sig, () => { void shutdown(0); });
|
|
2301
2302
|
|
|
2302
2303
|
const healthy = async (url, rec) => {
|
|
2303
2304
|
const deadline = Date.now() + 30_000;
|
|
@@ -2678,6 +2679,20 @@ export function backgroundOfferLines({ demo = false, keep = false, manager = nul
|
|
|
2678
2679
|
];
|
|
2679
2680
|
}
|
|
2680
2681
|
|
|
2682
|
+
/**
|
|
2683
|
+
* The signal that means the window was closed, which a foreground start treats as a stop.
|
|
2684
|
+
*
|
|
2685
|
+
* SIGHUP, on every platform. Closing a terminal sends it, and with no handler Node's default ended this
|
|
2686
|
+
* process on the spot: the teardown never ran, and the children, which lead sessions of their own, never
|
|
2687
|
+
* received the hangup. The portal, the door and the worker went on running with nothing supervising them
|
|
2688
|
+
* and a search in flight carried on (measured on Linux, 2026-09-23), while the banner above says closing
|
|
2689
|
+
* the window stops everything this command started. Windows reports closing the console window as SIGHUP
|
|
2690
|
+
* too.
|
|
2691
|
+
*/
|
|
2692
|
+
export function windowCloseSignals() {
|
|
2693
|
+
return ["SIGHUP"];
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2681
2696
|
/**
|
|
2682
2697
|
* What a reader is told when a child this install cannot run without exits.
|
|
2683
2698
|
*
|
package/build-info.json
CHANGED
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# clearotron-driver
|
|
2
2
|
|
|
3
|
+
## 0.4.0-beta.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Before you upgrade: a new operator key must now name the tools it may use, for example `--verbs start_run,stop_run`. Keys already issued keep working.
|
|
8
|
+
|
|
9
|
+
For operators: a revoked key stops working even where no revocation list was set up, because every connector now reads the install's own list.
|
|
10
|
+
|
|
11
|
+
Fixed: over the network, an operator key can no longer start a what-if that its assistant is not shown.
|
|
12
|
+
|
|
13
|
+
Fixed: the portal refuses a change sent from another website, or from another app on the same computer, including a sign-in.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Fixed: closing the window that runs `clearotron start` now stops Clearotron. Before, it kept running in the background.
|
|
18
|
+
- Fixed: Creating a company in the portal no longer stops with a request to run a git command.
|
|
19
|
+
- Fixed: A new company starts with no marketplaces. The company page offers the usual ones to add, and a company with none is still searched on the general web.
|
|
20
|
+
|
|
3
21
|
## 0.3.3
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
|
@@ -99,28 +99,21 @@ export function resolveFramework(requested, {
|
|
|
99
99
|
|
|
100
100
|
// ── the profile this writes ────────────────────────────────────────────────────────────────────────
|
|
101
101
|
/**
|
|
102
|
-
* Which marketplaces this brand owner's searches cover
|
|
102
|
+
* Which marketplaces this brand owner's searches cover: the ones supplied, or NONE.
|
|
103
103
|
*
|
|
104
|
-
* A customer bundle is a COMPLETE document in this design, not an overlay on generic: every
|
|
105
|
-
*
|
|
106
|
-
* command had no way to supply one and set none, so every bundle it wrote failed to load on this field
|
|
107
|
-
* as well as on the `key` field above — two independent invalidities, and onboarding could not produce
|
|
108
|
-
* a loadable brand owner at all.
|
|
104
|
+
* A customer bundle is a COMPLETE document in this design, not an overlay on generic: every profile
|
|
105
|
+
* carries its own `platforms` array, and the loader requires one on every file.
|
|
109
106
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
107
|
+
* NONE, NOT THE GENERIC DEFAULT, by the owner's ruling of 2026-09-23. A company created with only a name
|
|
108
|
+
* used to be given the Generic default's marketplaces, which no screen listed and which the company page
|
|
109
|
+
* then refused to let anybody remove. A new company now starts with an empty list and somebody picks
|
|
110
|
+
* from the Generic default's marketplaces, which the portal offers as suggestions. With an empty list the
|
|
111
|
+
* general web search and the meaning checks still run, and so do any stores the engine chooses for a matter
|
|
112
|
+
* (pipeline.mjs deriveGridSpec).
|
|
114
113
|
*/
|
|
115
|
-
export function resolvePlatforms(supplied
|
|
114
|
+
export function resolvePlatforms(supplied) {
|
|
116
115
|
if (supplied?.length) return { platforms: supplied, source: "supplied" };
|
|
117
|
-
|
|
118
|
-
if (!house.length)
|
|
119
|
-
throw new Refusal(
|
|
120
|
-
"no --platforms was given and the Generic default carries none, so there is nothing to onboard this "
|
|
121
|
-
+ "brand owner with. Pass --platforms, or repair the generic profile in the store.",
|
|
122
|
-
{ code: "no_marketplaces" });
|
|
123
|
-
return { platforms: [...house], source: "house default" };
|
|
116
|
+
return { platforms: [], source: "none" };
|
|
124
117
|
}
|
|
125
118
|
|
|
126
119
|
export function buildProfile({ key, name, domains, platforms, framework, industry,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// A CALL TO THE OPS DOOR THAT THE DEPLOYMENT CHECK COULD NOT MAKE — "roster resolves" (list_profiles) and
|
|
5
|
+
// "ops-MCP reachable" (describe_options). Extracted so it can be driven without a door to call, as
|
|
6
|
+
// plan-run-agreement-verdict.mjs was for the third call.
|
|
7
|
+
//
|
|
8
|
+
// Both rows used to FAIL on every error but an unset door, so the check exited 1, "drifted", when nothing
|
|
9
|
+
// had been compared. Measured on pre-prod 0.3.3-beta.0: both rows FAILed on a 401, the door refusing the
|
|
10
|
+
// check's own key. On the test box the same door's rate limit (429) refused the third call of a burst of
|
|
11
|
+
// checks; that was the plan_run call, and these two go to the same door.
|
|
12
|
+
//
|
|
13
|
+
// THREE ANSWERS MEAN THE CHECK DID NOT LOOK: a rate limit (429), a refusal of this check's own key (401,
|
|
14
|
+
// 403), and a request that got no answer in time. Each is a marked skip, which exits 3. Everything else
|
|
15
|
+
// stays a FAIL: a 5xx or a malformed answer is the door answering badly. A door with nothing listening
|
|
16
|
+
// at all stays a FAIL too, because whether that is a finding is not settled, and this does not settle it.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Error & {status?: number|null, timedOut?: boolean}} e what the call threw
|
|
20
|
+
* @param {{ asked: string, notCompared: string }} what the tool asked, and what was therefore not compared
|
|
21
|
+
* @returns {{state: "skip", blocked: true, message: string} | null} null: report it as the FAIL it was
|
|
22
|
+
*/
|
|
23
|
+
export function doorCallVerdict(e, { asked, notCompared }) {
|
|
24
|
+
const refused = e?.status === 429 || e?.status === 401 || e?.status === 403 || e?.timedOut === true;
|
|
25
|
+
if (!refused) return null;
|
|
26
|
+
return { state: "skip", blocked: true, message: `could not ask ${asked}, so ${notCompared} — ${String(e?.message ?? e).slice(0, 200)}` };
|
|
27
|
+
}
|
package/driver/driver.config.mjs
CHANGED
|
@@ -693,8 +693,7 @@ export const MODELS = {
|
|
|
693
693
|
// alias → full id; a value that's already a full provider/model id (contains "/") passes through.
|
|
694
694
|
//
|
|
695
695
|
// A BARE Anthropic id (dated or not — "claude-haiku-4-5-20251001", "claude-opus-5") normalises to the
|
|
696
|
-
// catalog form too.
|
|
697
|
-
// name their model that way because that is what the Messages API takes, so without this one model named
|
|
696
|
+
// catalog form too. Without this, one model named
|
|
698
697
|
// in two spellings — dated and undated — would key apart in a rollup. The date suffix is dropped;
|
|
699
698
|
// anything that does not look like a bare claude id is returned untouched, so a genuinely unknown model
|
|
700
699
|
// still keys as-is rather than being guessed at.
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.0-beta.0",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|
package/driver/pipeline.mjs
CHANGED
|
@@ -76,7 +76,7 @@ import { armCoverageForm, coverageFormInput, coverageFormPaths, coverageFormStam
|
|
|
76
76
|
import { unionPlacementForm } from "./placement-union.mjs";
|
|
77
77
|
import { readPlacementForm, readPlacementFormInput, writePlacementForm } from "./placement-form-io.mjs";
|
|
78
78
|
import { dictatedPaths, findStrayArtifacts, treeSnapshot, findStrayInTree, matterSiblings, findStrayMatterSiblings } from "./stray-artifacts.mjs"; // — a run dir holds no document no stage dictated; — nor does the doctrine tree
|
|
79
|
-
import { resolveProfile, resolveEffectiveProfile, derivedFloor, derivedBatchSize, applicantMatchesProfile, NEUTRAL_DELIVERY, deliveryForRun, recipeProseGuard, withRunPlatforms, profileStoreResolution } from "./profiles.mjs"; // adds profileStoreResolution — the CONFIG store's identity, beside the doctrine tree's
|
|
79
|
+
import { resolveProfile, resolveEffectiveProfile, derivedFloor, derivedBatchSize, gridBatchFor, applicantMatchesProfile, NEUTRAL_DELIVERY, deliveryForRun, recipeProseGuard, withRunPlatforms, profileStoreResolution } from "./profiles.mjs"; // adds profileStoreResolution — the CONFIG store's identity, beside the doctrine tree's
|
|
80
80
|
import { resolveSearchPolicy, gateResolvedPolicy, loadRecipes, policyFor, isRegisterOnly, reportIdentityFor, depthFor } from "./search-policy.mjs";
|
|
81
81
|
import { profileOrdinals } from "./profile-selection.mjs"; // lever 3 — driver selection
|
|
82
82
|
// THE OFFERING'S own sentence about where the native-language investigation can be bought. It reaches a
|
|
@@ -979,7 +979,12 @@ function deriveGridSpec(ctx) {
|
|
|
979
979
|
terms: null,
|
|
980
980
|
spec_inputs: { registerOnly: Boolean(ctx.registerOnly), gridVariants: ctx.gridVariants?.length ?? 0, profilePlatforms: ctx.profile?.platforms?.length ?? 0 },
|
|
981
981
|
};
|
|
982
|
-
|
|
982
|
+
// AN EMPTY MARKETPLACE LIST STILL WRITES A GRID. A company may pick no marketplaces (the owner's ruling of
|
|
983
|
+
// 2026-09-23), and the general-web cell and the meaning sweep ride in this spec: gating it on a non-empty
|
|
984
|
+
// list switched both off with the stores, and the downgrade clamp then read the missing spec as a failed
|
|
985
|
+
// sweep. The spec below is then the web cell plus whatever channels the matter frame names. Only a profile
|
|
986
|
+
// with no list at all (legacy) takes the spec-less path.
|
|
987
|
+
if (!ctx.registerOnly && ctx.gridVariants?.length && Array.isArray(ctx.profile?.platforms)) {
|
|
983
988
|
const gridSpecPath = P.gridSpec;
|
|
984
989
|
// #5 — required channels: a NAMED profile's curated platforms are authoritative. The GENERIC fallback
|
|
985
990
|
// derives the channels from the MATTER FRAME's industry/goods reasoning (its "Search channels:" line) so
|
|
@@ -1041,7 +1046,8 @@ function deriveGridSpec(ctx) {
|
|
|
1041
1046
|
terms: ctx.gridVariants,
|
|
1042
1047
|
platforms: [...channels, "web"], // the dictated channels + the general-web cell
|
|
1043
1048
|
output_path: P.commonLawGrid,
|
|
1044
|
-
|
|
1049
|
+
// SIZED BY THE CELLS THIS GRID RUNS, never larger than the profile's own figure (gridBatchFor).
|
|
1050
|
+
batch: gridBatchFor(ctx.profile, channels.length + 1),
|
|
1045
1051
|
// disposition_required (P2-C §8b leg 2): the receipt-presence stamp arming the commonLaw validator's
|
|
1046
1052
|
// receipts-disposition arm (the D1 ledger_required pattern — every fresh spec carries it; pre-P2-C
|
|
1047
1053
|
// archived specs lack it, so replay verdicts never flip). splitGridSpec spreads the connotation
|
|
@@ -10591,10 +10597,13 @@ async function pipelineInner(job, opts = {}) {
|
|
|
10591
10597
|
// back-compat: a pre-doc-35 receipt keyed only on the cell set (no source channels)
|
|
10592
10598
|
(priorReceipt.sig == null && !sourceChannels.length &&
|
|
10593
10599
|
JSON.stringify((priorReceipt.requested ?? []).map(cellKey).sort()) === JSON.stringify(closable.map(cellKey).sort())));
|
|
10600
|
+
// SIZED BY THE CELLS A CLOSURE GRID RUNS, as the main grid is (gridBatchFor): its spec carries only the
|
|
10601
|
+
// platforms of the cells it closes.
|
|
10602
|
+
const closureBatch = (cells) => gridBatchFor(ctx.profile, new Set(cells.map((c) => c.platform)).size);
|
|
10594
10603
|
if ((closable.length || sourceChannels.length) && !alreadyAttempted) {
|
|
10595
10604
|
const requested = closable;
|
|
10596
10605
|
const variants = [...new Set(closable.map((c) => c.variant))];
|
|
10597
|
-
const batchSize =
|
|
10606
|
+
const batchSize = closureBatch(closable);
|
|
10598
10607
|
const batches = Math.max(1, Math.ceil(variants.length / batchSize));
|
|
10599
10608
|
const gridCalls = batches + (sourceChannels.length ? 1 : 0); // grid call(s) + a channel-sweep call (tokens-only: no $ estimate)
|
|
10600
10609
|
note(`coverage closure: ${closable.length} machine-closable cell(s)${sourceChannels.length ? ` + ${sourceChannels.length} un-swept in-scope channel(s) [${sourceChannels.join(", ")}]` : ""} — one supplementary grid pass (${gridCalls} grid call(s))`);
|
|
@@ -10707,7 +10716,7 @@ async function pipelineInner(job, opts = {}) {
|
|
|
10707
10716
|
}
|
|
10708
10717
|
if (closable.length || exempt.length) {
|
|
10709
10718
|
// Tokens-only (owner directive 2026-07-11): the offer names the work (grid calls), never a $ figure.
|
|
10710
|
-
const batchesLeft = Math.max(1, Math.ceil([...new Set(closable.map((c) => c.variant))].length / (
|
|
10719
|
+
const batchesLeft = Math.max(1, Math.ceil([...new Set(closable.map((c) => c.variant))].length / closureBatch(closable)));
|
|
10711
10720
|
const offer = ` — closable on instruction (${batchesLeft} supplementary grid call(s))`;
|
|
10712
10721
|
ctx.coverageNote = `marketplace cells not executed: ${[...closable, ...exempt].map(cellKey).join("; ")}` +
|
|
10713
10722
|
(closable.length ? ` — attempted in-loop and still unreachable${offer}` : "") +
|
|
@@ -83,7 +83,7 @@ function post(urlStr, { headers = {}, body = "", wantId = null, timeoutMs = 3000
|
|
|
83
83
|
});
|
|
84
84
|
// — a timeout got no status and therefore no answer; it is marked at the throw rather than
|
|
85
85
|
// recognised later by its message, so nothing has to keep a regex in step with a sentence.
|
|
86
|
-
req.setTimeout(timeoutMs, () => { req.destroy(transportError(`MCP request timed out after ${timeoutMs}ms`, null)); });
|
|
86
|
+
req.setTimeout(timeoutMs, () => { req.destroy(Object.assign(transportError(`MCP request timed out after ${timeoutMs}ms`, null), { timedOut: true })); });
|
|
87
87
|
req.on("error", (e) => reject(isSocketFailure(e) ? transportError(e.message, null) : e));
|
|
88
88
|
req.end(body);
|
|
89
89
|
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// A STATE CHANGE MUST COME FROM THIS PORTAL'S OWN PAGES. The portal checked nothing about where a POST
|
|
5
|
+
// came from: the session cookie's SameSite attribute was the whole defence in local mode, and behind a
|
|
6
|
+
// proxy it was the proxy's cookie. A page on another site, or another app on this machine on another
|
|
7
|
+
// port (a browser treats every port on one host as the same site), could make a signed-in browser change
|
|
8
|
+
// state here. So every state-changing request is checked against this portal's own host, in both modes.
|
|
9
|
+
//
|
|
10
|
+
// THE HOST IS THE REQUEST'S OWN, NOT A LIST. `Host` is what the browser addressed. A proxy in front can
|
|
11
|
+
// rewrite it, and then says the original in `X-Forwarded-Host`, which Caddy sets by default. A page on
|
|
12
|
+
// another site cannot set either header on a victim's browser (a custom header forces a preflight the
|
|
13
|
+
// portal never grants), so accepting a match on either is still a match on this portal's own name.
|
|
14
|
+
// Compared with its port: another app on this machine differs only in the port.
|
|
15
|
+
//
|
|
16
|
+
// A DEFAULT PORT IS NO PORT. `https://portal.example` and `https://portal.example:443` are one origin, and
|
|
17
|
+
// so are `http://h` and `http://h:80`. A browser's Origin never writes the default port, but a proxy or a
|
|
18
|
+
// tunnel in front may write it into `Host`, and a plain comparison would then refuse every save the
|
|
19
|
+
// portal's own pages make. So the port the Origin's scheme implies is taken off both sides before they are
|
|
20
|
+
// compared, and only that one: `:443` beside an `http` Origin is still another port.
|
|
21
|
+
//
|
|
22
|
+
// A REQUEST WITH NEITHER `Origin` NOR `Sec-Fetch-Site` IS NOT FROM A BROWSER. Every current browser sends
|
|
23
|
+
// one of them on a POST, and a script or `curl` sends neither. A script cannot be tricked into carrying
|
|
24
|
+
// someone else's session, so there is nothing to refuse, and its own credential still has to pass.
|
|
25
|
+
|
|
26
|
+
const STATE_CHANGING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
27
|
+
const first = (v) => String(Array.isArray(v) ? v[0] : (v ?? "")).split(",")[0].trim();
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PORT = { "https:": "443", "http:": "80" };
|
|
30
|
+
|
|
31
|
+
/** `host` without the port `protocol` implies, when it carries exactly that one. */
|
|
32
|
+
function withoutDefaultPort(host, protocol) {
|
|
33
|
+
const port = DEFAULT_PORT[protocol];
|
|
34
|
+
return port && host.endsWith(`:${port}`) ? host.slice(0, -(port.length + 1)) : host;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The hosts this request says it was addressed to, lower-case, with their ports, less the default one. */
|
|
38
|
+
function ownHosts(headers, protocol) {
|
|
39
|
+
return new Set([first(headers?.host), first(headers?.["x-forwarded-host"])].filter(Boolean)
|
|
40
|
+
.map((h) => withoutDefaultPort(h.toLowerCase(), protocol)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Why this request is refused as coming from somewhere other than this portal, or null to let it through.
|
|
45
|
+
* @param {{ method?: string, headers?: Record<string, string|string[]|undefined> }} req
|
|
46
|
+
* @returns {string|null}
|
|
47
|
+
*/
|
|
48
|
+
export function crossSiteReason(req) {
|
|
49
|
+
if (!STATE_CHANGING.has(String(req?.method ?? "GET").toUpperCase())) return null;
|
|
50
|
+
const headers = req?.headers ?? {};
|
|
51
|
+
const origin = first(headers.origin);
|
|
52
|
+
if (origin) {
|
|
53
|
+
if (origin === "null") return "Origin is null";
|
|
54
|
+
let host, protocol;
|
|
55
|
+
try { ({ host, protocol } = new URL(origin)); } catch { return "Origin is not an address"; }
|
|
56
|
+
host = withoutDefaultPort(host.toLowerCase(), protocol);
|
|
57
|
+
const own = ownHosts(headers, protocol);
|
|
58
|
+
return own.has(host) ? null : `Origin ${host} is not this portal (${[...own].join(", ") || "no Host"})`;
|
|
59
|
+
}
|
|
60
|
+
const site = first(headers["sec-fetch-site"]).toLowerCase();
|
|
61
|
+
if (site) return site === "same-origin" || site === "none" ? null : `Sec-Fetch-Site is ${site}`;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a state-changing request carries a body that is not declared as JSON. The API reads every body as
|
|
67
|
+
* JSON; a form post (`text/plain`, `application/x-www-form-urlencoded`) is the shape a page on another site
|
|
68
|
+
* can send without a preflight, so it is refused here rather than parsed. A request with no body is not
|
|
69
|
+
* asked for a type: the portal's own pages send none on a POST that carries nothing.
|
|
70
|
+
*/
|
|
71
|
+
export function bodyNotJson(req) {
|
|
72
|
+
if (!STATE_CHANGING.has(String(req?.method ?? "GET").toUpperCase())) return false;
|
|
73
|
+
const headers = req?.headers ?? {};
|
|
74
|
+
const length = Number(first(headers["content-length"]) || 0);
|
|
75
|
+
const hasBody = length > 0 || Boolean(first(headers["transfer-encoding"]));
|
|
76
|
+
if (!hasBody) return false;
|
|
77
|
+
const type = first(headers["content-type"]).split(";")[0].trim().toLowerCase();
|
|
78
|
+
return type !== "application/json";
|
|
79
|
+
}
|
|
@@ -168,6 +168,7 @@ import { engineCommit } from "./engine-build.mjs"; // — the S
|
|
|
168
168
|
import { engineCommitDate, engineProvenance } from "./engine-build.mjs";
|
|
169
169
|
import { classifySkillsStore } from "./skills-store-provenance.mjs";
|
|
170
170
|
import { makeStaticHandler, reportCsp, docCsp } from "./portal-static.mjs";
|
|
171
|
+
import { crossSiteReason, bodyNotJson } from "./portal-request-origin.mjs";
|
|
171
172
|
import { bundleVerdict, healthUi } from "../shared/bundle-freshness.mjs"; // one definition of a usable bundle, shared with `doctor`
|
|
172
173
|
import { readReport, reportsOf, resolveReportFile, batchSummaryOf } from "./portal-report.mjs";
|
|
173
174
|
import { readArchivedSet, updateArchived } from "./publish/archive-tags.mjs";
|
|
@@ -1142,7 +1143,7 @@ export function makeConnectorKeyRevoker({ env = process.env, home = null } = {})
|
|
|
1142
1143
|
const base = home ?? homedir();
|
|
1143
1144
|
const recorded = recordedKeysFor(g, email);
|
|
1144
1145
|
const plan = disablePlan({ env, unitDir: join(base, ".config", "systemd", "user"), exists: existsSync,
|
|
1145
|
-
identity: email, recorded, denylistPath: denylistPathFor(env, base) });
|
|
1146
|
+
identity: email, recorded, denylistPath: denylistPathFor(env, base), home: base });
|
|
1146
1147
|
if (!plan.possible) return { grants: g, revoked: 0, jtis: [], lateArm: false, says: plan.says };
|
|
1147
1148
|
if (plan.lateArm) return { grants: g, revoked: 0, jtis: plan.jtis, lateArm: true, says: plan.says ?? [] };
|
|
1148
1149
|
// THE RECORD IS NEVER STRUCK FROM HERE, and the ledger step is dropped rather than no-opped.
|
|
@@ -1614,6 +1615,7 @@ export function makePortalService({
|
|
|
1614
1615
|
// identity gets {} here exactly as it does for names and keeps reading the staff-only roster.
|
|
1615
1616
|
let accountNames = {};
|
|
1616
1617
|
let accountFacts = {};
|
|
1618
|
+
let meRoster = null; // read once for this answer: the names below and the house marketplaces
|
|
1617
1619
|
// GENERIC IS NAMED TOO when the person sees an organisation's Generic. It is not a company, so it is
|
|
1618
1620
|
// never in `accounts`, and without a name here the switcher printed the raw key at everyone below
|
|
1619
1621
|
// everything. An organisation with no company yet is the case with an empty `accounts`, so the
|
|
@@ -1623,7 +1625,7 @@ export function makePortalService({
|
|
|
1623
1625
|
if (named.length) {
|
|
1624
1626
|
try {
|
|
1625
1627
|
const { companyFactsOf } = await import("./profiles.mjs");
|
|
1626
|
-
const profiles = await loadProfilesImpl();
|
|
1628
|
+
const profiles = meRoster = await loadProfilesImpl();
|
|
1627
1629
|
for (const key of named) {
|
|
1628
1630
|
const profile = profiles.get(key);
|
|
1629
1631
|
const name = profile?.name;
|
|
@@ -1662,9 +1664,19 @@ export function makePortalService({
|
|
|
1662
1664
|
// READ ONCE. The payload names it and the program reading below is gated on it; two calls to
|
|
1663
1665
|
// `flagView` here would be two reads of the same file that could disagree with each other.
|
|
1664
1666
|
const meEngineMode = flagView(poolRoot).engineMode;
|
|
1667
|
+
// THE HOUSE MARKETPLACES, offered as suggestions wherever a company's marketplaces are edited. A new
|
|
1668
|
+
// company starts with none (the owner's ruling of 2026-09-23) and somebody picks; these are the
|
|
1669
|
+
// Generic default's own list, read from the store so an install that changed it offers its own.
|
|
1670
|
+
// Deployment-wide and the same for everyone, like concurrentRuns. [] when the store cannot be read:
|
|
1671
|
+
// the box still takes any marketplace typed into it.
|
|
1672
|
+
let houseMarketplaces = [];
|
|
1673
|
+
try {
|
|
1674
|
+
const house = (meRoster ?? await loadProfilesImpl()).get("generic")?.platforms;
|
|
1675
|
+
if (Array.isArray(house)) houseMarketplaces = house.filter((d) => typeof d === "string" && d.trim());
|
|
1676
|
+
} catch { /* a suggestion list is a nicety; the door is not */ }
|
|
1665
1677
|
return { status: 200, json: { email: principal.email, ...principalView(principal, grantsHere, accountNames),
|
|
1666
1678
|
accounts: principal.accounts, accountNames, accountFacts,
|
|
1667
|
-
concurrentRuns: concurrentRunsCap(), brand: ORGANISATION_NAME, engineMode: meEngineMode,
|
|
1679
|
+
concurrentRuns: concurrentRunsCap(), brand: ORGANISATION_NAME, engineMode: meEngineMode, houseMarketplaces,
|
|
1668
1680
|
// WHERE A PERSON ASKS FOR A CHANGE TO THEIR SIGN-IN, beside the brand and read where it is read:
|
|
1669
1681
|
// an href Preferences links "Clearotron administrator" to, or null, and then the words are plain.
|
|
1670
1682
|
administratorContact: ADMINISTRATOR_CONTACT,
|
|
@@ -3267,15 +3279,12 @@ async function connectorDoorKind(url) {
|
|
|
3267
3279
|
const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
|
|
3268
3280
|
if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
|
|
3269
3281
|
} catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
|
|
3270
|
-
// WHETHER AN ISSUED KEY CAN BE WITHDRAWN AT ALL
|
|
3271
|
-
//
|
|
3272
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
//
|
|
3275
|
-
|
|
3276
|
-
// The connector is a separate unit with its own environment; this answers only for here.
|
|
3277
|
-
const keysRevocable = Boolean(revokeConnectorKeys)
|
|
3278
|
-
&& String(process.env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim() !== "";
|
|
3282
|
+
// WHETHER AN ISSUED KEY CAN BE WITHDRAWN AT ALL. This used to be whether a revocation list was
|
|
3283
|
+
// named in this process's environment, because a door with none named checked no list. Every door
|
|
3284
|
+
// now reads the install's default list when none is named (isRevoked, shared/scope.mjs), and that
|
|
3285
|
+
// is where a revocation from here is written, so keys can be withdrawn whenever this process has
|
|
3286
|
+
// a revoker at all.
|
|
3287
|
+
const keysRevocable = Boolean(revokeConnectorKeys);
|
|
3279
3288
|
return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn, keysRevocable }) };
|
|
3280
3289
|
}
|
|
3281
3290
|
// /portal/admin/people — give someone access. Manage-gated above; everything else is decided here.
|
|
@@ -3641,9 +3650,10 @@ export function accountCapAdvice(posture) {
|
|
|
3641
3650
|
}
|
|
3642
3651
|
return "Re-mint with `mcp-server/mint-token.mjs --scope ops"
|
|
3643
3652
|
+ `${posture.sub ? ` --sub ${posture.sub}` : ""}`
|
|
3644
|
-
//
|
|
3645
|
-
//
|
|
3646
|
-
|
|
3653
|
+
// A FULL-OPS TOKEN (`verbs: null`) IS RE-ISSUED WITH THE PORTAL'S OWN VERBS. It used to get no
|
|
3654
|
+
// `--verbs` at all, so as not to narrow it; mint-token now refuses an ops token that names none, so that
|
|
3655
|
+
// command would fail, and re-issuing verb-less tokens with verbs is the point. The portal calls these two.
|
|
3656
|
+
+ ` --verbs ${(posture.verbs ?? ["start_run", "stop_run"]).join(",")}`
|
|
3647
3657
|
+ " --accounts <keys>` to make it two walls.";
|
|
3648
3658
|
}
|
|
3649
3659
|
|
|
@@ -4288,6 +4298,18 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
4288
4298
|
});
|
|
4289
4299
|
}
|
|
4290
4300
|
|
|
4301
|
+
// ── A STATE CHANGE FROM ANOTHER SITE IS REFUSED BEFORE ANYTHING ELSE LOOKS AT IT ────────────────
|
|
4302
|
+
//
|
|
4303
|
+
// Before identity, so the sign-in and sign-out forms are covered as well as the API: a page elsewhere
|
|
4304
|
+
// that signs a browser in to an account of its choosing is the same attack as one that acts under the
|
|
4305
|
+
// browser's own session. The rule and why it compares hosts is in portal-request-origin.mjs.
|
|
4306
|
+
const crossSite = crossSiteReason(req);
|
|
4307
|
+
if (crossSite) {
|
|
4308
|
+
log(`refused a ${req.method} to ${url.pathname} from another site: ${crossSite}`);
|
|
4309
|
+
journal(403, "cross-site request");
|
|
4310
|
+
return send(res, 403, { error: "cross_site" });
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4291
4313
|
// ──: THE LOCAL SIGN-IN DOOR — before identity, because it is how identity is obtained ─────
|
|
4292
4314
|
//
|
|
4293
4315
|
// Mounted only when the local identity provider is running. On a Cloudflare-fronted instance
|
|
@@ -4420,6 +4442,12 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
4420
4442
|
journal(429, "rate_limited", identity?.email);
|
|
4421
4443
|
return send(res, 429, { error: "rate_limited" });
|
|
4422
4444
|
}
|
|
4445
|
+
// A BODY THAT DOES NOT SAY IT IS JSON IS NOT READ AS JSON. A form post is the one body another site
|
|
4446
|
+
// can send without asking first; the portal's own pages always declare JSON when they send a body.
|
|
4447
|
+
if (bodyNotJson(req)) {
|
|
4448
|
+
journal(415, "body is not JSON", identity?.email);
|
|
4449
|
+
return send(res, 415, { error: "unsupported_media_type" });
|
|
4450
|
+
}
|
|
4423
4451
|
// A FIXED reason, not `e.message`. The parser's message is about the bytes it was given, and this
|
|
4424
4452
|
// row must not become a place a caller can write into by sending a body that fails to parse.
|
|
4425
4453
|
let body;
|
package/driver/profile-page.html
CHANGED
|
@@ -214,7 +214,7 @@ function renderEditor(key, p, contextPack, derived, framework, isNew) {
|
|
|
214
214
|
$("editor").innerHTML = `
|
|
215
215
|
${isNew ? f("Customer key"+REQ, "f_key", "", `<span class="reqtag">Required.</span> A short lowercase id — letters, numbers and hyphens only. It becomes the customer's filename and can't be changed later.`, "e.g. acme-health") : `<div class="ro">Editing <b><span data-anon="mark" data-anon-key="${esc(key)}">${esc(key)}</span></b></div>`}
|
|
216
216
|
${f("Customer name"+REQ, "f_name", p.name||"", `<span class="reqtag">Required.</span> The customer's name. We use it to recognise their own marks so we never flag the customer against itself.`, undefined, true)}
|
|
217
|
-
${ta("Marketplaces and registers to search"
|
|
217
|
+
${ta("Marketplaces and registers to search", "f_platforms", (p.platforms||[]).join("\n"), `One website per line (e.g. amazon.com). These are where we look for real-world use of a mark. Don't add 'web' — the general web is always searched.`, "", true)}
|
|
218
218
|
${coverageNote}
|
|
219
219
|
<details class="more"${advancedSet?" open":""}>
|
|
220
220
|
<summary>More options</summary>
|
|
@@ -344,18 +344,15 @@ function clientValidate({ key, profile }) {
|
|
|
344
344
|
else if (!KEY_RE.test(key)) problems.push({ id:"f_key", message:"The customer key can only use lowercase letters, numbers and hyphens (2–39 characters)." });
|
|
345
345
|
}
|
|
346
346
|
if (!profile.name) problems.push({ id:"f_name", message:"Enter the customer’s name." });
|
|
347
|
+
// An empty list is valid: a company may pick no marketplaces (the owner's ruling of 2026-09-23).
|
|
347
348
|
const plats = profile.platforms || [];
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (!DOMAIN_RE.test(dom) || /\s/.test(dom)) { problems.push({ id:"f_platforms", message:`“${x}” doesn’t look like a website address — use a domain like amazon.com.` }); continue; }
|
|
356
|
-
if (seen.has(dom)) { problems.push({ id:"f_platforms", message:`“${x}” is listed twice.` }); continue; }
|
|
357
|
-
seen.add(dom);
|
|
358
|
-
}
|
|
349
|
+
const seen = new Set();
|
|
350
|
+
for (const x of plats) {
|
|
351
|
+
const dom = x.toLowerCase();
|
|
352
|
+
if (dom === "web") { problems.push({ id:"f_platforms", message:"Don’t list “web” — the general web is always searched. Remove it." }); continue; }
|
|
353
|
+
if (!DOMAIN_RE.test(dom) || /\s/.test(dom)) { problems.push({ id:"f_platforms", message:`“${x}” doesn’t look like a website address — use a domain like amazon.com.` }); continue; }
|
|
354
|
+
if (seen.has(dom)) { problems.push({ id:"f_platforms", message:`“${x}” is listed twice.` }); continue; }
|
|
355
|
+
seen.add(dom);
|
|
359
356
|
}
|
|
360
357
|
return problems;
|
|
361
358
|
}
|
|
@@ -366,7 +363,6 @@ function prettyServerError(msg) {
|
|
|
366
363
|
const M = String(msg||"");
|
|
367
364
|
const MAP = [
|
|
368
365
|
[/name \(string\) is required/i, "f_name", "Enter the customer’s name."],
|
|
369
|
-
[/platforms must be a non-empty array/i, "f_platforms", "Add at least one marketplace or register to search — a website like amazon.com, one per line."],
|
|
370
366
|
[/must not list "web"/i, "f_platforms", "Don’t list “web” — the general web is always searched. Remove it."],
|
|
371
367
|
[/must be a bare store DOMAIN/i, "f_platforms", "One of the marketplaces isn’t a website address — use domains like amazon.com, one per line."],
|
|
372
368
|
[/is duplicated/i, "f_platforms", "A marketplace is listed twice — remove the duplicate."],
|