@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.
- package/apps/storefront/astro.config.mjs +59 -0
- package/apps/storefront/env.d.ts +24 -8
- package/apps/storefront/package.json +1 -1
- 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 +193 -22
- 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 +6 -2
- 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/pnpm-workspace.yaml +1 -0
- package/scripts/dev/checkout-watch.mjs +9 -0
- package/scripts/dev/file-browser.mjs +5 -1
- package/scripts/dev/transient-files.mjs +34 -0
- package/scripts/tot-dev.mjs +16 -3
- 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 {
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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: {
|
|
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
|
|
96
|
-
|
|
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
|
|
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`)
|
|
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
|
-
|
|
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
|
|
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
|
}
|