@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
@@ -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 {
@@ -1,16 +1,23 @@
1
1
  /**
2
- * Per-vendor "local dev runtime" latest-state — the LIVE half of the
2
+ * Per-DEVELOPER "local dev runtime" latest-state — the LIVE half of the
3
3
  * local→hosted activity bridge (G1). Where activityStore.ts holds a ring buffer
4
- * of file-SAVE events, this holds ONE record per vendor: the most recent
5
- * heartbeat the developer's LOCAL `tot dev`/`tot start` sent (see the CLI's
6
- * dev-heartbeat.mjs). The hosted cockpit reads it to show a LIVE, clickable
7
- * local-dev link + the running CLI version.
4
+ * of file-SAVE events, this holds the most recent heartbeat a developer's LOCAL
5
+ * `tot dev`/`tot start` sent (see the CLI's dev-heartbeat.mjs). The hosted cockpit
6
+ * reads it to show a LIVE, clickable local-dev link + the running CLI version.
7
+ *
8
+ * SCOPED PER (tenant, developer email), NOT per tenant: a store can have many
9
+ * team members, each running their OWN local loop. Keying on the tenant alone
10
+ * made every teammate's heartbeat collapse into one latest-wins slot, so the
11
+ * owner saw "connected" whenever ANYONE with access was running `tot dev` —
12
+ * never their own machine specifically. The key is now
13
+ * `dev-runtime:<tenant>:<email>`; the POST writes under the HEARTBEATING dev's
14
+ * verified email, and the GET reads the VIEWER's own — so "connected" means
15
+ * "YOUR loop", not "someone on this store".
8
16
  *
9
17
  * Deliberately SEPARATE from the file-activity buffer: a heartbeat is
10
- * latest-wins state, not an appended event, so it lives under its own
11
- * `dev-runtime:<tenant>` key and never touches the ring buffer. No file CONTENT
12
- * is ever stored — `url` is only the local `http://localhost:<port>/<tenant>/`
13
- * (low-sensitivity, per decision).
18
+ * latest-wins state, not an appended event, so it lives under its own key and
19
+ * never touches the ring buffer. No file CONTENT is ever stored — `url` is only
20
+ * the local `http://localhost:<port>/<tenant>/` (low-sensitivity, per decision).
14
21
  *
15
22
  * Liveness is computed SERVER-side from `aliveAt` (the ingest-time server clock,
16
23
  * NOT the client's `at`) so a skewed local clock can't make a dead loop look
@@ -25,17 +32,62 @@ export interface DevRuntime {
25
32
  runnerVersion?: string;
26
33
  /** The local store URL — `http://localhost:<port>/<tenant>/`. Clickable in the cockpit. */
27
34
  url?: string;
35
+ /**
36
+ * The developer's terminal editor command ($VISUAL/$EDITOR), when set — lets the
37
+ * cockpit suggest the exact "open this file" command (e.g. `code content/home.html`).
38
+ * Low-sensitivity (a tool name, never a secret); absent when no editor is set.
39
+ */
40
+ editor?: string;
41
+ /**
42
+ * The developer's project directory (checkout root), when reported — lets the
43
+ * cockpit show the FULL path to the file to edit so the open command works from
44
+ * any directory. The dev's own local path (low-sensitivity, like `url`).
45
+ */
46
+ cwd?: string;
47
+ /** OS platform the CLI heartbeat reported (e.g. "darwin", "linux", "win32"). */
48
+ os?: string;
49
+ /** OS release/version string the heartbeat reported (e.g. "24.6.0"). */
50
+ osVersion?: string;
51
+ /** CPU architecture the heartbeat reported (e.g. "arm64", "x64"). */
52
+ arch?: string;
53
+ /**
54
+ * The invite→terminal→problem TRACE id — minted once when this developer's
55
+ * sign-in code was issued and carried in the activity-ingest-token claims, so
56
+ * every heartbeat stamps the SAME id here. Support looks a live session up by
57
+ * it (readRuntimeByTrace). Not PII; safe to expose in the cockpit view.
58
+ */
59
+ traceId?: string;
60
+ /**
61
+ * The developer's email, stamped from the ingest-token claims (NOT the request
62
+ * body) — so support can tie this runtime record to a person by traceId. PII:
63
+ * the STORE keeps it (the owner's own data), but it is deliberately stripped
64
+ * from the GET view (toRuntimeView) and must never reach a log/response sink.
65
+ */
66
+ email?: string;
28
67
  /** Epoch ms of the latest heartbeat, stamped by the SERVER at ingest (skew-proof). */
29
68
  aliveAt: number;
30
69
  }
