@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 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.4",
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
- * FAILS SILENT ON STDOUT, NEVER LOUD THERE: `get` prints NOTHING on stdout and
28
- * exits non-zero on any problem (not signed in, MCP unreachable, cwd isn't a
29
- * recognizable tenant checkout) — git then falls through to its next
30
- * configured helper or its own prompt, exactly as if this helper weren't
31
- * configured. A stack trace or a malformed credential line on STDOUT here
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
- * It is NOT silent on stderr for every failure, though: when the MCP itself
38
- * renders an authoritative decision — a `CliError` from `tenant_checkout` or
39
- * `repo_read_credential`, e.g. "this checkout names a tenant your session
40
- * doesn't hold" — that diagnosis is lost forever if swallowed, and git passes
41
- * a credential helper's stderr straight through to the developer's terminal.
42
- * So those specific catches print the CliError's house-style message (+ its
43
- * `next` step) to stderr before returning null. A bare network/auth failure
44
- * (not signed in, MCP unreachable, wrong dir) has no such decision to report
45
- * and stays silent exactly as before.
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 { CliError, formatError } from "../errors.mjs";
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 (wrong dir, not signed in, MCP unreachable) rather than
102
- * throwing — `get` treats null as "say nothing, exit non-zero".
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 authFailure = (e) => {
136
- if (e instanceof AuthUnavailableError) console.error(`tot: ${e.message} ${e.hint || `Run tot login --mcp ${authority}.`}`);
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
- authFailure(e);
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
- authFailure(e);
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
- // A CliError is an authoritative decision (e.g. the kit credential was refused) —
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
- authFailure(e);
243
- return null; // not signed in (or session unrefreshable) — nothing this helper can do
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
- // Same distinction as the kit-checkout catch above: `checkoutTenant` renders a
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; // silent — let git fall through to its next helper / its own prompt.
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
  }));
@@ -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.");
@@ -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 evidence = evidenceFromDir({ pagesDir, stylePath: args.style ? resolve(args.style) : null, lint });
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}`,
@@ -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 = async () => {
1664
- try {
1665
- const res = await checkout(client, { tenant, tag, cloneDir: null, redact: redactUrl });
1666
- return res.gitRemote || null;
1667
- } catch (e) {
1668
- recordDiagnostic(e, {
1669
- command: verb, operation: "tenant_checkout", required: false, degraded: true,
1670
- }, env);
1671
- console.error(
1672
- `~ couldn't mint a fresh push credential (${redactUrl(String(e?.message || e))}) — using the checkout's remote`,
1673
- );
1674
- return null;
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, "check your commit and that the checkout's remote is reachable, then re-run"));
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,
@@ -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;
@@ -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 warning = resolveNicotineWarning(isObject(config.compliance) ? config.compliance.nicotineWarning : undefined);
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 } = /** @type {Record<string, unknown>} */ (declaration);
673
- const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: preset, style)`);
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 and names the covering element.
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
  */
@@ -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() {
@@ -178,8 +178,38 @@ export function ecommerceStyleGuideCheck(styleJson) {
178
178
  };
179
179
  }
180
180
 
181
- export function buildReadinessChecks({ aggregate, styleJson, args }) {
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),