@tokenoftrust/storefront-runner 1.3.2 → 1.3.4-rc.2

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 (79) hide show
  1. package/apps/storefront/astro.config.mjs +59 -0
  2. package/apps/storefront/env.d.ts +24 -8
  3. package/apps/storefront/package.json +1 -1
  4. package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
  5. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
  6. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
  7. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
  8. package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
  9. package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
  10. package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
  11. package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
  12. package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
  13. package/apps/storefront/src/config/devTenantSeed.ts +65 -16
  14. package/apps/storefront/src/config/resolver.ts +48 -1
  15. package/apps/storefront/src/layouts/Layout.astro +14 -12
  16. package/apps/storefront/src/lib/admin/README.md +2 -2
  17. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
  18. package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
  19. package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
  20. package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
  21. package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
  22. package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
  23. package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
  24. package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
  25. package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
  26. package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
  27. package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
  28. package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
  29. package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
  30. package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
  31. package/apps/storefront/src/lib/dev/runtimeStore.ts +193 -22
  32. package/apps/storefront/src/lib/edgeCache.ts +7 -6
  33. package/apps/storefront/src/lib/health.ts +53 -0
  34. package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
  35. package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
  36. package/apps/storefront/src/lib/sections.ts +19 -23
  37. package/apps/storefront/src/lib/securityHeaders.ts +1 -1
  38. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
  39. package/apps/storefront/src/middleware/index.ts +29 -12
  40. package/apps/storefront/src/pages/[...slug].astro +5 -6
  41. package/apps/storefront/src/pages/admin.astro +10 -3
  42. package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
  43. package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
  44. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
  45. package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
  46. package/apps/storefront/src/pages/auth/login.astro +4 -1
  47. package/apps/storefront/src/pages/auth/magic.astro +263 -43
  48. package/apps/storefront/src/pages/capabilities.astro +1 -1
  49. package/apps/storefront/src/pages/cockpit.astro +2365 -0
  50. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
  51. package/apps/storefront/src/pages/index.astro +23 -29
  52. package/apps/storefront/src/pages/products/[handle].astro +50 -20
  53. package/package.json +6 -2
  54. package/packages/public-runtime/src/capabilities.ts +97 -9
  55. package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
  56. package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
  57. package/packages/public-runtime/src/checkout-style.ts +13 -13
  58. package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
  59. package/packages/public-runtime/src/csp.ts +11 -11
  60. package/packages/public-runtime/src/customization-scripts.ts +28 -0
  61. package/packages/public-runtime/src/extension-contract.ts +73 -0
  62. package/packages/public-runtime/src/index.ts +4 -2
  63. package/packages/public-runtime/src/product.ts +57 -0
  64. package/packages/public-runtime/src/raw-html-policy.ts +35 -0
  65. package/packages/public-runtime/src/tenant.ts +14 -11
  66. package/pnpm-workspace.yaml +1 -0
  67. package/scripts/dev/checkout-watch.mjs +9 -0
  68. package/scripts/dev/file-browser.mjs +5 -1
  69. package/scripts/dev/transient-files.mjs +34 -0
  70. package/scripts/tot-dev.mjs +16 -3
  71. package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
  72. package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
  73. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
  74. package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
  75. package/apps/storefront/src/pages/api/dev/file.ts +0 -57
  76. package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
  77. package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
  78. package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
  79. package/apps/storefront/src/pages/api/dev/write.ts +0 -116
@@ -5,7 +5,7 @@
5
5
  * interface (resolveTenant) is intentionally async + many-ready so the same seam
6
6
  * scales to Cloudflare-for-SaaS custom hostnames later without a rewrite.
7
7
  */
8
- import type { FoxyCommerceConfig } from "./foxy.js";
8
+ import type { ManagedCheckoutConfig } from "./checkout.js";
9
9
  import type { CapabilityConfig } from "./capabilities.js";
10
10
 
11
11
  /**
@@ -306,13 +306,15 @@ export function siteTypeDefaults(siteType: SiteType): TenantFeatures {
306
306
  * preserving the platform's original behavior for existing tenants.
307
307
  */
