@tokenoftrust/storefront-runner 4.9.2 → 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.
Files changed (27) hide show
  1. package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
  2. package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
  3. package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
  4. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
  5. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
  6. package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
  7. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
  8. package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
  9. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
  10. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
  11. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
  12. package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
  13. package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
  14. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
  15. package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
  16. package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
  17. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
  18. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
  19. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
  20. package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
  21. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
  22. package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
  23. package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
  24. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
  25. package/apps/storefront/src/pages/api/contact.ts +16 -4
  26. package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
  27. package/package.json +1 -1
@@ -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
+ }