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

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 (75) hide show
  1. package/apps/storefront/astro.config.mjs +50 -0
  2. package/apps/storefront/env.d.ts +24 -8
  3. package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
  4. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
  5. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
  6. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
  7. package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
  8. package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
  9. package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
  10. package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
  11. package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
  12. package/apps/storefront/src/config/devTenantSeed.ts +65 -16
  13. package/apps/storefront/src/config/resolver.ts +48 -1
  14. package/apps/storefront/src/layouts/Layout.astro +14 -12
  15. package/apps/storefront/src/lib/admin/README.md +2 -2
  16. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
  17. package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
  18. package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
  19. package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
  20. package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
  21. package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
  22. package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
  23. package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
  24. package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
  25. package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
  26. package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
  27. package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
  28. package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
  29. package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
  30. package/apps/storefront/src/lib/dev/runtimeStore.ts +136 -7
  31. package/apps/storefront/src/lib/edgeCache.ts +7 -6
  32. package/apps/storefront/src/lib/health.ts +53 -0
  33. package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
  34. package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
  35. package/apps/storefront/src/lib/sections.ts +19 -23
  36. package/apps/storefront/src/lib/securityHeaders.ts +1 -1
  37. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
  38. package/apps/storefront/src/middleware/index.ts +29 -12
  39. package/apps/storefront/src/pages/[...slug].astro +5 -6
  40. package/apps/storefront/src/pages/admin.astro +10 -3
  41. package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
  42. package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
  43. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
  44. package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
  45. package/apps/storefront/src/pages/auth/login.astro +4 -1
  46. package/apps/storefront/src/pages/auth/magic.astro +263 -43
  47. package/apps/storefront/src/pages/capabilities.astro +1 -1
  48. package/apps/storefront/src/pages/cockpit.astro +2365 -0
  49. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
  50. package/apps/storefront/src/pages/index.astro +23 -29
  51. package/apps/storefront/src/pages/products/[handle].astro +50 -20
  52. package/package.json +1 -1
  53. package/packages/public-runtime/src/capabilities.ts +97 -9
  54. package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
  55. package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
  56. package/packages/public-runtime/src/checkout-style.ts +13 -13
  57. package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
  58. package/packages/public-runtime/src/csp.ts +11 -11
  59. package/packages/public-runtime/src/customization-scripts.ts +28 -0
  60. package/packages/public-runtime/src/extension-contract.ts +73 -0
  61. package/packages/public-runtime/src/index.ts +4 -2
  62. package/packages/public-runtime/src/product.ts +57 -0
  63. package/packages/public-runtime/src/raw-html-policy.ts +35 -0
  64. package/packages/public-runtime/src/tenant.ts +14 -11
  65. package/scripts/dev/checkout-watch.mjs +28 -0
  66. package/scripts/dev/file-browser.mjs +5 -1
  67. package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
  68. package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
  69. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
  70. package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
  71. package/apps/storefront/src/pages/api/dev/file.ts +0 -57
  72. package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
  73. package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
  74. package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
  75. package/apps/storefront/src/pages/api/dev/write.ts +0 -116
@@ -26,6 +26,7 @@
26
26
  * local loop then simply runs with no hosted signal, same as today.
27
27
  */
28
28
  import { readEnv } from "@/lib/env";
29
+ import { redactEmailForHint } from "@/lib/privacy/emailHint";
29
30
 
30
31
  // The CLI's built-in default MCP (packages/cli login.mjs DEFAULT_MCP_URL). When the derived
31
32
  // MCP origin equals this, --mcp is redundant noise, so we omit it.