308
308
  export function resolveTenantFeatures(
309
- tenant: Pick<TenantConfig, "siteType" | "features">,
309
+ tenant: Pick<TenantConfig, "siteType" | "features" | "compliance">,
310
310
  ): TenantFeatures {
311
311
  const base = siteTypeDefaults(tenant.siteType ?? "commerce");
312
- if (!tenant.features) return base;
313
- for (const [k, v] of Object.entries(tenant.features)) {
314
- if (v !== undefined) base[k as keyof TenantFeatures] = v;
312
+ if (tenant.features) {
313
+ for (const [k, v] of Object.entries(tenant.features)) {
314
+ if (v !== undefined) base[k as keyof TenantFeatures] = v;
315
+ }
315
316
  }
317
+ if (tenant.compliance?.minAge) base.ageGate = true;
316
318
  return base;
317
319
  }
318
320
 
@@ -344,14 +346,15 @@ export interface TenantConfig {
344
346
  /** Regulated-commerce affordances; undefined for unregulated tenants. */
345
347
  compliance?: ComplianceConfig;
346
348
  /**
347
- * Cart/checkout engine wiring (FoxyCart, Track A). Present on commerce tenants
348
- * that have a FoxyCart store; drives the loader + signed add-to-cart. The
349
- * `storeSubDomain` here is a default — middleware may override it from env
350
- * (FOXY_STORE_SUBDOMAIN) so the demo store swaps with zero code change. The
351
- * signing SECRET is never stored on the tenant; it's an env Worker binding.
349
+ * Cart/checkout engine wiring (Track A). Present on commerce tenants
350
+ * that have a checkout store; drives the loader + signed add-to-cart. The
351
+ * `storeSubDomain` here is a default — middleware may override it from the
352
+ * existing provider-named env binding (FOXY_STORE_SUBDOMAIN) so the demo store
353
+ * swaps with zero code change. The signing SECRET is never stored on the
354
+ * tenant; it's an env Worker binding.
352
355
  */
353
356
  commerce?: {
354
- foxy?: FoxyCommerceConfig;
357
+ checkout?: ManagedCheckoutConfig;
355
358
  };
356
359
  /**
357
360
  * PR-gated capability switches (cart/checkout, age/identity verification,
@@ -1,6 +1,7 @@
1
1
  packages:
2
2
  - "apps/*"
3
3
  - "packages/*"
4
+ linkWorkspacePackages: true
4
5
  onlyBuiltDependencies:
5
6
  - esbuild
6
7
  - sharp
@@ -1,6 +1,11 @@
1
1
  // @ts-check
2
2
  import { watch, readdirSync, statSync, existsSync, cpSync, rmSync, mkdirSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
+ import { IGNORED_WATCH_DIRS, isTransientWatchFile } from "./transient-files.mjs";
5
+
6
+ // Re-exported so existing importers (checkout-watch.test.mjs) keep working after the
7
+ // predicate moved to its own typed module.
8
+ export { isTransientWatchFile };
4
9
 
5
10
  // Standalone (`tot dev --workspace`) re-sync: the developer's checkout is COPIED
6
11
  // into the storefront's in-tree tenants/<id>/ (Vite's server.fs.allow refuses files
@@ -68,9 +73,13 @@ export function startWatch(source, isDir, onChange) {
68
73
  const rel = relDir ? join(relDir, e.name) : e.name;
69
74
  const abs = join(absDir, e.name);
70
75
  if (e.isDirectory()) {
76
+ if (IGNORED_WATCH_DIRS.has(e.name)) continue;
71
77
  visit(abs, rel);
72
78
  continue;
73
79
  }
80
+ // Skip editor/OS transient files so a vim `.swp` (etc.) never counts as a
81
+ // content change (no resync, no reload, no "changed" report).
82
+ if (isTransientWatchFile(e.name)) continue;
74
83
  present.add(rel);
75
84
  let m;
76
85
  try {
@@ -17,6 +17,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
17
17
  import { join, extname, sep } from "node:path";
18
18
  import { resolveSafePath, matchesWriteAllowlist } from "./safe-path.mjs";
19
19
  import { resolveTenantSourceRoot } from "./tenant-source-root.mjs";
20
+ import { isTransientWatchFile } from "./transient-files.mjs";
20
21
 
21
22
  /** Above this, the viewer degrades to "not shown" rather than shipping a huge string. */
22
23
  const MAX_FILE_BYTES = 512 * 1024;
@@ -67,7 +68,10 @@ export function buildTree(sourceRoot, { ignore = IGNORE_NAMES } = {}) {
67
68
  }
68
69
  const nodes = [];
69
70
  for (const e of entries) {
70
- if (ignore.has(e.name)) continue;
71
+ // Editor/OS transient files (vim .swp, emacs #autosave#, ~backups, .DS_Store)
72
+ // are not the store's content — keep them out of the source tree so they never
73
+ // show up as an "untracked change" the developer has to reason about.
74
+ if (ignore.has(e.name) || isTransientWatchFile(e.name)) continue;
71
75
  const rel = relDir ? `${relDir}/${e.name}` : e.name;
72
76
  if (e.isDirectory()) {
73
77
  nodes.push({ name: e.name, path: rel, type: "dir", children: walk(join(absDir, e.name), rel) });
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+ /**
3
+ * Editor/OS transient-file predicate — shared by the local dev loop's file WATCHER
4
+ * (checkout-watch.mjs) and the source-tree browser (file-browser.mjs) so both hide
5
+ * the same non-content noise: a vim swap (`.home.html.swp`), an emacs autosave
6
+ * (`#home.html#`) or lock (`.#home.html`), a `~` backup, `.DS_Store`/`Thumbs.db`, or
7
+ * vim's `4913` write-probe. Kept in its own tiny, fully-typed module so a consumer
8
+ * that only needs the predicate never drags the watcher's other internals into its
9
+ * typecheck graph.
10
+ */
11
+
12
+ /** Directories never worth descending into — VCS/dep/build noise that would churn a poll. */
13
+ export const IGNORED_WATCH_DIRS = new Set([".git", "node_modules", ".astro", ".cache", ".turbo"]);
14
+
15
+ /**
16
+ * True for editor/OS transient files that are NOT real content edits. A raw poll of
17
+ * a checkout subtree would otherwise treat one as a content change — re-syncing it,
18
+ * firing a spurious reload, and showing it as an untracked change. Matched on the
19
+ * BASENAME so it's independent of depth. "Official" changes track the store's real
20
+ * content; untracked editor junk is surfaced only at publish time, not here.
21
+ * @param {string} name the file basename
22
+ * @returns {boolean}
23
+ */
24
+ export function isTransientWatchFile(name) {
25
+ return (
26
+ /\.(sw[a-z]{1,2}|tmp|temp|orig|bak)$/i.test(name) || // vim swap (.swp/.swo/.swx/.swpx…), temp/backup
27
+ /~$/.test(name) || // gedit/emacs/kate trailing-tilde backup
28
+ /^\.#/.test(name) || // emacs lockfile (.#file)
29
+ /^#.*#$/.test(name) || // emacs autosave (#file#)
30
+ name === ".DS_Store" ||
31
+ name === "Thumbs.db" ||
32
+ name === "4913" // vim's write-probe file
33
+ );
34
+ }
@@ -49,6 +49,7 @@
49
49
  * Ctrl-C stops the server. Exit 1 = can't resolve tenant/domain/workspace; 2 = bad usage.
50
50
  */
51
51
  import { spawn, spawnSync } from "node:child_process";
52
+ import { createRequire } from "node:module";
52
53
  import {
53
54
  readFileSync,
54
55
  existsSync,
@@ -356,10 +357,22 @@ const devSourceRoot = resolveDevSourceRoot(workspacePath, repoRoot, tenant);
356
357
  const spawnEnv = workspacePath
357
358
  ? { ...process.env, ASTRO_DEV_BACKGROUND: "1", TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot }
358
359
  : { ...process.env, TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot };
360
+ // LAUNCHER-AGNOSTIC astro spawn: the published runner tree may have been
361
+ // installed by pnpm OR npm (npm ships with every Node — the runner must not
362
+ // require pnpm at RUNTIME), so resolve astro's real entry through Node's own
363
+ // resolver from the app package and exec it with our Node. No package-manager
364
+ // binary, no .bin shims, and no coupling to the package's name (the build
365
+ // renames @tot/storefront → @tot/storefront-runner in the shipped tree, which
366
+ // the old `pnpm --filter <name> exec` had to track).
367
+ const appDir = resolve(repoRoot, "apps/storefront");
368
+ const astroEntry = resolve(
369
+ dirname(createRequire(join(appDir, "package.json")).resolve("astro/package.json")),
370
+ "bin/astro.mjs", // astro's package.json bin entry
371
+ );
359
372
  const child = spawn(
360
- "pnpm",
361
- ["--filter", "@tot/storefront-runner", "exec", "astro", "dev", "--port", port, "--host"],
362
- { cwd: repoRoot, stdio: "inherit", env: spawnEnv },
373
+ process.execPath,
374
+ [astroEntry, "dev", "--port", port, "--host"],
375
+ { cwd: appDir, stdio: "inherit", env: spawnEnv },
363
376
  );
364
377
 
365
378
  // Clean up grafts on every exit path so we never leave symlinks in the tree.
@@ -1,23 +0,0 @@
1
- ---
2
- /**
3
- * <FoxyLoader> — injects FoxyCart's loader.js (Track A).
4
- *
5
- * Foxy's loader turns our add-to-cart forms/links into the sidecart + hosted
6
- * checkout ("Powered by FoxyCart"). It's loaded from the store's CDN path and
7
- * must sit near the end of <body>. Rendered only when the tenant has a store
8
- * (locals.commerce); marketing tenants ship nothing.
9
- *
10
- * Note: Foxy's loader is a mutable, versioned CDN artifact and does NOT support
11
- * Subresource Integrity — pinning a hash would break it on Foxy's next release.
12
- * It's constrained instead by the per-tenant CSP allowlist (cdn.foxycart.com +
13
- * the store origin) set in middleware. It is loaded as a plain cross-origin
14
- * script (NO `crossorigin` attribute) — cdn.foxycart.com serves it without CORS
15
- * headers, so `crossorigin="anonymous"` would make the browser block it.
16
- */
17
- const { commerce } = Astro.locals;
18
- ---
19
- {
20
- commerce && (
21
- <script src={commerce.loaderSrc} async defer></script>
22
- )
23
- }
@@ -1,124 +0,0 @@
1
- /**
2
- * GET/POST /api/dev/activity — the hosted /dev panel's file-level activity
3
- * feed (local→hosted activity bridge). See handoff
4
- * 2026-07-14-hosted-dev-activity-bridge-and-stepper.
5
- *
6
- * GET: the signed-in developer's own browser polls this (same capability gate
7
- * as every other /dev route) to render the activity log and feed the Step-2
8
- * gate's completion signal. POLL, not a held-open stream — Cloudflare Workers
9
- * are stateless per request, so a poll means a Worker redeploy mid-session
10
- * needs no reconnect logic on either side.
11
- *
12
- * POST: the developer's LOCAL dev-loop process reports one file save,
13
- * authorized by the opaque bearer minted alongside their cli-signin-code
14
- * paste (lib/dev/activityIngestToken.ts) — NOT the browser session cookie,
15
- * since the local process has no cookie.
16
- */
17
- import type { APIContext } from "astro";
18
- import { readViewerSession } from "@/lib/auth/route";
19
- import { readKv } from "@/lib/env";
20
- import { resolvePreviewTenant } from "@/config/previewTenantLookup";
21
- import { verifyActivityIngestToken } from "@/lib/dev/activityIngestToken";
22
- import {
23
- appendActivity,
24
- readActivity,
25
- describeActivity,
26
- type ActivityEvent,
27
- type ActivityEventKind,
28
- } from "@/lib/dev/activityStore";
29
- import { readRuntime, writeRuntime, shapeRuntime, toRuntimeView } from "@/lib/dev/runtimeStore";
30
-
31
- export const prerender = false;
32
-
33
- function json(body: unknown, status = 200): Response {
34
- return new Response(JSON.stringify(body), {
35
- status,
36
- headers: { "content-type": "application/json" },
37
- });
38
- }
39
-
40
- export async function GET(context: APIContext): Promise<Response> {
41
- const session = await readViewerSession({ cookies: context.cookies });
42
- if (!session) return json({ error: "sign in required" }, 401);
43
- if (!session.capability || session.capability === "none") {
44
- return json({ error: "no developer capability on this account" }, 403);
45
- }
46
-
47
- // Same tenant resolution every other /dev route uses — the session's own store,
48
- // never caller input.
49
- const wanted = (session.tenants?.[0]?.resource ?? session.resource ?? "").toLowerCase();
50
- const tenant = wanted ? await resolvePreviewTenant(wanted) : null;
51
- if (!tenant) return json({ error: "no store linked to this account" }, 403);
52
-
53
- const kv = await readKv("TENANT_CACHE");
54
- const events = kv ? await readActivity(kv, tenant.tenant_id) : [];
55
- // The LIVE local-dev runtime (latest heartbeat), separate from the file-save
56
- // feed — the cockpit renders it as a clickable local link + CLI version, with
57
- // `live` computed server-side so a skewed local clock can't fake liveness.
58
- const runtime = kv ? await readRuntime(kv, tenant.tenant_id) : null;
59
- return json({
60
- events: events.map((e) => ({ ...e, description: describeActivity(e.file, e.event) })),
61
- runtime: runtime ? toRuntimeView(runtime) : null,
62
- });
63
- }
64
-
65
- export async function POST(context: APIContext): Promise<Response> {
66
- const auth = context.request.headers.get("authorization") || "";
67
- const m = /^Bearer\s+(.+)$/i.exec(auth);
68
- if (!m) return json({ error: "missing bearer token" }, 401);
69
-
70
- const sessionKv = await readKv("SESSION");
71
- if (!sessionKv) return json({ error: "activity bridge not configured" }, 503);
72
- const claims = await verifyActivityIngestToken(sessionKv, m[1] ?? "");
73
- if (!claims) return json({ error: "invalid or expired token" }, 401);
74
-
75
- let body: {
76
- file?: unknown;
77
- event?: unknown;
78
- at?: unknown;
79
- cliVersion?: unknown;
80
- runnerVersion?: unknown;
81
- url?: unknown;
82
- };
83
- try {
84
- body = (await context.request.json()) as typeof body;
85
- } catch {
86
- return json({ error: "invalid JSON body" }, 400);
87
- }
88
-
89
- // The token's own bound tenant, never a caller-supplied one — a stolen/expired
90
- // token can't be redirected at a different vendor's runtime/activity feed.
91
- const tenant = await resolvePreviewTenant(claims.tenant.toLowerCase());
92
- if (!tenant) return json({ error: "no store linked to this token" }, 403);
93
- const tenantCache = await readKv("TENANT_CACHE");
94
-
95
- // Heartbeat: latest-wins local-dev runtime (CLI version + live localhost URL),
96
- // stored SEPARATELY from the file-save ring buffer. Not a file event, so it
97
- // takes no `file`; liveness is stamped from the SERVER clock (aliveAt), never
98
- // the client's `at`.
99
- if (body.event === "heartbeat") {
100
- if (!tenantCache) return json({ ok: true, stored: false }); // no KV in this runtime (dev)
101
- await writeRuntime(tenantCache, tenant.tenant_id, shapeRuntime(body, Date.now()));
102
- return json({ ok: true, stored: true, kind: "runtime" });
103
- }
104
-
105
- const isUp = body.event === "up";
106
- const file = typeof body.file === "string" ? body.file : "";
107
- if (!file && !isUp) return json({ error: "file is required" }, 400);
108
- // A file-relative path never needs to be long; cap it so a misbehaving or
109
- // hostile bridge-token holder can't inflate the per-tenant KV ring-buffer
110
- // entry toward KV's per-value size limit.
111
- if (file.length > 512) return json({ error: "file path too long" }, 400);
112
- const event: ActivityEventKind = isUp
113
- ? "up"
114
- : body.event === "add" || body.event === "unlink"
115
- ? body.event
116
- : "change";
117
- const at = typeof body.at === "number" && Number.isFinite(body.at) ? body.at : Date.now();
118
-
119
- if (!tenantCache) return json({ ok: true, stored: false }); // no KV in this runtime (dev) — accepted, not stored
120
-
121
- const ev: ActivityEvent = { file, event, at };
122
- await appendActivity(tenantCache, tenant.tenant_id, ev);
123
- return json({ ok: true, stored: true });
124
- }
@@ -1,73 +0,0 @@
1
- /**
2
- * POST /api/dev/cli-signin-code — mint a browserless CLI sign-in command for the
3
- * SIGNED-IN developer (DevAccess #8425). The /dev getting-started page calls this to
4
- * render a copyable `tot login --code <token>` so the developer's terminal signs in with
5
- * no second browser round-trip and no OTP.
6
- *
7
- * AUTHORIZATION: the viewer's own session (the `tot_session` cookie). 401 if not signed
8
- * in, 403 if the session carries no developer capability (nothing to sign a CLI into).
9
- * The minted-for email is the VERIFIED session email — NEVER read from the body — so a
10
- * developer can only mint a code for themselves.
11
- *
12
- * TENANT-SCOPED CODE (roadtoa3 postmortem, 2026-07-12): the mint also carries the
13
- * developer's OWN tenant (their invite's vendor, the same resolution /dev renders with —
14
- * first membership, else the session resource; never client input). The MCP stores it
15
- * with the code so `tot login --code` lands the terminal ALREADY SCOPED to that vendor —
16
- * `tot start` goes straight to their store's source, no client_list round-trip, no
17
- * "no stores you can build on yet". See handoff
18
- * `2026-07-12-tot-mcp-cli-code-tenant-binding` for the MCP half (store tenant with the
19
- * code + write the durable subject-capability binding at mint).
20
- *
21
- * The response `command` CONTAINS the single-use token; it is returned only to this
22
- * authenticated developer's own browser and is never logged server-side.
23
- */
24
- import type { APIContext } from "astro";
25
- import { readViewerSession } from "@/lib/auth/route";
26
- import { mintCliSignInCommand } from "@/lib/dev/cliSignInCode";
27
- import { mintActivityIngestToken } from "@/lib/dev/activityIngestToken";
28
- import { readKv } from "@/lib/env";
29
-
30
- export const prerender = false;
31
-
32
- function json(body: unknown, status = 200): Response {
33
- return new Response(JSON.stringify(body), {
34
- status,
35
- headers: { "content-type": "application/json" },
36
- });
37
- }
38
-
39
- export async function POST(context: APIContext): Promise<Response> {
40
- const session = await readViewerSession(context);
41
- if (!session) return json({ error: "not signed in" }, 401);
42
- // Same gate the /dev page applies (hasStoreAccess): a real developer capability. A
43
- // no-capability session has no scoped CLI session to mint.
44
- if (!session.capability || session.capability === "none") {
45
- return json({ error: "no developer access on this session", reason: "notEligible" }, 403);
46
- }
47
-
48
- // The developer's own vendor — the tenant their invite granted (same resolution /dev
49
- // renders with). Rides with the code so the CLI session lands on this store directly.
50
- const tenant = session.tenants?.[0]?.resource ?? session.resource ?? null;
51
-
52
- // Mint the local→hosted activity-bridge credential alongside the CLI code (best
53
- // effort — a runtime without the SESSION KV binding just omits it, and the local
54
- // loop runs with no hosted signal, same as before this existed).
55
- let activityBridge: { token: string; url: string } | null = null;
56
- const sessionKv = await readKv("SESSION");
57
- if (sessionKv && tenant) {
58
- const { token } = await mintActivityIngestToken(sessionKv, {
59
- email: session.email,
60
- tenant,
61
- capability: session.capability,
62
- });
63
- activityBridge = { token, url: context.url.origin };
64
- }
65
-
66
- const result = await mintCliSignInCommand(session.email, tenant, {}, activityBridge);
67
- if (!result.ok) {
68
- // 503 = seam not configured / MCP route unmounted; 502 = MCP fault. Surface neither
69
- // the token nor internal detail beyond a terse reason.
70
- return json({ error: "CLI sign-in code unavailable", reason: result.reason }, result.status);
71
- }
72
- return json({ command: result.command, expiresAt: result.expiresAt });
73
- }
@@ -1,68 +0,0 @@
1
- /**
2
- * GET /api/dev/diff?path= — Phase 2 (cp-diff) of the hosted /dev cockpit
3
- * (workstream tot-lime-cloud-dev-cockpit, decision
4
- * architecture-environment-aware-cockpit). Diffs the developer's draft
5
- * overlay against published for one path, returning the SAME shape as the
6
- * local loop's `/__tot/diff` (scripts/dev/git-status.mjs handleDiff —
7
- * {ok,path,status,shown,before,after}) so dev.astro's existing diff UI
8
- * (doDiff/renderDiff) needs no client-side change.
9
- *
10
- * No git in the Worker — "status" derives from draft-present-vs-published
11
- * instead of `git status`: "unchanged" (no draft, or draft equals
12
- * published), "modified" (draft differs from an existing published value),
13
- * "added" (a draft exists for a path with no published counterpart yet —
14
- * e.g. a brand-new pages-html/<slug>.html the developer hasn't published).
15
- * Both sides are the raw-HTML/JSON writable surface, always text — no binary
16
- * degrade case exists here (unlike the local loop's arbitrary file tree).
17
- */
18
- import type { APIContext } from "astro";
19
- import { json } from "@/lib/grants/route.js";
20
- import {
21
- resolveCockpitContext,
22
- isCockpitContext,
23
- isWritablePath,
24
- readPublishedPath,
25
- resolveDraftState,
26
- } from "@/lib/dev/hostedCockpit";
27
-
28
- export const prerender = false;
29
-
30
- const MAX_DIFF_BYTES = 512 * 1024;
31
-
32
- export async function GET(context: APIContext): Promise<Response> {
33
- const ctx = await resolveCockpitContext(context);
34
- if (!isCockpitContext(ctx)) return ctx;
35
-
36
- const path = new URL(context.request.url).searchParams.get("path");
37
- if (!path) return json({ ok: false, status: "no_path", message: "path query param is required" }, 400);
38
- if (!isWritablePath(path)) {
39
- return json({ ok: false, status: "bad_path", message: `Not available on the hosted panel: ${path}` }, 400);
40
- }
41
-
42
- const [published, { draftContent }] = await Promise.all([
43
- readPublishedPath(ctx, path),
44
- resolveDraftState(context, ctx, path),
45
- ]);
46
-
47
- if (published === null && draftContent === null) {
48
- return json({ ok: false, status: "not_found", message: `No such file: ${path}` }, 404);
49
- }
50
-
51
- const status =
52
- draftContent === null
53
- ? "unchanged"
54
- : published === null
55
- ? "added"
56
- : draftContent === published
57
- ? "unchanged"
58
- : "modified";
59
-
60
- const before = published ?? "";
61
- const after = draftContent ?? published ?? "";
62
- const size = new TextEncoder().encode(before + after).length;
63
- if (size > MAX_DIFF_BYTES) {
64
- return json({ ok: true, path, status, shown: false, reason: "too_big" });
65
- }
66
-
67
- return json({ ok: true, path, status, shown: true, before, after });
68
- }
@@ -1,57 +0,0 @@
1
- /**
2
- * GET /api/dev/file?path= — Phase 1 (cp-file) of the hosted /dev cockpit
3
- * (workstream tot-lime-cloud-dev-cockpit, decision
4
- * architecture-environment-aware-cockpit). Reads one file off the same
5
- * synthesized tree cp-tree (/api/dev/tree) lists, mirroring the local loop's
6
- * `/__tot/file` response contract (scripts/dev/file-browser.mjs readFile/
7
- * handleFile) so dev.astro's filesScript viewer needs no client-side change.
8
- *
9
- * `path` is one of the three shapes cp-tree ever lists — content/home.html,
10
- * content/pages-html/<slug>.html, or theme.json (scripts/dev/safe-path.mjs
11
- * WRITE_ALLOWLIST) — so this route is a closed switch on those shapes, not a
12
- * general path resolver; anything else is a clean 400, never a traversal risk.
13
- *
14
- * Reads the developer's OWN draft overlay first (readEffectivePath), falling
15
- * back to published — so re-opening a file after a cp-write save shows the
16
- * edit, and the `mtimeMs` it hands back is the SAME contentVersion() cp-write
17
- * checks on the next save (the C6 stale-write guard).
18
- *
19
- * `writable:true` on all three is intentional now that cp-write exists: this
20
- * is genuinely the editable surface.
21
- */
22
- import type { APIContext } from "astro";
23
- import { json } from "@/lib/grants/route.js";
24
- import {
25
- resolveCockpitContext,
26
- isCockpitContext,
27
- isWritablePath,
28
- languageForPath,
29
- readEffectivePath,
30
- contentVersion,
31
- } from "@/lib/dev/hostedCockpit";
32
-
33
- export const prerender = false;
34
-
35
- const MAX_FILE_BYTES = 512 * 1024;
36
-
37
- export async function GET(context: APIContext): Promise<Response> {
38
- const ctx = await resolveCockpitContext(context);
39
- if (!isCockpitContext(ctx)) return ctx;
40
-
41
- const path = new URL(context.request.url).searchParams.get("path");
42
- if (!path) return json({ ok: false, status: "no_path", message: "path query param is required" }, 400);
43
- if (!isWritablePath(path)) {
44
- return json({ ok: false, status: "bad_path", message: `Not available on the hosted panel: ${path}` }, 400);
45
- }
46
-
47
- const { content } = await readEffectivePath(context, ctx, path);
48
- if (content === null) return json({ ok: false, status: "not_found", message: `No such file: ${path}` }, 404);
49
-
50
- const language = languageForPath(path);
51
- const mtimeMs = contentVersion(content);
52
- const size = new TextEncoder().encode(content).length;
53
- if (size > MAX_FILE_BYTES) {
54
- return json({ ok: true, status: "ok", path, tooBig: true, size, language, content: null, mtimeMs, writable: true });
55
- }
56
- return json({ ok: true, status: "ok", path, size, language, content, mtimeMs, writable: true });
57
- }
@@ -1,125 +0,0 @@
1
- /**
2
- * POST /api/dev/locate — Phase 2 (cp-locate) of the hosted /dev cockpit
3
- * (workstream tot-lime-cloud-dev-cockpit, decision
4
- * architecture-environment-aware-cockpit). Click-to-source in the Worker:
5
- * given a clicked preview URL + captured DOM path, resolve the source
6
- * line:col in the developer's raw-HTML content, using the exact SAME pure
7
- * parse5 walk as the local loop (scripts/dev/locate.mjs `locate()`) and the
8
- * same URL -> content-path mapping (scripts/dev/locate-handler.mjs
9
- * `resolvePageSource`), both imported directly (dependency-free besides
10
- * parse5, safe in the Worker).
11
- *
12
- * Honors decision click-to-source-anchors-at-content-region from the
13
- * local-loop board: a click resolves against the raw content region only; the
14
- * storefront's own chrome (header/nav/footer, not in tenant source) refuses
15
- * explicitly with `not_in_content`, never a coincidental miss.
16
- *
17
- * Unlike the local route, this reads the developer's DRAFT overlay first
18
- * (readEffectivePath) — correct, since that's what their preview iframe is
19
- * actually showing once they've saved an edit (cp-write). No open-in-editor
20
- * equivalent: the jump lands in the in-cockpit editor via the existing
21
- * tot:locate postMessage handler (c8Script), already wired and unchanged.
22
- *
23
- * Only `locate()` itself is imported from scripts/dev/locate.mjs (pure,
24
- * parse5-only — Workers-safe). The URL->content-path mapping
25
- * (resolvePageSource in scripts/dev/locate-handler.mjs) is reimplemented
26
- * here rather than imported, because that module also pulls in node:fs +
27
- * node:child_process (open-in-editor) which have no place in a Worker
28
- * bundle; isWritablePath (hostedCockpit.ts) already IS this route's write
29
- * allowlist, so the two definitions can't drift.
30
- *
31
- * NOTE (scope): this ships the server contract only. The client picker
32
- * widget (src/lib/dev/ai-widget-client.js) that lets a developer click an
33
- * element in the preview has no hosted injection path yet (dev-only today,
34
- * see rawMarketingHtml.ts) — tracked separately as cp-locate-widget-injection
35
- * so that security-sensitive gating gets its own review rather than riding
36
- * in here.
37
- */
38
- import type { APIContext } from "astro";
39
- import { json } from "@/lib/grants/route.js";
40
- import { locate } from "../../../../../../scripts/dev/locate.mjs";
41
- import {
42
- resolveCockpitContext,
43
- isCockpitContext,
44
- readEffectivePath,
45
- isWritablePath,
46
- } from "@/lib/dev/hostedCockpit";
47
-
48
- export const prerender = false;
49
-
50
- interface LocateBody {
51
- path?: unknown;
52
- domPath?: unknown;
53
- }
54
-
55
- /**
56
- * Map a clicked preview URL to its raw-HTML content source, root-relative.
57
- * Mirrors scripts/dev/locate-handler.mjs's resolvePageSource exactly (see
58
- * that module's header for the mapping rules); duplicated rather than
59
- * imported for the node:fs-free-Worker-bundle reason above.
60
- */
61
- function resolvePreviewPageSource(pathname: string, appDomain: string): string | null {
62
- let clean: string;
63
- try {
64
- clean = decodeURIComponent(pathname.split("?")[0]!.split("#")[0]!);
65
- } catch {
66
- return null;
67
- }
68
- const segments = clean.split("/").filter(Boolean);
69
- if (segments.length && segments[0] === appDomain) segments.shift();
70
- if (segments.some((s) => s === "..")) return null;
71
- const rel = segments.length === 0 ? "content/home.html" : `content/pages-html/${segments.join("/")}.html`;
72
- return isWritablePath(rel) ? rel : null;
73
- }
74
-
75
- function isDomPath(domPath: unknown): domPath is Array<{ tag: string; index: number }> {
76
- return (
77
- Array.isArray(domPath) &&
78
- domPath.length > 0 &&
79
- domPath.every(
80
- (s) => s && typeof s === "object" && typeof (s as any).tag === "string" && Number.isFinite(Number((s as any).index)),
81
- )
82
- );
83
- }
84
-
85
- export async function POST(context: APIContext): Promise<Response> {
86
- const ctx = await resolveCockpitContext(context);
87
- if (!isCockpitContext(ctx)) return ctx;
88
-
89
- let body: LocateBody;
90
- try {
91
- body = (await context.request.json()) as LocateBody;
92
- } catch {
93
- return json({ ok: false, status: "bad_json", message: "invalid JSON body" }, 400);
94
- }
95
-
96
- if (!isDomPath(body.domPath)) {
97
- return json({ ok: false, status: "bad_dom_path", message: "domPath must be a non-empty array of {tag,index}" }, 400);
98
- }
99
-
100
- const rel = typeof body.path === "string" ? resolvePreviewPageSource(body.path, ctx.tenant.appDomain) : null;
101
- if (!rel) {
102
- return json({
103
- ok: false,
104
- status: "not_raw_html",
105
- message: "This page isn’t a raw-HTML content page — click-to-source is for content/*.html tenants.",
106
- });
107
- }
108
-
109
- const { content } = await readEffectivePath(context, ctx, rel);
110
- if (content === null) {
111
- return json({ ok: false, status: "not_raw_html", message: "No raw-HTML source backs this page." });
112
- }
113
-
114
- const found = locate(content, body.domPath);
115
- if (typeof (found as { line?: number }).line === "number") {
116
- const { line, col } = found as { line: number; col: number };
117
- return json({ ok: true, path: rel, line, col });
118
- }
119
- return json({
120
- ok: false,
121
- status: "not_in_content",
122
- message:
123
- "That element is part of the storefront frame (header, nav, or footer), not your content source. Click something inside your page content.",
124
- });
125
- }