@tokenoftrust/storefront-runner 6.5.1 → 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/migrations-apps/0027_app_contact_authority.sql +70 -0
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +56 -1
- 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/apps/storefront/src/lib/integrations/apps/appContactAuthority.ts +27 -0
- package/apps/storefront/src/lib/integrations/apps/appContactConsents.ts +112 -0
- package/apps/storefront/src/lib/integrations/apps/registryService.ts +9 -0
- package/apps/storefront/src/lib/orders/vault/appRecipientRead.ts +217 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -3
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +57 -0
- package/package.json +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- Owner consent for a Private App install to read order recipients for one purpose. Opaque ids and
|
|
2
|
+
-- times only; granted_by is sha256 of the owner actor id. Revoke sets revoked_at once; rows are never
|
|
3
|
+
-- deleted, and re-granting inserts a new row.
|
|
4
|
+
CREATE TABLE `app_contact_consents` (
|
|
5
|
+
`consent_id` text PRIMARY KEY NOT NULL,
|
|
6
|
+
`install_id` text NOT NULL,
|
|
7
|
+
`tenant_id` text NOT NULL,
|
|
8
|
+
`stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
|
|
9
|
+
`purpose` text NOT NULL CHECK (`purpose` = 'fulfillment-shipping'),
|
|
10
|
+
`granted_by` text NOT NULL CHECK (length(`granted_by`) = 64 AND `granted_by` NOT GLOB '*[^0-9a-f]*'),
|
|
11
|
+
`granted_at` text NOT NULL,
|
|
12
|
+
`expires_at` text NOT NULL,
|
|
13
|
+
`revoked_at` text,
|
|
14
|
+
CHECK (`expires_at` > `granted_at`),
|
|
15
|
+
CHECK (julianday(`expires_at`) IS NOT NULL AND julianday(`granted_at`) IS NOT NULL
|
|
16
|
+
AND julianday(`expires_at`) - julianday(`granted_at`) <= 365)
|
|
17
|
+
);
|
|
18
|
+
--> statement-breakpoint
|
|
19
|
+
CREATE UNIQUE INDEX `app_contact_consents_live`
|
|
20
|
+
ON `app_contact_consents` (`install_id`, `tenant_id`, `stage`, `purpose`)
|
|
21
|
+
WHERE `revoked_at` IS NULL;
|
|
22
|
+
--> statement-breakpoint
|
|
23
|
+
CREATE TRIGGER `app_contact_consents_no_delete`
|
|
24
|
+
BEFORE DELETE ON `app_contact_consents`
|
|
25
|
+
BEGIN
|
|
26
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
27
|
+
END;
|
|
28
|
+
--> statement-breakpoint
|
|
29
|
+
CREATE TRIGGER `app_contact_consents_revoke_only`
|
|
30
|
+
BEFORE UPDATE ON `app_contact_consents`
|
|
31
|
+
WHEN OLD.`revoked_at` IS NOT NULL
|
|
32
|
+
OR NEW.`revoked_at` IS NULL
|
|
33
|
+
OR NEW.`consent_id` IS NOT OLD.`consent_id`
|
|
34
|
+
OR NEW.`install_id` IS NOT OLD.`install_id`
|
|
35
|
+
OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
|
|
36
|
+
OR NEW.`stage` IS NOT OLD.`stage`
|
|
37
|
+
OR NEW.`purpose` IS NOT OLD.`purpose`
|
|
38
|
+
OR NEW.`granted_by` IS NOT OLD.`granted_by`
|
|
39
|
+
OR NEW.`granted_at` IS NOT OLD.`granted_at`
|
|
40
|
+
OR NEW.`expires_at` IS NOT OLD.`expires_at`
|
|
41
|
+
BEGIN
|
|
42
|
+
SELECT RAISE(ABORT, 'revoke-only');
|
|
43
|
+
END;
|
|
44
|
+
--> statement-breakpoint
|
|
45
|
+
-- Every admitted app read of an order recipient, written before anything is decrypted. Opaque ids
|
|
46
|
+
-- only; append-only.
|
|
47
|
+
CREATE TABLE `app_contact_access_audit` (
|
|
48
|
+
`audit_id` text PRIMARY KEY NOT NULL,
|
|
49
|
+
`install_id` text NOT NULL,
|
|
50
|
+
`tenant_id` text NOT NULL,
|
|
51
|
+
`stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
|
|
52
|
+
`order_id` text NOT NULL,
|
|
53
|
+
`purpose` text NOT NULL CHECK (`purpose` = 'fulfillment-shipping'),
|
|
54
|
+
`at` text NOT NULL,
|
|
55
|
+
`outcome` text NOT NULL CHECK (`outcome` = 'admitted')
|
|
56
|
+
);
|
|
57
|
+
--> statement-breakpoint
|
|
58
|
+
CREATE INDEX `idx_app_contact_access_audit_quota` ON `app_contact_access_audit` (`install_id`, `tenant_id`, `at`);
|
|
59
|
+
--> statement-breakpoint
|
|
60
|
+
CREATE TRIGGER `app_contact_access_audit_no_update`
|
|
61
|
+
BEFORE UPDATE ON `app_contact_access_audit`
|
|
62
|
+
BEGIN
|
|
63
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
64
|
+
END;
|
|
65
|
+
--> statement-breakpoint
|
|
66
|
+
CREATE TRIGGER `app_contact_access_audit_no_delete`
|
|
67
|
+
BEFORE DELETE ON `app_contact_access_audit`
|
|
68
|
+
BEGIN
|
|
69
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
70
|
+
END;
|
|
@@ -18,7 +18,8 @@ export function auditRecordIds(ids: readonly string[]): string | null {
|
|
|
18
18
|
if (ids.length > AUDIT_RECORD_IDS_MAX) throw new Error("contact audit: too many record ids");
|
|
19
19
|
return ids.length === 0 ? null : ids.join(",");
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
/** The quota window. A row counts while its `at` is strictly after `now - HOUR_MS`: one exactly an hour old no longer counts. */
|
|
22
|
+
export const HOUR_MS = 3_600_000;
|
|
22
23
|
|
|
23
24
|
export interface ContactActor {
|
|
24
25
|
actorId: string;
|
|
@@ -88,6 +89,60 @@ export async function admitAudit(db: AuditDb, statement: AuditStatement): Promis
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/** Admitted app recipient reads per install and tenant per hour. */
|
|
93
|
+
export const APP_CONTACT_READS_PER_HOUR = 300;
|
|
94
|
+
export const APP_CONTACT_PURPOSE = "fulfillment-shipping";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The app-access audit insert (`app_contact_access_audit`): the recipient read's admission and its
|
|
98
|
+
* only write. It inserts one row only when, at that instant, the install is active on the tenant
|
|
99
|
+
* holding `scope`, the assignment is the install's active assignment of the order, the order still
|
|
100
|
+
* stands at `eventVersion` (the version its releasability was computed at), a live consent covers
|
|
101
|
+
* (install, tenant, stage, purpose), and the install has fewer than
|
|
102
|
+
* {@link APP_CONTACT_READS_PER_HOUR} admitted rows on the tenant in the last hour.
|
|
103
|
+
*/
|
|
104
|
+
export function appAuditStatement(input: {
|
|
105
|
+
auditId: string;
|
|
106
|
+
installId: string;
|
|
107
|
+
tenantId: string;
|
|
108
|
+
stage: VaultStage;
|
|
109
|
+
orderId: string;
|
|
110
|
+
assignmentId: string;
|
|
111
|
+
eventVersion: number;
|
|
112
|
+
scope: string;
|
|
113
|
+
now: Date;
|
|
114
|
+
}): AuditStatement {
|
|
115
|
+
const { auditId, installId, tenantId, stage, orderId, assignmentId, eventVersion, scope, now } = input;
|
|
116
|
+
const at = now.toISOString();
|
|
117
|
+
const hourAgo = new Date(now.getTime() - HOUR_MS).toISOString();
|
|
118
|
+
return {
|
|
119
|
+
sql: `INSERT INTO app_contact_access_audit (audit_id, install_id, tenant_id, stage, order_id, purpose, at, outcome)
|
|
120
|
+
SELECT ?, ?, ?, ?, ?, '${APP_CONTACT_PURPOSE}', ?, 'admitted'
|
|
121
|
+
WHERE EXISTS (SELECT 1 FROM app_installs AS ai
|
|
122
|
+
WHERE ai.install_id = ? AND ai.tenant_id = ? AND ai.status = 'active'
|
|
123
|
+
AND EXISTS (SELECT 1 FROM json_each(ai.scopes) WHERE json_each.value = ?))
|
|
124
|
+
AND EXISTS (SELECT 1 FROM fulfillment_assignments AS fa
|
|
125
|
+
WHERE fa.assignment_id = ? AND fa.tenant_id = ? AND fa.stage = ? AND fa.order_id = ?
|
|
126
|
+
AND fa.install_id = ? AND fa.state = 'active')
|
|
127
|
+
AND EXISTS (SELECT 1 FROM scrappy_commerce_orders AS o
|
|
128
|
+
WHERE o.tenant_id = ? AND o.stage = ? AND o.order_id = ? AND o.event_version = ?)
|
|
129
|
+
AND EXISTS (SELECT 1 FROM app_contact_consents AS c
|
|
130
|
+
WHERE c.install_id = ? AND c.tenant_id = ? AND c.stage = ? AND c.purpose = '${APP_CONTACT_PURPOSE}'
|
|
131
|
+
AND c.revoked_at IS NULL AND c.expires_at > ?)
|
|
132
|
+
AND (SELECT COUNT(*) FROM app_contact_access_audit AS q
|
|
133
|
+
WHERE q.install_id = ? AND q.tenant_id = ? AND q.at > ?) < ?
|
|
134
|
+
RETURNING audit_id`,
|
|
135
|
+
params: [
|
|
136
|
+
auditId, installId, tenantId, stage, orderId, at,
|
|
137
|
+
installId, tenantId, scope,
|
|
138
|
+
assignmentId, tenantId, stage, orderId, installId,
|
|
139
|
+
tenantId, stage, orderId, eventVersion,
|
|
140
|
+
installId, tenantId, stage, at,
|
|
141
|
+
installId, tenantId, hourAgo, APP_CONTACT_READS_PER_HOUR,
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
91
146
|
/** The same insert without RETURNING, for use inside a batch whose later statements check the row. */
|
|
92
147
|
export function batchAuditStatement(statement: AuditStatement): AuditStatement {
|
|
93
148
|
return { sql: statement.sql.replace(/\s*RETURNING audit_id$/, ""), params: statement.params };
|
|
@@ -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
|
+
}
|