@tokenoftrust/storefront-runner 2.4.79 → 2.4.80
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/env.d.ts
CHANGED
|
@@ -152,6 +152,14 @@ declare global {
|
|
|
152
152
|
* the pinned render.
|
|
153
153
|
*/
|
|
154
154
|
apiBasePath: string;
|
|
155
|
+
/**
|
|
156
|
+
* True when this request was admitted to a store that is NOT public on this
|
|
157
|
+
* host (a login-gated host, tenant without `access.public`) — by session,
|
|
158
|
+
* preview grant, or QA bypass. Visitor routes read it to avoid recording
|
|
159
|
+
* side effects, such as a newsletter signup, for an audience that cannot
|
|
160
|
+
* exist yet. Unset on every public request.
|
|
161
|
+
*/
|
|
162
|
+
storeNotPublic?: boolean;
|
|
155
163
|
/**
|
|
156
164
|
* Which customization environment to serve. "live" on production
|
|
157
165
|
* hosts; "test" only behind a valid, tenant-bound preview token on a
|
|
@@ -68,6 +68,14 @@ export function gatedHoldingHtml(loginHref: string): string {
|
|
|
68
68
|
</html>`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Names the gate's decision on every response it answers in place of the route, so a
|
|
73
|
+
* caller (or a curl) can tell "withheld by the store-not-public gate" from anything
|
|
74
|
+
* the route itself said without reading the body.
|
|
75
|
+
*/
|
|
76
|
+
export const GATE_HEADER = "x-tot-gate";
|
|
77
|
+
export const GATE_WITHHELD = "store-not-public";
|
|
78
|
+
|
|
71
79
|
/** A `200 OK` holding page — never indexed, never cached. */
|
|
72
80
|
export function gatedHoldingResponse(loginHref: string): Response {
|
|
73
81
|
return new Response(gatedHoldingHtml(loginHref), {
|
|
@@ -76,6 +84,39 @@ export function gatedHoldingResponse(loginHref: string): Response {
|
|
|
76
84
|
"content-type": "text/html; charset=utf-8",
|
|
77
85
|
"cache-control": "no-store",
|
|
78
86
|
"x-robots-tag": "noindex, nofollow",
|
|
87
|
+
[GATE_HEADER]: GATE_WITHHELD,
|
|
79
88
|
},
|
|
80
89
|
});
|
|
81
90
|
}
|
|
91
|
+
|
|
92
|
+
/** A browser form submission or link, as opposed to a script's fetch. */
|
|
93
|
+
function isNavigation(request: Request): boolean {
|
|
94
|
+
const mode = request.headers.get("sec-fetch-mode");
|
|
95
|
+
if (mode) return mode === "navigate";
|
|
96
|
+
const accept = request.headers.get("accept") ?? "";
|
|
97
|
+
return accept.includes("text/html") && !accept.includes("application/json");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The gate's answer to a withheld `/api/` request: `401`, never a `200` page a
|
|
102
|
+
* script would read as the route's own success. A browser form submission still gets
|
|
103
|
+
* the holding page with its Sign-in button, the same answer the store's pages give;
|
|
104
|
+
* a fetch gets JSON naming the reason and where to sign in.
|
|
105
|
+
*/
|
|
106
|
+
export function gatedApiResponse(loginHref: string, request: Request): Response {
|
|
107
|
+
const headers = {
|
|
108
|
+
"cache-control": "no-store",
|
|
109
|
+
"x-robots-tag": "noindex, nofollow",
|
|
110
|
+
[GATE_HEADER]: GATE_WITHHELD,
|
|
111
|
+
};
|
|
112
|
+
if (isNavigation(request)) {
|
|
113
|
+
return new Response(gatedHoldingHtml(loginHref), {
|
|
114
|
+
status: 401,
|
|
115
|
+
headers: { ...headers, "content-type": "text/html; charset=utf-8" },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return new Response(
|
|
119
|
+
JSON.stringify({ ok: false, error: GATE_WITHHELD, signIn: loginHref }),
|
|
120
|
+
{ status: 401, headers: { ...headers, "content-type": "application/json; charset=utf-8" } },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -273,6 +273,36 @@ export function isBypassPrivilegedPath(pathname: string): boolean {
|
|
|
273
273
|
return false;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The `/api/` routes a signed, tenant-bound preview grant admits: visitor forms on a
|
|
278
|
+
* tenant's own pages whose route has NO effect on a store that is not public. A
|
|
279
|
+
* reviewer on a copy-preview link submits them and gets the route's own answer
|
|
280
|
+
* instead of the holding page; the route reads `locals.storeNotPublic` and records
|
|
281
|
+
* nothing (the footer/homepage newsletter form confirms "off in preview").
|
|
282
|
+
*
|
|
283
|
+
* Membership is earned by that property, not by being visitor-facing. The checkout
|
|
284
|
+
* handoff and the compliance preflight stay refused: the preflight is the checkout
|
|
285
|
+
* enforcement gate and returns a real decision, and no preview credential may reach
|
|
286
|
+
* it. Admin, publish, change, and dashboard APIs still require a session, and beacons
|
|
287
|
+
* (RUM, tracking, CSP/tamper reports, blog reactions) are not admitted so preview
|
|
288
|
+
* traffic never lands in a store's live measurement.
|
|
289
|
+
*
|
|
290
|
+
* Exact paths, in BOTH addressing forms (the gate sees the PRE-STRIP path).
|
|
291
|
+
*/
|
|
292
|
+
export const PREVIEW_VISITOR_API_PATHS = ["/api/newsletter"] as const;
|
|
293
|
+
|
|
294
|
+
export function isPreviewVisitorApiPath(pathname: string): boolean {
|
|
295
|
+
if ((PREVIEW_VISITOR_API_PATHS as readonly string[]).includes(pathname)) return true;
|
|
296
|
+
const prefixed = /^\/[^/]+(\/api\/.*)$/.exec(pathname);
|
|
297
|
+
return !!prefixed && (PREVIEW_VISITOR_API_PATHS as readonly string[]).includes(prefixed[1]!);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Any `/api/` route, bare or tenant-prefixed. */
|
|
301
|
+
export function isApiPath(pathname: string): boolean {
|
|
302
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) return true;
|
|
303
|
+
return /^\/[^/]+\/api(?:\/|$)/.test(pathname);
|
|
304
|
+
}
|
|
305
|
+
|
|
276
306
|
/**
|
|
277
307
|
* Control surfaces a withheld visitor is sent straight to sign-in for, instead of the
|
|
278
308
|
* "Coming soon" holding page: admin and the cockpit (with its `/dev` alias), in both the
|
|
@@ -327,8 +357,9 @@ export interface LoginGateInput {
|
|
|
327
357
|
*
|
|
328
358
|
* Available to EVERY tenant: previewing an unpublished storefront is platform
|
|
329
359
|
* functionality, not a property of which tenant is being viewed. Passes the gate
|
|
330
|
-
* for PUBLIC CONTENT paths
|
|
331
|
-
* cockpit, /api) still
|
|
360
|
+
* for PUBLIC CONTENT paths and {@link isPreviewVisitorApiPath} routes only; every
|
|
361
|
+
* other {@link isBypassPrivilegedPath} path (admin, cockpit, /api) still requires a
|
|
362
|
+
* real session.
|
|
332
363
|
*/
|
|
333
364
|
previewTokenValid?: boolean;
|
|
334
365
|
/** A verified, tenant-bound candidate content pin admits public content only. */
|
|
@@ -357,11 +388,12 @@ export function evaluateLoginGate(input: LoginGateInput): LoginGateDecision {
|
|
|
357
388
|
// public content only. NEVER for admin/cockpit/api — that guard lives here (not
|
|
358
389
|
// just in middleware) so /admin stays gated even if the flag is set upstream.
|
|
359
390
|
if (input.qaBypass && !isBypassPrivilegedPath(input.pathname)) return { action: "pass" };
|
|
360
|
-
// Signed, tenant-bound preview grants admit public content
|
|
361
|
-
// cockpit, and API
|
|
391
|
+
// Signed, tenant-bound preview grants admit public content and the side-effect-free
|
|
392
|
+
// visitor forms on it. Admin, cockpit, and every other API route still require a
|
|
393
|
+
// session.
|
|
362
394
|
if (
|
|
363
395
|
(input.previewTokenValid || input.candidatePinValid) &&
|
|
364
|
-
!isBypassPrivilegedPath(input.pathname)
|
|
396
|
+
(!isBypassPrivilegedPath(input.pathname) || isPreviewVisitorApiPath(input.pathname))
|
|
365
397
|
) {
|
|
366
398
|
return { action: "pass" };
|
|
367
399
|
}
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
} from "@tot/public-runtime";
|
|
24
24
|
import {
|
|
25
25
|
evaluateLoginGate,
|
|
26
|
+
isApiPath,
|
|
26
27
|
isAuthPath,
|
|
27
28
|
isAdminPath,
|
|
28
29
|
isSignInRedirectPath,
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
} from "@/lib/auth/loginGate";
|
|
33
34
|
import { resolveAdminLanding, isAdminSelectPath } from "@/lib/auth/adminLanding";
|
|
34
35
|
import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
|
|
35
|
-
import { gatedHoldingResponse } from "@/lib/auth/gatePage";
|
|
36
|
+
import { gatedApiResponse, gatedHoldingResponse } from "@/lib/auth/gatePage";
|
|
36
37
|
import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
|
|
37
38
|
import { runRenderChain } from "./renderChain.js";
|
|
38
39
|
import { resolveCandidateContentPin } from "./candidatePin.js";
|
|
@@ -191,9 +192,15 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
|
|
|
191
192
|
if (isSignInRedirectPath(url.pathname)) {
|
|
192
193
|
return context.redirect(decision.location, 303);
|
|
193
194
|
}
|
|
195
|
+
// An API caller gets an honest 401, never a 200 page it would read as success.
|
|
196
|
+
if (isApiPath(url.pathname)) return gatedApiResponse(decision.location, request);
|
|
194
197
|
// Withhold content but offer sign-in (carrying the intended destination).
|
|
195
198
|
return gatedHoldingResponse(decision.location);
|
|
196
199
|
}
|
|
200
|
+
// Admitted to a store that is not public on this host: by session, preview
|
|
201
|
+
// grant, or QA bypass. Visitor routes read this to stay side-effect-free for an
|
|
202
|
+
// audience that does not exist yet (see pages/api/newsletter.ts).
|
|
203
|
+
if (!tenantExempt) locals.storeNotPublic = true;
|
|
197
204
|
if (admitted && viewerRecord) {
|
|
198
205
|
// Surface the viewer's standing FOR THIS host's tenant as chrome, BOUND to
|
|
199
206
|
// the host: a member sees their tenants[] role; the owner of their own tenant
|
|
@@ -1,29 +1,83 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Newsletter signup endpoint.
|
|
2
|
+
* Newsletter signup endpoint — the footer and homepage capture forms post here.
|
|
3
3
|
*
|
|
4
|
-
* ASSUMPTION (Phase 1): there is no ESP integration yet. We persist the
|
|
4
|
+
* ASSUMPTION (Phase 1): there is no ESP integration yet. We persist the signup to
|
|
5
5
|
* the TENANT_CACHE KV namespace when the Cloudflare runtime binding is present
|
|
6
|
-
* (key `newsletter:<email>`)
|
|
7
|
-
*
|
|
6
|
+
* (key `newsletter:<tenant_id>:<email>`). Wiring a real ESP (Klaviyo/Mailchimp) is a
|
|
7
|
+
* documented Phase 2 swap at the marked spot below.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* A store that is not public yet (`locals.storeNotPublic` — a preview, a staged
|
|
10
|
+
* release, an owner browsing before cutover) has no audience, so a submission there
|
|
11
|
+
* records nothing and says so.
|
|
12
|
+
*
|
|
13
|
+
* Every outcome is a confirmation page with a link back to where the visitor was;
|
|
14
|
+
* a script that asks for JSON gets `{ ok, status }`. Robustness: a signup must NEVER
|
|
15
|
+
* 500. All failures degrade to a friendly thank-you.
|
|
11
16
|
*/
|
|
12
17
|
import type { APIContext } from "astro";
|
|
13
18
|
import { readKv } from "@/lib/env";
|
|
19
|
+
import { withBase } from "@/lib/basePath";
|
|
14
20
|
|
|
15
21
|
// Minimal, permissive email shape check — we are not the source of truth for
|
|
16
22
|
// deliverability; the ESP validates properly downstream.
|
|
17
23
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
export type NewsletterStatus = "subscribed" | "preview" | "invalid";
|
|
26
|
+
|
|
27
|
+
const COPY: Record<NewsletterStatus, { heading: string; message: string }> = {
|
|
28
|
+
subscribed: {
|
|
29
|
+
heading: "You're on the list.",
|
|
30
|
+
message: "We'll send word when something worth knowing lands.",
|
|
31
|
+
},
|
|
32
|
+
preview: {
|
|
33
|
+
heading: "Newsletter is off in preview.",
|
|
34
|
+
message:
|
|
35
|
+
"This store isn't public yet, so no subscription was recorded. The form works the same way once the store is live.",
|
|
36
|
+
},
|
|
37
|
+
invalid: {
|
|
38
|
+
heading: "That didn't go through.",
|
|
39
|
+
message: "That address didn't look quite right — head back and try again.",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function escapeHtml(value: string): string {
|
|
44
|
+
return value.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where "back" goes: the submitting page when it is on this origin, else the store root. */
|
|
48
|
+
function backHref(context: APIContext): string {
|
|
49
|
+
const home = withBase(context.locals.basePath ?? "", "/");
|
|
50
|
+
const referer = context.request.headers.get("referer");
|
|
51
|
+
if (!referer) return home;
|
|
52
|
+
try {
|
|
53
|
+
const back = new URL(referer);
|
|
54
|
+
return back.origin === new URL(context.request.url).origin
|
|
55
|
+
? `${back.pathname}${back.search}${back.hash}`
|
|
56
|
+
: home;
|
|
57
|
+
} catch {
|
|
58
|
+
return home;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function wantsJson(request: Request): boolean {
|
|
63
|
+
return (request.headers.get("accept") ?? "").includes("application/json");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function respond(context: APIContext, status: NewsletterStatus): Response {
|
|
67
|
+
if (wantsJson(context.request)) {
|
|
68
|
+
return new Response(JSON.stringify({ ok: status !== "invalid", status }), {
|
|
69
|
+
status: 200,
|
|
70
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const { heading, message } = COPY[status];
|
|
20
74
|
const html = `<!doctype html>
|
|
21
75
|
<html lang="en">
|
|
22
76
|
<head>
|
|
23
77
|
<meta charset="utf-8" />
|
|
24
78
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
25
79
|
<meta name="robots" content="noindex" />
|
|
26
|
-
<title
|
|
80
|
+
<title>${escapeHtml(heading)}</title>
|
|
27
81
|
<style>
|
|
28
82
|
:root { color-scheme: light dark; }
|
|
29
83
|
body { font-family: ui-serif, Georgia, serif; margin: 0; min-height: 100vh;
|
|
@@ -36,16 +90,16 @@ function thankYou(message: string, status = 200): Response {
|
|
|
36
90
|
</style>
|
|
37
91
|
</head>
|
|
38
92
|
<body>
|
|
39
|
-
<div class="card">
|
|
40
|
-
<h1
|
|
41
|
-
<p>${message}</p>
|
|
42
|
-
<p><a href="
|
|
93
|
+
<div class="card" data-newsletter-status="${status}">
|
|
94
|
+
<h1>${escapeHtml(heading)}</h1>
|
|
95
|
+
<p>${escapeHtml(message)}</p>
|
|
96
|
+
<p><a href="${escapeHtml(backHref(context))}">Back to the shop →</a></p>
|
|
43
97
|
</div>
|
|
44
98
|
</body>
|
|
45
99
|
</html>`;
|
|
46
100
|
return new Response(html, {
|
|
47
|
-
status,
|
|
48
|
-
headers: { "content-type": "text/html; charset=utf-8" },
|
|
101
|
+
status: 200,
|
|
102
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
49
103
|
});
|
|
50
104
|
}
|
|
51
105
|
|
|
@@ -59,46 +113,32 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
59
113
|
const email = String(form.get("email") ?? "").trim().toLowerCase();
|
|
60
114
|
const source = String(form.get("source") ?? "unknown").trim();
|
|
61
115
|
|
|
62
|
-
if (!EMAIL_RE.test(email))
|
|
63
|
-
|
|
64
|
-
"That address didn't look quite right — head back and try again.",
|
|
65
|
-
200,
|
|
66
|
-
);
|
|
67
|
-
}
|
|
116
|
+
if (!EMAIL_RE.test(email)) return respond(context, "invalid");
|
|
117
|
+
if (context.locals.storeNotPublic) return respond(context, "preview");
|
|
68
118
|
|
|
69
119
|
// Persist. Phase 1: KV stub. Phase 2: replace with an ESP API call here.
|
|
120
|
+
const tenantId = context.locals.tenant.tenant_id;
|
|
70
121
|
const kv = await readKv("TENANT_CACHE");
|
|
71
122
|
if (kv) {
|
|
72
123
|
await kv.put(
|
|
73
|
-
`newsletter:${email}`,
|
|
124
|
+
`newsletter:${tenantId}:${email}`,
|
|
74
125
|
JSON.stringify({ email, source, at: new Date().toISOString() }),
|
|
75
126
|
);
|
|
76
127
|
} else {
|
|
77
|
-
// No KV binding (local/dev):
|
|
78
|
-
console.log(`[newsletter] signup
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Redirect back where they came from (or home) with a success flag, so the
|
|
82
|
-
// page can show its own confirmation in-context. Falls back to the
|
|
83
|
-
// thank-you page if there's no usable referer.
|
|
84
|
-
const referer = context.request.headers.get("referer");
|
|
85
|
-
if (referer) {
|
|
86
|
-
try {
|
|
87
|
-
const back = new URL(referer);
|
|
88
|
-
back.searchParams.set("subscribed", "1");
|
|
89
|
-
return Response.redirect(back.toString(), 303);
|
|
90
|
-
} catch {
|
|
91
|
-
// fall through to thank-you
|
|
92
|
-
}
|
|
128
|
+
// No KV binding (local/dev): nothing to persist to.
|
|
129
|
+
console.log(`[newsletter] signup not persisted (no TENANT_CACHE) tenant=${tenantId} source=${source}`);
|
|
93
130
|
}
|
|
94
|
-
return
|
|
95
|
-
} catch {
|
|
96
|
-
// Never 500 on a signup.
|
|
97
|
-
|
|
131
|
+
return respond(context, "subscribed");
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Never 500 on a signup, but never swallow the failure silently either.
|
|
134
|
+
console.error(
|
|
135
|
+
`[newsletter] signup failed tenant=${context.locals.tenant?.tenant_id ?? "?"}: ${err instanceof Error ? err.name : "unknown"}`,
|
|
136
|
+
);
|
|
137
|
+
return respond(context, "subscribed");
|
|
98
138
|
}
|
|
99
139
|
}
|
|
100
140
|
|
|
101
141
|
export async function GET(context: APIContext): Promise<Response> {
|
|
102
142
|
// Nothing to render here directly — bounce back to the shop.
|
|
103
|
-
return context.redirect("/", 303);
|
|
143
|
+
return context.redirect(withBase(context.locals.basePath ?? "", "/"), 303);
|
|
104
144
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.80",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|