@tokenoftrust/cli 2.5.4 → 2.5.6
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/bin/tot.mjs +3 -0
- package/package.json +1 -1
- package/src/auth.mjs +2 -0
- package/src/broker-outage.mjs +94 -0
- package/src/commands/git-credential.mjs +42 -47
- package/src/commands/grants.mjs +12 -1
- package/src/commands/report.mjs +7 -4
- package/src/commands/submit.mjs +44 -16
- package/src/commands/whoami.mjs +8 -2
- package/src/declared-config.d.mts +3 -0
- package/src/declared-config.mjs +49 -5
- package/src/diagnostics.mjs +18 -0
- package/src/readiness-checks.mjs +32 -1
package/bin/tot.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { emitActivity, capRendered } from "../src/activity.mjs";
|
|
|
24
24
|
import { maybeNotifyUpdate } from "../src/update-check.mjs";
|
|
25
25
|
import {
|
|
26
26
|
currentInvocationDiagnostics,
|
|
27
|
+
currentInvocationSession,
|
|
27
28
|
hasRequiredInvocationDiagnostic,
|
|
28
29
|
recordDiagnostic,
|
|
29
30
|
resetInvocationDiagnostics,
|
|
@@ -403,6 +404,7 @@ async function main() {
|
|
|
403
404
|
}
|
|
404
405
|
const diagnostics = currentInvocationDiagnostics();
|
|
405
406
|
const diagnosticIds = diagnostics.map((entry) => entry.diagnosticId).filter(Boolean);
|
|
407
|
+
const session = currentInvocationSession();
|
|
406
408
|
// Best-effort activity breadcrumb (never throws, never blocks). `feedback`'s own
|
|
407
409
|
// free-text message is omitted — it's user-typed and belongs only in the report.
|
|
408
410
|
recordActivity({
|
|
@@ -414,6 +416,7 @@ async function main() {
|
|
|
414
416
|
ms: durationMs,
|
|
415
417
|
...(errMsg ? { err: String(errMsg).slice(0, 200) } : {}),
|
|
416
418
|
...(diagnosticIds.length ? { diagnosticId: diagnosticIds.at(-1), diagnosticIds } : {}),
|
|
419
|
+
...(Object.keys(session).length ? { session } : {}),
|
|
417
420
|
});
|
|
418
421
|
// Emit `cli.command.result` (exit code + duration + a bounded/redacted
|
|
419
422
|
// rendered field). The house-style error text is capped + run through the JS
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.6",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/auth.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
defaultCredentialsPath, readCredentials, writeCredentials, isExpired, normalizeAuthority, selectedAuthority,
|
|
24
24
|
} from "./token-store.mjs";
|
|
25
25
|
import { refreshAccessToken, credentialsFromToken } from "./oauth.mjs";
|
|
26
|
+
import { noteInvocationSession } from "./diagnostics.mjs";
|
|
26
27
|
|
|
27
28
|
/** Thrown when no provider can authenticate — carries actionable guidance. */
|
|
28
29
|
export class AuthUnavailableError extends Error {
|
|
@@ -208,6 +209,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
208
209
|
const priorEmailHint = credentialEmailHint(prior);
|
|
209
210
|
if (priorEmailHint) creds.emailHint = priorEmailHint;
|
|
210
211
|
writeCredentials(path, creds);
|
|
212
|
+
noteInvocationSession({ refreshed: true });
|
|
211
213
|
}
|
|
212
214
|
|
|
213
215
|
if (client && typeof client.setToken === "function") client.setToken(creds.accessToken);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tot20's entitlement broker not answering withholds every store from an MCP session without
|
|
3
|
+
* withdrawing any: the session holds only what needs no broker (the platform tenant `tot`, for
|
|
4
|
+
* staff) until it answers again. The MCP says so — `client_list`/`grant_introspect` report
|
|
5
|
+
* status `degraded`, and a store call is refused with status `broker_unavailable`, retryable.
|
|
6
|
+
*
|
|
7
|
+
* This module recognises that refusal, waits a bounded time for the broker to come back (QA
|
|
8
|
+
* measured outages of one to two minutes), and otherwise names it — so a git push never falls
|
|
9
|
+
* into a username prompt and nobody is sent to `tot login`, which cannot help.
|
|
10
|
+
*
|
|
11
|
+
* Dependency-free.
|
|
12
|
+
*/
|
|
13
|
+
import { CliError } from "./errors.mjs";
|
|
14
|
+
|
|
15
|
+
export const BROKER_UNAVAILABLE = "broker_unavailable";
|
|
16
|
+
|
|
17
|
+
/** How long a store call waits for the broker before it reports the outage. */
|
|
18
|
+
export const BROKER_WAIT_MS = 45_000;
|
|
19
|
+
|
|
20
|
+
const BACKOFF_MS = [2_000, 4_000, 8_000];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Is this error the MCP's "the broker did not answer" refusal, or the CliError a wait for it
|
|
24
|
+
* ended in? Matched on the structured status the MCP client records for a tool error
|
|
25
|
+
* (mcp.mjs → diagnostic.serverStatus), never on message text. Pure.
|
|
26
|
+
* @param {unknown} error
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isBrokerUnavailable(error) {
|
|
30
|
+
const e = /** @type {any} */ (error);
|
|
31
|
+
const d = e?.diagnostic ?? e;
|
|
32
|
+
if (d?.serverStatus === BROKER_UNAVAILABLE || d?.serverCode === BROKER_UNAVAILABLE) return true;
|
|
33
|
+
return e instanceof CliError && e.cause !== undefined && isBrokerUnavailable(e.cause);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is a store-list result (`client_list` / `grant_introspect`) incomplete because the broker did
|
|
38
|
+
* not answer? Pure.
|
|
39
|
+
* @param {unknown} list
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isDegradedStoreList(list) {
|
|
43
|
+
const l = /** @type {any} */ (list);
|
|
44
|
+
return (l?.session?.brokerStatus ?? l?.brokerStatus) === "broker_error";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One line naming a degraded store list, for whoami/grants. Pure. */
|
|
48
|
+
export const DEGRADED_STORE_LIST =
|
|
49
|
+
"couldn't resolve all your stores right now — Token of Trust's entitlement service didn't " +
|
|
50
|
+
"answer, so this list is incomplete. Your access wasn't revoked: retry in a minute (`tot login` won't help).";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The CliError for a store call that outlasted the wait.
|
|
54
|
+
* @param {string} tenant
|
|
55
|
+
* @param {unknown} [cause]
|
|
56
|
+
*/
|
|
57
|
+
export function brokerUnavailableError(tenant, cause) {
|
|
58
|
+
return new CliError(
|
|
59
|
+
`couldn't confirm your access to ${tenant} right now — Token of Trust's entitlement service didn't answer`,
|
|
60
|
+
{
|
|
61
|
+
next: "your access wasn't revoked: retry in a minute (`tot login` won't help); " +
|
|
62
|
+
"`tot whoami` lists your stores again once it answers",
|
|
63
|
+
cause,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run `attempt`, retrying while the MCP reports the broker unavailable, for up to `waitMs`.
|
|
70
|
+
* Any other failure is rethrown untouched; an outage that outlasts the wait becomes
|
|
71
|
+
* brokerUnavailableError. `onWait` fires once, before the first wait.
|
|
72
|
+
* @template T
|
|
73
|
+
* @param {() => Promise<T>} attempt
|
|
74
|
+
* @param {{ tenant: string, onWait?: (waitMs: number) => void, waitMs?: number,
|
|
75
|
+
* sleep?: (ms: number) => Promise<void>, now?: () => number }} opts
|
|
76
|
+
* @returns {Promise<T>}
|
|
77
|
+
*/
|
|
78
|
+
export async function waitOutBrokerOutage(attempt, {
|
|
79
|
+
tenant, onWait = () => {}, waitMs = BROKER_WAIT_MS,
|
|
80
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now,
|
|
81
|
+
}) {
|
|
82
|
+
const deadline = now() + waitMs;
|
|
83
|
+
for (let i = 0; ; i++) {
|
|
84
|
+
try {
|
|
85
|
+
return await attempt();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (!isBrokerUnavailable(error)) throw error;
|
|
88
|
+
const delay = BACKOFF_MS[Math.min(i, BACKOFF_MS.length - 1)];
|
|
89
|
+
if (now() + delay > deadline) throw brokerUnavailableError(tenant, error);
|
|
90
|
+
if (i === 0) onWait(waitMs);
|
|
91
|
+
await sleep(delay);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -24,25 +24,21 @@
|
|
|
24
24
|
* by `tot clone` under `spaces/<id>/`) mints through `publication_checkout`, never
|
|
25
25
|
* cached, because each mint rotates the editor's older tokens.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* would otherwise corrupt EVERY git operation in the checkout. It also NEVER
|
|
33
|
-
* triggers an interactive sign-in — this runs as a non-interactive subprocess
|
|
34
|
-
* of `git`, so a missing session fails through rather than trying to open a
|
|
35
|
-
* browser mid-`git fetch`.
|
|
27
|
+
* NOTHING ON STDOUT BUT A CREDENTIAL: `get` prints nothing on stdout and exits
|
|
28
|
+
* non-zero on any problem — a stack trace or a malformed credential line there
|
|
29
|
+
* would corrupt EVERY git operation in the checkout. It also NEVER triggers an
|
|
30
|
+
* interactive sign-in — this runs as a non-interactive subprocess of `git`, so
|
|
31
|
+
* a missing session fails rather than opening a browser mid-`git fetch`.
|
|
36
32
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
33
|
+
* EVERY MINT FAILURE IS NAMED ON STDERR. The helper installed by `tot clone`
|
|
34
|
+
* clears any inherited helper for the forge host, so a failed `get` leaves git
|
|
35
|
+
* nothing but its own prompt: "could not read Username … terminal prompts
|
|
36
|
+
* disabled", which names neither the cause nor the fix. git passes a helper's
|
|
37
|
+
* stderr straight through, so every failure to mint — signed out, MCP
|
|
38
|
+
* unreachable, the MCP refusing the store — prints its house-style message and
|
|
39
|
+
* next step there. A store refused only because Token of Trust's entitlement
|
|
40
|
+
* broker did not answer (`broker_unavailable`, ../broker-outage.mjs) is retried
|
|
41
|
+
* for a bounded time first, since that outage withdraws nothing.
|
|
46
42
|
*
|
|
47
43
|
* Dependency-free (global fetch + `git`, via the same MCP client + auth
|
|
48
44
|
* module every other command uses).
|
|
@@ -56,7 +52,8 @@ import { checkoutTenant, mintPublicationSpace, mintReadOnlyAlias, resolveReadOnl
|
|
|
56
52
|
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
57
53
|
import { detectContext } from "../context.mjs";
|
|
58
54
|
import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
|
|
59
|
-
import {
|
|
55
|
+
import { formatError } from "../errors.mjs";
|
|
56
|
+
import { waitOutBrokerOutage } from "../broker-outage.mjs";
|
|
60
57
|
import {
|
|
61
58
|
parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
|
|
62
59
|
readCachedCredential, writeCachedCredential,
|
|
@@ -98,8 +95,8 @@ export function readStdin() {
|
|
|
98
95
|
* Mint (or reuse a cached) forge credential for the tenant the CURRENT
|
|
99
96
|
* directory checks out — the same `tenant_checkout` MCP call `tot clone`
|
|
100
97
|
* itself uses, so the credential is exactly as push-capable. Returns null on
|
|
101
|
-
* ANY failure
|
|
102
|
-
*
|
|
98
|
+
* ANY failure rather than throwing, having named a mint failure on stderr — `get`
|
|
99
|
+
* treats null as "nothing on stdout, exit non-zero".
|
|
103
100
|
*
|
|
104
101
|
* `createClient`/`establish`/`checkout` are injected (default to the real MCP
|
|
105
102
|
* client + auth + clone.mjs's checkoutTenant) purely so this is testable
|
|
@@ -120,21 +117,31 @@ export function readStdin() {
|
|
|
120
117
|
* checkout?: typeof checkoutTenant,
|
|
121
118
|
* mintReadOnly?: typeof mintReadOnlyAlias,
|
|
122
119
|
* mintSpace?: typeof mintPublicationSpace,
|
|
120
|
+
* brokerWait?: { waitMs?: number, sleep?: (ms: number) => Promise<void>, now?: () => number },
|
|
123
121
|
* }} [opts]
|
|
124
122
|
* @returns {Promise<{username:string,password:string}|null>}
|
|
125
123
|
*/
|
|
126
124
|
export async function mintOrCacheCredential({
|
|
127
125
|
env = process.env, cwd = process.cwd(), expectedHost = null,
|
|
128
126
|
createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
|
|
129
|
-
mintReadOnly = mintReadOnlyAlias, mintSpace = mintPublicationSpace,
|
|
127
|
+
mintReadOnly = mintReadOnlyAlias, mintSpace = mintPublicationSpace, brokerWait = {},
|
|
130
128
|
} = {}) {
|
|
131
129
|
let authority;
|
|
132
130
|
try { authority = selectedAuthority(env, { cwd }); }
|
|
133
131
|
catch { console.error("tot: this checkout has an invalid MCP authority. Run tot login --mcp <url> in this checkout."); return null; }
|
|
134
132
|
env = { ...env, TOT_SELECTED_MCP_URL: authority, MCP_BASE_URL: authority };
|
|
135
|
-
const
|
|
136
|
-
|
|
133
|
+
const report = (e) => {
|
|
134
|
+
console.error(e instanceof AuthUnavailableError
|
|
135
|
+
? `tot: ${e.message} ${e.hint || `Run tot login --mcp ${authority}.`}`
|
|
136
|
+
: formatError(e));
|
|
137
137
|
};
|
|
138
|
+
const waitOut = (tenant, attempt) => waitOutBrokerOutage(attempt, {
|
|
139
|
+
...brokerWait,
|
|
140
|
+
tenant,
|
|
141
|
+
onWait: (ms) => console.error(
|
|
142
|
+
`tot: Token of Trust's entitlement service didn't answer — retrying for up to ${Math.round(ms / 1000)}s…`,
|
|
143
|
+
),
|
|
144
|
+
});
|
|
138
145
|
const recorded = recordedCheckout(cwd);
|
|
139
146
|
if (recorded?.kind === "invalid") return null; // a half-recorded space mints nothing, never the store's
|
|
140
147
|
if (recorded?.kind === PUBLICATION_SPACE_KIND) {
|
|
@@ -144,20 +151,19 @@ export async function mintOrCacheCredential({
|
|
|
144
151
|
try {
|
|
145
152
|
await establish(client, { env });
|
|
146
153
|
} catch (e) {
|
|
147
|
-
|
|
154
|
+
report(e);
|
|
148
155
|
return null;
|
|
149
156
|
}
|
|
150
157
|
try {
|
|
151
158
|
const scoped = await scopeToTenant(client, recorded.tenant);
|
|
152
|
-
const res = await mintSpace(scoped, {
|
|
159
|
+
const res = await waitOut(recorded.tenant, () => mintSpace(scoped, {
|
|
153
160
|
id: recorded.spaceId, mount: "", visibility: "public",
|
|
154
161
|
source: { forge: "", repo: recorded.repo, defaultBranch: "" },
|
|
155
|
-
});
|
|
162
|
+
}));
|
|
156
163
|
const cred = splitAuthedRemote(res.gitRemote || "");
|
|
157
164
|
return cred ? { username: cred.username, password: cred.token } : null;
|
|
158
165
|
} catch (e) {
|
|
159
|
-
|
|
160
|
-
if (e instanceof CliError) console.error(formatError(e));
|
|
166
|
+
report(e);
|
|
161
167
|
return null;
|
|
162
168
|
}
|
|
163
169
|
}
|
|
@@ -173,19 +179,14 @@ export async function mintOrCacheCredential({
|
|
|
173
179
|
const client = createClient(baseUrl);
|
|
174
180
|
try {
|
|
175
181
|
await establish(client, { env });
|
|
176
|
-
const res = await mintReadOnly(client, alias);
|
|
182
|
+
const res = await waitOut(alias.target, () => mintReadOnly(client, alias));
|
|
177
183
|
const cred = splitAuthedRemote(res.gitRemote || "");
|
|
178
184
|
if (!cred) return null;
|
|
179
185
|
const fresh = { username: cred.username, password: cred.token };
|
|
180
186
|
writeCachedCredential(cachePath, fresh);
|
|
181
187
|
return fresh;
|
|
182
188
|
} catch (e) {
|
|
183
|
-
|
|
184
|
-
// report it on stderr so `git fetch` doesn't dead-end the developer in silence. A
|
|
185
|
-
// bare network/auth failure (not signed in, MCP unreachable) has no decision to
|
|
186
|
-
// report and stays silent, same as every other "nothing this helper can do" path.
|
|
187
|
-
authFailure(e);
|
|
188
|
-
if (e instanceof CliError) console.error(formatError(e));
|
|
189
|
+
report(e);
|
|
189
190
|
return null;
|
|
190
191
|
}
|
|
191
192
|
}
|
|
@@ -239,22 +240,16 @@ export async function mintOrCacheCredential({
|
|
|
239
240
|
try {
|
|
240
241
|
await establish(client, { env });
|
|
241
242
|
} catch (e) {
|
|
242
|
-
|
|
243
|
-
return null;
|
|
243
|
+
report(e);
|
|
244
|
+
return null;
|
|
244
245
|
}
|
|
245
246
|
try {
|
|
246
|
-
const res = await checkout(client, { tenant, tag, cloneDir: null });
|
|
247
|
+
const res = await waitOut(tenant, () => checkout(client, { tenant, tag, cloneDir: null }));
|
|
247
248
|
const cred = splitAuthedRemote(res.gitRemote || "");
|
|
248
249
|
if (!cred) return null;
|
|
249
250
|
return { username: cred.username, password: cred.token };
|
|
250
251
|
} catch (e) {
|
|
251
|
-
|
|
252
|
-
// CliError when the MCP (or this checkout's own state) has an authoritative answer
|
|
253
|
-
// — e.g. this checkout's `.tot/config.json` names a tenant the session doesn't hold,
|
|
254
|
-
// or `tenant_checkout` echoed a different tenant than the one asked for — and that
|
|
255
|
-
// diagnosis is lost forever if swallowed silently. A bare network/auth failure has
|
|
256
|
-
// no such decision and stays silent, exit non-zero, exactly as before.
|
|
257
|
-
if (e instanceof CliError) console.error(formatError(e));
|
|
252
|
+
report(e);
|
|
258
253
|
return null;
|
|
259
254
|
}
|
|
260
255
|
}
|
|
@@ -348,7 +343,7 @@ export async function run(argv, _ctx, opts = {}) {
|
|
|
348
343
|
if (op !== "get") return 0; // store: stateless, nothing to persist.
|
|
349
344
|
|
|
350
345
|
const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
|
|
351
|
-
if (!cred) return 1; //
|
|
346
|
+
if (!cred) return 1; // nothing on stdout; any mint failure was named on stderr.
|
|
352
347
|
process.stdout.write(formatCredentialOutput({
|
|
353
348
|
protocol: request.protocol, host: request.host, username: cred.username, password: cred.password,
|
|
354
349
|
}));
|
package/src/commands/grants.mjs
CHANGED
|
@@ -29,7 +29,8 @@ import { createMcpClient } from "../mcp.mjs";
|
|
|
29
29
|
import { storeListError, noStoresGuidance } from "./clone.mjs";
|
|
30
30
|
import { offerSignIn } from "./login.mjs";
|
|
31
31
|
import { recordServerPolicy } from "../update-check.mjs";
|
|
32
|
-
import { recordDiagnostic } from "../diagnostics.mjs";
|
|
32
|
+
import { noteInvocationSession, recordDiagnostic } from "../diagnostics.mjs";
|
|
33
|
+
import { DEGRADED_STORE_LIST, isDegradedStoreList } from "../broker-outage.mjs";
|
|
33
34
|
|
|
34
35
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
35
36
|
|
|
@@ -222,6 +223,8 @@ export async function run(argv, _ctx) {
|
|
|
222
223
|
|
|
223
224
|
const { introspected, rows } = normalizeGrantRows(resp);
|
|
224
225
|
const listErr = storeListError(resp);
|
|
226
|
+
const degraded = isDegradedStoreList(resp);
|
|
227
|
+
noteInvocationSession({ stores: rows.length, brokerStatus: resp?.session?.brokerStatus ?? null });
|
|
225
228
|
|
|
226
229
|
if (args.json) {
|
|
227
230
|
console.log(
|
|
@@ -229,6 +232,7 @@ export async function run(argv, _ctx) {
|
|
|
229
232
|
{
|
|
230
233
|
introspected,
|
|
231
234
|
error: listErr || null,
|
|
235
|
+
...(degraded ? { incomplete: true, reason: DEGRADED_STORE_LIST } : {}),
|
|
232
236
|
grants: rows.map((r) => ({ ...r, status: grantStatus(r, { now }) })),
|
|
233
237
|
},
|
|
234
238
|
null,
|
|
@@ -238,6 +242,13 @@ export async function run(argv, _ctx) {
|
|
|
238
242
|
return listErr && rows.length === 0 ? 1 : 0;
|
|
239
243
|
}
|
|
240
244
|
|
|
245
|
+
if (degraded) {
|
|
246
|
+
// Only what needs no entitlement answer resolved; naming the session's default as the
|
|
247
|
+
// "active tenant" would read as the store it had switched to.
|
|
248
|
+
console.log(`\n${DEGRADED_STORE_LIST.charAt(0).toUpperCase()}${DEGRADED_STORE_LIST.slice(1)}`);
|
|
249
|
+
for (const row of rows) console.log(` ${row.tenant}${row.environment ? ` [${row.environment}]` : ""}`);
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
241
252
|
if (listErr && rows.length === 0) {
|
|
242
253
|
console.log(`\nCouldn't read your grants: ${listErr}`);
|
|
243
254
|
console.log("Run `tot whoami` to check your session, or `tot login` again.");
|
package/src/commands/report.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export const USAGE = `tot report go-live — the go-live readiness report for yo
|
|
|
39
39
|
|
|
40
40
|
Save each page's JSON from the devkit page checks into <dir> first, e.g.
|
|
41
41
|
validate-page.sh <base> / > <dir>/home.json
|
|
42
|
-
validate-page-browser.mjs <base> / --provider cloudflare > <dir>/home.browser.json
|
|
42
|
+
validate-page-browser.mjs <base> / --store . --provider cloudflare > <dir>/home.browser.json
|
|
43
43
|
|
|
44
44
|
${TEASER}`;
|
|
45
45
|
|
|
@@ -147,9 +147,9 @@ export function storeConfigCheck(lint) {
|
|
|
147
147
|
/**
|
|
148
148
|
* Everything the report needs, from a page-results directory. A site-wide run's summary is used as
|
|
149
149
|
* is; otherwise the checks are derived here from the single-page results.
|
|
150
|
-
* @param {{ pagesDir: string, stylePath?: string|null, lint?: any }} input
|
|
150
|
+
* @param {{ pagesDir: string, stylePath?: string|null, lint?: any, config?: Record<string, any>|null }} input the store checkout's lint and `.tot/config.json`
|
|
151
151
|
*/
|
|
152
|
-
export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
|
|
152
|
+
export function evidenceFromDir({ pagesDir, stylePath = null, lint = null, config = null }) {
|
|
153
153
|
const summary = readJson(join(pagesDir, "site-summary.json"));
|
|
154
154
|
const styleJson = readJson(stylePath ?? join(pagesDir, "style.json"));
|
|
155
155
|
const browserDir = join(pagesDir, "browser");
|
|
@@ -177,6 +177,7 @@ export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
|
|
|
177
177
|
aggregate: { ...summary, totals: { ...summary.totals, issues: summary.totals?.issues ?? { lowRiskOpen: 0 } } },
|
|
178
178
|
styleJson: styleJson ?? (summary.style?.gates ? { gates: summary.style.gates } : null),
|
|
179
179
|
args: { manifest: summary.manifest ?? "(none supplied to the style-concept audit)" },
|
|
180
|
+
config,
|
|
180
181
|
}),
|
|
181
182
|
];
|
|
182
183
|
return {
|
|
@@ -195,6 +196,7 @@ export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
|
|
|
195
196
|
aggregate: { pages: results, totals: { issues: { lowRiskOpen: 0 } } },
|
|
196
197
|
styleJson,
|
|
197
198
|
args: { manifest: "(none supplied to the style-concept audit)" },
|
|
199
|
+
config,
|
|
198
200
|
}).filter((c) => c.area !== "Low-risk fix loop"),
|
|
199
201
|
];
|
|
200
202
|
const failing = results.filter((r) => Object.values(r.gates).some((v) => v === false)).length;
|
|
@@ -288,7 +290,8 @@ export function run(argv, ctx, io = {}) {
|
|
|
288
290
|
return 2;
|
|
289
291
|
}
|
|
290
292
|
const lint = storeExists ? validateTenant(store, { tenantId: tenant, mode: args.workspace ? "workspace" : "monorepo" }) : null;
|
|
291
|
-
const
|
|
293
|
+
const config = storeExists ? readJson(join(store, ".tot", "config.json")) : null;
|
|
294
|
+
const evidence = evidenceFromDir({ pagesDir, stylePath: args.style ? resolve(args.style) : null, lint, config });
|
|
292
295
|
if (!evidence.measured) {
|
|
293
296
|
err(`${fail(
|
|
294
297
|
`no page-check results under ${pagesDir}`,
|
package/src/commands/submit.mjs
CHANGED
|
@@ -54,7 +54,8 @@ import { normalizeRefreshResult } from "./accept.mjs";
|
|
|
54
54
|
import { validateTenant, ERROR, printLoudAdvisories } from "../validate.mjs";
|
|
55
55
|
import { openBrowser } from "../open.mjs";
|
|
56
56
|
import { startProgress } from "../progress.mjs";
|
|
57
|
-
import { fail } from "../errors.mjs";
|
|
57
|
+
import { CliError, fail } from "../errors.mjs";
|
|
58
|
+
import { isBrokerUnavailable, waitOutBrokerOutage } from "../broker-outage.mjs";
|
|
58
59
|
import { emitActivity } from "../activity.mjs";
|
|
59
60
|
import {
|
|
60
61
|
diagnosticReference,
|
|
@@ -828,6 +829,28 @@ export function tagFromRepoName(repoName, tenant) {
|
|
|
828
829
|
return r.startsWith(prefix) && r.length > prefix.length ? r.slice(prefix.length) : "main";
|
|
829
830
|
}
|
|
830
831
|
|
|
832
|
+
/**
|
|
833
|
+
* Mint the fresh push remote through `checkout`, waiting out a broker outage. A broker outage
|
|
834
|
+
* is rethrown (the checkout's remote authenticates through the same broker, so falling back
|
|
835
|
+
* would only trade the explanation for git's username prompt); any other failure calls
|
|
836
|
+
* `onFallback` and resolves null so the push uses the checkout's existing remote.
|
|
837
|
+
*
|
|
838
|
+
* @param {() => Promise<{ gitRemote?: string|null }>} checkoutFn
|
|
839
|
+
* @param {{ tenant: string, onFallback?: (e: unknown) => void, onWait?: (ms: number) => void,
|
|
840
|
+
* wait?: { sleep?: (ms: number) => Promise<void>, now?: () => number, waitMs?: number } }} opts
|
|
841
|
+
* @returns {Promise<string|null>}
|
|
842
|
+
*/
|
|
843
|
+
export async function mintPushRemote(checkoutFn, { tenant, onFallback = () => {}, onWait, wait = {} }) {
|
|
844
|
+
try {
|
|
845
|
+
const res = await waitOutBrokerOutage(checkoutFn, { tenant, onWait, ...wait });
|
|
846
|
+
return res.gitRemote || null;
|
|
847
|
+
} catch (e) {
|
|
848
|
+
if (isBrokerUnavailable(e)) throw e;
|
|
849
|
+
onFallback(e);
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
831
854
|
/**
|
|
832
855
|
* Push the preview ref (decision B): mint a FRESH, short-lived forge credential
|
|
833
856
|
* right before the push and hand it to git EPHEMERALLY (via `http.extraheader` on
|
|
@@ -1660,20 +1683,23 @@ export async function run(argv, ctx, {
|
|
|
1660
1683
|
// (never persisted to `.git/config`), re-minting once on an auth failure.
|
|
1661
1684
|
// `checkoutTenant(cloneDir:null)` mints without re-cloning.
|
|
1662
1685
|
const tag = tagFromRepoName(repo, tenant);
|
|
1663
|
-
const mintRemote =
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1686
|
+
const mintRemote = () => mintPushRemote(
|
|
1687
|
+
() => checkout(client, { tenant, tag, cloneDir: null, redact: redactUrl }),
|
|
1688
|
+
{
|
|
1689
|
+
tenant,
|
|
1690
|
+
onWait: (ms) => console.error(
|
|
1691
|
+
`~ Token of Trust's entitlement service didn't answer — retrying for up to ${Math.round(ms / 1000)}s`,
|
|
1692
|
+
),
|
|
1693
|
+
onFallback: (e) => {
|
|
1694
|
+
recordDiagnostic(e, {
|
|
1695
|
+
command: verb, operation: "tenant_checkout", required: false, degraded: true,
|
|
1696
|
+
}, env);
|
|
1697
|
+
console.error(
|
|
1698
|
+
`~ couldn't mint a fresh push credential (${redactUrl(String(/** @type {any} */ (e)?.message || e))}) — using the checkout's remote`,
|
|
1699
|
+
);
|
|
1700
|
+
},
|
|
1701
|
+
},
|
|
1702
|
+
);
|
|
1677
1703
|
|
|
1678
1704
|
console.error(`~ pushing ${short} → ${ref} (origin, fresh credential)`);
|
|
1679
1705
|
try {
|
|
@@ -1689,7 +1715,9 @@ export async function run(argv, ctx, {
|
|
|
1689
1715
|
const diagnostic = recordDiagnostic(new Error(msg, { cause: e }), {
|
|
1690
1716
|
command: verb, operation: "git_push", required: true, degraded: false,
|
|
1691
1717
|
}, env);
|
|
1692
|
-
console.error(fail(msg,
|
|
1718
|
+
console.error(fail(msg, e instanceof CliError && e.next
|
|
1719
|
+
? e.next
|
|
1720
|
+
: "check your commit and that the checkout's remote is reachable, then re-run"));
|
|
1693
1721
|
if (!args.json) console.error(` → ${diagnosticReference(diagnostic, env)}`);
|
|
1694
1722
|
emitJson(args, buildJsonResult({
|
|
1695
1723
|
ok: false, ref, commit, changeId, error: msg, diagnosticId: diagnostic.diagnosticId,
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -13,7 +13,8 @@ import { establishSession } from "../auth.mjs";
|
|
|
13
13
|
import { createMcpClient } from "../mcp.mjs";
|
|
14
14
|
import { normalizeStores, storeListError, noStoresGuidance } from "./clone.mjs";
|
|
15
15
|
import { recordServerPolicy } from "../update-check.mjs";
|
|
16
|
-
import { recordDiagnostic } from "../diagnostics.mjs";
|
|
16
|
+
import { noteInvocationSession, recordDiagnostic } from "../diagnostics.mjs";
|
|
17
|
+
import { DEGRADED_STORE_LIST, isDegradedStoreList } from "../broker-outage.mjs";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Pure summary of the cached session — no network. Returned for both display and
|
|
@@ -63,8 +64,13 @@ export async function run(argv, _ctx) {
|
|
|
63
64
|
recordServerPolicy(listResp?.cliPolicy, env);
|
|
64
65
|
const listErr = storeListError(listResp);
|
|
65
66
|
const stores = normalizeStores(listResp);
|
|
67
|
+
const degraded = isDegradedStoreList(listResp);
|
|
68
|
+
noteInvocationSession({ stores: stores.length, brokerStatus: listResp?.session?.brokerStatus ?? null });
|
|
69
|
+
if (degraded) console.log(` ! ${DEGRADED_STORE_LIST}`);
|
|
66
70
|
if (stores.length) {
|
|
67
|
-
console.log(` stores you can build on: ${stores.map((s) => s.id).join(", ")}`);
|
|
71
|
+
console.log(` stores you can build on${degraded ? " (incomplete)" : ""}: ${stores.map((s) => s.id).join(", ")}`);
|
|
72
|
+
} else if (degraded) {
|
|
73
|
+
// The outage line above is the whole story; the no-grants guidance would misdirect.
|
|
68
74
|
} else if (listErr) {
|
|
69
75
|
console.log(` (couldn't list your stores: ${listErr} — try \`tot login\` again)`);
|
|
70
76
|
} else {
|
|
@@ -59,7 +59,10 @@ export const NICOTINE_WARNING_TEXT: string;
|
|
|
59
59
|
export const CONSPICUITY_FLOOR: Readonly<{ contrast: number; fontSizePx: number; fontWeight: number }>;
|
|
60
60
|
export const DEFAULT_NICOTINE_WARNING_PRESET: string;
|
|
61
61
|
export const NICOTINE_WARNING_PRESETS: Readonly<Record<string, Readonly<NicotineWarningPresentation>>>;
|
|
62
|
+
export const NICOTINE_WARNING_KEYS: readonly string[];
|
|
62
63
|
export const NICOTINE_WARNING_STYLE_KEYS: readonly string[];
|
|
64
|
+
export const NICOTINE_WARNING_CHECK_WIDTH_BOUNDS: Readonly<{ min: number; max: number }>;
|
|
65
|
+
export function nicotineWarningCheckWidths(declaration: unknown): { widths: number[]; problems: string[] };
|
|
63
66
|
export function contrastRatio(a: string, b: string): number;
|
|
64
67
|
export function conspicuityProblems(p: NicotineWarningPresentation): string[];
|
|
65
68
|
export function resolveNicotineWarning(declaration: unknown): ResolvedNicotineWarning;
|
package/src/declared-config.mjs
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
|
|
14
14
|
* to npm on its own); test/declared-config-parity.test.mjs pins the two together.
|
|
15
|
-
* Dependency-free: the storefront runner copies this file as-is
|
|
15
|
+
* Dependency-free: the storefront runner copies this file as-is, and the devkit page checks carry
|
|
16
|
+
* a byte-identical copy (docs/devkit-staging/tools/lib/declared-config.mjs, pinned by
|
|
17
|
+
* test/declared-config-devkit-copy.test.mjs) so their gates read a store's declaration the same way.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
export const TOT_CONFIG_KEYS = [
|
|
@@ -184,6 +186,7 @@ export const MANDATED_NOTICE_KEYS = [
|
|
|
184
186
|
|
|
185
187
|
const ERROR = "error";
|
|
186
188
|
const WARN = "warn";
|
|
189
|
+
/** @type {(v: unknown) => v is Record<string, any>} */
|
|
187
190
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
188
191
|
|
|
189
192
|
/**
|
|
@@ -470,7 +473,13 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
470
473
|
}
|
|
471
474
|
}
|
|
472
475
|
out.push(...complianceFindings(config, file));
|
|
473
|
-
const
|
|
476
|
+
const declaration = isObject(config.compliance) ? config.compliance.nicotineWarning : undefined;
|
|
477
|
+
for (const problem of nicotineWarningCheckWidths(declaration).problems) {
|
|
478
|
+
out.push(mk(ERROR, "config-nicotine-warning-check-widths", `${file} compliance`,
|
|
479
|
+
`\`compliance.nicotineWarning\`: ${problem}. The page browser gate cannot check the warning at the widths you meant.`,
|
|
480
|
+
"list the widths where your layout changes (e.g. where your header becomes fixed), or remove `checkWidthsPx`"));
|
|
481
|
+
}
|
|
482
|
+
const warning = resolveNicotineWarning(declaration);
|
|
474
483
|
if (warning.enabled && warning.problems.length > 0) {
|
|
475
484
|
out.push(mk(ERROR, "config-nicotine-warning", `${file} compliance`, nicotineWarningProblemMessage(warning.problems),
|
|
476
485
|
'use `true`, `{ "preset": "dark" }` or `{ "preset": "light" }`, or fix the style values named above'));
|
|
@@ -504,6 +513,7 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
504
513
|
// "nicotineWarning": true → the "dark" preset
|
|
505
514
|
// "nicotineWarning": { "preset": "light" } → a validated preset
|
|
506
515
|
// "nicotineWarning": { "preset": "light", "style": {...} } → a custom presentation
|
|
516
|
+
// "nicotineWarning": { "checkWidthsPx": [800] } → also checked seen at 800px wide
|
|
507
517
|
//
|
|
508
518
|
// Every presentation — preset or custom — must clear CONSPICUITY_FLOOR. A preset needs no
|
|
509
519
|
// review. A custom presentation is a deviation the owner approves at go-live:
|
|
@@ -562,6 +572,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
|
|
|
562
572
|
* }} NicotineWarningPresentation
|
|
563
573
|
*/
|
|
564
574
|
|
|
575
|
+
/** Every key a `compliance.nicotineWarning` object may declare. */
|
|
576
|
+
export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWidthsPx"]);
|
|
577
|
+
|
|
565
578
|
export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
|
|
566
579
|
"background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
|
|
567
580
|
]);
|
|
@@ -669,8 +682,9 @@ export function resolveNicotineWarning(declaration) {
|
|
|
669
682
|
if (!isObject(declaration)) {
|
|
670
683
|
return resolved({ problems: ["must be `true` or an object like { \"preset\": \"light\" }"] });
|
|
671
684
|
}
|
|
672
|
-
const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, ...rest } =
|
|
673
|
-
|
|
685
|
+
const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, checkWidthsPx: _checkWidths, ...rest } =
|
|
686
|
+
/** @type {Record<string, unknown>} */ (declaration);
|
|
687
|
+
const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: ${NICOTINE_WARNING_KEYS.join(", ")})`);
|
|
674
688
|
const base = typeof presetName === "string" ? NICOTINE_WARNING_PRESETS[presetName] : undefined;
|
|
675
689
|
if (!base) {
|
|
676
690
|
problems.push(`\`preset\` must be one of ${Object.keys(NICOTINE_WARNING_PRESETS).map((p) => `"${p}"`).join(", ")}`);
|
|
@@ -695,6 +709,35 @@ export function resolveNicotineWarning(declaration) {
|
|
|
695
709
|
return resolved({ presentation, preset: matching ?? null, custom: !matching });
|
|
696
710
|
}
|
|
697
711
|
|
|
712
|
+
/** The viewport widths a store may add to the page browser gate's nicotine-warning check. */
|
|
713
|
+
export const NICOTINE_WARNING_CHECK_WIDTH_BOUNDS = Object.freeze({ min: 320, max: 3840 });
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The extra viewport widths at which the page browser gate checks the nicotine warning is seen.
|
|
717
|
+
*
|
|
718
|
+
* The gate always checks its own phone, tablet, laptop and desktop widths; `checkWidthsPx` adds
|
|
719
|
+
* the store's own breakpoints (e.g. the width where its header becomes fixed) and never removes
|
|
720
|
+
* a default. A store that does not declare the warning has no widths; `true` and an object
|
|
721
|
+
* without `checkWidthsPx` add none.
|
|
722
|
+
*
|
|
723
|
+
* "nicotineWarning": { "preset": "dark", "checkWidthsPx": [800, 1180] }
|
|
724
|
+
*
|
|
725
|
+
* `problems` lists why a declared value is unusable, in which case no extra width is used.
|
|
726
|
+
* @param {unknown} declaration the store's `compliance.nicotineWarning`
|
|
727
|
+
* @returns {{ widths: number[], problems: string[] }}
|
|
728
|
+
*/
|
|
729
|
+
export function nicotineWarningCheckWidths(declaration) {
|
|
730
|
+
if (!isObject(declaration)) return { widths: [], problems: [] };
|
|
731
|
+
const value = /** @type {Record<string, unknown>} */ (declaration).checkWidthsPx;
|
|
732
|
+
if (value === undefined) return { widths: [], problems: [] };
|
|
733
|
+
const { min, max } = NICOTINE_WARNING_CHECK_WIDTH_BOUNDS;
|
|
734
|
+
const usable = (/** @type {unknown} */ w) => Number.isInteger(w) && /** @type {number} */ (w) >= min && /** @type {number} */ (w) <= max;
|
|
735
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every(usable)) {
|
|
736
|
+
return { widths: [], problems: [`\`checkWidthsPx\` must be a non-empty array of whole pixel widths from ${min} to ${max}, e.g. [800]`] };
|
|
737
|
+
}
|
|
738
|
+
return { widths: [...new Set(/** @type {number[]} */ (value))].sort((a, b) => a - b), problems: [] };
|
|
739
|
+
}
|
|
740
|
+
|
|
698
741
|
/**
|
|
699
742
|
* The exact, human-readable statement of a custom presentation — what the owner approves.
|
|
700
743
|
* Every resolved value is in it, so two presentations that differ in any value describe
|
|
@@ -720,7 +763,8 @@ export function describeNicotineWarningPresentation(p) {
|
|
|
720
763
|
* fixed or sticky header pinned to the top of the viewport). Raising the warning above page
|
|
721
764
|
* chrome would paint it over the tenant's own open menus and drawers, so occlusion is measured
|
|
722
765
|
* instead: the page browser gate (`validate-page-browser.mjs`, `nicotineWarning`) fails a page
|
|
723
|
-
* whose warning is covered at a phone, tablet or desktop width
|
|
766
|
+
* whose warning is covered at a phone, tablet or desktop width, or at a width the store declares
|
|
767
|
+
* in `checkWidthsPx`, and names the covering element.
|
|
724
768
|
* @param {unknown} declaration the tenant's `compliance.nicotineWarning`
|
|
725
769
|
* @param {"bar" | "block"} variant
|
|
726
770
|
*/
|
package/src/diagnostics.mjs
CHANGED
|
@@ -37,6 +37,8 @@ const STRUCTURED_CORRELATION_ID = /^(?:[0-9a-f]{16,64}|[0-9a-f]{8}(?:-[0-9a-f]{4
|
|
|
37
37
|
|
|
38
38
|
let invocationDiagnostics = [];
|
|
39
39
|
let invocationContext = {};
|
|
40
|
+
/** @type {{ refreshed?: boolean, stores?: number, brokerStatus?: string|null }} */
|
|
41
|
+
let invocationSession = {};
|
|
40
42
|
|
|
41
43
|
export function diagnosticsLogPath(env = process.env) {
|
|
42
44
|
return join(env.TOT_HOME || homedir(), ".tot", "diagnostics.log");
|
|
@@ -154,6 +156,7 @@ export function recordDiagnostic(error, context = {}, env = process.env) {
|
|
|
154
156
|
httpStatus: source.httpStatus,
|
|
155
157
|
statusText: source.statusText,
|
|
156
158
|
serverCode: source.serverCode,
|
|
159
|
+
serverStatus: source.serverStatus,
|
|
157
160
|
message: source.message || (error && typeof error === "object" ? error.message : error),
|
|
158
161
|
traceId: source.traceId,
|
|
159
162
|
requestId: source.requestId,
|
|
@@ -206,6 +209,21 @@ export function diagnosticReference(record, env = process.env) {
|
|
|
206
209
|
export function resetInvocationDiagnostics(context = {}) {
|
|
207
210
|
invocationDiagnostics = [];
|
|
208
211
|
invocationContext = { ...context };
|
|
212
|
+
invocationSession = {};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Record a fact about this invocation's session for its activity-log line: that the token
|
|
217
|
+
* was refreshed, how many stores resolved, the broker's verdict. Counts and statuses only —
|
|
218
|
+
* never a token, never an identity.
|
|
219
|
+
* @param {{ refreshed?: boolean, stores?: number, brokerStatus?: string|null }} facts
|
|
220
|
+
*/
|
|
221
|
+
export function noteInvocationSession(facts) {
|
|
222
|
+
invocationSession = { ...invocationSession, ...facts };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function currentInvocationSession() {
|
|
226
|
+
return { ...invocationSession };
|
|
209
227
|
}
|
|
210
228
|
|
|
211
229
|
export function currentInvocationDiagnostics() {
|
package/src/readiness-checks.mjs
CHANGED
|
@@ -178,8 +178,38 @@ export function ecommerceStyleGuideCheck(styleJson) {
|
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
/**
|
|
182
|
+
* The store's declared compliance notices, judged on every page. Only a notice the store declares
|
|
183
|
+
* is a row, and a page that did not judge it fails: a declared legal notice nobody has seen on the
|
|
184
|
+
* page is not ready, whatever the page check reported.
|
|
185
|
+
* @param {{ gates?: Record<string, boolean | null> }[]} results
|
|
186
|
+
* @param {Record<string, any> | null | undefined} config the store's `.tot/config.json`
|
|
187
|
+
*/
|
|
188
|
+
export function complianceNoticesCheck(results, config) {
|
|
189
|
+
if (!config?.compliance?.nicotineWarning) return null;
|
|
190
|
+
const counts = countGate(results, "nicotineWarning");
|
|
191
|
+
const problem = counts.total === 0
|
|
192
|
+
? "No pages were discovered for validation."
|
|
193
|
+
: counts.fail > 0 || counts.unknown > 0
|
|
194
|
+
? `The store declares the FDA nicotine warning: ${counts.fail}/${counts.total} page(s) fail the visibility gate` +
|
|
195
|
+
`${counts.unknown > 0 ? ` and ${counts.unknown}/${counts.total} were not judged against the declaration` : ""}.`
|
|
196
|
+
: `The FDA nicotine warning is seen on all ${counts.total} page(s) at every checked width.`;
|
|
197
|
+
return {
|
|
198
|
+
area: "Compliance notices",
|
|
199
|
+
status: counts.total > 0 && counts.fail === 0 && counts.unknown === 0 ? "PASS" : "FAIL",
|
|
200
|
+
why: "A store that declares the FDA nicotine warning must show it, conspicuously and uncovered, on every page (21 CFR 1143.3).",
|
|
201
|
+
problem,
|
|
202
|
+
fix: "Run the browser page checks with --store <your checkout> on every page, then offset any fixed or sticky header the gate names; never restyle or move the warning.",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {{ aggregate: any, styleJson: any, args: { manifest: string }, config?: Record<string, any> | null }} input
|
|
208
|
+
* `config` is the store's `.tot/config.json`, which decides the compliance notices row.
|
|
209
|
+
*/
|
|
210
|
+
export function buildReadinessChecks({ aggregate, styleJson, args, config = null }) {
|
|
182
211
|
const results = aggregate.pages;
|
|
212
|
+
const compliance = complianceNoticesCheck(results, config);
|
|
183
213
|
const render = countGate(results, "render");
|
|
184
214
|
const boundary = countGate(results, "boundary");
|
|
185
215
|
const seo = combineCounts(countGate(results, "seo"), countGate(results, "sitemap"));
|
|
@@ -191,6 +221,7 @@ export function buildReadinessChecks({ aggregate, styleJson, args }) {
|
|
|
191
221
|
const lowRisk = aggregate.totals.issues.lowRiskOpen ?? 0;
|
|
192
222
|
|
|
193
223
|
return [
|
|
224
|
+
...(compliance ? [compliance] : []),
|
|
194
225
|
styleGuideCheck(styleJson, args.manifest),
|
|
195
226
|
ecommerceStyleGuideCheck(styleJson),
|
|
196
227
|
styleQualityCheck(styleJson),
|