@tokenoftrust/storefront-runner 6.5.0 → 6.5.2

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 (23) hide show
  1. package/apps/storefront/migrations-apps/0026_fulfillment_assignment.sql +44 -0
  2. package/apps/storefront/migrations-apps/0027_app_contact_authority.sql +70 -0
  3. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +56 -1
  4. package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +21 -1
  5. package/apps/storefront/src/lib/fulfillment/assignmentStore.ts +284 -0
  6. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +2 -0
  7. package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +18 -1
  8. package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +28 -0
  9. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +429 -70
  10. package/apps/storefront/src/lib/integrations/apps/appContactAuthority.ts +27 -0
  11. package/apps/storefront/src/lib/integrations/apps/appContactConsents.ts +112 -0
  12. package/apps/storefront/src/lib/integrations/apps/registryService.ts +101 -65
  13. package/apps/storefront/src/lib/integrations/webhooks/commercePayloads.ts +101 -0
  14. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +17 -34
  15. package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +130 -0
  16. package/apps/storefront/src/lib/integrations/webhooks/dispatcher.ts +1 -1
  17. package/apps/storefront/src/lib/integrations/webhooks/getDispatcher.ts +8 -3
  18. package/apps/storefront/src/lib/integrations/webhooks/orderFulfilledClaim.ts +75 -0
  19. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +65 -4
  20. package/apps/storefront/src/lib/orders/vault/appRecipientRead.ts +217 -0
  21. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -3
  22. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +57 -0
  23. package/package.json +1 -1
@@ -40,6 +40,8 @@
40
40
  * read-after-write discipline `AppRegistryService` uses.
41
41
  */
42
42
  import type { Queryable } from "../../catalog/d1/catalog.js";
43
+ import { TOPIC_REQUIRED_SCOPE } from "./events.js";
44
+ import { COMMERCE_TOPICS, commercePayloadAdmissible, type CommerceTopic } from "./commercePayloads.js";
43
45
 
44
46
  /** Mirrors the `Delivery.topic` / CloudEvents channel enum in the D1/D2 contract. */
45
47
  export type WebhookTopic =
@@ -668,6 +670,134 @@ export class WebhookDeliveryStore {
668
670
  }
669
671
  }
670
672
 
