@tokenoftrust/storefront-runner 4.9.1 → 4.9.3
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/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
- package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
- package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
- package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
- package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
- package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
- package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
- package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
- package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
- package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
- package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
- package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
- package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
- package/apps/storefront/src/pages/api/contact.ts +16 -4
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
- package/package.json +1 -1
- package/packages/public-runtime/tests/fixtures/compliance-decisions.golden.json +609 -0
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `contact_messages` store — visitor messages from a store's contact form, kept in
|
|
3
|
-
* `STOREFRONT_APPS_DB
|
|
3
|
+
* `STOREFRONT_APPS_DB` (migrations-apps/0021). Pure over a minimal SQL seam, so the Worker runs it
|
|
4
4
|
* against D1 and tests against `node:sqlite`.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The visitor's name, email, phone and message exist at rest only as one contact vault envelope
|
|
7
|
+
* (`lib/customers/contactVault/envelope.ts`, purpose `contact-message`) whose AAD binds tenant, stage, message
|
|
8
|
+
* id and the immutable `not_after`. A row still holding legacy plaintext is never read; the rollout
|
|
9
|
+
* and the hourly sweep seal it ({@link sealLegacyContactMessages}).
|
|
10
|
+
*
|
|
11
|
+
* Every method takes the tenant id and the routed stage and scopes its SQL by both: an id belonging
|
|
12
|
+
* to another tenant or stage is indistinguishable from an id that does not exist. Every read writes
|
|
13
|
+
* its `contact_access_audit` row before anything is decrypted. Nothing here logs.
|
|
9
14
|
*/
|
|
15
|
+
import {
|
|
16
|
+
openVaultRecord,
|
|
17
|
+
sealVaultRecord,
|
|
18
|
+
VaultKeyUnavailableError,
|
|
19
|
+
type VaultBinding,
|
|
20
|
+
type VaultKeyring,
|
|
21
|
+
} from "@/lib/customers/contactVault/envelope.js";
|
|
22
|
+
import { isReadable, notAfterFrom } from "@/lib/customers/contactVault/retention.js";
|
|
23
|
+
import { admitAudit, auditStatement, type ContactActor, type VaultStage } from "@/lib/customers/contactVault/contactAudit.js";
|
|
24
|
+
|
|
25
|
+
interface QueryStatement {
|
|
26
|
+
sql: string;
|
|
27
|
+
params?: readonly unknown[];
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
/** The SQL surface this store needs; D1's `fromD1` and the test harness both satisfy it. */
|
|
11
31
|
export interface ContactDb {
|
|
12
32
|
all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
|
|
13
33
|
first<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T | null>;
|
|
14
34
|
run(sql: string, ...params: unknown[]): Promise<void>;
|
|
35
|
+
/** D1 executes a batch sequentially inside one implicit transaction. */
|
|
36
|
+
batch?(statements: readonly QueryStatement[]): Promise<void>;
|
|
15
37
|
}
|
|
16
38
|
|
|
17
|
-
export const CONTACT_RETENTION_DAYS = 365;
|
|
18
39
|
export const CONTACT_LIST_LIMIT = 200;
|
|
19
40
|
|
|
20
41
|
export type ContactMessageStatus = "unread" | "read";
|
|
@@ -22,6 +43,7 @@ export type ContactMessageStatus = "unread" | "read";
|
|
|
22
43
|
export interface NewContactMessage {
|
|
23
44
|
id: string;
|
|
24
45
|
tenantId: string;
|
|
46
|
+
stage: VaultStage;
|
|
25
47
|
name: string;
|
|
26
48
|
email: string;
|
|
27
49
|
phone: string;
|
|
@@ -41,114 +63,269 @@ export interface ContactMessage {
|
|
|
41
63
|
readAt: string | null;
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
|
|
66
|
+
export type ContactRefusal =
|
|
67
|
+
| { ok: false; status: 404; reason: "not-found" }
|
|
68
|
+
| { ok: false; status: 429; reason: "quota" }
|
|
69
|
+
| { ok: false; status: 503; reason: "audit-unavailable" | "vault-key-unavailable" };
|
|
70
|
+
|
|
71
|
+
export type ListOutcome = { ok: true; messages: ContactMessage[] } | ContactRefusal;
|
|
72
|
+
export type SupportReadOutcome = { ok: true; message: ContactMessage } | ContactRefusal;
|
|
73
|
+
export type EraseOutcome = { ok: true } | ContactRefusal;
|
|
74
|
+
|
|
75
|
+
interface SealedRow {
|
|
45
76
|
id: string;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
77
|
+
key_id: string;
|
|
78
|
+
nonce: string;
|
|
79
|
+
ciphertext: string;
|
|
80
|
+
not_after: string;
|
|
81
|
+
expires_at: string;
|
|
50
82
|
page_path: string | null;
|
|
51
83
|
status: string;
|
|
52
84
|
created_at: string;
|
|
53
85
|
read_at: string | null;
|
|
54
86
|
}
|
|
55
87
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
interface ContactPlaintext {
|
|
89
|
+
name: string;
|
|
90
|
+
email: string;
|
|
91
|
+
phone: string;
|
|
92
|
+
message: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const SEALED_COLUMNS = "id, key_id, nonce, ciphertext, not_after, expires_at, page_path, status, created_at, read_at";
|
|
96
|
+
/** A row the store may act on: sealed, and strictly before both its expiry and its ceiling. */
|
|
97
|
+
const READABLE = "ciphertext IS NOT NULL AND ? < expires_at AND ? < not_after";
|
|
98
|
+
|
|
99
|
+
function binding(tenantId: string, stage: VaultStage, id: string, notAfter: string): VaultBinding {
|
|
100
|
+
return { purpose: "contact-message", tenantId, stage, recordId: id, notAfter };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requireBatch(db: ContactDb): NonNullable<ContactDb["batch"]> {
|
|
104
|
+
if (!db.batch) throw new Error("D1 batch is unavailable");
|
|
105
|
+
return db.batch.bind(db);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function asPlaintext(value: unknown): ContactPlaintext | null {
|
|
109
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
110
|
+
const v = value as Record<string, unknown>;
|
|
111
|
+
const fields = [v.name, v.email, v.phone, v.message];
|
|
112
|
+
if (!fields.every((f) => typeof f === "string")) return null;
|
|
113
|
+
return { name: v.name as string, email: v.email as string, phone: v.phone as string, message: v.message as string };
|
|
68
114
|
}
|
|
69
115
|
|
|
70
116
|
export class ContactMessagesStore {
|
|
71
117
|
constructor(
|
|
72
118
|
private readonly db: ContactDb,
|
|
119
|
+
private readonly keyring: VaultKeyring | null,
|
|
73
120
|
private readonly now: () => Date = () => new Date(),
|
|
74
121
|
) {}
|
|
75
122
|
|
|
123
|
+
/** Seal and store one message. Throws {@link VaultKeyUnavailableError} (writing nothing) without a keyring. */
|
|
76
124
|
async insert(input: NewContactMessage): Promise<void> {
|
|
125
|
+
const created = this.now();
|
|
126
|
+
const notAfter = notAfterFrom(created);
|
|
127
|
+
const plaintext: ContactPlaintext = { name: input.name, email: input.email, phone: input.phone, message: input.message };
|
|
128
|
+
const sealed = await sealVaultRecord(this.keyring, binding(input.tenantId, input.stage, input.id, notAfter), plaintext);
|
|
77
129
|
await this.db.run(
|
|
78
130
|
`INSERT INTO contact_messages
|
|
79
|
-
(id, tenant_id,
|
|
80
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', ?, 'pending', 0)`,
|
|
131
|
+
(id, tenant_id, stage, key_id, nonce, ciphertext, not_after, expires_at, page_path, status, created_at, notify_status, notify_attempts)
|
|
132
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unread', ?, 'pending', 0)`,
|
|
81
133
|
input.id,
|
|
82
134
|
input.tenantId,
|
|
83
|
-
input.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
135
|
+
input.stage,
|
|
136
|
+
sealed.keyId,
|
|
137
|
+
sealed.nonce,
|
|
138
|
+
sealed.ciphertext,
|
|
139
|
+
notAfter,
|
|
140
|
+
notAfter,
|
|
87
141
|
input.pagePath,
|
|
88
|
-
|
|
142
|
+
created.toISOString(),
|
|
89
143
|
);
|
|
90
144
|
}
|
|
91
145
|
|
|
92
|
-
/**
|
|
93
|
-
async list(tenantId: string): Promise<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
/** The owner inbox: one `list` audit row commits, then newest first, capped at {@link CONTACT_LIST_LIMIT}. */
|
|
147
|
+
async list(tenantId: string, stage: VaultStage, actor: ContactActor, auditId: string): Promise<ListOutcome> {
|
|
148
|
+
if (!this.keyring) return { ok: false, status: 503, reason: "vault-key-unavailable" };
|
|
149
|
+
const now = this.now();
|
|
150
|
+
const admitted = await admitAudit(this.db, auditStatement({
|
|
151
|
+
auditId, tenantId, stage, recordKind: "contact-message", recordId: "*", actor, action: "list", now,
|
|
152
|
+
}));
|
|
153
|
+
if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
|
|
154
|
+
if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
|
|
155
|
+
const iso = now.toISOString();
|
|
156
|
+
const rows = await this.db.all<SealedRow>(
|
|
157
|
+
`SELECT ${SEALED_COLUMNS} FROM contact_messages
|
|
158
|
+
WHERE tenant_id = ? AND stage = ? AND ${READABLE}
|
|
98
159
|
ORDER BY created_at DESC, id DESC
|
|
99
160
|
LIMIT ?`,
|
|
100
|
-
tenantId,
|
|
101
|
-
|
|
161
|
+
tenantId, stage, iso, iso, CONTACT_LIST_LIMIT,
|
|
162
|
+
);
|
|
163
|
+
const messages: ContactMessage[] = [];
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
const message = await this.open(tenantId, stage, row, now);
|
|
166
|
+
if (message) messages.push(message);
|
|
167
|
+
}
|
|
168
|
+
return { ok: true, messages };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* ToT support reads one message. The caller has already proven a live staff selection and a live
|
|
173
|
+
* owner-consented grant; the audit row (with the staff quota in the same statement) commits first.
|
|
174
|
+
*/
|
|
175
|
+
async supportRead(tenantId: string, stage: VaultStage, id: string, actor: ContactActor, auditId: string): Promise<SupportReadOutcome> {
|
|
176
|
+
if (!this.keyring) return { ok: false, status: 503, reason: "vault-key-unavailable" };
|
|
177
|
+
const now = this.now();
|
|
178
|
+
const admitted = await admitAudit(this.db, auditStatement({
|
|
179
|
+
auditId, tenantId, stage, recordKind: "contact-message", recordId: id, actor, action: "read", now,
|
|
180
|
+
}));
|
|
181
|
+
if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
|
|
182
|
+
if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
|
|
183
|
+
const iso = now.toISOString();
|
|
184
|
+
const row = await this.db.first<SealedRow>(
|
|
185
|
+
`SELECT ${SEALED_COLUMNS} FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ${READABLE}`,
|
|
186
|
+
tenantId, stage, id, iso, iso,
|
|
102
187
|
);
|
|
103
|
-
|
|
188
|
+
const message = row ? await this.open(tenantId, stage, row, now) : null;
|
|
189
|
+
return message ? { ok: true, message } : { ok: false, status: 404, reason: "not-found" };
|
|
104
190
|
}
|
|
105
191
|
|
|
106
|
-
async unreadCount(tenantId: string): Promise<number> {
|
|
192
|
+
async unreadCount(tenantId: string, stage: VaultStage): Promise<number> {
|
|
193
|
+
const iso = this.now().toISOString();
|
|
107
194
|
const row = await this.db.first<{ n: number }>(
|
|
108
|
-
`SELECT COUNT(*) AS n FROM contact_messages WHERE tenant_id = ? AND status = 'unread'`,
|
|
109
|
-
tenantId,
|
|
195
|
+
`SELECT COUNT(*) AS n FROM contact_messages WHERE tenant_id = ? AND stage = ? AND status = 'unread' AND ${READABLE}`,
|
|
196
|
+
tenantId, stage, iso, iso,
|
|
110
197
|
);
|
|
111
198
|
return Number(row?.n ?? 0);
|
|
112
199
|
}
|
|
113
200
|
|
|
114
|
-
/** True when the tenant
|
|
115
|
-
async markRead(tenantId: string, id: string): Promise<boolean> {
|
|
201
|
+
/** True when the tenant holds a readable message with this id in this stage (now marked read). */
|
|
202
|
+
async markRead(tenantId: string, stage: VaultStage, id: string): Promise<boolean> {
|
|
203
|
+
const iso = this.now().toISOString();
|
|
116
204
|
const rows = await this.db.all<{ id: string }>(
|
|
117
205
|
`UPDATE contact_messages
|
|
118
206
|
SET status = 'read', read_at = COALESCE(read_at, ?)
|
|
119
|
-
WHERE tenant_id = ? AND id = ?
|
|
207
|
+
WHERE tenant_id = ? AND stage = ? AND id = ? AND ${READABLE}
|
|
120
208
|
RETURNING id`,
|
|
121
|
-
|
|
122
|
-
tenantId,
|
|
123
|
-
id,
|
|
209
|
+
iso, tenantId, stage, id, iso, iso,
|
|
124
210
|
);
|
|
125
211
|
return rows.length > 0;
|
|
126
212
|
}
|
|
127
213
|
|
|
128
|
-
/**
|
|
129
|
-
async
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
214
|
+
/** Erase one sealed message: its audit row and its delete commit in one batch, or neither does. */
|
|
215
|
+
async erase(tenantId: string, stage: VaultStage, id: string, actor: ContactActor, auditId: string): Promise<EraseOutcome> {
|
|
216
|
+
const now = this.now();
|
|
217
|
+
const rowSql = `SELECT 1 FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ciphertext IS NOT NULL`;
|
|
218
|
+
const exists = await this.db.first(rowSql, tenantId, stage, id);
|
|
219
|
+
if (!exists) return { ok: false, status: 404, reason: "not-found" };
|
|
220
|
+
const audit = auditStatement({
|
|
221
|
+
auditId, tenantId, stage, recordKind: "contact-message", recordId: id, actor, action: "erase", now,
|
|
222
|
+
requireRow: { sql: rowSql, params: [tenantId, stage, id] },
|
|
223
|
+
});
|
|
224
|
+
const statements: QueryStatement[] = [
|
|
225
|
+
{ sql: audit.sql.replace(/\s*RETURNING audit_id$/, ""), params: audit.params },
|
|
226
|
+
{
|
|
227
|
+
sql: `DELETE FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ciphertext IS NOT NULL
|
|
228
|
+
AND EXISTS (SELECT 1 FROM contact_access_audit WHERE audit_id = ?)`,
|
|
229
|
+
params: [tenantId, stage, id, auditId],
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
try {
|
|
233
|
+
await requireBatch(this.db)(statements);
|
|
234
|
+
} catch {
|
|
235
|
+
return { ok: false, status: 503, reason: "audit-unavailable" };
|
|
236
|
+
}
|
|
237
|
+
const audited = await this.db.first(`SELECT 1 AS present FROM contact_access_audit WHERE audit_id = ?`, auditId);
|
|
238
|
+
if (audited) return { ok: true };
|
|
239
|
+
const still = await this.db.first(rowSql, tenantId, stage, id);
|
|
240
|
+
return still ? { ok: false, status: 429, reason: "quota" } : { ok: false, status: 404, reason: "not-found" };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Decrypt one row, or null when it is past retention or does not authenticate under its binding. */
|
|
244
|
+
private async open(tenantId: string, stage: VaultStage, row: SealedRow, now: Date): Promise<ContactMessage | null> {
|
|
245
|
+
if (!isReadable(now, row.expires_at, row.not_after)) return null;
|
|
246
|
+
const opened = await openVaultRecord(this.keyring, binding(tenantId, stage, row.id, row.not_after), {
|
|
247
|
+
keyId: row.key_id, nonce: row.nonce, ciphertext: row.ciphertext,
|
|
248
|
+
}).catch((error: unknown) => {
|
|
249
|
+
if (error instanceof VaultKeyUnavailableError) return null;
|
|
250
|
+
throw error;
|
|
251
|
+
});
|
|
252
|
+
const plain = asPlaintext(opened);
|
|
253
|
+
if (!plain) return null;
|
|
254
|
+
return {
|
|
255
|
+
id: row.id,
|
|
256
|
+
...plain,
|
|
257
|
+
pagePath: row.page_path,
|
|
258
|
+
status: row.status === "read" ? "read" : "unread",
|
|
259
|
+
createdAt: row.created_at,
|
|
260
|
+
readAt: row.read_at,
|
|
261
|
+
};
|
|
136
262
|
}
|
|
137
263
|
}
|
|
138
264
|
|
|
139
265
|
/**
|
|
140
|
-
* Delete every message
|
|
141
|
-
* Returns the number of rows removed.
|
|
266
|
+
* Delete every message at or past its expiry or its ceiling, across all tenants and stages (legacy
|
|
267
|
+
* rows included). Returns the number of rows removed. An unreadable clock throws.
|
|
142
268
|
*/
|
|
143
|
-
export async function purgeExpiredContactMessages(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
retentionDays: number = CONTACT_RETENTION_DAYS,
|
|
147
|
-
): Promise<number> {
|
|
148
|
-
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
|
|
269
|
+
export async function purgeExpiredContactMessages(db: ContactDb, now: Date): Promise<number> {
|
|
270
|
+
if (Number.isNaN(now.getTime())) throw new Error("contact message purge: unreadable clock");
|
|
271
|
+
const iso = now.toISOString();
|
|
149
272
|
const rows = await db.all<{ id: string }>(
|
|
150
|
-
`DELETE FROM contact_messages WHERE
|
|
151
|
-
|
|
273
|
+
`DELETE FROM contact_messages WHERE expires_at <= ? OR not_after <= ? RETURNING id`,
|
|
274
|
+
iso, iso,
|
|
152
275
|
);
|
|
153
276
|
return rows.length;
|
|
154
277
|
}
|
|
278
|
+
|
|
279
|
+
interface LegacyRow {
|
|
280
|
+
id: string;
|
|
281
|
+
tenant_id: string;
|
|
282
|
+
stage: VaultStage;
|
|
283
|
+
not_after: string;
|
|
284
|
+
legacy_name: string | null;
|
|
285
|
+
legacy_email: string | null;
|
|
286
|
+
legacy_phone: string | null;
|
|
287
|
+
legacy_message: string | null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Seal up to `limit` rows still holding legacy plaintext (oldest id first), across all tenants.
|
|
292
|
+
* Each row is one statement: the envelope is written and every `legacy_*` column nulled together,
|
|
293
|
+
* only while the row is still unsealed. `remaining` counts the unsealed rows left.
|
|
294
|
+
*/
|
|
295
|
+
export async function sealLegacyContactMessages(
|
|
296
|
+
db: ContactDb,
|
|
297
|
+
keyring: VaultKeyring,
|
|
298
|
+
now: Date,
|
|
299
|
+
limit: number,
|
|
300
|
+
): Promise<{ sealed: number; remaining: number }> {
|
|
301
|
+
if (Number.isNaN(now.getTime())) throw new Error("contact message sealing: unreadable clock");
|
|
302
|
+
const rows = await db.all<LegacyRow>(
|
|
303
|
+
`SELECT id, tenant_id, stage, not_after, legacy_name, legacy_email, legacy_phone, legacy_message
|
|
304
|
+
FROM contact_messages WHERE ciphertext IS NULL
|
|
305
|
+
ORDER BY id LIMIT ?`,
|
|
306
|
+
Math.max(0, Math.floor(limit)),
|
|
307
|
+
);
|
|
308
|
+
let sealed = 0;
|
|
309
|
+
for (const row of rows) {
|
|
310
|
+
const plaintext: ContactPlaintext = {
|
|
311
|
+
name: row.legacy_name ?? "",
|
|
312
|
+
email: row.legacy_email ?? "",
|
|
313
|
+
phone: row.legacy_phone ?? "",
|
|
314
|
+
message: row.legacy_message ?? "",
|
|
315
|
+
};
|
|
316
|
+
const envelope = await sealVaultRecord(keyring, binding(row.tenant_id, row.stage, row.id, row.not_after), plaintext);
|
|
317
|
+
const changed = await db.all<{ id: string }>(
|
|
318
|
+
`UPDATE contact_messages
|
|
319
|
+
SET key_id = ?, nonce = ?, ciphertext = ?,
|
|
320
|
+
legacy_name = NULL, legacy_email = NULL, legacy_phone = NULL, legacy_message = NULL
|
|
321
|
+
WHERE id = ? AND ciphertext IS NULL
|
|
322
|
+
RETURNING id`,
|
|
323
|
+
envelope.keyId, envelope.nonce, envelope.ciphertext, row.id,
|
|
324
|
+
);
|
|
325
|
+
sealed += changed.length;
|
|
326
|
+
}
|
|
327
|
+
const left = await db.first<{ n: number }>(
|
|
328
|
+
`SELECT COUNT(*) AS n FROM contact_messages WHERE ciphertext IS NULL`,
|
|
329
|
+
);
|
|
330
|
+
return { sealed, remaining: Number(left?.n ?? 0) };
|
|
331
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* → 200 { ok:true }
|
|
9
9
|
* 400 invalid_json | invalid_input (+ field)
|
|
10
10
|
* 403 forbidden_origin | invalid_token
|
|
11
|
+
* 409 stage-unknown (the store's stage could not be resolved)
|
|
11
12
|
* 413 too_large · 415 unsupported_media_type
|
|
12
13
|
* 429 rate_limited · 503 unavailable
|
|
13
14
|
* 500 store_failed (keep the visitor's text and retry)
|
|
@@ -56,6 +57,7 @@ export type ContactErrorCode =
|
|
|
56
57
|
| "unsupported_media_type"
|
|
57
58
|
| "rate_limited"
|
|
58
59
|
| "unavailable"
|
|
60
|
+
| "stage-unknown"
|
|
59
61
|
| "store_failed";
|
|
60
62
|
|
|
61
63
|
export type ContactSubmitResponse =
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
actorMayCommand,
|
|
19
19
|
decideCommerceCommand,
|
|
20
20
|
publicCommerceEvent,
|
|
21
|
-
readOwnerAddress,
|
|
22
21
|
totalsMatch,
|
|
23
22
|
type CommerceActor,
|
|
24
23
|
type CommerceEvidence,
|
|
@@ -32,6 +31,7 @@ import {
|
|
|
32
31
|
type StockState,
|
|
33
32
|
} from "@tot/public-runtime";
|
|
34
33
|
import { readD1 } from "@/lib/publication/shared/env";
|
|
34
|
+
import { terminalTransitionExpiryStatement } from "@/lib/orders/vault/orderContactVault.js";
|
|
35
35
|
import { decideAdminApiAccess } from "@/lib/access/auth/adminApiGuard";
|
|
36
36
|
import { readViewerSession } from "@/lib/access/auth/route";
|
|
37
37
|
import type { SessionRecord } from "@/lib/access/auth/session";
|
|
@@ -80,7 +80,6 @@ export interface SourceOrderSnapshot {
|
|
|
80
80
|
totals: MinorTotals;
|
|
81
81
|
evidence: CommerceEvidence;
|
|
82
82
|
lines: readonly { sku: string; quantity: number }[];
|
|
83
|
-
address: Record<string, unknown> | null;
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
export interface PackingSlip {
|
|
@@ -279,7 +278,6 @@ function observedEvent(input: {
|
|
|
279
278
|
label: LabelState;
|
|
280
279
|
totals: MinorTotals;
|
|
281
280
|
evidence: CommerceEvidence;
|
|
282
|
-
address: unknown;
|
|
283
281
|
}): string {
|
|
284
282
|
return jsonText(
|
|
285
283
|
publicCommerceEvent({
|
|
@@ -294,7 +292,6 @@ function observedEvent(input: {
|
|
|
294
292
|
label: input.label,
|
|
295
293
|
totals: input.totals,
|
|
296
294
|
verificationDetail: input.evidence,
|
|
297
|
-
address: input.address,
|
|
298
295
|
}),
|
|
299
296
|
);
|
|
300
297
|
}
|
|
@@ -586,32 +583,6 @@ export class FulfillmentService {
|
|
|
586
583
|
return ok({ batchId, orders });
|
|
587
584
|
}
|
|
588
585
|
|
|
589
|
-
async readAddress(
|
|
590
|
-
actor: FulfillmentActor,
|
|
591
|
-
orderId: string,
|
|
592
|
-
): Promise<FulfillmentResult<Record<string, unknown>>> {
|
|
593
|
-
const scope = { tenantId: actor.tenantId, stage: actor.stage };
|
|
594
|
-
let opened = false;
|
|
595
|
-
const gate = readOwnerAddress(actor, scope, () => {
|
|
596
|
-
opened = true;
|
|
597
|
-
return true;
|
|
598
|
-
});
|
|
599
|
-
if (!gate.ok || !opened || !isOpaqueId(actor.actorId)) return refuse("denied");
|
|
600
|
-
if (!isOpaqueId(orderId)) return refuse("not-found");
|
|
601
|
-
const row = await this.db.get<{ address_json: string }>(
|
|
602
|
-
`SELECT address_json FROM scrappy_order_addresses WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
|
|
603
|
-
[scope.tenantId, scope.stage, orderId],
|
|
604
|
-
);
|
|
605
|
-
if (!row) return refuse("not-found");
|
|
606
|
-
try {
|
|
607
|
-
const parsed = JSON.parse(row.address_json) as unknown;
|
|
608
|
-
if (!isRecord(parsed)) return refuse("unreadable");
|
|
609
|
-
return ok(parsed);
|
|
610
|
-
} catch {
|
|
611
|
-
return refuse("unreadable");
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
586
|
async applySourceSnapshot(
|
|
616
587
|
actor: FulfillmentActor,
|
|
617
588
|
snapshot: SourceOrderSnapshot,
|
|
@@ -956,6 +927,7 @@ export class FulfillmentService {
|
|
|
956
927
|
],
|
|
957
928
|
},
|
|
958
929
|
auditStatement(state, actor.actorId, "transition", state.order.fulfillment, "handed_off", version, stamp(this.now(), state.auditCount + 1)),
|
|
930
|
+
terminalTransitionExpiryStatement(state.order, version, new Date(this.now())),
|
|
959
931
|
],
|
|
960
932
|
};
|
|
961
933
|
});
|
|
@@ -1177,21 +1149,6 @@ export class FulfillmentService {
|
|
|
1177
1149
|
];
|
|
1178
1150
|
const gate = `tenant_id = ? AND stage = ? AND order_id = ? AND event_version = 1 AND source = ? AND source_order_id = ?`;
|
|
1179
1151
|
const gateParams = [snapshot.tenantId, snapshot.stage, snapshot.orderId, snapshot.source, snapshot.sourceOrderId];
|
|
1180
|
-
if (snapshot.address) {
|
|
1181
|
-
statements.push({
|
|
1182
|
-
sql: `INSERT INTO scrappy_order_addresses (tenant_id, stage, order_id, address_json)
|
|
1183
|
-
SELECT tenant_id, stage, order_id, ?
|
|
1184
|
-
FROM scrappy_commerce_orders
|
|
1185
|
-
WHERE ${gate}
|
|
1186
|
-
AND NOT EXISTS (
|
|
1187
|
-
SELECT 1 FROM scrappy_order_addresses AS a
|
|
1188
|
-
WHERE a.tenant_id = scrappy_commerce_orders.tenant_id
|
|
1189
|
-
AND a.stage = scrappy_commerce_orders.stage
|
|
1190
|
-
AND a.order_id = scrappy_commerce_orders.order_id
|
|
1191
|
-
)`,
|
|
1192
|
-
params: [jsonText(snapshot.address), ...gateParams],
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
1152
|
const stub = {
|
|
1196
1153
|
order: {
|
|
1197
1154
|
tenantId: snapshot.tenantId,
|
|
@@ -1215,7 +1172,6 @@ export class FulfillmentService {
|
|
|
1215
1172
|
gatedAudit(stub, actor.actorId, "evidence", "none", jsonText(snapshot.evidence), at, gate, gateParams),
|
|
1216
1173
|
gatedAudit(stub, actor.actorId, "lines", "none", jsonText(snapshot.lines), stamp(this.now(), 2), gate, gateParams),
|
|
1217
1174
|
gatedAudit(stub, actor.actorId, "totals", "none", jsonText(snapshot.totals), stamp(this.now(), 3), gate, gateParams),
|
|
1218
|
-
gatedAudit(stub, actor.actorId, "address-present", "none", snapshot.address ? "yes" : "no", stamp(this.now(), 4), gate, gateParams),
|
|
1219
1175
|
gatedAudit(
|
|
1220
1176
|
stub,
|
|
1221
1177
|
actor.actorId,
|
|
@@ -1233,7 +1189,6 @@ export class FulfillmentService {
|
|
|
1233
1189
|
label,
|
|
1234
1190
|
totals: snapshot.totals,
|
|
1235
1191
|
evidence: snapshot.evidence,
|
|
1236
|
-
address: snapshot.address,
|
|
1237
1192
|
}),
|
|
1238
1193
|
stamp(this.now(), 5),
|
|
1239
1194
|
gate,
|
|
@@ -1299,35 +1254,6 @@ export class FulfillmentService {
|
|
|
1299
1254
|
statements.push(auditStatement(state, actor.actorId, "lines", "source", jsonText(snapshot.lines), nextVersion, stamp(this.now(), n)));
|
|
1300
1255
|
n += 1;
|
|
1301
1256
|
statements.push(auditStatement(state, actor.actorId, "totals", "source", jsonText(snapshot.totals), nextVersion, stamp(this.now(), n)));
|
|
1302
|
-
if (snapshot.address) {
|
|
1303
|
-
statements.push({
|
|
1304
|
-
sql: `INSERT INTO scrappy_order_addresses (tenant_id, stage, order_id, address_json)
|
|
1305
|
-
SELECT ?, ?, ?, ?
|
|
1306
|
-
WHERE EXISTS (
|
|
1307
|
-
SELECT 1 FROM scrappy_commerce_orders
|
|
1308
|
-
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
1309
|
-
)
|
|
1310
|
-
ON CONFLICT(tenant_id, stage, order_id) DO UPDATE SET address_json = excluded.address_json
|
|
1311
|
-
WHERE EXISTS (
|
|
1312
|
-
SELECT 1 FROM scrappy_commerce_orders
|
|
1313
|
-
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
1314
|
-
)`,
|
|
1315
|
-
params: [
|
|
1316
|
-
snapshot.tenantId,
|
|
1317
|
-
snapshot.stage,
|
|
1318
|
-
snapshot.orderId,
|
|
1319
|
-
jsonText(snapshot.address),
|
|
1320
|
-
snapshot.tenantId,
|
|
1321
|
-
snapshot.stage,
|
|
1322
|
-
snapshot.orderId,
|
|
1323
|
-
nextVersion,
|
|
1324
|
-
snapshot.tenantId,
|
|
1325
|
-
snapshot.stage,
|
|
1326
|
-
snapshot.orderId,
|
|
1327
|
-
nextVersion,
|
|
1328
|
-
],
|
|
1329
|
-
});
|
|
1330
|
-
}
|
|
1331
1257
|
const storedFulfillment = state.order.fulfillment === "handed_off" ? "handed_off" : fulfillment;
|
|
1332
1258
|
n += 1;
|
|
1333
1259
|
statements.push(
|
|
@@ -1348,7 +1274,6 @@ export class FulfillmentService {
|
|
|
1348
1274
|
label,
|
|
1349
1275
|
totals: snapshot.totals,
|
|
1350
1276
|
evidence: snapshot.evidence,
|
|
1351
|
-
address: snapshot.address,
|
|
1352
1277
|
}),
|
|
1353
1278
|
nextVersion,
|
|
1354
1279
|
stamp(this.now(), n),
|
|
@@ -1357,6 +1282,7 @@ export class FulfillmentService {
|
|
|
1357
1282
|
if (confirmedCancel && state.order.fulfillment !== "handed_off") {
|
|
1358
1283
|
n += 1;
|
|
1359
1284
|
statements.push(auditStatement(state, actor.actorId, "pending-cancel", "open", "confirmed", nextVersion, stamp(this.now(), n)));
|
|
1285
|
+
statements.push(terminalTransitionExpiryStatement(state.order, nextVersion, new Date(this.now())));
|
|
1360
1286
|
}
|
|
1361
1287
|
if (snapshot.sourceVersion !== undefined) statements.push(projectionCheckpoint(snapshot,nextVersion,this.now()));
|
|
1362
1288
|
return { type: "write", statements, minimumChanges: 1 };
|
|
@@ -1657,7 +1583,6 @@ function validateSnapshot(snapshot: SourceOrderSnapshot): string | null {
|
|
|
1657
1583
|
if (!oneOf(snapshot.label, LABEL_STATES) || !oneOf(snapshot.stage, ["preview", "publication"] as const)) return "malformed";
|
|
1658
1584
|
if (!parseEvidence(snapshot.evidence) || !parseLines(snapshot.lines) || !parseTotals(snapshot.totals)) return "malformed";
|
|
1659
1585
|
if (snapshot.amountMinor !== snapshot.totals.totalMinor || !totalsMatch(snapshot.totals)) return "malformed";
|
|
1660
|
-
if (snapshot.address !== null && !isRecord(snapshot.address)) return "malformed";
|
|
1661
1586
|
return null;
|
|
1662
1587
|
}
|
|
1663
1588
|
|
|
@@ -291,9 +291,13 @@ const COMMERCE_PRIVATE_KEYS = new Set([
|
|
|
291
291
|
"phone",
|
|
292
292
|
"line1",
|
|
293
293
|
"line2",
|
|
294
|
+
"city",
|
|
295
|
+
"company",
|
|
294
296
|
"postalCode",
|
|
295
297
|
"shippingAddress",
|
|
296
298
|
"billingAddress",
|
|
299
|
+
"shipTo",
|
|
300
|
+
"billTo",
|
|
297
301
|
"address",
|
|
298
302
|
"firstName",
|
|
299
303
|
"lastName",
|