@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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
-- Contact vault: order customer contact and contact-form messages are stored only as AES-256-GCM
|
|
2
|
+
-- envelopes (src/lib/orders/vault/envelope.ts) in expiring rows. One transaction per file.
|
|
3
|
+
--
|
|
4
|
+
-- The plaintext scrappy_order_addresses table is dropped only when it is empty: the guard row's
|
|
5
|
+
-- CHECK fails on any other count and aborts the whole file.
|
|
6
|
+
CREATE TABLE `_gv_vault_guard` (`n` integer NOT NULL CHECK (`n` = 0));
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
INSERT INTO `_gv_vault_guard` (`n`) SELECT COUNT(*) FROM `scrappy_order_addresses`;
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
DROP TABLE `_gv_vault_guard`;
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
DROP TABLE `scrappy_order_addresses`;
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
CREATE TABLE `order_contact_vault` (
|
|
15
|
+
`tenant_id` text NOT NULL,
|
|
16
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
17
|
+
`order_id` text NOT NULL,
|
|
18
|
+
`key_id` text NOT NULL,
|
|
19
|
+
`nonce` text NOT NULL,
|
|
20
|
+
`ciphertext` text NOT NULL,
|
|
21
|
+
`not_after` text NOT NULL,
|
|
22
|
+
`expires_at` text NOT NULL CHECK (`expires_at` <= `not_after`),
|
|
23
|
+
`sealed_at` text NOT NULL,
|
|
24
|
+
`sealed_jti` text NOT NULL,
|
|
25
|
+
PRIMARY KEY (`tenant_id`, `stage`, `order_id`),
|
|
26
|
+
FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
|
|
27
|
+
);
|
|
28
|
+
--> statement-breakpoint
|
|
29
|
+
CREATE INDEX `idx_order_contact_vault_expires` ON `order_contact_vault` (`expires_at`);
|
|
30
|
+
--> statement-breakpoint
|
|
31
|
+
CREATE INDEX `idx_order_contact_vault_key` ON `order_contact_vault` (`key_id`);
|
|
32
|
+
--> statement-breakpoint
|
|
33
|
+
CREATE TRIGGER `order_contact_vault_retention_only_shrinks`
|
|
34
|
+
BEFORE UPDATE ON `order_contact_vault`
|
|
35
|
+
WHEN NEW.`expires_at` > OLD.`expires_at`
|
|
36
|
+
OR NEW.`not_after` IS NOT OLD.`not_after`
|
|
37
|
+
OR NEW.`key_id` IS NOT OLD.`key_id`
|
|
38
|
+
OR NEW.`nonce` IS NOT OLD.`nonce`
|
|
39
|
+
OR NEW.`ciphertext` IS NOT OLD.`ciphertext`
|
|
40
|
+
OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
|
|
41
|
+
OR NEW.`stage` IS NOT OLD.`stage`
|
|
42
|
+
OR NEW.`order_id` IS NOT OLD.`order_id`
|
|
43
|
+
BEGIN
|
|
44
|
+
SELECT RAISE(ABORT, 'vault-immutable');
|
|
45
|
+
END;
|
|
46
|
+
--> statement-breakpoint
|
|
47
|
+
CREATE TABLE `order_contact_tombstones` (
|
|
48
|
+
`tenant_id` text NOT NULL,
|
|
49
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
50
|
+
`order_id` text NOT NULL,
|
|
51
|
+
`reason` text NOT NULL CHECK (`reason` IN ('purged', 'erased')),
|
|
52
|
+
`at` text NOT NULL,
|
|
53
|
+
`not_after` text NOT NULL,
|
|
54
|
+
PRIMARY KEY (`tenant_id`, `stage`, `order_id`)
|
|
55
|
+
);
|
|
56
|
+
--> statement-breakpoint
|
|
57
|
+
CREATE INDEX `idx_order_contact_tombstones_not_after` ON `order_contact_tombstones` (`not_after`);
|
|
58
|
+
--> statement-breakpoint
|
|
59
|
+
CREATE TABLE `contact_access_audit` (
|
|
60
|
+
`audit_id` text NOT NULL PRIMARY KEY,
|
|
61
|
+
`tenant_id` text NOT NULL,
|
|
62
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
63
|
+
`record_kind` text NOT NULL CHECK (`record_kind` IN ('order-contact', 'contact-message')),
|
|
64
|
+
`record_id` text NOT NULL,
|
|
65
|
+
`at` text NOT NULL,
|
|
66
|
+
`actor_id` text NOT NULL,
|
|
67
|
+
`actor_role` text NOT NULL CHECK (`actor_role` IN ('owner', 'staff')),
|
|
68
|
+
`reason_code` text,
|
|
69
|
+
`ticket_ref` text,
|
|
70
|
+
`action` text NOT NULL CHECK (`action` IN ('read', 'list', 'erase')),
|
|
71
|
+
`outcome` text NOT NULL CHECK (`outcome` = 'admitted'),
|
|
72
|
+
CHECK (`actor_role` = 'owner' OR (`reason_code` IS NOT NULL AND `ticket_ref` IS NOT NULL))
|
|
73
|
+
);
|
|
74
|
+
--> statement-breakpoint
|
|
75
|
+
CREATE INDEX `idx_contact_access_audit_quota` ON `contact_access_audit` (`tenant_id`, `actor_id`, `actor_role`, `at`);
|
|
76
|
+
--> statement-breakpoint
|
|
77
|
+
CREATE TRIGGER `contact_access_audit_no_update`
|
|
78
|
+
BEFORE UPDATE ON `contact_access_audit`
|
|
79
|
+
BEGIN
|
|
80
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
81
|
+
END;
|
|
82
|
+
--> statement-breakpoint
|
|
83
|
+
CREATE TRIGGER `contact_access_audit_no_delete`
|
|
84
|
+
BEFORE DELETE ON `contact_access_audit`
|
|
85
|
+
BEGIN
|
|
86
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
87
|
+
END;
|
|
88
|
+
--> statement-breakpoint
|
|
89
|
+
-- contact_messages: every pre-existing row is kept as a legacy row (plaintext in legacy_* until
|
|
90
|
+
-- the rollout seals it) under stage publication; new rows are written sealed only.
|
|
91
|
+
CREATE TABLE `contact_messages_sealed` (
|
|
92
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
93
|
+
`tenant_id` text NOT NULL,
|
|
94
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
95
|
+
`key_id` text,
|
|
96
|
+
`nonce` text,
|
|
97
|
+
`ciphertext` text,
|
|
98
|
+
`not_after` text NOT NULL,
|
|
99
|
+
`expires_at` text NOT NULL CHECK (`expires_at` <= `not_after`),
|
|
100
|
+
`page_path` text,
|
|
101
|
+
`status` text NOT NULL DEFAULT 'unread' CHECK (`status` IN ('unread', 'read')),
|
|
102
|
+
`created_at` text NOT NULL,
|
|
103
|
+
`read_at` text,
|
|
104
|
+
`notify_status` text NOT NULL DEFAULT 'pending' CHECK (`notify_status` IN ('pending', 'sent', 'failed')),
|
|
105
|
+
`notify_attempts` integer NOT NULL DEFAULT 0,
|
|
106
|
+
`legacy_name` text,
|
|
107
|
+
`legacy_email` text,
|
|
108
|
+
`legacy_phone` text,
|
|
109
|
+
`legacy_message` text,
|
|
110
|
+
CHECK (
|
|
111
|
+
(`key_id` IS NOT NULL AND `nonce` IS NOT NULL AND `ciphertext` IS NOT NULL
|
|
112
|
+
AND `legacy_name` IS NULL AND `legacy_email` IS NULL AND `legacy_phone` IS NULL AND `legacy_message` IS NULL)
|
|
113
|
+
OR (`key_id` IS NULL AND `nonce` IS NULL AND `ciphertext` IS NULL
|
|
114
|
+
AND `legacy_email` IS NOT NULL AND `legacy_message` IS NOT NULL)
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
--> statement-breakpoint
|
|
118
|
+
INSERT INTO `contact_messages_sealed`
|
|
119
|
+
(`id`, `tenant_id`, `stage`, `not_after`, `expires_at`, `page_path`, `status`, `created_at`, `read_at`,
|
|
120
|
+
`notify_status`, `notify_attempts`, `legacy_name`, `legacy_email`, `legacy_phone`, `legacy_message`)
|
|
121
|
+
SELECT `id`, `tenant_id`, 'publication',
|
|
122
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', `created_at`, '+365 days'),
|
|
123
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', `created_at`, '+365 days'),
|
|
124
|
+
`page_path`, `status`, `created_at`, `read_at`, `notify_status`, `notify_attempts`,
|
|
125
|
+
`name`, `email`, `phone`, `message`
|
|
126
|
+
FROM `contact_messages`;
|
|
127
|
+
--> statement-breakpoint
|
|
128
|
+
DROP TABLE `contact_messages`;
|
|
129
|
+
--> statement-breakpoint
|
|
130
|
+
ALTER TABLE `contact_messages_sealed` RENAME TO `contact_messages`;
|
|
131
|
+
--> statement-breakpoint
|
|
132
|
+
CREATE INDEX `idx_contact_messages_tenant_stage_created` ON `contact_messages` (`tenant_id`, `stage`, `created_at`);
|
|
133
|
+
--> statement-breakpoint
|
|
134
|
+
CREATE INDEX `idx_contact_messages_tenant_stage_status` ON `contact_messages` (`tenant_id`, `stage`, `status`);
|
|
135
|
+
--> statement-breakpoint
|
|
136
|
+
CREATE INDEX `idx_contact_messages_expires` ON `contact_messages` (`expires_at`);
|
|
137
|
+
--> statement-breakpoint
|
|
138
|
+
CREATE INDEX `idx_contact_messages_legacy` ON `contact_messages` (`id`) WHERE `ciphertext` IS NULL;
|
|
139
|
+
--> statement-breakpoint
|
|
140
|
+
CREATE TRIGGER `contact_messages_no_plaintext_insert`
|
|
141
|
+
BEFORE INSERT ON `contact_messages`
|
|
142
|
+
WHEN NEW.`legacy_name` IS NOT NULL OR NEW.`legacy_email` IS NOT NULL
|
|
143
|
+
OR NEW.`legacy_phone` IS NOT NULL OR NEW.`legacy_message` IS NOT NULL
|
|
144
|
+
BEGIN
|
|
145
|
+
SELECT RAISE(ABORT, 'plaintext-refused');
|
|
146
|
+
END;
|
|
147
|
+
--> statement-breakpoint
|
|
148
|
+
CREATE TRIGGER `contact_messages_no_plaintext_update`
|
|
149
|
+
BEFORE UPDATE ON `contact_messages`
|
|
150
|
+
WHEN (NEW.`legacy_name` IS NOT NULL AND NEW.`legacy_name` IS NOT OLD.`legacy_name`)
|
|
151
|
+
OR (NEW.`legacy_email` IS NOT NULL AND NEW.`legacy_email` IS NOT OLD.`legacy_email`)
|
|
152
|
+
OR (NEW.`legacy_phone` IS NOT NULL AND NEW.`legacy_phone` IS NOT OLD.`legacy_phone`)
|
|
153
|
+
OR (NEW.`legacy_message` IS NOT NULL AND NEW.`legacy_message` IS NOT OLD.`legacy_message`)
|
|
154
|
+
OR NEW.`expires_at` > OLD.`expires_at`
|
|
155
|
+
OR NEW.`not_after` IS NOT OLD.`not_after`
|
|
156
|
+
OR (OLD.`ciphertext` IS NOT NULL AND (NEW.`ciphertext` IS NOT OLD.`ciphertext`
|
|
157
|
+
OR NEW.`key_id` IS NOT OLD.`key_id` OR NEW.`nonce` IS NOT OLD.`nonce`))
|
|
158
|
+
OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
|
|
159
|
+
OR NEW.`stage` IS NOT OLD.`stage`
|
|
160
|
+
BEGIN
|
|
161
|
+
SELECT RAISE(ABORT, 'vault-immutable');
|
|
162
|
+
END;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may read customer contact data on THIS tenant, and on what authority.
|
|
3
|
+
*
|
|
4
|
+
* `isOwnerViewer` is not membership: a ToT-staff selection carries a capability, and a selection
|
|
5
|
+
* whose capability is `owner` makes `isOwnerViewer` true (loginGate `sessionHostCapability`). Contact
|
|
6
|
+
* reads must tell the two apart, because reading order and customer PII is grant-required for staff
|
|
7
|
+
* (guidance://access-authority) while an owner reads their own store's data. So:
|
|
8
|
+
*
|
|
9
|
+
* owner — a `tenants[]` membership for this host, or a session scoped to this host
|
|
10
|
+
* (`record.resource === host`), whose role is owner or admin. A staff selection never
|
|
11
|
+
* counts, whatever its capability.
|
|
12
|
+
* staff — no owner standing, but a live staff selection for this host. Staff still need a live
|
|
13
|
+
* `customer-contact-read` grant ({@link resolveContactReadGrant}) and a reason.
|
|
14
|
+
* none — anything else.
|
|
15
|
+
*/
|
|
16
|
+
import type { SessionRecord } from "./session.js";
|
|
17
|
+
import { normalizeMemberships, staffAdmits } from "@/lib/access/dashboard/tenantSelection";
|
|
18
|
+
import { isOwnerCapability } from "@/lib/access/shared/capability";
|
|
19
|
+
import { resolveNamedSubjectGrant, type ViewerShipGrantDeps } from "@/lib/access/shared/viewerGrant";
|
|
20
|
+
|
|
21
|
+
export type ContactReaderAuthority =
|
|
22
|
+
| { kind: "owner"; actorId: string }
|
|
23
|
+
| { kind: "staff"; actorId: string; email: string }
|
|
24
|
+
| { kind: "none" };
|
|
25
|
+
|
|
26
|
+
const ACTOR_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
27
|
+
|
|
28
|
+
export function contactReaderAuthority(
|
|
29
|
+
record: SessionRecord | null | undefined,
|
|
30
|
+
hostResource: string,
|
|
31
|
+
nowSeconds: number,
|
|
32
|
+
): ContactReaderAuthority {
|
|
33
|
+
const host = (hostResource ?? "").trim();
|
|
34
|
+
if (!record || !host) return { kind: "none" };
|
|
35
|
+
const actorId = typeof record.subject === "string" && ACTOR_ID.test(record.subject) ? record.subject : null;
|
|
36
|
+
if (!actorId) return { kind: "none" };
|
|
37
|
+
|
|
38
|
+
const membership = normalizeMemberships(record.tenants).find((t) => t.resource === host);
|
|
39
|
+
const scoped = record.resource === host ? (record.capability ?? "").trim() : "";
|
|
40
|
+
if (isOwnerCapability(membership?.role) || isOwnerCapability(scoped)) return { kind: "owner", actorId };
|
|
41
|
+
|
|
42
|
+
const staffScope = Array.isArray(record.staff) && record.staff.length > 0;
|
|
43
|
+
if (staffScope && staffAdmits({ selection: record.staffSelection, nowSeconds }, host)) {
|
|
44
|
+
return { kind: "staff", actorId, email: record.email };
|
|
45
|
+
}
|
|
46
|
+
return { kind: "none" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const CONTACT_READ_CAPABILITY = "customer-contact-read";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The staff principal's LIVE owner-consented `customer-contact-read` grant over `tenant`, read from
|
|
53
|
+
* the tot-mcp subject-capability seam (contract: decision gv-contact-vault-ownership). True only on
|
|
54
|
+
* an explicit `200 { capability: "customer-contact-read" }`. Unconfigured, any non-2xx, a timeout,
|
|
55
|
+
* an unparseable body or any other capability is false. No cache: a revoke takes effect on the next
|
|
56
|
+
* read. Never throws.
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveContactReadGrant(
|
|
59
|
+
email: string | null | undefined,
|
|
60
|
+
tenant: string | null | undefined,
|
|
61
|
+
deps: ViewerShipGrantDeps = {},
|
|
62
|
+
): Promise<boolean> {
|
|
63
|
+
return resolveNamedSubjectGrant(email, tenant, CONTACT_READ_CAPABILITY, deps);
|
|
64
|
+
}
|
|
@@ -108,6 +108,36 @@ async function resolveRawViewerCapability(
|
|
|
108
108
|
return "preview-access";
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Whether `email` holds a LIVE owner-consented grant of exactly `capability` over `appDomain`,
|
|
113
|
+
* asked of the same subject-capability seam with the capability named. True only on an explicit
|
|
114
|
+
* `200 { capability: <capability> }`; unconfigured, any non-2xx, a timeout, an unparseable body or
|
|
115
|
+
* any other answer is false. No cache: a revoke takes effect on the next call. Never throws.
|
|
116
|
+
*/
|
|
117
|
+
export async function resolveNamedSubjectGrant(
|
|
118
|
+
email: string | null | undefined,
|
|
119
|
+
appDomain: string | null | undefined,
|
|
120
|
+
capability: "customer-contact-read",
|
|
121
|
+
deps: ViewerShipGrantDeps = {},
|
|
122
|
+
): Promise<boolean> {
|
|
123
|
+
const subject = (email ?? "").trim().toLowerCase();
|
|
124
|
+
const tenant = (appDomain ?? "").trim();
|
|
125
|
+
if (!subject || !tenant) return false;
|
|
126
|
+
try {
|
|
127
|
+
const outcome = await callMcpService({
|
|
128
|
+
method: "GET",
|
|
129
|
+
path: "/internal/dev-access/subject-capability",
|
|
130
|
+
query: { subject, tenant, app: "storefront", capability },
|
|
131
|
+
transport: deps.transport ?? new FetchShipGrantTransport(),
|
|
132
|
+
});
|
|
133
|
+
if (outcome.kind !== "ok") return false;
|
|
134
|
+
const payload = (await outcome.json()) as Record<string, unknown> | null;
|
|
135
|
+
return payload?.capability === capability;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
111
141
|
/**
|
|
112
142
|
* Resolve the viewer's LIVE ship capability over `appDomain`. Returns the granted
|
|
113
143
|
* `Capability` — `"ship-on-behalf"` ONLY on an explicit `200 { capability:
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contact access audit (`contact_access_audit`, append-only): every read, list and erase of
|
|
3
|
+
* sealed customer contact writes its row BEFORE anything is decrypted. Staff rows carry the
|
|
4
|
+
* 30-per-hour quota in the same statement, so concurrent reads can never pass it together.
|
|
5
|
+
* Rows hold opaque ids, codes and times only — never a contact field.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type VaultStage = "preview" | "publication";
|
|
9
|
+
|
|
10
|
+
export const STAFF_READS_PER_HOUR = 30;
|
|
11
|
+
const HOUR_MS = 3_600_000;
|
|
12
|
+
|
|
13
|
+
export interface ContactActor {
|
|
14
|
+
actorId: string;
|
|
15
|
+
role: "owner" | "staff";
|
|
16
|
+
reasonCode?: string;
|
|
17
|
+
ticketRef?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AuditStatement {
|
|
21
|
+
sql: string;
|
|
22
|
+
params: readonly unknown[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The SQL surface the audit needs; D1's `fromD1` and the sqlite test harness both satisfy it. */
|
|
26
|
+
export interface AuditDb {
|
|
27
|
+
all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The audit insert. RETURNING tells admitted from refused (quota, or `requireRow` absent); an
|
|
32
|
+
* error means the audit could not be written.
|
|
33
|
+
*/
|
|
34
|
+
export function auditStatement(input: {
|
|
35
|
+
auditId: string;
|
|
36
|
+
tenantId: string;
|
|
37
|
+
stage: VaultStage;
|
|
38
|
+
recordKind: "order-contact" | "contact-message";
|
|
39
|
+
recordId: string;
|
|
40
|
+
actor: ContactActor;
|
|
41
|
+
action: "read" | "list" | "erase";
|
|
42
|
+
now: Date;
|
|
43
|
+
requireRow?: AuditStatement;
|
|
44
|
+
}): AuditStatement {
|
|
45
|
+
const { auditId, tenantId, stage, recordKind, recordId, actor, action, now } = input;
|
|
46
|
+
const hourAgo = new Date(now.getTime() - HOUR_MS).toISOString();
|
|
47
|
+
const params: unknown[] = [
|
|
48
|
+
auditId, tenantId, stage, recordKind, recordId, now.toISOString(), actor.actorId, actor.role,
|
|
49
|
+
actor.role === "staff" ? actor.reasonCode ?? null : null,
|
|
50
|
+
actor.role === "staff" ? actor.ticketRef ?? null : null,
|
|
51
|
+
action,
|
|
52
|
+
actor.role, tenantId, actor.actorId, hourAgo, STAFF_READS_PER_HOUR,
|
|
53
|
+
];
|
|
54
|
+
const requireRow = input.requireRow ? ` AND EXISTS (${input.requireRow.sql})` : "";
|
|
55
|
+
if (input.requireRow) params.push(...input.requireRow.params);
|
|
56
|
+
return {
|
|
57
|
+
sql: `INSERT INTO contact_access_audit
|
|
58
|
+
(audit_id, tenant_id, stage, record_kind, record_id, at, actor_id, actor_role, reason_code, ticket_ref, action, outcome)
|
|
59
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'admitted'
|
|
60
|
+
WHERE (? = 'owner' OR (SELECT COUNT(*) FROM contact_access_audit
|
|
61
|
+
WHERE tenant_id = ? AND actor_id = ? AND actor_role = 'staff' AND at > ?) < ?)${requireRow}
|
|
62
|
+
RETURNING audit_id`,
|
|
63
|
+
params,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Run the audit insert alone: "admitted", "quota" (no row inserted) or "unavailable" (error). */
|
|
68
|
+
export async function admitAudit(db: AuditDb, statement: AuditStatement): Promise<"admitted" | "quota" | "unavailable"> {
|
|
69
|
+
try {
|
|
70
|
+
const rows = await db.all(statement.sql, ...statement.params);
|
|
71
|
+
return rows.length === 1 ? "admitted" : "quota";
|
|
72
|
+
} catch {
|
|
73
|
+
return "unavailable";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The same insert without RETURNING, for use inside a batch whose later statements check the row. */
|
|
78
|
+
export function batchAuditStatement(statement: AuditStatement): AuditStatement {
|
|
79
|
+
return { sql: statement.sql.replace(/\s*RETURNING audit_id$/, ""), params: statement.params };
|
|
80
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact vault envelope: AES-256-GCM under a per-tenant, per-purpose HKDF subkey of one keyring
|
|
3
|
+
* key. The AAD binds tenant, stage, record id, key id and the immutable `notAfter`, so a row read
|
|
4
|
+
* under any other binding fails its tag and reads as absent.
|
|
5
|
+
*
|
|
6
|
+
* The keyring is the `ORDER_CONTACT_VAULT_KEYRING` Worker secret: JSON
|
|
7
|
+
* `{ active: <kid>, keys: { <kid>: <base64 of 32 bytes> } }`. Errors carry fixed codes only —
|
|
8
|
+
* never a key, nonce, ciphertext or plaintext value.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type VaultPurpose = "order-contact" | "contact-message";
|
|
12
|
+
|
|
13
|
+
export interface VaultKeyring {
|
|
14
|
+
active: string;
|
|
15
|
+
keys: ReadonlyMap<string, Uint8Array>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface VaultBinding {
|
|
19
|
+
purpose: VaultPurpose;
|
|
20
|
+
tenantId: string;
|
|
21
|
+
stage: string;
|
|
22
|
+
recordId: string;
|
|
23
|
+
notAfter: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SealedEnvelope {
|
|
27
|
+
keyId: string;
|
|
28
|
+
nonce: string;
|
|
29
|
+
ciphertext: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class VaultKeyUnavailableError extends Error {
|
|
33
|
+
readonly code = "vault-key-unavailable";
|
|
34
|
+
constructor() {
|
|
35
|
+
super("vault-key-unavailable");
|
|
36
|
+
this.name = "VaultKeyUnavailableError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const KID = /^[A-Za-z0-9._-]{1,32}$/;
|
|
41
|
+
const HKDF_SALT = new TextEncoder().encode("tot-contact-vault/v1");
|
|
42
|
+
const encoder = new TextEncoder();
|
|
43
|
+
const decoder = new TextDecoder();
|
|
44
|
+
|
|
45
|
+
function fromBase64(value: string): Uint8Array | null {
|
|
46
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
|
|
47
|
+
let binary: string;
|
|
48
|
+
try {
|
|
49
|
+
binary = atob(value);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof DOMException && error.name === "InvalidCharacterError") return null;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
const out = new Uint8Array(binary.length);
|
|
55
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toBase64(bytes: Uint8Array): string {
|
|
60
|
+
let binary = "";
|
|
61
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
62
|
+
return btoa(binary);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parse the keyring secret, or null when it is absent or malformed in any way. */
|
|
66
|
+
export function parseVaultKeyring(raw: string | undefined | null): VaultKeyring | null {
|
|
67
|
+
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
68
|
+
let parsed: unknown;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(raw);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof SyntaxError) return null;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
76
|
+
const { active, keys } = parsed as { active?: unknown; keys?: unknown };
|
|
77
|
+
if (typeof active !== "string" || !KID.test(active)) return null;
|
|
78
|
+
if (!keys || typeof keys !== "object" || Array.isArray(keys)) return null;
|
|
79
|
+
const map = new Map<string, Uint8Array>();
|
|
80
|
+
for (const [kid, value] of Object.entries(keys as Record<string, unknown>)) {
|
|
81
|
+
if (!KID.test(kid) || typeof value !== "string") return null;
|
|
82
|
+
const bytes = fromBase64(value);
|
|
83
|
+
if (!bytes || bytes.length !== 32) return null;
|
|
84
|
+
map.set(kid, bytes);
|
|
85
|
+
}
|
|
86
|
+
if (!map.has(active)) return null;
|
|
87
|
+
return { active, keys: map };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Canonical AAD bytes: fixed key order, so equal bindings always produce equal AAD. */
|
|
91
|
+
export function vaultAad(binding: VaultBinding, keyId: string): Uint8Array {
|
|
92
|
+
return encoder.encode(JSON.stringify({
|
|
93
|
+
v: 1,
|
|
94
|
+
purpose: binding.purpose,
|
|
95
|
+
tenantId: binding.tenantId,
|
|
96
|
+
stage: binding.stage,
|
|
97
|
+
recordId: binding.recordId,
|
|
98
|
+
keyId,
|
|
99
|
+
notAfter: binding.notAfter,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function subkey(keyring: VaultKeyring, keyId: string, binding: VaultBinding): Promise<CryptoKey> {
|
|
104
|
+
const ikm = keyring.keys.get(keyId);
|
|
105
|
+
if (!ikm) throw new VaultKeyUnavailableError();
|
|
106
|
+
const base = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, ["deriveKey"]);
|
|
107
|
+
return crypto.subtle.deriveKey(
|
|
108
|
+
{ name: "HKDF", hash: "SHA-256", salt: HKDF_SALT, info: encoder.encode(`${binding.purpose}|${binding.tenantId}`) },
|
|
109
|
+
base,
|
|
110
|
+
{ name: "AES-GCM", length: 256 },
|
|
111
|
+
false,
|
|
112
|
+
["encrypt", "decrypt"],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Seal a JSON value under the keyring's active key. */
|
|
117
|
+
export async function sealVaultRecord(
|
|
118
|
+
keyring: VaultKeyring | null,
|
|
119
|
+
binding: VaultBinding,
|
|
120
|
+
value: unknown,
|
|
121
|
+
): Promise<SealedEnvelope> {
|
|
122
|
+
if (!keyring) throw new VaultKeyUnavailableError();
|
|
123
|
+
const keyId = keyring.active;
|
|
124
|
+
const key = await subkey(keyring, keyId, binding);
|
|
125
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
126
|
+
const sealed = await crypto.subtle.encrypt(
|
|
127
|
+
{ name: "AES-GCM", iv: nonce, additionalData: vaultAad(binding, keyId) as BufferSource },
|
|
128
|
+
key,
|
|
129
|
+
encoder.encode(JSON.stringify(value)),
|
|
130
|
+
);
|
|
131
|
+
return { keyId, nonce: toBase64(nonce), ciphertext: toBase64(new Uint8Array(sealed)) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Open a sealed record. Null when the binding, nonce or ciphertext does not authenticate — callers
|
|
136
|
+
* report that as not-found. A missing keyring or key id throws {@link VaultKeyUnavailableError}.
|
|
137
|
+
*/
|
|
138
|
+
export async function openVaultRecord(
|
|
139
|
+
keyring: VaultKeyring | null,
|
|
140
|
+
binding: VaultBinding,
|
|
141
|
+
envelope: SealedEnvelope,
|
|
142
|
+
): Promise<unknown | null> {
|
|
143
|
+
if (!keyring) throw new VaultKeyUnavailableError();
|
|
144
|
+
const key = await subkey(keyring, envelope.keyId, binding);
|
|
145
|
+
const nonce = fromBase64(envelope.nonce);
|
|
146
|
+
const ciphertext = fromBase64(envelope.ciphertext);
|
|
147
|
+
if (!nonce || nonce.length !== 12 || !ciphertext) return null;
|
|
148
|
+
let plain: ArrayBuffer;
|
|
149
|
+
try {
|
|
150
|
+
plain = await crypto.subtle.decrypt(
|
|
151
|
+
{ name: "AES-GCM", iv: nonce as BufferSource, additionalData: vaultAad(binding, envelope.keyId) as BufferSource },
|
|
152
|
+
key,
|
|
153
|
+
ciphertext as BufferSource,
|
|
154
|
+
);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// AES-GCM reports a failed tag (wrong binding, key or bytes) as OperationError; anything else is a fault.
|
|
157
|
+
if (error instanceof DOMException && error.name === "OperationError") return null;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(decoder.decode(plain));
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error instanceof SyntaxError) return null;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact vault retention. `notAfter` is the hard ceiling bound into the AAD: 365 days after the
|
|
3
|
+
* order was placed (or the message was received). Once an order is terminal (handed off or
|
|
4
|
+
* cancelled) its contact expires 180 days after that, never later than `notAfter`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const CONTACT_RETENTION_DAYS = 365;
|
|
8
|
+
export const TERMINAL_RETENTION_DAYS = 180;
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
|
|
11
|
+
const ISO_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
12
|
+
|
|
13
|
+
/** A strict ISO instant with an explicit zone and real calendar fields, or null. */
|
|
14
|
+
export function readInstant(value: unknown): Date | null {
|
|
15
|
+
if (typeof value !== "string") return null;
|
|
16
|
+
const match = ISO_INSTANT.exec(value);
|
|
17
|
+
if (!match) return null;
|
|
18
|
+
const [year, month, day, hour, minute, second] = match.slice(1, 7).map((part) => Number(part ?? 0));
|
|
19
|
+
const calendar = new Date(Date.UTC(year!, month! - 1, day!));
|
|
20
|
+
if (calendar.getUTCMonth() !== month! - 1 || calendar.getUTCDate() !== day! || hour! > 23 || minute! > 59 || second! > 59) return null;
|
|
21
|
+
const parsed = new Date(value);
|
|
22
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function notAfterFrom(start: Date): string {
|
|
26
|
+
return new Date(start.getTime() + CONTACT_RETENTION_DAYS * DAY_MS).toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The expiry for a record whose ceiling is `notAfter`, given the order's terminal time if any. */
|
|
30
|
+
export function expiresAtFor(notAfter: string, terminalAt: Date | null): string {
|
|
31
|
+
if (!terminalAt) return notAfter;
|
|
32
|
+
const terminal = new Date(terminalAt.getTime() + TERMINAL_RETENTION_DAYS * DAY_MS).toISOString();
|
|
33
|
+
return terminal < notAfter ? terminal : notAfter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True while a record may still be read: strictly before both its expiry and its ceiling. */
|
|
37
|
+
export function isReadable(now: Date, expiresAt: string, notAfter: string): boolean {
|
|
38
|
+
const iso = now.toISOString();
|
|
39
|
+
return iso < expiresAt && iso < notAfter;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function terminalExpiryFrom(at: Date): string {
|
|
43
|
+
return new Date(at.getTime() + TERMINAL_RETENTION_DAYS * DAY_MS).toISOString();
|
|
44
|
+
}
|