@tokenoftrust/storefront-runner 4.11.0 → 4.11.1

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 (27) hide show
  1. package/apps/storefront/migrations-apps/0021_contact_vault.sql +4 -0
  2. package/apps/storefront/migrations-apps/0022_contact_vault_fields.sql +342 -0
  3. package/apps/storefront/src/lib/customers/contactVault/blindIndex.ts +28 -0
  4. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +21 -7
  5. package/apps/storefront/src/lib/customers/contactVault/dataKeys.ts +256 -0
  6. package/apps/storefront/src/lib/customers/contactVault/fieldEnvelope.ts +88 -0
  7. package/apps/storefront/src/lib/customers/contactVault/isolateMetrics.ts +37 -0
  8. package/apps/storefront/src/lib/customers/contactVault/kms.ts +292 -0
  9. package/apps/storefront/src/lib/customers/contactVault/retention.ts +270 -12
  10. package/apps/storefront/src/lib/customers/contactVault/retentionPolicy.ts +290 -0
  11. package/apps/storefront/src/lib/customers/contactVault/zip3.ts +24 -0
  12. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +458 -118
  13. package/apps/storefront/src/lib/engagement/contact/messagesTabClient.ts +33 -3
  14. package/apps/storefront/src/lib/orders/admin/orderActions.ts +180 -32
  15. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +5 -3
  16. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -4
  17. package/apps/storefront/src/lib/orders/vault/kmsEnv.ts +11 -0
  18. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +1053 -227
  19. package/apps/storefront/src/lib/orders/vault/vault-test-support.ts +258 -0
  20. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +34 -5
  21. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contact-search.ts +25 -0
  22. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contacts.ts +21 -0
  23. package/apps/storefront/src/pages/api/contact.ts +7 -9
  24. package/apps/storefront/src/scheduled/contactVaultSweep.ts +55 -26
  25. package/package.json +1 -1
  26. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +0 -166
  27. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +0 -7
@@ -3,6 +3,10 @@
3
3
  --
4
4
  -- The plaintext scrappy_order_addresses table is dropped only when it is empty: the guard row's
5
5
  -- CHECK fails on any other count and aborts the whole file.
6
+ --
7
+ -- allow-destructive: the three drops lose no data — _gv_vault_guard is this file's own guard,
8
+ -- scrappy_order_addresses is dropped only after the guard proves it empty, and contact_messages is
9
+ -- dropped only after every row is copied into contact_messages_sealed (migration0021.test.ts).
6
10
  CREATE TABLE `_gv_vault_guard` (`n` integer NOT NULL CHECK (`n` = 0));
7
11
  --> statement-breakpoint
8
12
  INSERT INTO `_gv_vault_guard` (`n`) SELECT COUNT(*) FROM `scrappy_order_addresses`;
