@tokenoftrust/cli 1.3.3 → 1.3.4-rc.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/package.json +1 -1
- package/src/auth.mjs +71 -4
- package/src/commands/feedback.mjs +37 -6
- package/src/commands/login.mjs +62 -8
- package/src/commands/start.mjs +1 -1
- package/src/token-store.mjs +11 -2
- package/src/validate.mjs +220 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4-rc.0",
|
|
4
4
|
"description": "Token of Trust developer CLI — check out 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
|
@@ -31,13 +31,74 @@ import { recordServerPolicy } from "./update-check.mjs";
|
|
|
31
31
|
|
|
32
32
|
/** Thrown when no provider can authenticate — carries actionable guidance. */
|
|
33
33
|
export class AuthUnavailableError extends Error {
|
|
34
|
-
constructor(message, { hint } = {}) {
|
|
34
|
+
constructor(message, { hint, reason } = {}) {
|
|
35
35
|
super(message);
|
|
36
36
|
this.name = "AuthUnavailableError";
|
|
37
37
|
this.hint = hint || null;
|
|
38
|
+
this.reason = reason || null;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
const FULL_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
43
|
+
const SAFE_EMAIL_HINT_RE = /^[a-z0-9._%+\-]{1,96}@[a-z0-9.-]{1,96}\.[a-z]{2,63}$/i;
|
|
44
|
+
|
|
45
|
+
function maskEmailPart(part) {
|
|
46
|
+
if (part.length <= 2) return `${part.slice(0, 1)}...`;
|
|
47
|
+
return `${part.slice(0, 1)}...${part.slice(-1)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Display-only email hint for URLs/log-safe UX. Never returns the full address. */
|
|
51
|
+
export function redactEmailForHint(email) {
|
|
52
|
+
const raw = String(email || "").trim().toLowerCase();
|
|
53
|
+
const at = raw.indexOf("@");
|
|
54
|
+
if (at <= 0) return null;
|
|
55
|
+
const local = raw.slice(0, at);
|
|
56
|
+
const domain = raw.slice(at + 1);
|
|
57
|
+
const parts = domain.split(".").filter(Boolean);
|
|
58
|
+
if (parts.length < 2) return null;
|
|
59
|
+
const suffix = parts.pop();
|
|
60
|
+
const registrable = parts.join(".");
|
|
61
|
+
if (!suffix || !registrable) return null;
|
|
62
|
+
return `${maskEmailPart(local)}@${maskEmailPart(registrable)}.${suffix}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeEmailHint(input) {
|
|
66
|
+
const raw = String(input || "").trim().toLowerCase();
|
|
67
|
+
if (!raw || raw.length > 160 || /\s/.test(raw) || !raw.includes("@")) return null;
|
|
68
|
+
if (FULL_EMAIL_RE.test(raw) && !raw.includes("...")) return redactEmailForHint(raw);
|
|
69
|
+
return SAFE_EMAIL_HINT_RE.test(raw) ? raw : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function credentialEmailHint(creds) {
|
|
73
|
+
return (
|
|
74
|
+
normalizeEmailHint(creds?.emailHint) ||
|
|
75
|
+
normalizeEmailHint(creds?.email) ||
|
|
76
|
+
redactEmailForHint(emailFromJwt(creds?.accessToken || "") || "")
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Hosted cockpit URL for regenerating local CLI credentials, when we cached one. */
|
|
81
|
+
export function cockpitRecoveryUrl(activityUrl, emailHint = null) {
|
|
82
|
+
if (!activityUrl) return null;
|
|
83
|
+
try {
|
|
84
|
+
const url = new URL("/cockpit?recover=cli", String(activityUrl));
|
|
85
|
+
const hint = normalizeEmailHint(emailHint);
|
|
86
|
+
if (hint) url.searchParams.set("email_hint", hint);
|
|
87
|
+
return url.toString();
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Next-step copy for a stale local developer credential. */
|
|
94
|
+
export function developerCredentialRecoveryHint(creds) {
|
|
95
|
+
const url = cockpitRecoveryUrl(creds?.activityUrl, credentialEmailHint(creds));
|
|
96
|
+
if (url) {
|
|
97
|
+
return `open ${url}, sign in if prompted, click "Generate a fresh setup command", then paste it into your terminal.`;
|
|
98
|
+
}
|
|
99
|
+
return "run `tot login` to sign in again.";
|
|
100
|
+
}
|
|
101
|
+
|
|
41
102
|
/** True when a full operator credential triple is present in the environment. */
|
|
42
103
|
export function hasOperatorCreds(env = process.env) {
|
|
43
104
|
return Boolean(env.TOT_API_KEY && env.TOT_SECRET_KEY && env.TOT_APP_DOMAIN);
|
|
@@ -95,7 +156,10 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
95
156
|
if (!creds || !creds.accessToken) {
|
|
96
157
|
throw new AuthUnavailableError(
|
|
97
158
|
"you're not signed in to Token of Trust.",
|
|
98
|
-
{
|
|
159
|
+
{
|
|
160
|
+
reason: "missing",
|
|
161
|
+
hint: "run `tot login` to sign in (or `tot login --code <token>` to paste your invite token), then re-run.",
|
|
162
|
+
},
|
|
99
163
|
);
|
|
100
164
|
}
|
|
101
165
|
|
|
@@ -103,7 +167,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
103
167
|
if (!creds.refreshToken || !creds.tokenEndpoint) {
|
|
104
168
|
throw new AuthUnavailableError(
|
|
105
169
|
"your Token of Trust session has expired.",
|
|
106
|
-
{
|
|
170
|
+
{ reason: "expired", hint: developerCredentialRecoveryHint(creds) },
|
|
107
171
|
);
|
|
108
172
|
}
|
|
109
173
|
let refreshed;
|
|
@@ -116,7 +180,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
116
180
|
} catch (e) {
|
|
117
181
|
throw new AuthUnavailableError(
|
|
118
182
|
`couldn't refresh your Token of Trust session: ${e?.message || e}`,
|
|
119
|
-
{
|
|
183
|
+
{ reason: "refreshFailed", hint: developerCredentialRecoveryHint(creds) },
|
|
120
184
|
);
|
|
121
185
|
}
|
|
122
186
|
const prior = creds;
|
|
@@ -138,6 +202,9 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
138
202
|
creds.activityToken = prior.activityToken;
|
|
139
203
|
creds.activityUrl = prior.activityUrl;
|
|
140
204
|
}
|
|
205
|
+
if (prior.traceId) creds.traceId = prior.traceId;
|
|
206
|
+
const priorEmailHint = credentialEmailHint(prior);
|
|
207
|
+
if (priorEmailHint) creds.emailHint = priorEmailHint;
|
|
141
208
|
writeCredentials(path, creds);
|
|
142
209
|
}
|
|
143
210
|
|
|
@@ -24,6 +24,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
24
24
|
import { createMcpClient } from "../mcp.mjs";
|
|
25
25
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
26
26
|
import { readActivity, formatActivity } from "../activity-log.mjs";
|
|
27
|
+
import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
|
|
27
28
|
import { fail } from "../errors.mjs";
|
|
28
29
|
|
|
29
30
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
@@ -50,6 +51,30 @@ const USAGE = `tot feedback — send a note to Token of Trust (with your recent
|
|
|
50
51
|
|
|
51
52
|
Requires a signed-in session — run \`tot login\` first if needed.`;
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Assemble the `feedback_submit` payload. Pure + exported so the wire shape
|
|
56
|
+
* (including the additive `sensitive` activity attachment and the invite→problem
|
|
57
|
+
* `traceId` link) is unit-testable without driving the MCP handshake. Both extras
|
|
58
|
+
* are OMITTED when their source is absent — never sent as null/empty.
|
|
59
|
+
* @param {{ type: string, category: string, severity: string, title: string,
|
|
60
|
+
* description: string, activityText?: string|null, traceId?: string|null }} f
|
|
61
|
+
*/
|
|
62
|
+
export function buildFeedbackPayload({ type, category, severity, title, description, activityText, traceId }) {
|
|
63
|
+
return {
|
|
64
|
+
type,
|
|
65
|
+
category,
|
|
66
|
+
severity,
|
|
67
|
+
title,
|
|
68
|
+
description,
|
|
69
|
+
scenario: "tot-cli",
|
|
70
|
+
// Activity → `sensitive` (ToT-admins-only, never clustered/shared). It's already
|
|
71
|
+
// secret-redacted; `sensitive` is the belt-and-suspenders home for it.
|
|
72
|
+
...(activityText ? { sensitive: `tot CLI activity (most recent last):\n${activityText}` } : {}),
|
|
73
|
+
// Invite→terminal→problem trace link (omitted when this wasn't an invite login).
|
|
74
|
+
...(traceId ? { traceId } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
function parseArgs(argv) {
|
|
54
79
|
const a = {
|
|
55
80
|
mcp: null, type: "improvement", category: "product-ux", severity: "medium",
|
|
@@ -115,6 +140,14 @@ export async function run(argv) {
|
|
|
115
140
|
const activityText = entries.length ? formatActivity(entries) : null;
|
|
116
141
|
const title = message.length > 80 ? `${message.slice(0, 79)}…` : message;
|
|
117
142
|
|
|
143
|
+
// The invite→terminal→problem trace id `tot login` cached from the pasted invite
|
|
144
|
+
// command (see commands/login.mjs `cacheTraceId`). Stamping it onto the report lets
|
|
145
|
+
// triage join this problem back to the developer's invite + their live heartbeat
|
|
146
|
+
// session. Read it BEFORE establishSession — a silent token refresh rewrites the
|
|
147
|
+
// credential file, so read the cached value first. Absent for a bare (non-invite)
|
|
148
|
+
// login; omitted from the payload then.
|
|
149
|
+
const traceId = readCredentials(defaultCredentialsPath(env))?.traceId || null;
|
|
150
|
+
|
|
118
151
|
// Preview — sending publishes to Token of Trust, so show exactly what goes out.
|
|
119
152
|
console.error("\nAbout to send to Token of Trust:");
|
|
120
153
|
console.error(` ${args.type} · ${args.category} · ${args.severity}`);
|
|
@@ -152,17 +185,15 @@ export async function run(argv) {
|
|
|
152
185
|
env,
|
|
153
186
|
initialize: () => client.initialize({ name: "tot-cli", version: "feedback" }),
|
|
154
187
|
});
|
|
155
|
-
const payload = {
|
|
188
|
+
const payload = buildFeedbackPayload({
|
|
156
189
|
type: args.type,
|
|
157
190
|
category: args.category,
|
|
158
191
|
severity: args.severity,
|
|
159
192
|
title,
|
|
160
193
|
description: message,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
...(activityText ? { sensitive: `tot CLI activity (most recent last):\n${activityText}` } : {}),
|
|
165
|
-
};
|
|
194
|
+
activityText,
|
|
195
|
+
traceId,
|
|
196
|
+
});
|
|
166
197
|
const res = await client.callTool("feedback_submit", payload);
|
|
167
198
|
const id = res?.reportId || res?.id || null;
|
|
168
199
|
console.log(`\n+ sent — thank you.${id ? ` (report ${id})` : ""}`);
|
package/src/commands/login.mjs
CHANGED
|
@@ -23,13 +23,14 @@ import { loginFlow, deviceLoginFlow, redeemCodeFlow, NoOpenerError } from "../oa
|
|
|
23
23
|
import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
|
|
24
24
|
import { openBrowser } from "../open.mjs";
|
|
25
25
|
import { fail } from "../errors.mjs";
|
|
26
|
+
import { cockpitRecoveryUrl, normalizeEmailHint } from "../auth.mjs";
|
|
26
27
|
|
|
27
28
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
28
29
|
|
|
29
30
|
function parseArgs(argv) {
|
|
30
31
|
const a = {
|
|
31
32
|
mcp: null, device: false, code: null, help: false,
|
|
32
|
-
activityToken: null, activityUrl: null,
|
|
33
|
+
activityToken: null, activityUrl: null, traceId: null, emailHint: null,
|
|
33
34
|
};
|
|
34
35
|
for (let i = 0; i < argv.length; i++) {
|
|
35
36
|
const t = argv[i];
|
|
@@ -38,11 +39,44 @@ function parseArgs(argv) {
|
|
|
38
39
|
else if (t === "--code" || t === "--token") a.code = argv[++i];
|
|
39
40
|
else if (t === "--activity-token") a.activityToken = argv[++i];
|
|
40
41
|
else if (t === "--activity-url") a.activityUrl = argv[++i];
|
|
42
|
+
else if (t === "--trace-id") a.traceId = argv[++i];
|
|
43
|
+
else if (t === "--email-hint") a.emailHint = argv[++i];
|
|
44
|
+
else if (t === "--email") a.emailHint = argv[++i]; // legacy pasted commands
|
|
41
45
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
42
46
|
}
|
|
43
47
|
return a;
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/** Canonical UUID (8-4-4-4-12 hex). Used to reject a malformed --trace-id paste. */
|
|
51
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cache the invite→terminal→problem trace id (storefront mints it when it issues
|
|
55
|
+
* the developer's CLI sign-in code and appends it to the pasted login command as
|
|
56
|
+
* `--trace-id <uuid>` — see apps/storefront/src/lib/dev/cliSignInCode.ts) alongside
|
|
57
|
+
* the MCP creds `tot login` just wrote, so `tot feedback` can stamp a later problem
|
|
58
|
+
* report with it and triage can join the report to the invite + live heartbeat
|
|
59
|
+
* session. Additive, and mirrors cacheActivityBridge. A no-op when the flag is
|
|
60
|
+
* absent (older pastes / a bare `tot login`) or malformed (defensive — skip rather
|
|
61
|
+
* than persist junk that would break the join).
|
|
62
|
+
*/
|
|
63
|
+
export function cacheTraceId(env, traceId) {
|
|
64
|
+
if (!traceId || !UUID_RE.test(traceId)) return;
|
|
65
|
+
const path = defaultCredentialsPath(env);
|
|
66
|
+
const current = readCredentials(path) || {};
|
|
67
|
+
writeCredentials(path, { ...current, traceId });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Cache a display-only email hint from the setup command for later recovery UX. */
|
|
71
|
+
export function cacheEmailHint(env, emailHint) {
|
|
72
|
+
const normalized = normalizeEmailHint(emailHint);
|
|
73
|
+
if (!normalized) return;
|
|
74
|
+
const path = defaultCredentialsPath(env);
|
|
75
|
+
const current = readCredentials(path) || {};
|
|
76
|
+
const { email: _legacyEmail, ...safeCurrent } = current;
|
|
77
|
+
writeCredentials(path, { ...safeCurrent, emailHint: normalized });
|
|
78
|
+
}
|
|
79
|
+
|
|
46
80
|
/**
|
|
47
81
|
* Cache the local→hosted activity-bridge credential (storefront's
|
|
48
82
|
* cli-signin-code mint, piggybacked as two extra login flags — see
|
|
@@ -68,9 +102,10 @@ const USAGE = `tot login — sign in to Token of Trust
|
|
|
68
102
|
browser opener exists on this box)
|
|
69
103
|
tot login --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
70
104
|
|
|
71
|
-
--activity-token/--activity-url are set automatically by the pasted
|
|
72
|
-
command (report local dev-loop activity to your hosted /dev panel
|
|
73
|
-
|
|
105
|
+
--activity-token/--activity-url/--trace-id/--email-hint are set automatically by the pasted
|
|
106
|
+
invite command (report local dev-loop activity to your hosted /dev panel, and
|
|
107
|
+
link a later \`tot feedback\` report to your invite + session) — not meant to be
|
|
108
|
+
typed by hand.
|
|
74
109
|
|
|
75
110
|
After signing in, run \`tot whoami\` to confirm, then \`tot checkout\` / \`tot submit\`.`;
|
|
76
111
|
|
|
@@ -128,15 +163,31 @@ export async function redeemAndCache(mcpUrl, code, env = process.env) {
|
|
|
128
163
|
* prior --code sign-in would otherwise silently drop it on the next `writeCredentials`
|
|
129
164
|
* (a full-object overwrite, not a merge) — carry it forward when re-authing against
|
|
130
165
|
* the SAME mcpUrl (a different MCP means a different session; the old bridge
|
|
131
|
-
* credential no longer
|
|
166
|
+
* credential and trace no longer apply).
|
|
132
167
|
*/
|
|
133
168
|
export function mergeActivityBridge(prior, mcpUrl, creds) {
|
|
134
|
-
if (prior?.mcpUrl === mcpUrl
|
|
135
|
-
|
|
169
|
+
if (prior?.mcpUrl === mcpUrl) {
|
|
170
|
+
const emailHint = normalizeEmailHint(prior.emailHint) || normalizeEmailHint(prior.email);
|
|
171
|
+
return {
|
|
172
|
+
...creds,
|
|
173
|
+
...(prior.activityToken && prior.activityUrl
|
|
174
|
+
? { activityToken: prior.activityToken, activityUrl: prior.activityUrl }
|
|
175
|
+
: {}),
|
|
176
|
+
...(prior.traceId ? { traceId: prior.traceId } : {}),
|
|
177
|
+
...(emailHint ? { emailHint } : {}),
|
|
178
|
+
};
|
|
136
179
|
}
|
|
137
180
|
return creds;
|
|
138
181
|
}
|
|
139
182
|
|
|
183
|
+
export function inviteCodeRecoveryNext(activityUrl, emailHint = null) {
|
|
184
|
+
const url = cockpitRecoveryUrl(activityUrl, emailHint);
|
|
185
|
+
if (url) {
|
|
186
|
+
return `open ${url}, sign in if prompted, click "Generate a fresh setup command", then paste it into your terminal.`;
|
|
187
|
+
}
|
|
188
|
+
return 'open your developer invite again. If the link has expired, use "Send a fresh link", then generate a new setup command from cockpit.';
|
|
189
|
+
}
|
|
190
|
+
|
|
140
191
|
/** @param {string[]} argv @param {any} _ctx */
|
|
141
192
|
export async function run(argv, _ctx) {
|
|
142
193
|
const env = process.env;
|
|
@@ -153,6 +204,8 @@ export async function run(argv, _ctx) {
|
|
|
153
204
|
try {
|
|
154
205
|
await redeemAndCache(mcpUrl, args.code, env);
|
|
155
206
|
cacheActivityBridge(env, args.activityToken, args.activityUrl);
|
|
207
|
+
cacheTraceId(env, args.traceId);
|
|
208
|
+
cacheEmailHint(env, args.emailHint);
|
|
156
209
|
console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
|
|
157
210
|
console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
|
|
158
211
|
return 0;
|
|
@@ -160,7 +213,7 @@ export async function run(argv, _ctx) {
|
|
|
160
213
|
console.error(
|
|
161
214
|
fail(
|
|
162
215
|
`sign-in didn't complete: ${e?.message || e}`,
|
|
163
|
-
|
|
216
|
+
inviteCodeRecoveryNext(args.activityUrl, args.emailHint),
|
|
164
217
|
),
|
|
165
218
|
);
|
|
166
219
|
return 1;
|
|
@@ -170,6 +223,7 @@ export async function run(argv, _ctx) {
|
|
|
170
223
|
console.error(`~ signing in to Token of Trust (${mcpUrl})`);
|
|
171
224
|
try {
|
|
172
225
|
await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
|
|
226
|
+
cacheTraceId(env, args.traceId);
|
|
173
227
|
console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
|
|
174
228
|
console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
|
|
175
229
|
return 0;
|
package/src/commands/start.mjs
CHANGED
|
@@ -374,7 +374,7 @@ async function tryResolveSession(client, env, args) {
|
|
|
374
374
|
try {
|
|
375
375
|
return await loginStep(client, env, args);
|
|
376
376
|
} catch (e) {
|
|
377
|
-
if (e instanceof AuthUnavailableError) return null;
|
|
377
|
+
if (e instanceof AuthUnavailableError && e.reason === "missing") return null;
|
|
378
378
|
if (e instanceof CliError && /can't reach the Token of Trust MCP/.test(e.message)) return null;
|
|
379
379
|
throw e;
|
|
380
380
|
}
|
package/src/token-store.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* { mcpUrl, clientId, tokenEndpoint, scope,
|
|
9
9
|
* accessToken, refreshToken, expiresAt (epoch ms), obtainedAt,
|
|
10
|
-
* activityToken, activityUrl }
|
|
10
|
+
* activityToken, activityUrl, traceId, emailHint }
|
|
11
11
|
*
|
|
12
12
|
* We persist `clientId` + `tokenEndpoint` so a refresh needs no re-discovery /
|
|
13
13
|
* re-registration, and `mcpUrl` so we never present a token minted for one MCP
|
|
@@ -16,7 +16,16 @@
|
|
|
16
16
|
* invite paste carries it — see commands/login.mjs `cacheActivityBridge` and
|
|
17
17
|
* commands/dev.mjs `activityBridgeEnv`, which every runner-spawning path (native
|
|
18
18
|
* monorepo, native standalone, Docker) threads in so the local dev-loop process
|
|
19
|
-
* can report file saves to the developer's hosted /dev panel.
|
|
19
|
+
* can report file saves to the developer's hosted /dev panel. `traceId` is the
|
|
20
|
+
* storefront-minted invite→terminal→problem trace id (cached by `tot login`'s
|
|
21
|
+
* `--trace-id` flag, see commands/login.mjs `cacheTraceId`); `tot feedback`
|
|
22
|
+
* stamps it onto a problem report so triage can join it to the invite + the live
|
|
23
|
+
* heartbeat session. `emailHint` is masked, display-only recovery context from
|
|
24
|
+
* the setup command, used to orient cockpit/login after a local credential
|
|
25
|
+
* expires without persisting a raw address.
|
|
26
|
+
*
|
|
27
|
+
* The read/write is a plain JSON pass-through (no schema) — new fields like
|
|
28
|
+
* `traceId` ride along additively; readers simply pick the keys they need.
|
|
20
29
|
* Dependency-free (node:fs/os/path).
|
|
21
30
|
*
|
|
22
31
|
* `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
|
package/src/validate.mjs
CHANGED
|
@@ -119,6 +119,195 @@ function providesChrome(contentDir) {
|
|
|
119
119
|
return true;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
const CAPABILITY_KEYS = new Set(["cartCheckout", "ageVerification", "exciseTax"]);
|
|
123
|
+
|
|
124
|
+
function complianceObligations(config) {
|
|
125
|
+
const compliance = config?.compliance || {};
|
|
126
|
+
return {
|
|
127
|
+
ageVerification: !!compliance.minAge,
|
|
128
|
+
exciseTax: !!compliance.nicotineWarning,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rawHtmlAllowed(config) {
|
|
133
|
+
const obligations = complianceObligations(config);
|
|
134
|
+
const regulated = obligations.ageVerification || obligations.exciseTax;
|
|
135
|
+
return !regulated;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validateCapabilitiesDoc(doc, file, config) {
|
|
139
|
+
const out = [];
|
|
140
|
+
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
141
|
+
return [mk(ERROR, "capabilities-root", file, "capabilities must be a JSON object")];
|
|
142
|
+
}
|
|
143
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
144
|
+
if (!CAPABILITY_KEYS.has(key)) {
|
|
145
|
+
out.push(mk(ERROR, "capability-unknown", file, `unknown capability "${key}"`));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (value == null || typeof value !== "object" || typeof value.enabled !== "boolean") {
|
|
149
|
+
out.push(mk(ERROR, "capability-shape", file, `${key} must be an object with boolean "enabled"`));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const obligations = complianceObligations(config);
|
|
153
|
+
if (obligations.ageVerification && doc.ageVerification?.enabled === false) {
|
|
154
|
+
out.push(mk(ERROR, "capability-compliance-floor", file, "ageVerification cannot be disabled for a tenant with compliance.minAge"));
|
|
155
|
+
}
|
|
156
|
+
if (obligations.exciseTax && doc.exciseTax?.enabled === false) {
|
|
157
|
+
out.push(mk(ERROR, "capability-compliance-floor", file, "exciseTax cannot be disabled for a tenant with compliance.nicotineWarning"));
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateScriptsDoc(doc, file, config) {
|
|
163
|
+
const out = [];
|
|
164
|
+
if (doc == null || typeof doc !== "object" || !Array.isArray(doc.scripts)) {
|
|
165
|
+
return [mk(ERROR, "scripts-root", file, 'scripts.json must have a "scripts" array')];
|
|
166
|
+
}
|
|
167
|
+
const regulated = !rawHtmlAllowed(config);
|
|
168
|
+
for (const [i, entry] of doc.scripts.entries()) {
|
|
169
|
+
const at = `${file} scripts[${i}]`;
|
|
170
|
+
if (entry == null || typeof entry !== "object") {
|
|
171
|
+
out.push(mk(ERROR, "script-entry", at, "script entry must be an object"));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (entry.isolation === "inline") {
|
|
175
|
+
out.push(
|
|
176
|
+
regulated
|
|
177
|
+
? mk(ERROR, "script-inline-regulated", at, 'inline scripts are not allowed for regulated tenants; use isolation:"sandbox"')
|
|
178
|
+
: mk(WARN, "script-inline", at, 'inline scripts run same-origin; prefer isolation:"sandbox"'),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const SUPPORTED_BLOCK_CONTRACTS = new Set(["block-palette@1", "block-palette@2", "block-palette@3"]);
|
|
186
|
+
const KNOWN_BLOCKS = new Set([
|
|
187
|
+
"hero",
|
|
188
|
+
"promo_tiles",
|
|
189
|
+
"featured_collections",
|
|
190
|
+
"featured_products",
|
|
191
|
+
"editorial",
|
|
192
|
+
"newsletter",
|
|
193
|
+
"marketing_hero",
|
|
194
|
+
"trust_bar",
|
|
195
|
+
"split_compare",
|
|
196
|
+
"steps",
|
|
197
|
+
"card_grid",
|
|
198
|
+
"integrations",
|
|
199
|
+
"proof_strip",
|
|
200
|
+
"testimonials",
|
|
201
|
+
"faq",
|
|
202
|
+
"cta_band",
|
|
203
|
+
]);
|
|
204
|
+
const REQUIRED_BLOCK_PROPS = {
|
|
205
|
+
hero: ["headline"],
|
|
206
|
+
promo_tiles: ["tiles"],
|
|
207
|
+
featured_collections: ["handles"],
|
|
208
|
+
editorial: ["title"],
|
|
209
|
+
newsletter: ["title"],
|
|
210
|
+
marketing_hero: ["headline"],
|
|
211
|
+
trust_bar: ["items"],
|
|
212
|
+
split_compare: ["title", "before", "after"],
|
|
213
|
+
steps: ["title", "steps"],
|
|
214
|
+
card_grid: ["title", "cards"],
|
|
215
|
+
integrations: ["title", "platforms"],
|
|
216
|
+
proof_strip: ["title"],
|
|
217
|
+
testimonials: ["quotes"],
|
|
218
|
+
faq: ["title", "items"],
|
|
219
|
+
cta_band: ["title"],
|
|
220
|
+
};
|
|
221
|
+
const PRODUCT_SOURCE_KEYS = new Set([
|
|
222
|
+
"collection",
|
|
223
|
+
"tag",
|
|
224
|
+
"featured",
|
|
225
|
+
"newest",
|
|
226
|
+
"bestSelling",
|
|
227
|
+
"onSale",
|
|
228
|
+
"handles",
|
|
229
|
+
"related",
|
|
230
|
+
"recentlyViewed",
|
|
231
|
+
]);
|
|
232
|
+
const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
|
|
233
|
+
|
|
234
|
+
function validateProductSource(value, file, at) {
|
|
235
|
+
const out = [];
|
|
236
|
+
if (typeof value === "string") {
|
|
237
|
+
if (!PRODUCT_SOURCE_SHORTCUTS.has(value)) {
|
|
238
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source must be one of featured, newest, bestSelling, onSale or a source object`));
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
243
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source must be a source string or object`));
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined);
|
|
247
|
+
const recognized = keys.filter((k) => PRODUCT_SOURCE_KEYS.has(k));
|
|
248
|
+
if (keys.length !== 1 || recognized.length !== 1) {
|
|
249
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source must declare exactly one recognized source key`));
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
const key = recognized[0];
|
|
253
|
+
const got = value[key];
|
|
254
|
+
if (key === "handles") {
|
|
255
|
+
if (!Array.isArray(got) || !got.every((v) => typeof v === "string")) {
|
|
256
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source.handles must be a string array`));
|
|
257
|
+
}
|
|
258
|
+
} else if (key === "collection" || key === "tag" || key === "related") {
|
|
259
|
+
if (typeof got !== "string") {
|
|
260
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source.${key} must be a string`));
|
|
261
|
+
}
|
|
262
|
+
} else if (got !== true) {
|
|
263
|
+
out.push(mk(ERROR, "product-source", file, `${at}.source.${key} must be true`));
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function validateHomeDoc(doc, file) {
|
|
269
|
+
const out = [];
|
|
270
|
+
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
271
|
+
return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
|
|
272
|
+
}
|
|
273
|
+
if (doc.contract !== undefined && (typeof doc.contract !== "string" || !SUPPORTED_BLOCK_CONTRACTS.has(doc.contract))) {
|
|
274
|
+
out.push(mk(ERROR, "home-contract", file, `unsupported block contract "${String(doc.contract)}"`));
|
|
275
|
+
}
|
|
276
|
+
if (!Array.isArray(doc.blocks)) {
|
|
277
|
+
out.push(mk(ERROR, "home-blocks", file, "`blocks` must be an array"));
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
doc.blocks.forEach((block, i) => {
|
|
281
|
+
const at = `blocks[${i}]`;
|
|
282
|
+
if (block == null || typeof block !== "object" || Array.isArray(block)) {
|
|
283
|
+
out.push(mk(ERROR, "block-shape", file, `${at} must be an object`));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const component = block.component;
|
|
287
|
+
if (typeof component !== "string") {
|
|
288
|
+
out.push(mk(ERROR, "block-component", file, `${at}.component is required`));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!KNOWN_BLOCKS.has(component)) {
|
|
292
|
+
out.push(mk(ERROR, "block-unknown", file, `${at}.component "${component}" is not in the supported block palette`));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
|
|
296
|
+
if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
|
|
297
|
+
}
|
|
298
|
+
if (component === "featured_products") {
|
|
299
|
+
if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
|
|
300
|
+
if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
301
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
|
|
302
|
+
}
|
|
303
|
+
if (block.limit !== undefined && typeof block.limit !== "number") {
|
|
304
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
122
311
|
// --- filesystem helpers ------------------------------------------------------
|
|
123
312
|
function readJsonSafe(path) {
|
|
124
313
|
try {
|
|
@@ -181,6 +370,9 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
181
370
|
);
|
|
182
371
|
}
|
|
183
372
|
const scope = opts.scope || config?.scope;
|
|
373
|
+
if (config?.capabilities) {
|
|
374
|
+
findings.push(...validateCapabilitiesDoc(config.capabilities, ".tot/config.json capabilities", config));
|
|
375
|
+
}
|
|
184
376
|
|
|
185
377
|
// 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
|
|
186
378
|
const themePath = join(tenantDir, "theme.json");
|
|
@@ -194,6 +386,26 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
194
386
|
}
|
|
195
387
|
}
|
|
196
388
|
|
|
389
|
+
const capabilitiesPath = join(tenantDir, "capabilities.json");
|
|
390
|
+
if (existsSync(capabilitiesPath)) {
|
|
391
|
+
const { value, error } = readJsonSafe(capabilitiesPath);
|
|
392
|
+
if (error) {
|
|
393
|
+
findings.push(mk(ERROR, "capabilities-parse", "capabilities.json", `invalid JSON: ${error}`));
|
|
394
|
+
} else {
|
|
395
|
+
findings.push(...validateCapabilitiesDoc(value, "capabilities.json", config));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const scriptsPath = join(tenantDir, "scripts.json");
|
|
400
|
+
if (existsSync(scriptsPath)) {
|
|
401
|
+
const { value, error } = readJsonSafe(scriptsPath);
|
|
402
|
+
if (error) {
|
|
403
|
+
findings.push(mk(ERROR, "scripts-parse", "scripts.json", `invalid JSON: ${error}`));
|
|
404
|
+
} else {
|
|
405
|
+
findings.push(...validateScriptsDoc(value, "scripts.json", config));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
197
409
|
// 3. content JSON — parse + blocks shape
|
|
198
410
|
for (const name of ["home.json", "chrome.json"]) {
|
|
199
411
|
const p = join(contentDir, name);
|
|
@@ -201,8 +413,8 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
201
413
|
const { value, error } = readJsonSafe(p);
|
|
202
414
|
if (error) {
|
|
203
415
|
findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
|
|
204
|
-
} else if (name === "home.json"
|
|
205
|
-
findings.push(
|
|
416
|
+
} else if (name === "home.json") {
|
|
417
|
+
findings.push(...validateHomeDoc(value, "content/home.json"));
|
|
206
418
|
}
|
|
207
419
|
}
|
|
208
420
|
}
|
|
@@ -222,6 +434,12 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
222
434
|
findings.push(mk(ERROR, "html-invalid", r, `not a servable document: ${bad}`));
|
|
223
435
|
continue;
|
|
224
436
|
}
|
|
437
|
+
if (config && !rawHtmlAllowed(config)) {
|
|
438
|
+
findings.push(
|
|
439
|
+
mk(ERROR, "raw-html-compliance-bypass", r,
|
|
440
|
+
"raw HTML bypasses platform Layout compliance; regulated commerce tenants must use block composition or an extracted runtime"),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
225
443
|
if (!base && !isFullDocument(html) && !hasChrome) {
|
|
226
444
|
findings.push(
|
|
227
445
|
mk(ERROR, "html-fragment", r,
|