673
+ /** A commerce delivery enqueued after its commit becomes the tenant commerce alarm's only after this long. */
674
+ export const COMMERCE_DELIVERY_GRACE_MS = 60_000;
675
+
676
+ /** `asyncapi.yaml`'s `dataschemaversion` for V1. */
677
+ export const COMMERCE_DATASCHEMA_VERSION = "1";
678
+
679
+ /** Thrown before any statement is built when a commerce payload fails its topic's closed schema. */
680
+ export class CommercePayloadRefused extends Error {
681
+ constructor(readonly topic: CommerceTopic) {
682
+ super("commerce-payload-refused");
683
+ this.name = "CommercePayloadRefused";
684
+ }
685
+ }
686
+
687
+ export interface CommerceEventStatement {
688
+ sql: string;
689
+ params: unknown[];
690
+ }
691
+
692
+ /** SQL condition an eligible install row `i` satisfies; binds the tenant id, then the topic's required scope. */
693
+ const ELIGIBLE_INSTALL_CONDITION = `i.tenant_id = ? AND i.status = 'active' AND i.managed_kind IS NULL
694
+ AND COALESCE(i.webhook_endpoint, '') != ''
695
+ AND EXISTS (SELECT 1 FROM json_each(i.scopes) WHERE json_each.value = ?)`;
696
+
697
+ /** Installs on `tenantId` that would receive `topic` now, oldest first. Statements re-check this at write. */
698
+ export function commerceTargetsQuery(tenantId: string, topic: CommerceTopic): CommerceEventStatement {
699
+ return {
700
+ sql: `SELECT i.install_id AS install_id FROM app_installs AS i
701
+ WHERE ${ELIGIBLE_INSTALL_CONDITION}
702
+ ORDER BY i.created_at ASC, i.install_id ASC`,
703
+ params: [tenantId, TOPIC_REQUIRED_SCOPE[topic]],
704
+ };
705
+ }
706
+
707
+ /**
708
+ * The single commerce emitter: one `webhook_events` row written only when `gate` holds, and one
709
+ * pending `webhook_deliveries` row per target written only when that event row exists and the
710
+ * target install is still eligible. Ids are minted by the caller so the statements run inside the
711
+ * batch of the change they report; the delivery id is the CloudEvents `id`.
712
+ */
713
+ export function commerceEventStatements(input: {
714
+ eventId: string;
715
+ tenantId: string;
716
+ topic: CommerceTopic;
717
+ payload: unknown;
718
+ gate: CommerceEventStatement;
719
+ targets: readonly { installId: string; deliveryId: string }[];
720
+ now: string;
721
+ }): CommerceEventStatement[] {
722
+ if (!commercePayloadAdmissible(input.topic, input.payload)) throw new CommercePayloadRefused(input.topic);
723
+ const statements: CommerceEventStatement[] = [
724
+ {
725
+ sql: `INSERT INTO webhook_events (id, tenant_id, topic, dataschema_version, payload, created_at)
726
+ SELECT ?, ?, ?, ?, ?, ? WHERE (${input.gate.sql})`,
727
+ params: [
728
+ input.eventId,
729
+ input.tenantId,
730
+ input.topic,
731
+ COMMERCE_DATASCHEMA_VERSION,
732
+ JSON.stringify(input.payload),
733
+ input.now,
734
+ ...input.gate.params,
735
+ ],
736
+ },
737
+ ];
738
+ for (const target of input.targets) {
739
+ statements.push({
740
+ sql: `INSERT INTO webhook_deliveries
741
+ (delivery_id, event_id, install_id, tenant_id, topic, state, attempts, created_at, updated_at)
742
+ SELECT ?, ?, i.install_id, i.tenant_id, ?, 'pending', 0, ?, ?
743
+ FROM app_installs AS i
744
+ WHERE i.install_id = ? AND ${ELIGIBLE_INSTALL_CONDITION}
745
+ AND EXISTS (SELECT 1 FROM webhook_events WHERE id = ? AND tenant_id = ? AND topic = ?)`,
746
+ params: [
747
+ target.deliveryId,
748
+ input.eventId,
749
+ input.topic,
750
+ input.now,
751
+ input.now,
752
+ target.installId,
753
+ input.tenantId,
754
+ TOPIC_REQUIRED_SCOPE[input.topic],
755
+ input.eventId,
756
+ input.tenantId,
757
+ input.topic,
758
+ ],
759
+ });
760
+ }
761
+ return statements;
762
+ }
763
+
764
+ /**
765
+ * Commerce-topic deliveries for one tenant that the alarm owns: `pending` rows older than the
766
+ * enqueue grace, and `retrying` rows whose next attempt is due.
767
+ */
768
+ export async function dueCommerceDeliveries(
769
+ db: Queryable,
770
+ tenantId: string,
771
+ now: string,
772
+ graceMs: number,
773
+ ): Promise<{ due: string[]; nextAt: string | null }> {
774
+ const cutoff = new Date(Date.parse(now) - graceMs).toISOString();
775
+ const topics = [...COMMERCE_TOPICS];
776
+ const marks = topics.map(() => "?").join(",");
777
+ const rows = await db.all<{ delivery_id: string }>(
778
+ `SELECT delivery_id FROM webhook_deliveries
779
+ WHERE tenant_id = ? AND topic IN (${marks})
780
+ AND ((state = 'pending' AND created_at <= ?) OR (state = 'retrying' AND next_attempt_at <= ?))
781
+ ORDER BY created_at ASC, delivery_id ASC LIMIT 100`,
782
+ tenantId,
783
+ ...topics,
784
+ cutoff,
785
+ now,
786
+ );
787
+ const next = await db.first<{ next_at: string | null }>(
788
+ `SELECT MIN(CASE WHEN state = 'pending' THEN strftime('%Y-%m-%dT%H:%M:%fZ', created_at, ?) ELSE next_attempt_at END) AS next_at
789
+ FROM webhook_deliveries
790
+ WHERE tenant_id = ? AND topic IN (${marks})
791
+ AND ((state = 'pending' AND created_at > ?) OR (state = 'retrying' AND next_attempt_at > ?))`,
792
+ `+${graceMs / 1000} seconds`,
793
+ tenantId,
794
+ ...topics,
795
+ cutoff,
796
+ now,
797
+ );
798
+ return { due: rows.map((row) => row.delivery_id), nextAt: next?.next_at ?? null };
799
+ }
800
+
671
801
  export interface CommerceOutboxDue {
672
802
  order_id: string;
673
803
  outbox_id: string;
@@ -57,7 +57,7 @@ export interface WebhookDispatcher {
57
57
  export type AttemptOutcome =
58
58
  | { kind: "delivered" }
59
59
  | { kind: "retrying"; nextAttemptAt: string }
60
- | { kind: "dead-lettered"; reason: "install_unavailable" | "max_attempts" | "endpoint_blocked" };
60
+ | { kind: "dead-lettered"; reason: "install_unavailable" | "max_attempts" | "endpoint_blocked" | "payload_refused" };
61
61
 
62
62
  /** One attempt of one delivery. Implemented by `deliveryEngine.ts`'s `attemptDelivery`, partially applied over its deps. */
63
63
  export type AttemptDeliveryFn = (deliveryId: string) => Promise<AttemptOutcome>;
@@ -29,6 +29,13 @@ import { COMMERCE_SCHEDULER } from "@tot/public-runtime";
29
29
  /** Binding name `getDispatcher()` checks to pick the Cloudflare Queues adapter. Not yet declared in `wrangler.toml`. */
30
30
  export const WEBHOOK_QUEUE_BINDING = "WEBHOOK_DELIVERY_QUEUE";
31
31
 
32
+ /** The `attemptDelivery` composition every dispatcher and the commerce alarm run per delivery id. */
33
+ export function deliveryAttemptFor(db: Queryable, activeKid: string = DEFAULT_APP_GATEWAY_KID): AttemptDeliveryFn {
34
+ const store = new WebhookDeliveryStore(db);
35
+ const registry = new AppRegistryService(db, new D1CredentialStore(db, activeKid));
36
+ return (deliveryId) => attemptDelivery(deliveryId, { store, registry });
37
+ }
38
+
32
39
  /**
33
40
  * Build a `WebhookDispatcher` over the real `STOREFRONT_APPS_DB` binding, or
34
41
  * return a 503 `Response` when that binding isn't configured in this
@@ -39,10 +46,8 @@ export async function getDispatcher(): Promise<WebhookDispatcher | Response> {
39
46
  if (!d1) return json({ error: "webhook dispatcher not configured" }, 503);
40
47
  const db = fromD1(d1);
41
48
 
42
- const store = new WebhookDeliveryStore(db);
43
49
  const activeKid = (await readEnv(APP_GATEWAY_KID_ENV)) ?? DEFAULT_APP_GATEWAY_KID;
44
- const registry = new AppRegistryService(db, new D1CredentialStore(db, activeKid));
45
- const attempt: AttemptDeliveryFn = (deliveryId) => attemptDelivery(deliveryId, { store, registry });
50
+ const attempt = deliveryAttemptFor(db, activeKid);
46
51
 
47
52
  const queueSender = (await readQueue(WEBHOOK_QUEUE_BINDING)) as CloudflareQueueSender | undefined;
48
53
  if (queueSender) return new CloudflareQueueDispatcher(queueSender);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `order.fulfilled` is claimed once per order across its two producers: the fulfillment handoff
3
+ * and the order-forward receiver. The claim is keyed (tenant, stage, source, source order id,
4
+ * topic); each producer writes its claim and its gated event rows in one batch, and only the
5
+ * producer whose claim carries its event id writes the event.
6
+ */
7
+ import type { CommerceStage } from "@tot/public-runtime";
8
+ import type { CommerceEventStatement } from "./deliveryStore.js";
9
+
10
+ export interface OrderFulfilledClaimKey {
11
+ tenantId: string;
12
+ stage: CommerceStage;
13
+ source: string;
14
+ sourceOrderId: string;
15
+ }
16
+
17
+ /** The forward contract's `env` mapped to a commerce stage; any other value claims and emits nothing. */
18
+ export function forwardStage(env: string): CommerceStage | null {
19
+ if (env === "production") return "publication";
20
+ if (env === "sandbox") return "preview";
21
+ return null;
22
+ }
23
+
24
+ /** The handoff's claim: written only when no claim exists and `gate` holds (the handoff's CAS committed). */
25
+ export function handoffClaimStatement(
26
+ key: OrderFulfilledClaimKey,
27
+ eventId: string,
28
+ now: string,
29
+ gate: CommerceEventStatement,
30
+ ): CommerceEventStatement {
31
+ return {
32
+ sql: `INSERT INTO commerce_event_claims
33
+ (tenant_id, stage, source, source_order_id, topic, event_id, claimed_by, claimed_at)
34
+ SELECT ?, ?, ?, ?, 'order.fulfilled', ?, 'handoff', ?
35
+ WHERE NOT EXISTS (
36
+ SELECT 1 FROM commerce_event_claims
37
+ WHERE tenant_id = ? AND stage = ? AND source = ? AND source_order_id = ? AND topic = 'order.fulfilled'
38
+ ) AND (${gate.sql})`,
39
+ params: [
40
+ key.tenantId,
41
+ key.stage,
42
+ key.source,
43
+ key.sourceOrderId,
44
+ eventId,
45
+ now,
46
+ key.tenantId,
47
+ key.stage,
48
+ key.source,
49
+ key.sourceOrderId,
50
+ ...gate.params,
51
+ ],
52
+ };
53
+ }
54
+
55
+ /** The forward receiver's claim. */
56
+ export function forwardClaimStatement(key: OrderFulfilledClaimKey, eventId: string, now: string): CommerceEventStatement {
57
+ return {
58
+ sql: `INSERT OR IGNORE INTO commerce_event_claims
59
+ (tenant_id, stage, source, source_order_id, topic, event_id, claimed_by, claimed_at)
60
+ VALUES (?, ?, ?, ?, 'order.fulfilled', ?, 'forward', ?)`,
61
+ params: [key.tenantId, key.stage, key.source, key.sourceOrderId, eventId, now],
62
+ };
63
+ }
64
+
65
+ /** Event gate: the claim for `key` carries `eventId`. */
66
+ export function claimedByEventGate(key: OrderFulfilledClaimKey, eventId: string): CommerceEventStatement {
67
+ return {
68
+ sql: `EXISTS (
69
+ SELECT 1 FROM commerce_event_claims
70
+ WHERE tenant_id = ? AND stage = ? AND source = ? AND source_order_id = ? AND topic = 'order.fulfilled'
71
+ AND event_id = ?
72
+ )`,
73
+ params: [key.tenantId, key.stage, key.source, key.sourceOrderId, eventId],
74
+ };
75
+ }
@@ -21,6 +21,11 @@
21
21
  * corresponding topic — e.g. -> `cancelled`, which V1 has no topic for)
22
22
  * -> no emit; the row still updates, silently.
23
23
  *
24
+ * `order.fulfilled` is claimed once per order (`orderFulfilledClaim.ts`) under the key the signed
25
+ * intake and the fulfillment handoff use: (tenant, stage from `env`, `foxycart`, external order id).
26
+ * The claim, the event and its deliveries commit in one batch; a forward whose order is already
27
+ * claimed writes nothing, and an `env` other than production or sandbox emits no `order.fulfilled`.
28
+ *
24
29
  * Customer identity: `rawCustomerId` is optional (tot-foxycart's own
25
30
  * order-forward payload doesn't always carry one — mirrors `FoxyOrder`'s
26
31
  * own optional `customerEmail`). This receiver never derives, stores, or
@@ -36,7 +41,8 @@ import { SourceAuthorityStore } from "./sourceAuthorityStore.js";
36
41
  import { SourceAuthorityError, parseSourceWire, exactKeys, digestJson, type BeginAck, type ResultAck, type AuthorityCode, isRecord, isSourceId } from "./sourceAuthorityContract.js";
37
42
  import { OrdersStore, ScrappyOrderLedger, type OrderLineItem, type OrderStatus, type Order } from "./ordersStore.js";
38
43
  import { emitWebhookEvent } from "../../integrations/webhooks/emit.js";
39
- import type { WebhookTopic } from "../../integrations/webhooks/deliveryStore.js";
44
+ import { commerceEventStatements, commerceTargetsQuery, type WebhookTopic } from "../../integrations/webhooks/deliveryStore.js";
45
+ import { claimedByEventGate, forwardClaimStatement, forwardStage, type OrderFulfilledClaimKey } from "../../integrations/webhooks/orderFulfilledClaim.js";
40
46
  import type { Queryable } from "../../catalog/d1/catalog.js";
41
47
  import {
42
48
  createLocalJWKSet,
@@ -125,7 +131,11 @@ function topicsFor(previousStatus: OrderStatus | null, status: OrderStatus): Web
125
131
  * verified the request's HMAC signature — this function never re-checks
126
132
  * authenticity and must never be reached from an unverified request.
127
133
  */
128
- export async function receiveForwardedOrder(db: Queryable, input: OrderForwardInput): Promise<OrderForwardResult> {
134
+ export async function receiveForwardedOrder(
135
+ db: Queryable,
136
+ input: OrderForwardInput,
137
+ options: { notify?: (tenantId: string, deliveryIds: string[]) => void | Promise<void> } = {},
138
+ ): Promise<OrderForwardResult> {
129
139
  const store = new OrdersStore(db);
130
140
  // input.rawCustomerId is intentionally not read here — see the module doc's
131
141
  // "Customer identity" note for why no hash is derived from it.
@@ -142,14 +152,65 @@ export async function receiveForwardedOrder(db: Queryable, input: OrderForwardIn
142
152
  createdAt: input.createdAt ?? new Date().toISOString(),
143
153
  });
144
154
 
145
- const emitted = topicsFor(previousStatus, input.status);
146
- for (const topic of emitted) {
155
+ const emitted: WebhookTopic[] = [];
156
+ for (const topic of topicsFor(previousStatus, input.status)) {
157
+ if (topic === "order.fulfilled") {
158
+ if (await emitClaimedOrderFulfilled(db, input, order, options.notify)) emitted.push(topic);
159
+ continue;
160
+ }
147
161
  await emitWebhookEvent(topic, input.tenantId, toOrderData(order), {});
162
+ emitted.push(topic);
148
163
  }
149
164
 
150
165
  return { order, previousStatus, emitted };
151
166
  }
152
167
 
168
+ /** Claims `order.fulfilled` for this order and, only under that claim, writes its event and deliveries. */
169
+ async function emitClaimedOrderFulfilled(
170
+ db: Queryable,
171
+ input: OrderForwardInput,
172
+ order: Order,
173
+ notify: ((tenantId: string, deliveryIds: string[]) => void | Promise<void>) | undefined,
174
+ ): Promise<boolean> {
175
+ const stage = forwardStage(input.env);
176
+ if (!stage) return false;
177
+ if (!db.batch) throw new Error("Atomic D1 batch support is required.");
178
+ const key: OrderFulfilledClaimKey = {
179
+ tenantId: input.tenantId,
180
+ stage,
181
+ source: "foxycart",
182
+ sourceOrderId: input.externalOrderId,
183
+ };
184
+ const eventId = crypto.randomUUID();
185
+ const now = new Date().toISOString();
186
+ const targets = commerceTargetsQuery(input.tenantId, "order.fulfilled");
187
+ const installs = await db.all<{ install_id: string }>(targets.sql, ...targets.params);
188
+ const deliveries = installs.map((row) => ({ installId: row.install_id, deliveryId: crypto.randomUUID() }));
189
+ await db.batch([
190
+ forwardClaimStatement(key, eventId, now),
191
+ ...commerceEventStatements({
192
+ eventId,
193
+ tenantId: input.tenantId,
194
+ topic: "order.fulfilled",
195
+ payload: toOrderData(order),
196
+ gate: claimedByEventGate(key, eventId),
197
+ targets: deliveries,
198
+ now,
199
+ }),
200
+ ]);
201
+ const written = await db.first<{ ok: number }>(`SELECT 1 AS ok FROM webhook_events WHERE id = ?`, eventId);
202
+ if (!written) return false;
203
+ const committed = await db.all<{ delivery_id: string }>(`SELECT delivery_id FROM webhook_deliveries WHERE event_id = ?`, eventId);
204
+ if (notify && committed.length > 0) {
205
+ try {
206
+ await notify(input.tenantId, committed.map((row) => row.delivery_id));
207
+ } catch {
208
+ // The committed deliveries stay pending; the tenant commerce alarm redelivers them.
209
+ }
210
+ }
211
+ return true;
212
+ }
213
+
153
214
  export interface CommerceCallerConfig {
154
215
  expectedStage: CommerceStage;
155
216
  jwks: JSONWebKeySet;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * A Private App's read of one order's ship-to recipient (`getFulfillmentRecipient`). The caller has
3
+ * already verified the token and scope, refused query overrides, and resolved one stage that equals
4
+ * both the routed stage and the order's stage.
5
+ *
6
+ * Order of work:
7
+ * 1. The install must be active on the tenant (else the generic 401 `invalid_token`) and still hold
8
+ * the recipient scope (else 403 `scope_denied`). Has any assignment of this order named the
9
+ * install? If not, `not-assigned`, and no contact table is touched.
10
+ * 2. The order's current assignment must be this install's `active` assignment and the order
11
+ * releasable at its current event version, else `assignment-held` / `assignment-ended`.
12
+ * 3. The app-access audit insert (`appAuditStatement`) re-checks install, scope, assignment, order
13
+ * version, consent and quota in one statement and is the only write. Nothing is decrypted, and
14
+ * the ship group's state is not read, unless it committed its row.
15
+ * 4. Zero rows are classified from current state: install, scope, assignment, consent, then quota.
16
+ *
17
+ * Every failure is a code. A storage read that throws, before or after admission, answers 503
18
+ * `unavailable`; the audit insert's own error 503 `audit-unavailable`; the data key 503
19
+ * `vault-key-unavailable`. Responses carry codes and opaque ids only; the recipient is returned only
20
+ * on success.
21
+ */
22
+ import type { Queryable } from "@/lib/catalog/d1/catalog.js";
23
+ import type { KmsClient } from "@/lib/customers/contactVault/kms.js";
24
+ import {
25
+ APP_CONTACT_PURPOSE,
26
+ APP_CONTACT_READS_PER_HOUR,
27
+ appAuditStatement,
28
+ HOUR_MS,
29
+ type VaultStage,
30
+ } from "@/lib/customers/contactVault/contactAudit.js";
31
+ import { isOpaqueId, type FulfillmentService } from "@/lib/fulfillment/fulfillmentService";
32
+ import { readOrderShipRecipient } from "./orderContactVault.js";
33
+ import type { ContactAddress } from "./orderContact.js";
34
+
35
+ export const RECIPIENT_SCOPE = "orders:read:recipient";
36
+
37
+ export interface AppRecipient {
38
+ orderId: string;
39
+ name?: string;
40
+ company?: string;
41
+ line1: string;
42
+ line2?: string;
43
+ city: string;
44
+ region?: string;
45
+ postalCode?: string;
46
+ country: string;
47
+ }
48
+
49
+ export type AppRecipientCode =
50
+ | "invalid_token"
51
+ | "scope_denied"
52
+ | "not-assigned"
53
+ | "assignment-held"
54
+ | "assignment-ended"
55
+ | "consent-missing"
56
+ | "consent-revoked"
57
+ | "consent-expired"
58
+ | "quota"
59
+ | "contact-erased"
60
+ | "contact-expired"
61
+ | "contact-absent"
62
+ | "contact-unreadable"
63
+ | "audit-unavailable"
64
+ | "vault-key-unavailable"
65
+ | "unavailable";
66
+
67
+ export type AppRecipientOutcome =
68
+ | { ok: true; recipient: AppRecipient }
69
+ | { ok: false; status: number; code: AppRecipientCode };
70
+
71
+ export interface AppRecipientInput {
72
+ tenantId: string;
73
+ stage: VaultStage;
74
+ installId: string;
75
+ orderId: string;
76
+ now: Date;
77
+ }
78
+
79
+ export interface AppRecipientDeps {
80
+ /** The apps database: audit insert, vault and authority reads. */
81
+ db: Queryable;
82
+ /** Reads the order's fulfillment view (assignment, holds, version); never decrypts. */
83
+ service: Pick<FulfillmentService, "get">;
84
+ kms: KmsClient;
85
+ auditId?: () => string;
86
+ }
87
+
88
+ type Eligibility =
89
+ | { kind: "eligible"; assignmentId: string; eventVersion: number }
90
+ | { kind: "refused"; status: number; code: AppRecipientCode };
91
+
92
+ const refused = (status: number, code: AppRecipientCode) => ({ kind: "refused", status, code }) as const;
93
+
94
+ async function installRefusal(db: Queryable, input: AppRecipientInput): Promise<Eligibility | null> {
95
+ const install = await db.first<{ tenant_id: string; status: string; scopes: string }>(
96
+ "SELECT tenant_id, status, scopes FROM app_installs WHERE install_id = ?",
97
+ input.installId,
98
+ );
99
+ if (!install || install.tenant_id !== input.tenantId || install.status !== "active") return refused(401, "invalid_token");
100
+ let scopes: unknown;
101
+ try {
102
+ scopes = JSON.parse(install.scopes);
103
+ } catch {
104
+ scopes = [];
105
+ }
106
+ if (!Array.isArray(scopes) || !scopes.includes(RECIPIENT_SCOPE)) return refused(403, "scope_denied");
107
+ return null;
108
+ }
109
+
110
+ async function eligibility(deps: AppRecipientDeps, input: AppRecipientInput): Promise<Eligibility> {
111
+ const { tenantId, stage, installId, orderId } = input;
112
+ const installRefused = await installRefusal(deps.db, input);
113
+ if (installRefused) return installRefused;
114
+ const named = await deps.db.first(
115
+ `SELECT 1 AS named FROM fulfillment_assignments
116
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND install_id = ? LIMIT 1`,
117
+ tenantId, stage, orderId, installId,
118
+ );
119
+ if (!named) return refused(404, "not-assigned");
120
+ const view = await deps.service.get({ role: "caller", actorId: installId, tenantId, stage }, orderId);
121
+ const assignment = view.ok ? view.value.assignment : null;
122
+ if (!view.ok || !assignment || assignment.installId !== installId) return refused(409, "assignment-ended");
123
+ if (assignment.state !== "active") return refused(409, "assignment-held");
124
+ const releasable = view.value.fulfillment === "ready" && view.value.holds.length === 0 && view.value.lines.length > 0;
125
+ if (!releasable) return refused(409, "assignment-held");
126
+ return { kind: "eligible", assignmentId: assignment.assignmentId, eventVersion: view.value.eventVersion };
127
+ }
128
+
129
+ /** Why the audit insert admitted nothing, read from current state; null when nothing explains it. */
130
+ async function classifyRefusal(
131
+ deps: AppRecipientDeps,
132
+ input: AppRecipientInput,
133
+ admitted: { eventVersion: number },
134
+ ): Promise<{ status: number; code: AppRecipientCode } | null> {
135
+ const { db } = deps;
136
+ const { tenantId, stage, installId, now } = input;
137
+ const current = await eligibility(deps, input);
138
+ if (current.kind === "refused") return { status: current.status, code: current.code };
139
+ if (current.eventVersion !== admitted.eventVersion) return null;
140
+ const at = now.toISOString();
141
+ const live = await db.first(
142
+ `SELECT 1 AS live FROM app_contact_consents
143
+ WHERE install_id = ? AND tenant_id = ? AND stage = ? AND purpose = ? AND revoked_at IS NULL AND expires_at > ?`,
144
+ installId, tenantId, stage, APP_CONTACT_PURPOSE, at,
145
+ );
146
+ if (!live) {
147
+ const latest = await db.first<{ revoked_at: string | null }>(
148
+ `SELECT revoked_at FROM app_contact_consents
149
+ WHERE install_id = ? AND tenant_id = ? AND stage = ? AND purpose = ?
150
+ ORDER BY granted_at DESC, rowid DESC LIMIT 1`,
151
+ installId, tenantId, stage, APP_CONTACT_PURPOSE,
152
+ );
153
+ if (!latest) return { status: 403, code: "consent-missing" };
154
+ return { status: 403, code: latest.revoked_at !== null ? "consent-revoked" : "consent-expired" };
155
+ }
156
+ const hourAgo = new Date(now.getTime() - HOUR_MS).toISOString();
157
+ const used = await db.first<{ n: number }>(
158
+ "SELECT COUNT(*) AS n FROM app_contact_access_audit WHERE install_id = ? AND tenant_id = ? AND at > ?",
159
+ installId, tenantId, hourAgo,
160
+ );
161
+ if (Number(used?.n ?? 0) >= APP_CONTACT_READS_PER_HOUR) return { status: 429, code: "quota" };
162
+ return null;
163
+ }
164
+
165
+ function exported(orderId: string, shipTo: ContactAddress): AppRecipient {
166
+ const recipient: AppRecipient = { orderId, line1: shipTo.line1, city: shipTo.city, country: shipTo.country };
167
+ for (const field of ["name", "company", "line2", "region", "postalCode"] as const) {
168
+ const value = shipTo[field];
169
+ if (typeof value === "string" && value.length > 0) recipient[field] = value;
170
+ }
171
+ return recipient;
172
+ }
173
+
174
+ const ATTEMPTS = 2;
175
+ const UNAVAILABLE = { ok: false, status: 503, code: "unavailable" } as const;
176
+
177
+ export async function readAppRecipient(deps: AppRecipientDeps, input: AppRecipientInput): Promise<AppRecipientOutcome> {
178
+ if (!isOpaqueId(input.orderId)) return { ok: false, status: 404, code: "not-assigned" };
179
+ for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
180
+ let eligible: Eligibility;
181
+ try {
182
+ eligible = await eligibility(deps, input);
183
+ } catch {
184
+ return UNAVAILABLE;
185
+ }
186
+ if (eligible.kind === "refused") return { ok: false, status: eligible.status, code: eligible.code };
187
+ const admission = appAuditStatement({
188
+ auditId: deps.auditId?.() ?? crypto.randomUUID(),
189
+ installId: input.installId,
190
+ tenantId: input.tenantId,
191
+ stage: input.stage,
192
+ orderId: input.orderId,
193
+ assignmentId: eligible.assignmentId,
194
+ eventVersion: eligible.eventVersion,
195
+ scope: RECIPIENT_SCOPE,
196
+ now: input.now,
197
+ });
198
+ let read: Awaited<ReturnType<typeof readOrderShipRecipient>>;
199
+ try {
200
+ read = await readOrderShipRecipient(deps.db, deps.kms, {
201
+ tenantId: input.tenantId, stage: input.stage, orderId: input.orderId, admission, now: input.now,
202
+ });
203
+ } catch {
204
+ return UNAVAILABLE;
205
+ }
206
+ if (read.ok) return { ok: true, recipient: exported(input.orderId, read.shipTo) };
207
+ if (read.reason !== "not-admitted") return { ok: false, status: read.status, code: read.reason };
208
+ let why: { status: number; code: AppRecipientCode } | null;
209
+ try {
210
+ why = await classifyRefusal(deps, input, eligible);
211
+ } catch {
212
+ return UNAVAILABLE;
213
+ }
214
+ if (why) return { ok: false, ...why };
215
+ }
216
+ return { ok: false, status: 409, code: "assignment-held" };
217
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The admission every order-contact admin route runs before it touches the vault: the owner gate,
3
- * then the owner-or-staff split (contactReaderAuthority), then — for staff — a reason, a ticket and a
3
+ * then the owner-or-staff split (contactReaderAuthority; an owner route requires kind owner and a
4
+ * staff route kind staff, so any other kind is refused), then — for staff — a reason, a ticket and a
4
5
  * LIVE owner-consented customer-contact-read grant. The tenant comes from the routed host, never
5
6
  * from the request. An order contact's stage is the one its order is recorded under
6
7
  * (`resolveOrderStage`, shared with the Orders tab, with the shared-store admission its route
@@ -73,10 +74,10 @@ export async function openContactRoute(
73
74
 
74
75
  let actor: ContactActor;
75
76
  if (expect === "owner") {
76
- if (authority.kind === "staff") return refuse("support-read-required", 403);
77
+ if (authority.kind !== "owner") return refuse("support-read-required", 403);
77
78
  actor = { actorId: authority.actorId, role: "owner" };
78
79
  } else {
79
- if (authority.kind === "owner") return refuse("use-owner-route", 403);
80
+ if (authority.kind !== "staff") return refuse("use-owner-route", 403);
80
81
  const reason = readSupportReason(body);
81
82
  if (!reason) return refuse("malformed", 400);
82
83
  if (!(await resolveContactReadGrant(authority.email, host))) return refuse("grant-required", 403);
@@ -9,6 +9,8 @@
9
9
  * - seal: [new data-key rows] + conditional vault insert + marker + replay (both conditioned on the
10
10
  * vault row this command sealed) + key cleanup. Every refused or racing outcome commits nothing.
11
11
  * - read / list: the audit row commits (staff quota in the same statement) before any decrypt.
12
+ * - app recipient read: the app-access audit row commits before contact state is read or anything
13
+ * is decrypted, and only the ship group is opened.
12
14
  * - erase: audit, group clearing and detach commit together.
13
15
  * - purge: one clock reading; each page is one batch.
14
16
  *
@@ -46,6 +48,7 @@ import {
46
48
  auditRecordIds,
47
49
  auditStatement,
48
50
  batchAuditStatement,
51
+ type AuditStatement,
49
52
  type ContactActor,
50
53
  type VaultStage,
51
54
  } from "@/lib/customers/contactVault/contactAudit.js";
@@ -768,6 +771,60 @@ export async function readOrderContact(
768
771
  }
769
772
  }
770
773
 
774
+ export type ShipRecipientOutcome =
775
+ | { ok: true; shipTo: ContactAddress }
776
+ | { ok: false; status: 0; reason: "not-admitted" }
777
+ | {
778
+ ok: false;
779
+ status: 404 | 409 | 410 | 503;
780
+ reason:
781
+ | "contact-erased" | "contact-expired" | "contact-absent" | "contact-unreadable"
782
+ | "audit-unavailable" | "vault-key-unavailable" | "unavailable";
783
+ };
784
+
785
+ const SHIP_SELECT = `SELECT fact_id, order_kid, ship_ceiling, ship_city, ship_postal_code, ship_region, ship_country,
786
+ ${LAYOUT.ship.sealed.join(", ")}
787
+ FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`;
788
+
789
+ /**
790
+ * An app's recipient read of one order: the ship group only. `admission` is the app-access audit
791
+ * insert; it runs first and nothing here reads contact state, loads a key or decrypts unless it
792
+ * returned its row. Zero rows answer `not-admitted` (the caller classifies from authority state);
793
+ * an error answers 503 audit-unavailable. After admission the ship group's state classifies the
794
+ * order (erased, expired, absent, unreadable) and only a present group is opened. Every failure
795
+ * after admission is a code: the data key 503 vault-key-unavailable, any other storage error 503
796
+ * unavailable. No thrown message leaves this function.
797
+ */
798
+ export async function readOrderShipRecipient(
799
+ db: Queryable,
800
+ kms: KmsClient,
801
+ input: { tenantId: string; stage: VaultStage; orderId: string; admission: AuditStatement; now: Date; policy?: RetentionPolicy },
802
+ ): Promise<ShipRecipientOutcome> {
803
+ const { tenantId, stage, orderId, now } = input;
804
+ const admitted = await admitAudit(db, input.admission);
805
+ if (admitted === "quota") return { ok: false, status: 0, reason: "not-admitted" };
806
+ if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
807
+ const unreadable = { ok: false, status: 409, reason: "contact-unreadable" } as const;
808
+ try {
809
+ const availability = await readOrderContactAvailability(db, { tenantId, stage, orderIds: [orderId], now, policy: input.policy });
810
+ const state = availability.get(orderId)?.shipTo ?? "unreadable";
811
+ if (state === "erased") return { ok: false, status: 410, reason: "contact-erased" };
812
+ if (state === "expired") return { ok: false, status: 410, reason: "contact-expired" };
813
+ if (state === "absent") return { ok: false, status: 404, reason: "contact-absent" };
814
+ if (state !== "present") return unreadable;
815
+ const row = await db.first<VaultRow>(SHIP_SELECT, tenantId, stage, orderId);
816
+ const kid = row ? text(row, "order_kid") : null;
817
+ if (!row || !kid) return unreadable;
818
+ const key = await keyForKid(db, kms, keyContext(tenantId, stage, ORDER_KEY), kid);
819
+ if (!key) return unreadable;
820
+ const opened = await openGroups(key, row, tenantId, stage, ["ship"]);
821
+ return opened?.shipTo ? { ok: true, shipTo: opened.shipTo } : unreadable;
822
+ } catch (error) {
823
+ if (error instanceof VaultKeyUnavailableError) return { ok: false, status: 503, reason: "vault-key-unavailable" };
824
+ return { ok: false, status: 503, reason: "unavailable" };
825
+ }
826
+ }
827
+
771
828
  export interface ListedContact {
772
829
  orderId: string;
773
830
  contact: ReadableContact;