@@ -0,0 +1,342 @@
1
+ -- Field-level contact vault: each personal field is its own AES-256-GCM ciphertext under a
2
+ -- per-tenant data key wrapped by AWS KMS (src/lib/customers/contactVault/). Retention is per
3
+ -- field group: an immutable ceiling bound into every field's AAD, a shrink-only effective expiry,
4
+ -- and an immutable seal-time floor (retentionPolicy.ts). The vault row is also the long-lived
5
+ -- facts row: after its last group is cleared it is detached from the order and keeps only
6
+ -- unlinked month, zip3, region, country and totals.
7
+ --
8
+ -- allow-destructive: order_contact_vault, order_contact_tombstones and contact_access_audit are
9
+ -- dropped and rebuilt only when empty (the guard below aborts the whole file otherwise), and
10
+ -- contact_messages is rebuilt preserving every row; no contact data exists in any of them yet.
11
+ CREATE TABLE `_gv_vault_v2_guard` (`n` integer NOT NULL CHECK (`n` = 0));
12
+ --> statement-breakpoint
13
+ INSERT INTO `_gv_vault_v2_guard` (`n`)
14
+ SELECT (SELECT COUNT(*) FROM `order_contact_vault`)
15
+ + (SELECT COUNT(*) FROM `order_contact_tombstones`)
16
+ + (SELECT COUNT(*) FROM `contact_access_audit`)
17
+ + (SELECT COUNT(*) FROM `contact_messages` WHERE `ciphertext` IS NOT NULL);
18
+ --> statement-breakpoint
19
+ DROP TABLE `_gv_vault_v2_guard`;
20
+ --> statement-breakpoint
21
+ DROP TABLE `order_contact_vault`;
22
+ --> statement-breakpoint
23
+ DROP TABLE `order_contact_tombstones`;
24
+ --> statement-breakpoint
25
+ DROP TABLE `contact_access_audit`;
26
+ --> statement-breakpoint
27
+ CREATE TABLE `contact_vault_data_keys` (
28
+ `tenant_id` text NOT NULL,
29
+ `stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
30
+ `purpose` text NOT NULL CHECK (`purpose` IN ('order-contact', 'contact-message', 'blind-index')),
31
+ `kid` text NOT NULL,
32
+ `wrapped_key` text NOT NULL,
33
+ `kms_key_arn` text NOT NULL,
34
+ `created_at` text NOT NULL,
35
+ `retired_at` text,
36
+ PRIMARY KEY (`tenant_id`, `stage`, `purpose`, `kid`)
37
+ );
38
+ --> statement-breakpoint
39
+ CREATE UNIQUE INDEX `uq_contact_vault_data_keys_active`
40
+ ON `contact_vault_data_keys` (`tenant_id`, `stage`, `purpose`) WHERE `retired_at` IS NULL;
41
+ --> statement-breakpoint
42
+ CREATE TRIGGER `contact_vault_data_keys_immutable`
43
+ BEFORE UPDATE ON `contact_vault_data_keys`
44
+ WHEN NEW.`wrapped_key` IS NOT OLD.`wrapped_key`
45
+ OR NEW.`kms_key_arn` IS NOT OLD.`kms_key_arn`
46
+ OR NEW.`kid` IS NOT OLD.`kid`
47
+ OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
48
+ OR NEW.`stage` IS NOT OLD.`stage`
49
+ OR NEW.`purpose` IS NOT OLD.`purpose`
50
+ OR (OLD.`retired_at` IS NOT NULL AND NEW.`retired_at` IS NOT OLD.`retired_at`)
51
+ BEGIN
52
+ SELECT RAISE(ABORT, 'data-key-immutable');
53
+ END;
54
+ --> statement-breakpoint
55
+ CREATE TABLE `order_contact_vault` (
56
+ `fact_id` text PRIMARY KEY NOT NULL,
57
+ `tenant_id` text NOT NULL,
58
+ `stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
59
+ `order_id` text,
60
+ `placed_month` text NOT NULL CHECK (length(`placed_month`) = 6),
61
+ `currency` text,
62
+ `order_total_minor` integer,
63
+ `item_count` integer,
64
+ `ship_region` text,
65
+ `ship_country` text,
66
+ `ship_zip3` text,
67
+ `bill_region` text,
68
+ `bill_country` text,
69
+ `bill_zip3` text,
70
+ `time_scale` integer,
71
+ `rule_version` text,
72
+ `order_kid` text,
73
+ `bidx_kid` text,
74
+ `sealed_at` text,
75
+ `sealed_jti` text,
76
+ `erased_at` text,
77
+ `name_ct` text,
78
+ `company_ct` text,
79
+ `identity_ceiling` text,
80
+ `identity_expires_at` text,
81
+ `identity_floor_until` text,
82
+ `identity_rule` text,
83
+ `email_ct` text,
84
+ `email_bidx` text,
85
+ `email_ceiling` text,
86
+ `email_expires_at` text,
87
+ `email_floor_until` text,
88
+ `email_rule` text,
89
+ `phone_ct` text,
90
+ `phone_bidx` text,
91
+ `phone_ceiling` text,
92
+ `phone_expires_at` text,
93
+ `phone_floor_until` text,
94
+ `phone_rule` text,
95
+ `ship_name_ct` text,
96
+ `ship_company_ct` text,
97
+ `ship_line1_ct` text,
98
+ `ship_line2_ct` text,
99
+ `ship_city` text,
100
+ `ship_postal_code` text,
101
+ `ship_ceiling` text,
102
+ `ship_expires_at` text,
103
+ `ship_floor_until` text,
104
+ `ship_rule` text,
105
+ `bill_name_ct` text,
106
+ `bill_company_ct` text,
107
+ `bill_line1_ct` text,
108
+ `bill_line2_ct` text,
109
+ `bill_city` text,
110
+ `bill_postal_code` text,
111
+ `bill_ceiling` text,
112
+ `bill_expires_at` text,
113
+ `bill_floor_until` text,
114
+ `bill_rule` text,
115
+ CHECK (`identity_expires_at` IS NULL OR `identity_expires_at` <= `identity_ceiling`),
116
+ CHECK (`email_expires_at` IS NULL OR `email_expires_at` <= `email_ceiling`),
117
+ CHECK (`phone_expires_at` IS NULL OR `phone_expires_at` <= `phone_ceiling`),
118
+ CHECK (`ship_expires_at` IS NULL OR `ship_expires_at` <= `ship_ceiling`),
119
+ CHECK (`bill_expires_at` IS NULL OR `bill_expires_at` <= `bill_ceiling`),
120
+ FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
121
+ );
122
+ --> statement-breakpoint
123
+ CREATE UNIQUE INDEX `uq_order_contact_vault_order`
124
+ ON `order_contact_vault` (`tenant_id`, `stage`, `order_id`) WHERE `order_id` IS NOT NULL;
125
+ --> statement-breakpoint
126
+ CREATE INDEX `idx_order_contact_vault_email_bidx`
127
+ ON `order_contact_vault` (`tenant_id`, `stage`, `email_bidx`) WHERE `email_bidx` IS NOT NULL;
128
+ --> statement-breakpoint
129
+ CREATE INDEX `idx_order_contact_vault_phone_bidx`
130
+ ON `order_contact_vault` (`tenant_id`, `stage`, `phone_bidx`) WHERE `phone_bidx` IS NOT NULL;
131
+ --> statement-breakpoint
132
+ CREATE INDEX `idx_order_contact_vault_order_kid` ON `order_contact_vault` (`order_kid`) WHERE `order_kid` IS NOT NULL;
133
+ --> statement-breakpoint
134
+ CREATE INDEX `idx_order_contact_vault_bidx_kid` ON `order_contact_vault` (`bidx_kid`) WHERE `bidx_kid` IS NOT NULL;
135
+ --> statement-breakpoint
136
+ CREATE INDEX `idx_order_contact_vault_linked` ON `order_contact_vault` (`tenant_id`, `stage`) WHERE `order_id` IS NOT NULL;
137
+ --> statement-breakpoint
138
+ -- A group may only expire sooner (to a non-NULL value) and may only be cleared, never refilled;
139
+ -- its ceiling, floor and rule never change. The one exception is the detach statement (the order
140
+ -- link goes to NULL with every group already empty), which nulls all of them. A blind index takes
141
+ -- a new value only as a rotation recompute: its group still sealed with the same ciphertext and
142
+ -- bidx_kid moving to another kid in the same statement, replacing every index the row holds;
143
+ -- bidx_kid goes to NULL only with both indexes. The order link only ever goes to NULL.
144
+ CREATE TRIGGER `order_contact_vault_shrink_only`
145
+ BEFORE UPDATE ON `order_contact_vault`
146
+ WHEN NEW.`tenant_id` IS NOT OLD.`tenant_id`
147
+ OR NEW.`stage` IS NOT OLD.`stage`
148
+ OR NEW.`fact_id` IS NOT OLD.`fact_id`
149
+ OR NEW.`placed_month` IS NOT OLD.`placed_month`
150
+ OR (NEW.`order_id` IS NOT NULL AND NEW.`order_id` IS NOT OLD.`order_id`)
151
+ OR (NEW.`identity_ceiling` IS NOT OLD.`identity_ceiling` AND (NEW.`identity_ceiling` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
152
+ OR (NEW.`identity_floor_until` IS NOT OLD.`identity_floor_until` AND (NEW.`identity_floor_until` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
153
+ OR (NEW.`identity_rule` IS NOT OLD.`identity_rule` AND (NEW.`identity_rule` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
154
+ OR (NEW.`identity_expires_at` IS NOT OLD.`identity_expires_at` AND NOT ((NEW.`identity_expires_at` IS NULL AND (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)) OR (NEW.`identity_expires_at` IS NOT NULL AND OLD.`identity_expires_at` IS NOT NULL AND NEW.`identity_expires_at` < OLD.`identity_expires_at`)))
155
+ OR (NEW.`email_ceiling` IS NOT OLD.`email_ceiling` AND (NEW.`email_ceiling` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
156
+ OR (NEW.`email_floor_until` IS NOT OLD.`email_floor_until` AND (NEW.`email_floor_until` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
157
+ OR (NEW.`email_rule` IS NOT OLD.`email_rule` AND (NEW.`email_rule` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
158
+ OR (NEW.`email_expires_at` IS NOT OLD.`email_expires_at` AND NOT ((NEW.`email_expires_at` IS NULL AND (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)) OR (NEW.`email_expires_at` IS NOT NULL AND OLD.`email_expires_at` IS NOT NULL AND NEW.`email_expires_at` < OLD.`email_expires_at`)))
159
+ OR (NEW.`phone_ceiling` IS NOT OLD.`phone_ceiling` AND (NEW.`phone_ceiling` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
160
+ OR (NEW.`phone_floor_until` IS NOT OLD.`phone_floor_until` AND (NEW.`phone_floor_until` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
161
+ OR (NEW.`phone_rule` IS NOT OLD.`phone_rule` AND (NEW.`phone_rule` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
162
+ OR (NEW.`phone_expires_at` IS NOT OLD.`phone_expires_at` AND NOT ((NEW.`phone_expires_at` IS NULL AND (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)) OR (NEW.`phone_expires_at` IS NOT NULL AND OLD.`phone_expires_at` IS NOT NULL AND NEW.`phone_expires_at` < OLD.`phone_expires_at`)))
163
+ OR (NEW.`ship_ceiling` IS NOT OLD.`ship_ceiling` AND (NEW.`ship_ceiling` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
164
+ OR (NEW.`ship_floor_until` IS NOT OLD.`ship_floor_until` AND (NEW.`ship_floor_until` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
165
+ OR (NEW.`ship_rule` IS NOT OLD.`ship_rule` AND (NEW.`ship_rule` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
166
+ OR (NEW.`ship_expires_at` IS NOT OLD.`ship_expires_at` AND NOT ((NEW.`ship_expires_at` IS NULL AND (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)) OR (NEW.`ship_expires_at` IS NOT NULL AND OLD.`ship_expires_at` IS NOT NULL AND NEW.`ship_expires_at` < OLD.`ship_expires_at`)))
167
+ OR (NEW.`bill_ceiling` IS NOT OLD.`bill_ceiling` AND (NEW.`bill_ceiling` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
168
+ OR (NEW.`bill_floor_until` IS NOT OLD.`bill_floor_until` AND (NEW.`bill_floor_until` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
169
+ OR (NEW.`bill_rule` IS NOT OLD.`bill_rule` AND (NEW.`bill_rule` IS NOT NULL OR NOT (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)))
170
+ OR (NEW.`bill_expires_at` IS NOT OLD.`bill_expires_at` AND NOT ((NEW.`bill_expires_at` IS NULL AND (NEW.`order_id` IS NULL AND NEW.`name_ct` IS NULL AND NEW.`company_ct` IS NULL AND NEW.`email_ct` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_ct` IS NULL AND NEW.`phone_bidx` IS NULL AND NEW.`ship_name_ct` IS NULL AND NEW.`ship_company_ct` IS NULL AND NEW.`ship_line1_ct` IS NULL AND NEW.`ship_line2_ct` IS NULL AND NEW.`ship_city` IS NULL AND NEW.`ship_postal_code` IS NULL AND NEW.`bill_name_ct` IS NULL AND NEW.`bill_company_ct` IS NULL AND NEW.`bill_line1_ct` IS NULL AND NEW.`bill_line2_ct` IS NULL AND NEW.`bill_city` IS NULL AND NEW.`bill_postal_code` IS NULL)) OR (NEW.`bill_expires_at` IS NOT NULL AND OLD.`bill_expires_at` IS NOT NULL AND NEW.`bill_expires_at` < OLD.`bill_expires_at`)))
171
+ OR (NEW.`name_ct` IS NOT NULL AND NEW.`name_ct` IS NOT OLD.`name_ct`)
172
+ OR (NEW.`company_ct` IS NOT NULL AND NEW.`company_ct` IS NOT OLD.`company_ct`)
173
+ OR (NEW.`email_ct` IS NOT NULL AND NEW.`email_ct` IS NOT OLD.`email_ct`)
174
+ OR (NEW.`phone_ct` IS NOT NULL AND NEW.`phone_ct` IS NOT OLD.`phone_ct`)
175
+ OR (NEW.`ship_name_ct` IS NOT NULL AND NEW.`ship_name_ct` IS NOT OLD.`ship_name_ct`)
176
+ OR (NEW.`ship_company_ct` IS NOT NULL AND NEW.`ship_company_ct` IS NOT OLD.`ship_company_ct`)
177
+ OR (NEW.`ship_line1_ct` IS NOT NULL AND NEW.`ship_line1_ct` IS NOT OLD.`ship_line1_ct`)
178
+ OR (NEW.`ship_line2_ct` IS NOT NULL AND NEW.`ship_line2_ct` IS NOT OLD.`ship_line2_ct`)
179
+ OR (NEW.`ship_city` IS NOT NULL AND NEW.`ship_city` IS NOT OLD.`ship_city`)
180
+ OR (NEW.`ship_postal_code` IS NOT NULL AND NEW.`ship_postal_code` IS NOT OLD.`ship_postal_code`)
181
+ OR (NEW.`bill_name_ct` IS NOT NULL AND NEW.`bill_name_ct` IS NOT OLD.`bill_name_ct`)
182
+ OR (NEW.`bill_company_ct` IS NOT NULL AND NEW.`bill_company_ct` IS NOT OLD.`bill_company_ct`)
183
+ OR (NEW.`bill_line1_ct` IS NOT NULL AND NEW.`bill_line1_ct` IS NOT OLD.`bill_line1_ct`)
184
+ OR (NEW.`bill_line2_ct` IS NOT NULL AND NEW.`bill_line2_ct` IS NOT OLD.`bill_line2_ct`)
185
+ OR (NEW.`bill_city` IS NOT NULL AND NEW.`bill_city` IS NOT OLD.`bill_city`)
186
+ OR (NEW.`bill_postal_code` IS NOT NULL AND NEW.`bill_postal_code` IS NOT OLD.`bill_postal_code`)
187
+ OR (NEW.`email_bidx` IS NOT NULL AND NEW.`email_bidx` IS NOT OLD.`email_bidx` AND NOT (
188
+ OLD.`email_bidx` IS NOT NULL AND OLD.`email_ct` IS NOT NULL AND NEW.`email_ct` IS OLD.`email_ct`
189
+ AND OLD.`bidx_kid` IS NOT NULL AND NEW.`bidx_kid` IS NOT NULL AND NEW.`bidx_kid` IS NOT OLD.`bidx_kid`))
190
+ OR (NEW.`phone_bidx` IS NOT NULL AND NEW.`phone_bidx` IS NOT OLD.`phone_bidx` AND NOT (
191
+ OLD.`phone_bidx` IS NOT NULL AND OLD.`phone_ct` IS NOT NULL AND NEW.`phone_ct` IS OLD.`phone_ct`
192
+ AND OLD.`bidx_kid` IS NOT NULL AND NEW.`bidx_kid` IS NOT NULL AND NEW.`bidx_kid` IS NOT OLD.`bidx_kid`))
193
+ OR (NEW.`order_kid` IS NOT NULL AND NEW.`order_kid` IS NOT OLD.`order_kid`)
194
+ OR (NEW.`bidx_kid` IS NOT OLD.`bidx_kid` AND NOT (
195
+ (NEW.`bidx_kid` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_bidx` IS NULL)
196
+ OR (NEW.`bidx_kid` IS NOT NULL AND OLD.`bidx_kid` IS NOT NULL
197
+ AND (OLD.`email_bidx` IS NULL OR NEW.`email_bidx` IS NOT OLD.`email_bidx`)
198
+ AND (OLD.`phone_bidx` IS NULL OR NEW.`phone_bidx` IS NOT OLD.`phone_bidx`)
199
+ AND (NEW.`email_bidx` IS NOT NULL OR NEW.`phone_bidx` IS NOT NULL))))
200
+ BEGIN
201
+ SELECT RAISE(ABORT, 'vault-immutable');
202
+ END;
203
+ --> statement-breakpoint
204
+ CREATE TABLE `order_contact_markers` (
205
+ `tenant_id` text NOT NULL,
206
+ `stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
207
+ `order_id` text NOT NULL,
208
+ PRIMARY KEY (`tenant_id`, `stage`, `order_id`),
209
+ FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
210
+ );
211
+ --> statement-breakpoint
212
+ CREATE TABLE `contact_access_audit` (
213
+ `audit_id` text NOT NULL PRIMARY KEY,
214
+ `tenant_id` text NOT NULL,
215
+ `stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
216
+ `record_kind` text NOT NULL CHECK (`record_kind` IN ('order-contact', 'contact-message')),
217
+ `record_id` text,
218
+ `record_count` integer,
219
+ `at` text NOT NULL,
220
+ `actor_id` text NOT NULL,
221
+ `actor_role` text NOT NULL CHECK (`actor_role` IN ('owner', 'staff')),
222
+ `reason_code` text,
223
+ `ticket_ref` text,
224
+ `action` text NOT NULL CHECK (`action` IN ('read', 'list', 'erase', 'search')),
225
+ `groups` text,
226
+ `outcome` text NOT NULL CHECK (`outcome` = 'admitted'),
227
+ CHECK (`actor_role` = 'owner' OR (`reason_code` IS NOT NULL AND `ticket_ref` IS NOT NULL))
228
+ );
229
+ --> statement-breakpoint
230
+ CREATE INDEX `idx_contact_access_audit_quota` ON `contact_access_audit` (`tenant_id`, `actor_id`, `actor_role`, `at`);
231
+ --> statement-breakpoint
232
+ CREATE TRIGGER `contact_access_audit_no_update`
233
+ BEFORE UPDATE ON `contact_access_audit`
234
+ BEGIN
235
+ SELECT RAISE(ABORT, 'append-only');
236
+ END;
237
+ --> statement-breakpoint
238
+ CREATE TRIGGER `contact_access_audit_no_delete`
239
+ BEFORE DELETE ON `contact_access_audit`
240
+ BEGIN
241
+ SELECT RAISE(ABORT, 'append-only');
242
+ END;
243
+ --> statement-breakpoint
244
+ -- contact_messages: every row is kept; legacy plaintext rows stay in legacy_* until the rollout
245
+ -- seals them field by field. New rows are written sealed only.
246
+ CREATE TABLE `contact_messages_v2` (
247
+ `id` text PRIMARY KEY NOT NULL,
248
+ `tenant_id` text NOT NULL,
249
+ `stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
250
+ `msg_kid` text,
251
+ `bidx_kid` text,
252
+ `name_ct` text,
253
+ `email_ct` text,
254
+ `email_bidx` text,
255
+ `phone_ct` text,
256
+ `phone_bidx` text,
257
+ `message_ct` text,
258
+ `ceiling` text NOT NULL,
259
+ `expires_at` text NOT NULL CHECK (`expires_at` <= `ceiling`),
260
+ `time_scale` integer,
261
+ `rule_version` text,
262
+ `page_path` text,
263
+ `status` text NOT NULL DEFAULT 'unread' CHECK (`status` IN ('unread', 'read')),
264
+ `created_at` text NOT NULL,
265
+ `read_at` text,
266
+ `notify_status` text NOT NULL DEFAULT 'pending' CHECK (`notify_status` IN ('pending', 'sent', 'failed')),
267
+ `notify_attempts` integer NOT NULL DEFAULT 0,
268
+ `legacy_name` text,
269
+ `legacy_email` text,
270
+ `legacy_phone` text,
271
+ `legacy_message` text,
272
+ CHECK (`msg_kid` IS NULL OR (`legacy_name` IS NULL AND `legacy_email` IS NULL AND `legacy_phone` IS NULL AND `legacy_message` IS NULL))
273
+ );
274
+ --> statement-breakpoint
275
+ INSERT INTO `contact_messages_v2`
276
+ (`id`, `tenant_id`, `stage`, `ceiling`, `expires_at`, `page_path`, `status`, `created_at`, `read_at`,
277
+ `notify_status`, `notify_attempts`, `legacy_name`, `legacy_email`, `legacy_phone`, `legacy_message`)
278
+ SELECT `id`, `tenant_id`, `stage`, `not_after`, `expires_at`, `page_path`, `status`, `created_at`, `read_at`,
279
+ `notify_status`, `notify_attempts`, `legacy_name`, `legacy_email`, `legacy_phone`, `legacy_message`
280
+ FROM `contact_messages`;
281
+ --> statement-breakpoint
282
+ DROP TABLE `contact_messages`;
283
+ --> statement-breakpoint
284
+ ALTER TABLE `contact_messages_v2` RENAME TO `contact_messages`;
285
+ --> statement-breakpoint
286
+ CREATE INDEX `idx_contact_messages_tenant_stage_created` ON `contact_messages` (`tenant_id`, `stage`, `created_at`);
287
+ --> statement-breakpoint
288
+ CREATE INDEX `idx_contact_messages_tenant_stage_status` ON `contact_messages` (`tenant_id`, `stage`, `status`);
289
+ --> statement-breakpoint
290
+ CREATE INDEX `idx_contact_messages_expires` ON `contact_messages` (`expires_at`);
291
+ --> statement-breakpoint
292
+ CREATE INDEX `idx_contact_messages_legacy` ON `contact_messages` (`id`) WHERE `msg_kid` IS NULL AND `legacy_message` IS NOT NULL;
293
+ --> statement-breakpoint
294
+ CREATE INDEX `idx_contact_messages_email_bidx`
295
+ ON `contact_messages` (`tenant_id`, `stage`, `email_bidx`) WHERE `email_bidx` IS NOT NULL;
296
+ --> statement-breakpoint
297
+ CREATE INDEX `idx_contact_messages_phone_bidx`
298
+ ON `contact_messages` (`tenant_id`, `stage`, `phone_bidx`) WHERE `phone_bidx` IS NOT NULL;
299
+ --> statement-breakpoint
300
+ CREATE TRIGGER `contact_messages_no_plaintext_insert`
301
+ BEFORE INSERT ON `contact_messages`
302
+ WHEN NEW.`legacy_name` IS NOT NULL OR NEW.`legacy_email` IS NOT NULL
303
+ OR NEW.`legacy_phone` IS NOT NULL OR NEW.`legacy_message` IS NOT NULL
304
+ BEGIN
305
+ SELECT RAISE(ABORT, 'plaintext-refused');
306
+ END;
307
+ --> statement-breakpoint
308
+ -- Once a row is sealed (msg_kid set) its kid never changes, not even to NULL; its ciphertexts only
309
+ -- go to NULL; a blind index takes a new value only as a rotation recompute (bidx_kid moving to
310
+ -- another kid in the same statement, replacing every index the row holds); and bidx_kid goes to
311
+ -- NULL only with both indexes.
312
+ CREATE TRIGGER `contact_messages_no_plaintext_update`
313
+ BEFORE UPDATE ON `contact_messages`
314
+ WHEN (NEW.`legacy_name` IS NOT NULL AND NEW.`legacy_name` IS NOT OLD.`legacy_name`)
315
+ OR (NEW.`legacy_email` IS NOT NULL AND NEW.`legacy_email` IS NOT OLD.`legacy_email`)
316
+ OR (NEW.`legacy_phone` IS NOT NULL AND NEW.`legacy_phone` IS NOT OLD.`legacy_phone`)
317
+ OR (NEW.`legacy_message` IS NOT NULL AND NEW.`legacy_message` IS NOT OLD.`legacy_message`)
318
+ OR NEW.`expires_at` > OLD.`expires_at`
319
+ OR NEW.`ceiling` IS NOT OLD.`ceiling`
320
+ OR (OLD.`msg_kid` IS NOT NULL AND NEW.`msg_kid` IS NOT OLD.`msg_kid`)
321
+ OR (OLD.`msg_kid` IS NOT NULL AND (
322
+ (NEW.`name_ct` IS NOT NULL AND NEW.`name_ct` IS NOT OLD.`name_ct`)
323
+ OR (NEW.`email_ct` IS NOT NULL AND NEW.`email_ct` IS NOT OLD.`email_ct`)
324
+ OR (NEW.`phone_ct` IS NOT NULL AND NEW.`phone_ct` IS NOT OLD.`phone_ct`)
325
+ OR (NEW.`message_ct` IS NOT NULL AND NEW.`message_ct` IS NOT OLD.`message_ct`)
326
+ OR (NEW.`email_bidx` IS NOT NULL AND NEW.`email_bidx` IS NOT OLD.`email_bidx` AND NOT (
327
+ OLD.`email_bidx` IS NOT NULL AND OLD.`email_ct` IS NOT NULL AND NEW.`email_ct` IS OLD.`email_ct`
328
+ AND OLD.`bidx_kid` IS NOT NULL AND NEW.`bidx_kid` IS NOT NULL AND NEW.`bidx_kid` IS NOT OLD.`bidx_kid`))
329
+ OR (NEW.`phone_bidx` IS NOT NULL AND NEW.`phone_bidx` IS NOT OLD.`phone_bidx` AND NOT (
330
+ OLD.`phone_bidx` IS NOT NULL AND OLD.`phone_ct` IS NOT NULL AND NEW.`phone_ct` IS OLD.`phone_ct`
331
+ AND OLD.`bidx_kid` IS NOT NULL AND NEW.`bidx_kid` IS NOT NULL AND NEW.`bidx_kid` IS NOT OLD.`bidx_kid`))
332
+ OR (NEW.`bidx_kid` IS NOT OLD.`bidx_kid` AND NOT (
333
+ (NEW.`bidx_kid` IS NULL AND NEW.`email_bidx` IS NULL AND NEW.`phone_bidx` IS NULL)
334
+ OR (NEW.`bidx_kid` IS NOT NULL AND OLD.`bidx_kid` IS NOT NULL
335
+ AND (OLD.`email_bidx` IS NULL OR NEW.`email_bidx` IS NOT OLD.`email_bidx`)
336
+ AND (OLD.`phone_bidx` IS NULL OR NEW.`phone_bidx` IS NOT OLD.`phone_bidx`)
337
+ AND (NEW.`email_bidx` IS NOT NULL OR NEW.`phone_bidx` IS NOT NULL))))))
338
+ OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
339
+ OR NEW.`stage` IS NOT OLD.`stage`
340
+ BEGIN
341
+ SELECT RAISE(ABORT, 'vault-immutable');
342
+ END;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Blind indexes for exact-match contact search: base64url HMAC-SHA256 of a normalized value under
3
+ * the tenant's blind-index data key, so equal values match within one tenant and stage and never
4
+ * across tenants. Normalization is part of the index contract: changing it orphans every stored
5
+ * index.
6
+ */
7
+
8
+ /** Trim, NFKC, lowercase. Null when nothing remains. */
9
+ export function normalizeEmail(value: string): string | null {
10
+ const normalized = value.trim().normalize("NFKC").toLowerCase();
11
+ return normalized === "" ? null : normalized;
12
+ }
13
+
14
+ /** NFKC, then a leading '+' (if the trimmed value starts with one) and ASCII digits only. Null when no digit remains. */
15
+ export function normalizePhone(value: string): string | null {
16
+ const folded = value.normalize("NFKC").trim();
17
+ const digits = folded.replace(/[^0-9]/g, "");
18
+ if (digits === "") return null;
19
+ return folded.startsWith("+") ? `+${digits}` : digits;
20
+ }
21
+
22
+ /** base64url (no padding) HMAC-SHA256 of an already-normalized value. */
23
+ export async function blindIndex(hmacKey: CryptoKey, normalized: string): Promise<string> {
24
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", hmacKey, new TextEncoder().encode(normalized) as BufferSource));
25
+ let binary = "";
26
+ for (const byte of mac) binary += String.fromCharCode(byte);
27
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
28
+ }
@@ -2,12 +2,22 @@
2
2
  * The contact access audit (`contact_access_audit`, append-only): every read, list and erase of
3
3
  * sealed customer contact writes its row BEFORE anything is decrypted. Staff rows carry the
4
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.
5
+ * Rows hold opaque ids, codes and times only — never a contact field or a searched value. A list
6
+ * or search row names the record ids it returned (comma-joined in record_id, at most
7
+ * {@link AUDIT_RECORD_IDS_MAX}) and their count; an erase row names the field groups it cleared.
6
8
  */
7
9
 
8
10
  export type VaultStage = "preview" | "publication";
9
11
 
10
12
  export const STAFF_READS_PER_HOUR = 30;
13
+ /** The most ids one list or search audit row names; every list and search is capped at or below it. */
14
+ export const AUDIT_RECORD_IDS_MAX = 200;
15
+
16
+ /** A list or search row's record_id: the returned ids comma-joined, or null when it returned none. */
17
+ export function auditRecordIds(ids: readonly string[]): string | null {
18
+ if (ids.length > AUDIT_RECORD_IDS_MAX) throw new Error("contact audit: too many record ids");
19
+ return ids.length === 0 ? null : ids.join(",");
20
+ }
11
21
  const HOUR_MS = 3_600_000;
12
22
 
13
23
  export interface ContactActor {
@@ -36,27 +46,31 @@ export function auditStatement(input: {
36
46
  tenantId: string;
37
47
  stage: VaultStage;
38
48
  recordKind: "order-contact" | "contact-message";
39
- recordId: string;
49
+ recordId: string | null;
50
+ recordCount?: number;
51
+ groups?: readonly string[];
40
52
  actor: ContactActor;
41
- action: "read" | "list" | "erase";
53
+ action: "read" | "list" | "erase" | "search";
42
54
  now: Date;
43
55
  requireRow?: AuditStatement;
44
56
  }): AuditStatement {
45
57
  const { auditId, tenantId, stage, recordKind, recordId, actor, action, now } = input;
58
+ const recordCount = input.recordCount ?? null;
59
+ const groups = input.groups && input.groups.length > 0 ? [...input.groups].sort().join(",") : null;
46
60
  const hourAgo = new Date(now.getTime() - HOUR_MS).toISOString();
47
61
  const params: unknown[] = [
48
- auditId, tenantId, stage, recordKind, recordId, now.toISOString(), actor.actorId, actor.role,
62
+ auditId, tenantId, stage, recordKind, recordId, recordCount, now.toISOString(), actor.actorId, actor.role,
49
63
  actor.role === "staff" ? actor.reasonCode ?? null : null,
50
64
  actor.role === "staff" ? actor.ticketRef ?? null : null,
51
- action,
65
+ action, groups,
52
66
  actor.role, tenantId, actor.actorId, hourAgo, STAFF_READS_PER_HOUR,
53
67
  ];
54
68
  const requireRow = input.requireRow ? ` AND EXISTS (${input.requireRow.sql})` : "";
55
69
  if (input.requireRow) params.push(...input.requireRow.params);
56
70
  return {
57
71
  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'
72
+ (audit_id, tenant_id, stage, record_kind, record_id, record_count, at, actor_id, actor_role, reason_code, ticket_ref, action, groups, outcome)
73
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'admitted'
60
74
  WHERE (? = 'owner' OR (SELECT COUNT(*) FROM contact_access_audit
61
75
  WHERE tenant_id = ? AND actor_id = ? AND actor_role = 'staff' AND at > ?) < ?)${requireRow}
62
76
  RETURNING audit_id`,