@tokenoftrust/storefront-runner 6.5.2 → 6.5.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/src/lib/fulfillment/admin/commerceDesk.ts +26 -0
- package/apps/storefront/src/lib/fulfillment/admin/viewmodels/fulfillment.ts +8 -4
- package/apps/storefront/src/lib/fulfillment/appShipmentEvidence.ts +245 -0
- package/apps/storefront/src/lib/fulfillment/carrierEvidence.ts +65 -0
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +382 -55
- package/apps/storefront/src/lib/fulfillment/readiness.ts +18 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Operational browser client. All authority and state changes remain in the authenticated APIs. */
|
|
2
2
|
import type { FulfillmentView } from "../fulfillmentService.js";
|
|
3
3
|
import { parseCommerceDeskApiRoot } from "./commerceDeskRouting.js";
|
|
4
|
+
import { trackingUrlAllowed } from "../carrierEvidence.js";
|
|
4
5
|
export function mountCommerceDesk(root: HTMLElement): void {
|
|
5
6
|
const status = root.querySelector<HTMLElement>("[data-desk-status]")!;
|
|
6
7
|
const rows = root.querySelector<HTMLElement>("[data-desk-orders]")!;
|
|
@@ -31,6 +32,30 @@ export function mountCommerceDesk(root: HTMLElement): void {
|
|
|
31
32
|
parent.append(node);
|
|
32
33
|
return node;
|
|
33
34
|
}
|
|
35
|
+
/** Label and handoff state, carrier and tracking. The link is set only for an allowlisted https tracking URL. */
|
|
36
|
+
function renderShipment(shipment: NonNullable<FulfillmentView["shipment"]>, card: HTMLElement): void {
|
|
37
|
+
const block = document.createElement("div");
|
|
38
|
+
block.dataset.shipment = "";
|
|
39
|
+
card.append(block);
|
|
40
|
+
const state = text("p", `Label ${shipment.labelPurchaseState} · Handoff ${shipment.handoffState}`, block);
|
|
41
|
+
state.dataset.labelState = shipment.labelPurchaseState;
|
|
42
|
+
state.dataset.handoffState = shipment.handoffState;
|
|
43
|
+
if (shipment.carrier) {
|
|
44
|
+
const carrier = text("p", [shipment.carrier, shipment.service].filter(Boolean).join(" · "), block);
|
|
45
|
+
carrier.dataset.carrier = shipment.carrier;
|
|
46
|
+
}
|
|
47
|
+
if (shipment.trackingNumber) {
|
|
48
|
+
const tracking = text("a", `Tracking ${shipment.trackingNumber}`, block) as HTMLAnchorElement;
|
|
49
|
+
tracking.dataset.trackingNumber = shipment.trackingNumber;
|
|
50
|
+
tracking.rel = "noopener noreferrer";
|
|
51
|
+
tracking.referrerPolicy = "no-referrer";
|
|
52
|
+
if (trackingUrlAllowed(shipment.carrier, shipment.trackingUrl)) {
|
|
53
|
+
tracking.href = shipment.trackingUrl;
|
|
54
|
+
tracking.target = "_blank";
|
|
55
|
+
tracking.dataset.trackingUrl = shipment.trackingUrl;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
34
59
|
function render(order: FulfillmentView): void {
|
|
35
60
|
const card = document.createElement("section");
|
|
36
61
|
card.dataset.orderId = order.orderId;
|
|
@@ -43,6 +68,7 @@ export function mountCommerceDesk(root: HTMLElement): void {
|
|
|
43
68
|
const owner = text("p", `Assigned to processor ${order.assignment.installId} · ${order.assignment.state}`, card);
|
|
44
69
|
owner.dataset.assignmentState = order.assignment.state;
|
|
45
70
|
}
|
|
71
|
+
if (order.shipment) renderShipment(order.shipment, card);
|
|
46
72
|
const actions = order.batch
|
|
47
73
|
? [
|
|
48
74
|
["pick", "Confirm pick"],
|
|
@@ -21,11 +21,11 @@ import {
|
|
|
21
21
|
type PublicCommerceEvent,
|
|
22
22
|
type StockState,
|
|
23
23
|
} from "@tot/public-runtime";
|
|
24
|
+
import { trackingHostsFor } from "../../carrierEvidence.js";
|
|
24
25
|
|
|
25
26
|
export const SCRAPPY_FULFILLMENT_PATH = "/scrappy-fulfillment";
|
|
26
27
|
export const WORKSPACE_PAGE_LIMIT = 4;
|
|
27
28
|
export const TEST_ADAPTER_QUOTE_MINOR = 640;
|
|
28
|
-
export const TRACKING_HOSTS = ["tracking.example"] as const;
|
|
29
29
|
|
|
30
30
|
export const WORKSPACE_QUEUES = ["ready", "held", "in_progress", "shipped"] as const;
|
|
31
31
|
export type WorkspaceQueue = (typeof WORKSPACE_QUEUES)[number];
|
|
@@ -98,6 +98,7 @@ interface WorkspaceOrder {
|
|
|
98
98
|
purchaseKey: string | null;
|
|
99
99
|
purchaseId: string | null;
|
|
100
100
|
labelCostMinor: number | null;
|
|
101
|
+
carrier: string | null;
|
|
101
102
|
trackingNumber: string | null;
|
|
102
103
|
trackingUrl: string | null;
|
|
103
104
|
ownerDestination: string;
|
|
@@ -353,6 +354,7 @@ export function initialWorkspaceState(tenantId: string, stage: CommerceStage = "
|
|
|
353
354
|
purchaseKey: extra.purchaseKey ?? null,
|
|
354
355
|
purchaseId: extra.purchaseId ?? null,
|
|
355
356
|
labelCostMinor: extra.labelCostMinor ?? null,
|
|
357
|
+
carrier: extra.carrier ?? null,
|
|
356
358
|
trackingNumber: extra.trackingNumber ?? null,
|
|
357
359
|
trackingUrl: extra.trackingUrl ?? null,
|
|
358
360
|
ownerDestination: `dest-${id}`,
|
|
@@ -445,8 +447,9 @@ export function initialWorkspaceState(tenantId: string, stage: CommerceStage = "
|
|
|
445
447
|
purchaseKey: "key-shipped",
|
|
446
448
|
purchaseId: "test-key-shipped",
|
|
447
449
|
labelCostMinor: TEST_ADAPTER_QUOTE_MINOR,
|
|
450
|
+
carrier: "UPS",
|
|
448
451
|
trackingNumber: "TRK-ord-shipped",
|
|
449
|
-
trackingUrl: "https://
|
|
452
|
+
trackingUrl: "https://www.ups.com/track?tracknum=TRKORDSHIPPED",
|
|
450
453
|
}),
|
|
451
454
|
],
|
|
452
455
|
events: [],
|
|
@@ -471,7 +474,7 @@ function projectOrder(state: WorkspaceState, order: WorkspaceOrder, actor: Comme
|
|
|
471
474
|
actor,
|
|
472
475
|
resource: scope,
|
|
473
476
|
trackingUrl: order.trackingUrl,
|
|
474
|
-
allowedHosts:
|
|
477
|
+
allowedHosts: trackingHostsFor(order.carrier),
|
|
475
478
|
});
|
|
476
479
|
if (admitted.ok) trackingUrl = order.trackingUrl;
|
|
477
480
|
}
|
|
@@ -740,8 +743,9 @@ function applyToOrder(state: WorkspaceState, order: WorkspaceOrder, command: Wor
|
|
|
740
743
|
});
|
|
741
744
|
if (!decision.admit) return refuse(decision.reason);
|
|
742
745
|
order.fulfillment = "handed_off";
|
|
746
|
+
order.carrier = "UPS";
|
|
743
747
|
order.trackingNumber = `TRK-${order.id}`;
|
|
744
|
-
order.trackingUrl = `https://
|
|
748
|
+
order.trackingUrl = `https://www.ups.com/track?tracknum=${encodeURIComponent(order.id)}`;
|
|
745
749
|
return finish({ ...blankResult(command.command, order.id, "admitted", "handed-off"), purchaseId: order.purchaseId });
|
|
746
750
|
}
|
|
747
751
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The processor's shipment-evidence contract: the closed request schema, the minimized response,
|
|
3
|
+
* the active-install and active-assignment predicate every app write carries in its first
|
|
4
|
+
* statement, and the idempotency record a replay returns.
|
|
5
|
+
*/
|
|
6
|
+
import type { LabelState } from "@tot/public-runtime";
|
|
7
|
+
import { PROCESSOR_SCOPE } from "./assignmentStore.js";
|
|
8
|
+
import type { SqlStatement } from "./store.js";
|
|
9
|
+
import { CARRIER_SERVICE, TRACKING_NUMBER, parseCarrierCode, trackingUrlAllowed, type CarrierCode } from "./carrierEvidence.js";
|
|
10
|
+
|
|
11
|
+
/** The calling install and the assignment it writes under. Every app write requires both. */
|
|
12
|
+
export interface AppAssignmentBinding {
|
|
13
|
+
readonly installId: string;
|
|
14
|
+
readonly assignmentId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type AdultSignatureResult = "accepted" | "refused" | "absent";
|
|
18
|
+
|
|
19
|
+
export interface LabelOutcomeEvidence {
|
|
20
|
+
kind: "label-outcome";
|
|
21
|
+
assignmentId: string;
|
|
22
|
+
purchaseId: string;
|
|
23
|
+
outcome: "purchased" | "unknown" | "voided";
|
|
24
|
+
carrier?: CarrierCode;
|
|
25
|
+
service?: string;
|
|
26
|
+
trackingNumber?: string;
|
|
27
|
+
trackingUrl?: string;
|
|
28
|
+
adultSignature: AdultSignatureResult;
|
|
29
|
+
expectedVersion?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HandoffEvidence {
|
|
33
|
+
kind: "handoff";
|
|
34
|
+
assignmentId: string;
|
|
35
|
+
purchaseId: string;
|
|
36
|
+
carrier: CarrierCode;
|
|
37
|
+
expectedVersion?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ShipmentEvidence = LabelOutcomeEvidence | HandoffEvidence;
|
|
41
|
+
|
|
42
|
+
export interface AppShipmentView {
|
|
43
|
+
orderId: string;
|
|
44
|
+
version: number;
|
|
45
|
+
label: LabelState;
|
|
46
|
+
handoff: "none" | "handed_off" | "unknown";
|
|
47
|
+
purchaseId: string;
|
|
48
|
+
carrier?: CarrierCode;
|
|
49
|
+
tracking?: { number: string; url: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AppRefusalBody {
|
|
53
|
+
code: string;
|
|
54
|
+
error: string;
|
|
55
|
+
holds?: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AppEvidenceResponse {
|
|
59
|
+
status: number;
|
|
60
|
+
body: AppShipmentView | AppRefusalBody;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The facts a label-outcome audit row stores, read back by the desk and the app view. */
|
|
64
|
+
export interface StoredLabelFacts {
|
|
65
|
+
outcome: LabelOutcomeEvidence["outcome"] | null;
|
|
66
|
+
adultSignature: AdultSignatureResult;
|
|
67
|
+
carrier: CarrierCode | null;
|
|
68
|
+
service: string | null;
|
|
69
|
+
trackingNumber: string | null;
|
|
70
|
+
trackingUrl: string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const OPAQUE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
74
|
+
export const IDEMPOTENCY_KEY = /^[\x21-\x7e]{8,128}$/;
|
|
75
|
+
const LABEL_KEYS = new Set([
|
|
76
|
+
"kind",
|
|
77
|
+
"assignmentId",
|
|
78
|
+
"purchaseId",
|
|
79
|
+
"outcome",
|
|
80
|
+
"carrier",
|
|
81
|
+
"service",
|
|
82
|
+
"trackingNumber",
|
|
83
|
+
"trackingUrl",
|
|
84
|
+
"adultSignature",
|
|
85
|
+
"expectedVersion",
|
|
86
|
+
]);
|
|
87
|
+
const HANDOFF_KEYS = new Set(["kind", "assignmentId", "purchaseId", "carrier", "expectedVersion"]);
|
|
88
|
+
const PURCHASED_REQUIRES = ["carrier", "service", "trackingNumber", "trackingUrl", "adultSignature"] as const;
|
|
89
|
+
|
|
90
|
+
function opaque(value: unknown): value is string {
|
|
91
|
+
return typeof value === "string" && OPAQUE.test(value) && !value.includes("@");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
95
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The body in the closed schema, or null (400 malformed): unknown keys, a null value, unknown carrier, unsafe URL. */
|
|
99
|
+
export function parseShipmentEvidence(body: unknown): ShipmentEvidence | null {
|
|
100
|
+
if (!isRecord(body)) return null;
|
|
101
|
+
const keys = body.kind === "label-outcome" ? LABEL_KEYS : body.kind === "handoff" ? HANDOFF_KEYS : null;
|
|
102
|
+
if (!keys || Object.keys(body).some((key) => !keys.has(key))) return null;
|
|
103
|
+
if (Object.values(body).some((value) => value === null)) return null;
|
|
104
|
+
if (!opaque(body.assignmentId) || !opaque(body.purchaseId)) return null;
|
|
105
|
+
if (body.expectedVersion !== undefined && (!Number.isSafeInteger(body.expectedVersion) || Number(body.expectedVersion) < 0)) return null;
|
|
106
|
+
const expected = body.expectedVersion === undefined ? {} : { expectedVersion: Number(body.expectedVersion) };
|
|
107
|
+
if (body.kind === "handoff") {
|
|
108
|
+
const carrier = parseCarrierCode(body.carrier);
|
|
109
|
+
if (!carrier) return null;
|
|
110
|
+
return { kind: "handoff", assignmentId: body.assignmentId, purchaseId: body.purchaseId, carrier, ...expected };
|
|
111
|
+
}
|
|
112
|
+
if (body.outcome !== "purchased" && body.outcome !== "unknown" && body.outcome !== "voided") return null;
|
|
113
|
+
if (body.outcome === "purchased" && PURCHASED_REQUIRES.some((key) => body[key] === undefined)) return null;
|
|
114
|
+
const carrier = body.carrier === undefined ? undefined : parseCarrierCode(body.carrier);
|
|
115
|
+
if (carrier === null) return null;
|
|
116
|
+
if (body.service !== undefined && (typeof body.service !== "string" || !CARRIER_SERVICE.test(body.service))) return null;
|
|
117
|
+
if (body.trackingNumber !== undefined && (typeof body.trackingNumber !== "string" || !TRACKING_NUMBER.test(body.trackingNumber))) return null;
|
|
118
|
+
if (body.trackingUrl !== undefined && !trackingUrlAllowed(carrier, body.trackingUrl)) return null;
|
|
119
|
+
const adult = body.adultSignature ?? "absent";
|
|
120
|
+
if (adult !== "accepted" && adult !== "refused" && adult !== "absent") return null;
|
|
121
|
+
return {
|
|
122
|
+
kind: "label-outcome",
|
|
123
|
+
assignmentId: body.assignmentId,
|
|
124
|
+
purchaseId: body.purchaseId,
|
|
125
|
+
outcome: body.outcome,
|
|
126
|
+
adultSignature: adult,
|
|
127
|
+
...(carrier ? { carrier } : {}),
|
|
128
|
+
...(typeof body.service === "string" ? { service: body.service } : {}),
|
|
129
|
+
...(typeof body.trackingNumber === "string" ? { trackingNumber: body.trackingNumber } : {}),
|
|
130
|
+
...(typeof body.trackingUrl === "string" ? { trackingUrl: body.trackingUrl } : {}),
|
|
131
|
+
...expected,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The label-outcome audit `to_state`: closed keys, absent facts omitted. */
|
|
136
|
+
export function labelOutcomeRecord(evidence: LabelOutcomeEvidence): Record<string, string> {
|
|
137
|
+
return {
|
|
138
|
+
outcome: evidence.outcome,
|
|
139
|
+
adultSignature: evidence.adultSignature,
|
|
140
|
+
...(evidence.carrier ? { carrier: evidence.carrier } : {}),
|
|
141
|
+
...(evidence.service ? { service: evidence.service } : {}),
|
|
142
|
+
...(evidence.trackingNumber ? { trackingNumber: evidence.trackingNumber } : {}),
|
|
143
|
+
...(evidence.trackingUrl ? { trackingUrl: evidence.trackingUrl } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A stored label-outcome audit `to_state`, re-validated on read. Facts that fail their shape read as absent. */
|
|
148
|
+
export function parseStoredLabelFacts(value: unknown): StoredLabelFacts | null {
|
|
149
|
+
if (!isRecord(value)) return null;
|
|
150
|
+
if (value.adultSignature !== "accepted" && value.adultSignature !== "refused" && value.adultSignature !== "absent") return null;
|
|
151
|
+
const carrier = parseCarrierCode(value.carrier);
|
|
152
|
+
const outcome = value.outcome === "purchased" || value.outcome === "unknown" || value.outcome === "voided" ? value.outcome : null;
|
|
153
|
+
return {
|
|
154
|
+
outcome,
|
|
155
|
+
adultSignature: value.adultSignature,
|
|
156
|
+
carrier,
|
|
157
|
+
service: typeof value.service === "string" && CARRIER_SERVICE.test(value.service) ? value.service : null,
|
|
158
|
+
trackingNumber: typeof value.trackingNumber === "string" && TRACKING_NUMBER.test(value.trackingNumber) ? value.trackingNumber : null,
|
|
159
|
+
trackingUrl: trackingUrlAllowed(carrier, value.trackingUrl) ? value.trackingUrl : null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** True when a recorded purchase carries exactly these facts. */
|
|
164
|
+
export function sameLabelFacts(stored: StoredLabelFacts | null, evidence: LabelOutcomeEvidence): boolean {
|
|
165
|
+
return (
|
|
166
|
+
stored !== null &&
|
|
167
|
+
stored.adultSignature === evidence.adultSignature &&
|
|
168
|
+
stored.carrier === (evidence.carrier ?? null) &&
|
|
169
|
+
stored.service === (evidence.service ?? null) &&
|
|
170
|
+
stored.trackingNumber === (evidence.trackingNumber ?? null) &&
|
|
171
|
+
stored.trackingUrl === (evidence.trackingUrl ?? null)
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* SQL condition (no leading AND) every app write carries in statements[0]: the calling install is
|
|
177
|
+
* active on the host tenant with the processor scope, and the bound assignment of this order names
|
|
178
|
+
* it and is active.
|
|
179
|
+
*/
|
|
180
|
+
export function appWritePredicate(
|
|
181
|
+
binding: AppAssignmentBinding,
|
|
182
|
+
scope: { tenantId: string; stage: string; orderId: string },
|
|
183
|
+
): SqlStatement {
|
|
184
|
+
return {
|
|
185
|
+
sql: `EXISTS (
|
|
186
|
+
SELECT 1 FROM app_installs AS ai
|
|
187
|
+
WHERE ai.install_id = ? AND ai.tenant_id = ? AND ai.status = 'active'
|
|
188
|
+
AND EXISTS (SELECT 1 FROM json_each(ai.scopes) WHERE json_each.value = '${PROCESSOR_SCOPE}')
|
|
189
|
+
) AND EXISTS (
|
|
190
|
+
SELECT 1 FROM fulfillment_assignments AS fa
|
|
191
|
+
WHERE fa.assignment_id = ? AND fa.tenant_id = ? AND fa.stage = ? AND fa.order_id = ?
|
|
192
|
+
AND fa.install_id = ? AND fa.state = 'active'
|
|
193
|
+
)`,
|
|
194
|
+
params: [
|
|
195
|
+
binding.installId,
|
|
196
|
+
scope.tenantId,
|
|
197
|
+
binding.assignmentId,
|
|
198
|
+
scope.tenantId,
|
|
199
|
+
scope.stage,
|
|
200
|
+
scope.orderId,
|
|
201
|
+
binding.installId,
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const STATUS: Record<string, number> = {
|
|
207
|
+
malformed: 400,
|
|
208
|
+
invalid_token: 401,
|
|
209
|
+
scope_denied: 403,
|
|
210
|
+
denied: 403,
|
|
211
|
+
"not-assigned": 404,
|
|
212
|
+
unavailable: 503,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** A refusal response: `{ code, error }`, plus `holds` on a handoff refusal. */
|
|
216
|
+
export function appRefusal(code: string, holds?: readonly string[]): AppEvidenceResponse {
|
|
217
|
+
if (code === "invalid_token") return { status: 401, body: { error: "Unauthorized", code } };
|
|
218
|
+
const body: AppRefusalBody = holds && holds.length > 0 ? { code, error: code, holds: [...holds] } : { code, error: code };
|
|
219
|
+
return { status: STATUS[code] ?? 409, body };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The idempotency record: the response status and body a same-key, same-body retry returns. */
|
|
223
|
+
export function idempotencyRecord(response: AppEvidenceResponse): string {
|
|
224
|
+
return JSON.stringify({ status: response.status, body: response.body });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A recorded response, or null when the record is not one this route wrote. */
|
|
228
|
+
export function replayedResponse(recorded: unknown): AppEvidenceResponse | null {
|
|
229
|
+
if (!isRecord(recorded) || !Number.isSafeInteger(recorded.status) || !isRecord(recorded.body)) return null;
|
|
230
|
+
return { status: Number(recorded.status), body: recorded.body as unknown as AppShipmentView | AppRefusalBody };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The batch key insert: a plain INSERT, so a same-key race rolls the whole batch back. */
|
|
234
|
+
export function idempotencyInsertStatement(input: {
|
|
235
|
+
installId: string;
|
|
236
|
+
key: string;
|
|
237
|
+
bodyHash: string;
|
|
238
|
+
response: AppEvidenceResponse;
|
|
239
|
+
now: string;
|
|
240
|
+
}): SqlStatement {
|
|
241
|
+
return {
|
|
242
|
+
sql: `INSERT INTO idempotency_keys (install_id, idempotency_key, body_hash, response_body, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
243
|
+
params: [input.installId, input.key, input.bodyHash, idempotencyRecord(input.response), input.now],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier evidence a fulfillment processor reports: a closed set of canonical carrier codes and,
|
|
3
|
+
* per carrier, the tracking hosts a tracking URL may name. The same allowlist validates an app's
|
|
4
|
+
* write and gates the merchant desk's tracking link. Tracking URLs are never fetched.
|
|
5
|
+
*/
|
|
6
|
+
export const CARRIER_CODES = ["UPS", "FEDEX", "USPS", "DHL"] as const;
|
|
7
|
+
export type CarrierCode = (typeof CARRIER_CODES)[number];
|
|
8
|
+
|
|
9
|
+
export const CARRIER_TRACKING_HOSTS: Readonly<Record<CarrierCode, readonly string[]>> = {
|
|
10
|
+
UPS: ["www.ups.com"],
|
|
11
|
+
FEDEX: ["www.fedex.com"],
|
|
12
|
+
USPS: ["tools.usps.com"],
|
|
13
|
+
DHL: ["www.dhl.com"],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const TRACKING_NUMBER = /^[A-Z0-9]{6,40}$/;
|
|
17
|
+
export const CARRIER_SERVICE = /^[A-Z0-9_]{1,40}$/;
|
|
18
|
+
export const TRACKING_URL_MAX = 2048;
|
|
19
|
+
export const TRACKING_PART_MAX = 512;
|
|
20
|
+
|
|
21
|
+
/** The canonical form every carrier comparison uses: trimmed and uppercased. */
|
|
22
|
+
export function canonicalCarrier(value: string): string {
|
|
23
|
+
return value.trim().toUpperCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A supplied carrier as its canonical code, or null when it is not in the closed set. */
|
|
27
|
+
export function parseCarrierCode(value: unknown): CarrierCode | null {
|
|
28
|
+
if (typeof value !== "string") return null;
|
|
29
|
+
const canonical = canonicalCarrier(value);
|
|
30
|
+
return (CARRIER_CODES as readonly string[]).includes(canonical) ? (canonical as CarrierCode) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The tracking hosts allowed for `carrier`; none for a value outside the closed set. */
|
|
34
|
+
export function trackingHostsFor(carrier: unknown): readonly string[] {
|
|
35
|
+
const code = parseCarrierCode(carrier);
|
|
36
|
+
return code ? CARRIER_TRACKING_HOSTS[code] : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True when `url` is an https URL on one of `carrier`'s tracking hosts: at most 2048 characters,
|
|
41
|
+
* printable ASCII only, the authority exactly an allowed host (so no userinfo, port or IP literal),
|
|
42
|
+
* no fragment, and a path and query of at most 512 characters each.
|
|
43
|
+
*/
|
|
44
|
+
export function trackingUrlAllowed(carrier: unknown, url: unknown): url is string {
|
|
45
|
+
if (typeof url !== "string" || url.length > TRACKING_URL_MAX) return false;
|
|
46
|
+
if (!/^[\x21-\x7e]+$/.test(url) || url.includes("#") || url.includes("\\")) return false;
|
|
47
|
+
if (!url.startsWith("https://")) return false;
|
|
48
|
+
const authority = url.slice("https://".length).split(/[/?]/, 1)[0] ?? "";
|
|
49
|
+
if (!trackingHostsFor(carrier).includes(authority)) return false;
|
|
50
|
+
let parsed: URL;
|
|
51
|
+
try {
|
|
52
|
+
parsed = new URL(url);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "" || parsed.port !== "") return false;
|
|
57
|
+
if (parsed.host !== authority || parsed.hash !== "") return false;
|
|
58
|
+
return parsed.pathname.length <= TRACKING_PART_MAX && parsed.search.replace(/^\?/, "").length <= TRACKING_PART_MAX;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when `carrier` is in either prohibited set, both compared in canonical form. */
|
|
62
|
+
export function carrierProhibited(carrier: string, ...prohibited: ReadonlyArray<readonly string[]>): boolean {
|
|
63
|
+
const canonical = canonicalCarrier(carrier);
|
|
64
|
+
return prohibited.some((set) => set.some((item) => canonicalCarrier(item) === canonical));
|
|
65
|
+
}
|
|
@@ -38,6 +38,7 @@ import { readViewerSession } from "@/lib/access/auth/route";
|
|
|
38
38
|
import type { SessionRecord } from "@/lib/access/auth/session";
|
|
39
39
|
import {
|
|
40
40
|
canRelease,
|
|
41
|
+
carrierEvidenceRefusal,
|
|
41
42
|
carrierPolicyForTenant,
|
|
42
43
|
carrierSelectionRefusal,
|
|
43
44
|
handoffRefusal,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
type SqlStatement,
|
|
53
54
|
} from "./store.js";
|
|
54
55
|
import {
|
|
56
|
+
PROCESSOR_SCOPE,
|
|
55
57
|
assignmentAtGate,
|
|
56
58
|
insertAssignmentStatement,
|
|
57
59
|
moveAssignmentStatement,
|
|
@@ -70,6 +72,24 @@ import {
|
|
|
70
72
|
import { dispatchCommerceDeliveries } from "./commerceRuntime.js";
|
|
71
73
|
import type { CommerceTopic } from "../integrations/webhooks/commercePayloads.js";
|
|
72
74
|
import { claimedByEventGate, handoffClaimStatement } from "../integrations/webhooks/orderFulfilledClaim.js";
|
|
75
|
+
import { checkIdempotency, computeBodyHash } from "../integrations/apps/orders/idempotency.js";
|
|
76
|
+
import type { Queryable } from "../catalog/d1/catalog.js";
|
|
77
|
+
import {
|
|
78
|
+
IDEMPOTENCY_KEY,
|
|
79
|
+
appRefusal,
|
|
80
|
+
appWritePredicate,
|
|
81
|
+
idempotencyInsertStatement,
|
|
82
|
+
labelOutcomeRecord,
|
|
83
|
+
parseStoredLabelFacts,
|
|
84
|
+
replayedResponse,
|
|
85
|
+
sameLabelFacts,
|
|
86
|
+
type AppAssignmentBinding,
|
|
87
|
+
type AppEvidenceResponse,
|
|
88
|
+
type AppShipmentView,
|
|
89
|
+
type HandoffEvidence,
|
|
90
|
+
type LabelOutcomeEvidence,
|
|
91
|
+
type StoredLabelFacts,
|
|
92
|
+
} from "./appShipmentEvidence.js";
|
|
73
93
|
|
|
74
94
|
export const OPAQUE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
75
95
|
|
|
@@ -121,6 +141,11 @@ export interface ShipmentIntentView {
|
|
|
121
141
|
carrierAccountId: string | null;
|
|
122
142
|
adultSignatureRequired: boolean;
|
|
123
143
|
prohibitedCarriers: string[];
|
|
144
|
+
/** The recorded label's carrier, service and tracking, as the processor reported them. */
|
|
145
|
+
carrier: string | null;
|
|
146
|
+
service: string | null;
|
|
147
|
+
trackingNumber: string | null;
|
|
148
|
+
trackingUrl: string | null;
|
|
124
149
|
}
|
|
125
150
|
|
|
126
151
|
export interface FulfillmentView {
|
|
@@ -201,7 +226,7 @@ interface LoadedOrder {
|
|
|
201
226
|
packingSlip: PackingSlip | null;
|
|
202
227
|
shipment: { labelPurchaseState: string; handoffState: string } | null;
|
|
203
228
|
provider: { purchaseId: string; outcome: ProviderOutcome } | null;
|
|
204
|
-
labelOutcome:
|
|
229
|
+
labelOutcome: StoredLabelFacts | null;
|
|
205
230
|
carrierAccountId: string | null;
|
|
206
231
|
prohibitedCarriers: string[];
|
|
207
232
|
adultSignatureRequired: boolean;
|
|
@@ -230,6 +255,27 @@ type CommitPlan =
|
|
|
230
255
|
assignment?: boolean;
|
|
231
256
|
};
|
|
232
257
|
|
|
258
|
+
/** One app write: the install actor (built only by the route), the order, the key, the raw and parsed body. */
|
|
259
|
+
export interface AppEvidenceRequest<T> {
|
|
260
|
+
actor: FulfillmentActor;
|
|
261
|
+
orderId: string;
|
|
262
|
+
idempotencyKey: string;
|
|
263
|
+
body: unknown;
|
|
264
|
+
evidence: T;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
type AppPlan =
|
|
268
|
+
| { type: "done"; view: AppShipmentView }
|
|
269
|
+
| { type: "refuse"; code: string; holds?: readonly string[] }
|
|
270
|
+
| {
|
|
271
|
+
type: "write";
|
|
272
|
+
statements: SqlStatement[];
|
|
273
|
+
sourceAdvance: boolean;
|
|
274
|
+
deliveries: readonly PlannedDelivery[];
|
|
275
|
+
/** The planned post-state: the response and the idempotency record. */
|
|
276
|
+
view: AppShipmentView;
|
|
277
|
+
};
|
|
278
|
+
|
|
233
279
|
/** Statement and delivery accumulator for a plan that may carry commerce events. */
|
|
234
280
|
interface PlanStatements {
|
|
235
281
|
statements: SqlStatement[];
|
|
@@ -955,66 +1001,306 @@ export class FulfillmentService {
|
|
|
955
1001
|
return { type: "refuse", code: "label" };
|
|
956
1002
|
}
|
|
957
1003
|
const version = state.order.eventVersion + 1;
|
|
1004
|
+
const plan: PlanStatements = { deliveries: [], statements: this.handoffStatements(state, actor.actorId, version) };
|
|
1005
|
+
await this.appendOrderFulfilled(plan, state, version);
|
|
1006
|
+
return { type: "write", minimumChanges: 1, sourceAdvance: true, statements: plan.statements, deliveries: plan.deliveries };
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* The processor's label outcome under its assignment, from `app:<installId>` only. The first
|
|
1012
|
+
* outcome opens the whole-order shipment and its label operation under the platform purchase id.
|
|
1013
|
+
*/
|
|
1014
|
+
async appRecordLabelOutcome(request: AppEvidenceRequest<LabelOutcomeEvidence>): Promise<AppEvidenceResponse> {
|
|
1015
|
+
const evidence = request.evidence;
|
|
1016
|
+
return this.appCommit(request, async (state, binding, purchaseId) => {
|
|
1017
|
+
if (evidence.purchaseId !== purchaseId || (state.provider && state.provider.purchaseId !== purchaseId)) {
|
|
1018
|
+
return { type: "refuse", code: "conflict" };
|
|
1019
|
+
}
|
|
1020
|
+
const current = state.shipment?.labelPurchaseState ?? "none";
|
|
1021
|
+
const unchanged = { type: "done", view: appShipmentView(state, purchaseId) } as const;
|
|
1022
|
+
if (current === "purchased") {
|
|
1023
|
+
if (evidence.outcome !== "purchased") return { type: "refuse", code: "label-regress" };
|
|
1024
|
+
return sameLabelFacts(state.labelOutcome, evidence) ? unchanged : { type: "refuse", code: "conflict" };
|
|
1025
|
+
}
|
|
1026
|
+
if (current === "voided") return evidence.outcome === "voided" ? unchanged : { type: "refuse", code: "label-regress" };
|
|
1027
|
+
if (current === "unknown" && evidence.outcome === "unknown") return unchanged;
|
|
1028
|
+
if (state.order.fulfillment === "handed_off") return { type: "refuse", code: "fulfillment" };
|
|
1029
|
+
if (evidence.carrier) {
|
|
1030
|
+
const carrier = carrierEvidenceRefusal({ carrier: evidence.carrier, policy: this.policy, snapshotProhibited: state.prohibitedCarriers });
|
|
1031
|
+
if (carrier) return { type: "refuse", code: carrier };
|
|
1032
|
+
}
|
|
1033
|
+
const mapped = labelOutcomeColumns(evidence.outcome);
|
|
1034
|
+
const version = state.order.eventVersion + 1;
|
|
1035
|
+
const { order } = state;
|
|
1036
|
+
const actorId = request.actor.actorId;
|
|
1037
|
+
let n = state.auditCount;
|
|
1038
|
+
const nextStamp = () => stamp(this.now(), ++n);
|
|
1039
|
+
const predicate = appWritePredicate(binding, order);
|
|
1040
|
+
const statements: SqlStatement[] = [
|
|
1041
|
+
{
|
|
1042
|
+
sql: `UPDATE scrappy_commerce_orders
|
|
1043
|
+
SET label_state = ?, event_version = event_version + 1
|
|
1044
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
1045
|
+
AND fulfillment_state != 'handed_off' AND label_state = ?
|
|
1046
|
+
AND ${predicate.sql}`,
|
|
1047
|
+
params: [mapped.order, order.tenantId, order.stage, order.orderId, order.eventVersion, order.label, ...predicate.params],
|
|
1048
|
+
},
|
|
1049
|
+
];
|
|
1050
|
+
if (!state.shipment) statements.push(...(await this.intentStatements(state, actorId, version, nextStamp, { assigned: true })));
|
|
1051
|
+
statements.push(
|
|
1052
|
+
{
|
|
1053
|
+
sql: `UPDATE scrappy_shipments
|
|
1054
|
+
SET label_purchase_state = ?
|
|
1055
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND handoff_state = 'none'
|
|
1056
|
+
AND EXISTS (
|
|
1057
|
+
SELECT 1 FROM scrappy_commerce_orders
|
|
1058
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
1059
|
+
)`,
|
|
1060
|
+
params: [mapped.shipment, order.tenantId, order.stage, order.orderId, order.tenantId, order.stage, order.orderId, version],
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
sql: `UPDATE scrappy_provider_operations
|
|
1064
|
+
SET outcome = ?
|
|
1065
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND kind = 'label_purchase'
|
|
1066
|
+
AND provider = 'unselected' AND purchase_id = ?
|
|
1067
|
+
AND EXISTS (
|
|
1068
|
+
SELECT 1 FROM scrappy_commerce_orders
|
|
1069
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
1070
|
+
)`,
|
|
1071
|
+
params: [mapped.provider, order.tenantId, order.stage, order.orderId, purchaseId, order.tenantId, order.stage, order.orderId, version],
|
|
1072
|
+
},
|
|
1073
|
+
auditStatement(state, actorId, "label-outcome", order.label, jsonText(labelOutcomeRecord(evidence)), version, nextStamp()),
|
|
1074
|
+
);
|
|
1075
|
+
return {
|
|
1076
|
+
type: "write",
|
|
1077
|
+
statements,
|
|
1078
|
+
sourceAdvance: evidence.outcome === "purchased",
|
|
1079
|
+
deliveries: [],
|
|
1080
|
+
view: appShipmentView(state, purchaseId, { version, label: mapped.order, facts: parseStoredLabelFacts(labelOutcomeRecord(evidence)) }),
|
|
1081
|
+
};
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** The processor's confirmed handoff under its assignment: every desk handoff gate, the label carrier, one order.fulfilled. */
|
|
1086
|
+
async appConfirmHandoff(request: AppEvidenceRequest<HandoffEvidence>): Promise<AppEvidenceResponse> {
|
|
1087
|
+
const evidence = request.evidence;
|
|
1088
|
+
return this.appCommit(request, async (state, binding, purchaseId) => {
|
|
1089
|
+
if (evidence.purchaseId !== purchaseId || (state.provider && state.provider.purchaseId !== purchaseId)) {
|
|
1090
|
+
return { type: "refuse", code: "conflict" };
|
|
1091
|
+
}
|
|
1092
|
+
const labelCarrier = state.labelOutcome?.carrier ?? null;
|
|
1093
|
+
if (state.shipment?.handoffState === "handed_off" || state.order.fulfillment === "handed_off") {
|
|
1094
|
+
return labelCarrier === evidence.carrier
|
|
1095
|
+
? { type: "done", view: appShipmentView(state, purchaseId) }
|
|
1096
|
+
: { type: "refuse", code: "carrier-mismatch" };
|
|
1097
|
+
}
|
|
1098
|
+
const prohibited = carrierEvidenceRefusal({ carrier: evidence.carrier, policy: this.policy, snapshotProhibited: state.prohibitedCarriers });
|
|
1099
|
+
if (prohibited) return { type: "refuse", code: prohibited };
|
|
1100
|
+
const readiness = this.readiness(state);
|
|
1101
|
+
const code = handoffRefusal(readiness);
|
|
1102
|
+
if (code) return { type: "refuse", code, holds: releaseHolds(readiness) };
|
|
1103
|
+
if (state.shipment?.labelPurchaseState !== "purchased" || state.provider?.outcome !== "accepted") return { type: "refuse", code: "label" };
|
|
1104
|
+
const mismatch = carrierEvidenceRefusal({
|
|
1105
|
+
carrier: evidence.carrier,
|
|
1106
|
+
policy: this.policy,
|
|
1107
|
+
snapshotProhibited: state.prohibitedCarriers,
|
|
1108
|
+
labelCarrier,
|
|
1109
|
+
});
|
|
1110
|
+
if (mismatch) return { type: "refuse", code: mismatch };
|
|
1111
|
+
const version = state.order.eventVersion + 1;
|
|
1112
|
+
const [first, ...rest] = this.handoffStatements(state, request.actor.actorId, version);
|
|
1113
|
+
const predicate = appWritePredicate(binding, state.order);
|
|
958
1114
|
const plan: PlanStatements = {
|
|
959
1115
|
deliveries: [],
|
|
960
|
-
statements: [
|
|
961
|
-
{
|
|
962
|
-
sql: `UPDATE scrappy_commerce_orders
|
|
963
|
-
SET fulfillment_state = 'handed_off', event_version = event_version + 1
|
|
964
|
-
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
965
|
-
AND payment_state = 'captured' AND stock_state = 'confirmed'
|
|
966
|
-
AND fulfillment_state = 'ready' AND label_state IN ('purchased', 'printed')
|
|
967
|
-
AND EXISTS (
|
|
968
|
-
SELECT 1 FROM scrappy_shipments
|
|
969
|
-
WHERE tenant_id = ? AND stage = ? AND order_id = ?
|
|
970
|
-
AND scope = 'whole-order' AND label_purchase_state = 'purchased' AND handoff_state = 'none'
|
|
971
|
-
)`,
|
|
972
|
-
params: [
|
|
973
|
-
state.order.tenantId,
|
|
974
|
-
state.order.stage,
|
|
975
|
-
state.order.orderId,
|
|
976
|
-
state.order.eventVersion,
|
|
977
|
-
state.order.tenantId,
|
|
978
|
-
state.order.stage,
|
|
979
|
-
state.order.orderId,
|
|
980
|
-
],
|
|
981
|
-
},
|
|
982
|
-
{
|
|
983
|
-
sql: `UPDATE scrappy_shipments
|
|
984
|
-
SET handoff_state = 'handed_off'
|
|
985
|
-
WHERE tenant_id = ? AND stage = ? AND order_id = ?
|
|
986
|
-
AND scope = 'whole-order' AND label_purchase_state = 'purchased' AND handoff_state = 'none'
|
|
987
|
-
AND EXISTS (
|
|
988
|
-
SELECT 1 FROM scrappy_commerce_orders
|
|
989
|
-
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
990
|
-
)`,
|
|
991
|
-
params: [
|
|
992
|
-
state.order.tenantId,
|
|
993
|
-
state.order.stage,
|
|
994
|
-
state.order.orderId,
|
|
995
|
-
state.order.tenantId,
|
|
996
|
-
state.order.stage,
|
|
997
|
-
state.order.orderId,
|
|
998
|
-
version,
|
|
999
|
-
],
|
|
1000
|
-
},
|
|
1001
|
-
auditStatement(state, actor.actorId, "transition", state.order.fulfillment, "handed_off", version, stamp(this.now(), state.auditCount + 1)),
|
|
1002
|
-
terminalTransitionExpiryStatement(state.order, version, new Date(this.now())),
|
|
1003
|
-
],
|
|
1116
|
+
statements: [{ sql: `${first!.sql} AND ${predicate.sql}`, params: [...first!.params, ...predicate.params] }, ...rest],
|
|
1004
1117
|
};
|
|
1005
1118
|
await this.appendOrderFulfilled(plan, state, version);
|
|
1006
|
-
return {
|
|
1119
|
+
return {
|
|
1120
|
+
type: "write",
|
|
1121
|
+
statements: plan.statements,
|
|
1122
|
+
sourceAdvance: true,
|
|
1123
|
+
deliveries: plan.deliveries,
|
|
1124
|
+
view: appShipmentView(state, purchaseId, { version, handoff: "handed_off" }),
|
|
1125
|
+
};
|
|
1007
1126
|
});
|
|
1008
1127
|
}
|
|
1009
1128
|
|
|
1129
|
+
/**
|
|
1130
|
+
* The app write path: the key ledger first, the assignment binding, the plan, then one batch whose
|
|
1131
|
+
* first statement carries the install and assignment predicate and whose last records the key.
|
|
1132
|
+
* A plan-time answer records the key alone. A batch that does not commit is re-classified.
|
|
1133
|
+
*/
|
|
1134
|
+
private async appCommit(
|
|
1135
|
+
request: AppEvidenceRequest<LabelOutcomeEvidence | HandoffEvidence>,
|
|
1136
|
+
build: (state: LoadedOrder, binding: AppAssignmentBinding, purchaseId: string) => AppPlan | Promise<AppPlan>,
|
|
1137
|
+
): Promise<AppEvidenceResponse> {
|
|
1138
|
+
const binding = appBinding(request.actor, request.evidence.assignmentId);
|
|
1139
|
+
if (!binding) return appRefusal("denied");
|
|
1140
|
+
if (!IDEMPOTENCY_KEY.test(request.idempotencyKey)) return appRefusal("malformed");
|
|
1141
|
+
const { tenantId, stage } = request.actor;
|
|
1142
|
+
const { orderId, idempotencyKey: key } = request;
|
|
1143
|
+
if (!isOpaqueId(orderId)) return appRefusal("not-assigned");
|
|
1144
|
+
const bodyHash = await computeBodyHash({ body: request.body, assignmentId: binding.assignmentId, orderId });
|
|
1145
|
+
const prior = await this.priorResponse(binding.installId, key, bodyHash);
|
|
1146
|
+
if (prior) return prior;
|
|
1147
|
+
const state = await this.load(tenantId, stage, orderId);
|
|
1148
|
+
const assigned = await this.assignmentRefusal(binding, tenantId, stage, orderId, state?.assignment ?? null);
|
|
1149
|
+
if (assigned || !state) return appRefusal(assigned ?? "not-assigned");
|
|
1150
|
+
const expected = request.evidence.expectedVersion;
|
|
1151
|
+
if (expected !== undefined && state.order.eventVersion !== expected) return appRefusal("conflict");
|
|
1152
|
+
const purchaseId = await purchaseIdFor(tenantId, stage, orderId);
|
|
1153
|
+
let plan: AppPlan;
|
|
1154
|
+
try {
|
|
1155
|
+
plan = await build(state, binding, purchaseId);
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
if (error instanceof CommercePayloadRefused) return appRefusal("malformed");
|
|
1158
|
+
throw error;
|
|
1159
|
+
}
|
|
1160
|
+
if (plan.type === "done") return this.recordAppAnswer(binding.installId, key, bodyHash, { status: 200, body: plan.view });
|
|
1161
|
+
if (plan.type === "refuse") return this.recordAppAnswer(binding.installId, key, bodyHash, appRefusal(plan.code, plan.holds));
|
|
1162
|
+
const response: AppEvidenceResponse = { status: 200, body: plan.view };
|
|
1163
|
+
const fence = authorityFence(state.sourceAuthority, this.now(), plan.sourceAdvance);
|
|
1164
|
+
const [first, ...rest] = plan.statements;
|
|
1165
|
+
if (!first) return appRefusal("unavailable");
|
|
1166
|
+
const changes = await this.write([
|
|
1167
|
+
{ sql: `${first.sql} AND (${fence.sql})`, params: [...first.params, ...fence.params] },
|
|
1168
|
+
...rest,
|
|
1169
|
+
idempotencyInsertStatement({ installId: binding.installId, key, bodyHash, response, now: this.now() }),
|
|
1170
|
+
]);
|
|
1171
|
+
if (changes && (changes[0] ?? 0) >= 1) {
|
|
1172
|
+
await this.notifyCommitted(tenantId, plan.deliveries, changes);
|
|
1173
|
+
return response;
|
|
1174
|
+
}
|
|
1175
|
+
return this.classifyUncommitted(binding, state, plan.sourceAdvance, key, bodyHash, changes === null);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** Why an app batch did not commit, read once after it: install, scope, assignment, authority, key, version. */
|
|
1179
|
+
private async classifyUncommitted(
|
|
1180
|
+
binding: AppAssignmentBinding,
|
|
1181
|
+
original: LoadedOrder,
|
|
1182
|
+
sourceAdvance: boolean,
|
|
1183
|
+
key: string,
|
|
1184
|
+
bodyHash: string,
|
|
1185
|
+
failed: boolean,
|
|
1186
|
+
): Promise<AppEvidenceResponse> {
|
|
1187
|
+
const { tenantId, stage, orderId } = original.order;
|
|
1188
|
+
const install = await this.db.get<{ status: string; scopes: string }>(
|
|
1189
|
+
`SELECT status, scopes FROM app_installs WHERE install_id = ? AND tenant_id = ?`,
|
|
1190
|
+
[binding.installId, tenantId],
|
|
1191
|
+
);
|
|
1192
|
+
if (!install || install.status !== "active") return appRefusal("invalid_token");
|
|
1193
|
+
if (!scopeListIncludes(install.scopes, PROCESSOR_SCOPE)) return appRefusal("scope_denied");
|
|
1194
|
+
const current = await this.load(tenantId, stage, orderId);
|
|
1195
|
+
const assigned = await this.assignmentRefusal(binding, tenantId, stage, orderId, current?.assignment ?? null);
|
|
1196
|
+
if (assigned || !current) return appRefusal(assigned ?? "not-assigned");
|
|
1197
|
+
const fence = authorityFence(original.sourceAuthority, this.now(), sourceAdvance);
|
|
1198
|
+
const fenced = await this.db.get<{ ok: number }>(
|
|
1199
|
+
`SELECT 1 AS ok FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ? AND order_id = ? AND (${fence.sql})`,
|
|
1200
|
+
[tenantId, stage, orderId, ...fence.params],
|
|
1201
|
+
);
|
|
1202
|
+
if (!fenced) return appRefusal(current.sourceHolds[0] ?? "source-check-pending");
|
|
1203
|
+
const prior = await this.priorResponse(binding.installId, key, bodyHash);
|
|
1204
|
+
if (prior) return prior;
|
|
1205
|
+
return appRefusal(failed ? "unavailable" : "conflict");
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** A same-key request already answered: its recorded response, or idempotency-conflict for another body. */
|
|
1209
|
+
private async priorResponse(installId: string, key: string, bodyHash: string): Promise<AppEvidenceResponse | null> {
|
|
1210
|
+
const check = await checkIdempotency(this.ledger(), installId, key, bodyHash);
|
|
1211
|
+
if (check.status === "proceed") return null;
|
|
1212
|
+
if (check.status === "conflict") return appRefusal("idempotency-conflict");
|
|
1213
|
+
return replayedResponse(check.responseBody) ?? appRefusal("idempotency-conflict");
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** Records a plan-time answer under its key; a same-key race answers with whatever won. */
|
|
1217
|
+
private async recordAppAnswer(installId: string, key: string, bodyHash: string, response: AppEvidenceResponse): Promise<AppEvidenceResponse> {
|
|
1218
|
+
let changes: number[];
|
|
1219
|
+
try {
|
|
1220
|
+
changes = await this.db.batch([idempotencyInsertStatement({ installId, key, bodyHash, response, now: this.now() })]);
|
|
1221
|
+
} catch {
|
|
1222
|
+
return appRefusal("unavailable");
|
|
1223
|
+
}
|
|
1224
|
+
if ((changes[0] ?? 0) >= 1) return response;
|
|
1225
|
+
return (await this.priorResponse(installId, key, bodyHash)) ?? appRefusal("unavailable");
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** The order's assignment as the calling install sees it: null when its bound assignment is active. */
|
|
1229
|
+
private async assignmentRefusal(
|
|
1230
|
+
binding: AppAssignmentBinding,
|
|
1231
|
+
tenantId: string,
|
|
1232
|
+
stage: CommerceStage,
|
|
1233
|
+
orderId: string,
|
|
1234
|
+
open: OpenAssignment | null,
|
|
1235
|
+
): Promise<"not-assigned" | "assignment-ended" | "assignment-held" | null> {
|
|
1236
|
+
if (open && open.installId === binding.installId) {
|
|
1237
|
+
if (open.assignmentId !== binding.assignmentId) return "assignment-ended";
|
|
1238
|
+
return open.state === "held" ? "assignment-held" : null;
|
|
1239
|
+
}
|
|
1240
|
+
const named = await this.db.get<{ n: number }>(
|
|
1241
|
+
`SELECT 1 AS n FROM fulfillment_assignments WHERE tenant_id = ? AND stage = ? AND order_id = ? AND install_id = ? LIMIT 1`,
|
|
1242
|
+
[tenantId, stage, orderId, binding.installId],
|
|
1243
|
+
);
|
|
1244
|
+
return named ? "assignment-ended" : "not-assigned";
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** The idempotency ledger over this store. */
|
|
1248
|
+
private ledger(): Queryable {
|
|
1249
|
+
const db = this.db;
|
|
1250
|
+
return {
|
|
1251
|
+
all: async <T,>(sql: string, ...params: unknown[]) => (await db.all(sql, params)) as T[],
|
|
1252
|
+
first: async <T,>(sql: string, ...params: unknown[]) => ((await db.get(sql, params)) ?? null) as T | null,
|
|
1253
|
+
run: async (sql: string, ...params: unknown[]) => {
|
|
1254
|
+
await db.batch([{ sql, params }]);
|
|
1255
|
+
},
|
|
1256
|
+
exec: async () => {
|
|
1257
|
+
throw new Error("storage");
|
|
1258
|
+
},
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** The whole-order handoff: order CAS, shipment move, transition audit, and the contact expiry. */
|
|
1263
|
+
private handoffStatements(state: LoadedOrder, actorId: string, version: number): SqlStatement[] {
|
|
1264
|
+
const { order } = state;
|
|
1265
|
+
return [
|
|
1266
|
+
{
|
|
1267
|
+
sql: `UPDATE scrappy_commerce_orders
|
|
1268
|
+
SET fulfillment_state = 'handed_off', event_version = event_version + 1
|
|
1269
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
1270
|
+
AND payment_state = 'captured' AND stock_state = 'confirmed'
|
|
1271
|
+
AND fulfillment_state = 'ready' AND label_state IN ('purchased', 'printed')
|
|
1272
|
+
AND EXISTS (
|
|
1273
|
+
SELECT 1 FROM scrappy_shipments
|
|
1274
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ?
|
|
1275
|
+
AND scope = 'whole-order' AND label_purchase_state = 'purchased' AND handoff_state = 'none'
|
|
1276
|
+
)`,
|
|
1277
|
+
params: [order.tenantId, order.stage, order.orderId, order.eventVersion, order.tenantId, order.stage, order.orderId],
|
|
1278
|
+
},
|
|
1279
|
+
{
|
|
1280
|
+
sql: `UPDATE scrappy_shipments
|
|
1281
|
+
SET handoff_state = 'handed_off'
|
|
1282
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ?
|
|
1283
|
+
AND scope = 'whole-order' AND label_purchase_state = 'purchased' AND handoff_state = 'none'
|
|
1284
|
+
AND EXISTS (
|
|
1285
|
+
SELECT 1 FROM scrappy_commerce_orders
|
|
1286
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
1287
|
+
)`,
|
|
1288
|
+
params: [order.tenantId, order.stage, order.orderId, order.tenantId, order.stage, order.orderId, version],
|
|
1289
|
+
},
|
|
1290
|
+
auditStatement(state, actorId, "transition", order.fulfillment, "handed_off", version, stamp(this.now(), state.auditCount + 1)),
|
|
1291
|
+
terminalTransitionExpiryStatement(order, version, new Date(this.now())),
|
|
1292
|
+
];
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1010
1295
|
/** Returns are not automated. This records nothing. */
|
|
1011
1296
|
createReturn(actor: FulfillmentActor, _orderId: string): FulfillmentResult<never> {
|
|
1012
1297
|
if (!this.commandScope(actor)) return refuse("denied");
|
|
1013
1298
|
return refuse("returns-not-automated");
|
|
1014
1299
|
}
|
|
1015
1300
|
|
|
1301
|
+
/** Merchant and desk commands. An install actor (`app:`) commands only through the app methods. */
|
|
1016
1302
|
private commandScope(actor: FulfillmentActor): { tenantId: string; stage: CommerceStage } | null {
|
|
1017
|
-
if (!isOpaqueId(actor.actorId)) return null;
|
|
1303
|
+
if (!isOpaqueId(actor.actorId) || actor.actorId.startsWith(APP_ACTOR_PREFIX)) return null;
|
|
1018
1304
|
const scope = { tenantId: actor.tenantId, stage: actor.stage };
|
|
1019
1305
|
return actorMayCommand(actor, scope) ? scope : null;
|
|
1020
1306
|
}
|
|
@@ -1122,6 +1408,8 @@ export class FulfillmentService {
|
|
|
1122
1408
|
actorId: string,
|
|
1123
1409
|
version: number,
|
|
1124
1410
|
nextStamp: () => string,
|
|
1411
|
+
/** The assigned processor's first label outcome opens the shipment under its own assignment. */
|
|
1412
|
+
options: { assigned?: boolean } = {},
|
|
1125
1413
|
): Promise<SqlStatement[]> {
|
|
1126
1414
|
const purchaseId = state.provider?.purchaseId ?? (await purchaseIdFor(state.order.tenantId, state.order.stage, state.order.orderId));
|
|
1127
1415
|
const intent = {
|
|
@@ -1144,12 +1432,12 @@ export class FulfillmentService {
|
|
|
1144
1432
|
SELECT 1 FROM scrappy_shipments AS s
|
|
1145
1433
|
WHERE s.tenant_id = o.tenant_id AND s.stage = o.stage AND s.order_id = o.order_id
|
|
1146
1434
|
)
|
|
1147
|
-
AND NOT EXISTS (
|
|
1435
|
+
AND (? = 1 OR NOT EXISTS (
|
|
1148
1436
|
SELECT 1 FROM fulfillment_assignments AS fa
|
|
1149
1437
|
WHERE fa.tenant_id = o.tenant_id AND fa.stage = o.stage AND fa.order_id = o.order_id
|
|
1150
1438
|
AND fa.state IN ('active', 'held')
|
|
1151
|
-
)`,
|
|
1152
|
-
params: [order.tenantId, order.stage, order.orderId, version],
|
|
1439
|
+
))`,
|
|
1440
|
+
params: [order.tenantId, order.stage, order.orderId, version, options.assigned ? 1 : 0],
|
|
1153
1441
|
},
|
|
1154
1442
|
{
|
|
1155
1443
|
sql: `INSERT INTO scrappy_provider_operations (
|
|
@@ -1659,7 +1947,7 @@ export class FulfillmentService {
|
|
|
1659
1947
|
else if (audit.kind === "operator-hold") operatorHold = audit.to_state === "open";
|
|
1660
1948
|
else if (audit.kind === "pending-cancel") cancelPending = audit.to_state === "open";
|
|
1661
1949
|
else if (audit.kind === "packing-slip") packingSlip = parseSlip(parseJson(audit.to_state));
|
|
1662
|
-
else if (audit.kind === "label-outcome") labelOutcome =
|
|
1950
|
+
else if (audit.kind === "label-outcome") labelOutcome = parseStoredLabelFacts(parseJson(audit.to_state));
|
|
1663
1951
|
else if (audit.kind === "label-intent") {
|
|
1664
1952
|
const intent = parseJson(audit.to_state);
|
|
1665
1953
|
if (isRecord(intent)) {
|
|
@@ -1759,6 +2047,10 @@ export class FulfillmentService {
|
|
|
1759
2047
|
carrierAccountId: loaded.carrierAccountId,
|
|
1760
2048
|
adultSignatureRequired: loaded.adultSignatureRequired,
|
|
1761
2049
|
prohibitedCarriers: loaded.prohibitedCarriers,
|
|
2050
|
+
carrier: labelOutcome?.carrier ?? null,
|
|
2051
|
+
service: labelOutcome?.service ?? null,
|
|
2052
|
+
trackingNumber: labelOutcome?.trackingNumber ?? null,
|
|
2053
|
+
trackingUrl: labelOutcome?.trackingUrl ?? null,
|
|
1762
2054
|
}
|
|
1763
2055
|
: null;
|
|
1764
2056
|
return loaded;
|
|
@@ -1990,10 +2282,45 @@ function parseSlip(value: unknown): PackingSlip | null {
|
|
|
1990
2282
|
};
|
|
1991
2283
|
}
|
|
1992
2284
|
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2285
|
+
const APP_ACTOR_PREFIX = "app:";
|
|
2286
|
+
|
|
2287
|
+
/** The binding an install actor writes under; null for any other actor or an unusable id. */
|
|
2288
|
+
function appBinding(actor: FulfillmentActor, assignmentId: string): AppAssignmentBinding | null {
|
|
2289
|
+
if (actor.role !== "caller" || !actor.actorId.startsWith(APP_ACTOR_PREFIX)) return null;
|
|
2290
|
+
const installId = actor.actorId.slice(APP_ACTOR_PREFIX.length);
|
|
2291
|
+
if (!isOpaqueId(installId) || !isOpaqueId(assignmentId)) return null;
|
|
2292
|
+
if (!actorMayCommand(actor, { tenantId: actor.tenantId, stage: actor.stage })) return null;
|
|
2293
|
+
return { installId, assignmentId };
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
function scopeListIncludes(scopes: unknown, scope: string): boolean {
|
|
2297
|
+
const parsed = typeof scopes === "string" ? parseJson(scopes) : null;
|
|
2298
|
+
return Array.isArray(parsed) && parsed.includes(scope);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/** The minimized app view of `state`, or of its planned successor when `next` is given. */
|
|
2302
|
+
function appShipmentView(
|
|
2303
|
+
state: LoadedOrder,
|
|
2304
|
+
purchaseId: string,
|
|
2305
|
+
next: { version?: number; label?: LabelState; handoff?: AppShipmentView["handoff"]; facts?: StoredLabelFacts | null } = {},
|
|
2306
|
+
): AppShipmentView {
|
|
2307
|
+
const handoff =
|
|
2308
|
+
next.handoff ??
|
|
2309
|
+
(state.shipment?.handoffState === "handed_off" || state.order.fulfillment === "handed_off"
|
|
2310
|
+
? "handed_off"
|
|
2311
|
+
: state.order.fulfillment === "UNKNOWN"
|
|
2312
|
+
? "unknown"
|
|
2313
|
+
: "none");
|
|
2314
|
+
const facts = next.facts === undefined ? state.labelOutcome : next.facts;
|
|
2315
|
+
return {
|
|
2316
|
+
orderId: state.order.orderId,
|
|
2317
|
+
version: next.version ?? state.order.eventVersion,
|
|
2318
|
+
label: next.label ?? state.order.label,
|
|
2319
|
+
handoff,
|
|
2320
|
+
purchaseId,
|
|
2321
|
+
...(facts?.carrier ? { carrier: facts.carrier } : {}),
|
|
2322
|
+
...(facts?.trackingNumber && facts.trackingUrl ? { tracking: { number: facts.trackingNumber, url: facts.trackingUrl } } : {}),
|
|
2323
|
+
};
|
|
1997
2324
|
}
|
|
1998
2325
|
|
|
1999
2326
|
/** A stored audit state that is not JSON parses to null, which every caller fails closed on. */
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "@tot/public-runtime";
|
|
21
21
|
import type { SourceReadiness } from "../orders/data/sourceAuthorityStore.js";
|
|
22
22
|
import { COMPLIANCE_RULE_PROFILES } from "@/config/compliance/rulesets";
|
|
23
|
+
import { canonicalCarrier, carrierProhibited, type CarrierCode } from "./carrierEvidence.js";
|
|
23
24
|
|
|
24
25
|
export const RELEASE_HOLDS = [
|
|
25
26
|
"source-check-pending",
|
|
@@ -167,3 +168,20 @@ export function carrierSelectionRefusal(
|
|
|
167
168
|
if (policy.prohibitedCarriers.some((item) => item.toUpperCase() === normalized)) return "carrier-prohibited";
|
|
168
169
|
return "vendor-not-selected";
|
|
169
170
|
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A processor's reported carrier. Prohibited when it is in the current policy's set or the order's
|
|
174
|
+
* label-intent snapshot; a handoff carrier must equal the carrier its label was bought with.
|
|
175
|
+
*/
|
|
176
|
+
export function carrierEvidenceRefusal(input: {
|
|
177
|
+
carrier: CarrierCode;
|
|
178
|
+
policy: CarrierPolicy;
|
|
179
|
+
snapshotProhibited: readonly string[];
|
|
180
|
+
labelCarrier?: string | null;
|
|
181
|
+
}): "carrier-prohibited" | "carrier-mismatch" | null {
|
|
182
|
+
if (carrierProhibited(input.carrier, input.policy.prohibitedCarriers, input.snapshotProhibited)) return "carrier-prohibited";
|
|
183
|
+
if (input.labelCarrier !== undefined && (input.labelCarrier === null || canonicalCarrier(input.labelCarrier) !== input.carrier)) {
|
|
184
|
+
return "carrier-mismatch";
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "6.5.
|
|
3
|
+
"version": "6.5.3",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|