@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.
Files changed (57) hide show
  1. package/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
  2. package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
  3. package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
  4. package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
  5. package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
  6. package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
  7. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
  8. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
  9. package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
  10. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
  11. package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
  12. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
  13. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
  14. package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
  15. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
  16. package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
  17. package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
  18. package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
  19. package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
  20. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
  21. package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
  22. package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
  23. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
  24. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
  25. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
  26. package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
  27. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
  28. package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
  29. package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
  30. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
  31. package/apps/storefront/src/pages/api/contact.ts +16 -4
  32. package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
  33. package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
  34. package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
  35. package/package.json +1 -1
  36. package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
  37. package/packages/public-runtime/src/integration/events.ts +15 -19
  38. package/packages/public-runtime/src/integration/index.ts +1 -0
  39. package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
  40. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
  41. package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
  42. package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
  43. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
  44. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
  45. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
  46. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
  47. package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
  48. package/packages/public-runtime/src/integration/plan.ts +6 -4
  49. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
  50. package/packages/public-runtime/src/integration/render-phases.ts +10 -18
  51. package/packages/public-runtime/src/integration/resolve.ts +3 -3
  52. package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
  53. package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
  54. package/packages/public-runtime/src/tenant.ts +3 -3
  55. package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
  56. package/scripts/dev/ga4-gs2-canary.mjs +36 -10
  57. 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`. Pure over a minimal SQL seam, so the Worker runs it
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
- * 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.
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
- interface ContactMessageRow {
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
- name: string;
47
- email: string;
48
- phone: string;
49
- message: string;
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
- 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
- };
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, name, email, phone, message, page_path, status, created_at, notify_status, notify_attempts)
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.name,
84
- input.email,
85
- input.phone,
86
- input.message,
135
+ input.stage,
136
+ sealed.keyId,
137
+ sealed.nonce,
138
+ sealed.ciphertext,
139
+ notAfter,
140
+ notAfter,
87
141
  input.pagePath,
88
- this.now().toISOString(),
142
+ created.toISOString(),
89
143
  );
90
144
  }
91
145
 
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 = ?
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
- CONTACT_LIST_LIMIT,
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
- return rows.map(toMessage);
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 owns a message with this id (now marked read). */
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
- this.now().toISOString(),
122
- tenantId,
123
- id,
209
+ iso, tenantId, stage, id, iso, iso,
124
210
  );
125
211
  return rows.length > 0;
126
212
  }
127
213
 
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;
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 older than the retention window, across all tenants.
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
- 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();
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 created_at < ? RETURNING id`,
151
- cutoff,
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 =
@@ -104,7 +104,6 @@ export async function projectCommerceOrder(
104
104
  }
105
105
  : {}),
106
106
  lines,
107
- address: null,
108
107
  compliance: trusted ? snapshot.compliance : "UNKNOWN",
109
108
  stock: trusted && snapshot.payment === "captured" ? "confirmed" : "UNKNOWN",
110
109
  fulfillment: