@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,299 @@
1
+ // @ts-check
2
+ /**
3
+ * The tenant `analytics.json` contract, for the surfaces that cannot load the platform's
4
+ * TypeScript: `tot validate`, the kit's validate-site, `scripts/tenant/validate.mjs` and
5
+ * materialize-tenant.
6
+ *
7
+ * The platform materializes the file with `materializeTenantAnalyticsDeclarations`
8
+ * (`packages/public-runtime/src/integration/tenant-analytics.ts`), which builds each destination
9
+ * through the reviewed adapter builder. This module reports the same `{ path, code }` set for any
10
+ * input; `agency-kit/tools/lib/fixtures/analytics-declarations.json` is run through both, so a
11
+ * drift on either side fails a test.
12
+ *
13
+ * Messages carry no value from the file — never an id, never a key's value — only the field path
14
+ * and a fixed explanation, so a finding is safe to print, log or forward.
15
+ *
16
+ * Pure: callers pass the file's text. `packages/cli/src/analytics-declaration.mjs` is a
17
+ * byte-identical copy (a test pins it).
18
+ */
19
+
20
+ /** The tenant-owned file, at the tenant tree's root. */
21
+ export const ANALYTICS_DECLARATION_FILE = "analytics.json";
22
+
23
+ /** The current `analytics.json` contract version. */
24
+ export const ANALYTICS_DECLARATION_SCHEMA_VERSION = 1;
25
+
26
+ /** The stages a destination id may be declared for, in the platform's environment order. */
27
+ export const ANALYTICS_STAGES = Object.freeze(["local", "candidate", "staged", "live"]);
28
+
29
+ /** The `euUkVisitors` policies; `ask` is the default. */
30
+ export const ANALYTICS_EU_UK_POLICIES = Object.freeze(["ask", "count-only", "full", "none"]);
31
+
32
+ /**
33
+ * The first-class platform adapters a tenant may declare. Pinned against the platform's
34
+ * `PLATFORM_ADAPTERS` by `packages/public-runtime/tests/integration/tenant-analytics.test.ts`.
35
+ */
36
+ export const ANALYTICS_ADAPTERS = Object.freeze([
37
+ Object.freeze({
38
+ adapterId: "platform.google-measurement",
39
+ maxDestinationsPerStage: 4,
40
+ idPattern: /^G-[A-Z0-9]{4,20}$/,
41
+ idLabel: "GA4 measurement id (e.g. G-XXXXXXX)",
42
+ }),
43
+ Object.freeze({
44
+ adapterId: "platform.meta-pixel",
45
+ maxDestinationsPerStage: 1,
46
+ idPattern: /^\d{10,20}$/,
47
+ idLabel: "Meta pixel id (10–20 digits)",
48
+ }),
49
+ Object.freeze({
50
+ adapterId: "platform.amplitude",
51
+ maxDestinationsPerStage: 1,
52
+ idPattern: /^(?=[a-f0-9]{32}$)(?=.*[a-f])[a-f0-9]+$/i,
53
+ idLabel: "Amplitude project API key (32 hexadecimal characters)",
54
+ }),
55
+ ]);
56
+
57
+ const ANNOTATION_KEYS = ["$schema", "$comment", "$id"];
58
+ const ROOT_KEYS = ["schemaVersion", "destinations", "euUkVisitors"];
59
+ const DESTINATION_KEYS = ["adapter", "ids"];
60
+
61
+ /**
62
+ * @typedef {Object} AnalyticsViolation
63
+ * @property {string} path the offending field, e.g. `destinations[0].ids.live[1]`
64
+ * @property {string} code a stable machine code, e.g. `format`, `unknown_field`
65
+ */
66
+
67
+ /**
68
+ * @typedef {Object} AnalyticsFinding
69
+ * @property {string} path
70
+ * @property {string} code
71
+ * @property {string} message value-free: the path, the code and a fixed explanation
72
+ * @property {string} remedy
73
+ */
74
+
75
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
76
+ function isPlainObject(value) {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value);
78
+ }
79
+
80
+ /**
81
+ * @param {Record<string, unknown>} obj
82
+ * @param {readonly string[]} allowed
83
+ * @param {string} base
84
+ * @param {AnalyticsViolation[]} out
85
+ */
86
+ function unknownKeys(obj, allowed, base, out) {
87
+ for (const key of Object.keys(obj)) {
88
+ if (allowed.includes(key) || ANNOTATION_KEYS.includes(key)) continue;
89
+ out.push({ path: base ? `${base}.${key}` : key, code: "unknown_field" });
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The adapter builder's per-stage id rules, with its paths re-anchored under the destination.
95
+ * @param {Record<string, unknown>} ids
96
+ * @param {(typeof ANALYTICS_ADAPTERS)[number]} adapter
97
+ * @param {string} base `destinations[i]`
98
+ * @param {AnalyticsViolation[]} out
99
+ */
100
+ function stageIdViolations(ids, adapter, base, out) {
101
+ /** @type {Map<string, string[]>} */
102
+ const valid = new Map();
103
+ for (const [stage, raw] of Object.entries(ids)) {
104
+ const at = `${base}.ids.${stage}`;
105
+ if (!ANALYTICS_STAGES.includes(stage)) {
106
+ out.push({ path: at, code: "unknown_enum" });
107
+ continue;
108
+ }
109
+ if (!Array.isArray(raw)) {
110
+ out.push({ path: at, code: "type" });
111
+ continue;
112
+ }
113
+ if (raw.length === 0) {
114
+ out.push({ path: at, code: "empty" });
115
+ continue;
116
+ }
117
+ if (raw.length > adapter.maxDestinationsPerStage) {
118
+ out.push({ path: at, code: "too_many_destinations" });
119
+ continue;
120
+ }
121
+ let stageOk = true;
122
+ raw.forEach((id, index) => {
123
+ if (typeof id !== "string" || id.length === 0) {
124
+ out.push({ path: `${at}[${index}]`, code: "type" });
125
+ stageOk = false;
126
+ return;
127
+ }
128
+ if (!adapter.idPattern.test(id)) {
129
+ out.push({ path: `${at}[${index}]`, code: "format" });
130
+ stageOk = false;
131
+ }
132
+ });
133
+ if (!stageOk) continue;
134
+ if (new Set(raw).size !== raw.length) {
135
+ out.push({ path: at, code: "duplicate" });
136
+ continue;
137
+ }
138
+ valid.set(stage, /** @type {string[]} */ (raw));
139
+ }
140
+ const live = valid.get("live");
141
+ if (live === undefined) {
142
+ out.push({ path: `${base}.ids.live`, code: "required" });
143
+ return;
144
+ }
145
+ for (const stage of ANALYTICS_STAGES) {
146
+ if (stage === "live") continue;
147
+ (valid.get(stage) ?? []).forEach((id, index) => {
148
+ if (live.includes(id)) out.push({ path: `${base}.ids.${stage}[${index}]`, code: "non_live_reuses_live_id" });
149
+ });
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Every violation in an already-parsed `analytics.json` value. `undefined`/`null` (no file) is
155
+ * the undeclared norm and has none.
156
+ * @param {unknown} value
157
+ * @returns {AnalyticsViolation[]}
158
+ */
159
+ export function analyticsDeclarationViolations(value) {
160
+ /** @type {AnalyticsViolation[]} */
161
+ const out = [];
162
+ if (value === undefined || value === null) return out;
163
+ if (!isPlainObject(value)) {
164
+ out.push({ path: "(root)", code: "type" });
165
+ return out;
166
+ }
167
+ unknownKeys(value, ROOT_KEYS, "", out);
168
+ if ("euUkVisitors" in value && !ANALYTICS_EU_UK_POLICIES.includes(/** @type {string} */ (value.euUkVisitors))) {
169
+ out.push({ path: "euUkVisitors", code: "unknown_enum" });
170
+ }
171
+ if ("schemaVersion" in value && value.schemaVersion !== ANALYTICS_DECLARATION_SCHEMA_VERSION) {
172
+ out.push({ path: "schemaVersion", code: "unsupported_version" });
173
+ }
174
+ const destinations = value.destinations;
175
+ if (!Array.isArray(destinations)) {
176
+ out.push({ path: "destinations", code: "type" });
177
+ return out;
178
+ }
179
+ /** @type {Set<string>} */
180
+ const seenAdapters = new Set();
181
+ destinations.forEach((destination, index) => {
182
+ const base = `destinations[${index}]`;
183
+ if (!isPlainObject(destination)) {
184
+ out.push({ path: base, code: "type" });
185
+ return;
186
+ }
187
+ unknownKeys(destination, DESTINATION_KEYS, base, out);
188
+ const adapterId = destination.adapter;
189
+ if (typeof adapterId !== "string" || adapterId.length === 0) {
190
+ out.push({ path: `${base}.adapter`, code: "type" });
191
+ return;
192
+ }
193
+ const ids = destination.ids;
194
+ if (!isPlainObject(ids)) {
195
+ out.push({ path: `${base}.ids`, code: "type" });
196
+ return;
197
+ }
198
+ if (seenAdapters.has(adapterId)) {
199
+ out.push({ path: `${base}.adapter`, code: "duplicate" });
200
+ return;
201
+ }
202
+ seenAdapters.add(adapterId);
203
+ const adapter = ANALYTICS_ADAPTERS.find((a) => a.adapterId === adapterId);
204
+ if (!adapter) {
205
+ out.push({ path: `${base}.adapter`, code: "unknown_adapter" });
206
+ return;
207
+ }
208
+ stageIdViolations(ids, adapter, base, out);
209
+ });
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * Parse `analytics.json` text. Unparseable text is a `(root)` `invalid_json` violation, never an
215
+ * absent file: a declaration that cannot be read must refuse, not publish with no analytics.
216
+ * @param {string} text
217
+ * @returns {{ ok: true, value: unknown } | { ok: false, violations: AnalyticsViolation[] }}
218
+ */
219
+ export function parseAnalyticsDeclaration(text) {
220
+ try {
221
+ return { ok: true, value: JSON.parse(text) };
222
+ } catch {
223
+ return { ok: false, violations: [{ path: "(root)", code: "invalid_json" }] };
224
+ }
225
+ }
226
+
227
+ const ADAPTER_IDS = ANALYTICS_ADAPTERS.map((a) => a.adapterId).join(", ");
228
+ const ID_FORMATS = ANALYTICS_ADAPTERS.map((a) => `${a.adapterId}: ${a.idLabel}`).join("; ");
229
+
230
+ /**
231
+ * A fixed, value-free explanation and remedy for a violation.
232
+ * @param {AnalyticsViolation} violation
233
+ * @returns {{ message: string, remedy: string }}
234
+ */
235
+ export function analyticsViolationText({ path, code }) {
236
+ switch (code) {
237
+ case "invalid_json":
238
+ return { message: "analytics.json is not valid JSON", remedy: "fix the JSON syntax" };
239
+ case "type":
240
+ return {
241
+ message: "has the wrong type",
242
+ remedy: 'use { "schemaVersion": 1, "destinations": [{ "adapter": "<adapter id>", "ids": { "live": ["<id>"] } }] }',
243
+ };
244
+ case "unknown_field":
245
+ return { message: "is not an analytics.json field", remedy: "remove it; analytics.json is a closed contract" };
246
+ case "unknown_enum":
247
+ return path === "euUkVisitors"
248
+ ? { message: "is not an EU/UK visitor policy", remedy: `use one of: ${ANALYTICS_EU_UK_POLICIES.join(", ")}` }
249
+ : { message: "is not a stage", remedy: `use one of: ${ANALYTICS_STAGES.join(", ")}` };
250
+ case "unsupported_version":
251
+ return { message: "is not a supported schemaVersion", remedy: `set it to ${ANALYTICS_DECLARATION_SCHEMA_VERSION}` };
252
+ case "unknown_adapter":
253
+ return { message: "is not a first-class analytics adapter", remedy: `use one of: ${ADAPTER_IDS}` };
254
+ case "empty":
255
+ return { message: "names no destination id", remedy: "name at least one id, or remove the stage" };
256
+ case "too_many_destinations":
257
+ return { message: "names more destination ids than this adapter accepts per stage", remedy: "remove the extra ids" };
258
+ case "format":
259
+ return { message: "is not a valid destination id for this adapter", remedy: `use the adapter's id format (${ID_FORMATS})` };
260
+ case "duplicate":
261
+ return path.endsWith(".adapter")
262
+ ? { message: "names an adapter an earlier destination already names", remedy: "declare each adapter once, with all of its ids" }
263
+ : { message: "names the same destination id twice", remedy: "list each id once" };
264
+ case "required":
265
+ return { message: "has no valid live destination id", remedy: "declare a valid live id for every destination" };
266
+ case "non_live_reuses_live_id":
267
+ return {
268
+ message: "reuses a live destination id, so non-live traffic would reach the live property",
269
+ remedy: "use a separate test id, or leave the stage unset",
270
+ };
271
+ default:
272
+ return { message: "is invalid", remedy: "see docs/architecture/analytics-activation.md" };
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Every violation in an `analytics.json` file's text. A file that is present but holds `null` is
278
+ * a `(root)` `type` violation: only an absent file declares nothing.
279
+ * @param {string} text
280
+ * @returns {AnalyticsViolation[]}
281
+ */
282
+ export function analyticsDeclarationTextViolations(text) {
283
+ const parsed = parseAnalyticsDeclaration(text);
284
+ if (!parsed.ok) return [...parsed.violations];
285
+ if (parsed.value === null) return [{ path: "(root)", code: "type" }];
286
+ return analyticsDeclarationViolations(parsed.value);
287
+ }
288
+
289
+ /**
290
+ * Every finding in an `analytics.json` file's text, each value-free.
291
+ * @param {string} text
292
+ * @returns {AnalyticsFinding[]}
293
+ */
294
+ export function analyticsDeclarationFindings(text) {
295
+ return analyticsDeclarationTextViolations(text).map((violation) => {
296
+ const { message, remedy } = analyticsViolationText(violation);
297
+ return { ...violation, message: `${violation.path}: [${violation.code}] ${message}`, remedy };
298
+ });
299
+ }
@@ -0,0 +1,162 @@
1
+ -- Contact vault: order customer contact and contact-form messages are stored only as AES-256-GCM
2
+ -- envelopes (src/lib/orders/vault/envelope.ts) in expiring rows. One transaction per file.
3
+ --
4
+ -- The plaintext scrappy_order_addresses table is dropped only when it is empty: the guard row's
5
+ -- CHECK fails on any other count and aborts the whole file.
6
+ CREATE TABLE `_gv_vault_guard` (`n` integer NOT NULL CHECK (`n` = 0));
7
+ --> statement-breakpoint
8
+ INSERT INTO `_gv_vault_guard` (`n`) SELECT COUNT(*) FROM `scrappy_order_addresses`;
9
+ --> statement-breakpoint
10
+ DROP TABLE `_gv_vault_guard`;
11
+ --> statement-breakpoint
12
+ DROP TABLE `scrappy_order_addresses`;
13
+ --> statement-breakpoint
14
+ CREATE TABLE `order_contact_vault` (
15
+ `tenant_id` text NOT NULL,
16
+ `stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
17
+ `order_id` text NOT NULL,
18
+ `key_id` text NOT NULL,
19
+ `nonce` text NOT NULL,
20
+ `ciphertext` text NOT NULL,
21
+ `not_after` text NOT NULL,
22
+ `expires_at` text NOT NULL CHECK (`expires_at` <= `not_after`),
23
+ `sealed_at` text NOT NULL,
24
+ `sealed_jti` text NOT NULL,
25
+ PRIMARY KEY (`tenant_id`, `stage`, `order_id`),
26
+ FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
27
+ );
28
+ --> statement-breakpoint
29
+ CREATE INDEX `idx_order_contact_vault_expires` ON `order_contact_vault` (`expires_at`);
30
+ --> statement-breakpoint
31
+ CREATE INDEX `idx_order_contact_vault_key` ON `order_contact_vault` (`key_id`);
32
+ --> statement-breakpoint
33
+ CREATE TRIGGER `order_contact_vault_retention_only_shrinks`
34
+ BEFORE UPDATE ON `order_contact_vault`
35
+ WHEN NEW.`expires_at` > OLD.`expires_at`
36
+ OR NEW.`not_after` IS NOT OLD.`not_after`
37
+ OR NEW.`key_id` IS NOT OLD.`key_id`
38
+ OR NEW.`nonce` IS NOT OLD.`nonce`
39
+ OR NEW.`ciphertext` IS NOT OLD.`ciphertext`
40
+ OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
41
+ OR NEW.`stage` IS NOT OLD.`stage`
42
+ OR NEW.`order_id` IS NOT OLD.`order_id`
43
+ BEGIN
44
+ SELECT RAISE(ABORT, 'vault-immutable');
45
+ END;
46
+ --> statement-breakpoint
47
+ CREATE TABLE `order_contact_tombstones` (
48
+ `tenant_id` text NOT NULL,
49
+ `stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
50
+ `order_id` text NOT NULL,
51
+ `reason` text NOT NULL CHECK (`reason` IN ('purged', 'erased')),
52
+ `at` text NOT NULL,
53
+ `not_after` text NOT NULL,
54
+ PRIMARY KEY (`tenant_id`, `stage`, `order_id`)
55
+ );
56
+ --> statement-breakpoint
57
+ CREATE INDEX `idx_order_contact_tombstones_not_after` ON `order_contact_tombstones` (`not_after`);
58
+ --> statement-breakpoint
59
+ CREATE TABLE `contact_access_audit` (
60
+ `audit_id` text NOT NULL PRIMARY KEY,
61
+ `tenant_id` text NOT NULL,
62
+ `stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
63
+ `record_kind` text NOT NULL CHECK (`record_kind` IN ('order-contact', 'contact-message')),
64
+ `record_id` text NOT NULL,
65
+ `at` text NOT NULL,
66
+ `actor_id` text NOT NULL,
67
+ `actor_role` text NOT NULL CHECK (`actor_role` IN ('owner', 'staff')),
68
+ `reason_code` text,
69
+ `ticket_ref` text,
70
+ `action` text NOT NULL CHECK (`action` IN ('read', 'list', 'erase')),
71
+ `outcome` text NOT NULL CHECK (`outcome` = 'admitted'),
72
+ CHECK (`actor_role` = 'owner' OR (`reason_code` IS NOT NULL AND `ticket_ref` IS NOT NULL))
73
+ );
74
+ --> statement-breakpoint
75
+ CREATE INDEX `idx_contact_access_audit_quota` ON `contact_access_audit` (`tenant_id`, `actor_id`, `actor_role`, `at`);
76
+ --> statement-breakpoint
77
+ CREATE TRIGGER `contact_access_audit_no_update`
78
+ BEFORE UPDATE ON `contact_access_audit`
79
+ BEGIN
80
+ SELECT RAISE(ABORT, 'append-only');
81
+ END;
82
+ --> statement-breakpoint
83
+ CREATE TRIGGER `contact_access_audit_no_delete`
84
+ BEFORE DELETE ON `contact_access_audit`
85
+ BEGIN
86
+ SELECT RAISE(ABORT, 'append-only');
87
+ END;
88
+ --> statement-breakpoint
89
+ -- contact_messages: every pre-existing row is kept as a legacy row (plaintext in legacy_* until
90
+ -- the rollout seals it) under stage publication; new rows are written sealed only.
91
+ CREATE TABLE `contact_messages_sealed` (
92
+ `id` text PRIMARY KEY NOT NULL,
93
+ `tenant_id` text NOT NULL,
94
+ `stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
95
+ `key_id` text,
96
+ `nonce` text,
97
+ `ciphertext` text,
98
+ `not_after` text NOT NULL,
99
+ `expires_at` text NOT NULL CHECK (`expires_at` <= `not_after`),
100
+ `page_path` text,
101
+ `status` text NOT NULL DEFAULT 'unread' CHECK (`status` IN ('unread', 'read')),
102
+ `created_at` text NOT NULL,
103
+ `read_at` text,
104
+ `notify_status` text NOT NULL DEFAULT 'pending' CHECK (`notify_status` IN ('pending', 'sent', 'failed')),
105
+ `notify_attempts` integer NOT NULL DEFAULT 0,
106
+ `legacy_name` text,
107
+ `legacy_email` text,
108
+ `legacy_phone` text,
109
+ `legacy_message` text,
110
+ CHECK (
111
+ (`key_id` IS NOT NULL AND `nonce` IS NOT NULL AND `ciphertext` IS NOT NULL
112
+ AND `legacy_name` IS NULL AND `legacy_email` IS NULL AND `legacy_phone` IS NULL AND `legacy_message` IS NULL)
113
+ OR (`key_id` IS NULL AND `nonce` IS NULL AND `ciphertext` IS NULL
114
+ AND `legacy_email` IS NOT NULL AND `legacy_message` IS NOT NULL)
115
+ )
116
+ );
117
+ --> statement-breakpoint
118
+ INSERT INTO `contact_messages_sealed`
119
+ (`id`, `tenant_id`, `stage`, `not_after`, `expires_at`, `page_path`, `status`, `created_at`, `read_at`,
120
+ `notify_status`, `notify_attempts`, `legacy_name`, `legacy_email`, `legacy_phone`, `legacy_message`)
121
+ SELECT `id`, `tenant_id`, 'publication',
122
+ strftime('%Y-%m-%dT%H:%M:%fZ', `created_at`, '+365 days'),
123
+ strftime('%Y-%m-%dT%H:%M:%fZ', `created_at`, '+365 days'),
124
+ `page_path`, `status`, `created_at`, `read_at`, `notify_status`, `notify_attempts`,
125
+ `name`, `email`, `phone`, `message`
126
+ FROM `contact_messages`;
127
+ --> statement-breakpoint
128
+ DROP TABLE `contact_messages`;
129
+ --> statement-breakpoint
130
+ ALTER TABLE `contact_messages_sealed` RENAME TO `contact_messages`;
131
+ --> statement-breakpoint
132
+ CREATE INDEX `idx_contact_messages_tenant_stage_created` ON `contact_messages` (`tenant_id`, `stage`, `created_at`);
133
+ --> statement-breakpoint
134
+ CREATE INDEX `idx_contact_messages_tenant_stage_status` ON `contact_messages` (`tenant_id`, `stage`, `status`);
135
+ --> statement-breakpoint
136
+ CREATE INDEX `idx_contact_messages_expires` ON `contact_messages` (`expires_at`);
137
+ --> statement-breakpoint
138
+ CREATE INDEX `idx_contact_messages_legacy` ON `contact_messages` (`id`) WHERE `ciphertext` IS NULL;
139
+ --> statement-breakpoint
140
+ CREATE TRIGGER `contact_messages_no_plaintext_insert`
141
+ BEFORE INSERT ON `contact_messages`
142
+ WHEN NEW.`legacy_name` IS NOT NULL OR NEW.`legacy_email` IS NOT NULL
143
+ OR NEW.`legacy_phone` IS NOT NULL OR NEW.`legacy_message` IS NOT NULL
144
+ BEGIN
145
+ SELECT RAISE(ABORT, 'plaintext-refused');
146
+ END;
147
+ --> statement-breakpoint
148
+ CREATE TRIGGER `contact_messages_no_plaintext_update`
149
+ BEFORE UPDATE ON `contact_messages`
150
+ WHEN (NEW.`legacy_name` IS NOT NULL AND NEW.`legacy_name` IS NOT OLD.`legacy_name`)
151
+ OR (NEW.`legacy_email` IS NOT NULL AND NEW.`legacy_email` IS NOT OLD.`legacy_email`)
152
+ OR (NEW.`legacy_phone` IS NOT NULL AND NEW.`legacy_phone` IS NOT OLD.`legacy_phone`)
153
+ OR (NEW.`legacy_message` IS NOT NULL AND NEW.`legacy_message` IS NOT OLD.`legacy_message`)
154
+ OR NEW.`expires_at` > OLD.`expires_at`
155
+ OR NEW.`not_after` IS NOT OLD.`not_after`
156
+ OR (OLD.`ciphertext` IS NOT NULL AND (NEW.`ciphertext` IS NOT OLD.`ciphertext`
157
+ OR NEW.`key_id` IS NOT OLD.`key_id` OR NEW.`nonce` IS NOT OLD.`nonce`))
158
+ OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
159
+ OR NEW.`stage` IS NOT OLD.`stage`
160
+ BEGIN
161
+ SELECT RAISE(ABORT, 'vault-immutable');
162
+ END;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Who may read customer contact data on THIS tenant, and on what authority.
3
+ *
4
+ * `isOwnerViewer` is not membership: a ToT-staff selection carries a capability, and a selection
5
+ * whose capability is `owner` makes `isOwnerViewer` true (loginGate `sessionHostCapability`). Contact
6
+ * reads must tell the two apart, because reading order and customer PII is grant-required for staff
7
+ * (guidance://access-authority) while an owner reads their own store's data. So:
8
+ *
9
+ * owner — a `tenants[]` membership for this host, or a session scoped to this host
10
+ * (`record.resource === host`), whose role is owner or admin. A staff selection never
11
+ * counts, whatever its capability.
12
+ * staff — no owner standing, but a live staff selection for this host. Staff still need a live
13
+ * `customer-contact-read` grant ({@link resolveContactReadGrant}) and a reason.
14
+ * none — anything else.
15
+ */
16
+ import type { SessionRecord } from "./session.js";
17
+ import { normalizeMemberships, staffAdmits } from "@/lib/access/dashboard/tenantSelection";
18
+ import { isOwnerCapability } from "@/lib/access/shared/capability";
19
+ import { resolveNamedSubjectGrant, type ViewerShipGrantDeps } from "@/lib/access/shared/viewerGrant";
20
+
21
+ export type ContactReaderAuthority =
22
+ | { kind: "owner"; actorId: string }
23
+ | { kind: "staff"; actorId: string; email: string }
24
+ | { kind: "none" };
25
+
26
+ const ACTOR_ID = /^[A-Za-z0-9._:-]{1,128}$/;
27
+
28
+ export function contactReaderAuthority(
29
+ record: SessionRecord | null | undefined,
30
+ hostResource: string,
31
+ nowSeconds: number,
32
+ ): ContactReaderAuthority {
33
+ const host = (hostResource ?? "").trim();
34
+ if (!record || !host) return { kind: "none" };
35
+ const actorId = typeof record.subject === "string" && ACTOR_ID.test(record.subject) ? record.subject : null;
36
+ if (!actorId) return { kind: "none" };
37
+
38
+ const membership = normalizeMemberships(record.tenants).find((t) => t.resource === host);
39
+ const scoped = record.resource === host ? (record.capability ?? "").trim() : "";
40
+ if (isOwnerCapability(membership?.role) || isOwnerCapability(scoped)) return { kind: "owner", actorId };
41
+
42
+ const staffScope = Array.isArray(record.staff) && record.staff.length > 0;
43
+ if (staffScope && staffAdmits({ selection: record.staffSelection, nowSeconds }, host)) {
44
+ return { kind: "staff", actorId, email: record.email };
45
+ }
46
+ return { kind: "none" };
47
+ }
48
+
49
+ export const CONTACT_READ_CAPABILITY = "customer-contact-read";
50
+
51
+ /**
52
+ * The staff principal's LIVE owner-consented `customer-contact-read` grant over `tenant`, read from
53
+ * the tot-mcp subject-capability seam (contract: decision gv-contact-vault-ownership). True only on
54
+ * an explicit `200 { capability: "customer-contact-read" }`. Unconfigured, any non-2xx, a timeout,
55
+ * an unparseable body or any other capability is false. No cache: a revoke takes effect on the next
56
+ * read. Never throws.
57
+ */
58
+ export async function resolveContactReadGrant(
59
+ email: string | null | undefined,
60
+ tenant: string | null | undefined,
61
+ deps: ViewerShipGrantDeps = {},
62
+ ): Promise<boolean> {
63
+ return resolveNamedSubjectGrant(email, tenant, CONTACT_READ_CAPABILITY, deps);
64
+ }
@@ -19,7 +19,7 @@ export const STATIC_SCRIPT_HASHES: readonly string[] = [
19
19
  "'sha256-YicfxUFBkKrQapZ/tzZKlaKTdNHgwVfv7y2Wa0NC6/k='",
20
20
  "'sha256-eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc='",
21
21
  "'sha256-elMLh/6Oubj8u9FRLwZLyRa6TqKikGs75mf0jfWcoxI='",
22
- "'sha256-bANHtvoDkw4GnpBynM5Ib1cxdoDeNsGkPhPnDEphW3Y='",
22
+ "'sha256-MG9XnFVbN8UmLcNws9A8ckOhQJI+MnAKxpCI7oBMS3Q='",
23
23
  ];
24
24
 
25
25
  export const STATIC_STYLE_HASHES: readonly string[] = [
@@ -108,6 +108,36 @@ async function resolveRawViewerCapability(
108
108
  return "preview-access";
109
109
  }
110
110
 
111
+ /**
112
+ * Whether `email` holds a LIVE owner-consented grant of exactly `capability` over `appDomain`,
113
+ * asked of the same subject-capability seam with the capability named. True only on an explicit
114
+ * `200 { capability: <capability> }`; unconfigured, any non-2xx, a timeout, an unparseable body or
115
+ * any other answer is false. No cache: a revoke takes effect on the next call. Never throws.
116
+ */
117
+ export async function resolveNamedSubjectGrant(
118
+ email: string | null | undefined,
119
+ appDomain: string | null | undefined,
120
+ capability: "customer-contact-read",
121
+ deps: ViewerShipGrantDeps = {},
122
+ ): Promise<boolean> {
123
+ const subject = (email ?? "").trim().toLowerCase();
124
+ const tenant = (appDomain ?? "").trim();
125
+ if (!subject || !tenant) return false;
126
+ try {
127
+ const outcome = await callMcpService({
128
+ method: "GET",
129
+ path: "/internal/dev-access/subject-capability",
130
+ query: { subject, tenant, app: "storefront", capability },
131
+ transport: deps.transport ?? new FetchShipGrantTransport(),
132
+ });
133
+ if (outcome.kind !== "ok") return false;
134
+ const payload = (await outcome.json()) as Record<string, unknown> | null;
135
+ return payload?.capability === capability;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
111
141
  /**
112
142
  * Resolve the viewer's LIVE ship capability over `appDomain`. Returns the granted
113
143
  * `Capability` — `"ship-on-behalf"` ONLY on an explicit `200 { capability:
@@ -12,7 +12,7 @@
12
12
  * - a CloudFront-forwarded Tier 3 request (its `cf` describes CloudFront, not the
13
13
  * viewer);
14
14
  * - no `request.cf` at all.
15
- * The page then keeps whatever class it already holds, or none (count-only).
15
+ * The page then keeps whatever class it already holds, or none (the EU/UK policy).
16
16
  *
17
17
  * The local dev loop's `request.cf` comes from the dev runtime's cached `cf.json`, which
18
18
  * describes the developer's own location. `?tot_region=eu|x|u` overrides it so both
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The contact access audit (`contact_access_audit`, append-only): every read, list and erase of
3
+ * sealed customer contact writes its row BEFORE anything is decrypted. Staff rows carry the
4
+ * 30-per-hour quota in the same statement, so concurrent reads can never pass it together.
5
+ * Rows hold opaque ids, codes and times only — never a contact field.
6
+ */
7
+
8
+ export type VaultStage = "preview" | "publication";
9
+
10
+ export const STAFF_READS_PER_HOUR = 30;
11
+ const HOUR_MS = 3_600_000;
12
+
13
+ export interface ContactActor {
14
+ actorId: string;
15
+ role: "owner" | "staff";
16
+ reasonCode?: string;
17
+ ticketRef?: string;
18
+ }
19
+
20
+ export interface AuditStatement {
21
+ sql: string;
22
+ params: readonly unknown[];
23
+ }
24
+
25
+ /** The SQL surface the audit needs; D1's `fromD1` and the sqlite test harness both satisfy it. */
26
+ export interface AuditDb {
27
+ all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
28
+ }
29
+
30
+ /**
31
+ * The audit insert. RETURNING tells admitted from refused (quota, or `requireRow` absent); an
32
+ * error means the audit could not be written.
33
+ */
34
+ export function auditStatement(input: {
35
+ auditId: string;
36
+ tenantId: string;
37
+ stage: VaultStage;
38
+ recordKind: "order-contact" | "contact-message";
39
+ recordId: string;
40
+ actor: ContactActor;
41
+ action: "read" | "list" | "erase";
42
+ now: Date;
43
+ requireRow?: AuditStatement;
44
+ }): AuditStatement {
45
+ const { auditId, tenantId, stage, recordKind, recordId, actor, action, now } = input;
46
+ const hourAgo = new Date(now.getTime() - HOUR_MS).toISOString();
47
+ const params: unknown[] = [
48
+ auditId, tenantId, stage, recordKind, recordId, now.toISOString(), actor.actorId, actor.role,
49
+ actor.role === "staff" ? actor.reasonCode ?? null : null,
50
+ actor.role === "staff" ? actor.ticketRef ?? null : null,
51
+ action,
52
+ actor.role, tenantId, actor.actorId, hourAgo, STAFF_READS_PER_HOUR,
53
+ ];
54
+ const requireRow = input.requireRow ? ` AND EXISTS (${input.requireRow.sql})` : "";
55
+ if (input.requireRow) params.push(...input.requireRow.params);
56
+ return {
57
+ sql: `INSERT INTO contact_access_audit
58
+ (audit_id, tenant_id, stage, record_kind, record_id, at, actor_id, actor_role, reason_code, ticket_ref, action, outcome)
59
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'admitted'
60
+ WHERE (? = 'owner' OR (SELECT COUNT(*) FROM contact_access_audit
61
+ WHERE tenant_id = ? AND actor_id = ? AND actor_role = 'staff' AND at > ?) < ?)${requireRow}
62
+ RETURNING audit_id`,
63
+ params,
64
+ };
65
+ }
66
+
67
+ /** Run the audit insert alone: "admitted", "quota" (no row inserted) or "unavailable" (error). */
68
+ export async function admitAudit(db: AuditDb, statement: AuditStatement): Promise<"admitted" | "quota" | "unavailable"> {
69
+ try {
70
+ const rows = await db.all(statement.sql, ...statement.params);
71
+ return rows.length === 1 ? "admitted" : "quota";
72
+ } catch {
73
+ return "unavailable";
74
+ }
75
+ }
76
+
77
+ /** The same insert without RETURNING, for use inside a batch whose later statements check the row. */
78
+ export function batchAuditStatement(statement: AuditStatement): AuditStatement {
79
+ return { sql: statement.sql.replace(/\s*RETURNING audit_id$/, ""), params: statement.params };
80
+ }