@tokenoftrust/cli 1.4.0 → 1.5.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 +148 -57
- package/package.json +6 -1
- package/src/activity.mjs +379 -0
- 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 +498 -59
- 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 +297 -0
- package/src/commands/cleanup.mjs +264 -0
- package/src/commands/clone.mjs +307 -25
- package/src/commands/dev.mjs +440 -156
- package/src/commands/doctor.mjs +4 -4
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +9 -5
- package/src/commands/grants.mjs +7 -5
- package/src/commands/hotfix.mjs +428 -0
- 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 +62 -25
- 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 +322 -0
- package/src/commands/rollback.mjs +18 -16
- package/src/commands/ship.mjs +51 -14
- package/src/commands/start.mjs +101 -59
- package/src/commands/submit.mjs +1183 -169
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +10 -4
- 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 +257 -0
- package/src/last-tenant.mjs +1 -1
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +18 -14
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +83 -15
- package/src/sample.mjs +4 -4
- package/src/validate.mjs +187 -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/validate.mjs
CHANGED
|
@@ -27,6 +27,38 @@ function mk(level, rule, file, message, fix) {
|
|
|
27
27
|
return { level, rule, file, message, fix };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// --- git conflict markers ----------------------------------------------------
|
|
31
|
+
// A half-resolved merge/rebase can commit literal conflict markers into content
|
|
32
|
+
// (the incident: `<<<<<<<`/`=======`/`>>>>>>>` in content/home.html slipped past
|
|
33
|
+
// preview as "validated"). These are the default and diff3 marker lines, anchored
|
|
34
|
+
// at line start and exactly 7 chars with a trailing boundary — precise enough that
|
|
35
|
+
// real content never matches. `=======` / `|||||||` ALONE are NOT flagged (a lone
|
|
36
|
+
// `=======` is a common markdown/prose horizontal rule); only the START (`<<<<<<<`)
|
|
37
|
+
// and END (`>>>>>>>`) markers trigger — either one is a near-certain conflict, so
|
|
38
|
+
// we err false-negative-averse and flag on either.
|
|
39
|
+
const CONFLICT_START = /^<{7}(?=[ \t]|$)/;
|
|
40
|
+
const CONFLICT_END = /^>{7}(?=[ \t]|$)/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Line numbers (1-based) of git conflict markers in `content`. Empty ⇒ none.
|
|
44
|
+
* Pure — exported for focused testing.
|
|
45
|
+
* @param {string} content
|
|
46
|
+
* @returns {number[]}
|
|
47
|
+
*/
|
|
48
|
+
export function detectConflictMarkers(content) {
|
|
49
|
+
if (typeof content !== "string" || (!content.includes("<<<<<<<") && !content.includes(">>>>>>>"))) return [];
|
|
50
|
+
const lines = content.split(/\r?\n/);
|
|
51
|
+
const hits = [];
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
if (CONFLICT_START.test(lines[i]) || CONFLICT_END.test(lines[i])) hits.push(i + 1);
|
|
54
|
+
}
|
|
55
|
+
return hits;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Text artifacts a conflict marker can hide in (images/fonts live in public/, not scanned).
|
|
59
|
+
const CONFLICT_SCAN_EXT = new Set([".html", ".htm", ".json", ".md", ".txt", ".css", ".js", ".mjs", ".svg", ".xml"]);
|
|
60
|
+
const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])[0].toLowerCase());
|
|
61
|
+
|
|
30
62
|
// --- canonical `.tot/config.json` shape (the #24/#25 regression guard) --------
|
|
31
63
|
const KNOWN_KINDS = new Set(["file", "tree"]);
|
|
32
64
|
const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
|
|
@@ -98,31 +130,110 @@ function validateRawHtmlBody(html) {
|
|
|
98
130
|
if (!html.includes("<")) return "no markup tags found";
|
|
99
131
|
return null;
|
|
100
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
|
+
|
|
101
162
|
/**
|
|
102
|
-
* Does the tenant provide chrome to wrap body-only fragments? True when it
|
|
103
|
-
* EITHER a hand-authored `content/chrome.html`, OR a `content/chrome.json`
|
|
104
|
-
*
|
|
105
|
-
* apps/storefront getChromeHtml() →
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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.
|
|
108
169
|
*/
|
|
109
170
|
function providesChrome(contentDir) {
|
|
110
171
|
if (existsSync(join(contentDir, "chrome.html"))) return true;
|
|
111
172
|
const configPath = join(contentDir, "chrome.json");
|
|
112
173
|
if (!existsSync(configPath)) return false;
|
|
113
174
|
const { value } = readJsonSafe(configPath);
|
|
114
|
-
|
|
115
|
-
if (value.sharedChrome !== true) return false;
|
|
116
|
-
const brand = value.brand;
|
|
117
|
-
if (brand == null || typeof brand !== "object" || typeof brand.label !== "string") return false;
|
|
118
|
-
if (!Array.isArray(value.nav)) return false;
|
|
119
|
-
const footer = value.footer;
|
|
120
|
-
if (footer == null || typeof footer !== "object" || !Array.isArray(footer.columns)) return false;
|
|
121
|
-
return true;
|
|
175
|
+
return looksLikeChromeConfig(value);
|
|
122
176
|
}
|
|
123
177
|
|
|
124
178
|
const CAPABILITY_KEYS = new Set(["cartCheckout", "ageVerification", "exciseTax"]);
|
|
125
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
|
+
|
|
126
237
|
function complianceObligations(config) {
|
|
127
238
|
const compliance = config?.compliance || {};
|
|
128
239
|
return {
|
|
@@ -481,6 +592,16 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
481
592
|
}
|
|
482
593
|
}
|
|
483
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
|
+
|
|
484
605
|
// 3. content JSON — parse + blocks shape
|
|
485
606
|
for (const name of ["home.json", "chrome.json"]) {
|
|
486
607
|
const p = join(contentDir, name);
|
|
@@ -519,7 +640,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
519
640
|
findings.push(
|
|
520
641
|
mk(ERROR, "html-fragment", r,
|
|
521
642
|
"is an HTML fragment but the tenant ships no chrome to wrap it — it will serve unwrapped/naked",
|
|
522
|
-
"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-->"),
|
|
523
644
|
);
|
|
524
645
|
}
|
|
525
646
|
if (STYLE_OPEN_WITH_ATTRS_RE.test(html)) {
|
|
@@ -539,6 +660,53 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
539
660
|
}
|
|
540
661
|
}
|
|
541
662
|
|
|
663
|
+
// 5. git conflict markers — advisory (never blocks), but LOUD: a half-resolved
|
|
664
|
+
// merge/rebase must not slip past as "validated". Scans text artifacts under
|
|
665
|
+
// content/ plus the root config files.
|
|
666
|
+
const conflictScanFiles = [
|
|
667
|
+
...walk(contentDir, hasScanExt),
|
|
668
|
+
...["theme.json", "capabilities.json", "scripts.json", join(".tot", "config.json")]
|
|
669
|
+
.map((f) => join(tenantDir, f))
|
|
670
|
+
.filter((p) => existsSync(p)),
|
|
671
|
+
];
|
|
672
|
+
for (const p of conflictScanFiles) {
|
|
673
|
+
const lines = detectConflictMarkers(readFileSync(p, "utf8"));
|
|
674
|
+
if (lines.length) {
|
|
675
|
+
findings.push(
|
|
676
|
+
mk(WARN, "git-conflict-markers", rel(p),
|
|
677
|
+
`git conflict markers at line(s) ${lines.join(", ")} — looks like an unfinished merge/rebase (the page would still build/serve broken)`,
|
|
678
|
+
"resolve the conflict and remove the <<<<<<< / ======= / >>>>>>> lines before submitting"),
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
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
|
+
|
|
542
710
|
const ok = !findings.some((f) => f.level === ERROR);
|
|
543
711
|
return { ok, findings };
|
|
544
712
|
}
|
|
@@ -555,6 +723,10 @@ function buildPageTargetSet(contentDir, pagesDir) {
|
|
|
555
723
|
return set;
|
|
556
724
|
}
|
|
557
725
|
|
|
726
|
+
/**
|
|
727
|
+
* @param {any} href @param {any} file @param {any} scope @param {any} pageTargets
|
|
728
|
+
* @param {(path: string) => boolean} [ownsPlatformRoute]
|
|
729
|
+
*/
|
|
558
730
|
function checkLink(href, file, scope, pageTargets, ownsPlatformRoute = () => false) {
|
|
559
731
|
const out = [];
|
|
560
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)).
|
package/src/commands/retire.mjs
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `tot retire --tenant <t> --pr <N>` — OPERATOR retire/GC (unit U7).
|
|
3
|
-
*
|
|
4
|
-
* Evict a candidate PR's hosted preview — its `ReviewEnvironment` projection +
|
|
5
|
-
* its immutable site version — to RECLAIM SPACE. It calls the
|
|
6
|
-
* session-authenticated storefront endpoint `POST /api/preview/retire` (unit
|
|
7
|
-
* U7), which deletes the candidate record + prunes the tenant index and evicts
|
|
8
|
-
* the candidate version (guarded: it refuses any version a channel points at).
|
|
9
|
-
*
|
|
10
|
-
* DISTINCT FROM `tot accept` / reject: retire touches NO change lifecycle — the
|
|
11
|
-
* change stays open. It is REVERSIBLE-BY-REBUILD: because U1 build-on-demand
|
|
12
|
-
* (`tot preview build`) + the U2 fallback page can rematerialize the PR head on
|
|
13
|
-
* request, a retired PR degrades to "not built yet", NOT a dead 404.
|
|
14
|
-
*
|
|
15
|
-
* IDEMPOTENT — retiring an already-absent candidate is a clean success
|
|
16
|
-
* (nothing to evict). Unlike `tot preview build`, retire needs NO head sha: it
|
|
17
|
-
* targets an EXISTING candidate by `--pr` (→ `pr-<N>`) or an explicit
|
|
18
|
-
* `--change-id`.
|
|
19
|
-
*
|
|
20
|
-
* AUTH — the storefront endpoint accepts the headless Bearer-operator-secret
|
|
21
|
-
* path (`resolveOwnerSession` fallback). This verb sends `Authorization: Bearer
|
|
22
|
-
* <secret>` (from `PREVIEW_RECONCILE_SECRET` / `GRANTS_ADMIN_SECRET` /
|
|
23
|
-
* `TOT_OPERATOR_SECRET`, or `--secret`), `X-Tot-Owner: <tenant>`, and
|
|
24
|
-
* `X-Tot-Capability: ship-on-behalf` — mirroring `tot preview build` and the
|
|
25
|
-
* `/admin` AdminPublishTab retire call.
|
|
26
|
-
*
|
|
27
|
-
* SELF-DECLARING — it prints the EXACT plan (which PR → which tenant → evict +
|
|
28
|
-
* rebuildable) and confirms before acting (`--yes` to skip; a non-TTY without
|
|
29
|
-
* `--yes` aborts rather than acting silently). Retire is INERT re: shared
|
|
30
|
-
* channels — it flips no preview/live channel and touches no live pointer — so
|
|
31
|
-
* this is a teardown, not a deploy. The plan itself is built by the SHARED plan
|
|
32
|
-
* module (`../plan.mjs`, unit U10) — the same affordance every other mutating
|
|
33
|
-
* operator verb (build/accept/ship) and the `/admin` confirm dialog use, per
|
|
34
|
-
* decision `operator-verb-and-hosting-model`.
|
|
35
|
-
*
|
|
36
|
-
* Dependency-free (global fetch + the shared plan module).
|
|
37
|
-
*/
|
|
38
|
-
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
39
|
-
|
|
40
|
-
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
41
|
-
|
|
42
|
-
/** Parse `tot retire` argv. Pure — unit-testable. */
|
|
43
|
-
export function parseRetireArgs(argv) {
|
|
44
|
-
const a = {
|
|
45
|
-
tenant: null,
|
|
46
|
-
pr: null,
|
|
47
|
-
changeId: null,
|
|
48
|
-
url: null,
|
|
49
|
-
secret: null,
|
|
50
|
-
yes: false,
|
|
51
|
-
help: false,
|
|
52
|
-
};
|
|
53
|
-
for (let i = 0; i < argv.length; i++) {
|
|
54
|
-
const t = argv[i];
|
|
55
|
-
if (t === "--tenant") a.tenant = argv[++i];
|
|
56
|
-
else if (t === "--pr") a.pr = argv[++i];
|
|
57
|
-
else if (t === "--change-id") a.changeId = argv[++i];
|
|
58
|
-
else if (t === "--url") a.url = argv[++i];
|
|
59
|
-
else if (t === "--secret") a.secret = argv[++i];
|
|
60
|
-
else if (t === "--yes" || t === "-y") a.yes = true;
|
|
61
|
-
else if (t === "--help" || t === "-h") a.help = true;
|
|
62
|
-
}
|
|
63
|
-
return a;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function renderUsage() {
|
|
67
|
-
return `tot retire — operator retire/GC: evict a candidate PR's hosted preview
|
|
68
|
-
|
|
69
|
-
Usage:
|
|
70
|
-
tot retire --tenant <appDomain> --pr <N> [options]
|
|
71
|
-
tot retire --tenant <appDomain> --change-id <id> [options]
|
|
72
|
-
|
|
73
|
-
Evicts the candidate preview for PR #N of <tenant> — its review environment +
|
|
74
|
-
immutable version — to reclaim space. REVERSIBLE: rebuild it any time with
|
|
75
|
-
\`tot preview build\` (the change stays open; this is NOT reject). It flips NO
|
|
76
|
-
shared channel and is NOT go-live.
|
|
77
|
-
|
|
78
|
-
Options:
|
|
79
|
-
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
80
|
-
current checkout's tenant when run inside one.
|
|
81
|
-
--pr <N> PR number to retire (identifies the candidate pr-<N>).
|
|
82
|
-
--change-id <id> Optional explicit candidate id (defaults to pr-<N>).
|
|
83
|
-
--url <origin> Storefront origin. Defaults to $TOT_STOREFRONT_URL or
|
|
84
|
-
${DEFAULT_STOREFRONT_URL}.
|
|
85
|
-
--secret <s> Operator secret. Prefer the env vars below.
|
|
86
|
-
--yes, -y Skip the confirmation prompt.
|
|
87
|
-
--help, -h Show this help.
|
|
88
|
-
|
|
89
|
-
Auth (operator secret, from env, first found):
|
|
90
|
-
PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* @param {string[]} argv
|
|
95
|
-
* @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
|
|
96
|
-
*/
|
|
97
|
-
export async function run(argv, ctx) {
|
|
98
|
-
const args = parseRetireArgs(argv);
|
|
99
|
-
if (args.help) {
|
|
100
|
-
console.log(renderUsage());
|
|
101
|
-
return 0;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const base = (args.url || process.env.TOT_STOREFRONT_URL || process.env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL)
|
|
105
|
-
.trim()
|
|
106
|
-
.replace(/\/+$/, "");
|
|
107
|
-
|
|
108
|
-
const tenant = (args.tenant || ctx?.tenant || "").trim();
|
|
109
|
-
if (!tenant) {
|
|
110
|
-
console.error(
|
|
111
|
-
"✗ no target tenant.\n\n → next: pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), " +
|
|
112
|
-
"or run inside a store checkout.",
|
|
113
|
-
);
|
|
114
|
-
return 2;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const prRaw = args.pr;
|
|
118
|
-
const pr = prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
|
|
119
|
-
const changeId = (args.changeId || "").trim() || null;
|
|
120
|
-
if (pr == null && !changeId) {
|
|
121
|
-
console.error(
|
|
122
|
-
"✗ no candidate to retire.\n\n → next: pass --pr <N> (the PR number to retire), or --change-id <id>.",
|
|
123
|
-
);
|
|
124
|
-
return 2;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const secret = (args.secret || process.env.PREVIEW_RECONCILE_SECRET || process.env.GRANTS_ADMIN_SECRET || process.env.TOT_OPERATOR_SECRET || "").trim();
|
|
128
|
-
if (!secret) {
|
|
129
|
-
console.error(
|
|
130
|
-
"✗ no operator secret.\n\n → next: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / " +
|
|
131
|
-
"TOT_OPERATOR_SECRET) in the environment.",
|
|
132
|
-
);
|
|
133
|
-
return 2;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Self-declaring: state the EXACT plan before acting (the shared plan module).
|
|
137
|
-
const planLines = planForAction({
|
|
138
|
-
action: "retire",
|
|
139
|
-
tenant,
|
|
140
|
-
pr,
|
|
141
|
-
changeId,
|
|
142
|
-
endpoint: `${base}/api/preview/retire`,
|
|
143
|
-
});
|
|
144
|
-
const { confirmed, reason } = await printPlanAndConfirm(planLines, {
|
|
145
|
-
yes: args.yes,
|
|
146
|
-
question: "Retire this preview now?",
|
|
147
|
-
});
|
|
148
|
-
if (!confirmed) {
|
|
149
|
-
if (reason === "non-tty") {
|
|
150
|
-
console.error("✗ refusing to retire without confirmation on a non-TTY.\n\n → next: re-run with --yes.");
|
|
151
|
-
return 1;
|
|
152
|
-
}
|
|
153
|
-
console.log("Aborted — nothing was retired.");
|
|
154
|
-
return 1;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const body = {
|
|
158
|
-
...(pr != null ? { pr } : {}),
|
|
159
|
-
...(changeId ? { changeId } : {}),
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
let res;
|
|
163
|
-
try {
|
|
164
|
-
res = await fetch(`${base}/api/preview/retire`, {
|
|
165
|
-
method: "POST",
|
|
166
|
-
headers: {
|
|
167
|
-
"content-type": "application/json",
|
|
168
|
-
authorization: `Bearer ${secret}`,
|
|
169
|
-
"x-tot-owner": tenant,
|
|
170
|
-
"x-tot-capability": "ship-on-behalf",
|
|
171
|
-
},
|
|
172
|
-
body: JSON.stringify(body),
|
|
173
|
-
});
|
|
174
|
-
} catch (e) {
|
|
175
|
-
console.error(`✗ could not reach ${base}: ${e?.message || e}\n\n → next: check --url / your network.`);
|
|
176
|
-
return 1;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
let data = {};
|
|
180
|
-
try {
|
|
181
|
-
data = await res.json();
|
|
182
|
-
} catch {
|
|
183
|
-
/* non-JSON error body */
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const label = pr != null ? `PR #${pr}` : changeId;
|
|
187
|
-
|
|
188
|
-
if (res.ok && data?.ok) {
|
|
189
|
-
if (data.evicted) {
|
|
190
|
-
console.log(`✓ Retired ${label} for ${tenant} — the preview was evicted (rebuildable on demand).`);
|
|
191
|
-
} else {
|
|
192
|
-
console.log(`✓ Retire ${label} for ${tenant}: nothing to evict (already retired).`);
|
|
193
|
-
}
|
|
194
|
-
console.log("\n → rebuild any time: `tot preview build --tenant " + tenant + (pr != null ? " --pr " + pr : "") + "`\n");
|
|
195
|
-
return 0;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// A refusal (e.g. 409 version-pinned) or an auth/other failure.
|
|
199
|
-
const message = data?.error || `HTTP ${res.status}`;
|
|
200
|
-
console.error(`✗ Retire did not succeed (HTTP ${res.status}).`);
|
|
201
|
-
console.error(` • ${message}`);
|
|
202
|
-
return 1;
|
|
203
|
-
}
|