@tokenoftrust/storefront-runner 4.9.2 → 4.10.0
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/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
- package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
- package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
- package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
- package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
- package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
- package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
- package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
- package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
- package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
- package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
- package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
- package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
- package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
- package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
- package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
- package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
- package/apps/storefront/src/pages/api/contact.ts +16 -4
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
- package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
- package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
- package/package.json +1 -1
- package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
- package/packages/public-runtime/src/integration/events.ts +15 -19
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
- package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
- package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
- package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
- package/packages/public-runtime/src/integration/plan.ts +6 -4
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
- package/packages/public-runtime/src/integration/render-phases.ts +10 -18
- package/packages/public-runtime/src/integration/resolve.ts +3 -3
- package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
- package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
- package/packages/public-runtime/src/tenant.ts +3 -3
- package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
- package/scripts/dev/ga4-gs2-canary.mjs +36 -10
- package/scripts/tenant/validate.mjs +9 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact vault envelope: AES-256-GCM under a per-tenant, per-purpose HKDF subkey of one keyring
|
|
3
|
+
* key. The AAD binds tenant, stage, record id, key id and the immutable `notAfter`, so a row read
|
|
4
|
+
* under any other binding fails its tag and reads as absent.
|
|
5
|
+
*
|
|
6
|
+
* The keyring is the `ORDER_CONTACT_VAULT_KEYRING` Worker secret: JSON
|
|
7
|
+
* `{ active: <kid>, keys: { <kid>: <base64 of 32 bytes> } }`. Errors carry fixed codes only —
|
|
8
|
+
* never a key, nonce, ciphertext or plaintext value.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type VaultPurpose = "order-contact" | "contact-message";
|
|
12
|
+
|
|
13
|
+
export interface VaultKeyring {
|
|
14
|
+
active: string;
|
|
15
|
+
keys: ReadonlyMap<string, Uint8Array>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface VaultBinding {
|
|
19
|
+
purpose: VaultPurpose;
|
|
20
|
+
tenantId: string;
|
|
21
|
+
stage: string;
|
|
22
|
+
recordId: string;
|
|
23
|
+
notAfter: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SealedEnvelope {
|
|
27
|
+
keyId: string;
|
|
28
|
+
nonce: string;
|
|
29
|
+
ciphertext: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class VaultKeyUnavailableError extends Error {
|
|
33
|
+
readonly code = "vault-key-unavailable";
|
|
34
|
+
constructor() {
|
|
35
|
+
super("vault-key-unavailable");
|
|
36
|
+
this.name = "VaultKeyUnavailableError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const KID = /^[A-Za-z0-9._-]{1,32}$/;
|
|
41
|
+
const HKDF_SALT = new TextEncoder().encode("tot-contact-vault/v1");
|
|
42
|
+
const encoder = new TextEncoder();
|
|
43
|
+
const decoder = new TextDecoder();
|
|
44
|
+
|
|
45
|
+
function fromBase64(value: string): Uint8Array | null {
|
|
46
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
|
|
47
|
+
let binary: string;
|
|
48
|
+
try {
|
|
49
|
+
binary = atob(value);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof DOMException && error.name === "InvalidCharacterError") return null;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
const out = new Uint8Array(binary.length);
|
|
55
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toBase64(bytes: Uint8Array): string {
|
|
60
|
+
let binary = "";
|
|
61
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
62
|
+
return btoa(binary);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parse the keyring secret, or null when it is absent or malformed in any way. */
|
|
66
|
+
export function parseVaultKeyring(raw: string | undefined | null): VaultKeyring | null {
|
|
67
|
+
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
68
|
+
let parsed: unknown;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(raw);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof SyntaxError) return null;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
76
|
+
const { active, keys } = parsed as { active?: unknown; keys?: unknown };
|
|
77
|
+
if (typeof active !== "string" || !KID.test(active)) return null;
|
|
78
|
+
if (!keys || typeof keys !== "object" || Array.isArray(keys)) return null;
|
|
79
|
+
const map = new Map<string, Uint8Array>();
|
|
80
|
+
for (const [kid, value] of Object.entries(keys as Record<string, unknown>)) {
|
|
81
|
+
if (!KID.test(kid) || typeof value !== "string") return null;
|
|
82
|
+
const bytes = fromBase64(value);
|
|
83
|
+
if (!bytes || bytes.length !== 32) return null;
|
|
84
|
+
map.set(kid, bytes);
|
|
85
|
+
}
|
|
86
|
+
if (!map.has(active)) return null;
|
|
87
|
+
return { active, keys: map };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Canonical AAD bytes: fixed key order, so equal bindings always produce equal AAD. */
|
|
91
|
+
export function vaultAad(binding: VaultBinding, keyId: string): Uint8Array {
|
|
92
|
+
return encoder.encode(JSON.stringify({
|
|
93
|
+
v: 1,
|
|
94
|
+
purpose: binding.purpose,
|
|
95
|
+
tenantId: binding.tenantId,
|
|
96
|
+
stage: binding.stage,
|
|
97
|
+
recordId: binding.recordId,
|
|
98
|
+
keyId,
|
|
99
|
+
notAfter: binding.notAfter,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function subkey(keyring: VaultKeyring, keyId: string, binding: VaultBinding): Promise<CryptoKey> {
|
|
104
|
+
const ikm = keyring.keys.get(keyId);
|
|
105
|
+
if (!ikm) throw new VaultKeyUnavailableError();
|
|
106
|
+
const base = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, ["deriveKey"]);
|
|
107
|
+
return crypto.subtle.deriveKey(
|
|
108
|
+
{ name: "HKDF", hash: "SHA-256", salt: HKDF_SALT, info: encoder.encode(`${binding.purpose}|${binding.tenantId}`) },
|
|
109
|
+
base,
|
|
110
|
+
{ name: "AES-GCM", length: 256 },
|
|
111
|
+
false,
|
|
112
|
+
["encrypt", "decrypt"],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Seal a JSON value under the keyring's active key. */
|
|
117
|
+
export async function sealVaultRecord(
|
|
118
|
+
keyring: VaultKeyring | null,
|
|
119
|
+
binding: VaultBinding,
|
|
120
|
+
value: unknown,
|
|
121
|
+
): Promise<SealedEnvelope> {
|
|
122
|
+
if (!keyring) throw new VaultKeyUnavailableError();
|
|
123
|
+
const keyId = keyring.active;
|
|
124
|
+
const key = await subkey(keyring, keyId, binding);
|
|
125
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
126
|
+
const sealed = await crypto.subtle.encrypt(
|
|
127
|
+
{ name: "AES-GCM", iv: nonce, additionalData: vaultAad(binding, keyId) as BufferSource },
|
|
128
|
+
key,
|
|
129
|
+
encoder.encode(JSON.stringify(value)),
|
|
130
|
+
);
|
|
131
|
+
return { keyId, nonce: toBase64(nonce), ciphertext: toBase64(new Uint8Array(sealed)) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Open a sealed record. Null when the binding, nonce or ciphertext does not authenticate — callers
|
|
136
|
+
* report that as not-found. A missing keyring or key id throws {@link VaultKeyUnavailableError}.
|
|
137
|
+
*/
|
|
138
|
+
export async function openVaultRecord(
|
|
139
|
+
keyring: VaultKeyring | null,
|
|
140
|
+
binding: VaultBinding,
|
|
141
|
+
envelope: SealedEnvelope,
|
|
142
|
+
): Promise<unknown | null> {
|
|
143
|
+
if (!keyring) throw new VaultKeyUnavailableError();
|
|
144
|
+
const key = await subkey(keyring, envelope.keyId, binding);
|
|
145
|
+
const nonce = fromBase64(envelope.nonce);
|
|
146
|
+
const ciphertext = fromBase64(envelope.ciphertext);
|
|
147
|
+
if (!nonce || nonce.length !== 12 || !ciphertext) return null;
|
|
148
|
+
let plain: ArrayBuffer;
|
|
149
|
+
try {
|
|
150
|
+
plain = await crypto.subtle.decrypt(
|
|
151
|
+
{ name: "AES-GCM", iv: nonce as BufferSource, additionalData: vaultAad(binding, envelope.keyId) as BufferSource },
|
|
152
|
+
key,
|
|
153
|
+
ciphertext as BufferSource,
|
|
154
|
+
);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// AES-GCM reports a failed tag (wrong binding, key or bytes) as OperationError; anything else is a fault.
|
|
157
|
+
if (error instanceof DOMException && error.name === "OperationError") return null;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(decoder.decode(plain));
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error instanceof SyntaxError) return null;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact vault retention. `notAfter` is the hard ceiling bound into the AAD: 365 days after the
|
|
3
|
+
* order was placed (or the message was received). Once an order is terminal (handed off or
|
|
4
|
+
* cancelled) its contact expires 180 days after that, never later than `notAfter`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const CONTACT_RETENTION_DAYS = 365;
|
|
8
|
+
export const TERMINAL_RETENTION_DAYS = 180;
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
|
|
11
|
+
const ISO_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
12
|
+
|
|
13
|
+
/** A strict ISO instant with an explicit zone and real calendar fields, or null. */
|
|
14
|
+
export function readInstant(value: unknown): Date | null {
|
|
15
|
+
if (typeof value !== "string") return null;
|
|
16
|
+
const match = ISO_INSTANT.exec(value);
|
|
17
|
+
if (!match) return null;
|
|
18
|
+
const [year, month, day, hour, minute, second] = match.slice(1, 7).map((part) => Number(part ?? 0));
|
|
19
|
+
const calendar = new Date(Date.UTC(year!, month! - 1, day!));
|
|
20
|
+
if (calendar.getUTCMonth() !== month! - 1 || calendar.getUTCDate() !== day! || hour! > 23 || minute! > 59 || second! > 59) return null;
|
|
21
|
+
const parsed = new Date(value);
|
|
22
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function notAfterFrom(start: Date): string {
|
|
26
|
+
return new Date(start.getTime() + CONTACT_RETENTION_DAYS * DAY_MS).toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The expiry for a record whose ceiling is `notAfter`, given the order's terminal time if any. */
|
|
30
|
+
export function expiresAtFor(notAfter: string, terminalAt: Date | null): string {
|
|
31
|
+
if (!terminalAt) return notAfter;
|
|
32
|
+
const terminal = new Date(terminalAt.getTime() + TERMINAL_RETENTION_DAYS * DAY_MS).toISOString();
|
|
33
|
+
return terminal < notAfter ? terminal : notAfter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True while a record may still be read: strictly before both its expiry and its ceiling. */
|
|
37
|
+
export function isReadable(now: Date, expiresAt: string, notAfter: string): boolean {
|
|
38
|
+
const iso = now.toISOString();
|
|
39
|
+
return iso < expiresAt && iso < notAfter;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function terminalExpiryFrom(at: Date): string {
|
|
43
|
+
return new Date(at.getTime() + TERMINAL_RETENTION_DAYS * DAY_MS).toISOString();
|
|
44
|
+
}
|
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `contact_messages` store — visitor messages from a store's contact form, kept in
|
|
3
|
-
* `STOREFRONT_APPS_DB
|
|
3
|
+
* `STOREFRONT_APPS_DB` (migrations-apps/0021). Pure over a minimal SQL seam, so the Worker runs it
|
|
4
4
|
* against D1 and tests against `node:sqlite`.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The visitor's name, email, phone and message exist at rest only as one contact vault envelope
|
|
7
|
+
* (`lib/customers/contactVault/envelope.ts`, purpose `contact-message`) whose AAD binds tenant, stage, message
|
|
8
|
+
* id and the immutable `not_after`. A row still holding legacy plaintext is never read; the rollout
|
|
9
|
+
* and the hourly sweep seal it ({@link sealLegacyContactMessages}).
|
|
10
|
+
*
|
|
11
|
+
* Every method takes the tenant id and the routed stage and scopes its SQL by both: an id belonging
|
|
12
|
+
* to another tenant or stage is indistinguishable from an id that does not exist. Every read writes
|
|
13
|
+
* its `contact_access_audit` row before anything is decrypted. Nothing here logs.
|
|
9
14
|
*/
|
|
15
|
+
import {
|
|
16
|
+
openVaultRecord,
|
|
17
|
+
sealVaultRecord,
|
|
18
|
+
VaultKeyUnavailableError,
|
|
19
|
+
type VaultBinding,
|
|
20
|
+
type VaultKeyring,
|
|
21
|
+
} from "@/lib/customers/contactVault/envelope.js";
|
|
22
|
+
import { isReadable, notAfterFrom } from "@/lib/customers/contactVault/retention.js";
|
|
23
|
+
import { admitAudit, auditStatement, type ContactActor, type VaultStage } from "@/lib/customers/contactVault/contactAudit.js";
|
|
24
|
+
|
|
25
|
+
interface QueryStatement {
|
|
26
|
+
sql: string;
|
|
27
|
+
params?: readonly unknown[];
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
/** The SQL surface this store needs; D1's `fromD1` and the test harness both satisfy it. */
|
|
11
31
|
export interface ContactDb {
|
|
12
32
|
all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
|
|
13
33
|
first<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T | null>;
|
|
14
34
|
run(sql: string, ...params: unknown[]): Promise<void>;
|
|
35
|
+
/** D1 executes a batch sequentially inside one implicit transaction. */
|
|
36
|
+
batch?(statements: readonly QueryStatement[]): Promise<void>;
|
|
15
37
|
}
|
|
16
38
|
|
|
17
|
-
export const CONTACT_RETENTION_DAYS = 365;
|
|
18
39
|
export const CONTACT_LIST_LIMIT = 200;
|
|
19
40
|
|
|
20
41
|
export type ContactMessageStatus = "unread" | "read";
|
|
@@ -22,6 +43,7 @@ export type ContactMessageStatus = "unread" | "read";
|
|
|
22
43
|
export interface NewContactMessage {
|
|
23
44
|
id: string;
|
|
24
45
|
tenantId: string;
|
|
46
|
+
stage: VaultStage;
|
|
25
47
|
name: string;
|
|
26
48
|
email: string;
|
|
27
49
|
phone: string;
|
|
@@ -41,114 +63,269 @@ export interface ContactMessage {
|
|
|
41
63
|
readAt: string | null;
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
|
|
66
|
+
export type ContactRefusal =
|
|
67
|
+
| { ok: false; status: 404; reason: "not-found" }
|
|
68
|
+
| { ok: false; status: 429; reason: "quota" }
|
|
69
|
+
| { ok: false; status: 503; reason: "audit-unavailable" | "vault-key-unavailable" };
|
|
70
|
+
|
|
71
|
+
export type ListOutcome = { ok: true; messages: ContactMessage[] } | ContactRefusal;
|
|
72
|
+
export type SupportReadOutcome = { ok: true; message: ContactMessage } | ContactRefusal;
|
|
73
|
+
export type EraseOutcome = { ok: true } | ContactRefusal;
|
|
74
|
+
|
|
75
|
+
interface SealedRow {
|
|
45
76
|
id: string;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
77
|
+
key_id: string;
|
|
78
|
+
nonce: string;
|
|
79
|
+
ciphertext: string;
|
|
80
|
+
not_after: string;
|
|
81
|
+
expires_at: string;
|
|
50
82
|
page_path: string | null;
|
|
51
83
|
status: string;
|
|
52
84
|
created_at: string;
|
|
53
85
|
read_at: string | null;
|
|
54
86
|
}
|
|
55
87
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
interface ContactPlaintext {
|
|
89
|
+
name: string;
|
|
90
|
+
email: string;
|
|
91
|
+
phone: string;
|
|
92
|
+
message: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const SEALED_COLUMNS = "id, key_id, nonce, ciphertext, not_after, expires_at, page_path, status, created_at, read_at";
|
|
96
|
+
/** A row the store may act on: sealed, and strictly before both its expiry and its ceiling. */
|
|
97
|
+
const READABLE = "ciphertext IS NOT NULL AND ? < expires_at AND ? < not_after";
|
|
98
|
+
|
|
99
|
+
function binding(tenantId: string, stage: VaultStage, id: string, notAfter: string): VaultBinding {
|
|
100
|
+
return { purpose: "contact-message", tenantId, stage, recordId: id, notAfter };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requireBatch(db: ContactDb): NonNullable<ContactDb["batch"]> {
|
|
104
|
+
if (!db.batch) throw new Error("D1 batch is unavailable");
|
|
105
|
+
return db.batch.bind(db);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function asPlaintext(value: unknown): ContactPlaintext | null {
|
|
109
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
110
|
+
const v = value as Record<string, unknown>;
|
|
111
|
+
const fields = [v.name, v.email, v.phone, v.message];
|
|
112
|
+
if (!fields.every((f) => typeof f === "string")) return null;
|
|
113
|
+
return { name: v.name as string, email: v.email as string, phone: v.phone as string, message: v.message as string };
|
|
68
114
|
}
|
|
69
115
|
|
|
70
116
|
export class ContactMessagesStore {
|
|
71
117
|
constructor(
|
|
72
118
|
private readonly db: ContactDb,
|
|
119
|
+
private readonly keyring: VaultKeyring | null,
|
|
73
120
|
private readonly now: () => Date = () => new Date(),
|
|
74
121
|
) {}
|
|
75
122
|
|
|
123
|
+
/** Seal and store one message. Throws {@link VaultKeyUnavailableError} (writing nothing) without a keyring. */
|
|
76
124
|
async insert(input: NewContactMessage): Promise<void> {
|
|
125
|
+
const created = this.now();
|
|
126
|
+
const notAfter = notAfterFrom(created);
|
|
127
|
+
const plaintext: ContactPlaintext = { name: input.name, email: input.email, phone: input.phone, message: input.message };
|
|
128
|
+
const sealed = await sealVaultRecord(this.keyring, binding(input.tenantId, input.stage, input.id, notAfter), plaintext);
|
|
77
129
|
await this.db.run(
|
|
78
130
|
`INSERT INTO contact_messages
|
|
79
|
-
(id, tenant_id,
|
|
80
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', ?, 'pending', 0)`,
|
|
131
|
+
(id, tenant_id, stage, key_id, nonce, ciphertext, not_after, expires_at, page_path, status, created_at, notify_status, notify_attempts)
|
|
132
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unread', ?, 'pending', 0)`,
|
|
81
133
|
input.id,
|
|
82
134
|
input.tenantId,
|
|
83
|
-
input.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
135
|
+
input.stage,
|
|
136
|
+
sealed.keyId,
|
|
137
|
+
sealed.nonce,
|
|
138
|
+
sealed.ciphertext,
|
|
139
|
+
notAfter,
|
|
140
|
+
notAfter,
|
|
87
141
|
input.pagePath,
|
|
88
|
-
|
|
142
|
+
created.toISOString(),
|
|
89
143
|
);
|
|
90
144
|
}
|
|
91
145
|
|
|
92
|
-
/**
|
|
93
|
-
async list(tenantId: string): Promise<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
/** The owner inbox: one `list` audit row commits, then newest first, capped at {@link CONTACT_LIST_LIMIT}. */
|
|
147
|
+
async list(tenantId: string, stage: VaultStage, actor: ContactActor, auditId: string): Promise<ListOutcome> {
|
|
148
|
+
if (!this.keyring) return { ok: false, status: 503, reason: "vault-key-unavailable" };
|
|
149
|
+
const now = this.now();
|
|
150
|
+
const admitted = await admitAudit(this.db, auditStatement({
|
|
151
|
+
auditId, tenantId, stage, recordKind: "contact-message", recordId: "*", actor, action: "list", now,
|
|
152
|
+
}));
|
|
153
|
+
if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
|
|
154
|
+
if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
|
|
155
|
+
const iso = now.toISOString();
|
|
156
|
+
const rows = await this.db.all<SealedRow>(
|
|
157
|
+
`SELECT ${SEALED_COLUMNS} FROM contact_messages
|
|
158
|
+
WHERE tenant_id = ? AND stage = ? AND ${READABLE}
|
|
98
159
|
ORDER BY created_at DESC, id DESC
|
|
99
160
|
LIMIT ?`,
|
|
100
|
-
tenantId,
|
|
101
|
-
|
|
161
|
+
tenantId, stage, iso, iso, CONTACT_LIST_LIMIT,
|
|
162
|
+
);
|
|
163
|
+
const messages: ContactMessage[] = [];
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
const message = await this.open(tenantId, stage, row, now);
|
|
166
|
+
if (message) messages.push(message);
|
|
167
|
+
}
|
|
168
|
+
return { ok: true, messages };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* ToT support reads one message. The caller has already proven a live staff selection and a live
|
|
173
|
+
* owner-consented grant; the audit row (with the staff quota in the same statement) commits first.
|
|
174
|
+
*/
|
|
175
|
+
async supportRead(tenantId: string, stage: VaultStage, id: string, actor: ContactActor, auditId: string): Promise<SupportReadOutcome> {
|
|
176
|
+
if (!this.keyring) return { ok: false, status: 503, reason: "vault-key-unavailable" };
|
|
177
|
+
const now = this.now();
|
|
178
|
+
const admitted = await admitAudit(this.db, auditStatement({
|
|
179
|
+
auditId, tenantId, stage, recordKind: "contact-message", recordId: id, actor, action: "read", now,
|
|
180
|
+
}));
|
|
181
|
+
if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
|
|
182
|
+
if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
|
|
183
|
+
const iso = now.toISOString();
|
|
184
|
+
const row = await this.db.first<SealedRow>(
|
|
185
|
+
`SELECT ${SEALED_COLUMNS} FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ${READABLE}`,
|
|
186
|
+
tenantId, stage, id, iso, iso,
|
|
102
187
|
);
|
|
103
|
-
|
|
188
|
+
const message = row ? await this.open(tenantId, stage, row, now) : null;
|
|
189
|
+
return message ? { ok: true, message } : { ok: false, status: 404, reason: "not-found" };
|
|
104
190
|
}
|
|
105
191
|
|
|
106
|
-
async unreadCount(tenantId: string): Promise<number> {
|
|
192
|
+
async unreadCount(tenantId: string, stage: VaultStage): Promise<number> {
|
|
193
|
+
const iso = this.now().toISOString();
|
|
107
194
|
const row = await this.db.first<{ n: number }>(
|
|
108
|
-
`SELECT COUNT(*) AS n FROM contact_messages WHERE tenant_id = ? AND status = 'unread'`,
|
|
109
|
-
tenantId,
|
|
195
|
+
`SELECT COUNT(*) AS n FROM contact_messages WHERE tenant_id = ? AND stage = ? AND status = 'unread' AND ${READABLE}`,
|
|
196
|
+
tenantId, stage, iso, iso,
|
|
110
197
|
);
|
|
111
198
|
return Number(row?.n ?? 0);
|
|
112
199
|
}
|
|
113
200
|
|
|
114
|
-
/** True when the tenant
|
|
115
|
-
async markRead(tenantId: string, id: string): Promise<boolean> {
|
|
201
|
+
/** True when the tenant holds a readable message with this id in this stage (now marked read). */
|
|
202
|
+
async markRead(tenantId: string, stage: VaultStage, id: string): Promise<boolean> {
|
|
203
|
+
const iso = this.now().toISOString();
|
|
116
204
|
const rows = await this.db.all<{ id: string }>(
|
|
117
205
|
`UPDATE contact_messages
|
|
118
206
|
SET status = 'read', read_at = COALESCE(read_at, ?)
|
|
119
|
-
WHERE tenant_id = ? AND id = ?
|
|
207
|
+
WHERE tenant_id = ? AND stage = ? AND id = ? AND ${READABLE}
|
|
120
208
|
RETURNING id`,
|
|
121
|
-
|
|
122
|
-
tenantId,
|
|
123
|
-
id,
|
|
209
|
+
iso, tenantId, stage, id, iso, iso,
|
|
124
210
|
);
|
|
125
211
|
return rows.length > 0;
|
|
126
212
|
}
|
|
127
213
|
|
|
128
|
-
/**
|
|
129
|
-
async
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
214
|
+
/** Erase one sealed message: its audit row and its delete commit in one batch, or neither does. */
|
|
215
|
+
async erase(tenantId: string, stage: VaultStage, id: string, actor: ContactActor, auditId: string): Promise<EraseOutcome> {
|
|
216
|
+
const now = this.now();
|
|
217
|
+
const rowSql = `SELECT 1 FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ciphertext IS NOT NULL`;
|
|
218
|
+
const exists = await this.db.first(rowSql, tenantId, stage, id);
|
|
219
|
+
if (!exists) return { ok: false, status: 404, reason: "not-found" };
|
|
220
|
+
const audit = auditStatement({
|
|
221
|
+
auditId, tenantId, stage, recordKind: "contact-message", recordId: id, actor, action: "erase", now,
|
|
222
|
+
requireRow: { sql: rowSql, params: [tenantId, stage, id] },
|
|
223
|
+
});
|
|
224
|
+
const statements: QueryStatement[] = [
|
|
225
|
+
{ sql: audit.sql.replace(/\s*RETURNING audit_id$/, ""), params: audit.params },
|
|
226
|
+
{
|
|
227
|
+
sql: `DELETE FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ciphertext IS NOT NULL
|
|
228
|
+
AND EXISTS (SELECT 1 FROM contact_access_audit WHERE audit_id = ?)`,
|
|
229
|
+
params: [tenantId, stage, id, auditId],
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
try {
|
|
233
|
+
await requireBatch(this.db)(statements);
|
|
234
|
+
} catch {
|
|
235
|
+
return { ok: false, status: 503, reason: "audit-unavailable" };
|
|
236
|
+
}
|
|
237
|
+
const audited = await this.db.first(`SELECT 1 AS present FROM contact_access_audit WHERE audit_id = ?`, auditId);
|
|
238
|
+
if (audited) return { ok: true };
|
|
239
|
+
const still = await this.db.first(rowSql, tenantId, stage, id);
|
|
240
|
+
return still ? { ok: false, status: 429, reason: "quota" } : { ok: false, status: 404, reason: "not-found" };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Decrypt one row, or null when it is past retention or does not authenticate under its binding. */
|
|
244
|
+
private async open(tenantId: string, stage: VaultStage, row: SealedRow, now: Date): Promise<ContactMessage | null> {
|
|
245
|
+
if (!isReadable(now, row.expires_at, row.not_after)) return null;
|
|
246
|
+
const opened = await openVaultRecord(this.keyring, binding(tenantId, stage, row.id, row.not_after), {
|
|
247
|
+
keyId: row.key_id, nonce: row.nonce, ciphertext: row.ciphertext,
|
|
248
|
+
}).catch((error: unknown) => {
|
|
249
|
+
if (error instanceof VaultKeyUnavailableError) return null;
|
|
250
|
+
throw error;
|
|
251
|
+
});
|
|
252
|
+
const plain = asPlaintext(opened);
|
|
253
|
+
if (!plain) return null;
|
|
254
|
+
return {
|
|
255
|
+
id: row.id,
|
|
256
|
+
...plain,
|
|
257
|
+
pagePath: row.page_path,
|
|
258
|
+
status: row.status === "read" ? "read" : "unread",
|
|
259
|
+
createdAt: row.created_at,
|
|
260
|
+
readAt: row.read_at,
|
|
261
|
+
};
|
|
136
262
|
}
|
|
137
263
|
}
|
|
138
264
|
|
|
139
265
|
/**
|
|
140
|
-
* Delete every message
|
|
141
|
-
* Returns the number of rows removed.
|
|
266
|
+
* Delete every message at or past its expiry or its ceiling, across all tenants and stages (legacy
|
|
267
|
+
* rows included). Returns the number of rows removed. An unreadable clock throws.
|
|
142
268
|
*/
|
|
143
|
-
export async function purgeExpiredContactMessages(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
retentionDays: number = CONTACT_RETENTION_DAYS,
|
|
147
|
-
): Promise<number> {
|
|
148
|
-
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
|
|
269
|
+
export async function purgeExpiredContactMessages(db: ContactDb, now: Date): Promise<number> {
|
|
270
|
+
if (Number.isNaN(now.getTime())) throw new Error("contact message purge: unreadable clock");
|
|
271
|
+
const iso = now.toISOString();
|
|
149
272
|
const rows = await db.all<{ id: string }>(
|
|
150
|
-
`DELETE FROM contact_messages WHERE
|
|
151
|
-
|
|
273
|
+
`DELETE FROM contact_messages WHERE expires_at <= ? OR not_after <= ? RETURNING id`,
|
|
274
|
+
iso, iso,
|
|
152
275
|
);
|
|
153
276
|
return rows.length;
|
|
154
277
|
}
|
|
278
|
+
|
|
279
|
+
interface LegacyRow {
|
|
280
|
+
id: string;
|
|
281
|
+
tenant_id: string;
|
|
282
|
+
stage: VaultStage;
|
|
283
|
+
not_after: string;
|
|
284
|
+
legacy_name: string | null;
|
|
285
|
+
legacy_email: string | null;
|
|
286
|
+
legacy_phone: string | null;
|
|
287
|
+
legacy_message: string | null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Seal up to `limit` rows still holding legacy plaintext (oldest id first), across all tenants.
|
|
292
|
+
* Each row is one statement: the envelope is written and every `legacy_*` column nulled together,
|
|
293
|
+
* only while the row is still unsealed. `remaining` counts the unsealed rows left.
|
|
294
|
+
*/
|
|
295
|
+
export async function sealLegacyContactMessages(
|
|
296
|
+
db: ContactDb,
|
|
297
|
+
keyring: VaultKeyring,
|
|
298
|
+
now: Date,
|
|
299
|
+
limit: number,
|
|
300
|
+
): Promise<{ sealed: number; remaining: number }> {
|
|
301
|
+
if (Number.isNaN(now.getTime())) throw new Error("contact message sealing: unreadable clock");
|
|
302
|
+
const rows = await db.all<LegacyRow>(
|
|
303
|
+
`SELECT id, tenant_id, stage, not_after, legacy_name, legacy_email, legacy_phone, legacy_message
|
|
304
|
+
FROM contact_messages WHERE ciphertext IS NULL
|
|
305
|
+
ORDER BY id LIMIT ?`,
|
|
306
|
+
Math.max(0, Math.floor(limit)),
|
|
307
|
+
);
|
|
308
|
+
let sealed = 0;
|
|
309
|
+
for (const row of rows) {
|
|
310
|
+
const plaintext: ContactPlaintext = {
|
|
311
|
+
name: row.legacy_name ?? "",
|
|
312
|
+
email: row.legacy_email ?? "",
|
|
313
|
+
phone: row.legacy_phone ?? "",
|
|
314
|
+
message: row.legacy_message ?? "",
|
|
315
|
+
};
|
|
316
|
+
const envelope = await sealVaultRecord(keyring, binding(row.tenant_id, row.stage, row.id, row.not_after), plaintext);
|
|
317
|
+
const changed = await db.all<{ id: string }>(
|
|
318
|
+
`UPDATE contact_messages
|
|
319
|
+
SET key_id = ?, nonce = ?, ciphertext = ?,
|
|
320
|
+
legacy_name = NULL, legacy_email = NULL, legacy_phone = NULL, legacy_message = NULL
|
|
321
|
+
WHERE id = ? AND ciphertext IS NULL
|
|
322
|
+
RETURNING id`,
|
|
323
|
+
envelope.keyId, envelope.nonce, envelope.ciphertext, row.id,
|
|
324
|
+
);
|
|
325
|
+
sealed += changed.length;
|
|
326
|
+
}
|
|
327
|
+
const left = await db.first<{ n: number }>(
|
|
328
|
+
`SELECT COUNT(*) AS n FROM contact_messages WHERE ciphertext IS NULL`,
|
|
329
|
+
);
|
|
330
|
+
return { sealed, remaining: Number(left?.n ?? 0) };
|
|
331
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* → 200 { ok:true }
|
|
9
9
|
* 400 invalid_json | invalid_input (+ field)
|
|
10
10
|
* 403 forbidden_origin | invalid_token
|
|
11
|
+
* 409 stage-unknown (the store's stage could not be resolved)
|
|
11
12
|
* 413 too_large · 415 unsupported_media_type
|
|
12
13
|
* 429 rate_limited · 503 unavailable
|
|
13
14
|
* 500 store_failed (keep the visitor's text and retry)
|
|
@@ -56,6 +57,7 @@ export type ContactErrorCode =
|
|
|
56
57
|
| "unsupported_media_type"
|
|
57
58
|
| "rate_limited"
|
|
58
59
|
| "unavailable"
|
|
60
|
+
| "stage-unknown"
|
|
59
61
|
| "store_failed";
|
|
60
62
|
|
|
61
63
|
export type ContactSubmitResponse =
|