@tokenoftrust/storefront-runner 4.7.1 → 4.7.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/env.d.ts +6 -0
- package/apps/storefront/migrations-apps/0020_contact_messages.sql +18 -0
- package/apps/storefront/src/lib/access/admin/adminShell.ts +1 -0
- package/apps/storefront/src/lib/access/rate/kvWindow.ts +35 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +154 -0
- package/apps/storefront/src/lib/engagement/contact/contract.ts +113 -0
- package/apps/storefront/src/lib/engagement/contact/messagesTabClient.ts +145 -0
- package/apps/storefront/src/lib/engagement/contact/submission.ts +112 -0
- package/apps/storefront/src/lib/engagement/contact/submitToken.ts +83 -0
- package/apps/storefront/src/lib/publication/d1/schema-apps.ts +30 -0
- package/apps/storefront/src/lib/publication/shared/cdnForward.ts +29 -6
- package/apps/storefront/src/middleware/index.ts +1 -0
- package/apps/storefront/src/pages/api/contact/token.ts +29 -0
- package/apps/storefront/src/pages/api/contact.ts +134 -0
- package/package.json +1 -1
package/apps/storefront/env.d.ts
CHANGED
|
@@ -159,6 +159,12 @@ declare global {
|
|
|
159
159
|
* the pinned render.
|
|
160
160
|
*/
|
|
161
161
|
apiBasePath: string;
|
|
162
|
+
/**
|
|
163
|
+
* The origin the viewer addressed: `https://<viewer host>` for an
|
|
164
|
+
* authenticated CDN forward (ADR 0013 Tier 3), else the request URL's own
|
|
165
|
+
* origin. Same-origin checks compare `Origin` against THIS.
|
|
166
|
+
*/
|
|
167
|
+
requestOrigin?: string;
|
|
162
168
|
/**
|
|
163
169
|
* True when this request was admitted to a store that is NOT public on this
|
|
164
170
|
* host (a login-gated host, tenant without `access.public`) — by session,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Visitor contact-form messages, tenant-keyed, retained 365 days.
|
|
2
|
+
CREATE TABLE contact_messages (
|
|
3
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
4
|
+
tenant_id TEXT NOT NULL,
|
|
5
|
+
name TEXT NOT NULL DEFAULT '',
|
|
6
|
+
email TEXT NOT NULL,
|
|
7
|
+
phone TEXT NOT NULL DEFAULT '',
|
|
8
|
+
message TEXT NOT NULL,
|
|
9
|
+
page_path TEXT,
|
|
10
|
+
status TEXT NOT NULL DEFAULT 'unread' CHECK(status IN ('unread','read')),
|
|
11
|
+
created_at TEXT NOT NULL,
|
|
12
|
+
read_at TEXT,
|
|
13
|
+
notify_status TEXT NOT NULL DEFAULT 'pending' CHECK(notify_status IN ('pending','sent','failed')),
|
|
14
|
+
notify_attempts INTEGER NOT NULL DEFAULT 0
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX idx_contact_messages_tenant_created ON contact_messages(tenant_id,created_at);
|
|
17
|
+
CREATE INDEX idx_contact_messages_tenant_status ON contact_messages(tenant_id,status);
|
|
18
|
+
CREATE INDEX idx_contact_messages_created ON contact_messages(created_at);
|
|
@@ -23,6 +23,7 @@ export const ADMIN_SHELL_CONFIG = {
|
|
|
23
23
|
{ id: "reporting", label: "Reporting", icon: "i-chart" },
|
|
24
24
|
{ id: "ai-workflows", label: "AI Workflows", icon: "i-workflows" },
|
|
25
25
|
{ id: "blog", label: "Blog", icon: "i-file" },
|
|
26
|
+
{ id: "messages", label: "Messages", icon: "i-user" },
|
|
26
27
|
{ id: "publish", label: "Publish", icon: "i-export" },
|
|
27
28
|
{ id: "integrations", label: "Integrations", icon: "i-workflows" },
|
|
28
29
|
{ id: "settings", label: "Settings", icon: "i-gear" },
|
|
@@ -51,3 +51,38 @@ export async function checkRateLimit(
|
|
|
51
51
|
return { allowed: true };
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
export type ClosedRateLimitVerdict =
|
|
56
|
+
| { allowed: true }
|
|
57
|
+
| { allowed: false; reason: "limited" | "unavailable" };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The same fixed window, FAIL-CLOSED: no KV binding, or a KV read/write error,
|
|
61
|
+
* refuses with `reason: "unavailable"` instead of admitting. For public writes
|
|
62
|
+
* whose abuse is not low-stakes (a stored visitor message), where an unthrottled
|
|
63
|
+
* window is worse than a refused request.
|
|
64
|
+
*/
|
|
65
|
+
export async function checkRateLimitClosed(
|
|
66
|
+
kv: KVNamespace | undefined,
|
|
67
|
+
key: string,
|
|
68
|
+
opts: RateLimitOptions,
|
|
69
|
+
nowMs: number = Date.now(),
|
|
70
|
+
): Promise<ClosedRateLimitVerdict> {
|
|
71
|
+
if (!kv) return { allowed: false, reason: "unavailable" };
|
|
72
|
+
let count: number;
|
|
73
|
+
const bucketKey = `${key}:${windowBucket(opts.windowSeconds, nowMs)}`;
|
|
74
|
+
try {
|
|
75
|
+
const raw = await kv.get(bucketKey);
|
|
76
|
+
const current = raw == null ? 0 : Number.parseInt(raw, 10);
|
|
77
|
+
count = Number.isFinite(current) && current >= 0 ? current : 0;
|
|
78
|
+
} catch {
|
|
79
|
+
return { allowed: false, reason: "unavailable" };
|
|
80
|
+
}
|
|
81
|
+
if (count >= opts.maxRequests) return { allowed: false, reason: "limited" };
|
|
82
|
+
try {
|
|
83
|
+
await kv.put(bucketKey, String(count + 1), { expirationTtl: opts.windowSeconds });
|
|
84
|
+
} catch {
|
|
85
|
+
return { allowed: false, reason: "unavailable" };
|
|
86
|
+
}
|
|
87
|
+
return { allowed: true };
|
|
88
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `contact_messages` store — visitor messages from a store's contact form, kept in
|
|
3
|
+
* `STOREFRONT_APPS_DB`. Pure over a minimal SQL seam, so the Worker runs it
|
|
4
|
+
* against D1 and tests against `node:sqlite`.
|
|
5
|
+
*
|
|
6
|
+
* Every method takes the tenant id and scopes its SQL by it: an id belonging to
|
|
7
|
+
* another tenant is indistinguishable from an id that does not exist. The caller
|
|
8
|
+
* resolves `tenantId` from the routed tenant, never from a request body.
|
|
9
|
+
*/
|
|
10
|
+
/** The SQL surface this store needs; D1's `fromD1` and the test harness both satisfy it. */
|
|
11
|
+
export interface ContactDb {
|
|
12
|
+
all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
|
|
13
|
+
first<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T | null>;
|
|
14
|
+
run(sql: string, ...params: unknown[]): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const CONTACT_RETENTION_DAYS = 365;
|
|
18
|
+
export const CONTACT_LIST_LIMIT = 200;
|
|
19
|
+
|
|
20
|
+
export type ContactMessageStatus = "unread" | "read";
|
|
21
|
+
|
|
22
|
+
export interface NewContactMessage {
|
|
23
|
+
id: string;
|
|
24
|
+
tenantId: string;
|
|
25
|
+
name: string;
|
|
26
|
+
email: string;
|
|
27
|
+
phone: string;
|
|
28
|
+
message: string;
|
|
29
|
+
pagePath: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ContactMessage {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
email: string;
|
|
36
|
+
phone: string;
|
|
37
|
+
message: string;
|
|
38
|
+
pagePath: string | null;
|
|
39
|
+
status: ContactMessageStatus;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
readAt: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ContactMessageRow {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
email: string;
|
|
48
|
+
phone: string;
|
|
49
|
+
message: string;
|
|
50
|
+
page_path: string | null;
|
|
51
|
+
status: string;
|
|
52
|
+
created_at: string;
|
|
53
|
+
read_at: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toMessage(row: ContactMessageRow): ContactMessage {
|
|
57
|
+
return {
|
|
58
|
+
id: row.id,
|
|
59
|
+
name: row.name,
|
|
60
|
+
email: row.email,
|
|
61
|
+
phone: row.phone,
|
|
62
|
+
message: row.message,
|
|
63
|
+
pagePath: row.page_path,
|
|
64
|
+
status: row.status === "read" ? "read" : "unread",
|
|
65
|
+
createdAt: row.created_at,
|
|
66
|
+
readAt: row.read_at,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ContactMessagesStore {
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly db: ContactDb,
|
|
73
|
+
private readonly now: () => Date = () => new Date(),
|
|
74
|
+
) {}
|
|
75
|
+
|
|
76
|
+
async insert(input: NewContactMessage): Promise<void> {
|
|
77
|
+
await this.db.run(
|
|
78
|
+
`INSERT INTO contact_messages
|
|
79
|
+
(id, tenant_id, name, email, phone, message, page_path, status, created_at, notify_status, notify_attempts)
|
|
80
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', ?, 'pending', 0)`,
|
|
81
|
+
input.id,
|
|
82
|
+
input.tenantId,
|
|
83
|
+
input.name,
|
|
84
|
+
input.email,
|
|
85
|
+
input.phone,
|
|
86
|
+
input.message,
|
|
87
|
+
input.pagePath,
|
|
88
|
+
this.now().toISOString(),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Newest first, capped at {@link CONTACT_LIST_LIMIT}. */
|
|
93
|
+
async list(tenantId: string): Promise<ContactMessage[]> {
|
|
94
|
+
const rows = await this.db.all<ContactMessageRow>(
|
|
95
|
+
`SELECT id, name, email, phone, message, page_path, status, created_at, read_at
|
|
96
|
+
FROM contact_messages
|
|
97
|
+
WHERE tenant_id = ?
|
|
98
|
+
ORDER BY created_at DESC, id DESC
|
|
99
|
+
LIMIT ?`,
|
|
100
|
+
tenantId,
|
|
101
|
+
CONTACT_LIST_LIMIT,
|
|
102
|
+
);
|
|
103
|
+
return rows.map(toMessage);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async unreadCount(tenantId: string): Promise<number> {
|
|
107
|
+
const row = await this.db.first<{ n: number }>(
|
|
108
|
+
`SELECT COUNT(*) AS n FROM contact_messages WHERE tenant_id = ? AND status = 'unread'`,
|
|
109
|
+
tenantId,
|
|
110
|
+
);
|
|
111
|
+
return Number(row?.n ?? 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when the tenant owns a message with this id (now marked read). */
|
|
115
|
+
async markRead(tenantId: string, id: string): Promise<boolean> {
|
|
116
|
+
const rows = await this.db.all<{ id: string }>(
|
|
117
|
+
`UPDATE contact_messages
|
|
118
|
+
SET status = 'read', read_at = COALESCE(read_at, ?)
|
|
119
|
+
WHERE tenant_id = ? AND id = ?
|
|
120
|
+
RETURNING id`,
|
|
121
|
+
this.now().toISOString(),
|
|
122
|
+
tenantId,
|
|
123
|
+
id,
|
|
124
|
+
);
|
|
125
|
+
return rows.length > 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** True when the tenant owned a message with this id (now deleted). */
|
|
129
|
+
async delete(tenantId: string, id: string): Promise<boolean> {
|
|
130
|
+
const rows = await this.db.all<{ id: string }>(
|
|
131
|
+
`DELETE FROM contact_messages WHERE tenant_id = ? AND id = ? RETURNING id`,
|
|
132
|
+
tenantId,
|
|
133
|
+
id,
|
|
134
|
+
);
|
|
135
|
+
return rows.length > 0;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Delete every message older than the retention window, across all tenants.
|
|
141
|
+
* Returns the number of rows removed.
|
|
142
|
+
*/
|
|
143
|
+
export async function purgeExpiredContactMessages(
|
|
144
|
+
db: ContactDb,
|
|
145
|
+
now: Date = new Date(),
|
|
146
|
+
retentionDays: number = CONTACT_RETENTION_DAYS,
|
|
147
|
+
): Promise<number> {
|
|
148
|
+
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
|
|
149
|
+
const rows = await db.all<{ id: string }>(
|
|
150
|
+
`DELETE FROM contact_messages WHERE created_at < ? RETURNING id`,
|
|
151
|
+
cutoff,
|
|
152
|
+
);
|
|
153
|
+
return rows.length;
|
|
154
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public contact-form wire contract, shared by the Worker routes and any page
|
|
3
|
+
* block that submits to them. Browser-safe: no server imports.
|
|
4
|
+
*
|
|
5
|
+
* GET {apiBasePath}/api/contact/token → 200 ContactTokenResponse
|
|
6
|
+
* 503 { ok:false, error:"unavailable" }
|
|
7
|
+
* POST {apiBasePath}/api/contact ← ContactSubmission (application/json, same origin)
|
|
8
|
+
* → 200 { ok:true }
|
|
9
|
+
* 400 invalid_json | invalid_input (+ field)
|
|
10
|
+
* 403 forbidden_origin | invalid_token
|
|
11
|
+
* 413 too_large · 415 unsupported_media_type
|
|
12
|
+
* 429 rate_limited · 503 unavailable
|
|
13
|
+
* 500 store_failed (keep the visitor's text and retry)
|
|
14
|
+
*
|
|
15
|
+
* The token must be at least {@link CONTACT_TOKEN_MIN_AGE_MS} old and at most
|
|
16
|
+
* {@link CONTACT_TOKEN_MAX_AGE_MS} when the form is posted. `company` is the honeypot
|
|
17
|
+
* field: render it hidden from people and always send it empty.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const CONTACT_LIMITS = {
|
|
21
|
+
bodyBytes: 16_384,
|
|
22
|
+
name: 100,
|
|
23
|
+
email: 254,
|
|
24
|
+
phone: 32,
|
|
25
|
+
messageMin: 1,
|
|
26
|
+
message: 5000,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export const CONTACT_TOKEN_MIN_AGE_MS = 3_000;
|
|
30
|
+
export const CONTACT_TOKEN_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
export interface ContactTokenResponse {
|
|
33
|
+
ok: true;
|
|
34
|
+
token: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ContactSubmission {
|
|
38
|
+
token: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
email: string;
|
|
41
|
+
phone?: string;
|
|
42
|
+
message: string;
|
|
43
|
+
company?: string;
|
|
44
|
+
challenge?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ContactField = "name" | "email" | "phone" | "message";
|
|
48
|
+
|
|
49
|
+
export type ContactErrorCode =
|
|
50
|
+
| "not_found"
|
|
51
|
+
| "invalid_json"
|
|
52
|
+
| "invalid_input"
|
|
53
|
+
| "forbidden_origin"
|
|
54
|
+
| "invalid_token"
|
|
55
|
+
| "too_large"
|
|
56
|
+
| "unsupported_media_type"
|
|
57
|
+
| "rate_limited"
|
|
58
|
+
| "unavailable"
|
|
59
|
+
| "store_failed";
|
|
60
|
+
|
|
61
|
+
export type ContactSubmitResponse =
|
|
62
|
+
| { ok: true }
|
|
63
|
+
| { ok: false; error: ContactErrorCode; field?: ContactField };
|
|
64
|
+
|
|
65
|
+
export interface ContactClientResult {
|
|
66
|
+
status: number;
|
|
67
|
+
body: ContactSubmitResponse;
|
|
68
|
+
/** True when the visitor should keep their text and may try again. */
|
|
69
|
+
retryable: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const RETRYABLE = new Set([429, 500, 503]);
|
|
73
|
+
|
|
74
|
+
function apiUrl(apiBasePath: string, path: string): string {
|
|
75
|
+
return `${apiBasePath.replace(/\/+$/, "")}${path}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fetch a fresh submit token. Call when the form is shown, not on submit. */
|
|
79
|
+
export async function fetchContactToken(
|
|
80
|
+
apiBasePath: string,
|
|
81
|
+
fetchImpl: typeof fetch = fetch,
|
|
82
|
+
): Promise<string> {
|
|
83
|
+
const res = await fetchImpl(apiUrl(apiBasePath, "/api/contact/token"), {
|
|
84
|
+
headers: { accept: "application/json" },
|
|
85
|
+
credentials: "same-origin",
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) throw new Error(`contact token unavailable (${res.status})`);
|
|
88
|
+
const body = (await res.json()) as Partial<ContactTokenResponse>;
|
|
89
|
+
if (body.ok !== true || typeof body.token !== "string") throw new Error("contact token malformed");
|
|
90
|
+
return body.token;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Post one submission. A transport failure throws; every HTTP outcome resolves. */
|
|
94
|
+
export async function submitContactMessage(
|
|
95
|
+
apiBasePath: string,
|
|
96
|
+
submission: ContactSubmission,
|
|
97
|
+
fetchImpl: typeof fetch = fetch,
|
|
98
|
+
): Promise<ContactClientResult> {
|
|
99
|
+
const res = await fetchImpl(apiUrl(apiBasePath, "/api/contact"), {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
102
|
+
credentials: "same-origin",
|
|
103
|
+
body: JSON.stringify(submission),
|
|
104
|
+
});
|
|
105
|
+
const text = await res.text();
|
|
106
|
+
let body: ContactSubmitResponse;
|
|
107
|
+
try {
|
|
108
|
+
body = JSON.parse(text) as ContactSubmitResponse;
|
|
109
|
+
} catch {
|
|
110
|
+
body = { ok: false, error: res.ok ? "store_failed" : "unavailable" };
|
|
111
|
+
}
|
|
112
|
+
return { status: res.status, body, retryable: RETRYABLE.has(res.status) };
|
|
113
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client logic for the admin Messages tab (`/api/admin/messages`).
|
|
3
|
+
*
|
|
4
|
+
* Every visitor-supplied value is written with `textContent`; nothing from a
|
|
5
|
+
* message is ever parsed as markup.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface InboxMessage {
|
|
9
|
+
id: string;
|
|
10
|
+
status: string;
|
|
11
|
+
name?: string | null;
|
|
12
|
+
email?: string | null;
|
|
13
|
+
phone?: string | null;
|
|
14
|
+
message?: string | null;
|
|
15
|
+
pagePath?: string | null;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ApiResult {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
status: number;
|
|
22
|
+
data: { ok?: boolean; error?: string; messages?: InboxMessage[]; unread?: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const UNREADABLE = { ok: false, error: "unreadable_response" } as const;
|
|
26
|
+
|
|
27
|
+
async function call(url: string, init?: RequestInit): Promise<ApiResult> {
|
|
28
|
+
const res = await fetch(url, init);
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
let data: ApiResult["data"] = { ...UNREADABLE };
|
|
31
|
+
try {
|
|
32
|
+
data = JSON.parse(text) as ApiResult["data"];
|
|
33
|
+
} catch {
|
|
34
|
+
data = { ...UNREADABLE };
|
|
35
|
+
}
|
|
36
|
+
return { ok: res.ok && data.ok === true, status: res.status, data };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setText(row: HTMLElement, field: string, value: string | null | undefined): void {
|
|
40
|
+
const el = row.querySelector<HTMLElement>(`[data-field="${field}"]`);
|
|
41
|
+
if (!el) return;
|
|
42
|
+
el.textContent = value || "";
|
|
43
|
+
el.hidden = !value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function initMessagesTab(root: HTMLElement): void {
|
|
47
|
+
const statusEl = root.querySelector<HTMLElement>("[data-messages-status]");
|
|
48
|
+
const panel = root.querySelector<HTMLElement>("[data-messages-panel]");
|
|
49
|
+
const list = root.querySelector<HTMLElement>("[data-messages-list]");
|
|
50
|
+
const empty = root.querySelector<HTMLElement>("[data-messages-empty]");
|
|
51
|
+
const unreadEl = root.querySelector<HTMLElement>("[data-messages-unread]");
|
|
52
|
+
const template = root.querySelector<HTMLTemplateElement>("template[data-messages-row]");
|
|
53
|
+
const refresh = root.querySelector<HTMLElement>("[data-messages-refresh]");
|
|
54
|
+
if (!statusEl || !panel || !list || !empty || !unreadEl || !template || !refresh) return;
|
|
55
|
+
let loaded = false;
|
|
56
|
+
|
|
57
|
+
const setStatus = (text: string, tone: "error" | null): void => {
|
|
58
|
+
statusEl.textContent = text;
|
|
59
|
+
if (tone) statusEl.setAttribute("data-tone", tone);
|
|
60
|
+
else statusEl.removeAttribute("data-tone");
|
|
61
|
+
statusEl.hidden = !text;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const renderRow = (message: InboxMessage): HTMLElement => {
|
|
65
|
+
const row = template.content.firstElementChild!.cloneNode(true) as HTMLElement;
|
|
66
|
+
row.dataset.messageId = message.id;
|
|
67
|
+
row.dataset.status = message.status;
|
|
68
|
+
setText(row, "name", message.name || "(no name)");
|
|
69
|
+
setText(row, "email", message.email);
|
|
70
|
+
setText(row, "phone", message.phone);
|
|
71
|
+
setText(row, "message", message.message);
|
|
72
|
+
setText(row, "pagePath", message.pagePath ? `Sent from ${message.pagePath}` : "");
|
|
73
|
+
const when = row.querySelector<HTMLElement>('[data-field="createdAt"]')!;
|
|
74
|
+
when.setAttribute("datetime", message.createdAt);
|
|
75
|
+
when.textContent = new Date(message.createdAt).toLocaleString();
|
|
76
|
+
row.querySelector<HTMLElement>('[data-field="unread"]')!.hidden = message.status !== "unread";
|
|
77
|
+
const readBtn = row.querySelector<HTMLButtonElement>('[data-message-action="read"]')!;
|
|
78
|
+
readBtn.hidden = message.status !== "unread";
|
|
79
|
+
readBtn.addEventListener("click", () => void act(row, "read"));
|
|
80
|
+
row
|
|
81
|
+
.querySelector<HTMLButtonElement>('[data-message-action="delete"]')!
|
|
82
|
+
.addEventListener("click", () => void act(row, "delete"));
|
|
83
|
+
return row;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const load = async (): Promise<void> => {
|
|
87
|
+
loaded = true;
|
|
88
|
+
setStatus("Loading messages…", null);
|
|
89
|
+
try {
|
|
90
|
+
const { ok, status, data } = await call("/api/admin/messages", {
|
|
91
|
+
headers: { accept: "application/json" },
|
|
92
|
+
});
|
|
93
|
+
if (!ok) {
|
|
94
|
+
setStatus(`Could not load messages (${data.error || status}).`, "error");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const messages = data.messages ?? [];
|
|
98
|
+
list.replaceChildren(...messages.map(renderRow));
|
|
99
|
+
empty.hidden = messages.length !== 0;
|
|
100
|
+
unreadEl.textContent = `${data.unread ?? 0} unread`;
|
|
101
|
+
panel.hidden = false;
|
|
102
|
+
setStatus("", null);
|
|
103
|
+
} catch {
|
|
104
|
+
setStatus("Could not load messages: the request did not reach the server.", "error");
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const act = async (row: HTMLElement, action: "read" | "delete"): Promise<void> => {
|
|
109
|
+
const id = row.dataset.messageId ?? "";
|
|
110
|
+
if (action === "delete" && !confirm("Delete this message? This cannot be undone.")) return;
|
|
111
|
+
const url = `/api/admin/messages/${encodeURIComponent(id)}`;
|
|
112
|
+
const init: RequestInit =
|
|
113
|
+
action === "delete"
|
|
114
|
+
? { method: "DELETE" }
|
|
115
|
+
: {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "content-type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ action: "read" }),
|
|
119
|
+
};
|
|
120
|
+
const buttons = row.querySelectorAll<HTMLButtonElement>("button");
|
|
121
|
+
buttons.forEach((b) => (b.disabled = true));
|
|
122
|
+
try {
|
|
123
|
+
const { ok, status, data } = await call(url, init);
|
|
124
|
+
if (!ok) {
|
|
125
|
+
setStatus(`Could not update the message (${data.error || status}).`, "error");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
await load();
|
|
129
|
+
} catch {
|
|
130
|
+
setStatus("Could not update the message: the request did not reach the server.", "error");
|
|
131
|
+
} finally {
|
|
132
|
+
buttons.forEach((b) => (b.disabled = false));
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
refresh.addEventListener("click", () => void load());
|
|
137
|
+
const loadIfShown = (): void => {
|
|
138
|
+
if (!loaded && document.documentElement.dataset.adminTab === "messages") void load();
|
|
139
|
+
};
|
|
140
|
+
new MutationObserver(loadIfShown).observe(document.documentElement, {
|
|
141
|
+
attributes: true,
|
|
142
|
+
attributeFilter: ["data-admin-tab"],
|
|
143
|
+
});
|
|
144
|
+
loadIfShown();
|
|
145
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side parsing of one contact-form submission: a byte-capped body read,
|
|
3
|
+
* field caps, and header-injection stripping. Pure apart from reading the body.
|
|
4
|
+
*/
|
|
5
|
+
import { CONTACT_LIMITS, type ContactField } from "./contract.js";
|
|
6
|
+
|
|
7
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
8
|
+
const SINGLE_LINE_STRIP = /[\r\n\0]/g;
|
|
9
|
+
const NUL = /\0/g;
|
|
10
|
+
|
|
11
|
+
export type BodyRead = { ok: true; text: string } | { ok: false; reason: "too_large" };
|
|
12
|
+
|
|
13
|
+
/** Read the body, refusing as soon as it exceeds `maxBytes`. */
|
|
14
|
+
export async function readCappedBody(request: Request, maxBytes: number = CONTACT_LIMITS.bodyBytes): Promise<BodyRead> {
|
|
15
|
+
const declared = Number(request.headers.get("content-length") ?? "");
|
|
16
|
+
if (Number.isFinite(declared) && declared > maxBytes) return { ok: false, reason: "too_large" };
|
|
17
|
+
if (!request.body) return { ok: true, text: "" };
|
|
18
|
+
const reader = request.body.getReader();
|
|
19
|
+
const chunks: Uint8Array[] = [];
|
|
20
|
+
let total = 0;
|
|
21
|
+
for (;;) {
|
|
22
|
+
const { done, value } = await reader.read();
|
|
23
|
+
if (done) break;
|
|
24
|
+
total += value.byteLength;
|
|
25
|
+
if (total > maxBytes) {
|
|
26
|
+
await reader.cancel();
|
|
27
|
+
return { ok: false, reason: "too_large" };
|
|
28
|
+
}
|
|
29
|
+
chunks.push(value);
|
|
30
|
+
}
|
|
31
|
+
const bytes = new Uint8Array(total);
|
|
32
|
+
let offset = 0;
|
|
33
|
+
for (const chunk of chunks) {
|
|
34
|
+
bytes.set(chunk, offset);
|
|
35
|
+
offset += chunk.byteLength;
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, text: new TextDecoder().decode(bytes) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ParsedContactFields {
|
|
41
|
+
name: string;
|
|
42
|
+
email: string;
|
|
43
|
+
phone: string;
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type FieldParse = { ok: true; fields: ParsedContactFields } | { ok: false; field: ContactField };
|
|
48
|
+
|
|
49
|
+
/** Remove CR, LF and NUL, then trim: these values may later reach mail headers. */
|
|
50
|
+
export function singleLine(value: string): string {
|
|
51
|
+
return value.replace(SINGLE_LINE_STRIP, "").trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stringField(body: Record<string, unknown>, key: string): string | undefined {
|
|
55
|
+
const value = body[key];
|
|
56
|
+
if (value === undefined || value === null) return "";
|
|
57
|
+
return typeof value === "string" ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseContactFields(body: Record<string, unknown>): FieldParse {
|
|
61
|
+
const rawName = stringField(body, "name");
|
|
62
|
+
const rawEmail = stringField(body, "email");
|
|
63
|
+
const rawPhone = stringField(body, "phone");
|
|
64
|
+
const rawMessage = stringField(body, "message");
|
|
65
|
+
if (rawName === undefined) return { ok: false, field: "name" };
|
|
66
|
+
if (rawEmail === undefined) return { ok: false, field: "email" };
|
|
67
|
+
if (rawPhone === undefined) return { ok: false, field: "phone" };
|
|
68
|
+
if (rawMessage === undefined) return { ok: false, field: "message" };
|
|
69
|
+
|
|
70
|
+
const name = singleLine(rawName);
|
|
71
|
+
const email = singleLine(rawEmail).toLowerCase();
|
|
72
|
+
const phone = singleLine(rawPhone);
|
|
73
|
+
const message = rawMessage.replace(NUL, "").trim();
|
|
74
|
+
|
|
75
|
+
if (name.length > CONTACT_LIMITS.name) return { ok: false, field: "name" };
|
|
76
|
+
if (email.length > CONTACT_LIMITS.email || !EMAIL_RE.test(email)) return { ok: false, field: "email" };
|
|
77
|
+
if (phone.length > CONTACT_LIMITS.phone) return { ok: false, field: "phone" };
|
|
78
|
+
if (message.length < CONTACT_LIMITS.messageMin || message.length > CONTACT_LIMITS.message) {
|
|
79
|
+
return { ok: false, field: "message" };
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, fields: { name, email, phone, message } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when the honeypot field carries anything. */
|
|
85
|
+
export function honeypotFilled(body: Record<string, unknown>): boolean {
|
|
86
|
+
const value = body.company;
|
|
87
|
+
if (value === undefined || value === null) return false;
|
|
88
|
+
return typeof value !== "string" || value.trim() !== "";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ChallengeVerdict = { ok: true } | { ok: false };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The bot-challenge seam. Turnstile verification plugs in here; until then every
|
|
95
|
+
* submission passes.
|
|
96
|
+
*/
|
|
97
|
+
export async function verifyChallenge(_input: {
|
|
98
|
+
tenantId: string;
|
|
99
|
+
challenge: string | undefined;
|
|
100
|
+
request: Request;
|
|
101
|
+
}): Promise<ChallengeVerdict> {
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The submitting page's path from Referer, when it is same-origin. */
|
|
106
|
+
export function sameOriginPagePath(request: Request, expectedOrigin: string): string | null {
|
|
107
|
+
const referer = request.headers.get("referer");
|
|
108
|
+
if (!referer || !URL.canParse(referer)) return null;
|
|
109
|
+
const url = new URL(referer);
|
|
110
|
+
if (url.origin !== expectedOrigin) return null;
|
|
111
|
+
return url.pathname.slice(0, 512);
|
|
112
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-signed minimum-time-to-submit token for the contact form.
|
|
3
|
+
*
|
|
4
|
+
* `issueContactToken` signs `{ tenant, issuedAt }` with HMAC-SHA256 under
|
|
5
|
+
* `CONTACT_FORM_TOKEN_SECRET`; `verifyContactToken` accepts it only for the same
|
|
6
|
+
* tenant, between {@link CONTACT_TOKEN_MIN_AGE_MS} and {@link CONTACT_TOKEN_MAX_AGE_MS}
|
|
7
|
+
* after issue. A script that posts without first loading the form, or posts
|
|
8
|
+
* faster than a person can type, is refused. Web Crypto only.
|
|
9
|
+
*
|
|
10
|
+
* Wire form: `v1.<base64url(JSON payload)>.<base64url(HMAC)>`.
|
|
11
|
+
*/
|
|
12
|
+
import { CONTACT_TOKEN_MAX_AGE_MS, CONTACT_TOKEN_MIN_AGE_MS } from "./contract.js";
|
|
13
|
+
|
|
14
|
+
const VERSION = "v1";
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
|
|
17
|
+
export type ContactTokenVerdict =
|
|
18
|
+
| { ok: true }
|
|
19
|
+
| { ok: false; reason: "malformed" | "bad_signature" | "wrong_tenant" | "too_early" | "expired" };
|
|
20
|
+
|
|
21
|
+
function base64url(bytes: Uint8Array): string {
|
|
22
|
+
let bin = "";
|
|
23
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
24
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fromBase64url(text: string): Uint8Array<ArrayBuffer> | undefined {
|
|
28
|
+
if (!/^[A-Za-z0-9_-]+$/.test(text) || text.length % 4 === 1) return undefined;
|
|
29
|
+
const bin = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
30
|
+
const out = new Uint8Array(bin.length);
|
|
31
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hmacKey(secret: string, usage: "sign" | "verify"): Promise<CryptoKey> {
|
|
36
|
+
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function signedBytes(payload: string): Uint8Array<ArrayBuffer> {
|
|
40
|
+
return new Uint8Array(encoder.encode(`contact-form:${VERSION}:${payload}`));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function issueContactToken(
|
|
44
|
+
secret: string,
|
|
45
|
+
tenantId: string,
|
|
46
|
+
nowMs: number = Date.now(),
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const payload = base64url(encoder.encode(JSON.stringify({ tenant: tenantId, issuedAt: nowMs })));
|
|
49
|
+
const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret, "sign"), signedBytes(payload));
|
|
50
|
+
return `${VERSION}.${payload}.${base64url(new Uint8Array(mac))}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function verifyContactToken(
|
|
54
|
+
secret: string,
|
|
55
|
+
token: string,
|
|
56
|
+
tenantId: string,
|
|
57
|
+
nowMs: number = Date.now(),
|
|
58
|
+
): Promise<ContactTokenVerdict> {
|
|
59
|
+
const parts = token.split(".");
|
|
60
|
+
if (parts.length !== 3 || parts[0] !== VERSION) return { ok: false, reason: "malformed" };
|
|
61
|
+
const [, payload, sig] = parts as [string, string, string];
|
|
62
|
+
const sigBytes = fromBase64url(sig);
|
|
63
|
+
const payloadBytes = fromBase64url(payload);
|
|
64
|
+
if (!sigBytes || !payloadBytes) return { ok: false, reason: "malformed" };
|
|
65
|
+
|
|
66
|
+
const valid = await crypto.subtle.verify("HMAC", await hmacKey(secret, "verify"), sigBytes, signedBytes(payload));
|
|
67
|
+
if (!valid) return { ok: false, reason: "bad_signature" };
|
|
68
|
+
|
|
69
|
+
let claims: { tenant?: unknown; issuedAt?: unknown };
|
|
70
|
+
try {
|
|
71
|
+
claims = JSON.parse(new TextDecoder().decode(payloadBytes)) as typeof claims;
|
|
72
|
+
} catch {
|
|
73
|
+
return { ok: false, reason: "malformed" };
|
|
74
|
+
}
|
|
75
|
+
if (typeof claims.issuedAt !== "number" || !Number.isFinite(claims.issuedAt)) {
|
|
76
|
+
return { ok: false, reason: "malformed" };
|
|
77
|
+
}
|
|
78
|
+
if (claims.tenant !== tenantId) return { ok: false, reason: "wrong_tenant" };
|
|
79
|
+
const age = nowMs - claims.issuedAt;
|
|
80
|
+
if (age < CONTACT_TOKEN_MIN_AGE_MS) return { ok: false, reason: "too_early" };
|
|
81
|
+
if (age > CONTACT_TOKEN_MAX_AGE_MS) return { ok: false, reason: "expired" };
|
|
82
|
+
return { ok: true };
|
|
83
|
+
}
|
|
@@ -548,6 +548,36 @@ export const newsletterSubscribers = sqliteTable(
|
|
|
548
548
|
}),
|
|
549
549
|
);
|
|
550
550
|
|
|
551
|
+
// --- Contact messages -------------------------------------------------------
|
|
552
|
+
// Visitor messages from a store's contact form, written here FIRST so a message is
|
|
553
|
+
// never lost to a mail failure. `tenantId` comes from the routed tenant, never a
|
|
554
|
+
// client body; every read and write is scoped by it. `status`: unread | read.
|
|
555
|
+
// `notifyStatus` (pending | sent | failed) and `notifyAttempts` belong to the
|
|
556
|
+
// owner-notification sender. Rows are retained for 365 days (`purgeExpiredContactMessages`).
|
|
557
|
+
// Timestamps are ISO-8601 text.
|
|
558
|
+
export const contactMessages = sqliteTable(
|
|
559
|
+
"contact_messages",
|
|
560
|
+
{
|
|
561
|
+
id: text("id").primaryKey(),
|
|
562
|
+
tenantId: text("tenant_id").notNull(),
|
|
563
|
+
name: text("name").notNull().default(""),
|
|
564
|
+
email: text("email").notNull(),
|
|
565
|
+
phone: text("phone").notNull().default(""),
|
|
566
|
+
message: text("message").notNull(),
|
|
567
|
+
pagePath: text("page_path"),
|
|
568
|
+
status: text("status").notNull().default("unread"),
|
|
569
|
+
createdAt: text("created_at").notNull(),
|
|
570
|
+
readAt: text("read_at"),
|
|
571
|
+
notifyStatus: text("notify_status").notNull().default("pending"),
|
|
572
|
+
notifyAttempts: integer("notify_attempts").notNull().default(0),
|
|
573
|
+
},
|
|
574
|
+
(t) => ({
|
|
575
|
+
idxContactTenantCreated: index("idx_contact_messages_tenant_created").on(t.tenantId, t.createdAt),
|
|
576
|
+
idxContactTenantStatus: index("idx_contact_messages_tenant_status").on(t.tenantId, t.status),
|
|
577
|
+
idxContactCreated: index("idx_contact_messages_created").on(t.createdAt),
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
|
|
551
581
|
// Idempotent per-digest send log so a re-triggered digest can't double-send.
|
|
552
582
|
// Keyed on `(tenant_id, collection, digest_id)` (e.g. `the-build-2026-W34`);
|
|
553
583
|
// `postSlugs` is a JSON array of the slugs included in that digest.
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* path-scoped Tier 3 behaviors (ADR 0013: sign-in, the commerce and compliance
|
|
4
4
|
* APIs, search, the dashboard). The public static path never reaches here.
|
|
5
5
|
*
|
|
6
|
-
* CloudFront supplies
|
|
6
|
+
* CloudFront supplies four headers a viewer cannot set: the tenant id and an
|
|
7
7
|
* HMAC over it are origin custom headers (CloudFront replaces any viewer-sent
|
|
8
|
-
* header of the same name), and the viewer's Host
|
|
9
|
-
* viewer-request function, which overwrites any viewer-sent value. The
|
|
10
|
-
* the only trust anchor: without a valid one none of the
|
|
11
|
-
* request is routed by its own Host like any other.
|
|
8
|
+
* header of the same name), and the viewer's Host and IP are copied by the
|
|
9
|
+
* behavior's viewer-request function, which overwrites any viewer-sent value. The
|
|
10
|
+
* HMAC is the only trust anchor: without a valid one none of the four is read and
|
|
11
|
+
* the request is routed by its own Host like any other.
|
|
12
12
|
*
|
|
13
13
|
* The node-side mirror that computes the header value for a distribution is
|
|
14
14
|
* scripts/publish/lib/worker-tier3.mjs `cdnOriginAuthValue`; both are pinned to
|
|
@@ -19,16 +19,20 @@ import { timingSafeEqual } from "@/lib/activity/ingestAuth";
|
|
|
19
19
|
export const CDN_TENANT_HEADER = "x-tot-cdn-tenant";
|
|
20
20
|
export const CDN_AUTH_HEADER = "x-tot-cdn-origin-auth";
|
|
21
21
|
export const CDN_VIEWER_HOST_HEADER = "x-tot-viewer-host";
|
|
22
|
+
export const CDN_VIEWER_IP_HEADER = "x-tot-viewer-ip";
|
|
22
23
|
const AUTH_VERSION = "v1";
|
|
23
24
|
|
|
24
25
|
export interface CdnForward {
|
|
25
26
|
tenantId: string;
|
|
26
27
|
/** The Host the viewer addressed — the public origin the request belongs to. */
|
|
27
28
|
viewerHost: string;
|
|
29
|
+
/** The viewer's IP as CloudFront saw it; absent when the forward carries none. */
|
|
30
|
+
viewerIp?: string;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
const HOST_SHAPE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d{1,5})?$/i;
|
|
31
34
|
const TENANT_SHAPE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i;
|
|
35
|
+
const IP_SHAPE = /^(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f]{0,4}(?::[0-9a-f]{0,4}){2,7}(?::\d{1,3}(?:\.\d{1,3}){3})?)$/i;
|
|
32
36
|
|
|
33
37
|
/** `v1.<hex HMAC-SHA256(secret, "cdn-origin:v1:" + tenantId)>`. */
|
|
34
38
|
export async function cdnOriginAuthValue(secret: string, tenantId: string): Promise<string> {
|
|
@@ -60,7 +64,26 @@ export async function verifyCdnForward(
|
|
|
60
64
|
} catch {
|
|
61
65
|
return null;
|
|
62
66
|
}
|
|
63
|
-
|
|
67
|
+
const viewerIp = headers.get(CDN_VIEWER_IP_HEADER)?.trim() ?? "";
|
|
68
|
+
return IP_SHAPE.test(viewerIp) ? { tenantId, viewerHost, viewerIp } : { tenantId, viewerHost };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The visitor's IP, for keying per-visitor limits. On a verified CloudFront forward it is
|
|
73
|
+
* the viewer IP the distribution's function supplied; `cf-connecting-ip` there is the
|
|
74
|
+
* CloudFront edge, shared by every visitor behind it. Otherwise — a Cloudflare-served
|
|
75
|
+
* request, or a viewer-IP header with no valid forward proof — it is `cf-connecting-ip`,
|
|
76
|
+
* which Cloudflare sets and a client cannot. The first `x-forwarded-for` hop is the
|
|
77
|
+
* local dev fallback. Never throws.
|
|
78
|
+
*/
|
|
79
|
+
export async function requestClientIp(headers: Headers, cdnOriginSecret: string | undefined): Promise<string> {
|
|
80
|
+
const forward = await verifyCdnForward(headers, cdnOriginSecret);
|
|
81
|
+
if (forward?.viewerIp) return forward.viewerIp;
|
|
82
|
+
const cf = headers.get("cf-connecting-ip");
|
|
83
|
+
if (cf) return cf;
|
|
84
|
+
const xff = headers.get("x-forwarded-for");
|
|
85
|
+
if (xff) return xff.split(",")[0]!.trim();
|
|
86
|
+
return "unknown";
|
|
64
87
|
}
|
|
65
88
|
|
|
66
89
|
const FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"];
|
|
@@ -154,6 +154,7 @@ async function serveRequest(
|
|
|
154
154
|
seen.cdnForwarded = !!cdn;
|
|
155
155
|
if (cdn) host = cdn.viewerHost;
|
|
156
156
|
const origin = cdn ? `https://${cdn.viewerHost}` : url.origin;
|
|
157
|
+
locals.requestOrigin = origin;
|
|
157
158
|
const crossSite = crossSiteFormRefusal(request, origin);
|
|
158
159
|
if (crossSite) return crossSite;
|
|
159
160
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/contact/token` — a fresh signed submit token for this tenant's
|
|
3
|
+
* contact form (`lib/engagement/contact/submitToken.ts`). Without
|
|
4
|
+
* `CONTACT_FORM_TOKEN_SECRET` the form cannot work, so this answers 503 rather
|
|
5
|
+
* than handing out an unsigned token.
|
|
6
|
+
*/
|
|
7
|
+
import type { APIContext } from "astro";
|
|
8
|
+
import { readEnv } from "@/lib/publication/shared/env";
|
|
9
|
+
import { issueContactToken } from "@/lib/engagement/contact/submitToken";
|
|
10
|
+
|
|
11
|
+
export const prerender = false;
|
|
12
|
+
|
|
13
|
+
const HEADERS = {
|
|
14
|
+
"content-type": "application/json; charset=utf-8",
|
|
15
|
+
"cache-control": "no-store",
|
|
16
|
+
"x-robots-tag": "noindex, nofollow",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
20
|
+
const tenantId = context.locals?.tenant?.tenant_id;
|
|
21
|
+
if (!tenantId) return new Response(JSON.stringify({ ok: false, error: "not_found" }), { status: 404, headers: HEADERS });
|
|
22
|
+
const secret = await readEnv("CONTACT_FORM_TOKEN_SECRET");
|
|
23
|
+
if (!secret) {
|
|
24
|
+
console.log(`[contact] tenant=${tenantId} outcome=token_no_secret`);
|
|
25
|
+
return new Response(JSON.stringify({ ok: false, error: "unavailable" }), { status: 503, headers: HEADERS });
|
|
26
|
+
}
|
|
27
|
+
const token = await issueContactToken(secret, tenantId);
|
|
28
|
+
return new Response(JSON.stringify({ ok: true, token }), { headers: HEADERS });
|
|
29
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /api/contact` — a visitor message from a store's contact form, stored in
|
|
3
|
+
* `STOREFRONT_APPS_DB` for the owner's /admin Messages inbox. The wire contract is
|
|
4
|
+
* `lib/engagement/contact/contract.ts`.
|
|
5
|
+
*
|
|
6
|
+
* The tenant is `locals.tenant`, never the body. The request must be same-origin
|
|
7
|
+
* JSON carrying a signed submit token (`GET /api/contact/token`) of the right age
|
|
8
|
+
* for this tenant. A filled honeypot, and any submission to a store that is not
|
|
9
|
+
* public, gets the success response and stores nothing. A missing secret, KV or D1
|
|
10
|
+
* binding refuses with 503; the rate limiter fails closed. Responses never echo
|
|
11
|
+
* input, and logs carry only tenant, submission id and outcome.
|
|
12
|
+
*/
|
|
13
|
+
import type { APIContext } from "astro";
|
|
14
|
+
import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
|
|
15
|
+
import { fromD1 } from "@/lib/catalog/d1/catalog";
|
|
16
|
+
import { checkRateLimitClosed } from "@/lib/access/rate/kvWindow";
|
|
17
|
+
import { hashConsentIp } from "@/lib/engagement/newsletter/consent";
|
|
18
|
+
import { ContactMessagesStore } from "@/lib/engagement/contact/contactMessages";
|
|
19
|
+
import { verifyContactToken } from "@/lib/engagement/contact/submitToken";
|
|
20
|
+
import { requestClientIp } from "@/lib/publication/shared/cdnForward";
|
|
21
|
+
import {
|
|
22
|
+
honeypotFilled,
|
|
23
|
+
parseContactFields,
|
|
24
|
+
readCappedBody,
|
|
25
|
+
sameOriginPagePath,
|
|
26
|
+
verifyChallenge,
|
|
27
|
+
} from "@/lib/engagement/contact/submission";
|
|
28
|
+
import type { ContactErrorCode, ContactField, ContactSubmitResponse } from "@/lib/engagement/contact/contract";
|
|
29
|
+
|
|
30
|
+
export const prerender = false;
|
|
31
|
+
|
|
32
|
+
const CONTACT_RATE_WINDOW_SECONDS = 3600;
|
|
33
|
+
const CONTACT_RATE_MAX_PER_WINDOW = 10;
|
|
34
|
+
|
|
35
|
+
const HEADERS = {
|
|
36
|
+
"content-type": "application/json; charset=utf-8",
|
|
37
|
+
"cache-control": "no-store",
|
|
38
|
+
"x-robots-tag": "noindex, nofollow",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function respond(body: ContactSubmitResponse, status = 200): Response {
|
|
42
|
+
return new Response(JSON.stringify(body), { status, headers: HEADERS });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function log(tenantId: string, submissionId: string, outcome: string): void {
|
|
46
|
+
console.log(`[contact] tenant=${tenantId} submission=${submissionId} outcome=${outcome}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function POST(context: APIContext): Promise<Response> {
|
|
50
|
+
const tenantId = context.locals?.tenant?.tenant_id;
|
|
51
|
+
if (!tenantId) return respond({ ok: false, error: "not_found" }, 404);
|
|
52
|
+
const submissionId = crypto.randomUUID();
|
|
53
|
+
const { request } = context;
|
|
54
|
+
const refuse = (outcome: string, error: ContactErrorCode, status: number, field?: ContactField): Response => {
|
|
55
|
+
log(tenantId, submissionId, outcome);
|
|
56
|
+
return respond(field ? { ok: false, error, field } : { ok: false, error }, status);
|
|
57
|
+
};
|
|
58
|
+
const accept = (outcome: string): Response => {
|
|
59
|
+
log(tenantId, submissionId, outcome);
|
|
60
|
+
return respond({ ok: true });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const expectedOrigin = context.locals.requestOrigin ?? new URL(request.url).origin;
|
|
64
|
+
if (request.headers.get("origin") !== expectedOrigin) {
|
|
65
|
+
return refuse("forbidden_origin", "forbidden_origin", 403);
|
|
66
|
+
}
|
|
67
|
+
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
68
|
+
if (!contentType.startsWith("application/json")) {
|
|
69
|
+
return refuse("unsupported_media_type", "unsupported_media_type", 415);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const read = await readCappedBody(request);
|
|
73
|
+
if (!read.ok) return refuse("too_large", "too_large", 413);
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(read.text);
|
|
77
|
+
} catch {
|
|
78
|
+
return refuse("invalid_json", "invalid_json", 400);
|
|
79
|
+
}
|
|
80
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
81
|
+
return refuse("invalid_json", "invalid_json", 400);
|
|
82
|
+
}
|
|
83
|
+
const body = parsed as Record<string, unknown>;
|
|
84
|
+
|
|
85
|
+
if (honeypotFilled(body)) return accept("honeypot");
|
|
86
|
+
|
|
87
|
+
const fields = parseContactFields(body);
|
|
88
|
+
if (!fields.ok) return refuse("invalid_input", "invalid_input", 400, fields.field);
|
|
89
|
+
|
|
90
|
+
const secret = await readEnv("CONTACT_FORM_TOKEN_SECRET");
|
|
91
|
+
if (!secret) return refuse("no_token_secret", "unavailable", 503);
|
|
92
|
+
const token = typeof body.token === "string" ? body.token : "";
|
|
93
|
+
const verdict = await verifyContactToken(secret, token, tenantId);
|
|
94
|
+
if (!verdict.ok) return refuse(`token_${verdict.reason}`, "invalid_token", 403);
|
|
95
|
+
|
|
96
|
+
const challenge = await verifyChallenge({
|
|
97
|
+
tenantId,
|
|
98
|
+
challenge: typeof body.challenge === "string" ? body.challenge : undefined,
|
|
99
|
+
request,
|
|
100
|
+
});
|
|
101
|
+
if (!challenge.ok) return refuse("challenge_failed", "invalid_token", 403);
|
|
102
|
+
|
|
103
|
+
if (context.locals.storeNotPublic || context.locals.customizationEnv !== "live") {
|
|
104
|
+
return accept("not_public");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const kv = await readKv("TENANT_CACHE");
|
|
108
|
+
const ip = await requestClientIp(request.headers, await readEnv("CDN_ORIGIN_SECRET"));
|
|
109
|
+
const ipKey = await hashConsentIp(ip, `contact-rate:${secret}`);
|
|
110
|
+
const limit = await checkRateLimitClosed(kv, `contact:${tenantId}:${ipKey}`, {
|
|
111
|
+
windowSeconds: CONTACT_RATE_WINDOW_SECONDS,
|
|
112
|
+
maxRequests: CONTACT_RATE_MAX_PER_WINDOW,
|
|
113
|
+
});
|
|
114
|
+
if (!limit.allowed) {
|
|
115
|
+
return limit.reason === "limited"
|
|
116
|
+
? refuse("rate_limited", "rate_limited", 429)
|
|
117
|
+
: refuse("no_rate_limiter", "unavailable", 503);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
121
|
+
if (!d1) return refuse("no_database", "unavailable", 503);
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await new ContactMessagesStore(fromD1(d1)).insert({
|
|
125
|
+
id: submissionId,
|
|
126
|
+
tenantId,
|
|
127
|
+
...fields.fields,
|
|
128
|
+
pagePath: sameOriginPagePath(request, expectedOrigin),
|
|
129
|
+
});
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return refuse(`store_failed:${err instanceof Error ? err.name : "unknown"}`, "store_failed", 500);
|
|
132
|
+
}
|
|
133
|
+
return accept("stored");
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.2",
|
|
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",
|