31
70
 
32
- /** The runtime as the GET feed exposes it — the stored record plus a computed `live` flag. */
33
- export interface DevRuntimeView extends DevRuntime {
71
+ /**
72
+ * The runtime as the GET feed exposes it — the stored record plus a computed
73
+ * `live` flag, with `email` (PII) omitted. Support reads the raw record by
74
+ * traceId; the developer's own browser only ever sees the email-free view.
75
+ */
76
+ export interface DevRuntimeView extends Omit<DevRuntime, "email"> {
34
77
  /** True when the last heartbeat landed within LIVE_WINDOW_MS of `now`. */
35
78
  live: boolean;
36
79
  }
37
80
 
38
81
  const KEY_PREFIX = "dev-runtime:";
82
+ /**
83
+ * Secondary index: traceId -> {appDomain, email} (JSON), so support can look a
84
+ * developer's LIVE session up by the trace id minted at sign-in without scanning.
85
+ * Stores BOTH components because the record key is now per (tenant, email).
86
+ * Mirrors the developer+tenant index in activityIngestToken.ts; a KV store has no
87
+ * GSI, so the smallest correct discovery path is a maintained index key (written
88
+ * on every heartbeat, cleared on reset). Same TTL as the record.
89
+ */
90
+ const TRACE_INDEX_PREFIX = "dev-runtime-by-trace:";
39
91
  /**
40
92
  * How recent the last heartbeat must be to read as "live". The CLI beats every
41
93
  * ~10s, so 30s tolerates a couple of missed/slow beats before we call the loop
@@ -52,9 +104,29 @@ const TTL_SECONDS = 3600;
52
104
  /** Cap the free-text fields a bridge-token holder can push into KV. */
53
105
  const MAX_VERSION_LEN = 64;
54
106
  const MAX_URL_LEN = 256;
107
+ /** An editor command may carry flags/a path (e.g. "code --wait") — a touch more room. */
108
+ const MAX_EDITOR_LEN = 128;
109
+ /** A filesystem path to the project root — allow a deep absolute path. */
110
+ const MAX_CWD_LEN = 512;
111
+ /** OS platform / release / arch are short tokens ("darwin", "24.6.0", "arm64"). */
112
+ const MAX_OS_LEN = 64;
113
+ /** RFC-max email length. */
114
+ const MAX_EMAIL_LEN = 320;
115
+ /** A UUID; a touch of headroom for other id shapes. */
116
+ const MAX_TRACE_LEN = 64;
55
117
 
56
- function key(appDomain: string): string {
57
- return KEY_PREFIX + appDomain;
118
+ /**
119
+ * The per-developer record key: `dev-runtime:<tenant>:<email>`. Email is the
120
+ * session principal (lowercased here for a stable key). An absent email degrades
121
+ * to a tenant-only suffix — never silently shared, just an unattributed slot the
122
+ * viewer's own-email read won't match.
123
+ */
124
+ function key(appDomain: string, email: string | undefined): string {
125
+ return `${KEY_PREFIX}${appDomain}:${(email ?? "").toLowerCase()}`;
126
+ }
127
+
128
+ function traceKey(traceId: string): string {
129
+ return TRACE_INDEX_PREFIX + traceId;
58
130
  }
59
131
 
60
132
  function clip(v: unknown, max: number): string | undefined {
@@ -73,29 +145,72 @@ function localUrl(v: unknown): string | undefined {
73
145
  return s && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(s) ? s : undefined;
74
146
  }
75
147
 
148
+ /**
149
+ * Trusted identity for the runtime record, sourced from the VERIFIED ingest-token
150
+ * claims (never the request body): the trace id minted at sign-in and the
151
+ * developer's email. Kept separate from `body` so a token holder can't spoof
152
+ * either by putting it in the heartbeat payload.
153
+ */
154
+ export interface RuntimeIdentity {
155
+ traceId?: string;
156
+ email?: string;
157
+ }
158
+
76
159
  /**
77
160
  * Normalize a raw heartbeat body into a stored DevRuntime — dropping anything
78
161
  * 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.
162
+ * exported so the route's shaping is unit-tested without KV. `identity` carries
163
+ * the trace id + developer email from the ingest-token claims (trusted), stamped
164
+ * onto the record so support can find a live session by trace and tie it to a
165
+ * person; body-supplied os/osVersion/arch are whitelisted like the other fields.
80
166
  */
81
167
  export function shapeRuntime(
82
- body: { cliVersion?: unknown; runnerVersion?: unknown; url?: unknown },
168
+ body: {
169
+ cliVersion?: unknown;
170
+ runnerVersion?: unknown;
171
+ url?: unknown;
172
+ editor?: unknown;
173
+ cwd?: unknown;
174
+ os?: unknown;
175
+ osVersion?: unknown;
176
+ arch?: unknown;
177
+ },
83
178
  aliveAt: number,
179
+ identity: RuntimeIdentity = {},
84
180
  ): DevRuntime {
85
181
  const rt: DevRuntime = { aliveAt };
86
182
  const cliVersion = clip(body.cliVersion, MAX_VERSION_LEN);
87
183
  const runnerVersion = clip(body.runnerVersion, MAX_VERSION_LEN);
88
184
  const url = localUrl(body.url);
185
+ const editor = clip(body.editor, MAX_EDITOR_LEN);
186
+ const cwd = clip(body.cwd, MAX_CWD_LEN);
187
+ const os = clip(body.os, MAX_OS_LEN);
188
+ const osVersion = clip(body.osVersion, MAX_OS_LEN);
189
+ const arch = clip(body.arch, MAX_OS_LEN);
190
+ const traceId = clip(identity.traceId, MAX_TRACE_LEN);
191
+ const email = clip(identity.email, MAX_EMAIL_LEN);
89
192
  if (cliVersion) rt.cliVersion = cliVersion;
90
193
  if (runnerVersion) rt.runnerVersion = runnerVersion;
91
194
  if (url) rt.url = url;
195
+ if (editor) rt.editor = editor;
196
+ if (cwd) rt.cwd = cwd;
197
+ if (os) rt.os = os;
198
+ if (osVersion) rt.osVersion = osVersion;
199
+ if (arch) rt.arch = arch;
200
+ if (traceId) rt.traceId = traceId;
201
+ if (email) rt.email = email;
92
202
  return rt;
93
203
  }
94
204
 
95
- /** Read the vendor's latest runtime, or null when none/malformed. Never throws. */
96
- export async function readRuntime(kv: KVNamespace, appDomain: string): Promise<DevRuntime | null> {
205
+ /** Read a developer's latest runtime (their tenant + email), or null when
206
+ * none/malformed. Never throws. */
207
+ export async function readRuntime(
208
+ kv: KVNamespace,
209
+ appDomain: string,
210
+ email: string | undefined,
211
+ ): Promise<DevRuntime | null> {
97
212
  try {
98
- const raw = await kv.get(key(appDomain));
213
+ const raw = await kv.get(key(appDomain, email));
99
214
  if (!raw) return null;
100
215
  const parsed = JSON.parse(raw);
101
216
  if (!parsed || typeof parsed !== "object" || typeof parsed.aliveAt !== "number") return null;
@@ -105,16 +220,72 @@ export async function readRuntime(kv: KVNamespace, appDomain: string): Promise<D
105
220
  }
106
221
  }
107
222
 
108
- /** Overwrite the vendor's runtime with the latest heartbeat (latest-wins, TTL'd). */
223
+ /** Overwrite a developer's runtime with their latest heartbeat (latest-wins, TTL'd),
224
+ * scoped to (tenant, email). When the record carries a traceId, also (re)write the
225
+ * by-trace index (storing BOTH tenant + email) so support can resolve
226
+ * trace -> record without a scan. */
109
227
  export async function writeRuntime(
110
228
  kv: KVNamespace,
111
229
  appDomain: string,
230
+ email: string | undefined,
112
231
  rt: DevRuntime,
113
232
  ): Promise<void> {
114
- await kv.put(key(appDomain), JSON.stringify(rt), { expirationTtl: TTL_SECONDS });
233
+ await kv.put(key(appDomain, email), JSON.stringify(rt), { expirationTtl: TTL_SECONDS });
234
+ if (rt.traceId) {
235
+ await kv.put(traceKey(rt.traceId), JSON.stringify({ appDomain, email: email ?? "" }), {
236
+ expirationTtl: TTL_SECONDS,
237
+ });
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Resolve a developer's live runtime by the trace id minted at sign-in — the
243
+ * support lookup path. Reads the by-trace index to find the tenant key, then the
244
+ * record. Returns the RAW record (email included) — a support/internal read, not
245
+ * the developer-facing GET view. Null when the trace is unknown/expired. Never
246
+ * throws.
247
+ */
248
+ export async function readRuntimeByTrace(
249
+ kv: KVNamespace,
250
+ traceId: string,
251
+ ): Promise<DevRuntime | null> {
252
+ if (!traceId) return null;
253
+ const ptr = await kv.get(traceKey(traceId));
254
+ if (!ptr) return null;
255
+ // Pointer is JSON {appDomain, email}. A bare-string pointer is a legacy
256
+ // (pre per-developer) entry with no email to scope by — treat as unresolvable
257
+ // (it re-establishes on the next heartbeat, well within the 1h TTL).
258
+ let appDomain: string | undefined;
259
+ let email: string | undefined;
260
+ try {
261
+ const parsed = JSON.parse(ptr);
262
+ if (parsed && typeof parsed === "object") {
263
+ appDomain = parsed.appDomain;
264
+ email = parsed.email;
265
+ }
266
+ } catch {
267
+ return null;
268
+ }
269
+ if (!appDomain) return null;
270
+ return readRuntime(kv, appDomain, email);
271
+ }
272
+
273
+ /** Delete a developer's runtime record (their tenant + email) — a "true reset" so a
274
+ * killed loop stops reading as up immediately instead of lingering until the
275
+ * TTL/live-window expires. Also drops the by-trace index entry for its current trace. */
276
+ export async function clearRuntime(
277
+ kv: KVNamespace,
278
+ appDomain: string,
279
+ email: string | undefined,
280
+ ): Promise<void> {
281
+ const existing = await readRuntime(kv, appDomain, email);
282
+ await kv.delete(key(appDomain, email));
283
+ if (existing?.traceId) await kv.delete(traceKey(existing.traceId));
115
284
  }
116
285
 
117
- /** Attach the computed `live` flag (aliveAt within LIVE_WINDOW_MS of `now`). Pure. */
286
+ /** Attach the computed `live` flag (aliveAt within LIVE_WINDOW_MS of `now`) and
287
+ * STRIP `email` (PII) — the developer-facing view never carries it. Pure. */
118
288
  export function toRuntimeView(rt: DevRuntime, now: number = Date.now()): DevRuntimeView {
119
- return { ...rt, live: now - rt.aliveAt <= LIVE_WINDOW_MS };
289
+ const { email: _email, ...safe } = rt;
290
+ return { ...safe, live: now - rt.aliveAt <= LIVE_WINDOW_MS };
120
291
  }
@@ -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
  }