@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.
- 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/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/apps/storefront/src/lib/customers/contactVault/envelope.ts +0 -166
- package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +0 -7
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test support for the order contact vault suites: node:sqlite with every migrations-apps file
|
|
3
|
+
* applied in order (fault hooks to inject failures and force races), a stubbed KMS that records
|
|
4
|
+
* every EncryptionContext it sees, and synthetic fixtures. Never imported by Worker code.
|
|
5
|
+
*/
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import type { Queryable, QueryStatement } from "../../catalog/d1/catalog.js";
|
|
11
|
+
import { VaultKeyUnavailableError, type EncryptionContext, type KmsClient } from "@/lib/customers/contactVault/kms.js";
|
|
12
|
+
import { readOrderContactCommand, type OrderContactCommand } from "./orderContact.js";
|
|
13
|
+
|
|
14
|
+
export interface SqliteStmt {
|
|
15
|
+
all(...params: unknown[]): Array<Record<string, unknown>>;
|
|
16
|
+
get(...params: unknown[]): Record<string, unknown> | undefined;
|
|
17
|
+
run(...params: unknown[]): unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface SqliteDatabase {
|
|
20
|
+
prepare(sql: string): SqliteStmt;
|
|
21
|
+
exec(sql: string): void;
|
|
22
|
+
}
|
|
23
|
+
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as {
|
|
24
|
+
DatabaseSync: new (path: string) => SqliteDatabase;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", "migrations-apps");
|
|
28
|
+
|
|
29
|
+
export function migrationFiles(): string[] {
|
|
30
|
+
return readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function newDatabase(): SqliteDatabase {
|
|
34
|
+
return new DatabaseSync(":memory:");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function applyMigration(raw: SqliteDatabase, file: string): void {
|
|
38
|
+
raw.exec(readFileSync(join(MIGRATIONS_DIR, file), "utf8"));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Fault hooks a test sets to inject failures or force two operations to race. */
|
|
42
|
+
export interface Faults {
|
|
43
|
+
/** Called after statement `index` of a batch ran; throwing rolls the batch back. */
|
|
44
|
+
afterBatchStatement?: (index: number, sql: string) => void;
|
|
45
|
+
/** Awaited before a batch starts. */
|
|
46
|
+
beforeBatch?: () => Promise<void>;
|
|
47
|
+
/** Called (and awaited) before `all`; throwing fails that statement. */
|
|
48
|
+
beforeAll?: (sql: string) => Promise<void> | void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Harness {
|
|
52
|
+
db: Queryable;
|
|
53
|
+
raw: SqliteDatabase;
|
|
54
|
+
faults: Faults;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function harness(): Harness {
|
|
58
|
+
const raw = newDatabase();
|
|
59
|
+
const faults: Faults = {};
|
|
60
|
+
const db: Queryable = {
|
|
61
|
+
all: async (sql, ...params) => {
|
|
62
|
+
await faults.beforeAll?.(sql);
|
|
63
|
+
return raw.prepare(sql).all(...params) as never;
|
|
64
|
+
},
|
|
65
|
+
first: async (sql, ...params) => (raw.prepare(sql).get(...params) ?? null) as never,
|
|
66
|
+
run: async (sql, ...params) => {
|
|
67
|
+
raw.prepare(sql).run(...params);
|
|
68
|
+
},
|
|
69
|
+
exec: async (sql) => {
|
|
70
|
+
raw.exec(sql);
|
|
71
|
+
},
|
|
72
|
+
batch: async (statements: readonly QueryStatement[]) => {
|
|
73
|
+
await faults.beforeBatch?.();
|
|
74
|
+
raw.exec("BEGIN");
|
|
75
|
+
try {
|
|
76
|
+
statements.forEach((statement, index) => {
|
|
77
|
+
raw.prepare(statement.sql).run(...(statement.params ?? []));
|
|
78
|
+
faults.afterBatchStatement?.(index, statement.sql);
|
|
79
|
+
});
|
|
80
|
+
raw.exec("COMMIT");
|
|
81
|
+
} catch (error) {
|
|
82
|
+
raw.exec("ROLLBACK");
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
for (const file of migrationFiles()) applyMigration(raw, file);
|
|
88
|
+
return { db, raw, faults };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resolves once `size` callers are waiting, so they proceed together. */
|
|
92
|
+
export function barrier(size: number): () => Promise<void> {
|
|
93
|
+
let waiting = 0;
|
|
94
|
+
let release!: () => void;
|
|
95
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
96
|
+
return async () => {
|
|
97
|
+
waiting += 1;
|
|
98
|
+
if (waiting >= size) release();
|
|
99
|
+
await gate;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A placeholder key ARN, assembled at runtime so no account-id-shaped literal ships in the runner. */
|
|
104
|
+
export const KMS_KEY_ARN = ["arn", "aws", "kms", "us-east-1", "0".repeat(12), `key/${"0".repeat(8)}-0000-0000-0000-${"0".repeat(12)}`].join(":");
|
|
105
|
+
|
|
106
|
+
export interface StubKms {
|
|
107
|
+
client: KmsClient;
|
|
108
|
+
generated: EncryptionContext[];
|
|
109
|
+
decrypted: EncryptionContext[];
|
|
110
|
+
/** Every key byte string (base64 and hex) and wrapped blob the stub ever handed out. */
|
|
111
|
+
material(): string[];
|
|
112
|
+
fail: boolean;
|
|
113
|
+
/** Decrypt ignores the tenant in the context (simulates a mis-scoped KMS policy: only the AAD protects). */
|
|
114
|
+
ignoreTenant: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function stubKms(): StubKms {
|
|
118
|
+
const store = new Map<string, { bytes: Uint8Array; ctx: EncryptionContext }>();
|
|
119
|
+
const stub: StubKms = {
|
|
120
|
+
generated: [],
|
|
121
|
+
decrypted: [],
|
|
122
|
+
fail: false,
|
|
123
|
+
ignoreTenant: false,
|
|
124
|
+
material: () => [...store.entries()].flatMap(([blob, { bytes }]) => [
|
|
125
|
+
blob, Buffer.from(bytes).toString("base64"), Buffer.from(bytes).toString("hex"),
|
|
126
|
+
]),
|
|
127
|
+
client: {
|
|
128
|
+
async generateDataKey(ctx) {
|
|
129
|
+
if (stub.fail) throw new VaultKeyUnavailableError();
|
|
130
|
+
stub.generated.push({ ...ctx });
|
|
131
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
132
|
+
const blob = Buffer.from(crypto.getRandomValues(new Uint8Array(48))).toString("base64");
|
|
133
|
+
store.set(blob, { bytes: bytes.slice(), ctx: { ...ctx } });
|
|
134
|
+
return { plaintext: bytes, ciphertextBlob: blob, keyArn: KMS_KEY_ARN };
|
|
135
|
+
},
|
|
136
|
+
async decrypt(blob, ctx, keyArn) {
|
|
137
|
+
if (stub.fail) throw new VaultKeyUnavailableError();
|
|
138
|
+
stub.decrypted.push({ ...ctx });
|
|
139
|
+
const entry = store.get(blob);
|
|
140
|
+
if (!entry || keyArn !== KMS_KEY_ARN || entry.ctx.stage !== ctx.stage || entry.ctx.purpose !== ctx.purpose
|
|
141
|
+
|| (!stub.ignoreTenant && entry.ctx.tenantId !== ctx.tenantId)) {
|
|
142
|
+
throw new VaultKeyUnavailableError();
|
|
143
|
+
}
|
|
144
|
+
return entry.bytes.slice();
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
return stub;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const DAY_MS = 86_400_000;
|
|
152
|
+
export const HOUR_MS = 3_600_000;
|
|
153
|
+
export const T0 = Date.parse("2026-01-01T00:00:00.000Z");
|
|
154
|
+
export const day = (n: number): Date => new Date(T0 + n * DAY_MS);
|
|
155
|
+
export const iso = (n: number): string => day(n).toISOString();
|
|
156
|
+
|
|
157
|
+
export const TENANT = "synthetic-tenant";
|
|
158
|
+
export const OTHER_TENANT = "other-synthetic-tenant";
|
|
159
|
+
export const ISSUER = "synthetic-issuer";
|
|
160
|
+
export const SYNTHETIC = [
|
|
161
|
+
"Synthetic Customer", "customer@example.test", "+1 555 0100", "15550100", "1 Example Way", "Suite 9", "Synthetic Billing",
|
|
162
|
+
"Example City", "90210",
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
export function contact(orderId = "order-1", overrides: { phone?: string; email?: string } = {}): OrderContactCommand {
|
|
166
|
+
const read = readOrderContactCommand({
|
|
167
|
+
kind: "order-contact",
|
|
168
|
+
v: 1,
|
|
169
|
+
orderId,
|
|
170
|
+
customer: { name: "Synthetic Customer", email: overrides.email ?? "customer@example.test", phone: overrides.phone ?? "+1 555 0100" },
|
|
171
|
+
shipTo: { name: "Synthetic Customer", line1: "1 Example Way", line2: "Suite 9", city: "Example City", region: "CA", postalCode: "90210", country: "US" },
|
|
172
|
+
billTo: { name: "Synthetic Billing", line1: "1 Example Way", city: "Example City", postalCode: "90210", country: "US" },
|
|
173
|
+
});
|
|
174
|
+
if (!read) throw new Error("fixture command did not parse");
|
|
175
|
+
return read;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function seedOrder(
|
|
179
|
+
h: { raw: SqliteDatabase },
|
|
180
|
+
orderId: string,
|
|
181
|
+
options: { placedAt?: string; fulfillment?: string; tenantId?: string; stage?: string; amountMinor?: number } = {},
|
|
182
|
+
): void {
|
|
183
|
+
h.raw.prepare(
|
|
184
|
+
`INSERT INTO scrappy_commerce_orders
|
|
185
|
+
(tenant_id, stage, order_id, source, source_order_id, currency, placed_at, payment_state, compliance_state,
|
|
186
|
+
stock_state, fulfillment_state, label_state, amount_minor, event_version)
|
|
187
|
+
VALUES (?, ?, ?, 'foxycart', ?, 'USD', ?, 'captured', 'fresh', 'confirmed', ?, 'none', ?, 1)`,
|
|
188
|
+
).run(
|
|
189
|
+
options.tenantId ?? TENANT, options.stage ?? "publication", orderId, orderId,
|
|
190
|
+
options.placedAt ?? iso(0), options.fulfillment ?? "ready", options.amountMinor ?? 2500,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function seedAllocation(h: { raw: SqliteDatabase }, orderId: string, sku: string, quantity: number, stage = "publication"): void {
|
|
195
|
+
h.raw.prepare(
|
|
196
|
+
`INSERT INTO scrappy_allocations (tenant_id, stage, order_id, sku, location_id, quantity, state)
|
|
197
|
+
VALUES (?, ?, ?, ?, 'unspecified', ?, 'confirmed')`,
|
|
198
|
+
).run(TENANT, stage, orderId, sku, quantity);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let auditSeq = 0;
|
|
202
|
+
/** A terminal shipment-audit row, written in the real `<ISO>#NNNNNN` stamp format by default. */
|
|
203
|
+
export function terminalAudit(
|
|
204
|
+
h: { raw: SqliteDatabase },
|
|
205
|
+
orderId: string,
|
|
206
|
+
at: string,
|
|
207
|
+
options: { kind?: "transition" | "pending-cancel"; stage?: string; raw?: boolean; tenantId?: string } = {},
|
|
208
|
+
): void {
|
|
209
|
+
auditSeq += 1;
|
|
210
|
+
const kind = options.kind ?? "transition";
|
|
211
|
+
const stamp = options.raw ? at : `${at}#${String(auditSeq).padStart(6, "0")}`;
|
|
212
|
+
h.raw.prepare(
|
|
213
|
+
`INSERT INTO scrappy_shipment_audit (audit_id, tenant_id, stage, order_id, at, actor_id, kind, from_state, to_state)
|
|
214
|
+
VALUES (?, ?, ?, ?, ?, 'subject-merchant', ?, ?, ?)`,
|
|
215
|
+
).run(`ship-audit-${auditSeq}`, options.tenantId ?? TENANT, options.stage ?? "publication", orderId, stamp, kind,
|
|
216
|
+
kind === "transition" ? "ready" : "requested", kind === "transition" ? "handed_off" : "confirmed");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Make an order terminal (handed off) at `at`, as the fulfillment service records it. */
|
|
220
|
+
export function handOff(h: { raw: SqliteDatabase }, orderId: string, at: string, stage = "publication"): void {
|
|
221
|
+
h.raw.prepare(`UPDATE scrappy_commerce_orders SET fulfillment_state = 'handed_off' WHERE order_id = ? AND stage = ?`).run(orderId, stage);
|
|
222
|
+
terminalAudit(h, orderId, at, { stage });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function count(h: { raw: SqliteDatabase }, table: string, where = "1 = 1", ...params: unknown[]): number {
|
|
226
|
+
return Number(h.raw.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get(...params)?.n ?? 0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Row counts of every table an intake, read, erase or purge could touch. */
|
|
230
|
+
export function counts(h: { raw: SqliteDatabase }): Record<string, number> {
|
|
231
|
+
return {
|
|
232
|
+
vault: count(h, "order_contact_vault"),
|
|
233
|
+
markers: count(h, "order_contact_markers"),
|
|
234
|
+
keys: count(h, "contact_vault_data_keys"),
|
|
235
|
+
replay: count(h, "scrappy_caller_replay"),
|
|
236
|
+
audit: count(h, "contact_access_audit"),
|
|
237
|
+
orders: count(h, "scrappy_commerce_orders"),
|
|
238
|
+
messages: count(h, "contact_messages"),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function vaultRow(h: { raw: SqliteDatabase }, orderId: string, tenantId = TENANT, stage = "publication"): Record<string, unknown> | undefined {
|
|
243
|
+
return h.raw.prepare(`SELECT * FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`).get(tenantId, stage, orderId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Every table's contents as one string, to prove no synthetic contact value lands outside a ciphertext. */
|
|
247
|
+
export function allTableText(h: { raw: SqliteDatabase }): string {
|
|
248
|
+
const tables = h.raw.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((row) => String(row.name));
|
|
249
|
+
return tables.map((table) => JSON.stringify(h.raw.prepare(`SELECT * FROM "${table}"`).all())).join("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Every ciphertext and blind index currently stored in the vault. */
|
|
253
|
+
export function storedSecrets(h: { raw: SqliteDatabase }): string[] {
|
|
254
|
+
const rows = h.raw.prepare(`SELECT * FROM order_contact_vault`).all();
|
|
255
|
+
return rows.flatMap((row) => Object.entries(row)
|
|
256
|
+
.filter(([column, value]) => (column.endsWith("_ct") || column.endsWith("_bidx")) && typeof value === "string")
|
|
257
|
+
.map(([, value]) => String(value)));
|
|
258
|
+
}
|
|
@@ -3,11 +3,15 @@ import { routedApiParam } from "@/lib/publication/shared/routedApiParam";
|
|
|
3
3
|
* Owner retrieval and erase of an order's customer contact (name, email, phone, ship-to, bill-to)
|
|
4
4
|
* from the contact vault. ToT staff are refused here (403 support-read-required) and use the
|
|
5
5
|
* reason-and-grant routes under ./contact/. Every read and erase is audited before it acts.
|
|
6
|
+
*
|
|
7
|
+
* GET returns only the field groups readable now, each with its expiry and retention rule id.
|
|
8
|
+
* DELETE erases the groups named in an optional JSON body `{groups: [...]}` (default: every group no
|
|
9
|
+
* retention floor holds); a floor-held group answers 409 retention-hold with its hold-until date.
|
|
6
10
|
*/
|
|
7
11
|
import type { APIContext } from "astro";
|
|
8
12
|
import { isOpaqueId } from "@/lib/fulfillment/fulfillmentService";
|
|
9
13
|
import { contactJson, openContactRoute } from "@/lib/orders/vault/contactRoutes";
|
|
10
|
-
import { eraseOrderContact, readOrderContact } from "@/lib/orders/vault/orderContactVault";
|
|
14
|
+
import { eraseOrderContact, readEraseGroups, readOrderContact, type Group } from "@/lib/orders/vault/orderContactVault";
|
|
11
15
|
|
|
12
16
|
export const prerender = false;
|
|
13
17
|
|
|
@@ -16,11 +20,29 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
16
20
|
if (!orderId || !isOpaqueId(orderId)) return contactJson({ error: "not-found" }, 404);
|
|
17
21
|
const route = await openContactRoute(context, "owner");
|
|
18
22
|
if (!route.ok) return route.response;
|
|
19
|
-
const read = await readOrderContact(route.db, route.
|
|
23
|
+
const read = await readOrderContact(route.db, route.kms, {
|
|
20
24
|
tenantId: route.tenantId, stage: route.stage, orderId, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(),
|
|
21
25
|
});
|
|
22
26
|
if (!read.ok) return contactJson({ error: read.reason }, read.status);
|
|
23
|
-
return contactJson({ ...read.contact, expiresAt: read.expiresAt }, 200);
|
|
27
|
+
return contactJson({ ...read.contact, expiresAt: read.expiresAt, groups: read.groups }, 200);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The erase body: empty means the default groups; otherwise exactly `{groups}`. Null when malformed. */
|
|
31
|
+
async function eraseGroups(request: Request): Promise<Group[] | undefined | null> {
|
|
32
|
+
const raw = await request.text();
|
|
33
|
+
if (raw.trim() === "") return undefined;
|
|
34
|
+
if (raw.length > 4096) return null;
|
|
35
|
+
let body: unknown;
|
|
36
|
+
try {
|
|
37
|
+
body = JSON.parse(raw);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof SyntaxError) return null;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
43
|
+
const keys = Object.keys(body);
|
|
44
|
+
if (keys.length !== 1 || keys[0] !== "groups") return null;
|
|
45
|
+
return readEraseGroups((body as { groups: unknown }).groups);
|
|
24
46
|
}
|
|
25
47
|
|
|
26
48
|
export async function DELETE(context: APIContext): Promise<Response> {
|
|
@@ -28,9 +50,16 @@ export async function DELETE(context: APIContext): Promise<Response> {
|
|
|
28
50
|
if (!orderId || !isOpaqueId(orderId)) return contactJson({ error: "not-found" }, 404);
|
|
29
51
|
const route = await openContactRoute(context, "owner");
|
|
30
52
|
if (!route.ok) return route.response;
|
|
53
|
+
const groups = await eraseGroups(context.request);
|
|
54
|
+
if (groups === null) return contactJson({ error: "malformed" }, 400);
|
|
31
55
|
const erased = await eraseOrderContact(route.db, {
|
|
32
56
|
tenantId: route.tenantId, stage: route.stage, orderId, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(),
|
|
57
|
+
...(groups ? { groups } : {}),
|
|
33
58
|
});
|
|
34
|
-
if (!erased.ok)
|
|
35
|
-
|
|
59
|
+
if (!erased.ok) {
|
|
60
|
+
return erased.reason === "retention-hold"
|
|
61
|
+
? contactJson({ error: erased.reason, group: erased.group, holdUntil: erased.holdUntil }, erased.status)
|
|
62
|
+
: contactJson({ error: erased.reason }, erased.status);
|
|
63
|
+
}
|
|
64
|
+
return contactJson({ erased: true, groups: erased.groups }, 200);
|
|
36
65
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /api/admin/fulfillment/orders/contact-search` — owner exact-match search of order contact by
|
|
3
|
+
* email or phone. The value travels only in the JSON body `{email}` or `{phone}`: Workers logs
|
|
4
|
+
* record URLs, so any query string is refused before the body is read. Returns the routed tenant
|
|
5
|
+
* and stage's matching order ids (no-store) and writes a `search` audit row holding the count only.
|
|
6
|
+
* Staff are refused 403 support-read-required. Nothing here logs the body.
|
|
7
|
+
*/
|
|
8
|
+
import type { APIContext } from "astro";
|
|
9
|
+
import { contactJson, openContactRoute, readRequestJson } from "@/lib/orders/vault/contactRoutes";
|
|
10
|
+
import { readSearchQuery, searchOrderContacts } from "@/lib/orders/vault/orderContactVault";
|
|
11
|
+
|
|
12
|
+
export const prerender = false;
|
|
13
|
+
|
|
14
|
+
export async function POST(context: APIContext): Promise<Response> {
|
|
15
|
+
if (new URL(context.request.url).search !== "") return contactJson({ error: "malformed" }, 400);
|
|
16
|
+
const route = await openContactRoute(context, "owner");
|
|
17
|
+
if (!route.ok) return route.response;
|
|
18
|
+
const query = readSearchQuery(await readRequestJson(context.request));
|
|
19
|
+
if (!query) return contactJson({ error: "malformed" }, 400);
|
|
20
|
+
const found = await searchOrderContacts(route.db, route.kms, {
|
|
21
|
+
tenantId: route.tenantId, stage: route.stage, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(), query,
|
|
22
|
+
});
|
|
23
|
+
if (!found.ok) return contactJson({ error: found.reason }, found.status);
|
|
24
|
+
return contactJson({ orderIds: found.orderIds }, 200);
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/admin/fulfillment/orders/contacts` — the Orders panel's contact list for the routed
|
|
3
|
+
* tenant and stage: one query joining orders to the vault, one `list` audit row carrying the count
|
|
4
|
+
* (written before anything is decrypted), then only the field groups readable now with their
|
|
5
|
+
* expiries and rule ids. Owner only; staff are refused 403 support-read-required.
|
|
6
|
+
*/
|
|
7
|
+
import type { APIContext } from "astro";
|
|
8
|
+
import { contactJson, openContactRoute } from "@/lib/orders/vault/contactRoutes";
|
|
9
|
+
import { listOrderContacts } from "@/lib/orders/vault/orderContactVault";
|
|
10
|
+
|
|
11
|
+
export const prerender = false;
|
|
12
|
+
|
|
13
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
14
|
+
const route = await openContactRoute(context, "owner");
|
|
15
|
+
if (!route.ok) return route.response;
|
|
16
|
+
const listed = await listOrderContacts(route.db, route.kms, {
|
|
17
|
+
tenantId: route.tenantId, stage: route.stage, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(),
|
|
18
|
+
});
|
|
19
|
+
if (!listed.ok) return contactJson({ error: listed.reason }, listed.status);
|
|
20
|
+
return contactJson({ contacts: listed.contacts }, 200);
|
|
21
|
+
}
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* binding refuses with 503; the rate limiter fails closed. Responses never echo
|
|
11
11
|
* input, and logs carry only tenant, submission id and outcome.
|
|
12
12
|
*
|
|
13
|
-
* The message is stored only as
|
|
14
|
-
* (`customizationEnv` live = publication, test = preview). An unknown
|
|
15
|
-
* 409
|
|
13
|
+
* The message is stored only as field-level contact vault ciphertexts under the
|
|
14
|
+
* routed stage (`customizationEnv` live = publication, test = preview). An unknown
|
|
15
|
+
* stage refuses 409 before any write; a missing or malformed `CONTACT_VAULT_KMS`
|
|
16
|
+
* secret, a KMS error or a KMS timeout refuses 503 (retryable) with nothing written.
|
|
16
17
|
*/
|
|
17
18
|
import type { APIContext } from "astro";
|
|
18
19
|
import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
|
|
@@ -21,8 +22,8 @@ import { checkRateLimit } from "@/lib/access/rate/kvWindow";
|
|
|
21
22
|
import { hashConsentIp } from "@/lib/engagement/newsletter/consent";
|
|
22
23
|
import { ContactMessagesStore } from "@/lib/engagement/contact/contactMessages";
|
|
23
24
|
import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
25
|
+
import { VaultKeyUnavailableError } from "@/lib/customers/contactVault/kms.js";
|
|
26
|
+
import { loadVaultKms } from "@/lib/orders/vault/kmsEnv";
|
|
26
27
|
import { verifyContactToken } from "@/lib/engagement/contact/submitToken";
|
|
27
28
|
import { requestClientIp } from "@/lib/publication/shared/cdnForward";
|
|
28
29
|
import {
|
|
@@ -127,11 +128,8 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
127
128
|
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
128
129
|
if (!d1) return refuse("no_database", "unavailable", 503);
|
|
129
130
|
|
|
130
|
-
const keyring = await loadVaultKeyring();
|
|
131
|
-
if (!keyring) return refuse("vault_key_unavailable", "unavailable", 503);
|
|
132
|
-
|
|
133
131
|
try {
|
|
134
|
-
await new ContactMessagesStore(fromD1(d1),
|
|
132
|
+
await new ContactMessagesStore(fromD1(d1), await loadVaultKms()).insert({
|
|
135
133
|
id: submissionId,
|
|
136
134
|
tenantId,
|
|
137
135
|
stage,
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The hourly contact vault sweep — the Worker's `scheduled` handler body. One `now` per firing,
|
|
3
|
-
* taken from its scheduled time:
|
|
4
|
-
* orders' retention,
|
|
5
|
-
* only; it never touches an order
|
|
3
|
+
* taken from its scheduled time: clear expired order-contact field groups and contact messages,
|
|
4
|
+
* shorten terminal orders' retention, seal any contact message still held as legacy plaintext,
|
|
5
|
+
* rotate data keys older than a year, and recompute blind indexes still under a retired kid. Expiry predicates only; it never touches an order or audit row.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Purge never needs a key, so it always runs; sealing and rotation need KMS and are skipped (with
|
|
8
|
+
* `kmsAvailable: false`) when it refuses. It logs ONE structured line of counts on success and ONE
|
|
9
|
+
* fixed error code on failure — never an error message, id or row, since either could carry data.
|
|
10
|
+
* It composes orders and engagement, which no module under src/lib may do, so it lives beside the
|
|
11
|
+
* Worker entry.
|
|
10
12
|
*/
|
|
11
13
|
import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
|
|
12
|
-
import type
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
import { VaultKeyUnavailableError, type KmsClient } from "@/lib/customers/contactVault/kms.js";
|
|
15
|
+
import {
|
|
16
|
+
purgeContactMessages,
|
|
17
|
+
recomputeMessageBlindIndexes,
|
|
18
|
+
sealLegacyContactMessages,
|
|
19
|
+
type MessagePurgeCounts,
|
|
20
|
+
} from "@/lib/engagement/contact/contactMessages.js";
|
|
21
|
+
import { loadVaultKms } from "@/lib/orders/vault/kmsEnv.js";
|
|
22
|
+
import { purgeOrderContacts, rotateContactVaultKeys, type PurgeCounts } from "@/lib/orders/vault/orderContactVault.js";
|
|
16
23
|
import { readD1 } from "@/lib/publication/shared/env";
|
|
17
24
|
|
|
18
25
|
export const SWEEP_LEGACY_SEAL_LIMIT = 500;
|
|
@@ -22,25 +29,45 @@ export const SWEEP_FAILED = "contact-vault-sweep-failed";
|
|
|
22
29
|
|
|
23
30
|
export interface SweepCounts {
|
|
24
31
|
orders: PurgeCounts;
|
|
25
|
-
|
|
32
|
+
messages: MessagePurgeCounts;
|
|
33
|
+
kmsAvailable: boolean;
|
|
26
34
|
legacySealed: number;
|
|
27
35
|
legacyRemaining: number;
|
|
36
|
+
keysRotated: number;
|
|
37
|
+
blindIndexesRecomputed: number;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
|
-
|
|
40
|
+
/** Run a KMS-dependent step; a KMS refusal reports unavailable instead of failing the sweep. */
|
|
41
|
+
async function withKms<T>(step: () => Promise<T>): Promise<T | null> {
|
|
42
|
+
try {
|
|
43
|
+
return await step();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof VaultKeyUnavailableError) return null;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function runContactVaultSweep(db: Queryable, kms: KmsClient, now: Date): Promise<SweepCounts> {
|
|
31
51
|
if (Number.isNaN(now.getTime())) throw new Error("contact vault sweep: unreadable clock");
|
|
32
52
|
const orders = await purgeOrderContacts(db, now);
|
|
33
|
-
const
|
|
34
|
-
const legacy =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
const messages = await purgeContactMessages(db, now);
|
|
54
|
+
const legacy = await withKms(() => sealLegacyContactMessages(db, kms, now, SWEEP_LEGACY_SEAL_LIMIT));
|
|
55
|
+
const rotation = await withKms(() => rotateContactVaultKeys(db, kms, now, { recompute: [recomputeMessageBlindIndexes] }));
|
|
56
|
+
return {
|
|
57
|
+
orders,
|
|
58
|
+
messages,
|
|
59
|
+
kmsAvailable: legacy !== null && rotation !== null,
|
|
60
|
+
legacySealed: legacy?.sealed ?? 0,
|
|
61
|
+
legacyRemaining: legacy?.remaining ?? -1,
|
|
62
|
+
keysRotated: rotation?.rotated ?? 0,
|
|
63
|
+
blindIndexesRecomputed: rotation?.recomputed ?? 0,
|
|
64
|
+
};
|
|
38
65
|
}
|
|
39
66
|
|
|
40
67
|
export interface ScheduledSweepDeps {
|
|
41
68
|
openDb(): Promise<Queryable | null>;
|
|
42
|
-
|
|
43
|
-
sweep(db: Queryable,
|
|
69
|
+
loadKms(): Promise<KmsClient>;
|
|
70
|
+
sweep(db: Queryable, kms: KmsClient, now: Date): Promise<SweepCounts>;
|
|
44
71
|
log(line: string): void;
|
|
45
72
|
logError(line: string): void;
|
|
46
73
|
}
|
|
@@ -50,7 +77,7 @@ const workerDeps: ScheduledSweepDeps = {
|
|
|
50
77
|
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
51
78
|
return d1 ? fromD1(d1) : null;
|
|
52
79
|
},
|
|
53
|
-
|
|
80
|
+
loadKms: loadVaultKms,
|
|
54
81
|
sweep: runContactVaultSweep,
|
|
55
82
|
log: (line) => console.log(line),
|
|
56
83
|
logError: (line) => console.error(line),
|
|
@@ -62,17 +89,19 @@ export async function runScheduledContactVaultSweep(scheduledTime: number, deps:
|
|
|
62
89
|
try {
|
|
63
90
|
const db = await deps.openDb();
|
|
64
91
|
if (!db) return fail(SWEEP_DB_UNAVAILABLE);
|
|
65
|
-
const
|
|
66
|
-
const counts = await deps.sweep(db, keyring, new Date(scheduledTime));
|
|
92
|
+
const counts = await deps.sweep(db, await deps.loadKms(), new Date(scheduledTime));
|
|
67
93
|
deps.log(JSON.stringify({
|
|
68
94
|
event: CONTACT_VAULT_SWEEP_EVENT,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
95
|
+
kmsAvailable: counts.kmsAvailable,
|
|
96
|
+
groupsPurged: counts.orders.groupsPurged,
|
|
97
|
+
ordersDetached: counts.orders.detached,
|
|
98
|
+
ordersPreviewDeleted: counts.orders.previewDeleted,
|
|
72
99
|
ordersShortened: counts.orders.shortened,
|
|
73
|
-
|
|
100
|
+
messages: counts.messages,
|
|
74
101
|
legacySealed: counts.legacySealed,
|
|
75
102
|
legacyRemaining: counts.legacyRemaining,
|
|
103
|
+
keysRotated: counts.keysRotated,
|
|
104
|
+
blindIndexesRecomputed: counts.blindIndexesRecomputed,
|
|
76
105
|
}));
|
|
77
106
|
} catch {
|
|
78
107
|
fail(SWEEP_FAILED);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.11.
|
|
3
|
+
"version": "4.11.1",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|