@@ -69,6 +70,7 @@ export async function mintCliSignInCommand(
69
70
  tenant: string | null = null,
70
71
  deps: { transport?: HttpTransport } = {},
71
72
  activityBridge: { token: string; url: string } | null = null,
73
+ traceId: string | null = null,
72
74
  ): Promise<CliSignInCodeResult> {
73
75
  const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
74
76
  const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
@@ -125,11 +127,21 @@ export async function mintCliSignInCommand(
125
127
  }
126
128
 
127
129
  const mcpFlag = mcpOrigin === PROD_MCP_ORIGIN ? "" : ` --mcp ${mcpOrigin}`;
130
+ // Display-only recovery hint for a stale one-use code. If redemption fails before
131
+ // anything is cached locally, `tot login` can still send the developer back to
132
+ // cockpit with the right address prefilled.
133
+ const emailHint = redactEmailForHint(email);
134
+ const emailHintFlag = emailHint ? ` --email-hint ${emailHint}` : "";
128
135
  // Piggyback the activity-bridge credential onto the SAME login line — additive
129
136
  // flags, ignored by older CLI builds that don't yet recognize them.
130
137
  const activityFlags = activityBridge
131
138
  ? ` --activity-token ${activityBridge.token} --activity-url ${activityBridge.url}`
132
139
  : "";
140
+ // The invite→terminal→problem trace id, minted once with this sign-in code and
141
+ // baked into the activity-ingest-token claims. Emitted here too so `tot login`
142
+ // records it locally — additive, ignored by older CLI builds. The receiver gets
143
+ // the SAME id from the ingest-token claims, so runtime/activity writes stamp it.
144
+ const traceFlag = traceId ? ` --trace-id ${traceId}` : "";
133
145
  // A single copy → a three-line paste that takes the developer all the way to a running
134
146
  // store, no browser, AND leaves `tot` on their PATH so every later command is a bare
135
147
  // `tot …` (not `npx @tokenoftrust/cli …`):
@@ -140,11 +152,18 @@ export async function mintCliSignInCommand(
140
152
  //
141
153
  // The trailing `&&` chains them: each step runs ONLY if the previous succeeded. In
142
154
  // particular a stale/burned code (single-use, short-TTL) fails step 2 and stops there
143
- // with `login`'s clear "ask for a fresh invite" error — it never falls through to
144
- // `start`'s BROWSER sign-in (the surprise we're avoiding).
155
+ // with `login`'s cockpit recovery step — it never falls through to `start`'s browser
156
+ // sign-in (the surprise we're avoiding).
157
+ // The EXACT version this build was cut alongside — `@tokenoftrust/cli@<version>` — baked
158
+ // in at build time (astro.config Vite define) so the paste installs precisely the CLI this
159
+ // cockpit expects, never a floating dist-tag the terminal resolves (which can drift behind
160
+ // the build; that gap shipped stampless installs). `typeof` guard: the define isn't applied
161
+ // under vitest, where it falls back to the unpinned spec.
162
+ const cliInstallSpec =
163
+ typeof __CLI_INSTALL_SPEC__ !== "undefined" ? __CLI_INSTALL_SPEC__ : "@tokenoftrust/cli";
145
164
  const command = [
146
- `npm i -g @tokenoftrust/cli &&`,
147
- `tot login --code ${payload.token}${mcpFlag}${activityFlags} &&`,
165
+ `npm i -g ${cliInstallSpec} &&`,
166
+ `tot login --code ${payload.token}${emailHintFlag}${mcpFlag}${traceFlag}${activityFlags} &&`,
148
167
  `tot start`,
149
168
  ].join("\n");
150
169
  return {
@@ -25,17 +25,61 @@ export interface DevRuntime {
25
25
  runnerVersion?: string;
26
26
  /** The local store URL — `http://localhost:<port>/<tenant>/`. Clickable in the cockpit. */
27
27
  url?: string;
28
+ /**
29
+ * The developer's terminal editor command ($VISUAL/$EDITOR), when set — lets the
30
+ * cockpit suggest the exact "open this file" command (e.g. `code content/home.html`).
31
+ * Low-sensitivity (a tool name, never a secret); absent when no editor is set.
32
+ */
33
+ editor?: string;
34
+ /**
35
+ * The developer's project directory (checkout root), when reported — lets the
36
+ * cockpit show the FULL path to the file to edit so the open command works from
37
+ * any directory. The dev's own local path (low-sensitivity, like `url`).
38
+ */
39
+ cwd?: string;
40
+ /** OS platform the CLI heartbeat reported (e.g. "darwin", "linux", "win32"). */
41
+ os?: string;
42
+ /** OS release/version string the heartbeat reported (e.g. "24.6.0"). */
43
+ osVersion?: string;
44
+ /** CPU architecture the heartbeat reported (e.g. "arm64", "x64"). */
45
+ arch?: string;
46
+ /**
47
+ * The invite→terminal→problem TRACE id — minted once when this developer's
48
+ * sign-in code was issued and carried in the activity-ingest-token claims, so
49
+ * every heartbeat stamps the SAME id here. Support looks a live session up by
50
+ * it (readRuntimeByTrace). Not PII; safe to expose in the cockpit view.
51
+ */
52
+ traceId?: string;
53
+ /**
54
+ * The developer's email, stamped from the ingest-token claims (NOT the request
55
+ * body) — so support can tie this runtime record to a person by traceId. PII:
56
+ * the STORE keeps it (the owner's own data), but it is deliberately stripped
57
+ * from the GET view (toRuntimeView) and must never reach a log/response sink.
58
+ */
59
+ email?: string;
28
60
  /** Epoch ms of the latest heartbeat, stamped by the SERVER at ingest (skew-proof). */
29
61
  aliveAt: number;
30
62
  }
31
63
 
32
- /** The runtime as the GET feed exposes it — the stored record plus a computed `live` flag. */
33
- export interface DevRuntimeView extends DevRuntime {
64
+ /**
65
+ * The runtime as the GET feed exposes it — the stored record plus a computed
66
+ * `live` flag, with `email` (PII) omitted. Support reads the raw record by
67
+ * traceId; the developer's own browser only ever sees the email-free view.
68
+ */
69
+ export interface DevRuntimeView extends Omit<DevRuntime, "email"> {
34
70
  /** True when the last heartbeat landed within LIVE_WINDOW_MS of `now`. */
35
71
  live: boolean;
36
72
  }
37
73
 
38
74
  const KEY_PREFIX = "dev-runtime:";
75
+ /**
76
+ * Secondary index: traceId -> the runtime record's tenant key, so support can
77
+ * look a developer's LIVE session up by the trace id minted at sign-in without
78
+ * scanning. Mirrors the developer+tenant index in activityIngestToken.ts; a KV
79
+ * store has no GSI, so the smallest correct discovery path is a maintained index
80
+ * key (written on every heartbeat, cleared on reset). Same TTL as the record.
81
+ */
82
+ const TRACE_INDEX_PREFIX = "dev-runtime-by-trace:";
39
83
  /**
40
84
  * How recent the last heartbeat must be to read as "live". The CLI beats every
41
85
  * ~10s, so 30s tolerates a couple of missed/slow beats before we call the loop
@@ -52,11 +96,25 @@ const TTL_SECONDS = 3600;
52
96
  /** Cap the free-text fields a bridge-token holder can push into KV. */
53
97
  const MAX_VERSION_LEN = 64;
54
98
  const MAX_URL_LEN = 256;
99
+ /** An editor command may carry flags/a path (e.g. "code --wait") — a touch more room. */
100
+ const MAX_EDITOR_LEN = 128;
101
+ /** A filesystem path to the project root — allow a deep absolute path. */
102
+ const MAX_CWD_LEN = 512;
103
+ /** OS platform / release / arch are short tokens ("darwin", "24.6.0", "arm64"). */
104
+ const MAX_OS_LEN = 64;
105
+ /** RFC-max email length. */
106
+ const MAX_EMAIL_LEN = 320;
107
+ /** A UUID; a touch of headroom for other id shapes. */
108
+ const MAX_TRACE_LEN = 64;
55
109
 
56
110
  function key(appDomain: string): string {
57
111
  return KEY_PREFIX + appDomain;
58
112
  }
59
113
 
114
+ function traceKey(traceId: string): string {
115
+ return TRACE_INDEX_PREFIX + traceId;
116
+ }
117
+
60
118
  function clip(v: unknown, max: number): string | undefined {
61
119
  return typeof v === "string" && v.length > 0 ? v.slice(0, max) : undefined;
62
120
  }
@@ -73,22 +131,60 @@ function localUrl(v: unknown): string | undefined {
73
131
  return s && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(s) ? s : undefined;
74
132
  }
75
133
 
134
+ /**
135
+ * Trusted identity for the runtime record, sourced from the VERIFIED ingest-token
136
+ * claims (never the request body): the trace id minted at sign-in and the
137
+ * developer's email. Kept separate from `body` so a token holder can't spoof
138
+ * either by putting it in the heartbeat payload.
139
+ */
140
+ export interface RuntimeIdentity {
141
+ traceId?: string;
142
+ email?: string;
143
+ }
144
+
76
145
  /**
77
146
  * Normalize a raw heartbeat body into a stored DevRuntime — dropping anything
78
147
  * that isn't a bounded string and stamping the SERVER's `aliveAt`. Pure +
79
- * exported so the route's shaping is unit-tested without KV.
148
+ * exported so the route's shaping is unit-tested without KV. `identity` carries
149
+ * the trace id + developer email from the ingest-token claims (trusted), stamped
150
+ * onto the record so support can find a live session by trace and tie it to a
151
+ * person; body-supplied os/osVersion/arch are whitelisted like the other fields.
80
152
  */
81
153
  export function shapeRuntime(
82
- body: { cliVersion?: unknown; runnerVersion?: unknown; url?: unknown },
154
+ body: {
155
+ cliVersion?: unknown;
156
+ runnerVersion?: unknown;
157
+ url?: unknown;
158
+ editor?: unknown;
159
+ cwd?: unknown;
160
+ os?: unknown;
161
+ osVersion?: unknown;
162
+ arch?: unknown;
163
+ },
83
164
  aliveAt: number,
165
+ identity: RuntimeIdentity = {},
84
166
  ): DevRuntime {
85
167
  const rt: DevRuntime = { aliveAt };
86
168
  const cliVersion = clip(body.cliVersion, MAX_VERSION_LEN);
87
169
  const runnerVersion = clip(body.runnerVersion, MAX_VERSION_LEN);
88
170
  const url = localUrl(body.url);
171
+ const editor = clip(body.editor, MAX_EDITOR_LEN);
172
+ const cwd = clip(body.cwd, MAX_CWD_LEN);
173
+ const os = clip(body.os, MAX_OS_LEN);
174
+ const osVersion = clip(body.osVersion, MAX_OS_LEN);
175
+ const arch = clip(body.arch, MAX_OS_LEN);
176
+ const traceId = clip(identity.traceId, MAX_TRACE_LEN);
177
+ const email = clip(identity.email, MAX_EMAIL_LEN);
89
178
  if (cliVersion) rt.cliVersion = cliVersion;
90
179
  if (runnerVersion) rt.runnerVersion = runnerVersion;
91
180
  if (url) rt.url = url;
181
+ if (editor) rt.editor = editor;
182
+ if (cwd) rt.cwd = cwd;
183
+ if (os) rt.os = os;
184
+ if (osVersion) rt.osVersion = osVersion;
185
+ if (arch) rt.arch = arch;
186
+ if (traceId) rt.traceId = traceId;
187
+ if (email) rt.email = email;
92
188
  return rt;
93
189
  }
94
190
 
@@ -105,16 +201,49 @@ export async function readRuntime(kv: KVNamespace, appDomain: string): Promise<D
105
201
  }
106
202
  }
107
203
 
108
- /** Overwrite the vendor's runtime with the latest heartbeat (latest-wins, TTL'd). */
204
+ /** Overwrite the vendor's runtime with the latest heartbeat (latest-wins, TTL'd).
205
+ * When the record carries a traceId, also (re)write the by-trace index so support
206
+ * can resolve trace -> tenant -> live runtime without a scan. */
109
207
  export async function writeRuntime(
110
208
  kv: KVNamespace,
111
209
  appDomain: string,
112
210
  rt: DevRuntime,
113
211
  ): Promise<void> {
114
212
  await kv.put(key(appDomain), JSON.stringify(rt), { expirationTtl: TTL_SECONDS });
213
+ if (rt.traceId) {
214
+ await kv.put(traceKey(rt.traceId), appDomain, { expirationTtl: TTL_SECONDS });
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Resolve a developer's live runtime by the trace id minted at sign-in — the
220
+ * support lookup path. Reads the by-trace index to find the tenant key, then the
221
+ * record. Returns the RAW record (email included) — a support/internal read, not
222
+ * the developer-facing GET view. Null when the trace is unknown/expired. Never
223
+ * throws.
224
+ */
225
+ export async function readRuntimeByTrace(
226
+ kv: KVNamespace,
227
+ traceId: string,
228
+ ): Promise<DevRuntime | null> {
229
+ if (!traceId) return null;
230
+ const appDomain = await kv.get(traceKey(traceId));
231
+ if (!appDomain) return null;
232
+ return readRuntime(kv, appDomain);
233
+ }
234
+
235
+ /** Delete the vendor's runtime record — a "true reset" so a killed store stops
236
+ * reading as up immediately instead of lingering until the TTL/live-window expires.
237
+ * Also drops the by-trace index entry for its current trace, if any. */
238
+ export async function clearRuntime(kv: KVNamespace, appDomain: string): Promise<void> {
239
+ const existing = await readRuntime(kv, appDomain);
240
+ await kv.delete(key(appDomain));
241
+ if (existing?.traceId) await kv.delete(traceKey(existing.traceId));
115
242
  }
116
243
 
117
- /** Attach the computed `live` flag (aliveAt within LIVE_WINDOW_MS of `now`). Pure. */
244
+ /** Attach the computed `live` flag (aliveAt within LIVE_WINDOW_MS of `now`) and
245
+ * STRIP `email` (PII) — the developer-facing view never carries it. Pure. */
118
246
  export function toRuntimeView(rt: DevRuntime, now: number = Date.now()): DevRuntimeView {
119
- return { ...rt, live: now - rt.aliveAt <= LIVE_WINDOW_MS };
247
+ const { email: _email, ...safe } = rt;
248
+ return { ...safe, live: now - rt.aliveAt <= LIVE_WINDOW_MS };
120
249
  }
@@ -42,14 +42,15 @@ export function isCacheablePath(pathname: string, params: URLSearchParams): bool
42
42
  // the bare `/dashboard` index is a single-segment slug that would otherwise
43
43
  // match the editorial-page rule below and be served across viewers.
44
44
  if (pathname === "/dashboard" || pathname.startsWith("/dashboard/")) return false;
45
- // `/dev` is the authenticated developer surface — per-viewer (it renders the
46
- // signed-in developer's own store, email, and gated demo), so like /dashboard
47
- // it must never be shared across viewers. It's also the loudest failure if
48
- // stale: its styles build to a CONTENT-HASHED asset (/_astro/dev.<hash>.css),
45
+ // `/cockpit` (formerly `/dev`) is the authenticated developer surface — per-viewer
46
+ // (it renders the signed-in developer's own store, email, and gated demo), so like
47
+ // /dashboard it must never be shared across viewers. It's also the loudest failure
48
+ // if stale: its styles build to a CONTENT-HASHED asset (/_astro/cockpit.<hash>.css),
49
49
  // so a cached old HTML keeps pointing at a hash that no longer exists after the
50
50
  // next deploy → the stylesheet 404s and the page renders unstyled (its inline
51
- // nonce'd <script> tags break the same way). Never cache it.
52
- if (pathname === "/dev") return false;
51
+ // nonce'd <script> tags break the same way). Never cache it. (/dev is kept too —
52
+ // it 302-redirects to /cockpit, and redirects must not be cached either.)
53
+ if (pathname === "/cockpit" || pathname === "/dev") return false;
53
54
  if (pathname === "/" || pathname === "/search") return true;
54
55
  if (pathname.startsWith("/products/")) return true;
55
56
  if (pathname.startsWith("/collections/")) return true;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * GET /health — liveness + deploy-identity probe.
3
+ *
4
+ * Answered at the very TOP of middleware (src/middleware), before tenant resolution,
5
+ * the demo/login access gate, the maintenance kill-switch, and the edge cache — so it
6
+ * reports the running build's identity regardless of Host, unresolved tenant, or a
7
+ * closed site. That makes it the canonical "what commit is live?" check:
8
+ *
9
+ * curl -s https://<host>/health | jq .sha
10
+ *
11
+ * The SHA is baked at BUILD time (astro.config.mjs Vite `define` → __GIT_SHA__). Because
12
+ * deploy.sh's source guard pins HEAD to origin/<deploySourceBranch> before shipping, a
13
+ * clean deploy's /health .sha equals the pushed umbrella tip — compare the two to know
14
+ * whether the latest is out there. Falls back to "unknown" when the build had no git
15
+ * context (the `typeof` guard also covers vitest, where the define isn't applied).
16
+ */
17
+ export const HEALTH_PATH = "/health";
18
+
19
+ export interface HealthPayload {
20
+ status: "ok";
21
+ /** Full 40-char commit SHA this build was cut from, or "unknown". */
22
+ sha: string;
23
+ /** First 7 chars of `sha` for eyeballing against `git log`, or "unknown". */
24
+ shortSha: string;
25
+ }
26
+
27
+ /**
28
+ * Build the health payload. The build-time SHA is read via a `typeof` guard so this is
29
+ * safe under vitest (define not applied). `overrides` exists only for tests.
30
+ *
31
+ * Deliberately reports only build IDENTITY (the commit) — no build timestamp or other
32
+ * fingerprint that would leak deploy cadence or make the response non-reproducible.
33
+ */
34
+ export function healthPayload(overrides?: { sha?: string }): HealthPayload {
35
+ const sha =
36
+ overrides?.sha ?? (typeof __GIT_SHA__ !== "undefined" ? __GIT_SHA__ : "unknown");
37
+ return {
38
+ status: "ok",
39
+ sha,
40
+ shortSha: sha === "unknown" ? "unknown" : sha.slice(0, 7),
41
+ };
42
+ }
43
+
44
+ /** 200 JSON `{status,sha,shortSha}`, never cached. */
45
+ export function healthResponse(): Response {
46
+ return new Response(JSON.stringify(healthPayload()), {
47
+ status: 200,
48
+ headers: {
49
+ "content-type": "application/json; charset=utf-8",
50
+ "cache-control": "no-store",
51
+ },
52
+ });
53
+ }
@@ -0,0 +1,33 @@
1
+ const FULL_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2
+ const SAFE_HINT_RE = /^[a-z0-9._%+\-]{1,96}@[a-z0-9.-]{1,96}\.[a-z]{2,63}$/i;
3
+
4
+ function maskPart(part: string): string {
5
+ if (part.length <= 2) return `${part.slice(0, 1)}...`;
6
+ return `${part.slice(0, 1)}...${part.slice(-1)}`;
7
+ }
8
+
9
+ /** Display-only email hint for URLs/log-safe UX. Never returns the full address. */
10
+ export function redactEmailForHint(email: string): string | null {
11
+ const raw = String(email || "").trim().toLowerCase();
12
+ const at = raw.indexOf("@");
13
+ if (at <= 0) return null;
14
+ const local = raw.slice(0, at);
15
+ const domain = raw.slice(at + 1);
16
+ const parts = domain.split(".").filter(Boolean);
17
+ if (parts.length < 2) return null;
18
+ const suffix = parts.pop();
19
+ const registrable = parts.join(".");
20
+ if (!suffix || !registrable) return null;
21
+ return `${maskPart(local)}@${maskPart(registrable)}.${suffix}`;
22
+ }
23
+
24
+ /**
25
+ * Accept a display hint from a URL/CLI flag. Legacy full-email values are redacted
26
+ * before display or persistence; malformed values are dropped.
27
+ */
28
+ export function normalizeEmailHint(input: string | null | undefined): string | null {
29
+ const raw = String(input || "").trim().toLowerCase();
30
+ if (!raw || raw.length > 160 || /\s/.test(raw) || !raw.includes("@")) return null;
31
+ if (FULL_EMAIL_RE.test(raw) && !raw.includes("...")) return redactEmailForHint(raw);
32
+ return SAFE_HINT_RE.test(raw) ? raw : null;
33
+ }
@@ -15,6 +15,7 @@
15
15
  * injection is a deliberate string replace — robust for first-party documents, not a
16
16
  * sanitizer. Untrusted vendor HTML must be sandboxed/sanitized instead.
17
17
  */
18
+ export { rawHtmlPolicy } from "@tot/public-runtime";
18
19
 
19
20
  /**
20
21
  * Marker a body-only page fragment's chrome wrapper uses to say "the page body
@@ -10,7 +10,11 @@
10
10
  * the source they point a section at — NEVER from forking a component per
11
11
  * merchant. That's what keeps a tenant single-tenant-extractable.
12
12
  */
13
- import type { CatalogProduct } from "@tot/public-runtime";
13
+ import {
14
+ normalizeProductSectionSource,
15
+ type CatalogProduct,
16
+ type ProductSectionSource,
17
+ } from "@tot/public-runtime";
14
18
  import type { ToTClient } from "@/lib/tot/ToTClient";
15
19
  import { isOnSale } from "@/lib/tot/query";
16
20
 
@@ -18,16 +22,7 @@ import { isOnSale } from "@/lib/tot/query";
18
22
  * Where a merchandising section gets its products. Exactly one shape per source.
19
23
  * `handle` placeholders are product/collection handles (unique per tenant).
20
24
  */
21
- export type SectionSource =
22
- | { collection: string } // products in a collection (by handle)
23
- | { tag: string } // products carrying a tag
24
- | { featured: true } // the curated/featured set (getFeatured)
25
- | { newest: true } // newest-first
26
- | { bestSelling: true } // bestseller-tagged
27
- | { onSale: true } // anything with a live compare-at markdown
28
- | { handles: string[] } // an explicit, ordered handle list
29
- | { related: string } // products related to a base product (by handle)
30
- | { recentlyViewed: true }; // client-personalized — resolves empty server-side
25
+ export type SectionSource = ProductSectionSource;
31
26
 
32
27
  export const DEFAULT_SECTION_LIMIT = 12;
33
28
 
@@ -48,51 +43,52 @@ export async function resolveSectionSource(
48
43
  tot: ToTClient,
49
44
  limit: number = DEFAULT_SECTION_LIMIT,
50
45
  ): Promise<CatalogProduct[]> {
51
- if ("collection" in source) {
46
+ const resolved = normalizeProductSectionSource(source);
47
+ if ("collection" in resolved) {
52
48
  const { products } = await tot.listProducts({
53
- collection: source.collection,
49
+ collection: resolved.collection,
54
50
  pageSize: limit,
55
51
  });
56
52
  return products.slice(0, limit);
57
53
  }
58
54
 
59
- if ("featured" in source) {
55
+ if ("featured" in resolved) {
60
56
  return (await tot.getFeatured(limit)).slice(0, limit);
61
57
  }
62
58
 
63
- if ("newest" in source) {
59
+ if ("newest" in resolved) {
64
60
  const { products } = await tot.listProducts({ sort: "newest", pageSize: limit });
65
61
  return products.slice(0, limit);
66
62
  }
67
63
 
68
- if ("handles" in source) {
64
+ if ("handles" in resolved) {
69
65
  const found = await Promise.all(
70
- source.handles.map((h) => tot.getProductByHandle(h)),
66
+ resolved.handles.map((h) => tot.getProductByHandle(h)),
71
67
  );
72
68
  return found
73
69
  .filter((p): p is CatalogProduct => !!p && isActive(p))
74
70
  .slice(0, limit);
75
71
  }
76
72
 
77
- if ("tag" in source) {
73
+ if ("tag" in resolved) {
78
74
  const all = await tot.getAllActiveProducts();
79
- return all.filter((p) => p.tags.includes(source.tag)).slice(0, limit);
75
+ return all.filter((p) => p.tags.includes(resolved.tag)).slice(0, limit);
80
76
  }
81
77
 
82
- if ("onSale" in source) {
78
+ if ("onSale" in resolved) {
83
79
  const all = await tot.getAllActiveProducts();
84
80
  return all.filter(isOnSale).slice(0, limit);
85
81
  }
86
82
 
87
- if ("bestSelling" in source) {
83
+ if ("bestSelling" in resolved) {
88
84
  const all = await tot.getAllActiveProducts();
89
85
  return all
90
86
  .filter((p) => p.tags.some((t) => BESTSELLER_TAGS.has(t)))
91
87
  .slice(0, limit);
92
88
  }
93
89
 
94
- if ("related" in source) {
95
- const base = await tot.getProductByHandle(source.related);
90
+ if ("related" in resolved) {
91
+ const base = await tot.getProductByHandle(resolved.related);
96
92
  if (!base) return [];
97
93
  const baseCollections = new Set(base.collections);
98
94
  const baseTags = new Set(base.tags);
@@ -17,7 +17,7 @@ export const SECURITY_HEADERS: Readonly<Record<string, string>> = {
17
17
  "X-Frame-Options": "SAMEORIGIN",
18
18
  "Referrer-Policy": "strict-origin-when-cross-origin",
19
19
  // Lock powerful features off for the browse storefront. Verification and
20
- // checkout (Powered by FoxyCart) run in their own surfaces that grant only
20
+ // checkout run in their own surfaces that grant only
21
21
  // what they need via per-frame Permissions-Policy.
22
22
  "Permissions-Policy":
23
23
  "camera=(), microphone=(), geolocation=(), browsing-topics=(), interest-cohort=()",
@@ -8,6 +8,7 @@
8
8
  * pilot CHILD space holds the content instances. See DECISIONS.md for the
9
9
  * parent/child + Shared Components plan.
10
10
  */
11
+ import type { ProductSectionSource } from "@tot/public-runtime";
11
12
 
12
13
  export type HomeBlock =
13
14
  | HeroBlock
@@ -52,8 +53,8 @@ export interface FeaturedCollectionsBlock {
52
53
  export interface FeaturedProductsBlock {
53
54
  component: "featured_products";
54
55
  title?: string;
55
- /** "featured" | "newest" | explicit handles. */
56
- source: "featured" | "newest";
56
+ /** Public merchandising source contract. Omit for featured; legacy handles still work. */
57
+ source?: ProductSectionSource;
57
58
  handles?: string[];
58
59
  limit?: number;
59
60
  }
@@ -29,7 +29,7 @@ import {
29
29
  resolveCustomizationEnv,
30
30
  buildCsp,
31
31
  STOREFRONT_CSP_ALLOWLIST,
32
- foxyCspAdditions,
32
+ checkoutCspAdditions,
33
33
  CSP_REPORT_GROUP,
34
34
  CSP_REPORT_PATH,
35
35
  readPublishedArtifact,
@@ -39,7 +39,7 @@ import {
39
39
  capabilitiesArtifactPath,
40
40
  } from "@tot/public-runtime";
41
41
  import type { CapabilityConfig } from "@tot/public-runtime";
42
- import { resolveTenantCommerce } from "@/lib/foxyCommerce";
42
+ import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
43
43
  import { evaluateDemoGate, hostInGateList } from "@/lib/demoGate";
44
44
  import {
45
45
  evaluateLoginGate,
@@ -51,6 +51,7 @@ import {
51
51
  import { gatedHoldingResponse } from "@/lib/auth/gatePage";
52
52
  import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
53
53
  import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
54
+ import { HEALTH_PATH, healthResponse } from "@/lib/health";
54
55
  import { membershipFor } from "@/lib/dashboard/tenantSelection";
55
56
  import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
56
57
 
@@ -60,6 +61,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
60
61
  const host =
61
62
  request.headers.get("host") ?? url.host ?? "localhost";
62
63
 
64
+ // ---- /health — liveness + deploy-identity probe ---------------------------
65
+ // Answered FIRST: before tenant resolution, the access gate, the maintenance
66
+ // kill-switch, and the edge cache — so it reports the running build's commit SHA
67
+ // regardless of Host, an unresolved tenant, or a closed site. See lib/health.
68
+ if (url.pathname === HEALTH_PATH) {
69
+ return healthResponse();
70
+ }
71
+
63
72
  // ---- Site maintenance kill-switch (fail-safe full close) ------------------
64
73
  // MAINTENANCE_MODE=1 takes the ENTIRE site offline (every host + path) with a
65
74
  // static "Coming Soon" holding page, BEFORE tenant resolution, the access
@@ -324,7 +333,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
324
333
  // read these instead of hard-coding tenant identity checks.
325
334
  locals.features = resolveTenantFeatures(tenant);
326
335
  locals.capabilities = capabilities;
327
- // FoxyCart cart/checkout facts (Track A) — only when the tenant has a catalog
336
+ // Cart/checkout facts (Track A) — only when the tenant has a catalog
328
337
  // AND the cartCheckout capability is enabled for THIS environment. Drives the
329
338
  // loader + signed add-to-cart, and the CSP additions below.
330
339
  const commerce =
@@ -356,6 +365,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
356
365
  // the tenant prefix on the client.
357
366
  locals.basePath = basePath;
358
367
 
368
+ // /dev was renamed to /cockpit (2026-07-20). 302-redirect any old /dev link —
369
+ // host-routed (/dev) or path-routed (/<vendor>/dev) — to the new path, preserving
370
+ // the tenant prefix + query. Temporary (302) while the cockpit UX is still evolving.
371
+ if (renderPath === "/dev" || renderPath.startsWith("/dev/")) {
372
+ const rest = renderPath.slice("/dev".length); // "" or "/sub…"
373
+ return Response.redirect(`${url.origin}${basePath}/cockpit${rest}${url.search}`, 302);
374
+ }
375
+
359
376
  // When a /<domain>/ prefix selected the tenant, rewrite to the stripped route
360
377
  // so the existing file-based routes match. next(path) rewrites WITHOUT
361
378
  // re-running middleware, so the locals set above are preserved.
@@ -406,10 +423,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
406
423
  "Reporting-Endpoints",
407
424
  `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"`,
408
425
  );
409
- // FoxyCart origins (loader CDN + the store's cart/checkout host) added to the
426
+ // Checkout origins (loader CDN + the store's cart/checkout host) added to the
410
427
  // strict policy only when this tenant has a store — so add-to-cart, the
411
428
  // sidecart iframe/XHR, and the cross-origin form POST all pass.
412
- const foxyCsp = commerce ? foxyCspAdditions(commerce.origin) : null;
429
+ const checkoutCsp = commerce ? checkoutCspAdditions(commerce.origin) : null;
413
430
  response.headers.set(
414
431
  cspHeader,
415
432
  buildCsp({
@@ -420,21 +437,21 @@ export const onRequest = defineMiddleware(async (context, next) => {
420
437
  scriptSrc: [
421
438
  ...STOREFRONT_CSP_ALLOWLIST.scriptSrc,
422
439
  ...STATIC_SCRIPT_HASHES,
423
- ...(foxyCsp?.scriptSrc ?? []),
440
+ ...(checkoutCsp?.scriptSrc ?? []),
424
441
  ],
425
442
  styleSrc: [
426
443
  ...STOREFRONT_CSP_ALLOWLIST.styleSrc,
427
444
  ...STATIC_STYLE_HASHES,
428
- ...(foxyCsp?.styleSrc ?? []),
445
+ ...(checkoutCsp?.styleSrc ?? []),
429
446
  ],
430
- fontSrc: [...STOREFRONT_CSP_ALLOWLIST.fontSrc, ...(foxyCsp?.fontSrc ?? [])],
447
+ fontSrc: [...STOREFRONT_CSP_ALLOWLIST.fontSrc, ...(checkoutCsp?.fontSrc ?? [])],
431
448
  connectSrc: [
432
449
  ...STOREFRONT_CSP_ALLOWLIST.connectSrc,
433
- ...(foxyCsp?.connectSrc ?? []),
450
+ ...(checkoutCsp?.connectSrc ?? []),
434
451
  ],
435
- imgSrc: [...STOREFRONT_CSP_ALLOWLIST.imgSrc, ...(foxyCsp?.imgSrc ?? [])],
436
- frameSrc: foxyCsp?.frameSrc ?? [],
437
- formAction: foxyCsp?.formAction ?? [],
452
+ imgSrc: [...STOREFRONT_CSP_ALLOWLIST.imgSrc, ...(checkoutCsp?.imgSrc ?? [])],
453
+ frameSrc: checkoutCsp?.frameSrc ?? [],
454
+ formAction: checkoutCsp?.formAction ?? [],
438
455
  reportGroup: CSP_REPORT_GROUP,
439
456
  reportUri: CSP_REPORT_PATH,
440
457
  }),
@@ -9,7 +9,7 @@ import Layout from "@/layouts/Layout.astro";
9
9
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
10
10
  import { breadcrumbLd } from "@/lib/jsonld";
11
11
  import { truncate } from "@/lib/format";
12
- import { rawHtmlResponse } from "@/lib/rawMarketingHtml";
12
+ import { rawHtmlPolicy, rawHtmlResponse } from "@/lib/rawMarketingHtml";
13
13
 
14
14
  const { tenant, canonicalBase, content, basePath } = Astro.locals;
15
15
  const slug = (Astro.params.slug ?? "").replace(/^\/+|\/+$/g, "");
@@ -34,12 +34,11 @@ if (slug === "_style-guide" || slug.startsWith("_style-guide/")) {
34
34
  }
35
35
 
36
36
  // Raw tenant-authored marketing subpage (vendor-injected HTML) — serve the
37
- // self-contained document VERBATIM, bypassing the block Layout, with the request
38
- // CSP nonce stamped onto its first-party inline <style>/<script>. Same path as the
39
- // raw home in index.astro. Middleware still applies canonical host, security
40
- // headers, CSP, and (prod) the edge cache.
37
+ // self-contained document VERBATIM only when rawHtmlPolicy says this tenant may
38
+ // bypass Layout. Regulated commerce tenants fall through to platform-composed
39
+ // pages so compliance UI cannot be removed by raw HTML.
41
40
  const rawHtml = await content.getPageHtml(slug);
42
- if (rawHtml) {
41
+ if (rawHtml && rawHtmlPolicy(tenant).allowed) {
43
42
  // Shared chrome: a body-only fragment gets wrapped in the tenant's chrome.html;
44
43
  // a full document serves verbatim (backward-compatible). See rawMarketingHtml.ts.
45
44
  const chromeWrapper = await content.getChromeHtml();