@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.
- package/apps/storefront/astro.config.mjs +50 -0
- package/apps/storefront/env.d.ts +24 -8
- package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
- package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
- package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
- package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
- package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
- package/apps/storefront/src/config/devTenantSeed.ts +65 -16
- package/apps/storefront/src/config/resolver.ts +48 -1
- package/apps/storefront/src/layouts/Layout.astro +14 -12
- package/apps/storefront/src/lib/admin/README.md +2 -2
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
- package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
- package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
- package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
- package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
- package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
- package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
- package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
- package/apps/storefront/src/lib/dev/runtimeStore.ts +136 -7
- package/apps/storefront/src/lib/edgeCache.ts +7 -6
- package/apps/storefront/src/lib/health.ts +53 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
- package/apps/storefront/src/lib/sections.ts +19 -23
- package/apps/storefront/src/lib/securityHeaders.ts +1 -1
- package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
- package/apps/storefront/src/middleware/index.ts +29 -12
- package/apps/storefront/src/pages/[...slug].astro +5 -6
- package/apps/storefront/src/pages/admin.astro +10 -3
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
- package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
- package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
- package/apps/storefront/src/pages/auth/login.astro +4 -1
- package/apps/storefront/src/pages/auth/magic.astro +263 -43
- package/apps/storefront/src/pages/capabilities.astro +1 -1
- package/apps/storefront/src/pages/cockpit.astro +2365 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
- package/apps/storefront/src/pages/index.astro +23 -29
- package/apps/storefront/src/pages/products/[handle].astro +50 -20
- package/package.json +1 -1
- package/packages/public-runtime/src/capabilities.ts +97 -9
- package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
- package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
- package/packages/public-runtime/src/checkout-style.ts +13 -13
- package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
- package/packages/public-runtime/src/csp.ts +11 -11
- package/packages/public-runtime/src/customization-scripts.ts +28 -0
- package/packages/public-runtime/src/extension-contract.ts +73 -0
- package/packages/public-runtime/src/index.ts +4 -2
- package/packages/public-runtime/src/product.ts +57 -0
- package/packages/public-runtime/src/raw-html-policy.ts +35 -0
- package/packages/public-runtime/src/tenant.ts +14 -11
- package/scripts/dev/checkout-watch.mjs +28 -0
- package/scripts/dev/file-browser.mjs +5 -1
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
- package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
- package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
- package/apps/storefront/src/pages/api/dev/file.ts +0 -57
- package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
- package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
- package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
- 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
|
|
144
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
33
|
-
|
|
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: {
|
|
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`)
|
|
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
|
-
|
|
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
|
|
46
|
-
// signed-in developer's own store, email, and gated demo), so like
|
|
47
|
-
// it must never be shared across viewers. It's also the loudest failure
|
|
48
|
-
// stale: its styles build to a CONTENT-HASHED asset (/_astro/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
46
|
+
const resolved = normalizeProductSectionSource(source);
|
|
47
|
+
if ("collection" in resolved) {
|
|
52
48
|
const { products } = await tot.listProducts({
|
|
53
|
-
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
|
|
55
|
+
if ("featured" in resolved) {
|
|
60
56
|
return (await tot.getFeatured(limit)).slice(0, limit);
|
|
61
57
|
}
|
|
62
58
|
|
|
63
|
-
if ("newest" in
|
|
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
|
|
64
|
+
if ("handles" in resolved) {
|
|
69
65
|
const found = await Promise.all(
|
|
70
|
-
|
|
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
|
|
73
|
+
if ("tag" in resolved) {
|
|
78
74
|
const all = await tot.getAllActiveProducts();
|
|
79
|
-
return all.filter((p) => p.tags.includes(
|
|
75
|
+
return all.filter((p) => p.tags.includes(resolved.tag)).slice(0, limit);
|
|
80
76
|
}
|
|
81
77
|
|
|
82
|
-
if ("onSale" in
|
|
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
|
|
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
|
|
95
|
-
const base = await tot.getProductByHandle(
|
|
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
|
|
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
|
-
/**
|
|
56
|
-
source
|
|
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
|
-
|
|
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/
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
...(
|
|
440
|
+
...(checkoutCsp?.scriptSrc ?? []),
|
|
424
441
|
],
|
|
425
442
|
styleSrc: [
|
|
426
443
|
...STOREFRONT_CSP_ALLOWLIST.styleSrc,
|
|
427
444
|
...STATIC_STYLE_HASHES,
|
|
428
|
-
...(
|
|
445
|
+
...(checkoutCsp?.styleSrc ?? []),
|
|
429
446
|
],
|
|
430
|
-
fontSrc: [...STOREFRONT_CSP_ALLOWLIST.fontSrc, ...(
|
|
447
|
+
fontSrc: [...STOREFRONT_CSP_ALLOWLIST.fontSrc, ...(checkoutCsp?.fontSrc ?? [])],
|
|
431
448
|
connectSrc: [
|
|
432
449
|
...STOREFRONT_CSP_ALLOWLIST.connectSrc,
|
|
433
|
-
...(
|
|
450
|
+
...(checkoutCsp?.connectSrc ?? []),
|
|
434
451
|
],
|
|
435
|
-
imgSrc: [...STOREFRONT_CSP_ALLOWLIST.imgSrc, ...(
|
|
436
|
-
frameSrc:
|
|
437
|
-
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
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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();
|