@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.
Files changed (53) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +58 -79
  3. package/package.json +6 -1
  4. package/src/activity.mjs +15 -14
  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 +65 -38
  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 +4 -3
  13. package/src/commands/cleanup.mjs +7 -11
  14. package/src/commands/clone.mjs +23 -20
  15. package/src/commands/dev.mjs +42 -24
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +2 -2
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +1 -1
  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 +33 -19
  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 +5 -5
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +29 -12
  32. package/src/commands/start.mjs +61 -51
  33. package/src/commands/submit.mjs +360 -50
  34. package/src/commands/sync.mjs +2 -2
  35. package/src/commands/validate.mjs +4 -3
  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 +94 -21
  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/oauth.mjs +18 -14
  45. package/src/obstacle-beacon.cjs +2 -2
  46. package/src/obstacle.mjs +1 -1
  47. package/src/plan.mjs +16 -21
  48. package/src/sample.mjs +4 -4
  49. package/src/validate.mjs +135 -15
  50. package/src/vendor/private-apps-devkit.mjs +3 -3
  51. package/src/viewer-session.mjs +118 -0
  52. package/template/private-app/README.md +12 -6
  53. package/src/commands/retire.mjs +0 -203
package/src/plan.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The shared "operation plan" affordance (unit U10) — the load-bearing
3
- * cross-cutting requirement from decision `operator-verb-and-hosting-model`:
4
- * every MUTATING operator verb (build / accept / ship / retire) must STATE
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 — unit b08 — rather than merging it to main.) No silent multi-step mutations, in either surface (the CLI
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 (unit b10 — supersedes the retired context-dependent
19
- * ship, decision `ship-context-dependent-semantics`): it publishes the
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"|"retire"|"revert"|"cleanup"|"hotfix",
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 (unit b08), NOT merge-to-main:
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 (b10): publish the tenant's CURRENT GREEN AGGREGATE to live.
120
- // No merge — b09's orchestrator ships the already-materialized, already-
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 (P1 items 9/10): delete ONLY the exact terminal refs a fresh
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 (b21): REMOVE already-integrated content from the protected `preview`
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 (b22): the EXPLICIT EXCEPTION lane. Release the reviewed fix from
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 H1/H2 runner-sanitization work — with this generic sample.)
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 = "ENOTEMPTY_SAMPLE";
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 = "SAMPLE_UNAVAILABLE";
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 ships
135
- * EITHER a hand-authored `content/chrome.html`, OR a `content/chrome.json` that
136
- * opts into the canonical shared chrome. Mirrors the serve-time resolution in
137
- * apps/storefront getChromeHtml() → parseChromeConfig(): an opted-in config is
138
- * rendered into the wrapper (renderRawChrome), so a fragment is NOT served naked.
139
- * 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.
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
- if (value == null || typeof value !== "object") return false;
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 \"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-->"),
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 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)).