@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.
@@ -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: { adultSignature: "accepted" | "refused" | "absent" } | null;
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 { type: "write", minimumChanges: 1, sourceAdvance: true, statements: plan.statements, deliveries: plan.deliveries };
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 = parseLabelOutcome(parseJson(audit.to_state));
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
- function parseLabelOutcome(value: unknown): { adultSignature: "accepted" | "refused" | "absent" } | null {
1994
- if (!isRecord(value)) return null;
1995
- if (value.adultSignature !== "accepted" && value.adultSignature !== "refused" && value.adultSignature !== "absent") return null;
1996
- return { adultSignature: value.adultSignature };
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
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Whether a verified Private App request may ask for order contact at all: the only source of the
3
+ * `app` contact-reader kind. It is separate from `contactReaderAuthority`, whose outcomes stay
4
+ * owner, staff or none, so no human contact route can ever see an app.
5
+ *
6
+ * Every predicate is required: an install record, active, on the host tenant, the token's subject
7
+ * naming it, and the scope in both the install grant and the token. Consent, assignment and quota
8
+ * are decided by the audit insert, not here.
9
+ */
10
+ export type AppContactAuthority = { kind: "app"; installId: string; tenantId: string } | { kind: "none" };
11
+
12
+ export interface AppContactAuthorityInput {
13
+ install: { installId: string; tenantId: string; status: string; scopes: readonly string[] } | null | undefined;
14
+ claims: { sub: string; scopes: readonly string[] } | null | undefined;
15
+ hostResource: string;
16
+ scope: string;
17
+ }
18
+
19
+ export function appContactAuthority(input: AppContactAuthorityInput): AppContactAuthority {
20
+ const { install, claims, scope } = input;
21
+ const host = input.hostResource.trim();
22
+ if (!install || !claims || !host || !scope) return { kind: "none" };
23
+ if (install.status !== "active" || install.tenantId !== host) return { kind: "none" };
24
+ if (!install.installId || claims.sub !== install.installId) return { kind: "none" };
25
+ if (!install.scopes.includes(scope) || !claims.scopes.includes(scope)) return { kind: "none" };
26
+ return { kind: "app", installId: install.installId, tenantId: host };
27
+ }