@tokenoftrust/cli 1.4.1 → 2.0.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/README.md +5 -0
- package/bin/tot.mjs +58 -79
- package/package.json +6 -1
- package/src/activity.mjs +15 -14
- package/src/app-scaffold.mjs +4 -4
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +3 -3
- package/src/commands/accept.mjs +65 -38
- package/src/commands/app/dev.mjs +8 -4
- package/src/commands/app/index.mjs +3 -3
- package/src/commands/app/scaffold.mjs +1 -1
- package/src/commands/branches.mjs +4 -3
- package/src/commands/cleanup.mjs +7 -11
- package/src/commands/clone.mjs +23 -20
- package/src/commands/dev.mjs +42 -24
- package/src/commands/doctor.mjs +4 -4
- package/src/commands/git-credential.mjs +2 -2
- package/src/commands/go-live.mjs +9 -5
- package/src/commands/grants.mjs +7 -5
- package/src/commands/hotfix.mjs +1 -1
- package/src/commands/ideas.mjs +2 -2
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +5 -6
- package/src/commands/pr.mjs +33 -19
- package/src/commands/preview-build.mjs +6 -6
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview-retry-evidence.mjs +156 -0
- package/src/commands/preview.mjs +19 -3
- package/src/commands/revert.mjs +5 -5
- package/src/commands/rollback.mjs +18 -16
- package/src/commands/ship.mjs +29 -12
- package/src/commands/start.mjs +61 -51
- package/src/commands/submit.mjs +360 -50
- package/src/commands/sync.mjs +2 -2
- package/src/commands/validate.mjs +4 -3
- package/src/commands/whoami.mjs +1 -1
- package/src/dev-heartbeat.mjs +3 -2
- package/src/dev-logs.mjs +2 -2
- package/src/errors.mjs +11 -4
- package/src/git-credential.mjs +94 -21
- package/src/last-tenant.mjs +1 -1
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/oauth.mjs +18 -14
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +16 -21
- package/src/sample.mjs +4 -4
- package/src/validate.mjs +135 -15
- package/src/vendor/private-apps-devkit.mjs +3 -3
- package/src/viewer-session.mjs +118 -0
- package/template/private-app/README.md +12 -6
- package/src/commands/retire.mjs +0 -203
package/src/plan.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The shared "operation plan" affordance
|
|
3
|
-
* cross-cutting requirement
|
|
4
|
-
* every MUTATING operator verb (build / accept / ship /
|
|
2
|
+
* The shared "operation plan" affordance — the load-bearing
|
|
3
|
+
* cross-cutting requirement that
|
|
4
|
+
* every MUTATING operator verb (build / accept / ship / cleanup) must STATE
|
|
5
5
|
* EXACTLY what it will do — which PR is queued/integrated/deployed, which
|
|
6
6
|
* deploy targets (preview / live) are touched, and their URLs — and get an
|
|
7
7
|
* explicit confirm before acting. (`accept` now queue-integrates a PR into the
|
|
8
|
-
* `preview` aggregate —
|
|
8
|
+
* `preview` aggregate — rather than merging it to main.) No silent multi-step mutations, in either surface (the CLI
|
|
9
9
|
* here, and `AdminPublishTab.astro`'s confirm dialog, which renders the same
|
|
10
10
|
* shape of plan text server-side/inline).
|
|
11
11
|
*
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* half — print the plan, then gate on an explicit yes (reusing `prompt.mjs`'s
|
|
16
16
|
* TTY-safe `promptYesNo`; a non-TTY without `--yes` never silently proceeds).
|
|
17
17
|
*
|
|
18
|
-
* SHIP has ONE meaning (
|
|
19
|
-
*
|
|
18
|
+
* SHIP has ONE meaning (supersedes the retired context-dependent ship
|
|
19
|
+
* semantics): it publishes the
|
|
20
20
|
* tenant's CURRENT GREEN AGGREGATE — the batch of PRs that integrated
|
|
21
21
|
* cleanly — to live. No merge, no PR/candidate targeting, no developer-vs-
|
|
22
22
|
* operator branching. The plan states the pinned aggregate sha, its
|
|
@@ -46,7 +46,7 @@ function targetLabel({ pr, changeId }) {
|
|
|
46
46
|
* console output, no network, no prompting.
|
|
47
47
|
*
|
|
48
48
|
* @param {{
|
|
49
|
-
* action: "build"|"accept"|"ship"|"
|
|
49
|
+
* action: "build"|"accept"|"ship"|"revert"|"cleanup"|"hotfix",
|
|
50
50
|
* tenant: string,
|
|
51
51
|
* pr?: number|string|null,
|
|
52
52
|
* changeId?: string|null,
|
|
@@ -57,11 +57,11 @@ function targetLabel({ pr, changeId }) {
|
|
|
57
57
|
* context?: "developer"|"operator",
|
|
58
58
|
* pinnedSha?: string|null,
|
|
59
59
|
* artifactDigest?: string|null,
|
|
60
|
-
* includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }
|
|
60
|
+
* includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|null,
|
|
61
61
|
* rollbackTarget?: { receiptId: string, aggregateSha: string } | null,
|
|
62
62
|
* paywall?: { allowed: boolean, message?: string|null } | null,
|
|
63
|
-
* refs?: Array<{ ref: string, sha?: string|null, reason?: string|null }
|
|
64
|
-
* bypassedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }
|
|
63
|
+
* refs?: Array<{ ref: string, sha?: string|null, reason?: string|null }>|null,
|
|
64
|
+
* bypassedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|null,
|
|
65
65
|
* bypassedPreviewSha?: string|null,
|
|
66
66
|
* }} params
|
|
67
67
|
* @returns {string[]} plan lines (no leading/trailing blank line)
|
|
@@ -105,7 +105,7 @@ export function planForAction({
|
|
|
105
105
|
break;
|
|
106
106
|
}
|
|
107
107
|
case "accept": {
|
|
108
|
-
// Accept now means QUEUE-INTEGRATE-INTO-PREVIEW
|
|
108
|
+
// Accept now means QUEUE-INTEGRATE-INTO-PREVIEW, NOT merge-to-main:
|
|
109
109
|
// the candidate lands in the protected `preview` aggregate (serialized merge →
|
|
110
110
|
// rebuild → combined-evidence gate), and the aggregate goes live only later via
|
|
111
111
|
// `tot ship`. So the plan states the integration, never a merge or a go-live.
|
|
@@ -116,8 +116,8 @@ export function planForAction({
|
|
|
116
116
|
break;
|
|
117
117
|
}
|
|
118
118
|
case "ship": {
|
|
119
|
-
// ONE meaning
|
|
120
|
-
// No merge —
|
|
119
|
+
// ONE meaning: publish the tenant's CURRENT GREEN AGGREGATE to live.
|
|
120
|
+
// No merge — the orchestrator ships the already-materialized, already-
|
|
121
121
|
// reviewed digest. State exactly WHAT that is: the pinned sha, the
|
|
122
122
|
// content-addressed artifact digest, every included PR, the rollback
|
|
123
123
|
// target, and the go-live paywall verdict — so the human reviews the
|
|
@@ -145,12 +145,8 @@ export function planForAction({
|
|
|
145
145
|
}
|
|
146
146
|
break;
|
|
147
147
|
}
|
|
148
|
-
case "retire": {
|
|
149
|
-
lines.push(` effect: evict ${label}'s preview environment + version (rebuildable).`);
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
148
|
case "cleanup": {
|
|
153
|
-
// Branch GC
|
|
149
|
+
// Branch GC: delete ONLY the exact terminal refs a fresh
|
|
154
150
|
// server-side classification (candidate_list) marked eligible — never by
|
|
155
151
|
// age alone, never main/preview, never an orphan. State the exact set so
|
|
156
152
|
// the human confirms precisely what will be removed, not "some branches".
|
|
@@ -166,7 +162,7 @@ export function planForAction({
|
|
|
166
162
|
break;
|
|
167
163
|
}
|
|
168
164
|
case "revert": {
|
|
169
|
-
// Revert
|
|
165
|
+
// Revert: REMOVE already-integrated content from the protected `preview`
|
|
170
166
|
// aggregate by creating a NEW auditable revert commit — never a force-reset,
|
|
171
167
|
// never a branch delete. The aggregate rebuilds and ships only when green
|
|
172
168
|
// again. State exactly that: preview-only, a new commit, NO touch to main.
|
|
@@ -178,7 +174,7 @@ export function planForAction({
|
|
|
178
174
|
break;
|
|
179
175
|
}
|
|
180
176
|
case "hotfix": {
|
|
181
|
-
// Hotfix
|
|
177
|
+
// Hotfix: the EXPLICIT EXCEPTION lane. Release the reviewed fix from
|
|
182
178
|
// `main` to live, EXCLUDING the unshipped `preview` work — then automatically
|
|
183
179
|
// forward-integrate `main` into `preview` and re-validate. State exactly that,
|
|
184
180
|
// and — critically — list the unshipped preview work this deliberately BYPASSES,
|
|
@@ -223,7 +219,6 @@ function titleFor(action) {
|
|
|
223
219
|
if (action === "build") return "Build-on-demand";
|
|
224
220
|
if (action === "accept") return "Accept";
|
|
225
221
|
if (action === "ship") return "Ship";
|
|
226
|
-
if (action === "retire") return "Retire";
|
|
227
222
|
if (action === "revert") return "Revert";
|
|
228
223
|
if (action === "cleanup") return "Cleanup";
|
|
229
224
|
if (action === "hotfix") return "Hotfix (owner-only exception lane)";
|
package/src/sample.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* (Earlier this borrowed a real vape merchant's identity + content to get a
|
|
23
23
|
* populated catalog; that leaked private merchant IP into public npm and was
|
|
24
|
-
* replaced — see the
|
|
24
|
+
* replaced — see the runner-sanitization work — with this generic sample.)
|
|
25
25
|
*
|
|
26
26
|
* Dependency-free (node:fs + node:path only).
|
|
27
27
|
*/
|
|
@@ -94,7 +94,7 @@ export function pickNvmrcVersion(env = process.env) {
|
|
|
94
94
|
const best = readdirSync(root)
|
|
95
95
|
.map((name) => /^v(\d+)\.(\d+)\.(\d+)$/.exec(name))
|
|
96
96
|
.filter((m) => m && nodeMeetsFloor(m.slice(1).join(".")))
|
|
97
|
-
.map((m) => m.slice(1).map(Number))
|
|
97
|
+
.map((m) => /** @type {RegExpExecArray} */ (m).slice(1).map(Number))
|
|
98
98
|
.sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2])[0];
|
|
99
99
|
if (best) return best.join(".");
|
|
100
100
|
} catch {
|
|
@@ -177,7 +177,7 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
|
|
|
177
177
|
const err = new Error(
|
|
178
178
|
`${dir} isn't empty and isn't a sample checkout — scaffold into an empty directory (or pass a new --workspace)`,
|
|
179
179
|
);
|
|
180
|
-
err.code =
|
|
180
|
+
/** @type {any} */ (err).code ="ENOTEMPTY_SAMPLE";
|
|
181
181
|
throw err;
|
|
182
182
|
}
|
|
183
183
|
|
|
@@ -187,7 +187,7 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
|
|
|
187
187
|
"the sample store isn't available in this release yet — it's coming soon. " +
|
|
188
188
|
"To build a real store now: `tot login --code <invite>` then `tot start`.",
|
|
189
189
|
);
|
|
190
|
-
err.code =
|
|
190
|
+
/** @type {any} */ (err).code ="SAMPLE_UNAVAILABLE";
|
|
191
191
|
throw err;
|
|
192
192
|
}
|
|
193
193
|
|
package/src/validate.mjs
CHANGED
|
@@ -130,31 +130,110 @@ function validateRawHtmlBody(html) {
|
|
|
130
130
|
if (!html.includes("<")) return "no markup tags found";
|
|
131
131
|
return null;
|
|
132
132
|
}
|
|
133
|
+
// Compact structural mirror of @tot/public-runtime chrome.ts's `ChromeConfig`
|
|
134
|
+
// shape — the consolidated header/footer schema (retired both the old lean
|
|
135
|
+
// `SiteChrome` and the `sharedChrome: true`-gated `TenantChrome`). This is
|
|
136
|
+
// NOT a full validateChromeConfig() pass — deep per-item errors (a nav item
|
|
137
|
+
// missing its `id`, an invalid header `variant`, ...) are the write-path's
|
|
138
|
+
// job (packages/private-controlplane/src/customization-chrome.ts, which does
|
|
139
|
+
// call the real validateChromeConfig()); this only decides whether the
|
|
140
|
+
// tenant's chrome.json looks enough like a ChromeConfig to opt the tenant
|
|
141
|
+
// into shared chrome for the html-fragment check below. The published `tot`
|
|
142
|
+
// CLI bundle can't reach the repo's public-runtime package at runtime (same
|
|
143
|
+
// constraint as the KNOWN_EMBEDS mirror above), so this is a copy — parity
|
|
144
|
+
// test: packages/public-runtime/tests/chrome-config-shape-parity.test.ts.
|
|
145
|
+
const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal"]);
|
|
146
|
+
const CHROME_FOOTER_VARIANTS = new Set(["default"]);
|
|
147
|
+
export function looksLikeChromeConfig(value) {
|
|
148
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
149
|
+
const header = value.header;
|
|
150
|
+
if (header == null || typeof header !== "object" || Array.isArray(header)) return false;
|
|
151
|
+
if (!CHROME_HEADER_VARIANTS.has(header.variant)) return false;
|
|
152
|
+
const brand = header.brand;
|
|
153
|
+
if (brand == null || typeof brand !== "object" || typeof brand.label !== "string" || brand.label.trim() === "") return false;
|
|
154
|
+
if (!Array.isArray(header.nav)) return false;
|
|
155
|
+
const footer = value.footer;
|
|
156
|
+
if (footer == null || typeof footer !== "object" || Array.isArray(footer)) return false;
|
|
157
|
+
if (!CHROME_FOOTER_VARIANTS.has(footer.variant)) return false;
|
|
158
|
+
if (!Array.isArray(footer.columns)) return false;
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
133
162
|
/**
|
|
134
|
-
* Does the tenant provide chrome to wrap body-only fragments? True when it
|
|
135
|
-
* EITHER a hand-authored `content/chrome.html`, OR a `content/chrome.json`
|
|
136
|
-
*
|
|
137
|
-
* apps/storefront getChromeHtml() →
|
|
138
|
-
*
|
|
139
|
-
*
|
|
163
|
+
* Does the tenant provide chrome to wrap body-only fragments? True when it
|
|
164
|
+
* ships EITHER a hand-authored `content/chrome.html`, OR a `content/chrome.json`
|
|
165
|
+
* that looks like a consolidated `ChromeConfig` (see {@link looksLikeChromeConfig}).
|
|
166
|
+
* Mirrors the serve-time resolution in apps/storefront getChromeHtml() →
|
|
167
|
+
* parseChromeConfig(): a config that parses is rendered into the wrapper
|
|
168
|
+
* (renderRawChrome), so a fragment is NOT served naked.
|
|
140
169
|
*/
|
|
141
170
|
function providesChrome(contentDir) {
|
|
142
171
|
if (existsSync(join(contentDir, "chrome.html"))) return true;
|
|
143
172
|
const configPath = join(contentDir, "chrome.json");
|
|
144
173
|
if (!existsSync(configPath)) return false;
|
|
145
174
|
const { value } = readJsonSafe(configPath);
|
|
146
|
-
|
|
147
|
-
if (value.sharedChrome !== true) return false;
|
|
148
|
-
const brand = value.brand;
|
|
149
|
-
if (brand == null || typeof brand !== "object" || typeof brand.label !== "string") return false;
|
|
150
|
-
if (!Array.isArray(value.nav)) return false;
|
|
151
|
-
const footer = value.footer;
|
|
152
|
-
if (footer == null || typeof footer !== "object" || !Array.isArray(footer.columns)) return false;
|
|
153
|
-
return true;
|
|
175
|
+
return looksLikeChromeConfig(value);
|
|
154
176
|
}
|
|
155
177
|
|
|
156
178
|
const CAPABILITY_KEYS = new Set(["cartCheckout", "ageVerification", "exciseTax"]);
|
|
157
179
|
|
|
180
|
+
// Compact mirror of EMBED_PROVIDERS (packages/public-runtime/src/embed-catalog.mjs)
|
|
181
|
+
// for the embed-CSP shift-left scan. The published `tot` CLI bundle can't reach
|
|
182
|
+
// the repo's public-runtime package at runtime, so this is a copy — but a parity
|
|
183
|
+
// test (packages/public-runtime/tests/embed-catalog-parity.test.ts) pins it to
|
|
184
|
+
// the shared source so it can't silently drift. markers = substrings meaning the
|
|
185
|
+
// embed is on the page; origins = the exact hosts it needs.
|
|
186
|
+
export const KNOWN_EMBEDS = [
|
|
187
|
+
{
|
|
188
|
+
slug: "pipedrive",
|
|
189
|
+
label: "Pipedrive Web Forms",
|
|
190
|
+
markers: ["webforms.pipedrive.com", "pipedriveWebForms"],
|
|
191
|
+
origins: ["https://webforms.pipedrive.com"],
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
const KNOWN_EMBED_SLUGS = new Set(KNOWN_EMBEDS.map((p) => p.slug));
|
|
195
|
+
const EMBED_ALWAYS_ALLOWED = new Set(["static.cloudflareinsights.com", "cloudflareinsights.com"]);
|
|
196
|
+
const EMBED_SRC_RE = /<(?:script|iframe)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi;
|
|
197
|
+
|
|
198
|
+
/** Classify one HTML doc: which known providers + unknown external src origins. */
|
|
199
|
+
function scanHtmlForEmbeds(html) {
|
|
200
|
+
const knownSlugs = KNOWN_EMBEDS.filter((p) => p.markers.some((m) => html.includes(m))).map((p) => p.slug);
|
|
201
|
+
const catalogOrigins = new Set(KNOWN_EMBEDS.flatMap((p) => p.origins));
|
|
202
|
+
const unknown = new Set();
|
|
203
|
+
EMBED_SRC_RE.lastIndex = 0;
|
|
204
|
+
let m;
|
|
205
|
+
while ((m = EMBED_SRC_RE.exec(html))) {
|
|
206
|
+
let origin;
|
|
207
|
+
try {
|
|
208
|
+
origin = new URL(m[1]).origin;
|
|
209
|
+
} catch {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const host = new URL(origin).hostname;
|
|
213
|
+
if (EMBED_ALWAYS_ALLOWED.has(host) || catalogOrigins.has(origin)) continue;
|
|
214
|
+
unknown.add(origin);
|
|
215
|
+
}
|
|
216
|
+
return { knownSlugs, unknownOrigins: [...unknown] };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateEmbedsDoc(doc, file) {
|
|
220
|
+
// embeds.json: the tenant's self-serve third-party embed opt-in — a JSON array
|
|
221
|
+
// of catalogued provider slugs (e.g. ["pipedrive"]). Unknown slug = WARNING
|
|
222
|
+
// (resolveEffectiveEmbeds drops it, so it's inert, not dangerous).
|
|
223
|
+
if (!Array.isArray(doc)) {
|
|
224
|
+
return [mk(ERROR, "embeds-root", file, 'embeds.json must be a JSON array of provider slugs, e.g. ["pipedrive"]')];
|
|
225
|
+
}
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const slug of doc) {
|
|
228
|
+
if (typeof slug !== "string") {
|
|
229
|
+
out.push(mk(ERROR, "embeds-shape", file, `every entry must be a string slug; got ${typeof slug}`));
|
|
230
|
+
} else if (!KNOWN_EMBED_SLUGS.has(slug)) {
|
|
231
|
+
out.push(mk(WARN, "embed-unknown", file, `"${slug}" is not a known embed provider — it will be ignored (the CSP will still block that embed). Known: ${[...KNOWN_EMBED_SLUGS].join(", ")}.`));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
158
237
|
function complianceObligations(config) {
|
|
159
238
|
const compliance = config?.compliance || {};
|
|
160
239
|
return {
|
|
@@ -513,6 +592,16 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
513
592
|
}
|
|
514
593
|
}
|
|
515
594
|
|
|
595
|
+
const embedsPath = join(tenantDir, "embeds.json");
|
|
596
|
+
if (existsSync(embedsPath)) {
|
|
597
|
+
const { value, error } = readJsonSafe(embedsPath);
|
|
598
|
+
if (error) {
|
|
599
|
+
findings.push(mk(ERROR, "embeds-parse", "embeds.json", `invalid JSON: ${error}`));
|
|
600
|
+
} else {
|
|
601
|
+
findings.push(...validateEmbedsDoc(value, "embeds.json"));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
516
605
|
// 3. content JSON — parse + blocks shape
|
|
517
606
|
for (const name of ["home.json", "chrome.json"]) {
|
|
518
607
|
const p = join(contentDir, name);
|
|
@@ -551,7 +640,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
551
640
|
findings.push(
|
|
552
641
|
mk(ERROR, "html-fragment", r,
|
|
553
642
|
"is an HTML fragment but the tenant ships no chrome to wrap it — it will serve unwrapped/naked",
|
|
554
|
-
"make it a full <!doctype html> document, add a content/chrome.json with
|
|
643
|
+
"make it a full <!doctype html> document, add a content/chrome.json with header.variant/header.brand.label/header.nav and footer.variant/footer.columns, or add content/chrome.html with <!--PAGE_BODY-->"),
|
|
555
644
|
);
|
|
556
645
|
}
|
|
557
646
|
if (STYLE_OPEN_WITH_ATTRS_RE.test(html)) {
|
|
@@ -591,6 +680,33 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
591
680
|
}
|
|
592
681
|
}
|
|
593
682
|
|
|
683
|
+
// Embed CSP shift-left: warn (in the dev loop) about third-party embeds that
|
|
684
|
+
// the strict CSP will silently block, instead of leaving a blank spot. A known
|
|
685
|
+
// provider not opted into embeds.json, or an unknown external <script>/<iframe>
|
|
686
|
+
// origin, is blocking; advisory (info) when there's no embeds.json to compare.
|
|
687
|
+
{
|
|
688
|
+
let declaredEmbeds = null;
|
|
689
|
+
const embedsFile = join(tenantDir, "embeds.json");
|
|
690
|
+
if (existsSync(embedsFile)) {
|
|
691
|
+
const { value } = readJsonSafe(embedsFile);
|
|
692
|
+
if (Array.isArray(value)) declaredEmbeds = new Set(value.filter((s) => typeof s === "string"));
|
|
693
|
+
}
|
|
694
|
+
for (const file of walk(contentDir, (p) => p.endsWith(".html"))) {
|
|
695
|
+
const { knownSlugs, unknownOrigins } = scanHtmlForEmbeds(readFileSync(file, "utf8"));
|
|
696
|
+
for (const slug of knownSlugs) {
|
|
697
|
+
const label = KNOWN_EMBEDS.find((p) => p.slug === slug)?.label ?? slug;
|
|
698
|
+
if (declaredEmbeds && !declaredEmbeds.has(slug)) {
|
|
699
|
+
findings.push(mk(ERROR, "embed-csp", rel(file), `embeds ${label} but "${slug}" is not in embeds.json — the CSP will block it. Add "${slug}" to embeds.json.`));
|
|
700
|
+
} else if (!declaredEmbeds) {
|
|
701
|
+
findings.push(mk("info", "embed-csp", rel(file), `embeds ${label}: ensure "${slug}" is in embeds.json, or the CSP will block it.`));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
for (const origin of unknownOrigins) {
|
|
705
|
+
findings.push(mk(ERROR, "embed-csp", rel(file), `embeds ${origin}, which is not a known embed provider — the CSP will block it. Needs a platform catalog entry, or remove the embed.`));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
594
710
|
const ok = !findings.some((f) => f.level === ERROR);
|
|
595
711
|
return { ok, findings };
|
|
596
712
|
}
|
|
@@ -607,6 +723,10 @@ function buildPageTargetSet(contentDir, pagesDir) {
|
|
|
607
723
|
return set;
|
|
608
724
|
}
|
|
609
725
|
|
|
726
|
+
/**
|
|
727
|
+
* @param {any} href @param {any} file @param {any} scope @param {any} pageTargets
|
|
728
|
+
* @param {(path: string) => boolean} [ownsPlatformRoute]
|
|
729
|
+
*/
|
|
610
730
|
function checkLink(href, file, scope, pageTargets, ownsPlatformRoute = () => false) {
|
|
611
731
|
const out = [];
|
|
612
732
|
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return out;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vendored subset of `@tokenoftrust/private-apps-devkit` (PrivateApps epic
|
|
3
|
-
*
|
|
4
|
-
* `tot app`
|
|
2
|
+
* Vendored subset of `@tokenoftrust/private-apps-devkit` (PrivateApps epic,
|
|
3
|
+
* `packages/private-apps-devkit/src/{signing,jwt,manifest}.ts`) — for
|
|
4
|
+
* `tot app` to sign/verify webhook deliveries, mint dev JWTs,
|
|
5
5
|
* and validate `tot-app.json` manifests WITHOUT a package.json dependency on
|
|
6
6
|
* that package.
|
|
7
7
|
*
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewer-session transport for the ship surface — the NO-operator-secret path.
|
|
3
|
+
*
|
|
4
|
+
* An invited developer holds a `tot login` MCP session but no operator secret and no
|
|
5
|
+
* storefront cookie, so the old ship-surface transport dead-ended on them. This mints
|
|
6
|
+
* a storefront VIEWER session from the developer's OWN MCP token (POST
|
|
7
|
+
* /api/dev/cli-session on the tenant's own host, which resolves WHO the opaque token
|
|
8
|
+
* is via the MCP and hands back a `tot_session` cookie), then hands the caller a
|
|
9
|
+
* cookie-based transport pointed at the tenant host. Authorization is still the
|
|
10
|
+
* developer's live `ship-on-behalf` grant, enforced server-side at the ship route —
|
|
11
|
+
* this only carries their identity, it grants nothing.
|
|
12
|
+
*
|
|
13
|
+
* Minted FRESH per call (no disk cache): a `tot accept` is interactive + infrequent,
|
|
14
|
+
* and minting-per-call means a revoked session/grant is never honored past its life.
|
|
15
|
+
*/
|
|
16
|
+
import { resolveDeveloperSession, AuthUnavailableError } from "./auth.mjs";
|
|
17
|
+
|
|
18
|
+
const SESSION_COOKIE = "tot_session";
|
|
19
|
+
|
|
20
|
+
/** The tenant's own storefront host — the dev-viewer admission derives the tenant
|
|
21
|
+
* from the request host, so the session + integrate MUST target it (not the generic
|
|
22
|
+
* storefront origin + X-Tot-Owner, which only steers the operator-secret path). */
|
|
23
|
+
function tenantBase(tenant) {
|
|
24
|
+
return `https://${String(tenant || "").trim().toLowerCase()}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pull `tot_session=<id>` out of a (possibly comma-folded) Set-Cookie header. The
|
|
28
|
+
* id is base64url — no comma/semicolon — so a non-greedy stop-set is unambiguous. */
|
|
29
|
+
export function parseSessionCookie(setCookie) {
|
|
30
|
+
if (!setCookie) return null;
|
|
31
|
+
const m = new RegExp(`${SESSION_COOKIE}=([^;,\\s]+)`).exec(setCookie);
|
|
32
|
+
return m ? m[1] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a viewer-session transport for `tenant`. Returns
|
|
37
|
+
* { ok:true, base, authHeaders } — base = tenant host; cookie transport
|
|
38
|
+
* { ok:false, message, hint } — a clean, actionable refusal
|
|
39
|
+
* Never throws.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ tenant:string, env?:NodeJS.ProcessEnv, fetchImpl?:typeof fetch,
|
|
42
|
+
* resolveDev?:typeof resolveDeveloperSession }} params
|
|
43
|
+
* @returns {Promise<
|
|
44
|
+
* { ok:true, base:string, authHeaders:Record<string,string> } |
|
|
45
|
+
* { ok:false, message:string, hint:string }
|
|
46
|
+
* >}
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveViewerTransport({
|
|
49
|
+
tenant,
|
|
50
|
+
env = process.env,
|
|
51
|
+
fetchImpl = fetch,
|
|
52
|
+
resolveDev = resolveDeveloperSession,
|
|
53
|
+
}) {
|
|
54
|
+
// The developer's OWN MCP token (read + silently refreshed by the resolver). No
|
|
55
|
+
// client needed — resolveDeveloperSession tolerates a null client.
|
|
56
|
+
let dev;
|
|
57
|
+
try {
|
|
58
|
+
dev = await resolveDev(null, env, { fetchImpl });
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (e instanceof AuthUnavailableError) {
|
|
61
|
+
return { ok: false, message: e.message, hint: e.hint || "run `tot login`, then re-run." };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
message: `couldn't read your Token of Trust session: ${e?.message || e}`,
|
|
66
|
+
hint: "run `tot login`, then re-run.",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const base = tenantBase(tenant);
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetchImpl(`${base}/api/dev/cli-session`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { authorization: `Bearer ${dev.token}` },
|
|
76
|
+
});
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
message: `couldn't reach ${base} to start a session: ${e?.message || e}`,
|
|
81
|
+
hint: "check the --tenant domain / your network, then re-run.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (res.status === 401) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
message: "your `tot` session wasn't recognized for this store.",
|
|
88
|
+
hint: "run `tot login`, then re-run.",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
message: `couldn't start a session at ${base} (HTTP ${res.status}).`,
|
|
95
|
+
hint: "retry shortly; if it persists, this store may not be set up for CLI publishing yet.",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cookie = parseSessionCookie(res.headers.get("set-cookie"));
|
|
100
|
+
if (!cookie) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
message: "the store started a session but returned no session cookie.",
|
|
104
|
+
hint: "re-run; if it persists, report it via `tot` feedback.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
base,
|
|
111
|
+
authHeaders: {
|
|
112
|
+
cookie: `${SESSION_COOKIE}=${cookie}`,
|
|
113
|
+
// Matches the admin browser client; the server IGNORES this hint and re-reads
|
|
114
|
+
// the live ship-on-behalf grant, so it authorizes nothing on its own.
|
|
115
|
+
"x-tot-capability": "ship-on-behalf",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
# my-app
|
|
2
2
|
|
|
3
|
-
A Storefront Private App, scaffolded by `tot app scaffold`.
|
|
4
|
-
[
|
|
5
|
-
for the
|
|
3
|
+
A Storefront Private App, scaffolded by `tot app scaffold`. Start with the
|
|
4
|
+
[public Storefront Devkit Apps / Private track](https://github.com/tokenoftrust/storefront-devkit/tree/main/apps/private)
|
|
5
|
+
for the canonical contract this manifest and receiver implement. The Devkit's
|
|
6
|
+
root [`README.md`](https://github.com/tokenoftrust/storefront-devkit/blob/main/README.md),
|
|
7
|
+
[`AGENTS.md`](https://github.com/tokenoftrust/storefront-devkit/blob/main/AGENTS.md), and
|
|
8
|
+
[`manifest.json`](https://github.com/tokenoftrust/storefront-devkit/blob/main/manifest.json)
|
|
9
|
+
are the stable human and LLM entry points.
|
|
6
10
|
|
|
7
11
|
## What's here
|
|
8
12
|
|
|
9
13
|
- **`tot-app.json`** — your app's manifest: identity, scopes, webhook
|
|
10
14
|
subscriptions, widgets. Validated against
|
|
11
|
-
[`tot-app.schema.json`](https://github.com/tokenoftrust/storefront/blob/main/
|
|
15
|
+
[`tot-app.schema.json`](https://github.com/tokenoftrust/storefront-devkit/blob/main/apps/private/contract/tot-app.schema.json).
|
|
12
16
|
Everything here is a PLACEHOLDER — edit `id`, `owner`, `webhooks.endpoint`,
|
|
13
17
|
and `scopes` for your real app before you install it anywhere.
|
|
14
|
-
- **`fixtures/`** — sample CloudEvents (from
|
|
18
|
+
- **`fixtures/`** — sample CloudEvents (from the Devkit's
|
|
19
|
+
[`apps/private/contract/examples/`](https://github.com/tokenoftrust/storefront-devkit/tree/main/apps/private/contract/examples/))
|
|
15
20
|
you can sign and deliver to your own receiver locally, before you have a
|
|
16
21
|
real install. Add one fixture per topic you subscribe to.
|
|
17
22
|
- **`server.js`** — a minimal Node HTTP receiver for `webhooks.endpoint`. It
|
|
@@ -42,4 +47,5 @@ for the full command surface.
|
|
|
42
47
|
2. Get your manifest installed for a real `(tenant, env)` — this issues your
|
|
43
48
|
real client credentials; nothing above touches them.
|
|
44
49
|
3. Swap the dev-only signature verification for the real gateway's published
|
|
45
|
-
JWKS/signing-key metadata (see the
|
|
50
|
+
JWKS/signing-key metadata (see the Devkit's
|
|
51
|
+
[`webhooks-and-api.md`](https://github.com/tokenoftrust/storefront-devkit/blob/main/apps/private/webhooks-and-api.md)).
|