@tokenoftrust/storefront-runner 4.10.1 → 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.
- package/agency-kit/tools/lib/analytics-declaration.mjs +21 -5
- package/apps/storefront/migrations-apps/0021_contact_vault.sql +4 -0
- package/apps/storefront/migrations-apps/0022_contact_vault_fields.sql +342 -0
- package/apps/storefront/src/lib/customers/contactVault/blindIndex.ts +28 -0
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +21 -7
- package/apps/storefront/src/lib/customers/contactVault/dataKeys.ts +256 -0
- package/apps/storefront/src/lib/customers/contactVault/fieldEnvelope.ts +88 -0
- package/apps/storefront/src/lib/customers/contactVault/isolateMetrics.ts +37 -0
- package/apps/storefront/src/lib/customers/contactVault/kms.ts +292 -0
- package/apps/storefront/src/lib/customers/contactVault/retention.ts +270 -12
- package/apps/storefront/src/lib/customers/contactVault/retentionPolicy.ts +290 -0
- package/apps/storefront/src/lib/customers/contactVault/zip3.ts +24 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +458 -118
- package/apps/storefront/src/lib/engagement/contact/messagesTabClient.ts +33 -3
- package/apps/storefront/src/lib/integrations/integration/stageDestinationIds.ts +5 -2
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +180 -32
- package/apps/storefront/src/lib/orders/vault/contactIntake.ts +5 -3
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -4
- package/apps/storefront/src/lib/orders/vault/kmsEnv.ts +11 -0
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +1053 -227
- package/apps/storefront/src/lib/orders/vault/vault-test-support.ts +258 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +34 -5
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/contact-search.ts +25 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/contacts.ts +21 -0
- package/apps/storefront/src/pages/api/contact.ts +7 -9
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +55 -26
- package/package.json +1 -1
- package/packages/public-runtime/src/integration/platform-adapters.ts +4 -2
- package/packages/public-runtime/src/integration/result.ts +12 -2
- package/apps/storefront/src/lib/customers/contactVault/envelope.ts +0 -166
- package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +0 -7
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contact vault retention
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Contact vault retention arithmetic over the declared rule table (retentionPolicy.ts).
|
|
3
|
+
*
|
|
4
|
+
* Every sealed order carries, per field group:
|
|
5
|
+
* - `ceiling`: the latest instant any applicable rule could require (the terminal-relative
|
|
6
|
+
* fulfillment rule counted at its cap). Immutable while the group is sealed; bound into the AAD.
|
|
7
|
+
* - `expiresAt`: the effective expiry. Starts at the value derived from the current terminal time
|
|
8
|
+
* and only ever shrinks, never above the ceiling and never below `floorUntil`.
|
|
9
|
+
* - `floorUntil`: the latest end of the floor rules that applied at seal, or null. Immutable.
|
|
10
|
+
* - `rule`: the id of the rule that set the seal-time expiry.
|
|
11
|
+
*
|
|
12
|
+
* Preview rows run the same rules on a compressed clock: every rule's offset is divided by the
|
|
13
|
+
* policy's `previewTimeScale` (rounded down), and every preview ceiling is clamped to the anchor
|
|
14
|
+
* plus `previewCutoffDays`, past which nothing is admitted. The anchor is placed_at for orders and
|
|
15
|
+
* received_at for messages. A terminal-relative rule measures from the observed terminal instant,
|
|
16
|
+
* which on preview already lies on the compressed clock, so only its duration and cap are scaled.
|
|
17
|
+
*
|
|
18
|
+
* All functions are pure; `now` is always passed in.
|
|
5
19
|
*/
|
|
6
20
|
|
|
7
|
-
|
|
8
|
-
|
|
21
|
+
import {
|
|
22
|
+
GROUPS,
|
|
23
|
+
RETENTION_POLICY,
|
|
24
|
+
applicableRules,
|
|
25
|
+
ruleVersion,
|
|
26
|
+
type Group,
|
|
27
|
+
type RetentionPolicy,
|
|
28
|
+
type Rule,
|
|
29
|
+
} from "./retentionPolicy.js";
|
|
30
|
+
|
|
31
|
+
export type RetentionStage = "preview" | "publication";
|
|
32
|
+
|
|
9
33
|
const DAY_MS = 86_400_000;
|
|
10
34
|
|
|
11
35
|
const ISO_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
@@ -22,23 +46,257 @@ export function readInstant(value: unknown): Date | null {
|
|
|
22
46
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
23
47
|
}
|
|
24
48
|
|
|
49
|
+
const STAMP_SUFFIX = /#\d{6,}$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A shipment-audit stamp (`<ISO>#NNNNNN`, the sequence suffix written by the fulfillment service)
|
|
53
|
+
* or a bare ISO instant, read strictly; null otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export function readStampInstant(value: unknown): Date | null {
|
|
56
|
+
if (typeof value !== "string") return null;
|
|
57
|
+
return readInstant(value.replace(STAMP_SUFFIX, ""));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The order's terminal time (handed off, or a confirmed pending-cancel): the earliest matching
|
|
62
|
+
* shipment-audit stamp with its `#NNNNNN` suffix removed. Bind tenant_id, stage, order_id; read
|
|
63
|
+
* the `at` column with `readStampInstant`.
|
|
64
|
+
*/
|
|
65
|
+
export const TERMINAL_AT_SQL = `SELECT MIN(substr(at, 1, instr(at || '#', '#') - 1)) AS at FROM scrappy_shipment_audit
|
|
66
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ?
|
|
67
|
+
AND ((kind = 'transition' AND to_state = 'handed_off') OR (kind = 'pending-cancel' AND to_state = 'confirmed'))`;
|
|
68
|
+
|
|
69
|
+
export interface GroupRetention {
|
|
70
|
+
ceiling: string;
|
|
71
|
+
expiresAt: string;
|
|
72
|
+
floorUntil: string | null;
|
|
73
|
+
rule: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type SealRetentionResult =
|
|
77
|
+
| { kind: "refused"; reason: "contact-expired" | "order-time-unreadable" }
|
|
78
|
+
| { kind: "ok"; timeScale: number; ruleVersion: string; groups: Record<Group, GroupRetention> };
|
|
79
|
+
|
|
80
|
+
export interface SealRetentionInput {
|
|
81
|
+
stage: RetentionStage;
|
|
82
|
+
placedAt: Date;
|
|
83
|
+
terminalAt: Date | null;
|
|
84
|
+
shipCountry: string | null;
|
|
85
|
+
shipRegion: string | null;
|
|
86
|
+
regulated: boolean;
|
|
87
|
+
tenantId: string;
|
|
88
|
+
now: Date;
|
|
89
|
+
policy?: RetentionPolicy;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validDate(value: unknown): value is Date {
|
|
93
|
+
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function timeScaleFor(stage: RetentionStage, policy: RetentionPolicy): number {
|
|
97
|
+
return stage === "preview" ? policy.previewTimeScale : 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function scaled(ms: number, scale: number): number {
|
|
101
|
+
return Math.floor(ms / scale);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function previewCutoffMs(anchorMs: number, policy: RetentionPolicy): number {
|
|
105
|
+
return anchorMs + policy.previewCutoffDays * DAY_MS;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** End of a rule (ms) given the terminal time; `terminalMs` null means not yet terminal. */
|
|
109
|
+
function ruleEnd(rule: Rule, placedMs: number, terminalMs: number | null, scale: number): number {
|
|
110
|
+
const d = rule.duration;
|
|
111
|
+
switch (d.kind) {
|
|
112
|
+
case "days-from-placed":
|
|
113
|
+
return placedMs + scaled(d.days * DAY_MS, scale);
|
|
114
|
+
case "days-after-terminal": {
|
|
115
|
+
const cap = placedMs + scaled(d.capDaysFromPlaced * DAY_MS, scale);
|
|
116
|
+
if (terminalMs === null) return cap;
|
|
117
|
+
return Math.min(terminalMs + scaled(d.days * DAY_MS, scale), cap);
|
|
118
|
+
}
|
|
119
|
+
case "through-nth-full-calendar-year-after-placed": {
|
|
120
|
+
const end = Date.UTC(new Date(placedMs).getUTCFullYear() + d.n + 1, 0, 1);
|
|
121
|
+
return placedMs + scaled(end - placedMs, scale);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The latest end a rule could ever require: a terminal-relative rule counts at its cap. */
|
|
127
|
+
function ruleCeiling(rule: Rule, placedMs: number, scale: number): number {
|
|
128
|
+
return ruleEnd(rule, placedMs, null, scale);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fulfillmentRule(policy: RetentionPolicy): Rule {
|
|
132
|
+
const rule = policy.rules.find((r) => r.groups.length === GROUPS.length && r.duration.kind === "days-after-terminal");
|
|
133
|
+
if (!rule) throw new Error("retention policy has no terminal-relative rule covering every group");
|
|
134
|
+
return rule;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function rulesForGroup(group: Group, rules: readonly Rule[], policy: RetentionPolicy): Rule[] {
|
|
138
|
+
const covering = rules.filter((rule) => rule.groups.includes(group));
|
|
139
|
+
return covering.length > 0 ? covering : [fulfillmentRule(policy)];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function iso(ms: number): string {
|
|
143
|
+
return new Date(ms).toISOString();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Seal-time retention for every group of an order. Refuses when the inputs are not real instants,
|
|
148
|
+
* when a preview order is at or past its cutoff, or when every group's expiry has already passed.
|
|
149
|
+
*/
|
|
150
|
+
export async function sealRetention(input: SealRetentionInput): Promise<SealRetentionResult> {
|
|
151
|
+
const policy = input.policy ?? RETENTION_POLICY;
|
|
152
|
+
if (!validDate(input.placedAt) || !validDate(input.now) || (input.terminalAt !== null && !validDate(input.terminalAt))) {
|
|
153
|
+
return { kind: "refused", reason: "order-time-unreadable" };
|
|
154
|
+
}
|
|
155
|
+
const nowMs = input.now.getTime();
|
|
156
|
+
const placedMs = input.placedAt.getTime();
|
|
157
|
+
const terminalMs = input.terminalAt ? input.terminalAt.getTime() : null;
|
|
158
|
+
const scale = timeScaleFor(input.stage, policy);
|
|
159
|
+
const cutoff = input.stage === "preview" ? previewCutoffMs(placedMs, policy) : Infinity;
|
|
160
|
+
if (nowMs >= cutoff) return { kind: "refused", reason: "contact-expired" };
|
|
161
|
+
|
|
162
|
+
const rules = applicableRules(
|
|
163
|
+
input.tenantId,
|
|
164
|
+
{ shipCountry: input.shipCountry, shipRegion: input.shipRegion, regulated: input.regulated },
|
|
165
|
+
policy,
|
|
166
|
+
);
|
|
167
|
+
const groups = {} as Record<Group, GroupRetention>;
|
|
168
|
+
let anyLive = false;
|
|
169
|
+
for (const group of GROUPS) {
|
|
170
|
+
const covering = rulesForGroup(group, rules, policy);
|
|
171
|
+
const ceiling = Math.min(cutoff, Math.max(...covering.map((rule) => ruleCeiling(rule, placedMs, scale))));
|
|
172
|
+
const ends = covering.map((rule) => ({ rule, end: ruleEnd(rule, placedMs, terminalMs, scale) }));
|
|
173
|
+
const latest = Math.max(...ends.map((entry) => entry.end));
|
|
174
|
+
const binding = ends.find((entry) => entry.end === latest && entry.rule.floor) ?? ends.find((entry) => entry.end === latest)!;
|
|
175
|
+
const expiresAt = Math.min(latest, ceiling);
|
|
176
|
+
const floors = ends.filter((entry) => entry.rule.floor).map((entry) => entry.end);
|
|
177
|
+
const floorUntil = floors.length > 0 ? Math.min(Math.max(...floors), ceiling) : null;
|
|
178
|
+
if (expiresAt > nowMs) anyLive = true;
|
|
179
|
+
groups[group] = {
|
|
180
|
+
ceiling: iso(ceiling),
|
|
181
|
+
expiresAt: iso(expiresAt),
|
|
182
|
+
floorUntil: floorUntil === null ? null : iso(floorUntil),
|
|
183
|
+
rule: binding.rule.id,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (!anyLive) return { kind: "refused", reason: "contact-expired" };
|
|
187
|
+
return { kind: "ok", timeScale: scale, ruleVersion: await ruleVersion(policy), groups };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface RederiveGroupInput {
|
|
191
|
+
group: Group;
|
|
192
|
+
ceiling: string;
|
|
193
|
+
expiresAt: string;
|
|
194
|
+
floorUntil: string | null;
|
|
195
|
+
/** The time scale recorded on the row at seal. */
|
|
196
|
+
timeScale: number;
|
|
197
|
+
stage: RetentionStage;
|
|
198
|
+
placedAt: Date;
|
|
199
|
+
terminalAt: Date | null;
|
|
200
|
+
/** The rules from the current table that apply to this order (`applicableRules`). */
|
|
201
|
+
rules: readonly Rule[];
|
|
202
|
+
policy?: RetentionPolicy;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The group's new effective expiry under the current table and terminal time: the latest current
|
|
207
|
+
* non-floor rule, raised to the recorded floor, and never above the ceiling or the current expiry.
|
|
208
|
+
* Null when a recorded value is unreadable, so the caller leaves the row unchanged.
|
|
209
|
+
*/
|
|
210
|
+
export function rederiveGroup(input: RederiveGroupInput): string | null {
|
|
211
|
+
const policy = input.policy ?? RETENTION_POLICY;
|
|
212
|
+
const ceiling = readInstant(input.ceiling);
|
|
213
|
+
const current = readInstant(input.expiresAt);
|
|
214
|
+
const floor = input.floorUntil === null ? null : readInstant(input.floorUntil);
|
|
215
|
+
if (!ceiling || !current || (input.floorUntil !== null && !floor)) return null;
|
|
216
|
+
if (!validDate(input.placedAt) || (input.terminalAt !== null && !validDate(input.terminalAt))) return null;
|
|
217
|
+
if (!Number.isInteger(input.timeScale) || input.timeScale < 1) return null;
|
|
218
|
+
const placedMs = input.placedAt.getTime();
|
|
219
|
+
const terminalMs = input.terminalAt ? input.terminalAt.getTime() : null;
|
|
220
|
+
const nonFloor = input.rules.filter((rule) => !rule.floor && rule.groups.includes(input.group));
|
|
221
|
+
const covering = nonFloor.length > 0 ? nonFloor : [fulfillmentRule(policy)];
|
|
222
|
+
let next = Math.max(...covering.map((rule) => ruleEnd(rule, placedMs, terminalMs, input.timeScale)));
|
|
223
|
+
if (floor) next = Math.max(next, floor.getTime());
|
|
224
|
+
next = Math.min(next, ceiling.getTime(), current.getTime());
|
|
225
|
+
if (input.stage === "preview") next = Math.min(next, previewCutoffMs(placedMs, policy));
|
|
226
|
+
return iso(next);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type MessageRetentionResult =
|
|
230
|
+
| { kind: "refused"; reason: "contact-expired" | "message-time-unreadable" }
|
|
231
|
+
| {
|
|
232
|
+
kind: "ok";
|
|
233
|
+
timeScale: number;
|
|
234
|
+
ruleVersion: string;
|
|
235
|
+
rule: string;
|
|
236
|
+
ceiling: string;
|
|
237
|
+
expiresAt: string;
|
|
238
|
+
/** Preview only: when the whole row is deleted. */
|
|
239
|
+
deleteAt: string | null;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export async function messageRetention(input: {
|
|
243
|
+
stage: RetentionStage;
|
|
244
|
+
receivedAt: Date;
|
|
245
|
+
now: Date;
|
|
246
|
+
policy?: RetentionPolicy;
|
|
247
|
+
}): Promise<MessageRetentionResult> {
|
|
248
|
+
const policy = input.policy ?? RETENTION_POLICY;
|
|
249
|
+
if (!validDate(input.receivedAt) || !validDate(input.now)) return { kind: "refused", reason: "message-time-unreadable" };
|
|
250
|
+
const receivedMs = input.receivedAt.getTime();
|
|
251
|
+
const scale = timeScaleFor(input.stage, policy);
|
|
252
|
+
const deleteAt = input.stage === "preview" ? previewCutoffMs(receivedMs, policy) : null;
|
|
253
|
+
const ceiling = Math.min(receivedMs + scaled(policy.message.daysFromReceived * DAY_MS, scale), deleteAt ?? Infinity);
|
|
254
|
+
const nowMs = input.now.getTime();
|
|
255
|
+
if (nowMs >= ceiling || (deleteAt !== null && nowMs >= deleteAt)) return { kind: "refused", reason: "contact-expired" };
|
|
256
|
+
return {
|
|
257
|
+
kind: "ok",
|
|
258
|
+
timeScale: scale,
|
|
259
|
+
ruleVersion: await ruleVersion(policy),
|
|
260
|
+
rule: policy.message.id,
|
|
261
|
+
ceiling: iso(ceiling),
|
|
262
|
+
expiresAt: iso(ceiling),
|
|
263
|
+
deleteAt: deleteAt === null ? null : iso(deleteAt),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** True while a group may be read: strictly before both its expiry and its ceiling. */
|
|
268
|
+
export function isGroupReadable(now: Date, expiresAt: string | null, ceiling: string | null): boolean {
|
|
269
|
+
if (!validDate(now)) return false;
|
|
270
|
+
const expires = readInstant(expiresAt);
|
|
271
|
+
const cap = readInstant(ceiling);
|
|
272
|
+
if (!expires || !cap) return false;
|
|
273
|
+
const nowMs = now.getTime();
|
|
274
|
+
return nowMs < expires.getTime() && nowMs < cap.getTime();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** When a preview order's row, facts and marker are deleted whole. */
|
|
278
|
+
export function previewDeleteAt(placedAt: Date, policy: RetentionPolicy = RETENTION_POLICY): string {
|
|
279
|
+
return iso(previewCutoffMs(placedAt.getTime(), policy));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const LEGACY_CONTACT_RETENTION_DAYS = 365;
|
|
283
|
+
const LEGACY_TERMINAL_RETENTION_DAYS = 180;
|
|
284
|
+
|
|
25
285
|
export function notAfterFrom(start: Date): string {
|
|
26
|
-
return
|
|
286
|
+
return iso(start.getTime() + LEGACY_CONTACT_RETENTION_DAYS * DAY_MS);
|
|
27
287
|
}
|
|
28
288
|
|
|
29
|
-
/** The expiry for a record whose ceiling is `notAfter`, given the order's terminal time if any. */
|
|
30
289
|
export function expiresAtFor(notAfter: string, terminalAt: Date | null): string {
|
|
31
290
|
if (!terminalAt) return notAfter;
|
|
32
|
-
const terminal =
|
|
291
|
+
const terminal = terminalExpiryFrom(terminalAt);
|
|
33
292
|
return terminal < notAfter ? terminal : notAfter;
|
|
34
293
|
}
|
|
35
294
|
|
|
36
|
-
/** True while a record may still be read: strictly before both its expiry and its ceiling. */
|
|
37
295
|
export function isReadable(now: Date, expiresAt: string, notAfter: string): boolean {
|
|
38
|
-
const
|
|
39
|
-
return
|
|
296
|
+
const at = now.toISOString();
|
|
297
|
+
return at < expiresAt && at < notAfter;
|
|
40
298
|
}
|
|
41
299
|
|
|
42
300
|
export function terminalExpiryFrom(at: Date): string {
|
|
43
|
-
return
|
|
301
|
+
return iso(at.getTime() + LEGACY_TERMINAL_RETENTION_DAYS * DAY_MS);
|
|
44
302
|
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared, reviewed retention rule table for the contact vault.
|
|
3
|
+
*
|
|
4
|
+
* Each field group of a sealed order keeps its contact data for as long as the longest rule that
|
|
5
|
+
* applies to it (retention.ts does the arithmetic). A rule names its authority: a statute citation,
|
|
6
|
+
* or 'owner policy' where no statute is cited. Rules marked `floor` record a seal-time
|
|
7
|
+
* `floor_until` that no later rule change, override or tenant-class change can shorten.
|
|
8
|
+
*
|
|
9
|
+
* Owner policy rules are pending counsel review. No rule here claims a GDPR Article 17(3)
|
|
10
|
+
* exemption from a US statute.
|
|
11
|
+
*
|
|
12
|
+
* Per-tenant overrides may add a rule (new id) or lengthen a default (same id). `validatePolicy`
|
|
13
|
+
* refuses an override that shortens, narrows or un-floors a default rule.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const GROUPS = ["identity", "email", "phone", "ship", "bill"] as const;
|
|
17
|
+
export type Group = (typeof GROUPS)[number];
|
|
18
|
+
|
|
19
|
+
export type RuleDuration =
|
|
20
|
+
| { kind: "days-from-placed"; days: number }
|
|
21
|
+
| { kind: "days-after-terminal"; days: number; capDaysFromPlaced: number }
|
|
22
|
+
| { kind: "through-nth-full-calendar-year-after-placed"; n: number };
|
|
23
|
+
|
|
24
|
+
export interface RuleAppliesWhen {
|
|
25
|
+
/** ISO 3166-1 alpha-2 ship-to countries. Absent means any ship-to country. */
|
|
26
|
+
shipCountries?: readonly string[];
|
|
27
|
+
/** Ship-to regions (subdivision codes). Absent means any region. */
|
|
28
|
+
regions?: readonly string[];
|
|
29
|
+
/** Present means the rule applies only to orders of a regulated tenant. */
|
|
30
|
+
regulated?: true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Rule {
|
|
34
|
+
id: string;
|
|
35
|
+
purpose: string;
|
|
36
|
+
authority: string;
|
|
37
|
+
groups: readonly Group[];
|
|
38
|
+
duration: RuleDuration;
|
|
39
|
+
appliesWhen: RuleAppliesWhen;
|
|
40
|
+
floor: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MessageRule {
|
|
44
|
+
id: string;
|
|
45
|
+
purpose: string;
|
|
46
|
+
authority: string;
|
|
47
|
+
daysFromReceived: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RetentionPolicy {
|
|
51
|
+
rules: readonly Rule[];
|
|
52
|
+
tenantOverrides: Readonly<Record<string, readonly Rule[]>>;
|
|
53
|
+
message: MessageRule;
|
|
54
|
+
previewTimeScale: number;
|
|
55
|
+
previewCutoffDays: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const OWNER_POLICY = "owner policy";
|
|
59
|
+
|
|
60
|
+
/** EU member states, the EEA additions (IS, LI, NO) and the United Kingdom (GB). */
|
|
61
|
+
export const EU_EEA_UK: readonly string[] = [
|
|
62
|
+
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV",
|
|
63
|
+
"LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE",
|
|
64
|
+
"IS", "LI", "NO",
|
|
65
|
+
"GB",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_RULES: readonly Rule[] = [
|
|
69
|
+
{
|
|
70
|
+
id: "fulfillment-and-service",
|
|
71
|
+
purpose: "Fulfil the order and answer service requests about it",
|
|
72
|
+
authority: OWNER_POLICY,
|
|
73
|
+
groups: GROUPS,
|
|
74
|
+
duration: { kind: "days-after-terminal", days: 90, capDaysFromPlaced: 730 },
|
|
75
|
+
appliesWhen: {},
|
|
76
|
+
floor: false,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "card-disputes",
|
|
80
|
+
purpose: "Answer card chargebacks and payment disputes",
|
|
81
|
+
authority: OWNER_POLICY,
|
|
82
|
+
groups: ["identity", "email", "phone", "ship", "bill"],
|
|
83
|
+
duration: { kind: "days-from-placed", days: 540 },
|
|
84
|
+
appliesWhen: {},
|
|
85
|
+
floor: false,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "returns-and-warranty",
|
|
89
|
+
purpose: "Handle returns and warranty claims",
|
|
90
|
+
authority: OWNER_POLICY,
|
|
91
|
+
groups: ["identity", "email", "ship"],
|
|
92
|
+
duration: { kind: "days-from-placed", days: 365 },
|
|
93
|
+
appliesWhen: {},
|
|
94
|
+
floor: false,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "returns-and-warranty-eu-eea-uk",
|
|
98
|
+
purpose: "Handle returns and legal-guarantee claims for EU, EEA and UK ship-to addresses",
|
|
99
|
+
authority: OWNER_POLICY,
|
|
100
|
+
groups: ["identity", "email", "ship"],
|
|
101
|
+
duration: { kind: "days-from-placed", days: 730 },
|
|
102
|
+
appliesWhen: { shipCountries: EU_EEA_UK },
|
|
103
|
+
floor: false,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "sales-tax-sourcing",
|
|
107
|
+
purpose: "Substantiate destination sourcing of sales tax",
|
|
108
|
+
authority: OWNER_POLICY,
|
|
109
|
+
groups: ["ship"],
|
|
110
|
+
duration: { kind: "through-nth-full-calendar-year-after-placed", n: 4 },
|
|
111
|
+
appliesWhen: { shipCountries: ["US"] },
|
|
112
|
+
floor: false,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "legal-claims",
|
|
116
|
+
purpose: "Establish, exercise or defend legal claims about the sale",
|
|
117
|
+
authority: OWNER_POLICY,
|
|
118
|
+
groups: ["identity", "ship"],
|
|
119
|
+
duration: { kind: "through-nth-full-calendar-year-after-placed", n: 3 },
|
|
120
|
+
appliesWhen: {},
|
|
121
|
+
floor: false,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "pact-delivery-sale",
|
|
125
|
+
purpose: "Keep delivery-sale records of covered products",
|
|
126
|
+
authority:
|
|
127
|
+
"15 U.S.C. 376a(c) for delivery sales of covered products; applying it to every order of a regulated tenant is owner policy",
|
|
128
|
+
groups: ["identity", "ship"],
|
|
129
|
+
duration: { kind: "through-nth-full-calendar-year-after-placed", n: 4 },
|
|
130
|
+
appliesWhen: { shipCountries: ["US"], regulated: true },
|
|
131
|
+
floor: true,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
export const TENANT_OVERRIDES: Readonly<Record<string, readonly Rule[]>> = {};
|
|
136
|
+
|
|
137
|
+
export const MESSAGE_RULE: MessageRule = {
|
|
138
|
+
id: "contact-message",
|
|
139
|
+
purpose: "Answer a storefront contact message",
|
|
140
|
+
authority: OWNER_POLICY,
|
|
141
|
+
daysFromReceived: 365,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const RETENTION_POLICY: RetentionPolicy = {
|
|
145
|
+
rules: DEFAULT_RULES,
|
|
146
|
+
tenantOverrides: TENANT_OVERRIDES,
|
|
147
|
+
message: MESSAGE_RULE,
|
|
148
|
+
previewTimeScale: 6261,
|
|
149
|
+
previewCutoffDays: 7,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** The rules for one tenant: defaults with that tenant's overrides replacing by id or appended. */
|
|
153
|
+
export function rulesForTenant(tenantId: string, policy: RetentionPolicy = RETENTION_POLICY): Rule[] {
|
|
154
|
+
const overrides = Object.prototype.hasOwnProperty.call(policy.tenantOverrides, tenantId) ? policy.tenantOverrides[tenantId]! : [];
|
|
155
|
+
const byId = new Map(overrides.map((rule) => [rule.id, rule]));
|
|
156
|
+
const merged = policy.rules.map((rule) => byId.get(rule.id) ?? rule);
|
|
157
|
+
const defaultIds = new Set(policy.rules.map((rule) => rule.id));
|
|
158
|
+
for (const rule of overrides) if (!defaultIds.has(rule.id)) merged.push(rule);
|
|
159
|
+
return merged;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface OrderFacts {
|
|
163
|
+
shipCountry: string | null;
|
|
164
|
+
shipRegion: string | null;
|
|
165
|
+
regulated: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeCode(value: string | null): string | null {
|
|
169
|
+
if (typeof value !== "string") return null;
|
|
170
|
+
const trimmed = value.trim().toUpperCase();
|
|
171
|
+
return trimmed === "" ? null : trimmed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function ruleApplies(rule: Rule, facts: OrderFacts): boolean {
|
|
175
|
+
const when = rule.appliesWhen;
|
|
176
|
+
if (when.regulated && facts.regulated !== true) return false;
|
|
177
|
+
if (when.shipCountries) {
|
|
178
|
+
const country = normalizeCode(facts.shipCountry);
|
|
179
|
+
if (country === null || !when.shipCountries.includes(country)) return false;
|
|
180
|
+
}
|
|
181
|
+
if (when.regions) {
|
|
182
|
+
const region = normalizeCode(facts.shipRegion);
|
|
183
|
+
if (region === null || !when.regions.map((r) => r.toUpperCase()).includes(region)) return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The tenant's rules that apply to an order with these facts. */
|
|
189
|
+
export function applicableRules(tenantId: string, facts: OrderFacts, policy: RetentionPolicy = RETENTION_POLICY): Rule[] {
|
|
190
|
+
return rulesForTenant(tenantId, policy).filter((rule) => ruleApplies(rule, facts));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
194
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function validateRuleShape(rule: Rule, where: string): void {
|
|
198
|
+
if (typeof rule.id !== "string" || rule.id.trim() === "") throw new Error(`${where}: rule id is required`);
|
|
199
|
+
if (typeof rule.authority !== "string" || rule.authority.trim() === "") throw new Error(`${where}: rule ${rule.id} names no authority`);
|
|
200
|
+
if (!Array.isArray(rule.groups) || rule.groups.length === 0 || rule.groups.some((g) => !(GROUPS as readonly string[]).includes(g))) {
|
|
201
|
+
throw new Error(`${where}: rule ${rule.id} has invalid groups`);
|
|
202
|
+
}
|
|
203
|
+
const d = rule.duration;
|
|
204
|
+
const ok =
|
|
205
|
+
(d.kind === "days-from-placed" && isPositiveInteger(d.days)) ||
|
|
206
|
+
(d.kind === "days-after-terminal" && isPositiveInteger(d.days) && isPositiveInteger(d.capDaysFromPlaced)) ||
|
|
207
|
+
(d.kind === "through-nth-full-calendar-year-after-placed" && isPositiveInteger(d.n));
|
|
208
|
+
if (!ok) throw new Error(`${where}: rule ${rule.id} has an invalid duration`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function durationNotShorter(next: RuleDuration, base: RuleDuration): boolean {
|
|
212
|
+
if (next.kind === "days-from-placed" && base.kind === "days-from-placed") return next.days >= base.days;
|
|
213
|
+
if (next.kind === "days-after-terminal" && base.kind === "days-after-terminal") {
|
|
214
|
+
return next.days >= base.days && next.capDaysFromPlaced >= base.capDaysFromPlaced;
|
|
215
|
+
}
|
|
216
|
+
if (next.kind === "through-nth-full-calendar-year-after-placed" && base.kind === "through-nth-full-calendar-year-after-placed") {
|
|
217
|
+
return next.n >= base.n;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** True when `next` applies to every order `base` applies to. */
|
|
223
|
+
function appliesAtLeastAsWidely(next: RuleAppliesWhen, base: RuleAppliesWhen): boolean {
|
|
224
|
+
if (next.regulated && !base.regulated) return false;
|
|
225
|
+
if (next.shipCountries && (!base.shipCountries || base.shipCountries.some((c) => !next.shipCountries!.includes(c)))) return false;
|
|
226
|
+
if (next.regions && (!base.regions || base.regions.some((r) => !next.regions!.includes(r)))) return false;
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Throws unless the policy is well formed: unique ids, a named authority and valid duration on every
|
|
232
|
+
* rule, and every override of a default rule at least as long, at least as wide (groups and
|
|
233
|
+
* appliesWhen) and still a floor when the default is one.
|
|
234
|
+
*/
|
|
235
|
+
export function validatePolicy(policy: RetentionPolicy = RETENTION_POLICY): void {
|
|
236
|
+
if (!isPositiveInteger(policy.previewTimeScale)) throw new Error("previewTimeScale must be a positive integer");
|
|
237
|
+
if (!isPositiveInteger(policy.previewCutoffDays)) throw new Error("previewCutoffDays must be a positive integer");
|
|
238
|
+
if (!isPositiveInteger(policy.message.daysFromReceived)) throw new Error("message rule needs positive days");
|
|
239
|
+
const ids = new Set<string>();
|
|
240
|
+
for (const rule of policy.rules) {
|
|
241
|
+
validateRuleShape(rule, "default");
|
|
242
|
+
if (ids.has(rule.id)) throw new Error(`duplicate rule id ${rule.id}`);
|
|
243
|
+
ids.add(rule.id);
|
|
244
|
+
}
|
|
245
|
+
const fulfillment = policy.rules.find((rule) => rule.groups.length === GROUPS.length && rule.duration.kind === "days-after-terminal");
|
|
246
|
+
if (!fulfillment) throw new Error("the policy needs a terminal-relative rule covering every group");
|
|
247
|
+
const defaults = new Map(policy.rules.map((rule) => [rule.id, rule]));
|
|
248
|
+
for (const [tenantId, overrides] of Object.entries(policy.tenantOverrides)) {
|
|
249
|
+
const seen = new Set<string>();
|
|
250
|
+
for (const rule of overrides) {
|
|
251
|
+
const where = `override for ${tenantId}`;
|
|
252
|
+
validateRuleShape(rule, where);
|
|
253
|
+
if (seen.has(rule.id)) throw new Error(`${where}: duplicate rule id ${rule.id}`);
|
|
254
|
+
seen.add(rule.id);
|
|
255
|
+
const base = defaults.get(rule.id);
|
|
256
|
+
if (!base) continue;
|
|
257
|
+
if (base.floor && !rule.floor) throw new Error(`${where}: rule ${rule.id} removes a floor`);
|
|
258
|
+
if (!durationNotShorter(rule.duration, base.duration)) throw new Error(`${where}: rule ${rule.id} shortens the default`);
|
|
259
|
+
if (base.groups.some((group) => !rule.groups.includes(group))) throw new Error(`${where}: rule ${rule.id} drops a group`);
|
|
260
|
+
if (!appliesAtLeastAsWidely(rule.appliesWhen, base.appliesWhen)) throw new Error(`${where}: rule ${rule.id} narrows where it applies`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function canonicalJson(value: unknown): string {
|
|
266
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
267
|
+
if (value !== null && typeof value === "object") {
|
|
268
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
269
|
+
.filter(([, child]) => child !== undefined)
|
|
270
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
271
|
+
return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`).join(",")}}`;
|
|
272
|
+
}
|
|
273
|
+
return JSON.stringify(value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const versionCache = new WeakMap<RetentionPolicy, Promise<string>>();
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The rule-table version: hex SHA-256 of the canonical JSON of the whole policy. Async because the
|
|
280
|
+
* Worker runtime offers SHA-256 only through Web Crypto (`crypto.subtle`); memoized per policy.
|
|
281
|
+
*/
|
|
282
|
+
export function ruleVersion(policy: RetentionPolicy = RETENTION_POLICY): Promise<string> {
|
|
283
|
+
const cached = versionCache.get(policy);
|
|
284
|
+
if (cached) return cached;
|
|
285
|
+
const computed = crypto.subtle
|
|
286
|
+
.digest("SHA-256", new TextEncoder().encode(canonicalJson(policy)))
|
|
287
|
+
.then((digest) => Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""));
|
|
288
|
+
versionCache.set(policy, computed);
|
|
289
|
+
return computed;
|
|
290
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-digit ZIP areas for the unlinked order facts.
|
|
3
|
+
*
|
|
4
|
+
* Follows the HIPAA Safe Harbor de-identification standard, 45 CFR 164.514(b)(2)(i)(B): the initial
|
|
5
|
+
* three digits of a ZIP code may be kept only where the geographic unit formed by all ZIP codes
|
|
6
|
+
* sharing them contains more than 20,000 people; every other three-digit area is replaced by 000.
|
|
7
|
+
* RESTRICTED_ZIP3 is the HHS-published list of three-digit areas at or under 20,000 people.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const RESTRICTED_ZIP3: ReadonlySet<string> = new Set([
|
|
11
|
+
"036", "059", "063", "102", "203", "556", "692", "790", "821", "823", "830", "831", "878", "879", "884", "890", "893",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const US_ZIP = /^(\d{3})\d{2}(?:-?\d{4})?$/;
|
|
15
|
+
|
|
16
|
+
/** The de-identified three-digit ZIP area of a US postal code; null for non-US or unreadable codes. */
|
|
17
|
+
export function zip3(postalCode: unknown, country: unknown): string | null {
|
|
18
|
+
if (typeof country !== "string" || country.trim().toUpperCase() !== "US") return null;
|
|
19
|
+
if (typeof postalCode !== "string") return null;
|
|
20
|
+
const match = US_ZIP.exec(postalCode.trim());
|
|
21
|
+
if (!match) return null;
|
|
22
|
+
const area = match[1]!;
|
|
23
|
+
return RESTRICTED_ZIP3.has(area) ? "000" : area;
|
|
24
|
+
}
|