@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.
Files changed (54) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +148 -57
  3. package/package.json +6 -1
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +4 -4
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +3 -3
  8. package/src/commands/accept.mjs +498 -59
  9. package/src/commands/app/dev.mjs +8 -4
  10. package/src/commands/app/index.mjs +3 -3
  11. package/src/commands/app/scaffold.mjs +1 -1
  12. package/src/commands/branches.mjs +297 -0
  13. package/src/commands/cleanup.mjs +264 -0
  14. package/src/commands/clone.mjs +307 -25
  15. package/src/commands/dev.mjs +440 -156
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +180 -0
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +428 -0
  21. package/src/commands/ideas.mjs +2 -2
  22. package/src/commands/link.mjs +2 -2
  23. package/src/commands/login.mjs +5 -6
  24. package/src/commands/pr.mjs +62 -25
  25. package/src/commands/preview-build.mjs +6 -6
  26. package/src/commands/preview-doctor.mjs +225 -0
  27. package/src/commands/preview-retry-evidence.mjs +156 -0
  28. package/src/commands/preview.mjs +19 -3
  29. package/src/commands/revert.mjs +322 -0
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +51 -14
  32. package/src/commands/start.mjs +101 -59
  33. package/src/commands/submit.mjs +1183 -169
  34. package/src/commands/sync.mjs +203 -0
  35. package/src/commands/validate.mjs +10 -4
  36. package/src/commands/whoami.mjs +1 -1
  37. package/src/dev-heartbeat.mjs +3 -2
  38. package/src/dev-logs.mjs +2 -2
  39. package/src/errors.mjs +11 -4
  40. package/src/git-credential.mjs +257 -0
  41. package/src/last-tenant.mjs +1 -1
  42. package/src/mcp.mjs +6 -1
  43. package/src/merge-doctor-report.mjs +208 -0
  44. package/src/no-gitea-links.test.mjs +55 -0
  45. package/src/oauth.mjs +18 -14
  46. package/src/obstacle-beacon.cjs +2 -2
  47. package/src/obstacle.mjs +1 -1
  48. package/src/plan.mjs +83 -15
  49. package/src/sample.mjs +4 -4
  50. package/src/validate.mjs +187 -15
  51. package/src/vendor/private-apps-devkit.mjs +3 -3
  52. package/src/viewer-session.mjs +118 -0
  53. package/template/private-app/README.md +12 -6
  54. 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 ships
103
- * EITHER a hand-authored `content/chrome.html`, OR a `content/chrome.json` that
104
- * opts into the canonical shared chrome. Mirrors the serve-time resolution in
105
- * apps/storefront getChromeHtml() → parseChromeConfig(): an opted-in config is
106
- * rendered into the wrapper (renderRawChrome), so a fragment is NOT served naked.
107
- * Kept in step with lib/chrome/parseConfig.ts's opt-in gate.
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
- if (value == null || typeof value !== "object") return false;
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 \"sharedChrome\": true (brand + nav + footer), or add content/chrome.html with <!--PAGE_BODY-->"),
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 D6
3
- * Chunk B, `packages/private-apps-devkit/src/{signing,jwt,manifest}.ts`) — for
4
- * `tot app` (D6 Chunk C+D) to sign/verify webhook deliveries, mint dev JWTs,
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`. See
4
- [the Private Apps devbook](https://github.com/tokenoftrust/storefront/blob/main/docs/private-apps/devbook/private-apps-devbook.md)
5
- for the full contract this manifest and receiver implement.
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/docs/private-apps/contract/tot-app.schema.json).
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 `docs/private-apps/contract/examples/`)
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 devbook's Signature Verification section).
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)).
@@ -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
- }